Merge pull request #5855 from kk376/fix/svg-clippath-child-clip

SVG import: clip path on clipPath children clips child silhouettes before union (#5841)
This commit is contained in:
BFlat
2026-11-06 08:43:05 +08:00
committed by GitHub
4 changed files with 210 additions and 3 deletions
Generated
+1
View File
@@ -6471,6 +6471,7 @@ dependencies = [
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-pathops",
"vectorcraft-testkit",
"vectorcraft-text",
]
+1
View File
@@ -17,6 +17,7 @@ vectorcraft-doc = { workspace = true }
vectorcraft-effects = { workspace = true }
vectorcraft-brush = { workspace = true }
vectorcraft-text = { workspace = true }
vectorcraft-pathops = { workspace = true }
kurbo = { workspace = true }
usvg = { workspace = true }
thiserror = { workspace = true }
+67 -3
View File
@@ -186,22 +186,85 @@ pub(super) fn drawn_ellipse(p: &usvg::tiny_skia_path::Path) -> Option<PathData>
Some(PathData::single(SubPath::new(anchors, true)))
}
const MAX_CLIP_DEPTH: usize = 16;
/// Most clip paths read for one clip. They are shared, so children that each name the next one
/// multiply the work at every level: without this, a few lines of SVG would never finish.
const MAX_CLIP_READS: usize = 1024;
/// The shapes of clip path `cp` on an element whose user space `ts` maps to the document.
pub(super) fn clip_shapes(cp: &usvg::ClipPath, ts: Affine) -> Vec<(BezPath, FillRule)> {
fn collect(g: &usvg::Group, m: Affine, out: &mut Vec<(BezPath, FillRule)>) {
let mut reads = MAX_CLIP_READS;
clip_shapes_depth(cp, ts, 0, &mut reads)
}
/// Recursively collect shapes for `cp` in document space up to `MAX_CLIP_DEPTH`, and while `reads`
/// are left.
fn clip_shapes_depth(cp: &usvg::ClipPath, ts: Affine, depth: usize, reads: &mut usize) -> Vec<(BezPath, FillRule)> {
if depth > MAX_CLIP_DEPTH || *reads == 0 {
return Vec::new();
}
*reads -= 1;
fn collect(g: &usvg::Group, m: Affine, depth: usize, reads: &mut usize, out: &mut Vec<(BezPath, FillRule)>) {
for c in g.children() {
match c {
usvg::Node::Group(g) => collect(g, m * aff(g.transform()), out),
usvg::Node::Group(g) => {
let m_child = m * aff(g.transform());
if let Some(child_cp) = g.clip_path() {
let mut child_shapes = Vec::new();
collect(g, m_child, depth + 1, reads, &mut child_shapes);
if !child_shapes.is_empty() {
let clip_paths = clip_shapes_depth(child_cp, m_child, depth + 1, reads);
intersect_shapes(&child_shapes, &clip_paths, out);
}
} else {
collect(g, m_child, depth, reads, out);
}
}
usvg::Node::Path(p) => out.push((bezpath(p.data(), m), p.fill().map(|f| rule(f.rule())).unwrap_or_default())),
_ => {}
}
}
}
let mut shapes = Vec::new();
collect(cp.root(), ts * aff(cp.transform()), &mut shapes);
collect(cp.root(), ts * aff(cp.transform()), depth, reads, &mut shapes);
if !shapes.is_empty()
&& depth > 0
&& depth < MAX_CLIP_DEPTH
&& *reads > 0
&& let Some(parent_cp) = cp.clip_path()
{
let parent_shapes = clip_shapes_depth(parent_cp, ts, depth + 1, reads);
let mut intersected = Vec::new();
intersect_shapes(&shapes, &parent_shapes, &mut intersected);
shapes = intersected;
}
shapes
}
/// Intersect `shapes` with `clips`: SVG 1.1 §14.3.5 specifies that a clip path clips to the union
/// of its children, each child first clipped by its own clip-path if present.
fn intersect_shapes(shapes: &[(BezPath, FillRule)], clips: &[(BezPath, FillRule)], out: &mut Vec<(BezPath, FillRule)>) {
if shapes.is_empty() || clips.is_empty() {
return;
}
let clip_data = match clips {
[(bp, rule)] => (PathData::from_bezpath(bp), *rule),
_ => {
let paths: Vec<(PathData, FillRule)> = clips.iter().map(|(bp, r)| (PathData::from_bezpath(bp), *r)).collect();
let refs: Vec<(&PathData, FillRule)> = paths.iter().map(|(p, r)| (p, *r)).collect();
(vectorcraft_pathops::unite_all(&refs), FillRule::NonZero)
}
};
for (bp, r) in shapes {
let child_data = PathData::from_bezpath(bp);
let inter = vectorcraft_pathops::boolean(&child_data, *r, &clip_data.0, clip_data.1, vectorcraft_pathops::BoolOp::Intersect);
if !inter.subpaths.is_empty() {
out.push((inter.to_bezpath(), FillRule::NonZero));
}
}
}
/// Does clip path `cp` (on an element whose user space `ts` maps to the document) leave `art`,
/// placed by `xf`, whole? (Only its bounds are checked: the box must be inside a clip shape.)
pub(super) fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine) -> bool {
@@ -217,6 +280,7 @@ pub(super) fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine) ->
corners.iter().all(|c| shapes.iter().any(|(s, _)| kurbo::Shape::contains(s, xf * *c)))
}
/// Convert a usvg fill rule to the document model fill rule.
pub(super) fn rule(r: usvg::FillRule) -> FillRule {
match r {
usvg::FillRule::NonZero => FillRule::NonZero,
+141
View File
@@ -252,6 +252,147 @@ fn clip_path_children_unite_under_their_own_rules() {
assert!(matches!(children[1].kind, NodeKind::Compound { rule: vectorcraft_geom::FillRule::EvenOdd, .. }));
}
/// SVG 1.1 §14.3.5: a child of a clipPath element with its own clip-path is clipped by it before
/// contributing to the clip (https://www.w3.org/TR/SVG11/masking.html#EstablishingANewClippingPath).
#[test]
fn clip_path_child_clip_path_clips_child() {
let page = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<defs>
<clipPath id="inner"><rect width="50" height="200"/></clipPath>
<clipPath id="outer">
<rect width="200" height="200" clip-path="url(#inner)"/>
</clipPath>
</defs>
<rect width="200" height="200" fill="#f00" clip-path="url(#outer)"/>
</svg>"##;
let (d, w) = import_with_report(page).unwrap();
assert!(w.is_empty(), "{w:?}");
let mine = render_artboard(&d);
assert_similar(&mine, &resvg_render(page, 200, 200), 24.0, 0.003);
assert_eq!(mine.over_white(25, 25), [255, 0, 0], "the left strip is clipped in");
assert_eq!(mine.over_white(100, 100), [255, 255, 255], "the right side is cut by inner clip");
// Written back out, the clip stays identical.
let out = vectorcraft_svg::export(&d, &Default::default());
assert_similar(&render_artboard(&vectorcraft_svg::import(&out).unwrap()), &mine, 24.0, 0.003);
}
#[test]
fn clip_path_child_with_chained_clip_path_intersects() {
let page = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<defs>
<clipPath id="base"><rect y="0" width="200" height="100"/></clipPath>
<clipPath id="inner" clip-path="url(#base)"><rect x="0" width="50" height="200"/></clipPath>
<clipPath id="outer">
<rect width="200" height="200" clip-path="url(#inner)"/>
</clipPath>
</defs>
<rect width="200" height="200" fill="#f00" clip-path="url(#outer)"/>
</svg>"##;
let (d, w) = import_with_report(page).unwrap();
assert!(w.is_empty(), "{w:?}");
let mine = render_artboard(&d);
assert_similar(&mine, &resvg_render(page, 200, 200), 24.0, 0.003);
assert_eq!(mine.over_white(25, 25), [255, 0, 0], "top left quadrant is inside both clips");
assert_eq!(mine.over_white(25, 150), [255, 255, 255], "bottom left is cut by base clip");
assert_eq!(mine.over_white(100, 25), [255, 255, 255], "top right is cut by inner clip");
}
/// Clip paths are shared, so children that each name the next one multiply at every level: ten
/// children on seven levels would take 10^7 reads unbounded. The import must still finish.
#[test]
fn clip_paths_whose_children_fan_out_still_import() {
let mut defs = String::from(r#"<clipPath id="c0"><rect width="10" height="10"/></clipPath>"#);
for n in 1..8 {
let kids = format!(r#"<rect width="10" height="10" clip-path="url(#c{})"/>"#, n - 1).repeat(10);
defs += &format!(r#"<clipPath id="c{n}">{kids}</clipPath>"#);
}
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><defs>{defs}</defs><rect width="20" height="20" clip-path="url(#c7)"/></svg>"#
);
assert!(import_with_report(&svg).is_ok());
}
#[test]
fn clip_path_deep_chain_preserves_art() {
let mut defs = String::from(r#"<clipPath id="c0"><rect width="100" height="100"/></clipPath>"#);
for n in 1..=20 {
defs += &format!(r#"<clipPath id="c{n}" clip-path="url(#c{})"><rect width="100" height="100"/></clipPath>"#, n - 1);
}
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{defs}</defs><rect width="100" height="100" fill="#f00" clip-path="url(#c20)"/></svg>"##
);
let (d, w) = import_with_report(&svg).unwrap();
assert!(!w.is_empty(), "expected warning about deep clip nesting");
let mine = render_artboard(&d);
assert_eq!(mine.over_white(50, 50), [255, 0, 0], "red rect should be preserved despite depth limit");
}
#[test]
fn clip_path_child_deep_chain_preserves_art() {
let mut defs = String::from(r#"<clipPath id="c0"><rect width="100" height="100"/></clipPath>"#);
for n in 1..=20 {
defs += &format!(r#"<clipPath id="c{n}" clip-path="url(#c{})"><rect width="100" height="100"/></clipPath>"#, n - 1);
}
defs += r#"<clipPath id="outer"><rect width="100" height="100" clip-path="url(#c20)"/></clipPath>"#;
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{defs}</defs><rect width="100" height="100" fill="#f00" clip-path="url(#outer)"/></svg>"##
);
let (d, _w) = import_with_report(&svg).unwrap();
let mine = render_artboard(&d);
assert_eq!(mine.over_white(50, 50), [255, 0, 0], "red rect should be preserved despite depth limit in child clip");
}
#[test]
fn clip_path_preserves_union_when_read_budget_exhausted() {
let mut defs = String::from(r#"<clipPath id="tile"><rect width="10" height="10"/></clipPath>"#);
let mut kids = String::new();
for i in 0..1100 {
let x = (i % 20) * 10;
let y = (i / 20) * 10;
kids += &format!(r##"<rect x="{x}" y="{y}" width="10" height="10" clip-path="url(#tile)"/>"##);
}
defs += &format!(r#"<clipPath id="many">{kids}</clipPath>"#);
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="600"><defs>{defs}</defs><rect width="200" height="600" fill="#f00" clip-path="url(#many)"/></svg>"##
);
let (d, _w) = import_with_report(&svg).unwrap();
let mine = render_artboard(&d);
// Early tiles processed within the 1024 read budget must be preserved in the union.
assert_eq!(mine.over_white(5, 5), [255, 0, 0], "early tile inside read budget is preserved");
}
#[test]
fn clip_path_empty_clips_everything() {
let page = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<defs>
<clipPath id="empty"/>
</defs>
<rect width="100" height="100" fill="#f00" clip-path="url(#empty)"/>
</svg>"##;
let (d, _w) = import_with_report(page).unwrap();
let mine = render_artboard(&d);
assert_eq!(mine.over_white(50, 50), [255, 255, 255], "genuinely empty clip path clips away all art");
}
#[test]
fn clip_path_child_with_empty_linked_clip_clips_everything() {
let page = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<defs>
<clipPath id="empty"/>
<clipPath id="inner" clip-path="url(#empty)">
<rect width="100" height="100"/>
</clipPath>
<clipPath id="outer">
<rect width="100" height="100" clip-path="url(#inner)"/>
</clipPath>
</defs>
<rect width="100" height="100" fill="#f00" clip-path="url(#outer)"/>
</svg>"##;
let (d, _w) = import_with_report(page).unwrap();
let mine = render_artboard(&d);
assert_eq!(mine.over_white(50, 50), [255, 255, 255], "empty linked clip path clips away all art");
}
#[test]
fn undisplayed_objects_import_hidden() {
let body = r##"<rect width="10" height="10" display="none"/><g style="display:none"><circle cx="50" cy="50" r="5"/></g>