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, LineEnd, LineStyle, PointShape, PointStyle, RectStyle, TextAnchor,
9    TextStyle,
10};
11use super::{Rect, RenderError};
12
13/// Accumulates SVG markup for a plot rendered via [`DrawBackend`].
14///
15/// Every number written to an attribute is finite: marks whose geometry is
16/// `NaN`/`±inf` are dropped (a polyline keeps its finite vertices), and
17/// non-finite style values fall back to safe defaults. All data-derived
18/// attribute values and text go through [`escape`].
19pub struct SvgBackend {
20    plot_area: Rect,
21    total_area: Rect,
22    body: String,
23    tooltip: Option<String>,
24    axis_key: Option<String>,
25    series_key: Option<String>,
26    value_key: Option<String>,
27    mark_part: Option<String>,
28    mark_attrs: Vec<(String, String)>,
29    /// Geom of the layer being drawn (see [`DrawBackend::set_mark_geom`]).
30    mark_geom: Option<String>,
31    root_attrs: Vec<(String, String)>,
32    warnings: Vec<String>,
33    /// Emit the root `data-plot` panel rect (off for composite pages, whose
34    /// root is not a single panel).
35    plot_attr: bool,
36    /// B2-4: whether `GGPlot::svg_formatted_values` is on — see
37    /// [`DrawBackend::wants_formatted_values`].
38    formatted_values: bool,
39    /// `data-fvalue` for the next drawn mark(s) — see
40    /// [`DrawBackend::set_mark_fvalue`].
41    mark_fvalue: Option<String>,
42    /// `data-fx` for the next drawn mark(s) — see
43    /// [`DrawBackend::set_mark_fx`].
44    mark_fx: Option<String>,
45}
46
47impl SvgBackend {
48    pub fn new(width: u32, height: u32, plot_area: Rect) -> Self {
49        SvgBackend {
50            plot_area,
51            total_area: Rect {
52                x: 0.0,
53                y: 0.0,
54                width: width as f64,
55                height: height as f64,
56            },
57            body: String::new(),
58            tooltip: None,
59            axis_key: None,
60            series_key: None,
61            value_key: None,
62            mark_part: None,
63            mark_attrs: Vec::new(),
64            mark_geom: None,
65            root_attrs: Vec::new(),
66            warnings: Vec::new(),
67            plot_attr: true,
68            formatted_values: false,
69            mark_fvalue: None,
70            mark_fx: None,
71        }
72    }
73
74    /// Turn `data-fvalue`/`data-fx` on for the whole render
75    /// ([`GGPlot::svg_formatted_values`](crate::plot::GGPlot::svg_formatted_values)).
76    /// Default off (set once, before drawing).
77    pub(crate) fn set_formatted_values(&mut self, on: bool) {
78        self.formatted_values = on;
79    }
80
81    /// Add extra attributes to the root `<svg>` element (e.g. the
82    /// `data-domain` family from [`root_data_attrs`]). Names must be plain
83    /// attribute names; values are escaped on output.
84    pub fn set_root_attrs(&mut self, attrs: Vec<(String, String)>) {
85        self.root_attrs = attrs;
86    }
87
88    /// Append extra attributes to the root `<svg>` element, after any
89    /// already set by [`set_root_attrs`](Self::set_root_attrs) (e.g. the
90    /// `data-domain` family from [`root_data_attrs`]). Unlike
91    /// `set_root_attrs`, this does not replace what is already there.
92    /// Callers (`GGPlot::svg_attr`/`PlotGrid::svg_attr`, via
93    /// [`validate_root_attrs`]) are responsible for name validation and
94    /// collision checks; this just appends. Values are escaped on output.
95    pub fn extend_root_attrs(&mut self, attrs: Vec<(String, String)>) {
96        self.root_attrs.extend(attrs);
97    }
98
99    /// Omit the root `data-plot` attribute (composite pages).
100    pub(crate) fn without_plot_attr(&mut self) {
101        self.plot_attr = false;
102    }
103
104    /// Append pre-rendered, already-escaped SVG markup (e.g. a nested plot
105    /// fragment) to the body. Crate-internal: callers guarantee well-formedness.
106    pub(crate) fn push_raw(&mut self, markup: &str) {
107        self.body.push_str(markup);
108    }
109
110    /// The accumulated body markup (without the root element).
111    pub(crate) fn body(&self) -> &str {
112        &self.body
113    }
114
115    /// Take the warnings reported while drawing (see [`DrawBackend::warn`]).
116    pub fn take_warnings(&mut self) -> Vec<String> {
117        std::mem::take(&mut self.warnings)
118    }
119
120    /// ` class="gg-mark gg-<kind>" data-geom="<geom>"` while a layer's geom is
121    /// drawing; empty for axes, grid lines, legends and backgrounds.
122    fn mark_class(&self, kind: &str) -> String {
123        match &self.mark_geom {
124            Some(g) => format!(" class=\"gg-mark gg-{kind}\" data-geom=\"{}\"", escape(g)),
125            None => String::new(),
126        }
127    }
128
129    /// Emit `<tag attrs/>`, or `<tag attrs data-…><title>tip</title></tag>`
130    /// when a tooltip / mark metadata is set (native SVG hover + host grouping).
131    ///
132    /// `kind` is the data-mark class suffix (see [`Self::mark_class`]) used
133    /// when no mark part is set. While a mark part *is* set (e.g. an
134    /// arrowhead), the part name is the effective kind instead — an
135    /// arrowhead gets `gg-mark gg-arrow`, not its parent's `gg-band`/`gg-line`.
136    fn push_mark(&mut self, tag: &str, kind: &str, attrs: &str) {
137        // Built-ins first, then user `data-*` attrs — re-validated here as
138        // defence in depth even though `geom::set_row_extras` already
139        // derives them only from validated `data_attr` mappings, because
140        // `set_mark_attrs` is a public `DrawBackend` method any caller could
141        // invoke directly. A later entry with the same name replaces an
142        // earlier one rather than duplicating it — including `mark_part`'s
143        // own `data-part`, added last, so it always wins over anything a
144        // caller supplied under that name (an element never carries the
145        // same attribute twice, which is an XML parse error).
146        let mut pairs: Vec<(String, String)> = Vec::new();
147        for (name, val) in [
148            ("data-x", &self.axis_key),
149            ("data-series", &self.series_key),
150            ("data-value", &self.value_key),
151        ] {
152            if let Some(v) = val {
153                pairs.push((name.to_string(), v.clone()));
154            }
155        }
156        // B2-4: `data-fx` only when it says something `data-x` doesn't —
157        // most marks have a plain numeric x, where the "formatted" value is
158        // the same string, and a redundant duplicate attribute is noise.
159        if let Some(fx) = &self.mark_fx {
160            if self.axis_key.as_deref() != Some(fx.as_str()) {
161                pairs.push(("data-fx".to_string(), fx.clone()));
162            }
163        }
164        if let Some(fvalue) = &self.mark_fvalue {
165            pairs.push(("data-fvalue".to_string(), fvalue.clone()));
166        }
167        for (name, val) in &self.mark_attrs {
168            if !is_valid_mark_attr_name(name) {
169                continue;
170            }
171            match pairs.iter_mut().find(|(n, _)| n == name) {
172                Some(existing) => existing.1 = val.clone(),
173                None => pairs.push((name.clone(), val.clone())),
174            }
175        }
176        // A mark part (e.g. an arrowhead) is decorative: no `<title>`, but it
177        // still carries `data-part` plus `aria-hidden` so assistive tech
178        // skips it (the parent mark already carries the semantic tooltip).
179        // Routed through the same dedup as the other attrs.
180        if let Some(part) = &self.mark_part {
181            match pairs.iter_mut().find(|(n, _)| n == "data-part") {
182                Some(existing) => existing.1 = part.clone(),
183                None => pairs.push(("data-part".to_string(), part.clone())),
184            }
185        }
186        let mut data = String::new();
187        for (name, val) in &pairs {
188            data.push_str(&format!(" {name}=\"{}\"", escape(val)));
189        }
190        if self.mark_part.is_some() {
191            data.push_str(" aria-hidden=\"true\"");
192        }
193        // The class goes last so existing attribute sequences stay
194        // byte-identical. Exactly one `class` attribute is ever written here
195        // (no `data_attr` mapping can be named bare `class` — see
196        // `is_valid_mark_attr_name` — so nothing in `pairs` can collide with it).
197        let effective_kind: &str = self.mark_part.as_deref().unwrap_or(kind);
198        data.push_str(&self.mark_class(effective_kind));
199        let mark = if self.mark_part.is_some() {
200            format!("<{tag} {attrs}{data}/>")
201        } else {
202            match &self.tooltip {
203                Some(t) => format!("<{tag} {attrs}{data}><title>{}</title></{tag}>", escape(t)),
204                None => format!("<{tag} {attrs}{data}/>"),
205            }
206        };
207        self.body.push_str(&mark);
208    }
209
210    fn root_open(&self, prefix: &str) -> String {
211        let p = &self.plot_area;
212        let (w, h) = (self.total_area.width as i64, self.total_area.height as i64);
213        let mut extra = String::new();
214        for (k, v) in &self.root_attrs {
215            extra.push_str(&format!(" {k}=\"{}\"", escape(v)));
216        }
217        let plot = if self.plot_attr {
218            format!(
219                " data-plot=\"{} {} {} {}\"",
220                num(p.x),
221                num(p.y),
222                num(p.width),
223                num(p.height),
224            )
225        } else {
226            String::new()
227        };
228        format!("<svg {prefix}width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\"{plot}{extra}>")
229    }
230
231    /// Wrap the accumulated elements in a complete `<svg>` document. The
232    /// `data-plot` attribute records the panel (data) area in viewBox units so a
233    /// host can map screen coordinates back to data (e.g. for zoom/crosshair).
234    pub fn finish(self) -> String {
235        let open = self.root_open("xmlns=\"http://www.w3.org/2000/svg\" ");
236        format!("{open}{}</svg>", self.body)
237    }
238
239    /// Wrap the accumulated elements in a *nested* `<svg>` positioned at
240    /// `(x, y)` in a parent SVG's user space — no `xmlns` (it inherits the
241    /// parent's namespace), so the result can be pasted into a larger SVG as-is.
242    /// `data-plot` stays in this fragment's own viewBox units.
243    pub fn finish_fragment(self, x: f64, y: f64) -> String {
244        let open = self.root_open(&format!("x=\"{}\" y=\"{}\" ", num(x), num(y)));
245        format!("{open}{}</svg>", self.body)
246    }
247
248    /// Emit one `<polyline>` for an already-finite run of points — see
249    /// [`DrawBackend::draw_line`]'s splitting at non-finite vertices.
250    fn draw_line_run(&mut self, pts: &[(f64, f64)], style: &LineStyle) {
251        let dash = match style
252            .linetype
253            .pattern()
254            .iter()
255            .flat_map(|(d, g)| [*d, *g])
256            .map(|v| format!("{v}"))
257            .collect::<Vec<_>>()
258            .join(",")
259        {
260            s if s.is_empty() => String::new(),
261            s => format!(" stroke-dasharray=\"{s}\""),
262        };
263        // `Butt` is SVG's own default: omitting the attribute keeps default
264        // output byte-identical.
265        let linecap = match style.lineend {
266            LineEnd::Butt => "",
267            LineEnd::Round => " stroke-linecap=\"round\"",
268            LineEnd::Square => " stroke-linecap=\"square\"",
269        };
270        let attrs = format!(
271            "points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{:.2}\"{}{}{}",
272            points(pts),
273            rgb(style.color),
274            finite_or(style.width, 1.0),
275            opacity_attr("stroke", style.alpha),
276            dash,
277            linecap
278        );
279        self.push_mark("polyline", "line", &attrs);
280    }
281}
282
283/// Format a coordinate with two decimals; non-finite becomes `0` (callers
284/// drop marks with non-finite geometry before this, so this is a last resort).
285fn num(v: f64) -> String {
286    if v.is_finite() {
287        format!("{v:.2}")
288    } else {
289        "0".to_string()
290    }
291}
292
293fn finite_or(v: f64, fallback: f64) -> f64 {
294    if v.is_finite() {
295        v
296    } else {
297        fallback
298    }
299}
300
301/// ` {prefix}-opacity="{alpha:.3}"` (with its own leading space), or the
302/// empty string when `alpha` — after the usual non-finite fallback to fully
303/// opaque — formats to `"1.000"`. Full opacity is the SVG default, so
304/// omitting it keeps default output byte-identical; an explicit `alpha = 1`
305/// is indistinguishable from "not set" and also omits the no-op attribute.
306fn opacity_attr(prefix: &str, alpha: f64) -> String {
307    let a = finite_or(alpha, 1.0);
308    if format!("{a:.3}") == "1.000" {
309        String::new()
310    } else {
311        format!(" {prefix}-opacity=\"{a:.3}\"")
312    }
313}
314
315/// Root `<svg>` data attributes describing the trained position scales, for
316/// hosts that map screen coordinates back to data (zoom, crosshair, brushing):
317///
318/// - `data-xdomain="x0 x1"` / `data-ydomain="y0 y1"` — the data values at the
319///   panel's left/right (bottom/top) edges, i.e. the trained limits *after*
320///   expansion, for a continuous (numeric or date-time, epoch seconds) axis.
321///   Values are in the scale's transformed space (e.g. log10 units for
322///   `scale_x_log10`).
323/// - `data-domain="x0 x1 y0 y1"` — both of the above, emitted only when both
324///   axes are continuous.
325/// - `data-xlevels` / `data-ylevels` — a JSON array of the level labels for a
326///   discrete axis, in axis order.
327/// - `data-flip="true"` under `coord_flip` (x is then drawn vertically).
328///
329/// The `x`/`y` names refer to the x/y *aesthetics*. Omitted entirely for
330/// plots with free facet scales (each panel has its own domain).
331pub fn root_data_attrs(built: &crate::build::BuiltPlot) -> Vec<(String, String)> {
332    use crate::aes::Aesthetic;
333    let mut out = Vec::new();
334    if !built.panel_scales.is_empty() {
335        return out;
336    }
337    let axis = |aes: &Aesthetic| -> (Option<(f64, f64)>, Option<String>) {
338        match built.scales.get(aes) {
339            Some(s) if s.is_discrete() => {
340                let labels: Vec<String> = s
341                    .breaks()
342                    .into_iter()
343                    .map(|(_, l)| json_string(&l))
344                    .collect();
345                (None, Some(format!("[{}]", labels.join(","))))
346            }
347            Some(s) => (s.expanded_domain(), None),
348            None => (None, None),
349        }
350    };
351    let (xd, xl) = axis(&Aesthetic::X);
352    let (yd, yl) = axis(&Aesthetic::Y);
353    if let (Some((x0, x1)), Some((y0, y1))) = (xd, yd) {
354        out.push((
355            "data-domain".into(),
356            format!("{} {} {} {}", g(x0), g(x1), g(y0), g(y1)),
357        ));
358    }
359    if let Some((a, b)) = xd {
360        out.push(("data-xdomain".into(), format!("{} {}", g(a), g(b))));
361    }
362    if let Some((a, b)) = yd {
363        out.push(("data-ydomain".into(), format!("{} {}", g(a), g(b))));
364    }
365    if let Some(l) = xl {
366        out.push(("data-xlevels".into(), l));
367    }
368    if let Some(l) = yl {
369        out.push(("data-ylevels".into(), l));
370    }
371    if built.coord.is_flipped() {
372        out.push(("data-flip".into(), "true".into()));
373    }
374    out
375}
376
377/// Shortest round-trip formatting of a finite number (domains are filtered to
378/// finite values before this is called).
379fn g(v: f64) -> String {
380    format!("{v}")
381}
382
383/// Minimal JSON string literal (quotes, backslashes and control characters
384/// escaped); the result is attribute-escaped again on output.
385fn json_string(s: &str) -> String {
386    let mut out = String::with_capacity(s.len() + 2);
387    out.push('"');
388    for c in s.chars() {
389        match c {
390            '"' => out.push_str("\\\""),
391            '\\' => out.push_str("\\\\"),
392            '\n' => out.push_str("\\n"),
393            '\r' => out.push_str("\\r"),
394            '\t' => out.push_str("\\t"),
395            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
396            c => out.push(c),
397        }
398    }
399    out.push('"');
400    out
401}
402
403/// Allow-listed root-level attribute names for `GGPlot::svg_attr`/
404/// `PlotGrid::svg_attr`: the fixed names `role`, `id`, `class`, `lang`, or
405/// `data-*`/`aria-*` with the same charset-restricted suffix `data_attr`
406/// uses (`^[a-z0-9][a-z0-9-]*$`, excluding the reserved `part`/`geom`
407/// suffixes — irrelevant at the root, but no reason to special-case them
408/// out). Anything else is rejected.
409fn is_allowed_root_attr_name(name: &str) -> bool {
410    matches!(name, "role" | "id" | "class" | "lang")
411        || name
412            .strip_prefix("data-")
413            .is_some_and(crate::aes::is_valid_data_attr_name)
414        || name
415            .strip_prefix("aria-")
416            .is_some_and(crate::aes::is_valid_data_attr_name)
417}
418
419/// Validate and filter a caller's requested root `<svg>` attributes
420/// (`GGPlot::svg_attr`/`PlotGrid::svg_attr`), checked centrally here and
421/// used by both callers rather than re-implemented per type. A name outside
422/// [`is_allowed_root_attr_name`]'s allow-list, or one already present in
423/// `existing` (what ggplot-rs itself is about to write to the same root —
424/// e.g. `data-plot`/`data-domain`/`data-grid`, or `data-warnings` when
425/// `svg_warnings_attr` is also on), is dropped with a warning instead of
426/// silently overwriting or duplicating it. `label` names the caller
427/// (`"plot"`/`"grid"`) in the warning text.
428pub(crate) fn validate_root_attrs(
429    requested: &[(String, String)],
430    existing: &[(String, String)],
431    label: &str,
432    warnings: &mut Vec<String>,
433) -> Vec<(String, String)> {
434    let built_in: Vec<&str> = existing.iter().map(|(k, _)| k.as_str()).collect();
435    let mut used: Vec<&str> = built_in.clone();
436    let mut out = Vec::new();
437    for (name, value) in requested {
438        if !is_allowed_root_attr_name(name) {
439            warnings.push(format!(
440                "{label}: svg_attr(\"{name}\") is not on the allow-list (data-*, aria-*, role, id, class, lang) — ignored"
441            ));
442            continue;
443        }
444        if used.contains(&name.as_str()) {
445            // Review PR#141 nit #9: distinguish an actual collision with
446            // something ggplot-rs itself writes (`data-plot`, `data-grid`,
447            // …) from a *second* `svg_attr` call for the same name — the
448            // latter is the caller's own earlier value, not ggplot-rs's.
449            if built_in.contains(&name.as_str()) {
450                warnings.push(format!(
451                    "{label}: svg_attr(\"{name}\") collides with an attribute ggplot-rs already writes to the root — ignored"
452                ));
453            } else {
454                warnings.push(format!(
455                    "{label}: svg_attr(\"{name}\") was already set by an earlier svg_attr call — ignored"
456                ));
457            }
458            continue;
459        }
460        used.push(name.as_str());
461        out.push((name.clone(), value.clone()));
462    }
463    out
464}
465
466/// `["warning one", "warning two"]` — a minimal hand-rolled JSON array of
467/// strings for `svg_warnings_attr` (no serde dependency, kept out of the
468/// `sf`-feature dependency tree — see [`json_string`]).
469pub(crate) fn warnings_json(warnings: &[String]) -> String {
470    let items: Vec<String> = warnings.iter().map(|w| json_string(w)).collect();
471    format!("[{}]", items.join(","))
472}
473
474/// Defence-in-depth gate for `push_mark`'s `mark_attrs`: `^data-[a-z0-9][a-z0-9-]*$`.
475/// `geom::set_row_extras` only ever supplies already-validated names, but
476/// `set_mark_attrs` is a public `DrawBackend` method any caller (a custom
477/// `Geom` impl, or misuse from outside the crate) could invoke directly with
478/// an arbitrary string, so this is checked again right before it reaches
479/// the wire — never trust a single validation layer for attribute-name
480/// injection.
481fn is_valid_mark_attr_name(name: &str) -> bool {
482    match name.strip_prefix("data-") {
483        Some(suffix) => crate::aes::is_valid_data_attr_name(suffix),
484        None => false,
485    }
486}
487
488fn rgb((r, g, b): (u8, u8, u8)) -> String {
489    format!("#{r:02X}{g:02X}{b:02X}")
490}
491
492/// Escape text for use in SVG element content *and* quoted attribute values.
493/// Quotes must be escaped too: data values land in `data-x="…"`, and the SVG is
494/// routinely inlined into HTML, so an unescaped `"` would let data inject
495/// attributes (e.g. event handlers). Characters that are not legal in XML 1.0
496/// (C0 controls other than tab/LF/CR) are dropped so the document stays
497/// well-formed.
498fn escape(s: &str) -> String {
499    let mut out = String::with_capacity(s.len());
500    for c in s.chars() {
501        match c {
502            '&' => out.push_str("&amp;"),
503            '<' => out.push_str("&lt;"),
504            '>' => out.push_str("&gt;"),
505            '"' => out.push_str("&quot;"),
506            '\'' => out.push_str("&#39;"),
507            '\t' | '\n' | '\r' => out.push(c),
508            c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {}
509            c => out.push(c),
510        }
511    }
512    out
513}
514
515fn points(pts: &[(f64, f64)]) -> String {
516    pts.iter()
517        .map(|(x, y)| format!("{x:.2},{y:.2}"))
518        .collect::<Vec<_>>()
519        .join(" ")
520}
521
522fn finite_pts(pts: &[(f64, f64)]) -> Vec<(f64, f64)> {
523    pts.iter()
524        .copied()
525        .filter(|(x, y)| x.is_finite() && y.is_finite())
526        .collect()
527}
528
529/// Split `pts` into maximal runs of consecutive finite vertices, dropping
530/// any run shorter than 2 points. Matches ggplot2's NA-break handling for
531/// lines: a non-finite vertex breaks the line instead of being silently
532/// skipped, which would otherwise visually bridge across the gap — but a
533/// single remaining observation isn't a line segment either (ggplot2 drops
534/// a one-point path piece the same way), so it draws nothing rather than
535/// an invisible 1-point `<polyline>` that would still carry a `<title>`
536/// and hover attributes.
537fn finite_runs(pts: &[(f64, f64)]) -> Vec<Vec<(f64, f64)>> {
538    let mut runs = Vec::new();
539    let mut cur: Vec<(f64, f64)> = Vec::new();
540    for &(x, y) in pts {
541        if x.is_finite() && y.is_finite() {
542            cur.push((x, y));
543        } else if !cur.is_empty() {
544            if cur.len() >= 2 {
545                runs.push(std::mem::take(&mut cur));
546            } else {
547                cur.clear();
548            }
549        }
550    }
551    if cur.len() >= 2 {
552        runs.push(cur);
553    }
554    runs
555}
556
557impl DrawBackend for SvgBackend {
558    fn plot_area(&self) -> Rect {
559        self.plot_area.clone()
560    }
561    fn total_area(&self) -> Rect {
562        self.total_area.clone()
563    }
564
565    fn set_tooltip(&mut self, tooltip: Option<String>) {
566        self.tooltip = tooltip;
567    }
568
569    fn set_mark_axis(&mut self, key: Option<String>) {
570        self.axis_key = key;
571    }
572
573    fn set_mark_series(&mut self, series: Option<String>) {
574        self.series_key = series;
575    }
576
577    fn set_mark_value(&mut self, value: Option<String>) {
578        self.value_key = value;
579    }
580
581    fn set_mark_geom(&mut self, geom: Option<&str>) {
582        self.mark_geom = geom.map(str::to_string);
583    }
584
585    fn warn(&mut self, message: String) {
586        self.warnings.push(message);
587    }
588
589    fn set_mark_part(&mut self, part: Option<&str>) {
590        self.mark_part = part.map(|s| s.to_string());
591    }
592
593    fn set_mark_attrs(&mut self, attrs: Vec<(String, String)>) {
594        self.mark_attrs = attrs;
595    }
596
597    fn wants_formatted_values(&self) -> bool {
598        self.formatted_values
599    }
600
601    fn set_mark_fvalue(&mut self, value: Option<String>) {
602        self.mark_fvalue = value;
603    }
604
605    fn set_mark_fx(&mut self, value: Option<String>) {
606        self.mark_fx = value;
607    }
608
609    fn draw_circle(
610        &mut self,
611        (cx, cy): (f64, f64),
612        radius: f64,
613        style: &PointStyle,
614    ) -> Result<(), RenderError> {
615        if !(cx.is_finite() && cy.is_finite() && radius.is_finite()) {
616            return Ok(());
617        }
618        let attrs = format!(
619            "cx=\"{cx:.2}\" cy=\"{cy:.2}\" r=\"{:.2}\" fill=\"{}\"{}",
620            radius.max(0.0),
621            rgb(style.color),
622            opacity_attr("fill", style.alpha)
623        );
624        self.push_mark("circle", "point", &attrs);
625        Ok(())
626    }
627
628    /// Native shapes: filled polygons for square/triangle/diamond, a single
629    /// stroked `<path>` for the `+` / `×` glyphs (so each point stays one
630    /// hoverable mark carrying its `data-*` attributes).
631    fn draw_shape(
632        &mut self,
633        (cx, cy): (f64, f64),
634        radius: f64,
635        style: &PointStyle,
636    ) -> Result<(), RenderError> {
637        if !(cx.is_finite() && cy.is_finite() && radius.is_finite()) {
638            return Ok(());
639        }
640        let r = radius.max(0.0);
641        let color = rgb(style.color);
642        let fill_opacity = opacity_attr("fill", style.alpha);
643        let stroke_opacity = opacity_attr("stroke", style.alpha);
644        let poly = |pts: &[(f64, f64)]| {
645            if style.filled {
646                format!("points=\"{}\" fill=\"{color}\"{fill_opacity}", points(pts))
647            } else {
648                format!(
649                    "points=\"{}\" fill=\"none\" stroke=\"{color}\"{stroke_opacity}",
650                    points(pts)
651                )
652            }
653        };
654        match style.shape {
655            PointShape::Circle => return self.draw_circle((cx, cy), radius, style),
656            PointShape::Square => {
657                let a = poly(&[
658                    (cx - r, cy - r),
659                    (cx + r, cy - r),
660                    (cx + r, cy + r),
661                    (cx - r, cy + r),
662                ]);
663                self.push_mark("polygon", "point", &a);
664            }
665            PointShape::Triangle => {
666                let a = poly(&[(cx, cy - r), (cx + r, cy + r), (cx - r, cy + r)]);
667                self.push_mark("polygon", "point", &a);
668            }
669            PointShape::Diamond => {
670                let a = poly(&[(cx, cy - r), (cx + r, cy), (cx, cy + r), (cx - r, cy)]);
671                self.push_mark("polygon", "point", &a);
672            }
673            PointShape::Plus | PointShape::Cross => {
674                let d = if style.shape == PointShape::Plus {
675                    format!(
676                        "M{:.2} {cy:.2}H{:.2}M{cx:.2} {:.2}V{:.2}",
677                        cx - r,
678                        cx + r,
679                        cy - r,
680                        cy + r
681                    )
682                } else {
683                    format!(
684                        "M{:.2} {:.2}L{:.2} {:.2}M{:.2} {:.2}L{:.2} {:.2}",
685                        cx - r,
686                        cy - r,
687                        cx + r,
688                        cy + r,
689                        cx - r,
690                        cy + r,
691                        cx + r,
692                        cy - r
693                    )
694                };
695                let a = format!(
696                    "d=\"{d}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"{:.2}\"{stroke_opacity}",
697                    (r / 2.5).clamp(1.0, 3.0)
698                );
699                self.push_mark("path", "point", &a);
700            }
701        }
702        Ok(())
703    }
704
705    fn draw_line(&mut self, pts: &[(f64, f64)], style: &LineStyle) -> Result<(), RenderError> {
706        // Split at non-finite vertices instead of silently dropping them:
707        // dropping-and-joining would visually bridge the gap, but ggplot2
708        // treats a non-finite (NA) vertex as a break in the line (see
709        // `finite_runs`). Each run becomes its own `<polyline>` mark, with
710        // the same style and the same data-x/series/value/part attrs.
711        for run in finite_runs(pts) {
712            self.draw_line_run(&run, style);
713        }
714        Ok(())
715    }
716
717    /// Emit a native `<path d="M.. Q..">` quadratic Bézier (one real curve
718    /// instead of a flattened polyline). Non-finite input draws nothing.
719    fn draw_quadratic(
720        &mut self,
721        p0: (f64, f64),
722        ctrl: (f64, f64),
723        p1: (f64, f64),
724        style: &LineStyle,
725    ) -> Result<(), RenderError> {
726        if !(p0.0.is_finite()
727            && p0.1.is_finite()
728            && ctrl.0.is_finite()
729            && ctrl.1.is_finite()
730            && p1.0.is_finite()
731            && p1.1.is_finite())
732        {
733            return Ok(());
734        }
735        let dash = match style
736            .linetype
737            .pattern()
738            .iter()
739            .flat_map(|(d, g)| [*d, *g])
740            .map(|v| format!("{v}"))
741            .collect::<Vec<_>>()
742            .join(",")
743        {
744            s if s.is_empty() => String::new(),
745            s => format!(" stroke-dasharray=\"{s}\""),
746        };
747        let linecap = match style.lineend {
748            LineEnd::Butt => "",
749            LineEnd::Round => " stroke-linecap=\"round\"",
750            LineEnd::Square => " stroke-linecap=\"square\"",
751        };
752        let d = format!(
753            "M{:.2} {:.2} Q{:.2} {:.2} {:.2} {:.2}",
754            p0.0, p0.1, ctrl.0, ctrl.1, p1.0, p1.1
755        );
756        let attrs = format!(
757            "d=\"{d}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{:.2}\"{}{}{}",
758            rgb(style.color),
759            finite_or(style.width, 1.0),
760            opacity_attr("stroke", style.alpha),
761            dash,
762            linecap
763        );
764        self.push_mark("path", "line", &attrs);
765        Ok(())
766    }
767
768    fn draw_rect(
769        &mut self,
770        (x0, y0): (f64, f64),
771        (x1, y1): (f64, f64),
772        style: &RectStyle,
773    ) -> Result<(), RenderError> {
774        if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) {
775            return Ok(());
776        }
777        let (x, y) = (x0.min(x1), y0.min(y1));
778        let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs());
779        let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
780        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
781        let attrs = format!(
782            "x=\"{x:.2}\" y=\"{y:.2}\" width=\"{w:.2}\" height=\"{h:.2}\" fill=\"{fill}\"{} \
783             stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
784            opacity_attr("fill", style.alpha),
785            finite_or(style.stroke_width, 0.0)
786        );
787        self.push_mark("rect", "bar", &attrs);
788        Ok(())
789    }
790
791    fn draw_polygon(&mut self, pts: &[(f64, f64)], style: &RectStyle) -> Result<(), RenderError> {
792        let pts = finite_pts(pts);
793        if pts.len() < 3 {
794            return Ok(());
795        }
796        let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
797        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
798        let attrs = format!(
799            "points=\"{}\" fill=\"{fill}\"{} stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
800            points(&pts),
801            opacity_attr("fill", style.alpha),
802            finite_or(style.stroke_width, 0.0)
803        );
804        self.push_mark("polygon", "band", &attrs);
805        Ok(())
806    }
807
808    fn draw_text(
809        &mut self,
810        text: &str,
811        (x, y): (f64, f64),
812        style: &TextStyle,
813    ) -> Result<(), RenderError> {
814        if !(x.is_finite() && y.is_finite()) {
815            return Ok(());
816        }
817        let anchor = match style.anchor {
818            TextAnchor::Start => "start",
819            TextAnchor::Middle => "middle",
820            TextAnchor::End => "end",
821        };
822        let family = escape(style.family.as_deref().unwrap_or("sans-serif"));
823        let weight = if style.face == FontFace::Bold {
824            " font-weight=\"bold\""
825        } else {
826            ""
827        };
828        let fstyle = if style.face == FontFace::Italic {
829            " font-style=\"italic\""
830        } else {
831            ""
832        };
833        let angle = finite_or(style.angle, 0.0);
834        let transform = if angle.abs() > 0.01 {
835            format!(" transform=\"rotate({angle:.1} {x:.2} {y:.2})\"")
836        } else {
837            String::new()
838        };
839        let class = self.mark_class("text");
840        self.body.push_str(&format!(
841            "<text x=\"{x:.2}\" y=\"{y:.2}\" font-size=\"{:.2}\" text-anchor=\"{anchor}\" \
842             dominant-baseline=\"middle\" font-family=\"{family}\"{weight}{fstyle} fill=\"{}\"{transform}{class}>{}</text>",
843            finite_or(style.size, 12.0),
844            rgb(style.color),
845            escape(text)
846        ));
847        Ok(())
848    }
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854    use crate::render::Rect;
855
856    fn backend() -> SvgBackend {
857        SvgBackend::new(
858            100,
859            100,
860            Rect {
861                x: 0.0,
862                y: 0.0,
863                width: 100.0,
864                height: 100.0,
865            },
866        )
867    }
868
869    #[test]
870    fn draw_quadratic_emits_a_path_with_m_and_q() {
871        let mut b = backend();
872        b.draw_quadratic((0.0, 0.0), (10.0, 20.0), (20.0, 0.0), &LineStyle::default())
873            .unwrap();
874        let svg = b.finish();
875        assert!(svg.contains("<path"));
876        assert!(svg.contains("d=\"M0.00 0.00 Q10.00 20.00 20.00 0.00\""));
877        assert!(!svg.contains("NaN"));
878        assert!(!svg.contains("inf"));
879    }
880
881    #[test]
882    fn draw_quadratic_drops_non_finite_input() {
883        let mut b = backend();
884        b.draw_quadratic(
885            (f64::NAN, 0.0),
886            (10.0, 20.0),
887            (20.0, 0.0),
888            &LineStyle::default(),
889        )
890        .unwrap();
891        b.draw_quadratic(
892            (0.0, 0.0),
893            (f64::INFINITY, 20.0),
894            (20.0, 0.0),
895            &LineStyle::default(),
896        )
897        .unwrap();
898        let svg = b.finish();
899        assert!(!svg.contains("<path"));
900    }
901
902    #[test]
903    fn draw_line_omits_linecap_by_default_but_writes_it_when_set() {
904        let mut b = backend();
905        b.draw_line(&[(0.0, 0.0), (10.0, 10.0)], &LineStyle::default())
906            .unwrap();
907        let svg = b.finish();
908        assert!(!svg.contains("stroke-linecap"));
909
910        let mut b2 = backend();
911        let style = LineStyle {
912            lineend: LineEnd::Round,
913            ..Default::default()
914        };
915        b2.draw_line(&[(0.0, 0.0), (10.0, 10.0)], &style).unwrap();
916        let svg2 = b2.finish();
917        assert!(svg2.contains("stroke-linecap=\"round\""));
918    }
919
920    #[test]
921    fn square_linecap_is_written_on_both_draw_line_and_draw_quadratic() {
922        let style = LineStyle {
923            lineend: LineEnd::Square,
924            ..Default::default()
925        };
926        let mut b = backend();
927        b.draw_line(&[(0.0, 0.0), (10.0, 10.0)], &style).unwrap();
928        assert!(b.finish().contains("stroke-linecap=\"square\""));
929
930        let mut b2 = backend();
931        b2.draw_quadratic((0.0, 0.0), (5.0, 10.0), (10.0, 0.0), &style)
932            .unwrap();
933        assert!(b2.finish().contains("stroke-linecap=\"square\""));
934    }
935
936    /// `push_mark`'s defence-in-depth gate: even if an invalid name reaches
937    /// `set_mark_attrs` directly (bypassing `Aes::data_attr`'s own
938    /// validation — `set_mark_attrs` is a public `DrawBackend` method any
939    /// caller could invoke), it's dropped before it reaches the wire.
940    #[test]
941    fn set_mark_attrs_defence_in_depth_drops_invalid_names() {
942        let mut b = backend();
943        b.set_mark_attrs(vec![
944            ("not-data-prefixed".to_string(), "x".to_string()),
945            ("data-".to_string(), "x".to_string()), // empty suffix
946            ("data-Upper".to_string(), "x".to_string()), // uppercase
947            ("data-ok".to_string(), "valid".to_string()), // the one legitimate entry
948        ]);
949        b.draw_circle((1.0, 1.0), 1.0, &PointStyle::default())
950            .unwrap();
951        let svg = b.finish();
952        assert!(!svg.contains("not-data-prefixed"));
953        assert!(!svg.contains("data-Upper"));
954        assert!(!svg.contains("data-=\""));
955        assert!(svg.contains("data-ok=\"valid\""));
956    }
957}