From f2ba41d1c18054cc252576da44db8c58d54eb7d1 Mon Sep 17 00:00:00 2001 From: Reinhard Schuerer Date: Sat, 10 Oct 2026 01:50:56 +0200 Subject: [PATCH] Open .ai files whose non-native art has ASCII85 lines starting with %_ A letterhead saved by Illustrator 28 without PDF compatibility wouldn't open: "its editing data can't be read (it ends inside a layer)". Its layer starts with a /ForeignObject (non-native art: the content of a placed PDF), whose /Data entry is the PDF as ASCII85 in comment lines. ASCII85 includes `_`, so about one line in 85 starts with `%_`, which the lexer reads as hidden tokens; a `(` in such a line opened a string that swallowed the dictionary's end, the rest of the art and the layer's LB. The reader now skips to the data's `~>` after `/Data ,` in a /ForeignObject, as it already did for a /Binary dictionary's ASCII85, and leaves the non-native art out with a note (nothing draws its PDF from the editing data yet). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017ZwZjADUZP4VzvrFGpiZch --- crates/eps/src/import/ai/read.rs | 15 +++++++++++++-- crates/eps/src/import/ai/tests.rs | 12 ++++++++++++ docs/mcp.md | 3 ++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 31d1c965..a068a397 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -311,6 +311,9 @@ const SKIPPED: &[(&str, &str)] = &[ ("Alternate_Content", "End_Versioned_Content"), ]; +/// The note for a `/ForeignObject`, art the editor shows but doesn't edit. +pub const NON_NATIVE_ART: &str = "non-native art (a placed PDF's content) is left out: VectorCraft doesn't draw it from the editing data yet"; + /// A section comment's marker without its `AI_` prefix and value (`AI14_BeginSymbol` → /// `BeginSymbol`); DSC comments (`%%BeginProlog`) keep their `%`. fn marker(c: &str) -> &str { @@ -645,8 +648,13 @@ impl<'a> Reader<'a> { "," => { if let Some(Frame { kind: Kind::Obj(o), base, .. }) = self.frames.last_mut() { obj::add_entry(o, &mut self.stack, *base); - // Binary data in ASCII85 follows, up to its `~>`. - if o.ty == "Binary" && o.entries.last().is_some_and(|(k, _)| k.as_deref() == Some("ASCII85Decode")) { + // Binary data in ASCII85 follows, up to its `~>`: a `/Binary` dictionary's + // (`/ASCII85Decode ,`) or a non-native art object's (`/ForeignObject`'s + // `/Data ,`, the PDF it keeps), in comment lines. ASCII85 has `_`, so a line + // 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")) || (o.ty == "ForeignObject" && key == Some("Data")) { self.lex.skip_past(b"~>"); } } @@ -826,6 +834,9 @@ impl<'a> Reader<'a> { "Document" => self.document(&o), "AI11Text" => self.text_slot(&o)?, "SymbolInstance" => self.unreadable("symbols"), + // Art Illustrator shows but doesn't edit (a placed PDF's content): its PDF is kept in + // the dictionary, which nothing draws from yet, so the file opens without it. + "ForeignObject" => self.warn(NON_NATIVE_ART), _ => {} } Ok(()) diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index bb4429df..87eb5a1b 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -329,6 +329,18 @@ fn sections_it_skips() { assert_eq!(art(&doc).len(), 1); } +/// 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 +/// 28 wouldn't open: "it ends inside a layer"). The art is left out with a note. +#[test] +fn non_native_art_is_skipped_with_its_ascii85_data() { + let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\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 s = read_ok(&layer(&format!("{foreign}{}", rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"))); + assert_eq!(art(&s.doc).len(), 1); + assert!(s.warnings.iter().any(|w| w == read::NON_NATIVE_ART), "{:?}", s.warnings); +} + #[test] fn cmyk_documents() { let doc = read_ok(&format!("%AI9_ColorModel: 2\n{}", layer(""))).doc; diff --git a/docs/mcp.md b/docs/mcp.md index c4ee8a06..5be2e57a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -450,7 +450,8 @@ CMYK too. PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) open through the EPS reader (see EPS and PostScript import). An `.ai` saved without PDF compatibility (its PDF part is only a placeholder page) opens from its editing data alone: its type is made from the file's text document where it can be (point type), and -what can't be is left out with a warning; without editing data it says it can't be opened. +what can't be is left out with a warning; so is non-native art (the content of a placed PDF, which Illustrator shows but +doesn't edit and keeps as a PDF inside the editing data); without editing data it says it can't be opened. What a PDF holds comes in as editable art: soft masks become opacity masks (an alpha mask as a white copy of its art; the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout,