Merge pull request #6032 from dotCooCoo/fix/ai-clip-path-keeps-fill

.ai import: a filled clipping path keeps its fill
This commit is contained in:
BFlat
2026-11-08 12:34:55 +08:00
committed by GitHub
10 changed files with 341 additions and 108 deletions
+7 -7
View File
@@ -1396,8 +1396,8 @@ fn transparency_is_written_in_its_forms() {
);
}
/// A clipping path filled and stroked keeps its stroke; the reader reads its fill as a path of
/// its own under the clipped art, as the file's PDF page draws it.
/// A clipping path filled and stroked is written with its fill and stroke, and reads back with
/// both on the clipping path.
#[test]
fn a_painted_clipping_path_is_written_with_its_paint() {
let mut doc = Document::new(200.0, 100.0);
@@ -1421,8 +1421,7 @@ fn a_painted_clipping_path_is_written_with_its_paint() {
assert_eq!(
painted,
[
(true, None, Some(paint(&solid(Color::BLACK)))),
(false, Some(paint(&solid(Color::rgb(1.0, 1.0, 0.0)))), None),
(true, Some(paint(&solid(Color::rgb(1.0, 1.0, 0.0)))), Some(paint(&solid(Color::BLACK)))),
(false, Some(paint(&solid(Color::rgb(0.0, 0.0, 1.0)))), None)
]
);
@@ -2090,8 +2089,7 @@ fn gradients_and_patterns_are_written_in_their_forms() {
assert!(art.contains(" p\n") && art.contains(" P\n"), "{art}");
}
/// A clipping path filled with a gradient keeps it: the reader reads it as the clipping path and
/// its fill as a path of its own under the art.
/// A clipping path filled with a gradient keeps it, and reads back with it.
#[test]
fn a_clipping_path_keeps_its_gradient() {
let mut doc = Document::new(200.0, 100.0);
@@ -2107,7 +2105,9 @@ fn a_clipping_path_keeps_its_gradient() {
assert!(text.contains("h\nW\nBb\n0 (Gradient) "), "{text}");
let back = read_back(&doc);
let NodeKind::Group { children, clip: true } = &back.layers[0].children().unwrap()[0].kind else { panic!("a clipping group") };
let fill = &children[1];
assert_eq!(children.len(), 2, "the clipping path and the art");
let fill = &children[0];
assert!(matches!(fill.kind, NodeKind::Path { clipping: true, .. }), "{:?}", fill.kind);
let bounds = Rect::new(10.0, 10.0, 90.0, 60.0);
let want = drawn(&doc, &gradient_paint(sunset(), None, None), bounds);
assert_eq!(drawn(&back, fill.appearance.items[0].paint(), bounds), want);
+2 -1
View File
@@ -515,7 +515,8 @@ struct Reader<'a> {
/// The state a layer's last group or compound path had before an `Xy` right after its end,
/// which is the layer's instead when the layer's own `7 () XW` follows.
container_undo: Option<(f32, BlendMode, bool, Knockout, bool)>,
/// Painted copies of clipping paths that have a fill (drawn under what they clip).
/// The fills of compound clipping paths whose subpaths paint differently, as compound paths of their own (drawn
/// under what they clip).
clip_fills: HashSet<NodeId>,
/// Groups closed as an object's drawn look (`9 () XW`).
looks: HashSet<NodeId>,
+6 -35
View File
@@ -216,13 +216,11 @@ impl<'a> Reader<'a> {
// A gradient instance's paint replaces the missing pattern.
let unread = (fill && self.pending_unread_fill && fill_paint.is_none()) || (stroke && self.pending_unread_stroke && stroke_paint.is_none());
let mut items = Vec::new();
if fill && !clip {
items.push(AppearanceItem::Fill(FillLayer {
overprint: gs.overprint_fill,
..FillLayer::new(fill_paint.clone().unwrap_or(gs.fill.clone()))
}));
// A clipping path keeps its fill and its stroke: its fill paints under the art it clips and its stroke over it
// (`Node::clip_paint`), as the file's PDF part draws them.
if fill {
items.push(AppearanceItem::Fill(FillLayer { overprint: gs.overprint_fill, ..FillLayer::new(fill_paint.unwrap_or(gs.fill.clone())) }));
}
// A clipping path keeps its stroke, which paints over the art it clips.
if stroke {
let mut st = StrokeLayer::new(stroke_paint.unwrap_or(gs.stroke.clone()), gs.line_width());
st.cap = gs.cap;
@@ -254,23 +252,6 @@ impl<'a> Reader<'a> {
if clip && let Some(f) = self.frames.last_mut() {
f.clips.push(f.children.len());
}
// A clipping path's fill is painted too, under the art it clips, as the file's PDF part draws it.
let painted = clip && fill && !matches!(gs.fill, Paint::None);
let copy = painted.then(|| {
let mut copy = node.clone();
copy.id = self.doc.alloc_id();
if let NodeKind::Path { clipping, .. } = &mut copy.kind {
*clipping = false;
}
let paint = fill_paint.clone().unwrap_or_else(|| gs.fill.clone());
copy.appearance = Appearance {
items: vec![AppearanceItem::Fill(FillLayer { overprint: gs.overprint_fill, ..FillLayer::new(paint) })],
..Appearance::default()
}
.into();
copy
});
// The note and Show Center are the path's, not its painted copy's.
node.edit_attrs(|a| {
a.note = gs.note.clone().unwrap_or_default();
a.show_center = gs.show_center;
@@ -297,10 +278,6 @@ impl<'a> Reader<'a> {
}
}
}
if let Some(copy) = copy {
self.clip_fills.insert(copy.id);
self.add(copy, hidden)?;
}
Ok(())
}
@@ -342,19 +319,13 @@ impl<'a> Reader<'a> {
Ok(())
}
/// Stroke the path just painted (a gradient-filled one, `1 BB` or `2 BB`) with the stroke's state. A clipping path
/// takes the stroke itself, not the painted copy of its fill.
/// Stroke the path just painted (a gradient-filled one, `1 BB` or `2 BB`) with the stroke's state.
pub(super) fn stroke_last(&mut self) {
let gs = self.gs.clone();
if !self.after_object {
return;
}
let Some(kids) = self.frames.last_mut().map(|f| &mut f.children) else { return };
let Some(mut at) = kids.len().checked_sub(1) else { return };
if kids.get(at).is_some_and(|n| self.clip_fills.contains(&n.id)) && at > 0 {
at -= 1;
}
let Some(n) = kids.get_mut(at) else { return };
let Some(n) = self.frames.last_mut().and_then(|f| f.children.last_mut()) else { return };
if !matches!(n.kind, NodeKind::Path { .. }) || n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))) {
return;
}
+82 -15
View File
@@ -147,10 +147,19 @@ impl<'a> Reader<'a> {
self.new_node(NodeKind::Group { children, clip }, &gs)
}
Kind::Compound => {
// A group in a compound path (`*u u … U *U`) only holds its paths. Clipping subpaths'
// painted copies make a compound of their own, under what it clips.
let (fills, kids): (Vec<Node>, Vec<Node>) = ungrouped(kids).into_iter().partition(|n| self.clip_fills.contains(&n.id));
painted = self.compound(fills, &gs);
// A group in a compound path (`*u u … U *U`) only holds its paths.
let mut kids = ungrouped(kids);
// A compound clipping path keeps its subpaths' fill when they all paint alike. When they paint
// differently, the fills of the filled clipping ones are a compound path of their own, under what it clips.
let look = |n: &Node| (n.appearance.clone(), n.opacity, n.blend);
if clipping && kids.windows(2).any(|w| look(&w[0]) != look(&w[1])) {
let fills: Vec<Node> = kids
.iter_mut()
.filter(|k| matches!(k.kind, NodeKind::Path { clipping: true, .. }))
.filter_map(|k| self.fill_apart(k))
.collect();
painted = self.compound(fills, &gs);
}
let Some(n) = self.compound(kids, &gs) else {
if gs.hidden || !self.shown() {
self.hidden_unread.extend(unread);
@@ -228,9 +237,9 @@ impl<'a> Reader<'a> {
/// Several clipping paths in a clipping group or a layer clip to their intersection, as each `W` reduces the clip
/// set by the ones before it: each clipping path after the first clips a clipping group nested in the one before
/// it, and the innermost one holds the art. As with one clipping path, every object in the group is clipped by all
/// of them, wherever it is written. A clipping path's painted fill stays at its path's level. In a compound path
/// (`union`), several clipping paths are one compound path. `at`: how deep the group or layer made of them is in the
/// document.
/// of them, wherever it is written. The fill of a compound clipping path read apart from it (`clip_fills`) stays at
/// its path's level. In a compound path (`union`), several clipping paths are one compound path. `at`: how deep the
/// group or layer made of them is in the document.
fn clip_first(&mut self, kids: Vec<Node>, clips: &[usize], union: bool, at: usize) -> Result<(Vec<Arc<Node>>, bool), String> {
if union || clips.len() < 2 {
return Ok(self.clip_union(kids, clips));
@@ -238,7 +247,7 @@ impl<'a> Reader<'a> {
let clips: std::collections::HashSet<usize> = clips.iter().copied().collect();
let (clip, rest): (Vec<Indexed>, Vec<Indexed>) = kids.into_iter().enumerate().partition(|(i, _)| clips.contains(i));
let innermost = clip.len() - 1;
// A painted fill follows its clipping path; the art goes in the innermost level.
// A fill read apart follows its clipping path; the art goes in the innermost level.
let level = |i: usize, n: &Node| {
if self.clip_fills.contains(&n.id) { clip.iter().rposition(|(c, _)| *c < i).unwrap_or(0) } else { innermost }
};
@@ -251,8 +260,7 @@ impl<'a> Reader<'a> {
if at + deepest > MAX_DEPTH || (at + deepest > vectorcraft_doc::MAX_DEPTH && at + flat <= vectorcraft_doc::MAX_DEPTH) {
return Err("its clipping masks are nested too deeply".into());
}
// Each level: its clipping path, the clipping paths' own fills, which paint under the art they clip, then the
// art.
// Each level: its clipping path, the fills read apart from it, which paint under the art they clip, then the art.
let mut fills_at: Vec<Vec<Node>> = clip.iter().map(|_| Vec::new()).collect();
let mut art_at: Vec<Vec<Node>> = clip.iter().map(|_| Vec::new()).collect();
for (i, n) in rest {
@@ -282,13 +290,18 @@ impl<'a> Reader<'a> {
fn clip_union(&mut self, kids: Vec<Node>, clips: &[usize]) -> (Vec<Arc<Node>>, bool) {
let clips: std::collections::HashSet<usize> = clips.iter().copied().collect();
let (mut clip, rest): (Vec<Indexed>, Vec<Indexed>) = kids.into_iter().enumerate().partition(|(i, _)| clips.contains(i));
let mut fills = Vec::new();
let clip_node = match clip.len() {
0 => None,
1 => clip.pop().map(|(_, n)| n),
_ => {
let rule = clip.first().map_or(FillRule::NonZero, |(_, n)| path_rule(n));
// The compound path keeps the clipping paths' stroke, opacity and blend mode when they all have the same
// ones, and the knockout shape flag when they all have it: it has one appearance for all its paths.
// Each clipping path's fill is a path of its own, under the art it clips, as each one paints it. The
// compound path keeps the clipping paths' stroke, opacity and blend mode when they all have the same ones,
// and the knockout shape flag when they all have it: it has one appearance for all its paths.
for (_, n) in &mut clip {
fills.extend(self.fill_apart(n));
}
let shape = clip.iter().all(|(_, n)| n.knockout_shape);
let look = |n: &Node| (n.appearance.clone(), n.opacity, n.blend);
let shared =
@@ -305,12 +318,66 @@ impl<'a> Reader<'a> {
}
};
let clips = clip_node.is_some();
// A clipping path's own fill paints under the art it clips.
let (fills, rest): (Vec<Indexed>, Vec<Indexed>) = rest.into_iter().partition(|(_, n)| self.clip_fills.contains(&n.id));
let children = clip_node.into_iter().chain(fills.into_iter().chain(rest).map(|(_, n)| n)).map(Arc::new).collect();
// A fill read apart paints under the art it clips.
let (apart, rest): (Vec<Indexed>, Vec<Indexed>) = rest.into_iter().partition(|(_, n)| self.clip_fills.contains(&n.id));
let children = clip_node.into_iter().chain(fills).chain(apart.into_iter().chain(rest).map(|(_, n)| n)).map(Arc::new).collect();
(children, clips)
}
/// Clipping path `n`'s fills as a path of their own, taken off `n`; `None` when it has no fill. The path keeps `n`'s
/// transparency, visibility, lock and opacity mask, and none of its other attributes.
pub(super) fn fill_apart(&mut self, n: &mut Node) -> Option<Node> {
let is_fill = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(_));
if !n.appearance.items.iter().any(is_fill) {
return None;
}
let mut kind = n.kind.clone();
if let NodeKind::Path { clipping, .. } = &mut kind {
*clipping = false;
}
let mut fill = Node::new(self.doc.alloc_id(), kind);
// The copies of a path read on `%_` lines are on those lines too, as `add` records them.
let hidden = self.hidden_ids.contains(&n.id);
if hidden {
self.hidden_ids.insert(fill.id);
}
// A compound clipping path's subpaths: copies with ids of their own, linked to the same swatches, that don't clip.
for c in fill.children_mut().into_iter().flatten() {
let mut copy = self.doc.reid(c);
self.reid_masks(&mut copy, 0);
if let NodeKind::Path { clipping, .. } = &mut copy.kind {
*clipping = false;
}
if hidden {
self.hidden_ids.insert(copy.id);
}
*c = Arc::new(copy);
}
(fill.opacity, fill.blend, fill.isolate, fill.knockout, fill.knockout_shape, fill.visible, fill.locked) =
(n.opacity, n.blend, n.isolate, n.knockout, n.knockout_shape, n.visible, n.locked);
// The opacity mask's art is a copy with ids of its own, linked to the same swatches.
if let Some(mut mask) = n.extras().mask.clone() {
let mut art = self.doc.reid(&mask.art);
self.reid_masks(&mut art, 0);
mask.art = Arc::new(art);
fill.extras_mut().mask = Some(mask);
}
let (mut fills, mut rest) = ((*n.appearance).clone(), (*n.appearance).clone());
fills.items.retain(is_fill);
rest.items.retain(|i| !is_fill(i));
fill.appearance = fills.into();
n.appearance = rest.into();
// The new path, its subpaths and their points count toward the objects read, as a multi-layer mask's copies do.
let mut weight = fill.count_with_masks();
fill.walk(&mut |p| {
if let NodeKind::Path { path, .. } = &p.kind {
weight = weight.saturating_add(path.anchor_count());
}
});
self.nodes = self.nodes.saturating_add(weight);
Some(fill)
}
pub(super) fn end_layer(&mut self) -> Result<(), String> {
self.pending_unread_fill = false;
self.pending_unread_stroke = false;
+2 -2
View File
@@ -648,10 +648,10 @@ impl<'a> Reader<'a> {
named
}
/// Give the art of the opacity masks in `n` (a copy of a symbol's art with ids of this
/// Give the art of the opacity masks in `n` (a copy of a symbol's art or of a clipping path with ids of this
/// document, [`vectorcraft_doc::Document::reid`], which leaves masks as they are) ids of this
/// document too.
fn reid_masks(&mut self, n: &mut Node, depth: usize) {
pub(super) fn reid_masks(&mut self, n: &mut Node, depth: usize) {
if depth > MAX_DEPTH {
return;
}
+22 -5
View File
@@ -57,32 +57,49 @@ impl Reader<'_> {
/// `Md`: the clipping path painted last since `Mb`, in the same group or layer, is the mask.
fn define_multi_mask(&mut self) -> Result<(), String> {
let depth = self.frames.len();
let Some(m) = self.multi_mask.as_mut() else { return Err("a multi-layer mask's operators are out of order".into()) };
let Some(m) = self.multi_mask.as_ref() else { return Err("a multi-layer mask's operators are out of order".into()) };
if m.mask.is_some() || depth == 0 {
return Err("a multi-layer mask's operators are out of order".into());
}
if m.depth != depth {
return Err("a group crosses a multi-layer mask".into());
}
let start = m.start;
let Some(f) = self.frames.last_mut() else { return Err("a multi-layer mask's operators are out of order".into()) };
let Some(at) = f.clips.iter().copied().filter(|i| *i >= m.start && *i < f.children.len()).max() else {
let Some(at) = f.clips.iter().copied().filter(|i| *i >= start && *i < f.children.len()).max() else {
return Err("a multi-layer mask needs one clipping path".into());
};
let mask = f.children.remove(at);
let mut mask = f.children.remove(at);
f.clips.retain(|i| *i != at);
for i in &mut f.clips {
if *i > at {
*i -= 1;
}
}
// The mask's fill paints once, where its path is, under the objects masked after it: it stays a path of its own
// among them, and the copies of the mask in each layer it covers have no fill.
if let Some(fill) = self.fill_apart(&mut mask) {
if self.nodes > MAX_NODES {
return Err(format!("it has more than {MAX_NODES} objects"));
}
let Some(f) = self.frames.last_mut() else { return Err("a multi-layer mask's operators are out of order".into()) };
f.children.insert(at, fill);
for i in &mut f.clips {
if *i >= at {
*i += 1;
}
}
}
let mut weight = mask.count_with_masks();
mask.walk(&mut |n| {
if let NodeKind::Path { path, .. } = &n.kind {
weight = weight.saturating_add(path.anchor_count());
}
});
m.weight = weight;
m.mask = Some(mask);
if let Some(m) = self.multi_mask.as_mut() {
m.weight = weight;
m.mask = Some(mask);
}
self.after_object = false;
Ok(())
}
+2 -2
View File
@@ -81,8 +81,8 @@ impl<'a> Reader<'a> {
/// `AdobeURL` tag sets the object's URL, and every other tag is added to the object's data in
/// order, up to [`MAX_TAGS`]. A tag with no object before it, with an empty identifier, or with
/// an identifier or string longer than [`MAX_TAG_TEXT`] bytes is ignored, and so are tags in a
/// pattern's or brush's art, which is not an object of the document. A clipping path's tags are
/// its own, not those of the copy that paints its fill.
/// pattern's or brush's art, which is not an object of the document. A compound clipping path's tags are its own,
/// not those of the compound path that paints its fill apart from it.
fn object_tag(&mut self, vals: &[V], hidden: bool) {
let [.., V::Name(id), V::Str(value)] = vals else {
self.note_unknown("XT", hidden);
+208 -38
View File
@@ -187,9 +187,9 @@ fn a_note_before_a_group_or_compound_path_is_not_theirs() {
}
}
/// A clipping path's note is its own, not that of the copy that paints its fill.
/// A filled clipping path keeps its note, and no other object takes it.
#[test]
fn a_clipping_paths_note_is_not_copied_to_its_painted_fill() {
fn a_filled_clipping_paths_note_is_its_own() {
let body =
format!("0 Ae\nq\n{RED}%AI3_Note:clip\n{}W\nf\n%AI3_Note:\n{}f\nQ\n", rect_path(5.0, 5.0, 25.0, 25.0), rect_path(0.0, 0.0, 30.0, 30.0));
let doc = read_ok(&layer(&body)).doc;
@@ -203,7 +203,7 @@ fn a_clipping_paths_note_is_not_copied_to_its_painted_fill() {
walk(&art(&doc), &mut all);
let noted: Vec<_> = all.iter().filter(|(_, n)| n == "clip").collect();
assert_eq!(noted, [&(true, "clip".to_string())], "{all:?}");
assert!(all.len() >= 3, "the painted copy is there: {all:?}");
assert_eq!(all.len(), 3, "the group, its clipping path and the art: {all:?}");
}
/// `1 Ap` shows the center point of the paths after it until `0 Ap` (§12, showCenter), which leaves
@@ -294,8 +294,8 @@ fn object_tags_without_an_object_before_them_tag_nothing() {
assert!(err.contains("operators VectorCraft doesn't know"), "{err}");
}
/// A clipping path's tags are its own, not those of the copy that paints its fill, and a clip
/// group's tags after `Q` are the group's. One object keeps at most 64 tags in its data.
/// A filled clipping path's tags are its own, and a clip group's tags after `Q` are the group's.
/// One object keeps at most 64 tags in its data.
#[test]
fn object_tags_on_clipping_paths_and_the_tag_limit() {
let body = format!(
@@ -307,6 +307,7 @@ fn object_tags_on_clipping_paths_and_the_tag_limit() {
let top = &art(&doc);
assert_eq!(top.len(), 1);
assert_eq!(tags(&top[0]).1, [tag("group", "clipped")]);
assert_eq!(top[0].children().unwrap().len(), 2, "the clipping path and the art");
let tagged: Vec<_> = top[0].children().unwrap().iter().filter(|k| !tags(k).1.is_empty()).map(|k| k.kind.clone()).collect();
assert_eq!(tagged.len(), 1, "{tagged:?}");
assert!(matches!(tagged[0], NodeKind::Path { clipping: true, .. }), "{tagged:?}");
@@ -374,6 +375,28 @@ fn a_drawn_look_keeps_its_objects_tags() {
assert_eq!(tags(&a[0]), ("https://example.com/n".to_string(), vec![tag("own", "L"), tag("sku", "N-1")]));
}
/// A clipping subpath on `%_` lines in a drawn look leaves with the fill read apart from it.
#[test]
fn a_hidden_clipping_paths_fill_leaves_the_look_with_it() {
let (a, b, c) = (rect_path(0.0, 0.0, 30.0, 30.0), rect_path(5.0, 5.0, 25.0, 25.0), rect_path(0.0, 0.0, 10.0, 10.0));
let two = rect_path(8.0, 8.0, 20.0, 20.0);
let second: String = format!("0 0 1 0 0 0 1 Xa\n{two}h\nW\nf\n").lines().map(|l| format!("%_{l}\n")).collect();
let hidden_clip = format!("*u\n{RED}{a}h\nW\nf\n{second}*U\n");
let body = format!("0 Ae\nu\nu\n{hidden_clip}{b}f\nU\nU\n0 0 Xd\n6 () XW\n%_{}n\n1 (Gone style) XW\n", c.replace('\n', "\n%_"));
let doc = read_ok(&layer(&body)).doc;
let mut leaves = vec![];
doc.layers.iter().for_each(|l| {
l.walk(&mut |n| {
if let NodeKind::Path { clipping, .. } = &n.kind {
leaves.push((*clipping, fill(n).color()));
}
})
});
// The first subpath, its fill and the look's plain path stay; the second subpath and its fill leave.
let (red, blue) = (Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0)));
assert_eq!(leaves, [(true, None), (false, red), (false, blue)]);
}
/// Tags in a brush's art don't keep it from being read, and stay out of the art: it isn't an object
/// of the document.
#[test]
@@ -1245,20 +1268,76 @@ fn compound_paths_and_clipping() {
// The clipping path goes first.
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
assert!(matches!(children[1].kind, NodeKind::Path { clipping: false, .. }));
// A clipping path with a fill paints it too, under the art it clips; without one, nothing.
// A clipping path with a fill keeps it, and paints it under the art it clips (`Node::clip_paint`); without one,
// nothing.
let blue = Some(Color::rgb(0.0, 0.0, 1.0));
let filled = format!("0 Ae\nq\n{RED}{}f\n0 0 1 0 0 0 1 Xa\n{}W\nf\nQ\n", rect_path(0.0, 0.0, 30.0, 30.0), rect_path(5.0, 5.0, 25.0, 25.0));
let doc = read_ok(&layer(&filled)).doc;
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
let kinds: Vec<_> = children.iter().map(|c| (matches!(c.kind, NodeKind::Path { clipping: true, .. }), fill(c).color())).collect();
assert_eq!(kinds, [(true, None), (false, Some(Color::rgb(0.0, 0.0, 1.0))), (false, Some(Color::rgb(1.0, 0.0, 0.0)))]);
assert_eq!(kinds, [(true, blue), (false, Some(Color::rgb(1.0, 0.0, 0.0)))]);
assert_eq!(children[0].clip_paint().fill.map(|f| fill(&f).color()), Some(blue));
// A clipping path in a layer clips the layer.
let doc = read_ok(&layer(&format!("{}f\n{}W\nn\n", rect_path(0.0, 0.0, 9.0, 9.0), rect_path(0.0, 0.0, 5.0, 5.0)))).doc;
let NodeKind::Layer { clip, children, .. } = &doc.layers[0].kind else { panic!() };
assert!(*clip && matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
// A filled clipping path in a layer keeps its fill too.
let doc = read_ok(&layer(&format!("{RED}{}f\n0 0 1 0 0 0 1 Xa\n{}W\nf\n", rect_path(0.0, 0.0, 9.0, 9.0), rect_path(0.0, 0.0, 5.0, 5.0)))).doc;
let NodeKind::Layer { clip: true, children, .. } = &doc.layers[0].kind else { panic!("{:?}", doc.layers[0].kind) };
let kinds: Vec<_> = children.iter().map(|c| (matches!(c.kind, NodeKind::Path { clipping: true, .. }), fill(c).color())).collect();
assert_eq!(kinds, [(true, blue), (false, Some(Color::rgb(1.0, 0.0, 0.0)))]);
// A compound clipping path keeps its subpaths' fill.
let compound = format!(
"0 Ae\nq\n{RED}{}f\n0 0 1 0 0 0 1 Xa\n*u\n{}h\nW\nf\n{}h\nW\nf\n*U\nQ\n",
rect_path(0.0, 0.0, 30.0, 30.0),
rect_path(5.0, 5.0, 25.0, 25.0),
rect_path(10.0, 10.0, 20.0, 20.0)
);
let doc = read_ok(&layer(&compound)).doc;
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
assert!(matches!(children[0].kind, NodeKind::Compound { .. }) && children.len() == 2, "{children:?}");
assert_eq!((fill(&children[0]).color(), fill(&children[1]).color()), (blue, Some(Color::rgb(1.0, 0.0, 0.0))));
// One subpath filled and a later one stroked: the compound clipping path keeps the stroke, and the filled subpath's
// fill is a compound path of its own under the art, so the stroked subpath stays unfilled.
let mixed = format!(
"0 Ae\nq\n{RED}{}f\n0 0 1 0 0 0 1 Xa\n0 1 0 0 0 1 0 XA\n3 w\n*u\n{}h\nW\nf\n{}h\nW\nS\n*U\nQ\n",
rect_path(0.0, 0.0, 30.0, 30.0),
rect_path(5.0, 5.0, 25.0, 25.0),
rect_path(10.0, 10.0, 20.0, 20.0)
);
let doc = read_ok(&layer(&mixed)).doc;
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
assert!(matches!(children[0].kind, NodeKind::Compound { .. }) && children.len() == 3, "{children:?}");
let stroke = children[0].appearance.stroke().map(|s| (s.paint.color(), s.width));
assert_eq!((fill(&children[0]).color(), stroke), (None, Some((Some(Color::rgb(0.0, 1.0, 0.0)), 3.0))));
let NodeKind::Compound { children: filled, .. } = &children[1].kind else { panic!("{:?}", children[1].kind) };
assert_eq!(filled.len(), 1, "only the filled subpath: {filled:?}");
assert!(filled.iter().all(|p| matches!(p.kind, NodeKind::Path { clipping: false, .. })), "{filled:?}");
assert_eq!((fill(&children[1]).color(), children[1].appearance.stroke().is_none()), (blue, true));
assert_eq!(fill(&children[2]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
// A subpath that doesn't clip keeps its fill; only the clipping subpath's fill is read apart.
let green = Some(Color::rgb(0.0, 1.0, 0.0));
let plain = format!(
"0 Ae\nq\n{RED}{}f\n*u\n0 0 1 0 0 0 1 Xa\n{}f\n0 1 0 0 0 1 0 Xa\n{}h\nW\nf\n*U\nQ\n",
rect_path(0.0, 0.0, 30.0, 30.0),
rect_path(5.0, 5.0, 25.0, 25.0),
rect_path(10.0, 10.0, 20.0, 20.0)
);
let doc = read_ok(&layer(&plain)).doc;
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
let NodeKind::Compound { children: subpaths, .. } = &children[0].kind else { panic!("{:?}", children[0].kind) };
let kinds: Vec<_> = subpaths.iter().map(|c| (matches!(c.kind, NodeKind::Path { clipping: true, .. }), fill(c).color())).collect();
assert_eq!(kinds, [(false, blue), (true, None)]);
let NodeKind::Compound { children: apart, .. } = &children[1].kind else { panic!("{:?}", children[1].kind) };
assert_eq!((apart.len(), fill(&children[1]).color()), (1, green), "only the clipping subpath's fill: {apart:?}");
// A tag after the compound clipping path is its own.
let doc = read_ok(&layer(&mixed.replace("*U\n", "*U\n/edge (clip) XT\n"))).doc;
let NodeKind::Group { children, .. } = &art(&doc)[0].kind else { panic!("a clipping group") };
assert_eq!((tags(&children[0]).1, tags(&children[1]).1), (vec![tag("edge", "clip")], vec![]));
}
/// A stroked clipping path (`W S`, `W B`, or a gradient fill with `1 BB`) keeps its stroke on the clipping path, and a
/// fill goes on the painted copy under the clipped art.
/// A stroked clipping path (`W S`, `W B`, or a gradient fill with `1 BB`) keeps its stroke and its fill on the clipping
/// path.
#[test]
fn a_clipping_paths_stroke_stays_on_the_clipping_path() {
let blue = Some(Color::rgb(0.0, 0.0, 1.0));
@@ -1279,17 +1358,11 @@ fn a_clipping_paths_stroke_stays_on_the_clipping_path() {
let stroked = format!("0 Ae\nq\n{RED}{under}f\n0 0 1 0 0 0 1 XA\n3 w\n{clip}W\nS\nQ\n");
assert_eq!(children(&read_ok(&layer(&stroked)).doc), [(true, None, false, Some((blue, 3.0))), (false, red, false, None)]);
let both = format!("0 Ae\nq\n{RED}{under}f\n0 1 0 0 0 1 0 Xa\n0 0 1 0 0 0 1 XA\n3 w\n{clip}W\nB\nQ\n");
assert_eq!(
children(&read_ok(&layer(&both)).doc),
[(true, None, false, Some((blue, 3.0))), (false, Some(Color::rgb(0.0, 1.0, 0.0)), false, None), (false, red, false, None)]
);
assert_eq!(children(&read_ok(&layer(&both)).doc), [(true, Some(Color::rgb(0.0, 1.0, 0.0)), false, Some((blue, 3.0))), (false, red, false, None)]);
let defs = "%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n0 0 0 0 1 0 0 2 0 6 %_BS\n%_0 0 0 0 1 0 0 2 0 6 0 0 Bs\n\
%_0 0 0 0 0 0 1 2 1 6 100 50 Bs\nBD\n%AI5_EndGradient\n";
let gradient = format!("0 Ae\nq\n{RED}{under}f\n0 0 1 0 0 0 1 XA\n3 w\n{clip}W\nBb\n0 0 0 0 Bh\n1 (G) 5 5 0 20 1 0 0 1 0 0 1 Bg\nf\n1 BB\nQ\n");
assert_eq!(
children(&read_ok(&format!("{defs}{}", layer(&gradient))).doc),
[(true, None, false, Some((blue, 3.0))), (false, None, true, None), (false, red, false, None)]
);
assert_eq!(children(&read_ok(&format!("{defs}{}", layer(&gradient))).doc), [(true, None, true, Some((blue, 3.0))), (false, red, false, None)]);
// Several clipping paths each keep their own stroke, opacity and blend mode: the second one clips a clipping group
// inside the first one's.
let inner = rect_path(10.0, 10.0, 20.0, 20.0);
@@ -1331,8 +1404,7 @@ fn the_compound_of_clipping_paths_keeps_their_knockout_shape() {
/// Several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection: each `W`
/// reduces the clip set by the ones before it (AI7 spec §5.6.2). Each clipping path after the first clips a clipping
/// group inside the one before it, and the innermost one holds the art; a clipping path's painted fill stays at its own
/// level.
/// group inside the one before it, and the innermost one holds the art; a filled clipping path keeps its fill.
#[test]
fn several_clipping_paths_clip_to_their_intersection() {
let square = rect_path(0.0, 0.0, 80.0, 80.0);
@@ -1364,11 +1436,12 @@ fn several_clipping_paths_clip_to_their_intersection() {
assert_eq!((two.len(), two[0].len()), (2, 2), "{two:?}");
assert_eq!(two[0][0], (true, None, ra));
assert_eq!(two[1], [(true, None, rb), (false, red, r(0.0, 0.0, 80.0, 80.0))]);
// Three clipping paths nest twice; a filled clipping path's painted copy is clipped by the paths before it only.
// Three clipping paths nest twice; a filled clipping path keeps its fill at its own level, so the fill is clipped
// by that path and the paths before it only.
let three =
group(&format!("0 Ae\nq\n{a}h\nW\nn\n0 0 1 0 0 0 1 Xa\n{b}h\nW\nf\n{}h\nW\nn\n{RED}{square}f\nQ\n", rect_path(30.0, 10.0, 50.0, 90.0)));
assert_eq!(three.len(), 3, "{three:?}");
assert_eq!(three[1][..2], [(true, None, rb), (false, blue, rb)]);
assert_eq!((three[1].len(), three[1][0]), (2, (true, blue, rb)), "{three:?}");
assert_eq!(three[2], [(true, None, r(30.0, 10.0, 50.0, 90.0)), (false, red, r(0.0, 0.0, 80.0, 80.0))]);
// As with one clipping path, every object in the group is clipped by all of them, in the order written, wherever
// it is written.
@@ -1766,6 +1839,61 @@ fn a_compound_path_holding_its_drawn_look_paints_its_paths_apart() {
assert_eq!(fills, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{a:#?}");
}
/// Clipping paths in a compound path that holds its drawn look clip as one compound path. Each one's fill is a path of
/// its own under the art they clip, with the clipping path's opacity mask, so overlapping fills and fills that differ
/// paint as each clipping path paints them.
#[test]
fn clipping_paths_beside_a_compound_paths_look_keep_their_fills() {
let (a, b) = (rect_path(0.0, 0.0, 20.0, 20.0), rect_path(5.0, 5.0, 25.0, 25.0));
let look = format!("0 Ae\nu\n{RED}{}f\n0 0 0 0 0 0 1 Xa\n{}f\nU\n9 () XW\n", rect_path(0.0, 0.0, 30.0, 30.0), rect_path(10.0, 10.0, 15.0, 15.0));
let mask_art: String =
format!("0 0 0 0 (Global white) 1 0 Xk\n{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)).lines().map(|l| format!("%_{l}\n")).collect();
let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_; (AI9_Mask) ,\n%_;\n");
let children = |second: &str, after_first: &str| {
let body = format!("0 Ae\n*u\n{RED}{a}h\nW\nf\n{after_first}{second}{b}h\nW\nf\n{look}*U\n");
let doc = read_ok(&layer(&body)).doc;
// Every object, opacity masks' art included, has an id of its own.
fn ids(n: &Node, seen: &mut std::collections::HashSet<vectorcraft_doc::NodeId>) {
assert!(seen.insert(n.id), "{:?} appears twice", n.id);
if let Some(m) = &n.extras().mask {
ids(&m.art, seen);
}
for c in n.children().into_iter().flatten() {
ids(c, seen);
}
}
let mut seen = std::collections::HashSet::new();
doc.layers.iter().for_each(|l| ids(l, &mut seen));
let a = art(&doc);
let NodeKind::Group { children, clip: true } = &a[0].kind else { panic!("{a:#?}") };
// An opacity mask: the swatch its art's fill is linked to.
let mask = |c: &Node| {
c.extras().mask.as_ref().map(|m| {
let mut swatch = None;
m.art.walk(&mut |p| {
if let Paint::Solid { swatch: Some(s), .. } = fill(p) {
swatch = Some(s);
}
});
swatch
})
};
children.iter().map(|c| (c.kind.clone(), fill(c).color(), mask(c))).collect::<Vec<_>>()
};
let (red, blue) = (Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0)));
// A child's kind, fill color and, with an opacity mask, the swatch its art is linked to.
type Child = (NodeKind, Option<Color>, Option<Option<String>>);
let paths = |c: &[Child]| {
assert!(matches!(c[0].0, NodeKind::Compound { .. }) && c.len() == 4, "{c:?}");
c[..3].iter().map(|(k, c, m)| (matches!(k, NodeKind::Path { clipping: false, .. }), *c, m.clone())).collect::<Vec<_>>()
};
assert_eq!(paths(&children("", "")), [(false, None, None), (true, red, None), (true, red, None)]);
assert_eq!(paths(&children("0 0 0 0 0 0 1 Xa\n", "")), [(false, None, None), (true, red, None), (true, blue, None)]);
// The fill's copy of the mask stays linked to the mask art's global swatch.
let linked = Some(Some("Global white".to_string()));
assert_eq!(paths(&children("0 0 0 0 0 0 1 Xa\n", &mask)), [(false, None, None), (true, red, linked), (true, blue, None)]);
}
#[test]
fn a_compound_path_of_a_plain_group_stays_one_compound_path() {
// GUARDS: #980. Files write `9 () XW` after every group, the one a compound path keeps its
@@ -3700,7 +3828,7 @@ fn legacy_type_in_a_mask_rendering_mode_clips_its_clipping_group() {
assert!(matches!(inner[0].kind, NodeKind::Path { clipping: true, .. }) && matches!(inner[1].kind, NodeKind::Path { clipping: false, .. }));
}
/// A filled clipping path (`W` then `f`) beside type used as a mask: its painted fill stays at its own path's level,
/// A filled clipping path (`W` then `f`) beside type used as a mask keeps its fill, which paints at its own path's level,
/// as beside another clipping path (the reader's rule for `W` paths). Written after the type, it is painted within the
/// type's mask, since a later mask narrows the clip set by the earlier ones (§5.6.2); written before it, the type doesn't
/// clip it.
@@ -3709,23 +3837,21 @@ fn a_filled_clipping_path_beside_legacy_type_used_as_a_mask() {
let square = format!("1 0 0 Xa\n{}f\n", rect_path(0.0, 0.0, 100.0, 100.0));
let filled = format!("0 0 1 Xa\n{}h\nW\nf\n", rect_path(0.0, 0.0, 30.0, 30.0));
let text = point_type("7 Tr", "Hi");
let is_fill = |n: &Node| matches!(n.kind, NodeKind::Path { clipping: false, .. }) && fill(n).color() == Some(Color::rgb(0.0, 0.0, 1.0));
let is_clip = |n: &Node| matches!(n.kind, NodeKind::Path { clipping: true, .. }) && fill(n).color() == Some(Color::rgb(0.0, 0.0, 1.0));
let is_art = |n: &Node| matches!(n.kind, NodeKind::Path { clipping: false, .. }) && fill(n).color() == Some(Color::rgb(1.0, 0.0, 0.0));
// The type first: the path's fill is in the clipping group the path clips, inside the type's.
// The type first: the filled path clips a clipping group inside the type's.
let doc = legacy_read(&format!("q\n{text}{filled}{square}Q\n")).doc;
let a = art(&doc);
let NodeKind::Group { children, clip: true } = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert!(matches!(children[0].kind, NodeKind::Text(_)) && children.len() == 2, "{children:?}");
let NodeKind::Group { children: inner, clip: true } = &children[1].kind else { panic!("{:?}", children[1].kind) };
assert!(matches!(inner[0].kind, NodeKind::Path { clipping: true, .. }), "{inner:?}");
assert!(is_fill(&inner[1]) && is_art(&inner[2]) && inner.len() == 3, "{inner:?}");
// The path first: its fill is in the outer clipping group, which only the path clips; the type clips the art.
assert!(is_clip(&inner[0]) && is_art(&inner[1]) && inner.len() == 2, "{inner:?}");
// The path first: it clips the outer clipping group, so only the path clips its fill; the type clips the art.
let doc = legacy_read(&format!("q\n{filled}{text}{square}Q\n")).doc;
let a = art(&doc);
let NodeKind::Group { children, clip: true } = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }), "{children:?}");
assert!(is_fill(&children[1]) && children.len() == 3, "{children:?}");
let NodeKind::Group { children: inner, clip: true } = &children[2].kind else { panic!("{:?}", children[2].kind) };
assert!(is_clip(&children[0]) && children.len() == 2, "{children:?}");
let NodeKind::Group { children: inner, clip: true } = &children[1].kind else { panic!("{:?}", children[1].kind) };
assert!(matches!(inner[0].kind, NodeKind::Text(_)) && is_art(&inner[1]) && inner.len() == 2, "{inner:?}");
}
@@ -5206,8 +5332,9 @@ fn artboards_after_what_stops_the_reading() {
#[test]
fn a_compound_clipping_path_with_a_fill_paints_once_under_its_clip() {
// GUARDS: the painted copies of a compound's clipping subpaths went into the compound itself,
// doubling each subpath, so under even-odd the clip cancelled to nothing.
// GUARDS: copies that painted a compound's clipping subpaths went into the compound itself,
// doubling each subpath, so under even-odd the clip cancelled to nothing. The compound
// clipping path keeps the fill itself.
let art_ = format!(
"0 Ae\nq\n{RED}{}f\n1 XR\n*u\n0 0 0 0 0 0 1 Xa\n{}W\nf\n{}W\nf\n*U\nQ\n",
rect_path(0.0, 0.0, 100.0, 100.0),
@@ -5216,14 +5343,13 @@ fn a_compound_clipping_path_with_a_fill_paints_once_under_its_clip() {
);
let doc = read_ok(&layer(&art_)).doc;
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
assert_eq!(children.len(), 3, "{children:#?}");
assert_eq!(children.len(), 2, "{children:#?}");
let NodeKind::Compound { children: clip, rule } = &children[0].kind else { panic!("{:?}", children[0].kind) };
assert_eq!(*rule, vectorcraft_geom::FillRule::EvenOdd);
assert_eq!(clip.len(), 2);
assert!(clip.iter().all(|c| matches!(c.kind, NodeKind::Path { clipping: true, .. })));
let NodeKind::Compound { children: painted, .. } = &children[1].kind else { panic!("{:?}", children[1].kind) };
assert_eq!((painted.len(), fill(&children[1]).color()), (2, Some(Color::rgb(0.0, 0.0, 1.0))));
assert_eq!(fill(&children[2]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
assert_eq!(fill(&children[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
assert_eq!(fill(&children[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
}
#[test]
@@ -8664,13 +8790,31 @@ fn a_multi_layer_mask_in_a_group_or_sublayer() {
#[test]
fn a_multi_layer_masks_path_can_be_painted_or_compound() {
// `W f`: the path also paints, under what the mask covers.
// `W f`: the path also paints, once, under what the mask covers after it: its fill is a path of its own in the first
// layer, and the copies of the mask in each layer have no fill.
let black = Some(Color::cmyk(0.0, 0.0, 0.0, 1.0));
let painted = format!("{}Mb\n0 0 0 1 k\n{}h\nW\nf\nMd\n{}MB\n{END7}", begin7("A", 1), rect_path(40.0, 40.0, 120.0, 120.0), masked(15.0));
let doc = read7(&painted).doc;
assert_eq!(layer_trees(&doc)[0].1, "k[c p p]");
let group = doc.layers[0].children().unwrap()[0].clone();
let kids = group.children().unwrap();
assert_eq!(fill(&kids[1]).color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)), "the painted path first");
assert_eq!(fill(&kids[0]), Paint::None, "the clipping path has no fill");
assert_eq!(fill(&kids[1]).color(), black, "the painted path first");
let two = format!(
"{}Mb\n0 0 0 1 k\n{}h\nW\nf\nMd\n{}{END7}{}{}MB\n{END7}",
begin7("A", 1),
rect_path(40.0, 40.0, 120.0, 120.0),
masked(15.0),
begin7("B", 1),
masked(30.0)
);
let doc = read7(&two).doc;
assert_eq!(layer_trees(&doc), [("A".to_string(), "k[c p p]".to_string(), false), ("B".to_string(), "k[c p]".to_string(), false)]);
let kids = |l: usize| doc.layers[l].children().unwrap()[0].children().unwrap().to_vec();
let (a, b) = (kids(0), kids(1));
assert_eq!((fill(&a[0]), fill(&b[0])), (Paint::None, Paint::None), "no copy of the mask paints");
assert_eq!(fill(&a[1]).color(), black);
assert_eq!(fill(&b[1]).color(), Some(Color::cmyk(0.5, 0.0, 0.0, 0.0)), "layer B's square, unpainted over");
// A compound path of clipping paths.
let compound = format!(
"{}Mb\n*u\n{}h\nW\nn\n{}h\nW\nn\n*U\nMd\n{}MB\n{END7}",
@@ -8680,6 +8824,28 @@ fn a_multi_layer_masks_path_can_be_painted_or_compound() {
masked(15.0)
);
assert_eq!(layer_trees(&read7(&compound).doc)[0].1, "k[o p]");
// A filled one over two layers: its fill is a compound path of its own in the first, whose paths don't clip.
let filled = format!(
"{}Mb\n0 0 0 1 k\n*u\n{}h\nW\nf\n{}h\nW\nf\n*U\nMd\n{}{END7}{}{}MB\n{END7}",
begin7("A", 1),
rect_path(40.0, 40.0, 80.0, 80.0),
rect_path(90.0, 90.0, 120.0, 120.0),
masked(15.0),
begin7("B", 1),
masked(30.0)
);
let doc = read7(&filled).doc;
assert_eq!(layer_trees(&doc), [("A".to_string(), "k[o o p]".to_string(), false), ("B".to_string(), "k[o p]".to_string(), false)]);
let kids = |l: usize| doc.layers[l].children().unwrap()[0].children().unwrap().to_vec();
let (a, b) = (kids(0), kids(1));
assert_eq!((fill(&a[0]), fill(&b[0])), (Paint::None, Paint::None), "no copy of the mask paints");
assert_eq!(fill(&a[1]).color(), black);
let NodeKind::Compound { children: subpaths, .. } = &a[1].kind else { panic!("{:?}", a[1].kind) };
assert!(subpaths.iter().all(|p| matches!(p.kind, NodeKind::Path { clipping: false, .. })), "{subpaths:#?}");
let mut ids = std::collections::HashSet::new();
for l in &doc.layers {
l.walk(&mut |n| assert!(ids.insert(n.id), "{:?} appears twice", n.id));
}
}
/// `Mv` starts a mask as `Mb` does, `ME` defines one as `Md` does, and `Mi` changes nothing.
@@ -8762,4 +8928,8 @@ fn copies_of_a_multi_layer_mask_count_toward_the_limit() {
let two = format!("{}{mask}{}{END7}{}{}MB\n{END7}", begin7("A", 1), masked(15.0), begin7("B", 1), masked(30.0));
let e = read(mask_stream7(&two).as_bytes()).unwrap_err();
assert!(e.contains("objects"), "{e}");
// A filled mask's fill, a path of its own, counts too.
let filled = format!("{}{}{}MB\n{END7}", begin7("A", 1), mask.replace("W\nn\n", "W\nf\n"), masked(15.0));
let e = read(mask_stream7(&filled).as_bytes()).unwrap_err();
assert!(e.contains("objects"), "{e}");
}
+4 -3
View File
@@ -177,11 +177,12 @@ fn sublayers_groups_compounds_clip_groups_and_hidden_objects_come_in() {
let page = [10, 20, 30, 80].map(|x| page_square(x, 10)).join("\n") + "\n" + clip_fill;
let r = import(&eps(&page, &art)).unwrap();
let outer = &r.document.layers[0];
assert_eq!(kinds(outer), ["group[1]", "compound[2]", "clip[3]", "group[1]", "other"]);
assert_eq!(kinds(outer), ["group[1]", "compound[2]", "clip[2]", "group[1]", "other"]);
let children = outer.children().unwrap();
assert!(children[0].visible && !children[3].visible, "the hidden object is hidden, not left out");
// The clip path comes first in its group.
assert!(matches!(children[2].children().unwrap()[0].kind, NodeKind::Path { clipping: true, .. }));
// The clip path comes first in its group, with its fill.
let clip = &children[2].children().unwrap()[0];
assert!(matches!(clip.kind, NodeKind::Path { clipping: true, .. }) && clip.clip_paint().fill.is_some(), "{clip:?}");
// The sublayer is a layer of the layer.
assert!(matches!(&children[4].kind, NodeKind::Layer { .. }));
assert_eq!(children[4].name.as_deref(), Some("Inner"));
File diff suppressed because one or more lines are too long