Skip to main content

ravenrs_report_xlsx/
lib.rs

1//! XLSX and CSV export for RavenRS Reporting: lays a [`Document`] out on a grid
2//! of cells that follows the positions on the page, with real numbers and dates
3//! in the cells so a spreadsheet can sum and sort them.
4//!
5//! Pages flow into one sheet: the page header prints once, at the top, and page
6//! footers are left out.
7//!
8//! ```
9//! use ravenrs_report::{Band, Control, Report, ReportEngine};
10//! use serde_json::json;
11//!
12//! let report = Report::new("Sales").culture("pt-BR").detail(
13//!     Band::new(18.0)
14//!         .control(Control::field("[Customer]").at(0.0, 0.0, 200.0, 18.0))
15//!         .control(Control::field("[Amount]").format("N2").at(200.0, 0.0, 80.0, 18.0)),
16//! );
17//! let document = ReportEngine::new().render(&report, &json!([{ "Customer": "Ana", "Amount": 10.5 }]));
18//! assert_eq!(ravenrs_report_xlsx::to_csv(&document), "Ana;10,50\r\n");
19//! let xlsx = ravenrs_report_xlsx::to_xlsx(&document)?;
20//! assert!(xlsx.starts_with(b"PK"));
21//! # Ok::<(), ravenrs_report_xlsx::XlsxExportError>(())
22//! ```
23
24mod format;
25
26use std::collections::{BTreeMap, HashMap, HashSet};
27use std::fmt;
28
29use ravenrs_core::{Color, ImageData, Rect};
30use ravenrs_report::vector::{Shape, rasterize};
31use ravenrs_report::{Culture, Document, DocumentImage, Item, TextAlign, TextBlock};
32use rust_xlsxwriter::{
33    Color as XlsxColor, Format, FormatAlign, FormatBorder, Image, Url, Workbook, Worksheet, XlsxError,
34};
35
36/// Why a document could not be written as XLSX.
37#[derive(Debug)]
38#[non_exhaustive]
39pub enum XlsxExportError {
40    /// The workbook writer refused the content, for example past Excel's row limit.
41    Workbook(XlsxError),
42}
43
44impl fmt::Display for XlsxExportError {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            Self::Workbook(e) => write!(f, "could not write the workbook: {e}"),
48        }
49    }
50}
51
52impl std::error::Error for XlsxExportError {
53    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
54        match self {
55            Self::Workbook(e) => Some(e),
56        }
57    }
58}
59
60impl From<XlsxError> for XlsxExportError {
61    fn from(e: XlsxError) -> Self {
62        Self::Workbook(e)
63    }
64}
65
66/// Edges closer than this, in points, are one grid line.
67const SNAP: f32 = 1.5;
68/// A rule this close to a grid line, in points, borders it instead of adding a row.
69const RULE_SNAP: f32 = 6.0;
70/// The closest font to the report's Inter that every spreadsheet program has.
71const FONT: &str = "Arial";
72/// Pixels per point in Excel's column widths.
73const PIXELS_PER_POINT: f32 = 96.0 / 72.0;
74
75/// Writes `document` as an XLSX workbook with one sheet.
76pub fn to_xlsx(document: &Document) -> Result<Vec<u8>, XlsxExportError> {
77    let flow = Flow::new(document);
78    let culture = Culture::named(&document.culture);
79
80    let mut xs: Vec<f32> = Vec::new();
81    let mut ys: Vec<f32> = Vec::new();
82    for t in &flow.texts {
83        xs.extend([t.rect.x(), t.rect.max_x()]);
84        ys.extend([t.rect.y(), t.rect.max_y()]);
85    }
86    for b in &flow.boxes {
87        xs.extend([b.rect.x(), b.rect.max_x()]);
88        ys.extend([b.rect.y(), b.rect.max_y()]);
89    }
90    for drawing in &flow.drawings {
91        xs.extend([drawing.rect.x(), drawing.rect.max_x()]);
92        ys.extend([drawing.rect.y(), drawing.rect.max_y()]);
93    }
94    for picture in &flow.pictures {
95        xs.extend([picture.rect.x(), picture.rect.max_x()]);
96        ys.extend([picture.rect.y(), picture.rect.max_y()]);
97    }
98    let xs = Grid::new(xs, SNAP);
99    let mut ys = Grid::new(ys, SNAP);
100    for rule in &flow.rules {
101        ys.add_if_far(rule.y, RULE_SNAP);
102    }
103
104    let mut styles: HashMap<(u32, u16), CellStyle> = HashMap::new();
105    for b in &flow.boxes {
106        let (r0, r1) = ys.span(b.rect.y(), b.rect.max_y());
107        let (c0, c1) = xs.span(b.rect.x(), b.rect.max_x());
108        for r in r0..r1 {
109            for c in c0..c1 {
110                let style = styles.entry((r, column(c))).or_default();
111                if let Some(fill) = b.fill {
112                    style.fill = Some(fill);
113                }
114                if let Some(line) = b.stroke {
115                    if r == r0 {
116                        style.top = Some(line);
117                    }
118                    if r + 1 == r1 {
119                        style.bottom = Some(line);
120                    }
121                    if c == c0 {
122                        style.left = Some(line);
123                    }
124                    if c + 1 == c1 {
125                        style.right = Some(line);
126                    }
127                }
128            }
129        }
130    }
131    for rule in &flow.rules {
132        let row = ys.nearest(rule.y);
133        let (c0, c1) = xs.span(rule.x0, rule.x1);
134        for c in c0..c1 {
135            // The line between two rows belongs to the one below it; the last has none below.
136            if row < ys.rows() {
137                styles.entry((row, column(c))).or_default().top = Some(rule.color);
138            } else if row > 0 {
139                styles.entry((row - 1, column(c))).or_default().bottom = Some(rule.color);
140            }
141        }
142    }
143
144    let mut book = Workbook::new();
145    let sheet = book.add_worksheet();
146    sheet.set_name(sheet_name(&document.title))?;
147    sheet.set_screen_gridlines(false);
148    for (c, width) in xs.sizes().enumerate() {
149        sheet
150            .set_column_width_pixels(column(c as u32), (width * PIXELS_PER_POINT).round().max(1.0) as u32)?;
151    }
152    for (r, height) in ys.sizes().enumerate() {
153        sheet.set_row_height(r as u32, f64::from(height).max(1.0))?;
154    }
155
156    let mut formats = Formats::default();
157    let mut taken: HashSet<(u32, u16)> = HashSet::new();
158    let mut cells: BTreeMap<(u32, u16), Placed<'_>> = BTreeMap::new();
159    for t in &flow.texts {
160        let (r0, r1) = ys.span(t.rect.y(), t.rect.max_y());
161        let (c0, c1) = xs.span(t.rect.x(), t.rect.max_x());
162        let key = (r0, column(c0));
163        // Two controls over the same corner share the cell, as Excel has only one there.
164        if let Some(placed) = cells.get_mut(&key) {
165            placed.text.push('\n');
166            placed.text.push_str(&t.text);
167            placed.block = None;
168            continue;
169        }
170        let free = (r0..r1).all(|r| (c0..c1).all(|c| !taken.contains(&(r, column(c)))));
171        let (r1, c1) = if free { (r1, c1) } else { (r0 + 1, c0 + 1) };
172        for r in r0..r1 {
173            for c in c0..c1 {
174                taken.insert((r, column(c)));
175            }
176        }
177        let center = t.rect.center();
178        let url = flow.links.iter().find(|l| l.rect.contains(center)).map(|l| l.url.as_str());
179        cells.insert(key, Placed { text: t.text.clone(), block: Some(t.block), r1, c1, url });
180    }
181
182    // Filled and bordered cells without text.
183    for (&(r, c), style) in &styles {
184        if !taken.contains(&(r, c)) {
185            let format = formats.get(&Look { style: *style, ..Look::default() });
186            sheet.write_blank(r, c, format)?;
187        }
188    }
189    for (&(r0, c0), placed) in &cells {
190        write_cell(sheet, &mut formats, &styles, &culture, (r0, c0), placed)?;
191    }
192    for drawing in &flow.drawings {
193        let Some(pixels) = rasterize(&drawing.shapes, drawing.page_rect, DRAWING_SCALE) else { continue };
194        let png = drawing_png(&pixels)?;
195        let width = (drawing.rect.width() * PIXELS_PER_POINT).max(1.0).round() as u32;
196        let height = (drawing.rect.height() * PIXELS_PER_POINT).max(1.0).round() as u32;
197        let image = Image::new_from_buffer(&png)?.set_scale_to_size(width, height, false);
198        sheet.insert_image(ys.nearest(drawing.rect.y()), column(xs.nearest(drawing.rect.x())), &image)?;
199    }
200    for picture in &flow.pictures {
201        let width = (picture.rect.width() * PIXELS_PER_POINT).max(1.0).round() as u32;
202        let height = (picture.rect.height() * PIXELS_PER_POINT).max(1.0).round() as u32;
203        let image = Image::new_from_buffer(picture.image.encoded())?.set_scale_to_size(width, height, false);
204        sheet.insert_image(ys.nearest(picture.rect.y()), column(xs.nearest(picture.rect.x())), &image)?;
205    }
206    Ok(book.save_to_buffer()?)
207}
208
209/// Writes `document` as CSV: one record per line of the report, one field per
210/// column of text, with the text as printed. Fields are separated by `;` in
211/// cultures whose decimal separator is a comma and by `,` in the others, and
212/// records end in CRLF.
213pub fn to_csv(document: &Document) -> String {
214    let flow = Flow::new(document);
215    let separator = if Culture::named(&document.culture).decimal == ',' { ';' } else { ',' };
216    let xs = Grid::new(flow.texts.iter().map(|t| t.rect.x()).collect(), SNAP);
217    let ys = Grid::new(flow.texts.iter().map(|t| t.rect.y()).collect(), SNAP);
218    let mut rows: BTreeMap<u32, BTreeMap<u32, String>> = BTreeMap::new();
219    for t in &flow.texts {
220        let field =
221            rows.entry(ys.nearest(t.rect.y())).or_default().entry(xs.nearest(t.rect.x())).or_default();
222        if !field.is_empty() {
223            field.push(' ');
224        }
225        field.push_str(&t.text);
226    }
227    let used_last = rows.values().filter_map(|r| r.keys().next_back().copied()).max().unwrap_or(0);
228    let mut out = String::new();
229    for row in rows.values() {
230        for c in 0..=used_last {
231            if c > 0 {
232                out.push(separator);
233            }
234            if let Some(text) = row.get(&c) {
235                ravenrs_core::csv::push_field(&mut out, text, separator);
236            }
237        }
238        out.push_str("\r\n");
239    }
240    out
241}
242
243/// The document's pages flowed into one long page.
244struct Flow<'d> {
245    texts: Vec<FlowText<'d>>,
246    boxes: Vec<FlowBox>,
247    rules: Vec<Rule>,
248    pictures: Vec<FlowPicture>,
249    drawings: Vec<FlowDrawing>,
250    links: Vec<FlowLink>,
251}
252
253/// Shapes placed as one picture, such as a chart or a barcode. Coordinates stay those of the
254/// page, so `page_rect` is what they are drawn from.
255struct FlowDrawing {
256    rect: Rect,
257    page_rect: Rect,
258    shapes: Vec<Shape>,
259}
260
261struct FlowPicture {
262    rect: Rect,
263    image: DocumentImage,
264}
265
266struct FlowLink {
267    rect: Rect,
268    url: String,
269}
270
271struct FlowText<'d> {
272    rect: Rect,
273    text: String,
274    block: &'d TextBlock,
275}
276
277struct FlowBox {
278    rect: Rect,
279    fill: Option<Rgb>,
280    stroke: Option<Rgb>,
281}
282
283/// A horizontal line.
284struct Rule {
285    y: f32,
286    x0: f32,
287    x1: f32,
288    color: Rgb,
289}
290
291impl<'d> Flow<'d> {
292    fn new(document: &'d Document) -> Self {
293        let mut flow = Self {
294            texts: Vec::new(),
295            boxes: Vec::new(),
296            rules: Vec::new(),
297            pictures: Vec::new(),
298            drawings: Vec::new(),
299            links: Vec::new(),
300        };
301        // Where the next page's first item lands.
302        let mut bottom = 0.0_f32;
303        for (n, page) in document.pages.iter().enumerate() {
304            let kept: Vec<&Item> = page
305                .items
306                .iter()
307                .enumerate()
308                .filter(|(i, _)| !page.page_footer.contains(i) && (n == 0 || !page.page_header.contains(i)))
309                .map(|(_, item)| item)
310                .collect();
311            let top = kept.iter().filter_map(|i| extent(i)).map(|(t, _)| t).reduce(f32::min).unwrap_or(0.0);
312            let end = kept.iter().filter_map(|i| extent(i)).map(|(_, b)| b).reduce(f32::max).unwrap_or(top);
313            let dy = bottom - top;
314            for item in kept {
315                flow.add(item, dy);
316            }
317            bottom += end - top;
318        }
319        flow
320    }
321
322    fn add(&mut self, item: &'d Item, dy: f32) {
323        match item {
324            Item::Text(block) => {
325                let text = block.lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>().join("\n");
326                if !text.is_empty() {
327                    self.texts.push(FlowText { rect: shifted(block.clip, dy), text, block });
328                }
329            }
330            Item::Rect { rect, fill, stroke, .. } | Item::Ellipse { rect, fill, stroke } => {
331                let fill = fill.filter(|c| c.a > 0.0).map(rgb);
332                let stroke = stroke.filter(|(w, c)| *w > 0.0 && c.a > 0.0).map(|(_, c)| rgb(c));
333                if fill.is_some() || stroke.is_some() {
334                    self.boxes.push(FlowBox { rect: shifted(*rect, dy), fill, stroke });
335                }
336            }
337            Item::Line { from, to, color, .. } if (from.y - to.y).abs() < 0.5 => {
338                self.rules.push(Rule {
339                    y: from.y + dy,
340                    x0: from.x.min(to.x),
341                    x1: from.x.max(to.x),
342                    color: rgb(*color),
343                });
344            }
345            Item::Image { rect, image, .. } => {
346                self.pictures.push(FlowPicture { rect: shifted(*rect, dy), image: image.clone() });
347            }
348            Item::Vector { rect, shapes } => self.drawings.push(FlowDrawing {
349                rect: shifted(*rect, dy),
350                page_rect: *rect,
351                shapes: shapes.clone(),
352            }),
353            Item::Link { rect, url } => {
354                self.links.push(FlowLink { rect: shifted(*rect, dy), url: url.clone() })
355            }
356            _ => {}
357        }
358    }
359}
360
361/// The top and bottom of an item.
362fn extent(item: &Item) -> Option<(f32, f32)> {
363    match item {
364        Item::Text(t) => Some((t.clip.y(), t.clip.max_y())),
365        Item::Rect { rect, .. } | Item::Ellipse { rect, .. } => Some((rect.y(), rect.max_y())),
366        Item::Line { from, to, .. } => Some((from.y.min(to.y), from.y.max(to.y))),
367        Item::Image { clip, .. } => Some((clip.y(), clip.max_y())),
368        Item::Vector { rect, .. } => Some((rect.y(), rect.max_y())),
369        _ => None,
370    }
371}
372
373fn shifted(rect: Rect, dy: f32) -> Rect {
374    Rect::new(rect.x(), rect.y() + dy, rect.width(), rect.height())
375}
376
377/// Pixels per point when rasterizing drawings: sharp enough for a scanner on paper.
378const DRAWING_SCALE: f32 = 6.0;
379
380fn drawing_png(pixels: &ImageData) -> Result<Vec<u8>, XlsxExportError> {
381    let image = image::RgbaImage::from_raw(pixels.width(), pixels.height(), pixels.pixels().to_vec())
382        .ok_or_else(|| XlsxExportError::Workbook(XlsxError::ParameterError("bad chart size".to_owned())))?;
383    let mut bytes = std::io::Cursor::new(Vec::new());
384    image
385        .write_to(&mut bytes, image::ImageFormat::Png)
386        .map_err(|e| XlsxExportError::Workbook(XlsxError::ParameterError(e.to_string())))?;
387    Ok(bytes.into_inner())
388}
389
390/// Sorted grid lines on one axis.
391struct Grid {
392    lines: Vec<f32>,
393}
394
395impl Grid {
396    fn new(mut edges: Vec<f32>, snap: f32) -> Self {
397        edges.sort_by(f32::total_cmp);
398        let mut lines: Vec<f32> = Vec::new();
399        for edge in edges {
400            if lines.last().is_none_or(|last| edge - last >= snap) {
401                lines.push(edge);
402            }
403        }
404        Self { lines }
405    }
406
407    fn add_if_far(&mut self, edge: f32, snap: f32) {
408        let at = self.lines.partition_point(|l| *l < edge);
409        let near = |i: usize| self.lines.get(i).is_some_and(|l| (l - edge).abs() < snap);
410        if !(near(at) || (at > 0 && near(at - 1))) {
411            self.lines.insert(at, edge);
412        }
413    }
414
415    /// The index of the grid line nearest to `edge`.
416    fn nearest(&self, edge: f32) -> u32 {
417        let at = self.lines.partition_point(|l| *l < edge);
418        let index = if at == 0 {
419            0
420        } else if at == self.lines.len() || edge - self.lines[at - 1] <= self.lines[at] - edge {
421            at - 1
422        } else {
423            at
424        };
425        index as u32
426    }
427
428    /// The cells between two edges, at least one.
429    fn span(&self, from: f32, to: f32) -> (u32, u32) {
430        let start = self.nearest(from);
431        (start, self.nearest(to).max(start + 1))
432    }
433
434    /// The number of cells between the lines.
435    fn rows(&self) -> u32 {
436        self.lines.len().saturating_sub(1) as u32
437    }
438
439    /// The size of each cell.
440    fn sizes(&self) -> impl Iterator<Item = f32> + '_ {
441        self.lines.windows(2).map(|w| w[1] - w[0])
442    }
443}
444
445fn column(c: u32) -> u16 {
446    u16::try_from(c).unwrap_or(u16::MAX)
447}
448
449/// A color as `0xRRGGBB`, translucent colors blended over the white page.
450type Rgb = u32;
451
452fn rgb(c: Color) -> Rgb {
453    let channel = |v: f32| ((v * c.a + 1.0 - c.a).clamp(0.0, 1.0) * 255.0).round() as u32;
454    channel(c.r) << 16 | channel(c.g) << 8 | channel(c.b)
455}
456
457/// Fill and borders of a cell.
458#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
459struct CellStyle {
460    fill: Option<Rgb>,
461    top: Option<Rgb>,
462    bottom: Option<Rgb>,
463    left: Option<Rgb>,
464    right: Option<Rgb>,
465}
466
467impl CellStyle {
468    /// Adds what `other` sets; used to give a merged cell the borders of its parts.
469    fn merge(&mut self, other: CellStyle) {
470        self.fill = self.fill.or(other.fill);
471        self.top = self.top.or(other.top);
472        self.bottom = self.bottom.or(other.bottom);
473        self.left = self.left.or(other.left);
474        self.right = self.right.or(other.right);
475    }
476}
477
478/// Everything a cell's format depends on.
479#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
480struct Look {
481    style: CellStyle,
482    /// Font size in hundredths of a point; zero for a cell without text.
483    size: u32,
484    bold: bool,
485    color: Rgb,
486    align: u8,
487    wrap: bool,
488    number: Option<String>,
489}
490
491/// One format per distinct look: workbooks have a limit, and it keeps files small.
492#[derive(Default)]
493struct Formats(HashMap<Look, Format>);
494
495impl Formats {
496    fn get(&mut self, look: &Look) -> &Format {
497        self.0.entry(look.clone()).or_insert_with(|| build_format(look))
498    }
499}
500
501fn build_format(look: &Look) -> Format {
502    let mut f = Format::new();
503    if look.size > 0 {
504        f = f
505            .set_font_name(FONT)
506            .set_font_size(f64::from(look.size) / 100.0)
507            .set_font_color(XlsxColor::RGB(look.color));
508        f = f.set_align(FormatAlign::VerticalCenter).set_align(match look.align {
509            1 => FormatAlign::Center,
510            2 => FormatAlign::Right,
511            _ => FormatAlign::Left,
512        });
513    }
514    if look.bold {
515        f = f.set_bold();
516    }
517    if look.wrap {
518        f = f.set_text_wrap();
519    }
520    if let Some(number) = &look.number {
521        f = f.set_num_format(number);
522    }
523    let s = look.style;
524    if let Some(fill) = s.fill {
525        f = f.set_background_color(XlsxColor::RGB(fill));
526    }
527    if let Some(c) = s.top {
528        f = f.set_border_top(FormatBorder::Thin).set_border_top_color(XlsxColor::RGB(c));
529    }
530    if let Some(c) = s.bottom {
531        f = f.set_border_bottom(FormatBorder::Thin).set_border_bottom_color(XlsxColor::RGB(c));
532    }
533    if let Some(c) = s.left {
534        f = f.set_border_left(FormatBorder::Thin).set_border_left_color(XlsxColor::RGB(c));
535    }
536    if let Some(c) = s.right {
537        f = f.set_border_right(FormatBorder::Thin).set_border_right_color(XlsxColor::RGB(c));
538    }
539    f
540}
541
542/// A text placed on the grid.
543struct Placed<'d> {
544    text: String,
545    /// `None` once two controls share the cell: its value is then just text.
546    block: Option<&'d TextBlock>,
547    r1: u32,
548    c1: u32,
549    /// The address the cell opens, when it is a link.
550    url: Option<&'d str>,
551}
552
553fn write_cell(
554    sheet: &mut Worksheet,
555    formats: &mut Formats,
556    styles: &HashMap<(u32, u16), CellStyle>,
557    culture: &Culture,
558    (r0, c0): (u32, u16),
559    placed: &Placed<'_>,
560) -> Result<(), XlsxError> {
561    let c1 = column(placed.c1);
562    let mut style = CellStyle::default();
563    for r in r0..placed.r1 {
564        for c in c0..c1 {
565            if let Some(part) = styles.get(&(r, c)) {
566                style.merge(*part);
567            }
568        }
569    }
570    let cell =
571        placed.block.and_then(|b| b.value.as_ref().map(|v| format::typed(v, b.format.as_deref(), culture)));
572    let (number, excel) = cell.flatten().map_or((None, None), |(n, f)| (Some(n), f));
573    let block = placed.block;
574    let look = Look {
575        style,
576        size: block.map_or(1100, |b| (b.size * 100.0).round() as u32),
577        bold: block.is_some_and(|b| b.bold),
578        color: block.map_or(0, |b| rgb(b.color)),
579        align: match block.map(|b| b.align) {
580            Some(TextAlign::Center) => 1,
581            Some(TextAlign::Right) => 2,
582            _ => 0,
583        },
584        wrap: placed.text.contains('\n'),
585        number: excel,
586    };
587    let format = formats.get(&look).clone();
588    if placed.r1 > r0 + 1 || c1 > c0 + 1 {
589        sheet.merge_range(r0, c0, placed.r1 - 1, c1 - 1, &placed.text, &format)?;
590    }
591    if let Some(url) = placed.url {
592        // Excel refuses very long addresses; the text matters more than the link.
593        let link = Url::new(url).set_text(&placed.text);
594        if sheet.write_url_with_format(r0, c0, link, &format).is_ok() {
595            return Ok(());
596        }
597    }
598    match number {
599        Some(n) => sheet.write_number_with_format(r0, c0, n, &format)?,
600        None => sheet.write_string_with_format(r0, c0, &placed.text, &format)?,
601    };
602    Ok(())
603}
604
605/// A sheet name Excel accepts: at most 31 characters, none of `[]:*?/\`, and
606/// not starting or ending in an apostrophe.
607fn sheet_name(title: &str) -> String {
608    let clean: String = title
609        .chars()
610        .map(|c| if matches!(c, '[' | ']' | ':' | '*' | '?' | '/' | '\\') { ' ' } else { c })
611        .take(31)
612        .collect();
613    let clean = clean.trim().trim_matches('\'').trim();
614    if clean.is_empty() { "Report".to_owned() } else { clean.to_owned() }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    #[test]
622    fn grid_lines_snap_and_cells_never_collapse() {
623        let grid = Grid::new(vec![10.0, 10.5, 40.0, 41.0, 100.0], 1.5);
624        assert_eq!(grid.lines, [10.0, 40.0, 100.0]);
625        assert_eq!(grid.span(10.4, 40.7), (0, 1));
626        assert_eq!(grid.span(40.0, 40.2), (1, 2));
627        assert_eq!(grid.span(10.0, 100.0), (0, 2));
628    }
629
630    #[test]
631    fn rules_near_a_line_add_no_row() {
632        let mut grid = Grid::new(vec![0.0, 20.0], 1.5);
633        grid.add_if_far(21.0, 6.0);
634        assert_eq!(grid.lines, [0.0, 20.0]);
635        grid.add_if_far(40.0, 6.0);
636        assert_eq!(grid.lines, [0.0, 20.0, 40.0]);
637    }
638
639    #[test]
640    fn sheet_names_are_what_excel_accepts() {
641        assert_eq!(sheet_name("Vendas: 2026/11"), "Vendas  2026 11");
642        assert_eq!(sheet_name("'Fatura'"), "Fatura");
643        assert_eq!(sheet_name("???"), "Report");
644        assert_eq!(sheet_name(&"x".repeat(40)).chars().count(), 31);
645    }
646
647    #[test]
648    fn translucent_colors_blend_over_white() {
649        assert_eq!(rgb(Color::rgba(0.0, 0.0, 0.0, 1.0)), 0x000000);
650        assert_eq!(rgb(Color::rgba(0.0, 0.0, 0.0, 0.0)), 0xffffff);
651        assert_eq!(rgb(Color::rgba(1.0, 0.0, 0.0, 0.5)), 0xff8080);
652    }
653}