From 47b20b436698fc04536921617a7f189bfd406ac3 Mon Sep 17 00:00:00 2001 From: jmverges Date: Tue, 10 Nov 2026 04:24:12 +0000 Subject: [PATCH 1/6] .ai import: envelopes of type open live where they draw as their drawn art An envelope whose contents hold type kept its drawn art, with the type hidden, because the type is laid out from the text document only after the layers are read. It now waits for that, as warped type does: once its text slots hold their type, the envelope is made of that type and drawn by VectorCraft's own rule, and where it draws as the drawn art the file saves (the same check and budget as other envelopes) it takes the drawn art's place, with the type visible and editable in Edit Contents. The hidden copy of the type goes. As other live envelopes are, it is held to the page: when the layers with it don't draw as the page, its drawn art and hidden type are put back. A .ai whose layers would open alone, without its PDF part, now waits on the PDF part when an envelope of type is in them; where none opens live, the layers open alone as before. Layers built without a page leave envelopes of type their drawn art. Elsewhere it keeps its drawn art and hidden type, with the note: type in a font that isn't available, a slot without made type (threaded, bound to a text variable, a wrap object, type a look draws), an envelope that doesn't read, no check left, or one that draws differently. Envelopes in a symbol's or a pattern's art keep their drawn art. Part of #3179. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai.rs | 1 - crates/eps/src/import/ai/envelope.rs | 178 +++++++++++++- crates/eps/src/import/ai/read.rs | 4 + crates/eps/src/import/ai/read/containers.rs | 55 ++++- crates/eps/src/import/ai/read/document.rs | 11 +- crates/eps/src/import/ai/read/types.rs | 3 + crates/eps/src/import/ai/tests.rs | 2 +- crates/eps/src/import/native/build.rs | 66 +++-- crates/eps/src/import/native/layers.rs | 49 +++- crates/eps/src/import/tests_native.rs | 253 ++++++++++++++++++++ docs/file-format-parity.md | 4 +- docs/target-app-parity.md | 2 +- 12 files changed, 579 insertions(+), 49 deletions(-) diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 5f4a5b631..be2d679a7 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -37,7 +37,6 @@ use crate::ps; pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; #[cfg(test)] pub(crate) use envelope::MAX_CHECKED; -#[cfg(test)] pub(crate) use envelope::TYPE_DRAWN as ENVELOPE_TYPE_DRAWN; pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType}; pub(crate) use frames::text_frames; diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index 3368a97be..04b859946 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -23,7 +23,7 @@ //! one-patch mesh. An envelope whose parts don't read, or that doesn't come out as drawn, keeps its //! drawn art. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Arc; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; @@ -46,6 +46,12 @@ pub(crate) const DRAWN: &str = "an envelope VectorCraft couldn't read as a live pub(crate) const TYPE_DRAWN: &str = "type in an envelope comes in as the envelope's drawn art, not as editable type"; /// The note for warped type that keeps its drawn art, before how many. pub(crate) const WARPED_TYPE_DRAWN: &str = "warped text objects that came in as drawn art, not as editable type"; + +/// The note for an envelope that keeps its drawn art: [`TYPE_DRAWN`] for an envelope of type +/// (`typed`), else [`DRAWN`]. +pub(crate) fn drawn_note(typed: bool) -> &'static str { + if typed { TYPE_DRAWN } else { DRAWN } +} /// The most envelopes waiting for their drawn art at once. pub(super) const MAX_PENDING: usize = 64; /// The most envelopes and warped text objects one import draws to check; more keep their drawn art. @@ -371,18 +377,157 @@ pub(super) struct WarpedLook { pub(super) left_out: Option, } -/// The text objects read with a Warp, and what the import has left of the budget for checks -/// ([`drawn_alike`]). Type is laid out after the layers are read, so these wait for that -/// ([`Self::settle`]). +/// An envelope of type read with its drawn art: its dictionary, the group on `%_` lines before it +/// and the art in its `AIDeformArtKey` block, as [`live`] takes them (their text slots still +/// empty), and the id of its drawn art in the document. Type is laid out after the layers are read, +/// so it waits for that ([`WarpedType::settle_envelopes`]). +#[derive(Debug)] +pub(crate) struct TypedEnvelope { + pub(super) obj: Obj, + pub(super) before: Option, + pub(super) inner: Vec, + pub(super) drawn: NodeId, +} + +/// Why an envelope of type kept its drawn art. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TypeKept { + /// Its type is set in a font that isn't available: the fallback font doesn't draw as the file. + Font, + /// A text slot of it has no type of its own (none made, or type a look draws, threaded type, + /// type bound to a variable or a wrap object). + Type, + /// Its parts don't make a live envelope, or its drawn art is gone. + Read, + /// The import has no check left. + Budget, + /// Its live envelope doesn't draw as its drawn art. + Differs, +} + +/// What [`WarpedType::settle_envelopes`] did. +#[derive(Debug, Default)] +pub(crate) struct TypedSettled { + /// How many envelopes of type opened live. + pub(crate) live: usize, + /// Why each of the others kept its drawn art. + pub(crate) kept: Vec, + /// Each live envelope's id, with what takes its place again when the layers with it don't draw + /// as the page: its hidden type, then its drawn art. + pub(crate) put_back: Vec<(NodeId, Vec)>, +} + +/// The text objects read with a Warp and the envelopes of type, and what the import has left of the +/// budget for checks ([`drawn_alike`]). Type is laid out after the layers are read, so these wait +/// for that ([`Self::settle`], [`Self::settle_envelopes`]). #[derive(Debug, Default)] pub struct WarpedType { pub(super) looks: Vec, + pub(super) envelopes: Vec, pub(super) checks: usize, pub(super) spent: Spent, pub(super) page: Option, } impl WarpedType { + /// Are envelopes of type waiting for their type? + pub(crate) fn has_envelopes(&self) -> bool { + !self.envelopes.is_empty() + } + + /// These, for layers `compared` with a page. Layers that aren't leave the envelopes of type + /// their drawn art and their hidden type, with the note ([`TYPE_DRAWN`]) added to `notes`: a + /// live envelope opens only where the page chooses it. + pub(crate) fn compared(mut self, compared: bool, notes: &mut Vec) -> Self { + if !compared && !std::mem::take(&mut self.envelopes).is_empty() && !notes.iter().any(|n| n == TYPE_DRAWN) { + notes.push(TYPE_DRAWN.into()); + } + self + } + + /// Make each envelope of type live where it draws as its drawn art, its contents' text slots + /// holding their type: `type_of` gives, for a slot's id, the hidden text object it became in + /// `doc` and the type the contents take in its place (the slot's visibility and lock are the + /// type's). The live envelope takes its drawn art's place and the hidden type goes. Each check + /// uses one of the import's checks and, for an envelope that shows, adds to what the page check + /// allows ([`drawn_alike`]). Elsewhere, and where the type is set in a font that isn't + /// available, the drawn art and the hidden type stay. + pub(crate) fn settle_envelopes(&mut self, doc: &mut Document, type_of: impl Fn(&Document, NodeId) -> Option<(NodeId, Node)>) -> TypedSettled { + let mut settled = TypedSettled::default(); + if self.envelopes.is_empty() { + return settled; + } + let mut shown = BTreeSet::new(); + doc.layers.iter().for_each(|l| collect_shown(l, &mut shown)); + for envelope in std::mem::take(&mut self.envelopes) { + match self.typed_live(doc, envelope, &type_of, &shown) { + Ok(put_back) => { + settled.live += 1; + settled.put_back.push(put_back); + } + Err(why) => settled.kept.push(why), + } + } + settled + } + + /// [`Self::settle_envelopes`] for `envelope`, the shown objects of `doc` being `shown`: the live + /// envelope's id and what takes its place again, or why its drawn art stays. + fn typed_live( + &mut self, + doc: &mut Document, + envelope: TypedEnvelope, + type_of: &impl Fn(&Document, NodeId) -> Option<(NodeId, Node)>, + shown: &BTreeSet, + ) -> Result<(NodeId, Vec), TypeKept> { + let TypedEnvelope { obj, mut before, mut inner, drawn } = envelope; + let art = doc.node(drawn).cloned().ok_or(TypeKept::Read)?; + // The text slots in the contents, in the order the reader put their hidden copies in. + let mut slots = vec![]; + before.iter().chain(&inner).for_each(|n| text_slots_in(n, &mut slots)); + if slots.is_empty() { + return Err(TypeKept::Type); + } + let db = vectorcraft_text::FontDb::global(); + let (mut texts, mut hidden) = (HashMap::new(), vec![]); + for (slot, look) in slots { + let (id, mut text) = type_of(doc, slot).filter(|_| !look).ok_or(TypeKept::Type)?; + let NodeKind::Text(t) = &text.kind else { return Err(TypeKept::Type) }; + if t.runs.iter().any(|r| r.style.font_family.is_empty() || !db.has_family(&r.style.font_family)) { + return Err(TypeKept::Font); + } + text.id = doc.alloc_id(); + texts.insert(slot, text); + hidden.push(id); + } + for n in before.iter_mut().chain(inner.iter_mut()) { + put_type(n, &texts); + } + let kind = live(&obj, before, inner).ok_or(TypeKept::Read)?; + if self.checks == 0 { + return Err(TypeKept::Budget); + } + self.checks -= 1; + let mut envelope = art.clone(); + (envelope.id, envelope.kind) = (doc.alloc_id(), kind); + let layer = doc.alloc_id(); + let shared = Shared { patterns: &doc.patterns, images: &doc.images }; + let (mut live_one, mut plain) = (envelope.clone(), art.clone()); + (live_one.visible, live_one.opacity) = (true, 1.0); + (plain.visible, plain.opacity) = (true, 1.0); + let page = self.page.filter(|_| shown.contains(&drawn)); + if !drawn_alike(&live_one, &plain, layer, &shared, page, &mut self.spent) { + return Err(TypeKept::Differs); + } + // The type is the envelope's now: its hidden copy goes, kept for when the drawn art comes back. + let mut put_back: Vec = hidden.into_iter().filter_map(|id| doc.remove(id).ok()).map(Arc::unwrap_or_clone).collect(); + let id = envelope.id; + let Some(slot) = doc.node_mut(drawn) else { return Err(TypeKept::Read) }; + *slot = envelope; + put_back.push(art); + Ok((id, put_back)) + } + /// Replace each warped text object of `doc` that doesn't draw as its saved look (or that the /// budget has no check left for) with that look: `made` gives the text object a slot became. A /// frame in `threaded` stays type, since the frames of a thread share one story. A replaced @@ -395,7 +540,7 @@ impl WarpedType { threaded: &BTreeSet, restyled: &mut Vec, ) -> usize { - let Self { looks, mut checks, mut spent, page } = self; + let Self { looks, mut checks, mut spent, page, .. } = self; let mut shown_ids = BTreeSet::new(); doc.layers.iter().for_each(|l| collect_shown(l, &mut shown_ids)); let mut replaced = 0; @@ -439,6 +584,29 @@ impl WarpedType { } } +/// The ids of the text slots in `n` and its groups, in order, each with whether it is a slot of type +/// that a look draws ([`super::is_look_slot`]). +fn text_slots_in(n: &Node, out: &mut Vec<(NodeId, bool)>) { + let look = super::is_look_slot(n.name.as_deref()); + if look || super::slot_of(n.name.as_deref()).is_some() { + out.push((n.id, look)); + } else if !matches!(n.kind, NodeKind::Compound { .. }) { + n.children().into_iter().flatten().for_each(|c| text_slots_in(c, out)); + } +} + +/// `n` with each text slot in it or its groups replaced by its type in `texts`, by the slot's id, +/// shown and locked as the slot is. +fn put_type(n: &mut Node, texts: &HashMap) { + if let Some(text) = texts.get(&n.id) { + let (visible, locked) = (n.visible, n.locked); + *n = text.clone(); + (n.visible, n.locked) = (visible, locked); + } else if !matches!(n.kind, NodeKind::Compound { .. }) { + n.children_mut().into_iter().flatten().for_each(|c| put_type(Arc::make_mut(c), texts)); + } +} + /// Add to `out` the ids of `n` and what is in it, unless hidden. fn collect_shown(n: &Node, out: &mut BTreeSet) { if n.visible { diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index cb0bc1909..5a253f21a 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -557,6 +557,9 @@ struct Reader<'a> { envelope_spent: super::envelope::Spent, /// The text objects read with a Warp ([`super::envelope::WarpedType`]). warped_type: Vec, + /// The envelopes of type read with their drawn art, settled once the type is laid out + /// ([`super::envelope::TypedEnvelope`]). + typed_envelopes: Vec, /// The live envelopes made so far and the drawn art each took the place of /// ([`Structure::envelopes_drawn`]). envelopes_drawn: Vec<(NodeId, Node)>, @@ -769,6 +772,7 @@ impl<'a> Reader<'a> { envelope_checks: super::envelope::MAX_CHECKED, envelope_spent: super::envelope::Spent::default(), warped_type: Vec::new(), + typed_envelopes: Vec::new(), envelopes_drawn: Vec::new(), clip_fills: HashSet::new(), looks: HashSet::new(), diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 3de4daadf..02f6f8097 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -516,17 +516,17 @@ impl<'a> Reader<'a> { slot.visible = false; self.add(slot, false)?; } - // An envelope of type keeps its drawn art and its type, hidden, as above: the - // type is filled in after reading, which the envelope can't wait for. - if typed { - self.warn(crate::import::ai::envelope::TYPE_DRAWN); - return Ok(()); - } - if self.envelopes.len() >= crate::import::ai::envelope::MAX_PENDING { - self.warn(crate::import::ai::envelope::DRAWN); + // An envelope of type keeps its drawn art and its type, hidden, as above, until + // the type is filled in after reading: then it opens live where it draws as its + // drawn art ([`crate::import::ai::envelope::TypedEnvelope`]). In a symbol's or a + // pattern's art it keeps them. + let waits = !typed || (self.symbol.is_none() && self.pattern.is_none()); + if !waits || self.envelopes.len() >= crate::import::ai::envelope::MAX_PENDING { + self.warn(crate::import::ai::envelope::drawn_note(typed)); } else { let siblings = self.frames.last().map_or(0, |f| f.children.len()); - self.envelopes.push(PendingEnvelope { obj: *o, before, inner: children, depth: self.frames.len(), siblings }); + let depth = self.frames.len(); + self.envelopes.push(PendingEnvelope { obj: *o, before, inner: children, depth, siblings, typed }); } } } @@ -863,9 +863,8 @@ impl<'a> Reader<'a> { /// in containers that closed keep their drawn art. pub(super) fn end_envelope(&mut self) { let depth = self.frames.len(); - while self.envelopes.last().is_some_and(|p| p.depth > depth) { - self.envelopes.pop(); - self.warn(crate::import::ai::envelope::DRAWN); + while let Some(p) = self.envelopes.pop_if(|p| p.depth > depth) { + self.warn(crate::import::ai::envelope::drawn_note(p.typed)); } if self.envelopes.last().is_none_or(|p| p.depth != depth) { return; @@ -873,9 +872,13 @@ impl<'a> Reader<'a> { let Some(p) = self.envelopes.pop() else { return }; let drawn = self.frames.last().filter(|f| f.children.len() == p.siblings + 1).and_then(|f| f.children.last()); let Some(drawn) = drawn else { - self.warn(crate::import::ai::envelope::DRAWN); + self.warn(crate::import::ai::envelope::drawn_note(p.typed)); return; }; + if p.typed { + self.wait_for_type(p); + return; + } let live = (self.envelope_checks > 0).then(|| crate::import::ai::envelope::live(&p.obj, p.before, p.inner)).flatten(); let Some(kind) = live else { self.warn(crate::import::ai::envelope::DRAWN); @@ -932,6 +935,32 @@ impl<'a> Reader<'a> { } } +impl Reader<'_> { + /// An envelope of type `p` whose drawn art was just read (the last object of this container): + /// it waits for its type to be laid out ([`crate::import::ai::envelope::TypedEnvelope`]), unless + /// objects the reader couldn't read are in it, or too many already wait. Then it keeps its + /// drawn art and its type, hidden. + fn wait_for_type(&mut self, p: PendingEnvelope) { + let Some(drawn) = self.frames.last().and_then(|f| f.children.last()) else { return }; + let mut ids = HashSet::new(); + fn collect(n: &Node, ids: &mut HashSet) { + ids.insert(n.id); + for c in n.children().into_iter().flatten() { + collect(c, ids); + } + } + collect(drawn, &mut ids); + p.before.iter().chain(&p.inner).for_each(|n| collect(n, &mut ids)); + let unread = self.unread.iter().any(|(id, _)| ids.contains(id)); + if unread || self.typed_envelopes.len() >= crate::import::ai::envelope::MAX_CHECKED { + self.warn(crate::import::ai::envelope::TYPE_DRAWN); + return; + } + let drawn = drawn.id; + self.typed_envelopes.push(crate::import::ai::envelope::TypedEnvelope { obj: p.obj, before: p.before, inner: p.inner, drawn }); + } +} + /// Does `node` hold a text slot? fn has_text_slot(node: &Node) -> bool { let mut slots = vec![]; diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 1b962064c..63e9f3df7 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1165,9 +1165,8 @@ impl<'a> Reader<'a> { self.warn(super::types::GRAPH_UNENDED); } // Envelopes whose plugin never closed them keep their drawn art. - if !self.envelopes.is_empty() { - self.envelopes.clear(); - self.warn(crate::import::ai::envelope::DRAWN); + for p in std::mem::take(&mut self.envelopes) { + self.warn(crate::import::ai::envelope::drawn_note(p.typed)); } if !self.unknown.is_empty() { let ops: Vec = self.unknown.iter().map(|o| format!("`{o}`")).collect(); @@ -1297,6 +1296,11 @@ impl<'a> Reader<'a> { for w in &mut self.warped_type { vectorcraft_doc::swatches::map_node_solid_paints(&mut w.look, &mut follow); } + for e in &mut self.typed_envelopes { + for n in e.before.iter_mut().chain(e.inner.iter_mut()) { + vectorcraft_doc::swatches::map_node_solid_paints(n, &mut follow); + } + } } let mut warnings = self.warnings; if let Some((lib, unread)) = brushes { @@ -1341,6 +1345,7 @@ impl<'a> Reader<'a> { envelopes_drawn: self.envelopes_drawn, warped_type: super::super::envelope::WarpedType { looks: self.warped_type, + envelopes: self.typed_envelopes, checks: self.envelope_checks, spent: self.envelope_spent, page, diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 473a07079..6457c1cde 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -224,6 +224,9 @@ pub(super) struct PendingEnvelope { /// had: its drawn art is the one child after them. pub(super) depth: usize, pub(super) siblings: usize, + /// Its contents hold type: it is settled once the type is laid out + /// ([`super::super::envelope::TypedEnvelope`]). + pub(super) typed: bool, } /// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 2f0fca73b..2b127e275 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -7992,7 +7992,7 @@ fn warped_type_on_the_page_spends_what_is_left_of_the_budget() { let copy = |w: &super::envelope::WarpedLook| super::envelope::WarpedLook { slot: w.slot, look: w.look.clone(), left_out: None }; let page = Some(Rect::new(0.0, 60.0, 60.0, 100.0)); let settle = |w: &super::envelope::WarpedLook, spent| { - let warped = super::envelope::WarpedType { looks: vec![copy(w)], checks: 1, spent, page }; + let warped = super::envelope::WarpedType { looks: vec![copy(w)], envelopes: vec![], checks: 1, spent, page }; warped.settle(&mut doc.clone(), Some, &Default::default(), &mut vec![]) }; let spent = || super::envelope::Spent { any: MAX_DIFFERENCE / 2.0, solid: MAX_SOLID_DIFFERENCE / 2.0 }; diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index e0d00ac82..4266b945c 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -104,8 +104,7 @@ pub(super) type Built = Result<(Document, Vec), String>; /// units ([`page_scale_alone`]). The document is drawn at that scale. Else `Err(structure)`, for /// [`build_from`] with the page. pub(super) fn build_without_page(structure: Structure, data: &[u8], pages: &[(f64, f64)], unit: f64) -> Result> { - let Some(scale) = page_scale_alone(&structure, pages, unit).filter(|_| nothing_waits_on_page(&structure) && type_all_made(&structure, data)) - else { + let Some(scale) = alone_scale(&structure, data, pages, unit) else { return Err(Box::new(structure)); }; let styles = structure.styles_read; @@ -120,6 +119,12 @@ pub(super) fn build_without_page(structure: Structure, data: &[u8], pages: &[(f6 Ok(built) } +/// The scale [`build_without_page`] draws `structure` (the editing copy `data` read) at on `pages` +/// in units of `unit` points, when it builds it without the page. +pub(super) fn alone_scale(structure: &Structure, data: &[u8], pages: &[(f64, f64)], unit: f64) -> Option { + page_scale_alone(structure, pages, unit).filter(|_| nothing_waits_on_page(structure) && type_all_made(structure, data)) +} + /// Is nothing in `structure` waiting on the page: no object unread (the page's art takes its /// place), no placed file with another placement, no live envelope with the drawn art it replaced /// (the comparison with the page chooses between them), no linked image showing a placeholder @@ -425,7 +430,17 @@ pub(super) fn build_from( // Area type flows around the wrap objects above it before it is compared with the page. resolve_text_wrap(&mut doc)?; let bound_text = text_bound_slots(&doc, &bindings, &xml_ids); - settle_warped_type(warped_type, &mut doc, &filled, &threaded_frames, &bound_text, &mut restyled, &mut notes)?; + // Without a page to compare the layers with, envelopes of type keep their drawn art: like the + // other live envelopes, one opens live only where the layers with it draw as the page. + let warped_type = warped_type.compared(frame.is_some(), &mut notes); + let typed = settle_warped_type(warped_type, &mut doc, &filled, &threaded_frames, &bound_text, &mut restyled, &mut notes)?; + // The live envelopes and what takes the place of each again, when the layers with them don't + // draw as the page, with the note that says so. + let envelopes_drawn: Vec<(NodeId, Vec, &str)> = envelopes_drawn + .into_iter() + .map(|(id, art)| (id, vec![art], ai::ENVELOPE_DRAWN)) + .chain(typed.into_iter().map(|(id, nodes)| (id, nodes, ai::ENVELOPE_TYPE_DRAWN))) + .collect(); if empty > 0 { notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT })); } @@ -630,26 +645,44 @@ fn text_bound_slots(doc: &Document, bindings: &[ai::BindingRef], ids: &std::coll /// (`bound_text`, by slot). Elsewhere the look takes its place, and one note says how many did. /// `filled` says what each slot became, and the comparisons with the page leave out the boxes in /// `restyled`. Fails as [`resolve_text_wrap`] does when the looks give the wrap shapes too many -/// anchors. +/// anchors. Before that, the envelopes of type open live where they draw as their drawn art, with +/// the type of their text slots (made type, one text object for the slot, not threaded, bound to +/// no text variable and no wrap object); the others keep their drawn art, with a note. Returns +/// each live envelope of type's id with what takes its place again (its hidden type, then its +/// drawn art). fn settle_warped_type( - warped: ai::WarpedType, + mut warped: ai::WarpedType, doc: &mut Document, filled: &std::collections::HashMap, threaded: &BTreeSet, bound_text: &BTreeSet, restyled: &mut Vec, notes: &mut Vec, -) -> Result<(), String> { +) -> Result)>, String> { let made = |slot| match filled.get(&slot).map(|f| f.text.as_slice()).filter(|_| !bound_text.contains(&slot)) { Some(&[one]) => Some(one), _ => None, }; + // An envelope's type: the one text object a slot became (hidden, with nothing else), named as + // the slot (made type is no longer weighed against the page: the envelope draws it). + let type_of = |doc: &Document, slot: NodeId| { + let id = made(slot).filter(|id| !threaded.contains(id) && filled.get(&slot).is_some_and(|f| f.objects == [*id]))?; + let mut text = doc.node(id).filter(|n| matches!(n.kind, NodeKind::Text(_)) && n.extras().wrap.is_none())?.clone(); + if let Some(name) = text.name.as_deref().and_then(|n| n.strip_prefix(MADE)) { + text.name = (!name.is_empty()).then(|| name.to_string()); + } + Some((id, text)) + }; + let typed = warped.settle_envelopes(doc, type_of); + if !typed.kept.is_empty() && !notes.iter().any(|n| n == ai::ENVELOPE_TYPE_DRAWN) { + notes.push(ai::ENVELOPE_TYPE_DRAWN.into()); + } let replaced = warped.settle(doc, made, threaded, restyled); if replaced > 0 { notes.push(format!("{}: {replaced}", ai::WARPED_TYPE_DRAWN)); resolve_text_wrap(doc)?; } - Ok(()) + Ok(typed.put_back) } /// Most anchors copied into the wrap shapes of a file's area type ([`vectorcraft_doc::text::wrap_work`]). @@ -1261,7 +1294,7 @@ fn drawn_or_trusted( frame: &Frame<'_>, view: &mut PageView, doc: &mut Document, - drawn: &[(NodeId, Node)], + drawn: &[(NodeId, Vec, &str)], notes: &[String], unseen: usize, trust: bool, @@ -1270,18 +1303,19 @@ fn drawn_or_trusted( ) -> Result, String> { let unseen_note = |n: usize| (n > 0).then(|| format!("{n} {UNSEEN_TEXT}")); let mut put_back = doc.clone(); - let mut any = false; - for (id, art) in drawn { - if let Some(n) = put_back.node_mut(*id) { - *n = art.clone(); - any = true; - } - } + // Each envelope still there, in place of the art it took the place of. + let there: Vec<&(NodeId, Vec, &str)> = drawn.iter().filter(|(id, ..)| put_back.node(*id).is_some()).collect(); + let any = !there.is_empty() && put_back.insert_beside(there.iter().map(|(id, art, _)| (*id, art.clone())).collect(), true).is_ok(); if any { let more = prune_unseen(frame, view, &mut put_back, reflow); if let Ok(note) = compare(frame, view, &put_back, reflow) { *doc = put_back; - let envelope = (!notes.iter().any(|n| n == ai::ENVELOPE_DRAWN)).then(|| ai::ENVELOPE_DRAWN.to_string()); + let mut envelope: Vec = vec![]; + for (.., say) in there { + if !notes.iter().chain(&envelope).any(|n| n == say) { + envelope.push(say.to_string()); + } + } return Ok(envelope.into_iter().chain(unseen_note(unseen + more)).chain(note).collect()); } } diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index c0331574c..ee39aec1f 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -1,4 +1,4 @@ -use super::build::{PAGE_MOVE, Page, build, build_from, build_on_page, build_without_page, reid_page_art}; +use super::build::{PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_without_page, reid_page_art}; use super::slots::LAYERS_UNREAD; use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai}; @@ -137,7 +137,7 @@ pub fn layered_ai_from( all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part")); all }; - let AiLayers { data, structure } = layers; + let AiLayers { data, structure, alone } = layers; let data = match data { Ok(data) => data, Err(why) => return (visible, unread(warnings, &why), false), @@ -180,6 +180,18 @@ pub fn layered_ai_from( let built = structure .unwrap_or_else(|| ai::read_over_page(&data, false, names).map(Box::new)) .and_then(|structure| build_from(*structure, &data, Some(&visible), Page::Artboard, outlined, trust, unit)); + // Layers that waited on the page only for their envelopes of type open alone, as they would + // have without them, each envelope keeping its drawn art, unless one opened live with the page. + let live = matches!(&built, Ok((done, _)) if holds_type_envelope(&done.layers)); + if !live + && let Some((mut done, notes)) = alone.and_then(|(pages, unit)| { + let structure = ai::read_over_page(&data, false, names).ok()?; + build_without_page(structure, &data, &pages, unit).ok()?.ok() + }) + { + done.setup.bleed = visible.setup.bleed; + return (done, notes, true); + } match built { Ok((mut done, notes)) => { done.setup.bleed = visible.setup.bleed; @@ -191,6 +203,17 @@ pub fn layered_ai_from( } } +/// Is there an envelope of type (one whose contents hold type) in `nodes` or what they hold? +fn holds_type_envelope(nodes: &[Arc]) -> bool { + fn holds_type(n: &Node) -> bool { + matches!(n.kind, NodeKind::Text(_)) || n.children().is_some_and(|c| c.iter().any(|c| holds_type(c))) + } + nodes.iter().any(|n| match &n.kind { + NodeKind::Envelope { content, .. } => content.iter().any(|c| holds_type(c)), + _ => n.children().is_some_and(|c| holds_type_envelope(c)), + }) +} + /// The `.ai` whose editing copy is `private` (as [`layered_ai`] takes it) from its layers alone, /// without its PDF part, whose pages (each its trim box in points, width and height) are `pages` /// and are in units of `unit` points (its first page's `UserUnit`): the document and the notes of @@ -201,20 +224,27 @@ pub fn layered_ai_from( pub fn ai_layers_alone(private: &[u8], pages: &[(f64, f64)], unit: f64, names: ai::NameEncoding) -> Result<(Document, Vec), AiLayers> { let data = match ai::decode_private(private) { Ok(data) => data, - Err(why) => return Err(AiLayers { data: Err(why), structure: None }), + Err(why) => return Err(AiLayers { data: Err(why), structure: None, alone: None }), }; if !has_layers(&data) { - return Err(AiLayers { data: Ok(data), structure: None }); + return Err(AiLayers { data: Ok(data), structure: None, alone: None }); } // An image past the hard limits is read as an unread box, which the PDF part's art replaces. let structure = match ai::read_over_page(&data, false, names) { Ok(structure) => structure, - Err(why) => return Err(AiLayers { data: Ok(data), structure: Some(Err(why)) }), + Err(why) => return Err(AiLayers { data: Ok(data), structure: Some(Err(why)), alone: None }), }; + // An envelope of type opens live only where the layers with it draw as the page, as the other + // live envelopes do: the PDF part is imported to compare them with. Should none open live, the + // layers open alone as they would have, the envelopes keeping their drawn art ([`layered_ai_from`]). + if structure.warped_type.has_envelopes() && alone_scale(&structure, &data, pages, unit).is_some() { + let alone = Some((pages.to_vec(), unit)); + return Err(AiLayers { data: Ok(data), structure: Some(Ok(Box::new(structure))), alone }); + } match build_without_page(structure, &data, pages, unit) { Ok(Ok((doc, notes))) => Ok((doc, notes)), - Ok(_) => Err(AiLayers { data: Ok(data), structure: None }), - Err(structure) => Err(AiLayers { data: Ok(data), structure: Some(Ok(structure)) }), + Ok(_) => Err(AiLayers { data: Ok(data), structure: None, alone: None }), + Err(structure) => Err(AiLayers { data: Ok(data), structure: Some(Ok(structure)), alone: None }), } } @@ -226,12 +256,15 @@ pub struct AiLayers { /// Its layers read with [`ai::read`] as [`layered_ai`] reads them, or why they can't be; `None` /// when they are still to be read. structure: Option, String>>, + /// The pages and their unit the layers open on without the PDF part, when they wait on it only + /// for their envelopes of type ([`ai_layers_alone`]). + alone: Option<(Vec<(f64, f64)>, f64)>, } impl AiLayers { /// The editing copy `private` (as [`layered_ai`] takes it), decoded. pub fn decode(private: &[u8]) -> Self { - Self { data: ai::decode_private(private), structure: None } + Self { data: ai::decode_private(private), structure: None, alone: None } } } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 931717655..cbfb7c928 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5226,6 +5226,259 @@ fn envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance() assert_eq!(super::tests::program_warnings(&r.warnings), [crate::import::ai::ENVELOPE_TYPE_DRAWN], "{:?}", r.warnings); } +/// A family that is installed, for type that must draw as itself; `None` (the test is skipped) +/// when no font is available. +fn installed_family() -> Option { + let db = vectorcraft_text::FontDb::global(); + let plain = |f: &String| !f.is_empty() && f.chars().all(|c| c.is_ascii_alphanumeric() || c == ' '); + ["DejaVu Sans", "Liberation Sans", "Noto Sans", "Source Sans 3", "Arial", "Helvetica"] + .iter() + .map(|f| f.to_string()) + .find(|f| db.has_family(f)) + .or_else(|| db.families().into_iter().find(|f| plain(f) && db.has_family(f))) +} + +/// The art space of the 100 × 100 page of [`native_with_text`] has y up. +fn to_art(p: kurbo::Point) -> (f64, f64) { + (p.x, 100.0 - p.y) +} + +/// What the live envelope `envelope` (on the page axes) draws, as VectorCraft draws it: the +/// format's paths (each subpath filled on its own, in the color its type is drawn in) and the +/// page's PostScript for them. +fn drawn_envelope(envelope: &Node) -> (String, String) { + fn walk(n: &Node, color: [f32; 3], art: &mut String, page: &mut String) { + let [r, g, b] = n.appearance.fill().and_then(|f| f.paint.color()).map_or(color, |c| c.to_rgb()); + let NodeKind::Path { path, .. } = &n.kind else { + n.children().into_iter().flatten().for_each(|c| walk(c, [r, g, b], art, page)); + return; + }; + let pt = |p: kurbo::Point| { + let (x, y) = to_art(p); + format!("{x} {y}") + }; + for el in path.to_bezpath().elements() { + match *el { + kurbo::PathEl::MoveTo(p) => { + *art += &format!("0 0 0 0 {r} {g} {b} Xa\n{} m\n", pt(p)); + *page += &format!("{r} {g} {b} setrgbcolor newpath {} moveto ", pt(p)); + } + kurbo::PathEl::LineTo(p) => { + *art += &format!("{} L\n", pt(p)); + *page += &format!("{} lineto ", pt(p)); + } + kurbo::PathEl::CurveTo(a, c, p) => { + *art += &format!("{} {} {} C\n", pt(a), pt(c), pt(p)); + *page += &format!("{} {} {} curveto ", pt(a), pt(c), pt(p)); + } + kurbo::PathEl::QuadTo(..) => {} + kurbo::PathEl::ClosePath => { + *art += "f\n"; + *page += "closepath fill\n"; + } + } + } + } + let (mut art, mut page) = (String::new(), String::new()); + walk(&vectorcraft_effects::expand_live_deep(None, envelope), [0.0; 3], &mut art, &mut page); + (art, page) +} + +/// An arc warp of `bend` (a fraction) in the editing data's keys, with the Envelope Options new +/// envelopes have and Fidelity 50. +fn arc_keys(bend: f64) -> String { + format!( + "131073 /Int (AIDeformOptionsKey) ,\n50 /Int (AIDeformFidelityKey) ,\n0 /Bool (AIDeformEditModeKey) ,\n1 /Int (DeformStyle) ,\n0 /Real (DeformHoriz) ,\n{bend} /Real (DeformValue) ,\n0 /Real (DeformVert) ,\n0 /Bool (Rotate) ,\n" + ) +} + +/// An envelope made with a warp (`keys`) around the type of story 0, as the editing data writes +/// it: on `%_` lines the group with its mesh, its dictionary with the text object in its contents, +/// then its drawn art `drawn` on plain lines and the plugin's `XP`. +fn type_envelope(keys: &str, drawn: &str) -> String { + let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() }; + let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n"); + let mesh = format!( + "/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[1 1] /Size X#\n/Data X#\n/DeviceGray /CS X#\n[0 0] /P X#\n{}{}{}{}/E X#\n/End X!\n", + corner(20.0, 40.0, 20.0, 50.0, 30.0, 40.0), + corner(20.0, 70.0, 30.0, 70.0, 20.0, 60.0), + corner(80.0, 70.0, 80.0, 60.0, 70.0, 70.0), + corner(80.0, 40.0, 70.0, 40.0, 80.0, 50.0), + ); + format!( + "{}{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_1 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n", + hidden(&format!("u\n{mesh}U\n")), + hidden(&format!("/ArtDictionary :\n{keys}")), + hidden(&format!("u\n{}U\n9 () XW\n", text_object(0))), + ) +} + +/// The text document of the type in [`type_envelope`]: "FLUX" set in `family`, drawn in `paint` (a +/// text paint, the default's when `None`). +fn type_envelope_text(family: &str, paint: Option<&str>) -> String { + let doc = text_document(&[("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0)]).replace("(Helvetica)", &format!("({family})")); + let mut doc = match paint { + Some(p) => doc.replace("/0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >>", p), + None => doc, + }; + // [`native_with_text`] writes 60 characters a line: the end of the data, `~>`, isn't split. + while ascii85(doc.as_bytes()).len() % 60 == 1 { + doc.push('\n'); + } + doc +} + +/// The live envelope an arc warp of `bend` (in percent) makes of `text`, as the reader makes it. +fn arc_envelope(text: &Node, bend: f64) -> Node { + let mut text = text.clone(); + text.visible = true; + let kind = vectorcraft_doc::live::EnvelopeKind::Warp { style: "arc".into(), bend, h: 0.0, v: 0.0, horizontal: true }; + Node::new( + vectorcraft_doc::NodeId(1_000_000), + NodeKind::Envelope { + content: vec![std::sync::Arc::new(text)], + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options: vectorcraft_doc::live::EnvelopeOptions::NEW, + frame: Box::default(), + }, + ) +} + +/// The type of the file with an envelope of type whose drawn art is `drawn`, read with its text +/// document `doc`: the hidden text object it keeps, as the reader makes it. +fn made_envelope_type(doc: &str) -> Node { + let art = native_with_text(&layer("Art", true, &type_envelope(&arc_keys(0.3), &format!("{}\n", square(10, 10)))), doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let mut text = None; + r.document.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + text = Some(n.clone()); + } + }); + text.unwrap_or_else(|| panic!("no type: {:?}", r.warnings)) +} + +/// The envelopes and the type, shown or not, in the layers of `d`: each envelope's contents, and +/// the plain text of the type outside envelopes with whether it shows. +fn envelopes_and_type(d: &Document) -> (Vec>, Vec<(String, bool)>) { + let (mut envelopes, mut loose) = (vec![], vec![]); + fn walk(n: &Node, envelopes: &mut Vec>, loose: &mut Vec<(String, bool)>) { + match &n.kind { + NodeKind::Envelope { content, .. } => envelopes.push( + content + .iter() + .map(|c| { + if let NodeKind::Text(t) = &c.kind { + format!("{}{}", t.plain_text(), if c.visible { "" } else { " (hidden)" }) + } else { + "art".into() + } + }) + .collect(), + ), + NodeKind::Text(t) => loose.push((t.plain_text(), n.visible)), + _ => n.children().into_iter().flatten().for_each(|c| walk(c, envelopes, loose)), + } + } + d.layers.iter().for_each(|l| walk(l, &mut envelopes, &mut loose)); + (envelopes, loose) +} + +/// An envelope of type whose live envelope, its type laid out from the text document, draws as its +/// drawn art opens live: the type is its contents, shown and editable, and the hidden copy of the +/// type goes (#3179). +#[test] +fn an_envelope_of_type_that_draws_as_its_drawn_art_opens_live() { + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let (drawn, page) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0)); + let art = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope(&arc_keys(0.3), &drawn))), &doc); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings); + assert!(loose.is_empty(), "{loose:?}"); + assert!(super::tests::program_warnings(&r.warnings).is_empty(), "{:?}", r.warnings); +} + +/// An envelope of type whose live envelope draws apart from its drawn art (the file's art bends the +/// other way) keeps its drawn art and its type, hidden, with a note. +#[test] +fn an_envelope_of_type_that_draws_apart_from_its_drawn_art_keeps_it() { + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let (drawn, page) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), -30.0)); + let art = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope(&arc_keys(0.3), &drawn))), &doc); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert!(envelopes.is_empty(), "{envelopes:?}"); + assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings); + assert_eq!(super::tests::program_warnings(&r.warnings), [crate::import::ai::ENVELOPE_TYPE_DRAWN], "{:?}", r.warnings); +} + +/// White type in an envelope that draws apart from its drawn art keeps the drawn art too: the +/// envelope and its drawn art are each drawn on their own, where white shows only on black. +#[test] +fn an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it() { + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, Some("/0 << /0 1 /1 [ 1.0 1.0 1.0 1.0 ] >>")); + let text = made_envelope_type(&doc); + assert_eq!(text_fill(&text), Some(vectorcraft_color::Color::rgb(1.0, 1.0, 1.0))); + let (drawn, page) = drawn_envelope(&arc_envelope(&text, -30.0)); + // A dark square under it, so the page shows the white type. + let ground = "0 0 0 1 k\n0 0 m\n100 0 L\n100 100 L\n0 100 L\nf\n"; + let art = native_with_text(&layer("Art", true, &format!("{ground}{}", type_envelope(&arc_keys(0.3), &drawn))), &doc); + let r = import(&eps(&format!("0 0 0 1 setcmykcolor 0 0 moveto 100 0 lineto 100 100 lineto 0 100 lineto closepath fill\n{page}"), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert!(envelopes.is_empty(), "{envelopes:?}"); + assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings); +} + +/// A `.ai` whose layers would open alone, without its PDF part, waits on the PDF part when an +/// envelope of type is in them, as for the other live envelopes: one opens live only where the +/// layers with it draw as the page. Where they don't, they open alone as before, the envelope +/// keeping its drawn art and its type, hidden, with the note, even though it would draw as that +/// drawn art; where they do, it opens live (#3179). +#[test] +fn an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone() { + let Some(family) = installed_family() else { return }; + let stories = [("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8170.0, 8220.0), (0.0, 0.0), 0.0)]; + let mut doc = text_document(&stories).replace("(Helvetica)", &format!("({family})")); + // [`native_with_text`] writes 60 characters a line: the end of the data, `~>`, isn't split. + while ascii85(doc.as_bytes()).len() % 60 == 1 { + doc.push('\n'); + } + let (drawn, _) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0)); + let art = format!("u\n{}U\n{}", text_object(1), type_envelope(&arc_keys(0.3), &drawn)); + let file = private(&native_with_text(&layer("Art", true, &art), &doc)); + let names = crate::NameEncoding::default(); + let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") }; + // A PDF part the layers don't draw as. + let (d, notes, read) = crate::layered_ai_from(layers, pdf_part(80.0, 80.0), vec![], false, false, 1.0, names); + assert!(read, "{notes:?}"); + let (envelopes, loose) = envelopes_and_type(&d); + assert!(envelopes.is_empty(), "{envelopes:?}"); + assert_eq!(loose, [("Note".to_string(), true), ("FLUX".to_string(), false)], "{notes:?}"); + assert!(notes.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{notes:?}"); + // A PDF part that draws as those layers, with the drawn art: the envelope opens live. + let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") }; + let (d, notes, read) = crate::layered_ai_from(layers, d, vec![], false, false, 1.0, names); + assert!(read, "{notes:?}"); + let (envelopes, loose) = envelopes_and_type(&d); + assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{notes:?}"); + assert_eq!(loose, [("Note".to_string(), true)], "{notes:?}"); +} + +/// The fill of the first run of type `n`. +fn text_fill(n: &Node) -> Option { + let NodeKind::Text(t) = &n.kind else { return None }; + t.runs.first().and_then(|r| r.style.fill.color()) +} + /// A live envelope that draws a little apart from its drawn art, in layers that already differ /// from the page almost as much as the page check takes, would make them differ too much: its /// drawn art is put back, so the file still opens from its layers. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f4652f539..60bec1191 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-11-10 · **Change:** minor (`.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (`.ai` import: envelopes of type open live where they draw as their drawn art, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **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 @@ -336,6 +336,8 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | `.ai` and EPS import: an envelope of type (a warp, a mesh or a top object around type) kept its drawn art, its type hidden, because the type is laid out from the text document only after the layers are read. It now waits for that: its text slots get their type, and where VectorCraft's own envelope of that type draws as the drawn art the file saves (the same check and budget as other envelopes, seen on white and on black), it opens live in the drawn art's place, its type editable in Edit Contents, and the hidden copy goes. As with other live envelopes, the layers with one are then compared with the page, and when they don't draw as it the drawn art and the hidden type are put back: a `.ai` whose layers would open alone, without its PDF part, imports the PDF part to compare them with, and should they still not draw as it, or should no envelope of type open live, they open alone as before, with the envelopes' drawn art. Type in a font that isn't available, slots without type of their own (threaded, bound to a text variable, a wrap object, type a look draws), envelopes in a symbol's or a pattern's art, and envelopes that draw differently keep their drawn art, with the note (#3179). Tests `tests_native::an_envelope_of_type_that_draws_as_its_drawn_art_opens_live`, `an_envelope_of_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone` | +| 2026-11-10 | minor | `.ai` and EPS import: an envelope of type, which keeps its drawn art with its type hidden, now says so in the import notes ("type in an envelope comes in as the envelope's drawn art, not as editable type: the type is kept, hidden"); nothing said it before, as the other envelopes that keep their drawn art do. The type is still not editable in the envelope (part of #3179). Test `tests_native::envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance` | | 2026-11-10 | minor | `.ai` and EPS import: an envelope of type, which keeps its drawn art with its type hidden, now says so in the import notes ("type in an envelope comes in as the envelope's drawn art, not as editable type"); nothing said it before, while the other envelopes that keep their drawn art have a note. The type is still not editable in the envelope (part of #3179). Test `tests_native::envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance` | | 2026-11-10 | minor | `.ai` and EPS import: the check that keeps a live envelope or warped type only where it draws as the art saved with it renders each on its own, over nothing, and compared the pixels seen on white, where white art looks like nothing. White contents that drew somewhere else passed it and opened live in the wrong place (white warped type off its banner, in users' files). Pixels now also differ when they look different seen on black, so such art keeps its saved art, as colored art does. Tests `import::ai::tests::a_white_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art`, `white_warped_type_that_draws_elsewhere_comes_in_as_its_look` | | 2026-11-10 | minor | PDF import: an image in a DeviceN space of one colorant opens as a Separation image does (#6314): a grayscale image of its tints colorized by its ink. One of two or more colorants that are all process plates (Cyan, Magenta, Yellow, Black, and the ink `None`) opens as a CMYK image, each colorant's tint in its plate and its Decode array applied (8 or 16 bits a sample; one that can't be read, such as a JPEG, opens in RGB with the CMYK images' warning). Both used to open as RGB images, losing their inks. A DeviceN image that mixes a spot ink with other colorants, such as a duotone, still opens as RGB: an image holds one ink or the process plates, not both (#6348). Tests `colorize::a_devicen_image_of_one_ink_opens_colorized_by_it`, `colorize::a_devicen_image_of_process_plates_opens_in_cmyk`, `colorize::a_duotone_devicen_image_still_opens_as_rgb` | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a8bd43ad5..76d190f08 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1403,7 +1403,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). - In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). + In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and, as with them, only where the layers with it are compared with the page and draw as it); elsewhere, and where the type's font isn't available or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points. From 0916b406549294db24d37b70004083353f79764e Mon Sep 17 00:00:00 2001 From: jmverges Date: Tue, 10 Nov 2026 15:46:26 +0000 Subject: [PATCH 2/6] .ai import: the page check weighs a live envelope of type as the art it draws The comparison with the page counts the area of each text object that shows as type, which it holds to less (type laid out again from the file's text can sit a little apart from the page's). It took the text object in a live envelope's contents for such type, at its own unbent box, so most of what an envelope of type draws went unweighed: one opened live where the page draws other art there, with only a note that the type differs. The envelope draws its type bent, as art, and is now weighed as such, as the drawn art it takes the place of is. Part of #3179. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/native/compare.rs | 7 +++++-- crates/eps/src/import/tests_native.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/native/compare.rs b/crates/eps/src/import/native/compare.rs index 99cafb771..5b87a03c7 100644 --- a/crates/eps/src/import/native/compare.rs +++ b/crates/eps/src/import/native/compare.rs @@ -714,7 +714,8 @@ fn compared_with_solid(x: &vectorcraft_render::Rendered, y: &vectorcraft_render: } /// Where the type that shows in `nodes` is, in pixels of `region` drawn at `scale` (with a margin -/// for the strokes round it). +/// for the strokes round it). The type in a live envelope isn't: the envelope draws it bent, away +/// from its text object's box, and is weighed as the art it draws, as its drawn art would be. fn type_areas(nodes: &[Arc], region: Rect, scale: f64, out: &mut Vec) { for n in nodes.iter().filter(|n| n.visible) { if let NodeKind::Text(_) = n.kind @@ -723,7 +724,9 @@ fn type_areas(nodes: &[Arc], region: Rect, scale: f64, out: &mut Vec let px = |r: Rect| Rect::new((r.x0 - region.x0) * scale, (r.y0 - region.y0) * scale, (r.x1 - region.x0) * scale, (r.y1 - region.y0) * scale); out.push(px(b.inflate(OUTLINE_REACH, OUTLINE_REACH))); - } else if let Some(c) = n.children() { + } else if !matches!(n.kind, NodeKind::Envelope { .. }) + && let Some(c) = n.children() + { type_areas(c, region, scale, out); } } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index cbfb7c928..ffa209a84 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5473,6 +5473,24 @@ fn an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone() { assert_eq!(loose, [("Note".to_string(), true)], "{notes:?}"); } +/// An envelope of type that draws as its drawn art doesn't open live where the page draws other art +/// there (the warp bent the other way): the page check weighs the live envelope as the art it draws, +/// not as type laid out again, so it sees the two differ. The file comes in as its page, as it does +/// with the drawn art (#3179). +#[test] +fn an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live() { + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let text = made_envelope_type(&doc); + let (drawn, _) = drawn_envelope(&arc_envelope(&text, 30.0)); + let (_, page) = drawn_envelope(&arc_envelope(&text, -30.0)); + let art = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope(&arc_keys(0.3), &drawn))), &doc); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + let (envelopes, _) = envelopes_and_type(&r.document); + assert!(envelopes.is_empty(), "{envelopes:?} {:?}", r.warnings); + assert!(r.warnings.iter().any(|n| n.contains("layers weren't read") && n.contains("differs from the page's")), "{:?}", r.warnings); +} + /// The fill of the first run of type `n`. fn text_fill(n: &Node) -> Option { let NodeKind::Text(t) = &n.kind else { return None }; From bf716bfeccc87b141cf69741cf93507fd88aeec6 Mon Sep 17 00:00:00 2001 From: jmverges Date: Tue, 10 Nov 2026 15:49:23 +0000 Subject: [PATCH 3/6] .ai import: envelopes of type keep their drawn art in layers used though they differ from the page An envelope of type opens live only where the layers with it draw as the page. Two ways of using layers that don't left it live all the same: the editing data trusted (they come from it with a note), and the objects that draw differently coming in as the page's art. In both the envelope now keeps its drawn art and its type, hidden, with the note, as where its drawn art is put back. A .ai whose layers would open alone, without its PDF part, then opens alone again, as it did before envelopes of type could open live, rather than compared with the page. The type put back no longer carries the mark of type made from the text document in its name. Part of #3179. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/native/build.rs | 41 ++++++++++++-- crates/eps/src/import/tests_native.rs | 79 +++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 4266b945c..e2480d6c5 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -493,9 +493,14 @@ pub(super) fn build_from( other => match drawn_or_trusted(&frame, &mut view, &mut doc, &envelopes_drawn, ¬es, unseen, trust, why, reflow) { Ok(more) => notes.extend(more), // The objects that draw differently from the page come in as its art, from - // the placement that draws closer to it. + // the placement that draws closer to it. The layers don't draw as the page: + // envelopes of type keep their drawn art and their type, hidden. Err(why) => { - let other = other.map(|(_, other, unseen)| (other, unseen)); + let typed = type_drawn(&mut doc, &envelopes_drawn); + let other = other.map(|(_, mut other, unseen)| { + type_drawn(&mut other, &envelopes_drawn); + (other, unseen) + }); let placements = closer_first(&frame, &mut view, (doc, unseen), other, reflow); let Some((kept, unseen, kept_notes)) = placements.into_iter().find_map(|(base, unseen)| { keep_differing(&base, &frame, &mut view, &threaded_frames, &shown, reflow).map(|(kept, n)| (kept, unseen, n)) @@ -503,7 +508,8 @@ pub(super) fn build_from( return Err(why); }; doc = kept; - notes.extend(unseen_note(unseen).into_iter().chain(kept_notes)); + let typed = typed.then(|| ai::ENVELOPE_TYPE_DRAWN.to_string()).filter(|n| !notes.contains(n)); + notes.extend(typed.into_iter().chain(unseen_note(unseen)).chain(kept_notes)); } }, }, @@ -1287,8 +1293,9 @@ fn slot_content( /// The layers `doc` don't draw as the page (`why`), `unseen` text objects of them taken out: the /// notes to add when `doc` with each live envelope `drawn` lists replaced by the drawn art it took /// the place of (see [`ai::Structure::envelopes_drawn`]) draws as the page, `doc` then being that; -/// else, with `trust`, the notes for `doc` as it is. An error, `why`, otherwise. The envelope note -/// is added unless `notes` has it. +/// else, with `trust`, the notes for `doc` as it is but for its envelopes of type, which keep their +/// drawn art ([`type_drawn`]). An error, `why`, otherwise. The envelope note is added unless +/// `notes` has it. #[allow(clippy::too_many_arguments)] fn drawn_or_trusted( frame: &Frame<'_>, @@ -1320,11 +1327,33 @@ fn drawn_or_trusted( } } if trust { - return Ok(unseen_note(unseen).into_iter().chain([format!("{why}: they come from its editing data all the same")]).collect()); + let typed = type_drawn(doc, drawn).then(|| ai::ENVELOPE_TYPE_DRAWN.to_string()).filter(|n| !notes.contains(n)); + let trusted = format!("{why}: they come from its editing data all the same"); + return Ok(typed.into_iter().chain(unseen_note(unseen)).chain([trusted]).collect()); } Err(why) } +/// Put each live envelope of type in `drawn` (as [`drawn_or_trusted`] takes them) that `doc` still +/// has back as its hidden type and its drawn art: whether one was. An envelope of type opens live +/// only where the layers with it draw as the page, so where they don't, though they are used all +/// the same (the editing data trusted, or the objects that differ come in as the page's art), it +/// keeps its drawn art. +fn type_drawn(doc: &mut Document, drawn: &[(NodeId, Vec, &str)]) -> bool { + let typed: Vec<(NodeId, Vec)> = drawn + .iter() + .filter(|(id, _, say)| *say == ai::ENVELOPE_TYPE_DRAWN && doc.node(*id).is_some()) + .map(|(id, art, _)| (*id, art.clone())) + .collect(); + if typed.is_empty() || doc.insert_beside(typed, true).is_err() { + return false; + } + // The type put back is still marked as made from the text document, which the rest no longer + // is once the page has weighed it ([`prune_unseen`]). + unmark(&mut doc.layers, true, true, &mut vec![]); + true +} + /// `doc` with the placed files `placed` lists at their other placement (see /// [`ai::Structure::placed_elsewhere`]): each image's transform followed by its move. `None` when /// no listed image survives replacement. diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index ffa209a84..6a2723dc9 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5473,6 +5473,20 @@ fn an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone() { assert_eq!(loose, [("Note".to_string(), true)], "{notes:?}"); } +/// The hidden type outside envelopes in the layers of `d`: each text object's name. +fn loose_type_names(d: &Document) -> Vec> { + let mut out = vec![]; + fn walk(n: &Node, out: &mut Vec>) { + match &n.kind { + NodeKind::Envelope { .. } => {} + NodeKind::Text(_) => out.push(n.name.clone()), + _ => n.children().into_iter().flatten().for_each(|c| walk(c, out)), + } + } + d.layers.iter().for_each(|l| walk(l, &mut out)); + out +} + /// An envelope of type that draws as its drawn art doesn't open live where the page draws other art /// there (the warp bent the other way): the page check weighs the live envelope as the art it draws, /// not as type laid out again, so it sees the two differ. The file comes in as its page, as it does @@ -5491,6 +5505,71 @@ fn an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live() { assert!(r.warnings.iter().any(|n| n.contains("layers weren't read") && n.contains("differs from the page's")), "{:?}", r.warnings); } +/// Layers used though they draw differently from the page (the editing data trusted) leave an +/// envelope of type its drawn art and its type, hidden, with the note, even where it draws as that +/// drawn art: one opens live only where the layers with it draw as the page. A `.ai` whose layers +/// would open alone then opens alone, as it would without the envelope (#3179). +#[test] +fn trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_drawn_art() { + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let (drawn, _) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0)); + let art = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope(&arc_keys(0.3), &drawn))), &doc); + // The page has the square only. + let r = crate::import_trusting(&eps(&page_square(10, 10), &art), true, true).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert!(envelopes.is_empty(), "{envelopes:?}"); + assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings); + // The type put back is named as the file names it. + assert_eq!(loose_type_names(&r.document), [None]); + assert!(r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings); + assert!(r.warnings.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{:?}", r.warnings); + // A `.ai` whose layers would open alone, its PDF part drawn apart from them. + let stories = [("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8170.0, 8220.0), (0.0, 0.0), 0.0)]; + let mut doc = text_document(&stories).replace("(Helvetica)", &format!("({family})")); + while ascii85(doc.as_bytes()).len() % 60 == 1 { + doc.push('\n'); + } + let art = format!("u\n{}U\n{}", text_object(1), type_envelope(&arc_keys(0.3), &drawn)); + let file = private(&native_with_text(&layer("Art", true, &art), &doc)); + let names = crate::NameEncoding::default(); + let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") }; + let (d, notes, read) = crate::layered_ai_from(layers, pdf_part(80.0, 80.0), vec![], false, true, 1.0, names); + assert!(read, "{notes:?}"); + let (envelopes, loose) = envelopes_and_type(&d); + assert!(envelopes.is_empty(), "{envelopes:?}"); + assert_eq!(loose, [("Note".to_string(), true), ("FLUX".to_string(), false)], "{notes:?}"); + assert!(notes.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{notes:?}"); + assert!(!notes.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{notes:?}"); +} + +/// Layers whose objects that draw differently from the page come in as its art leave an envelope +/// of type its drawn art and its type, hidden, with the note, even where the envelope draws as the +/// page: the layers with it don't (#3179). +#[test] +fn layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_its_drawn_art() { + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let (drawn, page) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0)); + // A square black in the layers and cyan on the page, away from the envelope. + let body = format!("{}\n{}\n{}", square(10, 10), square_16(76, BLACK), type_envelope(&arc_keys(0.3), &drawn)); + let art = native_with_text(&layer("Art", true, &body), &doc); + let cyan = "1 0 0 0 setcmykcolor 76 10 moveto 92 10 lineto 92 26 lineto 76 26 lineto closepath fill"; + let r = import(&eps(&format!("{}\n{cyan}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + assert!( + r.warnings.iter().any(|n| n.starts_with("objects that drew differently from the file's own page came in as its art")), + "{:?}", + r.warnings + ); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert!(envelopes.is_empty(), "{envelopes:?}"); + assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings); + assert_eq!(loose_type_names(&r.document), [None]); + assert!(r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings); +} + /// The fill of the first run of type `n`. fn text_fill(n: &Node) -> Option { let NodeKind::Text(t) = &n.kind else { return None }; From 06d0651ebf3a8eaf2219852394c7f56bffd6c473 Mon Sep 17 00:00:00 2001 From: jmverges Date: Tue, 10 Nov 2026 15:51:01 +0000 Subject: [PATCH 4/6] .ai import: an envelope of type bound to a visibility variable keeps its drawn art A variable library binds the text object a slot of the envelope's contents became, which stays hidden beside the drawn art. Once the envelope opened live that hidden text object went, and its type in the envelope has an id of its own, so a visibility binding was dropped and counted as one that wasn't made. A slot bound to a text variable already kept the drawn art; one bound to a visibility variable does too now, with the note, so the binding is kept. Carrying the binding over to the type in the live envelope would bind an object inside an envelope's contents, which nothing in the import, the data sets or the .ai writer is known to handle, so it is left for later. Part of #3179. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/native/build.rs | 39 ++++++++++++++++--------- crates/eps/src/import/tests_native.rs | 42 ++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index e2480d6c5..b037b33fe 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -429,11 +429,13 @@ pub(super) fn build_from( } // Area type flows around the wrap objects above it before it is compared with the page. resolve_text_wrap(&mut doc)?; - let bound_text = text_bound_slots(&doc, &bindings, &xml_ids); + let bound_text = bound_slots(&doc, &bindings, &xml_ids, VariableKind::Text); + let bound_visible = bound_slots(&doc, &bindings, &xml_ids, VariableKind::Visibility); // Without a page to compare the layers with, envelopes of type keep their drawn art: like the // other live envelopes, one opens live only where the layers with it draw as the page. let warped_type = warped_type.compared(frame.is_some(), &mut notes); - let typed = settle_warped_type(warped_type, &mut doc, &filled, &threaded_frames, &bound_text, &mut restyled, &mut notes)?; + let bound = [&bound_text, &bound_visible]; + let typed = settle_warped_type(warped_type, &mut doc, &filled, &threaded_frames, bound, &mut restyled, &mut notes)?; // The live envelopes and what takes the place of each again, when the layers with them don't // draw as the page, with the note that says so. let envelopes_drawn: Vec<(NodeId, Vec, &str)> = envelopes_drawn @@ -637,41 +639,52 @@ fn bind_variables( notes.extend(ai::bindings_note(skipped)); } -/// The text object slots of `doc` that `bindings` binds to a text variable, each named by its XML id -/// (`ids`). -fn text_bound_slots(doc: &Document, bindings: &[ai::BindingRef], ids: &std::collections::HashMap>) -> BTreeSet { +/// The objects of `doc`, text object slots among them, that `bindings` binds to a variable of +/// `kind`, each named by its XML id (`ids`). +fn bound_slots( + doc: &Document, + bindings: &[ai::BindingRef], + ids: &std::collections::HashMap>, + kind: VariableKind, +) -> BTreeSet { bindings .iter() - .filter(|b| doc.variables.variable(&b.variable).is_some_and(|v| v.kind == VariableKind::Text)) + .filter(|b| doc.variables.variable(&b.variable).is_some_and(|v| v.kind == kind)) .filter_map(|b| b.target.as_ref().and_then(|t| ids.get(t).copied().flatten())) .collect() } /// Warped type stays type where its live warp draws as its look, and where a text variable binds it -/// (`bound_text`, by slot). Elsewhere the look takes its place, and one note says how many did. +/// (`bound`'s first set, by slot). Elsewhere the look takes its place, and one note says how many did. /// `filled` says what each slot became, and the comparisons with the page leave out the boxes in /// `restyled`. Fails as [`resolve_text_wrap`] does when the looks give the wrap shapes too many /// anchors. Before that, the envelopes of type open live where they draw as their drawn art, with /// the type of their text slots (made type, one text object for the slot, not threaded, bound to -/// no text variable and no wrap object); the others keep their drawn art, with a note. Returns -/// each live envelope of type's id with what takes its place again (its hidden type, then its -/// drawn art). +/// no text variable and no visibility variable, `bound`'s second set, and no wrap object); the +/// others keep their drawn art, with a note. Returns each live envelope of type's id with what +/// takes its place again (its hidden type, then its drawn art). fn settle_warped_type( mut warped: ai::WarpedType, doc: &mut Document, filled: &std::collections::HashMap, threaded: &BTreeSet, - bound_text: &BTreeSet, + bound: [&BTreeSet; 2], restyled: &mut Vec, notes: &mut Vec, ) -> Result)>, String> { + let [bound_text, bound_visible] = bound; let made = |slot| match filled.get(&slot).map(|f| f.text.as_slice()).filter(|_| !bound_text.contains(&slot)) { Some(&[one]) => Some(one), _ => None, }; // An envelope's type: the one text object a slot became (hidden, with nothing else), named as - // the slot (made type is no longer weighed against the page: the envelope draws it). + // the slot (made type is no longer weighed against the page: the envelope draws it). A variable + // binds the hidden text object a slot became, which the live envelope's type takes the place of + // with an id of its own: a slot bound to one keeps its type hidden, and the drawn art stays. let type_of = |doc: &Document, slot: NodeId| { + if bound_visible.contains(&slot) { + return None; + } let id = made(slot).filter(|id| !threaded.contains(id) && filled.get(&slot).is_some_and(|f| f.objects == [*id]))?; let mut text = doc.node(id).filter(|n| matches!(n.kind, NodeKind::Text(_)) && n.extras().wrap.is_none())?.clone(); if let Some(name) = text.name.as_deref().and_then(|n| n.strip_prefix(MADE)) { @@ -2063,7 +2076,7 @@ mod candidate_tests { let warped = ai::WarpedType::unchecked(vec![(id, (*look).clone())]); let filled = std::collections::HashMap::from([(id, Filled { text: vec![id], objects: vec![id] })]); let mut notes = vec![]; - let settled = settle_warped_type(warped, &mut doc, &filled, &BTreeSet::new(), &BTreeSet::new(), &mut vec![], &mut notes); + let settled = settle_warped_type(warped, &mut doc, &filled, &BTreeSet::new(), [&BTreeSet::new(); 2], &mut vec![], &mut notes); assert_eq!(settled.is_ok(), ok, "{texts} text objects"); assert_eq!(notes, ["warped text objects that came in as drawn art, not as editable type: 1"]); } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 6a2723dc9..f71c037c9 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5296,6 +5296,12 @@ fn arc_keys(bend: f64) -> String { /// it: on `%_` lines the group with its mesh, its dictionary with the text object in its contents, /// then its drawn art `drawn` on plain lines and the plugin's `XP`. fn type_envelope(keys: &str, drawn: &str) -> String { + type_envelope_with(keys, drawn, "") +} + +/// [`type_envelope`], with `after` (on plain lines, written on `%_` ones) after the text object in +/// its contents, such as the text object's XML id. +fn type_envelope_with(keys: &str, drawn: &str, after: &str) -> String { let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() }; let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n"); let mesh = format!( @@ -5309,7 +5315,7 @@ fn type_envelope(keys: &str, drawn: &str) -> String { "{}{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_1 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n", hidden(&format!("u\n{mesh}U\n")), hidden(&format!("/ArtDictionary :\n{keys}")), - hidden(&format!("u\n{}U\n9 () XW\n", text_object(0))), + hidden(&format!("u\n{}{after}U\n9 () XW\n", text_object(0))), ) } @@ -5570,6 +5576,40 @@ fn layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_it assert!(r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings); } +/// An envelope of type whose type a visibility variable binds keeps its drawn art, as one whose type +/// a text variable binds does: the binding stays the hidden type's, which the live envelope would +/// take the place of (#3179). +#[test] +fn an_envelope_of_type_bound_to_a_visibility_variable_keeps_its_drawn_art() { + use vectorcraft_testkit::ai::{XmlValue, named, variable_library_data, xml_element, xml_node}; + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let (drawn, page) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0)); + let reference = "/XMLUIDREF : (type_x5F_1) ;"; + let attributes = [("trait", XmlValue::Str("visibility")), ("varName", XmlValue::Unicode("v_shown")), ("docRef", XmlValue::Raw(reference))]; + let declared = [xml_node(1, "variable", XmlValue::Str(""), &attributes, &[])]; + let set = xml_element("variableSet", &[("locked", "none")], &[xml_element("variables", &[], &declared)]); + let library = variable_library_data(&xml_element("variableSets", &[], &[set])); + // The text object's XML id, on plain lines as `type_envelope_with` takes it. + let id = named("type_x5F_1").replace("%_", ""); + let envelope = type_envelope_with(&arc_keys(0.3), &drawn, &id); + let art = native_with_text(&(library + &layer("Art", true, &format!("{}\n{envelope}", square(10, 10)))), &doc); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert!(envelopes.is_empty(), "{envelopes:?}"); + assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings); + let mut text = vec![]; + r.document.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + text.push(n.id); + } + }); + assert_eq!(r.document.variables.objects_of("v_shown"), text, "{:?}", r.warnings); + assert!(r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|n| n.contains("variable bindings")), "{:?}", r.warnings); +} + /// The fill of the first run of type `n`. fn text_fill(n: &Node) -> Option { let NodeKind::Text(t) = &n.kind else { return None }; From 3995dc79e4e7719091df3ef35398011485d572e2 Mon Sep 17 00:00:00 2001 From: jmverges Date: Tue, 10 Nov 2026 15:51:18 +0000 Subject: [PATCH 5/6] docs: envelopes of type held to the page, and visibility bindings The file-format revision row for #3179 says that the page check weighs a live envelope of type as the art it draws, that layers used though they don't draw as the page leave envelopes of type their drawn art, and that a slot bound to a visibility variable keeps it too, and lists the new tests. The parity assessment's evidence says the same, and the change gets its row and status entry there. Part of #3179. Co-Authored-By: Claude Opus 5.5 --- docs/file-format-parity.md | 4 ++-- docs/target-app-parity.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 60bec1191..7f36fd1c3 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-11-10 · **Change:** minor (`.ai` import: envelopes of type open live where they draw as their drawn art, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (`.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **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 @@ -336,7 +336,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| -| 2026-11-10 | minor | `.ai` and EPS import: an envelope of type (a warp, a mesh or a top object around type) kept its drawn art, its type hidden, because the type is laid out from the text document only after the layers are read. It now waits for that: its text slots get their type, and where VectorCraft's own envelope of that type draws as the drawn art the file saves (the same check and budget as other envelopes, seen on white and on black), it opens live in the drawn art's place, its type editable in Edit Contents, and the hidden copy goes. As with other live envelopes, the layers with one are then compared with the page, and when they don't draw as it the drawn art and the hidden type are put back: a `.ai` whose layers would open alone, without its PDF part, imports the PDF part to compare them with, and should they still not draw as it, or should no envelope of type open live, they open alone as before, with the envelopes' drawn art. Type in a font that isn't available, slots without type of their own (threaded, bound to a text variable, a wrap object, type a look draws), envelopes in a symbol's or a pattern's art, and envelopes that draw differently keep their drawn art, with the note (#3179). Tests `tests_native::an_envelope_of_type_that_draws_as_its_drawn_art_opens_live`, `an_envelope_of_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone` | +| 2026-11-10 | minor | `.ai` and EPS import: an envelope of type (a warp, a mesh or a top object around type) kept its drawn art, its type hidden, because the type is laid out from the text document only after the layers are read. It now waits for that: its text slots get their type, and where VectorCraft's own envelope of that type draws as the drawn art the file saves (the same check and budget as other envelopes, seen on white and on black), it opens live in the drawn art's place, its type editable in Edit Contents, and the hidden copy goes. As with other live envelopes, the layers with one are then compared with the page, which weighs the envelope as the art it draws (not as type laid out again, which the comparison allows more for), and when they don't draw as it the drawn art and the hidden type are put back. Layers used though they don't draw as the page (the editing data trusted, or the objects that differ coming in as the page's art) leave envelopes of type their drawn art and hidden type too. A `.ai` whose layers would open alone, without its PDF part, imports the PDF part to compare them with, and should they still not draw as it, or should no envelope of type open live, they open alone as before, with the envelopes' drawn art. Type in a font that isn't available, slots without type of their own (threaded, bound to a text or a visibility variable, a wrap object, type a look draws), envelopes in a symbol's or a pattern's art, and envelopes that draw differently keep their drawn art, with the note (#3179). Tests `tests_native::an_envelope_of_type_that_draws_as_its_drawn_art_opens_live`, `an_envelope_of_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone`, `an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live`, `trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_drawn_art`, `layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_its_drawn_art`, `an_envelope_of_type_bound_to_a_visibility_variable_keeps_its_drawn_art` | | 2026-11-10 | minor | `.ai` and EPS import: an envelope of type, which keeps its drawn art with its type hidden, now says so in the import notes ("type in an envelope comes in as the envelope's drawn art, not as editable type: the type is kept, hidden"); nothing said it before, as the other envelopes that keep their drawn art do. The type is still not editable in the envelope (part of #3179). Test `tests_native::envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance` | | 2026-11-10 | minor | `.ai` and EPS import: an envelope of type, which keeps its drawn art with its type hidden, now says so in the import notes ("type in an envelope comes in as the envelope's drawn art, not as editable type"); nothing said it before, while the other envelopes that keep their drawn art have a note. The type is still not editable in the envelope (part of #3179). Test `tests_native::envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance` | | 2026-11-10 | minor | `.ai` and EPS import: the check that keeps a live envelope or warped type only where it draws as the art saved with it renders each on its own, over nothing, and compared the pixels seen on white, where white art looks like nothing. White contents that drew somewhere else passed it and opened live in the wrong place (white warped type off its banner, in users' files). Pixels now also differ when they look different seen on black, so such art keeps its saved art, as colored art does. Tests `import::ai::tests::a_white_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art`, `white_warped_type_that_draws_elsewhere_comes_in_as_its_look` | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 76d190f08..833579abc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (`.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -1403,7 +1403,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). - In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and, as with them, only where the layers with it are compared with the page and draw as it); elsewhere, and where the type's font isn't available or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). + In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points. @@ -1939,6 +1939,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | File formats: in a `.ai` or EPS opened from its editing data, an envelope of type opens live where it draws as its drawn art and the layers with it draw as the page (the envelope weighed as the art it draws); elsewhere, and where a text or visibility variable binds its type, it keeps its drawn art, with a note (#3179); evidence updated; scores and hours unchanged. | | 2026-11-10 | minor | Bundled content: built-in Game HUD graphic style library; style libraries evidence updated; ~34% → ~36%; more built-in graphic-style libraries remain. | | 2026-11-10 | minor | Bundled content: built-in Map Markers symbol library (Pin, Flag, Crosshair, Checkpoint); symbol libraries evidence updated; ~32% → ~34%; more built-in symbol libraries remain. | | 2026-11-10 | minor | Type advanced: Spelling › Auto Spell Check in the context menu of type, checked while it is on, #4936; evidence added; the dialogs remain; scores and hours unchanged. | From 309d7e53638e67e070789b1dc02d533cad546e37 Mon Sep 17 00:00:00 2001 From: jmverges Date: Tue, 10 Nov 2026 16:55:33 +0000 Subject: [PATCH 6/6] Envelopes: put back only the outermost drawn art, and keep hidden type until the drawn art is found The put-back after a failed page check replaces each live envelope with its drawn art in one go, which fails as a whole when one envelope lies in another's art: only the outermost are put back now. A live envelope of type removes its hidden type only once it has found the drawn art it replaces. The file format docs lose a revision row left twice by a rebase. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/envelope.rs | 3 +++ crates/eps/src/import/native/build.rs | 13 ++++++++++++- docs/file-format-parity.md | 1 - 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index 04b859946..c0da27f2a 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -519,6 +519,9 @@ impl WarpedType { if !drawn_alike(&live_one, &plain, layer, &shared, page, &mut self.spent) { return Err(TypeKept::Differs); } + if doc.node(drawn).is_none() { + return Err(TypeKept::Read); + } // The type is the envelope's now: its hidden copy goes, kept for when the drawn art comes back. let mut put_back: Vec = hidden.into_iter().filter_map(|id| doc.remove(id).ok()).map(Arc::unwrap_or_clone).collect(); let id = envelope.id; diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index b037b33fe..31cd90d79 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -1325,7 +1325,18 @@ fn drawn_or_trusted( let mut put_back = doc.clone(); // Each envelope still there, in place of the art it took the place of. let there: Vec<&(NodeId, Vec, &str)> = drawn.iter().filter(|(id, ..)| put_back.node(*id).is_some()).collect(); - let any = !there.is_empty() && put_back.insert_beside(there.iter().map(|(id, art, _)| (*id, art.clone())).collect(), true).is_ok(); + // One inside another's art goes with it: only the outermost are put back (a place inside + // another place fails the whole put-back). + let mut inside = std::collections::HashSet::new(); + for n in there.iter().filter_map(|(id, ..)| put_back.node(*id)) { + n.walk(&mut |c| { + if c.id != n.id { + inside.insert(c.id); + } + }); + } + let outermost = there.iter().filter(|(id, ..)| !inside.contains(id)).map(|(id, art, _)| (*id, art.clone())).collect(); + let any = !there.is_empty() && put_back.insert_beside(outermost, true).is_ok(); if any { let more = prune_unseen(frame, view, &mut put_back, reflow); if let Ok(note) = compare(frame, view, &put_back, reflow) { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7f36fd1c3..fe06c7d2d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -337,7 +337,6 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| | 2026-11-10 | minor | `.ai` and EPS import: an envelope of type (a warp, a mesh or a top object around type) kept its drawn art, its type hidden, because the type is laid out from the text document only after the layers are read. It now waits for that: its text slots get their type, and where VectorCraft's own envelope of that type draws as the drawn art the file saves (the same check and budget as other envelopes, seen on white and on black), it opens live in the drawn art's place, its type editable in Edit Contents, and the hidden copy goes. As with other live envelopes, the layers with one are then compared with the page, which weighs the envelope as the art it draws (not as type laid out again, which the comparison allows more for), and when they don't draw as it the drawn art and the hidden type are put back. Layers used though they don't draw as the page (the editing data trusted, or the objects that differ coming in as the page's art) leave envelopes of type their drawn art and hidden type too. A `.ai` whose layers would open alone, without its PDF part, imports the PDF part to compare them with, and should they still not draw as it, or should no envelope of type open live, they open alone as before, with the envelopes' drawn art. Type in a font that isn't available, slots without type of their own (threaded, bound to a text or a visibility variable, a wrap object, type a look draws), envelopes in a symbol's or a pattern's art, and envelopes that draw differently keep their drawn art, with the note (#3179). Tests `tests_native::an_envelope_of_type_that_draws_as_its_drawn_art_opens_live`, `an_envelope_of_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone`, `an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live`, `trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_drawn_art`, `layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_its_drawn_art`, `an_envelope_of_type_bound_to_a_visibility_variable_keeps_its_drawn_art` | -| 2026-11-10 | minor | `.ai` and EPS import: an envelope of type, which keeps its drawn art with its type hidden, now says so in the import notes ("type in an envelope comes in as the envelope's drawn art, not as editable type: the type is kept, hidden"); nothing said it before, as the other envelopes that keep their drawn art do. The type is still not editable in the envelope (part of #3179). Test `tests_native::envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance` | | 2026-11-10 | minor | `.ai` and EPS import: an envelope of type, which keeps its drawn art with its type hidden, now says so in the import notes ("type in an envelope comes in as the envelope's drawn art, not as editable type"); nothing said it before, while the other envelopes that keep their drawn art have a note. The type is still not editable in the envelope (part of #3179). Test `tests_native::envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance` | | 2026-11-10 | minor | `.ai` and EPS import: the check that keeps a live envelope or warped type only where it draws as the art saved with it renders each on its own, over nothing, and compared the pixels seen on white, where white art looks like nothing. White contents that drew somewhere else passed it and opened live in the wrong place (white warped type off its banner, in users' files). Pixels now also differ when they look different seen on black, so such art keeps its saved art, as colored art does. Tests `import::ai::tests::a_white_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art`, `white_warped_type_that_draws_elsewhere_comes_in_as_its_look` | | 2026-11-10 | minor | PDF import: an image in a DeviceN space of one colorant opens as a Separation image does (#6314): a grayscale image of its tints colorized by its ink. One of two or more colorants that are all process plates (Cyan, Magenta, Yellow, Black, and the ink `None`) opens as a CMYK image, each colorant's tint in its plate and its Decode array applied (8 or 16 bits a sample; one that can't be read, such as a JPEG, opens in RGB with the CMYK images' warning). Both used to open as RGB images, losing their inks. A DeviceN image that mixes a spot ink with other colorants, such as a duotone, still opens as RGB: an image holds one ink or the process plates, not both (#6348). Tests `colorize::a_devicen_image_of_one_ink_opens_colorized_by_it`, `colorize::a_devicen_image_of_process_plates_opens_in_cmyk`, `colorize::a_duotone_devicen_image_still_opens_as_rgb` |