1
//! SVG renderer. Uses plotters' `SVGBackend` and the shared `draw`
2
//! dispatch in `draw.rs` so the canvas renderer produces identical
3
//! layouts.
4

            
5
use plotters::prelude::*;
6

            
7
use crate::draw::draw_on;
8
use 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]
14
5
pub fn render_svg(spec: &ChartSpec, width: u32, height: u32) -> String {
15
5
    let mut buf = String::new();
16
5
    let ok = {
17
5
        let backend = SVGBackend::with_string(&mut buf, (width, height));
18
5
        let root = backend.into_drawing_area();
19
5
        draw_on(&root, spec).is_ok()
20
    };
21

            
22
5
    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
5
    }
32
5
    buf
33
5
}
34

            
35
fn escape_xml(s: &str) -> String {
36
    s.replace('&', "&amp;")
37
        .replace('<', "&lt;")
38
        .replace('>', "&gt;")
39
}
40

            
41
#[cfg(test)]
42
mod tests {
43
    use super::*;
44
    use crate::spec::{ChartKind, Series, SeriesPoint};
45

            
46
4
    fn sample(kind: ChartKind) -> ChartSpec {
47
4
        ChartSpec {
48
4
            title: "Test".to_string(),
49
4
            kind,
50
4
            x_label: "X".to_string(),
51
4
            y_label: "Y".to_string(),
52
4
            series: vec![Series {
53
4
                label: "A".to_string(),
54
4
                commodity_symbol: "USD".to_string(),
55
4
                points: vec![
56
4
                    SeriesPoint {
57
4
                        x: "Jan".to_string(),
58
4
                        y_num: 100,
59
4
                        y_denom: 1,
60
4
                    },
61
4
                    SeriesPoint {
62
4
                        x: "Feb".to_string(),
63
4
                        y_num: 200,
64
4
                        y_denom: 1,
65
4
                    },
66
4
                ],
67
4
            }],
68
4
            notes: vec![],
69
4
        }
70
4
    }
71

            
72
    #[test]
73
1
    fn render_svg_emits_non_empty_svg_for_each_kind() {
74
3
        for kind in [ChartKind::Bar, ChartKind::StackedBar, ChartKind::Line] {
75
3
            let svg = render_svg(&sample(kind), 400, 300);
76
3
            assert!(svg.contains("<svg"), "missing <svg for {kind:?}");
77
3
            assert!(svg.contains("Test"), "title missing for {kind:?}");
78
        }
79
1
    }
80

            
81
    #[test]
82
1
    fn render_svg_includes_series_label() {
83
1
        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
1
        assert!(svg.contains('A'), "series label missing");
87
1
    }
88

            
89
    #[test]
90
1
    fn render_svg_empty_spec_is_safe() {
91
1
        let spec = ChartSpec {
92
1
            title: "Empty".to_string(),
93
1
            kind: ChartKind::Bar,
94
1
            x_label: String::new(),
95
1
            y_label: String::new(),
96
1
            series: vec![],
97
1
            notes: vec![],
98
1
        };
99
1
        let svg = render_svg(&spec, 400, 300);
100
1
        assert!(svg.contains("<svg"));
101
1
    }
102
}