Merge pull request #5849 from mockydevs/fix/svg-clippath-union

SVG import: a clipPath's children clip to their union, each under its own clip-rule (#5840)
This commit is contained in:
BFlat
2026-11-06 07:37:10 +08:00
committed by GitHub
2 changed files with 64 additions and 11 deletions
+25 -11
View File
@@ -7,6 +7,7 @@ use super::{
StrokeLayer, Vec2, fnv1a, fx, shapes, spread, text, vector_effect,
};
use crate::SvgError;
use kurbo::BezPath;
/// How many clip paths deep (`<clipPath clip-path>`) an object's clip is read.
const MAX_CLIP_NEST: usize = 16;
@@ -446,19 +447,32 @@ impl Importer {
}
fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option<Node> {
let shapes = clip_shapes(cp, ts);
if shapes.is_empty() {
let mut shapes = clip_shapes(cp, ts);
let id = cp.id().to_string();
if shapes.len() == 1 {
let (bp, r) = shapes.pop()?;
let kind = self.clip_shape(&bp, r, true)?;
return self.named(&id, kind);
}
// SVG 1.1 §14.3.5: the clip is the union of the children's silhouettes, each under its own
// clip-rule, so they don't cancel or share one rule: a group of them, which clips to that
// union (`Node::clip_shapes`).
let mut children = Vec::with_capacity(shapes.len());
for (bp, r) in &shapes {
let kind = self.clip_shape(bp, *r, false)?;
children.push(Arc::new(Node::new(self.alloc_id()?, kind)));
}
if children.is_empty() {
return None;
}
let r = shapes[0].1;
let mut subs = Vec::new();
for (bp, _) in &shapes {
subs.extend(PathData::from_bezpath(bp).subpaths);
}
let id = cp.id().to_string();
self.named(&id, NodeKind::Group { children, clip: false })
}
/// One clip path child: a path, or a compound path of its subpaths when it has several.
fn clip_shape(&mut self, bp: &BezPath, r: FillRule, clipping: bool) -> Option<NodeKind> {
let subs = PathData::from_bezpath(bp).subpaths;
if subs.len() == 1 {
let path = PathData::new(subs);
return self.named(&id, NodeKind::Path { path, rule: r, live: None, clipping: true, guide: false });
return Some(NodeKind::Path { path: PathData::new(subs), rule: r, live: None, clipping, guide: false });
}
let mut children = Vec::with_capacity(subs.len());
for sp in subs {
@@ -466,7 +480,7 @@ impl Importer {
children
.push(Arc::new(Node::new(nid, NodeKind::Path { path: PathData::single(sp), rule: r, live: None, clipping: false, guide: false })));
}
self.named(&id, NodeKind::Compound { children, rule: r })
Some(NodeKind::Compound { children, rule: r })
}
/// A usvg paint mapped by `m`, for art covering `area` (after `m`: where a reflected or
+39
View File
@@ -213,6 +213,45 @@ fn nested_clips_intersect() {
assert_ne!(mine.over_white(90, 60), [255, 255, 255]);
}
/// SVG 1.1 §14.3.5: a clip path clips to the union of its children's silhouettes, each under its
/// own `clip-rule` (https://www.w3.org/TR/SVG11/masking.html#EstablishingANewClippingPath).
#[test]
fn clip_path_children_unite_under_their_own_rules() {
// Two squares wound in opposite directions overlap at 60–100: the overlap stays clipped in.
let opposite = r##"<defs><clipPath id="c"><path d="M20 20H100V100H20Z"/><path d="M60 60V140H140V60Z"/></clipPath></defs>
<rect width="200" height="200" fill="#f00" clip-path="url(#c)"/>"##;
// A small non-zero square first, then an even-odd ring: the ring's hole stays out.
let ring = r##"<defs><clipPath id="c"><path d="M0 0H10V10H0Z"/>
<path clip-rule="evenodd" d="M40 20H160V140H40Z M70 50H130V110H70Z"/></clipPath></defs>
<rect width="200" height="200" fill="#f00" clip-path="url(#c)"/>"##;
let page = |body: &str| format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">{body}</svg>"#);
for body in [opposite, ring] {
let (d, w) = import_with_report(&page(body)).unwrap();
assert!(w.is_empty(), "{w:?}");
let mine = render_artboard(&d);
assert_similar(&mine, &resvg_render(&page(body), 200, 200), 24.0, 0.003);
// Written back out, the clip is still the union.
let out = vectorcraft_svg::export(&d, &Default::default());
assert_similar(&render_artboard(&vectorcraft_svg::import(&out).unwrap()), &mine, 24.0, 0.003);
}
let (d, _) = import_with_report(&page(opposite)).unwrap();
let mine = render_artboard(&d);
assert_eq!(mine.over_white(80, 80), [255, 0, 0], "the overlap is clipped in");
assert_eq!(mine.over_white(120, 120), [255, 0, 0]);
assert_eq!(mine.over_white(130, 30), [255, 255, 255]);
let (d, _) = import_with_report(&page(ring)).unwrap();
let mine = render_artboard(&d);
assert_eq!(mine.over_white(100, 80), [255, 255, 255], "the even-odd hole stays out");
assert_eq!(mine.over_white(50, 80), [255, 0, 0]);
assert_eq!(mine.over_white(5, 5), [255, 0, 0]);
// Each child keeps its own rule in the clipping group, still named after the clip path.
let clip = &art(&d)[1];
assert_eq!(clip.name.as_deref(), Some("c"));
let NodeKind::Group { children, clip: false } = &clip.kind else { panic!("{clip:?}") };
assert!(matches!(children[0].kind, NodeKind::Path { rule: vectorcraft_geom::FillRule::NonZero, .. }));
assert!(matches!(children[1].kind, NodeKind::Compound { rule: vectorcraft_geom::FillRule::EvenOdd, .. }));
}
#[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>