diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index bf1611fc7..1949a0fa5 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -2,7 +2,7 @@ use super::compare::{MAX_DIFFERENCE, PageView, compare}; use super::plan::plan; use super::slots::{ ART_LEFT_OUT, Content, MAX_PAIRS, MAX_SLOTS, MAX_TEXT, OUTLINE_REACH, TEXT_LEFT_OUT, TYPE_LEFT_OUT, collect_slots, extent, few, fill_slots, - text_objects, + letters, text_objects, words, }; use super::{Affine, Arc, BTreeSet, Document, Node, NodeId, NodeKind, PathData, Rect, Scaling, Story, Structure, Texts, Unit, Vec2, ai}; @@ -178,14 +178,9 @@ pub(super) fn build_from( if outlined && infos.iter().any(|i| i.shown) { 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() { - 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 that needs no text object of the layers: the layers' own legacy text, or + // placed files that show it. + let as_outlines = page_type_elsewhere(infos.is_empty(), infos.iter().all(|i| !i.shown), &mut shown, &mut hidden, &doc, &placed_elsewhere)?; // 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 { return Err("it has too many text objects".into()); @@ -233,6 +228,9 @@ pub(super) fn build_from( if short > 0 { notes.push(format!("{short} {PAGE_TYPE_WITHOUT_OUTLINES}")); } + if as_outlines > 0 { + notes.push(format!("{as_outlines} {PAGE_TYPE_AS_OUTLINES}")); + } if !hidden_unread.is_empty() { notes.push(format!("{ART_LEFT_OUT} ({}), left out of them", few(hidden_unread))); } @@ -304,6 +302,71 @@ fn elsewhere(doc: &Document, placed: &[(NodeId, Affine)]) -> Option { Some(doc) } +/// The page's type that needs no text object of the layers, taken out of `shown` and `hidden` (the +/// page's text objects that show and that don't): how many of them the layers have as outlines. +/// Type in the legacy format (`To` … `TO`, as files saved for Illustrator 10 and earlier keep it) +/// is read as text objects of the layers' own, with no slot (`no_slots`) for the page's type; the +/// page's type that their words don't hold is outlines on the layers, and the comparison with the +/// page says whether they draw it. Without such text and with no slot that shows (`none_shown`), +/// the page's type must all lie inside placed files, which show it. +fn page_type_elsewhere( + no_slots: bool, + none_shown: bool, + shown: &mut Vec>>, + hidden: &mut Vec>>, + doc: &Document, + placed: &[(NodeId, Affine)], +) -> Result { + let mut own = vec![]; + own_text(&doc.layers, &mut own); + let mut as_outlines = 0; + if no_slots && !own.is_empty() { + as_outlines = shown.iter().filter(|o| !in_own_text(o, &own)).count(); + shown.clear(); + hidden.clear(); + } + if none_shown && !shown.is_empty() && own.is_empty() { + if !in_placed_files(shown, doc, placed) { + 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(); + } + Ok(as_outlines) +} + +/// Is the page's text object `object` the layers' own text (`own`, from [`own_text`]): one with its +/// words next to it? The rest of the page's type is what the editing data keeps as outlines (what +/// the legacy format can't hold), and the comparison with the page says whether the layers draw it. +fn in_own_text(object: &[Arc], own: &[(Rect, String)]) -> bool { + let Some(r) = extent(object) else { return false }; + let near: String = + own.iter().filter(|(b, _)| !b.inflate(OUTLINE_REACH, OUTLINE_REACH).intersect(r).is_zero_area()).map(|(_, l)| l.as_str()).collect(); + let page_words: Vec = object + .iter() + .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(words(&t.plain_text().to_lowercase())) } else { None }) + .flatten() + .collect(); + !near.is_empty() && page_words.iter().all(|w| near.contains(w.as_str())) +} + +/// The text objects of their own (not slots for the page's type) that show in `nodes`: their +/// bounds and their letters, in lower case. +fn own_text(nodes: &[Arc], out: &mut Vec<(Rect, String)>) { + for n in nodes.iter().filter(|n| n.visible) { + match &n.kind { + NodeKind::Text(t) => out.extend(n.visual_bounds().map(|b| (b, letters(&t.plain_text().to_lowercase())))), + NodeKind::Compound { .. } => {} + _ => { + if let Some(c) = n.children() { + own_text(c, out); + } + } + } + } +} + /// Has `r` the size of `page`'s first artboard (in points)? fn same_size(r: Option, page: &Document) -> bool { let (Some(r), Some(p)) = (r, page.artboards.first().map(|a| a.rect)) else { return false }; @@ -378,6 +441,9 @@ const UNSEEN_TEXT: &str = "text objects that the file keeps but its page doesn't /// left out. const PAGE_TYPE_WITHOUT_OUTLINES: &str = "text objects hold their type as the page draws it, without the characters the page has only as outlines"; +/// The page's text objects that the layers have as outlines, as the editing data keeps them. +const PAGE_TYPE_AS_OUTLINES: &str = "text objects of the page are outlines on the layers, as the file's editing data keeps them"; + /// How much more of the page (0 to 1) the layers may differ without a text object and still look no worse. const UNSEEN_MARGIN: f64 = 5e-5; diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index fb1c65c91..9ae526552 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -192,6 +192,33 @@ fn text_and_its_strokes_come_from_the_page_into_the_layer_of_its_text_object() { assert!(r.warnings.is_empty(), "{:?}", r.warnings); } +/// A point type object in the legacy format (`To` … `TO`), as an EPS saved for Illustrator 10 +/// keeps its type in the editing data: no `/AI11Text` for the page's type to go in. +fn legacy_text(x: u32, y: u32, text: &str) -> String { + format!("0 To\n1 0 0 1 {x} {y} 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n({text}) Tx\nTO") +} + +#[test] +fn type_in_the_legacy_format_is_the_layers_own_and_the_page_has_text() { + let page = format!("{}\n0 0 0 1 setcmykcolor /Helvetica findfont 12 scalefont setfont 10 50 moveto (Hi) show", page_square(10, 10)); + let art = native(&(layer("Art", true, &square(10, 10)) + &layer("Words", true, &legacy_text(10, 50, "Hi")))); + let r = import(&eps(&page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + assert_eq!(texts[0].plain_text(), "Hi"); + assert!(!r.warnings.iter().any(|w| w.contains("left out") || w.contains("weren't read")), "{:?}", r.warnings); + // Type the editing data keeps as outlines (here a stand-in, a small square where the page has + // more type) comes in as they are, and the import says so. + let page = format!("{page}\n/Helvetica findfont 4 scalefont setfont 60 20 moveto (o) show"); + let outlines = format!("{}\n0 0 0 1 k\n60.5 20 m\n62.5 20 L\n62.5 22 L\n60.5 22 L\nf", legacy_text(10, 50, "Hi")); + let art = native(&(layer("Art", true, &square(10, 10)) + &layer("Words", true, &outlines))); + let r = import(&eps(&page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings); + assert_eq!(kinds(&r.document.layers[1]), ["text", "path"], "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w.starts_with("1 text objects of the page are outlines on the layers")), "{:?}", r.warnings); +} + #[test] fn a_damaged_editing_copy_leaves_the_page() { // Not ASCII85, ASCII85 of something that isn't Zstandard, Zstandard of something that isn't art. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 536c0069b..2dbb65d4d 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 layer's state after its last mesh stays the layer's) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (EPS and `.ai` import: layers whose type is in the legacy format, beside a page with text) · **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 | EPS and `.ai` import: editing data whose type is in the legacy format (`To` … `TO`, as an EPS saved for Illustrator 10 keeps it, with no `/AI11Text` for the page's type to go in) is read through its layers when the page has text: its text objects are the layers' own, and the comparison with the page says whether the layers draw it. Before, such a file came in as its page ("its layers have no text objects to put it in"). Type the editing data keeps only as outlines (what the legacy format can't hold) stays outlines, with a note counting the page's text objects that are; a file whose layers have no text object at all still comes in as its page. Test `import::tests_native::type_in_the_legacy_format_is_the_layers_own_and_the_page_has_text`; checked locally against a user's files (of 6 such EPS that came in as their page, 3 now come in through their layers, within the comparison's limits; 1 is still refused by the comparison for art that isn't type, and 2 have their type only as outlines; no change in the other 284 EPS with editing data and 117 `.ai` files checked); scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: an `Xy` right after a gradient mesh's `/End X!` is the mesh's only when its own `6 () XW` follows; when the layer's `LB` follows (directly or after the layer's `Xd` and `7 () XW`) it is the layer's, as before #4744, and the mesh keeps the state it had. Test `import::ai::tests::a_layer_keeps_its_own_transparency`; scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: type on a path is spaced round curves as the file's own layout spaces it, closer round the outside and further apart round the inside, though its frame keeps no spacing: the Type on a Path Options › Spacing that does that is fitted from the layout's glyph positions, where it came in at the font's advances and ran on past where the page ends it. In 8 files of a user's set with type round curves (19 stories), the fitted spacing is a fifth of the line's ascent outside a curve (17 stories) and half of it inside (2), and the 4 files whose pages were measured space their glyphs as their layouts do. Own-fixture tests in `import::tests_native` and `import::ate`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a placed (linked) file whose data gives two placements is put at the corner of its box when that draws closer to the page and the layers still match it, not only when the first placement fails the comparison: a small logo in the wrong place differed too little to fail, and stayed there. Test `import::tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`; scores unchanged |