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