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