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