Skip to main content

ggplot_rs/render/
svg_backend.rs

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