From 9af023b084b46dc2685af548f53621772fefb6e6 Mon Sep 17 00:00:00 2001 From: YOU WISH! GAMES <269624847+youwishgames@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:29:16 -0700 Subject: [PATCH] .ai import: the -- placeholder in a gradient highlight and the XC operator no longer send the file to its PDF part (fixes #4176) An undefined number (--) in `-- -- angle length Bh` is read as a radial gradient with no highlight, and XC (a color space name after a linked image's file name) is ignored like its neighbours. Either one used to make the whole editing data unreadable, so the file opened as its PDF part and lost its layers and live text. A -- anywhere else is still an operator the reader doesn't know. Co-Authored-By: Claude Sonnet 5.5 --- crates/eps/src/import/ai/paint.rs | 7 +++++-- crates/eps/src/import/ai/read.rs | 35 ++++++++++++++++++++++--------- crates/eps/src/import/ai/tests.rs | 32 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 1 + 4 files changed, 63 insertions(+), 12 deletions(-) diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index 1a42856b0..94c65ef89 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -242,10 +242,13 @@ impl Instance { self.xm = Self::matrix(vals); } - /// `x y angle length Bh`. + /// `x y angle length Bh`. The point is `--` (undefined) when the gradient has no highlight. pub fn bh(&mut self, vals: &[V]) { let n: Vec = vals.iter().filter_map(V::num).collect(); - if let [x, y, ..] = n.as_slice() { + if let [x, y, ..] = n.as_slice() + && x.is_finite() + && y.is_finite() + { self.hilight = Vec2::new(*x, *y); } } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 6028bb422..45c3c89e0 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -562,6 +562,8 @@ const IGNORED: &[&str] = &[ "Xd", "Xr", "XG", + // `/DeviceGray XC` after a linked image's file name (`XG`): its colour space. + "XC", "Xh", "XH", "XF", @@ -1128,6 +1130,9 @@ impl<'a> Reader<'a> { fn op(&mut self, w: &str, hidden: bool) -> Result<(), String> { match w { + // The writer's placeholder for a number the object doesn't have (a radial gradient's + // highlight `-- -- angle length Bh`): a NaN keeps the other operands where they are. + "--" => return self.push(V::Num(f64::NAN)), "[" => { self.marks.push(self.stack.len()); return self.push(V::Mark); @@ -1184,6 +1189,12 @@ impl<'a> Reader<'a> { let group_ended = std::mem::take(&mut self.group_ended); let vals = self.operands(); let vals = self.take_head(vals); + // Only `Bh` reads an undefined number. Anywhere else it would reach a point or a size, so + // the operator is one the reader doesn't know, as before. + if w != "Bh" && !self.frames.is_empty() && vals.iter().any(|v| matches!(v, V::Num(n) if n.is_nan())) { + self.note_unknown("--"); + return Ok(()); + } if let Some(d) = self.gradient_def.as_mut() { match w { "Bs" => d.stop(&vals, &mut self.stop_spots), @@ -1390,20 +1401,24 @@ impl<'a> Reader<'a> { _ if IGNORED.contains(&w) => {} // Before the art, the setup runs procedures of the printing format. _ if self.frames.is_empty() => {} - _ => { - let op: String = w.chars().take(24).collect(); - if !self.shown() { - if self.hidden_unread.len() < 16 { - self.hidden_unread.insert(format!("`{op}`")); - } - } else if self.unknown.len() < 16 { - self.unknown.insert(op); - } - } + _ => self.note_unknown(w), } Ok(()) } + /// An operator the reader doesn't know: on hidden art it is only noted, on shown art the file + /// is read from its PDF part. + fn note_unknown(&mut self, w: &str) { + let op: String = w.chars().take(24).collect(); + if !self.shown() { + if self.hidden_unread.len() < 16 { + self.hidden_unread.insert(format!("`{op}`")); + } + } else if self.unknown.len() < 16 { + self.unknown.insert(op); + } + } + /// `:` after `/Type`: a dictionary opens. fn begin_obj(&mut self, hidden: bool) -> Result<(), String> { let ty = match self.stack.pop() { diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 3bd312297..3d90a4323 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1161,6 +1161,38 @@ fn ai_private_data() { assert!(decode_private(b"%AI12_CompressedDataxx").is_err()); } +const G_DEFS: &str = "%AI5_BeginGradient: (G)\n(G) 1 2 Bd\n[\n<00FF>\n0\n0 0 0 0 1 0 0 2 1 6 %_BS\n\ + %_0 0 0 0 1 0 0 2 1 6 40 0 Bs\n%_0 0 0 0 0 0 1 2 0.5 6 50 100 Bs\nBD\n%AI5_EndGradient\n"; + +/// A radial gradient instance whose highlight line is `bh`, on a rectangle. +fn highlighted(bh: &str) -> String { + let art_ = format!("{RED}{}Bb\n{bh} Bh\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n50 0 0 -50 100 50 Bm\nf\n0 BB\n", rect_path(50.0, 0.0, 150.0, 100.0)); + format!("{G_DEFS}{}", layer(&art_)) +} + +#[test] +fn an_undefined_highlight_is_a_gradient_without_one() { + // `--` stands for a number the writer has no value for, here the highlight's point. + let none = read_ok(&highlighted("-- -- 269.6 0.87")).doc; + let zero = read_ok(&highlighted("0 0 269.6 0.87")).doc; + let Paint::Gradient(g) = fill(&art(&none)[0]) else { panic!("{:?}", fill(&art(&none)[0])) }; + let Paint::Gradient(z) = fill(&art(&zero)[0]) else { panic!("{:?}", fill(&art(&zero)[0])) }; + assert_eq!(g.gradient.kind, GradientKind::Radial); + assert_eq!(g.geom, z.geom, "the same gradient as one with the highlight at the centre"); +} + +#[test] +fn an_undefined_number_anywhere_else_is_still_an_operator_the_reader_doesnt_know() { + let err = read(stream(&layer(&format!("{RED}-- 5 m\n0 10 L\n10 10 L\nf\n"))).as_bytes()).unwrap_err(); + assert!(err.contains("operators VectorCraft doesn't know (`--`)"), "{err}"); +} + +#[test] +fn a_linked_images_colour_space_operator_is_ignored() { + let art_ = format!("{RED}1 /DeviceGray XC\n{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)); + assert_eq!(art(&read_ok(&layer(&art_)).doc).len(), 1); +} + #[test] fn many_zstandard_frames_are_read_in_one_pass_and_capped() { let frame = zstd(b" "); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index da372641d..f2eea9f27 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -130,6 +130,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | `.ai` import: the `--` placeholder of a radial gradient's highlight (`-- -- angle length Bh`, a gradient without one) and the `XC` operator after a linked image's file name no longer send the whole file to its PDF part, so files with them keep their layers, groups and live text (#4176). A `--` anywhere else is still an operator the reader doesn't know. Own-fixture tests in `import::ai::tests`; scores and hours unchanged | | 2026-10-24 | minor | EPS, EMF/WMF and legacy `.ai`: type with ruby (ふりがな) is written with the reading's glyphs where the canvas draws them (they were dropped); legacy `.ai` writes such type as outlines, since a text object can't hold ruby. Tests `eps::tests::ruby_is_written_with_its_type`, `tests_legacy_ai::type_with_ruby_is_outlines_with_the_reading`, `metafile::tests::ruby_is_written_with_its_type`; scores and hours unchanged | | 2026-10-24 | minor | `.ai` import: an image larger than the editing data's reader takes (2^25 pixels, a halftone texture at print resolution) no longer leaves its layer blank: the file comes in as its PDF part, which reads larger images, with a note saying why (#1479). Own-fixture test; scores and hours unchanged | | 2026-10-24 | minor | PDF export: a pattern fill or stroke is written as a PDF tiling pattern (ISO 32000-1, 8.7.3.1) of one period of the tiling, as the canvas repeats it, so grid, brick and hex tilings and overlapping tiles keep their look in a few kilobytes, and PDF import reads it back as one object with a pattern swatch instead of hundreds of tile paths (#3849). Patterns nested more than four deep, or with tiles beyond a billion points, are still written as their tiles, now with a warning. Tests: the issue's hatch round trip, a brick tiling read back against the canvas, the nesting warning; scores and hours unchanged |