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, FontFace, LineEnd, LineStyle, PointShape, PointStyle, RectStyle, TextAnchor,
9    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    root_attrs: Vec<(String, String)>,
30    warnings: Vec<String>,
31    /// Emit the root `data-plot` panel rect (off for composite pages, whose
32    /// root is not a single panel).
33    plot_attr: bool,
34}
35
36impl SvgBackend {
37    pub fn new(width: u32, height: u32, plot_area: Rect) -> Self {
38        SvgBackend {
39            plot_area,
40            total_area: Rect {
41                x: 0.0,
42                y: 0.0,
43                width: width as f64,
44                height: height as f64,
45            },
46            body: String::new(),
47            tooltip: None,
48            axis_key: None,
49            series_key: None,
50            value_key: None,
51            mark_part: None,
52            mark_attrs: Vec::new(),
53            root_attrs: Vec::new(),
54            warnings: Vec::new(),
55            plot_attr: true,
56        }
57    }
58
59    /// Add extra attributes to the root `<svg>` element (e.g. the
60    /// `data-domain` family from [`root_data_attrs`]). Names must be plain
61    /// attribute names; values are escaped on output.
62    pub fn set_root_attrs(&mut self, attrs: Vec<(String, String)>) {
63        self.root_attrs = attrs;
64    }
65
66    /// Omit the root `data-plot` attribute (composite pages).
67    pub(crate) fn without_plot_attr(&mut self) {
68        self.plot_attr = false;
69    }
70
71    /// Append pre-rendered, already-escaped SVG markup (e.g. a nested plot
72    /// fragment) to the body. Crate-internal: callers guarantee well-formedness.
73    pub(crate) fn push_raw(&mut self, markup: &str) {
74        self.body.push_str(markup);
75    }
76
77    /// The accumulated body markup (without the root element).
78    pub(crate) fn body(&self) -> &str {
79        &self.body
80    }
81
82    /// Take the warnings reported while drawing (see [`DrawBackend::warn`]).
83    pub fn take_warnings(&mut self) -> Vec<String> {
84        std::mem::take(&mut self.warnings)
85    }
86
87    /// Emit `<tag attrs/>`, or `<tag attrs data-…><title>tip</title></tag>`
88    /// when a tooltip / mark metadata is set (native SVG hover + host grouping).
89    fn push_mark(&mut self, tag: &str, attrs: &str) {
90        // Built-ins first, then user `data-*` attrs — re-validated here as
91        // defence in depth even though `geom::set_row_extras` already
92        // derives them only from validated `data_attr` mappings, because
93        // `set_mark_attrs` is a public `DrawBackend` method any caller could
94        // invoke directly. A later entry with the same name replaces an
95        // earlier one rather than duplicating it — including `mark_part`'s
96        // own `data-part`, added last, so it always wins over anything a
97        // caller supplied under that name (an element never carries the
98        // same attribute twice, which is an XML parse error).
99        let mut pairs: Vec<(String, String)> = Vec::new();
100        for (name, val) in [
101            ("data-x", &self.axis_key),
102            ("data-series", &self.series_key),
103            ("data-value", &self.value_key),
104        ] {
105            if let Some(v) = val {
106                pairs.push((name.to_string(), v.clone()));
107            }
108        }
109        for (name, val) in &self.mark_attrs {
110            if !is_valid_mark_attr_name(name) {
111                continue;
112            }
113            match pairs.iter_mut().find(|(n, _)| n == name) {
114                Some(existing) => existing.1 = val.clone(),
115                None => pairs.push((name.clone(), val.clone())),
116            }
117        }
118        // A mark part (e.g. an arrowhead) is decorative: no `<title>`, but it
119        // still carries `data-part` plus `aria-hidden` so assistive tech
120        // skips it (the parent mark already carries the semantic tooltip).
121        // Routed through the same dedup as the other attrs.
122        if let Some(part) = &self.mark_part {
123            match pairs.iter_mut().find(|(n, _)| n == "data-part") {
124                Some(existing) => existing.1 = part.clone(),
125                None => pairs.push(("data-part".to_string(), part.clone())),
126            }
127        }
128        let mut data = String::new();
129        for (name, val) in &pairs {
130            data.push_str(&format!(" {name}=\"{}\"", escape(val)));
131        }
132        if self.mark_part.is_some() {
133            data.push_str(" aria-hidden=\"true\"");
134        }
135        let mark = if self.mark_part.is_some() {
136            format!("<{tag} {attrs}{data}/>")
137        } else {
138            match &self.tooltip {
139                Some(t) => format!("<{tag} {attrs}{data}><title>{}</title></{tag}>", escape(t)),
140                None => format!("<{tag} {attrs}{data}/>"),
141            }
142        };
143        self.body.push_str(&mark);
144    }
145
146    fn root_open(&self, prefix: &str) -> String {
147        let p = &self.plot_area;
148        let (w, h) = (self.total_area.width as i64, self.total_area.height as i64);
149        let mut extra = String::new();
150        for (k, v) in &self.root_attrs {
151            extra.push_str(&format!(" {k}=\"{}\"", escape(v)));
152        }
153        let plot = if self.plot_attr {
154            format!(
155                " data-plot=\"{} {} {} {}\"",
156                num(p.x),
157                num(p.y),
158                num(p.width),
159                num(p.height),
160            )
161        } else {
162            String::new()
163        };
164        format!("<svg {prefix}width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\"{plot}{extra}>")
165    }
166
167    /// Wrap the accumulated elements in a complete `<svg>` document. The
168    /// `data-plot` attribute records the panel (data) area in viewBox units so a
169    /// host can map screen coordinates back to data (e.g. for zoom/crosshair).
170    pub fn finish(self) -> String {
171        let open = self.root_open("xmlns=\"http://www.w3.org/2000/svg\" ");
172        format!("{open}{}</svg>", self.body)
173    }
174
175    /// Wrap the accumulated elements in a *nested* `<svg>` positioned at
176    /// `(x, y)` in a parent SVG's user space — no `xmlns` (it inherits the
177    /// parent's namespace), so the result can be pasted into a larger SVG as-is.
178    /// `data-plot` stays in this fragment's own viewBox units.
179    pub fn finish_fragment(self, x: f64, y: f64) -> String {
180        let open = self.root_open(&format!("x=\"{}\" y=\"{}\" ", num(x), num(y)));
181        format!("{open}{}</svg>", self.body)
182    }
183}
184
185/// Format a coordinate with two decimals; non-finite becomes `0` (callers
186/// drop marks with non-finite geometry before this, so this is a last resort).
187fn num(v: f64) -> String {
188    if v.is_finite() {
189        format!("{v:.2}")
190    } else {
191        "0".to_string()
192    }
193}
194
195fn finite_or(v: f64, fallback: f64) -> f64 {
196    if v.is_finite() {
197        v
198    } else {
199        fallback
200    }
201}
202
203/// Root `<svg>` data attributes describing the trained position scales, for
204/// hosts that map screen coordinates back to data (zoom, crosshair, brushing):
205///
206/// - `data-xdomain="x0 x1"` / `data-ydomain="y0 y1"` — the data values at the
207///   panel's left/right (bottom/top) edges, i.e. the trained limits *after*
208///   expansion, for a continuous (numeric or date-time, epoch seconds) axis.
209///   Values are in the scale's transformed space (e.g. log10 units for
210///   `scale_x_log10`).
211/// - `data-domain="x0 x1 y0 y1"` — both of the above, emitted only when both
212///   axes are continuous.
213/// - `data-xlevels` / `data-ylevels` — a JSON array of the level labels for a
214///   discrete axis, in axis order.
215/// - `data-flip="true"` under `coord_flip` (x is then drawn vertically).
216///
217/// The `x`/`y` names refer to the x/y *aesthetics*. Omitted entirely for
218/// plots with free facet scales (each panel has its own domain).
219pub fn root_data_attrs(built: &crate::build::BuiltPlot) -> Vec<(String, String)> {
220    use crate::aes::Aesthetic;
221    let mut out = Vec::new();
222    if !built.panel_scales.is_empty() {
223        return out;
224    }
225    let axis = |aes: &Aesthetic| -> (Option<(f64, f64)>, Option<String>) {
226        match built.scales.get(aes) {
227            Some(s) if s.is_discrete() => {
228                let labels: Vec<String> = s
229                    .breaks()
230                    .into_iter()
231                    .map(|(_, l)| json_string(&l))
232                    .collect();
233                (None, Some(format!("[{}]", labels.join(","))))
234            }
235            Some(s) => (s.expanded_domain(), None),
236            None => (None, None),
237        }
238    };
239    let (xd, xl) = axis(&Aesthetic::X);
240    let (yd, yl) = axis(&Aesthetic::Y);
241    if let (Some((x0, x1)), Some((y0, y1))) = (xd, yd) {
242        out.push((
243            "data-domain".into(),
244            format!("{} {} {} {}", g(x0), g(x1), g(y0), g(y1)),
245        ));
246    }
247    if let Some((a, b)) = xd {
248        out.push(("data-xdomain".into(), format!("{} {}", g(a), g(b))));
249    }
250    if let Some((a, b)) = yd {
251        out.push(("data-ydomain".into(), format!("{} {}", g(a), g(b))));
252    }
253    if let Some(l) = xl {
254        out.push(("data-xlevels".into(), l));
255    }
256    if let Some(l) = yl {
257        out.push(("data-ylevels".into(), l));
258    }
259    if built.coord.is_flipped() {
260        out.push(("data-flip".into(), "true".into()));
261    }
262    out
263}
264
265/// Shortest round-trip formatting of a finite number (domains are filtered to
266/// finite values before this is called).
267fn g(v: f64) -> String {
268    format!("{v}")
269}
270
271/// Minimal JSON string literal (quotes, backslashes and control characters
272/// escaped); the result is attribute-escaped again on output.
273fn json_string(s: &str) -> String {
274    let mut out = String::with_capacity(s.len() + 2);
275    out.push('"');
276    for c in s.chars() {
277        match c {
278            '"' => out.push_str("\\\""),
279            '\\' => out.push_str("\\\\"),
280            '\n' => out.push_str("\\n"),
281            '\r' => out.push_str("\\r"),
282            '\t' => out.push_str("\\t"),
283            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
284            c => out.push(c),
285        }
286    }
287    out.push('"');
288    out
289}
290
291/// Defence-in-depth gate for `push_mark`'s `mark_attrs`: `^data-[a-z0-9][a-z0-9-]*$`.
292/// `geom::set_row_extras` only ever supplies already-validated names, but
293/// `set_mark_attrs` is a public `DrawBackend` method any caller (a custom
294/// `Geom` impl, or misuse from outside the crate) could invoke directly with
295/// an arbitrary string, so this is checked again right before it reaches
296/// the wire — never trust a single validation layer for attribute-name
297/// injection.
298fn is_valid_mark_attr_name(name: &str) -> bool {
299    match name.strip_prefix("data-") {
300        Some(suffix) => crate::aes::is_valid_data_attr_name(suffix),
301        None => false,
302    }
303}
304
305fn rgb((r, g, b): (u8, u8, u8)) -> String {
306    format!("#{r:02X}{g:02X}{b:02X}")
307}
308
309/// Escape text for use in SVG element content *and* quoted attribute values.
310/// Quotes must be escaped too: data values land in `data-x="…"`, and the SVG is
311/// routinely inlined into HTML, so an unescaped `"` would let data inject
312/// attributes (e.g. event handlers). Characters that are not legal in XML 1.0
313/// (C0 controls other than tab/LF/CR) are dropped so the document stays
314/// well-formed.
315fn escape(s: &str) -> String {
316    let mut out = String::with_capacity(s.len());
317    for c in s.chars() {
318        match c {
319            '&' => out.push_str("&amp;"),
320            '<' => out.push_str("&lt;"),
321            '>' => out.push_str("&gt;"),
322            '"' => out.push_str("&quot;"),
323            '\'' => out.push_str("&#39;"),
324            '\t' | '\n' | '\r' => out.push(c),
325            c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {}
326            c => out.push(c),
327        }
328    }
329    out
330}
331
332fn points(pts: &[(f64, f64)]) -> String {
333    pts.iter()
334        .map(|(x, y)| format!("{x:.2},{y:.2}"))
335        .collect::<Vec<_>>()
336        .join(" ")
337}
338
339fn finite_pts(pts: &[(f64, f64)]) -> Vec<(f64, f64)> {
340    pts.iter()
341        .copied()
342        .filter(|(x, y)| x.is_finite() && y.is_finite())
343        .collect()
344}
345
346impl DrawBackend for SvgBackend {
347    fn plot_area(&self) -> Rect {
348        self.plot_area.clone()
349    }
350    fn total_area(&self) -> Rect {
351        self.total_area.clone()
352    }
353
354    fn set_tooltip(&mut self, tooltip: Option<String>) {
355        self.tooltip = tooltip;
356    }
357
358    fn set_mark_axis(&mut self, key: Option<String>) {
359        self.axis_key = key;
360    }
361
362    fn set_mark_series(&mut self, series: Option<String>) {
363        self.series_key = series;
364    }
365
366    fn set_mark_value(&mut self, value: Option<String>) {
367        self.value_key = value;
368    }
369
370    fn warn(&mut self, message: String) {
371        self.warnings.push(message);
372    }
373
374    fn set_mark_part(&mut self, part: Option<&str>) {
375        self.mark_part = part.map(|s| s.to_string());
376    }
377
378    fn set_mark_attrs(&mut self, attrs: Vec<(String, String)>) {
379        self.mark_attrs = attrs;
380    }
381
382    fn draw_circle(
383        &mut self,
384        (cx, cy): (f64, f64),
385        radius: f64,
386        style: &PointStyle,
387    ) -> Result<(), RenderError> {
388        if !(cx.is_finite() && cy.is_finite() && radius.is_finite()) {
389            return Ok(());
390        }
391        let attrs = format!(
392            "cx=\"{cx:.2}\" cy=\"{cy:.2}\" r=\"{:.2}\" fill=\"{}\" fill-opacity=\"{:.3}\"",
393            radius.max(0.0),
394            rgb(style.color),
395            finite_or(style.alpha, 1.0)
396        );
397        self.push_mark("circle", &attrs);
398        Ok(())
399    }
400
401    /// Native shapes: filled polygons for square/triangle/diamond, a single
402    /// stroked `<path>` for the `+` / `×` glyphs (so each point stays one
403    /// hoverable mark carrying its `data-*` attributes).
404    fn draw_shape(
405        &mut self,
406        (cx, cy): (f64, f64),
407        radius: f64,
408        style: &PointStyle,
409    ) -> Result<(), RenderError> {
410        if !(cx.is_finite() && cy.is_finite() && radius.is_finite()) {
411            return Ok(());
412        }
413        let r = radius.max(0.0);
414        let color = rgb(style.color);
415        let alpha = finite_or(style.alpha, 1.0);
416        let poly = |pts: &[(f64, f64)]| {
417            if style.filled {
418                format!(
419                    "points=\"{}\" fill=\"{color}\" fill-opacity=\"{alpha:.3}\"",
420                    points(pts)
421                )
422            } else {
423                format!(
424                    "points=\"{}\" fill=\"none\" stroke=\"{color}\" stroke-opacity=\"{alpha:.3}\"",
425                    points(pts)
426                )
427            }
428        };
429        match style.shape {
430            PointShape::Circle => return self.draw_circle((cx, cy), radius, style),
431            PointShape::Square => {
432                let a = poly(&[
433                    (cx - r, cy - r),
434                    (cx + r, cy - r),
435                    (cx + r, cy + r),
436                    (cx - r, cy + r),
437                ]);
438                self.push_mark("polygon", &a);
439            }
440            PointShape::Triangle => {
441                let a = poly(&[(cx, cy - r), (cx + r, cy + r), (cx - r, cy + r)]);
442                self.push_mark("polygon", &a);
443            }
444            PointShape::Diamond => {
445                let a = poly(&[(cx, cy - r), (cx + r, cy), (cx, cy + r), (cx - r, cy)]);
446                self.push_mark("polygon", &a);
447            }
448            PointShape::Plus | PointShape::Cross => {
449                let d = if style.shape == PointShape::Plus {
450                    format!(
451                        "M{:.2} {cy:.2}H{:.2}M{cx:.2} {:.2}V{:.2}",
452                        cx - r,
453                        cx + r,
454                        cy - r,
455                        cy + r
456                    )
457                } else {
458                    format!(
459                        "M{:.2} {:.2}L{:.2} {:.2}M{:.2} {:.2}L{:.2} {:.2}",
460                        cx - r,
461                        cy - r,
462                        cx + r,
463                        cy + r,
464                        cx - r,
465                        cy + r,
466                        cx + r,
467                        cy - r
468                    )
469                };
470                let a = format!(
471                    "d=\"{d}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"{:.2}\" \
472                     stroke-opacity=\"{alpha:.3}\"",
473                    (r / 2.5).clamp(1.0, 3.0)
474                );
475                self.push_mark("path", &a);
476            }
477        }
478        Ok(())
479    }
480
481    fn draw_line(&mut self, pts: &[(f64, f64)], style: &LineStyle) -> Result<(), RenderError> {
482        let pts = finite_pts(pts);
483        if pts.len() < 2 {
484            return Ok(());
485        }
486        let dash = match style
487            .linetype
488            .pattern()
489            .iter()
490            .flat_map(|(d, g)| [*d, *g])
491            .map(|v| format!("{v}"))
492            .collect::<Vec<_>>()
493            .join(",")
494        {
495            s if s.is_empty() => String::new(),
496            s => format!(" stroke-dasharray=\"{s}\""),
497        };
498        // `Butt` is SVG's own default: omitting the attribute keeps default
499        // output byte-identical.
500        let linecap = match style.lineend {
501            LineEnd::Butt => "",
502            LineEnd::Round => " stroke-linecap=\"round\"",
503            LineEnd::Square => " stroke-linecap=\"square\"",
504        };
505        let attrs = format!(
506            "points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{:.2}\" stroke-opacity=\"{:.3}\"{}{}",
507            points(&pts),
508            rgb(style.color),
509            finite_or(style.width, 1.0),
510            finite_or(style.alpha, 1.0),
511            dash,
512            linecap
513        );
514        self.push_mark("polyline", &attrs);
515        Ok(())
516    }
517
518    /// Emit a native `<path d="M.. Q..">` quadratic Bézier (one real curve
519    /// instead of a flattened polyline). Non-finite input draws nothing.
520    fn draw_quadratic(
521        &mut self,
522        p0: (f64, f64),
523        ctrl: (f64, f64),
524        p1: (f64, f64),
525        style: &LineStyle,
526    ) -> Result<(), RenderError> {
527        if !(p0.0.is_finite()
528            && p0.1.is_finite()
529            && ctrl.0.is_finite()
530            && ctrl.1.is_finite()
531            && p1.0.is_finite()
532            && p1.1.is_finite())
533        {
534            return Ok(());
535        }
536        let dash = match style
537            .linetype
538            .pattern()
539            .iter()
540            .flat_map(|(d, g)| [*d, *g])
541            .map(|v| format!("{v}"))
542            .collect::<Vec<_>>()
543            .join(",")
544        {
545            s if s.is_empty() => String::new(),
546            s => format!(" stroke-dasharray=\"{s}\""),
547        };
548        let linecap = match style.lineend {
549            LineEnd::Butt => "",
550            LineEnd::Round => " stroke-linecap=\"round\"",
551            LineEnd::Square => " stroke-linecap=\"square\"",
552        };
553        let d = format!(
554            "M{:.2} {:.2} Q{:.2} {:.2} {:.2} {:.2}",
555            p0.0, p0.1, ctrl.0, ctrl.1, p1.0, p1.1
556        );
557        let attrs = format!(
558            "d=\"{d}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{:.2}\" stroke-opacity=\"{:.3}\"{}{}",
559            rgb(style.color),
560            finite_or(style.width, 1.0),
561            finite_or(style.alpha, 1.0),
562            dash,
563            linecap
564        );
565        self.push_mark("path", &attrs);
566        Ok(())
567    }
568
569    fn draw_rect(
570        &mut self,
571        (x0, y0): (f64, f64),
572        (x1, y1): (f64, f64),
573        style: &RectStyle,
574    ) -> Result<(), RenderError> {
575        if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) {
576            return Ok(());
577        }
578        let (x, y) = (x0.min(x1), y0.min(y1));
579        let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs());
580        let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
581        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
582        let attrs = format!(
583            "x=\"{x:.2}\" y=\"{y:.2}\" width=\"{w:.2}\" height=\"{h:.2}\" fill=\"{fill}\" \
584             fill-opacity=\"{:.3}\" stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
585            finite_or(style.alpha, 1.0),
586            finite_or(style.stroke_width, 0.0)
587        );
588        self.push_mark("rect", &attrs);
589        Ok(())
590    }
591
592    fn draw_polygon(&mut self, pts: &[(f64, f64)], style: &RectStyle) -> Result<(), RenderError> {
593        let pts = finite_pts(pts);
594        if pts.len() < 3 {
595            return Ok(());
596        }
597        let fill = style.fill.map(rgb).unwrap_or_else(|| "none".into());
598        let stroke = style.stroke.map(rgb).unwrap_or_else(|| "none".into());
599        let attrs = format!(
600            "points=\"{}\" fill=\"{fill}\" fill-opacity=\"{:.3}\" stroke=\"{stroke}\" stroke-width=\"{:.2}\"",
601            points(&pts),
602            finite_or(style.alpha, 1.0),
603            finite_or(style.stroke_width, 0.0)
604        );
605        self.push_mark("polygon", &attrs);
606        Ok(())
607    }
608
609    fn draw_text(
610        &mut self,
611        text: &str,
612        (x, y): (f64, f64),
613        style: &TextStyle,
614    ) -> Result<(), RenderError> {
615        if !(x.is_finite() && y.is_finite()) {
616            return Ok(());
617        }
618        let anchor = match style.anchor {
619            TextAnchor::Start => "start",
620            TextAnchor::Middle => "middle",
621            TextAnchor::End => "end",
622        };
623        let family = escape(style.family.as_deref().unwrap_or("sans-serif"));
624        let weight = if style.face == FontFace::Bold {
625            " font-weight=\"bold\""
626        } else {
627            ""
628        };
629        let fstyle = if style.face == FontFace::Italic {
630            " font-style=\"italic\""
631        } else {
632            ""
633        };
634        let angle = finite_or(style.angle, 0.0);
635        let transform = if angle.abs() > 0.01 {
636            format!(" transform=\"rotate({angle:.1} {x:.2} {y:.2})\"")
637        } else {
638            String::new()
639        };
640        self.body.push_str(&format!(
641            "<text x=\"{x:.2}\" y=\"{y:.2}\" font-size=\"{:.2}\" text-anchor=\"{anchor}\" \
642             dominant-baseline=\"middle\" font-family=\"{family}\"{weight}{fstyle} fill=\"{}\"{transform}>{}</text>",
643            finite_or(style.size, 12.0),
644            rgb(style.color),
645            escape(text)
646        ));
647        Ok(())
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use crate::render::Rect;
655
656    fn backend() -> SvgBackend {
657        SvgBackend::new(
658            100,
659            100,
660            Rect {
661                x: 0.0,
662                y: 0.0,
663                width: 100.0,
664                height: 100.0,
665            },
666        )
667    }
668
669    #[test]
670    fn draw_quadratic_emits_a_path_with_m_and_q() {
671        let mut b = backend();
672        b.draw_quadratic((0.0, 0.0), (10.0, 20.0), (20.0, 0.0), &LineStyle::default())
673            .unwrap();
674        let svg = b.finish();
675        assert!(svg.contains("<path"));
676        assert!(svg.contains("d=\"M0.00 0.00 Q10.00 20.00 20.00 0.00\""));
677        assert!(!svg.contains("NaN"));
678        assert!(!svg.contains("inf"));
679    }
680
681    #[test]
682    fn draw_quadratic_drops_non_finite_input() {
683        let mut b = backend();
684        b.draw_quadratic(
685            (f64::NAN, 0.0),
686            (10.0, 20.0),
687            (20.0, 0.0),
688            &LineStyle::default(),
689        )
690        .unwrap();
691        b.draw_quadratic(
692            (0.0, 0.0),
693            (f64::INFINITY, 20.0),
694            (20.0, 0.0),
695            &LineStyle::default(),
696        )
697        .unwrap();
698        let svg = b.finish();
699        assert!(!svg.contains("<path"));
700    }
701
702    #[test]
703    fn draw_line_omits_linecap_by_default_but_writes_it_when_set() {
704        let mut b = backend();
705        b.draw_line(&[(0.0, 0.0), (10.0, 10.0)], &LineStyle::default())
706            .unwrap();
707        let svg = b.finish();
708        assert!(!svg.contains("stroke-linecap"));
709
710        let mut b2 = backend();
711        let style = LineStyle {
712            lineend: LineEnd::Round,
713            ..Default::default()
714        };
715        b2.draw_line(&[(0.0, 0.0), (10.0, 10.0)], &style).unwrap();
716        let svg2 = b2.finish();
717        assert!(svg2.contains("stroke-linecap=\"round\""));
718    }
719
720    #[test]
721    fn square_linecap_is_written_on_both_draw_line_and_draw_quadratic() {
722        let style = LineStyle {
723            lineend: LineEnd::Square,
724            ..Default::default()
725        };
726        let mut b = backend();
727        b.draw_line(&[(0.0, 0.0), (10.0, 10.0)], &style).unwrap();
728        assert!(b.finish().contains("stroke-linecap=\"square\""));
729
730        let mut b2 = backend();
731        b2.draw_quadratic((0.0, 0.0), (5.0, 10.0), (10.0, 0.0), &style)
732            .unwrap();
733        assert!(b2.finish().contains("stroke-linecap=\"square\""));
734    }
735
736    /// `push_mark`'s defence-in-depth gate: even if an invalid name reaches
737    /// `set_mark_attrs` directly (bypassing `Aes::data_attr`'s own
738    /// validation — `set_mark_attrs` is a public `DrawBackend` method any
739    /// caller could invoke), it's dropped before it reaches the wire.
740    #[test]
741    fn set_mark_attrs_defence_in_depth_drops_invalid_names() {
742        let mut b = backend();
743        b.set_mark_attrs(vec![
744            ("not-data-prefixed".to_string(), "x".to_string()),
745            ("data-".to_string(), "x".to_string()), // empty suffix
746            ("data-Upper".to_string(), "x".to_string()), // uppercase
747            ("data-ok".to_string(), "valid".to_string()), // the one legitimate entry
748        ]);
749        b.draw_circle((1.0, 1.0), 1.0, &PointStyle::default())
750            .unwrap();
751        let svg = b.finish();
752        assert!(!svg.contains("not-data-prefixed"));
753        assert!(!svg.contains("data-Upper"));
754        assert!(!svg.contains("data-=\""));
755        assert!(svg.contains("data-ok=\"valid\""));
756    }
757}