diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 8d7093398..e13f97d0c 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -24,6 +24,7 @@ impl<'a> Reader<'a> { base, clips: Vec::new(), compound_members: false, + state: None, }); self.after_object = false; Ok(()) @@ -260,13 +261,16 @@ impl<'a> Reader<'a> { return Ok(()); } let Some(f) = self.frames.pop() else { return Ok(()) }; - let Frame { kind, children, base, hidden, gs, after, mesh_ended, .. } = f; + let Frame { kind, children, base, hidden, gs, after, mesh_ended, state, .. } = 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, 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; + if let Some(state) = state { + self.keep_state(state); + } self.after_object = after; self.mesh_ended = mesh_ended; if matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 65cec1b07..9deba8889 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -446,6 +446,28 @@ impl<'a> Reader<'a> { // art moves on, so a path's state before its paint isn't read as its layer's. self.layer_state = in_layer.then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); } + self.note_state(); + } + + /// Inside a dictionary in the art (a text's frame, a mask's art), the transparency just set + /// stays after the dictionary: the writer goes on from it, so the objects after an area text + /// whose frame sets one have no `Xy` of their own. + fn note_state(&mut self) { + let gs = &self.gs; + let state = (gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape); + if self.frames.iter().any(|f| matches!(f.kind, Kind::Layer(_))) + && let Some(f) = self.frames.iter_mut().rev().find(|f| matches!(f.kind, Kind::Obj(_))) + { + f.state = Some(state); + } + } + + /// A dictionary's art set transparency `state` (see [`Self::note_state`]): it is the current + /// one now, and the enclosing dictionary's. + pub(super) fn keep_state(&mut self, state: (f32, BlendMode, bool, Knockout, bool)) { + let gs = &mut self.gs; + (gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape) = state; + self.note_state(); } /// `n (style) XW` after an object: `1 (style)` after the object written on `%_` lines, its diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 481a5670a..6d948ae81 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -153,6 +153,8 @@ pub(super) struct Frame { pub(super) clips: Vec, /// A compound member group explicitly declares its subpaths' winding with `D`. pub(super) compound_members: bool, + /// A dictionary in the art: the transparency (`Xy`) its art set last, which stays after it. + pub(super) state: Option<(f32, BlendMode, bool, Knockout, bool)>, } /// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 649d51c38..b5c155d6c 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -508,6 +508,21 @@ fn a_masked_gradient_mesh_takes_the_transparency_written_after_its_mask() { } } +#[test] +fn a_transparency_set_in_a_dictionary_s_art_stays_after_it() { + // GUARDS: an area text's frame (its `/ConfiningPath` art) sets its own transparency; the objects + // after the text, with no `Xy` of their own, draw with it. Read as the dictionary's alone, they + // drew with the Lighten of the path before the text and vanished on a light background. + let text = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n/Art :\nX=\n0 Ap\n0 1 0 0 0 Xy\n0 w\n100 0 m\n100 10 L\nn\nX+\n\ + ; /ConfiningPath ,\n2 /TextAntialiasing ,\n;\n"; + let body = format!("9 0.5 0 0 0 Xy\n{RED}{}f\n{text}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0), rect_path(20.0, 0.0, 30.0, 10.0)); + let a = art(&read_ok(&layer(&body)).doc); + let paths: Vec<_> = a.iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect(); + assert_eq!(paths.len(), 2, "{a:#?}"); + assert_eq!((paths[0].opacity, paths[0].blend), (0.5, BlendMode::Lighten), "the state before the text"); + assert_eq!((paths[1].opacity, paths[1].blend), (1.0, BlendMode::Normal), "the state the text's art set"); +} + #[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 44a180f1f..ad10039d6 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-31 · **Change:** minor (area type from an Illustrator file's text document starts its first line where the file lays it out) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a transparency an Illustrator file sets in a dictionary's art, as an area text's frame does, stays for the objects after it) · **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-31 | minor | `.ai`/EPS editing data: a transparency (`Xy`) set by art inside a dictionary, as an area text's frame (its `/ConfiningPath`) or a mask's art sets one, stays the current one after the dictionary: the writer goes on from it, so the objects after such a text have no `Xy` of their own. It was dropped when the dictionary closed, and a user's file drew its logo, calligraphy, footer and type after an area text with the Lighten of the glow before it, so they faded into the light background. Test `import::ai::tests::a_transparency_set_in_a_dictionary_s_art_stays_after_it`; checked locally against that file: 8.1% → 1.7% from its page, so it opens from its layers (5% is the limit); scores unchanged | | 2026-10-31 | minor | `.ai`/EPS editing data: area type made from the file's text document put its first baseline the font's ascent below the frame's top, where the file's own layout (and its page) can have it higher: Oswald's and Urbanist's headings came in a third and an eighth of their size lower than on the page, and each text over them was taken to differ. When the text document's first line is more than half a point from where it is laid out here, the first baseline is fixed (Area Type Options › First Baseline › Fixed) where the file has it; the type's own font metrics aren't changed. Test `import::ate::tests::area_type_s_first_line_goes_where_the_file_lays_it_out`; checked locally against users' files: two election posters come 5.4% → 1.1% and 8.6% → 4.3% from their pages, so they open from their layers, and another 1.7% → 0.3%; scores unchanged | | 2026-10-31 | minor | `.ai`/EPS editing data: type made from the file's text document took each character attribute its run doesn't set from the document's base attributes, not from the character style the run names (`[Normal Character Style]` or another of the file's, and the styles each is based on). Where they differ the type came in wrong: a user's file whose base fill is white and whose Normal Character Style is black came in with its text white, so most of its type was missing wherever the page's own type didn't stand in for it. A run now takes its own attributes, then its character style's, then the document's ([Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html)). Test `import::tests_native::type_from_the_text_document_takes_its_character_styles_attributes`; checked locally against 377 users' files: none newly opens from its PDF part, and with #4791 that file opens from its layers (6.3% → 1.1% from its page); scores unchanged | | 2026-10-31 | minor | SVG export (and Object › Expand, EPS, DXF, EMF and the other outputs that cut a gradient mesh into flat pieces): each patch was cut into 8 × 8 quads whatever its colours, so a patch of one colour became 64 identical pieces. Neighbouring quads are now joined into square blocks wherever the colour changes by no more than about one 8-bit step across the block, so a flat patch is one piece and a patch that shades strongly keeps all 64; a block's outline runs through its quads' corners, and a patch that folds over itself is not joined. A synthetic page of 400 meshes (6 × 6 patches, half flat) exports 468,000 pieces instead of 921,600 (35 MB instead of 66 MB), and resvg draws the two files identically. Tests `mesh_export` (crate `vectorcraft-svg`), `tests::live::mesh_expand_to_flat_pieces`; scores unchanged |