1use 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
20const DEFAULT_COL_WIDTH_PX: f64 = 64.0;
22
23const DEFAULT_ROW_HEIGHT_PX: f64 = 20.0;
25
26const 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
34fn col_width_to_px(width: f64) -> f64 {
38 width * 7.0 + 5.0
39}
40
41fn row_height_to_px(height: f64) -> f64 {
44 height * 4.0 / 3.0
45}
46
47pub struct RenderOptions {
49 pub sheet_name: String,
51 pub range: Option<String>,
53 pub show_gridlines: bool,
55 pub show_headers: bool,
57 pub scale: f64,
59 pub default_font_family: String,
61 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
79struct CellLayout {
81 x: f64,
82 y: f64,
83 width: f64,
84 height: f64,
85 col: u32,
86 row: u32,
87}
88
89pub 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 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 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 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(&mut svg, ws, sst, stylesheet, &layouts, min_col, min_row);
168
169 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(&mut svg, ws, stylesheet, &layouts, min_col, min_row);
184
185 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
194fn 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, &[])?;
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
237fn 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
250fn 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#[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
294fn 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
320fn 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
345fn 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
376fn 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
405fn 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#[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
531fn 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
543fn 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
560fn 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
577fn 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 .map(|ci| resolve_cell_value(&row_data.cells[ci], sst, &[]))
592 })
593 .unwrap_or(CellValue::Empty)
594}
595
596fn 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 format!("#{}", &stripped[2..])
608 } else {
609 format!("#{stripped}")
610 }
611 }
612 StyleColor::Theme(_) | StyleColor::Indexed(_) => "#000000".to_string(),
613 }
614}
615
616fn 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
633fn 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("&"),
639 '<' => out.push_str("<"),
640 '>' => out.push_str(">"),
641 '"' => out.push_str("""),
642 '\'' => out.push_str("'"),
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"); sst.add("Score"); sst.add("Alice"); 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 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 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 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 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&b");
1017 assert_eq!(xml_escape("a<b"), "a<b");
1018 assert_eq!(xml_escape("a>b"), "a>b");
1019 assert_eq!(xml_escape("a\"b"), "a"b");
1020 assert_eq!(xml_escape("a'b"), "a'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 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 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}