From 1946ffca9ca9ff3ca110141b83db4d409b37c7fa Mon Sep 17 00:00:00 2001 From: jmverges Date: Fri, 16 Oct 2026 13:03:46 +0000 Subject: [PATCH] #2405: an envelope's mesh in an .ai isn't read as art An envelope is written on `%_` lines as a group holding the mesh that shapes it, with the distortion (`AIDeformStyle`, `DeformValue`) in the group's dictionary; the distorted art the PDF part draws follows on plain lines. The reader kept that group, so the mesh drew as a grey gradient mesh behind the art. A group on `%_` lines that holds only meshes and carries a distortion is now dropped; a mesh with no distortion is still art. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6znVFRqGqvB9f7njwZ46r --- crates/eps/src/import/ai/read.rs | 29 ++++++++++++++++++++++ crates/eps/src/import/ai/tests.rs | 40 +++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index cc8fdf3a0..790fee3a2 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -1434,6 +1434,29 @@ impl<'a> Reader<'a> { } } + /// Drop the object just read when it is an envelope's own mesh: a group on `%_` lines holding + /// only gradient meshes. Whether it was dropped. + fn drop_envelope_mesh(&mut self) -> bool { + fn only_meshes(n: &Node) -> bool { + match &n.kind { + NodeKind::Mesh(_) => true, + NodeKind::Group { children, .. } => !children.is_empty() && children.iter().all(|c| only_meshes(c)), + _ => false, + } + } + let Some(n) = self.last_object() else { return false }; + let id = n.id; + if !matches!(n.kind, NodeKind::Group { .. }) || !only_meshes(n) || !self.hidden_ids.contains(&id) { + return false; + } + if let Some(f) = self.frames.last_mut() { + f.children.pop(); + } + self.hidden_ids.remove(&id); + self.after_object = false; + true + } + /// Do the layers being read show? fn shown(&self) -> bool { self.frames.iter().all(|f| !matches!(&f.kind, Kind::Layer(a) if !a.visible)) @@ -1442,6 +1465,12 @@ impl<'a> Reader<'a> { /// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the /// opacity mask it may carry. fn art_dictionary(&mut self, o: &Obj) { + // An envelope (a distortion, `AIDeformStyle` / `DeformValue`) is written on `%_` lines as + // a group holding the mesh that shapes it; the distorted art the file's PDF part draws + // follows on plain lines. That mesh isn't art, so it goes (#2405). + if (o.get("AIDeformStyle").is_some() || o.get("DeformValue").is_some()) && self.drop_envelope_mesh() { + return; + } if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask")) { if let Some(art) = &mask.art { let on = |key: &str| mask.nums(key).first().copied() == Some(1.0); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 0e5bbe3bb..d8bd3d794 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1226,6 +1226,46 @@ fn a_styled_object_keeps_the_transparency_written_on_its_look() { assert_eq!((a[0].opacity, a[0].blend), (0.6, BlendMode::Multiply)); } +/// An envelope is written on `%_` lines as a group holding the mesh that shapes it, with its +/// distortion in the group's dictionary; the distorted art follows on plain lines. Only that art +/// is drawn, as in the file's PDF part, not the envelope's mesh (#2405). +#[test] +fn an_envelope_mesh_is_not_read_as_art() { + let corner = |x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| { + format!("%_[0.82 0.83 0.83 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1) + }; + let mesh = format!( + "%_/Mesh X!\n%_(DeviceRGB) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n%_/End X!\n", + corner(0, 0, (0, 10), (10, 0)), + corner(0, 40, (10, 40), (0, 30)), + corner(40, 40, (40, 30), (30, 40)), + corner(40, 0, (30, 0), (40, 10)), + ); + let envelope = |dictionary: &str| { + format!( + "%_0 Ae\n%_u\n{mesh}%_0 0 Xd\n%_6 () XW\n%_U\n%_/ArtDictionary :\n{dictionary}%_;\n%_\n%_0 0 Xd\n%_6 () XW\n{RED}{}f\n", + rect_path(0.0, 0.0, 40.0, 40.0) + ) + }; + let has_mesh = |n: &Node| -> bool { + fn walk(n: &Node) -> bool { + matches!(n.kind, NodeKind::Mesh(_)) || n.children().is_some_and(|c| c.iter().any(|k| walk(k))) + } + walk(n) + }; + + let warped = envelope("%_(Warp: Arch) /UnicodeString (DisplayString) ,\n%_0.27 /Real (DeformValue) ,\n%_1 /Int (AIDeformStyle) ,\n"); + let doc = read_ok(&layer(&warped)).doc; + let a = art(&doc); + assert!(!a.iter().any(|n| has_mesh(n)), "{a:#?}"); + assert_eq!(a.len(), 1, "{a:#?}"); + assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); + + // A mesh in a group with no distortion is still art. + let doc = read_ok(&layer(&envelope(""))).doc; + assert!(art(&doc).iter().any(|n| has_mesh(n)), "{:#?}", art(&doc)); +} + #[test] fn a_gradient_mesh_reads_as_a_patch() { let corner = |rgb: &str, x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| {