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..4db08ea21 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -279,6 +279,10 @@ 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(); + // 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 { @@ -286,11 +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 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); @@ -306,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 2bd94479d..154f917e8 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((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 dc3089024..28e95451d 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,101 @@ 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(Clone, 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, + /// 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 +/// 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')) { + // 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 Some(at + i + 2); + } + at += line.len() + 1; + } + None +} + +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)>) { + 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.push(end.map(str::to_owned)), + } + } + } + + /// 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::*; @@ -1132,6 +1228,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 27609b10b..e6470d84d 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -4503,6 +4503,166 @@ 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}"); + } +} + +/// 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}"); + } +} + +/// 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] +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 = [