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, FillPattern, FontFace, LineEnd, LineStyle, PointShape, PointStyle, RectStyle,
9    TextAnchor, 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    /// Patterns registered so far via [`Self::pattern_url`] (#206),
46    /// deduped by equality: `(pattern, "<id_prefix>-pat-<n>")`. Drained
47    /// into a `<defs>` block by [`Self::pattern_defs`] on `finish`/
48    /// `finish_fragment`.
49    patterns: Vec<(FillPattern, String)>,
50    /// This backend instance's id prefix for `<defs>` — process-unique
51    /// (a monotonic counter), so inlining several rendered SVGs into one
52    /// HTML page never lets one's `url(#…)` resolve into another's
53    /// `<pattern>` (SVG ids share one DOM-wide namespace once inlined).
54    id_prefix: String,
55}
56
57/// Source for [`SvgBackend::id_prefix`] — process-wide, so every backend
58/// instance gets a distinct prefix regardless of thread.
59static NEXT_ID_PREFIX: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
60
61impl SvgBackend {
62    pub fn new(width: u32, height: u32, plot_area: Rect) -> Self {
63        SvgBackend {
64            plot_area,
65            total_area: Rect {
66                x: 0.0,
67                y: 0.0,
68                width: width as f64,
69                height: height as f64,
70            },
71            body: String::new(),
72            tooltip: None,
73            axis_key: None,
74            series_key: None,
75            value_key: None,
76            mark_part: None,
77            mark_attrs: Vec::new(),
78            mark_geom: None,
79            root_attrs: Vec::new(),
80            warnings: Vec::new(),
81            plot_attr: true,
82            formatted_values: false,
83            mark_fvalue: None,
84            mark_fx: None,
85            patterns: Vec::new(),
86            id_prefix: format!(
87                "gg{:x}",
88                NEXT_ID_PREFIX.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
89            ),
90        }
91    }
92
93    /// The `url(#…)` fill value for `pattern`, registering its `<defs>
94    /// <pattern>` the first time this exact pattern is seen on this
95    /// backend (later calls with an equal pattern reuse the same id).
96    fn pattern_url(&mut self, pattern: &FillPattern) -> String {
97        if let Some((_, id)) = self.patterns.iter().find(|(p, _)| p == pattern) {
98            return format!("url(#{id})");
99        }
100        let id = format!("{}-pat-{}", self.id_prefix, self.patterns.len());
101        self.patterns.push((*pattern, id.clone()));
102        format!("url(#{id})")
103    }
104
105    /// Shared by [`DrawBackend::draw_rect`]/[`DrawBackend::draw_rect_pattern`]
106    /// — `fill` is already resolved (a hex colour or a pattern's `url(#…)`).
107    fn rect_markup(
108        &mut self,
109        (x0, y0): (f64, f64),
110        (x1, y1): (f64, f64),
111        fill: &str,
112        style: &RectStyle,
113    ) -> Result<(), RenderError> {
114        if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) {
115            return Ok(());
116        }
117        let (x, y) = (x0.min(x1), y0.min(y1));
118        let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs());
119        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
120        // SVG `<rect>` takes rx/ry natively — clamp to half the shorter side
121        // so an oversized radius draws a "stadium" instead of a malformed box.
122        let r = style.corner_radius.max(0.0).min(w / 2.0).min(h / 2.0);
123        let radius = if r > 0.0 {
124            format!(" rx=\"{r:.2}\" ry=\"{r:.2}\"")
125        } else {
126            String::new()
127        };
128        let attrs = format!(
129            "x=\"{x:.2}\" y=\"{y:.2}\" width=\"{w:.2}\" height=\"{h:.2}\"{radius} fill=\"{fill}\"{} \
130             stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
131            opacity_attr("fill", style.alpha),
132            finite_or(style.stroke_width, 0.0)
133        );
134        self.push_mark("rect", "bar", &attrs);
135        Ok(())
136    }
137
138    /// Shared by [`DrawBackend::draw_polygon`]/[`DrawBackend::draw_polygon_pattern`].
139    fn polygon_markup(
140        &mut self,
141        pts: &[(f64, f64)],
142        fill: &str,
143        style: &RectStyle,
144    ) -> Result<(), RenderError> {
145        let pts = finite_pts(pts);
146        if pts.len() < 3 {
147            return Ok(());
148        }
149        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
150        let attrs = format!(
151            "points=\"{}\" fill=\"{fill}\"{} stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
152            points(&pts),
153            opacity_attr("fill", style.alpha),
154            finite_or(style.stroke_width, 0.0)
155        );
156        self.push_mark("polygon", "band", &attrs);
157        Ok(())
158    }
159
160    /// Shared by [`DrawBackend::draw_polygon_rings`]/
161    /// [`DrawBackend::draw_polygon_rings_pattern`].
162    fn polygon_rings_markup(
163        &mut self,
164        rings: &[Vec<(f64, f64)>],
165        fill: &str,
166        style: &RectStyle,
167    ) -> Result<(), RenderError> {
168        let mut d = String::new();
169        for ring in rings {
170            let pts = finite_pts(ring);
171            if pts.len() < 3 {
172                continue;
173            }
174            d.push('M');
175            d.push_str(&format!("{},{}", num(pts[0].0), num(pts[0].1)));
176            for p in &pts[1..] {
177                d.push('L');
178                d.push_str(&format!("{},{}", num(p.0), num(p.1)));
179            }
180            d.push('Z');
181        }
182        if d.is_empty() {
183            return Ok(());
184        }
185        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
186        let attrs = format!(
187            "d=\"{d}\" fill-rule=\"evenodd\" fill=\"{fill}\"{} stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
188            opacity_attr("fill", style.alpha),
189            finite_or(style.stroke_width, 0.0)
190        );
191        self.push_mark("path", "band", &attrs);
192        Ok(())
193    }
194
195    /// The `<defs>` block for every pattern [`Self::pattern_url`]
196    /// registered (empty when none were) — a `<pattern>` tile per the
197    /// stripe's own cycle, rotated by `patternTransform`, a `bg`-filled
198    /// tile with a narrower `fg` stripe at its edge (#206).
199    fn pattern_defs(&self) -> String {
200        if self.patterns.is_empty() {
201            return String::new();
202        }
203        let mut defs = String::from("<defs>");
204        for (p, id) in &self.patterns {
205            let FillPattern::Stripe {
206                fg,
207                bg,
208                size_px,
209                angle_deg,
210            } = p;
211            let size = if *size_px > 0.0 { *size_px } else { 1.0 };
212            defs.push_str(&format!(
213                "<pattern id=\"{id}\" width=\"{size}\" height=\"{size}\" patternUnits=\"userSpaceOnUse\" patternTransform=\"rotate({angle_deg})\"><rect width=\"{size}\" height=\"{size}\" fill=\"{}\"/><rect width=\"{:.3}\" height=\"{size}\" fill=\"{}\"/></pattern>",
214                rgb(*bg),
215                size * 0.3,
216                rgb(*fg),
217            ));
218        }
219        defs.push_str("</defs>");
220        defs
221    }
222
223    /// Turn `data-fvalue`/`data-fx` on for the whole render
224    /// ([`GGPlot::svg_formatted_values`](crate::plot::GGPlot::svg_formatted_values)).
225    /// Default off (set once, before drawing).
226    pub(crate) fn set_formatted_values(&mut self, on: bool) {
227        self.formatted_values = on;
228    }
229
230    /// Add extra attributes to the root `<svg>` element (e.g. the
231    /// `data-domain` family from [`root_data_attrs`]). Names must be plain
232    /// attribute names; values are escaped on output.
233    pub fn set_root_attrs(&mut self, attrs: Vec<(String, String)>) {
234        self.root_attrs = attrs;
235    }
236
237    /// Append extra attributes to the root `<svg>` element, after any
238    /// already set by [`set_root_attrs`](Self::set_root_attrs) (e.g. the
239    /// `data-domain` family from [`root_data_attrs`]). Unlike
240    /// `set_root_attrs`, this does not replace what is already there.
241    /// Callers (`GGPlot::svg_attr`/`PlotGrid::svg_attr`, via
242    /// [`validate_root_attrs`]) are responsible for name validation and
243    /// collision checks; this just appends. Values are escaped on output.
244    pub fn extend_root_attrs(&mut self, attrs: Vec<(String, String)>) {
245        self.root_attrs.extend(attrs);
246    }
247
248    /// Omit the root `data-plot` attribute (composite pages).
249    pub(crate) fn without_plot_attr(&mut self) {
250        self.plot_attr = false;
251    }
252
253    /// Append pre-rendered, already-escaped SVG markup (e.g. a nested plot
254    /// fragment) to the body. Crate-internal: callers guarantee well-formedness.
255    pub(crate) fn push_raw(&mut self, markup: &str) {
256        self.body.push_str(markup);
257    }
258
259    /// The accumulated body markup (without the root element).
260    pub(crate) fn body(&self) -> &str {
261        &self.body
262    }
263
264    /// Wrap the accumulated body in a `<g>` carrying `attrs` (escaped) — a
265    /// `PlotGrid` cell's own `svg_attr`s (#208), which belong on that
266    /// cell's content, not the cell's own root `<svg>` (ggplot-rs's own
267    /// `root_data_attrs`/`data-panel`) or the grid's root (the grid's own
268    /// `svg_attr`s). A no-op for an empty `attrs` — unaffected output,
269    /// same as every other panel without a `svg_attr` call.
270    pub(crate) fn wrap_body_in_group(&mut self, attrs: &[(String, String)]) {
271        if attrs.is_empty() {
272            return;
273        }
274        let mut open = String::from("<g");
275        for (k, v) in attrs {
276            open.push_str(&format!(" {k}=\"{}\"", escape(v)));
277        }
278        open.push('>');
279        self.body = format!("{open}{}</g>", self.body);
280    }
281
282    /// Take the warnings reported while drawing (see [`DrawBackend::warn`]).
283    pub fn take_warnings(&mut self) -> Vec<String> {
284        std::mem::take(&mut self.warnings)
285    }
286
287    /// ` class="gg-mark gg-<kind>" data-geom="<geom>"` while a layer's geom is
288    /// drawing; empty for axes, grid lines, legends and backgrounds.
289    fn mark_class(&self, kind: &str) -> String {
290        match &self.mark_geom {
291            Some(g) => format!(" class=\"gg-mark gg-{kind}\" data-geom=\"{}\"", escape(g)),
292            None => String::new(),
293        }
294    }
295
296    /// Emit `<tag attrs/>`, or `<tag attrs data-…><title>tip</title></tag>`
297    /// when a tooltip / mark metadata is set (native SVG hover + host grouping).
298    ///
299    /// `kind` is the data-mark class suffix (see [`Self::mark_class`]) used
300    /// when no mark part is set. While a mark part *is* set (e.g. an
301    /// arrowhead), the part name is the effective kind instead — an
302    /// arrowhead gets `gg-mark gg-arrow`, not its parent's `gg-band`/`gg-line`.
303    fn push_mark(&mut self, tag: &str, kind: &str, attrs: &str) {
304        // Built-ins first, then user `data-*` attrs — re-validated here as
305        // defence in depth even though `geom::set_row_extras` already
306        // derives them only from validated `data_attr` mappings, because
307        // `set_mark_attrs` is a public `DrawBackend` method any caller could
308        // invoke directly. A later entry with the same name replaces an
309        // earlier one rather than duplicating it — including `mark_part`'s
310        // own `data-part`, added last, so it always wins over anything a
311        // caller supplied under that name (an element never carries the
312        // same attribute twice, which is an XML parse error).
313        let mut pairs: Vec<(String, String)> = Vec::new();
314        for (name, val) in [
315            ("data-x", &self.axis_key),
316            ("data-series", &self.series_key),
317            ("data-value", &self.value_key),
318        ] {
319            if let Some(v) = val {
320                pairs.push((name.to_string(), v.clone()));
321            }
322        }
323        // B2-4: `data-fx` only when it says something `data-x` doesn't —
324        // most marks have a plain numeric x, where the "formatted" value is
325        // the same string, and a redundant duplicate attribute is noise.
326        if let Some(fx) = &self.mark_fx {
327            if self.axis_key.as_deref() != Some(fx.as_str()) {
328                pairs.push(("data-fx".to_string(), fx.clone()));
329            }
330        }
331        if let Some(fvalue) = &self.mark_fvalue {
332            pairs.push(("data-fvalue".to_string(), fvalue.clone()));
333        }
334        for (name, val) in &self.mark_attrs {
335            if !is_valid_mark_attr_name(name) {
336                continue;
337            }
338            match pairs.iter_mut().find(|(n, _)| n == name) {
339                Some(existing) => existing.1 = val.clone(),
340                None => pairs.push((name.clone(), val.clone())),
341            }
342        }
343        // A mark part (e.g. an arrowhead) is decorative: no `<title>`, but it
344        // still carries `data-part` plus `aria-hidden` so assistive tech
345        // skips it (the parent mark already carries the semantic tooltip).
346        // Routed through the same dedup as the other attrs.
347        if let Some(part) = &self.mark_part {
348            match pairs.iter_mut().find(|(n, _)| n == "data-part") {
349                Some(existing) => existing.1 = part.clone(),
350                None => pairs.push(("data-part".to_string(), part.clone())),
351            }
352        }
353        let mut data = String::new();
354        for (name, val) in &pairs {
355            data.push_str(&format!(" {name}=\"{}\"", escape(val)));
356        }
357        if self.mark_part.is_some() {
358            data.push_str(" aria-hidden=\"true\"");
359        }
360        // The class goes last so existing attribute sequences stay
361        // byte-identical. Exactly one `class` attribute is ever written here
362        // (no `data_attr` mapping can be named bare `class` — see
363        // `is_valid_mark_attr_name` — so nothing in `pairs` can collide with it).
364        let effective_kind: &str = self.mark_part.as_deref().unwrap_or(kind);
365        data.push_str(&self.mark_class(effective_kind));
366        let mark = if self.mark_part.is_some() {
367            format!("<{tag} {attrs}{data}/>")
368        } else {
369            match &self.tooltip {
370                Some(t) => format!("<{tag} {attrs}{data}><title>{}</title></{tag}>", escape(t)),
371                None => format!("<{tag} {attrs}{data}/>"),
372            }
373        };
374        self.body.push_str(&mark);
375    }
376
377    fn root_open(&self, prefix: &str) -> String {
378        let p = &self.plot_area;
379        let (w, h) = (self.total_area.width as i64, self.total_area.height as i64);
380        let mut extra = String::new();
381        for (k, v) in &self.root_attrs {
382            extra.push_str(&format!(" {k}=\"{}\"", escape(v)));
383        }
384        let plot = if self.plot_attr {
385            format!(
386                " data-plot=\"{} {} {} {}\"",
387                num(p.x),
388                num(p.y),
389                num(p.width),
390                num(p.height),
391            )
392        } else {
393            String::new()
394        };
395        format!("<svg {prefix}width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\"{plot}{extra}>")
396    }
397
398    /// Wrap the accumulated elements in a complete `<svg>` document. The
399    /// `data-plot` attribute records the panel (data) area in viewBox units so a
400    /// host can map screen coordinates back to data (e.g. for zoom/crosshair).
401    pub fn finish(self) -> String {
402        let open = self.root_open("xmlns=\"http://www.w3.org/2000/svg\" ");
403        let defs = self.pattern_defs();
404        format!("{open}{defs}{}</svg>", self.body)
405    }
406
407    /// Wrap the accumulated elements in a *nested* `<svg>` positioned at
408    /// `(x, y)` in a parent SVG's user space — no `xmlns` (it inherits the
409    /// parent's namespace), so the result can be pasted into a larger SVG as-is.
410    /// `data-plot` stays in this fragment's own viewBox units.
411    pub fn finish_fragment(self, x: f64, y: f64) -> String {
412        let open = self.root_open(&format!("x=\"{}\" y=\"{}\" ", num(x), num(y)));
413        let defs = self.pattern_defs();
414        format!("{open}{defs}{}</svg>", self.body)
415    }
416
417    /// Emit one `<polyline>` for an already-finite run of points — see
418    /// [`DrawBackend::draw_line`]'s splitting at non-finite vertices.
419    fn draw_line_run(&mut self, pts: &[(f64, f64)], style: &LineStyle) {
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        // `Butt` is SVG's own default: omitting the attribute keeps default
433        // output byte-identical.
434        let linecap = match style.lineend {
435            LineEnd::Butt => "",
436            LineEnd::Round => " stroke-linecap=\"round\"",
437            LineEnd::Square => " stroke-linecap=\"square\"",
438        };
439        let attrs = format!(
440            "points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{:.2}\"{}{}{}",
441            points(pts),
442            rgb(style.color),
443            finite_or(style.width, 1.0),
444            opacity_attr("stroke", style.alpha),
445            dash,
446            linecap
447        );
448        self.push_mark("polyline", "line", &attrs);
449    }
450}
451
452/// Format a coordinate with two decimals; non-finite becomes `0` (callers
453/// drop marks with non-finite geometry before this, so this is a last resort).
454fn num(v: f64) -> String {
455    if v.is_finite() {
456        format!("{v:.2}")
457    } else {
458        "0".to_string()
459    }
460}
461
462/// #214: `d` for [`SvgBackend::draw_polygon_rings_precise`] — one `M<x>,<y>`
463/// per ring (the first point, absolute) followed by one `l` command whose
464/// coordinate pairs are **deltas from the previous point**, SVG's implicit
465/// lineto-repeat (`l dx1,dy1 dx2,dy2 …`), each number rounded to
466/// `precision` decimals with trailing zeros trimmed. Deltas are typically
467/// far smaller than absolute screen coordinates, and the single `l` letter
468/// (vs. one `L` per point) is the other half of the size win; see
469/// [`push_num`] for the separator-omission this relies on. A ring under 3
470/// finite points is dropped, same as the absolute encoder.
471fn compact_rings_d(rings: &[Vec<(f64, f64)>], precision: u8) -> String {
472    let mut d = String::new();
473    for ring in rings {
474        let pts = finite_pts(ring);
475        if pts.len() < 3 {
476            continue;
477        }
478        d.push('M');
479        push_num(&mut d, &fmt_compact(pts[0].0, precision));
480        push_num(&mut d, &fmt_compact(pts[0].1, precision));
481        if pts.len() > 1 {
482            d.push('l');
483            let mut prev = pts[0];
484            for p in &pts[1..] {
485                push_num(&mut d, &fmt_compact(p.0 - prev.0, precision));
486                push_num(&mut d, &fmt_compact(p.1 - prev.1, precision));
487                prev = *p;
488            }
489        }
490        d.push('z');
491    }
492    d
493}
494
495/// Append `s` (a formatted number) to `d`, inserting a comma first only
496/// when one is actually needed to keep the path parseable: right after a
497/// digit, with the new number *not* itself starting with `-` (whose sign
498/// already disambiguates where the previous number ended, SVG's own
499/// "no separator needed before a negative number" rule — the "no
500/// redundant separators" #214 asks for). Right after a command letter
501/// (`M`/`l`/`z`) no separator is ever needed.
502fn push_num(d: &mut String, s: &str) {
503    let needs_sep =
504        matches!(d.chars().last(), Some(c) if c.is_ascii_digit()) && !s.starts_with('-');
505    if needs_sep {
506        d.push(',');
507    }
508    d.push_str(s);
509}
510
511/// Format `v` to `precision` decimals, then trim trailing zeros and a
512/// trailing `.` (`"5.0"` → `"5"`, `"1.20"` → `"1.2"`) — the SVG path
513/// grammar allows a bare integer anywhere a number is valid. Non-finite
514/// becomes `"0"`; `-0`-after-rounding (e.g. a tiny negative delta that
515/// rounds to zero) becomes `"0"` too, not `"-0"`.
516fn fmt_compact(v: f64, precision: u8) -> String {
517    if !v.is_finite() {
518        return "0".to_string();
519    }
520    let s = format!("{v:.*}", precision as usize);
521    let trimmed = if s.contains('.') {
522        s.trim_end_matches('0').trim_end_matches('.')
523    } else {
524        s.as_str()
525    };
526    match trimmed {
527        "" | "-" | "-0" => "0".to_string(),
528        other => other.to_string(),
529    }
530}
531
532fn finite_or(v: f64, fallback: f64) -> f64 {
533    if v.is_finite() {
534        v
535    } else {
536        fallback
537    }
538}
539
540/// ` {prefix}-opacity="{alpha:.3}"` (with its own leading space), or the
541/// empty string when `alpha` — after the usual non-finite fallback to fully
542/// opaque — formats to `"1.000"`. Full opacity is the SVG default, so
543/// omitting it keeps default output byte-identical; an explicit `alpha = 1`
544/// is indistinguishable from "not set" and also omits the no-op attribute.
545fn opacity_attr(prefix: &str, alpha: f64) -> String {
546    let a = finite_or(alpha, 1.0);
547    if format!("{a:.3}") == "1.000" {
548        String::new()
549    } else {
550        format!(" {prefix}-opacity=\"{a:.3}\"")
551    }
552}
553
554/// Root `<svg>` data attributes describing the trained position scales, for
555/// hosts that map screen coordinates back to data (zoom, crosshair, brushing):
556///
557/// - `data-xdomain="x0 x1"` / `data-ydomain="y0 y1"` — the data values at the
558///   panel's left/right (bottom/top) edges, i.e. the trained limits *after*
559///   expansion, for a continuous (numeric or date-time, epoch seconds) axis.
560///   Values are in the scale's transformed space (e.g. log10 units for
561///   `scale_x_log10`).
562/// - `data-domain="x0 x1 y0 y1"` — both of the above, emitted only when both
563///   axes are continuous.
564/// - `data-xlevels` / `data-ylevels` — a JSON array of the level labels for a
565///   discrete axis, in axis order.
566/// - `data-flip="true"` under `coord_flip` (x is then drawn vertically).
567///
568/// The `x`/`y` names refer to the x/y *aesthetics*. Omitted entirely for
569/// plots with free facet scales (each panel has its own domain).
570pub fn root_data_attrs(built: &crate::build::BuiltPlot) -> Vec<(String, String)> {
571    use crate::aes::Aesthetic;
572    let mut out = Vec::new();
573    if !built.panel_scales.is_empty() {
574        return out;
575    }
576    let axis = |aes: &Aesthetic| -> (Option<(f64, f64)>, Option<String>) {
577        match built.scales.get(aes) {
578            Some(s) if s.is_discrete() => {
579                let labels: Vec<String> = s
580                    .breaks()
581                    .into_iter()
582                    .map(|(_, l)| json_string(&l))
583                    .collect();
584                (None, Some(format!("[{}]", labels.join(","))))
585            }
586            Some(s) => (s.expanded_domain(), None),
587            None => (None, None),
588        }
589    };
590    let (xd, xl) = axis(&Aesthetic::X);
591    let (yd, yl) = axis(&Aesthetic::Y);
592    if let (Some((x0, x1)), Some((y0, y1))) = (xd, yd) {
593        out.push((
594            "data-domain".into(),
595            format!("{} {} {} {}", g(x0), g(x1), g(y0), g(y1)),
596        ));
597    }
598    if let Some((a, b)) = xd {
599        out.push(("data-xdomain".into(), format!("{} {}", g(a), g(b))));
600    }
601    if let Some((a, b)) = yd {
602        out.push(("data-ydomain".into(), format!("{} {}", g(a), g(b))));
603    }
604    if let Some(l) = xl {
605        out.push(("data-xlevels".into(), l));
606    }
607    if let Some(l) = yl {
608        out.push(("data-ylevels".into(), l));
609    }
610    if built.coord.is_flipped() {
611        out.push(("data-flip".into(), "true".into()));
612    }
613    out
614}
615
616/// Shortest round-trip formatting of a finite number (domains are filtered to
617/// finite values before this is called).
618fn g(v: f64) -> String {
619    format!("{v}")
620}
621
622/// Minimal JSON string literal (quotes, backslashes and control characters
623/// escaped); the result is attribute-escaped again on output.
624fn json_string(s: &str) -> String {
625    let mut out = String::with_capacity(s.len() + 2);
626    out.push('"');
627    for c in s.chars() {
628        match c {
629            '"' => out.push_str("\\\""),
630            '\\' => out.push_str("\\\\"),
631            '\n' => out.push_str("\\n"),
632            '\r' => out.push_str("\\r"),
633            '\t' => out.push_str("\\t"),
634            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
635            c => out.push(c),
636        }
637    }
638    out.push('"');
639    out
640}
641
642/// Allow-listed root-level attribute names for `GGPlot::svg_attr`/
643/// `PlotGrid::svg_attr`: the fixed names `role`, `id`, `class`, `lang`, or
644/// `data-*`/`aria-*` with the same charset-restricted suffix `data_attr`
645/// uses (`^[a-z0-9][a-z0-9-]*$`, excluding the reserved `part`/`geom`
646/// suffixes — irrelevant at the root, but no reason to special-case them
647/// out). Anything else is rejected.
648fn is_allowed_root_attr_name(name: &str) -> bool {
649    matches!(name, "role" | "id" | "class" | "lang")
650        || name
651            .strip_prefix("data-")
652            .is_some_and(crate::aes::is_valid_data_attr_name)
653        || name
654            .strip_prefix("aria-")
655            .is_some_and(crate::aes::is_valid_data_attr_name)
656}
657
658/// Validate and filter a caller's requested root `<svg>` attributes
659/// (`GGPlot::svg_attr`/`PlotGrid::svg_attr`), checked centrally here and
660/// used by both callers rather than re-implemented per type. A name outside
661/// [`is_allowed_root_attr_name`]'s allow-list, or one already present in
662/// `existing` (what ggplot-rs itself is about to write to the same root —
663/// e.g. `data-plot`/`data-domain`/`data-grid`, or `data-warnings` when
664/// `svg_warnings_attr` is also on), is dropped with a warning instead of
665/// silently overwriting or duplicating it. `label` names the caller
666/// (`"plot"`/`"grid"`) in the warning text.
667pub(crate) fn validate_root_attrs(
668    requested: &[(String, String)],
669    existing: &[(String, String)],
670    label: &str,
671    warnings: &mut Vec<String>,
672) -> Vec<(String, String)> {
673    let built_in: Vec<&str> = existing.iter().map(|(k, _)| k.as_str()).collect();
674    let mut used: Vec<&str> = built_in.clone();
675    let mut out = Vec::new();
676    for (name, value) in requested {
677        if !is_allowed_root_attr_name(name) {
678            warnings.push(format!(
679                "{label}: svg_attr(\"{name}\") is not on the allow-list (data-*, aria-*, role, id, class, lang) — ignored"
680            ));
681            continue;
682        }
683        if used.contains(&name.as_str()) {
684            // Review PR#141 nit #9: distinguish an actual collision with
685            // something ggplot-rs itself writes (`data-plot`, `data-grid`,
686            // …) from a *second* `svg_attr` call for the same name — the
687            // latter is the caller's own earlier value, not ggplot-rs's.
688            if built_in.contains(&name.as_str()) {
689                warnings.push(format!(
690                    "{label}: svg_attr(\"{name}\") collides with an attribute ggplot-rs already writes to the root — ignored"
691                ));
692            } else {
693                warnings.push(format!(
694                    "{label}: svg_attr(\"{name}\") was already set by an earlier svg_attr call — ignored"
695                ));
696            }
697            continue;
698        }
699        used.push(name.as_str());
700        out.push((name.clone(), value.clone()));
701    }
702    out
703}
704
705/// `["warning one", "warning two"]` — a minimal hand-rolled JSON array of
706/// strings for `svg_warnings_attr` (no serde dependency, kept out of the
707/// `sf`-feature dependency tree — see [`json_string`]).
708pub(crate) fn warnings_json(warnings: &[String]) -> String {
709    let items: Vec<String> = warnings.iter().map(|w| json_string(w)).collect();
710    format!("[{}]", items.join(","))
711}
712
713/// Defence-in-depth gate for `push_mark`'s `mark_attrs`: `^data-[a-z0-9][a-z0-9-]*$`.
714/// `geom::set_row_extras` only ever supplies already-validated names, but
715/// `set_mark_attrs` is a public `DrawBackend` method any caller (a custom
716/// `Geom` impl, or misuse from outside the crate) could invoke directly with
717/// an arbitrary string, so this is checked again right before it reaches
718/// the wire — never trust a single validation layer for attribute-name
719/// injection.
720fn is_valid_mark_attr_name(name: &str) -> bool {
721    match name.strip_prefix("data-") {
722        Some(suffix) => crate::aes::is_valid_data_attr_name(suffix),
723        None => false,
724    }
725}
726
727fn rgb((r, g, b): (u8, u8, u8)) -> String {
728    format!("#{r:02X}{g:02X}{b:02X}")
729}
730
731/// Escape text for use in SVG element content *and* quoted attribute values.
732/// Quotes must be escaped too: data values land in `data-x="…"`, and the SVG is
733/// routinely inlined into HTML, so an unescaped `"` would let data inject
734/// attributes (e.g. event handlers). Characters that are not legal in XML 1.0
735/// (C0 controls other than tab/LF/CR) are dropped so the document stays
736/// well-formed.
737fn escape(s: &str) -> String {
738    let mut out = String::with_capacity(s.len());
739    for c in s.chars() {
740        match c {
741            '&' => out.push_str("&amp;"),
742            '<' => out.push_str("&lt;"),
743            '>' => out.push_str("&gt;"),
744            '"' => out.push_str("&quot;"),
745            '\'' => out.push_str("&#39;"),
746            '\t' | '\n' | '\r' => out.push(c),
747            c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {}
748            c => out.push(c),
749        }
750    }
751    out
752}
753
754fn points(pts: &[(f64, f64)]) -> String {
755    pts.iter()
756        .map(|(x, y)| format!("{x:.2},{y:.2}"))
757        .collect::<Vec<_>>()
758        .join(" ")
759}
760
761fn finite_pts(pts: &[(f64, f64)]) -> Vec<(f64, f64)> {
762    pts.iter()
763        .copied()
764        .filter(|(x, y)| x.is_finite() && y.is_finite())
765        .collect()
766}
767
768/// Split `pts` into maximal runs of consecutive finite vertices, dropping
769/// any run shorter than 2 points. Matches ggplot2's NA-break handling for
770/// lines: a non-finite vertex breaks the line instead of being silently
771/// skipped, which would otherwise visually bridge across the gap — but a
772/// single remaining observation isn't a line segment either (ggplot2 drops
773/// a one-point path piece the same way), so it draws nothing rather than
774/// an invisible 1-point `<polyline>` that would still carry a `<title>`
775/// and hover attributes.
776fn finite_runs(pts: &[(f64, f64)]) -> Vec<Vec<(f64, f64)>> {
777    let mut runs = Vec::new();
778    let mut cur: Vec<(f64, f64)> = Vec::new();
779    for &(x, y) in pts {
780        if x.is_finite() && y.is_finite() {
781            cur.push((x, y));
782        } else if !cur.is_empty() {
783            if cur.len() >= 2 {
784                runs.push(std::mem::take(&mut cur));
785            } else {
786                cur.clear();
787            }
788        }
789    }
790    if cur.len() >= 2 {
791        runs.push(cur);
792    }
793    runs
794}
795
796impl DrawBackend for SvgBackend {
797    fn plot_area(&self) -> Rect {
798        self.plot_area.clone()
799    }
800    fn total_area(&self) -> Rect {
801        self.total_area.clone()
802    }
803
804    fn set_tooltip(&mut self, tooltip: Option<String>) {
805        self.tooltip = tooltip;
806    }
807
808    fn set_mark_axis(&mut self, key: Option<String>) {
809        self.axis_key = key;
810    }
811
812    fn set_mark_series(&mut self, series: Option<String>) {
813        self.series_key = series;
814    }
815
816    fn set_mark_value(&mut self, value: Option<String>) {
817        self.value_key = value;
818    }
819
820    fn set_mark_geom(&mut self, geom: Option<&str>) {
821        self.mark_geom = geom.map(str::to_string);
822    }
823
824    fn warn(&mut self, message: String) {
825        self.warnings.push(message);
826    }
827
828    fn set_mark_part(&mut self, part: Option<&str>) {
829        self.mark_part = part.map(|s| s.to_string());
830    }
831
832    fn set_mark_attrs(&mut self, attrs: Vec<(String, String)>) {
833        self.mark_attrs = attrs;
834    }
835
836    fn wants_formatted_values(&self) -> bool {
837        self.formatted_values
838    }
839
840    fn set_mark_fvalue(&mut self, value: Option<String>) {
841        self.mark_fvalue = value;
842    }
843
844    fn set_mark_fx(&mut self, value: Option<String>) {
845        self.mark_fx = value;
846    }
847
848    fn draw_circle(
849        &mut self,
850        (cx, cy): (f64, f64),
851        radius: f64,
852        style: &PointStyle,
853    ) -> Result<(), RenderError> {
854        if !(cx.is_finite() && cy.is_finite() && radius.is_finite()) {
855            return Ok(());
856        }
857        let attrs = format!(
858            "cx=\"{cx:.2}\" cy=\"{cy:.2}\" r=\"{:.2}\" fill=\"{}\"{}",
859            radius.max(0.0),
860            rgb(style.color),
861            opacity_attr("fill", style.alpha)
862        );
863        self.push_mark("circle", "point", &attrs);
864        Ok(())
865    }
866
867    /// Native shapes: filled polygons for square/triangle/diamond, a single
868    /// stroked `<path>` for the `+` / `×` glyphs (so each point stays one
869    /// hoverable mark carrying its `data-*` attributes).
870    fn draw_shape(
871        &mut self,
872        (cx, cy): (f64, f64),
873        radius: f64,
874        style: &PointStyle,
875    ) -> Result<(), RenderError> {
876        if !(cx.is_finite() && cy.is_finite() && radius.is_finite()) {
877            return Ok(());
878        }
879        let r = radius.max(0.0);
880        let color = rgb(style.color);
881        let fill_opacity = opacity_attr("fill", style.alpha);
882        let stroke_opacity = opacity_attr("stroke", style.alpha);
883        let poly = |pts: &[(f64, f64)]| {
884            if style.filled {
885                format!("points=\"{}\" fill=\"{color}\"{fill_opacity}", points(pts))
886            } else {
887                format!(
888                    "points=\"{}\" fill=\"none\" stroke=\"{color}\"{stroke_opacity}",
889                    points(pts)
890                )
891            }
892        };
893        match style.shape {
894            PointShape::Circle => return self.draw_circle((cx, cy), radius, style),
895            PointShape::Square => {
896                let a = poly(&[
897                    (cx - r, cy - r),
898                    (cx + r, cy - r),
899                    (cx + r, cy + r),
900                    (cx - r, cy + r),
901                ]);
902                self.push_mark("polygon", "point", &a);
903            }
904            PointShape::Triangle => {
905                let a = poly(&[(cx, cy - r), (cx + r, cy + r), (cx - r, cy + r)]);
906                self.push_mark("polygon", "point", &a);
907            }
908            PointShape::Diamond => {
909                let a = poly(&[(cx, cy - r), (cx + r, cy), (cx, cy + r), (cx - r, cy)]);
910                self.push_mark("polygon", "point", &a);
911            }
912            PointShape::Plus | PointShape::Cross => {
913                let d = if style.shape == PointShape::Plus {
914                    format!(
915                        "M{:.2} {cy:.2}H{:.2}M{cx:.2} {:.2}V{:.2}",
916                        cx - r,
917                        cx + r,
918                        cy - r,
919                        cy + r
920                    )
921                } else {
922                    format!(
923                        "M{:.2} {:.2}L{:.2} {:.2}M{:.2} {:.2}L{:.2} {:.2}",
924                        cx - r,
925                        cy - r,
926                        cx + r,
927                        cy + r,
928                        cx - r,
929                        cy + r,
930                        cx + r,
931                        cy - r
932                    )
933                };
934                let a = format!(
935                    "d=\"{d}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"{:.2}\"{stroke_opacity}",
936                    (r / 2.5).clamp(1.0, 3.0)
937                );
938                self.push_mark("path", "point", &a);
939            }
940        }
941        Ok(())
942    }
943
944    fn draw_line(&mut self, pts: &[(f64, f64)], style: &LineStyle) -> Result<(), RenderError> {
945        // Split at non-finite vertices instead of silently dropping them:
946        // dropping-and-joining would visually bridge the gap, but ggplot2
947        // treats a non-finite (NA) vertex as a break in the line (see
948        // `finite_runs`). Each run becomes its own `<polyline>` mark, with
949        // the same style and the same data-x/series/value/part attrs.
950        for run in finite_runs(pts) {
951            self.draw_line_run(&run, style);
952        }
953        Ok(())
954    }
955
956    /// Emit a native `<path d="M.. Q..">` quadratic Bézier (one real curve
957    /// instead of a flattened polyline). Non-finite input draws nothing.
958    fn draw_quadratic(
959        &mut self,
960        p0: (f64, f64),
961        ctrl: (f64, f64),
962        p1: (f64, f64),
963        style: &LineStyle,
964    ) -> Result<(), RenderError> {
965        if !(p0.0.is_finite()
966            && p0.1.is_finite()
967            && ctrl.0.is_finite()
968            && ctrl.1.is_finite()
969            && p1.0.is_finite()
970            && p1.1.is_finite())
971        {
972            return Ok(());
973        }
974        let dash = match style
975            .linetype
976            .pattern()
977            .iter()
978            .flat_map(|(d, g)| [*d, *g])
979            .map(|v| format!("{v}"))
980            .collect::<Vec<_>>()
981            .join(",")
982        {
983            s if s.is_empty() => String::new(),
984            s => format!(" stroke-dasharray=\"{s}\""),
985        };
986        let linecap = match style.lineend {
987            LineEnd::Butt => "",
988            LineEnd::Round => " stroke-linecap=\"round\"",
989            LineEnd::Square => " stroke-linecap=\"square\"",
990        };
991        let d = format!(
992            "M{:.2} {:.2} Q{:.2} {:.2} {:.2} {:.2}",
993            p0.0, p0.1, ctrl.0, ctrl.1, p1.0, p1.1
994        );
995        let attrs = format!(
996            "d=\"{d}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{:.2}\"{}{}{}",
997            rgb(style.color),
998            finite_or(style.width, 1.0),
999            opacity_attr("stroke", style.alpha),
1000            dash,
1001            linecap
1002        );
1003        self.push_mark("path", "line", &attrs);
1004        Ok(())
1005    }
1006
1007    fn draw_rect(
1008        &mut self,
1009        top_left: (f64, f64),
1010        bottom_right: (f64, f64),
1011        style: &RectStyle,
1012    ) -> Result<(), RenderError> {
1013        let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
1014        self.rect_markup(top_left, bottom_right, &fill, style)
1015    }
1016
1017    /// #206: same markup [`draw_rect`](Self::draw_rect) builds, filled
1018    /// with `pattern`'s registered `url(#…)` instead of `style.fill`.
1019    fn draw_rect_pattern(
1020        &mut self,
1021        top_left: (f64, f64),
1022        bottom_right: (f64, f64),
1023        pattern: &FillPattern,
1024        style: &RectStyle,
1025    ) -> Result<(), RenderError> {
1026        let fill = self.pattern_url(pattern);
1027        self.rect_markup(top_left, bottom_right, &fill, style)
1028    }
1029
1030    fn draw_polygon(&mut self, pts: &[(f64, f64)], style: &RectStyle) -> Result<(), RenderError> {
1031        let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
1032        self.polygon_markup(pts, &fill, style)
1033    }
1034
1035    /// #206: same markup [`draw_polygon`](Self::draw_polygon) builds,
1036    /// filled with `pattern`'s registered `url(#…)` instead of `style.fill`.
1037    fn draw_polygon_pattern(
1038        &mut self,
1039        pts: &[(f64, f64)],
1040        pattern: &FillPattern,
1041        style: &RectStyle,
1042    ) -> Result<(), RenderError> {
1043        let fill = self.pattern_url(pattern);
1044        self.polygon_markup(pts, &fill, style)
1045    }
1046
1047    /// One `<path>`, every ring a subpath (`M…Z`), `fill-rule="evenodd"` so
1048    /// holes (rings after the first) are cut out of the fill — SVG's native
1049    /// equivalent of the even-odd rasteriser the pixel/plotters backends run
1050    /// (#178). A ring with fewer than 3 finite points is dropped; the whole
1051    /// shape draws nothing when every ring is.
1052    fn draw_polygon_rings(
1053        &mut self,
1054        rings: &[Vec<(f64, f64)>],
1055        style: &RectStyle,
1056    ) -> Result<(), RenderError> {
1057        let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
1058        self.polygon_rings_markup(rings, &fill, style)
1059    }
1060
1061    /// #206: same markup [`draw_polygon_rings`](Self::draw_polygon_rings)
1062    /// builds (holes cut out under the same even-odd rule), filled with
1063    /// `pattern`'s registered `url(#…)` instead of `style.fill`.
1064    fn draw_polygon_rings_pattern(
1065        &mut self,
1066        rings: &[Vec<(f64, f64)>],
1067        pattern: &FillPattern,
1068        style: &RectStyle,
1069    ) -> Result<(), RenderError> {
1070        let fill = self.pattern_url(pattern);
1071        self.polygon_rings_markup(rings, &fill, style)
1072    }
1073
1074    /// #214: with `precision` set, writes a compact `d` (absolute `M` per
1075    /// ring, then one `l` with implicit-repeat relative coordinate pairs,
1076    /// rounded to `precision` decimals) instead of the one-`L`-per-point
1077    /// absolute encoding [`Self::polygon_rings_markup`] builds. `None`
1078    /// falls back to the trait default (today's encoding, unchanged).
1079    fn draw_polygon_rings_precise(
1080        &mut self,
1081        rings: &[Vec<(f64, f64)>],
1082        pattern: Option<&FillPattern>,
1083        precision: Option<u8>,
1084        style: &RectStyle,
1085    ) -> Result<(), RenderError> {
1086        let Some(precision) = precision else {
1087            return match pattern {
1088                Some(p) => self.draw_polygon_rings_pattern(rings, p, style),
1089                None => self.draw_polygon_rings(rings, style),
1090            };
1091        };
1092        let d = compact_rings_d(rings, precision);
1093        if d.is_empty() {
1094            return Ok(());
1095        }
1096        let fill = match pattern {
1097            Some(p) => self.pattern_url(p),
1098            None => style.fill.map(rgb).unwrap_or_else(|| "none".into()),
1099        };
1100        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
1101        let attrs = format!(
1102            "d=\"{d}\" fill-rule=\"evenodd\" fill=\"{fill}\"{} stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
1103            opacity_attr("fill", style.alpha),
1104            finite_or(style.stroke_width, 0.0)
1105        );
1106        self.push_mark("path", "band", &attrs);
1107        Ok(())
1108    }
1109
1110    fn draw_text(
1111        &mut self,
1112        text: &str,
1113        pos: (f64, f64),
1114        style: &TextStyle,
1115    ) -> Result<(), RenderError> {
1116        self.draw_text_with_title(text, pos, style, None)
1117    }
1118
1119    /// #159: with `title`, wraps the `<text>` in a `<title>` child
1120    /// carrying the full (escaped) label — e.g. a legend key truncated
1121    /// with an ellipsis to fit a vertical multi-column grid.
1122    fn draw_text_with_title(
1123        &mut self,
1124        text: &str,
1125        (x, y): (f64, f64),
1126        style: &TextStyle,
1127        title: Option<&str>,
1128    ) -> Result<(), RenderError> {
1129        if !(x.is_finite() && y.is_finite()) {
1130            return Ok(());
1131        }
1132        let anchor = match style.anchor {
1133            TextAnchor::Start => "start",
1134            TextAnchor::Middle => "middle",
1135            TextAnchor::End => "end",
1136        };
1137        let family = escape(style.family.as_deref().unwrap_or("sans-serif"));
1138        let weight = if style.face == FontFace::Bold {
1139            " font-weight=\"bold\""
1140        } else {
1141            ""
1142        };
1143        let fstyle = if style.face == FontFace::Italic {
1144            " font-style=\"italic\""
1145        } else {
1146            ""
1147        };
1148        let angle = finite_or(style.angle, 0.0);
1149        let transform = if angle.abs() > 0.01 {
1150            format!(" transform=\"rotate({angle:.1} {x:.2} {y:.2})\"")
1151        } else {
1152            String::new()
1153        };
1154        let class = self.mark_class("text");
1155        let title_tag = match title {
1156            Some(t) => format!("<title>{}</title>", escape(t)),
1157            None => String::new(),
1158        };
1159        self.body.push_str(&format!(
1160            "<text x=\"{x:.2}\" y=\"{y:.2}\" font-size=\"{:.2}\" text-anchor=\"{anchor}\" \
1161             dominant-baseline=\"middle\" font-family=\"{family}\"{weight}{fstyle} fill=\"{}\"{transform}{class}>{title_tag}{}</text>",
1162            finite_or(style.size, 12.0),
1163            rgb(style.color),
1164            escape(text)
1165        ));
1166        Ok(())
1167    }
1168
1169    /// #196: each linked run becomes `<a href="…" target="_blank"
1170    /// rel="noopener"><tspan text-decoration="underline">…</tspan></a>`
1171    /// (`rel="noopener"` so the opened tab can't reach back via
1172    /// `window.opener`); a plain run is a bare `<tspan>`. All runs sit
1173    /// inside one `<text>`, so `style.anchor` aligns their combined width
1174    /// exactly as it would a single run's.
1175    fn draw_text_runs(
1176        &mut self,
1177        runs: &[(&str, Option<&str>)],
1178        (x, y): (f64, f64),
1179        style: &TextStyle,
1180    ) -> Result<(), RenderError> {
1181        if !(x.is_finite() && y.is_finite()) {
1182            return Ok(());
1183        }
1184        let anchor = match style.anchor {
1185            TextAnchor::Start => "start",
1186            TextAnchor::Middle => "middle",
1187            TextAnchor::End => "end",
1188        };
1189        let family = escape(style.family.as_deref().unwrap_or("sans-serif"));
1190        let weight = if style.face == FontFace::Bold {
1191            " font-weight=\"bold\""
1192        } else {
1193            ""
1194        };
1195        let fstyle = if style.face == FontFace::Italic {
1196            " font-style=\"italic\""
1197        } else {
1198            ""
1199        };
1200        let angle = finite_or(style.angle, 0.0);
1201        let transform = if angle.abs() > 0.01 {
1202            format!(" transform=\"rotate({angle:.1} {x:.2} {y:.2})\"")
1203        } else {
1204            String::new()
1205        };
1206        let class = self.mark_class("text");
1207        let mut inner = String::new();
1208        for (text, href) in runs {
1209            match href {
1210                Some(u) => inner.push_str(&format!(
1211                    "<a href=\"{}\" target=\"_blank\" rel=\"noopener\"><tspan text-decoration=\"underline\">{}</tspan></a>",
1212                    escape(u),
1213                    escape(text)
1214                )),
1215                None => inner.push_str(&format!("<tspan>{}</tspan>", escape(text))),
1216            }
1217        }
1218        self.body.push_str(&format!(
1219            "<text x=\"{x:.2}\" y=\"{y:.2}\" font-size=\"{:.2}\" text-anchor=\"{anchor}\" \
1220             dominant-baseline=\"middle\" font-family=\"{family}\"{weight}{fstyle} fill=\"{}\"{transform}{class}>{inner}</text>",
1221            finite_or(style.size, 12.0),
1222            rgb(style.color),
1223        ));
1224        Ok(())
1225    }
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230    use super::*;
1231    use crate::render::Rect;
1232
1233    fn backend() -> SvgBackend {
1234        SvgBackend::new(
1235            100,
1236            100,
1237            Rect {
1238                x: 0.0,
1239                y: 0.0,
1240                width: 100.0,
1241                height: 100.0,
1242            },
1243        )
1244    }
1245
1246    #[test]
1247    fn draw_quadratic_emits_a_path_with_m_and_q() {
1248        let mut b = backend();
1249        b.draw_quadratic((0.0, 0.0), (10.0, 20.0), (20.0, 0.0), &LineStyle::default())
1250            .unwrap();
1251        let svg = b.finish();
1252        assert!(svg.contains("<path"));
1253        assert!(svg.contains("d=\"M0.00 0.00 Q10.00 20.00 20.00 0.00\""));
1254        assert!(!svg.contains("NaN"));
1255        assert!(!svg.contains("inf"));
1256    }
1257
1258    #[test]
1259    fn draw_quadratic_drops_non_finite_input() {
1260        let mut b = backend();
1261        b.draw_quadratic(
1262            (f64::NAN, 0.0),
1263            (10.0, 20.0),
1264            (20.0, 0.0),
1265            &LineStyle::default(),
1266        )
1267        .unwrap();
1268        b.draw_quadratic(
1269            (0.0, 0.0),
1270            (f64::INFINITY, 20.0),
1271            (20.0, 0.0),
1272            &LineStyle::default(),
1273        )
1274        .unwrap();
1275        let svg = b.finish();
1276        assert!(!svg.contains("<path"));
1277    }
1278
1279    #[test]
1280    fn draw_line_omits_linecap_by_default_but_writes_it_when_set() {
1281        let mut b = backend();
1282        b.draw_line(&[(0.0, 0.0), (10.0, 10.0)], &LineStyle::default())
1283            .unwrap();
1284        let svg = b.finish();
1285        assert!(!svg.contains("stroke-linecap"));
1286
1287        let mut b2 = backend();
1288        let style = LineStyle {
1289            lineend: LineEnd::Round,
1290            ..Default::default()
1291        };
1292        b2.draw_line(&[(0.0, 0.0), (10.0, 10.0)], &style).unwrap();
1293        let svg2 = b2.finish();
1294        assert!(svg2.contains("stroke-linecap=\"round\""));
1295    }
1296
1297    #[test]
1298    fn square_linecap_is_written_on_both_draw_line_and_draw_quadratic() {
1299        let style = LineStyle {
1300            lineend: LineEnd::Square,
1301            ..Default::default()
1302        };
1303        let mut b = backend();
1304        b.draw_line(&[(0.0, 0.0), (10.0, 10.0)], &style).unwrap();
1305        assert!(b.finish().contains("stroke-linecap=\"square\""));
1306
1307        let mut b2 = backend();
1308        b2.draw_quadratic((0.0, 0.0), (5.0, 10.0), (10.0, 0.0), &style)
1309            .unwrap();
1310        assert!(b2.finish().contains("stroke-linecap=\"square\""));
1311    }
1312
1313    /// `push_mark`'s defence-in-depth gate: even if an invalid name reaches
1314    /// `set_mark_attrs` directly (bypassing `Aes::data_attr`'s own
1315    /// validation — `set_mark_attrs` is a public `DrawBackend` method any
1316    /// caller could invoke), it's dropped before it reaches the wire.
1317    #[test]
1318    fn set_mark_attrs_defence_in_depth_drops_invalid_names() {
1319        let mut b = backend();
1320        b.set_mark_attrs(vec![
1321            ("not-data-prefixed".to_string(), "x".to_string()),
1322            ("data-".to_string(), "x".to_string()), // empty suffix
1323            ("data-Upper".to_string(), "x".to_string()), // uppercase
1324            ("data-ok".to_string(), "valid".to_string()), // the one legitimate entry
1325        ]);
1326        b.draw_circle((1.0, 1.0), 1.0, &PointStyle::default())
1327            .unwrap();
1328        let svg = b.finish();
1329        assert!(!svg.contains("not-data-prefixed"));
1330        assert!(!svg.contains("data-Upper"));
1331        assert!(!svg.contains("data-=\""));
1332        assert!(svg.contains("data-ok=\"valid\""));
1333    }
1334
1335    // #206: pattern fills.
1336
1337    fn stripe() -> FillPattern {
1338        FillPattern::Stripe {
1339            fg: (10, 20, 30),
1340            bg: (200, 210, 220),
1341            size_px: 4.0,
1342            angle_deg: 45.0,
1343        }
1344    }
1345
1346    fn pattern_id(svg: &str) -> String {
1347        let i = svg.find("<pattern id=\"").unwrap() + "<pattern id=\"".len();
1348        svg[i..i + svg[i..].find('"').unwrap()].to_string()
1349    }
1350
1351    #[test]
1352    fn a_pattern_fill_registers_one_defs_pattern_and_references_it() {
1353        let mut b = backend();
1354        b.draw_rect_pattern((0.0, 0.0), (10.0, 10.0), &stripe(), &RectStyle::default())
1355            .unwrap();
1356        let svg = b.finish();
1357        assert_eq!(svg.matches("<pattern ").count(), 1, "{svg}");
1358        let id = pattern_id(&svg);
1359        assert!(
1360            svg.contains(&format!("fill=\"url(#{id})\"")),
1361            "rect references the registered pattern id {id}: {svg}"
1362        );
1363    }
1364
1365    #[test]
1366    fn a_rounded_rect_pattern_still_rounds_its_corners() {
1367        let mut b = backend();
1368        b.draw_rect_pattern(
1369            (0.0, 0.0),
1370            (10.0, 10.0),
1371            &stripe(),
1372            &RectStyle {
1373                corner_radius: 3.0,
1374                ..Default::default()
1375            },
1376        )
1377        .unwrap();
1378        let svg = b.finish();
1379        assert!(svg.contains("rx=\"3.00\""), "{svg}");
1380        assert!(svg.contains("fill=\"url(#"), "{svg}");
1381    }
1382
1383    #[test]
1384    fn an_equal_pattern_reuses_the_same_defs_entry() {
1385        let mut b = backend();
1386        b.draw_rect_pattern((0.0, 0.0), (5.0, 5.0), &stripe(), &RectStyle::default())
1387            .unwrap();
1388        b.draw_polygon_pattern(
1389            &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)],
1390            &stripe(),
1391            &RectStyle::default(),
1392        )
1393        .unwrap();
1394        let svg = b.finish();
1395        assert_eq!(
1396            svg.matches("<pattern ").count(),
1397            1,
1398            "two equal patterns share one <defs> entry: {svg}"
1399        );
1400    }
1401
1402    #[test]
1403    fn different_patterns_get_their_own_defs_entries() {
1404        let mut b = backend();
1405        b.draw_rect_pattern((0.0, 0.0), (5.0, 5.0), &stripe(), &RectStyle::default())
1406            .unwrap();
1407        let other = FillPattern::Stripe {
1408            fg: (1, 1, 1),
1409            bg: (2, 2, 2),
1410            size_px: 8.0,
1411            angle_deg: 0.0,
1412        };
1413        b.draw_rect_pattern((0.0, 0.0), (5.0, 5.0), &other, &RectStyle::default())
1414            .unwrap();
1415        let svg = b.finish();
1416        assert_eq!(svg.matches("<pattern ").count(), 2, "{svg}");
1417    }
1418
1419    #[test]
1420    fn no_pattern_used_means_no_defs_block_at_all() {
1421        let mut b = backend();
1422        b.draw_rect((0.0, 0.0), (5.0, 5.0), &RectStyle::default())
1423            .unwrap();
1424        let svg = b.finish();
1425        assert!(!svg.contains("<defs>"), "{svg}");
1426    }
1427
1428    #[test]
1429    fn pattern_fill_cuts_out_holes_like_a_flat_fill_does() {
1430        let mut b = backend();
1431        let outer = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
1432        let hole = vec![(2.0, 2.0), (8.0, 2.0), (8.0, 8.0), (2.0, 8.0)];
1433        b.draw_polygon_rings_pattern(&[outer, hole], &stripe(), &RectStyle::default())
1434            .unwrap();
1435        let svg = b.finish();
1436        assert!(svg.contains("fill-rule=\"evenodd\""), "{svg}");
1437        assert_eq!(
1438            svg.matches("<path").count(),
1439            1,
1440            "one path, both rings: {svg}"
1441        );
1442    }
1443
1444    #[test]
1445    fn two_backends_on_one_page_never_collide_on_a_pattern_id() {
1446        let mut a = backend();
1447        a.draw_rect_pattern((0.0, 0.0), (5.0, 5.0), &stripe(), &RectStyle::default())
1448            .unwrap();
1449        let mut b = backend();
1450        b.draw_rect_pattern((0.0, 0.0), (5.0, 5.0), &stripe(), &RectStyle::default())
1451            .unwrap();
1452        let (sa, sb) = (a.finish(), b.finish());
1453        assert_ne!(pattern_id(&sa), pattern_id(&sb), "{sa}\n{sb}");
1454    }
1455
1456    /// #214: decode a [`compact_rings_d`]-style `d` back into absolute
1457    /// points, for the round-trip test below. Relies on the same
1458    /// separator rule [`push_num`] writes: a number starts right after a
1459    /// command letter, right after a comma, or right at a `-` sign (which
1460    /// needs no comma before it); every other digit boundary has an
1461    /// explicit comma.
1462    fn decode_compact_d(d: &str) -> Vec<Vec<(f64, f64)>> {
1463        let b = d.as_bytes();
1464        let mut i = 0;
1465        let read_num = |b: &[u8], i: &mut usize| -> f64 {
1466            let start = *i;
1467            if *i < b.len() && b[*i] == b'-' {
1468                *i += 1;
1469            }
1470            while *i < b.len() && b[*i].is_ascii_digit() {
1471                *i += 1;
1472            }
1473            if *i < b.len() && b[*i] == b'.' {
1474                *i += 1;
1475                while *i < b.len() && b[*i].is_ascii_digit() {
1476                    *i += 1;
1477                }
1478            }
1479            std::str::from_utf8(&b[start..*i]).unwrap().parse().unwrap()
1480        };
1481        let is_num_start = |c: u8| c.is_ascii_digit() || c == b'-' || c == b'.';
1482        let mut rings = Vec::new();
1483        let mut current = Vec::new();
1484        let mut cursor = (0.0, 0.0);
1485        while i < b.len() {
1486            match b[i] {
1487                b'M' => {
1488                    if !current.is_empty() {
1489                        rings.push(std::mem::take(&mut current));
1490                    }
1491                    i += 1;
1492                    let x = read_num(b, &mut i);
1493                    if i < b.len() && b[i] == b',' {
1494                        i += 1;
1495                    }
1496                    let y = read_num(b, &mut i);
1497                    cursor = (x, y);
1498                    current.push(cursor);
1499                }
1500                b'l' => {
1501                    i += 1;
1502                    while i < b.len() && is_num_start(b[i]) {
1503                        let dx = read_num(b, &mut i);
1504                        if i < b.len() && b[i] == b',' {
1505                            i += 1;
1506                        }
1507                        let dy = read_num(b, &mut i);
1508                        if i < b.len() && b[i] == b',' {
1509                            i += 1;
1510                        }
1511                        cursor = (cursor.0 + dx, cursor.1 + dy);
1512                        current.push(cursor);
1513                    }
1514                }
1515                b'z' => {
1516                    i += 1;
1517                    if !current.is_empty() {
1518                        rings.push(std::mem::take(&mut current));
1519                    }
1520                }
1521                _ => i += 1,
1522            }
1523        }
1524        if !current.is_empty() {
1525            rings.push(current);
1526        }
1527        rings
1528    }
1529
1530    /// A noisy, roughly-circular ring with `n` points, the shape of a
1531    /// simplified real boundary (not axis-aligned, no repeating deltas).
1532    fn noisy_ring(n: usize, cx: f64, cy: f64, r: f64) -> Vec<(f64, f64)> {
1533        (0..n)
1534            .map(|i| {
1535                let t = i as f64 / n as f64 * std::f64::consts::TAU;
1536                let wobble = 1.0 + 0.03 * ((i * 7) as f64).sin();
1537                (cx + r * wobble * t.cos(), cy + r * wobble * t.sin())
1538            })
1539            .collect()
1540    }
1541
1542    #[test]
1543    fn compact_encoding_is_well_under_the_absolute_encodings_size() {
1544        // Isolated from simplification entirely: the same 2,000-point
1545        // ring, encoded both ways directly (no GeomSf/simplify_px
1546        // involved) — the byte-size win is the encoding alone.
1547        let ring = noisy_ring(2000, 400.0, 300.0, 280.0);
1548        let mut b = backend();
1549        b.draw_polygon_rings(std::slice::from_ref(&ring), &RectStyle::default())
1550            .unwrap();
1551        let absolute_svg = b.finish();
1552        let absolute_d = absolute_svg
1553            .split("d=\"")
1554            .nth(1)
1555            .and_then(|s| s.split('"').next())
1556            .expect("absolute path d");
1557
1558        let compact_d = compact_rings_d(std::slice::from_ref(&ring), 1);
1559
1560        assert!(
1561            compact_d.len() * 3 < absolute_d.len() * 2,
1562            "compact {} bytes should be well under 2/3 of absolute {} bytes",
1563            compact_d.len(),
1564            absolute_d.len()
1565        );
1566    }
1567
1568    #[test]
1569    fn compact_encoding_round_trips_within_precision() {
1570        let ring = noisy_ring(40, 100.0, 80.0, 60.0);
1571        let precision: u8 = 1;
1572        let d = compact_rings_d(std::slice::from_ref(&ring), precision);
1573        let decoded = decode_compact_d(&d);
1574        assert_eq!(decoded.len(), 1, "{d}");
1575        assert_eq!(decoded[0].len(), ring.len(), "{d}");
1576        // Each step's rounding error is at most half a unit of precision;
1577        // reconstructed points accumulate it additively from M, so the
1578        // tolerance at point k scales with k (still a tight, provable
1579        // bound, not an arbitrary constant).
1580        let step_tol = 0.5 * 10f64.powi(-(precision as i32));
1581        for (k, (orig, got)) in ring.iter().zip(decoded[0].iter()).enumerate() {
1582            let tol = step_tol * (k as f64 + 1.0) + 1e-9;
1583            assert!(
1584                (orig.0 - got.0).abs() <= tol && (orig.1 - got.1).abs() <= tol,
1585                "point {k}: {orig:?} vs {got:?} (tol {tol}): {d}"
1586            );
1587        }
1588    }
1589
1590    #[test]
1591    fn compact_encoding_round_trips_a_hole_and_a_multipolygons_parts() {
1592        // Two parts (first a plain square, second a square with a hole) —
1593        // exactly the ring-set shape `GeomSf::draw_rings` now passes
1594        // through for a MultiPolygon row (#213) or a Polygon with holes.
1595        let part1 = vec![(0.0, 0.0), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0)];
1596        let part2_outer = vec![(50.0, 0.0), (90.0, 0.0), (90.0, 40.0), (50.0, 40.0)];
1597        let part2_hole = vec![(60.0, 10.0), (80.0, 10.0), (80.0, 30.0), (60.0, 30.0)];
1598        let rings = vec![part1.clone(), part2_outer.clone(), part2_hole.clone()];
1599        let d = compact_rings_d(&rings, 1);
1600        let decoded = decode_compact_d(&d);
1601        assert_eq!(decoded.len(), 3, "{d}");
1602        for (orig, got) in [part1, part2_outer, part2_hole].iter().zip(decoded.iter()) {
1603            assert_eq!(orig.len(), got.len(), "{d}");
1604            for (o, g) in orig.iter().zip(got.iter()) {
1605                assert!((o.0 - g.0).abs() <= 0.1 && (o.1 - g.1).abs() <= 0.1, "{d}");
1606            }
1607        }
1608        assert!(d.contains("M0,0l20,0,0,20-20,0z"), "{d}");
1609    }
1610}