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

            
22
8
    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
8
    }
32
8
    buf
33
8
}
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
7
    fn sample(kind: ChartKind) -> ChartSpec {
47
7
        ChartSpec {
48
7
            title: "Test".to_string(),
49
7
            kind,
50
7
            x_label: "X".to_string(),
51
7
            y_label: "Y".to_string(),
52
7
            series: vec![Series {
53
7
                label: "A".to_string(),
54
7
                commodity_symbol: "USD".to_string(),
55
7
                points: vec![
56
7
                    SeriesPoint {
57
7
                        x: "Jan".to_string(),
58
7
                        y_num: 100,
59
7
                        y_denom: 1,
60
7
                    },
61
7
                    SeriesPoint {
62
7
                        x: "Feb".to_string(),
63
7
                        y_num: 200,
64
7
                        y_denom: 1,
65
7
                    },
66
7
                ],
67
7
            }],
68
7
            notes: vec![],
69
7
        }
70
7
    }
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
    /// The title-only fallback also contains "<svg" and the title, so the tests
82
    /// above would pass with charts completely broken — which is exactly what
83
    /// happened when font lookup failed at runtime. Pin the real thing: axis
84
    /// ticks and labels mean text was actually shaped, not skipped.
85
    #[test]
86
1
    fn render_svg_draws_a_real_chart_not_the_fallback() {
87
3
        for kind in [ChartKind::Bar, ChartKind::StackedBar, ChartKind::Line] {
88
3
            let svg = render_svg(&sample(kind), 400, 300);
89
3
            let texts = svg.matches("<text").count();
90
3
            assert!(
91
3
                texts >= 3,
92
                "{kind:?}: {texts} <text> elements — this is the degraded fallback, \
93
                 not a drawn chart (font registration or drawing failed)"
94
            );
95
3
            assert!(
96
3
                svg.contains("<path") || svg.contains("<rect") || svg.contains("<polyline"),
97
                "{kind:?}: no chart geometry in the SVG"
98
            );
99
        }
100
1
    }
101

            
102
    #[test]
103
1
    fn render_svg_includes_series_label() {
104
1
        let svg = render_svg(&sample(ChartKind::Line), 400, 300);
105
        // Plotters escapes text; "A" should appear at least once in
106
        // the legend or axis.
107
1
        assert!(svg.contains('A'), "series label missing");
108
1
    }
109

            
110
    #[test]
111
1
    fn render_svg_empty_spec_is_safe() {
112
1
        let spec = ChartSpec {
113
1
            title: "Empty".to_string(),
114
1
            kind: ChartKind::Bar,
115
1
            x_label: String::new(),
116
1
            y_label: String::new(),
117
1
            series: vec![],
118
1
            notes: vec![],
119
1
        };
120
1
        let svg = render_svg(&spec, 400, 300);
121
1
        assert!(svg.contains("<svg"));
122
1
    }
123
}