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