From a1fc3cfdb1e28d43bcebfa8a422d7583f8ed62ee Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:42:55 +0300 Subject: [PATCH] fix(eps): a masked gradient mesh takes the transparency after its mask A gradient mesh with an opacity mask has its own state (`Xy`) after the mask's dictionary, not right after its `/End X!`. The operators of the mask's art, on `%_` lines, cleared the flag that says a mesh just ended, so the state was read as the next object's and each masked mesh in a run drew with the blend mode of the one before it. The flag now goes back to what it was when a dictionary opened once it closes, as `after_object` does. --- crates/eps/src/import/ai/read.rs | 3 ++- crates/eps/src/import/ai/read/containers.rs | 8 ++++++-- crates/eps/src/import/ai/read/types.rs | 2 ++ crates/eps/src/import/ai/tests.rs | 22 +++++++++++++++++++++ docs/file-format-parity.md | 3 ++- 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index b3050abea..773087e3a 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -183,7 +183,8 @@ struct Reader<'a> { blend_budget: usize, /// A group just ended, its state (`Xy`, `Xd`, `XW`) not yet read. group_ended: bool, - /// A gradient mesh just ended (`/End X!`), its own state (`Xy`) not yet read. + /// A gradient mesh just ended (`/End X!`), its own state (`Xy`) not yet read: also after its + /// dictionary (its name, its opacity mask and the mask's art). mesh_ended: bool, /// A layer's own state (`Xy` after its art, before `LB`), until something says the art moves on. layer_state: Option<(f32, BlendMode, bool, Knockout)>, diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 19239fa87..0c6a6a6b0 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -19,6 +19,7 @@ impl<'a> Reader<'a> { children: Vec::new(), hidden, after: self.after_object, + mesh_ended: self.mesh_ended, gs: self.gs.clone(), base, clips: Vec::new(), @@ -243,6 +244,7 @@ impl<'a> Reader<'a> { let art = self.new_node(NodeKind::Group { children, clip }, &f.gs); self.gs = f.gs; self.after_object = f.after; + self.mesh_ended = f.mesh_ended; let Some(Frame { kind: Kind::Obj(o), .. }) = self.frames.last_mut() else { return Err("mask art ends outside a mask dictionary".into()); }; @@ -258,13 +260,15 @@ impl<'a> Reader<'a> { return Ok(()); } let Some(f) = self.frames.pop() else { return Ok(()) }; - let Frame { kind, children, base, hidden, gs, after, .. } = f; + let Frame { kind, children, base, hidden, gs, after, mesh_ended, .. } = f; let Kind::Obj(mut o) = kind else { return Ok(()) }; obj::close(&mut o, &mut self.stack, base); self.obj_hidden = hidden; - // Art made inside a dictionary (type's paths) isn't kept. + // Art made inside a dictionary (type's paths, a mask's art) isn't kept, and doesn't end what + // follows the object the dictionary is about: a mesh's state comes after its mask. self.gs = gs; self.after_object = after; + self.mesh_ended = mesh_ended; if matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { return self.push(V::Obj(Rc::new(*o))); } diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index fccbc6c48..481a5670a 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -142,6 +142,8 @@ pub(super) struct Frame { pub(super) hidden: bool, /// [`Reader::after_object`] when it opened. pub(super) after: bool, + /// [`Reader::mesh_ended`] when it opened. + pub(super) mesh_ended: bool, /// The state when it opened (a container's lock and visibility; a dictionary's state to go /// back to). pub(super) gs: GState, diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 1320aac92..cdc096b40 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -486,6 +486,28 @@ fn a_gradient_mesh_takes_the_transparency_written_after_it() { assert_eq!((a[2].opacity, a[2].blend), (0.7, BlendMode::Screen), "a state after the mesh's `Xd` and `XW` is the next object's"); } +#[test] +fn a_masked_gradient_mesh_takes_the_transparency_written_after_its_mask() { + // GUARDS: a mesh with an opacity mask has its state after the mask's dictionary, whose art (on + // `%_` lines) has operators of its own; read as the next object's, each masked mesh in a run + // drew with the blend mode of the one before it. + let corners: String = ["10 20 10 25 1 15 20 1", "10 50 15 50 1 10 45 1", "40 50 40 45 1 35 50 1", "40 20 35 20 1 40 25 1"] + .iter() + .map(|g| format!("%_[0.5 1 {g}] /N X#\n")) + .collect(); + let mesh = format!("/Mesh X!\n%_/DeviceGray /CS X#\n{corners}%_/E X#\n/End X!\n"); + let mask = "%_/ArtDictionary :\n%_/Mask :\n%_X=\n%_u\n%_1 g\n%_10 20 m\n%_10 50 L\n%_40 50 L\n%_40 20 L\n%_f\n%_U\n%_X+\n\ + %_(Art) ,\n%_1 /Bool (Clipping) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n"; + let next = format!("0 1 0 0 0 Xy\n{RED}{}f\n", rect_path(60.0, 20.0, 80.0, 40.0)); + for (state, expected) in [("1 0.5 0 0 0 Xy\n", (0.5, BlendMode::Multiply)), ("", (1.0, BlendMode::Overlay))] { + let a = art(&read_ok(&layer(&format!("3 1 0 0 0 Xy\n{mesh}{mask}{state}0 0 Xd\n6 () XW\n{next}"))).doc); + assert_eq!(a.len(), 2, "{a:?}"); + assert!(matches!(a[0].kind, NodeKind::Mesh(_)) && a[0].extras().mask.is_some(), "{:?}", a[0]); + assert_eq!((a[0].opacity, a[0].blend), expected, "{state:?}: the state after the mask is the mesh's"); + assert_eq!((a[1].opacity, a[1].blend), (1.0, BlendMode::Normal), "the next object's own state"); + } +} + #[test] fn gradients() { let defs = "%AI5_BeginGradient: (G)\n(G) 1 2 Bd\n[\n<00FF>\n0\n0 0 0 0 1 0 0 2 1 6 %_BS\n\ diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7542f27fe..1f6f152af 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai`/EPS editing data: a layer's state block with the stroke style in it keeps the layer's opacity) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai`/EPS editing data: a gradient mesh with an opacity mask takes the transparency written after the mask) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | `.ai`/EPS editing data: a gradient mesh with an opacity mask takes the transparency (`Xy`) written after the mask's dictionary, as one without a mask takes the one right after its `/End X!`; the operators of the mask's art (on `%_` lines) hid it, so the state was read as the next object's and each masked mesh in a run drew with the blend mode of the one before it (a Multiply shading drawn Normal, an Overlay tint drawn Multiply over the art below). Test `import::ai::tests::a_masked_gradient_mesh_takes_the_transparency_written_after_its_mask`; checked locally against a user's files: one opens from its layers 0.1% from its page (it was 0.6%, just inside the limit), another comes 1.6% → 1.3% nearer; scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: a layer's own state block (`Xy`, `Xd`, `7 () XW` before `LB`) can have the stroke style (`J`, `j`, `w`, `M`, `d`, `XR`) between its `Xy` and `Xd`; read as the art moving on, it lost the layer's opacity, so a layer at 17% drew opaque over the art under it and the file came in as its page. When the layer's last object is a gradient mesh, the mesh keeps its own state through the stroke style too (#4748). An object's own state block (`6 () XW`) still isn't the layer's. Of 408 users' files whose editing data was checked, 3 have such a block: an EPS that now opens from its layers 0.5% from its page (it was 18.8%), one whose layer now draws at 85% as its page does, and one whose layers are all at 100%; the others draw as before. Test `import::ai::tests::a_layers_state_keeps_through_the_stroke_style_written_in_it`; scores unchanged | | 2026-10-30 | minor | EPS and `.ai` import: editing data whose type is in the legacy format (`To` … `TO`, as an EPS saved for Illustrator 10 keeps it, with no `/AI11Text` for the page's type to go in) is read through its layers when the page has text: its text objects are the layers' own, and the comparison with the page says whether the layers draw it. Before, such a file came in as its page ("its layers have no text objects to put it in"). Type the editing data keeps only as outlines (what the legacy format can't hold) stays outlines, with a note counting the page's text objects that are; a file whose layers have no text object at all still comes in as its page. Test `import::tests_native::type_in_the_legacy_format_is_the_layers_own_and_the_page_has_text`; checked locally against a user's files (of 6 such EPS that came in as their page, 3 now come in through their layers, within the comparison's limits; 1 is still refused by the comparison for art that isn't type, and 2 have their type only as outlines; no change in the other 284 EPS with editing data and 117 `.ai` files checked); scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: an `Xy` right after a gradient mesh's `/End X!` is the mesh's only when its own `6 () XW` follows; when the layer's `LB` follows (directly or after the layer's `Xd` and `7 () XW`) it is the layer's, as before #4744, and the mesh keeps the state it had. Test `import::ai::tests::a_layer_keeps_its_own_transparency`; scores unchanged |