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