From f69f7976b7089b24a083abde2b9227f06758d59d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 11:42:27 +0700 Subject: [PATCH] .ai import: legacy files without layers open through the format reader Squashed from #5209 onto main. --- crates/engine/tests/import_fuzz.rs | 34 ++++++++++++++++-- crates/eps/src/import.rs | 5 +-- crates/eps/src/import/native/layers.rs | 36 ++++++++++++------- crates/eps/src/import/tests_illustrator.rs | 41 ++++++++++++++++++++++ docs/file-format-parity.md | 2 ++ 5 files changed, 102 insertions(+), 16 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index e1522d83b..2d45f047b 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2070,10 +2070,11 @@ proptest! { /// Hostile PostScript programs: operators in any order with any operands, opened and placed; /// as files in the legacy Illustrator format too (the creator line is what turns on its /// documented `u` … `U` groups), the groups unbalanced, deep and among clips; in a layer, which - /// the editing data reader reads from the program too. + /// the editing data reader reads from the program too, or after a setup without a layer, which + /// it reads with the art on one layer. #[test] fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::(), layers in any::()) { - let head = if illustrator { "%%Creator: Adobe Illustrator(R) 8.0\n%%EndComments\n/u {} def /U {} def" } else { "%%EndComments" }; + let head = if illustrator { "%%Creator: Adobe Illustrator(R) 8.0\n%%EndComments\n/u {} def /U {} def\n%%BeginSetup\n%%EndSetup" } else { "%%EndComments" }; let (open, close) = if layers { ("/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def\n%AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(L) Ln\n", "\nLB\n%AI5_EndLayer--") } else { @@ -3441,6 +3442,35 @@ proptest! { } } +/// A `.ai` in the legacy format without layers (Illustrator 3) whose prolog is included: its +/// program runs, and its art is read on one layer. +const LAYERLESS_AI: &str = "%!PS-Adobe-3.0 EPSF-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 200 100\n%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n\ + %%BeginProlog\n/m {moveto} def /L {lineto} def /f {closepath fill} def /S {stroke} def /A {pop} def /k {setcmykcolor} def /u {} def /U {} def\n\ + %%EndProlog\n%%BeginSetup\n%%EndSetup\n1 A\n0 0 0 1 k\n10 10 m\n90 10 L\n90 90 L\nf\n0 A\nu\n110 10 m\n190 90 L\n150 90 L\nf\nU\n\ + %%PageTrailer\nshowpage\n%%Trailer\n%%EOF\n"; + +/// The fixture, as it is, opens through the reader: its first path keeps its lock. +#[test] +fn the_layerless_fixture_opens_through_the_reader() { + let l = vectorcraft_engine::cmd::fileio::load("x.ai", LAYERLESS_AI.as_bytes()).unwrap(); + assert_eq!(l.doc.layers.len(), 1, "{:?}", l.warnings); + let art = l.doc.layers[0].children().unwrap(); + assert_eq!(art.len(), 2, "{:?}", l.warnings); + assert!(art[0].locked && !art[1].locked); +} + +proptest! { + #![proptest_config(config())] + + /// A `.ai` in the legacy format without layers, damaged: read through the reader, as its page, + /// or refused. + #[test] + fn layerless_ai_never_panics(cut in 0usize..800, edits in prop::collection::vec((0usize..800, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '{', '}', '%', 'u', 'U', 'L', 'f', 'A', 'k'])), 0..12)) { + let text = mutate_text(LAYERLESS_AI, cut, &edits); + survive("mutated layerless .ai", || vectorcraft_engine::cmd::fileio::load("x.ai", text.as_bytes()).ok().map(|l| l.doc))?; + } +} + /// An operand of a layer's options (`Lb`): a flag, a colour index or component, a percentage, or /// anything else. fn arb_layer_operand() -> impl Strategy { diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 5b50cbbcf..9c84001f7 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -18,7 +18,8 @@ //! `%%Creator`) and these operators: Adobe Illustrator File Format Specification, version 7.0 //! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423. A file in that //! format whose program has its layers (`%AI5_BeginLayer`) comes in through them, as `native` reads -//! an Illustrator EPS's editing copy (#1027). One VectorCraft wrote (its prolog the procedure set +//! an Illustrator EPS's editing copy (#1027), and one without layers (Illustrator 3) comes in +//! through its art put on one layer, as its page when that can't be read. One VectorCraft wrote (its prolog the procedure set //! [`crate::legacy_ai::PROCSET`], which prints its gradients flat and leaves its type out) comes in //! from those layers alone, without its page. //! @@ -266,7 +267,7 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result< } let drew = !out.drawn.is_empty(); let why = match (&result, drew) { - (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust)), + (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust, dsc.illustrator)), (Ok(()), true) => return Ok(finish(out)), (Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(), (Err(e), _) => reason(e, fault.as_ref()), diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index 02f739eb6..e8f25370c 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -8,9 +8,17 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect /// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it /// has one that can't be used, `visible` with a warning that says why, with the swatches of the /// editing copy's palette ([`use_palette`]) when it could be read, and with its layer tree, empty, -/// below the page's layer when that could be read (#2033). -pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Imported { - let Some(data) = editing_copy(ps) else { return visible }; +/// below the page's layer when that could be read (#2033). A program in the legacy format without +/// layers (`illustrator`, see [`program_alone`]) is read with its art on one layer; when that +/// can't be used, the file is `visible` as it was. +pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illustrator: bool) -> Imported { + let Some(data) = editing_copy(ps) else { + let read = illustrator.then(|| one_layer(ps)).flatten().and_then(|p| build(&p, Some(&visible.document), Page::Art, false, trust, 1.0).ok()); + return match read { + Some((document, warnings)) => with_page_notes(document, warnings, &visible), + None => visible, + }; + }; // The layer tree is kept from the structure read, for the page to come in on when the layers // can't be used. // Private data kept apart from the program: the program's own header gives the author and @@ -24,14 +32,7 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Im Err(why) => (vec![], Err(why.clone())), }; match result { - Ok((document, mut warnings)) => { - for w in &visible.warnings { - if !warnings.contains(w) { - warnings.push(w.clone()); - } - } - Imported { document, warnings, preview: false } - } + Ok((document, warnings)) => with_page_notes(document, warnings, &visible), Err(why) => { let mut v = visible; if let Ok(data) = &data { @@ -61,6 +62,17 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Im } } +/// `document`, read from the editing copy with the notes `warnings`, followed by the notes of +/// reading `visible` that it doesn't already have. +fn with_page_notes(document: Document, mut warnings: Vec, visible: &Imported) -> Imported { + for w in &visible.warnings { + if !warnings.contains(w) { + warnings.push(w.clone()); + } + } + Imported { document, warnings, preview: false } +} + /// The layers in `nodes` with only their sublayers in them: names, order and options without the art. fn layer_tree(nodes: &[Arc]) -> Vec> { let layers = nodes.iter().filter(|n| matches!(n.kind, NodeKind::Layer { .. })); @@ -256,7 +268,7 @@ pub(in super::super) fn program_alone(ps: &[u8], illustrator: bool) -> Option Vec { + format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%%EndComments\n\ + %%BeginProlog\n/m {{moveto}} def /L {{lineto}} def /f {{closepath fill}} def /A {{pop}} def /k {{setcmykcolor}} def\n\ + %%EndProlog\n%%BeginSetup\n%%EndSetup\n{art}\n%%PageTrailer\nshowpage\n%%Trailer\n%%EOF\n" + ) + .into_bytes() +} + +/// A file in the legacy format without layers whose prolog is included runs as its page, and comes +/// in through its art on one layer when that looks like the page: what only the format keeps (here +/// a lock) stays. +#[test] +fn a_legacy_file_without_layers_comes_in_through_its_art_on_one_layer() { + let file = layerless(&format!("1 A\n0 0 0 1 k\n{}", tri(10))); + let r = import(&file).unwrap(); + assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings); + let d = &r.document; + assert_eq!(d.layers.len(), 1); + let art = d.layers[0].children().unwrap(); + assert_eq!(tree(art), "p"); + assert!(art[0].locked, "the lock is kept"); + // Without the editing data, it is its page, which has no locks. + let page = crate::import::import_with(&file, false).unwrap(); + assert!(!page.document.layers[0].children().unwrap()[0].locked); +} + +/// When its art can't be read as the format (here a procedure of its own draws it), the file comes +/// in as its page, as before, without a note. +#[test] +fn a_legacy_file_without_layers_whose_art_cannot_be_read_comes_in_as_its_page() { + let file = layerless("/x {20 20 moveto 80 20 lineto 80 80 lineto closepath fill} def x"); + let r = import(&file).unwrap(); + assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings); + let page = crate::import::import_with(&file, false).unwrap(); + assert_eq!(tree(r.document.layers[0].children().unwrap()), tree(page.document.layers[0].children().unwrap())); + assert_eq!(r.document.layers[0].children().unwrap()[0].visual_bounds(), page.document.layers[0].children().unwrap()[0].visual_bounds()); +} + /// A version 7 palette writes no swatch names (the AI7 spec's §11 example): its swatches come in /// named by their paint, in its order, in place of the defaults. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0717b5584..c4a348d7d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -43,6 +43,8 @@ private editing data. VectorCraft: write) is its own editing data: it opens through the layers its program has, compared with its page when the program runs, from the layers alone when it names its prolog without including it (#1027), and with its art on one layer when it has no layers (Illustrator 3, and image editors' exported paths: storytold/photocraft#6036). + A file without layers whose program runs opens through that layer too, compared with its page, so its + objects keep what only the format records (locks, for example); it opens as its page when the layer can't be used. A multi-layer mask (`Mb` … `Md` … `MB`) comes in as a clipping group in each layer it covers, each clipped by a copy of the mask's path. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb`