.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 <noreply@anthropic.com>
This commit is contained in:
jmverges
2026-11-10 17:26:19 +00:00
co-authored by Claude Opus 5.5
parent d70f4a59e4
commit 47b20b4366
12 changed files with 579 additions and 49 deletions
-1
View File
@@ -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;
+173 -5
View File
@@ -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<Rect>,
}
/// 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<Node>,
pub(super) inner: Vec<Node>,
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<TypeKept>,
/// 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<Node>)>,
}
/// 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<WarpedLook>,
pub(super) envelopes: Vec<TypedEnvelope>,
pub(super) checks: usize,
pub(super) spent: Spent,
pub(super) page: Option<Rect>,
}
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<String>) -> 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<NodeId>,
) -> Result<(NodeId, Vec<Node>), 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<Node> = 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<NodeId>,
restyled: &mut Vec<Rect>,
) -> 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<NodeId, Node>) {
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<NodeId>) {
if n.visible {
+4
View File
@@ -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<super::envelope::WarpedLook>,
/// The envelopes of type read with their drawn art, settled once the type is laid out
/// ([`super::envelope::TypedEnvelope`]).
typed_envelopes: Vec<super::envelope::TypedEnvelope>,
/// 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(),
+42 -13
View File
@@ -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<NodeId>) {
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![];
+8 -3
View File
@@ -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<String> = 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,
+3
View File
@@ -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
+1 -1
View File
@@ -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 };
+50 -16
View File
@@ -104,8 +104,7 @@ pub(super) type Built = Result<(Document, Vec<String>), 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<Built, Box<Structure>> {
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<f64> {
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<Node>, &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<NodeId, Filled>,
threaded: &BTreeSet<NodeId>,
bound_text: &BTreeSet<NodeId>,
restyled: &mut Vec<Rect>,
notes: &mut Vec<String>,
) -> Result<(), String> {
) -> Result<Vec<(NodeId, Vec<Node>)>, 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<Node>, &str)],
notes: &[String],
unseen: usize,
trust: bool,
@@ -1270,18 +1303,19 @@ fn drawn_or_trusted(
) -> Result<Vec<String>, 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<Node>, &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<String> = 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());
}
}
+41 -8
View File
@@ -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<Node>]) -> 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<String>), 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<Result<Box<ai::Structure>, 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 }
}
}
+253
View File
@@ -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<String> {
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>>, Vec<(String, bool)>) {
let (mut envelopes, mut loose) = (vec![], vec![]);
fn walk(n: &Node, envelopes: &mut Vec<Vec<String>>, 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<vectorcraft_color::Color> {
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.
+3 -1
View File
@@ -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` |
+1 -1
View File
@@ -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.