From 68f6f8755ba75002573475e79feb3947c708cc44 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 02:22:18 -0800 Subject: [PATCH] .ai import: several clipping paths clip to their intersection In `.ai` and EPS editing-data import, a clipping group, layer or opacity mask's art that holds more than one clipping path (`W`) now clips to the intersection of the paths, as each `W` intersects the current clip (AI7 spec section 5.6.2, https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf). Before, the paths were joined into one compound path, which clipped to their union, and kept a stroke only when every path had the same one. Each clipping path after the first clips a clipping group nested inside the one before it, and keeps its own stroke, opacity and blend mode. The innermost clipping group holds the art in the order written; as before, every object in the group is clipped by all of its clipping paths. A clipping path's painted fill stays at its path's level. A compound path of clipping paths (`*u` ... `*U`) is still one clipping path. Clipping paths that would nest the art past the document's 32 levels stop the editing data from being read. --- crates/engine/tests/import_fuzz.rs | 6 + crates/eps/src/import/ai/read/containers.rs | 92 +++++++++++++- crates/eps/src/import/ai/tests.rs | 128 ++++++++++++++++++-- docs/target-app-parity.md | 1 + 4 files changed, 215 insertions(+), 12 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index f86875c3e..44bc4eff0 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3892,6 +3892,12 @@ fn arb_ai_token() -> impl Strategy { "*U", "q", "Q", + // Several clipping paths in one clipping group or layer clip to their intersection (AI7 spec §5.6.2), each + // nesting a clipping group. + "0 0 m 9 0 L 9 9 L h W n 1 1 m 8 1 L 8 8 L h W f", + "q 0 0 m 9 0 L 9 9 L h W n 1 1 m 8 1 L 8 8 L h W n 0 0 m 5 5 L 0 5 L f Q", + "u 0 0 m 9 0 L 9 9 L h W n 1 1 m 8 1 L 8 8 L h W n U", + "q 0 0 m 9 0 L 9 9 L h W n 0 0 m 9 0 L 9 9 L h W n 0 0 m 9 0 L 9 9 L h W n 0 0 m 9 0 L 9 9 L h W n 0 0 m 9 0 L 9 9 L h W n 0 0 m 9 0 L 9 9 L h W n 0 0 m 9 0 L 9 9 L h W n 0 0 m 9 0 L 9 9 L h W n", "LB", "Lb", "Ln", diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 7ddd62323..5c58988bd 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -133,7 +133,7 @@ impl<'a> Reader<'a> { // Some files mark every group so, the plain one holding the subpaths too (#980): one // paint for all its paths keeps it one compound path, its holes cut. Kind::Compound if kids.iter().any(|k| self.looks.contains(&k.id) && paints_apart(k)) => { - let (children, clip) = self.clip_first(kids, &clips); + let (children, clip) = self.clip_first(kids, &clips, true, self.nesting() + 1)?; self.new_node(NodeKind::Group { children, clip }, &gs) } Kind::Compound => { @@ -153,7 +153,7 @@ impl<'a> Reader<'a> { } // A group with a clipping path (`q` … `Q`, or a `W` path in a group) is a clipping group. _ => { - let (children, clip) = self.clip_first(kids, &clips); + let (children, clip) = self.clip_first(kids, &clips, false, self.nesting() + 1)?; self.new_node(NodeKind::Group { children, clip }, &gs) } }; @@ -200,9 +200,76 @@ impl<'a> Reader<'a> { Some(n) } + /// How deep in the document the objects the open frames hold are, less one: a dictionary adds no level, and an + /// opacity mask's art is two under the frames around the object it masks. + fn nesting(&self) -> usize { + self.frames + .iter() + .map(|f| match f.kind { + Kind::Obj(_) => 0, + Kind::Mask => 2, + _ => 1, + }) + .sum() + } + + /// `kids` with the clipping paths among them (indexes `clips`) first → the children and whether they clip. + /// + /// 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. + fn clip_first(&mut self, kids: Vec, clips: &[usize], union: bool, at: usize) -> Result<(Vec>, bool), String> { + if union || clips.len() < 2 { + return Ok(self.clip_union(kids, clips)); + } + let clips: std::collections::HashSet = clips.iter().copied().collect(); + let (clip, rest): (Vec, Vec) = 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. + let level = |i: usize, n: &Node| { + if self.clip_fills.contains(&n.id) { clip.iter().rposition(|(c, _)| *c < i).unwrap_or(0) } else { innermost } + }; + // Each nested clipping group adds a level over the nodes placed in it, the clipping paths included. Past the + // reader's limit, or past the document's when the nodes fit in it without those levels (the document would merge + // the deepest groups and drop their clipping paths), the editing data isn't read. + let deepest = + rest.iter().map(|(i, n)| level(*i, n) + depth(n)).chain(clip.iter().enumerate().map(|(k, (_, n))| k + depth(n))).max().unwrap_or(0); + let flat = rest.iter().map(|(_, n)| depth(n)).chain(clip.iter().map(|(_, n)| depth(n))).max().unwrap_or(0); + 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. + let mut fills_at: Vec> = clip.iter().map(|_| Vec::new()).collect(); + let mut art_at: Vec> = clip.iter().map(|_| Vec::new()).collect(); + for (i, n) in rest { + let k = level(i, &n); + let at = if self.clip_fills.contains(&n.id) { &mut fills_at } else { &mut art_at }; + if let Some(v) = at.get_mut(k) { + v.push(n); + } + } + let mut children: Vec> = Vec::new(); + let mut levels = clip.into_iter().map(|(_, n)| n).zip(fills_at.into_iter().zip(art_at)).rev().peekable(); + while let Some((path, (fills, art))) = levels.next() { + let mut inner: Vec> = Vec::with_capacity(fills.len() + art.len() + children.len() + 1); + inner.push(Arc::new(path)); + inner.extend(fills.into_iter().chain(art).map(Arc::new)); + inner.append(&mut children); + if levels.peek().is_none() { + return Ok((inner, true)); + } + children = vec![Arc::new(Node::new(self.doc.alloc_id(), NodeKind::Group { children: inner, clip: true }))]; + } + Ok((children, false)) + } + /// `kids` with the clipping paths among them (indexes `clips`) first, several as one compound /// path → the children and whether they clip. - fn clip_first(&mut self, kids: Vec, clips: &[usize]) -> (Vec>, bool) { + fn clip_union(&mut self, kids: Vec, clips: &[usize]) -> (Vec>, bool) { let clips: std::collections::HashSet = clips.iter().copied().collect(); let (mut clip, rest): (Vec, Vec) = kids.into_iter().enumerate().partition(|(i, _)| clips.contains(i)); let clip_node = match clip.len() { @@ -245,7 +312,7 @@ impl<'a> Reader<'a> { (mesh.opacity, mesh.blend, mesh.isolate, mesh.knockout) = before; } let Kind::Layer(a) = f.kind else { return Ok(()) }; - let (children, clip) = self.clip_first(f.children, &f.clips); + let (children, clip) = self.clip_first(f.children, &f.clips, false, self.nesting() + 1)?; let color = a.color.unwrap_or(LayerColor::Preset(0)); let mut layer = Node::new( self.doc.alloc_id(), @@ -307,7 +374,8 @@ impl<'a> Reader<'a> { pub(super) fn end_mask(&mut self) -> Result<(), String> { self.end_path_quietly(); let f = self.close("X+", |k| matches!(k, Kind::Mask))?; - let (children, clip) = self.clip_first(f.children, &f.clips); + // The mask's art is one level under the object it masks. + let (children, clip) = self.clip_first(f.children, &f.clips, false, self.nesting() + 2)?; let art = self.new_node(NodeKind::Group { children, clip }, &f.gs); self.gs = f.gs; self.pending_unread_fill = f.pending_unread_fill; @@ -732,6 +800,20 @@ pub(super) fn close(bp: &mut BezPath) { } } +/// How many levels of nodes `n` holds, itself included, its opacity masks' art included. +fn depth(n: &Node) -> usize { + let mut deepest = 0; + let mut todo = vec![(n, 1)]; + while let Some((n, d)) = todo.pop() { + deepest = deepest.max(d); + todo.extend(n.children().into_iter().flatten().map(|c| (c.as_ref(), d + 1))); + if let Some(m) = n.extras().mask.as_ref() { + todo.push((m.art.as_ref(), d + 1)); + } + } + deepest +} + /// The fill rule of a path or compound node. /// `kids` with the members of each plain group among them in the group's place, at any depth. fn ungrouped(kids: Vec) -> Vec { diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 3fa9abc9d..5203b1695 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1060,18 +1060,132 @@ fn a_clipping_paths_stroke_stays_on_the_clipping_path() { children(&read_ok(&format!("{defs}{}", layer(&gradient))).doc), [(true, None, false, Some((blue, 3.0))), (false, None, true, None), (false, red, false, None)] ); - // Several clipping paths clip as one compound path, which keeps their stroke when they all have the same one. + // 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); - let compound_stroke = |first: &str, second: &str| { + let strokes = |first: &str, second: &str| { let body = format!("0 Ae\nq\n{RED}{under}f\n0 0 1 0 0 0 1 XA\n3 w\n{first}{clip}W\nS\n{second}{inner}W\nS\nQ\n"); let doc = read_ok(&layer(&body)).doc; let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("{:?}", art(&doc)[0].kind) }; - assert!(matches!(children[0].kind, NodeKind::Compound { .. }), "{:?}", children[0].kind); - (children[0].appearance.stroke().map(|s| (s.paint.color(), s.width)), children[0].opacity, children[0].blend) + let NodeKind::Group { children: nested, clip: true } = &children[1].kind else { panic!("{:?}", children[1].kind) }; + [&children[0], &nested[0]].map(|c| { + assert!(matches!(c.kind, NodeKind::Path { clipping: true, .. }), "{:?}", c.kind); + (c.appearance.stroke().map(|s| (s.paint.color(), s.width)), c.opacity, c.blend) + }) }; - assert_eq!(compound_stroke("", ""), (Some((blue, 3.0)), 1.0, BlendMode::Normal)); - assert_eq!(compound_stroke("9 0.5 0 0 0 Xy\n", ""), (Some((blue, 3.0)), 0.5, BlendMode::Lighten)); - assert_eq!(compound_stroke("", "5 w\n"), (None, 1.0, BlendMode::Normal), "strokes that differ"); + let plain = (Some((blue, 3.0)), 1.0, BlendMode::Normal); + assert_eq!(strokes("", ""), [plain, plain]); + assert_eq!(strokes("", "9 0.5 0 0 0 Xy\n"), [plain, (Some((blue, 3.0)), 0.5, BlendMode::Lighten)]); + assert_eq!(strokes("", "5 w\n"), [plain, (Some((blue, 5.0)), 1.0, BlendMode::Normal)], "strokes that differ"); +} + +/// 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. +#[test] +fn several_clipping_paths_clip_to_their_intersection() { + let square = rect_path(0.0, 0.0, 80.0, 80.0); + let (a, b) = (rect_path(20.0, 20.0, 60.0, 60.0), rect_path(25.0, 25.0, 70.0, 55.0)); + // (clipping, fill color, bounds) of a clipping group's children, with a nested clipping group's children listed + // after it. + type Level = Vec<(bool, Option, Option)>; + fn levels(children: &[Arc]) -> Vec { + let mut out = vec![]; + let mut at = children; + loop { + out.push(at.iter().map(|c| (matches!(c.kind, NodeKind::Path { clipping: true, .. }), fill(c).color(), c.geometric_bounds())).collect()); + let Some(NodeKind::Group { children, clip: true }) = at.last().map(|c| &c.kind) else { return out }; + at = children; + } + } + let red = Some(Color::rgb(1.0, 0.0, 0.0)); + let blue = Some(Color::rgb(0.0, 0.0, 1.0)); + // A rectangle as written, in the document's coordinates (y down from the 100 pt high page's top). + let r = |x0, y0, x1, y1| Some(Rect::new(x0, 100.0 - y1, x1, 100.0 - y0)); + let (ra, rb) = (r(20.0, 20.0, 60.0, 60.0), r(25.0, 25.0, 70.0, 55.0)); + let group = |body: &str| { + let doc = read_ok(&layer(body)).doc; + let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("{:?}", art(&doc)[0].kind) }; + levels(children) + }; + // The art after both clipping paths, in a clipping group of the second inside the first's. + let two = group(&format!("0 Ae\nq\n{a}h\nW\nn\n{b}h\nW\nn\n{RED}{square}f\nQ\n")); + 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. + 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[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. + let small = rect_path(0.0, 0.0, 10.0, 10.0); + let before = group(&format!("0 Ae\nq\n{RED}{small}f\n{a}h\nW\nn\n{b}h\nW\nn\n0 0 1 0 0 0 1 Xa\n{square}f\nQ\n")); + assert_eq!(before.len(), 2, "{before:?}"); + assert_eq!(before[1], [(true, None, rb), (false, red, r(0.0, 0.0, 10.0, 10.0)), (false, blue, r(0.0, 0.0, 80.0, 80.0))]); + let between = group(&format!("0 Ae\nq\n{RED}{small}f\n{a}h\nW\nn\n0 0 1 0 0 0 1 Xa\n{square}f\n{b}h\nW\nn\n{RED}{square}f\nQ\n")); + assert_eq!((between.len(), between[0].len()), (2, 2), "{between:?}"); + assert_eq!( + between[1], + [(true, None, rb), (false, red, r(0.0, 0.0, 10.0, 10.0)), (false, blue, r(0.0, 0.0, 80.0, 80.0)), (false, red, r(0.0, 0.0, 80.0, 80.0))] + ); + // A compound clipping path and a clipping path after it: their union, then that intersected with the second. + let c = rect_path(30.0, 10.0, 50.0, 90.0); + let mixed = format!("0 Ae\nq\n*u\n{a}h\nW\nn\n{b}h\nW\nn\n*U\n{c}h\nW\nn\n{RED}{square}f\nQ\n"); + let doc = read_ok(&layer(&mixed)).doc; + let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("{:?}", art(&doc)[0].kind) }; + assert!(matches!(children[0].kind, NodeKind::Compound { .. }) && children.len() == 2, "{children:?}"); + assert_eq!(levels(children)[1], [(true, None, r(30.0, 10.0, 50.0, 90.0)), (false, red, r(0.0, 0.0, 80.0, 80.0))]); + // Clipping paths alone in a group (`u W n W n U`) clip the clipping group around it the same way. + let grouped = group(&format!("0 Ae\nq\n{RED}{square}f\n0 Ae\nu\n{a}h\nW\nn\n{b}h\nW\nn\nU\nQ\n")); + assert_eq!(grouped.len(), 2, "{grouped:?}"); + assert_eq!(grouped[1], [(true, None, rb), (false, red, r(0.0, 0.0, 80.0, 80.0))]); + // A compound path of clipping paths is still one clipping path, their union. + let compound = format!("0 Ae\nq\n*u\n{a}h\nW\nn\n{b}h\nW\nn\n*U\n{RED}{square}f\nQ\n"); + let doc = read_ok(&layer(&compound)).doc; + let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("{:?}", art(&doc)[0].kind) }; + assert!(matches!(children[0].kind, NodeKind::Compound { .. }) && children.len() == 2, "{children:?}"); + // Two clipping paths in a layer: the layer clips by the first, a clipping group in it by the second. + let doc = read_ok(&layer(&format!("{a}h\nW\nn\n{b}h\nW\nn\n{RED}{square}f\n"))).doc; + let NodeKind::Layer { clip: true, children, .. } = &doc.layers[0].kind else { panic!("{:?}", doc.layers[0].kind) }; + let in_layer = levels(children); + assert_eq!(in_layer.len(), 2, "{in_layer:?}"); + assert_eq!(in_layer[1], [(true, None, rb), (false, red, r(0.0, 0.0, 80.0, 80.0))]); + // Two clipping paths in an opacity mask's art: the art clips by the first, a clipping group in it by the second. + let mask_art: String = format!("{a}h\nW\nn\n{b}h\nW\nn\n1 g\n{square}f\n").lines().map(|l| format!("%_{l}\n")).collect(); + let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n"); + let doc = read_ok(&layer(&format!("{RED}{square}f\n{mask}"))).doc; + let m = art(&doc)[0].extras().mask.clone().unwrap(); + let NodeKind::Group { children, clip: true } = &m.art.kind else { panic!("{:?}", m.art.kind) }; + let in_mask = levels(children); + assert_eq!(in_mask.len(), 2, "{in_mask:?}"); + assert_eq!((in_mask[0][0].0, in_mask[0][0].2, in_mask[1][0]), (true, ra, (true, None, rb))); + assert_eq!(in_mask[1].len(), 2, "{in_mask:?}"); + // Clipping paths are nested as far as the document's depth limit allows; past it they are refused rather than + // merged away, in a clipping group, a layer and an opacity mask's art. + let fits = |text: &dyn Fn(usize) -> String| (2..400).take_while(|n| read(stream(&layer(&text(*n))).as_bytes()).is_ok()).last().unwrap(); + let deep = |n: usize, last: &str| format!("0 Ae\nq\n{}{last}{RED}{square}f\nQ\n", format!("{a}h\nW\nn\n").repeat(n)); + // The layer is 1 deep, the clipping group 2, and the art in the innermost of `n` levels `n + 2`. + let in_group = fits(&|n| deep(n, "")); + assert_eq!(in_group, vectorcraft_doc::MAX_DEPTH - 2); + let r = read_ok(&layer(&deep(in_group, ""))); + let NodeKind::Group { children, clip: true } = &art(&r.doc)[0].kind else { panic!("{:?}", art(&r.doc)[0].kind) }; + assert_eq!(levels(children).len(), in_group, "{:?}", r.warnings); + assert!(read(stream(&layer(&deep(400, ""))).as_bytes()).unwrap_err().contains("nested too deeply")); + let in_layer = fits(&|n| format!("{}{RED}{square}f\n", format!("{a}h\nW\nn\n").repeat(n))); + assert_eq!(in_layer, vectorcraft_doc::MAX_DEPTH - 1); + let in_mask = fits(&|n| { + let art: String = format!("{}1 g\n{square}f\n", format!("{a}h\nW\nn\n").repeat(n)).lines().map(|l| format!("%_{l}\n")).collect(); + format!("{RED}{square}f\n%_/ArtDictionary :\n%_/Mask :\n%_X=\n{art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n") + }); + // The masked object is 2 deep and its mask's art 3. + assert_eq!(in_mask, vectorcraft_doc::MAX_DEPTH - 3); + // A compound clipping path holds its subpaths one level down: as the innermost one, it adds a level. + let compound_last = format!("*u\n{a}h\nW\nn\n{b}h\nW\nn\n*U\n"); + assert!(read(stream(&layer(&deep(in_group - 1, &compound_last))).as_bytes()).unwrap_err().contains("nested too deeply")); } /// A clipping group's clipping path can sit alone in a group of its own, after the art it clips (`q … u W n U diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e467f2669..584a83d3a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1203,6 +1203,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). In a `.ai` or EPS opened from its editing data, and in a legacy `.ai`, each anchor is smooth or a corner as the case of the segment operator ending on it says (`l`, `c`, `v`, `y` smooth; `L`, `C`, `V`, `Y` corner), and a closed path's first anchor takes the case of the segment back to it; points and handles stay where they are. Paths, compound paths, clipping paths, guide paths, area type frames and type-on-a-path paths all keep it (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/legacy_ai/tests.rs`). + In `.ai` and EPS editing-data import, a clipping group, layer or opacity mask's art with several clipping paths (`W`) clips to their intersection: each clipping path after the first clips a clipping group nested inside the one before it, and each keeps its own stroke and transparency. A compound path of clipping paths (`*u` … `*U`) is still one clipping path, and clipping paths that would nest the art past the document's 32 levels stop the editing data from being read (`crates/eps/src/import/ai/tests.rs`). `.ai` and EPS editing-data import keeps template layers, and opens canvas and artboard ruler guides as ruler guides on their layers; guide objects stay editable guide paths (#779; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). `.ai` and EPS editing-data import keeps each path's Attributes panel note (`%AI3_Note:`, `%%Note:`, more lines in `%AI5_NoteMore:`, up to 1,024 characters) and Show Center (`1 Ap`); a note or Show Center applies to every path after it until the next one, and a note before a group or compound path is not theirs (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`). `.ai` and EPS editing-data import reads object tags (`/identifier (string) XT`) on the object before them, whatever kind of object it is: `AdobeURL` becomes the object's URL, and every other tag is kept in the object's data in order, up to 64 per object; a tag longer than 64 KiB, and tags in a pattern's or brush's art, are ignored. A tag on art that shows no longer stops the editing data from being read, and brush art that holds tags is read (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`).