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, class, data } => {
82            // Bar animation: transform-origin at bottom center so scaleY grows upward
83            let origin_x = x + width / 2.0;
84            let origin_y = y + height;
85            write!(
86                buf,
87                r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}" style="transform-origin: {}px {}px;""#,
88                x, y, width, height, xml_escape(fill), origin_x, origin_y,
89            ).unwrap();
90            if let Some(s) = stroke {
91                write!(buf, r#" stroke="{}""#, xml_escape(s)).unwrap();
92            }
93            if !class.is_empty() {
94                write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
95            }
96            write_data_attrs(buf, data);
97            buf.push_str("/>");
98        }
99
100        ChartElement::Path { d, fill, stroke, stroke_width, stroke_dasharray, opacity, class, data } => {
101            write!(buf, r#"<path d="{}""#, xml_escape(d)).unwrap();
102            match fill.as_deref() {
103                Some(f) => write!(buf, r#" fill="{}""#, xml_escape(f)).unwrap(),
104                None => buf.push_str(r#" fill="none""#),
105            }
106            match stroke.as_deref() {
107                Some(s) => write!(buf, r#" stroke="{}""#, xml_escape(s)).unwrap(),
108                None => buf.push_str(r#" stroke="none""#),
109            }
110            if let Some(sw) = stroke_width {
111                write!(buf, r#" stroke-width="{}""#, sw).unwrap();
112            }
113            if let Some(sda) = stroke_dasharray {
114                write!(buf, r#" stroke-dasharray="{}""#, xml_escape(sda)).unwrap();
115            }
116            if let Some(op) = opacity {
117                write!(buf, r#" opacity="{}""#, op).unwrap();
118            }
119            if !class.is_empty() {
120                write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
121            }
122            write_data_attrs(buf, data);
123            buf.push_str("/>");
124        }
125
126        ChartElement::Circle { cx, cy, r, fill, stroke, class, data } => {
127            write!(
128                buf,
129                r#"<circle cx="{}" cy="{}" r="{}" fill="{}""#,
130                cx, cy, r, xml_escape(fill),
131            ).unwrap();
132            if let Some(s) = stroke {
133                write!(buf, r#" stroke="{}""#, xml_escape(s)).unwrap();
134            }
135            if !class.is_empty() {
136                write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
137            }
138            write_data_attrs(buf, data);
139            buf.push_str("/>");
140        }
141
142        ChartElement::Line { x1, y1, x2, y2, stroke, stroke_width, stroke_dasharray, class } => {
143            write!(
144                buf,
145                r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}""#,
146                x1, y1, x2, y2, xml_escape(stroke),
147            ).unwrap();
148            if let Some(sw) = stroke_width {
149                write!(buf, r#" stroke-width="{}""#, sw).unwrap();
150            }
151            if let Some(sda) = stroke_dasharray {
152                write!(buf, r#" stroke-dasharray="{}""#, xml_escape(sda)).unwrap();
153            }
154            if !class.is_empty() {
155                write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
156            }
157            buf.push_str("/>");
158        }
159
160        ChartElement::Text { x, y, content, anchor, dominant_baseline, transform, font_size, font_weight, fill, class, .. } => {
161            write!(
162                buf,
163                r#"<text x="{}" y="{}" text-anchor="{}" font-family="{}""#,
164                x, y, anchor, DEFAULT_FONT_FAMILY,
165            ).unwrap();
166            if let Some(db) = dominant_baseline {
167                write!(buf, r#" dominant-baseline="{}""#, xml_escape(db)).unwrap();
168            }
169            if let Some(t) = transform {
170                write!(buf, r#" transform="{}""#, t.to_svg_string()).unwrap();
171            }
172            if let Some(fs) = font_size {
173                write!(buf, r#" font-size="{}""#, xml_escape(fs)).unwrap();
174            }
175            if let Some(fw) = font_weight {
176                write!(buf, r#" font-weight="{}""#, xml_escape(fw)).unwrap();
177            }
178            if let Some(f) = fill {
179                write!(buf, r#" fill="{}""#, xml_escape(f)).unwrap();
180            }
181            if !class.is_empty() {
182                write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
183            }
184            buf.push('>');
185            buf.push_str(&xml_escape(content));
186            buf.push_str("</text>");
187        }
188
189        ChartElement::Div { class, style, children } => {
190            buf.push_str(r#"<div xmlns="http://www.w3.org/1999/xhtml""#);
191            if !class.is_empty() {
192                write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
193            }
194            let style_str = style_map_to_string(style);
195            if !style_str.is_empty() {
196                write!(buf, r#" style="{}""#, xml_escape(&style_str)).unwrap();
197            }
198            buf.push('>');
199            for child in children {
200                write_element(buf, child);
201            }
202            buf.push_str("</div>");
203        }
204
205        ChartElement::Span { class, style, content } => {
206            buf.push_str("<span");
207            if !class.is_empty() {
208                write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
209            }
210            let style_str = style_map_to_string(style);
211            if !style_str.is_empty() {
212                write!(buf, r#" style="{}""#, xml_escape(&style_str)).unwrap();
213            }
214            buf.push('>');
215            buf.push_str(&xml_escape(content));
216            buf.push_str("</span>");
217        }
218    }
219}
220
221/// Convert a style HashMap to a CSS string (sorted for deterministic output).
222fn style_map_to_string(style: &std::collections::HashMap<String, String>) -> String {
223    let mut pairs: Vec<_> = style.iter().collect();
224    pairs.sort_by_key(|(k, _)| (*k).clone());
225    pairs.iter()
226        .map(|(k, v)| format!("{}: {}", k, v))
227        .collect::<Vec<_>>()
228        .join("; ")
229}
230
231/// Write data-* attributes for interactive tooltip support.
232fn write_data_attrs(buf: &mut String, data: &Option<ElementData>) {
233    if let Some(d) = data {
234        if !d.label.is_empty() {
235            write!(buf, r#" data-label="{}""#, xml_escape(&d.label)).unwrap();
236        }
237        if !d.value.is_empty() {
238            write!(buf, r#" data-value="{}""#, xml_escape(&d.value)).unwrap();
239        }
240        if let Some(ref s) = d.series {
241            write!(buf, r#" data-series="{}""#, xml_escape(s)).unwrap();
242        }
243    }
244}
245
246/// XML-escape a string for use in attributes and text content.
247fn xml_escape(s: &str) -> String {
248    let mut result = String::with_capacity(s.len());
249    for c in s.chars() {
250        match c {
251            '&' => result.push_str("&amp;"),
252            '<' => result.push_str("&lt;"),
253            '>' => result.push_str("&gt;"),
254            '"' => result.push_str("&quot;"),
255            '\'' => result.push_str("&apos;"),
256            _ => result.push(c),
257        }
258    }
259    result
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use chartml_core::element::{ViewBox, Transform, TextAnchor, ElementData};
266    use std::collections::HashMap;
267
268    #[test]
269    fn simple_svg_with_rect() {
270        let element = ChartElement::Svg {
271            viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
272            width: Some(800.0),
273            height: Some(400.0),
274            class: "chart".to_string(),
275            children: vec![
276                ChartElement::Rect {
277                    x: 10.0, y: 20.0, width: 50.0, height: 100.0,
278                    fill: "#ff0000".to_string(), stroke: None,
279                    class: "bar".to_string(), data: None,
280                },
281            ],
282        };
283
284        let svg = element_to_svg(&element, 800.0, 400.0);
285        assert!(svg.contains(r#"<svg xmlns="http://www.w3.org/2000/svg""#));
286        assert!(svg.contains(r#"viewBox="0 0 800 400""#));
287        assert!(svg.contains(r##"fill="#ff0000""##));
288        assert!(svg.contains("</svg>"));
289    }
290
291    #[test]
292    fn group_with_transform() {
293        let element = ChartElement::Group {
294            class: "bars".to_string(),
295            transform: Some(Transform::Translate(10.0, 20.0)),
296            children: vec![
297                ChartElement::Rect {
298                    x: 0.0, y: 0.0, width: 50.0, height: 100.0,
299                    fill: "blue".to_string(), stroke: Some("black".to_string()),
300                    class: "".to_string(), data: None,
301                },
302            ],
303        };
304
305        let svg = element_to_svg(&element, 800.0, 400.0);
306        // Non-SVG root gets wrapped in SVG + foreignObject... actually Group is SVG
307        // but not an <svg> root. Let me check — it should get wrapped.
308        assert!(svg.contains(r#"transform="translate(10,20)""#));
309    }
310
311    #[test]
312    fn text_element_with_font() {
313        let element = ChartElement::Svg {
314            viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
315            width: Some(800.0),
316            height: Some(400.0),
317            class: "".to_string(),
318            children: vec![
319                ChartElement::Text {
320                    x: 400.0, y: 20.0,
321                    content: "Revenue & Costs".to_string(),
322                    anchor: TextAnchor::Middle,
323                    dominant_baseline: None,
324                    transform: None,
325                    font_size: Some("16".to_string()),
326                    font_weight: Some("bold".to_string()),
327                    fill: Some("#333".to_string()),
328                    class: "title".to_string(),
329                    data: None,
330                },
331            ],
332        };
333
334        let svg = element_to_svg(&element, 800.0, 400.0);
335        assert!(svg.contains(r#"font-family="Inter, Liberation Sans, Arial, sans-serif""#));
336        assert!(svg.contains("Revenue &amp; Costs"));
337        assert!(svg.contains(r#"text-anchor="middle""#));
338    }
339
340    #[test]
341    fn path_element() {
342        let element = ChartElement::Svg {
343            viewbox: ViewBox::new(0.0, 0.0, 100.0, 100.0),
344            width: Some(100.0),
345            height: Some(100.0),
346            class: "".to_string(),
347            children: vec![
348                ChartElement::Path {
349                    d: "M0,0 L100,100".to_string(),
350                    fill: None,
351                    stroke: Some("#000".to_string()),
352                    stroke_width: Some(2.0),
353                    stroke_dasharray: Some("5,3".to_string()),
354                    opacity: Some(0.5),
355                    class: "line".to_string(),
356                    data: None,
357                },
358            ],
359        };
360
361        let svg = element_to_svg(&element, 100.0, 100.0);
362        assert!(svg.contains(r#"d="M0,0 L100,100""#));
363        assert!(svg.contains(r#"fill="none""#));
364        assert!(svg.contains(r##"stroke="#000""##));
365        assert!(svg.contains(r#"stroke-width="2""#));
366        assert!(svg.contains(r#"stroke-dasharray="5,3""#));
367        assert!(svg.contains(r#"opacity="0.5""#));
368    }
369
370    #[test]
371    fn circle_element() {
372        let element = ChartElement::Svg {
373            viewbox: ViewBox::new(0.0, 0.0, 100.0, 100.0),
374            width: Some(100.0),
375            height: Some(100.0),
376            class: "".to_string(),
377            children: vec![
378                ChartElement::Circle {
379                    cx: 50.0, cy: 50.0, r: 5.0,
380                    fill: "red".to_string(), stroke: None,
381                    class: "dot".to_string(), data: None,
382                },
383            ],
384        };
385
386        let svg = element_to_svg(&element, 100.0, 100.0);
387        assert!(svg.contains(r#"<circle cx="50" cy="50" r="5" fill="red""#));
388    }
389
390    #[test]
391    fn line_element() {
392        let element = ChartElement::Svg {
393            viewbox: ViewBox::new(0.0, 0.0, 100.0, 100.0),
394            width: Some(100.0),
395            height: Some(100.0),
396            class: "".to_string(),
397            children: vec![
398                ChartElement::Line {
399                    x1: 0.0, y1: 0.0, x2: 100.0, y2: 100.0,
400                    stroke: "#ccc".to_string(),
401                    stroke_width: Some(1.0),
402                    stroke_dasharray: None,
403                    class: "grid".to_string(),
404                },
405            ],
406        };
407
408        let svg = element_to_svg(&element, 100.0, 100.0);
409        assert!(svg.contains(r##"stroke="#ccc""##));
410    }
411
412    #[test]
413    fn div_span_metric_card() {
414        let element = ChartElement::Div {
415            class: "metric".to_string(),
416            style: HashMap::from([
417                ("font-size".to_string(), "36px".to_string()),
418                ("color".to_string(), "#333".to_string()),
419            ]),
420            children: vec![
421                ChartElement::Span {
422                    class: "value".to_string(),
423                    style: HashMap::new(),
424                    content: "$1,234".to_string(),
425                },
426            ],
427        };
428
429        let svg = element_to_svg(&element, 200.0, 100.0);
430        // Non-SVG root should be wrapped in SVG + foreignObject
431        assert!(svg.contains(r#"<svg xmlns="http://www.w3.org/2000/svg""#));
432        assert!(svg.contains("<foreignObject"));
433        assert!(svg.contains(r#"<div xmlns="http://www.w3.org/1999/xhtml""#));
434        assert!(svg.contains("$1,234"));
435    }
436
437    #[test]
438    fn xml_escape_special_chars() {
439        assert_eq!(xml_escape("a & b"), "a &amp; b");
440        assert_eq!(xml_escape("<script>"), "&lt;script&gt;");
441        assert_eq!(xml_escape(r#"say "hi""#), "say &quot;hi&quot;");
442    }
443
444    #[test]
445    fn interactive_data_ignored_for_svg() {
446        // ElementData is for interactive tooltips — ignored in static SVG
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::Rect {
454                    x: 0.0, y: 0.0, width: 50.0, height: 50.0,
455                    fill: "blue".to_string(), stroke: None,
456                    class: "".to_string(),
457                    data: Some(ElementData::new("Jan", "1234")),
458                },
459            ],
460        };
461
462        let svg = element_to_svg(&element, 100.0, 100.0);
463        // Should still render the rect, just without tooltip data
464        assert!(svg.contains(r#"<rect x="0" y="0""#));
465        // Should NOT contain any tooltip/data attributes
466        assert!(!svg.contains("Jan"));
467        assert!(!svg.contains("1234"));
468    }
469}