Skip to main content

chartml_render/
svg.rs

1//! ChartElement tree → SVG string serialization.
2//!
3//! Walks the ChartElement tree recursively and emits SVG XML.
4//! Follows the same pattern as `chartml-leptos/src/element.rs` but
5//! produces a string instead of Leptos views.
6
7use chartml_core::element::{ChartElement, ElementData};
8use std::fmt::Write;
9
10/// Default font family for SVG text elements.
11const DEFAULT_FONT_FAMILY: &str = "Inter, Liberation Sans, Arial, sans-serif";
12
13/// Convert a ChartElement tree to an SVG string.
14///
15/// The root element should be a `ChartElement::Svg`. If it's a `Div` (e.g. metric cards),
16/// the output wraps it in an SVG with a `<foreignObject>`.
17pub fn element_to_svg(element: &ChartElement, width: f64, height: f64) -> String {
18    let mut buf = String::with_capacity(4096);
19
20    match element {
21        ChartElement::Svg { .. } => {
22            write_element(&mut buf, element);
23        }
24        // Non-SVG root (metric cards, multi-chart grids) — wrap in SVG + foreignObject
25        _ => {
26            write!(
27                &mut buf,
28                r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {} {}" width="{}" height="{}">"#,
29                width, height, width, height,
30            ).unwrap();
31            buf.push_str(r#"<foreignObject x="0" y="0" width="100%" height="100%">"#);
32            write_element(&mut buf, element);
33            buf.push_str("</foreignObject>");
34            buf.push_str("</svg>");
35        }
36    }
37
38    buf
39}
40
41/// Recursively write a ChartElement to the buffer as SVG/HTML.
42fn write_element(buf: &mut String, element: &ChartElement) {
43    match element {
44        ChartElement::Svg { viewbox, width, height, class, children } => {
45            write!(
46                buf,
47                r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{}""##,
48                viewbox.to_svg_string(),
49            ).unwrap();
50            if let Some(w) = width {
51                write!(buf, r#" width="{}""#, w).unwrap();
52            }
53            if let Some(h) = height {
54                write!(buf, r#" height="{}""#, h).unwrap();
55            }
56            if !class.is_empty() {
57                write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
58            }
59            buf.push('>');
60            for child in children {
61                write_element(buf, child);
62            }
63            buf.push_str("</svg>");
64        }
65
66        ChartElement::Group { class, transform, children } => {
67            buf.push_str("<g");
68            if !class.is_empty() {
69                write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
70            }
71            if let Some(t) = transform {
72                write!(buf, r#" transform="{}""#, t.to_svg_string()).unwrap();
73            }
74            buf.push('>');
75            for child in children {
76                write_element(buf, child);
77            }
78            buf.push_str("</g>");
79        }
80
81        ChartElement::Rect { x, y, width, height, fill, stroke, rx, ry, class, data, animation_origin: _ } => {
82            // Static SVG renders deliberately ignore `animation_origin` for
83            // `Rect`, keeping the legacy width-vs-height heuristic. The
84            // `backward_compat_goldens_byte_identical` test pins every
85            // pre-theme-hooks baseline byte-for-byte, and those baselines
86            // were captured with this exact (buggy-for-square-bars and
87            // negative-bars) heuristic emission. The heuristic only matters
88            // for animation, and animation is invisible in a static SVG
89            // snapshot — so freezing the legacy bytes here costs nothing
90            // visually. Live, animated renders go through
91            // `chartml-leptos/src/element.rs`, which honors `animation_origin`
92            // and produces the correct origin per orientation/sign.
93            //
94            // Top-rounded bars (`BarCornerRadius::Top`) are emitted as
95            // `ChartElement::Path` instead of `Rect` — the `Path` arm below
96            // honors `animation_origin` because no pre-theme-hooks baseline
97            // contains a Path bar (default theme uses `Uniform(0.0)`).
98            let (origin_x, origin_y) = if width > height {
99                (*x, y + height / 2.0)
100            } else {
101                (x + width / 2.0, y + height)
102            };
103            write!(
104                buf,
105                r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}" style="transform-origin: {}px {}px;""#,
106                x, y, width, height, xml_escape(fill), origin_x, origin_y,
107            ).unwrap();
108            if let Some(s) = stroke {
109                write!(buf, r#" stroke="{}""#, xml_escape(s)).unwrap();
110            }
111            if let Some(r) = rx {
112                write!(buf, r#" rx="{}""#, r).unwrap();
113            }
114            if let Some(r) = ry {
115                write!(buf, r#" ry="{}""#, r).unwrap();
116            }
117            if !class.is_empty() {
118                write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
119            }
120            write_data_attrs(buf, data);
121            buf.push_str("/>");
122        }
123
124        ChartElement::Path { d, fill, stroke, stroke_width, stroke_dasharray, opacity, class, data, animation_origin } => {
125            write!(buf, r#"<path d="{}""#, xml_escape(d)).unwrap();
126            // When the emitter populates `animation_origin` (top-rounded
127            // bar paths from `build_bar_element`), inline the
128            // `transform-origin` style so CSS keyframes animate from the
129            // bar's baseline. Default-theme baselines never emit Path bars,
130            // so the unconditional `style=` insertion below stays absent
131            // for every pre-theme-hooks snapshot.
132            if let Some((ox, oy)) = animation_origin {
133                write!(buf, r#" style="transform-origin: {}px {}px;""#, ox, oy).unwrap();
134            }
135            match fill.as_deref() {
136                Some(f) => write!(buf, r#" fill="{}""#, xml_escape(f)).unwrap(),
137                None => buf.push_str(r#" fill="none""#),
138            }
139            match stroke.as_deref() {
140                Some(s) => write!(buf, r#" stroke="{}""#, xml_escape(s)).unwrap(),
141                None => buf.push_str(r#" stroke="none""#),
142            }
143            if let Some(sw) = stroke_width {
144                write!(buf, r#" stroke-width="{}""#, sw).unwrap();
145            }
146            if let Some(sda) = stroke_dasharray {
147                write!(buf, r#" stroke-dasharray="{}""#, xml_escape(sda)).unwrap();
148            }
149            if let Some(op) = opacity {
150                write!(buf, r#" opacity="{}""#, op).unwrap();
151            }
152            if !class.is_empty() {
153                write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
154            }
155            write_data_attrs(buf, data);
156            buf.push_str("/>");
157        }
158
159        ChartElement::Circle { cx, cy, r, fill, stroke, class, data } => {
160            write!(
161                buf,
162                r#"<circle cx="{}" cy="{}" r="{}" fill="{}""#,
163                cx, cy, r, xml_escape(fill),
164            ).unwrap();
165            if let Some(s) = stroke {
166                write!(buf, r#" stroke="{}""#, xml_escape(s)).unwrap();
167            }
168            if !class.is_empty() {
169                write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
170            }
171            write_data_attrs(buf, data);
172            buf.push_str("/>");
173        }
174
175        ChartElement::Line { x1, y1, x2, y2, stroke, stroke_width, stroke_dasharray, class } => {
176            write!(
177                buf,
178                r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}""#,
179                x1, y1, x2, y2, xml_escape(stroke),
180            ).unwrap();
181            if let Some(sw) = stroke_width {
182                write!(buf, r#" stroke-width="{}""#, sw).unwrap();
183            }
184            if let Some(sda) = stroke_dasharray {
185                write!(buf, r#" stroke-dasharray="{}""#, xml_escape(sda)).unwrap();
186            }
187            if !class.is_empty() {
188                write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
189            }
190            buf.push_str("/>");
191        }
192
193        ChartElement::Text {
194            x, y, content, anchor, dominant_baseline, transform,
195            font_family, font_size, font_weight, letter_spacing, text_transform,
196            fill, class, ..
197        } => {
198            // Per-element font-family overrides the hardcoded default. When
199            // the Text carries no family, fall back to the legacy default
200            // (matches pre-Phase-4 baseline output byte-for-byte).
201            let family = font_family.as_deref().unwrap_or(DEFAULT_FONT_FAMILY);
202            write!(
203                buf,
204                r#"<text x="{}" y="{}" text-anchor="{}" font-family="{}""#,
205                x, y, anchor, xml_escape(family),
206            ).unwrap();
207            if let Some(db) = dominant_baseline {
208                write!(buf, r#" dominant-baseline="{}""#, xml_escape(db)).unwrap();
209            }
210            if let Some(t) = transform {
211                write!(buf, r#" transform="{}""#, t.to_svg_string()).unwrap();
212            }
213            if let Some(fs) = font_size {
214                write!(buf, r#" font-size="{}""#, xml_escape(fs)).unwrap();
215            }
216            if let Some(fw) = font_weight {
217                write!(buf, r#" font-weight="{}""#, xml_escape(fw)).unwrap();
218            }
219            if let Some(ls) = letter_spacing {
220                write!(buf, r#" letter-spacing="{}""#, xml_escape(ls)).unwrap();
221            }
222            if let Some(tt) = text_transform {
223                write!(buf, r#" text-transform="{}""#, xml_escape(tt)).unwrap();
224            }
225            if let Some(f) = fill {
226                write!(buf, r#" fill="{}""#, xml_escape(f)).unwrap();
227            }
228            if !class.is_empty() {
229                write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
230            }
231            buf.push('>');
232            buf.push_str(&xml_escape(content));
233            buf.push_str("</text>");
234        }
235
236        ChartElement::Div { class, style, children } => {
237            buf.push_str(r#"<div xmlns="http://www.w3.org/1999/xhtml""#);
238            if !class.is_empty() {
239                write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
240            }
241            let style_str = style_map_to_string(style);
242            if !style_str.is_empty() {
243                write!(buf, r#" style="{}""#, xml_escape(&style_str)).unwrap();
244            }
245            buf.push('>');
246            for child in children {
247                write_element(buf, child);
248            }
249            buf.push_str("</div>");
250        }
251
252        ChartElement::Span { class, style, content } => {
253            buf.push_str("<span");
254            if !class.is_empty() {
255                write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
256            }
257            let style_str = style_map_to_string(style);
258            if !style_str.is_empty() {
259                write!(buf, r#" style="{}""#, xml_escape(&style_str)).unwrap();
260            }
261            buf.push('>');
262            buf.push_str(&xml_escape(content));
263            buf.push_str("</span>");
264        }
265    }
266}
267
268/// Convert a style HashMap to a CSS string (sorted for deterministic output).
269fn style_map_to_string(style: &std::collections::HashMap<String, String>) -> String {
270    let mut pairs: Vec<_> = style.iter().collect();
271    pairs.sort_by_key(|(k, _)| (*k).clone());
272    pairs.iter()
273        .map(|(k, v)| format!("{}: {}", k, v))
274        .collect::<Vec<_>>()
275        .join("; ")
276}
277
278/// Write data-* attributes for interactive tooltip support.
279fn write_data_attrs(buf: &mut String, data: &Option<ElementData>) {
280    if let Some(d) = data {
281        if !d.label.is_empty() {
282            write!(buf, r#" data-label="{}""#, xml_escape(&d.label)).unwrap();
283        }
284        if !d.value.is_empty() {
285            write!(buf, r#" data-value="{}""#, xml_escape(&d.value)).unwrap();
286        }
287        if let Some(ref s) = d.series {
288            write!(buf, r#" data-series="{}""#, xml_escape(s)).unwrap();
289        }
290    }
291}
292
293/// XML-escape a string for use in attributes and text content.
294fn xml_escape(s: &str) -> String {
295    let mut result = String::with_capacity(s.len());
296    for c in s.chars() {
297        match c {
298            '&' => result.push_str("&amp;"),
299            '<' => result.push_str("&lt;"),
300            '>' => result.push_str("&gt;"),
301            '"' => result.push_str("&quot;"),
302            '\'' => result.push_str("&apos;"),
303            _ => result.push(c),
304        }
305    }
306    result
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use chartml_core::element::{ViewBox, Transform, TextAnchor, ElementData};
313    use std::collections::HashMap;
314
315    #[test]
316    fn simple_svg_with_rect() {
317        let element = ChartElement::Svg {
318            viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
319            width: Some(800.0),
320            height: Some(400.0),
321            class: "chart".to_string(),
322            children: vec![
323                ChartElement::Rect {
324                    x: 10.0, y: 20.0, width: 50.0, height: 100.0,
325                    fill: "#ff0000".to_string(), stroke: None,
326                    rx: None, ry: None,
327                    class: "bar".to_string(), data: None,
328                    animation_origin: None,
329                },
330            ],
331        };
332
333        let svg = element_to_svg(&element, 800.0, 400.0);
334        assert!(svg.contains(r#"<svg xmlns="http://www.w3.org/2000/svg""#));
335        assert!(svg.contains(r#"viewBox="0 0 800 400""#));
336        assert!(svg.contains(r##"fill="#ff0000""##));
337        assert!(svg.contains("</svg>"));
338    }
339
340    #[test]
341    fn group_with_transform() {
342        let element = ChartElement::Group {
343            class: "bars".to_string(),
344            transform: Some(Transform::Translate(10.0, 20.0)),
345            children: vec![
346                ChartElement::Rect {
347                    x: 0.0, y: 0.0, width: 50.0, height: 100.0,
348                    fill: "blue".to_string(), stroke: Some("black".to_string()),
349                    rx: None, ry: None,
350                    class: "".to_string(), data: None,
351                    animation_origin: None,
352                },
353            ],
354        };
355
356        let svg = element_to_svg(&element, 800.0, 400.0);
357        // Non-SVG root gets wrapped in SVG + foreignObject... actually Group is SVG
358        // but not an <svg> root. Let me check — it should get wrapped.
359        assert!(svg.contains(r#"transform="translate(10,20)""#));
360    }
361
362    #[test]
363    fn text_element_with_font() {
364        let element = ChartElement::Svg {
365            viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
366            width: Some(800.0),
367            height: Some(400.0),
368            class: "".to_string(),
369            children: vec![
370                ChartElement::Text {
371                    x: 400.0, y: 20.0,
372                    content: "Revenue & Costs".to_string(),
373                    anchor: TextAnchor::Middle,
374                    dominant_baseline: None,
375                    transform: None,
376                    font_family: None,
377                    font_size: Some("16".to_string()),
378                    font_weight: Some("bold".to_string()),
379                    letter_spacing: None,
380                    text_transform: None,
381                    fill: Some("#333".to_string()),
382                    class: "title".to_string(),
383                    data: None,
384                },
385            ],
386        };
387
388        let svg = element_to_svg(&element, 800.0, 400.0);
389        assert!(svg.contains(r#"font-family="Inter, Liberation Sans, Arial, sans-serif""#));
390        assert!(svg.contains("Revenue &amp; Costs"));
391        assert!(svg.contains(r#"text-anchor="middle""#));
392    }
393
394    #[test]
395    fn path_element() {
396        let element = ChartElement::Svg {
397            viewbox: ViewBox::new(0.0, 0.0, 100.0, 100.0),
398            width: Some(100.0),
399            height: Some(100.0),
400            class: "".to_string(),
401            children: vec![
402                ChartElement::Path {
403                    d: "M0,0 L100,100".to_string(),
404                    fill: None,
405                    stroke: Some("#000".to_string()),
406                    stroke_width: Some(2.0),
407                    stroke_dasharray: Some("5,3".to_string()),
408                    opacity: Some(0.5),
409                    class: "line".to_string(),
410                    data: None,
411                    animation_origin: None,
412                },
413            ],
414        };
415
416        let svg = element_to_svg(&element, 100.0, 100.0);
417        assert!(svg.contains(r#"d="M0,0 L100,100""#));
418        assert!(svg.contains(r#"fill="none""#));
419        assert!(svg.contains(r##"stroke="#000""##));
420        assert!(svg.contains(r#"stroke-width="2""#));
421        assert!(svg.contains(r#"stroke-dasharray="5,3""#));
422        assert!(svg.contains(r#"opacity="0.5""#));
423    }
424
425    #[test]
426    fn circle_element() {
427        let element = ChartElement::Svg {
428            viewbox: ViewBox::new(0.0, 0.0, 100.0, 100.0),
429            width: Some(100.0),
430            height: Some(100.0),
431            class: "".to_string(),
432            children: vec![
433                ChartElement::Circle {
434                    cx: 50.0, cy: 50.0, r: 5.0,
435                    fill: "red".to_string(), stroke: None,
436                    class: "dot".to_string(), data: None,
437                },
438            ],
439        };
440
441        let svg = element_to_svg(&element, 100.0, 100.0);
442        assert!(svg.contains(r#"<circle cx="50" cy="50" r="5" fill="red""#));
443    }
444
445    #[test]
446    fn line_element() {
447        let element = ChartElement::Svg {
448            viewbox: ViewBox::new(0.0, 0.0, 100.0, 100.0),
449            width: Some(100.0),
450            height: Some(100.0),
451            class: "".to_string(),
452            children: vec![
453                ChartElement::Line {
454                    x1: 0.0, y1: 0.0, x2: 100.0, y2: 100.0,
455                    stroke: "red".to_string(),
456                    stroke_width: Some(1.0),
457                    stroke_dasharray: None,
458                    class: "grid".to_string(),
459                },
460            ],
461        };
462
463        let svg = element_to_svg(&element, 100.0, 100.0);
464        assert!(svg.contains(r##"stroke="red""##));
465    }
466
467    #[test]
468    fn div_span_metric_card() {
469        let element = ChartElement::Div {
470            class: "metric".to_string(),
471            style: HashMap::from([
472                ("font-size".to_string(), "36px".to_string()),
473                ("color".to_string(), "#333".to_string()),
474            ]),
475            children: vec![
476                ChartElement::Span {
477                    class: "value".to_string(),
478                    style: HashMap::new(),
479                    content: "$1,234".to_string(),
480                },
481            ],
482        };
483
484        let svg = element_to_svg(&element, 200.0, 100.0);
485        // Non-SVG root should be wrapped in SVG + foreignObject
486        assert!(svg.contains(r#"<svg xmlns="http://www.w3.org/2000/svg""#));
487        assert!(svg.contains("<foreignObject"));
488        assert!(svg.contains(r#"<div xmlns="http://www.w3.org/1999/xhtml""#));
489        assert!(svg.contains("$1,234"));
490    }
491
492    #[test]
493    fn xml_escape_special_chars() {
494        assert_eq!(xml_escape("a & b"), "a &amp; b");
495        assert_eq!(xml_escape("<script>"), "&lt;script&gt;");
496        assert_eq!(xml_escape(r#"say "hi""#), "say &quot;hi&quot;");
497    }
498
499    #[test]
500    fn interactive_data_ignored_for_svg() {
501        // ElementData is for interactive tooltips — ignored in static SVG
502        let element = ChartElement::Svg {
503            viewbox: ViewBox::new(0.0, 0.0, 100.0, 100.0),
504            width: Some(100.0),
505            height: Some(100.0),
506            class: "".to_string(),
507            children: vec![
508                ChartElement::Rect {
509                    x: 0.0, y: 0.0, width: 50.0, height: 50.0,
510                    fill: "blue".to_string(), stroke: None,
511                    rx: None, ry: None,
512                    class: "".to_string(),
513                    data: Some(ElementData::new("Jan", "1234")),
514                    animation_origin: None,
515                },
516            ],
517        };
518
519        let svg = element_to_svg(&element, 100.0, 100.0);
520        // Should render the rect with data attributes for tooltip support
521        assert!(svg.contains(r#"<rect x="0" y="0""#));
522        assert!(svg.contains(r#"data-label="Jan""#));
523        assert!(svg.contains(r#"data-value="1234""#));
524    }
525}