Skip to main content

ggplot_rs/render/
svg_backend.rs

1//! A self-contained SVG `DrawBackend` — a *second* backend (no plotters),
2//! proving the `DrawBackend` abstraction: the same `PlotRenderer` drives it.
3//!
4//! It emits SVG elements directly, so SVG output needs no glyph rasterization
5//! (text is `<text>` with font attributes the viewer renders).
6
7use super::backend::{
8    DrawBackend, FontFace, LineStyle, PointStyle, RectStyle, TextAnchor, TextStyle,
9};
10use super::{Rect, RenderError};
11
12/// Accumulates SVG markup for a plot rendered via [`DrawBackend`].
13pub struct SvgBackend {
14    plot_area: Rect,
15    total_area: Rect,
16    body: String,
17}
18
19impl SvgBackend {
20    pub fn new(width: u32, height: u32, plot_area: Rect) -> Self {
21        SvgBackend {
22            plot_area,
23            total_area: Rect {
24                x: 0.0,
25                y: 0.0,
26                width: width as f64,
27                height: height as f64,
28            },
29            body: String::new(),
30        }
31    }
32
33    /// Wrap the accumulated elements in a complete `<svg>` document.
34    pub fn finish(self) -> String {
35        format!(
36            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" \
37             viewBox=\"0 0 {0} {1}\">{}</svg>",
38            self.total_area.width as i64, self.total_area.height as i64, self.body
39        )
40    }
41}
42
43fn rgb((r, g, b): (u8, u8, u8)) -> String {
44    format!("#{r:02X}{g:02X}{b:02X}")
45}
46
47fn escape(s: &str) -> String {
48    s.replace('&', "&amp;")
49        .replace('<', "&lt;")
50        .replace('>', "&gt;")
51}
52
53fn points(pts: &[(f64, f64)]) -> String {
54    pts.iter()
55        .map(|(x, y)| format!("{x:.2},{y:.2}"))
56        .collect::<Vec<_>>()
57        .join(" ")
58}
59
60impl DrawBackend for SvgBackend {
61    fn plot_area(&self) -> Rect {
62        self.plot_area.clone()
63    }
64    fn total_area(&self) -> Rect {
65        self.total_area.clone()
66    }
67
68    fn draw_circle(
69        &mut self,
70        (cx, cy): (f64, f64),
71        radius: f64,
72        style: &PointStyle,
73    ) -> Result<(), RenderError> {
74        self.body.push_str(&format!(
75            "<circle cx=\"{cx:.2}\" cy=\"{cy:.2}\" r=\"{radius:.2}\" fill=\"{}\" fill-opacity=\"{:.3}\"/>",
76            rgb(style.color), style.alpha
77        ));
78        Ok(())
79    }
80
81    fn draw_line(&mut self, pts: &[(f64, f64)], style: &LineStyle) -> Result<(), RenderError> {
82        let dash = match style
83            .linetype
84            .pattern()
85            .iter()
86            .flat_map(|(d, g)| [*d, *g])
87            .map(|v| format!("{v}"))
88            .collect::<Vec<_>>()
89            .join(",")
90        {
91            s if s.is_empty() => String::new(),
92            s => format!(" stroke-dasharray=\"{s}\""),
93        };
94        self.body.push_str(&format!(
95            "<polyline points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{:.2}\" stroke-opacity=\"{:.3}\"{}/>",
96            points(pts), rgb(style.color), style.width, style.alpha, dash
97        ));
98        Ok(())
99    }
100
101    fn draw_rect(
102        &mut self,
103        (x0, y0): (f64, f64),
104        (x1, y1): (f64, f64),
105        style: &RectStyle,
106    ) -> Result<(), RenderError> {
107        let (x, y) = (x0.min(x1), y0.min(y1));
108        let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs());
109        let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
110        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
111        self.body.push_str(&format!(
112            "<rect x=\"{x:.2}\" y=\"{y:.2}\" width=\"{w:.2}\" height=\"{h:.2}\" fill=\"{fill}\" \
113             fill-opacity=\"{:.3}\" stroke=\"{stroke}\" stroke-width=\"{:.2}\"/>",
114            style.alpha, style.stroke_width
115        ));
116        Ok(())
117    }
118
119    fn draw_polygon(&mut self, pts: &[(f64, f64)], style: &RectStyle) -> Result<(), RenderError> {
120        let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
121        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
122        self.body.push_str(&format!(
123            "<polygon points=\"{}\" fill=\"{fill}\" fill-opacity=\"{:.3}\" stroke=\"{stroke}\" stroke-width=\"{:.2}\"/>",
124            points(pts), style.alpha, style.stroke_width
125        ));
126        Ok(())
127    }
128
129    fn draw_text(
130        &mut self,
131        text: &str,
132        (x, y): (f64, f64),
133        style: &TextStyle,
134    ) -> Result<(), RenderError> {
135        let anchor = match style.anchor {
136            TextAnchor::Start => "start",
137            TextAnchor::Middle => "middle",
138            TextAnchor::End => "end",
139        };
140        let family = style.family.as_deref().unwrap_or("sans-serif");
141        let weight = if style.face == FontFace::Bold {
142            " font-weight=\"bold\""
143        } else {
144            ""
145        };
146        let fstyle = if style.face == FontFace::Italic {
147            " font-style=\"italic\""
148        } else {
149            ""
150        };
151        let transform = if style.angle.abs() > 0.01 {
152            format!(" transform=\"rotate({:.1} {x:.2} {y:.2})\"", style.angle)
153        } else {
154            String::new()
155        };
156        self.body.push_str(&format!(
157            "<text x=\"{x:.2}\" y=\"{y:.2}\" font-size=\"{:.2}\" text-anchor=\"{anchor}\" \
158             dominant-baseline=\"middle\" font-family=\"{family}\"{weight}{fstyle} fill=\"{}\"{transform}>{}</text>",
159            style.size, rgb(style.color), escape(text)
160        ));
161        Ok(())
162    }
163}