Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 31 additions & 21 deletions crates/engine/src/interchange/vector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,38 +72,48 @@ 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
}
Expand Down
35 changes: 35 additions & 0 deletions crates/engine/src/tests/vector_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion crates/svg/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 14 additions & 1 deletion crates/svg/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) `<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.
Other unsupported content (ignored): text, images, filters, clip paths, masks, markers and
animation, including pattern animation.
XML parsing uses `roxmltree` (MIT/Apache-2.0). Test fixtures are original work (see
`tests/fixtures/*.attribution`).
Loading
Loading