mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 12:24:54 +00:00
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:
Generated
+1
@@ -6471,6 +6471,7 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user