From babbbb9eb0349f32dd363ad2e9ed119f83516957 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 07:07:13 -0800 Subject: [PATCH 1/3] .ai import: ASCII85 data in a section does not hide the section's end A section scan lexed a /ForeignObject's /Data, a /Binary dictionary's data and a text document's data as tokens. A `%_` line of that data holding a `(` opened a string, so the section ran past its end marker: the editing data failed to read, or the art after it was misread. Reader::section now skips the data up to its `~>`, as the reader does for art outside a section. Data without its `~>` before the section's end marker is not skipped. The scan for swatch names does the same. --- crates/engine/tests/import_fuzz.rs | 14 ++- crates/eps/src/import/ai/lex.rs | 3 +- crates/eps/src/import/ai/read.rs | 4 + crates/eps/src/import/ai/read/directives.rs | 6 +- crates/eps/src/import/ai/read/ops.rs | 97 ++++++++++++++++++++- crates/eps/src/import/ai/tests.rs | 92 +++++++++++++++++++ 6 files changed, 206 insertions(+), 10 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index aac335bf6..d4d545b9e 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3633,8 +3633,8 @@ proptest! { } } -/// Editing data with two symbols (S2's art holds an instance of S1), the symbol list and a layer of -/// instances, as the format writes them. +/// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the +/// symbol list and a layer of instances, as the format writes them. fn symbols_text() -> String { use vectorcraft_testkit::ai; let def = |name: &str, art: &str, reg: u32| { @@ -3643,7 +3643,12 @@ fn symbols_text() -> String { ) }; let inst = |name: &str, m: &str| format!("/SymbolInstance :\n({name}) /SymbolRef ,\n{m} /RTransformMatrix\n;\n9 () XW\n"); - let defs = def("S1", "0 1 1 0 1 0 0 Xa\n-2 -2 m\n2 -2 L\n2 2 L\n-2 2 L\nf\n", 5) + &def("S2", &inst("S1", "1 0 0 -1 -8081 8237"), 1); + // Non-native art keeps ASCII85 data in comment lines, some of them `%_` lines holding a `(`. + let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n\ + %,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@\n;\n"; + let defs = def("F", &format!("{foreign}-2 -2 m\n2 -2 L\n2 2 L\nf\n"), 5) + + &def("S1", "0 1 1 0 1 0 0 Xa\n-2 -2 m\n2 -2 L\n2 2 L\n-2 2 L\nf\n", 5) + + &def("S2", &inst("S1", "1 0 0 -1 -8081 8237"), 1); let list = "%AI24_BeginSymbolList\n(S2)\n(S1)\n%AI24_EndSymbolList\n"; let page = ai::layer( "L", @@ -3668,7 +3673,7 @@ fn the_symbols_fuzz_data_opens_live() { let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&symbols_text())].concat(); let (doc, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0); assert!(layered, "{warnings:?}"); - let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect(); + let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).filter(|n| *n != "F").collect(); assert_eq!(names, ["S2", "S1"]); let mut instances = 0; doc.walk(|n| instances += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. }))); @@ -4165,6 +4170,7 @@ fn arb_ai_token() -> impl Strategy { "\n%_/AI11Text :\n%_0 /StoryIndex ,\n%_;\n", "/SymbolInstance :", "/Binary : /ASCII85Decode ,", + "/AI11TextDocument : /ASCII85Decode ,", "~>", "p", "P", diff --git a/crates/eps/src/import/ai/lex.rs b/crates/eps/src/import/ai/lex.rs index b96b85c06..810a01c3f 100644 --- a/crates/eps/src/import/ai/lex.rs +++ b/crates/eps/src/import/ai/lex.rs @@ -133,7 +133,8 @@ impl<'a> Lexer<'a> { self.src.get(self.pos).copied() } - fn rest(&self) -> &'a [u8] { + /// The data from where the next token is looked for. + pub fn rest(&self) -> &'a [u8] { self.src.get(self.pos..).unwrap_or_default() } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 2cf9dc736..d84338094 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -279,6 +279,8 @@ fn swatch_names(data: &[u8]) -> ShownNames { let (mut palette_at, mut palette) = (None, None); // A pattern's name is the first string of its definition. let mut head = false; + // ASCII85 data in comment lines is skipped past, as the reader skips it. + let mut dictionaries = ops::Dictionaries::default(); loop { let before = lex.offset(); let Some(t) = lex.next_token() else { @@ -291,6 +293,8 @@ fn swatch_names(data: &[u8]) -> ShownNames { _ => break, } }; + let end = skip.map(|s| s.1).or(palette_at.map(|_| "EndPalette")).or(section.map(|s| s.1)); + dictionaries.pass(&t.tok, &mut lex, end); match &t.tok { Tok::Comment(c) => { let line = String::from_utf8_lossy(c); diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 2bd94479d..fbc889c54 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use super::super::brush; use super::art::{four, sane, two}; +use super::ops::Dictionaries; use super::types::{ FLIP, GState, Kind, MAX_DEPTH, MAX_HEX, MAX_LISTED, MAX_NODES, MAX_NOTE, MAX_PATTERNS, MAX_PLACED_FOLDER, MAX_STACK, MAX_SUBREADERS, MAX_SYMBOLS, PatternHead, Place, Raster, SKIPPED, SymbolHead, VERSIONED_UNENDED, @@ -330,15 +331,18 @@ impl<'a> Reader<'a> { self.section(end).map(|_| ()) } - /// [`Self::skip_to`] → what was skipped, up to the comment that ends it. + /// [`Self::skip_to`] → what was skipped, up to the comment that ends it. ASCII85 data in comment + /// lines is skipped past as [`Self::add_object_entry`] skips it. fn section(&mut self, end: &str) -> Result<&'a [u8], String> { let begin = SKIPPED.iter().find(|(_, e)| *e == end).map_or("", |(b, _)| *b); let begin = if end == "End_Versioned_Content" { "Begin_Content_if_version_gt" } else { begin }; let start = self.lex.offset(); let mut depth = 0usize; + let mut dictionaries = Dictionaries::default(); loop { let before = self.lex.offset(); let Some(t) = self.lex.next_token() else { break }; + dictionaries.pass(&t.tok, &mut self.lex, Some(end)); if let Tok::Comment(c) = t.tok { let line = String::from_utf8_lossy(c); let m = marker(&line); diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index dc3089024..5510663ca 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -34,10 +34,11 @@ impl<'a> Reader<'a> { // of it may start with `%_` and read as hidden tokens: a `(` there would open // a string that swallows the rest of the layer. let key = o.entries.last().and_then(|(k, _)| k.as_deref()); - if o.ty == "Binary" && key == Some("ASCII85Decode") { - self.lex.skip_past(b"~>"); - } else if o.ty == "ForeignObject" && key == Some("Data") { - self.foreign_data = Some(self.lex.skip_past(b"~>").to_vec()); + if ascii85_follows(&o.ty, key) { + let data = self.lex.skip_past(b"~>"); + if o.ty == "ForeignObject" { + self.foreign_data = Some(data.to_vec()); + } } } } @@ -1122,6 +1123,94 @@ fn strip(n: &mut Node, hidden: &HashSet) { } } +/// Whether ASCII85 data in comment lines, up to its `~>`, follows the entry keyed `key` of a +/// dictionary of type `ty`: the `/ASCII85Decode` of a `/Binary` dictionary or a text document, or a +/// non-native art object's (`/ForeignObject`'s) `/Data`. +pub(super) fn ascii85_follows(ty: &str, key: Option<&str>) -> bool { + matches!((ty, key), ("Binary" | "AI11TextDocument" | "AI11UndoFreeTextDocument", Some("ASCII85Decode")) | ("ForeignObject", Some("Data"))) +} + +/// The dictionaries (`/Type :` … `;`) a scan of tokens passes that the reader doesn't read, kept to +/// tell where an entry's ASCII85 data follows ([`ascii85_follows`]), so the scan can skip past it +/// as the reader does: a `(` on a `%_` line of the data would otherwise open a string. +#[derive(Default)] +pub(super) struct Dictionaries<'s> { + /// The open dictionaries' types, innermost last, up to [`MAX_DEPTH`]. + types: Vec<&'s [u8]>, + /// How many more are open inside the last of `types`. + deeper: usize, + /// The name just before the token taken. + name: Option<&'s [u8]>, + /// Whether the string just before the token taken is a key that ASCII85 data follows. + string_key: bool, + /// Data that starts before this offset has no `~>`, so it isn't searched for again. + no_end_before: usize, +} + +/// The offset just past the `~>` of ASCII85 data at the start of `rest`. `Err` holds the offset of +/// the comment with marker `end` that comes first, or the end of `rest`. A line of the data that +/// reads as that marker counts as the marker. +fn data_end(rest: &[u8], end: Option<&str>) -> Result { + let mut at = 0; + for line in rest.split(|b| matches!(b, b'\r' | b'\n')) { + let comment = line.strip_prefix(b"%").and_then(|c| std::str::from_utf8(c).ok()); + if comment.is_some_and(|c| Some(super::marker(c)) == end) { + return Err(at); + } + if let Some(i) = line.windows(2).position(|w| w == b"~>") { + return Ok(at + i + 2); + } + at += line.len() + 1; + } + Err(rest.len()) +} + +impl<'s> Dictionaries<'s> { + /// Take `tok`, the token `lex` just gave, and skip past the ASCII85 data that follows it. Data + /// without its `~>` before `end`, the end marker of the section being scanned ([`data_end`]), + /// isn't skipped, so the scan still finds that marker. + pub(super) fn pass(&mut self, tok: &super::Tok<'s>, lex: &mut super::Lexer<'s>, end: Option<&str>) { + let at = lex.offset(); + if self.data_follows(tok) && at >= self.no_end_before { + match data_end(lex.rest(), end) { + Ok(end) => lex.seek(at + end), + Err(stop) => self.no_end_before = at + stop, + } + } + } + + /// Take the next token → whether ASCII85 data follows it. + fn data_follows(&mut self, tok: &super::Tok<'s>) -> bool { + // Comments between a name and what follows it are passed over, as the reader passes them. + if matches!(tok, super::Tok::Comment(_)) { + return false; + } + let name = self.name.take(); + let string_key = std::mem::take(&mut self.string_key); + match tok { + super::Tok::Name(n) => self.name = Some(n), + // A string keys an entry as a name does, but gives a dictionary no type. + super::Tok::Str(s) | super::Tok::Hex(s) => self.string_key = self.keys_data(s), + super::Tok::Word(b":") if self.types.len() < MAX_DEPTH => self.types.push(name.unwrap_or_default()), + super::Tok::Word(b":") => self.deeper = self.deeper.saturating_add(1), + super::Tok::Word(b";") if self.deeper > 0 => self.deeper -= 1, + super::Tok::Word(b";") => { + self.types.pop(); + } + super::Tok::Word(b",") => return string_key || name.is_some_and(|key| self.keys_data(key)), + _ => {} + } + false + } + + /// Whether ASCII85 data follows an entry keyed `key` of the innermost dictionary. + fn keys_data(&self, key: &[u8]) -> bool { + let Some(ty) = self.types.last().filter(|_| self.deeper == 0) else { return false }; + let (Ok(ty), Ok(key)) = (std::str::from_utf8(ty), std::str::from_utf8(key)) else { return false }; + ascii85_follows(ty, Some(key)) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 27609b10b..30b53a3e2 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -4503,6 +4503,98 @@ fn non_native_art_that_is_not_a_pdf_is_left_out_with_a_note() { assert!(s.warnings.iter().any(|w| w.starts_with(read::NON_NATIVE_ART_UNREAD)), "{:?}", s.warnings); } +/// The same data in a symbol's definition: its `%_` line with a `(` opens no string, so the +/// definition ends at its own end marker and the symbol after it comes in. +#[test] +fn non_native_art_in_a_symbol_ends_at_the_definitions_end() { + let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@\n;\n"; + let defs = symbols(&(symbol("Pdf", &format!("{foreign}{}", dot()), 5) + &symbol("Dot", &dot(), 5))); + let s = read_ok(&(defs + &layer(&instance("Dot", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0])))); + let names: Vec<&str> = s.doc.symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"Dot"), "{names:?}"); + let a = art(&s.doc); + assert_eq!(a.len(), 1); + assert!(matches!(&a[0].kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Dot"), "{:?}", a[0].kind); +} + +/// Comment lines between a dictionary's name and its `:`, or an entry's key and its `,`, don't hide +/// the data from the scan of a symbol's definition. +#[test] +fn non_native_art_with_comments_in_a_symbol_ends_at_the_definitions_end() { + let foreign = "/ForeignObject\n%note\n:\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data\n%note\n,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@\n;\n"; + let defs = symbols(&(symbol("Pdf", &format!("{foreign}{}", dot()), 5) + &symbol("Dot", &dot(), 5))); + let s = read_ok(&(defs + &layer(&instance("Dot", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0])))); + let names: Vec<&str> = s.doc.symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"Dot"), "{names:?}"); +} + +/// A string keys an entry as a name does: `(Data) ,` doesn't hide the data from the scan of a +/// symbol's definition either. +#[test] +fn non_native_art_keyed_by_a_string_in_a_symbol_ends_at_the_definitions_end() { + for key in ["(Data)", "<44617461>"] { + let foreign = format!( + "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n{key} ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@\n;\n" + ); + let defs = symbols(&(symbol("Pdf", &format!("{foreign}{}", dot()), 5) + &symbol("Dot", &dot(), 5))); + let s = read_ok(&(defs + &layer(&instance("Dot", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0])))); + let names: Vec<&str> = s.doc.symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"Dot"), "{key}: {names:?}"); + } +} + +/// A `/Binary` dictionary's data in a skipped alternate: its `%_` line with a `(` opens no string, +/// so the block ends at its own end marker. +#[test] +fn binary_data_in_a_skipped_alternate_ends_at_the_blocks_end() { + let binary = "%_/Document :\n%_/Binary : /ASCII85Decode ,\n%_(]<[%\n%;;;~>\n%_; (Profile) ,\n%_;\n"; + let body = format!( + "%AI17_Begin_Content_if_version_gt:24 4\n%AI17_Alternate_Content\n{binary}frobnicate\n%AI17_End_Versioned_Content\n{}", + layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")) + ); + let s = read_ok(&body); + assert_eq!(art(&s.doc).len(), 1); + assert!(!s.warnings.iter().any(|w| w == read::VERSIONED_UNENDED), "{:?}", s.warnings); +} + +/// A text document's data, the free type's undo copy included: its `%_` line with a `(` opens no +/// string, even after a line that reads as another section's end marker, so the section ends at +/// its own end marker and the art after it comes in. +#[test] +fn text_document_data_ends_at_the_sections_end() { + for ty in ["AI11TextDocument", "AI11UndoFreeTextDocument"] { + let document = + format!("%AI11_BeginTextDocument\n/{ty} : /ASCII85Decode ,\n%,u@!!/MSk8\n%AI3_EndPattern\n%_(]<[%\n%;;;~>\n%_;\n%AI11_EndTextDocument\n"); + let s = read_ok(&format!("{document}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))); + assert_eq!(art(&s.doc).len(), 1, "{ty}"); + } +} + +/// Data without its `~>` isn't skipped: the section still ends at its own end marker, with no `~>` +/// after it, with one in a later section, and with later data that holds a `(` on a `%_` line. +#[test] +fn data_without_its_end_does_not_hide_the_sections_end() { + let unended = "%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n%,u@!!/MSk8\n%AI11_EndTextDocument\n"; + let ended = "%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n%,u@!!/MSk8\n%_(]<[%\n%;;;~>\n%_;\n%AI11_EndTextDocument\n"; + let art_layer = layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")); + for body in [format!("{unended}{art_layer}"), format!("{unended}{art_layer}{ended}"), format!("{unended}{ended}{art_layer}")] { + let s = read_ok(&body); + assert_eq!(art(&s.doc).len(), 1, "{body}"); + } +} + +/// The names the swatches take are found past the same data: a named color after it still takes +/// its name first, and the pattern named after it is numbered. +#[test] +fn swatch_names_are_found_past_non_native_art_data() { + let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@\n;\n0 0 Xd\n6 () XW\n0 Ae\n"; + let sea = format!("0 1 1 0 (Sea) 0 x\n{}f\n", rect_path(0.0, 0.0, 5.0, 5.0)); + let body = format!("{}{}", DOTS.replace("(Dots)", "(Sea\\n1)"), layer(&format!("{foreign}{sea}"))); + let d = read(stream(&body).as_bytes()).unwrap().doc; + let names: Vec<&str> = d.patterns.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Sea 2"]); +} + /// A PDF with one page of `size` points holding `content`. fn tiny_pdf(size: f64, content: &str) -> Vec { let objects = [ From 3e1893d39998ea3e4be181019faece6454909c18 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 08:43:02 -0800 Subject: [PATCH 2/3] .ai import: ASCII85 data without its end does not hide the section's end Data with no `~>` before the section's end marker was lexed as tokens, so a `(` on a `%_` line of it opened a string that ran past the marker and the section did not end. The scan now skips that data up to the section's end marker, or up to the start of a section of the same kind nested after it. A `%` on the key's own line starts no marker, and a marker line with bytes that are not UTF-8 counts, as the scan reads comments. When versioned content does not end, the scan for swatch names goes back to where its dictionaries stood at the start of that content, so data in the range it reads again is skipped. --- crates/eps/src/import/ai/read.rs | 8 ++- crates/eps/src/import/ai/read/directives.rs | 2 +- crates/eps/src/import/ai/read/ops.rs | 61 ++++++++++++++------- crates/eps/src/import/ai/tests.rs | 49 +++++++++++++++++ 4 files changed, 96 insertions(+), 24 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index d84338094..4db08ea21 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -281,6 +281,8 @@ fn swatch_names(data: &[u8]) -> ShownNames { let mut head = false; // ASCII85 data in comment lines is skipped past, as the reader skips it. let mut dictionaries = ops::Dictionaries::default(); + // The scan's state where the versioned content being skipped starts, to go back to. + let mut versioned = None; loop { let before = lex.offset(); let Some(t) = lex.next_token() else { @@ -288,13 +290,14 @@ fn swatch_names(data: &[u8]) -> ShownNames { match skip.take() { Some((.., Some(at))) => { lex.seek(at); + dictionaries = versioned.take().unwrap_or_default(); continue; } _ => break, } }; - let end = skip.map(|s| s.1).or(palette_at.map(|_| "EndPalette")).or(section.map(|s| s.1)); - dictionaries.pass(&t.tok, &mut lex, end); + let markers = skip.map(|s| (s.0, s.1)).or(palette_at.map(|_| ("", "EndPalette"))).or(section.map(|s| (s.0, s.1))); + dictionaries.pass(&t.tok, &mut lex, markers); match &t.tok { Tok::Comment(c) => { let line = String::from_utf8_lossy(c); @@ -310,6 +313,7 @@ fn swatch_names(data: &[u8]) -> ShownNames { } } else if m.starts_with("Alternate_Content") && section.is_none_or(|s| s.3) { skip = Some(("Begin_Content_if_version_gt", "End_Versioned_Content", 0, Some(lex.offset()))); + versioned = Some(dictionaries.clone()); } else if m == "%BeginProlog" { skip = Some(("", "%EndProlog", 0, None)); } else if m == "%BeginResource" { diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index fbc889c54..154f917e8 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -342,7 +342,7 @@ impl<'a> Reader<'a> { loop { let before = self.lex.offset(); let Some(t) = self.lex.next_token() else { break }; - dictionaries.pass(&t.tok, &mut self.lex, Some(end)); + dictionaries.pass(&t.tok, &mut self.lex, Some((begin, end))); if let Tok::Comment(c) = t.tok { let line = String::from_utf8_lossy(c); let m = marker(&line); diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 5510663ca..5b2f86102 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1133,7 +1133,7 @@ pub(super) fn ascii85_follows(ty: &str, key: Option<&str>) -> bool { /// The dictionaries (`/Type :` … `;`) a scan of tokens passes that the reader doesn't read, kept to /// tell where an entry's ASCII85 data follows ([`ascii85_follows`]), so the scan can skip past it /// as the reader does: a `(` on a `%_` line of the data would otherwise open a string. -#[derive(Default)] +#[derive(Clone, Default)] pub(super) struct Dictionaries<'s> { /// The open dictionaries' types, innermost last, up to [`MAX_DEPTH`]. types: Vec<&'s [u8]>, @@ -1143,38 +1143,43 @@ pub(super) struct Dictionaries<'s> { name: Option<&'s [u8]>, /// Whether the string just before the token taken is a key that ASCII85 data follows. string_key: bool, - /// Data that starts before this offset has no `~>`, so it isn't searched for again. - no_end_before: usize, + /// The rest of the data has no `~>` or marker to stop at, so they aren't searched for again. + no_end: bool, } -/// The offset just past the `~>` of ASCII85 data at the start of `rest`. `Err` holds the offset of -/// the comment with marker `end` that comes first, or the end of `rest`. A line of the data that -/// reads as that marker counts as the marker. -fn data_end(rest: &[u8], end: Option<&str>) -> Result { +/// Where the ASCII85 data at the start of `rest` stops: just past its `~>`, or at the comment that +/// comes first with a marker of `section`, the begin and end markers of the section being scanned +/// (no begin marker when it's empty). A line of the data that reads as one of them counts as the +/// marker. `None` when `rest` has neither. +fn data_end(rest: &[u8], section: Option<(&str, &str)>) -> Option { let mut at = 0; for line in rest.split(|b| matches!(b, b'\r' | b'\n')) { - let comment = line.strip_prefix(b"%").and_then(|c| std::str::from_utf8(c).ok()); - if comment.is_some_and(|c| Some(super::marker(c)) == end) { - return Err(at); + // The first line is what is left of the key's line, where a `%` starts no marker. + if at > 0 + && let Some((comment, (begin, end))) = line.strip_prefix(b"%").zip(section) + { + let comment = String::from_utf8_lossy(comment); + let marker = super::marker(&comment); + if marker == end || (!begin.is_empty() && marker == begin) { + return Some(at); + } } if let Some(i) = line.windows(2).position(|w| w == b"~>") { - return Ok(at + i + 2); + return Some(at + i + 2); } at += line.len() + 1; } - Err(rest.len()) + None } impl<'s> Dictionaries<'s> { - /// Take `tok`, the token `lex` just gave, and skip past the ASCII85 data that follows it. Data - /// without its `~>` before `end`, the end marker of the section being scanned ([`data_end`]), - /// isn't skipped, so the scan still finds that marker. - pub(super) fn pass(&mut self, tok: &super::Tok<'s>, lex: &mut super::Lexer<'s>, end: Option<&str>) { - let at = lex.offset(); - if self.data_follows(tok) && at >= self.no_end_before { - match data_end(lex.rest(), end) { - Ok(end) => lex.seek(at + end), - Err(stop) => self.no_end_before = at + stop, + /// Take `tok`, the token `lex` just gave, and skip the ASCII85 data that follows it, up to where + /// [`data_end`] stops for `section`, so the scan still finds that section's markers. + pub(super) fn pass(&mut self, tok: &super::Tok<'s>, lex: &mut super::Lexer<'s>, section: Option<(&str, &str)>) { + if self.data_follows(tok) && !self.no_end { + match data_end(lex.rest(), section) { + Some(stop) => lex.seek(lex.offset() + stop), + None => self.no_end = true, } } } @@ -1221,6 +1226,20 @@ mod tests { Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(fill, Paint::None, 0.0))) } + #[test] + fn data_stops_at_its_end_or_a_marker_of_the_section() { + let section = Some(("BeginTextDocument", "EndTextDocument")); + assert_eq!(data_end(b"\n%_(a~>\n%AI11_EndTextDocument\n", section), Some(7)); + assert_eq!(data_end(b"\n%_(a\n%AI11_EndTextDocument\n", section), Some(6)); + assert_eq!(data_end(b"\n%_(a\n%AI11_BeginTextDocument\n", section), Some(6)); + // Bytes that aren't UTF-8 after the marker, as the scan reads a comment. + assert_eq!(data_end(b"\n%_(a\n%AI11_EndTextDocument \xff\n", section), Some(6)); + // Right after the key a `%` starts no marker, and an empty begin marker matches no comment. + assert_eq!(data_end(b"%AI11_EndTextDocument\n%_(a\n", section), None); + assert_eq!(data_end(b"\n%\n", Some(("", "EndPalette"))), None); + assert_eq!(data_end(b"\n%AI11_EndTextDocument\n", None), None); + } + #[test] fn only_outlines_without_gradients_move_alike() { // GUARDS: an image's pixels and a placed gradient stay behind when an effect moves the outline. diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 30b53a3e2..33e8e10f0 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -4583,6 +4583,55 @@ fn data_without_its_end_does_not_hide_the_sections_end() { } } +/// Data without its `~>` is skipped up to the section's end marker: its `%_` line with a `(` opens +/// no string, so the section ends there and the art after it comes in. A `%` right after the key +/// starts no marker, so it doesn't end the data. +#[test] +fn data_without_its_end_holding_a_string_ends_at_the_sections_end() { + let art_layer = layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")); + for document in [ + "%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n%,u@!!/MSk8\n%_(]<[%\n%AI11_EndTextDocument\n", + "%AI11_BeginTextDocument\r\n/AI11TextDocument : /ASCII85Decode ,\r\n%_(]<[%\r\n%AI11_EndTextDocument\r\n", + "%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,%AI11_EndTextDocument\n%_(]<[%\n%AI11_EndTextDocument\n", + ] { + let s = read_ok(&format!("{document}{art_layer}")); + assert_eq!(art(&s.doc).len(), 1, "{document}"); + } +} + +/// Data without its `~>` stops at the start of a section of the same kind nested after it too, so +/// the outer section still ends at its own end marker and not at the nested one's. +#[test] +fn data_without_its_end_stops_at_a_nested_sections_start() { + let square = rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"; + let body = format!( + "%AI17_Begin_Content_if_version_gt:24 4\n%AI17_Alternate_Content\n/Binary : /ASCII85Decode ,\n%_(]<[%\n\ + %AI17_Begin_Content_if_version_gt:23 1\nfrobnicate\n%AI17_End_Versioned_Content\n{square}%AI17_End_Versioned_Content\n{}", + layer(&square) + ); + let s = read_ok(&body); + assert_eq!(art(&s.doc).len(), 1); + assert!(!s.warnings.iter().any(|w| w == read::VERSIONED_UNENDED), "{:?}", s.warnings); +} + +/// Versioned content that doesn't end is scanned again for the swatches' names: data with its `~>` +/// is still skipped, though data with none comes after it, and the dictionary open where the +/// content starts still counts. +#[test] +fn swatch_names_are_found_past_data_in_versioned_content_that_does_not_end() { + let entries = "1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@\n;\n0 0 Xd\n6 () XW\n0 Ae\n"; + let alternate = "%AI17_Begin_Content_if_version_gt:24 4\n%AI17_Alternate_Content\n"; + let sea = format!("0 1 1 0 (Sea) 0 x\n{}f\n", rect_path(0.0, 0.0, 5.0, 5.0)); + let unended = "%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n%,u@!!/MSk8\n%AI11_EndTextDocument\n"; + for art_text in [format!("{alternate}/ForeignObject :\n{entries}{sea}{unended}"), format!("/ForeignObject :\n{alternate}{entries}{sea}{unended}")] + { + let body = format!("{}{}", DOTS.replace("(Dots)", "(Sea\\n1)"), layer(&art_text)); + let d = read(stream(&body).as_bytes()).unwrap().doc; + let names: Vec<&str> = d.patterns.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Sea 2"], "{art_text}"); + } +} + /// The names the swatches take are found past the same data: a named color after it still takes /// its name first, and the pattern named after it is numbered. #[test] From a17907ee7d5c1fd47a11462bd5fb54eedae17b49 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 10:06:16 -0800 Subject: [PATCH 3/3] .ai import: data with no end outside a section does not hide a later section's end When the scan for swatch names searched data outside a section and found no `~>`, it stopped searching for the end of all later data. Data in a later section then was not skipped, so a `(` on a `%_` line of it opened a string that ran past the section's end marker, and a color named after the section did not reserve its name. The scan now records the search that found nothing per section's end marker, so a search in another section still stops at that section's markers. --- crates/eps/src/import/ai/read/ops.rs | 10 ++++++---- crates/eps/src/import/ai/tests.rs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 5b2f86102..28e95451d 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1143,8 +1143,9 @@ pub(super) struct Dictionaries<'s> { name: Option<&'s [u8]>, /// Whether the string just before the token taken is a key that ASCII85 data follows. string_key: bool, - /// The rest of the data has no `~>` or marker to stop at, so they aren't searched for again. - no_end: bool, + /// The end markers of the sections being scanned (`None`: no section) where the rest of the data + /// had no `~>` or marker to stop at, so they aren't searched for again in those sections. + no_end: Vec>, } /// Where the ASCII85 data at the start of `rest` stops: just past its `~>`, or at the comment that @@ -1176,10 +1177,11 @@ impl<'s> Dictionaries<'s> { /// Take `tok`, the token `lex` just gave, and skip the ASCII85 data that follows it, up to where /// [`data_end`] stops for `section`, so the scan still finds that section's markers. pub(super) fn pass(&mut self, tok: &super::Tok<'s>, lex: &mut super::Lexer<'s>, section: Option<(&str, &str)>) { - if self.data_follows(tok) && !self.no_end { + let end = section.map(|(_, end)| end); + if self.data_follows(tok) && !self.no_end.iter().any(|e| e.as_deref() == end) { match data_end(lex.rest(), section) { Some(stop) => lex.seek(lex.offset() + stop), - None => self.no_end = true, + None => self.no_end.push(end.map(str::to_owned)), } } } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 33e8e10f0..e6470d84d 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -4632,6 +4632,25 @@ fn swatch_names_are_found_past_data_in_versioned_content_that_does_not_end() { } } +/// A search for where data stops that found nothing outside a section doesn't stop a later +/// section's data at that section's end, so the named color after it still takes its name first. +/// The `/Binary` holds a group, where the reader doesn't take what follows its key as data. +#[test] +fn swatch_names_are_found_past_data_in_a_section_after_a_search_outside_sections_found_nothing() { + let sea = format!("0 1 1 0 (Sea) 0 x\n{}f\n", rect_path(0.0, 0.0, 5.0, 5.0)); + let before = "/Binary :\nu\n/ASCII85Decode ,\nU\n;\n"; + for section in [ + "%AI17_Begin_Content_if_version_gt:24 4\n%AI17_Alternate_Content\n/Binary : /ASCII85Decode ,\n%_(]<[%\n%AI17_End_Versioned_Content\n", + "%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n%_(]<[%\n%AI11_EndTextDocument\n", + ] { + let art_text = format!("{before}{section}{sea}"); + let body = format!("{}{}", DOTS.replace("(Dots)", "(Sea\\n1)"), layer(&art_text)); + let d = read(stream(&body).as_bytes()).unwrap().doc; + let names: Vec<&str> = d.patterns.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Sea 2"], "{art_text}"); + } +} + /// The names the swatches take are found past the same data: a named color after it still takes /// its name first, and the pattern named after it is numbered. #[test]