diff --git a/crates/engine/src/interchange/vector.rs b/crates/engine/src/interchange/vector.rs index 324d3a82ae..183d1664cb 100644 --- a/crates/engine/src/interchange/vector.rs +++ b/crates/engine/src/interchange/vector.rs @@ -72,38 +72,48 @@ 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 } diff --git a/crates/engine/src/tests/vector_import.rs b/crates/engine/src/tests/vector_import.rs index 10c07c2adb..0b865812f1 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/svg/Cargo.toml b/crates/svg/Cargo.toml index 0e1af7719d..799c1382b7 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 862bd01d0a..644a4651fb 100644 --- a/crates/svg/README.md +++ b/crates/svg/README.md @@ -18,9 +18,22 @@ 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 `