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)?;
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
235fn 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
248fn 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#[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
292fn 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
318fn 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
343fn 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
374fn 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
403fn 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#[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
529fn 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
541fn 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
558fn 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
575fn 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
592fn 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 format!("#{}", &stripped[2..])
604 } else {
605 format!("#{stripped}")
606 }
607 }
608 StyleColor::Theme(_) | StyleColor::Indexed(_) => "#000000".to_string(),
609 }
610}
611
612fn 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
629fn 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("&"),
635 '<' => out.push_str("<"),
636 '>' => out.push_str(">"),
637 '"' => out.push_str("""),
638 '\'' => out.push_str("'"),
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"); sst.add("Score"); sst.add("Alice"); 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 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 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 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 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&b");
1013 assert_eq!(xml_escape("a<b"), "a<b");
1014 assert_eq!(xml_escape("a>b"), "a>b");
1015 assert_eq!(xml_escape("a\"b"), "a"b");
1016 assert_eq!(xml_escape("a'b"), "a'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 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 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}