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    tooltip: Option<String>,
18}
19
20impl SvgBackend {
21    pub fn new(width: u32, height: u32, plot_area: Rect) -> Self {
22        SvgBackend {
23            plot_area,
24            total_area: Rect {
25                x: 0.0,
26                y: 0.0,
27                width: width as f64,
28                height: height as f64,
29            },
30            body: String::new(),
31            tooltip: None,
32        }
33    }
34
35    /// Emit `<tag attrs/>`, or `<tag attrs><title>tip</title></tag>` when a
36    /// tooltip is set (native SVG hover).
37    fn push_mark(&mut self, tag: &str, attrs: &str) {
38        let mark = match &self.tooltip {
39            Some(t) => format!("<{tag} {attrs}><title>{}</title></{tag}>", escape(t)),
40            None => format!("<{tag} {attrs}/>"),
41        };
42        self.body.push_str(&mark);
43    }
44
45    /// Wrap the accumulated elements in a complete `<svg>` document.
46    pub fn finish(self) -> String {
47        format!(
48            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" \
49             viewBox=\"0 0 {0} {1}\">{}</svg>",
50            self.total_area.width as i64, self.total_area.height as i64, self.body
51        )
52    }
53}
54
55fn rgb((r, g, b): (u8, u8, u8)) -> String {
56    format!("#{r:02X}{g:02X}{b:02X}")
57}
58
59fn escape(s: &str) -> String {
60    s.replace('&', "&amp;")
61        .replace('<', "&lt;")
62        .replace('>', "&gt;")
63}
64
65fn points(pts: &[(f64, f64)]) -> String {
66    pts.iter()
67        .map(|(x, y)| format!("{x:.2},{y:.2}"))
68        .collect::<Vec<_>>()
69        .join(" ")
70}
71
72impl DrawBackend for SvgBackend {
73    fn plot_area(&self) -> Rect {
74        self.plot_area.clone()
75    }
76    fn total_area(&self) -> Rect {
77        self.total_area.clone()
78    }
79
80    fn set_tooltip(&mut self, tooltip: Option<String>) {
81        self.tooltip = tooltip;
82    }
83
84    fn draw_circle(
85        &mut self,
86        (cx, cy): (f64, f64),
87        radius: f64,
88        style: &PointStyle,
89    ) -> Result<(), RenderError> {
90        let attrs = format!(
91            "cx=\"{cx:.2}\" cy=\"{cy:.2}\" r=\"{radius:.2}\" fill=\"{}\" fill-opacity=\"{:.3}\"",
92            rgb(style.color),
93            style.alpha
94        );
95        self.push_mark("circle", &attrs);
96        Ok(())
97    }
98
99    fn draw_line(&mut self, pts: &[(f64, f64)], style: &LineStyle) -> Result<(), RenderError> {
100        let dash = match style
101            .linetype
102            .pattern()
103            .iter()
104            .flat_map(|(d, g)| [*d, *g])
105            .map(|v| format!("{v}"))
106            .collect::<Vec<_>>()
107            .join(",")
108        {
109            s if s.is_empty() => String::new(),
110            s => format!(" stroke-dasharray=\"{s}\""),
111        };
112        let attrs = format!(
113            "points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{:.2}\" stroke-opacity=\"{:.3}\"{}",
114            points(pts), rgb(style.color), style.width, style.alpha, dash
115        );
116        self.push_mark("polyline", &attrs);
117        Ok(())
118    }
119
120    fn draw_rect(
121        &mut self,
122        (x0, y0): (f64, f64),
123        (x1, y1): (f64, f64),
124        style: &RectStyle,
125    ) -> Result<(), RenderError> {
126        let (x, y) = (x0.min(x1), y0.min(y1));
127        let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs());
128        let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
129        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
130        let attrs = format!(
131            "x=\"{x:.2}\" y=\"{y:.2}\" width=\"{w:.2}\" height=\"{h:.2}\" fill=\"{fill}\" \
132             fill-opacity=\"{:.3}\" stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
133            style.alpha, style.stroke_width
134        );
135        self.push_mark("rect", &attrs);
136        Ok(())
137    }
138
139    fn draw_polygon(&mut self, pts: &[(f64, f64)], style: &RectStyle) -> Result<(), RenderError> {
140        let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
141        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
142        let attrs = format!(
143            "points=\"{}\" fill=\"{fill}\" fill-opacity=\"{:.3}\" stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
144            points(pts), style.alpha, style.stroke_width
145        );
146        self.push_mark("polygon", &attrs);
147        Ok(())
148    }
149
150    fn draw_text(
151        &mut self,
152        text: &str,
153        (x, y): (f64, f64),
154        style: &TextStyle,
155    ) -> Result<(), RenderError> {
156        let anchor = match style.anchor {
157            TextAnchor::Start => "start",
158            TextAnchor::Middle => "middle",
159            TextAnchor::End => "end",
160        };
161        let family = style.family.as_deref().unwrap_or("sans-serif");
162        let weight = if style.face == FontFace::Bold {
163            " font-weight=\"bold\""
164        } else {
165            ""
166        };
167        let fstyle = if style.face == FontFace::Italic {
168            " font-style=\"italic\""
169        } else {
170            ""
171        };
172        let transform = if style.angle.abs() > 0.01 {
173            format!(" transform=\"rotate({:.1} {x:.2} {y:.2})\"", style.angle)
174        } else {
175            String::new()
176        };
177        self.body.push_str(&format!(
178            "<text x=\"{x:.2}\" y=\"{y:.2}\" font-size=\"{:.2}\" text-anchor=\"{anchor}\" \
179             dominant-baseline=\"middle\" font-family=\"{family}\"{weight}{fstyle} fill=\"{}\"{transform}>{}</text>",
180            style.size, rgb(style.color), escape(text)
181        ));
182        Ok(())
183    }
184}