SVG: support static pattern fills and strokes (#2732)

* fix(svg): support static SVG patterns

* fix(svg): cache patterns per viewport

* fix(svg): share cached pattern content

* fix(svg): harden pattern cache keys

* fix(svg): harden pattern inheritance and caching

* Preserve SVG pattern fallback paint

* Format SVG pattern fallback conversion

* Harden SVG pattern resource bounds

* Fix SVG pattern test formatting

* fix(svg): bound hostile path and raster work

* Bound SVG ellipse expansion

* fix(ui-egui): suppress clippy warnings on wasm target

Rename unused `frame` parameter to `_frame` in draw_inner since
pointer_wrap::finish_native is cfg'd out on wasm32. Guard State::disabled
field with #[cfg(any(not(target_arch = "wasm32"), test))] so it only
exists where it's read (finish_frame on native + tests).

* fix: resolve clippy warnings blocking CI

- Rename unused `frame` parameter to `_frame` in draw_inner (wasm target)
- Guard State::disabled field with cfg(native + test) (wasm target)
- Replace chunks_exact(2) with as_chunks::<2>() in SVG polyline/polygon parsing

* fix(svg): budget raster work by affected region and report refusals

Address review on #2732: the finite-work guards charged the whole canvas,
dropping valid sparse art (8x8 image scaled to 1px on 2049x2049, 2x2 rect
under a 1,028-element clip on 512x512, one of six 2x2 rects sharing a 1x1
pattern on 3840x2160).

- Build fill/stroke/image/clip coverage over the device region only and
  charge that region; remaining whole-canvas passes are charged to a
  render-wide pixel budget (caps are not raised).
- Add try_rasterize/try_rasterize_with returning RasterError; rasterize
  keeps its signature. media layered decode now surfaces refusals.
- Pattern content reached through href inherits styles from the owning
  pattern; device_region falls back to the full canvas on non-finite input.
- Document new items and the limits in the svg README; regression tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Altynzhan KarybaiandClaude Sonnet 5.5 authored and GitHub committed 2026-11-12 21:12:42 +01:00
1 parent fbfeed2974
commit 4035d18132
13 files changed
+2486 -232

No files matched your search

+45 -21
View File
@@ -8,6 +8,7 @@ use effectcraft_project::{Comp, ItemId, Layer, LayerSource, MaskMode, Project, P
use effectcraft_svg::{Affine, Doc, Geom, GradientKind, Node, Paint};
use kurbo::{BezPath, Point};
/// Convert a kurbo path into shape path property groups.
fn shape_paths(ids: &mut Ids, path: &BezPath) -> Vec<PropGroup> {
effectcraft_path::from_kurbo(path)
.into_iter()
@@ -20,6 +21,7 @@ fn shape_paths(ids: &mut Ids, path: &BezPath) -> Vec<PropGroup> {
.collect()
}
/// Uniform scale factor of a transform (square root of its determinant magnitude).
fn det_scale(m: Affine) -> f64 {
let c = m.as_coeffs();
(c[0] * c[3] - c[1] * c[2]).abs().sqrt()
@@ -31,6 +33,7 @@ fn axis_aligned(m: Affine) -> Option<(f64, f64, f64, f64)> {
(c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && c[0] > 0.0 && c[3] > 0.0).then_some((c[0], c[3], c[4], c[5]))
}
/// Convert an SVG gradient into the engine's gradient value.
fn gradient_value(g: &effectcraft_svg::Gradient) -> Gradient {
Gradient {
colors: g.stops.iter().map(|(o, c)| (*o, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0])).collect(),
@@ -52,6 +55,7 @@ fn gradient_points(g: &effectcraft_svg::Gradient, m: Affine, bbox: kurbo::Rect)
}
}
/// Convert one SVG shape under transform `m` into shape-layer items.
fn shape_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine) -> Vec<PropGroup> {
use kurbo::Shape as _;
let mut items = vec![];
@@ -72,48 +76,60 @@ fn shape_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine) -> Vec<Prop
let bbox = s.geom.to_path().bounding_box();
if let Some(st) = &s.stroke {
let width = st.width * det_scale(m);
let mut g = match &st.paint {
Paint::Color(c) => build::shape_stroke(ids, [c[0], c[1], c[2], 1.0], width),
let paint = match &st.paint {
Paint::Color(c) => Some(build::shape_stroke(ids, [c[0], c[1], c[2], 1.0], width)),
Paint::Gradient(gr) => {
let (radial, a, b) = gradient_points(gr, m, bbox);
build::shape_gradient_stroke(ids, radial, a, b, gradient_value(gr), width)
Some(build::shape_gradient_stroke(ids, radial, a, b, gradient_value(gr), width))
}
// Editable shape layers do not yet have a pattern paint operator. Keep the geometry
// and any retained fallback as solid paint; SVG footage retains the rasterised pattern.
Paint::Pattern(pattern) => pattern.fallback.map(|c| build::shape_stroke(ids, [c[0], c[1], c[2], c[3]], width)),
};
set(&mut g, "opacity", Value::Scalar(st.opacity * 100.0));
set(&mut g, "cap", Value::Enum(st.cap as u32));
set(&mut g, "join", Value::Enum(st.join as u32));
set(&mut g, "miter", Value::Scalar(st.miter));
if let Some((d, off)) = &st.dash
&& let Some(dg) = g.sub_mut("dashes")
{
let k = det_scale(m);
set(dg, "dash", Value::Scalar(d.first().copied().unwrap_or(0.0) * k));
set(dg, "gap", Value::Scalar(d.get(1).or(d.first()).copied().unwrap_or(0.0) * k));
set(dg, "offset", Value::Scalar(off * k));
if let Some(mut g) = paint {
set(&mut g, "opacity", Value::Scalar(st.opacity * 100.0));
set(&mut g, "cap", Value::Enum(st.cap as u32));
set(&mut g, "join", Value::Enum(st.join as u32));
set(&mut g, "miter", Value::Scalar(st.miter));
if let Some((d, off)) = &st.dash
&& let Some(dg) = g.sub_mut("dashes")
{
let k = det_scale(m);
set(dg, "dash", Value::Scalar(d.first().copied().unwrap_or(0.0) * k));
set(dg, "gap", Value::Scalar(d.get(1).or(d.first()).copied().unwrap_or(0.0) * k));
set(dg, "offset", Value::Scalar(off * k));
}
items.push(g);
}
items.push(g);
}
if let Some(f) = &s.fill {
let mut g = match f {
Paint::Color(c) => build::shape_fill(ids, [c[0], c[1], c[2], 1.0]),
let paint = match f {
Paint::Color(c) => Some(build::shape_fill(ids, [c[0], c[1], c[2], 1.0])),
Paint::Gradient(gr) => {
let (radial, a, b) = gradient_points(gr, m, bbox);
build::shape_gradient_fill(ids, radial, a, b, gradient_value(gr))
Some(build::shape_gradient_fill(ids, radial, a, b, gradient_value(gr)))
}
// Pattern artwork is preserved in rasterised SVG footage; editable shapes keep only
// any retained fallback as solid paint.
Paint::Pattern(pattern) => pattern.fallback.map(|c| build::shape_fill(ids, [c[0], c[1], c[2], c[3]])),
};
set(&mut g, "opacity", Value::Scalar(s.fill_opacity * 100.0));
set(&mut g, "rule", Value::Enum(u32::from(s.fill_rule == effectcraft_svg::FillRule::EvenOdd)));
items.push(g);
if let Some(mut g) = paint {
set(&mut g, "opacity", Value::Scalar(s.fill_opacity * 100.0));
set(&mut g, "rule", Value::Enum(u32::from(s.fill_rule == effectcraft_svg::FillRule::EvenOdd)));
items.push(g);
}
}
items
}
/// Set a child property's value if present.
fn set(g: &mut PropGroup, k: &str, v: Value) {
if let Some(p) = g.get_mut(k) {
p.value = v;
}
}
/// Wrap items in a named shape group with the given opacity (0..1).
fn group(ids: &mut Ids, name: &str, items: Vec<PropGroup>, opacity: f64) -> PropGroup {
let mut g = build::shape_group(ids, name, items);
if let Some(p) = g.prop_mut("transform/opacity") {
@@ -165,6 +181,7 @@ fn clipped_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine, clips: &[
/// [`shapes_from_vector`]). Images are skipped (Layer ▸ Create makes them footage layers, see
/// [`split_at_images`]); soft masks are not converted.
pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec<PropGroup> {
/// Convert nodes to property groups, applying transform `m` and clip paths.
fn walk(ids: &mut Ids, nodes: &[Node], m: Affine, clips: &[BezPath]) -> Vec<PropGroup> {
let mut out = vec![];
for n in nodes.iter().rev() {
@@ -406,6 +423,7 @@ fn copy_transform(proj: &mut Project, src: &Layer, dst: &mut Layer) {
}
}
/// Copy timing and parenting from `src` to `dst`.
fn like_source(dst: &mut Layer, src: &Layer) {
dst.start_time = src.start_time;
dst.in_point = src.in_point;
@@ -418,13 +436,17 @@ fn like_source(dst: &mut Layer, src: &Layer) {
/// A raster image of a vector document (Create Shapes from Vector Layer makes it a footage
/// layer: shape layers have no images).
pub struct VectorImage {
/// Image name.
pub name: String,
/// Pixel width.
pub width: u32,
/// Pixel height.
pub height: u32,
/// Straight RGBA8.
pub rgba: std::sync::Arc<Vec<u8>>,
/// Image pixels → document pixels.
pub transform: Affine,
/// Opacity, 0..1.
pub opacity: f64,
/// Inside a clipping group or under a soft mask (not carried over).
pub clipped: bool,
@@ -432,6 +454,7 @@ pub struct VectorImage {
/// The document's images, bottom first, and how many soft-masked groups it has.
pub fn vector_images(doc: &Doc) -> (Vec<VectorImage>, usize) {
/// Collect images from a group tree, counting soft-masked groups.
fn walk(g: &effectcraft_svg::Group, m: Affine, opacity: f64, clipped: bool, out: &mut Vec<VectorImage>, masks: &mut usize) {
let m = m * g.transform;
let opacity = opacity * g.opacity;
@@ -467,6 +490,7 @@ pub fn vector_images(doc: &Doc) -> (Vec<VectorImage>, usize) {
/// layers and image footage layers can stack in the document's order. One more segment than
/// images.
pub fn split_at_images(doc: &Doc) -> Vec<Doc> {
/// Count the images in a group tree.
fn count(g: &effectcraft_svg::Group) -> usize {
g.children
.iter()
+35
View File
@@ -320,6 +320,41 @@ fn paste_svg_markup_as_a_shape_layer() {
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
}
#[test]
fn pasted_svg_pattern_keeps_geometry_and_editable_fallback_paint() {
let (mut s, _) = text_session();
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<defs><pattern id="p" patternUnits="userSpaceOnUse" width="4" height="4">
<circle cx="2" cy="2" r="1" fill="red"/>
</pattern></defs>
<rect id="patterned" width="16" height="6" fill="url(#p) rgba(255, 0, 0, 0.5)"/>
<path id="patterned-stroke" d="M0 8 H16" fill="none" stroke="url(#p) rgba(0, 0, 255, 0.25)" stroke-width="2"/>
<rect id="patterned-no-fallback" y="10" width="16" height="6" fill="url(#p)"/>
<path id="patterned-stroke-no-fallback" d="M0 15 H16" fill="none" stroke="url(#p)" stroke-width="2"/>
</svg>"##;
let result = s.execute_checked("edit.pasteSvg", json!({"svg": svg})).unwrap();
let layer_id = effectcraft_project::LayerId(result["layer"].as_u64().unwrap());
let layer = s.active_comp().unwrap().layer(layer_id).unwrap();
let shape = layer.props.sub("contents").unwrap().groups().find(|g| g.name == "patterned").unwrap();
let items: Vec<_> = shape.sub("contents").unwrap().groups().collect();
assert!(items.iter().any(|item| item.match_id == "rect"), "the editable geometry remains");
let fill = items.iter().find(|item| item.match_id == "fill").expect("fallback fill remains editable");
assert_eq!(fill.get("color").unwrap().value, KV::Color([1.0, 0.0, 0.0, 0.5]));
let stroke = layer.props.sub("contents").unwrap().groups().find(|g| g.name == "patterned-stroke").unwrap();
let items: Vec<_> = stroke.sub("contents").unwrap().groups().collect();
assert!(items.iter().any(|item| item.match_id == "path"), "the editable stroke path remains");
let stroke = items.iter().find(|item| item.match_id == "stroke").expect("fallback stroke remains editable");
assert_eq!(stroke.get("color").unwrap().value, KV::Color([0.0, 0.0, 1.0, 0.25]));
let no_fallback = layer.props.sub("contents").unwrap().groups().find(|g| g.name == "patterned-no-fallback").unwrap();
let items: Vec<_> = no_fallback.sub("contents").unwrap().groups().collect();
assert!(items.iter().any(|item| item.match_id == "rect"), "the editable geometry remains without a fallback");
assert!(!items.iter().any(|item| item.match_id == "fill"), "pattern paint without a fallback is omitted");
let no_fallback_stroke = layer.props.sub("contents").unwrap().groups().find(|g| g.name == "patterned-stroke-no-fallback").unwrap();
let items: Vec<_> = no_fallback_stroke.sub("contents").unwrap().groups().collect();
assert!(items.iter().any(|item| item.match_id == "path"), "the editable stroke geometry remains without a fallback");
assert!(!items.iter().any(|item| item.match_id == "stroke"), "pattern stroke without a fallback is omitted");
}
#[test]
fn paste_svg_rejects_text_that_is_not_usable_svg() {
let (mut s, _) = text_session();
+22 -5
View File
@@ -119,7 +119,7 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
if u64::from(w) * u64::from(h) > u64::from(MAX_VECTOR_SIDE) * u64::from(MAX_VECTOR_SIDE) {
return Err(MediaError::Decode(format!("{path}: {w}×{h} pixels is larger than vector footage can be drawn ({MAX_VECTOR_SIDE}×{MAX_VECTOR_SIDE})")));
}
let img = effectcraft_svg::rasterize_with(&doc, w, h, to_image);
let img = effectcraft_svg::try_rasterize_with(&doc, w, h, to_image).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
// The rasteriser produces premultiplied pixels already.
let _ = op;
return Ok(Some(img));
@@ -132,10 +132,15 @@ const MAX_VECTOR_SIDE: u32 = 16384;
/// The vector footage `footage` (of the file `path` with these `bytes`) rasterised at `scale` ×
/// its size as [`effectcraft_render::vector_fit`] draws it (Continuously Rasterize).
pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], footage: &Footage, scale: f64) -> Option<Image> {
///
/// `None` when the file cannot be read as a vector document. `Some(Err(_))` when a render budget
/// refused part of the drawing (for example a layer scaled past the surface limit), so the caller
/// can report it instead of showing a partial or empty image as if it were complete.
pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], footage: &Footage, scale: f64) -> Option<Result<Image>> {
let doc = footage_doc(path, bytes, footage)?.ok()?;
let ((sw, sh), to_image) = fit(&doc, footage, scale);
Some(effectcraft_svg::rasterize_with(&doc, sw.clamp(1, MAX_VECTOR_SIDE), sh.clamp(1, MAX_VECTOR_SIDE), to_image))
let drawn = effectcraft_svg::try_rasterize_with(&doc, sw.clamp(1, MAX_VECTOR_SIDE), sh.clamp(1, MAX_VECTOR_SIDE), to_image);
Some(drawn.map_err(|e| MediaError::Decode(format!("{path}: {e}"))))
}
/// The page framing of vector footage: as After Effects places it for footage from an After
@@ -173,6 +178,18 @@ mod tests {
assert_eq!((img.width, img.height), (40, 20));
}
/// Continuously Rasterize reports a layer scaled past the render budget as an error instead of
/// returning a partial or empty image, and still draws the same layer at an ordinary scale.
#[test]
fn rasterizing_vector_footage_past_the_budget_is_reported() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100"/></svg>"#;
let footage = probe("scaled.svg", svg).unwrap().unwrap();
let ok = rasterize_vector("scaled.svg", svg, &footage, 1.0).unwrap().unwrap();
assert_eq!((ok.width, ok.height), (100, 100));
let e = rasterize_vector("scaled.svg", svg, &footage, 1000.0).unwrap().err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("scaled.svg"), "{e}");
}
/// A 200 × 100 pt page: red left half, blue right half.
fn halves_pdf() -> Vec<u8> {
effectcraft_pdf::write::pdf(
@@ -214,7 +231,7 @@ mod tests {
(footage.width, footage.height) = (100, 80);
let unplaced = decode("art.ai", &pdf, &footage, op).unwrap().unwrap();
assert!(unplaced == own, "not placed: drawn as before");
assert_eq!(rasterize_vector("art.ai", &pdf, &footage, 0.5).map(|i| (i.width, i.height)), Some((100, 50)));
assert_eq!(rasterize_vector("art.ai", &pdf, &footage, 0.5).and_then(|r| r.ok()).map(|i| (i.width, i.height)), Some((100, 50)));
footage.placed_page = true;
let img = decode("art.ai", &pdf, &footage, op).unwrap().unwrap();
@@ -226,7 +243,7 @@ mod tests {
assert!(is_blue(img.get(x, y)), "blue at ({x}, {y}): {:?}", img.get(x, y));
}
let sharp = rasterize_vector("art.ai", &pdf, &footage, 2.0).unwrap();
let sharp = rasterize_vector("art.ai", &pdf, &footage, 2.0).unwrap().unwrap();
assert_eq!((sharp.width, sharp.height), (200, 160));
assert!(is_red(sharp.get(50, 155)) && is_blue(sharp.get(150, 155)));
// Placed at the page's own size: 1:1.
+14 -2
View File
@@ -1110,7 +1110,7 @@ impl FootageSource for MediaPool {
}
fn problem(&self, footage: &Footage) -> Option<String> {
if footage.missing || footage.kind != FootageKind::Video {
if footage.missing || (footage.kind != FootageKind::Video && !effectcraft_render::is_vector_footage(footage)) {
return None;
}
lock(&self.inner.problems).get(footage.path.as_str()).cloned()
@@ -1121,7 +1121,19 @@ impl FootageSource for MediaPool {
return None;
}
let bytes = self.inner.read(&footage.path).ok()?;
crate::layered::rasterize_vector(&footage.path, &bytes, footage, scale).map(Arc::new)
let path: Arc<str> = footage.path.as_str().into();
match crate::layered::rasterize_vector(&footage.path, &bytes, footage, scale)? {
Ok(img) => {
lock(&self.inner.problems).remove(&path);
Some(Arc::new(img))
}
// Refused by a render budget (a layer scaled too far): no frame, with the reason
// reported through `problem` rather than showing a partial image as complete.
Err(e) => {
self.inner.note_problem(&path, "", &e);
None
}
}
}
fn set_aux_footage(&self, paths: &[String]) {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "effectcraft-svg"
description = "SVG import: parses SVG 1.1 / SVG 2 static vector content (paths, basic shapes, groups, transforms, use, CSS styles, solid and gradient fills/strokes, opacity) and rasterises it at any scale"
description = "SVG import: parses SVG 1.1 / SVG 2 static vector content (paths, basic shapes, groups, transforms, use, CSS styles, solid, gradient and pattern fills/strokes, opacity) and rasterises it at any scale"
version.workspace = true
edition.workspace = true
license.workspace = true
+23 -1
View File
@@ -18,9 +18,31 @@ here, plus **CSS Color Module Level 3** for colour keywords and functions:
`stroke-opacity`, `opacity`, `display`, `visibility`, `color`/`currentColor`;
- `linearGradient` and `radialGradient` (with `fx`/`fy` focal point), `gradientUnits`,
`gradientTransform`, `spreadMethod`, `href` inheritance of stops and attributes;
- static (non-animated) `<pattern>` fill/stroke with `patternUnits`, `patternContentUnits`, `patternTransform`,
tile `x`/`y`/`width`/`height`, local `href`/`xlink:href` inheritance and solid paints on
supported shape/group children;
- styling through presentation attributes, the `style` attribute and `<style>` sheets with
type, class, id and universal selectors (simple selectors and selector lists).
Not supported (ignored): text, images, filters, clip paths, masks, patterns, markers, animation.
Pattern `viewBox`, external pattern references, nested paint servers and non-shape pattern content
are unsupported; invalid patterns use a `url(...)` fallback when present. Local pattern `href`
chains are depth-limited and cycles are rejected. Tiles render once and are sampled across the
painted area; both live surfaces and aggregate tile work are bounded. Pattern paints are
rasterised in SVG footage; editable shape layers retain geometry and convert only a supplied
`url(...)` fallback to solid fill/stroke.
Path expansion and gradient resolution have per-import work limits. The rasterizer also caps live
surface memory and cumulative pattern, clip-coverage, and source-image work. Over-budget imports
return an error; over-budget raster operations are skipped, using a paint fallback where available.
`rasterize`/`rasterize_with` drop content refused by a budget and return the image drawn so far
(an empty image if the canvas itself is refused). `try_rasterize`/`try_rasterize_with` return
`Err(RasterError)` instead (`SurfaceLimit` for memory, `WorkLimit` for work). Budgets per render:
512 MiB of live offscreen surfaces, 512 MiB of pattern tiles, 256 Mi units each of pattern
render work, clip-path rasterisation work and image sampling work, and 2^31 pixels touched across
all passes.
For SVG input, text, images, filters, clip paths, masks, markers and animation (including pattern
animation) are unsupported and ignored. The PDF importers' documents do use clip, mask, blend and
image nodes, which the rasterizer renders under the same budgets.
XML parsing uses `roxmltree` (MIT/Apache-2.0). Test fixtures are original work (see
`tests/fixtures/*.attribution`).
+1
View File
@@ -251,6 +251,7 @@ mod tests {
use super::parse_color;
#[test]
/// Non-finite channel values are rejected.
fn non_finite_rejected() {
for s in [
"rgb(nan,0,0)",
+561 -48
View File
@@ -11,12 +11,12 @@ mod color;
mod pathdata;
mod raster;
use std::collections::HashMap;
use std::{collections::HashMap, sync::Arc};
pub use color::parse_color;
pub use kurbo::{Affine, BezPath};
pub use pathdata::parse_path_data;
pub use raster::{blend_pixel, rasterize, rasterize_with};
pub use raster::{RasterError, blend_pixel, rasterize, rasterize_with, try_rasterize, try_rasterize_with};
#[derive(Debug, Clone, PartialEq)]
pub enum Error {
@@ -73,6 +73,15 @@ pub enum Spread {
Repeat,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
/// How pattern tile coordinates are interpreted.
pub enum PatternUnits {
/// Coordinates are fractions of the painted shape's bounding box.
ObjectBoundingBox,
/// Coordinates are expressed in the current user space.
UserSpaceOnUse,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum GradientKind {
Linear { x1: f64, y1: f64, x2: f64, y2: f64 },
@@ -83,7 +92,7 @@ pub enum GradientKind {
pub struct Gradient {
pub kind: GradientKind,
/// (offset 0..1, straight RGBA 0..1), sorted.
pub stops: Vec<(f64, [f64; 4])>,
pub stops: Arc<[(f64, [f64; 4])]>,
/// `gradientUnits="objectBoundingBox"` (the default): coordinates are fractions of the
/// shape's bounding box.
pub bbox_units: bool,
@@ -91,10 +100,36 @@ pub struct Gradient {
pub spread: Spread,
}
#[derive(Clone, Debug, PartialEq)]
/// A resolved `<pattern>` paint: tile geometry, coordinate units and shared content.
pub struct Pattern {
/// Tile origin and size in `pattern_units` coordinates.
pub x: f64,
/// Tile origin y in `pattern_units` coordinates.
pub y: f64,
/// Tile width in `pattern_units` coordinates.
pub width: f64,
/// Tile height in `pattern_units` coordinates.
pub height: f64,
/// Coordinate system used for the tile origin and size.
pub pattern_units: PatternUnits,
/// Coordinate system used by the tile's child shapes.
pub content_units: PatternUnits,
/// Transform applied to the repeating tile.
pub transform: Affine,
/// Immutable tile content shared by every use of the resolved pattern.
pub content: Arc<Group>,
/// Conservative shape/path traversal cost per tile pixel, charged during rasterisation.
pub render_complexity: usize,
/// The fallback from `url(#id) fallback`, used if the target bbox or tile is invalid.
pub fallback: Option<[f64; 4]>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Paint {
Color([f64; 3]),
Gradient(Gradient),
Pattern(Pattern),
}
#[derive(Clone, Debug, PartialEq)]
@@ -418,17 +453,24 @@ struct Rule {
/// must have (id, else first class, else type) so an element only checks rules that can match.
#[derive(Default, Debug)]
struct StyleSheet {
/// Distinct selector rules in source order.
rules: Vec<Rule>,
/// Declaration blocks; comma-list selectors share one block.
blocks: Vec<Vec<(String, String)>>,
/// Declarations over all blocks.
decls: usize,
/// Rules keyed by their id selector.
by_id: HashMap<String, Vec<usize>>,
/// Rules keyed by their first class selector.
by_class: HashMap<String, Vec<usize>>,
/// Rules keyed by their type selector.
by_tag: HashMap<String, Vec<usize>>,
/// Rules with no id, class or type to index on.
universal: Vec<usize>,
}
impl StyleSheet {
/// Build the id, class, tag and universal lookup buckets from `rules`.
fn index(&mut self) {
for (i, r) in self.rules.iter().enumerate() {
let bucket = if let Some(id) = &r.id {
@@ -591,6 +633,7 @@ const PROPS: &[&str] = &[
struct Parser<'a, 'i> {
ids: HashMap<String, roxmltree::Node<'a, 'i>>,
/// The parsed `<style>` sheets.
css: StyleSheet,
/// CSS matching work done so far; past [`MAX_CSS_WORK`] rules stop applying and the import errs.
css_work: std::cell::Cell<usize>,
@@ -603,6 +646,32 @@ struct Parser<'a, 'i> {
/// The `<use>` targets being expanded, outermost first: a reference back into one is a
/// cycle and is skipped.
using: Vec<roxmltree::NodeId>,
/// Pattern contents may use only solid paints; nested paint servers invalidate that pattern.
in_pattern_content: bool,
/// Set once pattern content uses a paint server, invalidating that pattern.
invalid_pattern_content: bool,
/// Work revisited while resolving patterns, bounded across all references.
pattern_work: usize,
/// Maximum pattern resolution work allowed for this import.
pattern_work_budget: usize,
/// Set when pattern resolution work passed its budget.
pattern_work_exceeded: bool,
/// Path data bytes (weighted) parsed so far.
path_data_work: usize,
/// Path elements produced so far across all shapes.
path_element_work: usize,
/// Maximum weighted path data work allowed for this import.
path_work_budget: usize,
/// Set when path data or element work passed its budget.
path_work_exceeded: bool,
/// Parsed pattern content is viewport-dependent because lengths may be percentages; the cache is bounded.
pattern_cache: HashMap<(roxmltree::NodeId, u64, u64), Option<Pattern>>,
/// Resolved gradients share their stop arrays across repeated paint references.
gradient_cache: HashMap<(roxmltree::NodeId, u64, u64), Gradient>,
/// Gradient resolution work done so far.
gradient_work: usize,
/// Set when gradient work passed [`MAX_GRADIENT_WORK`].
gradient_work_exceeded: bool,
/// Path elements and gradient stops kept so far, `<use>` copies and repeated gradient
/// references included; past [`MAX_RETAINED`] the import errs.
retained: std::cell::Cell<usize>,
@@ -649,6 +718,53 @@ impl<'a, 'i> Parser<'a, 'i> {
w <= MAX_CSS_WORK
}
/// Charge work while resolving pattern references. Attribute bytes and each traversed child
/// both count so a cache miss cannot repeatedly scan an enormous descriptive-only pattern.
fn charge_pattern_work(&mut self, work: usize) -> bool {
self.pattern_work = self.pattern_work.saturating_add(work);
if self.pattern_work > self.pattern_work_budget {
self.pattern_work_exceeded = true;
return false;
}
true
}
/// Charge gradient resolution work; false once the budget is exceeded.
fn charge_gradient_work(&mut self, work: usize) -> bool {
self.gradient_work = self.gradient_work.saturating_add(work);
if self.gradient_work > MAX_GRADIENT_WORK {
self.gradient_work_exceeded = true;
return false;
}
true
}
/// Charge path data bytes against the path work budget; false once exceeded.
fn charge_path_data(&mut self, bytes: usize) -> bool {
self.path_data_work = self.path_data_work.saturating_add(bytes.saturating_mul(PATH_DATA_WORK_FACTOR));
if self.path_data_work > self.path_work_budget {
self.path_work_exceeded = true;
return false;
}
true
}
/// Charge produced path elements against the per-import cap; false once exceeded.
fn charge_path_elements(&mut self, elements: usize) -> bool {
self.path_element_work = self.path_element_work.saturating_add(elements);
if self.path_element_work > MAX_PATH_ELEMENTS_PER_IMPORT {
self.path_work_exceeded = true;
return false;
}
true
}
/// Charge a pattern node's attributes and traversal while resolving a pattern reference.
fn charge_pattern_attributes(&mut self, n: roxmltree::Node) -> bool {
let bytes = n.attributes().map(|attribute| attribute.name().len().saturating_add(attribute.value().len())).fold(0usize, usize::saturating_add);
self.charge_pattern_work(bytes.saturating_add(1))
}
/// Apply the matching `<style>` rules to `out`, by specificity then source order.
fn apply_rules(&self, n: roxmltree::Node, out: &mut HashMap<String, String>) {
let css = &self.css;
@@ -705,7 +821,7 @@ impl<'a, 'i> Parser<'a, 'i> {
unit = &s[num_end - 1..];
}
let v: f64 = num.parse().ok()?;
Some(match unit.trim() {
let value = match unit.trim() {
"" | "px" => v,
"pt" => v * 4.0 / 3.0,
"pc" => v * 16.0,
@@ -723,7 +839,8 @@ impl<'a, 'i> Parser<'a, 'i> {
v / 100.0 * base
}
_ => v,
})
};
value.is_finite().then_some(value)
}
fn attr_len(&self, n: roxmltree::Node, name: &str, axis: char, st: &Style) -> f64 {
@@ -740,7 +857,8 @@ impl<'a, 'i> Parser<'a, 'i> {
}
if let Some(rest) = v.strip_prefix("url(") {
let (url, fallback) = rest.split_once(')').unwrap_or((rest, ""));
let id = url.trim().trim_matches(['"', '\'']).trim_start_matches('#').to_string();
let url = url.trim().trim_matches(['"', '\'']);
let id = url.strip_prefix('#').filter(|id| !id.is_empty()).unwrap_or_default().to_string();
let fb = fallback.trim();
let fb = if fb.is_empty() || fb == "none" { None } else { parse_color(fb) };
return Some(Some(PaintRef::Url(id, fb)));
@@ -754,8 +872,11 @@ impl<'a, 'i> Parser<'a, 'i> {
/// Apply an element's declared inherited properties to `st`. Returns (opacity, display).
fn cascade(&self, n: roxmltree::Node, st: &mut Style) -> (f64, bool) {
let d = self.declared(n);
let num =
|k: &str| d.get(k).and_then(|v| v.trim().trim_end_matches('%').parse::<f64>().ok().map(|x| if v.trim().ends_with('%') { x / 100.0 } else { x }));
let num = |k: &str| {
d.get(k)
.and_then(|v| v.trim().trim_end_matches('%').parse::<f64>().ok().map(|x| if v.trim().ends_with('%') { x / 100.0 } else { x }))
.filter(|x| x.is_finite())
};
if let Some(c) = d.get("color").and_then(|v| parse_color(v)) {
st.color = c;
}
@@ -820,29 +941,72 @@ impl<'a, 'i> Parser<'a, 'i> {
(num("opacity").unwrap_or(1.0).clamp(0.0, 1.0), display)
}
fn resolve_paint(&self, p: &PaintRef, st: &Style) -> Option<(Paint, f64)> {
/// Resolve a paint reference, retaining a per-reference fallback for patterns.
fn resolve_paint(&mut self, p: &PaintRef, st: &Style) -> Option<(Paint, f64)> {
if self.in_pattern_content && matches!(p, PaintRef::Url(..)) {
self.invalid_pattern_content = true;
return None;
}
match p {
PaintRef::Color(c) => Some((Paint::Color([c[0], c[1], c[2]]), c[3])),
PaintRef::Current => Some((Paint::Color([st.color[0], st.color[1], st.color[2]]), st.color[3])),
PaintRef::Url(id, fb) => match self.gradient(id, 0) {
PaintRef::Url(id, fallback) => match self.gradient(id, 0) {
Some(g) if g.stops.len() == 1 => {
let c = g.stops[0].1;
let (_, c) = g.stops.first()?;
Some((Paint::Color([c[0], c[1], c[2]]), c[3]))
}
Some(g) if !g.stops.is_empty() => Some((Paint::Gradient(g), 1.0)),
Some(_) => None,
None => fb.map(|c| (Paint::Color([c[0], c[1], c[2]]), c[3])),
None => match self.pattern(id) {
Some(mut pattern) => {
pattern.fallback = *fallback;
Some((Paint::Pattern(pattern), 1.0))
}
None => fallback.map(|c| (Paint::Color([c[0], c[1], c[2]]), c[3])),
},
},
}
}
fn href(&self, n: roxmltree::Node<'a, 'i>) -> Option<roxmltree::Node<'a, 'i>> {
let h = n.attributes().find(|a| a.name() == "href").map(|a| a.value())?;
self.ids.get(h.trim().trim_start_matches('#')).copied()
/// Read a local `href` or namespaced `xlink:href` value from an SVG element.
fn href_value(n: roxmltree::Node<'a, 'i>) -> Option<String> {
/// XML namespace of `xlink:href`.
const XLINK: &str = "http://www.w3.org/1999/xlink";
let href = n
.attributes()
.find(|a| a.name() == "href" && a.namespace().is_none())
.or_else(|| n.attributes().find(|a| a.name() == "href" && a.namespace() == Some(XLINK)))?;
Some(href.value().to_string())
}
/// A gradient by id, with `href` inheritance of attributes and stops.
fn gradient(&self, id: &str, depth: usize) -> Option<Gradient> {
fn href(&self, n: roxmltree::Node<'a, 'i>) -> Option<roxmltree::Node<'a, 'i>> {
let h = Self::href_value(n)?;
let id = h.trim().strip_prefix('#')?;
if id.is_empty() {
return None;
}
self.ids.get(id).copied()
}
/// A gradient by id, with a viewport-specific cache for its resolved attributes and stops.
fn gradient(&mut self, id: &str, depth: usize) -> Option<Gradient> {
let node = *self.ids.get(id)?;
if !matches!(node.tag_name().name(), "linearGradient" | "radialGradient") || depth > 16 {
return None;
}
let key = (node.id(), self.vw.to_bits(), self.vh.to_bits());
if let Some(gradient) = self.gradient_cache.get(&key) {
return Some(gradient.clone());
}
let gradient = self.parse_gradient(id, depth)?;
if self.gradient_cache.len() < MAX_GRADIENT_CACHE_ENTRIES {
self.gradient_cache.insert(key, gradient.clone());
}
Some(gradient)
}
/// Parse a gradient by id, charging reference-chain and stop work before retaining the result.
fn parse_gradient(&mut self, id: &str, depth: usize) -> Option<Gradient> {
let n = *self.ids.get(id)?;
let tag = n.tag_name().name();
if !matches!(tag, "linearGradient" | "radialGradient") || depth > 16 {
@@ -851,24 +1015,31 @@ impl<'a, 'i> Parser<'a, 'i> {
// Chain: this element, then what it references.
let mut chain = vec![n];
let mut cur = n;
if !self.charge_gradient_work(cur.attributes().map(|a| a.name().len().saturating_add(a.value().len())).fold(1usize, usize::saturating_add)) {
return None;
}
while let Some(next) = self.href(cur) {
if chain.len() > 16 || chain.contains(&next) {
break;
}
chain.push(next);
cur = next;
if !self.charge_gradient_work(cur.attributes().map(|a| a.name().len().saturating_add(a.value().len())).fold(1usize, usize::saturating_add)) {
return None;
}
}
let attr = |name: &str| chain.iter().find_map(|e| e.attribute(name));
let bbox_units = attr("gradientUnits") != Some("userSpaceOnUse");
let st = Style::default();
let len = |name: &str, axis: char, def: &str| -> f64 {
let v = attr(name).unwrap_or(def);
if bbox_units {
let value = if bbox_units {
let v = v.trim();
if let Some(p) = v.strip_suffix('%') { p.parse::<f64>().unwrap_or(0.0) / 100.0 } else { v.parse::<f64>().unwrap_or(0.0) }
} else {
self.length(v, axis, st.font_size).unwrap_or(0.0)
}
};
if value.is_finite() { value } else { 0.0 }
};
let kind = if tag == "linearGradient" {
GradientKind::Linear { x1: len("x1", 'x', "0%"), y1: len("y1", 'y', "0%"), x2: len("x2", 'x', "100%"), y2: len("y2", 'y', "0%") }
@@ -888,13 +1059,29 @@ impl<'a, 'i> Parser<'a, 'i> {
};
// Stops: from the first element in the chain that has any.
let mut stops = vec![];
if let Some(src) = chain.iter().find(|e| e.children().any(|c| c.is_element() && c.tag_name().name() == "stop")) {
let mut source = None;
'source: for candidate in &chain {
for child in candidate.children().filter(|child| child.is_element()) {
if !self.charge_gradient_work(1) {
return None;
}
if child.tag_name().name() == "stop" {
source = Some(*candidate);
break 'source;
}
}
}
if let Some(src) = source {
// Charged before the stops are read: every reference reads and keeps them again.
if !self.charge_retained(src.children().filter(|c| c.is_element()).count()) {
return None;
}
let mut last = 0.0f64;
for s in src.children().filter(|c| c.is_element() && c.tag_name().name() == "stop") {
let stop_work = s.attributes().map(|a| a.name().len().saturating_add(a.value().len())).fold(1usize, usize::saturating_add);
if !self.charge_gradient_work(stop_work) {
return None;
}
let d = self.declared(s);
let off = s
.attribute("offset")
@@ -910,11 +1097,207 @@ impl<'a, 'i> Parser<'a, 'i> {
.get("stop-color")
.and_then(|c| if c.trim() == "currentColor" { d.get("color").and_then(|x| parse_color(x)) } else { parse_color(c) })
.unwrap_or([0.0, 0.0, 0.0, 1.0]);
let o = d.get("stop-opacity").and_then(|v| v.trim().parse::<f64>().ok()).unwrap_or(1.0).clamp(0.0, 1.0);
let o = d.get("stop-opacity").and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0);
stops.push((off, [c[0], c[1], c[2], c[3] * o]));
}
}
Some(Gradient { kind, stops, bbox_units, transform, spread })
Some(Gradient { kind, stops: Arc::from(stops.into_boxed_slice()), bbox_units, transform, spread })
}
/// Parse a pattern length using the coordinate system selected by its units attribute.
fn pattern_length(&self, value: &str, axis: char, font: f64, units: PatternUnits) -> Option<f64> {
let value = value.trim();
let length = match units {
PatternUnits::ObjectBoundingBox => match value.strip_suffix('%') {
Some(percent) => percent.trim().parse::<f64>().ok()? / 100.0,
None => value.parse::<f64>().ok()?,
},
PatternUnits::UserSpaceOnUse => {
let number_end = value.find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E'))).unwrap_or(value.len());
let (mut number, mut unit) = (&value[..number_end], &value[number_end..]);
// The length scanner admits `e` for exponents; back it off when it starts `em` or
// `ex`, or follows a complete exponent (for example `1e2em`).
if number.parse::<f64>().is_err() && (number.ends_with('e') || number.ends_with('E')) {
number = &number[..number.len().saturating_sub(1)];
unit = &value[number_end.saturating_sub(1)..];
}
number.parse::<f64>().ok()?;
let unit = unit.trim();
if !matches!(unit, "" | "px" | "pt" | "pc" | "mm" | "cm" | "in" | "em" | "ex" | "%") {
return None;
}
self.length(value, axis, font)?
}
};
length.is_finite().then_some(length)
}
/// Resolve a local static pattern. The nearest chain member supplies each attribute;
/// child content comes from the first member with non-descriptive element children.
fn pattern(&mut self, id: &str) -> Option<Pattern> {
let target = *self.ids.get(id)?;
if target.tag_name().name() != "pattern" {
return None;
}
let key = (target.id(), self.vw.to_bits(), self.vh.to_bits());
if let Some(pattern) = self.pattern_cache.get(&key) {
return pattern.clone();
}
let pattern = self.parse_pattern(id);
if pattern.is_some() && self.pattern_cache.len() < MAX_PATTERN_CACHE_ENTRIES {
self.pattern_cache.insert(key, pattern.clone());
}
pattern
}
/// Parse one pattern and account for all work done while resolving its inheritance chain.
fn parse_pattern(&mut self, id: &str) -> Option<Pattern> {
/// Longest `href` inheritance chain followed when resolving a pattern.
const MAX_PATTERN_HREF_DEPTH: usize = 16;
let first = *self.ids.get(id)?;
if first.tag_name().name() != "pattern" {
return None;
}
let mut chain = vec![first];
loop {
let current = *chain.last()?;
if !self.charge_pattern_attributes(current) {
return None;
}
let Some(href) = Self::href_value(current) else { break };
if chain.len() >= MAX_PATTERN_HREF_DEPTH {
return None;
}
let target_id = href.trim().strip_prefix('#')?;
if target_id.is_empty() {
return None;
}
let target = *self.ids.get(target_id)?;
if target.tag_name().name() != "pattern" || chain.iter().any(|seen| seen.id() == target.id()) {
return None;
}
chain.push(target);
}
// This implementation has no viewBox mapper for pattern contents; don't silently paint
// them in the wrong coordinate system.
if chain.iter().any(|node| node.attribute("viewBox").is_some()) {
return None;
}
let attr = |name: &str| chain.iter().find_map(|node| node.attribute(name).map(str::to_string));
let mut content_style = Style::default();
let mut content_opacity = 1.0;
let ancestors: Vec<_> = first.ancestors().filter(|node| node.is_element()).collect();
for ancestor in ancestors.into_iter().rev() {
if ancestor.id() != first.id() && !self.charge_pattern_attributes(ancestor) {
return None;
}
let (opacity, _) = self.cascade(ancestor, &mut content_style);
if ancestor.id() == first.id() {
content_opacity = opacity;
}
}
let font_size = content_style.font_size;
let units = match attr("patternUnits").as_deref() {
None | Some("objectBoundingBox") => PatternUnits::ObjectBoundingBox,
Some("userSpaceOnUse") => PatternUnits::UserSpaceOnUse,
Some(_) => return None,
};
let content_units = match attr("patternContentUnits").as_deref() {
None | Some("userSpaceOnUse") => PatternUnits::UserSpaceOnUse,
Some("objectBoundingBox") => PatternUnits::ObjectBoundingBox,
Some(_) => return None,
};
let x = match attr("x").as_deref() {
Some(value) => self.pattern_length(value, 'x', font_size, units)?,
None => 0.0,
};
let y = match attr("y").as_deref() {
Some(value) => self.pattern_length(value, 'y', font_size, units)?,
None => 0.0,
};
let width = self.pattern_length(attr("width")?.as_str(), 'x', font_size, units)?;
let height = self.pattern_length(attr("height")?.as_str(), 'y', font_size, units)?;
if width <= 0.0 || height <= 0.0 {
return None;
}
let transform = attr("patternTransform").as_deref().map(parse_transform).unwrap_or(Affine::IDENTITY);
let coeffs = transform.as_coeffs();
let determinant = coeffs[0] * coeffs[3] - coeffs[1] * coeffs[2];
if !coeffs.iter().all(|v| v.is_finite())
|| !determinant.is_finite()
|| determinant == 0.0
|| !transform.inverse().as_coeffs().iter().all(|v| v.is_finite())
{
return None;
}
let (old_pattern_content, old_invalid) = (self.in_pattern_content, self.invalid_pattern_content);
let (old_vw, old_vh) = (self.vw, self.vh);
self.in_pattern_content = true;
self.invalid_pattern_content = false;
if content_units == PatternUnits::ObjectBoundingBox {
self.vw = 1.0;
self.vh = 1.0;
}
let source = chain.iter().copied().find(|node| {
node.children().any(|child| {
if !self.charge_pattern_work(1) {
// Stop the scan immediately; the exceeded flag below rejects this pattern.
return true;
}
child.is_element() && !matches!(child.tag_name().name(), "title" | "desc" | "metadata")
})
});
if self.pattern_work_exceeded {
return None;
}
// Content inherits from the element that actually owns it, which differs from `first`
// when the content comes through an `href` chain.
if let Some(source) = source.filter(|s| s.id() != first.id()) {
let mut inherited = Style::default();
let owners: Vec<_> = source.ancestors().filter(|node| node.is_element()).collect();
for owner in owners.into_iter().rev() {
if !self.charge_pattern_attributes(owner) {
self.in_pattern_content = old_pattern_content;
self.invalid_pattern_content = old_invalid;
self.vw = old_vw;
self.vh = old_vh;
return None;
}
self.cascade(owner, &mut inherited);
}
content_style = inherited;
}
let mut children = Vec::new();
if let Some(source) = source {
for child in source.children() {
self.node(child, &content_style, &mut children, 1);
if self.pattern_work_exceeded {
break;
}
}
}
let invalid = self.invalid_pattern_content;
self.in_pattern_content = old_pattern_content;
self.invalid_pattern_content = old_invalid;
self.vw = old_vw;
self.vh = old_vh;
if invalid || self.pattern_work_exceeded {
return None;
}
let render_complexity = pattern_render_complexity(&children);
let content = Group {
name: source.map_or_else(|| Self::element_name(first), Self::element_name),
transform: Affine::IDENTITY,
opacity: content_opacity,
children,
clip: vec![],
blend: BlendMode::Normal,
mask: None,
isolated: true,
knockout: false,
};
Some(Pattern { x, y, width, height, pattern_units: units, content_units, transform, content: Arc::new(content), render_complexity, fallback: None })
}
fn element_name(n: roxmltree::Node) -> String {
@@ -926,10 +1309,19 @@ impl<'a, 'i> Parser<'a, 'i> {
if depth > 64 || !is_svg_ns(&n) || self.work > self.budget {
return;
}
if self.in_pattern_content && !self.charge_pattern_attributes(n) {
return;
}
// Every element converted counts, each `<use>` copy again: a few references to groups
// that reference each other several times can multiply into billions (#1236).
self.work += 1;
let tag = n.tag_name().name();
if self.in_pattern_content
&& !matches!(tag, "g" | "rect" | "circle" | "ellipse" | "path" | "line" | "polyline" | "polygon" | "defs" | "title" | "desc" | "metadata" | "style")
{
self.invalid_pattern_content = true;
return;
}
match tag {
"defs" | "title" | "desc" | "metadata" | "style" | "linearGradient" | "radialGradient" | "symbol" | "clipPath" | "mask" | "pattern" | "marker"
| "script" => return,
@@ -1009,9 +1401,12 @@ impl<'a, 'i> Parser<'a, 'i> {
let mut inner = vec![];
let mut sst = st.clone();
let (o, _) = self.cascade(target, &mut sst);
let saved_viewport = (self.vw, self.vh);
(self.vw, self.vh) = viewbox(target).map_or((w, h), |(_, vw, vh)| (vw, vh));
for c in target.children() {
self.node(c, &sst, &mut inner, depth + 1);
}
(self.vw, self.vh) = saved_viewport;
children.push(Node::Group(Group {
name: Self::element_name(target),
transform: vb,
@@ -1043,7 +1438,40 @@ impl<'a, 'i> Parser<'a, 'i> {
if !st.visible {
return;
}
let Some(geom) = self.geometry(n, &st) else { return };
let (path_data, points) = match tag {
"path" => {
let Some(data) = n.attribute("d") else { return };
if !self.charge_path_data(data.len()) {
return;
}
let (path, exceeded) = pathdata::parse_path_data_bounded(data, MAX_PATH_ELEMENTS_PER_IMPORT_ELEMENT);
if exceeded || !self.charge_path_elements(path.elements().len()) {
self.path_work_exceeded = true;
return;
}
(Some(path), None)
}
"polyline" | "polygon" => {
let Some(data) = n.attribute("points") else { return };
if !self.charge_path_data(data.len()) {
return;
}
let max_values = MAX_PATH_ELEMENTS_PER_IMPORT_ELEMENT.saturating_sub(1).saturating_mul(2);
let (points, exceeded) = pathdata::numbers_bounded(data, max_values);
if exceeded {
self.path_work_exceeded = true;
return;
}
let point_count = points.len() / 2;
let elements = point_count.saturating_add(usize::from(tag == "polygon"));
if points.len() >= 4 && !self.charge_path_elements(elements) {
return;
}
(None, Some(points))
}
_ => (None, None),
};
let Some(geom) = self.geometry(n, &st, path_data, points) else { return };
if let Geom::Path(p) = &geom
&& !self.charge_retained(p.elements().len())
{
@@ -1099,11 +1527,12 @@ impl<'a, 'i> Parser<'a, 'i> {
}
}
fn geometry(&self, n: roxmltree::Node, st: &Style) -> Option<Geom> {
/// Resolve an element's geometry from its attributes, or from pre-parsed path data / points.
fn geometry(&self, n: roxmltree::Node, st: &Style, path_data: Option<BezPath>, points: Option<Vec<f64>>) -> Option<Geom> {
let l = |name: &str, axis: char| self.attr_len(n, name, axis, st);
Some(match n.tag_name().name() {
"path" => {
let p = parse_path_data(n.attribute("d")?);
let p = path_data?;
if p.elements().is_empty() {
return None;
}
@@ -1114,6 +1543,10 @@ impl<'a, 'i> Parser<'a, 'i> {
if !(w > 0.0 && h > 0.0) {
return None;
}
let (x, y) = (l("x", 'x'), l("y", 'y'));
if !(x + w).is_finite() || !(y + h).is_finite() {
return None;
}
let rx_a = n.attribute("rx").and_then(|v| self.length(v, 'x', st.font_size));
let ry_a = n.attribute("ry").and_then(|v| self.length(v, 'y', st.font_size));
let (rx, ry) = match (rx_a, ry_a) {
@@ -1122,21 +1555,29 @@ impl<'a, 'i> Parser<'a, 'i> {
(None, Some(y)) => (y, y),
(None, None) => (0.0, 0.0),
};
Geom::Rect { x: l("x", 'x'), y: l("y", 'y'), w, h, rx: rx.clamp(0.0, w / 2.0), ry: ry.clamp(0.0, h / 2.0) }
Geom::Rect { x, y, w, h, rx: rx.clamp(0.0, w / 2.0), ry: ry.clamp(0.0, h / 2.0) }
}
"circle" => {
let r = l("r", 'o');
if r <= 0.0 {
if !(r > 0.0 && r <= MAX_ELLIPSE_RADIUS) {
return None;
}
Geom::Ellipse { cx: l("cx", 'x'), cy: l("cy", 'y'), rx: r, ry: r }
let (cx, cy) = (l("cx", 'x'), l("cy", 'y'));
if ![cx - r, cx + r, cy - r, cy + r].iter().all(|value| value.is_finite()) {
return None;
}
Geom::Ellipse { cx, cy, rx: r, ry: r }
}
"ellipse" => {
let (rx, ry) = (l("rx", 'x'), l("ry", 'y'));
if rx <= 0.0 || ry <= 0.0 {
if !(rx > 0.0 && rx <= MAX_ELLIPSE_RADIUS && ry > 0.0 && ry <= MAX_ELLIPSE_RADIUS) {
return None;
}
Geom::Ellipse { cx: l("cx", 'x'), cy: l("cy", 'y'), rx, ry }
let (cx, cy) = (l("cx", 'x'), l("cy", 'y'));
if ![cx - rx, cx + rx, cy - ry, cy + ry].iter().all(|value| value.is_finite()) {
return None;
}
Geom::Ellipse { cx, cy, rx, ry }
}
"line" => {
let mut p = BezPath::new();
@@ -1145,14 +1586,16 @@ impl<'a, 'i> Parser<'a, 'i> {
Geom::Path(p)
}
"polyline" | "polygon" => {
let nums = pathdata::numbers(n.attribute("points")?);
let nums = points?;
if nums.len() < 4 {
return None;
}
let mut p = BezPath::new();
p.move_to((nums[0], nums[1]));
for c in nums[2..].as_chunks::<2>().0 {
p.line_to((c[0], c[1]));
let first_pair = nums.get(..2)?;
let (Some(&first_x), Some(&first_y)) = (first_pair.first(), first_pair.get(1)) else { return None };
p.move_to((first_x, first_y));
for [x, y] in nums.get(2..).unwrap_or_default().as_chunks::<2>().0 {
p.line_to((*x, *y));
}
if n.tag_name().name() == "polygon" {
p.close_path();
@@ -1164,9 +1607,32 @@ impl<'a, 'i> Parser<'a, 'i> {
}
}
/// Estimate tile raster work from group traversal and the shape/path operations it contains.
fn pattern_render_complexity(nodes: &[Node]) -> usize {
nodes.iter().fold(0usize, |total, node| {
let cost = match node {
Node::Group(group) => 4usize.saturating_add(pattern_render_complexity(&group.children)),
Node::Shape(shape) => {
let segments = match &shape.geom {
Geom::Path(path) => path.elements().len().max(1),
Geom::Rect { .. } | Geom::Ellipse { .. } => 8,
};
let paints = usize::from(shape.fill.is_some()).saturating_add(usize::from(shape.stroke.is_some()));
1usize.saturating_add(segments.saturating_mul(paints))
}
Node::Image(_) => 1,
};
total.saturating_add(cost)
})
}
fn viewbox(n: roxmltree::Node) -> Option<([f64; 2], f64, f64)> {
let v = pathdata::numbers(n.attribute("viewBox")?);
(v.len() == 4 && v.iter().all(|x| x.is_finite()) && v[2] > 0.0 && v[3] > 0.0).then(|| ([v[0], v[1]], v[2], v[3]))
let (v, exceeded) = pathdata::numbers_bounded(n.attribute("viewBox")?, 5);
if exceeded || !v.iter().all(|x| x.is_finite()) {
return None;
}
let [x, y, width, height] = v.as_slice() else { return None };
(*width > 0.0 && *height > 0.0).then_some(([*x, *y], *width, *height))
}
/// The viewBox → viewport transform (`preserveAspectRatio`, default `xMidYMid meet`).
@@ -1210,19 +1676,23 @@ pub fn parse_transform(s: &str) -> Affine {
while let Some(open) = rest.find('(') {
let name = rest[..open].trim().trim_start_matches(',').trim();
let Some(close) = rest[open..].find(')') else { break };
let a = pathdata::numbers(&rest[open + 1..open + close]);
let (a, too_many) = pathdata::numbers_bounded(&rest[open + 1..open + close], 6);
rest = &rest[open + close + 1..];
let t = match (name, a.len()) {
("matrix", 6) => Affine::new([a[0], a[1], a[2], a[3], a[4], a[5]]),
("translate", 1) => Affine::translate((a[0], 0.0)),
("translate", 2) => Affine::translate((a[0], a[1])),
("scale", 1) => Affine::scale(a[0]),
("scale", 2) => Affine::scale_non_uniform(a[0], a[1]),
("rotate", 1) => Affine::rotate(a[0].to_radians()),
("rotate", 3) => Affine::translate((a[1], a[2])) * Affine::rotate(a[0].to_radians()) * Affine::translate((-a[1], -a[2])),
("skewX", 1) => Affine::new([1.0, 0.0, a[0].to_radians().tan(), 1.0, 0.0, 0.0]),
("skewY", 1) => Affine::new([1.0, a[0].to_radians().tan(), 0.0, 1.0, 0.0, 0.0]),
_ => Affine::IDENTITY,
let t = if too_many {
Affine::IDENTITY
} else {
match (name, a.as_slice()) {
("matrix", [a0, a1, a2, a3, a4, a5]) => Affine::new([*a0, *a1, *a2, *a3, *a4, *a5]),
("translate", [x]) => Affine::translate((*x, 0.0)),
("translate", [x, y]) => Affine::translate((*x, *y)),
("scale", [scale]) => Affine::scale(*scale),
("scale", [x, y]) => Affine::scale_non_uniform(*x, *y),
("rotate", [angle]) => Affine::rotate(angle.to_radians()),
("rotate", [angle, x, y]) => Affine::translate((*x, *y)) * Affine::rotate(angle.to_radians()) * Affine::translate((-*x, -*y)),
("skewX", [angle]) => Affine::new([1.0, 0.0, angle.to_radians().tan(), 1.0, 0.0, 0.0]),
("skewY", [angle]) => Affine::new([1.0, angle.to_radians().tan(), 0.0, 1.0, 0.0, 0.0]),
_ => Affine::IDENTITY,
}
};
m *= t;
}
@@ -1236,6 +1706,25 @@ const MAX_XML_DEPTH: usize = 256;
/// The most elements an import converts, `<use>` copies included, unless the file itself has
/// more than half as many (#1236).
const MAX_EXPANDED_ELEMENTS: usize = 500_000;
/// Bound path geometry expanded from a single path or point-list element.
const MAX_PATH_ELEMENTS_PER_IMPORT_ELEMENT: usize = pathdata::MAX_PATH_ELEMENTS;
/// Bound the path geometry retained across all path and point-list elements, including `<use>` copies.
const MAX_PATH_ELEMENTS_PER_IMPORT: usize = 1_000_000;
/// Bound repeated gradient-chain and stop scanning across the import.
const MAX_GRADIENT_WORK: usize = 64 * 1024 * 1024;
/// Bound viewport-specific resolved gradients; stop data is shared between clones.
const MAX_GRADIENT_CACHE_ENTRIES: usize = 256;
/// Bound ellipse expansion before kurbo approximates it with tolerance-dependent path segments.
const MAX_ELLIPSE_RADIUS: f64 = 1.0e9;
/// Weight applied to each path data byte when charging path work.
const PATH_DATA_WORK_FACTOR: usize = 8;
/// Aggregate work that pattern references may revisit, including attributes and scanned children,
/// even when many shapes share one large pattern definition.
const MAX_PATTERN_WORK_BYTES: usize = 256 * 1024 * 1024;
/// Lower bound of the pattern work budget regardless of document size.
const MIN_PATTERN_WORK_BYTES: usize = 8 * 1024 * 1024;
/// Bound viewport-specific pattern results; uncached resolutions remain covered by the work budget.
const MAX_PATTERN_CACHE_ENTRIES: usize = 256;
/// The deepest element nesting of `text` (a quick scan that skips comments, CDATA, processing
/// instructions, declarations and quoted attribute values).
@@ -1317,6 +1806,8 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
let vb = viewbox(root);
// Room for generous reuse, and always for every element of the file itself.
let budget = MAX_EXPANDED_ELEMENTS.max(root.descendants().filter(|n| n.is_element()).count().saturating_mul(2));
let pattern_work_budget = bytes.len().saturating_mul(8).clamp(MIN_PATTERN_WORK_BYTES, MAX_PATTERN_WORK_BYTES);
let path_work_budget = pattern_work_budget;
let mut p = Parser {
ids,
css,
@@ -1327,6 +1818,19 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
work: 0,
budget,
using: vec![],
in_pattern_content: false,
invalid_pattern_content: false,
pattern_work: 0,
pattern_work_budget,
pattern_work_exceeded: false,
path_data_work: 0,
path_element_work: 0,
path_work_budget,
path_work_exceeded: false,
pattern_cache: HashMap::new(),
gradient_cache: HashMap::new(),
gradient_work: 0,
gradient_work_exceeded: false,
retained: std::cell::Cell::new(0),
};
let st = Style::default();
@@ -1368,6 +1872,15 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
// Never a silently partial drawing.
return Err(Error::Xml(format!("its <use> references expand to more than {} elements", p.budget)));
}
if p.pattern_work_exceeded {
return Err(Error::Xml(format!("resolving its <pattern> references scans more than {} work units", p.pattern_work_budget)));
}
if p.path_work_exceeded {
return Err(Error::Xml("its SVG paths expand beyond the bounded path-data work budget".to_string()));
}
if p.gradient_work_exceeded {
return Err(Error::Xml(format!("resolving its gradients exceeds the {MAX_GRADIENT_WORK}-unit work budget")));
}
if p.retained.get() > MAX_RETAINED {
return Err(Error::Xml(format!("its paths and gradients, with their copies, hold more than {MAX_RETAINED} elements")));
}
+180 -75
View File
@@ -2,7 +2,10 @@
//! coordinates, smooth curves and elliptical arcs (converted to cubics through kurbo's `SvgArc`).
//! Parsing stops at the first error, keeping what was read (as the specification asks).
use kurbo::{BezPath, Point, SvgArc, Vec2};
use kurbo::{BezPath, PathEl, Point, SvgArc, Vec2};
/// Maximum path elements retained from one SVG path or point list.
pub(crate) const MAX_PATH_ELEMENTS: usize = 100_000;
/// Tokenizer over numbers and command letters.
struct Lex<'a> {
@@ -66,7 +69,8 @@ impl Lex<'_> {
}
}
self.i = i;
std::str::from_utf8(&b[start..i]).ok()?.parse().ok()
let number = std::str::from_utf8(&b[start..i]).ok()?.parse::<f64>().ok()?;
number.is_finite().then_some(number)
}
/// Arc flags may be written without separators ("a1 1 0 00 1 1").
fn flag(&mut self) -> Option<bool> {
@@ -81,31 +85,63 @@ impl Lex<'_> {
}
}
/// All numbers in a list (`points`, `viewBox`, transform arguments).
pub fn numbers(s: &str) -> Vec<f64> {
/// Parse no more than `max_values`; the flag reports whether another valid value was present.
pub(crate) fn numbers_bounded(s: &str, max_values: usize) -> (Vec<f64>, bool) {
let mut l = Lex { b: s.as_bytes(), i: 0 };
let mut v = vec![];
while l.at_number() {
if v.len() >= max_values {
return (v, true);
}
match l.number() {
Some(x) => v.push(x),
None => break,
}
}
v
(v, false)
}
/// Parse path data into a kurbo path.
/// Parse path data into a kurbo path, retaining at most [`MAX_PATH_ELEMENTS`] elements.
/// Malformed or overlong input returns the prefix parsed before the problem.
pub fn parse_path_data(d: &str) -> BezPath {
parse_path_data_bounded(d, MAX_PATH_ELEMENTS).0
}
/// Pen position and reflection state while parsing path commands.
struct PathState {
/// Current point.
current: Point,
/// Start of the current subpath.
start: Point,
/// Last cubic control point, for smooth curves.
last_cubic: Option<Point>,
/// Last quadratic control point, for smooth curves.
last_quad: Option<Point>,
/// Whether a subpath is open.
open: bool,
}
/// Cap on path elements and whether it has been hit.
struct PathLimit {
/// Maximum number of path elements.
max_elements: usize,
/// Set when the cap or a non-finite coordinate stopped output.
exceeded: bool,
}
/// Parse at most `max_elements`, reporting when more path geometry was present.
pub(crate) fn parse_path_data_bounded(d: &str, max_elements: usize) -> (BezPath, bool) {
let mut l = Lex { b: d.as_bytes(), i: 0 };
let mut p = BezPath::new();
let mut cur = Point::ZERO;
let mut start = Point::ZERO;
// Last control point of the previous C/S (cubic) or Q/T (quad) command.
let mut last_cubic: Option<Point> = None;
let mut last_quad: Option<Point> = None;
let mut state = PathState { current: Point::ZERO, start: Point::ZERO, last_cubic: None, last_quad: None, open: false };
let mut limit = PathLimit { max_elements, exceeded: false };
let mut cmd: Option<u8> = None;
let mut open = false;
loop {
if p.elements().len() >= limit.max_elements {
l.skip_sep();
limit.exceeded = l.i < l.b.len();
break;
}
let c = match l.command() {
Some(c) => c,
None => match cmd {
@@ -120,103 +156,146 @@ pub fn parse_path_data(d: &str) -> BezPath {
},
};
let rel = c.is_ascii_lowercase();
let base = if rel { cur.to_vec2() } else { Vec2::ZERO };
let base = if rel { state.current.to_vec2() } else { Vec2::ZERO };
let pt = |l: &mut Lex| -> Option<Point> { Some(Point::new(l.number()?, l.number()?) + base) };
let ok = (|| -> Option<()> {
match c.to_ascii_uppercase() {
b'M' => {
cur = pt(&mut l)?;
p.move_to(cur);
start = cur;
open = true;
last_cubic = None;
last_quad = None;
state.current = pt(&mut l)?;
if !push_element(&mut p, PathEl::MoveTo(state.current), &mut limit) {
return None;
}
state.start = state.current;
state.open = true;
state.last_cubic = None;
state.last_quad = None;
}
b'Z' => {
if open {
p.close_path();
if state.open && !push_element(&mut p, PathEl::ClosePath, &mut limit) {
return None;
}
cur = start;
last_cubic = None;
last_quad = None;
state.current = state.start;
state.last_cubic = None;
state.last_quad = None;
}
_ if !open => {
_ if !state.open => {
// A segment without a preceding moveto starts at the current point.
p.move_to(cur);
open = true;
return segment(&mut l, c, base, &mut p, &mut cur, &mut last_cubic, &mut last_quad);
if !push_element(&mut p, PathEl::MoveTo(state.current), &mut limit) {
return None;
}
state.open = true;
return segment(&mut l, c, base, &mut p, &mut state, &mut limit);
}
_ => return segment(&mut l, c, base, &mut p, &mut cur, &mut last_cubic, &mut last_quad),
_ => return segment(&mut l, c, base, &mut p, &mut state, &mut limit),
}
Some(())
})();
if ok.is_none() {
break;
}
if limit.exceeded {
break;
}
cmd = Some(c);
if c.eq_ignore_ascii_case(&b'z') {
// After Z a new segment starts from the subpath start (an unused move is dropped
// below).
p.move_to(start);
open = true;
if !push_element(&mut p, PathEl::MoveTo(state.start), &mut limit) {
break;
}
state.open = true;
}
}
strip_trailing_moves(p)
(strip_trailing_moves(p), limit.exceeded)
}
fn segment(l: &mut Lex, c: u8, base: Vec2, p: &mut BezPath, cur: &mut Point, last_cubic: &mut Option<Point>, last_quad: &mut Option<Point>) -> Option<()> {
/// Push an element if finite and under the cap; otherwise mark the limit exceeded and return false.
fn push_element(p: &mut BezPath, element: PathEl, limit: &mut PathLimit) -> bool {
let finite = match element {
PathEl::MoveTo(point) | PathEl::LineTo(point) => point.x.is_finite() && point.y.is_finite(),
PathEl::QuadTo(control, point) => [control.x, control.y, point.x, point.y].iter().all(|value| value.is_finite()),
PathEl::CurveTo(first, second, point) => [first.x, first.y, second.x, second.y, point.x, point.y].iter().all(|value| value.is_finite()),
PathEl::ClosePath => true,
};
if p.elements().len() >= limit.max_elements || !finite {
limit.exceeded = true;
return false;
}
p.push(element);
true
}
/// Parse one path command's arguments into `p`, returning `None` on malformed or over-limit input.
fn segment(l: &mut Lex, c: u8, base: Vec2, p: &mut BezPath, state: &mut PathState, limit: &mut PathLimit) -> Option<()> {
let num = |l: &mut Lex| l.number();
let pt = |l: &mut Lex| -> Option<Point> { Some(Point::new(l.number()?, l.number()?) + base) };
match c.to_ascii_uppercase() {
b'L' => {
*cur = pt(l)?;
p.line_to(*cur);
*last_cubic = None;
*last_quad = None;
let current = pt(l)?;
if !push_element(p, PathEl::LineTo(current), limit) {
return None;
}
state.current = current;
state.last_cubic = None;
state.last_quad = None;
}
b'H' => {
let x = num(l)? + base.x;
*cur = Point::new(x, cur.y);
p.line_to(*cur);
*last_cubic = None;
*last_quad = None;
let current = Point::new(x, state.current.y);
if !push_element(p, PathEl::LineTo(current), limit) {
return None;
}
state.current = current;
state.last_cubic = None;
state.last_quad = None;
}
b'V' => {
let y = num(l)? + base.y;
*cur = Point::new(cur.x, y);
p.line_to(*cur);
*last_cubic = None;
*last_quad = None;
let current = Point::new(state.current.x, y);
if !push_element(p, PathEl::LineTo(current), limit) {
return None;
}
state.current = current;
state.last_cubic = None;
state.last_quad = None;
}
b'C' => {
let (c1, c2, e) = (pt(l)?, pt(l)?, pt(l)?);
p.curve_to(c1, c2, e);
*last_cubic = Some(c2);
*last_quad = None;
*cur = e;
if !push_element(p, PathEl::CurveTo(c1, c2, e), limit) {
return None;
}
state.last_cubic = Some(c2);
state.last_quad = None;
state.current = e;
}
b'S' => {
let c1 = last_cubic.map_or(*cur, |c| *cur + (*cur - c));
let c1 = state.last_cubic.map_or(state.current, |c| state.current + (state.current - c));
let (c2, e) = (pt(l)?, pt(l)?);
p.curve_to(c1, c2, e);
*last_cubic = Some(c2);
*last_quad = None;
*cur = e;
if !push_element(p, PathEl::CurveTo(c1, c2, e), limit) {
return None;
}
state.last_cubic = Some(c2);
state.last_quad = None;
state.current = e;
}
b'Q' => {
let (q, e) = (pt(l)?, pt(l)?);
p.quad_to(q, e);
*last_quad = Some(q);
*last_cubic = None;
*cur = e;
if !push_element(p, PathEl::QuadTo(q, e), limit) {
return None;
}
state.last_quad = Some(q);
state.last_cubic = None;
state.current = e;
}
b'T' => {
let q = last_quad.map_or(*cur, |c| *cur + (*cur - c));
let q = state.last_quad.map_or(state.current, |c| state.current + (state.current - c));
let e = pt(l)?;
p.quad_to(q, e);
*last_quad = Some(q);
*last_cubic = None;
*cur = e;
if !push_element(p, PathEl::QuadTo(q, e), limit) {
return None;
}
state.last_quad = Some(q);
state.last_cubic = None;
state.current = e;
}
b'A' => {
let rx = num(l)?.abs();
@@ -225,23 +304,30 @@ fn segment(l: &mut Lex, c: u8, base: Vec2, p: &mut BezPath, cur: &mut Point, las
let large = l.flag()?;
let sweep = l.flag()?;
let e = pt(l)?;
let arc = SvgArc { from: *cur, to: e, radii: Vec2::new(rx, ry), x_rotation: rot.to_radians(), large_arc: large, sweep };
let arc = SvgArc { from: state.current, to: e, radii: Vec2::new(rx, ry), x_rotation: rot.to_radians(), large_arc: large, sweep };
let mut last_end = None;
if let Some(a) = kurbo::Arc::from_svg_arc(&arc) {
// Scale tolerance with the radius so the segment count stays bounded.
let tol = 0.1_f64.max(a.radii.x.max(a.radii.y) * 1e-4);
a.to_cubic_beziers(tol, |c1, c2, end| {
p.curve_to(c1, c2, end);
last_end = Some(end);
if limit.exceeded {
return;
}
if push_element(p, PathEl::CurveTo(c1, c2, end), limit) {
last_end = Some(end);
}
});
}
// Degenerate arcs (e.g. huge radii) may emit nothing or stop short of the end point.
if last_end.is_none_or(|q| (q - e).hypot() > 1e-3 * (1.0 + e.to_vec2().hypot())) {
p.line_to(e);
if limit.exceeded {
return None;
}
*cur = e;
*last_cubic = None;
*last_quad = None;
// Degenerate arcs (e.g. huge radii) may emit nothing or stop short of the end point.
if last_end.is_none_or(|q| (q - e).hypot() > 1e-3 * (1.0 + e.to_vec2().hypot())) && !push_element(p, PathEl::LineTo(e), limit) {
return None;
}
state.current = e;
state.last_cubic = None;
state.last_quad = None;
}
_ => return None,
}
@@ -271,6 +357,7 @@ mod tests {
use kurbo::PathEl;
#[test]
/// Huge arc radii stay bounded.
fn huge_arc_radius_stays_bounded() {
let p = parse_path_data("M0 0A1e36 1e36 0 1 1 1e30 0");
assert!(p.elements().len() < 1000, "got {} elements", p.elements().len());
@@ -310,6 +397,7 @@ mod tests {
}
#[test]
/// Huge-radius arcs still reach their end point.
fn huge_radius_arc_still_reaches_end_point() {
for d in ["M0 0 A1e20 1e20 0 0 1 10 0 L10 10", "M0 0 A1e20 1e20 0 1 1 10 0 L10 10", "M0 0 a1e20 1e20 0 0 1 10 0 L10 10"] {
let p = parse_path_data(d);
@@ -323,8 +411,25 @@ mod tests {
#[test]
fn number_lists() {
assert_eq!(numbers("0 0,100 50"), vec![0.0, 0.0, 100.0, 50.0]);
assert_eq!(numbers("-1-2.5.5"), vec![-1.0, -2.5, 0.5]);
assert_eq!(numbers("1e2,2E-1"), vec![100.0, 0.2]);
assert_eq!(numbers_bounded("0 0,100 50", 8).0, vec![0.0, 0.0, 100.0, 50.0]);
assert_eq!(numbers_bounded("-1-2.5.5", 8).0, vec![-1.0, -2.5, 0.5]);
assert_eq!(numbers_bounded("1e2,2E-1", 8).0, vec![100.0, 0.2]);
}
#[test]
/// The element cap stops expansion and reports truncation.
fn path_element_limit_stops_expansion_and_reports_truncation() {
let data = format!("M0 0{}", "h1".repeat(MAX_PATH_ELEMENTS + 1));
let (path, exceeded) = parse_path_data_bounded(&data, 32);
assert!(exceeded);
assert!(path.elements().len() <= 32);
}
#[test]
/// The number list cap stops retaining values.
fn number_list_limit_stops_retaining_values() {
let (values, exceeded) = numbers_bounded("1 2 3 4", 3);
assert_eq!(values, [1.0, 2.0, 3.0]);
assert!(exceeded);
}
}
+928 -73
View File
File diff suppressed because it is too large. Load diff
+668 -3
View File
@@ -1,4 +1,5 @@
use super::*;
use kurbo::Shape as _;
const BASIC: &[u8] = include_bytes!("../tests/fixtures/basic-shapes.svg");
const PATHS: &[u8] = include_bytes!("../tests/fixtures/paths-gradients.svg");
@@ -28,6 +29,426 @@ fn raster_surface_budget_rejects_oversized_destination_before_allocation() {
let img = rasterize(&doc, 16_384, 16_384, 1.0);
assert!(img.data.is_empty());
assert_eq!((img.width, img.height), (0, 0));
assert!(matches!(try_rasterize(&doc, 16_384, 16_384, 1.0), Err(RasterError::SurfaceLimit(_))), "the refusal is reported, not silent");
}
/// Pattern fills and strokes repeat from the tile origin.
#[test]
fn static_pattern_fill_and_stroke_repeat_with_tile_origin() {
let doc = parse(
br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="4">
<defs><pattern id="p" patternUnits="userSpaceOnUse" x="1" width="2" height="2">
<rect x="1" width="1" height="2" fill="red"/>
<rect x="2" width="1" height="2" fill="blue"/>
</pattern></defs>
<rect id="filled" width="8" height="4" fill="url(#p)"/>
<path d="M0 3 H8" fill="none" stroke="url(#p)" stroke-width="2"/>
</svg>"#,
)
.unwrap();
let shapes = doc.flatten();
assert!(matches!(shapes[0].2.fill.as_ref(), Some(Paint::Pattern(_))));
assert!(matches!(shapes[1].2.stroke.as_ref().map(|s| &s.paint), Some(Paint::Pattern(_))));
let img = rasterize(&doc, 8, 4, 1.0);
// The tile occupies [1, 3): blue wraps into x=0, red starts at x=1.
assert!(near(px(&img, 0, 1), [0.0, 0.0, 1.0, 1.0], 1e-3));
assert!(near(px(&img, 1, 1), [1.0, 0.0, 0.0, 1.0], 1e-3));
assert!(near(px(&img, 2, 1), [0.0, 0.0, 1.0, 1.0], 1e-3));
// The same pattern server paints the stroke.
assert!(near(px(&img, 1, 2), [1.0, 0.0, 0.0, 1.0], 1e-3));
}
/// objectBoundingBox pattern units map tile and content coordinates to the shape's box.
#[test]
fn object_bounding_box_pattern_units_map_tile_and_content_coordinates() {
let doc = parse(
br#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="8">
<defs><pattern id="p" patternUnits="objectBoundingBox" width="0.5" height="1" patternContentUnits="objectBoundingBox">
<rect width="25%" height="100%" fill="green"/>
</pattern></defs>
<rect x="10" y="2" width="8" height="4" fill="url(#p)" stroke="url(#p)" stroke-width="1"/>
</svg>"#,
)
.unwrap();
let shapes = doc.flatten();
assert!(matches!(shapes[0].2.fill.as_ref(), Some(Paint::Pattern(_))));
assert!(matches!(shapes[0].2.stroke.as_ref().map(|stroke| &stroke.paint), Some(Paint::Pattern(_))));
let img = rasterize(&doc, 20, 8, 1.0);
assert!(near(px(&img, 10, 3), [0.0, 128.0 / 255.0, 0.0, 1.0], 1e-3));
assert_eq!(px(&img, 12, 3)[3], 0.0);
assert!(near(px(&img, 14, 3), [0.0, 128.0 / 255.0, 0.0, 1.0], 1e-3));
assert_eq!(px(&img, 16, 3)[3], 0.0);
}
/// patternTransform scales tile spacing and content together.
#[test]
fn pattern_transform_scales_tile_spacing_and_its_content_together() {
let doc = parse(
br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="2">
<defs><pattern id="p" patternUnits="userSpaceOnUse" width="2" height="2" patternTransform="scale(2)">
<rect width="1" height="2" fill="red"/>
</pattern></defs>
<rect width="8" height="2" fill="url(#p)"/>
</svg>"#,
)
.unwrap();
let img = rasterize(&doc, 8, 2, 1.0);
assert!(near(px(&img, 0, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
assert!(near(px(&img, 1, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
assert_eq!(px(&img, 2, 0)[3], 0.0);
assert_eq!(px(&img, 3, 0)[3], 0.0);
assert!(near(px(&img, 4, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
}
/// A pattern href inherits units and content, and plain `href` wins over `xlink:href`.
#[test]
fn pattern_href_inherits_units_and_content_and_prefers_plain_href() {
let doc = parse(
br##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="8" height="2">
<defs>
<pattern id="base" patternUnits="userSpaceOnUse" width="2" height="2">
<rect width="1" height="2" fill="red"/>
</pattern>
<pattern id="other" patternUnits="userSpaceOnUse" width="2" height="2">
<rect width="2" height="2" fill="blue"/>
</pattern>
<pattern id="derived" href="#base" xlink:href="#other"/>
<pattern id="xlink-derived" xlink:href="#base"/>
</defs>
<rect width="8" height="2" fill="url(#derived)"/>
<rect x="8" width="8" height="2" fill="url(#xlink-derived)"/>
</svg>"##,
)
.unwrap();
let img = rasterize(&doc, 16, 2, 1.0);
assert!(near(px(&img, 0, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
assert_eq!(px(&img, 1, 0)[3], 0.0);
assert!(near(px(&img, 2, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
assert!(near(px(&img, 8, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
assert_eq!(px(&img, 9, 0)[3], 0.0);
}
/// Pattern content inherits styles from the pattern's ancestors.
#[test]
fn pattern_content_inherits_styles_from_its_ancestors() {
let doc = parse(
br#"<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2" fill="red">
<defs><pattern id="p" patternUnits="userSpaceOnUse" width="2" height="2">
<rect width="2" height="2"/>
</pattern></defs>
<rect width="2" height="2" fill="url(#p) blue"/>
</svg>"#,
)
.unwrap();
let img = rasterize(&doc, 2, 2, 1.0);
assert!(near(px(&img, 0, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
}
/// Content reached through `href` inherits styles from the pattern that owns it, not the referrer.
#[test]
fn pattern_href_content_inherits_styles_from_the_owning_pattern() {
let doc = parse(
br##"<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2">
<defs>
<g fill="red"><pattern id="base" patternUnits="userSpaceOnUse" width="2" height="2">
<rect width="2" height="2"/>
</pattern></g>
<pattern id="derived" href="#base"/>
</defs>
<rect width="2" height="2" fill="url(#derived)"/>
</svg>"##,
)
.unwrap();
let img = rasterize(&doc, 2, 2, 1.0);
assert!(near(px(&img, 0, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
}
/// Descriptive children (title, desc) do not count as inheritable pattern content.
#[test]
fn pattern_href_ignores_descriptive_children_when_inheriting_content() {
let doc = parse(
br##"<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2">
<defs>
<pattern id="base" patternUnits="userSpaceOnUse" width="2" height="2">
<rect width="2" height="2" fill="red"/>
</pattern>
<pattern id="derived" href="#base">
<title>Documentation only</title>
<desc>Still documentation only</desc>
<metadata><author>Test</author></metadata>
</pattern>
</defs>
<rect width="2" height="2" fill="url(#derived)"/>
</svg>"##,
)
.unwrap();
let img = rasterize(&doc, 2, 2, 1.0);
assert!(near(px(&img, 0, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
}
/// The pattern cache is shared for equal viewports but split for different percentage-sized ones.
#[test]
fn cached_patterns_reuse_same_viewport_but_separate_percentage_sized_viewports() {
let doc = parse(
br##"<svg xmlns="http://www.w3.org/2000/svg" width="120" height="10" viewBox="0 0 120 10">
<defs><pattern id="p" patternUnits="userSpaceOnUse" width="50%" height="100%">
<rect width="4" height="100%" fill="red"/>
</pattern></defs>
<svg width="40" height="10" viewBox="0 0 40 10">
<rect id="small-one" width="20" height="10" fill="url(#p) blue"/>
<rect id="small-two" x="20" width="20" height="10" fill="url(#p) green"/>
</svg>
<svg x="40" width="80" height="10" viewBox="0 0 80 10">
<rect id="large" width="80" height="10" fill="url(#p) yellow"/>
</svg>
</svg>"##,
)
.unwrap();
let shapes = doc.flatten();
let Some((_, _, small_one)) = shapes.iter().find(|(_, _, shape)| shape.name == "small-one") else { panic!("small-one expected") };
let Some(Paint::Pattern(small_one)) = small_one.fill.as_ref() else { panic!("small-one pattern expected") };
let Some((_, _, small_two)) = shapes.iter().find(|(_, _, shape)| shape.name == "small-two") else { panic!("small-two expected") };
let Some(Paint::Pattern(small_two)) = small_two.fill.as_ref() else { panic!("small-two pattern expected") };
let Some((_, _, large)) = shapes.iter().find(|(_, _, shape)| shape.name == "large") else { panic!("large expected") };
let Some(Paint::Pattern(large)) = large.fill.as_ref() else { panic!("large pattern expected") };
assert_eq!(small_one.width, 20.0);
assert_eq!(small_two.width, 20.0);
assert_eq!(large.width, 40.0);
assert_eq!(small_one.fallback, Some([0.0, 0.0, 1.0, 1.0]));
assert_eq!(small_two.fallback, Some([0.0, 128.0 / 255.0, 0.0, 1.0]));
assert_eq!(large.fallback, Some([1.0, 1.0, 0.0, 1.0]));
assert!(std::sync::Arc::ptr_eq(&small_one.content, &small_two.content));
assert!(!std::sync::Arc::ptr_eq(&small_one.content, &large.content));
let img = rasterize(&doc, 120, 10, 1.0);
for x in [1, 21, 41, 81] {
assert!(near(px(&img, x, 5), [1.0, 0.0, 0.0, 1.0], 1e-3), "x={x}");
}
for x in [5, 25, 45, 85] {
assert_eq!(px(&img, x, 5)[3], 0.0, "x={x}");
}
}
/// Different symbol viewports use distinct pattern cache entries for percentage sizes.
#[test]
fn symbol_viewports_select_distinct_percentage_sized_pattern_cache_entries() {
let doc = parse(
br##"<svg xmlns="http://www.w3.org/2000/svg" width="60" height="10">
<defs>
<pattern id="p" patternUnits="userSpaceOnUse" width="50%" height="100%">
<rect width="4" height="100%" fill="red"/>
</pattern>
<symbol id="small-symbol" viewBox="0 0 20 10">
<rect id="small-symbol-shape" width="20" height="10" fill="url(#p) blue"/>
</symbol>
<symbol id="large-symbol" viewBox="0 0 40 10">
<rect id="large-symbol-shape" width="40" height="10" fill="url(#p) green"/>
</symbol>
</defs>
<use href="#small-symbol" width="20" height="10"/>
<use href="#large-symbol" x="20" width="40" height="10"/>
</svg>"##,
)
.unwrap();
let shapes = doc.flatten();
let Some((_, _, small)) = shapes.iter().find(|(_, _, shape)| shape.name == "small-symbol-shape") else { panic!("small symbol shape expected") };
let Some(Paint::Pattern(small)) = small.fill.as_ref() else { panic!("small symbol pattern expected") };
let Some((_, _, large)) = shapes.iter().find(|(_, _, shape)| shape.name == "large-symbol-shape") else { panic!("large symbol shape expected") };
let Some(Paint::Pattern(large)) = large.fill.as_ref() else { panic!("large symbol pattern expected") };
assert_eq!(small.width, 10.0);
assert_eq!(large.width, 20.0);
let img = rasterize(&doc, 60, 10, 1.0);
assert!(near(px(&img, 1, 5), [1.0, 0.0, 0.0, 1.0], 1e-3));
assert_eq!(px(&img, 5, 5)[3], 0.0);
assert!(near(px(&img, 21, 5), [1.0, 0.0, 0.0, 1.0], 1e-3));
assert_eq!(px(&img, 25, 5)[3], 0.0);
assert!(near(px(&img, 41, 5), [1.0, 0.0, 0.0, 1.0], 1e-3));
}
/// Bare `url()` and href values without `#` do not resolve to local pattern ids.
#[test]
fn bare_urls_and_hrefs_do_not_resolve_as_local_pattern_ids() {
let doc = parse(
br##"<svg xmlns="http://www.w3.org/2000/svg" width="6" height="2">
<defs>
<pattern id="target" patternUnits="userSpaceOnUse" width="2" height="2">
<rect width="2" height="2" fill="red"/>
</pattern>
<pattern id="bare-href" href="target" width="2" height="2"/>
</defs>
<rect width="2" height="2" fill="url(target) blue"/>
<rect x="2" width="2" height="2" fill="url(#bare-href) green"/>
<rect x="4" width="2" height="2" fill="url(#target)"/>
</svg>"##,
)
.unwrap();
let shapes = doc.flatten();
assert_eq!(shapes[0].2.fill, Some(Paint::Color([0.0, 0.0, 1.0])));
assert_eq!(shapes[1].2.fill, Some(Paint::Color([0.0, 128.0 / 255.0, 0.0])));
assert!(matches!(shapes[2].2.fill.as_ref(), Some(Paint::Pattern(_))));
let img = rasterize(&doc, 6, 2, 1.0);
assert!(near(px(&img, 0, 0), [0.0, 0.0, 1.0, 1.0], 1e-3));
assert!(near(px(&img, 2, 0), [0.0, 128.0 / 255.0, 0.0, 1.0], 1e-3));
assert!(near(px(&img, 4, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
}
/// A valid but empty pattern paints nothing rather than its fallback.
#[test]
fn empty_valid_pattern_is_transparent_instead_of_using_its_fallback() {
let doc = parse(
br##"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="2">
<defs><pattern id="empty" patternUnits="userSpaceOnUse" width="2" height="2"/></defs>
<rect width="4" height="2" fill="url(#empty) red"/>
</svg>"##,
)
.unwrap();
let Some(Paint::Pattern(pattern)) = doc.flatten()[0].2.fill.as_ref() else { panic!("valid pattern expected") };
assert_eq!(pattern.fallback, Some([1.0, 0.0, 0.0, 1.0]));
let img = rasterize(&doc, 4, 2, 1.0);
assert_eq!(px(&img, 0, 0)[3], 0.0);
assert_eq!(px(&img, 3, 0)[3], 0.0);
}
/// Pattern content attribute work stays bounded across many distinct viewports.
#[test]
fn pattern_content_attribute_work_is_bounded_across_distinct_viewports() {
let large_attribute = "x".repeat(2 * 1024 * 1024);
let shapes: String = (0..12)
.map(|index| format!(r##"<svg x="{index}" width="1" height="1" viewBox="0 0 {} 1"><rect width="1" height="1" fill="url(#p)"/></svg>"##, index + 1))
.collect();
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="1"><defs>
<pattern id="p" data-repeat="{large_attribute}" patternUnits="userSpaceOnUse" width="50%" height="100%">
<rect width="1" height="1" fill="red"/>
</pattern></defs>{shapes}</svg>"##
);
let error = parse(svg.as_bytes()).unwrap_err();
assert!(error.to_string().contains("<pattern> references scans more than"), "{error}");
}
/// Exceeding the pattern href depth limit uses the paint fallback.
#[test]
fn pattern_href_depth_limit_uses_paint_fallback() {
let patterns: String = (0..=16)
.map(|index| {
if index == 16 {
format!(r#"<pattern id="p{index}" patternUnits="userSpaceOnUse" width="1" height="1"/>"#)
} else {
format!(r##"<pattern id="p{index}" href="#p{}"/>"##, index + 1)
}
})
.collect();
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><defs>{patterns}</defs><rect width="1" height="1" fill="url(#p0) blue"/></svg>"##
);
let doc = parse(svg.as_bytes()).unwrap();
assert_eq!(doc.flatten()[0].2.fill, Some(Paint::Color([0.0, 0.0, 1.0])));
}
/// Ancestor attribute work for patterns stays bounded across many distinct viewports.
#[test]
fn pattern_ancestor_attribute_work_is_bounded_across_distinct_viewports() {
let large_attribute = "x".repeat(2 * 1024 * 1024);
let shapes: String = (0..12)
.map(|index| format!(r##"<svg x="{index}" width="1" height="1" viewBox="0 0 {} 1"><rect width="1" height="1" fill="url(#p)"/></svg>"##, index + 1))
.collect();
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="1"><defs data-repeat="{large_attribute}">
<pattern id="p" patternUnits="userSpaceOnUse" width="50%" height="100%">
<rect width="1" height="1" fill="red"/>
</pattern></defs>{shapes}</svg>"##
);
let error = parse(svg.as_bytes()).unwrap_err();
assert!(error.to_string().contains("<pattern> references scans more than"), "{error}");
}
/// Pattern tile alpha composites as premultiplied color.
#[test]
fn pattern_tile_alpha_composites_as_premultiplied_color() {
let doc = parse(
br#"<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2">
<defs><pattern id="p" patternUnits="userSpaceOnUse" width="1" height="1">
<rect width="1" height="1" fill="red" fill-opacity="0.5"/>
</pattern></defs>
<rect width="2" height="2" fill="url(#p)"/>
</svg>"#,
)
.unwrap();
let img = rasterize(&doc, 2, 2, 1.0);
assert!(near(px(&img, 0, 0), [0.5, 0.0, 0.0, 0.5], 1e-3));
assert!(near(px(&img, 1, 1), [0.5, 0.0, 0.0, 0.5], 1e-3));
}
/// An oversized pattern tile uses the fallback without allocating it.
#[test]
fn oversized_pattern_tile_falls_back_without_allocating() {
let doc = parse(
br##"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1">
<defs><pattern id="huge" patternUnits="userSpaceOnUse" width="1e300" height="1e300">
<rect width="1" height="1" fill="red"/>
</pattern></defs>
<rect width="1" height="1" fill="url(#huge) gold"/>
</svg>"##,
)
.unwrap();
let img = rasterize(&doc, 1, 1, 1.0);
assert!(near(px(&img, 0, 0), [1.0, 215.0 / 255.0, 0.0, 1.0], 1e-3));
}
/// Invalid or cyclic patterns use the paint fallback without being expanded.
#[test]
fn invalid_or_cyclic_pattern_uses_paint_fallback_without_expanding() {
let doc = parse(
br##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="2">
<defs>
<pattern id="bad" width="0" height="1"/>
<pattern id="viewbox" width="1" height="1" viewBox="0 0 1 1"/>
<pattern id="collapsed" patternUnits="userSpaceOnUse" width="1" height="1" patternTransform="scale(0)"/>
<pattern id="a" href="#b"/>
<pattern id="b" href="#a"/>
<pattern id="self" href="#self"/>
</defs>
<rect width="2" height="2" fill="url(#bad) blue"/>
<rect x="2" width="2" height="2" fill="url(#viewbox) red"/>
<rect x="4" width="2" height="2" fill="url(#collapsed) yellow"/>
<rect x="6" width="2" height="2" fill="url(#a) green"/>
<rect x="8" width="2" height="2" fill="url(#self) cyan"/>
</svg>"##,
)
.unwrap();
let shapes = doc.flatten();
assert_eq!(shapes[0].2.fill, Some(Paint::Color([0.0, 0.0, 1.0])));
assert_eq!(shapes[1].2.fill, Some(Paint::Color([1.0, 0.0, 0.0])));
assert_eq!(shapes[2].2.fill, Some(Paint::Color([1.0, 1.0, 0.0])));
assert_eq!(shapes[3].2.fill, Some(Paint::Color([0.0, 128.0 / 255.0, 0.0])));
assert_eq!(shapes[4].2.fill, Some(Paint::Color([0.0, 1.0, 1.0])));
let img = rasterize(&doc, 10, 2, 1.0);
assert!(near(px(&img, 0, 0), [0.0, 0.0, 1.0, 1.0], 1e-3));
assert!(near(px(&img, 3, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
assert!(near(px(&img, 5, 0), [1.0, 1.0, 0.0, 1.0], 1e-3));
assert!(near(px(&img, 7, 0), [0.0, 128.0 / 255.0, 0.0, 1.0], 1e-3));
assert!(near(px(&img, 9, 0), [0.0, 1.0, 1.0, 1.0], 1e-3));
}
/// A one-pixel pattern tiles a large rectangle from a single surface.
#[test]
fn one_pixel_pattern_tiles_a_large_rectangle_from_one_surface() {
let doc = parse(
br#"<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024">
<defs><pattern id="p" patternUnits="userSpaceOnUse" width="1" height="1">
<rect width="1" height="1" fill="red"/>
</pattern></defs>
<rect width="1024" height="1024" fill="url(#p)"/>
</svg>"#,
)
.unwrap();
let img = rasterize(&doc, 1024, 1024, 1.0);
assert_eq!((img.width, img.height), (1024, 1024));
for (x, y) in [(0, 0), (511, 511), (1023, 1023)] {
assert!(near(px(&img, x, y), [1.0, 0.0, 0.0, 1.0], 1e-3), "({x}, {y})");
}
}
#[test]
@@ -105,6 +526,41 @@ fn paths_gradients_css_use() {
assert!(c[0] > 0.9 && c[3] > 0.9, "{c:?}");
}
/// Repeated gradient paints share one cached stop list.
#[test]
fn repeated_gradient_paints_share_their_cached_stop_storage() {
let doc = parse(
br#"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="2">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="red"/><stop offset="1" stop-color="blue"/>
</linearGradient></defs>
<rect id="first" width="2" height="2" fill="url(#g)"/>
<rect id="second" x="2" width="2" height="2" fill="url(#g)"/>
</svg>"#,
)
.unwrap();
let shapes = doc.flatten();
let Some((_, _, first)) = shapes.iter().find(|(_, _, shape)| shape.name == "first") else { panic!("first shape expected") };
let Some(Paint::Gradient(first_gradient)) = first.fill.as_ref() else { panic!("first gradient expected") };
let Some((_, _, second)) = shapes.iter().find(|(_, _, shape)| shape.name == "second") else { panic!("second shape expected") };
let Some(Paint::Gradient(second_gradient)) = second.fill.as_ref() else { panic!("second gradient expected") };
assert!(std::sync::Arc::ptr_eq(&first_gradient.stops, &second_gradient.stops));
}
/// Oversized path geometry fails with a bounded import error.
#[test]
fn oversized_svg_path_geometry_fails_with_a_bounded_import_error() {
let data = format!("M0 0{}", "h1".repeat(pathdata::MAX_PATH_ELEMENTS + 1));
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg"><path d="{data}"/></svg>"#);
let error = parse(svg.as_bytes()).unwrap_err();
assert!(error.to_string().contains("bounded path-data work budget"), "{error}");
let points = (0..=pathdata::MAX_PATH_ELEMENTS).map(|x| format!("{x} 0")).collect::<Vec<_>>().join(" ");
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg"><polyline points="{points}"/></svg>"#);
let error = parse(svg.as_bytes()).unwrap_err();
assert!(error.to_string().contains("bounded path-data work budget"), "{error}");
}
#[test]
fn root_svg_percentages_use_the_document_viewport() {
for (attrs, width, height) in [
@@ -160,6 +616,72 @@ fn nested_svg_percentages_use_the_nearest_viewport() {
assert_eq!(px(&img, 55, 65)[3], 0.0);
}
/// A symbol viewport sets percentage pattern units and the cache key.
#[test]
fn symbol_viewport_sets_percentage_pattern_units_and_cache_key() {
let doc = parse(
br##"<svg xmlns="http://www.w3.org/2000/svg" width="60" height="10">
<defs>
<pattern id="p" patternUnits="userSpaceOnUse" width="50%" height="100%">
<rect width="4" height="100%" fill="red"/>
</pattern>
<symbol id="small" viewBox="0 0 20 10">
<rect id="small-shape" width="20" height="10" fill="url(#p)"/>
</symbol>
<symbol id="large" viewBox="0 0 40 10">
<rect id="large-shape" width="40" height="10" fill="url(#p)"/>
</symbol>
</defs>
<use href="#small" width="20" height="10"/>
<use href="#large" x="20" width="40" height="10"/>
</svg>"##,
)
.unwrap();
let shapes = doc.flatten();
let Some((_, _, small)) = shapes.iter().find(|(_, _, shape)| shape.name == "small-shape") else { panic!("small symbol shape expected") };
let Some(Paint::Pattern(small)) = small.fill.as_ref() else { panic!("small symbol pattern expected") };
let Some((_, _, large)) = shapes.iter().find(|(_, _, shape)| shape.name == "large-shape") else { panic!("large symbol shape expected") };
let Some(Paint::Pattern(large)) = large.fill.as_ref() else { panic!("large symbol pattern expected") };
assert_eq!(small.width, 10.0);
assert_eq!(large.width, 20.0);
}
/// Non-finite opacity values never write NaN pixels.
#[test]
fn non_finite_opacity_values_do_not_write_nan_pixels() {
let doc = parse(
br#"<svg xmlns="http://www.w3.org/2000/svg" width="6" height="2">
<defs><linearGradient id="g"><stop offset="0" stop-color="red" stop-opacity="NaN"/><stop offset="1" stop-color="blue"/></linearGradient></defs>
<rect width="2" height="2" fill="red" fill-opacity="NaN"/>
<rect x="2" width="2" height="2" fill="blue" opacity="1e400"/>
<rect x="4" width="2" height="2" fill="url(#g)"/>
</svg>"#,
)
.unwrap();
let img = rasterize(&doc, 6, 2, 1.0);
assert!(img.data.iter().flatten().all(|channel| channel.is_finite()));
assert!(near(px(&img, 0, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
assert!(near(px(&img, 3, 1), [0.0, 0.0, 1.0, 1.0], 1e-3));
assert!(px(&img, 4, 1)[3].is_finite());
}
/// Basic shapes whose derived bounds overflow are skipped.
#[test]
fn basic_shapes_with_overflowing_derived_bounds_are_skipped() {
let doc = parse(
br#"<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2">
<rect x="1e308" y="0" width="1e308" height="1" fill="red"/>
<circle cx="1e308" cy="0" r="1e308" fill="blue"/>
<circle cx="0" cy="0" r="1e300" fill="green"/>
</svg>"#,
)
.unwrap();
assert!(doc.flatten().is_empty());
let image = rasterize(&doc, 2, 2, 1.0);
assert!(image.data.iter().flatten().all(|channel| channel.is_finite()));
}
#[test]
fn non_finite_viewboxes_do_not_panic_or_produce_nan_geometry() {
for attrs in ["viewBox=\"0 0 1e400 100\"", "viewBox=\"0 0 100 1e400\"", "viewBox=\"-1e400 0 100 100\""] {
@@ -447,13 +969,156 @@ fn at_rule_blocks_are_skipped_by_brace_depth() {
assert_eq!(sheet.rules[0].classes, vec!["e".to_string()]);
}
/// Build a `w` x `h` document from `nodes` for sparse-content render tests.
fn sparse_doc(w: f64, h: f64, nodes: Vec<Node>) -> Doc {
let mut root = Group::new("root");
root.children = nodes;
Doc { width: w, height: h, root, skipped: Vec::new() }
}
/// A tiny image on a large canvas is still drawn.
#[test]
fn gradient_stops_copied_past_the_budget_are_refused() {
// 4,000 stops referenced by 2,100 shapes would keep 8.4 million stops.
fn sparse_minified_image_on_a_large_canvas_is_still_drawn() {
let image = Image {
name: "i".into(),
transform: Affine::scale(1.0 / 8.0),
width: 8,
height: 8,
rgba: std::sync::Arc::new([255u8, 0, 0, 255].repeat(64)),
opacity: 1.0,
};
let doc = sparse_doc(2049.0, 2049.0, vec![Node::Image(image)]);
let img = try_rasterize(&doc, 2049, 2049, 1.0).expect("sparse image is within budget");
assert!(px(&img, 0, 0)[3] > 0.99, "the covered pixel must not be dropped by whole-canvas budgeting");
}
/// A small shape with a many-element clip is still drawn.
#[test]
fn sparse_shape_with_a_many_element_clip_is_still_drawn() {
let mut clip = kurbo::BezPath::new();
clip.move_to((0.0, 0.0));
for i in 0..1026 {
clip.line_to((2.0 * (i % 2) as f64, 2.0 * ((i + 1) % 2) as f64));
}
clip.line_to((2.0, 2.0));
clip.line_to((0.0, 2.0));
clip.close_path();
assert!(clip.elements().len() > 1000);
let mut g = Group::new("clipped");
g.clip = vec![(clip, FillRule::NonZero)];
let doc = parse(br#"<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512"><rect width="2" height="2" fill="red"/></svg>"#).unwrap();
g.children = doc.root.children.clone();
let doc = sparse_doc(512.0, 512.0, vec![Node::Group(g)]);
let img = try_rasterize(&doc, 512, 512, 1.0).expect("sparse clip is within budget");
assert!(img.data.iter().any(|p| p[3] > 0.5), "clip work is charged by clip area, not canvas area");
}
/// Sparse pattern fills on a 4K canvas all render.
#[test]
fn sparse_pattern_fills_on_a_4k_canvas_all_render() {
let rects: String = (0..6).map(|i| format!(r#"<rect x="{}" y="0" width="2" height="2" fill="url(#p)"/>"#, i * 4)).collect();
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="3840" height="2160">
<defs><pattern id="p" patternUnits="userSpaceOnUse" width="1" height="1"><rect width="1" height="1" fill="red"/></pattern></defs>
{rects}</svg>"#
);
let doc = parse(svg.as_bytes()).unwrap();
let img = try_rasterize(&doc, 3840, 2160, 1.0).expect("sparse patterns are within budget");
for i in 0..6 {
assert!(px(&img, i * 4, 0)[3] > 0.99, "rect {i} must be painted");
}
}
/// A genuinely huge covered area is still refused.
#[test]
fn genuinely_huge_covered_area_is_still_refused() {
// A 16392x1 strip squeezed 8:1 across the whole canvas needs 8x8 taps per pixel everywhere.
let image = Image {
name: "i".into(),
transform: Affine::scale_non_uniform(1.0 / 8.0, 2049.0),
width: 16392,
height: 1,
rgba: std::sync::Arc::new([255u8, 0, 0, 255].repeat(16392)),
opacity: 1.0,
};
let doc = sparse_doc(2049.0, 2049.0, vec![Node::Image(image)]);
assert_eq!(
try_rasterize(&doc, 2049, 2049, 1.0).err(),
Some(RasterError::WorkLimit("image sampling")),
"a fully covered 64-tap canvas exceeds the work cap"
);
let img = rasterize(&doc, 2049, 2049, 1.0);
assert!(img.data.iter().all(|p| p[3] == 0.0), "the lossy wrapper drops the refused image");
}
/// A group of `count` 1x1 red rects, each inside its own tiny clip, spread over a large canvas.
fn tiny_clipped_rects(count: usize) -> Vec<Node> {
(0..count)
.map(|i| {
let (x, y) = ((i % 100) as f64 * 4.0, (i / 100) as f64 * 4.0);
let mut g = Group::new("clipped");
g.clip = vec![(kurbo::Rect::new(x, y, x + 2.0, y + 2.0).to_path(0.01), FillRule::NonZero)];
let mut rect = Group::new("r");
rect.children = parse(
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><rect x="{x}" y="{y}" width="1" height="1" fill="red"/></svg>"#)
.as_bytes(),
)
.map(|d| d.root.children)
.unwrap_or_default();
g.children = rect.children;
Node::Group(g)
})
.collect()
}
/// Thousands of tiny clipped items on a big canvas cost region-proportional work: a clipped
/// group works on a crop of its clip region, so the render finishes quickly and completely.
#[test]
fn thousands_of_tiny_clips_on_a_large_canvas_finish_quickly() {
let doc = sparse_doc(2048.0, 2048.0, tiny_clipped_rects(3000));
let start = std::time::Instant::now();
let img = try_rasterize(&doc, 2048, 2048, 1.0).expect("tiny clips stay within every budget");
assert!(start.elapsed() < std::time::Duration::from_secs(20), "region-bounded work: {:?}", start.elapsed());
assert!(px(&img, 0, 0)[3] > 0.99 && px(&img, 396, 28 * 4)[3] > 0.99);
}
/// Thousands of tiny pattern-filled shapes and tiny images on a big canvas finish quickly because
/// masks and scans cover only each item's region.
#[test]
fn thousands_of_tiny_pattern_and_image_items_on_a_large_canvas_finish_quickly() {
let mut nodes = Vec::new();
let rects: String = (0..3000).map(|i| format!(r#"<rect x="{}" y="{}" width="2" height="2" fill="url(#p)"/>"#, (i % 100) * 4, (i / 100) * 4)).collect();
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="3840" height="2160">
<defs><pattern id="p" patternUnits="userSpaceOnUse" width="1" height="1"><rect width="1" height="1" fill="red"/></pattern></defs>
{rects}</svg>"#
);
nodes.extend(parse(svg.as_bytes()).unwrap().root.children);
for i in 0..3000 {
nodes.push(Node::Image(Image {
name: "i".into(),
transform: Affine::translate(((i % 100) as f64 * 4.0, 100.0 + (i / 100) as f64 * 4.0)),
width: 1,
height: 1,
rgba: std::sync::Arc::new(vec![0, 255, 0, 255]),
opacity: 1.0,
}));
}
let doc = sparse_doc(3840.0, 2160.0, nodes);
let start = std::time::Instant::now();
let img = try_rasterize(&doc, 3840, 2160, 1.0).expect("tiny items stay within every budget");
assert!(start.elapsed() < std::time::Duration::from_secs(20), "region-bounded work: {:?}", start.elapsed());
assert!(px(&img, 396, 0)[3] > 0.99 && px(&img, 396, 100)[3] > 0.99);
}
#[test]
fn gradient_stops_shared_across_references_are_not_charged_per_copy() {
// 4,000 stops referenced by 2,100 shapes would be 8.4 million stops if copied, but resolved
// gradients share one stop array, so the import keeps only the 4,000 and succeeds.
let stops: String = (0..4000).map(|i| format!("<stop offset=\"{}\" stop-color=\"red\"/>", i as f64 / 4000.0)).collect();
let rects = "<rect width=\"1\" height=\"1\" fill=\"url(#g)\"/>".repeat(2100);
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\"><defs><linearGradient id=\"g\">{stops}</linearGradient></defs>{rects}</svg>");
assert!(matches!(parse(svg.as_bytes()), Err(Error::Xml(e)) if e.contains("gradients")));
assert!(parse(svg.as_bytes()).is_ok());
}
#[test]
+2 -2
View File
@@ -390,7 +390,7 @@ impl EffectcraftApp {
/// fonts are ready, draw the frame, finish pointer wrapping, pump queued wasm frames, record input activity,
/// publish the completed pass's geometry and pass sequence, acknowledge applied synthetic input and answer
/// `AfterInput` replies only after a non-discarded pass. Requests repaints while frames are in flight.
fn draw_inner(&mut self, ui: &mut egui::Ui, frame: &eframe::Frame) {
fn draw_inner(&mut self, ui: &mut egui::Ui, _frame: &eframe::Frame) {
let ctx = ui.ctx().clone();
if self.gpu_failure_applied == Some(true) {
self.close_after_device_loss(&ctx);
@@ -408,7 +408,7 @@ impl EffectcraftApp {
self.frame(ui);
panels::crypto_pick::upkeep(self, &ctx);
#[cfg(not(target_arch = "wasm32"))]
pointer_wrap::finish_native(&ctx, frame);
pointer_wrap::finish_native(&ctx, _frame);
// wasm32: no frame threads; render queued frames now, between UI frames.
if cfg!(target_arch = "wasm32") && self.frames.pump(std::time::Duration::from_millis(if self.playback.playing { 24 } else { 40 })) {
ctx.request_repaint();
+6 -1
View File
@@ -9,6 +9,7 @@ struct State {
numeric: Option<(Id, u64)>,
offset: Vec2,
pending: Option<(Pos2, Pos2)>,
#[cfg(any(not(target_arch = "wasm32"), test))]
disabled: bool,
}
@@ -55,7 +56,11 @@ pub(crate) fn prepare_input(ctx: &Context, raw: &mut RawInput, injected: bool) -
|| raw.events.iter().any(|e| matches!(e, Event::WindowFocused(false) | Event::PointerButton { button: PointerButton::Primary, .. }))
{
let ended = state.numeric.is_some();
*state = State { disabled: touch, ..Default::default() };
*state = State {
#[cfg(any(not(target_arch = "wasm32"), test))]
disabled: touch,
..Default::default()
};
return ended;
}
if state.numeric.is_none() {