diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index abcbf0219..b3050abea 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -187,6 +187,9 @@ struct Reader<'a> { 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)>, + /// The state a mesh had before an `Xy` right after its end, which is the layer's instead when + /// the layer's `LB` follows rather than the mesh's own `6 () XW`. + mesh_undo: Option<(f32, BlendMode, bool, Knockout)>, /// Painted copies of clipping paths that have a fill (drawn under what they clip). clip_fills: HashSet, /// Groups closed as an object's drawn look (`9 () XW`). @@ -292,6 +295,7 @@ impl<'a> Reader<'a> { group_ended: false, mesh_ended: false, layer_state: None, + mesh_undo: None, styles: HashMap::new(), palette: super::palette::Palette::default(), restyled: vec![], diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 09ccd14aa..19239fa87 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -171,7 +171,14 @@ impl<'a> Reader<'a> { } pub(super) fn end_layer(&mut self) -> Result<(), String> { - let f = self.close("LB", |k| matches!(k, Kind::Layer(_)))?; + let mut f = self.close("LB", |k| matches!(k, Kind::Layer(_)))?; + let state = self.layer_state.take(); + // An `Xy` right after the layer's last mesh, then `LB`: the layer's, not the mesh's. + if let (Some(_), Some(before)) = (state, self.mesh_undo.take()) + && let Some(mesh) = f.children.last_mut().filter(|n| matches!(n.kind, NodeKind::Mesh(_))) + { + (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 color = a.color.unwrap_or(LayerColor::Preset(0)); @@ -183,7 +190,7 @@ impl<'a> Reader<'a> { layer.visible = a.visible; layer.locked = a.locked; // The layer's own opacity, blend and knockout, written after its art (#2003). - if let Some((opacity, blend, isolate, knockout)) = self.layer_state.take() { + if let Some((opacity, blend, isolate, knockout)) = state { (layer.opacity, layer.blend, layer.isolate, layer.knockout) = (opacity, blend, isolate, knockout); } match self.frames.last_mut() { diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index d9dbab22a..42d6960a6 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -77,6 +77,7 @@ impl<'a> Reader<'a> { if !matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB") { self.after_object = false; self.layer_state = None; + self.mesh_undo = None; } let group_ended = std::mem::take(&mut self.group_ended); let mesh_ended = std::mem::take(&mut self.mesh_ended); @@ -213,7 +214,13 @@ impl<'a> Reader<'a> { } "Xw" => self.gs.hidden = Self::nums(&vals).last() == Some(&1.0), "A" => self.gs.locked = Self::nums(&vals).last() == Some(&1.0), - "XW" => self.style_marker(&vals, hidden), + "XW" => { + // An object's own state block ends `6 () XW`: an `Xy` after a mesh's end was the mesh's. + if Self::nums(&vals).last() == Some(&6.0) && self.mesh_undo.take().is_some() { + self.layer_state = None; + } + self.style_marker(&vals, hidden) + } // Gradients. "Bd" => self.gradient_def = GradientDef::begin(&vals), "Bs" | "BD" => {} @@ -390,7 +397,8 @@ impl<'a> Reader<'a> { } /// `mode opacity isolate knockout shape Xy`; after a group's end, the group's too, and right - /// after a gradient mesh's `/End X!` (before its `Xd` and `XW`), the mesh's. + /// after a gradient mesh's `/End X!` (before its `Xd` and `6 () XW`), the mesh's, unless the + /// layer's `LB` follows: then it is the layer's (see `end_layer`). fn transparency(&mut self, vals: &[V], mesh_ended: bool) { let n = Self::nums(vals); let [mode, opacity, rest @ ..] = n.as_slice() else { return }; @@ -405,9 +413,14 @@ impl<'a> Reader<'a> { }; gs.knockout_shape = rest.get(2).is_some_and(|v| *v != 0.0); let gs = self.gs.clone(); - if self.last_object().is_some_and(|n| { - matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. }) || (mesh_ended && matches!(n.kind, NodeKind::Mesh(_))) - }) { + let in_layer = matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. })); + if let Some(n) = self.last_object().filter(|n| mesh_ended && matches!(n.kind, NodeKind::Mesh(_))) { + // The mesh's, or the layer's when it is the layer's last object: kept for either. + let before = (n.opacity, n.blend, n.isolate, n.knockout); + (n.opacity, n.blend, n.isolate, n.knockout) = (gs.opacity, gs.blend, gs.isolate, gs.knockout); + self.mesh_undo = Some(before); + self.layer_state = in_layer.then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); + } else if self.last_object().is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. })) { if let Some(n) = self.last_object() { n.opacity = gs.opacity; n.blend = gs.blend; @@ -419,8 +432,7 @@ impl<'a> Reader<'a> { // After a layer's art and before its `LB`: the layer's own state (#2003). Kept until // an operator that isn't part of the state block (or the `LB` that takes it) says the // art moves on, so a path's state before its paint isn't read as its layer's. - self.layer_state = - matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. })).then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); + self.layer_state = in_layer.then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); } } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 813113d5b..12eb54ee3 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -391,6 +391,29 @@ fn a_layer_keeps_its_own_transparency() { let doc = read_ok(&layer(&art_)).doc; assert_eq!(doc.layers[0].opacity, 1.0, "no layer state written: the layer stays opaque"); assert_eq!(art(&doc)[0].opacity, 0.5, "the group's own state"); + // A layer whose last object is a gradient mesh: an `Xy` right after the mesh's end is the + // layer's when `LB` follows (directly or after the layer's `Xd` and `7 () XW`), and the mesh's + // when its own `6 () XW` follows. + let corner = + |x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| format!("%_[1 1 1 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1); + let mesh = format!( + "0 0.8 0 0 0 Xy\n/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)), + ); + for (after, layer_state, mesh_state) in [ + ("1 0.35 0 0 0 Xy\n", (0.35, BlendMode::Multiply), (0.8, BlendMode::Normal)), + ("1 0.35 0 0 0 Xy\n0 0 Xd\n7 () XW\n", (0.35, BlendMode::Multiply), (0.8, BlendMode::Normal)), + ("1 0.35 0 0 0 Xy\n0 0 Xd\n6 () XW\n", (1.0, BlendMode::Normal), (0.35, BlendMode::Multiply)), + ] { + let doc = read_ok(&layer(&format!("{mesh}{after}"))).doc; + let l = &doc.layers[0]; + assert!(matches!(art(&doc)[0].kind, NodeKind::Mesh(_)), "{after:?}"); + assert_eq!((l.opacity, l.blend), layer_state, "the layer's state after {after:?}"); + assert_eq!((art(&doc)[0].opacity, art(&doc)[0].blend), mesh_state, "the mesh's state after {after:?}"); + } } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4b487b5a4..536c0069b 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` import: type on a path spaced round curves as the file lays it out) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai`/EPS editing data: a layer's state after its last mesh stays the layer's) · **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: 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 | | 2026-10-30 | minor | `.ai`/EPS editing data: type on a path is spaced round curves as the file's own layout spaces it, closer round the outside and further apart round the inside, though its frame keeps no spacing: the Type on a Path Options › Spacing that does that is fitted from the layout's glyph positions, where it came in at the font's advances and ran on past where the page ends it. In 8 files of a user's set with type round curves (19 stories), the fitted spacing is a fifth of the line's ascent outside a curve (17 stories) and half of it inside (2), and the 4 files whose pages were measured space their glyphs as their layouts do. Own-fixture tests in `import::tests_native` and `import::ate`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a placed (linked) file whose data gives two placements is put at the corner of its box when that draws closer to the page and the layers still match it, not only when the first placement fails the comparison: a small logo in the wrong place differed too little to fail, and stayed there. Test `import::tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a file whose layers hold placed (linked) PDFs and no text object comes in from its layers when all of its page's type lies inside the placed files (at either of their placements): the PDF part draws each placed file, type and all, where the layers show its preview, so that type needs no text object. Such a file came in as its PDF part, saying its layers had no text objects for its page's text; type outside the placed files still needs one. Own-fixture test in `import::tests_native`; scores unchanged | @@ -141,7 +142,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | 2026-10-30 | minor | `.ai` import: when the layers are compared with the page again with placed files at the corner of their box, the text objects the page doesn't draw are weighed with the images there, from the layers as read: before, the text an image covered at its first placement had already been left out, so a file whose image covered type only there came in without that type or as its PDF part. Test `import::tests_native::text_a_placed_file_covers_only_at_its_first_placement_is_kept`; scores unchanged | | 2026-10-30 | minor | `.ai` import: type read from the editing data's text document keeps its case setting (character attribute 12: 1 Small Caps, at Document Setup's default size, 2 All Caps); it came in as typed, so a heading set in All Caps showed in lower or mixed case where the page draws capitals. Test `import::tests_native::type_from_the_text_document_keeps_its_case_setting`; checked locally against a user's files; scores unchanged | | 2026-10-30 | minor | PDF import: a patch or triangle mesh shading of up to 16,384 patches comes in as gradient meshes (it was 4,096; Illustrator writes up to 14,355 in files users sent), and a stroke painted with one (how Illustrator writes a freeform gradient on a stroke) is the meshes clipped to the area the stroke paints, with its width, caps, joins and dashes; both came in as a flat grey. In two files of a user's set, the formerly grey area now matches a reference rendering in 99% of its pixels. Own-fixture tests in `tests::import_fidelity`; scores unchanged | -| 2026-10-30 | minor | `.ai`/EPS editing data: gradient meshes the reader marked unread, which made the whole file come in as its page or PDF part, are read: a mesh starts grey and its `/CS` is written only where it changes, before the corner it applies to (within a patch too), so each corner takes the space last set before it; named colours (`[c m y k /DeviceCMYK (name) /Process]` or `/Spot`) paint at the corner's tint and `/NChannel` inks overprint at theirs; meshes up to 128 patches a side and 16,384 patches are read (files users sent had up to 97 × 72). Ten of eleven such files from a user's set now open from their layers; meshes with handle weights other than 1 still don't. Own-fixture tests in `import::ai::mesh`; scores unchanged | +| 2026-10-30 | minor | `.ai`/EPS editing data: gradient meshes the reader marked unread, which made the whole file come in as its page or PDF part, are read: a mesh starts grey and its `/CS` is written only where it changes, before the corner it applies to (within a patch too), so each corner takes the space last set before it; named colours (`[c m y k /DeviceCMYK (name) /Process]` or `/Spot`) paint at the corner's tint and `/NChannel` inks overprint at theirs; meshes up to 128 patches a side and 16,384 patches are read (files users sent had up to 97 × 72). Ten of eleven such files from a user's set now open from their layers; meshes with handle weights other than 0 or 1 still don't. Own-fixture tests in `import::ai::mesh`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a placed (linked) file is put where its matrix takes the canvas point of art space's origin, and, when the layers then don't draw as the page, at the corner of the box it is written with, which many files use; six files of a user's set whose photos landed off their artboards now open from their layers. Own-fixture test in `import::tests_native`; scores unchanged | | 2026-10-30 | minor | SVG export: axis-aligned rectangles are written as ``, circles and ellipses drawn with four standard arcs as ``/``, straight lines as `` and other straight-segment paths as ``/``, at the written precision; a rectangle or ellipse with a dashed stroke, turned ellipses, curves and compound paths stay `` (#4652); scores unchanged | | 2026-10-30 | minor | `.ai` import: a placed (linked) file written as an object's copy on `%_` lines is read: its `%AI5_BeginPlace` and `%AI5_EndPlace` markers there were dropped, so the `~` that ends it was an unknown operator and the whole file came in as its PDF part; its preview's header, on a `%_` line too, is read. Own-fixture test in `import::ai::tests`; scores unchanged |