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)]
646#[allow(clippy::field_reassign_with_default)]
647mod tests {
648    use super::*;
649    use crate::sst::SharedStringTable;
650    use crate::style::{add_style, StyleBuilder};
651    use sheetkit_xml::styles::StyleSheet;
652    use sheetkit_xml::worksheet::{Cell, CellTypeTag, Row, SheetData, WorksheetXml};
653
654    fn default_options(sheet: &str) -> RenderOptions {
655        RenderOptions {
656            sheet_name: sheet.to_string(),
657            ..RenderOptions::default()
658        }
659    }
660
661    fn make_num_cell(r: &str, col: u32, v: &str) -> Cell {
662        Cell {
663            r: r.into(),
664            col,
665            s: None,
666            t: CellTypeTag::None,
667            v: Some(v.to_string()),
668            f: None,
669            is: None,
670        }
671    }
672
673    fn make_sst_cell(r: &str, col: u32, sst_idx: u32) -> Cell {
674        Cell {
675            r: r.into(),
676            col,
677            s: None,
678            t: CellTypeTag::SharedString,
679            v: Some(sst_idx.to_string()),
680            f: None,
681            is: None,
682        }
683    }
684
685    fn simple_ws_and_sst() -> (WorksheetXml, SharedStringTable) {
686        let mut sst = SharedStringTable::new();
687        sst.add("Name"); // 0
688        sst.add("Score"); // 1
689        sst.add("Alice"); // 2
690
691        let mut ws = WorksheetXml::default();
692        ws.sheet_data = SheetData {
693            rows: vec![
694                Row {
695                    r: 1,
696                    spans: None,
697                    s: None,
698                    custom_format: None,
699                    ht: None,
700                    hidden: None,
701                    custom_height: None,
702                    outline_level: None,
703                    cells: vec![make_sst_cell("A1", 1, 0), make_sst_cell("B1", 2, 1)],
704                },
705                Row {
706                    r: 2,
707                    spans: None,
708                    s: None,
709                    custom_format: None,
710                    ht: None,
711                    hidden: None,
712                    custom_height: None,
713                    outline_level: None,
714                    cells: vec![make_sst_cell("A2", 1, 2), make_num_cell("B2", 2, "95")],
715                },
716            ],
717        };
718        (ws, sst)
719    }
720
721    #[test]
722    fn test_render_produces_valid_svg() {
723        let (ws, sst) = simple_ws_and_sst();
724        let ss = StyleSheet::default();
725        let opts = default_options("Sheet1");
726
727        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
728
729        assert!(svg.starts_with("<svg"));
730        assert!(svg.ends_with("</svg>"));
731        assert!(svg.contains("xmlns=\"http://www.w3.org/2000/svg\""));
732    }
733
734    #[test]
735    fn test_render_contains_cell_text() {
736        let (ws, sst) = simple_ws_and_sst();
737        let ss = StyleSheet::default();
738        let opts = default_options("Sheet1");
739
740        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
741
742        assert!(
743            svg.contains(">Name<"),
744            "SVG should contain cell text 'Name'"
745        );
746        assert!(
747            svg.contains(">Score<"),
748            "SVG should contain cell text 'Score'"
749        );
750        assert!(
751            svg.contains(">Alice<"),
752            "SVG should contain cell text 'Alice'"
753        );
754        assert!(svg.contains(">95<"), "SVG should contain cell text '95'");
755    }
756
757    #[test]
758    fn test_render_contains_headers() {
759        let (ws, sst) = simple_ws_and_sst();
760        let ss = StyleSheet::default();
761        let opts = default_options("Sheet1");
762
763        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
764
765        assert!(svg.contains(">A<"), "SVG should contain column header 'A'");
766        assert!(svg.contains(">B<"), "SVG should contain column header 'B'");
767        assert!(svg.contains(">1<"), "SVG should contain row header '1'");
768        assert!(svg.contains(">2<"), "SVG should contain row header '2'");
769    }
770
771    #[test]
772    fn test_render_no_headers() {
773        let (ws, sst) = simple_ws_and_sst();
774        let ss = StyleSheet::default();
775        let mut opts = default_options("Sheet1");
776        opts.show_headers = false;
777
778        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
779
780        // With headers off, the header background rects should not appear
781        assert!(
782            !svg.contains("fill=\"#F0F0F0\""),
783            "SVG should not contain header backgrounds"
784        );
785    }
786
787    #[test]
788    fn test_render_no_gridlines() {
789        let (ws, sst) = simple_ws_and_sst();
790        let ss = StyleSheet::default();
791        let mut opts = default_options("Sheet1");
792        opts.show_gridlines = false;
793
794        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
795
796        assert!(
797            !svg.contains("stroke=\"#D0D0D0\""),
798            "SVG should not contain gridlines"
799        );
800    }
801
802    #[test]
803    fn test_render_with_gridlines() {
804        let (ws, sst) = simple_ws_and_sst();
805        let ss = StyleSheet::default();
806        let opts = default_options("Sheet1");
807
808        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
809
810        assert!(
811            svg.contains("stroke=\"#D0D0D0\""),
812            "SVG should contain gridlines"
813        );
814    }
815
816    #[test]
817    fn test_render_custom_col_widths() {
818        let (mut ws, sst) = simple_ws_and_sst();
819        crate::col::set_col_width(&mut ws, "A", 20.0).unwrap();
820
821        let ss = StyleSheet::default();
822        let opts = default_options("Sheet1");
823
824        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
825
826        assert!(svg.starts_with("<svg"));
827        assert!(svg.contains(">Name<"));
828    }
829
830    #[test]
831    fn test_render_custom_row_heights() {
832        let (mut ws, sst) = simple_ws_and_sst();
833        crate::row::set_row_height(&mut ws, 1, 30.0).unwrap();
834
835        let ss = StyleSheet::default();
836        let opts = default_options("Sheet1");
837
838        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
839
840        assert!(svg.starts_with("<svg"));
841        assert!(svg.contains(">Name<"));
842    }
843
844    #[test]
845    fn test_render_with_range() {
846        let (ws, sst) = simple_ws_and_sst();
847        let ss = StyleSheet::default();
848        let mut opts = default_options("Sheet1");
849        opts.range = Some("A1:A2".to_string());
850
851        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
852
853        assert!(svg.contains(">Name<"));
854        assert!(svg.contains(">Alice<"));
855        // B column should not appear if the range is just A1:A2
856        assert!(!svg.contains(">Score<"));
857    }
858
859    #[test]
860    fn test_render_empty_sheet() {
861        let ws = WorksheetXml::default();
862        let sst = SharedStringTable::new();
863        let ss = StyleSheet::default();
864        let opts = default_options("Sheet1");
865
866        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
867
868        assert!(svg.starts_with("<svg"));
869        assert!(svg.ends_with("</svg>"));
870    }
871
872    #[test]
873    fn test_render_bold_text() {
874        let (mut ws, sst) = simple_ws_and_sst();
875        let mut ss = StyleSheet::default();
876
877        let bold_style = StyleBuilder::new().bold(true).build();
878        let style_id = add_style(&mut ss, &bold_style).unwrap();
879
880        // Apply style to cell A1
881        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
882
883        let opts = default_options("Sheet1");
884
885        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
886
887        assert!(
888            svg.contains("font-weight=\"bold\""),
889            "SVG should contain bold font attribute"
890        );
891    }
892
893    #[test]
894    fn test_render_colored_fill() {
895        let (mut ws, sst) = simple_ws_and_sst();
896        let mut ss = StyleSheet::default();
897
898        let fill_style = StyleBuilder::new().solid_fill("FFFFFF00").build();
899        let style_id = add_style(&mut ss, &fill_style).unwrap();
900
901        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
902
903        let opts = default_options("Sheet1");
904
905        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
906
907        assert!(
908            svg.contains("fill=\"#FFFF00\""),
909            "SVG should contain yellow fill color"
910        );
911    }
912
913    #[test]
914    fn test_render_font_color() {
915        let (mut ws, sst) = simple_ws_and_sst();
916        let mut ss = StyleSheet::default();
917
918        let style = StyleBuilder::new().font_color_rgb("FFFF0000").build();
919        let style_id = add_style(&mut ss, &style).unwrap();
920
921        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
922
923        let opts = default_options("Sheet1");
924
925        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
926
927        assert!(
928            svg.contains("fill=\"#FF0000\""),
929            "SVG should contain red font color"
930        );
931    }
932
933    #[test]
934    fn test_render_with_shared_strings() {
935        let mut sst = SharedStringTable::new();
936        sst.add("Hello");
937        sst.add("World");
938
939        let mut ws = WorksheetXml::default();
940        ws.sheet_data = SheetData {
941            rows: vec![Row {
942                r: 1,
943                spans: None,
944                s: None,
945                custom_format: None,
946                ht: None,
947                hidden: None,
948                custom_height: None,
949                outline_level: None,
950                cells: vec![
951                    Cell {
952                        r: "A1".into(),
953                        col: 1,
954                        s: None,
955                        t: CellTypeTag::SharedString,
956                        v: Some("0".to_string()),
957                        f: None,
958                        is: None,
959                    },
960                    Cell {
961                        r: "B1".into(),
962                        col: 2,
963                        s: None,
964                        t: CellTypeTag::SharedString,
965                        v: Some("1".to_string()),
966                        f: None,
967                        is: None,
968                    },
969                ],
970            }],
971        };
972
973        let ss = StyleSheet::default();
974        let opts = default_options("Sheet1");
975
976        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
977
978        assert!(svg.contains(">Hello<"));
979        assert!(svg.contains(">World<"));
980    }
981
982    #[test]
983    fn test_render_xml_escaping() {
984        let mut ws = WorksheetXml::default();
985        ws.sheet_data = SheetData {
986            rows: vec![Row {
987                r: 1,
988                spans: None,
989                s: None,
990                custom_format: None,
991                ht: None,
992                hidden: None,
993                custom_height: None,
994                outline_level: None,
995                cells: vec![],
996            }],
997        };
998
999        let sst = SharedStringTable::new();
1000        let ss = StyleSheet::default();
1001        let opts = default_options("Sheet1");
1002
1003        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
1004
1005        // Verify valid XML - at minimum it parses as SVG
1006        assert!(svg.starts_with("<svg"));
1007        assert!(svg.ends_with("</svg>"));
1008    }
1009
1010    #[test]
1011    fn test_xml_escape_special_chars() {
1012        assert_eq!(xml_escape("a&b"), "a&amp;b");
1013        assert_eq!(xml_escape("a<b"), "a&lt;b");
1014        assert_eq!(xml_escape("a>b"), "a&gt;b");
1015        assert_eq!(xml_escape("a\"b"), "a&quot;b");
1016        assert_eq!(xml_escape("a'b"), "a&apos;b");
1017        assert_eq!(xml_escape("normal"), "normal");
1018    }
1019
1020    #[test]
1021    fn test_style_color_to_hex_argb() {
1022        let color = StyleColor::Rgb("FFFF0000".to_string());
1023        assert_eq!(style_color_to_hex(&color), "#FF0000");
1024    }
1025
1026    #[test]
1027    fn test_style_color_to_hex_rgb() {
1028        let color = StyleColor::Rgb("00FF00".to_string());
1029        assert_eq!(style_color_to_hex(&color), "#00FF00");
1030    }
1031
1032    #[test]
1033    fn test_style_color_to_hex_theme_defaults_to_black() {
1034        let color = StyleColor::Theme(4);
1035        assert_eq!(style_color_to_hex(&color), "#000000");
1036    }
1037
1038    #[test]
1039    fn test_border_line_attrs_thin() {
1040        let (sw, color) = border_line_attrs(BorderLineStyle::Thin, None);
1041        assert_eq!(sw, 1.0);
1042        assert_eq!(color, "#000000");
1043    }
1044
1045    #[test]
1046    fn test_border_line_attrs_thick_with_color() {
1047        let c = StyleColor::Rgb("FF0000FF".to_string());
1048        let (sw, color) = border_line_attrs(BorderLineStyle::Thick, Some(&c));
1049        assert_eq!(sw, 3.0);
1050        assert_eq!(color, "#0000FF");
1051    }
1052
1053    #[test]
1054    fn test_render_center_aligned_text() {
1055        let (mut ws, sst) = simple_ws_and_sst();
1056        let mut ss = StyleSheet::default();
1057
1058        let style = StyleBuilder::new()
1059            .horizontal_align(HorizontalAlign::Center)
1060            .build();
1061        let style_id = add_style(&mut ss, &style).unwrap();
1062
1063        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
1064
1065        let opts = default_options("Sheet1");
1066
1067        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
1068
1069        assert!(
1070            svg.contains("text-anchor=\"middle\""),
1071            "SVG should contain centered text"
1072        );
1073    }
1074
1075    #[test]
1076    fn test_render_right_aligned_text() {
1077        let (mut ws, sst) = simple_ws_and_sst();
1078        let mut ss = StyleSheet::default();
1079
1080        let style = StyleBuilder::new()
1081            .horizontal_align(HorizontalAlign::Right)
1082            .build();
1083        let style_id = add_style(&mut ss, &style).unwrap();
1084
1085        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
1086
1087        let opts = default_options("Sheet1");
1088
1089        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
1090
1091        assert!(
1092            svg.contains("text-anchor=\"end\""),
1093            "SVG should contain right-aligned text"
1094        );
1095    }
1096
1097    #[test]
1098    fn test_render_italic_text() {
1099        let (mut ws, sst) = simple_ws_and_sst();
1100        let mut ss = StyleSheet::default();
1101
1102        let style = StyleBuilder::new().italic(true).build();
1103        let style_id = add_style(&mut ss, &style).unwrap();
1104
1105        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
1106
1107        let opts = default_options("Sheet1");
1108
1109        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
1110
1111        assert!(
1112            svg.contains("font-style=\"italic\""),
1113            "SVG should contain italic text"
1114        );
1115    }
1116
1117    #[test]
1118    fn test_render_border_lines() {
1119        let (mut ws, sst) = simple_ws_and_sst();
1120        let mut ss = StyleSheet::default();
1121
1122        let style = StyleBuilder::new()
1123            .border_all(
1124                BorderLineStyle::Thin,
1125                StyleColor::Rgb("FF000000".to_string()),
1126            )
1127            .build();
1128        let style_id = add_style(&mut ss, &style).unwrap();
1129
1130        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
1131
1132        let opts = default_options("Sheet1");
1133
1134        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
1135
1136        assert!(
1137            svg.contains("stroke=\"#000000\""),
1138            "SVG should contain border lines"
1139        );
1140    }
1141
1142    #[test]
1143    fn test_render_invalid_range_returns_error() {
1144        let (ws, sst) = simple_ws_and_sst();
1145        let ss = StyleSheet::default();
1146        let mut opts = default_options("Sheet1");
1147        opts.range = Some("INVALID".to_string());
1148
1149        let result = render_to_svg(&ws, &sst, &ss, &opts);
1150        assert!(result.is_err());
1151    }
1152
1153    #[test]
1154    fn test_render_scale_affects_dimensions() {
1155        let (ws, sst) = simple_ws_and_sst();
1156        let ss = StyleSheet::default();
1157
1158        let mut opts1 = default_options("Sheet1");
1159        opts1.scale = 1.0;
1160        let svg1 = render_to_svg(&ws, &sst, &ss, &opts1).unwrap();
1161
1162        let mut opts2 = default_options("Sheet1");
1163        opts2.scale = 2.0;
1164        let svg2 = render_to_svg(&ws, &sst, &ss, &opts2).unwrap();
1165
1166        // Extract width from the SVG tag
1167        fn extract_width(svg: &str) -> f64 {
1168            let start = svg.find("width=\"").unwrap() + 7;
1169            let end = svg[start..].find('"').unwrap() + start;
1170            svg[start..end].parse().unwrap()
1171        }
1172
1173        let w1 = extract_width(&svg1);
1174        let w2 = extract_width(&svg2);
1175        assert!(
1176            (w2 - w1 * 2.0).abs() < 0.01,
1177            "scale=2.0 should double the width: {w1} vs {w2}"
1178        );
1179    }
1180
1181    #[test]
1182    fn test_render_underline_text() {
1183        let (mut ws, sst) = simple_ws_and_sst();
1184        let mut ss = StyleSheet::default();
1185
1186        let style = StyleBuilder::new().underline(true).build();
1187        let style_id = add_style(&mut ss, &style).unwrap();
1188
1189        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
1190
1191        let opts = default_options("Sheet1");
1192
1193        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
1194
1195        assert!(
1196            svg.contains("text-decoration=\"underline\""),
1197            "SVG should contain underlined text"
1198        );
1199    }
1200
1201    #[test]
1202    fn test_render_strikethrough_text() {
1203        let (mut ws, sst) = simple_ws_and_sst();
1204        let mut ss = StyleSheet::default();
1205
1206        let style = StyleBuilder::new().strikethrough(true).build();
1207        let style_id = add_style(&mut ss, &style).unwrap();
1208
1209        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
1210
1211        let opts = default_options("Sheet1");
1212
1213        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
1214
1215        assert!(
1216            svg.contains("text-decoration=\"line-through\""),
1217            "SVG should contain strikethrough text"
1218        );
1219    }
1220
1221    #[test]
1222    fn test_style_color_to_hex_already_prefixed() {
1223        let color = StyleColor::Rgb("#FF0000".to_string());
1224        assert_eq!(style_color_to_hex(&color), "#FF0000");
1225    }
1226
1227    #[test]
1228    fn test_style_color_to_hex_prefixed_argb() {
1229        let color = StyleColor::Rgb("#FFFF0000".to_string());
1230        assert_eq!(style_color_to_hex(&color), "#FF0000");
1231    }
1232
1233    #[test]
1234    fn test_style_color_to_hex_no_double_hash() {
1235        let color = StyleColor::Rgb("#00FF00".to_string());
1236        let hex = style_color_to_hex(&color);
1237        assert!(
1238            !hex.starts_with("##"),
1239            "should not produce double hash, got: {hex}"
1240        );
1241        assert_eq!(hex, "#00FF00");
1242    }
1243
1244    #[test]
1245    fn test_render_underline_and_strikethrough_merged() {
1246        let (mut ws, sst) = simple_ws_and_sst();
1247        let mut ss = StyleSheet::default();
1248
1249        let style = StyleBuilder::new()
1250            .underline(true)
1251            .strikethrough(true)
1252            .build();
1253        let style_id = add_style(&mut ss, &style).unwrap();
1254
1255        ws.sheet_data.rows[0].cells[0].s = Some(style_id);
1256
1257        let opts = default_options("Sheet1");
1258        let svg = render_to_svg(&ws, &sst, &ss, &opts).unwrap();
1259
1260        assert!(
1261            svg.contains(r#"text-decoration="underline line-through""#),
1262            "both decorations should be merged in one attribute"
1263        );
1264        let count = svg.matches("text-decoration=").count();
1265        // Only one text-decoration attribute for the cell A1 text element
1266        assert_eq!(
1267            count, 1,
1268            "text-decoration should appear exactly once, found {count}"
1269        );
1270    }
1271
1272    #[test]
1273    fn test_render_scale_zero_returns_error() {
1274        let (ws, sst) = simple_ws_and_sst();
1275        let ss = StyleSheet::default();
1276        let mut opts = default_options("Sheet1");
1277        opts.scale = 0.0;
1278
1279        let result = render_to_svg(&ws, &sst, &ss, &opts);
1280        assert!(result.is_err(), "scale=0 should return an error");
1281        let err_msg = result.unwrap_err().to_string();
1282        assert!(
1283            err_msg.contains("scale must be positive"),
1284            "error should mention scale: {err_msg}"
1285        );
1286    }
1287
1288    #[test]
1289    fn test_render_scale_negative_returns_error() {
1290        let (ws, sst) = simple_ws_and_sst();
1291        let ss = StyleSheet::default();
1292        let mut opts = default_options("Sheet1");
1293        opts.scale = -1.0;
1294
1295        let result = render_to_svg(&ws, &sst, &ss, &opts);
1296        assert!(result.is_err(), "negative scale should return an error");
1297        let err_msg = result.unwrap_err().to_string();
1298        assert!(
1299            err_msg.contains("scale must be positive"),
1300            "error should mention scale: {err_msg}"
1301        );
1302    }
1303}