Skip to main content

orinium_browser/engine/svg/
rasterize.rs

1//! Scanline rasterizer: turns parsed `<svg>` XML into straight-alpha RGBA
2//! pixels, replacing the previous `resvg`/`tiny-skia` dependency.
3//!
4//! Coverage is intentionally minimal and matches the SVGs the engine embeds
5//! (audio icons, decoded `<img src="*.svg">`): `<svg>`, container `<g>`,
6//! `<path d>`, `<rect>`, `<circle>`, `<ellipse>`, `transform` attributes, solid
7//! `fill` colors from either a `fill` attribute or a `style=` declaration,
8//! `fill-rule`, and `opacity`/`fill-opacity`/`stroke-opacity` (approximated as
9//! a per-shape alpha multiplier), plus strokes (via outline expansion). Edges
10//! are anti-aliased with 4× vertical supersampling. `stroke` colors are
11//! resolved from a `stroke` attribute or a `style=` declaration, with the
12//! `stroke-width`/cap/join/miter-limit/dash parameters inherited down
13//! containers. Gradients, masks, `<clipPath>`, and text are out of scope.
14
15use std::cmp::Ordering;
16use std::collections::HashMap;
17use std::rc::Rc;
18
19use crate::engine::html::xml::XmlElement;
20use crate::engine::layouter::types::{Color, ColorScheme, Gradient};
21use crate::engine::renderer_model::{AffineTransform, FillRule, Path};
22use crate::engine::svg::{
23    parse_use_ref, resolve_fill_rule, ClipPath, PaintState, SvgStroke, ViewBox, element_shape,
24    numeric_presentation_value, parse_len, parse_transform, parse_view_box,
25    viewbox_meet_transform, with_alpha,
26};
27use crate::engine::tree::NodeRef;
28
29/// Largest single viewport edge the rasterizer will allocate, in pixels.
30const MAX_RASTER_DIMENSION: u32 = 8192;
31/// Largest total pixel count (width × height) the rasterizer will allocate.
32/// At 4 bytes per pixel this bounds a single buffer to ~128 MiB.
33const MAX_RASTER_PIXELS: u64 = 33_554_432;
34/// Maximum element nesting depth walked before bailing out, so a deeply nested
35/// SVG cannot overflow the stack.
36const MAX_SVG_DEPTH: usize = 128;
37
38/// Decoded RGBA8 pixels plus the rasterized viewport dimensions.
39#[derive(Debug, Clone, PartialEq)]
40pub struct RasterResult {
41    pub width: u32,
42    pub height: u32,
43    pub rgba: Vec<u8>,
44}
45
46/// Parses SVG bytes and rasterizes them to straight-alpha RGBA pixels.
47///
48/// `target_size` fixes the output viewport. When `None`, the SVG's intrinsic
49/// `width`/`height` (falling back to `viewBox`, then 300×150) determine the
50/// output size, matching `resvg`'s sizing behavior.
51pub fn rasterize_from_bytes(
52    svg: &[u8],
53    target_size: Option<(u32, u32)>,
54) -> Result<RasterResult, String> {
55    let markup =
56        std::str::from_utf8(svg).map_err(|error| format!("SVG is not valid UTF-8: {error}"))?;
57    rasterize(markup, target_size)
58}
59
60/// Rasterizes an SVG document string into a viewport-sized RGBA buffer.
61pub fn rasterize(markup: &str, target: Option<(u32, u32)>) -> Result<RasterResult, String> {
62    let tree = crate::engine::html::xml::parse(markup)?;
63    let root = tree.root.borrow();
64    let root_el = &root.value;
65    if root_el.name != "svg" {
66        return Err(format!(
67            "not an SVG document (root element is <{}>)",
68            root_el.name
69        ));
70    }
71
72    let view_box = root_el.attr("viewBox").map(parse_view_box);
73    let (vw, vh) = match target {
74        Some((w, h)) => (w as f32, h as f32),
75        None => (
76            viewport_len(root_el, "width", view_box, 300.0),
77            viewport_len(root_el, "height", view_box, 150.0),
78        ),
79    };
80    if !vw.is_finite() || !vh.is_finite() {
81        return Err("SVG viewport size is not finite".to_string());
82    }
83    let width = vw.clamp(1.0, MAX_RASTER_DIMENSION as f32) as u32;
84    let height = vh.clamp(1.0, MAX_RASTER_DIMENSION as f32) as u32;
85    if u64::from(width) * u64::from(height) > MAX_RASTER_PIXELS {
86        return Err(format!(
87            "SVG viewport {width}x{height} exceeds the rasterization limit"
88        ));
89    }
90    let buffer_len = (width as usize)
91        .checked_mul(height as usize)
92        .and_then(|pixels| pixels.checked_mul(4))
93        .ok_or_else(|| "SVG viewport buffer size overflowed".to_string())?;
94
95    let base = viewbox_meet_transform(view_box.unwrap_or((0.0, 0.0, 0.0, 0.0)), vw, vh);
96    let gradients = HashMap::new();
97    let mut id_map: HashMap<String, NodeRef<XmlElement>> = HashMap::new();
98    collect_tree_id_map(&tree.root, &mut id_map);
99    let mut clip_paths: HashMap<String, ClipPath> = HashMap::new();
100    collect_tree_clip_paths(&tree.root, &mut clip_paths);
101    let mut rgba = vec![0u8; buffer_len];
102    let mut use_stack: Vec<usize> = Vec::new();
103    for child in root.children() {
104        process_element(
105            child,
106            base,
107            1.0,
108            PaintState::new(),
109            &gradients,
110            &id_map,
111            &clip_paths,
112            &mut use_stack,
113            width,
114            height,
115            &mut rgba,
116            0,
117        )?;
118    }
119    Ok(RasterResult {
120        width,
121        height,
122        rgba,
123    })
124}
125
126/// Builds a map of `id` attribute values to node refs for all elements under
127/// `root`, so `<use href="#id">` can resolve its target in the standalone
128/// rasterizer (which walks `Tree<XmlElement>` rather than `DomSnapshot`).
129fn collect_tree_id_map(
130    root: &NodeRef<XmlElement>,
131    out: &mut HashMap<String, NodeRef<XmlElement>>,
132) {
133    let el = root.borrow();
134    if let Some(id_value) = el.value.attr("id")
135        && !id_value.is_empty()
136    {
137        out.entry(id_value.to_owned()).or_insert(Rc::clone(root));
138    }
139    drop(el);
140    for child in root.borrow().children() {
141        collect_tree_id_map(child, out);
142    }
143}
144
145/// Collects `<clipPath>` definitions under `root` into `out`, keyed by their
146/// `id` attribute. All shape descendants of a `<clipPath>` are combined into a
147/// single path (transformed by the clipPath element's `transform` attribute).
148fn collect_tree_clip_paths(
149    root: &NodeRef<XmlElement>,
150    out: &mut HashMap<String, ClipPath>,
151) {
152    let el = root.borrow();
153    if el.value.name == "clipPath"
154        && let Some(clip_id) = el.value.attr("id").filter(|id| !id.is_empty())
155    {
156            let transform = match el.value.attr("transform") {
157                Some(value) => parse_transform(value).unwrap_or(AffineTransform::identity()),
158                None => AffineTransform::identity(),
159            };
160            let rule = resolve_fill_rule(&el.value);
161            let mut path = Path::new();
162            collect_tree_clip_path_shapes(root, &transform, &mut path);
163            if !path.commands().is_empty() {
164                out.entry(clip_id.to_owned()).or_insert(ClipPath { path, rule });
165            }
166    }
167    drop(el);
168    for child in root.borrow().children() {
169        collect_tree_clip_paths(child, out);
170    }
171}
172
173/// Recursively collects all shape descendants under `root` into `path`,
174/// applying `transform` to each shape's geometry.
175fn collect_tree_clip_path_shapes(
176    root: &NodeRef<XmlElement>,
177    transform: &AffineTransform,
178    path: &mut Path,
179) {
180    let el = root.borrow();
181    if let Some(shape_path) = element_shape(&el.value) {
182        let transformed = crate::engine::renderer_model::transform_path(&shape_path, transform);
183        for subpath in transformed.subpaths() {
184            if let Some(&first) = subpath.first() {
185                path.move_to(first.0, first.1);
186                for &(x, y) in &subpath[1..] {
187                    path.line_to(x, y);
188                }
189                path.close();
190            }
191        }
192    }
193    drop(el);
194    for child in root.borrow().children() {
195        collect_tree_clip_path_shapes(child, transform, path);
196    }
197}
198
199/// Resolves the intrinsic viewport size of an `<svg>`: the `width`/`height`
200/// attributes when present (clamped to at least 1px), otherwise the matching
201/// `viewBox` edge, otherwise `fallback`.
202fn viewport_len(el: &XmlElement, attr: &str, view_box: Option<ViewBox>, fallback: f32) -> f32 {
203    if let Some(value) = el.attr(attr).and_then(parse_len) {
204        return value.max(1.0).ceil();
205    }
206    match view_box {
207        Some((_, _, w, _)) if attr == "width" => w.max(1.0).ceil(),
208        Some((_, _, _, h)) if attr == "height" => h.max(1.0).ceil(),
209        _ => fallback.max(1.0).ceil(),
210    }
211}
212
213/// Walks one element, composing its `transform` into the inherited transform
214/// and rasterizing any shape elements it describes.
215///
216/// `opacity` is the product of the `opacity` presentation values of this hook
217/// and its ancestors. Shapes apply it (plus their own `fill-opacity` /
218/// `stroke-opacity`) to the alpha. Group opacity is approximated by
219/// multiplying the alpha of every descendant shape rather than compositing the
220/// group as a single layer. `paint` carries the inherited `fill`/`stroke`
221/// state that containers set for their descendants, mirroring the inline
222/// `collect_svg` walk.
223#[allow(clippy::too_many_arguments)]
224fn process_element(
225    node: &NodeRef<XmlElement>,
226    inherited: AffineTransform,
227    opacity: f32,
228    mut paint: PaintState,
229    gradients: &HashMap<String, Gradient>,
230    id_map: &HashMap<String, NodeRef<XmlElement>>,
231    clip_paths: &HashMap<String, ClipPath>,
232    use_stack: &mut Vec<usize>,
233    width: u32,
234    height: u32,
235    rgba: &mut [u8],
236    depth: usize,
237) -> Result<(), String> {
238    if depth > MAX_SVG_DEPTH {
239        return Err(format!("SVG nesting exceeds {MAX_SVG_DEPTH} levels"));
240    }
241    let el = node.borrow();
242    let name = el.value.name.clone();
243    let eff = match el.value.attr("transform") {
244        Some(value) => inherited.then(&parse_transform(value).map_err(|e| format!("{name}: {e}"))?),
245        None => inherited,
246    };
247    let element_opacity = numeric_presentation_value(&el.value, "opacity").unwrap_or(1.0);
248    let opacity = opacity * element_opacity.clamp(0.0, 1.0);
249    paint.apply(&el.value, ColorScheme::Light, Color::default(), gradients);
250
251    match name.as_str() {
252        "g" | "a" | "svg" | "symbol" => {
253            for child in el.children() {
254                process_element(
255                    child,
256                    eff,
257                    opacity,
258                    paint.clone(),
259                    gradients,
260                    id_map,
261                    clip_paths,
262                    use_stack,
263                    width,
264                    height,
265                    rgba,
266                    depth + 1,
267                )?;
268            }
269        }
270        "use" => {
271            // `<use href="#id">` clones the referenced element's content at the
272            // use element's position, with x/y translating the content.
273            let href = el
274                .value
275                .attr("href")
276                .or_else(|| el.value.attr("xlink:href"));
277            let target_node = href
278                .and_then(parse_use_ref)
279                .and_then(|frag| id_map.get(&frag).cloned());
280            if let Some(target) = target_node {
281                let target_ptr = Rc::as_ptr(&target) as usize;
282                if !use_stack.contains(&target_ptr) {
283                    let x = el.value.attr("x").and_then(parse_len).unwrap_or(0.0);
284                    let y = el.value.attr("y").and_then(parse_len).unwrap_or(0.0);
285                    let use_transform = eff.then(&AffineTransform::translate(x, y));
286                    use_stack.push(target_ptr);
287                    process_element(
288                        &target,
289                        use_transform,
290                        opacity,
291                        paint.clone(),
292                        gradients,
293                        id_map,
294                        clip_paths,
295                        use_stack,
296                        width,
297                        height,
298                        rgba,
299                        depth + 1,
300                    )?;
301                    use_stack.pop();
302                }
303            }
304        }
305        "path" | "rect" | "circle" | "ellipse" | "polygon" | "polyline" | "line" => {
306            let Some(path) = element_shape(&el.value) else {
307                return Ok(());
308            };
309            if path.commands().is_empty() {
310                return Ok(());
311            }
312            let fill = solid_fallback_paint(&paint, opacity);
313            let stroke = paint.stroke.clone().map(|mut stroke| {
314                if stroke.gradient.is_some() {
315                    // The standalone rasterizer only paints solid strokes.
316                    stroke.gradient = None;
317                    stroke.color = with_alpha(stroke.color, opacity * paint.stroke_opacity);
318                } else {
319                    stroke.color = with_alpha(stroke.color, opacity * paint.stroke_opacity);
320                }
321                stroke
322            });
323            let clip_mask = el
324                .value
325                .attr("clip-path")
326                .and_then(parse_use_ref)
327                .and_then(|frag| clip_paths.get(&frag))
328                .map(|clip| {
329                    let mut buf = vec![0u8; (width as usize) * (height as usize) * 4];
330                    let transformed =
331                        crate::engine::renderer_model::transform_path(&clip.path, &eff);
332                    fill_path_into(
333                        &mut buf,
334                        width,
335                        height,
336                        &transformed,
337                        Color(255, 255, 255, 255),
338                        clip.rule,
339                        None,
340                    );
341                    buf
342                });
343            render_shape(
344                &path,
345                eff,
346                fill,
347                paint.rule,
348                stroke.as_ref(),
349                clip_mask.as_deref(),
350                width,
351                height,
352                rgba,
353            );
354        }
355        _ => {}
356    }
357    Ok(())
358}
359
360/// The standalone rasterizer renders gradients out of scope; a gradient paint
361/// falls back to its last stop's color so referenced shapes stay visible.
362fn solid_fallback_paint(paint: &PaintState, opacity: f32) -> Color {
363    if let Some(fill) = paint.fill {
364        return with_alpha(fill, opacity * paint.fill_opacity);
365    }
366    if let Some(gradient) = &paint.fill_gradient
367        && let Some(last) = gradient.stops.last()
368    {
369        return with_alpha(last.color, opacity * paint.fill_opacity);
370    }
371    Color(0, 0, 0, 255)
372}
373
374/// Fills `path` (expressed in viewBox coordinates by `eff`) into `rgba`,
375/// honoring `rule`, then paints the resolved stroke outline on top the same
376/// way the inline pipeline does: expand the outline in the already-transformed
377/// space and fill it with the nonzero rule.
378///
379/// `clip` is an optional alpha mask (width × height bytes) produced by
380/// rasterizing a `<clipPath>`; both the fill and stroke are masked by it.
381#[allow(clippy::too_many_arguments)]
382fn render_shape(
383    path: &Path,
384    eff: AffineTransform,
385    fill: Color,
386    rule: FillRule,
387    stroke: Option<&SvgStroke>,
388    clip: Option<&[u8]>,
389    width: u32,
390    height: u32,
391    rgba: &mut [u8],
392) {
393    let transformed = crate::engine::renderer_model::transform_path(path, &eff);
394    fill_path_into(rgba, width, height, &transformed, fill, rule, clip);
395    if let Some(stroke) = stroke {
396        let outline = crate::engine::renderer_model::stroke_path(&transformed, &stroke.to_stroke());
397        fill_path_into(
398            rgba,
399            width,
400            height,
401            &outline,
402            stroke.color,
403            FillRule::NonZero,
404            clip,
405        );
406    }
407}
408
409/// Number of sub-scanlines sampled per output row for anti-aliasing.
410const AA_SUBSAMPLES: u32 = 4;
411
412/// Fills `path` (already in viewport pixel coordinates) into `rgba`, honoring
413/// `rule`.
414///
415/// Each output row is sampled at [`AA_SUBSAMPLES`] sub-scanlines and the
416/// covered pixel columns are tallied, so boundary pixels blend into fractional
417/// alpha instead of a hard on/off edge. Fully interior pixels keep their exact
418/// fill alpha.
419///
420/// When `clip` is `Some`, it points to a `width * height` alpha mask produced
421/// by rasterizing a `<clipPath>`. Each output pixel's alpha is multiplied by
422/// the corresponding mask value, effectively clipping the fill to the clip
423/// region.
424pub(crate) fn fill_path_into(
425    rgba: &mut [u8],
426    width: u32,
427    height: u32,
428    path: &Path,
429    fill: Color,
430    rule: FillRule,
431    clip: Option<&[u8]>,
432) {
433    if fill.3 == 0 || width == 0 || height == 0 {
434        return;
435    }
436    let height_i = height as i64;
437
438    let rings: Vec<Vec<(f32, f32)>> = path
439        .subpaths()
440        .into_iter()
441        .filter(|ring| ring.len() >= 3)
442        .collect();
443    if rings.is_empty() {
444        return;
445    }
446
447    let mut min_y = f32::INFINITY;
448    let mut max_y = f32::NEG_INFINITY;
449    for ring in &rings {
450        for &(_, y) in ring {
451            min_y = min_y.min(y);
452            max_y = max_y.max(y);
453        }
454    }
455
456    let row_start = min_y.max(0.0).floor() as i64;
457    let row_end = (max_y.min(height as f32).ceil() as i64).clamp(0, height_i);
458
459    for row in row_start..row_end {
460        let row_offset = (row as usize) * (width as usize);
461        let mut coverage = vec![0u16; width as usize];
462        for sub in 0..AA_SUBSAMPLES {
463            let yc = (row as f32) + (sub as f32 + 0.5) / AA_SUBSAMPLES as f32;
464            for (prev_x, x) in inside_spans(&rings, yc, rule) {
465                mark_columns(&mut coverage, prev_x, x);
466            }
467        }
468        for (column, hits) in coverage.iter().enumerate() {
469            if *hits == 0 {
470                continue;
471            }
472            let covers = *hits as f32 / AA_SUBSAMPLES as f32;
473            let mut alpha = (fill.3 as f32 * covers).round() as u8;
474            if let Some(clip_mask) = clip {
475                let clip_alpha = clip_mask[(row_offset + column) * 4 + 3] as f32 / 255.0;
476                alpha = (alpha as f32 * clip_alpha).round() as u8;
477            }
478            blend_pixel(
479                rgba,
480                row_offset + column,
481                Color(fill.0, fill.1, fill.2, alpha),
482            );
483        }
484    }
485}
486
487/// Returns the scanline spans of `rings` at `yc` that are inside the shape per
488/// `rule`, as `(prev_x, x)` intervals (half-open along y and x).
489fn inside_spans(
490    rings: &[Vec<(f32, f32)>],
491    yc: f32,
492    rule: FillRule,
493) -> impl Iterator<Item = (f32, f32)> + '_ {
494    let mut edges: Vec<(f32, f32)> = Vec::new();
495    for ring in rings {
496        let last = ring.len() - 1;
497        for i in 0..ring.len() {
498            let (x0, y0) = ring[i];
499            let (x1, y1) = ring[if i == last { 0 } else { i + 1 }];
500            let (lo, hi) = if y0 < y1 { (y0, y1) } else { (y1, y0) };
501            // Half-open along y so each edge crosses exactly once per
502            // scanline, and horizontal edges never cross.
503            if yc < lo || yc >= hi {
504                continue;
505            }
506            let t = (yc - y0) / (y1 - y0);
507            let x = x0 + t * (x1 - x0);
508            let direction = if y1 > y0 { 1.0 } else { -1.0 };
509            edges.push((x, direction));
510        }
511    }
512    edges.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
513
514    Spans {
515        edges,
516        rule,
517        prev_x: f32::NEG_INFINITY,
518        winding: 0.0,
519        parity: false,
520    }
521}
522
523/// Lazily yields the inside intervals of the sorted edge list, carrying the
524/// winding count (non-zero rule) or parity (even-odd rule) across the whole
525/// edge list so overlapping rings accumulate correctly.
526struct Spans {
527    edges: Vec<(f32, f32)>,
528    rule: FillRule,
529    prev_x: f32,
530    winding: f32,
531    parity: bool,
532}
533
534impl Iterator for Spans {
535    type Item = (f32, f32);
536
537    fn next(&mut self) -> Option<(f32, f32)> {
538        while let Some((x, direction)) = self.edges.first().copied() {
539            self.edges.remove(0);
540            let span = match self.rule {
541                FillRule::NonZero => {
542                    let span = if self.winding != 0.0 && x > self.prev_x {
543                        Some((self.prev_x, x))
544                    } else {
545                        None
546                    };
547                    self.winding += direction;
548                    span
549                }
550                FillRule::EvenOdd => {
551                    let span = if self.parity && x > self.prev_x {
552                        Some((self.prev_x, x))
553                    } else {
554                        None
555                    };
556                    self.parity = !self.parity;
557                    span
558                }
559            };
560            self.prev_x = x;
561            if let Some(span) = span {
562                return Some(span);
563            }
564        }
565        None
566    }
567}
568
569/// Ticks the pixel columns whose centers land inside the scanline span
570/// `(prev_x, x)` in the supersample coverage tally.
571fn mark_columns(coverage: &mut [u16], prev_x: f32, x: f32) {
572    let width = coverage.len() as i64;
573    let c0 = (((prev_x - 0.5).ceil().max(0.0)) as i64).min(width);
574    let c1 = ((x - 0.5).ceil().max(0.0) as i64).min(width);
575    for c in c0.max(0)..c1 {
576        coverage[c as usize] += 1;
577    }
578}
579
580/// Sources a straight-alpha color over `rgba[idx]` (the destination) using the
581/// standard "over" compositing operator.
582fn blend_pixel(rgba: &mut [u8], index: usize, src: Color) {
583    let idx = index * 4;
584    let sa = src.3 as f32 / 255.0;
585    let da = rgba[idx + 3] as f32 / 255.0;
586    let out_alpha = sa + da * (1.0 - sa);
587    if out_alpha <= 0.0 {
588        return;
589    }
590    let rgba_f32 = [
591        src.0 as f32 / 255.0,
592        src.1 as f32 / 255.0,
593        src.2 as f32 / 255.0,
594    ];
595    for channel in 0..3 {
596        let s = rgba_f32[channel];
597        let d = rgba[idx + channel] as f32 / 255.0;
598        let composited = (s * sa + d * da * (1.0 - sa)) / out_alpha;
599        rgba[idx + channel] = (composited * 255.0).round() as u8;
600    }
601    rgba[idx + 3] = (out_alpha * 255.0).round() as u8;
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    #[test]
609    fn rasterizes_fill_attribute_rectangle() {
610        let result = rasterize(
611            r##"<svg width="4" height="3"><rect x="0" y="0" width="4" height="3" fill="#ff0000"/></svg>"##,
612            None,
613        )
614        .expect("rasterizes");
615        assert_eq!((result.width, result.height), (4, 3));
616        // Every pixel is solid red.
617        assert!(
618            result
619                .rgba
620                .as_chunks::<4>()
621                .0
622                .iter()
623                .all(|p| *p == [255, 0, 0, 255])
624        );
625    }
626
627    #[test]
628    fn rasterizes_style_fill_with_transform_and_viewbox() {
629        // A triangle in viewBox space, offset by the group's translate(2,2):
630        // points land at (4,10), (20,7), (4,4).
631        let result = rasterize(
632            r##"<svg width="20" height="10" viewBox="0 0 20 10">
633                <g transform="translate(2, 2)">
634                  <path d="M 2 8 L 18 5 L 2 2 Z" style="fill:#00ff00"/>
635                </g>
636               </svg>"##,
637            None,
638        )
639        .expect("rasterizes");
640        assert_eq!((result.width, result.height), (20, 10));
641        // The triangle is offset by translate(2,2): points land at
642        // (4,10), (20,7), (4,4). Pixel (10,5) sits on the slanted right edge,
643        // so anti-aliasing gives it partial coverage (191/255) rather than a
644        // hard step; (5,5) is fully interior.
645        let edge = result.rgba[(5 * 20 + 10) * 4..(5 * 20 + 10) * 4 + 4].to_vec();
646        assert_eq!(edge, [0, 255, 0, 191]);
647        let interior = result.rgba[(5 * 20 + 5) * 4..(5 * 20 + 5) * 4 + 4].to_vec();
648        assert_eq!(interior, [0, 255, 0, 255]);
649        assert_eq!(&result.rgba[0..4], &[0, 0, 0, 0]);
650    }
651
652    #[test]
653    fn viewbox_meet_centers_letterboxed_content() {
654        // viewBox 10x10 in a 20x10 viewport scales to fit and centers,
655        // leaving the left and right 5px empty.
656        let result = rasterize(
657            r##"<svg width="20" height="10" viewBox="0 0 10 10"><rect x="0" y="0" width="10" height="10" fill="#ff0000"/></svg>"##,
658            None,
659        )
660        .expect("rasterizes");
661        assert_eq!(&result.rgba[0..4], &[0, 0, 0, 0]);
662        assert_eq!(&result.rgba[40..44], &[255, 0, 0, 255]);
663    }
664
665    #[test]
666    fn preserves_transparent_regions_outside_shapes() {
667        let result = rasterize(
668            r##"<svg width="3" height="3"><rect x="1" y="1" width="1" height="1" fill="#ffffff"/></svg>"##,
669            None,
670        )
671        .expect("rasterizes");
672        assert_eq!(&result.rgba[0..4], &[0, 0, 0, 0]);
673        assert_eq!(&result.rgba[16..20], &[255, 255, 255, 255]);
674    }
675
676    #[test]
677    fn non_svg_root_is_rejected() {
678        assert!(rasterize("<html></html>", None).is_err());
679        assert!(rasterize("<svg", None).is_err());
680    }
681
682    #[test]
683    fn huge_intrinsic_viewport_is_clamped_or_rejected() {
684        // A scientific-notation edge parses to `inf`; reject rather than
685        // overflow the buffer size.
686        assert!(rasterize(r#"<svg width="1e300" height="10"></svg>"#, None).is_err());
687
688        // A finite but enormous viewport must not attempt a multi-gigabyte
689        // allocation; it is either clamped within limits or rejected.
690        if let Ok(result) = rasterize(r#"<svg width="100000" height="100000"></svg>"#, None) {
691            assert!(result.width <= MAX_RASTER_DIMENSION);
692            assert!(result.height <= MAX_RASTER_DIMENSION);
693            assert!(u64::from(result.width) * u64::from(result.height) <= MAX_RASTER_PIXELS);
694        }
695    }
696    #[test]
697    fn deeply_nested_elements_are_rejected() {
698        let mut svg = String::from("<svg width=\"10\" height=\"10\">");
699        for _ in 0..(MAX_SVG_DEPTH + 8) {
700            svg.push_str("<g>");
701        }
702        for _ in 0..(MAX_SVG_DEPTH + 8) {
703            svg.push_str("</g>");
704        }
705        svg.push_str("</svg>");
706        assert!(rasterize(&svg, None).is_err());
707    }
708
709    #[test]
710    fn rectangle_bounds_are_axis_aligned_after_scale() {
711        let result = rasterize(
712            r##"<svg width="10" height="10"><rect x="2" y="1" width="3" height="2" fill="#0000ff"/></svg>"##,
713            None,
714        )
715        .expect("rasterizes");
716        let px = |x: usize, y: usize| &result.rgba[(y * 10 + x) * 4..(y * 10 + x) * 4 + 4];
717        // Inside the rect: blue.
718        assert_eq!(px(2, 1), &[0, 0, 255, 255]);
719        // Outside: transparent.
720        assert_eq!(px(1, 1), &[0, 0, 0, 0]);
721        assert_eq!(px(5, 1), &[0, 0, 0, 0]);
722        assert_eq!(px(2, 0), &[0, 0, 0, 0]);
723    }
724
725    #[test]
726    fn even_odd_rule_punches_a_hole_between_overlapping_rings() {
727        // One path, two overlapping square subpaths. Nonzero winding keeps the
728        // overlap (winding 2) filled; even-odd leaves it empty.
729        let rings = r##"<svg width="10" height="10">
730            <path d="M1 1 L7 1 L7 7 L1 7 Z M3 3 L9 3 L9 9 L3 9 Z" fill="#ff0000"/>
731        </svg>"##;
732        let even_odd = rasterize(
733            r##"<svg width="10" height="10">
734                <path d="M1 1 L7 1 L7 7 L1 7 Z M3 3 L9 3 L9 9 L3 9 Z" fill="#ff0000" fill-rule="evenodd"/>
735            </svg>"##,
736            None,
737        )
738        .expect("rasterizes even-odd");
739        let nonzero = rasterize(rings, None).expect("rasterizes nonzero");
740        fn px(r: &RasterResult, x: usize, y: usize) -> &[u8] {
741            &r.rgba[(y * 10 + x) * 4..(y * 10 + x) * 4 + 4]
742        }
743        // Overlap region (5,5): hole in even-odd, filled in nonzero.
744        assert_eq!(px(&even_odd, 5, 5), &[0, 0, 0, 0]);
745        assert_eq!(px(&nonzero, 5, 5), &[255, 0, 0, 255]);
746        // Region inside exactly one ring (2,2): filled in both modes.
747        assert_eq!(px(&even_odd, 2, 2), &[255, 0, 0, 255]);
748        assert_eq!(px(&nonzero, 2, 2), &[255, 0, 0, 255]);
749    }
750
751    #[test]
752    fn anti_aliases_pixels_straddling_a_horizontal_edge() {
753        // A rectangle from y=0.5 to y=2.5 in a 3-row canvas: rows 0 and 2 are
754        // half covered (2 of 4 sub-scanlines), row 1 is fully covered.
755        let result = rasterize(
756            r##"<svg width="2" height="3"><rect x="0" y="0.5" width="2" height="2" fill="#ff0000"/></svg>"##,
757            None,
758        )
759        .expect("rasterizes");
760        let px = |row: usize| result.rgba[row * 8..row * 8 + 4].to_vec();
761        assert_eq!(px(0), [255, 0, 0, 128]);
762        assert_eq!(px(1), [255, 0, 0, 255]);
763        assert_eq!(px(2), [255, 0, 0, 128]);
764    }
765
766    #[test]
767    fn opacity_attribute_multiplies_fill_alpha() {
768        let result = rasterize(
769            r##"<svg width="2" height="2"><rect x="0" y="0" width="2" height="2" fill="#ff0000" opacity="0.5"/></svg>"##,
770            None,
771        )
772        .expect("rasterizes");
773        assert_eq!(&result.rgba[0..4], &[255, 0, 0, 128]);
774    }
775
776    #[test]
777    fn group_opacity_inherits_into_children() {
778        let result = rasterize(
779            r##"<svg width="2" height="2"><g opacity="0.5"><rect x="0" y="0" width="2" height="2" fill="#ff0000"/></g></svg>"##,
780            None,
781        )
782        .expect("rasterizes");
783        assert_eq!(&result.rgba[0..4], &[255, 0, 0, 128]);
784    }
785
786    #[test]
787    fn fill_opacity_compounds_with_element_opacity() {
788        let result = rasterize(
789            r##"<svg width="2" height="2"><rect x="0" y="0" width="2" height="2" fill="#ff0000" opacity="0.5" fill-opacity="0.5"/></svg>"##,
790            None,
791        )
792        .expect("rasterizes");
793        // 0.5 * 0.5 * 255 = 63.75 → 64.
794        assert_eq!(&result.rgba[0..4], &[255, 0, 0, 64]);
795    }
796
797    #[test]
798    fn current_color_falls_back_to_black_for_standalone_documents() {
799        let result = rasterize(
800            r##"<svg width="2" height="2"><rect x="0" y="0" width="2" height="2" fill="currentColor"/></svg>"##,
801            None,
802        )
803        .expect("rasterizes");
804        assert_eq!(&result.rgba[0..4], &[0, 0, 0, 255]);
805    }
806
807    #[test]
808    fn stroke_width_zero_and_stroke_none_suppress_the_line() {
809        // `stroke-width:0` and `stroke:none` (the audio icon cases) must leave
810        // the canvas untouched even when a fill paint exists.
811        let width_zero = rasterize(
812            r##"<svg width="3" height="3"><path d="M0 1.5 L3 1.5" stroke="#ff0000" stroke-width="0"/></svg>"##,
813            None,
814        )
815        .expect("rasterizes");
816        assert!(width_zero.rgba.chunks(4).all(|p| p[3] == 0));
817        let none = rasterize(
818            r##"<svg width="3" height="3"><path d="M0 1.5 L3 1.5" stroke="none" stroke-width="2"/></svg>"##,
819            None,
820        )
821        .expect("rasterizes");
822        assert!(none.rgba.chunks(4).all(|p| p[3] == 0));
823        let no_declaration = rasterize(
824            r##"<svg width="3" height="3"><path d="M0 1.5 L3 1.5"/></svg>"##,
825            None,
826        )
827        .expect("rasterizes");
828        assert!(no_declaration.rgba.chunks(4).all(|p| p[3] == 0));
829    }
830
831    #[test]
832    fn stroke_rasterizes_as_an_antialiased_band_around_the_line() {
833        let result = rasterize(
834            r##"<svg width="6" height="4"><path d="M0 1.5 L6 1.5" stroke="#ff0000" stroke-width="2" fill="none"/></svg>"##,
835            None,
836        )
837        .expect("rasterizes");
838        let px = |x: usize, y: usize| &result.rgba[(y * 6 + x) * 4..(y * 6 + x) * 4 + 4];
839        // Band spans y in [0.5, 2.5]: center row full, neighbors halves, the
840        // adjacent row empty (same AA profile as a filled rectangle).
841        assert_eq!(px(2, 1), &[255, 0, 0, 255]);
842        assert_eq!(px(2, 0), &[255, 0, 0, 128]);
843        assert_eq!(px(2, 2), &[255, 0, 0, 128]);
844        assert_eq!(px(2, 3), &[0, 0, 0, 0]);
845        // Butt caps stop at the endpoint; nothing fills the corner cells.
846        assert_eq!(px(0, 3), &[0, 0, 0, 0]);
847    }
848
849    #[test]
850    fn round_cap_bulges_past_the_endpoint_where_butt_does_not() {
851        let round = rasterize(
852            r##"<svg width="8" height="5"><path d="M0.5 2.5 L3.5 2.5" stroke="#ff0000" stroke-width="4" stroke-linecap="round" fill="none"/></svg>"##,
853            None,
854        )
855        .expect("rasterizes round");
856        let butt = rasterize(
857            r##"<svg width="8" height="5"><path d="M0.5 2.5 L3.5 2.5" stroke="#ff0000" stroke-width="4" fill="none"/></svg>"##,
858            None,
859        )
860        .expect("rasterizes butt");
861        fn px_at(rgba: &[u8], width: usize, x: usize, y: usize) -> &[u8] {
862            &rgba[(y * width + x) * 4..(y * width + x) * 4 + 4]
863        }
864
865        // Right of the end at x=3.5, the radius-2 cap circle reaches column 4
866        // only for the round cap.
867        assert_eq!(px_at(&round.rgba, 8, 4, 2), &[255, 0, 0, 255]);
868        assert_eq!(px_at(&butt.rgba, 8, 4, 2), &[0, 0, 0, 0]);
869        // Two widths further out no cap reaches.
870        assert_eq!(px_at(&round.rgba, 8, 6, 2), &[0, 0, 0, 0]);
871    }
872
873    #[test]
874    fn dasharray_splits_the_stroke_into_on_and_off_phases() {
875        let result = rasterize(
876            r##"<svg width="8" height="3"><path d="M0 1.5 L8 1.5" stroke="#ff0000" stroke-width="2" stroke-dasharray="3 3" fill="none"/></svg>"##,
877            None,
878        )
879        .expect("rasterizes");
880        let px = |x: usize, y: usize| &result.rgba[(y * 8 + x) * 4..(y * 8 + x) * 4 + 4];
881        assert_eq!(px(1, 1), &[255, 0, 0, 255]);
882        assert_eq!(px(4, 1), &[0, 0, 0, 0]);
883        assert_eq!(px(6, 1), &[255, 0, 0, 255]);
884    }
885
886    #[test]
887    fn stroke_parameters_inherit_through_a_group() {
888        let result = rasterize(
889            r##"<svg width="4" height="3"><g stroke="#00ff00" stroke-width="2"><path d="M0 1.5 L4 1.5" fill="none"/></g></svg>"##,
890            None,
891        )
892        .expect("rasterizes");
893        let px = |x: usize, y: usize| &result.rgba[(y * 4 + x) * 4..(y * 4 + x) * 4 + 4];
894        assert_eq!(px(1, 1), &[0, 255, 0, 255]);
895        assert_eq!(px(1, 0), &[0, 255, 0, 128]);
896    }
897
898    #[test]
899    fn parse_len_handles_units_and_exponents() {
900        assert_eq!(parse_len("10px"), Some(10.0));
901        assert_eq!(parse_len(" 1.5e1em "), Some(15.0));
902        assert_eq!(parse_len("-2e1"), Some(-20.0));
903        assert_eq!(parse_len("100%"), Some(100.0));
904        assert_eq!(parse_len("nope"), None);
905    }
906}