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`].
13///
14/// Every number written to an attribute is finite: marks whose geometry is
15/// `NaN`/`±inf` are dropped (a polyline keeps its finite vertices), and
16/// non-finite style values fall back to safe defaults. All data-derived
17/// attribute values and text go through [`escape`].
18pub struct SvgBackend {
19    plot_area: Rect,
20    total_area: Rect,
21    body: String,
22    tooltip: Option<String>,
23    axis_key: Option<String>,
24    series_key: Option<String>,
25    value_key: Option<String>,
26    root_attrs: Vec<(String, String)>,
27}
28
29impl SvgBackend {
30    pub fn new(width: u32, height: u32, plot_area: Rect) -> Self {
31        SvgBackend {
32            plot_area,
33            total_area: Rect {
34                x: 0.0,
35                y: 0.0,
36                width: width as f64,
37                height: height as f64,
38            },
39            body: String::new(),
40            tooltip: None,
41            axis_key: None,
42            series_key: None,
43            value_key: None,
44            root_attrs: Vec::new(),
45        }
46    }
47
48    /// Add extra attributes to the root `<svg>` element (e.g. the
49    /// `data-domain` family from [`root_data_attrs`]). Names must be plain
50    /// attribute names; values are escaped on output.
51    pub fn set_root_attrs(&mut self, attrs: Vec<(String, String)>) {
52        self.root_attrs = attrs;
53    }
54
55    /// Emit `<tag attrs/>`, or `<tag attrs data-…><title>tip</title></tag>`
56    /// when a tooltip / mark metadata is set (native SVG hover + host grouping).
57    fn push_mark(&mut self, tag: &str, attrs: &str) {
58        let mut data = String::new();
59        for (name, val) in [
60            ("data-x", &self.axis_key),
61            ("data-series", &self.series_key),
62            ("data-value", &self.value_key),
63        ] {
64            if let Some(v) = val {
65                data.push_str(&format!(" {name}=\"{}\"", escape(v)));
66            }
67        }
68        let mark = match &self.tooltip {
69            Some(t) => format!("<{tag} {attrs}{data}><title>{}</title></{tag}>", escape(t)),
70            None => format!("<{tag} {attrs}{data}/>"),
71        };
72        self.body.push_str(&mark);
73    }
74
75    fn root_open(&self, prefix: &str) -> String {
76        let p = &self.plot_area;
77        let (w, h) = (self.total_area.width as i64, self.total_area.height as i64);
78        let mut extra = String::new();
79        for (k, v) in &self.root_attrs {
80            extra.push_str(&format!(" {k}=\"{}\"", escape(v)));
81        }
82        format!(
83            "<svg {prefix}width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\" \
84             data-plot=\"{} {} {} {}\"{extra}>",
85            num(p.x),
86            num(p.y),
87            num(p.width),
88            num(p.height),
89        )
90    }
91
92    /// Wrap the accumulated elements in a complete `<svg>` document. The
93    /// `data-plot` attribute records the panel (data) area in viewBox units so a
94    /// host can map screen coordinates back to data (e.g. for zoom/crosshair).
95    pub fn finish(self) -> String {
96        let open = self.root_open("xmlns=\"http://www.w3.org/2000/svg\" ");
97        format!("{open}{}</svg>", self.body)
98    }
99
100    /// Wrap the accumulated elements in a *nested* `<svg>` positioned at
101    /// `(x, y)` in a parent SVG's user space — no `xmlns` (it inherits the
102    /// parent's namespace), so the result can be pasted into a larger SVG as-is.
103    /// `data-plot` stays in this fragment's own viewBox units.
104    pub fn finish_fragment(self, x: f64, y: f64) -> String {
105        let open = self.root_open(&format!("x=\"{}\" y=\"{}\" ", num(x), num(y)));
106        format!("{open}{}</svg>", self.body)
107    }
108}
109
110/// Format a coordinate with two decimals; non-finite becomes `0` (callers
111/// drop marks with non-finite geometry before this, so this is a last resort).
112fn num(v: f64) -> String {
113    if v.is_finite() {
114        format!("{v:.2}")
115    } else {
116        "0".to_string()
117    }
118}
119
120fn finite_or(v: f64, fallback: f64) -> f64 {
121    if v.is_finite() {
122        v
123    } else {
124        fallback
125    }
126}
127
128/// Root `<svg>` data attributes describing the trained position scales, for
129/// hosts that map screen coordinates back to data (zoom, crosshair, brushing):
130///
131/// - `data-xdomain="x0 x1"` / `data-ydomain="y0 y1"` — the data values at the
132///   panel's left/right (bottom/top) edges, i.e. the trained limits *after*
133///   expansion, for a continuous (numeric or date-time, epoch seconds) axis.
134///   Values are in the scale's transformed space (e.g. log10 units for
135///   `scale_x_log10`).
136/// - `data-domain="x0 x1 y0 y1"` — both of the above, emitted only when both
137///   axes are continuous.
138/// - `data-xlevels` / `data-ylevels` — a JSON array of the level labels for a
139///   discrete axis, in axis order.
140/// - `data-flip="true"` under `coord_flip` (x is then drawn vertically).
141///
142/// The `x`/`y` names refer to the x/y *aesthetics*. Omitted entirely for
143/// plots with free facet scales (each panel has its own domain).
144pub fn root_data_attrs(built: &crate::build::BuiltPlot) -> Vec<(String, String)> {
145    use crate::aes::Aesthetic;
146    let mut out = Vec::new();
147    if !built.panel_scales.is_empty() {
148        return out;
149    }
150    let axis = |aes: &Aesthetic| -> (Option<(f64, f64)>, Option<String>) {
151        match built.scales.get(aes) {
152            Some(s) if s.is_discrete() => {
153                let labels: Vec<String> = s
154                    .breaks()
155                    .into_iter()
156                    .map(|(_, l)| json_string(&l))
157                    .collect();
158                (None, Some(format!("[{}]", labels.join(","))))
159            }
160            Some(s) => (s.expanded_domain(), None),
161            None => (None, None),
162        }
163    };
164    let (xd, xl) = axis(&Aesthetic::X);
165    let (yd, yl) = axis(&Aesthetic::Y);
166    if let (Some((x0, x1)), Some((y0, y1))) = (xd, yd) {
167        out.push((
168            "data-domain".into(),
169            format!("{} {} {} {}", g(x0), g(x1), g(y0), g(y1)),
170        ));
171    }
172    if let Some((a, b)) = xd {
173        out.push(("data-xdomain".into(), format!("{} {}", g(a), g(b))));
174    }
175    if let Some((a, b)) = yd {
176        out.push(("data-ydomain".into(), format!("{} {}", g(a), g(b))));
177    }
178    if let Some(l) = xl {
179        out.push(("data-xlevels".into(), l));
180    }
181    if let Some(l) = yl {
182        out.push(("data-ylevels".into(), l));
183    }
184    if built.coord.is_flipped() {
185        out.push(("data-flip".into(), "true".into()));
186    }
187    out
188}
189
190/// Shortest round-trip formatting of a finite number (domains are filtered to
191/// finite values before this is called).
192fn g(v: f64) -> String {
193    format!("{v}")
194}
195
196/// Minimal JSON string literal (quotes, backslashes and control characters
197/// escaped); the result is attribute-escaped again on output.
198fn json_string(s: &str) -> String {
199    let mut out = String::with_capacity(s.len() + 2);
200    out.push('"');
201    for c in s.chars() {
202        match c {
203            '"' => out.push_str("\\\""),
204            '\\' => out.push_str("\\\\"),
205            '\n' => out.push_str("\\n"),
206            '\r' => out.push_str("\\r"),
207            '\t' => out.push_str("\\t"),
208            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
209            c => out.push(c),
210        }
211    }
212    out.push('"');
213    out
214}
215
216fn rgb((r, g, b): (u8, u8, u8)) -> String {
217    format!("#{r:02X}{g:02X}{b:02X}")
218}
219
220/// Escape text for use in SVG element content *and* quoted attribute values.
221/// Quotes must be escaped too: data values land in `data-x="…"`, and the SVG is
222/// routinely inlined into HTML, so an unescaped `"` would let data inject
223/// attributes (e.g. event handlers). Characters that are not legal in XML 1.0
224/// (C0 controls other than tab/LF/CR) are dropped so the document stays
225/// well-formed.
226fn escape(s: &str) -> String {
227    let mut out = String::with_capacity(s.len());
228    for c in s.chars() {
229        match c {
230            '&' => out.push_str("&amp;"),
231            '<' => out.push_str("&lt;"),
232            '>' => out.push_str("&gt;"),
233            '"' => out.push_str("&quot;"),
234            '\'' => out.push_str("&#39;"),
235            '\t' | '\n' | '\r' => out.push(c),
236            c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {}
237            c => out.push(c),
238        }
239    }
240    out
241}
242
243fn points(pts: &[(f64, f64)]) -> String {
244    pts.iter()
245        .map(|(x, y)| format!("{x:.2},{y:.2}"))
246        .collect::<Vec<_>>()
247        .join(" ")
248}
249
250fn finite_pts(pts: &[(f64, f64)]) -> Vec<(f64, f64)> {
251    pts.iter()
252        .copied()
253        .filter(|(x, y)| x.is_finite() && y.is_finite())
254        .collect()
255}
256
257impl DrawBackend for SvgBackend {
258    fn plot_area(&self) -> Rect {
259        self.plot_area.clone()
260    }
261    fn total_area(&self) -> Rect {
262        self.total_area.clone()
263    }
264
265    fn set_tooltip(&mut self, tooltip: Option<String>) {
266        self.tooltip = tooltip;
267    }
268
269    fn set_mark_axis(&mut self, key: Option<String>) {
270        self.axis_key = key;
271    }
272
273    fn set_mark_series(&mut self, series: Option<String>) {
274        self.series_key = series;
275    }
276
277    fn set_mark_value(&mut self, value: Option<String>) {
278        self.value_key = value;
279    }
280
281    fn draw_circle(
282        &mut self,
283        (cx, cy): (f64, f64),
284        radius: f64,
285        style: &PointStyle,
286    ) -> Result<(), RenderError> {
287        if !(cx.is_finite() && cy.is_finite() && radius.is_finite()) {
288            return Ok(());
289        }
290        let attrs = format!(
291            "cx=\"{cx:.2}\" cy=\"{cy:.2}\" r=\"{:.2}\" fill=\"{}\" fill-opacity=\"{:.3}\"",
292            radius.max(0.0),
293            rgb(style.color),
294            finite_or(style.alpha, 1.0)
295        );
296        self.push_mark("circle", &attrs);
297        Ok(())
298    }
299
300    fn draw_line(&mut self, pts: &[(f64, f64)], style: &LineStyle) -> Result<(), RenderError> {
301        let pts = finite_pts(pts);
302        if pts.len() < 2 {
303            return Ok(());
304        }
305        let dash = match style
306            .linetype
307            .pattern()
308            .iter()
309            .flat_map(|(d, g)| [*d, *g])
310            .map(|v| format!("{v}"))
311            .collect::<Vec<_>>()
312            .join(",")
313        {
314            s if s.is_empty() => String::new(),
315            s => format!(" stroke-dasharray=\"{s}\""),
316        };
317        let attrs = format!(
318            "points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{:.2}\" stroke-opacity=\"{:.3}\"{}",
319            points(&pts),
320            rgb(style.color),
321            finite_or(style.width, 1.0),
322            finite_or(style.alpha, 1.0),
323            dash
324        );
325        self.push_mark("polyline", &attrs);
326        Ok(())
327    }
328
329    fn draw_rect(
330        &mut self,
331        (x0, y0): (f64, f64),
332        (x1, y1): (f64, f64),
333        style: &RectStyle,
334    ) -> Result<(), RenderError> {
335        if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) {
336            return Ok(());
337        }
338        let (x, y) = (x0.min(x1), y0.min(y1));
339        let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs());
340        let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
341        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
342        let attrs = format!(
343            "x=\"{x:.2}\" y=\"{y:.2}\" width=\"{w:.2}\" height=\"{h:.2}\" fill=\"{fill}\" \
344             fill-opacity=\"{:.3}\" stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
345            finite_or(style.alpha, 1.0),
346            finite_or(style.stroke_width, 0.0)
347        );
348        self.push_mark("rect", &attrs);
349        Ok(())
350    }
351
352    fn draw_polygon(&mut self, pts: &[(f64, f64)], style: &RectStyle) -> Result<(), RenderError> {
353        let pts = finite_pts(pts);
354        if pts.len() < 3 {
355            return Ok(());
356        }
357        let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
358        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
359        let attrs = format!(
360            "points=\"{}\" fill=\"{fill}\" fill-opacity=\"{:.3}\" stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
361            points(&pts),
362            finite_or(style.alpha, 1.0),
363            finite_or(style.stroke_width, 0.0)
364        );
365        self.push_mark("polygon", &attrs);
366        Ok(())
367    }
368
369    fn draw_text(
370        &mut self,
371        text: &str,
372        (x, y): (f64, f64),
373        style: &TextStyle,
374    ) -> Result<(), RenderError> {
375        if !(x.is_finite() && y.is_finite()) {
376            return Ok(());
377        }
378        let anchor = match style.anchor {
379            TextAnchor::Start => "start",
380            TextAnchor::Middle => "middle",
381            TextAnchor::End => "end",
382        };
383        let family = escape(style.family.as_deref().unwrap_or("sans-serif"));
384        let weight = if style.face == FontFace::Bold {
385            " font-weight=\"bold\""
386        } else {
387            ""
388        };
389        let fstyle = if style.face == FontFace::Italic {
390            " font-style=\"italic\""
391        } else {
392            ""
393        };
394        let angle = finite_or(style.angle, 0.0);
395        let transform = if angle.abs() > 0.01 {
396            format!(" transform=\"rotate({angle:.1} {x:.2} {y:.2})\"")
397        } else {
398            String::new()
399        };
400        self.body.push_str(&format!(
401            "<text x=\"{x:.2}\" y=\"{y:.2}\" font-size=\"{:.2}\" text-anchor=\"{anchor}\" \
402             dominant-baseline=\"middle\" font-family=\"{family}\"{weight}{fstyle} fill=\"{}\"{transform}>{}</text>",
403            finite_or(style.size, 12.0),
404            rgb(style.color),
405            escape(text)
406        ));
407        Ok(())
408    }
409}