From 4035d181329e847d7a57bf3d059502ea2c91b560 Mon Sep 17 00:00:00 2001 From: Altynzhan Karybai Date: Fri, 13 Nov 2026 01:12:42 +0500 Subject: [PATCH] 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 --------- Co-authored-by: Claude Sonnet 5.5 --- crates/engine/src/interchange/vector.rs | 66 +- crates/engine/src/tests/vector_import.rs | 35 + crates/media/src/layered.rs | 27 +- crates/media/src/pool.rs | 16 +- crates/svg/Cargo.toml | 2 +- crates/svg/README.md | 24 +- crates/svg/src/color.rs | 1 + crates/svg/src/lib.rs | 609 +++++++++++-- crates/svg/src/pathdata.rs | 255 ++++-- crates/svg/src/raster.rs | 1001 ++++++++++++++++++++-- crates/svg/src/tests.rs | 671 ++++++++++++++- crates/ui-egui/src/app/frame.rs | 4 +- crates/ui-egui/src/pointer_wrap.rs | 7 +- 13 files changed, 2486 insertions(+), 232 deletions(-) diff --git a/crates/engine/src/interchange/vector.rs b/crates/engine/src/interchange/vector.rs index 569e8e5bc..64ef00253 100644 --- a/crates/engine/src/interchange/vector.rs +++ b/crates/engine/src/interchange/vector.rs @@ -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 { effectcraft_path::from_kurbo(path) .into_iter() @@ -20,6 +21,7 @@ fn shape_paths(ids: &mut Ids, path: &BezPath) -> Vec { .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 { 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 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, 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 { + /// Convert nodes to property groups, applying transform `m` and clip paths. fn walk(ids: &mut Ids, nodes: &[Node], m: Affine, clips: &[BezPath]) -> Vec { 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>, /// 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, 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, masks: &mut usize) { let m = m * g.transform; let opacity = opacity * g.opacity; @@ -467,6 +490,7 @@ pub fn vector_images(doc: &Doc) -> (Vec, 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 { + /// Count the images in a group tree. fn count(g: &effectcraft_svg::Group) -> usize { g.children .iter() diff --git a/crates/engine/src/tests/vector_import.rs b/crates/engine/src/tests/vector_import.rs index 2f41b16b2..6372ffb35 100644 --- a/crates/engine/src/tests/vector_import.rs +++ b/crates/engine/src/tests/vector_import.rs @@ -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##" + + + + + + + + "##; + 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(); diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index 64ade9394..adc23cf31 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -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 { +/// +/// `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> { 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#""#; + 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 { 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. diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 872e4fdfa..f5d109797 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -1110,7 +1110,7 @@ impl FootageSource for MediaPool { } fn problem(&self, footage: &Footage) -> Option { - 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 = 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]) { diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 03cacaa7b..e542bed47 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -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 diff --git a/crates/svg/README.md b/crates/svg/README.md index 862bd01d0..f39156f22 100644 --- a/crates/svg/README.md +++ b/crates/svg/README.md @@ -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) `` 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 `