From 2d4e9dc9c952d96998394746c1be16f0eb915886 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:05:16 +0300 Subject: [PATCH 1/2] EPS import: the printer's crop marks after the layers aren't art outside them An Illustrator EPS draws its crop marks after the last layer, between %AI3_BeginCrops and %AI3_EndCrops (inside userdict /AI3_noCropMarks known not { ... } if). The reader took them for art outside the layers, and the whole file came in as its page in one layer. The section is now skipped, as other non-art sections are; the crop area is %AI3_Cropmarks. --- crates/eps/src/import/ai/read/types.rs | 3 +++ crates/eps/src/import/ai/tests.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 95423c94e..fccbc6c48 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -246,6 +246,9 @@ pub(super) const SKIPPED: &[(&str, &str)] = &[ ("BeginSymbolList", "EndSymbolList"), ("BeginTextDocument", "EndTextDocument"), ("BeginEncoding", "EndEncoding"), + // The printer's crop marks, drawn after the layers (`userdict /AI3_noCropMarks known not { + // … } if`); the crop area they mark is `%AI3_Cropmarks`. + ("BeginCrops", "EndCrops"), ]; /// The note for versioned content whose `%AI17_End_Versioned_Content` is missing: every diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 1c3ff8acc..813113d5b 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1929,3 +1929,15 @@ fn a_blend_record_is_read_to_its_length() { let (blend, warnings) = read_blend(&blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &longer)); assert!(matches!(blend.kind, NodeKind::Blend { .. }), "{warnings:?}"); } + +/// The printer's crop marks Illustrator draws after the last layer (`%AI3_BeginCrops`, inside +/// `userdict /AI3_noCropMarks known not { … } if`) aren't art outside the layers: the file reads +/// from its layers, without them (the crop area is `%AI3_Cropmarks`). +#[test] +fn crop_marks_after_the_layers_are_not_art() { + let crops = "%AI3_BeginCrops\nuserdict /AI3_noCropMarks known not {\n0 A\nu\n0 R\n1 1 1 1 ([Registration]) 0 XS\n\ + 0 J 0 j 0.5 w 4 M []0 d\n0 XR\n-9 -400 m\n-36 -400 L\nS\nU\n} if\n%AI3_EndCrops\n"; + let r = read_ok(&format!("{}{crops}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))); + assert_eq!(art(&r.doc).len(), 1); + assert_eq!(r.doc.layers.len(), 1); +} From 67c8d86c6ff636653638f7ac3b6b34c0592d2886 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:54:02 +0300 Subject: [PATCH 2/2] .ai import: a page's type inside placed files needs no text object A .ai whose layers hold placed (linked) PDFs and no text object came in as its PDF part, saying its layers had no text objects for its page's text. The PDF part draws each placed file, type and all, where the layers show its preview, so type that lies inside the placed files (at either of their placements) is theirs and needs no text object. Type outside them still does. --- crates/eps/src/import/native/build.rs | 38 +++++++++++++++++++- crates/eps/src/import/tests_native.rs | 51 +++++++++++++++++++++++++++ docs/file-format-parity.md | 3 +- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index c36013cb8..92d80c2cd 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -179,7 +179,12 @@ pub(super) fn build_from( return Err("its type is to come in as outlines, which only its page has".into()); } if infos.iter().all(|i| !i.shown) && !shown.is_empty() { - return Err("its page has text, and its layers have no text objects to put it in".into()); + if !in_placed_files(&shown, &doc, &placed_elsewhere) { + return Err("its page has text, and its layers have no text objects to put it in".into()); + } + // The page's type is all inside placed files: it is theirs, which their previews show, and + // needs no text object. + shown.clear(); } // The page's type is compared with every text object of the layers: that has its limits. if infos.len() > MAX_SLOTS || infos.len().saturating_mul(shown.len()) > MAX_PAIRS { @@ -290,6 +295,37 @@ fn same_size(r: Option, page: &Document) -> bool { (r.width() - p.width()).abs() <= 0.5 && (r.height() - p.height()).abs() <= 0.5 } +/// Does all of `objects` (the page's text objects) lie inside placed files that show in `doc`? The +/// page draws a placed file, type and all, where the layers show its preview (a linked image, see +/// `ai::Reader::end_place`), at its first placement or at the other one that `placed` lists (see +/// [`elsewhere`]). +fn in_placed_files(objects: &[Vec>], doc: &Document, placed: &[(NodeId, Affine)]) -> bool { + let inside = |d: &Document| { + let mut files = vec![]; + placed_files(&d.layers, &mut files); + !files.is_empty() + && objects + .iter() + .all(|o| extent(o).is_some_and(|r| files.iter().any(|f: &Rect| f.inflate(OUTLINE_REACH, OUTLINE_REACH).contains_rect(r)))) + }; + inside(doc) || elsewhere(doc, placed).is_some_and(|other| inside(&other)) +} + +/// Where the placed files that show in `nodes` are: the linked images the editing data's reader +/// makes of them. +fn placed_files(nodes: &[Arc], out: &mut Vec) { + for n in nodes.iter().filter(|n| n.visible) { + match &n.kind { + NodeKind::Image(im) if im.link.is_some() => out.extend(n.visual_bounds()), + _ => { + if let Some(c) = n.children() { + placed_files(c, out); + } + } + } + } +} + /// Does the editing data's first artboard `board` (in the document read from it: art space through /// `to_doc`) have the size of `page`'s first artboard in units of `unit` points, `unit` being above /// 1, and not its size in points? `page` is a PDF part read in points from pages of `unit` points a diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index d4699b06d..d49b89540 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -266,6 +266,57 @@ fn an_ai_file_with_an_image_too_large_for_its_editing_data_comes_in_as_its_pdf_p assert!(read, "{warnings:?}"); } +/// A `.ai` whose layers hold a placed PDF and no text object: its PDF part draws the placed file, +/// type and all, where the layers show the file's preview. That type is the placed file's and needs +/// no text object, so the file comes in from its layers, the placed file where its matrix takes the +/// canvas point of art space's origin or at the corner of its box (see +/// `a_placed_file_at_its_boxs_corner_matches_the_page`). Type outside the placed file still needs +/// one, and without it the file comes in as its PDF part. +#[test] +fn type_of_the_page_inside_a_placed_file_needs_no_text_object() { + // A file placed on (30, 30)–(70, 70) whose preview is 2 × 2 pixels of half-transparent black, + // as in `a_placed_file_at_its_boxs_corner_matches_the_page`; with the template box, the canvas + // point of art space's origin puts it off the page, and the box's corner on it. + let place = format!( + "%AI5_BeginPlace\n%%FileType: 1\n[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0\n(/files/pages.pdf)`\n\ + %AI26_BeginPlacedObjectPreview\n2 2 8 32 17 {}\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n", + "\x7f\0\0\0".repeat(4) + ); + let at_origin = native(&layer("Pages", true, &place)); + let at_corner = at_origin.replace("%AI5_NumLayers", "%AI3_TemplateBox: 100 50 100 50\n%AI5_NumLayers"); + // The PDF part: the placed file's page in 50% grey, and type at (`x`, 55). + let pdf = |x: f64| { + let mut d = Document::new(100.0, 100.0); + let grey = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5))); + let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0)); + let page = Node::path( + d.alloc_id(), + rect, + vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(grey)], ..Default::default() }, + ); + let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() }; + let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(x, 55.0), "Hi", style); + let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(text))); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, page).unwrap(); + d.insert(Some(layer), 1, text).unwrap(); + d + }; + for data in [&at_origin, &at_corner] { + let (d, warnings, read) = crate::layered_ai(&private(data), pdf(38.0), vec![], false, false, 1.0); + assert!(read, "{warnings:?}"); + assert_eq!(names(&d), ["Pages"]); + let mut links = vec![]; + d.visit_images(|_, im| links.extend(im.link.as_ref().map(|l| l.path.clone()))); + assert_eq!(links, ["/files/pages.pdf"], "{warnings:?}"); + assert!(warnings.iter().all(|w| !w.contains("no text object")), "the type is the placed file's, not left out: {warnings:?}"); + // Type beside the placed file is a text object's, which the layers don't have. + let (_, warnings, read) = crate::layered_ai(&private(data), pdf(5.0), vec![], false, false, 1.0); + assert!(!read, "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("its layers have no text objects")), "{warnings:?}"); + } +} + /// A palette of `[None]` and White, and a color group `Inks` holding a spot color, Red ink. const PALETTE: &str = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n1 (Inks) 1 Pg\n0.06 0.98 1 0 (Red ink) 0 x\n(Red ink)\nPc\nPB\n%AI5_EndPalette\n"; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b85ff6dbb..b24f51742 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 (`.ai`/EPS editing data: a gradient mesh takes the transparency written after it) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: a page's type inside placed files needs no text object) · **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 file whose layers hold placed (linked) PDFs and no text object comes in from its layers when all of its page's type lies inside the placed files (at either of their placements): the PDF part draws each placed file, type and all, where the layers show its preview, so that type needs no text object. Such a file came in as its PDF part, saying its layers had no text objects for its page's text; type outside the placed files still needs one. Own-fixture test in `import::tests_native`; scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: a gradient mesh takes the transparency (`Xy`) written right after its `/End X!`, before its `Xd` and `XW`, as a group takes the one after its `U`; it was read as the next object's, so a Multiply shadow mesh painted opaque white over the art under it, where the file's page shows the shadow. A user's file now draws such a mesh as its page does. Own-fixture test in `import::ai::tests`; scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: a gradient mesh corner whose handle weights are 0 (one or both; the others are 1) is read like one whose weights are 1, so a mesh with such corners no longer leaves the whole file coming in as its page or PDF part; other weights still leave the mesh unread. Checked against the pages of two users' files: one opens from its layers 0.6% from its page, the other with 728 live meshes instead of its PDF part's 65,558 patches. Test `import::ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a PDF part whose pages are cropped to the art is recognised in a `UserUnit` page's units too (the art's box in those units, the artboard not the page in points), and its type is read from the first page only, since every such page holds all the art again: the copies on later pages were moved as the first page's and could be put into the layers' text objects. A page cropped to the art is taken only when every page is that box. Tests `import::tests_native::a_user_unit_page_cropped_to_the_art_is_read_in_its_units`, `type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`; scores unchanged |