Skip to main content

orinium_browser/engine/
svg.rs

1//! SVG support shared by the inline `<svg>` painter and the standalone
2//! rasterizer.
3//!
4//! # Inline SVG
5//!
6//! The layout builder calls [`collect_svg`] while building the node for an
7//! `<svg>` element. The renderer then paints the collected shapes into the
8//! element's content box via [`emit_commands`], mapping the `viewBox`
9//! rectangle onto the box with [`viewbox_meet_transform`] (`xMidYMid meet`, the
10//! SVG default).
11//!
12//! # Standalone SVG documents
13//!
14//! Audio icons and `<img src="*.svg">` are rasterized to RGBA pixels by
15//! [`rasterize_from_bytes`], which replaces the removed `resvg` dependency.
16//!
17//! Coverage stays focused on solid-color fills: `<path d>` (including arc
18//! segments, expanded into cubic Béziers), `<rect>`, `<circle>`, `<ellipse>`,
19//! container `<g>`/`<a>`/`<symbol>` with composed `transform`/`opacity`
20//! attributes, `fill-rule`, and `opacity`/`fill-opacity` (approximated as a
21//! per-shape alpha multiplier). `<use>`/`<clipPath>`/gradients, strokes, and
22//! text are out of scope.
23
24mod rasterize;
25
26pub use rasterize::{RasterResult, rasterize_from_bytes};
27
28use std::collections::HashMap;
29
30use crate::engine::css::values::CssValue;
31use crate::engine::layouter::css_resolver::resolve_inline_value;
32use crate::engine::layouter::dom_snapshot::{DomSnapshot, NodeId};
33use crate::engine::layouter::resolve_css_color;
34use crate::engine::layouter::types::{
35    Color, ColorScheme, ColorStop, Gradient, GradientKind, GradientSpread,
36};
37use crate::engine::renderer_model::Path;
38use crate::engine::renderer_model::{
39    AffineTransform, Brush, DrawCommand, FillRule, Paint, StrokeCap, StrokeJoin, ellipse_path,
40    line_path, polygon_path, polyline_path, rect_path, rounded_rect_path, stroke_path,
41    transform_path,
42};
43
44/// An `SVG viewBox` rectangle: `(min-x, min-y, width, height)`.
45pub type ViewBox = (f32, f32, f32, f32);
46
47/// A single shape inside an inline SVG: a viewBox-space path, its paint
48/// (solid color or gradient), and the fill rule (default nonzero). `stroke`
49/// is `None` when the shape has no stroke. `clip_path` optionally references a
50/// clip path that masks this shape.
51#[derive(Debug, Clone)]
52pub struct SvgShape {
53    pub path: Path,
54    pub fill: Brush,
55    pub rule: FillRule,
56    pub stroke: Option<SvgStroke>,
57    pub clip_path: Option<ClipPath>,
58}
59
60/// A clipping path: a viewBox-space path plus its fill rule, used to mask
61/// shapes that reference it via `clip-path="url(#id)"`.
62#[derive(Debug, Clone)]
63pub struct ClipPath {
64    pub path: Path,
65    pub rule: FillRule,
66}
67
68/// A resolved SVG stroke: paint color plus the geometry parameters that
69/// [`stroke_path`] expands the outline with. When `gradient` is set it paints
70/// the outline instead of `color`.
71#[derive(Debug, Clone, PartialEq)]
72pub struct SvgStroke {
73    pub color: Color,
74    pub gradient: Option<Gradient>,
75    pub width: f32,
76    pub cap: StrokeCap,
77    pub join: StrokeJoin,
78    pub miter_limit: f32,
79    pub dash: Option<Vec<f32>>,
80    pub dash_offset: f32,
81}
82
83impl SvgStroke {
84    /// The SVG defaults for an inherited stroke (color is provisional until a
85    /// paint value resolves).
86    pub(crate) fn inherited_from(inherited: Option<&SvgStroke>) -> SvgStroke {
87        inherited.cloned().unwrap_or(SvgStroke {
88            color: Color(0, 0, 0, 255),
89            gradient: None,
90            width: 1.0,
91            cap: StrokeCap::Butt,
92            join: StrokeJoin::Miter,
93            miter_limit: 4.0,
94            dash: None,
95            dash_offset: 0.0,
96        })
97    }
98
99    /// The geometry parameters as [`Stroke`] for [`stroke_path`].
100    pub(crate) fn to_stroke(&self) -> crate::engine::renderer_model::Stroke {
101        crate::engine::renderer_model::Stroke {
102            width: self.width,
103            cap: self.cap,
104            join: self.join,
105            miter_limit: self.miter_limit,
106            dash: self.dash.clone(),
107            dash_offset: self.dash_offset,
108        }
109    }
110}
111
112/// The vector content of an [`crate::engine::layouter::types::NodeKind::Svg`]
113/// layout node: the element's `viewBox` and its subpaths in document order.
114#[derive(Debug, Clone)]
115pub struct SvgContent {
116    pub view_box: ViewBox,
117    pub shapes: Vec<SvgShape>,
118}
119
120/// Translates an SVG path `d` string into a [`Path`].
121///
122/// Supports the moveto/lineto/curveto/closepath commands in both absolute and
123/// relative forms: `M/m`, `L/l`, `H/h`, `V/v`, `C/c`, `S/s`, `Q/q`, `T/t`,
124/// `Z/z`. Arc segments (`A/a`) are approximated with cubic Bézier segments via
125/// the SVG endpoint→center parametrization (radii are scaled up when they are
126/// too small for the chord, and arcs larger than a quarter turn are split).
127/// Repeated coordinate sets after a command repeat that command (per the SVG
128/// grammar `M m x y ...` implicit lineto included). Malformed data is skipped
129/// defensively.
130pub fn path_from_d(d: &str) -> Path {
131    let mut path = Path::new();
132    let mut cur: Option<(f32, f32)> = None;
133    let mut start: Option<(f32, f32)> = None;
134    // Reflection anchor of the previous curve command (C/S/Q/T), used by the
135    // smooth variants. `None` when the previous command was not a curve.
136    let mut prev_control: Option<(f32, f32)> = None;
137    let mut prev_was_curve = false;
138
139    let mut nums = SvgNum::new(d);
140
141    while let Some(cmd) = nums.next_command() {
142        match cmd {
143            'M' | 'm' => {
144                let rel = cmd == 'm';
145                if let Some((x, y)) = nums.next_point(rel, cur) {
146                    path.move_to(x, y);
147                    cur = Some((x, y));
148                    start = Some((x, y));
149                    // Extra coordinate pairs are implicit relative lineto.
150                    while let Some((x, y)) = nums.next_point(rel, cur) {
151                        path.line_to(x, y);
152                        cur = Some((x, y));
153                    }
154                    prev_was_curve = false;
155                }
156            }
157            'L' | 'l' => {
158                let rel = cmd == 'l';
159                while let Some((x, y)) = nums.next_point(rel, cur) {
160                    path.line_to(x, y);
161                    cur = Some((x, y));
162                }
163                prev_was_curve = false;
164            }
165            'H' | 'h' => {
166                let rel = cmd == 'h';
167                while let Some(x) = nums.next_number() {
168                    let nx = if rel {
169                        cur.unwrap_or((0.0, 0.0)).0 + x
170                    } else {
171                        x
172                    };
173                    path.line_to(nx, cur.map_or(0.0, |p| p.1));
174                    cur = Some((nx, cur.map_or(0.0, |p| p.1)));
175                }
176                prev_was_curve = false;
177            }
178            'V' | 'v' => {
179                let rel = cmd == 'v';
180                while let Some(y) = nums.next_number() {
181                    let ny = if rel {
182                        cur.unwrap_or((0.0, 0.0)).1 + y
183                    } else {
184                        y
185                    };
186                    path.line_to(cur.map_or(0.0, |p| p.0), ny);
187                    cur = Some((cur.map_or(0.0, |p| p.0), ny));
188                }
189                prev_was_curve = false;
190            }
191            'C' | 'c' => {
192                let rel = cmd == 'c';
193                let rel_ref = cur.unwrap_or((0.0, 0.0));
194                while let (Some(c1), Some(c2), Some(p)) = (
195                    nums.next_point(rel, Some(rel_ref)),
196                    nums.next_point(rel, Some(rel_ref)),
197                    nums.next_point(rel, cur),
198                ) {
199                    path.cubic_to(c1, c2, p);
200                    cur = Some(p);
201                    prev_control = Some(c2);
202                    prev_was_curve = true;
203                }
204            }
205            'S' | 's' => {
206                let rel = cmd == 's';
207                let rel_ref = cur.unwrap_or((0.0, 0.0));
208                while let (Some(c2), Some(p)) = (
209                    nums.next_point(rel, Some(rel_ref)),
210                    nums.next_point(rel, cur),
211                ) {
212                    let c1 = if prev_was_curve {
213                        let c2_ref = prev_control.unwrap_or(rel_ref);
214                        (2.0 * rel_ref.0 - c2_ref.0, 2.0 * rel_ref.1 - c2_ref.1)
215                    } else {
216                        rel_ref
217                    };
218                    path.cubic_to(c1, c2, p);
219                    cur = Some(p);
220                    prev_control = Some(c2);
221                    prev_was_curve = true;
222                }
223            }
224            'Q' | 'q' => {
225                let rel = cmd == 'q';
226                let rel_ref = cur.unwrap_or((0.0, 0.0));
227                while let (Some(c), Some(p)) = (
228                    nums.next_point(rel, Some(rel_ref)),
229                    nums.next_point(rel, cur),
230                ) {
231                    path.quad_to(c, p);
232                    cur = Some(p);
233                    prev_control = Some(c);
234                    prev_was_curve = true;
235                }
236            }
237            'T' | 't' => {
238                let rel = cmd == 't';
239                let rel_ref = cur.unwrap_or((0.0, 0.0));
240                while let Some(p) = nums.next_point(rel, cur) {
241                    let c = if prev_was_curve {
242                        let c_ref = prev_control.unwrap_or(rel_ref);
243                        (2.0 * rel_ref.0 - c_ref.0, 2.0 * rel_ref.1 - c_ref.1)
244                    } else {
245                        rel_ref
246                    };
247                    path.quad_to(c, p);
248                    cur = Some(p);
249                    prev_control = Some(c);
250                    prev_was_curve = true;
251                }
252            }
253            'A' | 'a' => {
254                let rel = cmd == 'a';
255                while let (Some(rx), Some(ry), Some(rot), Some(large), Some(sweep), Some(p)) = (
256                    nums.next_number(),
257                    nums.next_number(),
258                    nums.next_number(),
259                    nums.next_number(),
260                    nums.next_number(),
261                    nums.next_point(rel, cur),
262                ) {
263                    match cur {
264                        Some(start) => append_arc(
265                            &mut path,
266                            start,
267                            rx.abs(),
268                            ry.abs(),
269                            rot,
270                            large != 0.0,
271                            sweep != 0.0,
272                            p,
273                        ),
274                        // An arc with no current point is a fresh subpath.
275                        None => path.move_to(p.0, p.1),
276                    }
277                    cur = Some(p);
278                }
279                prev_was_curve = false;
280            }
281            'Z' | 'z' => {
282                path.close();
283                cur = start;
284                prev_was_curve = false;
285            }
286            _ => break,
287        }
288    }
289
290    path
291}
292
293/// Approximates an SVG elliptical arc from `from` to `to` with cubic Bézier
294/// segments using the SVG 1.1 endpoint→center parametrization (F.6).
295///
296/// Radii are scaled up when they are too small for the chord, and the sweep is
297/// split so no single cubic spans more than a quarter turn, keeping the
298/// 4/3·tan(Δ/4) control-point approximation accurate.
299#[allow(clippy::too_many_arguments)]
300fn append_arc(
301    path: &mut Path,
302    from: (f32, f32),
303    rx: f32,
304    ry: f32,
305    phi_deg: f32,
306    large_arc: bool,
307    sweep: bool,
308    to: (f32, f32),
309) {
310    let (x1, y1) = from;
311    let (x2, y2) = to;
312    if (rx == 0.0 || ry == 0.0)
313        || ((x1 - x2).abs() < f32::EPSILON && (y1 - y2).abs() < f32::EPSILON)
314    {
315        path.line_to(x2, y2);
316        return;
317    }
318    let phi = phi_deg.to_radians();
319    let (cos_phi, sin_phi) = (phi.cos(), phi.sin());
320
321    // Endpoints in the rotated (axis-aligned ellipse) frame F.6.5.1.
322    let (dx2, dy2) = ((x1 - x2) * 0.5, (y1 - y2) * 0.5);
323    let x1p = cos_phi * dx2 + sin_phi * dy2;
324    let y1p = -sin_phi * dx2 + cos_phi * dy2;
325
326    // Scale the radii up when they can't span the chord F.6.6.
327    let (mut rx, mut ry) = (rx, ry);
328    let lambda = (x1p / rx).powi(2) + (y1p / ry).powi(2);
329    if lambda > 1.0 {
330        let scale = lambda.sqrt();
331        rx *= scale;
332        ry *= scale;
333    }
334
335    // Center in the rotated frame F.6.5.2.
336    let rx2 = rx * rx;
337    let ry2 = ry * ry;
338    let numerator = rx2 * ry2 - rx2 * y1p * y1p - ry2 * x1p * x1p;
339    let denominator = rx2 * y1p * y1p + ry2 * x1p * x1p;
340    let coef = if denominator <= 0.0 {
341        0.0
342    } else {
343        (numerator / denominator).max(0.0).sqrt()
344    };
345    let sign = if large_arc == sweep { -1.0 } else { 1.0 };
346    let (cxp, cyp) = (
347        sign * coef * (rx * y1p / ry),
348        sign * coef * (-ry * x1p / rx),
349    );
350
351    // World-space center F.6.5.3 and parameter angles F.6.5.5-6. The endpoint
352    // is at ellipse-frame angle θ where (x1p, y1p) = (cxp, cyp) + (rx cosθ, ry
353    // sinθ), so the angles are those of the radii-scaled vectors.
354    let cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) * 0.5;
355    let cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) * 0.5;
356    let theta1 = ((y1p - cyp) / ry).atan2((x1p - cxp) / rx);
357    let mut delta = ((-y1p - cyp) / ry).atan2((-x1p - cxp) / rx) - theta1;
358    if sweep && delta < 0.0 {
359        delta += std::f32::consts::TAU;
360    } else if !sweep && delta > 0.0 {
361        delta -= std::f32::consts::TAU;
362    }
363
364    // Split the sweep into ≤ quarter-turn cubics.
365    let segments = (delta.abs() / std::f32::consts::FRAC_PI_2).ceil().max(1.0) as usize;
366    for i in 0..segments {
367        let a = theta1 + delta * (i as f32 / segments as f32);
368        let b = theta1 + delta * ((i + 1) as f32 / segments as f32);
369        let k = (4.0 / 3.0) * ((b - a) * 0.25).tan();
370        let (sin_a, cos_a) = (a.sin(), a.cos());
371        let (sin_b, cos_b) = (b.sin(), b.cos());
372        let world = |(px, py): (f32, f32)| {
373            (
374                cos_phi * px - sin_phi * py + cx,
375                sin_phi * px + cos_phi * py + cy,
376            )
377        };
378        // The last segment ends exactly at `to` to avoid a gap from floating
379        // point drift in the trigonometric end-point computation.
380        let (p3x, p3y) = if i + 1 == segments { to } else { world((rx * cos_b, ry * sin_b)) };
381        let (p1x, p1y) = world((rx * (cos_a - k * sin_a), ry * (sin_a + k * cos_a)));
382        let (p2x, p2y) = world((rx * (cos_b + k * sin_b), ry * (sin_b - k * cos_b)));
383        path.cubic_to((p1x, p1y), (p2x, p2y), (p3x, p3y));
384    }
385}
386
387/// Parses an SVG `viewBox` attribute into `(min-x, min-y, width, height)`,
388/// defaulting missing components to zero.
389pub fn parse_view_box(value: &str) -> ViewBox {
390    let mut nums = value
391        .split(|c: char| c.is_ascii_whitespace() || c == ',')
392        .filter_map(|token| token.parse::<f32>().ok());
393    (
394        nums.next().unwrap_or(0.0),
395        nums.next().unwrap_or(0.0),
396        nums.next().unwrap_or(0.0),
397        nums.next().unwrap_or(0.0),
398    )
399}
400
401/// Parses a `points` attribute into a vertex list, tolerating commas,
402/// whitespace, and sign-separated coordinates.
403pub(crate) fn parse_points(value: &str) -> Vec<(f32, f32)> {
404    let mut nums = SvgNum::new(value);
405    let mut points = Vec::new();
406    while let (Some(x), Some(y)) = (nums.next_number(), nums.next_number()) {
407        points.push((x, y));
408    }
409    points
410}
411
412/// Parses an SVG length, tolerating trailing unit suffixes (`px`, `%`, …) and
413/// scientific-notation exponents (`1.5e2`).
414pub(crate) fn parse_len(value: &str) -> Option<f32> {
415    let value = value.trim().trim_start_matches('+');
416    let bytes = value.as_bytes();
417    let mut i = 0;
418    if i < bytes.len() && bytes[i] == b'-' {
419        i += 1;
420    }
421    let digits_start = i;
422    while i < bytes.len() && bytes[i].is_ascii_digit() {
423        i += 1;
424    }
425    if i < bytes.len() && bytes[i] == b'.' {
426        i += 1;
427        while i < bytes.len() && bytes[i].is_ascii_digit() {
428            i += 1;
429        }
430    }
431    if i == digits_start {
432        return None;
433    }
434    if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
435        let mut j = i + 1;
436        if j < bytes.len() && (bytes[j] == b'+' || bytes[j] == b'-') {
437            j += 1;
438        }
439        if j < bytes.len() && bytes[j].is_ascii_digit() {
440            while j < bytes.len() && bytes[j].is_ascii_digit() {
441                j += 1;
442            }
443            i = j;
444        }
445    }
446    value[..i].parse::<f32>().ok()
447}
448
449/// Parses an SVG `transform` attribute into a single affine transform.
450///
451/// Supported functions: `matrix`, `translate`, `scale`, `rotate`, `skewX`,
452/// `skewY`. Multiple functions compose left-to-right per the SVG spec.
453pub(crate) fn parse_transform(value: &str) -> Result<AffineTransform, String> {
454    let mut out = AffineTransform::identity();
455    let mut rest = value;
456    loop {
457        let trimmed = rest.trim_start();
458        if trimmed.is_empty() {
459            return Ok(out);
460        }
461        let open = trimmed
462            .find('(')
463            .ok_or_else(|| format!("transform: missing '(' in {value:?}"))?;
464        let function = trimmed[..open].trim();
465        let inner = &trimmed[open + 1..];
466        let close = inner
467            .find(')')
468            .ok_or_else(|| format!("transform: missing ')' after {function:?} in {value:?}"))?;
469        let args = &inner[..close];
470        let next = parse_function(function, args).ok_or_else(|| {
471            format!("transform: unsupported or malformed {function:?} in {value:?}")
472        })?;
473        out = out.then(&next);
474        rest = &inner[close + 1..];
475    }
476}
477
478fn parse_function(name: &str, args: &str) -> Option<AffineTransform> {
479    let nums = args
480        .split(|c: char| c == ',' || c.is_whitespace())
481        .filter(|token| !token.trim().is_empty())
482        .filter_map(|token| token.trim().parse::<f32>().ok())
483        .collect::<Vec<f32>>();
484    match name {
485        "matrix" if nums.len() >= 6 => Some(AffineTransform {
486            m11: nums[0],
487            m12: nums[2],
488            m21: nums[1],
489            m22: nums[3],
490            dx: nums[4],
491            dy: nums[5],
492        }),
493        "translate" if !nums.is_empty() => Some(AffineTransform::translate(
494            nums[0],
495            nums.get(1).copied().unwrap_or(0.0),
496        )),
497        "scale" if !nums.is_empty() => Some(AffineTransform::scale(
498            nums[0],
499            nums.get(1).copied().unwrap_or(nums[0]),
500        )),
501        "rotate" if !nums.is_empty() => {
502            let angle = nums[0].to_radians();
503            if nums.len() >= 3 {
504                let (cx, cy) = (nums[1], nums[2]);
505                Some(
506                    AffineTransform::identity()
507                        .then(&AffineTransform::translate(-cx, -cy))
508                        .then(&AffineTransform::rotate(angle))
509                        .then(&AffineTransform::translate(cx, cy)),
510                )
511            } else {
512                Some(AffineTransform::rotate(angle))
513            }
514        }
515        "skewX" if nums.len() == 1 => Some(AffineTransform {
516            m12: nums[0].to_radians().tan(),
517            ..AffineTransform::identity()
518        }),
519        "skewY" if nums.len() == 1 => Some(AffineTransform {
520            m21: nums[0].to_radians().tan(),
521            ..AffineTransform::identity()
522        }),
523        _ => None,
524    }
525}
526
527/// Resolves an SVG presentation value such as `fill="#006AFF"` to a color,
528/// without round-tripping through a full CSS declaration block.
529///
530/// `none`/`transparent`/`initial`/`unset` resolve to a transparent color;
531/// `currentColor` resolves to the element's computed text color (`inherit`
532/// falls back to the SVG default, black, because the tree walker already
533/// propagates inherited fills); every other value goes through the standard
534/// CSS color pipeline. Unknown values return `None` so callers can apply the
535/// SVG default (black).
536pub(crate) fn parse_svg_fill(
537    value: &str,
538    color_scheme: ColorScheme,
539    current_color: Color,
540) -> Option<Color> {
541    let css = resolve_inline_value(value.trim())?;
542    match &css {
543        CssValue::Keyword(kw) if is_transparent_keyword(kw) => Some(Color(0, 0, 0, 0)),
544        CssValue::Keyword(kw) if kw.eq_ignore_ascii_case("currentColor") => Some(current_color),
545        CssValue::Keyword(kw) if kw.eq_ignore_ascii_case("inherit") => None,
546        // `url(...)` names a gradient paint server, never a solid color. Falling
547        // through to the CSS pipeline would log a spurious error and return
548        // `None` anyway, so short-circuit to keep the log clean.
549        CssValue::Function(name, _) if name.eq_ignore_ascii_case("url") => None,
550        _ => resolve_css_color("fill", &css, color_scheme),
551    }
552}
553
554/// Extracts the fragment `id` from a `url(#id)` paint-server reference like
555/// `fill="url(#blinearGradient971)"`. Returns `None` for any other value.
556pub(crate) fn parse_svg_url(value: &str) -> Option<String> {
557    let css = resolve_inline_value(value.trim())?;
558    let CssValue::Function(name, args) = &css else {
559        return None;
560    };
561    if !name.eq_ignore_ascii_case("url") {
562        return None;
563    }
564    // The fragment is either an unquoted identifier or a quoted string.
565    let fragment = args.first()?.iter().find_map(|arg| match arg {
566        CssValue::Keyword(kw) => Some(kw.to_string()),
567        CssValue::String(s) => Some(s.clone()),
568        _ => None,
569    })?;
570    let fragment = fragment.strip_prefix('#').unwrap_or(&fragment);
571    (!fragment.is_empty()).then(|| fragment.to_owned())
572}
573
574/// Parses a `<use href="...">` reference, accepting both `#id` (bare hash) and
575/// `url(#id)` forms. Returns the referenced fragment id.
576pub(crate) fn parse_use_ref(value: &str) -> Option<String> {
577    let value = value.trim();
578    if let Some(id) = value.strip_prefix('#') {
579        return (!id.is_empty()).then_some(id.to_owned());
580    }
581    parse_svg_url(value)
582}
583
584fn is_transparent_keyword(keyword: &str) -> bool {
585    keyword.eq_ignore_ascii_case("none")
586        || keyword.eq_ignore_ascii_case("transparent")
587        || keyword.eq_ignore_ascii_case("initial")
588        || keyword.eq_ignore_ascii_case("unset")
589}
590
591/// Uniform attribute access over the DOM snapshot's [`HtmlNodeType`] and the
592/// rasterizer's [`XmlElement`], so fill/rule/opacity resolution (and shape
593/// parsing) is shared.
594pub(crate) trait SvgElement {
595    fn svg_attr(&self, name: &str) -> Option<&str>;
596    fn svg_tag_name(&self) -> Option<&str>;
597}
598
599impl SvgElement for crate::engine::html::HtmlNodeType {
600    fn svg_attr(&self, name: &str) -> Option<&str> {
601        self.get_attr(name)
602    }
603    fn svg_tag_name(&self) -> Option<&str> {
604        self.tag_name()
605    }
606}
607
608impl SvgElement for crate::engine::html::xml::XmlElement {
609    fn svg_attr(&self, name: &str) -> Option<&str> {
610        self.attr(name)
611    }
612    fn svg_tag_name(&self) -> Option<&str> {
613        Some(&self.name)
614    }
615}
616
617/// Resolves an SVG `fill` into a concrete color: a `fill` attribute wins over
618/// a `style` attribute's `fill:` declaration, then the SVG default (black).
619/// `none`/`transparent` produce an invisible fill; `currentColor` resolves to
620/// `current_color`.
621#[cfg(test)]
622pub(crate) fn resolve_shape_fill(
623    el: &impl SvgElement,
624    color_scheme: ColorScheme,
625    current_color: Color,
626) -> Color {
627    match presentation_value(el, "fill") {
628        Some(value) => {
629            parse_svg_fill(value, color_scheme, current_color).unwrap_or(Color(0, 0, 0, 255))
630        }
631        None => Color(0, 0, 0, 255),
632    }
633}
634
635/// Resolves the `fill-rule` presentation value (`attribute` first, then the
636/// `style` declaration), defaulting to nonzero winding.
637pub(crate) fn resolve_fill_rule(el: &impl SvgElement) -> FillRule {
638    match presentation_value(el, "fill-rule") {
639        Some(value) if value.eq_ignore_ascii_case("evenodd") => FillRule::EvenOdd,
640        _ => FillRule::NonZero,
641    }
642}
643
644/// Resolves the `stroke` presentation value into a concrete stroke, threading
645/// the current stroke down the tree: a `stroke: none` (or transparent) stops
646/// stroking, `currentColor` resolves to `current_color`, and the remaining
647/// parameters keep whatever the ancestors declared when this element does not
648/// override them. A non-positive `stroke-width` suppresses the stroke. Returns
649/// `None` when nothing paints.
650pub(crate) fn resolve_stroke(
651    el: &impl SvgElement,
652    color_scheme: ColorScheme,
653    current_color: Color,
654    gradients: &HashMap<String, Gradient>,
655    inherited: Option<&SvgStroke>,
656) -> Option<SvgStroke> {
657    let mut stroke = SvgStroke::inherited_from(inherited);
658    // Only produce a stroke when the element (or an ancestor) actually
659    // declared one; the SVG default is `stroke: none`.
660    let mut declared = inherited.is_some();
661    if let Some(value) = presentation_value(el, "stroke") {
662        if is_transparent_keyword(value.trim()) {
663            return None;
664        }
665        declared = true;
666        if let Some(id) = parse_svg_url(value) {
667            // A gradient stroke paints even when the reference resolves; an
668            // unresolved reference also paints nothing.
669            stroke.gradient = Some(gradients.get(&id).cloned()?);
670        } else {
671            stroke.gradient = None;
672            match parse_svg_fill(value, color_scheme, current_color) {
673                Some(color) => stroke.color = color,
674                None => stroke.color = Color(0, 0, 0, 255),
675            }
676        }
677    }
678    if let Some(width) = numeric_presentation_value(el, "stroke-width") {
679        stroke.width = width;
680    }
681    if let Some(value) = presentation_value(el, "stroke-linecap") {
682        stroke.cap = match value.trim() {
683            v if v.eq_ignore_ascii_case("round") => StrokeCap::Round,
684            v if v.eq_ignore_ascii_case("square") => StrokeCap::Square,
685            _ => StrokeCap::Butt,
686        };
687    }
688    if let Some(value) = presentation_value(el, "stroke-linejoin") {
689        stroke.join = match value.trim() {
690            v if v.eq_ignore_ascii_case("round") => StrokeJoin::Round,
691            v if v.eq_ignore_ascii_case("bevel") => StrokeJoin::Bevel,
692            _ => StrokeJoin::Miter,
693        };
694    }
695    if let Some(limit) = numeric_presentation_value(el, "stroke-miterlimit") {
696        stroke.miter_limit = limit.max(1.0);
697    }
698    if let Some(value) = presentation_value(el, "stroke-dasharray") {
699        stroke.dash = parse_dasharray(value);
700    }
701    if let Some(offset) = numeric_presentation_value(el, "stroke-dashoffset") {
702        stroke.dash_offset = offset;
703    }
704    if !declared || stroke.width <= 0.0 {
705        return None;
706    }
707    let paints = stroke.gradient.is_some() || stroke.color.3 != 0;
708    if !paints {
709        return None;
710    }
711    Some(stroke)
712}
713
714/// Parses a `stroke-dasharray` value: `none` and empty lists yield `None`;
715/// otherwise the whitespace/comma separated non-negative lengths. A pattern
716/// that is entirely zero-length paints nothing.
717pub(crate) fn parse_dasharray(value: &str) -> Option<Vec<f32>> {
718    let value = value.trim();
719    if value.is_empty() || value.eq_ignore_ascii_case("none") {
720        return None;
721    }
722    let lengths: Vec<f32> = value
723        .split(',')
724        .flat_map(|part| part.split_ascii_whitespace())
725        .filter_map(|n| n.parse::<f32>().ok())
726        .map(|n| n.max(0.0))
727        .collect();
728    if lengths.is_empty() || lengths.iter().all(|n| *n <= 0.0) {
729        None
730    } else {
731        Some(lengths)
732    }
733}
734
735/// The inherited paint properties SVG carries down the tree, so a container's
736/// `fill`/`stroke` (and companions) are picked up by its descendants.
737#[derive(Debug, Clone)]
738pub(crate) struct PaintState {
739    pub fill: Option<Color>,
740    pub fill_gradient: Option<Gradient>,
741    pub fill_opacity: f32,
742    pub rule: FillRule,
743    pub stroke: Option<SvgStroke>,
744    pub stroke_opacity: f32,
745}
746
747impl PaintState {
748    pub(crate) fn new() -> Self {
749        PaintState {
750            fill: None,
751            fill_gradient: None,
752            fill_opacity: 1.0,
753            rule: FillRule::NonZero,
754            stroke: None,
755            stroke_opacity: 1.0,
756        }
757    }
758
759    /// Overlays `el`'s declarations on the incoming state. `gradients` resolves
760    /// `url(#id)` paint-server references to their gradient definitions.
761    pub(crate) fn apply(
762        &mut self,
763        el: &impl SvgElement,
764        color_scheme: ColorScheme,
765        current_color: Color,
766        gradients: &HashMap<String, Gradient>,
767    ) {
768        if let Some(value) = presentation_value(el, "fill") {
769            match parse_svg_url(value) {
770                Some(id) => {
771                    // A gradient fill overrides any inherited solid fill; an
772                    // unresolved reference paints nothing (`none`).
773                    if let Some(gradient) = gradients.get(&id) {
774                        self.fill_gradient = Some(gradient.clone());
775                        self.fill = None;
776                    } else {
777                        self.fill_gradient = None;
778                        self.fill = Some(Color(0, 0, 0, 0));
779                    }
780                }
781                None => {
782                    self.fill_gradient = None;
783                    self.fill = parse_svg_fill(value, color_scheme, current_color)
784                        .or(Some(Color(0, 0, 0, 255)));
785                }
786            }
787        }
788        self.fill_opacity *= numeric_presentation_value(el, "fill-opacity")
789            .unwrap_or(1.0)
790            .clamp(0.0, 1.0);
791        if let Some(value) = presentation_value(el, "fill-rule") {
792            self.rule = if value.eq_ignore_ascii_case("evenodd") {
793                FillRule::EvenOdd
794            } else {
795                FillRule::NonZero
796            };
797        }
798        self.stroke_opacity *= numeric_presentation_value(el, "stroke-opacity")
799            .unwrap_or(1.0)
800            .clamp(0.0, 1.0);
801        self.stroke = resolve_stroke(
802            el,
803            color_scheme,
804            current_color,
805            gradients,
806            self.stroke.as_ref(),
807        );
808    }
809}
810
811/// Reads a numeric presentation value such as `opacity` or `fill-opacity`
812/// (attribute first, then the matching `style` declaration). Malformed or
813/// non-finite values return `None`.
814pub(crate) fn numeric_presentation_value(el: &impl SvgElement, name: &str) -> Option<f32> {
815    presentation_value(el, name)?
816        .parse::<f32>()
817        .ok()
818        .filter(|v| v.is_finite())
819}
820
821/// Returns the `name` presentation value: the `name` attribute wins, then the
822/// matching `<name>:` declaration inside the `style` attribute.
823fn presentation_value<'a>(el: &'a impl SvgElement, name: &str) -> Option<&'a str> {
824    el.svg_attr(name)
825        .map(str::trim)
826        .filter(|value| !value.is_empty())
827        .or_else(|| {
828            el.svg_attr("style")
829                .and_then(|style| style_property(style, name))
830                .map(str::trim)
831        })
832}
833
834/// Returns the `<name>:` declaration within a `style` attribute.
835pub(crate) fn style_property<'a>(style: &'a str, name: &str) -> Option<&'a str> {
836    style.split(';').find_map(|pair| {
837        let (key, value) = pair.split_once(':')?;
838        key.trim().eq_ignore_ascii_case(name).then_some(value)
839    })
840}
841
842/// Multiplies the alpha channel of `color` by `alpha` (clamped to 0..=1).
843pub(crate) fn with_alpha(color: Color, alpha: f32) -> Color {
844    let a = (color.3 as f32 * alpha.clamp(0.0, 1.0)).round() as u8;
845    Color(color.0, color.1, color.2, a)
846}
847
848/// Collects the inline SVG content of an `<svg>` element: its `viewBox` and
849/// the solid-color shapes of its content, recursively.
850///
851/// Container elements (`g`, `a`, `symbol`) are walked with their `transform`
852/// and `opacity` attributes composed down the tree. `path`/`rect`/`circle`/
853/// `ellipse` become [`SvgShape`]s whose paths are pre-transformed into
854/// `viewBox` space, so [`emit_commands`] only maps the `viewBox` onto the
855/// content box. `text_color` is the `<svg>` element's computed CSS `color`,
856/// used to resolve `fill="currentColor"`. Returns `None` when the element has
857/// no usable `viewBox` or no paintable subpaths.
858pub fn collect_svg(
859    snapshot: &DomSnapshot,
860    svg_id: NodeId,
861    color_scheme: ColorScheme,
862    text_color: Color,
863) -> Option<SvgContent> {
864    let view_box = parse_view_box(snapshot.node(svg_id).kind.get_attr("viewBox")?);
865    if view_box.2 <= 0.0 || view_box.3 <= 0.0 {
866        return None;
867    }
868    let mut gradients = HashMap::new();
869    for &child in snapshot.children(svg_id) {
870        collect_gradient_defs(snapshot, child, color_scheme, text_color, &mut gradients);
871    }
872    let mut id_map = HashMap::new();
873    for &child in snapshot.children(svg_id) {
874        collect_id_map(snapshot, child, &mut id_map);
875    }
876    let mut clip_paths = HashMap::new();
877    for &child in snapshot.children(svg_id) {
878        collect_clip_path_defs(snapshot, child, &mut clip_paths);
879    }
880    let mut shapes = Vec::new();
881    let mut use_stack: Vec<NodeId> = Vec::new();
882    for &child in snapshot.children(svg_id) {
883        collect_content(
884            snapshot,
885            child,
886            &AffineTransform::identity(),
887            1.0,
888            color_scheme,
889            text_color,
890            PaintState::new(),
891            &gradients,
892            &id_map,
893            &clip_paths,
894            &mut use_stack,
895            &mut shapes,
896        );
897    }
898    if shapes.is_empty() {
899        return None;
900    }
901    Some(SvgContent { view_box, shapes })
902}
903
904/// Collects `<clipPath>` definitions under `root` into `out`, keyed by their
905/// `id` attribute. All shape descendants of a `<clipPath>` are combined into a
906/// single path (transformed by the clipPath element's `transform` attribute).
907fn collect_clip_path_defs(
908    snapshot: &DomSnapshot,
909    id: NodeId,
910    out: &mut HashMap<String, ClipPath>,
911) {
912    let el = &snapshot.node(id).kind;
913    if el.tag_name() == Some("clipPath")
914        && let Some(clip_id) = el.get_attr("id").filter(|id| !id.is_empty())
915    {
916        let mut path = Path::new();
917        let transform = match el.get_attr("transform") {
918            Some(value) => parse_transform(value).unwrap_or(AffineTransform::identity()),
919            None => AffineTransform::identity(),
920        };
921        let rule = resolve_fill_rule(el);
922        collect_clip_path_shapes(snapshot, id, &transform, &mut path);
923        if !path.commands().is_empty() {
924            out.entry(clip_id.to_owned()).or_insert(ClipPath { path, rule });
925        }
926    }
927    for &child in snapshot.children(id) {
928        collect_clip_path_defs(snapshot, child, out);
929    }
930}
931
932/// Recursively collects all shape descendants under `id` into `path`, applying
933/// `transform` to each shape's geometry.
934fn collect_clip_path_shapes(
935    snapshot: &DomSnapshot,
936    id: NodeId,
937    transform: &AffineTransform,
938    path: &mut Path,
939) {
940    let el = &snapshot.node(id).kind;
941    if let Some(shape_path) = element_shape(el) {
942        let transformed = transform_path(&shape_path, transform);
943        // Append the transformed subpaths into the combined path.
944        for subpath in transformed.subpaths() {
945            if let Some(&first) = subpath.first() {
946                path.move_to(first.0, first.1);
947                for &(x, y) in &subpath[1..] {
948                    path.line_to(x, y);
949                }
950                path.close();
951            }
952        }
953    }
954    for &child in snapshot.children(id) {
955        collect_clip_path_shapes(snapshot, child, transform, path);
956    }
957}
958
959/// Builds a map of `id` attribute values to node ids for all elements under
960/// `root`, so `<use href="#id">` can resolve its target.
961pub(crate) fn collect_id_map(
962    snapshot: &DomSnapshot,
963    id: NodeId,
964    out: &mut HashMap<String, NodeId>,
965) {
966    let el = &snapshot.node(id).kind;
967    if let Some(id_value) = el.get_attr("id")
968        && !id_value.is_empty()
969    {
970        out.entry(id_value.to_owned()).or_insert(id);
971    }
972    for &child in snapshot.children(id) {
973        collect_id_map(snapshot, child, out);
974    }
975}
976
977/// Collects the `<linearGradient>`/`<radialGradient>` definitions reachable
978/// under `id` (typically inside a `<defs>` block) into `out`, keyed by their
979/// `id` attribute.
980fn collect_gradient_defs(
981    snapshot: &DomSnapshot,
982    id: NodeId,
983    color_scheme: ColorScheme,
984    text_color: Color,
985    out: &mut HashMap<String, Gradient>,
986) {
987    let el = &snapshot.node(id).kind;
988    match el.tag_name() {
989        Some("linearGradient") => {
990            let stops = collect_stops(snapshot, id, color_scheme, text_color);
991            if let Some(gradient) = parse_linear_gradient(el, &stops, color_scheme, text_color)
992                && let Some(gid) = el.get_attr("id").filter(|id| !id.is_empty())
993            {
994                out.entry(gid.to_owned()).or_insert(gradient);
995            }
996        }
997        Some("radialGradient") => {
998            let stops = collect_stops(snapshot, id, color_scheme, text_color);
999            if let Some(gradient) = parse_radial_gradient(el, &stops, color_scheme, text_color)
1000                && let Some(gid) = el.get_attr("id").filter(|id| !id.is_empty())
1001            {
1002                out.entry(gid.to_owned()).or_insert(gradient);
1003            }
1004        }
1005        _ => {}
1006    }
1007    for &child in snapshot.children(id) {
1008        collect_gradient_defs(snapshot, child, color_scheme, text_color, out);
1009    }
1010}
1011
1012/// Resolves a gradient's geometry from its attributes. Both gradient kinds share
1013/// the `gradientUnits`/`spreadMethod` handling.
1014fn gradient_units_and_spread(el: &impl SvgElement) -> (bool, GradientSpread) {
1015    let user_space = el
1016        .svg_attr("gradientUnits")
1017        .map(|v| v.trim().eq_ignore_ascii_case("userSpaceOnUse"))
1018        .unwrap_or(false);
1019    let spread = match el
1020        .svg_attr("spreadMethod")
1021        .map(|v| v.trim())
1022        .unwrap_or("pad")
1023    {
1024        v if v.eq_ignore_ascii_case("repeat") => GradientSpread::Repeat,
1025        v if v.eq_ignore_ascii_case("reflect") => GradientSpread::Reflect,
1026        _ => GradientSpread::Pad,
1027    };
1028    (user_space, spread)
1029}
1030
1031/// Parses an `<linearGradient>` element's geometry around already-resolved
1032/// color stops. The defs live in `viewBox`/bbox space, so no transform is
1033/// applied here.
1034fn parse_linear_gradient(
1035    el: &impl SvgElement,
1036    stops: &[ColorStop],
1037    _color_scheme: ColorScheme,
1038    _text_color: Color,
1039) -> Option<Gradient> {
1040    let (user_space, spread) = gradient_units_and_spread(el);
1041    if stops.is_empty() {
1042        return None;
1043    }
1044    Some(Gradient {
1045        kind: GradientKind::SvgLinear {
1046            x1: el.svg_attr("x1").and_then(parse_len).unwrap_or(0.0),
1047            y1: el.svg_attr("y1").and_then(parse_len).unwrap_or(0.0),
1048            x2: el.svg_attr("x2").and_then(parse_len).unwrap_or(1.0),
1049            y2: el.svg_attr("y2").and_then(parse_len).unwrap_or(0.0),
1050            user_space,
1051        },
1052        stops: stops.to_vec(),
1053        spread,
1054    })
1055}
1056
1057/// Parses a `<radialGradient>` element's geometry. A missing focal point
1058/// degrades to the center (`fx/fy` inherit `cx/cy` per the SVG spec).
1059fn parse_radial_gradient(
1060    el: &impl SvgElement,
1061    stops: &[ColorStop],
1062    _color_scheme: ColorScheme,
1063    _text_color: Color,
1064) -> Option<Gradient> {
1065    let (user_space, spread) = gradient_units_and_spread(el);
1066    let cx = el.svg_attr("cx").and_then(parse_len).unwrap_or(0.5);
1067    let cy = el.svg_attr("cy").and_then(parse_len).unwrap_or(0.5);
1068    if stops.is_empty() {
1069        return None;
1070    }
1071    Some(Gradient {
1072        kind: GradientKind::SvgRadial {
1073            cx,
1074            cy,
1075            r: el.svg_attr("r").and_then(parse_len).unwrap_or(0.5),
1076            fx: el.svg_attr("fx").and_then(parse_len).unwrap_or(cx),
1077            fy: el.svg_attr("fy").and_then(parse_len).unwrap_or(cy),
1078            fr: el.svg_attr("fr").and_then(parse_len).unwrap_or(0.0),
1079            user_space,
1080        },
1081        stops: stops.to_vec(),
1082        spread,
1083    })
1084}
1085
1086/// Resolves the direct `<stop>` children of the gradient element `id`,
1087/// resolving each stop's color, opacity, and offset. Unparsable stops are
1088/// skipped.
1089fn collect_stops(
1090    snapshot: &DomSnapshot,
1091    id: NodeId,
1092    color_scheme: ColorScheme,
1093    text_color: Color,
1094) -> Vec<ColorStop> {
1095    let mut out = Vec::new();
1096    for &child in snapshot.children(id) {
1097        let el = &snapshot.node(child).kind;
1098        if el.tag_name() != Some("stop") {
1099            continue;
1100        }
1101        let offset = el.get_attr("offset").and_then(parse_stop_offset);
1102        let color = presentation_value(el, "stop-color")
1103            .and_then(|value| parse_svg_fill(value, color_scheme, text_color))
1104            .unwrap_or(Color(0, 0, 0, 255));
1105        let opacity = numeric_presentation_value(el, "stop-opacity")
1106            .unwrap_or(1.0)
1107            .clamp(0.0, 1.0);
1108        out.push(ColorStop {
1109            color: with_alpha(color, opacity),
1110            position: offset,
1111        });
1112    }
1113    out
1114}
1115
1116/// Parses an SVG gradient stop `offset` (`0.5`, `50%`, …) into a 0..1 fraction.
1117fn parse_stop_offset(value: &str) -> Option<f32> {
1118    let value = value.trim();
1119    if let Some(percent) = value.strip_suffix('%') {
1120        return parse_len(percent).map(|v| (v / 100.0).clamp(0.0, 1.0));
1121    }
1122    parse_len(value).map(|v| v.clamp(0.0, 1.0))
1123}
1124
1125/// Recursively gathers the paintable shapes under `id`, composing `transform`
1126/// and `opacity` into the inherited values as it walks containers. `paint`
1127/// carries the inherited `fill`/`stroke` state that containers set for their
1128/// descendants.
1129#[allow(clippy::too_many_arguments)]
1130fn collect_content(
1131    snapshot: &DomSnapshot,
1132    id: NodeId,
1133    inherited: &AffineTransform,
1134    opacity: f32,
1135    color_scheme: ColorScheme,
1136    text_color: Color,
1137    mut paint: PaintState,
1138    gradients: &HashMap<String, Gradient>,
1139    id_map: &HashMap<String, NodeId>,
1140    clip_paths: &HashMap<String, ClipPath>,
1141    use_stack: &mut Vec<NodeId>,
1142    shapes: &mut Vec<SvgShape>,
1143) {
1144    let el = &snapshot.node(id).kind;
1145    let eff = match el.get_attr("transform") {
1146        Some(value) => match parse_transform(value) {
1147            Ok(transform) => inherited.then(&transform),
1148            Err(_) => *inherited,
1149        },
1150        None => *inherited,
1151    };
1152    let element_opacity = numeric_presentation_value(el, "opacity").unwrap_or(1.0);
1153    let opacity = opacity * element_opacity.clamp(0.0, 1.0);
1154    paint.apply(el, color_scheme, text_color, gradients);
1155
1156    match el.tag_name() {
1157        Some("g" | "a" | "symbol") => {
1158            for &child in snapshot.children(id) {
1159                collect_content(
1160                    snapshot,
1161                    child,
1162                    &eff,
1163                    opacity,
1164                    color_scheme,
1165                    text_color,
1166                    paint.clone(),
1167                    gradients,
1168                    id_map,
1169                    clip_paths,
1170                    use_stack,
1171                    shapes,
1172                );
1173            }
1174        }
1175        Some("use") => {
1176            // `<use href="#id">` clones the referenced element's content at the
1177            // use element's position, with x/y translating the content.
1178            let href = el
1179                .svg_attr("href")
1180                .or_else(|| el.svg_attr("xlink:href"));
1181            let target_id = href
1182                .and_then(parse_use_ref)
1183                .and_then(|frag| id_map.get(&frag).copied());
1184            if let Some(target) = target_id {
1185                // Circular reference detection: bail out if the target is
1186                // already being expanded by an ancestor `<use>`.
1187                if !use_stack.contains(&target) {
1188                    let x = el.svg_attr("x").and_then(parse_len).unwrap_or(0.0);
1189                    let y = el.svg_attr("y").and_then(parse_len).unwrap_or(0.0);
1190                    let use_transform = eff.then(&AffineTransform::translate(x, y));
1191                    use_stack.push(target);
1192                    collect_content(
1193                        snapshot,
1194                        target,
1195                        &use_transform,
1196                        opacity,
1197                        color_scheme,
1198                        text_color,
1199                        paint.clone(),
1200                        gradients,
1201                        id_map,
1202                        clip_paths,
1203                        use_stack,
1204                        shapes,
1205                    );
1206                    use_stack.pop();
1207                }
1208            }
1209        }
1210        Some("path" | "rect" | "circle" | "ellipse" | "polygon" | "polyline" | "line") => {
1211            let Some(path) = element_shape(el) else {
1212                return;
1213            };
1214            let path = transform_path(&path, &eff);
1215            if path.commands().is_empty() {
1216                return;
1217            }
1218            let fill_alpha = opacity * paint.fill_opacity;
1219            let fill = match &paint.fill_gradient {
1220                Some(gradient) => Brush::Gradient(with_stop_alpha(gradient.clone(), fill_alpha)),
1221                None => Brush::Solid(with_alpha(
1222                    paint.fill.unwrap_or(Color(0, 0, 0, 255)),
1223                    fill_alpha,
1224                )),
1225            };
1226            let stroke = paint.stroke.clone().map(|mut stroke| {
1227                if let Some(gradient) = &mut stroke.gradient {
1228                    *gradient = with_stop_alpha(gradient.clone(), opacity * paint.stroke_opacity);
1229                } else {
1230                    stroke.color = with_alpha(stroke.color, opacity * paint.stroke_opacity);
1231                }
1232                stroke
1233            });
1234            let clip_path = el
1235                .svg_attr("clip-path")
1236                .and_then(parse_use_ref)
1237                .and_then(|frag| clip_paths.get(&frag).cloned());
1238            shapes.push(SvgShape {
1239                path,
1240                fill,
1241                rule: paint.rule,
1242                stroke,
1243                clip_path,
1244            });
1245        }
1246        _ => {}
1247    }
1248}
1249
1250/// Scales every gradient stop's alpha by `alpha` (clamped). Used to fold an
1251/// element's `opacity` and the fill/stroke opacity into the painted gradient,
1252/// mirroring how solid paints bake those into their alpha channel.
1253fn with_stop_alpha(gradient: Gradient, alpha: f32) -> Gradient {
1254    let mut gradient = gradient;
1255    for stop in &mut gradient.stops {
1256        stop.color = with_alpha(stop.color, alpha);
1257    }
1258    gradient
1259}
1260
1261/// Builds the shape-space path an element describes, or `None` when the element
1262/// is malformed or has no area.
1263fn element_shape(el: &impl SvgElement) -> Option<Path> {
1264    match el.svg_tag_name()? {
1265        "path" => {
1266            let d = el.svg_attr("d")?;
1267            let path = path_from_d(d);
1268            (!path.commands().is_empty()).then_some(path)
1269        }
1270        "rect" => {
1271            let x = el.svg_attr("x").and_then(parse_len).unwrap_or(0.0);
1272            let y = el.svg_attr("y").and_then(parse_len).unwrap_or(0.0);
1273            let w = el.svg_attr("width").and_then(parse_len).unwrap_or(0.0);
1274            let h = el.svg_attr("height").and_then(parse_len).unwrap_or(0.0);
1275            let rx = el
1276                .svg_attr("rx")
1277                .and_then(parse_len)
1278                .unwrap_or(0.0)
1279                .max(0.0);
1280            let ry = el
1281                .svg_attr("ry")
1282                .and_then(parse_len)
1283                .unwrap_or(0.0)
1284                .max(0.0);
1285            if w <= 0.0 || h <= 0.0 {
1286                return None;
1287            }
1288            if rx > 0.0 || ry > 0.0 {
1289                // A single rx/ry applies to every corner, per the SVG grammar.
1290                let radius = (rx, ry);
1291                Some(rounded_rect_path(
1292                    x, y, w, h, radius, radius, radius, radius,
1293                ))
1294            } else {
1295                Some(rect_path(x, y, w, h))
1296            }
1297        }
1298        "circle" | "ellipse" => {
1299            let cx = el.svg_attr("cx").and_then(parse_len).unwrap_or(0.0);
1300            let cy = el.svg_attr("cy").and_then(parse_len).unwrap_or(0.0);
1301            let (rx, ry) = if el.svg_tag_name() == Some("circle") {
1302                let r = el.svg_attr("r").and_then(parse_len).unwrap_or(0.0);
1303                (r, r)
1304            } else {
1305                (
1306                    el.svg_attr("rx").and_then(parse_len).unwrap_or(0.0),
1307                    el.svg_attr("ry").and_then(parse_len).unwrap_or(0.0),
1308                )
1309            };
1310            (rx > 0.0 && ry > 0.0).then_some(ellipse_path(cx, cy, rx, ry))
1311        }
1312        "polygon" => {
1313            let points = parse_points(el.svg_attr("points").unwrap_or_default());
1314            (!points.is_empty()).then(|| polygon_path(&points))
1315        }
1316        "polyline" => {
1317            let points = parse_points(el.svg_attr("points").unwrap_or_default());
1318            (!points.is_empty()).then(|| polyline_path(&points))
1319        }
1320        "line" => {
1321            let (x1, y1) = (
1322                el.svg_attr("x1").and_then(parse_len).unwrap_or(0.0),
1323                el.svg_attr("y1").and_then(parse_len).unwrap_or(0.0),
1324            );
1325            let (x2, y2) = (
1326                el.svg_attr("x2").and_then(parse_len).unwrap_or(0.0),
1327                el.svg_attr("y2").and_then(parse_len).unwrap_or(0.0),
1328            );
1329            Some(line_path(x1, y1, x2, y2))
1330        }
1331        _ => None,
1332    }
1333}
1334
1335/// Builds the transform mapping `viewBox` coordinates into a viewport using
1336/// `xMidYMid meet` (the SVG default: fit the whole box inside the viewport,
1337/// centered). Without a usable `viewBox`, coordinates map 1:1.
1338pub fn viewbox_meet_transform(view_box: ViewBox, vw: f32, vh: f32) -> AffineTransform {
1339    let (vx, vy, vbw, vbh) = view_box;
1340    if vbw <= 0.0 || vbh <= 0.0 {
1341        return AffineTransform::identity();
1342    }
1343    let scale = (vw / vbw).min(vh / vbh);
1344    let tx = (vw - vbw * scale) * 0.5;
1345    let ty = (vh - vbh * scale) * 0.5;
1346    AffineTransform {
1347        m11: scale,
1348        m22: scale,
1349        dx: tx - vx * scale,
1350        dy: ty - vy * scale,
1351        ..AffineTransform::identity()
1352    }
1353}
1354
1355/// Paints the shapes of an inline SVG into its content box.
1356///
1357/// Pushes a transform mapping the `viewBox` rectangle onto the content box
1358/// with [`viewbox_meet_transform`] (letterboxing when the aspect ratios
1359/// differ), emits one `Fill` per shape in document order, then pops the
1360/// transform. The caller must have already pushed the box model:
1361/// `push_box_model` translates block boxes to their content-box origin, and
1362/// inline boxes push no transform (so `is_inline` paints in absolute
1363/// coordinates). Paints nothing when the box or `viewBox` is degenerate.
1364pub fn emit_commands(
1365    cmd_buf: &mut Vec<DrawCommand>,
1366    content_box: (f32, f32, f32, f32),
1367    is_inline: bool,
1368    view_box: ViewBox,
1369    shapes: &[SvgShape],
1370    opacity: f32,
1371) {
1372    let (cx, cy, cw, ch) = content_box;
1373    if cw <= 0.0 || ch <= 0.0 {
1374        return;
1375    }
1376    let (ox, oy) = if is_inline { (cx, cy) } else { (0.0, 0.0) };
1377    let mut transform = viewbox_meet_transform(view_box, cw, ch);
1378    transform.dx += ox;
1379    transform.dy += oy;
1380    cmd_buf.push(DrawCommand::PushTransform { transform });
1381    for shape in shapes {
1382        if let Some(clip) = &shape.clip_path {
1383            cmd_buf.push(DrawCommand::PushClip {
1384                path: clip.path.clone(),
1385                rule: clip.rule,
1386            });
1387        }
1388        cmd_buf.push(DrawCommand::Fill {
1389            path: shape.path.clone(),
1390            paint: Paint {
1391                brush: shape.fill.clone(),
1392                opacity,
1393            },
1394            rule: shape.rule,
1395        });
1396        if let Some(stroke) = &shape.stroke {
1397            // The stroke outline is expanded in viewBox space (the same space
1398            // the shape's path lives in) and filled with the nonzero rule by
1399            // the same pipeline as the shape fill.
1400            let brush = match &stroke.gradient {
1401                Some(gradient) => Brush::Gradient(gradient.clone()),
1402                None => Brush::Solid(stroke.color),
1403            };
1404            cmd_buf.push(DrawCommand::Fill {
1405                path: stroke_path(&shape.path, &stroke.to_stroke()),
1406                paint: Paint { brush, opacity },
1407                rule: FillRule::NonZero,
1408            });
1409        }
1410        if shape.clip_path.is_some() {
1411            cmd_buf.push(DrawCommand::PopClip);
1412        }
1413    }
1414    cmd_buf.push(DrawCommand::PopTransform);
1415}
1416
1417/// SVG path number lexer: reads sign-separated floats per the path `d`
1418/// grammar, tracking the command letter position.
1419struct SvgNum {
1420    chars: Vec<char>,
1421    pos: usize,
1422}
1423
1424impl SvgNum {
1425    fn new(d: &str) -> Self {
1426        SvgNum {
1427            chars: d.chars().collect(),
1428            pos: 0,
1429        }
1430    }
1431
1432    fn skip_ws(&mut self) {
1433        while self.pos < self.chars.len() {
1434            let c = self.chars[self.pos];
1435            if c.is_ascii_whitespace() || c == ',' {
1436                self.pos += 1;
1437            } else {
1438                break;
1439            }
1440        }
1441    }
1442
1443    /// Next command letter (consuming it), or `None` at end of data.
1444    fn next_command(&mut self) -> Option<char> {
1445        self.skip_ws();
1446        let c = *self.chars.get(self.pos)?;
1447        if c.is_ascii_alphabetic() {
1448            self.pos += 1;
1449            Some(c)
1450        } else {
1451            None
1452        }
1453    }
1454
1455    fn next_number(&mut self) -> Option<f32> {
1456        self.skip_ws();
1457        if self.pos >= self.chars.len() {
1458            return None;
1459        }
1460        // A command letter terminates the current coordinate list.
1461        if self.chars[self.pos].is_ascii_alphabetic() {
1462            return None;
1463        }
1464        let start = self.pos;
1465        let mut i = self.pos;
1466        if i < self.chars.len() && (self.chars[i] == '+' || self.chars[i] == '-') {
1467            i += 1;
1468        }
1469        let mut digits = 0;
1470        while i < self.chars.len() && self.chars[i].is_ascii_digit() {
1471            i += 1;
1472            digits += 1;
1473        }
1474        if i < self.chars.len() && self.chars[i] == '.' {
1475            i += 1;
1476            while i < self.chars.len() && self.chars[i].is_ascii_digit() {
1477                i += 1;
1478                digits += 1;
1479            }
1480        }
1481        // A lone '.' is a valid zero in SVG data.
1482        if digits == 0 && start == self.pos {
1483            return None;
1484        }
1485        // Exponent.
1486        if i < self.chars.len() && (self.chars[i] == 'e' || self.chars[i] == 'E') {
1487            let mut j = i + 1;
1488            if j < self.chars.len() && (self.chars[j] == '+' || self.chars[j] == '-') {
1489                j += 1;
1490            }
1491            if j < self.chars.len() && self.chars[j].is_ascii_digit() {
1492                i = j;
1493                while i < self.chars.len() && self.chars[i].is_ascii_digit() {
1494                    i += 1;
1495                }
1496            }
1497        }
1498        // Reject trailing decimal point with no digits (e.g. "10.5M").
1499        let text: String = self.chars[start..i].iter().collect();
1500        match text.parse::<f32>() {
1501            Ok(v) => {
1502                self.pos = i;
1503                Some(v)
1504            }
1505            Err(_) => None,
1506        }
1507    }
1508
1509    /// A relative coordinate is relative to `ref` (the current point).
1510    fn next_value(&mut self, rel: bool, ref_val: Option<f32>) -> Option<f32> {
1511        let v = self.next_number()?;
1512        if rel {
1513            Some(ref_val.unwrap_or(0.0) + v)
1514        } else {
1515            Some(v)
1516        }
1517    }
1518
1519    fn next_point(&mut self, rel: bool, cur: Option<(f32, f32)>) -> Option<(f32, f32)> {
1520        let ref_pt = cur.unwrap_or((0.0, 0.0));
1521        let x = self.next_value(rel, Some(ref_pt.0))?;
1522        let y = self.next_value(rel, Some(ref_pt.1))?;
1523        Some((x, y))
1524    }
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529    use super::*;
1530    use crate::engine::html::HtmlNodeType;
1531    use crate::engine::html::tokenizer::Attribute;
1532    use crate::engine::layouter::dom_snapshot::DomSnapshot;
1533    use crate::engine::renderer_model::PathCommand;
1534    use crate::engine::tree::TreeNode;
1535    use std::rc::Rc;
1536
1537    fn element(tag: &str, attrs: &[(&str, &str)]) -> HtmlNodeType {
1538        HtmlNodeType::Element {
1539            tag_name: tag.to_string(),
1540            attributes: attrs
1541                .iter()
1542                .map(|(name, value)| Attribute {
1543                    name: name.to_string(),
1544                    value: value.to_string(),
1545                })
1546                .collect(),
1547        }
1548    }
1549
1550    #[test]
1551    fn smooth_curve_path_skips_previous_non_curve() {
1552        // A previous non-curve command forces S' first control to the current
1553        // point instead of a reflection.
1554        let path = path_from_d("M 10 10 L 20 10 S 30 0, 40 10");
1555        match &path.commands()[2] {
1556            PathCommand::CubicTo { c1x, c1y, .. } => {
1557                assert_eq!((*c1x, *c1y), (20.0, 10.0));
1558            }
1559            other => panic!("expected cubic, got {other:?}"),
1560        }
1561    }
1562
1563    #[test]
1564    fn path_parses_absolute_moveto_lineto_and_close() {
1565        let path = path_from_d("M 0 0 L 10 0 L 10 10 L 0 10 Z");
1566        assert_eq!(
1567            path.subpaths(),
1568            vec![vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]]
1569        );
1570    }
1571
1572    #[test]
1573    fn path_parses_relative_implicit_lineto_and_hv() {
1574        // `m 1 1 3 0` is a relative moveto plus an implicit relative lineto;
1575        // `h 2` / `v 2` are relative to the current point.
1576        let path = path_from_d("m 1 1 3 0 h 2 v 2 z");
1577        assert_eq!(
1578            path.subpaths(),
1579            vec![vec![(1.0, 1.0), (4.0, 1.0), (6.0, 1.0), (6.0, 3.0)]]
1580        );
1581    }
1582
1583    #[test]
1584    fn path_cubic_s_reflects_previous_control_point() {
1585        // After `C 10 10, 20 10, 30 0`, S reflects (20,10) about (30,0) → (40,-10).
1586        let path = path_from_d("M 0 0 C 10 10, 20 10, 30 0 S 50 -10, 60 0");
1587        match &path.commands()[2] {
1588            PathCommand::CubicTo {
1589                c1x,
1590                c1y,
1591                c2x,
1592                c2y,
1593                x,
1594                y,
1595            } => {
1596                assert_eq!((*c1x, *c1y), (40.0, -10.0));
1597                assert_eq!((*c2x, *c2y), (50.0, -10.0));
1598                assert_eq!((*x, *y), (60.0, 0.0));
1599            }
1600            other => panic!("expected cubic, got {other:?}"),
1601        }
1602    }
1603
1604    #[test]
1605    fn path_quad_t_reflects_previous_control_point() {
1606        // After `Q 10 20, 20 0`, T reflects (10,20) about (20,0) → (30,-20).
1607        let path = path_from_d("M 0 0 Q 10 20 20 0 T 40 0");
1608        match &path.commands()[2] {
1609            PathCommand::QuadTo { cx, cy, x, y } => {
1610                assert_eq!((*cx, *cy), (30.0, -20.0));
1611                assert_eq!((*x, *y), (40.0, 0.0));
1612            }
1613            other => panic!("expected quad, got {other:?}"),
1614        }
1615    }
1616
1617    #[test]
1618    fn arc_is_approximated_by_cubics_staying_on_the_circle() {
1619        // Semicircle from (0,0) to (10,0) at radius 5, center (5,0).
1620        let path = path_from_d("M 0 0 A 5 5 0 0 1 10 0");
1621        assert!(matches!(&path.commands()[0], PathCommand::MoveTo { .. }));
1622        let cubics = path
1623            .commands()
1624            .iter()
1625            .filter(|c| matches!(c, PathCommand::CubicTo { .. }))
1626            .count();
1627        assert!(cubics >= 2, "expected ≥ 2 cubics, got {cubics}");
1628        let ring = path.subpaths().into_iter().next().expect("one ring");
1629        assert_close(ring.first().copied().unwrap(), (0.0, 0.0));
1630        assert_close(ring.last().copied().unwrap(), (10.0, 0.0));
1631        for &(x, y) in &ring {
1632            let v = ((x - 5.0) / 5.0).powi(2) + (y / 5.0).powi(2);
1633            assert!((v - 1.0).abs() < 0.05, "point ({x},{y}) off circle: {v}");
1634        }
1635        // The arc bows up to the circle top instead of cutting straight across.
1636        assert!(
1637            ring.iter().map(|&(_, y)| y.abs()).fold(0.0f32, f32::max) > 4.0,
1638            "arc did not bulge"
1639        );
1640    }
1641
1642    #[test]
1643    fn arc_sweep_flag_selects_the_complementary_side() {
1644        // Same radii/endpoints: sweep=1 swings through negative y, sweep=0
1645        // through positive y (y-down screen coordinates).
1646        let sweep1 = path_from_d("M 0 0 A 5 5 0 0 1 10 0");
1647        let sweep0 = path_from_d("M 0 0 A 5 5 0 0 0 10 0");
1648        let extreme_y = |p: &Path, f: fn(f32, f32) -> f32| {
1649            p.subpaths()[0].iter().map(|&(_, y)| y).fold(f32::NAN, &f)
1650        };
1651        // sweep=1 reaches the top (most negative y); sweep=0 the bottom.
1652        assert!(
1653            extreme_y(&sweep1, f32::min) < -4.0,
1654            "sweep=1 should go over the top"
1655        );
1656        assert!(
1657            extreme_y(&sweep0, f32::max) > 4.0,
1658            "sweep=0 should go under the bottom"
1659        );
1660    }
1661
1662    #[test]
1663    fn arc_large_flag_takes_the_long_way_around() {
1664        // Chord 6 < diameter 10: the small arc is < 180° (a single cubic),
1665        // the large arc is the remaining sweep (split into several).
1666        let small = path_from_d("M 0 0 A 5 5 0 0 1 6 0");
1667        let large = path_from_d("M 0 0 A 5 5 0 1 1 6 0");
1668        let cubics = |p: &Path| {
1669            p.commands()
1670                .iter()
1671                .filter(|c| matches!(c, PathCommand::CubicTo { .. }))
1672                .count()
1673        };
1674        assert!(
1675            cubics(&large) > cubics(&small),
1676            "large arc should split more"
1677        );
1678        for p in [&small, &large] {
1679            let ring = &p.subpaths()[0];
1680            assert_close(ring.first().copied().unwrap(), (0.0, 0.0));
1681            assert_close(ring.last().copied().unwrap(), (6.0, 0.0));
1682        }
1683    }
1684
1685    #[test]
1686    fn arc_scales_radii_up_when_the_chord_exceeds_the_radius() {
1687        // rx=3 cannot span the (0,0)→(10,0) chord; radii must be scaled to 5.
1688        let path = path_from_d("M 0 0 A 3 3 0 0 1 10 0");
1689        let ring = &path.subpaths()[0];
1690        assert_close(ring.first().copied().unwrap(), (0.0, 0.0));
1691        assert_close(ring.last().copied().unwrap(), (10.0, 0.0));
1692        for &(x, y) in ring {
1693            let v = ((x - 5.0) / 5.0).powi(2) + (y / 5.0).powi(2);
1694            assert!((v - 1.0).abs() < 0.05, "point ({x},{y}) off circle: {v}");
1695        }
1696    }
1697
1698    #[test]
1699    fn arc_rotates_ellipse_with_x_axis_rotation() {
1700        // An ellipse rotated 45°, radii (20,10), chord length 20 fits inside
1701        // (λ ≤ 1), so no radii scaling. Verify every flattened vertex sits on
1702        // the ellipse whose center follows the spec's endpoint→center
1703        // parametrization.
1704        let path = path_from_d("M 0 0 A 20 10 45 0 1 20 0");
1705        let ring = &path.subpaths()[0];
1706        let phi = 45f32.to_radians();
1707        let (cos_p, sin_p) = (phi.cos(), phi.sin());
1708        let (x1p, y1p) = (cos_p * -10.0, -sin_p * -10.0);
1709        let num = 400.0 * 100.0 - 400.0 * y1p * y1p - 100.0 * x1p * x1p;
1710        let den = 400.0 * y1p * y1p + 100.0 * x1p * x1p;
1711        let coef = (num / den).max(0.0).sqrt();
1712        // large(0) != sweep(1), so the center is offset by +coef.
1713        let (cxp, cyp) = (coef * (20.0 * y1p / 10.0), coef * (-10.0 * x1p / 20.0));
1714        let (cx, cy) = (cos_p * cxp - sin_p * cyp + 10.0, sin_p * cxp + cos_p * cyp);
1715        for &(x, y) in ring {
1716            let (dx, dy) = (x - cx, y - cy);
1717            let (rx_local, ry_local) = (dx * cos_p + dy * sin_p, -dx * sin_p + dy * cos_p);
1718            let v = (rx_local / 20.0).powi(2) + (ry_local / 10.0).powi(2);
1719            assert!((v - 1.0).abs() < 0.05, "point ({x},{y}) off ellipse: {v}");
1720        }
1721    }
1722
1723    fn assert_close(a: (f32, f32), b: (f32, f32)) {
1724        assert!(
1725            (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3,
1726            "expected {b:?}, got {a:?}"
1727        );
1728    }
1729
1730    #[test]
1731    fn relative_arc_and_radii_of_zero_fall_back_to_lines() {
1732        // Relative `a` moves the endpoint by the current point.
1733        let path = path_from_d("M 0 0 a 5 5 0 0 1 6 0");
1734        assert_eq!(path.subpaths()[0].last().copied(), Some((6.0, 0.0)));
1735        // A zero radius degenerates to a straight line.
1736        let degenerate = path_from_d("M 0 0 A 0 0 0 0 1 10 10");
1737        assert_eq!(degenerate.subpaths(), vec![vec![(0.0, 0.0), (10.0, 10.0)]]);
1738    }
1739
1740    #[test]
1741    fn path_number_lexer_handles_exponents_commas_and_fractions() {
1742        let path = path_from_d("M1e2,1.5 L2E1,-.25 z");
1743        assert_eq!(path.subpaths(), vec![vec![(100.0, 1.5), (20.0, -0.25)]]);
1744    }
1745
1746    #[test]
1747    fn parse_view_box_defaults_missing_components_to_zero() {
1748        assert_eq!(parse_view_box("0 0 100 50"), (0.0, 0.0, 100.0, 50.0));
1749        assert_eq!(parse_view_box("10,20,30,40"), (10.0, 20.0, 30.0, 40.0));
1750        assert_eq!(parse_view_box("5 6"), (5.0, 6.0, 0.0, 0.0));
1751        assert_eq!(parse_view_box(""), (0.0, 0.0, 0.0, 0.0));
1752    }
1753
1754    #[test]
1755    fn parse_svg_fill_resolves_hex_named_and_function_colors() {
1756        let black = Color::default();
1757        assert_eq!(
1758            parse_svg_fill("#ff0000", ColorScheme::Light, black),
1759            Some(Color(255, 0, 0, 255))
1760        );
1761        assert_eq!(
1762            parse_svg_fill("red", ColorScheme::Light, black),
1763            Some(Color(255, 0, 0, 255))
1764        );
1765        assert_eq!(
1766            parse_svg_fill("rgb(0, 255, 0)", ColorScheme::Light, black),
1767            Some(Color(0, 255, 0, 255))
1768        );
1769    }
1770
1771    #[test]
1772    fn parse_svg_fill_resolves_current_color_and_transparency() {
1773        let text_color = Color(1, 2, 3, 255);
1774        assert_eq!(
1775            parse_svg_fill("currentColor", ColorScheme::Light, text_color),
1776            Some(text_color)
1777        );
1778        assert_eq!(
1779            parse_svg_fill("none", ColorScheme::Light, text_color),
1780            Some(Color(0, 0, 0, 0))
1781        );
1782        assert_eq!(
1783            parse_svg_fill("transparent", ColorScheme::Light, text_color),
1784            Some(Color(0, 0, 0, 0))
1785        );
1786        assert_eq!(
1787            parse_svg_fill("gibberish", ColorScheme::Light, text_color),
1788            None
1789        );
1790    }
1791
1792    #[test]
1793    fn resolve_shape_fill_prefers_attribute_over_style() {
1794        let el = element("path", &[("fill", "#ff0000"), ("style", "fill:#00ff00")]);
1795        assert_eq!(
1796            resolve_shape_fill(&el, ColorScheme::Light, Color::default()),
1797            Color(255, 0, 0, 255)
1798        );
1799        let style_only = element("path", &[("style", "fill: rgb(0, 0, 255)")]);
1800        assert_eq!(
1801            resolve_shape_fill(&style_only, ColorScheme::Light, Color::default()),
1802            Color(0, 0, 255, 255)
1803        );
1804        let inheriting = element("path", &[("fill", "currentColor")]);
1805        assert_eq!(
1806            resolve_shape_fill(&inheriting, ColorScheme::Light, Color(9, 8, 7, 255)),
1807            Color(9, 8, 7, 255)
1808        );
1809    }
1810
1811    #[test]
1812    fn resolve_fill_rule_supports_attribute_and_style() {
1813        assert!(matches!(
1814            resolve_fill_rule(&element("path", &[])),
1815            FillRule::NonZero
1816        ));
1817        assert!(matches!(
1818            resolve_fill_rule(&element("path", &[("fill-rule", "evenodd")])),
1819            FillRule::EvenOdd
1820        ));
1821        assert!(matches!(
1822            resolve_fill_rule(&element("path", &[("style", "fill-rule:evenodd")])),
1823            FillRule::EvenOdd
1824        ));
1825    }
1826
1827    #[test]
1828    fn resolve_stroke_defaults_to_none_without_any_declared_stroke() {
1829        assert_eq!(
1830            resolve_stroke(
1831                &element("path", &[]),
1832                ColorScheme::Light,
1833                Color::default(),
1834                &HashMap::new(),
1835                None
1836            ),
1837            None
1838        );
1839        // stroke-width on its own does not invent a stroke.
1840        assert_eq!(
1841            resolve_stroke(
1842                &element("path", &[("stroke-width", "2")]),
1843                ColorScheme::Light,
1844                Color::default(),
1845                &HashMap::new(),
1846                None
1847            ),
1848            None
1849        );
1850    }
1851
1852    #[test]
1853    fn resolve_stroke_honors_declared_parameters_including_style() {
1854        let el = element(
1855            "line",
1856            &[
1857                ("stroke", "#ff0000"),
1858                ("stroke-width", "3"),
1859                ("stroke-linecap", "round"),
1860                ("stroke-linejoin", "bevel"),
1861                ("stroke-miterlimit", "8"),
1862                ("stroke-dasharray", "4 2"),
1863                ("stroke-dashoffset", "1"),
1864            ],
1865        );
1866        let stroke = resolve_stroke(
1867            &el,
1868            ColorScheme::Light,
1869            Color::default(),
1870            &HashMap::new(),
1871            None,
1872        )
1873        .expect("stroke");
1874        assert_eq!(stroke.color, Color(255, 0, 0, 255));
1875        assert_eq!(stroke.width, 3.0);
1876        assert!(matches!(stroke.cap, StrokeCap::Round));
1877        assert!(matches!(stroke.join, StrokeJoin::Bevel));
1878        assert_eq!(stroke.miter_limit, 8.0);
1879        assert_eq!(stroke.dash, Some(vec![4.0, 2.0]));
1880        assert_eq!(stroke.dash_offset, 1.0);
1881
1882        // A style declaration drives every stroke parameter too.
1883        let style = element(
1884            "path",
1885            &[(
1886                "style",
1887                "stroke:#00ff00;stroke-width:5;stroke-linecap:square;\
1888                 stroke-linejoin:round;stroke-dasharray:none",
1889            )],
1890        );
1891        let stroke = resolve_stroke(
1892            &style,
1893            ColorScheme::Light,
1894            Color::default(),
1895            &HashMap::new(),
1896            None,
1897        )
1898        .expect("stroke");
1899        assert_eq!(stroke.color, Color(0, 255, 0, 255));
1900        assert!(matches!(stroke.cap, StrokeCap::Square));
1901        assert!(matches!(stroke.join, StrokeJoin::Round));
1902        assert_eq!(stroke.dash, None);
1903    }
1904
1905    #[test]
1906    fn resolve_stroke_none_and_namewidth_zero_suppress() {
1907        assert_eq!(
1908            resolve_stroke(
1909                &element("path", &[("stroke", "none")]),
1910                ColorScheme::Light,
1911                Color::default(),
1912                &HashMap::new(),
1913                None
1914            ),
1915            None
1916        );
1917        assert_eq!(
1918            resolve_stroke(
1919                &element("path", &[("stroke", "red"), ("stroke-width", "0")]),
1920                ColorScheme::Light,
1921                Color::default(),
1922                &HashMap::new(),
1923                None
1924            ),
1925            None
1926        );
1927        assert_eq!(
1928            resolve_stroke(
1929                &element("path", &[("stroke", "transparent")]),
1930                ColorScheme::Light,
1931                Color::default(),
1932                &HashMap::new(),
1933                None
1934            ),
1935            None
1936        );
1937    }
1938
1939    #[test]
1940    fn resolve_stroke_inherits_through_containers_until_overridden() {
1941        let parent = element(
1942            "g",
1943            &[
1944                ("stroke", "#00ff00"),
1945                ("stroke-width", "2"),
1946                ("stroke-linecap", "round"),
1947            ],
1948        );
1949        let inherited = resolve_stroke(
1950            &parent,
1951            ColorScheme::Light,
1952            Color::default(),
1953            &HashMap::new(),
1954            None,
1955        )
1956        .expect("stroke");
1957        // A child that declares nothing keeps the inherited stroke...
1958        let child = resolve_stroke(
1959            &element("rect", &[]),
1960            ColorScheme::Light,
1961            Color::default(),
1962            &HashMap::new(),
1963            Some(&inherited),
1964        )
1965        .expect("inherits");
1966        assert_eq!(child.color, Color(0, 255, 0, 255));
1967        assert_eq!(child.width, 2.0);
1968        assert!(matches!(child.cap, StrokeCap::Round));
1969        // ...one that sets stroke:none drops it entirely.
1970        assert_eq!(
1971            resolve_stroke(
1972                &element("rect", &[("stroke", "none")]),
1973                ColorScheme::Light,
1974                Color::default(),
1975                &HashMap::new(),
1976                Some(&inherited)
1977            ),
1978            None
1979        );
1980        // currentColor resolves against the passed text color.
1981        let cc = resolve_stroke(
1982            &element("line", &[("stroke", "currentColor")]),
1983            ColorScheme::Light,
1984            Color(9, 8, 7, 255),
1985            &HashMap::new(),
1986            None,
1987        )
1988        .expect("currentColor");
1989        assert_eq!(cc.color, Color(9, 8, 7, 255));
1990    }
1991
1992    #[test]
1993    fn parse_dasharray_handles_none_lists_and_odd_patterns() {
1994        assert_eq!(parse_dasharray("none"), None);
1995        assert_eq!(parse_dasharray(""), None);
1996        assert_eq!(parse_dasharray("0 0"), None);
1997        assert_eq!(parse_dasharray("4,2"), Some(vec![4.0, 2.0]));
1998        assert_eq!(parse_dasharray("4 2 1"), Some(vec![4.0, 2.0, 1.0]));
1999        assert_eq!(parse_dasharray("2,-1"), Some(vec![2.0, 0.0]));
2000        assert_eq!(parse_dasharray("bogus"), None);
2001    }
2002
2003    #[test]
2004    fn viewbox_meet_centers_content_and_preserves_aspect() {
2005        // viewBox 10x10 in a 20x10 viewport: scale 1, shifted right by 5.
2006        let t = viewbox_meet_transform((0.0, 0.0, 10.0, 10.0), 20.0, 10.0);
2007        assert!((t.m11 - 1.0).abs() < 1e-6);
2008        assert!((t.dx - 5.0).abs() < 1e-6);
2009        assert!((t.dy - 0.0).abs() < 1e-6);
2010        // A non-zero viewBox origin is mapped through the centered layout.
2011        let t = viewbox_meet_transform((2.0, 3.0, 10.0, 10.0), 20.0, 10.0);
2012        assert!((t.dx - 3.0).abs() < 1e-6);
2013        assert!((t.dy - -3.0).abs() < 1e-6);
2014        assert!(viewbox_meet_transform((0.0, 0.0, 0.0, 10.0), 5.0, 5.0).m11 == 1.0);
2015    }
2016
2017    #[test]
2018    fn emit_commands_letterboxes_and_honors_per_shape_fill_rule() {
2019        let shape = SvgShape {
2020            path: path_from_d("M0 0 L10 0 L10 10 Z"),
2021            fill: Brush::Solid(Color(255, 0, 0, 255)),
2022            rule: FillRule::EvenOdd,
2023            stroke: None,
2024            clip_path: None,
2025        };
2026        let mut buf = Vec::new();
2027        emit_commands(
2028            &mut buf,
2029            (0.0, 0.0, 20.0, 10.0),
2030            false,
2031            (0.0, 0.0, 10.0, 10.0),
2032            std::slice::from_ref(&shape),
2033            0.5,
2034        );
2035        assert_eq!(buf.len(), 3);
2036        let DrawCommand::PushTransform { transform } = &buf[0] else {
2037            panic!("expected transform");
2038        };
2039        assert!((transform.m11 - 1.0).abs() < 1e-6);
2040        assert!((transform.dx - 5.0).abs() < 1e-6);
2041        let DrawCommand::Fill { paint, rule, .. } = &buf[1] else {
2042            panic!("expected fill");
2043        };
2044        assert!(matches!(*rule, FillRule::EvenOdd));
2045        assert!((paint.opacity - 0.5).abs() < 1e-6);
2046        assert!(matches!(&buf[2], DrawCommand::PopTransform));
2047    }
2048
2049    #[test]
2050    fn emit_commands_paints_inline_at_absolute_position() {
2051        let shape = SvgShape {
2052            path: path_from_d("M0 0 L10 0 L10 10 Z"),
2053            fill: Brush::Solid(Color(255, 0, 0, 255)),
2054            rule: FillRule::NonZero,
2055            stroke: None,
2056            clip_path: None,
2057        };
2058        let mut buf = Vec::new();
2059        emit_commands(
2060            &mut buf,
2061            (30.0, 40.0, 10.0, 10.0),
2062            true,
2063            (0.0, 0.0, 10.0, 10.0),
2064            std::slice::from_ref(&shape),
2065            1.0,
2066        );
2067        let DrawCommand::PushTransform { transform } = &buf[0] else {
2068            panic!("expected transform");
2069        };
2070        assert!((transform.dx - 30.0).abs() < 1e-6);
2071        assert!((transform.dy - 40.0).abs() < 1e-6);
2072    }
2073
2074    #[test]
2075    fn emit_commands_paints_nothing_for_degenerate_box() {
2076        let shape = SvgShape {
2077            path: path_from_d("M0 0 L10 0 L10 10 Z"),
2078            fill: Brush::Solid(Color(255, 0, 0, 255)),
2079            rule: FillRule::NonZero,
2080            stroke: None,
2081            clip_path: None,
2082        };
2083        let mut buf = Vec::new();
2084        emit_commands(
2085            &mut buf,
2086            (0.0, 0.0, 0.0, 10.0),
2087            false,
2088            (0.0, 0.0, 10.0, 10.0),
2089            std::slice::from_ref(&shape),
2090            1.0,
2091        );
2092        assert!(buf.is_empty());
2093    }
2094
2095    #[test]
2096    fn collect_svg_gathers_paths_with_fill_rule_and_opacity() {
2097        let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
2098        TreeNode::add_child(
2099            &svg,
2100            TreeNode::new(element(
2101                "path",
2102                &[("d", "M0 0 L10 0 L10 10 Z"), ("fill", "currentColor")],
2103            )),
2104        );
2105        TreeNode::add_child(
2106            &svg,
2107            TreeNode::new(element(
2108                "path",
2109                &[
2110                    ("d", "M1 1 L5 1 L5 5 Z"),
2111                    ("fill", "#00ff00"),
2112                    ("fill-rule", "evenodd"),
2113                    ("fill-opacity", "0.5"),
2114                ],
2115            )),
2116        );
2117        TreeNode::add_child(&svg, TreeNode::new(element("rect", &[("x", "1")])));
2118
2119        let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
2120        let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color(1, 2, 3, 255)).unwrap();
2121
2122        assert_eq!(content.view_box, (0.0, 0.0, 10.0, 10.0));
2123        assert_eq!(content.shapes.len(), 2);
2124        assert_eq!(content.shapes[0].fill, Brush::Solid(Color(1, 2, 3, 255)));
2125        assert!(matches!(content.shapes[0].rule, FillRule::NonZero));
2126        assert_eq!(content.shapes[1].fill, Brush::Solid(Color(0, 255, 0, 128)));
2127        assert!(matches!(content.shapes[1].rule, FillRule::EvenOdd));
2128    }
2129
2130    #[test]
2131    fn collect_svg_returns_none_without_usable_viewbox_or_paths() {
2132        let no_viewbox = TreeNode::new(element("svg", &[]));
2133        let (snapshot, _) = DomSnapshot::from_tree(&no_viewbox);
2134        assert!(collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).is_none());
2135
2136        let empty = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
2137        let (snapshot, _) = DomSnapshot::from_tree(&empty);
2138        assert!(collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).is_none());
2139    }
2140
2141    #[test]
2142    fn collect_svg_walks_groups_applies_transforms_and_shapes() {
2143        let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 12 8")]));
2144        let g = TreeNode::new(element(
2145            "g",
2146            &[("transform", "translate(2 1)"), ("opacity", "0.5")],
2147        ));
2148        TreeNode::add_child(&svg, Rc::clone(&g));
2149        TreeNode::add_child(
2150            &g,
2151            TreeNode::new(element(
2152                "path",
2153                &[("d", "M1 1 L5 1 L3 5 Z"), ("fill", "#ff0000")],
2154            )),
2155        );
2156        TreeNode::add_child(
2157            &g,
2158            TreeNode::new(element(
2159                "rect",
2160                &[
2161                    ("x", "0"),
2162                    ("y", "6"),
2163                    ("width", "3"),
2164                    ("height", "2"),
2165                    ("fill", "#00ff00"),
2166                ],
2167            )),
2168        );
2169        TreeNode::add_child(&svg, TreeNode::new(element("circle", &[("r", "2")])));
2170
2171        let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
2172        let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color(9, 8, 7, 255)).unwrap();
2173
2174        assert_eq!(content.shapes.len(), 3);
2175        // Path vertices shifted by translate(2 1).
2176        assert_eq!(
2177            content.shapes[0].path.subpaths()[0][..3],
2178            [(3.0, 2.0), (7.0, 2.0), (5.0, 6.0)]
2179        );
2180        // 0.5 group opacity folds into the shape alpha.
2181        assert_eq!(content.shapes[0].fill, Brush::Solid(Color(255, 0, 0, 128)));
2182        // Rect corners translated into viewBox space.
2183        assert_eq!(
2184            content.shapes[1].path.subpaths()[0][..3],
2185            [(2.0, 7.0), (5.0, 7.0), (5.0, 9.0)]
2186        );
2187        // circle with no cx/cy defaults to the origin.
2188        assert_eq!(
2189            content.shapes[2].path.bounding_box().unwrap(),
2190            crate::engine::renderer_model::Rect::new(-2.0, -2.0, 4.0, 4.0)
2191        );
2192    }
2193
2194    #[test]
2195    fn collect_svg_supports_polygon_polyline_line_and_rounded_rect() {
2196        let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 20 20")]));
2197        TreeNode::add_child(
2198            &svg,
2199            TreeNode::new(element(
2200                "rect",
2201                &[
2202                    ("x", "1"),
2203                    ("y", "2"),
2204                    ("width", "8"),
2205                    ("height", "6"),
2206                    ("rx", "2"),
2207                    ("ry", "2"),
2208                ],
2209            )),
2210        );
2211        TreeNode::add_child(
2212            &svg,
2213            TreeNode::new(element("polygon", &[("points", "0,0 10,0 10,10")])),
2214        );
2215        TreeNode::add_child(
2216            &svg,
2217            TreeNode::new(element("polyline", &[("points", "0,0 5,5 10,0")])),
2218        );
2219        TreeNode::add_child(
2220            &svg,
2221            TreeNode::new(element(
2222                "line",
2223                &[("x1", "0"), ("y1", "0"), ("x2", "10"), ("y2", "0")],
2224            )),
2225        );
2226
2227        let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
2228        let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).unwrap();
2229        assert_eq!(content.shapes.len(), 4);
2230        // Rounded rect keeps its corners on the radius-2 arcs. The top-right arc is
2231        // centered at the corner inset by rx/ry: (7, 4) with radius 2.
2232        let arc_ok = content.shapes[0].path.subpaths()[0].iter().any(|&(x, y)| {
2233            ((x - 7.0).powi(2) + (y - 4.0).powi(2) - 4.0).abs() < 0.1 && x > 7.5 && y < 3.0
2234        });
2235        assert!(
2236            arc_ok,
2237            "rounded rect must keep arc points on the corner circle"
2238        );
2239        // Polygon closes implicitly, so its vertices are a closed ring.
2240        assert_eq!(
2241            content.shapes[1].path.subpaths()[0][..3],
2242            [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]
2243        );
2244        assert_eq!(
2245            content.shapes[2].path.subpaths()[0][..3],
2246            [(0.0, 0.0), (5.0, 5.0), (10.0, 0.0)]
2247        );
2248        assert_eq!(
2249            content.shapes[3].path.subpaths()[0][..2],
2250            [(0.0, 0.0), (10.0, 0.0)]
2251        );
2252    }
2253
2254    #[test]
2255    fn collect_svg_inherits_stroke_and_fill_through_containers() {
2256        let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
2257        let g = TreeNode::new(element(
2258            "g",
2259            &[
2260                ("fill", "#0000ff"),
2261                ("stroke", "#00ff00"),
2262                ("stroke-width", "2"),
2263                ("stroke-linecap", "round"),
2264            ],
2265        ));
2266        TreeNode::add_child(&svg, Rc::clone(&g));
2267        let inherits = TreeNode::new(element(
2268            "line",
2269            &[("x1", "0"), ("y1", "0"), ("x2", "5"), ("y2", "0")],
2270        ));
2271        let overrides = TreeNode::new(element(
2272            "rect",
2273            &[
2274                ("x", "0"),
2275                ("y", "1"),
2276                ("width", "3"),
2277                ("height", "3"),
2278                ("stroke", "none"),
2279            ],
2280        ));
2281        TreeNode::add_child(&g, inherits);
2282        TreeNode::add_child(&g, overrides);
2283
2284        let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
2285        let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).unwrap();
2286        assert_eq!(content.shapes.len(), 2);
2287
2288        let inherited_stroke = content.shapes[0].stroke.as_ref().expect("stroke inherited");
2289        assert_eq!(inherited_stroke.color, Color(0, 255, 0, 255));
2290        assert_eq!(inherited_stroke.width, 2.0);
2291        assert!(matches!(inherited_stroke.cap, StrokeCap::Round));
2292        assert_eq!(content.shapes[0].fill, Brush::Solid(Color(0, 0, 255, 255)));
2293
2294        assert_eq!(
2295            content.shapes[1].stroke, None,
2296            "stroke:none cancels inheritance"
2297        );
2298        assert_eq!(content.shapes[1].fill, Brush::Solid(Color(0, 0, 255, 255)));
2299    }
2300
2301    #[test]
2302    fn emit_commands_paints_a_stroke_outline_after_the_fill() {
2303        let shape = SvgShape {
2304            path: path_from_d("M0 5 L10 5"),
2305            fill: Brush::Solid(Color(0, 0, 0, 0)),
2306            rule: FillRule::NonZero,
2307            stroke: Some(SvgStroke {
2308                color: Color(255, 0, 0, 255),
2309                gradient: None,
2310                width: 2.0,
2311                cap: StrokeCap::Butt,
2312                join: StrokeJoin::Miter,
2313                miter_limit: 4.0,
2314                dash: None,
2315                dash_offset: 0.0,
2316            }),
2317            clip_path: None,
2318        };
2319        let mut buf = Vec::new();
2320        emit_commands(
2321            &mut buf,
2322            (0.0, 0.0, 10.0, 10.0),
2323            false,
2324            (0.0, 0.0, 10.0, 10.0),
2325            std::slice::from_ref(&shape),
2326            1.0,
2327        );
2328        // Push + shape fill + stroke fill + pop.
2329        assert_eq!(buf.len(), 4);
2330        let DrawCommand::Fill {
2331            paint: fill_paint, ..
2332        } = &buf[1]
2333        else {
2334            panic!("expected shape fill");
2335        };
2336        let fill_alpha = match &fill_paint.brush {
2337            Brush::Solid(color) => color.3,
2338            _ => panic!("solid"),
2339        };
2340        assert_eq!(fill_alpha, 0);
2341        let DrawCommand::Fill {
2342            path: stroke_path,
2343            paint,
2344            rule,
2345        } = &buf[2]
2346        else {
2347            panic!("expected stroke fill");
2348        };
2349        assert!(matches!(rule, FillRule::NonZero));
2350        let Brush::Solid(color) = &paint.brush else {
2351            panic!("solid stroke");
2352        };
2353        assert_eq!(*color, Color(255, 0, 0, 255));
2354        // The expanded outline hugs the horizontal line: width-2 stroke spans
2355        // y in [-1, 1] about the line at y=5.
2356        let bb = stroke_path.bounding_box().unwrap();
2357        assert!((bb.y - 4.0).abs() < 1e-3);
2358        assert!((bb.height - 2.0).abs() < 1e-3);
2359        assert!((bb.x - 0.0).abs() < 1e-3);
2360        assert!((bb.width - 10.0).abs() < 1e-3);
2361    }
2362
2363    #[test]
2364    fn parse_transform_composes_functions_left_to_right() {
2365        let t = parse_transform("translate(10, 20) scale(2)").expect("parses");
2366        assert_eq!(t.apply(1.0, 1.0), (12.0, 22.0));
2367        let rotated = parse_transform("rotate(90)").expect("parses");
2368        assert!((rotated.apply(1.0, 0.0).0).abs() < 1e-5);
2369        assert!((rotated.apply(1.0, 0.0).1 - 1.0).abs() < 1e-5);
2370        assert!(parse_transform("bogus(1)").is_err());
2371    }
2372
2373    /// Rasterizes `DrawCommand`s with a transform stack, mirroring how the GPU
2374    /// renderer lays out `PushTransform`/`Fill`/`PopTransform`.
2375    fn rasterize_commands(commands: &[DrawCommand], width: u32, height: u32) -> Vec<u8> {
2376        let mut rgba = vec![0u8; width as usize * height as usize * 4];
2377        let mut stack = vec![AffineTransform::identity()];
2378        let mut clip_stack: Vec<(Path, FillRule)> = Vec::new();
2379        for command in commands {
2380            match command {
2381                DrawCommand::PushTransform { transform } => {
2382                    let next = stack.last().unwrap().then(transform);
2383                    stack.push(next);
2384                }
2385                DrawCommand::PopTransform => {
2386                    stack.pop();
2387                }
2388                DrawCommand::PushClip { path, rule } => {
2389                    clip_stack.push((path.clone(), *rule));
2390                }
2391                DrawCommand::PopClip => {
2392                    clip_stack.pop();
2393                }
2394                DrawCommand::Fill { path, paint, rule } => {
2395                    let Brush::Solid(color) = &paint.brush else {
2396                        continue;
2397                    };
2398                    let fill = with_alpha(*color, paint.opacity);
2399                    let transformed = transform_path(path, stack.last().unwrap());
2400                    let clip_mask = if !clip_stack.is_empty() {
2401                        let mut combined = vec![255u8; (width as usize) * (height as usize) * 4];
2402                        for (clip_path, clip_rule) in &clip_stack {
2403                            let mut tmp = vec![0u8; (width as usize) * (height as usize) * 4];
2404                            let clip_transformed =
2405                                transform_path(clip_path, stack.last().unwrap());
2406                            super::rasterize::fill_path_into(
2407                                &mut tmp,
2408                                width,
2409                                height,
2410                                &clip_transformed,
2411                                Color(255, 255, 255, 255),
2412                                *clip_rule,
2413                                None,
2414                            );
2415                            // Intersect: combined alpha *= tmp alpha.
2416                            for i in (3..combined.len()).step_by(4) {
2417                                combined[i] =
2418                                    ((combined[i] as u16 * tmp[i] as u16) / 255) as u8;
2419                            }
2420                        }
2421                        Some(combined)
2422                    } else {
2423                        None
2424                    };
2425                    super::rasterize::fill_path_into(
2426                        &mut rgba,
2427                        width,
2428                        height,
2429                        &transformed,
2430                        fill,
2431                        *rule,
2432                        clip_mask.as_deref(),
2433                    );
2434                }
2435                _ => {}
2436            }
2437        }
2438        rgba
2439    }
2440
2441    #[test]
2442    fn parse_svg_fill_url_reference_is_handled_without_color_pipeline() {
2443        // `url(#id)` names a gradient, not a color: it must not trip the CSS
2444        // color pipeline's error log and resolves to `None` here.
2445        assert_eq!(
2446            parse_svg_fill(
2447                "url(#blinearGradient971)",
2448                ColorScheme::Light,
2449                Color::default()
2450            ),
2451            None
2452        );
2453        assert_eq!(
2454            parse_svg_url("url(#gradient-1)"),
2455            Some("gradient-1".to_owned())
2456        );
2457        assert_eq!(
2458            parse_svg_url("url(\"quoted-id\")"),
2459            Some("quoted-id".to_owned())
2460        );
2461        assert_eq!(parse_svg_url("#ff0000"), None);
2462        assert_eq!(parse_svg_url("red"), None);
2463    }
2464
2465    #[test]
2466    fn collect_svg_resolves_url_fill_to_gradient_brushes() {
2467        let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
2468        let defs = TreeNode::new(element("defs", &[]));
2469        TreeNode::add_child(&svg, Rc::clone(&defs));
2470
2471        let linear = TreeNode::new(element(
2472            "linearGradient",
2473            &[
2474                ("id", "g1"),
2475                ("x1", "0"),
2476                ("y1", "0"),
2477                ("x2", "1"),
2478                ("y2", "0"),
2479                ("spreadMethod", "reflect"),
2480            ],
2481        ));
2482        TreeNode::add_child(
2483            &linear,
2484            TreeNode::new(element(
2485                "stop",
2486                &[("offset", "0"), ("stop-color", "#ff0000")],
2487            )),
2488        );
2489        TreeNode::add_child(
2490            &linear,
2491            TreeNode::new(element(
2492                "stop",
2493                &[
2494                    ("offset", "1"),
2495                    ("style", "stop-color:#0000ff;stop-opacity:0.5"),
2496                ],
2497            )),
2498        );
2499        TreeNode::add_child(&defs, linear);
2500
2501        let radial = TreeNode::new(element(
2502            "radialGradient",
2503            &[
2504                ("id", "g2"),
2505                ("gradientUnits", "userSpaceOnUse"),
2506                ("cx", "5"),
2507                ("cy", "5"),
2508                ("r", "5"),
2509                ("fx", "4"),
2510                ("fy", "4"),
2511            ],
2512        ));
2513        TreeNode::add_child(
2514            &radial,
2515            TreeNode::new(element("stop", &[("offset", "0"), ("stop-color", "green")])),
2516        );
2517        TreeNode::add_child(
2518            &radial,
2519            TreeNode::new(element(
2520                "stop",
2521                &[("offset", "100%"), ("stop-color", "blue")],
2522            )),
2523        );
2524        TreeNode::add_child(&defs, radial);
2525
2526        TreeNode::add_child(
2527            &svg,
2528            TreeNode::new(element(
2529                "rect",
2530                &[
2531                    ("x", "1"),
2532                    ("y", "1"),
2533                    ("width", "8"),
2534                    ("height", "8"),
2535                    ("fill", "url(#g1)"),
2536                ],
2537            )),
2538        );
2539        TreeNode::add_child(
2540            &svg,
2541            TreeNode::new(element(
2542                "circle",
2543                &[("cx", "5"), ("cy", "5"), ("r", "4"), ("fill", "url(#g2)")],
2544            )),
2545        );
2546
2547        let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
2548        let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).unwrap();
2549        assert_eq!(content.shapes.len(), 2);
2550
2551        let Brush::Gradient(g1) = &content.shapes[0].fill else {
2552            panic!("rect should be a gradient fill");
2553        };
2554        assert!(matches!(
2555            g1.kind,
2556            GradientKind::SvgLinear {
2557                x1: 0.0,
2558                y1: 0.0,
2559                x2: 1.0,
2560                y2: 0.0,
2561                user_space: false
2562            }
2563        ));
2564        assert_eq!(g1.spread, GradientSpread::Reflect);
2565        assert_eq!(g1.stops.len(), 2);
2566        assert_eq!(g1.stops[0].color, Color(255, 0, 0, 255));
2567        assert_eq!(g1.stops[0].position, Some(0.0));
2568        assert_eq!(g1.stops[1].color, Color(0, 0, 255, 128));
2569        assert_eq!(g1.stops[1].position, Some(1.0));
2570
2571        let Brush::Gradient(g2) = &content.shapes[1].fill else {
2572            panic!("circle should be a gradient fill");
2573        };
2574        assert!(matches!(
2575            g2.kind,
2576            GradientKind::SvgRadial {
2577                cx: 5.0,
2578                cy: 5.0,
2579                r: 5.0,
2580                fx: 4.0,
2581                fy: 4.0,
2582                fr: 0.0,
2583                user_space: true
2584            }
2585        ));
2586        assert_eq!(g2.spread, GradientSpread::Pad);
2587        assert_eq!(g2.stops.len(), 2);
2588        assert_eq!(g2.stops[0].color, Color(0, 128, 0, 255));
2589        assert_eq!(g2.stops[1].position, Some(1.0));
2590    }
2591
2592    #[test]
2593    fn collect_svg_unresolved_url_fill_paints_nothing() {
2594        let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
2595        TreeNode::add_child(
2596            &svg,
2597            TreeNode::new(element(
2598                "rect",
2599                &[
2600                    ("x", "1"),
2601                    ("y", "1"),
2602                    ("width", "8"),
2603                    ("height", "8"),
2604                    ("fill", "url(#missing)"),
2605                ],
2606            )),
2607        );
2608        let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
2609        let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).unwrap();
2610        assert_eq!(content.shapes[0].fill, Brush::Solid(Color(0, 0, 0, 0)));
2611    }
2612
2613    #[test]
2614    fn resolve_stroke_resolves_url_paint_servers() {
2615        let mut gradients = HashMap::new();
2616        gradients.insert(
2617            "g".to_owned(),
2618            Gradient {
2619                kind: GradientKind::SvgLinear {
2620                    x1: 0.0,
2621                    y1: 0.0,
2622                    x2: 1.0,
2623                    y2: 0.0,
2624                    user_space: false,
2625                },
2626                stops: vec![ColorStop {
2627                    color: Color(255, 0, 0, 255),
2628                    position: Some(0.0),
2629                }],
2630                spread: GradientSpread::Pad,
2631            },
2632        );
2633        let stroke = resolve_stroke(
2634            &element("path", &[("stroke", "url(#g)"), ("stroke-width", "2")]),
2635            ColorScheme::Light,
2636            Color::default(),
2637            &gradients,
2638            None,
2639        )
2640        .expect("gradient stroke");
2641        assert!(stroke.gradient.is_some());
2642        assert_eq!(stroke.width, 2.0);
2643        // An unresolved reference paints nothing.
2644        assert_eq!(
2645            resolve_stroke(
2646                &element("path", &[("stroke", "url(#nope)")]),
2647                ColorScheme::Light,
2648                Color::default(),
2649                &HashMap::new(),
2650                None,
2651            ),
2652            None
2653        );
2654    }
2655
2656    #[test]
2657    fn emit_commands_paints_gradient_brushes_for_fill_and_stroke() {
2658        let shape = SvgShape {
2659            path: path_from_d("M0 0 L10 0 L10 10 Z"),
2660            fill: Brush::Gradient(Gradient {
2661                kind: GradientKind::SvgLinear {
2662                    x1: 0.0,
2663                    y1: 0.0,
2664                    x2: 1.0,
2665                    y2: 0.0,
2666                    user_space: false,
2667                },
2668                stops: vec![
2669                    ColorStop {
2670                        color: Color(255, 0, 0, 255),
2671                        position: Some(0.0),
2672                    },
2673                    ColorStop {
2674                        color: Color(0, 0, 255, 255),
2675                        position: Some(1.0),
2676                    },
2677                ],
2678                spread: GradientSpread::Pad,
2679            }),
2680            rule: FillRule::NonZero,
2681            stroke: Some(SvgStroke {
2682                color: Color(0, 0, 0, 255),
2683                gradient: Some(Gradient {
2684                    kind: GradientKind::SvgLinear {
2685                        x1: 0.0,
2686                        y1: 0.0,
2687                        x2: 1.0,
2688                        y2: 0.0,
2689                        user_space: false,
2690                    },
2691                    stops: vec![ColorStop {
2692                        color: Color(0, 255, 0, 255),
2693                        position: Some(0.0),
2694                    }],
2695                    spread: GradientSpread::Pad,
2696                }),
2697                width: 2.0,
2698                cap: StrokeCap::Butt,
2699                join: StrokeJoin::Miter,
2700                miter_limit: 4.0,
2701                dash: None,
2702                dash_offset: 0.0,
2703            }),
2704            clip_path: None,
2705        };
2706        let mut buf = Vec::new();
2707        emit_commands(
2708            &mut buf,
2709            (0.0, 0.0, 10.0, 10.0),
2710            false,
2711            (0.0, 0.0, 10.0, 10.0),
2712            std::slice::from_ref(&shape),
2713            1.0,
2714        );
2715        let DrawCommand::Fill { paint, .. } = &buf[1] else {
2716            panic!("expected fill");
2717        };
2718        assert!(matches!(paint.brush, Brush::Gradient(_)));
2719        let DrawCommand::Fill {
2720            paint: stroke_paint,
2721            ..
2722        } = &buf[2]
2723        else {
2724            panic!("expected stroke fill");
2725        };
2726        assert!(matches!(stroke_paint.brush, Brush::Gradient(_)));
2727    }
2728
2729    #[test]
2730    fn inline_content_rasterizes_identically_to_standalone_document() {
2731        // The same geometry as serialized SVG (rasterizer) and as inline SVG
2732        // content (`collect_svg` → `emit_commands` → software fill) must yield
2733        // byte-identical pixels: same transforms, opacity folding, and fill
2734        // rule at both layers.
2735        let markup = r##"<svg width="24" height="12" viewBox="0 0 24 12">
2736            <g transform="translate(2 2) skewX(10)" opacity="0.8">
2737              <path d="M2 2 L22 6 L2 10 Z M8 4 L16 4 L12 6 Z" fill="#ff0000" fill-rule="evenodd"/>
2738              <rect x="0" y="0" width="4" height="12" fill="#00ff00" fill-opacity="0.5"/>
2739              <circle cx="20" cy="3" r="2" fill="#0000ff"/>
2740            </g>
2741            <g transform="translate(4 6)">
2742              <line x1="0" y1="2" x2="18" y2="2" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-dasharray="4 2" fill="none"/>
2743              <rect x="0" y="0" width="6" height="4" fill="none" stroke="#00ffff" stroke-width="1"/>
2744            </g>
2745          </svg>"##;
2746        let standalone = super::rasterize::rasterize(markup, Some((24, 12))).expect("rasterizes");
2747
2748        let svg = TreeNode::new(element(
2749            "svg",
2750            &[("width", "24"), ("height", "12"), ("viewBox", "0 0 24 12")],
2751        ));
2752        let g = TreeNode::new(element(
2753            "g",
2754            &[
2755                ("transform", "translate(2 2) skewX(10)"),
2756                ("opacity", "0.8"),
2757            ],
2758        ));
2759        TreeNode::add_child(&svg, Rc::clone(&g));
2760        TreeNode::add_child(
2761            &g,
2762            TreeNode::new(element(
2763                "path",
2764                &[
2765                    ("d", "M2 2 L22 6 L2 10 Z M8 4 L16 4 L12 6 Z"),
2766                    ("fill", "#ff0000"),
2767                    ("fill-rule", "evenodd"),
2768                ],
2769            )),
2770        );
2771        TreeNode::add_child(
2772            &g,
2773            TreeNode::new(element(
2774                "rect",
2775                &[
2776                    ("x", "0"),
2777                    ("y", "0"),
2778                    ("width", "4"),
2779                    ("height", "12"),
2780                    ("fill", "#00ff00"),
2781                    ("fill-opacity", "0.5"),
2782                ],
2783            )),
2784        );
2785        TreeNode::add_child(
2786            &g,
2787            TreeNode::new(element(
2788                "circle",
2789                &[("cx", "20"), ("cy", "3"), ("r", "2"), ("fill", "#0000ff")],
2790            )),
2791        );
2792        let g2 = TreeNode::new(element("g", &[("transform", "translate(4 6)")]));
2793        TreeNode::add_child(&svg, Rc::clone(&g2));
2794        TreeNode::add_child(
2795            &g2,
2796            TreeNode::new(element(
2797                "line",
2798                &[
2799                    ("x1", "0"),
2800                    ("y1", "2"),
2801                    ("x2", "18"),
2802                    ("y2", "2"),
2803                    ("stroke", "#ffffff"),
2804                    ("stroke-width", "2"),
2805                    ("stroke-linecap", "round"),
2806                    ("stroke-dasharray", "4 2"),
2807                    ("fill", "none"),
2808                ],
2809            )),
2810        );
2811        TreeNode::add_child(
2812            &g2,
2813            TreeNode::new(element(
2814                "rect",
2815                &[
2816                    ("x", "0"),
2817                    ("y", "0"),
2818                    ("width", "6"),
2819                    ("height", "4"),
2820                    ("fill", "none"),
2821                    ("stroke", "#00ffff"),
2822                    ("stroke-width", "1"),
2823                ],
2824            )),
2825        );
2826
2827        let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
2828        let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default())
2829            .expect("collects inline SVG");
2830        let mut commands = Vec::new();
2831        emit_commands(
2832            &mut commands,
2833            (0.0, 0.0, 24.0, 12.0),
2834            false,
2835            content.view_box,
2836            &content.shapes,
2837            1.0,
2838        );
2839        let inline_pixels = rasterize_commands(&commands, 24, 12);
2840
2841        assert_eq!(
2842            inline_pixels, standalone.rgba,
2843            "inline and standalone SVG rasterization differ"
2844        );
2845        // Sanity: some pixels are actually painted.
2846        assert!(
2847            inline_pixels.chunks(4).any(|p| p[3] != 0),
2848            "expected non-transparent pixels"
2849        );
2850    }
2851
2852    /// Converts a parsed [`XmlElement`] tree into an [`HtmlNodeType`] tree so
2853    /// the inline SVG pipeline can process real SVG documents.
2854    fn xml_to_html_tree(
2855        node: &crate::engine::tree::NodeRef<crate::engine::html::xml::XmlElement>,
2856    ) -> crate::engine::tree::NodeRef<crate::engine::html::HtmlNodeType> {
2857        let el = node.borrow();
2858        let html_value = crate::engine::html::HtmlNodeType::Element {
2859            tag_name: el.value.name.clone(),
2860            attributes: el
2861                .value
2862                .attributes
2863                .iter()
2864                .map(|attr| crate::engine::html::tokenizer::Attribute {
2865                    name: attr.name.clone(),
2866                    value: attr.value.clone(),
2867                })
2868                .collect(),
2869        };
2870        let html_node = TreeNode::new(html_value);
2871        for child in el.children() {
2872            TreeNode::add_child(&html_node, xml_to_html_tree(child));
2873        }
2874        html_node
2875    }
2876
2877    /// Real-world icon SVGs must rasterize identically through the inline
2878    /// (`collect_svg` → `emit_commands`) and standalone (`rasterize_from_bytes`)
2879    /// pipelines.
2880    #[test]
2881    fn icon_svg_inline_matches_standalone() {
2882        fn run_icon_test(file_path: &str, target_size: (u32, u32)) {
2883            let svg_bytes = std::fs::read(file_path).expect("read icon svg");
2884            let markup = std::str::from_utf8(&svg_bytes).expect("valid UTF-8");
2885
2886            // Standalone rasterization.
2887            let standalone = super::rasterize::rasterize(markup, Some(target_size))
2888                .expect("rasterizes standalone");
2889
2890            // Inline pipeline: parse XML → HtmlNodeType tree → DomSnapshot →
2891            // collect_svg → emit_commands → software fill.
2892            let tree =
2893                crate::engine::html::xml::parse(markup).expect("parses SVG XML");
2894            let root = tree.root.borrow();
2895            assert_eq!(root.value.name, "svg");
2896            let html_root = xml_to_html_tree(&tree.root);
2897            let (snapshot, _refs) = DomSnapshot::from_tree(&html_root);
2898
2899            let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default())
2900                .expect("collects inline SVG");
2901            let mut commands = Vec::new();
2902            emit_commands(
2903                &mut commands,
2904                (0.0, 0.0, target_size.0 as f32, target_size.1 as f32),
2905                false,
2906                content.view_box,
2907                &content.shapes,
2908                1.0,
2909            );
2910            let inline_pixels = rasterize_commands(&commands, target_size.0, target_size.1);
2911
2912            assert_eq!(
2913                inline_pixels, standalone.rgba,
2914                "inline and standalone SVG rasterization differ for {file_path}"
2915            );
2916            assert!(
2917                inline_pixels.chunks(4).any(|p| p[3] != 0),
2918                "expected non-transparent pixels for {file_path}"
2919            );
2920        }
2921
2922        run_icon_test("resource/icons/audio_play.svg", (256, 256));
2923        run_icon_test("resource/icons/audio_stop.svg", (256, 256));
2924        run_icon_test("resource/icon.svg", (512, 512));
2925    }
2926
2927    /// Truncated path data (a command letter with missing coordinates) must not
2928    /// panic; the incomplete command is skipped defensively.
2929    #[test]
2930    fn path_from_d_truncated_command_does_not_panic() {
2931        // `M` with no following coordinates: nothing to move to.
2932        let path = path_from_d("M");
2933        assert!(path.commands().is_empty());
2934        // `L` with no coordinates: nothing to draw.
2935        let path = path_from_d("M 0 0 L");
2936        assert_eq!(path.subpaths(), vec![vec![(0.0, 0.0)]]);
2937        // Truncated cubic: missing control/end points.
2938        let path = path_from_d("M 0 0 C 10 10");
2939        assert_eq!(path.subpaths(), vec![vec![(0.0, 0.0)]]);
2940    }
2941
2942    /// An invalid `transform` attribute on a shape element must not crash; the
2943    /// element inherits the parent's transform unchanged.
2944    #[test]
2945    fn collect_svg_invalid_transform_is_ignored() {
2946        let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
2947        let g = TreeNode::new(element(
2948            "g",
2949            &[("transform", "not-a-valid-transform")],
2950        ));
2951        TreeNode::add_child(&svg, Rc::clone(&g));
2952        TreeNode::add_child(
2953            &g,
2954            TreeNode::new(element(
2955                "rect",
2956                &[
2957                    ("x", "0"),
2958                    ("y", "0"),
2959                    ("width", "5"),
2960                    ("height", "5"),
2961                    ("fill", "#ff0000"),
2962                ],
2963            )),
2964        );
2965
2966        let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
2967        let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).unwrap();
2968        assert_eq!(content.shapes.len(), 1);
2969        // Invalid transform is ignored: rect stays at (0,0)×(5,5).
2970        let bb = content.shapes[0].path.bounding_box().unwrap();
2971        assert!((bb.x - 0.0).abs() < 1e-6);
2972        assert!((bb.y - 0.0).abs() < 1e-6);
2973        assert!((bb.width - 5.0).abs() < 1e-6);
2974        assert!((bb.height - 5.0).abs() < 1e-6);
2975    }
2976
2977    /// A `<path>` without a `d` attribute produces no shape.
2978    #[test]
2979    fn element_shape_missing_d_attribute_returns_none() {
2980        let el = element("path", &[("fill", "#ff0000")]);
2981        assert!(crate::engine::svg::element_shape(&el).is_none());
2982    }
2983
2984    /// Malformed SVG markup is rejected by the standalone rasterizer instead of
2985    /// crashing or producing garbage pixels.
2986    #[test]
2987    fn rasterize_malformed_svg_returns_error() {
2988        assert!(super::rasterize::rasterize("<svg><unclosed>", None).is_err());
2989        assert!(super::rasterize::rasterize("<notsvg></notsvg>", None).is_err());
2990        // Extremely large viewport is rejected, not allocated.
2991        assert!(super::rasterize::rasterize(
2992            r##"<svg width="1e300" height="10"></svg>"##,
2993            None
2994        )
2995        .is_err());
2996    }
2997
2998    /// `<use href="#id">` clones the referenced element's content at the use
2999    /// element's position.
3000    #[test]
3001    fn collect_svg_use_element_references_target() {
3002        let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
3003        let defs = TreeNode::new(element("defs", &[]));
3004        TreeNode::add_child(&svg, Rc::clone(&defs));
3005        let rect = TreeNode::new(element(
3006            "rect",
3007            &[
3008                ("id", "myRect"),
3009                ("x", "1"),
3010                ("y", "1"),
3011                ("width", "3"),
3012                ("height", "3"),
3013                ("fill", "#ff0000"),
3014            ],
3015        ));
3016        TreeNode::add_child(&defs, Rc::clone(&rect));
3017        TreeNode::add_child(
3018            &svg,
3019            TreeNode::new(element(
3020                "use",
3021                &[("href", "#myRect"), ("x", "5"), ("y", "5")],
3022            )),
3023        );
3024
3025        let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
3026        let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).unwrap();
3027        assert_eq!(content.shapes.len(), 1);
3028        // The rect is translated by x=5,y=5: from (1,1)×(3,3) to (6,6)×(3,3).
3029        let bb = content.shapes[0].path.bounding_box().unwrap();
3030        assert!((bb.x - 6.0).abs() < 1e-6);
3031        assert!((bb.y - 6.0).abs() < 1e-6);
3032        assert!((bb.width - 3.0).abs() < 1e-6);
3033        assert!((bb.height - 3.0).abs() < 1e-6);
3034        assert_eq!(content.shapes[0].fill, Brush::Solid(Color(255, 0, 0, 255)));
3035    }
3036
3037    /// `<use>` referencing a `<g>` clones all of the group's children.
3038    #[test]
3039    fn collect_svg_use_element_references_group() {
3040        let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 20 10")]));
3041        let defs = TreeNode::new(element("defs", &[]));
3042        TreeNode::add_child(&svg, Rc::clone(&defs));
3043        let g = TreeNode::new(element("g", &[("id", "myGroup")]));
3044        TreeNode::add_child(
3045            &g,
3046            TreeNode::new(element(
3047                "rect",
3048                &[("x", "0"), ("y", "0"), ("width", "2"), ("height", "2"), ("fill", "#ff0000")],
3049            )),
3050        );
3051        TreeNode::add_child(
3052            &g,
3053            TreeNode::new(element(
3054                "circle",
3055                &[("cx", "5"), ("cy", "5"), ("r", "2"), ("fill", "#00ff00")],
3056            )),
3057        );
3058        TreeNode::add_child(&defs, Rc::clone(&g));
3059        TreeNode::add_child(
3060            &svg,
3061            TreeNode::new(element("use", &[("href", "#myGroup")])),
3062        );
3063
3064        let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
3065        let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).unwrap();
3066        assert_eq!(content.shapes.len(), 2);
3067        assert_eq!(content.shapes[0].fill, Brush::Solid(Color(255, 0, 0, 255)));
3068        assert_eq!(content.shapes[1].fill, Brush::Solid(Color(0, 255, 0, 255)));
3069    }
3070
3071    /// Circular `<use>` references do not cause infinite recursion.
3072    #[test]
3073    fn collect_svg_use_circular_reference() {
3074        let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
3075        let use_a = TreeNode::new(element("use", &[("id", "a"), ("href", "#b")]));
3076        let use_b = TreeNode::new(element("use", &[("id", "b"), ("href", "#a")]));
3077        TreeNode::add_child(&svg, Rc::clone(&use_a));
3078        TreeNode::add_child(&use_a, Rc::clone(&use_b));
3079
3080        let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
3081        let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default());
3082        // Circular reference is detected: no shapes are produced.
3083        assert!(content.is_none());
3084    }
3085
3086    /// `<use>` SVG rasterizes identically through inline and standalone pipelines.
3087    #[test]
3088    fn use_svg_inline_matches_standalone() {
3089        let markup = r##"<svg width="20" height="10" viewBox="0 0 20 10">
3090            <defs>
3091                <rect id="r1" x="0" y="0" width="4" height="4" fill="#ff0000"/>
3092                <circle id="c1" cx="8" cy="5" r="2" fill="#00ff00"/>
3093            </defs>
3094            <use href="#r1" x="2" y="2"/>
3095            <use href="#c1" x="4" y="0"/>
3096        </svg>"##;
3097
3098        // Standalone rasterization.
3099        let standalone = super::rasterize::rasterize(markup, Some((20, 10)))
3100            .expect("rasterizes standalone");
3101
3102        // Inline pipeline.
3103        let tree =
3104            crate::engine::html::xml::parse(markup).expect("parses SVG XML");
3105        let html_root = xml_to_html_tree(&tree.root);
3106        let (snapshot, _refs) = DomSnapshot::from_tree(&html_root);
3107
3108        let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default())
3109            .expect("collects inline SVG");
3110        let mut commands = Vec::new();
3111        emit_commands(
3112            &mut commands,
3113            (0.0, 0.0, 20.0, 10.0),
3114            false,
3115            content.view_box,
3116            &content.shapes,
3117            1.0,
3118        );
3119        let inline_pixels = rasterize_commands(&commands, 20, 10);
3120
3121        assert_eq!(
3122            inline_pixels, standalone.rgba,
3123            "inline and standalone SVG rasterization differ for <use>"
3124        );
3125        assert!(
3126            inline_pixels.chunks(4).any(|p| p[3] != 0),
3127            "expected non-transparent pixels"
3128        );
3129    }
3130
3131    /// `<clipPath id="...">` collects its shape descendants into a clip path
3132    /// that shapes can reference via `clip-path="url(#id)"`.
3133    #[test]
3134    fn collect_svg_clip_path() {
3135        let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
3136        let clip = TreeNode::new(element("clipPath", &[("id", "myClip")]));
3137        TreeNode::add_child(
3138            &clip,
3139            TreeNode::new(element(
3140                "rect",
3141                &[("x", "2"), ("y", "2"), ("width", "4"), ("height", "4")],
3142            )),
3143        );
3144        TreeNode::add_child(&svg, Rc::clone(&clip));
3145        TreeNode::add_child(
3146            &svg,
3147            TreeNode::new(element(
3148                "rect",
3149                &[
3150                    ("x", "0"),
3151                    ("y", "0"),
3152                    ("width", "10"),
3153                    ("height", "10"),
3154                    ("fill", "#ff0000"),
3155                    ("clip-path", "#myClip"),
3156                ],
3157            )),
3158        );
3159
3160        let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
3161        let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).unwrap();
3162        assert_eq!(content.shapes.len(), 1);
3163        let shape = &content.shapes[0];
3164        assert!(shape.clip_path.is_some());
3165        let clip = shape.clip_path.as_ref().unwrap();
3166        // The clip rect is (2,2)×(4,4).
3167        let bb = clip.path.bounding_box().unwrap();
3168        assert!((bb.x - 2.0).abs() < 1e-6);
3169        assert!((bb.y - 2.0).abs() < 1e-6);
3170        assert!((bb.width - 4.0).abs() < 1e-6);
3171        assert!((bb.height - 4.0).abs() < 1e-6);
3172    }
3173
3174    /// `<clipPath>` rasterizes identically through inline and standalone pipelines.
3175    #[test]
3176    fn clip_path_svg_inline_matches_standalone() {
3177        let markup = r##"<svg width="10" height="10" viewBox="0 0 10 10">
3178            <clipPath id="c1">
3179                <rect x="2" y="2" width="4" height="4"/>
3180            </clipPath>
3181            <rect x="0" y="0" width="10" height="10" fill="#ff0000" clip-path="#c1"/>
3182        </svg>"##;
3183
3184        let standalone = super::rasterize::rasterize(markup, Some((10, 10)))
3185            .expect("rasterizes standalone");
3186
3187        let tree =
3188            crate::engine::html::xml::parse(markup).expect("parses SVG XML");
3189        let html_root = xml_to_html_tree(&tree.root);
3190        let (snapshot, _refs) = DomSnapshot::from_tree(&html_root);
3191
3192        let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default())
3193            .expect("collects inline SVG");
3194        let mut commands = Vec::new();
3195        emit_commands(
3196            &mut commands,
3197            (0.0, 0.0, 10.0, 10.0),
3198            false,
3199            content.view_box,
3200            &content.shapes,
3201            1.0,
3202        );
3203        let inline_pixels = rasterize_commands(&commands, 10, 10);
3204
3205        assert_eq!(
3206            inline_pixels, standalone.rgba,
3207            "inline and standalone SVG rasterization differ for <clipPath>"
3208        );
3209    }
3210}