From 75958ce7dafd3888ac4edd9d77c7bdbb95e48f68 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:17:52 +0300 Subject: [PATCH] fix(ai-import): weigh unseen text again at the placed files' other placement prune_unseen ran before the retry that moves placed files to their box's corner, and that retry cloned the pruned layers: text an image covered only at its first placement was already gone, so the file came in without it or as its PDF part. Clone the layers before pruning and prune each placement on its own. --- crates/eps/src/import/native/build.rs | 23 +++++++----- crates/eps/src/import/tests_native.rs | 51 +++++++++++++++++++++++++++ docs/file-format-parity.md | 3 +- 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 7064ea133..687ea4a9d 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -219,19 +219,26 @@ pub(super) fn build_from( Some(frame) => { let mut view = PageView::new(&frame); view.restyled(&restyled, frame.rect); + // The placed files' other placement starts from the layers as read: which text the page + // doesn't draw is weighed again with the images there, since an image that covered a + // text object at the first placement may not cover it at the other. + let other = elsewhere(&doc, &placed_elsewhere); let unseen = prune_unseen(&frame, &mut view, &mut doc); - if unseen > 0 { - notes.push(format!("{unseen} {UNSEEN_TEXT}")); - } + let unseen_note = |n: usize| (n > 0).then(|| format!("{n} {UNSEEN_TEXT}")); match compare(&frame, &mut view, &doc) { - Ok(note) => notes.extend(note), + Ok(note) => notes.extend(unseen_note(unseen).into_iter().chain(note)), // Placed files at the other point their data gives, where that draws as the page. - Err(why) => match elsewhere(&doc, &placed_elsewhere).map(|other| (compare(&frame, &mut view, &other), other)) { - Some((Ok(note), other)) => { + Err(why) => match other.map(|mut other| { + let unseen = prune_unseen(&frame, &mut view, &mut other); + (compare(&frame, &mut view, &other), other, unseen) + }) { + Some((Ok(note), other, unseen)) => { doc = other; - notes.extend(note); + notes.extend(unseen_note(unseen).into_iter().chain(note)); + } + _ if trust => { + notes.extend(unseen_note(unseen).into_iter().chain([format!("{why}: they come from its editing data all the same")])) } - _ if trust => notes.push(format!("{why}: they come from its editing data all the same")), _ => return Err(why), }, } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index db7960c9f..bb1b867ca 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1628,3 +1628,54 @@ fn a_placed_file_at_its_boxs_corner_matches_the_page() { let b = image.geometric_bounds().unwrap(); assert!([b.x0 - 30.0, b.y0 - 30.0, b.x1 - 70.0, b.y1 - 70.0].iter().all(|v| v.abs() < 0.01), "on the page's square: {b:?}"); } + +#[test] +fn text_a_placed_file_covers_only_at_its_first_placement_is_kept() { + const OPAQUE: &str = ""; + // An opaque black 2 × 2 preview of a file whose box corner the matrix takes to (30, 30)–(70, + // 70), as the page draws it. The template box's canvas point puts it 10 to the left instead, + // over the 8 pt text object "Hi" at (21, 48): there the text can't be seen, and the page has + // no type for it (it draws it as outlines), so the text looks like type the app doesn't draw. + // The layers then differ from the page by little more than `compare` allows, so that is + // weighed. With the file at its box's corner the text shows, as the page has it. + let place = format!( + "%AI5_BeginPlace\n%%FileType: 1\n[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0\n(photo.png)`\n\ + %AI26_BeginPlacedObjectPreview\n2 2 8 32 17 {}\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n", + OPAQUE.repeat(4) + ); + // Canvas centre 8191.5 is the template box's centre (201.5, -191.5): the anchor (8011, 7952) is + // (21, 48) in art space. + let document = text_document(&[("Hi\r", 0, (8011.0, 7952.0), (0.0, 0.0), 0.0)]).replace("/1 14.0", "/1 8.0"); + let data = native_with_text(&layer("Art", true, &(text_object(0) + &place)), &document) + .replace("%AI3_TemplateBox: 50 50 50 50", "%AI3_TemplateBox: 201.5 -191.5 201.5 -191.5"); + // The page (y down): the image's black square, and the type's outlines, a magenta box where + // its glyphs are. + let mut page = Document::new(100.0, 100.0); + let l = page.layers[0].id; + for (rect, color) in [ + (vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0), vectorcraft_color::Color::BLACK), + (vectorcraft_geom::Rect::new(21.5, 46.5, 28.0, 52.0), vectorcraft_color::Color::Cmyk { c: 0.0, m: 1.0, y: 0.0, k: 0.0 }), + ] { + let id = page.alloc_id(); + let fill = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(color)); + let n = Node::path( + id, + vectorcraft_geom::shapes::rectangle(rect), + vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(fill)], ..Default::default() }, + ); + page.insert(Some(l), 0, n).unwrap(); + } + // The preview's pixels are opaque black (ARGB `ff 00 00 00`), bytes a `str` can't hold. + let bytes = + data.replace(OPAQUE, "\u{1}").into_bytes().into_iter().flat_map(|b| if b == 1 { vec![0xff, 0, 0, 0] } else { vec![b] }).collect::>(); + let private = + [b"%AI24_ZStandard_Data".as_slice(), &ruzstd::encoding::compress_to_vec(&bytes[..], ruzstd::encoding::CompressionLevel::Fastest)].concat(); + let (d, w, read) = crate::layered_ai(&private, page, vec![], false, false, 1.0); + assert!(read, "{w:?}"); + let art = d.layers[0].children().unwrap(); + let image = art.iter().find(|n| matches!(n.kind, NodeKind::Image(_))).expect("the image"); + let b = image.geometric_bounds().unwrap(); + assert!([b.x0 - 30.0, b.y0 - 30.0, b.x1 - 70.0, b.y1 - 70.0].iter().all(|v| v.abs() < 0.01), "on the page's square: {b:?}"); + assert_eq!(text_of(&d.layers[0]).iter().map(|t| t.plain_text()).collect::>(), ["Hi"], "{w:?}"); + assert!(!w.iter().any(|w| w.contains("doesn't draw")), "{w:?}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6961c1983..9385ff252 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` import: All Caps and Small Caps type from the editing data keeps its case setting) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: the placed files' other placement weighs the page's unseen text again) · **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: when the layers are compared with the page again with placed files at the corner of their box, the text objects the page doesn't draw are weighed with the images there, from the layers as read: before, the text an image covered at its first placement had already been left out, so a file whose image covered type only there came in without that type or as its PDF part. Test `import::tests_native::text_a_placed_file_covers_only_at_its_first_placement_is_kept`; scores unchanged | | 2026-10-30 | minor | `.ai` import: type read from the editing data's text document keeps its case setting (character attribute 12: 1 Small Caps, at Document Setup's default size, 2 All Caps); it came in as typed, so a heading set in All Caps showed in lower or mixed case where the page draws capitals. Test `import::tests_native::type_from_the_text_document_keeps_its_case_setting`; checked locally against a user's files; scores unchanged | | 2026-10-30 | minor | PDF import: a patch or triangle mesh shading of up to 16,384 patches comes in as gradient meshes (it was 4,096; Illustrator writes up to 14,355 in files users sent), and a stroke painted with one (how Illustrator writes a freeform gradient on a stroke) is the meshes clipped to the area the stroke paints, with its width, caps, joins and dashes; both came in as a flat grey. In two files of a user's set, the formerly grey area now matches a reference rendering in 99% of its pixels. Own-fixture tests in `tests::import_fidelity`; scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: gradient meshes the reader marked unread, which made the whole file come in as its page or PDF part, are read: a mesh starts grey and its `/CS` is written only where it changes, before the corner it applies to (within a patch too), so each corner takes the space last set before it; named colours (`[c m y k /DeviceCMYK (name) /Process]` or `/Spot`) paint at the corner's tint and `/NChannel` inks overprint at theirs; meshes up to 128 patches a side and 16,384 patches are read (files users sent had up to 97 × 72). Ten of eleven such files from a user's set now open from their layers; meshes with handle weights other than 1 still don't. Own-fixture tests in `import::ai::mesh`; scores unchanged |