From 027884b0a452ac142e08116541186a28291cbdff Mon Sep 17 00:00:00 2001 From: ham9zah Date: Wed, 28 Oct 2026 21:28:27 +0300 Subject: [PATCH] Import: read one alternative of versioned content, not two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Versioned content can hold three alternatives: newer files write a Dimension tool group, the same group again after `%AI17_Alternate_Content_if_version_gt:…` for a version in between, then plain art after `%AI17_Alternate_Content`. The reader skipped from the plain `Alternate_Content` marker only, so the middle alternative was read as art too and those objects came in twice. The reader now reads the first alternative and skips from the next one, whatever its marker, to the end of the block, nested blocks included. A block that doesn't end is read whole with a note instead of failing the file. The palette reader tracked alternatives with one flag that a block nested in an alternative reset; it now tracks the depth. --- crates/eps/src/import/ai/lex.rs | 5 ++ crates/eps/src/import/ai/palette.rs | 33 +++++++++--- crates/eps/src/import/ai/read.rs | 2 +- crates/eps/src/import/ai/read/directives.rs | 14 ++++- crates/eps/src/import/ai/read/types.rs | 5 +- crates/eps/src/import/ai/tests.rs | 60 +++++++++++++++++++++ 6 files changed, 109 insertions(+), 10 deletions(-) diff --git a/crates/eps/src/import/ai/lex.rs b/crates/eps/src/import/ai/lex.rs index 071176bb4..c7904948b 100644 --- a/crates/eps/src/import/ai/lex.rs +++ b/crates/eps/src/import/ai/lex.rs @@ -92,6 +92,11 @@ impl<'a> Lexer<'a> { self.pos } + /// Look for the next token at `pos` again (the end of the data at most). + pub fn seek(&mut self, pos: usize) { + self.pos = pos.min(self.src.len()); + } + /// The data from `from` to `to` (empty where that isn't in it). pub fn slice(&self, from: usize, to: usize) -> &'a [u8] { self.src.get(from..to).unwrap_or_default() diff --git a/crates/eps/src/import/ai/palette.rs b/crates/eps/src/import/ai/palette.rs index bf659cfe9..43955d5fc 100644 --- a/crates/eps/src/import/ai/palette.rs +++ b/crates/eps/src/import/ai/palette.rs @@ -69,19 +69,25 @@ pub(super) fn palette(data: &[u8]) -> Palette { let mut cell: Option = None; // Inside a gradient's `Bb` … `BB`. let mut in_gradient = false; - // Inside versioned content's alternate, which repeats what came before it. - let mut alternate = false; + // How deep in versioned content, and the depth of the block whose later alternatives (which + // repeat its first) are being skipped, nested blocks in them included. + let (mut depth, mut skipping) = (0usize, None::); while let Some(t) = lex.next_token() { if let Tok::Comment(c) = t.tok { let line = String::from_utf8_lossy(c); - if line.contains("_Alternate_Content") { - alternate = true; - } else if line.contains("_End_Versioned_Content") || line.contains("_Begin_Content_if_version") { - alternate = false; + if line.contains("_Begin_Content_if_version") { + depth = depth.saturating_add(1); + } else if line.contains("_Alternate_Content") { + skipping = skipping.or(Some(depth)); + } else if line.contains("_End_Versioned_Content") { + if skipping == Some(depth) { + skipping = None; + } + depth = depth.saturating_sub(1); } continue; } - if alternate { + if skipping.is_some() { continue; } let w = match t.tok { @@ -211,6 +217,19 @@ mod tests { assert_eq!(p.swatches.len(), 1); } + /// Three alternatives, and versioned content nested in a later one: still one swatch. + #[test] + fn versioned_swatches_with_more_alternatives_are_read_once() { + let p = palette( + b"0 0 Pb\n%AI17_Begin_Content_if_version_gt:24 15\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\n\ + %AI17_Alternate_Content_if_version_gt:24 10\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\n\ + %AI17_Begin_Content_if_version_gt:23 1\n0 0 0 1 k\n(Black)\nPc\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n\ + 0 0 1 0 k\n(Yellow)\nPc\n\ + %AI17_Alternate_Content\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\n%AI17_End_Versioned_Content\n0 0 0 1 k\n(After)\nPc\nPB\n", + ); + assert_eq!(p.swatches.iter().map(|e| e.name.as_str()).collect::>(), ["Red ink", "After"]); + } + #[test] fn what_it_doesnt_know_leaves_the_other_swatches() { let p = palette(b"0 0 Pb\n1 2 3 Zz\n(Odd)\nPc\n(no paint) Pc\n0 0 0 1 k\n(Black)\nPc\nPB\n"); diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index bba345254..50b4211b9 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -79,7 +79,7 @@ pub(crate) use document::{slot_frame, slot_of}; pub use types::IMAGE_TOO_LARGE; use types::{BLEND_BUDGET, FLIP, Frame, GState, Kind, PatternHead, Place, Raster}; #[cfg(test)] -pub use types::{NON_NATIVE_ART, NON_NATIVE_ART_UNREAD}; +pub use types::{NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED}; pub(super) use types::{blend_mode, blend_name}; #[path = "legacy_text.rs"] diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 47eef88dd..d2a4b6cc5 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -1,5 +1,5 @@ use super::art::{four, sane, two}; -use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, PatternHead, Place, Raster, SKIPPED}; +use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED}; use super::{Arc, Node, PatternDef, Reader, Rect, Tok, V, marker}; impl<'a> Reader<'a> { @@ -69,6 +69,18 @@ impl<'a> Reader<'a> { if m == "BeginEncoding" { self.type_state.encoding_comment(line); } + // Versioned content holds alternatives of the same art, the newest first: the first is + // read, and the next one (`%AI17_Alternate_Content`, or `…_if_version_gt:` for a version + // in between) starts what is skipped, up to the end of the block, nested blocks included. + if m.starts_with("Alternate_Content") { + let at = self.lex.offset(); + if self.skip_to("End_Versioned_Content").is_err() { + // A block that doesn't end: the rest of the file is read rather than lost. + self.lex.seek(at); + self.warn(VERSIONED_UNENDED); + } + return Ok(()); + } if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) { return self.skip_to(end); } diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 309061f3c..e4a404697 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -244,9 +244,12 @@ pub(super) const SKIPPED: &[(&str, &str)] = &[ ("BeginSymbolList", "EndSymbolList"), ("BeginTextDocument", "EndTextDocument"), ("BeginEncoding", "EndEncoding"), - ("Alternate_Content", "End_Versioned_Content"), ]; +/// The note for versioned content whose `%AI17_End_Versioned_Content` is missing: every +/// alternative after the first is read too, so some art may come in twice. +pub const VERSIONED_UNENDED: &str = "versioned content that doesn't end was read whole: some art may come in twice"; + /// The note for a `/ForeignObject` (non-native art, the content of a placed PDF, which the editor /// shows but doesn't edit) whose PDF can't be read. pub const NON_NATIVE_ART_UNREAD: &str = "non-native art (a placed PDF's content) that VectorCraft can't read is left out"; diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 721365c25..5077a30f2 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -953,6 +953,66 @@ fn sections_it_skips() { assert_eq!(art(&doc).len(), 1); } +/// A filled square at x = `10 × i` (5 pt wide), to tell the alternatives of versioned content apart. +fn alt_square(i: u32) -> String { + let x = f64::from(i) * 10.0; + rect_path(x, 0.0, x + 5.0, 5.0) + "f\n" +} + +/// The left edges of the layer's art, in order: which [`alt_square`]s came in. +fn squares(doc: &Document) -> Vec { + art(doc).iter().filter_map(|n| n.geometric_bounds()).map(|b| b.x0.round()).collect() +} + +/// Versioned content holds alternatives of the same art for newer and older versions (newer +/// files write a Dimension tool group, then the same group again for a version in between, then +/// plain art): only the first alternative is read, so the art comes in once, however many +/// alternatives follow and whatever versioned content is nested in them. +#[test] +fn versioned_content_reads_one_alternative() { + let (begin, between, alternate, end) = ( + "%AI17_Begin_Content_if_version_gt:24 11\n", + "%AI17_Alternate_Content_if_version_gt:24 10\n", + "%AI17_Alternate_Content\n", + "%AI17_End_Versioned_Content\n", + ); + let (a, b, c) = (alt_square(1), alt_square(2), alt_square(3)); + let cases = [ + ("one alternative", format!("{begin}{a}{end}"), vec![10.0]), + ("two alternatives", format!("{begin}{a}{alternate}{b}{end}"), vec![10.0]), + ("three alternatives", format!("{begin}{a}{between}{b}{alternate}{c}{end}"), vec![10.0]), + // Blocks nested in the first alternative read their own first; those in a later one aren't read. + ( + "nested", + format!( + "{begin}{a}{begin}{b}{alternate}{c}{end}{between}{}{begin}{}{alternate}{}{end}{alternate}{}{end}", + alt_square(4), + alt_square(5), + alt_square(6), + alt_square(7) + ), + vec![10.0, 20.0], + ), + // Art after the block is read as usual. + ("art after it", format!("{begin}{a}{between}{b}{alternate}{c}{end}{}", alt_square(8)), vec![10.0, 80.0]), + ]; + for (case, body, want) in cases { + let s = read_ok(&layer(&body)); + assert_eq!(squares(&s.doc), want, "{case}"); + assert!(!s.warnings.iter().any(|w| w == read::VERSIONED_UNENDED), "{case}: {:?}", s.warnings); + } +} + +/// Versioned content whose end is missing is read whole, with a note, rather than losing the rest +/// of the file. +#[test] +fn versioned_content_that_does_not_end_is_read_with_a_note() { + let body = format!("%AI17_Begin_Content_if_version_gt:24 11\n{}%AI17_Alternate_Content_if_version_gt:24 10\n{}", alt_square(1), alt_square(2)); + let s = read_ok(&layer(&body)); + assert_eq!(squares(&s.doc), [10.0, 20.0]); + assert!(s.warnings.iter().any(|w| w == read::VERSIONED_UNENDED), "{:?}", s.warnings); +} + /// Non-native art (a placed PDF's content) keeps its PDF as ASCII85 in comment lines after /// `/Data ,`. ASCII85 has `_`, so a line may start with `%_`, which otherwise reads as hidden /// tokens, and a `(` in it would swallow the rest of the layer (a letterhead saved by Illustrator