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 |