Skip to main content

chartml_core/
svg.rs

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