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