Skip to main content

plotting/
svg.rs

1//! SVG renderer. Uses plotters' `SVGBackend` and the shared `draw`
2//! dispatch in `draw.rs` so the canvas renderer produces identical
3//! layouts.
4
5use plotters::prelude::*;
6
7use crate::draw::draw_on;
8use crate::spec::ChartSpec;
9
10/// Render `spec` to an SVG string of the given dimensions. Drawing
11/// errors fall back to a minimal SVG with the chart title so a broken
12/// chart never fails a page render.
13#[must_use]
14pub fn render_svg(spec: &ChartSpec, width: u32, height: u32) -> String {
15    let mut buf = String::new();
16    let ok = {
17        let backend = SVGBackend::with_string(&mut buf, (width, height));
18        let root = backend.into_drawing_area();
19        draw_on(&root, spec).is_ok()
20    };
21
22    if !ok || buf.is_empty() {
23        return format!(
24            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width}\" height=\"{height}\">\
25                 <title>{}</title>\
26                 <text x=\"10\" y=\"20\" font-family=\"sans-serif\" font-size=\"14\">{}</text>\
27             </svg>",
28            escape_xml(&spec.title),
29            escape_xml(&spec.title),
30        );
31    }
32    buf
33}
34
35fn escape_xml(s: &str) -> String {
36    s.replace('&', "&amp;")
37        .replace('<', "&lt;")
38        .replace('>', "&gt;")
39}
40
41#[cfg(test)]
42mod tests {
43    use super::*;
44    use crate::spec::{ChartKind, Series, SeriesPoint};
45
46    fn sample(kind: ChartKind) -> ChartSpec {
47        ChartSpec {
48            title: "Test".to_string(),
49            kind,
50            x_label: "X".to_string(),
51            y_label: "Y".to_string(),
52            series: vec![Series {
53                label: "A".to_string(),
54                commodity_symbol: "USD".to_string(),
55                points: vec![
56                    SeriesPoint {
57                        x: "Jan".to_string(),
58                        y_num: 100,
59                        y_denom: 1,
60                    },
61                    SeriesPoint {
62                        x: "Feb".to_string(),
63                        y_num: 200,
64                        y_denom: 1,
65                    },
66                ],
67            }],
68            notes: vec![],
69        }
70    }
71
72    #[test]
73    fn render_svg_emits_non_empty_svg_for_each_kind() {
74        for kind in [ChartKind::Bar, ChartKind::StackedBar, ChartKind::Line] {
75            let svg = render_svg(&sample(kind), 400, 300);
76            assert!(svg.contains("<svg"), "missing <svg for {kind:?}");
77            assert!(svg.contains("Test"), "title missing for {kind:?}");
78        }
79    }
80
81    #[test]
82    fn render_svg_includes_series_label() {
83        let svg = render_svg(&sample(ChartKind::Line), 400, 300);
84        // Plotters escapes text; "A" should appear at least once in
85        // the legend or axis.
86        assert!(svg.contains('A'), "series label missing");
87    }
88
89    #[test]
90    fn render_svg_empty_spec_is_safe() {
91        let spec = ChartSpec {
92            title: "Empty".to_string(),
93            kind: ChartKind::Bar,
94            x_label: String::new(),
95            y_label: String::new(),
96            series: vec![],
97            notes: vec![],
98        };
99        let svg = render_svg(&spec, 400, 300);
100        assert!(svg.contains("<svg"));
101    }
102}