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