Skip to main content

sheetkit_core/
render.rs

1//! SVG renderer for worksheet visual output.
2//!
3//! Generates an SVG string from worksheet data including cell values,
4//! column widths, row heights, cell styles (fonts, fills, borders),
5//! optional gridlines, and optional row/column headers.
6
7use crate::cell::CellValue;
8use crate::col::get_col_width;
9use crate::error::{Error, Result};
10use crate::row::{get_row_height, get_rows, resolve_cell_value};
11use crate::sst::SharedStringTable;
12use crate::style::{
13    get_style, AlignmentStyle, BorderLineStyle, FontStyle, HorizontalAlign, PatternType,
14    StyleColor, VerticalAlign,
15};
16use crate::utils::cell_ref::{cell_name_to_coordinates, column_number_to_name};
17use sheetkit_xml::styles::StyleSheet;
18use sheetkit_xml::worksheet::WorksheetXml;
19
20/// Default column width in pixels (approximately 8.43 characters at 7px each).
21const DEFAULT_COL_WIDTH_PX: f64 = 64.0;
22
23/// Default row height in pixels (15 points).
24const DEFAULT_ROW_HEIGHT_PX: f64 = 20.0;
25
26/// Pixel width of the row/column header gutter area.
27const HEADER_WIDTH: f64 = 40.0;
28const HEADER_HEIGHT: f64 = 20.0;
29
30const HEADER_BG_COLOR: &str = "#F0F0F0";
31const HEADER_TEXT_COLOR: &str = "#666666";
32const GRIDLINE_COLOR: &str = "#D0D0D0";
33
34/// Conversion factor from Excel column width units to pixels.
35/// Excel column width is in "number of characters" based on the default font.
36/// The approximate conversion is: pixels = width * 7 + 5 (for padding).
37fn col_width_to_px(width: f64) -> f64 {
38    width * 7.0 + 5.0
39}
40
41/// Conversion factor from Excel row height (points) to pixels.
42/// 1 point = 4/3 pixels at 96 DPI.
43fn row_height_to_px(height: f64) -> f64 {
44    height * 4.0 / 3.0
45}
46
47/// Options for rendering a worksheet to SVG.
48pub struct RenderOptions {
49    /// Name of the sheet to render. Required.
50    pub sheet_name: String,
51    /// Optional cell range to render (e.g. "A1:F20"). None renders the used range.
52    pub range: Option<String>,
53    /// Whether to draw gridlines between cells.
54    pub show_gridlines: bool,
55    /// Whether to draw row and column headers (A, B, C... and 1, 2, 3...).
56    pub show_headers: bool,
57    /// Scale factor for the output (1.0 = 100%).
58    pub scale: f64,
59    /// Default font family for cell text.
60    pub default_font_family: String,
61    /// Default font size in points for cell text.
62    pub default_font_size: f64,
63}
64
65impl Default for RenderOptions {
66    fn default() -> Self {
67        Self {
68            sheet_name: String::new(),
69            range: None,
70            show_gridlines: true,
71            show_headers: true,
72            scale: 1.0,
73            default_font_family: "Arial".to_string(),
74            default_font_size: 11.0,
75        }
76    }
77}
78
79/// Computed layout for a single cell during rendering.
80struct CellLayout {
81    x: f64,
82    y: f64,
83    width: f64,
84    height: f64,
85    col: u32,
86    row: u32,
87}
88
89/// Render a worksheet to an SVG string.
90///
91/// Uses the worksheet XML, shared string table, and stylesheet to produce
92/// a visual representation of the sheet as SVG. The `options` parameter
93/// controls which sheet, range, and visual features to include.
94pub fn render_to_svg(
95    ws: &WorksheetXml,
96    sst: &SharedStringTable,
97    stylesheet: &StyleSheet,
98    options: &RenderOptions,
99) -> Result<String> {
100    if options.scale <= 0.0 {
101        return Err(Error::InvalidArgument(format!(
102            "render scale must be positive, got {}",
103            options.scale
104        )));
105    }
106
107    let (min_col, min_row, max_col, max_row) = compute_range(ws, sst, options)?;
108
109    let col_widths = compute_col_widths(ws, min_col, max_col);
110    let row_heights = compute_row_heights(ws, min_row, max_row);
111
112    let total_width: f64 = col_widths.iter().sum();
113    let total_height: f64 = row_heights.iter().sum();
114
115    let header_x_offset = if options.show_headers {
116        HEADER_WIDTH
117    } else {
118        0.0
119    };
120    let header_y_offset = if options.show_headers {
121        HEADER_HEIGHT
122    } else {
123        0.0
124    };
125
126    let svg_width = (total_width + header_x_offset) * options.scale;
127    let svg_height = (total_height + header_y_offset) * options.scale;
128
129    let mut svg = String::with_capacity(4096);
130    svg.push_str(&format!(
131        r#"<svg xmlns="http://www.w3.org/2000/svg" width="{svg_width}" height="{svg_height}" viewBox="0 0 {} {}">"#,
132        total_width + header_x_offset,
133        total_height + header_y_offset,
134    ));
135
136    svg.push_str(&format!(
137        r#"<style>text {{ font-family: {}; font-size: {}px; }}</style>"#,
138        &options.default_font_family, options.default_font_size
139    ));
140
141    // White background
142    svg.push_str(&format!(
143        r#"<rect width="{}" height="{}" fill="white"/>"#,
144        total_width + header_x_offset,
145        total_height + header_y_offset,
146    ));
147
148    // Render column headers
149    if options.show_headers {
150        render_column_headers(&mut svg, &col_widths, min_col, header_x_offset, options);
151        render_row_headers(&mut svg, &row_heights, min_row, header_y_offset, options);
152    }
153
154    // Build cell layouts
155    let layouts = build_cell_layouts(
156        &col_widths,
157        &row_heights,
158        min_col,
159        min_row,
160        max_col,
161        max_row,
162        header_x_offset,
163        header_y_offset,
164    );
165
166    // Render cell fills
167    render_cell_fills(&mut svg, ws, sst, stylesheet, &layouts, min_col, min_row);
168
169    // Render gridlines
170    if options.show_gridlines {
171        render_gridlines(
172            &mut svg,
173            &col_widths,
174            &row_heights,
175            total_width,
176            total_height,
177            header_x_offset,
178            header_y_offset,
179        );
180    }
181
182    // Render cell borders
183    render_cell_borders(&mut svg, ws, stylesheet, &layouts, min_col, min_row);
184
185    // Render cell text
186    render_cell_text(
187        &mut svg, ws, sst, stylesheet, &layouts, min_col, min_row, options,
188    );
189
190    svg.push_str("</svg>");
191    Ok(svg)
192}
193
194/// Determine the range of cells to render.
195fn compute_range(
196    ws: &WorksheetXml,
197    sst: &SharedStringTable,
198    options: &RenderOptions,
199) -> Result<(u32, u32, u32, u32)> {
200    if let Some(ref range) = options.range {
201        let parts: Vec<&str> = range.split(':').collect();
202        if parts.len() != 2 {
203            return Err(Error::InvalidCellReference(format!(
204                "expected range like 'A1:F20', got '{range}'"
205            )));
206        }
207        let (c1, r1) = cell_name_to_coordinates(parts[0])?;
208        let (c2, r2) = cell_name_to_coordinates(parts[1])?;
209        Ok((c1.min(c2), r1.min(r2), c1.max(c2), r1.max(r2)))
210    } else {
211        // Rendering computes bounds from cell presence only, so date
212        // promotion is not relevant here. Skip the lookup.
213        let rows = get_rows(ws, sst, &[])?;
214        if rows.is_empty() {
215            return Ok((1, 1, 1, 1));
216        }
217        let mut min_col = u32::MAX;
218        let mut max_col = 0u32;
219        let min_row = rows.first().map(|(r, _)| *r).unwrap_or(1);
220        let max_row = rows.last().map(|(r, _)| *r).unwrap_or(1);
221        for (_, cells) in &rows {
222            for (col, _) in cells {
223                min_col = min_col.min(*col);
224                max_col = max_col.max(*col);
225            }
226        }
227        if min_col == u32::MAX {
228            min_col = 1;
229        }
230        if max_col == 0 {
231            max_col = 1;
232        }
233        Ok((min_col, min_row, max_col, max_row))
234    }
235}
236
237/// Compute pixel widths for each column in the range.
238fn compute_col_widths(ws: &WorksheetXml, min_col: u32, max_col: u32) -> Vec<f64> {
239    (min_col..=max_col)
240        .map(|col_num| {
241            let col_name = column_number_to_name(col_num).unwrap_or_default();
242            match get_col_width(ws, &col_name) {
243                Some(w) => col_width_to_px(w),
244                None => DEFAULT_COL_WIDTH_PX,
245            }
246        })
247        .collect()
248}
249
250/// Compute pixel heights for each row in the range.
251fn compute_row_heights(ws: &WorksheetXml, min_row: u32, max_row: u32) -> Vec<f64> {
252    (min_row..=max_row)
253        .map(|row_num| match get_row_height(ws, row_num) {
254            Some(h) => row_height_to_px(h),
255            None => DEFAULT_ROW_HEIGHT_PX,
256        })
257        .collect()
258}
259
260/// Build a grid of CellLayout structs for every cell position in the range.
261#[allow(clippy::too_many_arguments)]
262fn build_cell_layouts(
263    col_widths: &[f64],
264    row_heights: &[f64],
265    min_col: u32,
266    min_row: u32,
267    max_col: u32,
268    max_row: u32,
269    x_offset: f64,
270    y_offset: f64,
271) -> Vec<CellLayout> {
272    let mut layouts = Vec::new();
273    let mut y = y_offset;
274    for (ri, row_num) in (min_row..=max_row).enumerate() {
275        let h = row_heights[ri];
276        let mut x = x_offset;
277        for (ci, col_num) in (min_col..=max_col).enumerate() {
278            let w = col_widths[ci];
279            layouts.push(CellLayout {
280                x,
281                y,
282                width: w,
283                height: h,
284                col: col_num,
285                row: row_num,
286            });
287            x += w;
288        }
289        y += h;
290    }
291    layouts
292}
293
294/// Render column header labels (A, B, C, ...).
295fn render_column_headers(
296    svg: &mut String,
297    col_widths: &[f64],
298    min_col: u32,
299    x_offset: f64,
300    _options: &RenderOptions,
301) {
302    let total_w: f64 = col_widths.iter().sum();
303    svg.push_str(&format!(
304        "<rect x=\"{x_offset}\" y=\"0\" width=\"{total_w}\" height=\"{HEADER_HEIGHT}\" fill=\"{HEADER_BG_COLOR}\"/>",
305    ));
306
307    let mut x = x_offset;
308    for (i, &w) in col_widths.iter().enumerate() {
309        let col_num = min_col + i as u32;
310        let col_name = column_number_to_name(col_num).unwrap_or_default();
311        let text_x = x + w / 2.0;
312        let text_y = HEADER_HEIGHT / 2.0 + 4.0;
313        svg.push_str(&format!(
314            "<text x=\"{text_x}\" y=\"{text_y}\" text-anchor=\"middle\" fill=\"{HEADER_TEXT_COLOR}\" font-size=\"10\">{col_name}</text>",
315        ));
316        x += w;
317    }
318}
319
320/// Render row header labels (1, 2, 3, ...).
321fn render_row_headers(
322    svg: &mut String,
323    row_heights: &[f64],
324    min_row: u32,
325    y_offset: f64,
326    _options: &RenderOptions,
327) {
328    let total_h: f64 = row_heights.iter().sum();
329    svg.push_str(&format!(
330        "<rect x=\"0\" y=\"{y_offset}\" width=\"{HEADER_WIDTH}\" height=\"{total_h}\" fill=\"{HEADER_BG_COLOR}\"/>",
331    ));
332
333    let mut y = y_offset;
334    for (i, &h) in row_heights.iter().enumerate() {
335        let row_num = min_row + i as u32;
336        let text_x = HEADER_WIDTH / 2.0;
337        let text_y = y + h / 2.0 + 4.0;
338        svg.push_str(&format!(
339            "<text x=\"{text_x}\" y=\"{text_y}\" text-anchor=\"middle\" fill=\"{HEADER_TEXT_COLOR}\" font-size=\"10\">{row_num}</text>",
340        ));
341        y += h;
342    }
343}
344
345/// Render cell background fills.
346fn render_cell_fills(
347    svg: &mut String,
348    ws: &WorksheetXml,
349    _sst: &SharedStringTable,
350    stylesheet: &StyleSheet,
351    layouts: &[CellLayout],
352    _min_col: u32,
353    _min_row: u32,
354) {
355    for layout in layouts {
356        let style_id = find_cell_style(ws, layout.col, layout.row);
357        if style_id == 0 {
358            continue;
359        }
360        if let Some(style) = get_style(stylesheet, style_id) {
361            if let Some(ref fill) = style.fill {
362                if fill.pattern == PatternType::Solid {
363                    if let Some(ref color) = fill.fg_color {
364                        let hex = style_color_to_hex(color);
365                        svg.push_str(&format!(
366                            r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}"/>"#,
367                            layout.x, layout.y, layout.width, layout.height, hex
368                        ));
369                    }
370                }
371            }
372        }
373    }
374}
375
376/// Render gridlines.
377fn render_gridlines(
378    svg: &mut String,
379    col_widths: &[f64],
380    row_heights: &[f64],
381    total_width: f64,
382    total_height: f64,
383    x_offset: f64,
384    y_offset: f64,
385) {
386    let mut y = y_offset;
387    for h in row_heights {
388        y += h;
389        let x2 = x_offset + total_width;
390        svg.push_str(&format!(
391            "<line x1=\"{x_offset}\" y1=\"{y}\" x2=\"{x2}\" y2=\"{y}\" stroke=\"{GRIDLINE_COLOR}\" stroke-width=\"0.5\"/>",
392        ));
393    }
394
395    let mut x = x_offset;
396    for w in col_widths {
397        x += w;
398        let y2 = y_offset + total_height;
399        svg.push_str(&format!(
400            "<line x1=\"{x}\" y1=\"{y_offset}\" x2=\"{x}\" y2=\"{y2}\" stroke=\"{GRIDLINE_COLOR}\" stroke-width=\"0.5\"/>",
401        ));
402    }
403}
404
405/// Render cell borders.
406fn render_cell_borders(
407    svg: &mut String,
408    ws: &WorksheetXml,
409    stylesheet: &StyleSheet,
410    layouts: &[CellLayout],
411    _min_col: u32,
412    _min_row: u32,
413) {
414    for layout in layouts {
415        let style_id = find_cell_style(ws, layout.col, layout.row);
416        if style_id == 0 {
417            continue;
418        }
419        let style = match get_style(stylesheet, style_id) {
420            Some(s) => s,
421            None => continue,
422        };
423        let border = match &style.border {
424            Some(b) => b,
425            None => continue,
426        };
427
428        let x1 = layout.x;
429        let y1 = layout.y;
430        let x2 = layout.x + layout.width;
431        let y2 = layout.y + layout.height;
432
433        if let Some(ref left) = border.left {
434            let (sw, color) = border_line_attrs(left.style, left.color.as_ref());
435            svg.push_str(&format!(
436                r#"<line x1="{x1}" y1="{y1}" x2="{x1}" y2="{y2}" stroke="{color}" stroke-width="{sw}"/>"#,
437            ));
438        }
439        if let Some(ref right) = border.right {
440            let (sw, color) = border_line_attrs(right.style, right.color.as_ref());
441            svg.push_str(&format!(
442                r#"<line x1="{x2}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{color}" stroke-width="{sw}"/>"#,
443            ));
444        }
445        if let Some(ref top) = border.top {
446            let (sw, color) = border_line_attrs(top.style, top.color.as_ref());
447            svg.push_str(&format!(
448                r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y1}" stroke="{color}" stroke-width="{sw}"/>"#,
449            ));
450        }
451        if let Some(ref bottom) = border.bottom {
452            let (sw, color) = border_line_attrs(bottom.style, bottom.color.as_ref());
453            svg.push_str(&format!(
454                r#"<line x1="{x1}" y1="{y2}" x2="{x2}" y2="{y2}" stroke="{color}" stroke-width="{sw}"/>"#,
455            ));
456        }
457    }
458}
459
460/// Render cell text values.
461#[allow(clippy::too_many_arguments)]
462fn render_cell_text(
463    svg: &mut String,
464    ws: &WorksheetXml,
465    sst: &SharedStringTable,
466    stylesheet: &StyleSheet,
467    layouts: &[CellLayout],
468    _min_col: u32,
469    _min_row: u32,
470    options: &RenderOptions,
471) {
472    for layout in layouts {
473        let cell_value = find_cell_value(ws, sst, layout.col, layout.row);
474        if cell_value == CellValue::Empty {
475            continue;
476        }
477
478        let display_text = cell_value.to_string();
479        if display_text.is_empty() {
480            continue;
481        }
482
483        let style_id = find_cell_style(ws, layout.col, layout.row);
484        let style = get_style(stylesheet, style_id);
485
486        let font = style.as_ref().and_then(|s| s.font.as_ref());
487        let alignment = style.as_ref().and_then(|s| s.alignment.as_ref());
488
489        let (text_x, anchor) = compute_text_x(layout, alignment);
490        let text_y = compute_text_y(layout, alignment, font, options);
491
492        let escaped = xml_escape(&display_text);
493
494        let mut attrs = String::new();
495        attrs.push_str(&format!(r#" x="{text_x}" y="{text_y}""#));
496        attrs.push_str(&format!(r#" text-anchor="{anchor}""#));
497
498        if let Some(f) = font {
499            if f.bold {
500                attrs.push_str(r#" font-weight="bold""#);
501            }
502            if f.italic {
503                attrs.push_str(r#" font-style="italic""#);
504            }
505            if let Some(ref name) = f.name {
506                attrs.push_str(&format!(r#" font-family="{name}""#));
507            }
508            if let Some(size) = f.size {
509                attrs.push_str(&format!(r#" font-size="{size}""#));
510            }
511            if let Some(ref color) = f.color {
512                let hex = style_color_to_hex(color);
513                attrs.push_str(&format!(r#" fill="{hex}""#));
514            }
515            let mut decorations = Vec::new();
516            if f.underline {
517                decorations.push("underline");
518            }
519            if f.strikethrough {
520                decorations.push("line-through");
521            }
522            if !decorations.is_empty() {
523                attrs.push_str(&format!(r#" text-decoration="{}""#, decorations.join(" ")));
524            }
525        }
526
527        svg.push_str(&format!("<text{attrs}>{escaped}</text>"));
528    }
529}
530
531/// Compute the x position and text-anchor for a cell's text based on alignment.
532fn compute_text_x(layout: &CellLayout, alignment: Option<&AlignmentStyle>) -> (f64, &'static str) {
533    let padding = 3.0;
534    match alignment.and_then(|a| a.horizontal) {
535        Some(HorizontalAlign::Center) | Some(HorizontalAlign::CenterContinuous) => {
536            (layout.x + layout.width / 2.0, "middle")
537        }
538        Some(HorizontalAlign::Right) => (layout.x + layout.width - padding, "end"),
539        _ => (layout.x + padding, "start"),
540    }
541}
542
543/// Compute the y position for a cell's text based on vertical alignment.
544fn compute_text_y(
545    layout: &CellLayout,
546    alignment: Option<&AlignmentStyle>,
547    font: Option<&FontStyle>,
548    options: &RenderOptions,
549) -> f64 {
550    let font_size = font
551        .and_then(|f| f.size)
552        .unwrap_or(options.default_font_size);
553    match alignment.and_then(|a| a.vertical) {
554        Some(VerticalAlign::Top) => layout.y + font_size + 2.0,
555        Some(VerticalAlign::Center) => layout.y + layout.height / 2.0 + font_size / 3.0,
556        _ => layout.y + layout.height - 4.0,
557    }
558}
559
560/// Find the style ID for a cell at the given coordinates.
561fn find_cell_style(ws: &WorksheetXml, col: u32, row: u32) -> u32 {
562    ws.sheet_data
563        .rows
564        .binary_search_by_key(&row, |r| r.r)
565        .ok()
566        .and_then(|idx| {
567            let row_data = &ws.sheet_data.rows[idx];
568            row_data
569                .cells
570                .binary_search_by_key(&col, |c| c.col)
571                .ok()
572                .and_then(|ci| row_data.cells[ci].s)
573        })
574        .unwrap_or(0)
575}
576
577/// Find the CellValue for a cell at the given coordinates.
578fn find_cell_value(ws: &WorksheetXml, sst: &SharedStringTable, col: u32, row: u32) -> CellValue {
579    ws.sheet_data
580        .rows
581        .binary_search_by_key(&row, |r| r.r)
582        .ok()
583        .and_then(|idx| {
584            let row_data = &ws.sheet_data.rows[idx];
585            row_data
586                .cells
587                .binary_search_by_key(&col, |c| c.col)
588                .ok()
589                // Rendering formats numeric values using the stylesheet
590                // directly; no date-promotion step is needed here.
591                .map(|ci| resolve_cell_value(&row_data.cells[ci], sst, &[]))
592        })
593        .unwrap_or(CellValue::Empty)
594}
595
596/// Convert a StyleColor to a CSS hex color string.
597///
598/// Handles several input formats: 8-char ARGB (`FF000000`), 6-char RGB
599/// (`000000`), and values already prefixed with `#`. Always returns a
600/// `#RRGGBB` string suitable for SVG attributes.
601fn style_color_to_hex(color: &StyleColor) -> String {
602    match color {
603        StyleColor::Rgb(rgb) => {
604            let stripped = rgb.strip_prefix('#').unwrap_or(rgb);
605            if stripped.len() == 8 {
606                // ARGB format (e.g. "FF000000") -> "#000000"
607                format!("#{}", &stripped[2..])
608            } else {
609                format!("#{stripped}")
610            }
611        }
612        StyleColor::Theme(_) | StyleColor::Indexed(_) => "#000000".to_string(),
613    }
614}
615
616/// Convert a border line style to SVG stroke-width and color.
617fn border_line_attrs(style: BorderLineStyle, color: Option<&StyleColor>) -> (f64, String) {
618    let stroke_width = match style {
619        BorderLineStyle::Thin | BorderLineStyle::Hair => 1.0,
620        BorderLineStyle::Medium
621        | BorderLineStyle::MediumDashed
622        | BorderLineStyle::MediumDashDot
623        | BorderLineStyle::MediumDashDotDot => 2.0,
624        BorderLineStyle::Thick => 3.0,
625        _ => 1.0,
626    };
627    let color_str = color
628        .map(style_color_to_hex)
629        .unwrap_or_else(|| "#000000".to_string());
630    (stroke_width, color_str)
631}
632
633/// Escape special XML characters in text content.
634fn xml_escape(s: &str) -> String {
635    let mut out = String::with_capacity(s.len());
636    for c in s.chars() {
637        match c {
638            '&' => out.push_str("&amp;"),
639            '<' => out.push_str("&lt;"),
640            '>' => out.push_str("&gt;"),
641            '"' => out.push_str("&quot;"),
642            '\'' => out.push_str("&apos;"),
643            _ => out.push(c),
644        }
645    }
646    out
647}
648
649#[cfg(test)]
650#[allow(clippy::field_reassign_with_default)]
651mod tests {
652    use super::*;
653    use crate::sst::SharedStringTable;
654    use crate::style::{add_style, StyleBuilder};
655    use sheetkit_xml::styles::StyleSheet;
656    use sheetkit_xml::worksheet::{Cell, CellTypeTag, Row, SheetData, WorksheetXml};
657
658    fn default_options(sheet: &str) -> RenderOptions {
659        RenderOptions {
660            sheet_name: sheet.to_string(),
661            ..RenderOptions::default()
662        }
663    }
664
665    fn make_num_cell(r: &str, col: u32, v: &str) -> Cell {
666        Cell {
667            r: r.into(),
668            col,
669            s: None,
670            t: CellTypeTag::None,
671            v: Some(v.to_string()),
672            f: None,
673            is: None,
674        }
675    }
676
677    fn make_sst_cell(r: &str, col: u32, sst_idx: u32) -> Cell {
678        Cell {
679            r: r.into(),
680            col,
681            s: None,
682            t: CellTypeTag::SharedString,
683            v: Some(sst_idx.to_string()),
684            f: None,
685            is: None,
686        }
687    }
688
689    fn simple_ws_and_sst() -> (WorksheetXml, SharedStringTable) {
690        let mut sst = SharedStringTable::new();
691        sst.add("Name"); // 0
692        sst.add("Score"); // 1
693        sst.add("Alice"); // 2
694
695        let mut ws = WorksheetXml::default();
696        ws.sheet_data = SheetData {
697            rows: vec![
698                Row {
699                    r: 1,
700                    spans: None,
701                    s: None,
702                    custom_format: None,
703                    ht: None,
704                    hidden: None,
705                    custom_height: None,
706                    outline_level: None,
707                    cells: vec![make_sst_cell("A1", 1, 0), make_sst_cell("B1", 2, 1)],
708                },
709                Row {
710                    r: 2,
711                    spans: None,
712                    s: None,
713                    custom_format: None,
714                    ht: None,
715                    hidden: None,
716                    custom_height: None,
717                    outline_level: None,
718                    cells: vec![make_sst_cell("A2", 1, 2), make_num_cell("B2", 2, "95")],
719                },
720            ],
721        };
722        (ws, sst)
723    }
724
725    #[test]
726    fn test_render_produces_valid_svg() {
727        let (ws, sst) = simple_ws_and_sst();
728        let ss = StyleSheet::default();
729        let opts = default_options("Sheet1");
730
731        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
732
733        assert!(svg.starts_with("<svg"));
734        assert!(svg.ends_with("</svg>"));
735        assert!(svg.contains("xmlns=\"http://www.w3.org/2000/svg\""));
736    }
737
738    #[test]
739    fn test_render_contains_cell_text() {
740        let (ws, sst) = simple_ws_and_sst();
741        let ss = StyleSheet::default();
742        let opts = default_options("Sheet1");
743
744        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
745
746        assert!(
747            svg.contains(">Name<"),
748            "SVG should contain cell text 'Name'"
749        );
750        assert!(
751            svg.contains(">Score<"),
752            "SVG should contain cell text 'Score'"
753        );
754        assert!(
755            svg.contains(">Alice<"),
756            "SVG should contain cell text 'Alice'"
757        );
758        assert!(svg.contains(">95<"), "SVG should contain cell text '95'");
759    }
760
761    #[test]
762    fn test_render_contains_headers() {
763        let (ws, sst) = simple_ws_and_sst();
764        let ss = StyleSheet::default();
765        let opts = default_options("Sheet1");
766
767        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
768
769        assert!(svg.contains(">A<"), "SVG should contain column header 'A'");
770        assert!(svg.contains(">B<"), "SVG should contain column header 'B'");
771        assert!(svg.contains(">1<"), "SVG should contain row header '1'");
772        assert!(svg.contains(">2<"), "SVG should contain row header '2'");
773    }
774
775    #[test]
776    fn test_render_no_headers() {
777        let (ws, sst) = simple_ws_and_sst();
778        let ss = StyleSheet::default();
779        let mut opts = default_options("Sheet1");
780        opts.show_headers = false;
781
782        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
783
784        // With headers off, the header background rects should not appear
785        assert!(
786            !svg.contains("fill=\"#F0F0F0\""),
787            "SVG should not contain header backgrounds"
788        );
789    }
790
791    #[test]
792    fn test_render_no_gridlines() {
793        let (ws, sst) = simple_ws_and_sst();
794        let ss = StyleSheet::default();
795        let mut opts = default_options("Sheet1");
796        opts.show_gridlines = false;
797
798        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
799
800        assert!(
801            !svg.contains("stroke=\"#D0D0D0\""),
802            "SVG should not contain gridlines"
803        );
804    }
805
806    #[test]
807    fn test_render_with_gridlines() {
808        let (ws, sst) = simple_ws_and_sst();
809        let ss = StyleSheet::default();
810        let opts = default_options("Sheet1");
811
812        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
813
814        assert!(
815            svg.contains("stroke=\"#D0D0D0\""),
816            "SVG should contain gridlines"
817        );
818    }
819
820    #[test]
821    fn test_render_custom_col_widths() {
822        let (mut ws, sst) = simple_ws_and_sst();
823        crate::col::set_col_width(&mut ws, "A", 20.0).unwrap();
824
825        let ss = StyleSheet::default();
826        let opts = default_options("Sheet1");
827
828        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
829
830        assert!(svg.starts_with("<svg"));
831        assert!(svg.contains(">Name<"));
832    }
833
834    #[test]
835    fn test_render_custom_row_heights() {
836        let (mut ws, sst) = simple_ws_and_sst();
837        crate::row::set_row_height(&mut ws, 1, 30.0).unwrap();
838
839        let ss = StyleSheet::default();
840        let opts = default_options("Sheet1");
841
842        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
843
844        assert!(svg.starts_with("<svg"));
845        assert!(svg.contains(">Name<"));
846    }
847
848    #[test]
849    fn test_render_with_range() {
850        let (ws, sst) = simple_ws_and_sst();
851        let ss = StyleSheet::default();
852        let mut opts = default_options("Sheet1");
853        opts.range = Some("A1:A2".to_string());
854
855        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
856
857        assert!(svg.contains(">Name<"));
858        assert!(svg.contains(">Alice<"));
859        // B column should not appear if the range is just A1:A2
860        assert!(!svg.contains(">Score<"));
861    }
862
863    #[test]
864    fn test_render_empty_sheet() {
865        let ws = WorksheetXml::default();
866        let sst = SharedStringTable::new();
867        let ss = StyleSheet::default();
868        let opts = default_options("Sheet1");
869
870        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
871
872        assert!(svg.starts_with("<svg"));
873        assert!(svg.ends_with("</svg>"));
874    }
875
876    #[test]
877    fn test_render_bold_text() {
878        let (mut ws, sst) = simple_ws_and_sst();
879        let mut ss = StyleSheet::default();
880
881        let bold_style = StyleBuilder::new().bold(true).build();
882        let style_id = add_style(&mut ss, &bold_style).unwrap();
883
884        // Apply style to cell A1
885        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
886
887        let opts = default_options("Sheet1");
888
889        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
890
891        assert!(
892            svg.contains("font-weight=\"bold\""),
893            "SVG should contain bold font attribute"
894        );
895    }
896
897    #[test]
898    fn test_render_colored_fill() {
899        let (mut ws, sst) = simple_ws_and_sst();
900        let mut ss = StyleSheet::default();
901
902        let fill_style = StyleBuilder::new().solid_fill("FFFFFF00").build();
903        let style_id = add_style(&mut ss, &fill_style).unwrap();
904
905        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
906
907        let opts = default_options("Sheet1");
908
909        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
910
911        assert!(
912            svg.contains("fill=\"#FFFF00\""),
913            "SVG should contain yellow fill color"
914        );
915    }
916
917    #[test]
918    fn test_render_font_color() {
919        let (mut ws, sst) = simple_ws_and_sst();
920        let mut ss = StyleSheet::default();
921
922        let style = StyleBuilder::new().font_color_rgb("FFFF0000").build();
923        let style_id = add_style(&mut ss, &style).unwrap();
924
925        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
926
927        let opts = default_options("Sheet1");
928
929        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
930
931        assert!(
932            svg.contains("fill=\"#FF0000\""),
933            "SVG should contain red font color"
934        );
935    }
936
937    #[test]
938    fn test_render_with_shared_strings() {
939        let mut sst = SharedStringTable::new();
940        sst.add("Hello");
941        sst.add("World");
942
943        let mut ws = WorksheetXml::default();
944        ws.sheet_data = SheetData {
945            rows: vec![Row {
946                r: 1,
947                spans: None,
948                s: None,
949                custom_format: None,
950                ht: None,
951                hidden: None,
952                custom_height: None,
953                outline_level: None,
954                cells: vec![
955                    Cell {
956                        r: "A1".into(),
957                        col: 1,
958                        s: None,
959                        t: CellTypeTag::SharedString,
960                        v: Some("0".to_string()),
961                        f: None,
962                        is: None,
963                    },
964                    Cell {
965                        r: "B1".into(),
966                        col: 2,
967                        s: None,
968                        t: CellTypeTag::SharedString,
969                        v: Some("1".to_string()),
970                        f: None,
971                        is: None,
972                    },
973                ],
974            }],
975        };
976
977        let ss = StyleSheet::default();
978        let opts = default_options("Sheet1");
979
980        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
981
982        assert!(svg.contains(">Hello<"));
983        assert!(svg.contains(">World<"));
984    }
985
986    #[test]
987    fn test_render_xml_escaping() {
988        let mut ws = WorksheetXml::default();
989        ws.sheet_data = SheetData {
990            rows: vec![Row {
991                r: 1,
992                spans: None,
993                s: None,
994                custom_format: None,
995                ht: None,
996                hidden: None,
997                custom_height: None,
998                outline_level: None,
999                cells: vec![],
1000            }],
1001        };
1002
1003        let sst = SharedStringTable::new();
1004        let ss = StyleSheet::default();
1005        let opts = default_options("Sheet1");
1006
1007        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
1008
1009        // Verify valid XML - at minimum it parses as SVG
1010        assert!(svg.starts_with("<svg"));
1011        assert!(svg.ends_with("</svg>"));
1012    }
1013
1014    #[test]
1015    fn test_xml_escape_special_chars() {
1016        assert_eq!(xml_escape("a&b"), "a&amp;b");
1017        assert_eq!(xml_escape("a<b"), "a&lt;b");
1018        assert_eq!(xml_escape("a>b"), "a&gt;b");
1019        assert_eq!(xml_escape("a\"b"), "a&quot;b");
1020        assert_eq!(xml_escape("a'b"), "a&apos;b");
1021        assert_eq!(xml_escape("normal"), "normal");
1022    }
1023
1024    #[test]
1025    fn test_style_color_to_hex_argb() {
1026        let color = StyleColor::Rgb("FFFF0000".to_string());
1027        assert_eq!(style_color_to_hex(&color), "#FF0000");
1028    }
1029
1030    #[test]
1031    fn test_style_color_to_hex_rgb() {
1032        let color = StyleColor::Rgb("00FF00".to_string());
1033        assert_eq!(style_color_to_hex(&color), "#00FF00");
1034    }
1035
1036    #[test]
1037    fn test_style_color_to_hex_theme_defaults_to_black() {
1038        let color = StyleColor::Theme(4);
1039        assert_eq!(style_color_to_hex(&color), "#000000");
1040    }
1041
1042    #[test]
1043    fn test_border_line_attrs_thin() {
1044        let (sw, color) = border_line_attrs(BorderLineStyle::Thin, None);
1045        assert_eq!(sw, 1.0);
1046        assert_eq!(color, "#000000");
1047    }
1048
1049    #[test]
1050    fn test_border_line_attrs_thick_with_color() {
1051        let c = StyleColor::Rgb("FF0000FF".to_string());
1052        let (sw, color) = border_line_attrs(BorderLineStyle::Thick, Some(&c));
1053        assert_eq!(sw, 3.0);
1054        assert_eq!(color, "#0000FF");
1055    }
1056
1057    #[test]
1058    fn test_render_center_aligned_text() {
1059        let (mut ws, sst) = simple_ws_and_sst();
1060        let mut ss = StyleSheet::default();
1061
1062        let style = StyleBuilder::new()
1063            .horizontal_align(HorizontalAlign::Center)
1064            .build();
1065        let style_id = add_style(&mut ss, &style).unwrap();
1066
1067        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
1068
1069        let opts = default_options("Sheet1");
1070
1071        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
1072
1073        assert!(
1074            svg.contains("text-anchor=\"middle\""),
1075            "SVG should contain centered text"
1076        );
1077    }
1078
1079    #[test]
1080    fn test_render_right_aligned_text() {
1081        let (mut ws, sst) = simple_ws_and_sst();
1082        let mut ss = StyleSheet::default();
1083
1084        let style = StyleBuilder::new()
1085            .horizontal_align(HorizontalAlign::Right)
1086            .build();
1087        let style_id = add_style(&mut ss, &style).unwrap();
1088
1089        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
1090
1091        let opts = default_options("Sheet1");
1092
1093        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
1094
1095        assert!(
1096            svg.contains("text-anchor=\"end\""),
1097            "SVG should contain right-aligned text"
1098        );
1099    }
1100
1101    #[test]
1102    fn test_render_italic_text() {
1103        let (mut ws, sst) = simple_ws_and_sst();
1104        let mut ss = StyleSheet::default();
1105
1106        let style = StyleBuilder::new().italic(true).build();
1107        let style_id = add_style(&mut ss, &style).unwrap();
1108
1109        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
1110
1111        let opts = default_options("Sheet1");
1112
1113        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
1114
1115        assert!(
1116            svg.contains("font-style=\"italic\""),
1117            "SVG should contain italic text"
1118        );
1119    }
1120
1121    #[test]
1122    fn test_render_border_lines() {
1123        let (mut ws, sst) = simple_ws_and_sst();
1124        let mut ss = StyleSheet::default();
1125
1126        let style = StyleBuilder::new()
1127            .border_all(
1128                BorderLineStyle::Thin,
1129                StyleColor::Rgb("FF000000".to_string()),
1130            )
1131            .build();
1132        let style_id = add_style(&mut ss, &style).unwrap();
1133
1134        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
1135
1136        let opts = default_options("Sheet1");
1137
1138        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
1139
1140        assert!(
1141            svg.contains("stroke=\"#000000\""),
1142            "SVG should contain border lines"
1143        );
1144    }
1145
1146    #[test]
1147    fn test_render_invalid_range_returns_error() {
1148        let (ws, sst) = simple_ws_and_sst();
1149        let ss = StyleSheet::default();
1150        let mut opts = default_options("Sheet1");
1151        opts.range = Some("INVALID".to_string());
1152
1153        let result = render_to_svg(&ws, &sst, &ss, &opts);
1154        assert!(result.is_err());
1155    }
1156
1157    #[test]
1158    fn test_render_scale_affects_dimensions() {
1159        let (ws, sst) = simple_ws_and_sst();
1160        let ss = StyleSheet::default();
1161
1162        let mut opts1 = default_options("Sheet1");
1163        opts1.scale = 1.0;
1164        let svg1 = render_to_svg(&ws, &sst, &ss, &opts1).unwrap();
1165
1166        let mut opts2 = default_options("Sheet1");
1167        opts2.scale = 2.0;
1168        let svg2 = render_to_svg(&ws, &sst, &ss, &opts2).unwrap();
1169
1170        // Extract width from the SVG tag
1171        fn extract_width(svg: &str) -> f64 {
1172            let start = svg.find("width=\"").unwrap() + 7;
1173            let end = svg[start..].find('"').unwrap() + start;
1174            svg[start..end].parse().unwrap()
1175        }
1176
1177        let w1 = extract_width(&svg1);
1178        let w2 = extract_width(&svg2);
1179        assert!(
1180            (w2 - w1 * 2.0).abs() < 0.01,
1181            "scale=2.0 should double the width: {w1} vs {w2}"
1182        );
1183    }
1184
1185    #[test]
1186    fn test_render_underline_text() {
1187        let (mut ws, sst) = simple_ws_and_sst();
1188        let mut ss = StyleSheet::default();
1189
1190        let style = StyleBuilder::new().underline(true).build();
1191        let style_id = add_style(&mut ss, &style).unwrap();
1192
1193        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
1194
1195        let opts = default_options("Sheet1");
1196
1197        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
1198
1199        assert!(
1200            svg.contains("text-decoration=\"underline\""),
1201            "SVG should contain underlined text"
1202        );
1203    }
1204
1205    #[test]
1206    fn test_render_strikethrough_text() {
1207        let (mut ws, sst) = simple_ws_and_sst();
1208        let mut ss = StyleSheet::default();
1209
1210        let style = StyleBuilder::new().strikethrough(true).build();
1211        let style_id = add_style(&mut ss, &style).unwrap();
1212
1213        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
1214
1215        let opts = default_options("Sheet1");
1216
1217        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
1218
1219        assert!(
1220            svg.contains("text-decoration=\"line-through\""),
1221            "SVG should contain strikethrough text"
1222        );
1223    }
1224
1225    #[test]
1226    fn test_style_color_to_hex_already_prefixed() {
1227        let color = StyleColor::Rgb("#FF0000".to_string());
1228        assert_eq!(style_color_to_hex(&color), "#FF0000");
1229    }
1230
1231    #[test]
1232    fn test_style_color_to_hex_prefixed_argb() {
1233        let color = StyleColor::Rgb("#FFFF0000".to_string());
1234        assert_eq!(style_color_to_hex(&color), "#FF0000");
1235    }
1236
1237    #[test]
1238    fn test_style_color_to_hex_no_double_hash() {
1239        let color = StyleColor::Rgb("#00FF00".to_string());
1240        let hex = style_color_to_hex(&color);
1241        assert!(
1242            !hex.starts_with("##"),
1243            "should not produce double hash, got: {hex}"
1244        );
1245        assert_eq!(hex, "#00FF00");
1246    }
1247
1248    #[test]
1249    fn test_render_underline_and_strikethrough_merged() {
1250        let (mut ws, sst) = simple_ws_and_sst();
1251        let mut ss = StyleSheet::default();
1252
1253        let style = StyleBuilder::new()
1254            .underline(true)
1255            .strikethrough(true)
1256            .build();
1257        let style_id = add_style(&mut ss, &style).unwrap();
1258
1259        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
1260
1261        let opts = default_options("Sheet1");
1262        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
1263
1264        assert!(
1265            svg.contains(r#"text-decoration="underline line-through""#),
1266            "both decorations should be merged in one attribute"
1267        );
1268        let count = svg.matches("text-decoration=").count();
1269        // Only one text-decoration attribute for the cell A1 text element
1270        assert_eq!(
1271            count, 1,
1272            "text-decoration should appear exactly once, found {count}"
1273        );
1274    }
1275
1276    #[test]
1277    fn test_render_scale_zero_returns_error() {
1278        let (ws, sst) = simple_ws_and_sst();
1279        let ss = StyleSheet::default();
1280        let mut opts = default_options("Sheet1");
1281        opts.scale = 0.0;
1282
1283        let result = render_to_svg(&ws, &sst, &ss, &opts);
1284        assert!(result.is_err(), "scale=0 should return an error");
1285        let err_msg = result.unwrap_err().to_string();
1286        assert!(
1287            err_msg.contains("scale must be positive"),
1288            "error should mention scale: {err_msg}"
1289        );
1290    }
1291
1292    #[test]
1293    fn test_render_scale_negative_returns_error() {
1294        let (ws, sst) = simple_ws_and_sst();
1295        let ss = StyleSheet::default();
1296        let mut opts = default_options("Sheet1");
1297        opts.scale = -1.0;
1298
1299        let result = render_to_svg(&ws, &sst, &ss, &opts);
1300        assert!(result.is_err(), "negative scale should return an error");
1301        let err_msg = result.unwrap_err().to_string();
1302        assert!(
1303            err_msg.contains("scale must be positive"),
1304            "error should mention scale: {err_msg}"
1305        );
1306    }
1307}