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, PointShape, 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    warnings: Vec<String>,
28    /// Emit the root `data-plot` panel rect (off for composite pages, whose
29    /// root is not a single panel).
30    plot_attr: bool,
31}
32
33impl SvgBackend {
34    pub fn new(width: u32, height: u32, plot_area: Rect) -> Self {
35        SvgBackend {
36            plot_area,
37            total_area: Rect {
38                x: 0.0,
39                y: 0.0,
40                width: width as f64,
41                height: height as f64,
42            },
43            body: String::new(),
44            tooltip: None,
45            axis_key: None,
46            series_key: None,
47            value_key: None,
48            root_attrs: Vec::new(),
49            warnings: Vec::new(),
50            plot_attr: true,
51        }
52    }
53
54    /// Add extra attributes to the root `<svg>` element (e.g. the
55    /// `data-domain` family from [`root_data_attrs`]). Names must be plain
56    /// attribute names; values are escaped on output.
57    pub fn set_root_attrs(&mut self, attrs: Vec<(String, String)>) {
58        self.root_attrs = attrs;
59    }
60
61    /// Omit the root `data-plot` attribute (composite pages).
62    pub(crate) fn without_plot_attr(&mut self) {
63        self.plot_attr = false;
64    }
65
66    /// Append pre-rendered, already-escaped SVG markup (e.g. a nested plot
67    /// fragment) to the body. Crate-internal: callers guarantee well-formedness.
68    pub(crate) fn push_raw(&mut self, markup: &str) {
69        self.body.push_str(markup);
70    }
71
72    /// The accumulated body markup (without the root element).
73    pub(crate) fn body(&self) -> &str {
74        &self.body
75    }
76
77    /// Take the warnings reported while drawing (see [`DrawBackend::warn`]).
78    pub fn take_warnings(&mut self) -> Vec<String> {
79        std::mem::take(&mut self.warnings)
80    }
81
82    /// Emit `<tag attrs/>`, or `<tag attrs data-…><title>tip</title></tag>`
83    /// when a tooltip / mark metadata is set (native SVG hover + host grouping).
84    fn push_mark(&mut self, tag: &str, attrs: &str) {
85        let mut data = String::new();
86        for (name, val) in [
87            ("data-x", &self.axis_key),
88            ("data-series", &self.series_key),
89            ("data-value", &self.value_key),
90        ] {
91            if let Some(v) = val {
92                data.push_str(&format!(" {name}=\"{}\"", escape(v)));
93            }
94        }
95        let mark = match &self.tooltip {
96            Some(t) => format!("<{tag} {attrs}{data}><title>{}</title></{tag}>", escape(t)),
97            None => format!("<{tag} {attrs}{data}/>"),
98        };
99        self.body.push_str(&mark);
100    }
101
102    fn root_open(&self, prefix: &str) -> String {
103        let p = &self.plot_area;
104        let (w, h) = (self.total_area.width as i64, self.total_area.height as i64);
105        let mut extra = String::new();
106        for (k, v) in &self.root_attrs {
107            extra.push_str(&format!(" {k}=\"{}\"", escape(v)));
108        }
109        let plot = if self.plot_attr {
110            format!(
111                " data-plot=\"{} {} {} {}\"",
112                num(p.x),
113                num(p.y),
114                num(p.width),
115                num(p.height),
116            )
117        } else {
118            String::new()
119        };
120        format!("<svg {prefix}width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\"{plot}{extra}>")
121    }
122
123    /// Wrap the accumulated elements in a complete `<svg>` document. The
124    /// `data-plot` attribute records the panel (data) area in viewBox units so a
125    /// host can map screen coordinates back to data (e.g. for zoom/crosshair).
126    pub fn finish(self) -> String {
127        let open = self.root_open("xmlns=\"http://www.w3.org/2000/svg\" ");
128        format!("{open}{}</svg>", self.body)
129    }
130
131    /// Wrap the accumulated elements in a *nested* `<svg>` positioned at
132    /// `(x, y)` in a parent SVG's user space — no `xmlns` (it inherits the
133    /// parent's namespace), so the result can be pasted into a larger SVG as-is.
134    /// `data-plot` stays in this fragment's own viewBox units.
135    pub fn finish_fragment(self, x: f64, y: f64) -> String {
136        let open = self.root_open(&format!("x=\"{}\" y=\"{}\" ", num(x), num(y)));
137        format!("{open}{}</svg>", self.body)
138    }
139}
140
141/// Format a coordinate with two decimals; non-finite becomes `0` (callers
142/// drop marks with non-finite geometry before this, so this is a last resort).
143fn num(v: f64) -> String {
144    if v.is_finite() {
145        format!("{v:.2}")
146    } else {
147        "0".to_string()
148    }
149}
150
151fn finite_or(v: f64, fallback: f64) -> f64 {
152    if v.is_finite() {
153        v
154    } else {
155        fallback
156    }
157}
158
159/// Root `<svg>` data attributes describing the trained position scales, for
160/// hosts that map screen coordinates back to data (zoom, crosshair, brushing):
161///
162/// - `data-xdomain="x0 x1"` / `data-ydomain="y0 y1"` — the data values at the
163///   panel's left/right (bottom/top) edges, i.e. the trained limits *after*
164///   expansion, for a continuous (numeric or date-time, epoch seconds) axis.
165///   Values are in the scale's transformed space (e.g. log10 units for
166///   `scale_x_log10`).
167/// - `data-domain="x0 x1 y0 y1"` — both of the above, emitted only when both
168///   axes are continuous.
169/// - `data-xlevels` / `data-ylevels` — a JSON array of the level labels for a
170///   discrete axis, in axis order.
171/// - `data-flip="true"` under `coord_flip` (x is then drawn vertically).
172///
173/// The `x`/`y` names refer to the x/y *aesthetics*. Omitted entirely for
174/// plots with free facet scales (each panel has its own domain).
175pub fn root_data_attrs(built: &crate::build::BuiltPlot) -> Vec<(String, String)> {
176    use crate::aes::Aesthetic;
177    let mut out = Vec::new();
178    if !built.panel_scales.is_empty() {
179        return out;
180    }
181    let axis = |aes: &Aesthetic| -> (Option<(f64, f64)>, Option<String>) {
182        match built.scales.get(aes) {
183            Some(s) if s.is_discrete() => {
184                let labels: Vec<String> = s
185                    .breaks()
186                    .into_iter()
187                    .map(|(_, l)| json_string(&l))
188                    .collect();
189                (None, Some(format!("[{}]", labels.join(","))))
190            }
191            Some(s) => (s.expanded_domain(), None),
192            None => (None, None),
193        }
194    };
195    let (xd, xl) = axis(&Aesthetic::X);
196    let (yd, yl) = axis(&Aesthetic::Y);
197    if let (Some((x0, x1)), Some((y0, y1))) = (xd, yd) {
198        out.push((
199            "data-domain".into(),
200            format!("{} {} {} {}", g(x0), g(x1), g(y0), g(y1)),
201        ));
202    }
203    if let Some((a, b)) = xd {
204        out.push(("data-xdomain".into(), format!("{} {}", g(a), g(b))));
205    }
206    if let Some((a, b)) = yd {
207        out.push(("data-ydomain".into(), format!("{} {}", g(a), g(b))));
208    }
209    if let Some(l) = xl {
210        out.push(("data-xlevels".into(), l));
211    }
212    if let Some(l) = yl {
213        out.push(("data-ylevels".into(), l));
214    }
215    if built.coord.is_flipped() {
216        out.push(("data-flip".into(), "true".into()));
217    }
218    out
219}
220
221/// Shortest round-trip formatting of a finite number (domains are filtered to
222/// finite values before this is called).
223fn g(v: f64) -> String {
224    format!("{v}")
225}
226
227/// Minimal JSON string literal (quotes, backslashes and control characters
228/// escaped); the result is attribute-escaped again on output.
229fn json_string(s: &str) -> String {
230    let mut out = String::with_capacity(s.len() + 2);
231    out.push('"');
232    for c in s.chars() {
233        match c {
234            '"' => out.push_str("\\\""),
235            '\\' => out.push_str("\\\\"),
236            '\n' => out.push_str("\\n"),
237            '\r' => out.push_str("\\r"),
238            '\t' => out.push_str("\\t"),
239            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
240            c => out.push(c),
241        }
242    }
243    out.push('"');
244    out
245}
246
247fn rgb((r, g, b): (u8, u8, u8)) -> String {
248    format!("#{r:02X}{g:02X}{b:02X}")
249}
250
251/// Escape text for use in SVG element content *and* quoted attribute values.
252/// Quotes must be escaped too: data values land in `data-x="…"`, and the SVG is
253/// routinely inlined into HTML, so an unescaped `"` would let data inject
254/// attributes (e.g. event handlers). Characters that are not legal in XML 1.0
255/// (C0 controls other than tab/LF/CR) are dropped so the document stays
256/// well-formed.
257fn escape(s: &str) -> String {
258    let mut out = String::with_capacity(s.len());
259    for c in s.chars() {
260        match c {
261            '&' => out.push_str("&amp;"),
262            '<' => out.push_str("&lt;"),
263            '>' => out.push_str("&gt;"),
264            '"' => out.push_str("&quot;"),
265            '\'' => out.push_str("&#39;"),
266            '\t' | '\n' | '\r' => out.push(c),
267            c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {}
268            c => out.push(c),
269        }
270    }
271    out
272}
273
274fn points(pts: &[(f64, f64)]) -> String {
275    pts.iter()
276        .map(|(x, y)| format!("{x:.2},{y:.2}"))
277        .collect::<Vec<_>>()
278        .join(" ")
279}
280
281fn finite_pts(pts: &[(f64, f64)]) -> Vec<(f64, f64)> {
282    pts.iter()
283        .copied()
284        .filter(|(x, y)| x.is_finite() && y.is_finite())
285        .collect()
286}
287
288impl DrawBackend for SvgBackend {
289    fn plot_area(&self) -> Rect {
290        self.plot_area.clone()
291    }
292    fn total_area(&self) -> Rect {
293        self.total_area.clone()
294    }
295
296    fn set_tooltip(&mut self, tooltip: Option<String>) {
297        self.tooltip = tooltip;
298    }
299
300    fn set_mark_axis(&mut self, key: Option<String>) {
301        self.axis_key = key;
302    }
303
304    fn set_mark_series(&mut self, series: Option<String>) {
305        self.series_key = series;
306    }
307
308    fn set_mark_value(&mut self, value: Option<String>) {
309        self.value_key = value;
310    }
311
312    fn warn(&mut self, message: String) {
313        self.warnings.push(message);
314    }
315
316    fn draw_circle(
317        &mut self,
318        (cx, cy): (f64, f64),
319        radius: f64,
320        style: &PointStyle,
321    ) -> Result<(), RenderError> {
322        if !(cx.is_finite() && cy.is_finite() && radius.is_finite()) {
323            return Ok(());
324        }
325        let attrs = format!(
326            "cx=\"{cx:.2}\" cy=\"{cy:.2}\" r=\"{:.2}\" fill=\"{}\" fill-opacity=\"{:.3}\"",
327            radius.max(0.0),
328            rgb(style.color),
329            finite_or(style.alpha, 1.0)
330        );
331        self.push_mark("circle", &attrs);
332        Ok(())
333    }
334
335    /// Native shapes: filled polygons for square/triangle/diamond, a single
336    /// stroked `<path>` for the `+` / `×` glyphs (so each point stays one
337    /// hoverable mark carrying its `data-*` attributes).
338    fn draw_shape(
339        &mut self,
340        (cx, cy): (f64, f64),
341        radius: f64,
342        style: &PointStyle,
343    ) -> Result<(), RenderError> {
344        if !(cx.is_finite() && cy.is_finite() && radius.is_finite()) {
345            return Ok(());
346        }
347        let r = radius.max(0.0);
348        let color = rgb(style.color);
349        let alpha = finite_or(style.alpha, 1.0);
350        let poly = |pts: &[(f64, f64)]| {
351            if style.filled {
352                format!(
353                    "points=\"{}\" fill=\"{color}\" fill-opacity=\"{alpha:.3}\"",
354                    points(pts)
355                )
356            } else {
357                format!(
358                    "points=\"{}\" fill=\"none\" stroke=\"{color}\" stroke-opacity=\"{alpha:.3}\"",
359                    points(pts)
360                )
361            }
362        };
363        match style.shape {
364            PointShape::Circle => return self.draw_circle((cx, cy), radius, style),
365            PointShape::Square => {
366                let a = poly(&[
367                    (cx - r, cy - r),
368                    (cx + r, cy - r),
369                    (cx + r, cy + r),
370                    (cx - r, cy + r),
371                ]);
372                self.push_mark("polygon", &a);
373            }
374            PointShape::Triangle => {
375                let a = poly(&[(cx, cy - r), (cx + r, cy + r), (cx - r, cy + r)]);
376                self.push_mark("polygon", &a);
377            }
378            PointShape::Diamond => {
379                let a = poly(&[(cx, cy - r), (cx + r, cy), (cx, cy + r), (cx - r, cy)]);
380                self.push_mark("polygon", &a);
381            }
382            PointShape::Plus | PointShape::Cross => {
383                let d = if style.shape == PointShape::Plus {
384                    format!(
385                        "M{:.2} {cy:.2}H{:.2}M{cx:.2} {:.2}V{:.2}",
386                        cx - r,
387                        cx + r,
388                        cy - r,
389                        cy + r
390                    )
391                } else {
392                    format!(
393                        "M{:.2} {:.2}L{:.2} {:.2}M{:.2} {:.2}L{:.2} {:.2}",
394                        cx - r,
395                        cy - r,
396                        cx + r,
397                        cy + r,
398                        cx - r,
399                        cy + r,
400                        cx + r,
401                        cy - r
402                    )
403                };
404                let a = format!(
405                    "d=\"{d}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"{:.2}\" \
406                     stroke-opacity=\"{alpha:.3}\"",
407                    (r / 2.5).clamp(1.0, 3.0)
408                );
409                self.push_mark("path", &a);
410            }
411        }
412        Ok(())
413    }
414
415    fn draw_line(&mut self, pts: &[(f64, f64)], style: &LineStyle) -> Result<(), RenderError> {
416        let pts = finite_pts(pts);
417        if pts.len() < 2 {
418            return Ok(());
419        }
420        let dash = match style
421            .linetype
422            .pattern()
423            .iter()
424            .flat_map(|(d, g)| [*d, *g])
425            .map(|v| format!("{v}"))
426            .collect::<Vec<_>>()
427            .join(",")
428        {
429            s if s.is_empty() => String::new(),
430            s => format!(" stroke-dasharray=\"{s}\""),
431        };
432        let attrs = format!(
433            "points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{:.2}\" stroke-opacity=\"{:.3}\"{}",
434            points(&pts),
435            rgb(style.color),
436            finite_or(style.width, 1.0),
437            finite_or(style.alpha, 1.0),
438            dash
439        );
440        self.push_mark("polyline", &attrs);
441        Ok(())
442    }
443
444    fn draw_rect(
445        &mut self,
446        (x0, y0): (f64, f64),
447        (x1, y1): (f64, f64),
448        style: &RectStyle,
449    ) -> Result<(), RenderError> {
450        if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) {
451            return Ok(());
452        }
453        let (x, y) = (x0.min(x1), y0.min(y1));
454        let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs());
455        let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
456        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
457        let attrs = format!(
458            "x=\"{x:.2}\" y=\"{y:.2}\" width=\"{w:.2}\" height=\"{h:.2}\" fill=\"{fill}\" \
459             fill-opacity=\"{:.3}\" stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
460            finite_or(style.alpha, 1.0),
461            finite_or(style.stroke_width, 0.0)
462        );
463        self.push_mark("rect", &attrs);
464        Ok(())
465    }
466
467    fn draw_polygon(&mut self, pts: &[(f64, f64)], style: &RectStyle) -> Result<(), RenderError> {
468        let pts = finite_pts(pts);
469        if pts.len() < 3 {
470            return Ok(());
471        }
472        let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
473        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
474        let attrs = format!(
475            "points=\"{}\" fill=\"{fill}\" fill-opacity=\"{:.3}\" stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
476            points(&pts),
477            finite_or(style.alpha, 1.0),
478            finite_or(style.stroke_width, 0.0)
479        );
480        self.push_mark("polygon", &attrs);
481        Ok(())
482    }
483
484    fn draw_text(
485        &mut self,
486        text: &str,
487        (x, y): (f64, f64),
488        style: &TextStyle,
489    ) -> Result<(), RenderError> {
490        if !(x.is_finite() && y.is_finite()) {
491            return Ok(());
492        }
493        let anchor = match style.anchor {
494            TextAnchor::Start => "start",
495            TextAnchor::Middle => "middle",
496            TextAnchor::End => "end",
497        };
498        let family = escape(style.family.as_deref().unwrap_or("sans-serif"));
499        let weight = if style.face == FontFace::Bold {
500            " font-weight=\"bold\""
501        } else {
502            ""
503        };
504        let fstyle = if style.face == FontFace::Italic {
505            " font-style=\"italic\""
506        } else {
507            ""
508        };
509        let angle = finite_or(style.angle, 0.0);
510        let transform = if angle.abs() > 0.01 {
511            format!(" transform=\"rotate({angle:.1} {x:.2} {y:.2})\"")
512        } else {
513            String::new()
514        };
515        self.body.push_str(&format!(
516            "<text x=\"{x:.2}\" y=\"{y:.2}\" font-size=\"{:.2}\" text-anchor=\"{anchor}\" \
517             dominant-baseline=\"middle\" font-family=\"{family}\"{weight}{fstyle} fill=\"{}\"{transform}>{}</text>",
518            finite_or(style.size, 12.0),
519            rgb(style.color),
520            escape(text)
521        ));
522        Ok(())
523    }
524}