diff --git a/crates/eps/src/import/ai/lex.rs b/crates/eps/src/import/ai/lex.rs index c7904948b..9b81e29a8 100644 --- a/crates/eps/src/import/ai/lex.rs +++ b/crates/eps/src/import/ai/lex.rs @@ -40,6 +40,8 @@ pub(super) struct Token<'a> { /// A placed file's preview (binary): (begin, end) markers. const PREVIEW: (&[u8], &[u8]) = (b"%AI26_BeginPlacedObjectPreview", b"%AI26_EndPlacedObjectPreview"); +/// A placed file's markers, which a hidden one has on `%_` lines. +const PLACE: [&[u8]; 2] = [b"%AI5_BeginPlace", b"%AI5_EndPlace"]; /// A binary section skipped whole: (begin, end) markers. const DOCUMENT: (&[u8], &[u8]) = (b"%%BeginDocument", b"%%EndDocument"); @@ -142,6 +144,10 @@ impl<'a> Lexer<'a> { if start { return Some(Token { tok: Tok::Comment(line.get(1..).unwrap_or_default()), hidden: false }); } + // So does a hidden placed file: its `~` ends what `%_%AI5_BeginPlace` begins. + if prefixed && PLACE.iter().any(|m| line.starts_with(m)) { + return Some(Token { tok: Tok::Comment(line.get(1..).unwrap_or_default()), hidden: true }); + } // A comment later in a line. continue; } diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 52bd7e685..70cbcf17b 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -504,7 +504,9 @@ pub(super) fn elements<'o>(o: &'o Obj, name: &'o str) -> impl Iterator Option<(Vec, u32, u32)> { - let mut rest = data.trim_ascii_start(); + // A placed file's copy on `%_` lines has its header on one too: `%_512 256 2048 32 17 `. + let data = data.trim_ascii_start(); + let mut rest = data.strip_prefix(b"%_").unwrap_or(data); let mut head = [0usize; 5]; for v in &mut head { let n = rest.iter().position(|b| *b == b' ')?; diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index f6ae83957..9b453de24 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -833,6 +833,42 @@ fn placed(visible: bool, matrix: &str, path: &str, preview: Option<&[u8]>, after s } +/// [`placed`]'s file with its placed file written as the copy of an object on `%_` lines (the +/// editable object whose drawn look comes before it): the lines from `%AI5_BeginPlace` to +/// `%AI5_EndPlace` start with `%_`, the versioned content's markers aside. The preview's header +/// line does too; its samples follow it raw. +fn hide_placed(data: &[u8]) -> Vec { + let find = |w: &[u8]| data.windows(w.len()).position(|x| x == w).unwrap(); + let (from, to) = (find(b"%AI5_BeginPlace"), find(b"%AI5_EndPlace\r") + 14); + let mut out = data[..from].to_vec(); + for line in data[from..to].split(|b| *b == b'\r') { + if !line.is_empty() && !line.starts_with(b"%AI17_") { + out.extend_from_slice(b"%_"); + } + out.extend_from_slice(line); + out.push(b'\r'); + } + out.pop(); + out.extend_from_slice(&data[to..]); + out +} + +#[test] +fn a_placed_file_on_commented_lines_is_read() { + // Its `%_%AI5_BeginPlace` was dropped as a comment after `%_`, so the `~` that ends it was an + // operator the reader didn't know, and the whole file came in as its PDF part; and its + // preview, whose header starts with `%_`, wasn't read. + let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat(); + let data = hide_placed(&placed(true, "[5 0 0 -5 0 200]", "/pics/photo.png", Some(&preview), "")); + assert!(data.windows(18).any(|w| w == b"%_%AI5_BeginPlace\r"), "{}", String::from_utf8_lossy(&data)); + let r = read(&data).unwrap(); + let a = art(&r.doc); + let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) }; + assert_eq!((im.link.as_ref().map(|l| l.path.as_str()), im.width, im.height), (Some("/pics/photo.png"), 2, 1)); + let (img, _) = pixels(&r.doc); + assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([255, 0, 0, 255], [0, 127, 0, 128]), "its preview"); +} + #[test] fn placed_files_are_linked_images_showing_their_preview() { // A 2×1 preview (premultiplied ARGB, a padded row) of a file of 4×2 pixels. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cf2fbe39e..77a5bd789 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (PDF export: opaque gradient meshes as smooth Type 6 patch shadings) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: placed files on `%_` lines no longer send the file to its PDF part) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | `.ai` import: a placed (linked) file written as an object's copy on `%_` lines is read: its `%AI5_BeginPlace` and `%AI5_EndPlace` markers there were dropped, so the `~` that ends it was an unknown operator and the whole file came in as its PDF part; its preview's header, on a `%_` line too, is read. Own-fixture test in `import::ai::tests`; scores unchanged | | 2026-10-30 | minor | PDF export: an opaque gradient mesh is written as one PDF Type 6 (Coons patch) shading (ISO 32000-1, 8.7.4.5.7) filling its outline, so viewers blend its colours smoothly instead of showing the bands and seams of the 8 × 8 flat quads a patch it was cut into; a file of 65,558 small meshes exports in 14 s instead of 56 s, at half the size. A mesh with see-through points is still written as quads. Own-fixture tests in `tests::mesh_export`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a file whose PDF part is an empty page opens from its editing data where that reads, instead of opening blank; scores unchanged | | 2026-10-30 | minor | `.ai` import: a file whose PDF part's pages are cropped to its art rather than its artboards no longer comes in as that PDF part (each page a copy of all the art) but from its layers, with its artboards, where they draw as the page does; own-fixture test in `tests_aiimport`; scores unchanged |