diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index a3de7560e3..4595061151 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -39,6 +39,7 @@ pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; pub(crate) use envelope::MAX_CHECKED; pub(crate) use envelope::TYPE_DRAWN as ENVELOPE_TYPE_DRAWN; pub(crate) use envelope::{GridPoint as EnvelopeGridPoint, PLUGIN as DEFORM_PLUGIN, outline as envelope_outline, upright as envelope_upright}; +pub(crate) use envelope::{MAX_ANY as ENVELOPE_MAX_ANY, MAX_SOLID as ENVELOPE_MAX_SOLID}; pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType}; pub(crate) use frames::text_frames; pub use obj::{NameEncoding, Names}; diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index 741ebfabdf..246121662d 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -73,9 +73,9 @@ const MOVED_SLACK: f64 = 0.5; const KNOWN_BITS: i64 = 0b111 | 1 << 16 | 1 << 17; /// More of the inked pixels than this differing, each with all its neighbours, and the envelope /// doesn't come out as drawn. -const MAX_SOLID: f64 = 0.03; +pub(crate) const MAX_SOLID: f64 = 0.03; /// More of the inked pixels than this differing at all, and the envelope doesn't come out as drawn. -const MAX_ANY: f64 = 0.1; +pub(crate) const MAX_ANY: f64 = 0.1; /// The whole number of entry `key`: `None` when it is missing, `Some(None)` when it isn't one. fn whole(o: &Obj, key: &str) -> Option> { @@ -648,6 +648,9 @@ pub(crate) enum TypeKept { Budget, /// Its live envelope doesn't draw as its drawn art. Differs, + /// A page that the layers, which don't draw as it, were compared with holds it against them + /// ([`WarpedType::open_alone`]). + Page, } /// What [`WarpedType::settle_envelopes`] did. @@ -657,9 +660,10 @@ pub(crate) struct TypedSettled { 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)>, + /// Each live envelope's place among the envelopes of type (in the order they were read) and + /// 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<(usize, NodeId, Vec)>, } /// The text objects read with a Warp and the envelopes of type, and what the import has left of the @@ -672,6 +676,9 @@ pub struct WarpedType { pub(super) checks: usize, pub(super) spent: Spent, pub(super) page: Option, + /// The envelopes of type that may open live in layers built without a page, by their place + /// among them ([`Self::open_alone`]): none when `None`. + pub(super) alone: Option>, } impl WarpedType { @@ -682,14 +689,33 @@ impl WarpedType { /// 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. + /// live envelope opens only where the page chooses it. Unless [`Self::open_alone`] names some: + /// then they all wait, as with a page, and only those named may stay live. 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) { + if !compared && self.alone.is_none() && !std::mem::take(&mut self.envelopes).is_empty() && !notes.iter().any(|n| n == TYPE_DRAWN) { notes.push(TYPE_DRAWN.into()); } self } + /// Let the envelopes of type at `places` (among them, in the order they were read) open live in + /// layers built without a page, where they draw as their drawn art: a page these same layers, + /// read the same way, were compared with weighed them and didn't hold them against the layers' + /// difference from it. All the envelopes of type are settled as they were then, in the same + /// order and with the same checks, so each that opens live draws as it was weighed; then the + /// others get their drawn art back ([`Self::settle_envelopes`]). `count` is how many envelopes + /// of type that reading had: a reading with another number of them names none. + pub(crate) fn open_alone(&mut self, places: &[usize], count: usize) { + if self.envelopes.len() == count { + self.alone = Some(places.iter().copied().filter(|i| *i < count).collect()); + } + } + + /// How many envelopes of type wait for their type. + pub(crate) fn envelope_count(&self) -> usize { + self.envelopes.len() + } + /// 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 @@ -713,16 +739,28 @@ impl WarpedType { doc.layers.iter().for_each(|l| collect_shown(l, &mut shown)); let envelopes = std::mem::take(&mut self.envelopes); let mut waiting = envelopes.len(); - for envelope in envelopes { + for (place, envelope) in envelopes.into_iter().enumerate() { waiting -= 1; match self.typed_live(doc, envelope, &type_of, &shown, waiting) { - Ok(put_back) => { + Ok((id, put_back)) => { settled.live += 1; - settled.put_back.push(put_back); + settled.put_back.push((place, id, put_back)); } Err(why) => settled.kept.push(why), } } + // Built without a page, the envelopes [`Self::open_alone`] doesn't name get their drawn art + // and hidden type back. One inside another that got its own back went with that art. + if let Some(free) = &self.alone { + let (stay, back): (Vec<_>, Vec<_>) = std::mem::take(&mut settled.put_back).into_iter().partition(|(place, ..)| free.contains(place)); + for (_, id, nodes) in back { + if doc.insert_beside(vec![(id, nodes)], true).is_ok() || doc.node(id).is_none() { + settled.live = settled.live.saturating_sub(1); + settled.kept.push(TypeKept::Page); + } + } + settled.put_back = stay; + } settled } diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index ca943e8817..0bcae74b32 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1367,6 +1367,7 @@ impl<'a> Reader<'a> { checks: self.envelope_checks, spent: self.envelope_spent, page, + alone: None, }, styles_read, text_patterns, diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 9b86f38350..f2a0a41ca7 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -8280,7 +8280,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)], envelopes: vec![], checks: 1, spent, page }; + let warped = super::envelope::WarpedType { looks: vec![copy(w)], envelopes: vec![], checks: 1, spent, page, alone: None }; 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 8adcfccef0..1b212194a1 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -1,5 +1,6 @@ use super::compare::{ - MAX_DIFFERENCE, MAX_PAGES_COMPARED, MAX_PIXELS_COMPARED, MAX_SOLID_DIFFERENCE, PageView, compare, comparison_samples, difference, pixels_compared, + MAX_DIFFERENCE, MAX_PAGES_COMPARED, MAX_PIXELS_COMPARED, MAX_SOLID_DIFFERENCE, PAST_THE_PAGE, PageView, compare, comparison_samples, difference, + pixels_compared, }; use super::plan::{made_only, make_slots, plan, readable_all}; use super::slots::{ @@ -230,6 +231,24 @@ pub(super) fn build_from( outlined: bool, trust: bool, unit: f64, +) -> Result<(Document, Vec), String> { + build_weighing(structure, data, visible, page, outlined, trust, unit, &mut None) +} + +/// [`build_from`]. When the layers with their live envelopes don't draw as the page, and don't with +/// the drawn art of every live envelope put back either, `free` is set to the envelopes of type +/// (by their place among them, as read) that opened live and that the page doesn't hold against +/// them ([`held_back`]), whether or not the layers are then used, and however. +#[allow(clippy::too_many_arguments)] +pub(super) fn build_weighing( + structure: Structure, + data: &[u8], + visible: Option<&Document>, + page: Page, + outlined: bool, + trust: bool, + unit: f64, + free: &mut Option>, ) -> Result<(Document, Vec), String> { let Structure { mut doc, @@ -468,12 +487,14 @@ pub(super) fn build_from( let warped_type = warped_type.compared(frame.is_some(), &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)?; + // Each live envelope of type's place among the envelopes of type, by its id. + let places: Vec<(NodeId, usize)> = typed.iter().map(|(place, id, _)| (*id, *place)).collect(); // 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))) + .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 })); @@ -525,20 +546,21 @@ pub(super) fn build_from( notes.extend(unseen_note(unseen).into_iter().chain(note)); } other => match drawn_or_trusted(&frame, &mut view, &mut doc, &envelopes_drawn, ¬es, unseen, trust, why, reflow) { - Ok(more) => notes.extend(more), + Ok((more, held)) => { + *free = held.map(|held| free_places(&places, &held)); + 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 layers don't draw as the page: - // envelopes of type keep their drawn art and their type, hidden. + // the envelopes of type it holds against them keep their drawn art and their + // type, hidden. Where no placement's differing objects can then come in as the + // page's art (an object that holds type isn't replaced), every envelope of type + // keeps its drawn art, and that is tried again. Err(why) => { - 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)) - }) else { + let other = other.map(|(_, other, unseen)| (other, unseen)); + let layers = Placements { doc: &doc, unseen, other: other.as_ref(), drawn: &envelopes_drawn, places: &places }; + let found = keep_differing_typed(&frame, &mut view, &layers, &threaded_frames, &shown, reflow, free); + let Some((kept, unseen, kept_notes, typed)) = found else { return Err(why); }; doc = kept; @@ -693,8 +715,9 @@ fn bound_slots( /// 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 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). +/// others keep their drawn art, with a note. Returns each live envelope of type's place among the +/// envelopes of type (in the order they were read) and 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, @@ -703,7 +726,7 @@ fn settle_warped_type( bound: [&BTreeSet; 2], restyled: &mut Vec, notes: &mut Vec, -) -> Result)>, String> { +) -> 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), @@ -1152,15 +1175,15 @@ const MAX_DIFFERING: usize = 64; const MAX_PATCH_PAIRS: usize = 4_000_000; /// The layers as read and at the placed files' other placement (each with its count of text -/// objects the page doesn't draw), the one that draws closer to the page first. `reflow` as in -/// [`PageView::difference`]. -fn closer_first( +/// objects the page doesn't draw, and what goes with it), the one that draws closer to the page +/// first. `reflow` as in [`PageView::difference`]. +fn closer_first( frame: &Frame<'_>, view: &mut PageView, - doc: (Document, usize), - other: Option<(Document, usize)>, + doc: (Document, usize, T), + other: Option<(Document, usize, T)>, reflow: bool, -) -> Vec<(Document, usize)> { +) -> Vec<(Document, usize, T)> { match other { Some(other) if view.difference_on_every_page(&other.0, frame.rect, reflow).any < view.difference_on_every_page(&doc.0, frame.rect, reflow).any => @@ -1278,6 +1301,73 @@ fn keep_differing( Some((kept, note.into_iter().chain([format!("{DREW_DIFFERENTLY}: {}", layers.join(", "))]).collect())) } +/// The places (among the envelopes of type, as read) of the live envelopes of type in `places` +/// (each with its id) that `held` doesn't list. +fn free_places(places: &[(NodeId, usize)], held: &BTreeSet) -> Vec { + places.iter().filter(|(id, _)| !held.contains(id)).map(|(_, place)| *place).collect() +} + +/// The layers of [`keep_differing_typed`]: `doc`, `unseen` text objects of it taken out; the placed +/// files' other placement of it, when it has one, with how many of its text objects were; its live +/// envelopes, as [`drawn_or_trusted`] takes them; and the place of each live envelope of type +/// among the envelopes of type, by its id. +struct Placements<'a> { + doc: &'a Document, + unseen: usize, + other: Option<&'a (Document, usize)>, + drawn: &'a [(NodeId, Vec, &'a str)], + places: &'a [(NodeId, usize)], +} + +/// [`keep_differing`] of the placement of `layers` that draws closer to the page, the envelopes of +/// type the page holds against that placement ([`held_back`], each placement weighed on its own: the +/// other one draws placed files elsewhere, under or over the envelopes) having their drawn art and +/// hidden type back; and where none of the placements' differing objects can then come in as the +/// page's art, every envelope of type: the document, how many of its text objects were taken out, +/// its notes, and whether an envelope of type got its drawn art back. A placement that draws as the +/// page with only the envelopes the page holds put back is used as it is, as one with all the live +/// envelopes' drawn art put back would have been ([`drawn_or_trusted`]): nothing in it differs to +/// come in as the page's art. `free` is set to the places of those the page doesn't hold against +/// the layers as read. +#[allow(clippy::too_many_arguments)] +fn keep_differing_typed( + frame: &Frame<'_>, + view: &mut PageView, + layers: &Placements<'_>, + threaded_frames: &BTreeSet, + shown: &[Vec>], + reflow: bool, + free: &mut Option>, +) -> Option<(Document, usize, Vec, bool)> { + let held = held_back(frame, view, layers.doc, layers.drawn, reflow); + *free = Some(free_places(layers.places, &held)); + let held_other = layers.other.map(|(other, _)| held_back(frame, view, other, layers.drawn, reflow)); + let all: BTreeSet = layers.places.iter().map(|(id, _)| *id).collect(); + let fewer = held.len() < all.len() || held_other.as_ref().is_some_and(|h| h.len() < all.len()); + let mut tries = vec![]; + if fewer { + tries.push((held, held_other)); + } + tries.push((all.clone(), Some(all.clone()))); + tries.into_iter().find_map(|(held, held_other)| { + // Each placement with what it put back: whether an envelope of type, and whether only some. + let mut base = layers.doc.clone(); + let typed = (type_drawn(&mut base, layers.drawn, &held), held.len() < all.len()); + let other = layers.other.map(|(other, unseen)| { + let (mut other, held) = (other.clone(), held_other.as_ref().unwrap_or(&held)); + let typed = (type_drawn(&mut other, layers.drawn, held), held.len() < all.len()); + (other, *unseen, typed) + }); + let placements = closer_first(frame, view, (base, layers.unseen, typed), other, reflow); + placements.into_iter().find_map(|(base, unseen, (typed, some))| { + if some && let Ok(notes) = compare(frame, view, &base, reflow) { + return Some((base, unseen, notes, typed)); + } + keep_differing(&base, frame, view, threaded_frames, shown, reflow).map(|(kept, notes)| (kept, unseen, notes, typed)) + }) + }) +} + /// Copy page drawing IDs, including mask art outside the ordinary child tree. pub(super) fn reid_page_art(doc: &mut Document, source: &Node) -> Node { let mut copy = doc.reid(source); @@ -1338,9 +1428,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 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. +/// else, with `trust`, the notes for `doc` as it is but for the envelopes of type the page holds +/// against it ([`held_back`]), which keep their drawn art ([`type_drawn`]), with those envelopes. +/// An error, `why`, otherwise. The envelope note is added unless `notes` has it. #[allow(clippy::too_many_arguments)] fn drawn_or_trusted( frame: &Frame<'_>, @@ -1352,7 +1442,7 @@ fn drawn_or_trusted( trust: bool, why: String, reflow: bool, -) -> Result, String> { +) -> Result<(Vec, Option>), String> { let unseen_note = |n: usize| (n > 0).then(|| format!("{n} {UNSEEN_TEXT}")); let mut put_back = doc.clone(); // Each envelope still there, in place of the art it took the place of. @@ -1379,26 +1469,143 @@ fn drawn_or_trusted( envelope.push(say.to_string()); } } - return Ok(envelope.into_iter().chain(unseen_note(unseen + more)).chain(note).collect()); + return Ok((envelope.into_iter().chain(unseen_note(unseen + more)).chain(note).collect(), None)); } } if trust { - let typed = type_drawn(doc, drawn).then(|| ai::ENVELOPE_TYPE_DRAWN.to_string()).filter(|n| !notes.contains(n)); + let held = held_back(frame, view, doc, drawn, reflow); + let typed = type_drawn(doc, drawn, &held).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()); + return Ok((typed.into_iter().chain(unseen_note(unseen)).chain([trusted]).collect(), Some(held))); } Err(why) } +/// The live envelopes of type in `drawn` (as [`drawn_or_trusted`] takes them) that the page holds +/// against the layers `doc`, which don't draw as it even with every live envelope's drawn art put +/// back: by their ids, those where the layers don't draw as the page. The envelope and its drawn art +/// are drawn together on a layer of their own, without the rest of the layers, on the page check's +/// drawing of each page they lie on: in the pixels they ink there, the layers with the envelope may +/// differ from the page in no more than the envelope's own check allows against its drawn art (at +/// all, and with all their neighbors, away from type: [`compare`]); and in the order they were +/// read, the envelopes that stay live may differ there in no more of each page together than half +/// of what the page check allows (a budget of the same size as the one their own checks share, kept +/// apart from it). Art that differs elsewhere on the page doesn't hold them. One the layers don't +/// draw where it is and as itself ([`drawn_in_place`]) is held, wherever it lies: drawn on its own, +/// it would be weighed elsewhere than it draws, or on pixels a clip or mask hides, and what holds it +/// can draw it onto a page from off it. Another envelope on no page stays live, as the page check +/// doesn't see it, unless the page holds all the art that prints ([`Frame::whole`]), where art past +/// it is compared too: there one reaching past the page is held. Past [`MAX_WEIGHED`] of them on +/// the pages, or where the page check can't weigh one, they are all held. +fn held_back(frame: &Frame<'_>, view: &mut PageView, doc: &Document, drawn: &[(NodeId, Vec, &str)], reflow: bool) -> BTreeSet { + let typed: Vec<(NodeId, &Node, &Node)> = drawn + .iter() + .filter(|(_, _, say)| *say == ai::ENVELOPE_TYPE_DRAWN) + .filter_map(|(id, art, _)| Some((*id, doc.node(*id)?, art.last()?))) + .collect(); + let all: BTreeSet = typed.iter().map(|(id, ..)| *id).collect(); + if all.is_empty() { + return all; + } + let pages = view.regions(frame.rect); + let past = |b: Rect| frame.whole && !frame.rect.inflate(PAST_THE_PAGE, PAST_THE_PAGE).contains_rect(b); + let mut held = BTreeSet::new(); + let mut in_place = std::collections::HashMap::new(); + drawn_in_place(&doc.layers, &all, true, &mut in_place); + // Those the page check sees, by the boxes of the envelope and its drawn art. + let mut seen = vec![]; + for (id, live, art) in typed { + if !in_place.get(&id).copied().unwrap_or(false) { + held.insert(id); + continue; + } + match vectorcraft_geom::union_opt(live.visual_bounds(), art.visual_bounds()) { + Some(b) if past(b) => { + held.insert(id); + } + Some(b) if pages_touched(b, &pages, 1.0).is_empty() => {} + _ => seen.push((id, live, art)), + } + } + if seen.len() > MAX_WEIGHED { + return all; + } + // The pixels of each page where the envelopes that stay live differ from it: at all, and with + // all their neighbors. + let mut spent = vec![(0usize, 0usize); pages.len()]; + for (id, live, art) in seen { + // The envelope and its drawn art, shown, on a layer of their own in `doc`. + let mut nodes: Vec> = [live, art].into_iter().cloned().map(Arc::new).collect(); + for n in &mut nodes { + let n = Arc::make_mut(n); + (n.visible, n.opacity) = (true, 1.0); + } + let mut one = doc.clone(); + let mut layer = Node::layer(NodeId(0), "", vectorcraft_doc::LayerColor::Preset(0)); + if let Some(children) = layer.children_mut() { + *children = nodes; + } + one.layers = vec![Arc::new(layer)]; + let bounds = vectorcraft_render::encode::art_bounds(&one); + if bounds.is_some_and(past) { + held.insert(id); + continue; + } + let on = bounds.map_or(vec![], |b| pages_touched(b, &pages, 1.0)); + let mut added = spent.clone(); + let mut fits = true; + for (i, ink) in view.inked(&one, &on) { + let Some((any, solid)) = view.differing_in(doc, i, &ink, reflow) else { return all }; + let inked = ink.iter().filter(|p| **p).count() as f64; + let pixels = ink.len().max(1) as f64; + let Some((a, s)) = added.get_mut(i) else { return all }; + *a += any; + *s += solid; + fits &= any as f64 <= inked * ai::ENVELOPE_MAX_ANY + && solid as f64 <= inked * ai::ENVELOPE_MAX_SOLID + && *a as f64 <= pixels * MAX_DIFFERENCE / 2.0 + && *s as f64 <= pixels * MAX_SOLID_DIFFERENCE / 2.0; + } + if fits { + spent = added; + } else { + held.insert(id); + } + } + held +} + +/// Which of the objects `ids` among `nodes` (or what they hold) are drawn where they are and as +/// themselves (`plain`: so far, above `nodes`), into `out` by id: under layers and groups that +/// neither clip them, mask them nor have an appearance of their own (whose effects can move or copy +/// them), and in no envelope, repeat, blend or other object that draws what it holds elsewhere or +/// more than once. +fn drawn_in_place(nodes: &[Arc], ids: &BTreeSet, plain: bool, out: &mut std::collections::HashMap) { + for n in nodes { + if ids.contains(&n.id) { + out.insert(n.id, plain); + } + let Some(children) = n.children() else { continue }; + let keeps = match n.kind { + NodeKind::Layer { clip, .. } | NodeKind::Group { clip, .. } => { + !clip && n.appearance == vectorcraft_doc::Appearance::default() && n.extras().mask.is_none() + } + _ => false, + }; + drawn_in_place(children, ids, plain && keeps, out); + } +} + /// 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 { +/// has and `held` lists back as its hidden type and its drawn art: whether one was. An envelope of +/// type opens live only where the page doesn't hold it against the layers ([`held_back`]), so where +/// they don't draw as the page though they are used all the same (the editing data trusted, or the +/// objects that differ come in as the page's art), one the page holds against them keeps its drawn +/// art. +fn type_drawn(doc: &mut Document, drawn: &[(NodeId, Vec, &str)], held: &BTreeSet) -> bool { let typed: Vec<(NodeId, Vec)> = drawn .iter() - .filter(|(id, _, say)| *say == ai::ENVELOPE_TYPE_DRAWN && doc.node(*id).is_some()) + .filter(|(id, _, say)| *say == ai::ENVELOPE_TYPE_DRAWN && held.contains(id) && doc.node(*id).is_some()) .map(|(id, art, _)| (*id, art.clone())) .collect(); if typed.is_empty() || doc.insert_beside(typed, true).is_err() { @@ -2562,4 +2769,359 @@ mod candidate_tests { replace_unread(&mut doc, &frame, &unread, &[], &BTreeSet::new(), &mut copied).unwrap(); assert!(copied.contains(&nodes[0].id) && !copied.contains(&nodes[1].id)); } + + /// A rectangle on `rect` filled with `color`, with an id of `doc`'s. + fn filled_rect(doc: &mut Document, rect: Rect, color: vectorcraft_color::Color) -> Node { + let paint = vectorcraft_color::Paint::solid(color); + Node::path( + doc.alloc_id(), + vectorcraft_geom::shapes::rectangle(rect), + vectorcraft_doc::Appearance::basic(paint, vectorcraft_color::Paint::None, 0.0), + ) + } + + /// A live envelope as [`drawn_or_trusted`] takes it: its id, what takes its place again, its note. + type Envelope = (NodeId, Vec, &'static str); + + /// The layers `doc` with a black rectangle on each of `lives` standing in for a live envelope of + /// type, its drawn art a black rectangle on the matching one of `arts`: the layers, and the + /// envelopes as [`drawn_or_trusted`] takes them. + fn stand_ins(lives: &[Rect], arts: &[Rect]) -> (Document, Vec) { + let mut doc = Document::new(100.0, 100.0); + let layer = doc.layers[0].id; + let mut drawn = vec![]; + for (live, art) in lives.iter().zip(arts) { + let n = filled_rect(&mut doc, *live, vectorcraft_color::Color::BLACK); + let id = n.id; + doc.insert(Some(layer), 0, n).unwrap(); + let art = filled_rect(&mut doc, *art, vectorcraft_color::Color::BLACK); + drawn.push((id, vec![art], ai::ENVELOPE_TYPE_DRAWN)); + } + (doc, drawn) + } + + /// The page holds an envelope of type against layers that don't draw as it only where the + /// layers with the envelope differ from the page where it or its drawn art inks: not where the + /// page draws other art that the layers don't have. + #[test] + fn the_page_holds_back_only_envelopes_of_type_where_it_differs_from_them() { + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let mut page = Document::new(100.0, 100.0); + let layer = page.layers[0].id; + // The first two envelopes' drawn art, and a square the layers don't have. + for r in [Rect::new(10.0, 10.0, 30.0, 30.0), Rect::new(60.0, 10.0, 80.0, 30.0), Rect::new(10.0, 60.0, 30.0, 80.0)] { + let n = filled_rect(&mut page, r, vectorcraft_color::Color::BLACK); + page.insert(Some(layer), 0, n).unwrap(); + } + // Live where its drawn art is; moved off its drawn art; and where the page draws nothing. + let (doc, drawn) = stand_ins( + &[Rect::new(10.0, 10.0, 30.0, 30.0), Rect::new(66.0, 10.0, 86.0, 30.0), Rect::new(60.0, 60.0, 80.0, 80.0)], + &[Rect::new(10.0, 10.0, 30.0, 30.0), Rect::new(60.0, 10.0, 80.0, 30.0), Rect::new(60.0, 60.0, 80.0, 80.0)], + ); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] }; + let mut view = PageView::new(&frame); + assert!(compare(&frame, &mut view, &doc, false).is_err()); + assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), BTreeSet::from([drawn[1].0, drawn[2].0])); + // Only envelopes of type are weighed. + let other: Vec<_> = drawn.iter().map(|(id, art, _)| (*id, art.clone(), ai::ENVELOPE_DRAWN)).collect(); + assert!(held_back(&frame, &mut view, &doc, &other, false).is_empty()); + } + + /// The envelopes of type that stay live may together differ from the page where they ink in no + /// more than half of what the page check allows: the next one past that is held back, though + /// on its own it would stay. + #[test] + fn envelopes_of_type_stay_live_within_half_of_what_the_page_check_allows() { + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let halves = [Rect::new(0.0, 0.0, 100.0, 25.0), Rect::new(0.0, 25.0, 100.0, 50.0)]; + let mut page = Document::new(100.0, 100.0); + let layer = page.layers[0].id; + for r in halves { + let n = filled_rect(&mut page, r, vectorcraft_color::Color::BLACK); + page.insert(Some(layer), 0, n).unwrap(); + } + // White lines two pixels high over each, 6 points apart: 8% of what each inks differs, 2% of + // the page, and none of it with all its neighbors. + for (i, y) in [3.0, 9.0, 15.0, 21.0, 28.0, 34.0, 40.0, 46.0].into_iter().enumerate() { + let n = filled_rect(&mut page, Rect::new(0.0, y, 100.0, y + 0.5), vectorcraft_color::Color::WHITE); + page.insert(Some(layer), halves.len() + i, n).unwrap(); + } + let (doc, drawn) = stand_ins(&halves, &halves); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] }; + let mut view = PageView::new(&frame); + assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), BTreeSet::from([drawn[1].0])); + assert!(held_back(&frame, &mut view, &doc, &drawn[1..], false).is_empty()); + } + + /// A page of 100 points with a black rectangle on each of `rects`, and its frame (`whole`: the + /// page holds all the art that prints). + fn black_page(rects: &[Rect]) -> Document { + let mut page = Document::new(100.0, 100.0); + let layer = page.layers[0].id; + for r in rects { + let n = filled_rect(&mut page, *r, vectorcraft_color::Color::BLACK); + page.insert(Some(layer), 0, n).unwrap(); + } + page + } + + /// The frame of `page`, its 100-point square (`whole`: the page holds all the art that prints). + fn frame_of(page: &Document, whole: bool) -> Frame<'_> { + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + Frame { page, page_rect: rect, rect, whole, pages: vec![(rect, Affine::IDENTITY)], more: vec![] } + } + + /// An envelope of type is held back where the layers with it differ from the page in more of + /// the pixels it and its drawn art ink than its own check allows against its drawn art, though + /// that is far less of the page than the page check allows. + #[test] + fn an_envelope_of_type_differing_in_more_of_its_pixels_than_its_check_allows_is_held_back() { + // Half a point off its drawn art, which the page draws: 22% of the pixels they ink differ, + // a tenth of a percent of the page, and none with all their neighbors. + let page = black_page(&[Rect::new(30.5, 10.0, 34.5, 20.0)]); + let (doc, drawn) = stand_ins(&[Rect::new(30.0, 10.0, 34.0, 20.0)], &[Rect::new(30.5, 10.0, 34.5, 20.0)]); + let frame = frame_of(&page, false); + let mut view = PageView::new(&frame); + assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), BTreeSet::from([drawn[0].0])); + } + + /// An envelope of type that the layers don't draw where it is and as itself is held back though + /// it draws as the page, wherever it lies: one that a clip group or a clipping sublayer clips, an + /// opacity mask masks, or that a group with an appearance of its own, another envelope, a repeat + /// or a blend holds. Drawn on its own, it would be weighed on pixels a clip hides, or elsewhere + /// than it draws, and what holds it can draw it onto the page from off it. One in a plain group + /// is weighed, and one off the page in a plain group stays live. + #[test] + fn envelopes_of_type_the_layers_dont_draw_in_place_are_held_back() { + let at = |i: usize| { + let (x, y) = if i < 4 { (5.0 + 20.0 * i as f64, 5.0) } else { (5.0 + 20.0 * (i - 4) as f64, 30.0) }; + Rect::new(x, y, x + 15.0, y + 15.0) + }; + let off = [Rect::new(120.0, 5.0, 135.0, 20.0), Rect::new(140.0, 5.0, 155.0, 20.0)]; + let page = black_page(&(0..8).map(at).collect::>()); + let mut doc = Document::new(100.0, 100.0); + let layer = doc.layers[0].id; + let mut drawn = vec![]; + let mut stand_in = |doc: &mut Document, r: Rect| { + let n = filled_rect(doc, r, vectorcraft_color::Color::BLACK); + drawn.push((n.id, vec![filled_rect(doc, r, vectorcraft_color::Color::BLACK)], ai::ENVELOPE_TYPE_DRAWN)); + Arc::new(n) + }; + let group = |doc: &mut Document, children: Vec>, clip: bool| Node::new(doc.alloc_id(), NodeKind::Group { children, clip }); + let fill = vectorcraft_doc::Appearance { + items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid( + vectorcraft_color::Color::BLACK, + )))], + ..Default::default() + }; + let mut objects = vec![]; + // 0: in a plain group. + let n = stand_in(&mut doc, at(0)); + objects.push(group(&mut doc, vec![n], false)); + // 1: in a clip group. + let n = stand_in(&mut doc, at(1)); + let mut clip = filled_rect(&mut doc, at(1).inflate(2.0, 2.0), vectorcraft_color::Color::BLACK); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + objects.push(group(&mut doc, vec![Arc::new(clip), n], true)); + // 2: in another envelope. + let n = stand_in(&mut doc, at(2)); + let r = at(2); + let points = vec![kurbo::Point::new(r.x0, r.y0), kurbo::Point::new(r.x1, r.y0), kurbo::Point::new(r.x0, r.y1), kurbo::Point::new(r.x1, r.y1)]; + let kind = Box::new(vectorcraft_doc::live::EnvelopeKind::Mesh { rows: 1, cols: 1, points, handles: vec![] }); + let options = vectorcraft_doc::live::EnvelopeOptions::NEW; + objects.push(Node::new( + doc.alloc_id(), + NodeKind::Envelope { content: vec![n], kind, fidelity: 50.0, editing: false, options, frame: Box::default() }, + )); + // 3: in a group with an appearance of its own. + let n = stand_in(&mut doc, at(3)); + let mut styled = group(&mut doc, vec![n], false); + styled.appearance = fill.clone().into(); + objects.push(styled); + // 4: in a group with an opacity mask. + let n = stand_in(&mut doc, at(4)); + let mut masked = group(&mut doc, vec![n], false); + let mask = filled_rect(&mut doc, at(4).inflate(2.0, 2.0), vectorcraft_color::Color::WHITE); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(mask, true))); + objects.push(masked); + // 5: in a clipping sublayer. + let n = stand_in(&mut doc, at(5)); + let mut clip = filled_rect(&mut doc, at(5).inflate(2.0, 2.0), vectorcraft_color::Color::BLACK); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let mut sublayer = Node::layer(doc.alloc_id(), "Clipped", vectorcraft_doc::LayerColor::Preset(1)); + if let NodeKind::Layer { clip: c, children, .. } = &mut sublayer.kind { + (*c, *children) = (true, vec![Arc::new(clip), n]); + } + objects.push(sublayer); + // 6: in a repeat; 7: in a blend. + let n = stand_in(&mut doc, at(6)); + objects.push(Node::new(doc.alloc_id(), NodeKind::Repeat(vectorcraft_doc::RepeatSpec::grid(vec![n], 0.0, 0.0)))); + let n = stand_in(&mut doc, at(7)); + objects.push(Node::new(doc.alloc_id(), NodeKind::Blend { children: vec![n], spec: Box::default() })); + // 8: off the page in a group with an appearance of its own (an effect could draw it onto the + // page); 9: off the page in a plain group. + let n = stand_in(&mut doc, off[0]); + let mut styled = group(&mut doc, vec![n], false); + styled.appearance = fill.into(); + objects.push(styled); + let n = stand_in(&mut doc, off[1]); + objects.push(group(&mut doc, vec![n], false)); + for n in objects { + doc.insert(Some(layer), 0, n).unwrap(); + } + let frame = frame_of(&page, false); + let mut view = PageView::new(&frame); + let held = held_back(&frame, &mut view, &doc, &drawn, false); + assert_eq!(held, drawn[1..9].iter().map(|(id, ..)| *id).collect::>()); + } + + /// On a page that holds all the art that prints, which the page check also compares past the + /// page, an envelope of type reaching past the page, or lying off it, is held back; on another + /// page, the part on the page is weighed, and one off it stays live. + #[test] + fn on_a_page_that_holds_all_the_art_an_envelope_of_type_past_it_is_held_back() { + let on = Rect::new(10.0, 10.0, 30.0, 30.0); + let across = Rect::new(90.0, 40.0, 110.0, 60.0); + let off = Rect::new(120.0, 10.0, 140.0, 30.0); + let page = black_page(&[on, Rect::new(90.0, 40.0, 100.0, 60.0)]); + let (doc, drawn) = stand_ins(&[on, across, off], &[on, across, off]); + for (whole, held) in [(true, BTreeSet::from([drawn[1].0, drawn[2].0])), (false, BTreeSet::new())] { + let frame = frame_of(&page, whole); + let mut view = PageView::new(&frame); + assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), held, "whole: {whole}"); + } + // On the page by its box, past it by what it draws: a Transform effect of its own draws a + // copy of it 100 points to the right, off the page. + let page = black_page(&[on]); + let (mut doc, drawn) = stand_ins(&[on], &[on]); + let effect = + vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 100, "copies": 1}), visible: true }; + doc.node_mut(drawn[0].0).unwrap().appearance.effects.push(effect); + for (whole, held) in [(true, BTreeSet::from([drawn[0].0])), (false, BTreeSet::new())] { + let frame = frame_of(&page, whole); + let mut view = PageView::new(&frame); + assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), held, "drawn past the page, whole: {whole}"); + } + } + + /// Where the objects that draw differently from the page are to come in as its art and the + /// envelopes of type the page doesn't hold stay live, but one of those holds the difference in + /// its box (an object that holds type isn't replaced), every envelope of type gets its drawn art + /// back and that is tried again: the layers are still used. + #[test] + fn objects_come_in_as_the_page_art_with_every_envelope_of_type_put_back_where_a_live_one_holds_a_difference() { + let (a, b, dot, other) = ( + Rect::new(10.0, 10.0, 20.0, 20.0), + Rect::new(40.0, 10.0, 50.0, 20.0), + Rect::new(27.0, 12.0, 33.0, 18.0), + Rect::new(70.0, 70.0, 90.0, 90.0), + ); + // The page draws a dot in the gap between the two rectangles of the envelope's stand-in. + let page = black_page(&[a, b, dot, other]); + let mut doc = Document::new(100.0, 100.0); + let layer = doc.layers[0].id; + let square = filled_rect(&mut doc, other, vectorcraft_color::Color::BLACK); + doc.insert(Some(layer), 0, square).unwrap(); + // The stand-in holds type, hidden; its drawn art is the two rectangles. + let style = vectorcraft_doc::CharStyle::default(); + let mut text = + Node::new(doc.alloc_id(), NodeKind::Text(Box::new(vectorcraft_doc::TextObject::point(kurbo::Point::new(12.0, 18.0), "A", style)))); + text.visible = false; + let parts = vec![ + Arc::new(filled_rect(&mut doc, a, vectorcraft_color::Color::BLACK)), + Arc::new(filled_rect(&mut doc, b, vectorcraft_color::Color::BLACK)), + ]; + let mut children = parts.clone(); + children.push(Arc::new(text)); + let live = Node::new(doc.alloc_id(), NodeKind::Group { children, clip: false }); + let id = live.id; + doc.insert(Some(layer), 0, live).unwrap(); + let art = Node::new(doc.alloc_id(), NodeKind::Group { children: parts, clip: false }); + let drawn: Vec = vec![(id, vec![art], ai::ENVELOPE_TYPE_DRAWN)]; + let frame = frame_of(&page, false); + let mut view = PageView::new(&frame); + assert!(compare(&frame, &mut view, &doc, false).is_err()); + let layers = Placements { doc: &doc, unseen: 0, other: None, drawn: &drawn, places: &[(id, 0)] }; + let mut free = None; + let found = keep_differing_typed(&frame, &mut view, &layers, &BTreeSet::new(), &[], false, &mut free); + assert_eq!(free, Some(vec![0]), "the page doesn't hold the envelope"); + let Some((kept, _, notes, typed)) = found else { panic!("the layers aren't used") }; + assert!(typed, "the envelope got its drawn art back"); + assert!(kept.node(id).is_none()); + assert!(notes.iter().any(|n| n.starts_with(DREW_DIFFERENTLY)), "{notes:?}"); + } + + /// The placed files' other placement is weighed on its own: an envelope of type that the layers + /// as read draw as the page, but that a placed file moved over it at the other placement makes + /// differ, gets its drawn art back there, where that placement's differing objects then come in + /// as the page's art. + #[test] + fn the_other_placement_of_placed_files_is_weighed_on_its_own() { + let black = vectorcraft_color::Color::BLACK; + let gray = vectorcraft_color::Color::rgb(0.5, 0.5, 0.5); + let (envelope, fillers) = (Rect::new(10.0, 10.0, 30.0, 30.0), [Rect::new(40.0, 80.0, 50.0, 90.0), Rect::new(70.0, 80.0, 80.0, 90.0)]); + let placed = Rect::new(60.0, 60.0, 76.0, 76.0); + // The page draws the envelope's drawn art in two halves. + let page = black_page(&[Rect::new(10.0, 10.0, 20.0, 30.0), Rect::new(20.0, 10.0, 30.0, 30.0), placed, fillers[0], fillers[1]]); + let (mut doc, drawn) = stand_ins(&[envelope], &[envelope]); + let layer = doc.layers[0].id; + for r in fillers { + let n = filled_rect(&mut doc, r, black); + doc.insert(Some(layer), 0, n).unwrap(); + } + // Two placed files, standing in as rectangles: one 20 points above where the page has it, one + // a gray rectangle off the art. At their other placement the first is where the page has it + // and the gray one lies over half of the envelope. + let image = filled_rect(&mut doc, Rect::new(60.0, 40.0, 76.0, 56.0), black); + let strip = filled_rect(&mut doc, Rect::new(85.0, 40.0, 95.0, 60.0), gray); + // On top of the layer: the strip covers the envelope where it lies over it. + let top = doc.layers[0].children().map_or(0, Vec::len); + let (image, strip) = (doc.insert(Some(layer), top, image).unwrap(), doc.insert(Some(layer), top + 1, strip).unwrap()); + let mut other = doc.clone(); + other.node_mut(image).unwrap().transform(Affine::translate(Vec2::new(0.0, 20.0)), PAGE_MOVE); + other.node_mut(strip).unwrap().transform(Affine::translate(Vec2::new(-65.0, -30.0)), PAGE_MOVE); + let frame = frame_of(&page, false); + let mut view = PageView::new(&frame); + assert!(held_back(&frame, &mut view, &doc, &drawn, false).is_empty()); + assert_eq!(held_back(&frame, &mut view, &other, &drawn, false), BTreeSet::from([drawn[0].0])); + let other = (other, 0); + let layers = Placements { doc: &doc, unseen: 0, other: Some(&other), drawn: &drawn, places: &[(drawn[0].0, 0)] }; + let mut free = None; + let found = keep_differing_typed(&frame, &mut view, &layers, &BTreeSet::new(), &[], false, &mut free); + assert_eq!(free, Some(vec![0]), "the layers as read don't hold the envelope"); + let Some((kept, _, notes, typed)) = found else { panic!("the layers aren't used") }; + assert!(notes.iter().any(|n| n.starts_with(DREW_DIFFERENTLY)), "{notes:?}"); + assert!(typed && kept.node(drawn[0].0).is_none(), "the envelope keeps its drawn art at the other placement"); + } + + /// Where putting back only the envelopes of type the page holds makes the layers draw as the page, + /// though putting back every live envelope's drawn art doesn't (an envelope of type the page + /// doesn't hold has stale drawn art), the layers are used as they then are: nothing differs to + /// come in as the page's art. + #[test] + fn layers_that_draw_as_the_page_with_the_held_envelopes_of_type_put_back_are_used_so() { + let (held, kept_live) = (Rect::new(10.0, 10.0, 30.0, 30.0), Rect::new(60.0, 10.0, 80.0, 30.0)); + let page = black_page(&[held, kept_live]); + // The first is drawn 6 points off its drawn art, which the page draws; the second draws as the + // page, its drawn art 6 points off. + let (doc, drawn) = stand_ins(&[Rect::new(16.0, 10.0, 36.0, 30.0), kept_live], &[held, Rect::new(66.0, 10.0, 86.0, 30.0)]); + let frame = frame_of(&page, false); + let mut view = PageView::new(&frame); + assert!(compare(&frame, &mut view, &doc, false).is_err()); + let mut all = doc.clone(); + all.insert_beside(drawn.iter().map(|(id, art, _)| (*id, art.clone())).collect(), true).unwrap(); + assert!(compare(&frame, &mut view, &all, false).is_err(), "with every drawn art put back they don't draw as the page"); + let places = [(drawn[0].0, 0), (drawn[1].0, 1)]; + let layers = Placements { doc: &doc, unseen: 0, other: None, drawn: &drawn, places: &places }; + let mut free = None; + let found = keep_differing_typed(&frame, &mut view, &layers, &BTreeSet::new(), &[], false, &mut free); + assert_eq!(free, Some(vec![1])); + let Some((kept, _, notes, typed)) = found else { panic!("the layers aren't used") }; + assert!(typed && kept.node(drawn[0].0).is_none() && kept.node(drawn[1].0).is_some()); + assert!(!notes.iter().any(|n| n.starts_with(DREW_DIFFERENTLY)), "{notes:?}"); + } } diff --git a/crates/eps/src/import/native/compare.rs b/crates/eps/src/import/native/compare.rs index 34f458f328..424ac4079a 100644 --- a/crates/eps/src/import/native/compare.rs +++ b/crates/eps/src/import/native/compare.rs @@ -570,6 +570,34 @@ impl PageView { } } } + + /// Which pixels of each of the pages `on` `art` inks (draws over with an alpha above 8 of 255), + /// drawn as the layers are drawn on them, row by row. + pub(super) fn inked(&mut self, art: &Document, on: &[usize]) -> Vec<(usize, Vec)> { + self.draw(art, on) + .into_iter() + .map(|(i, drawn)| (i, drawn.pixels.as_chunks::<4>().0.iter().map(|p| u32::from(p[3]) > WHITE).collect())) + .collect() + } + + /// Of the pixels of page `i` that `marks` marks (row by row), how many `b` draws differently from + /// the page, as [`compared`] counts them: those that differ (with `reflow`, those away from type), + /// and those that differ with all their neighbors, away from type. `None` when `marks` isn't the + /// page's size. + pub(super) fn differing_in(&mut self, b: &Document, i: usize, marks: &[bool], reflow: bool) -> Option<(usize, usize)> { + self.layered(b, &[i]); + let printed = as_printed(b); + let b = printed.as_ref().unwrap_or(b); + let page = self.pages.get(i)?; + let layered = self.drawn.last()?.pages.get(i)?.as_ref()?; + let (differs, typed, solid) = differing_pixels(&page.drawn, &layered.pixels, &page.counted_type(b, page.region, reflow), &page.skipped())?; + if marks.len() != differs.len() { + return None; + } + let any = marks.iter().zip(&differs).zip(&typed).filter(|((m, d), t)| **m && **d && !(reflow && **t)).count(); + let solid = marks.iter().zip(&solid).filter(|(m, s)| **m && **s).count(); + Some((any, solid)) + } } /// The `w` × `h` pixels of `from` from pixel (`x`, `y`): `None` unless they lie in it, from a pixel. @@ -682,8 +710,27 @@ pub(super) fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render: /// [`compared`], and which of the pixels (row by row) are counted in its `solid` share: none when /// the drawings can't be compared. fn compared_with_solid(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect], skip: &[Rect]) -> (Difference, Vec) { - if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height { + let Some((differs, typed, solid)) = differing_pixels(x, y, areas, skip) else { return (Difference { any: 1.0, art: 1.0, solid: 1.0, typed: 0.0 }, vec![]); + }; + let area = (f64::from(x.width) * f64::from(x.height)).max(1.0); + let any = differs.iter().filter(|d| **d).count() as f64 / area; + let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count() as f64 / area; + let shares = Difference { any, art, solid: solid.iter().filter(|s| **s).count() as f64 / area, typed: any - art }; + (shares, solid) +} + +/// Which pixels (row by row) of two drawings of the same size differ ([`differ`], those in `skip` +/// counting as alike), which of those lie in the type's `areas`, and which differ with all their +/// neighbors away from type: [`compared`]'s pixels. `None` when the drawings can't be compared. +fn differing_pixels( + x: &vectorcraft_render::Rendered, + y: &vectorcraft_render::Rendered, + areas: &[Rect], + skip: &[Rect], +) -> Option<(Vec, Vec, Vec)> { + if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height { + return None; } let (w, h) = (x.width as usize, x.height as usize); // Pixels in `skip` count as alike. @@ -691,10 +738,9 @@ fn compared_with_solid(x: &vectorcraft_render::Rendered, y: &vectorcraft_render: let differs: Vec = x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).zip(&skipped).map(|((p, q), s)| !*s && differ(p, q)).collect(); if !differs.contains(&true) { - return (Difference { any: 0.0, art: 0.0, solid: 0.0, typed: 0.0 }, vec![false; w * h]); + return Some((differs, vec![false; w * h], vec![false; w * h])); } - let typed = in_areas(areas, w, h); - let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count(); + let typed: Vec = in_areas(areas, w, h).into_iter().zip(&differs).map(|(t, d)| t && *d).collect(); // Which pixels differ with their left and right neighbors. let mut across = vec![false; w * h]; for (k, a) in across.iter_mut().enumerate() { @@ -706,11 +752,7 @@ fn compared_with_solid(x: &vectorcraft_render::Rendered, y: &vectorcraft_render: for (k, s) in solid.iter_mut().enumerate().skip(w).take(w * h.saturating_sub(2)) { *s = !typed.get(k).copied().unwrap_or(true) && [k - w, k, k + w].iter().all(|n| across.get(*n).copied().unwrap_or(false)); } - let area = (w as f64 * h as f64).max(1.0); - let any = differs.iter().filter(|d| **d).count() as f64 / area; - let art = art as f64 / area; - let shares = Difference { any, art, solid: solid.iter().filter(|s| **s).count() as f64 / area, typed: any - art }; - (shares, solid) + Some((differs, typed, solid)) } /// Where the type that shows in `nodes` is, in pixels of `region` drawn at `scale` (with a margin @@ -879,7 +921,7 @@ const NOTABLE_DIFFERENCE: f64 = 0.002; pub(in crate::import) const MAX_SOLID_DIFFERENCE: f64 = 0.001; /// How far (points) the art the layers print may reach past a page that holds it all. -const PAST_THE_PAGE: f64 = 1.0; +pub(super) const PAST_THE_PAGE: f64 = 1.0; /// Do the layers' art and the page's own look alike (where the page holds all the art that prints, /// also round it), on every page the frame has ([`Frame::more`])? An error says they don't; `Ok` has diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index ee39aec1f1..d1af3301df 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, alone_scale, 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_weighing, build_without_page, reid_page_art}; use super::slots::LAYERS_UNREAD; use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai}; @@ -177,15 +177,27 @@ pub fn layered_ai_from( add_notes(&mut all, read.notes()); (visible, all, false) }; - 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)); + // The envelopes of type the page doesn't hold against layers that don't draw as it, and how many + // envelopes of type the layers had. + let (mut free, mut count) = (None, 0); + let built = structure.unwrap_or_else(|| ai::read_over_page(&data, false, names).map(Box::new)).and_then(|structure| { + count = structure.warped_type.envelope_count(); + build_weighing(*structure, &data, Some(&visible), Page::Artboard, outlined, trust, unit, &mut free) + }); // 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)); + // have without them, unless one opened live with the layers drawing as the page. Alone, each + // envelope keeps its drawn art but those the page didn't hold against the layers, which open + // live as they were weighed. They were weighed on the layers as compared with the page, before + // any object of them came in as the page's art: those layers aren't used here, whatever came of + // them. + let live = free.is_none() && 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()?; + let mut structure = ai::read_over_page(&data, false, names).ok()?; + // With none free, nothing needs settling again: every envelope keeps its drawn art. + if let Some(free) = free.as_deref().filter(|free| !free.is_empty()) { + structure.warped_type.open_alone(free, count); + } build_without_page(structure, &data, &pages, unit).ok()?.ok() }) { diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index c7cdf20e11..1f634b3744 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5528,6 +5528,11 @@ fn type_envelope(keys: &str, drawn: &str) -> String { /// [`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 { + type_envelope_of(0, keys, drawn, after) +} + +/// [`type_envelope_with`] around the type of story `story`. +fn type_envelope_of(story: u32, keys: &str, drawn: &str, after: &str) -> String { 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", @@ -5536,18 +5541,23 @@ fn type_envelope_with(keys: &str, drawn: &str, after: &str) -> String { 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), ); - type_envelope_on(keys, &mesh, drawn, after) + type_envelope_on_story(story, keys, &mesh, drawn, after) } /// [`type_envelope_with`], with `shape` (on plain lines, written on `%_` ones) in the group before /// its dictionary, where the editing data saves the envelope's mesh. fn type_envelope_on(keys: &str, shape: &str, drawn: &str, after: &str) -> String { + type_envelope_on_story(0, keys, shape, drawn, after) +} + +/// [`type_envelope_on`] around the type of story `story`. +fn type_envelope_on_story(story: u32, keys: &str, shape: &str, drawn: &str, after: &str) -> String { let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() }; 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{shape}U\n")), hidden(&format!("/ArtDictionary :\n{keys}")), - hidden(&format!("u\n{}{after}U\n9 () XW\n", text_object(0))), + hidden(&format!("u\n{}{after}U\n9 () XW\n", text_object(story))), ) } @@ -5594,7 +5604,12 @@ fn arc_envelope(text: &Node, bend: f64) -> Node { /// 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); + made_envelope_type_of(doc, 0) +} + +/// [`made_envelope_type`] of story `story`. +fn made_envelope_type_of(doc: &str, story: u32) -> Node { + let art = native_with_text(&layer("Art", true, &type_envelope_of(story, &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| { @@ -5685,9 +5700,9 @@ fn an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it() { /// 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). +/// layers with it draw as the page. Where they don't, the page drawing nothing where the envelope +/// is, 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 }; @@ -5751,9 +5766,10 @@ fn an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live() { } /// 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). +/// envelope of type its drawn art and its type, hidden, with the note, where the page draws +/// nothing where the envelope is, even where it draws as that drawn art: one opens live only where +/// the layers with it draw as the page there. 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 }; @@ -5789,19 +5805,27 @@ fn trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_dr 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. +/// The layers and the page of a file with an envelope of type that draws as its drawn art, and a +/// square black in the layers and cyan on the page, away from the envelope; the page draws the +/// envelope bent by `page_bend` (percent). +fn envelope_beside_a_differing_square(family: &str, page_bend: f64) -> (String, String) { + 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, page_bend)); 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(); + (native_with_text(&layer("Art", true, &body), &doc), format!("{}\n{cyan}\n{page}", page_square(10, 10))) +} + +/// Layers whose objects that draw differently from the page come in as its art keep an envelope of +/// type live where the envelope draws as the page: the art that differs isn't the envelope's, and +/// the layers with it then draw as the page (#3179). +#[test] +fn layers_with_objects_that_come_in_as_the_page_art_keep_an_envelope_of_type_that_draws_as_the_page_live() { + let Some(family) = installed_family() else { return }; + let (art, page) = envelope_beside_a_differing_square(&family, 30.0); + let r = import(&eps(&page, &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")), @@ -5809,12 +5833,169 @@ fn layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_it 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!(!r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", 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, where the page draws it +/// otherwise than they do (bent the other way), though other art differs too: the page holds the +/// envelope against them where it is (#3179). +#[test] +fn trusted_layers_leave_an_envelope_of_type_the_page_draws_otherwise_its_drawn_art() { + let Some(family) = installed_family() else { return }; + let (art, page) = envelope_beside_a_differing_square(&family, -30.0); + let r = crate::import_trusting(&eps(&page, &art), true, true).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + assert!(r.warnings.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{:?}", 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); } +/// Layers used though they draw differently from the page (the editing data trusted) keep an +/// envelope of type live where the page draws it as they do and the art that differs is elsewhere +/// (#3179). +#[test] +fn trusted_layers_keep_an_envelope_of_type_live_where_other_art_differs_from_the_page() { + let Some(family) = installed_family() else { return }; + let (art, page) = envelope_beside_a_differing_square(&family, 30.0); + let r = crate::import_trusting(&eps(&page, &art), true, true).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + assert!(r.warnings.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{:?}", 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!(!r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings); +} + +/// A `.ai` whose layers would open alone, and that waits on its PDF part for an envelope of type, +/// opens alone with the envelope live where the page draws it as the layers do, though the page +/// also draws art the layers don't have: the envelope's drawn art put back wouldn't make the layers +/// draw as the page (#3179). +#[test] +fn layers_that_would_open_alone_keep_an_envelope_of_type_live_where_other_art_differs_from_the_page() { + 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 open = |page: Document| { + let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") }; + crate::layered_ai_from(layers, page, vec![], false, false, 1.0, names) + }; + // The layers alone, with the envelope's drawn art: what the page draws. + let (mut page, notes, _) = open(pdf_part(80.0, 80.0)); + assert!(envelopes_and_type(&page).0.is_empty(), "{notes:?}"); + // And a black square the layers don't have, away from the type. + add_black_square(&mut page, 80.0, 4.0); + let (d, notes, read) = open(page); + 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:?}"); + assert!(!notes.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{notes:?}"); +} + +/// A black square of 16 points at (`x`, `y`) put on top of `page`'s first layer. +fn add_black_square(page: &mut Document, x: f64, y: f64) { + let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK); + let item = vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint)); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, y, x + 16.0, y + 16.0)); + let square = Node::path(page.alloc_id(), path, vectorcraft_doc::Appearance { items: vec![item], ..Default::default() }); + let layer = page.layers[0].id; + page.insert(Some(layer), 0, square).unwrap(); +} + +/// A `.ai` whose layers would open alone, with the editing data trusted, opens alone with an +/// envelope of type live where the page draws it as the layers do, though the page also draws art +/// they don't have: not from the layers compared with the page, which would say they come from the +/// editing data all the same (#3179). +#[test] +fn trusted_layers_that_would_open_alone_open_alone_with_an_envelope_of_type_the_page_doesnt_hold_live() { + 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})")); + 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 open = |page: Document, trust: bool| { + let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") }; + crate::layered_ai_from(layers, page, vec![], false, trust, 1.0, names) + }; + let (mut page, _, _) = open(pdf_part(80.0, 80.0), false); + add_black_square(&mut page, 80.0, 4.0); + let (d, notes, read) = open(page, true); + 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:?}"); + assert!(notes.is_empty(), "{notes:?}"); +} + +/// Of two envelopes of type in a `.ai` whose layers would open alone, the one the page holds +/// against the layers (it draws nothing where the envelope is) keeps its drawn art and its type, +/// hidden, with the note, and the other opens live: each by its place among the envelopes of type +/// as the layers are read, both times they are (#3179). +#[test] +fn layers_that_would_open_alone_keep_live_only_the_envelopes_of_type_the_page_doesnt_hold() { + let Some(family) = installed_family() else { return }; + let stories = [ + ("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0), + ("HILL\r", 0, (8170.0, 8200.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 drawn = |story| drawn_envelope(&arc_envelope(&made_envelope_type_of(&doc, story), 30.0)).0; + let art = format!( + "u\n{}U\n{}{}", + text_object(2), + type_envelope_of(0, &arc_keys(0.3), &drawn(0), ""), + type_envelope_of(1, &arc_keys(0.3), &drawn(1), "") + ); + let file = private(&native_with_text(&layer("Art", true, &art), &doc)); + let names = crate::NameEncoding::default(); + let open = |page: Document| { + let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") }; + crate::layered_ai_from(layers, page, vec![], false, false, 1.0, names) + }; + // The layers alone, with the drawn art of both envelopes, but the first's: the page draws + // nothing where the first is. + let (mut page, notes, _) = open(pdf_part(80.0, 80.0)); + let top = page.layers[0].clone(); + let first = top + .children() + .into_iter() + .flatten() + .filter(|n| matches!(n.kind, NodeKind::Group { .. })) + .min_by(|a, b| a.visual_bounds().map_or(f64::MAX, |r| r.y0).total_cmp(&b.visual_bounds().map_or(f64::MAX, |r| r.y0))) + .map(|n| n.id) + .unwrap_or_else(|| panic!("no drawn art: {notes:?}")); + page.remove(first).unwrap(); + let (d, notes, read) = open(page); + assert!(read, "{notes:?}"); + let (envelopes, mut loose) = envelopes_and_type(&d); + assert_eq!(envelopes, [vec!["HILL".to_string()]], "{notes:?}"); + loose.sort(); + assert_eq!(loose, [("FLUX".to_string(), false), ("Note".to_string(), true)], "{notes:?}"); + assert!(notes.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{notes:?}"); +} + /// 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). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f752a8973b..1232e3ea49 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-13 · **Change:** minor (`.ai` and EPS import: a top-object envelope whose mesh saves its sides' curves in pieces draws its contents along each whole curve, as its drawn art does, #7097; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: `.ai` and EPS import: type from the text document, type a PostScript program sets and legacy `.ai` type are set in an installed face of their font's PostScript name, exact name first, in the version a text document's record keeps; the `.ai` save writes a static font's version when its name alone gives another copy, #6983; `.ai` and EPS import: type keeps the manual kerning of its text document, #6899; `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, its strokes' miter tips and strokes a Transform effect moves included, after its glyphs grown by half its strokes' weight, #6824; `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; #6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (`.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; `.ai` and EPS import: a top-object envelope whose mesh saves its sides' curves in pieces draws its contents along each whole curve, as its drawn art does, #7097; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: `.ai` and EPS import: type from the text document, type a PostScript program sets and legacy `.ai` type are set in an installed face of their font's PostScript name, exact name first, in the version a text document's record keeps; the `.ai` save writes a static font's version when its name alone gives another copy, #6983; `.ai` and EPS import: type keeps the manual kerning of its text document, #6899; `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, its strokes' miter tips and strokes a Transform effect moves included, after its glyphs grown by half its strokes' weight, #6824; `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; #6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.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) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). @@ -323,6 +323,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | `.ai` and EPS import: layers that don't draw as the page, even with the drawn art of every live envelope put back, no longer put back the drawn art of every envelope of type. Each live envelope of type is weighed in the page check's own drawing of the pages it lies on, on the pixels that it and its drawn art ink there (the two drawn together on a layer of their own, without the rest of the layers; a pixel inks with an alpha above 8 of 255): it keeps its drawn art and its type, hidden, with the note, where the layers with it differ from the page in more of those pixels than its own check allows against its drawn art (10% of them, or 3% differing with all their neighbours, away from type), or where the envelopes of type that stay live, in the order they were read, would together differ there in more of a page than half of what the page check allows (a budget of the same size as the one their own checks share, kept apart from it). Art that differs elsewhere on the page no longer costs them their editable type; art that differs on their pixels still does. An envelope of type that a clip group or a clipping sublayer clips, an opacity mask masks, or a group with an appearance of its own, another envelope, a repeat or a blend holds keeps its drawn art as before, wherever it lies: drawn on its own it would be weighed elsewhere than it draws, and what holds it can draw it onto the page from off it. An envelope on no page stays live, as the page check doesn't see it, unless the page holds all the art that prints (an EPS's page that is its art's box, where art past the page is compared too): there one reaching past the page keeps its drawn art. Past 12 of them on the pages, all keep it. This holds where the editing data is trusted, where the objects that differ come in as the page's art (the placed files' other placement weighed on its own, as it draws them elsewhere under or over the envelopes; layers that draw as the page with only the envelopes the page holds put back are used as they are; where none of the objects that differ can then come in as the page's art, because an object holds type, the drawn art of every envelope of type is put back and that is tried again, as before), and for a `.ai` whose layers would open alone, which still opens alone: its envelopes of type are all settled again as they were with the page, in the same order and with the same checks, so each that opens live draws as it was weighed, and those the page held get their drawn art back. Where putting the drawn art of every live envelope back makes the layers draw as the page, it is still put back (#5065). In 224 users' files (used locally, never committed): envelopes of type that open live go from 174 to 186, in 8 files, none fewer in any file; no file changes between its layers and its PDF part; 7 renders change, by at most 0.7% of their pixels at 40 ppi (1 closer to the page, 1 alike, 5 further, by at most 0.7 of a percentage point, where VectorCraft's own envelope draws a little apart from the drawn art, within its check); the note for type in an envelope goes from 6 files. Of the 14 envelopes of type that had opened live and lost it to the page check, 2 keep their drawn art: one where the page draws something else where it is, and one in a file that comes in as its PDF part (#7141). Tests `build::candidate_tests::the_page_holds_back_only_envelopes_of_type_where_it_differs_from_them`, `envelopes_of_type_stay_live_within_half_of_what_the_page_check_allows`, `an_envelope_of_type_differing_in_more_of_its_pixels_than_its_check_allows_is_held_back`, `envelopes_of_type_the_layers_dont_draw_in_place_are_held_back`, `on_a_page_that_holds_all_the_art_an_envelope_of_type_past_it_is_held_back`, `objects_come_in_as_the_page_art_with_every_envelope_of_type_put_back_where_a_live_one_holds_a_difference`, `tests_native::layers_that_would_open_alone_keep_an_envelope_of_type_live_where_other_art_differs_from_the_page`, `layers_that_would_open_alone_keep_live_only_the_envelopes_of_type_the_page_doesnt_hold`, `trusted_layers_that_would_open_alone_open_alone_with_an_envelope_of_type_the_page_doesnt_hold_live`, `layers_with_objects_that_come_in_as_the_page_art_keep_an_envelope_of_type_that_draws_as_the_page_live`, `trusted_layers_keep_an_envelope_of_type_live_where_other_art_differs_from_the_page`, `trusted_layers_leave_an_envelope_of_type_the_page_draws_otherwise_its_drawn_art`, `build::candidate_tests::the_other_placement_of_placed_files_is_weighed_on_its_own`, `layers_that_draw_as_the_page_with_the_held_envelopes_of_type_put_back_are_used_so` | | 2026-11-13 | minor | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke instead of being left out, adding no text (#6971; `empty_actual_text_keeps_the_glyphs_art` in `crates/pdf/src/tests/actual_text.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | `.ai` and EPS import, and what the canvas, every export and `.ai` save draw: a top-object envelope's mesh can save a side's curve in pieces. In users' files (used locally, never committed), 10 top objects save a grid of three patches across whose top and bottom edges are each one cubic split into thirds, and their drawn art follows the whole curve's parameter. VectorCraft gave each piece the share of its control polygon, so their type came out up to about half a point off the drawn art along the side, and one envelope of type kept its drawn art instead of opening live. A side whose pieces are all one curve split at anchors (each within 0.02 pt of that curve's piece) now follows that curve's parameter (#7097). Synthetic test: `a_top_object_whose_top_is_one_curve_split_in_thirds_opens_live`. Scores unchanged. | | 2026-11-12 | minor | `.ai` and EPS import (#6983): type from the text document is set in an installed face of the PostScript name its font record gives, one of exactly that name first. That face's family is no longer swapped for another family spelled alike but for case and punctuation (`FontDb::find_family`, which picked `SAMPLE INK` for `SampleInk`, the PostScript name of `Sample/Ink`); among the faces `FontDb::postscript_faces` lists for the name, the import takes one whose name matches in case and punctuation before one that matches only without them; and when the family and style alone resolve to another installed copy, the type names the face's version. A font record's key 5, the version string (name ID 5), picks that version among the faces of its PostScript name, also when the record spells the name with other punctuation (`Sample-Slab` for faces named `SampleSlab`); each record is resolved once. Type a PostScript program sets and legacy `.ai` type take the face of their font's name the same way. The `.ai` save writes a static font's version (`/5`) when its PostScript name alone would give the reader another installed copy (another version, or the same name in another family), so two such copies are two fonts of the set, and checks that the type comes back in the same family, style and face, taking the record's version as the reader does (up to 256 bytes): such type round-trips without the type attributes note, and so does a version that isn't installed, which draws in the face the font resolves to anyway. In 224 users' files (used locally, never committed; all PDF-compatible, so the PostScript and legacy paths are not exercised), 16 files set some type in another installed face; 12 render differently against their own page, 10 closer (in one from 2.55% to 0.01% of the page differing) and 2 further by 0.03% of the page (an envelope of type that now opens live, and a copy of the same widths in the version the record names); envelopes of type that open live go from 154 to 159, none fewer in any file; no file opens in another way. The notes of how far a file's layers differ from its page shrink in 2 files and appear in 2 where an envelope of type now opens live, so the file is compared with its page. Two installed copies of one PostScript name, family and style that differ in widths, in files whose records keep no version, still take the copy the family and style resolve to: only the page's widths tell them apart. Tests: `tests_native::type_from_the_text_document_takes_the_face_of_its_exact_postscript_name`, `tests_native::type_from_the_text_document_takes_the_version_its_font_record_keeps`, `tests_fontnames::shown_type_takes_the_face_of_its_exact_postscript_name`, `ai::tests::legacy_type_takes_the_face_of_its_exact_postscript_name`, `ai_editing::tests::type_in_a_font_beside_a_family_spelled_alike_keeps_its_font`, `ai_editing::tests::type_in_another_version_of_a_static_font_keeps_it`, `ai_editing::tests::type_in_a_copy_its_postscript_name_alone_doesnt_give_keeps_it`, `ai_editing::tests::a_version_longer_than_the_reader_takes_is_reported`, `vectorcraft-text` `tests::versions::every_face_of_a_postscript_name_is_found`. Scores unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c50a45b639..c1ca6d7a06 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-13 · **Change:** minor (Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (`.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously 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) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -1609,7 +1609,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 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`). A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, where the size the file saves for it doesn't already give that box; after those tries (and before a grid moved onto its drawn art), it is also tried mapped from the box of what it paints, where that is another box, as users' files map some such type: its glyph outlines with their fills and strokes baked as the envelope bakes them, so geometry effects on them count (a stroke a Transform effect moves paints where it is moved to), each measured by its visual bounds (exact for miter joins, a tip counting where the miter limit keeps it, and for round joins; an upper bound for bevel joins, miters the limit cuts, width profiles and square-capped dashes), and a contour of zero length, which paints nothing, left out; effects on the type object itself are left out, as VectorCraft applies them after the envelope bends the type. The drawn-art check decides which one opens. An envelope's grid reads its way round from all its rows and columns, so a mesh bent far round reads the way it runs. While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art (as if the mesh hadn't followed the envelope when it moved): a mesh or top object saved in Edit Contents whose mesh draws the contents as large as the drawn art but elsewhere is tried again, after every other way, with its mesh moved onto that art, and opens there where it then draws as it. The drawn art of these files strokes type in an envelope along its bent outlines at the stroke's own weight, Distort Appearance on or off, and VectorCraft draws it so (#6644): an envelope of stroked type whose grid squeezes or stretches it opens live where it draws as that art. + 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; where they don't, even with the drawn art of every live envelope put back, and are used all the same, trusted, with the objects that differ coming in as the page's art, or opened alone, it stays live where, in the pixels it and its drawn art ink (drawn together without the rest of the layers), the layers with it differ from the page in no more than its own check allows and the envelopes that stay differ there in no more than half of what the page check allows, and nothing above it clips, masks or moves it); 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`). A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, where the size the file saves for it doesn't already give that box; after those tries (and before a grid moved onto its drawn art), it is also tried mapped from the box of what it paints, where that is another box, as users' files map some such type: its glyph outlines with their fills and strokes baked as the envelope bakes them, so geometry effects on them count (a stroke a Transform effect moves paints where it is moved to), each measured by its visual bounds (exact for miter joins, a tip counting where the miter limit keeps it, and for round joins; an upper bound for bevel joins, miters the limit cuts, width profiles and square-capped dashes), and a contour of zero length, which paints nothing, left out; effects on the type object itself are left out, as VectorCraft applies them after the envelope bends the type. The drawn-art check decides which one opens. An envelope's grid reads its way round from all its rows and columns, so a mesh bent far round reads the way it runs. While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art (as if the mesh hadn't followed the envelope when it moved): a mesh or top object saved in Edit Contents whose mesh draws the contents as large as the drawn art but elsewhere is tried again, after every other way, with its mesh moved onto that art, and opens there where it then draws as it. The drawn art of these files strokes type in an envelope along its bent outlines at the stroke's own weight, Distort Appearance on or off, and VectorCraft draws it so (#6644): an envelope of stroked type whose grid squeezes or stretches it opens live where it draws as that art. 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); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, on by default; `.ai` saves leave it off) writes such pages in points without a `UserUnit` at every PDF version, in files without a PDF/X or PDF/A standard, so readers that ignore `UserUnit` show them at full size. @@ -2236,6 +2236,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | File formats: in a `.ai` or EPS opened from its editing data whose layers don't draw as the page even with the drawn art of every live envelope put back, and are used all the same (trusted, their objects that differ coming in as the page's art, or opened alone), art that differs away from an envelope of type no longer costs it its editable type: it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, and where a clip, mask, group appearance, another envelope, a repeat or a blend holds it (#7141); evidence updated; scores and hours unchanged. | | 2026-11-13 | minor | Character panel: the Set Font Height Reference dropdown shows an icon of the height each reference measures before its name, in the open list and in the closed field (#6304). Scores and estimates unchanged. | | 2026-11-13 | minor | Envelopes: a top object's side whose pieces, from corner to corner, are all one cubic curve split at anchors (each within 0.02 pt of that curve's piece) follows that curve's parameter instead of sharing the content out by the pieces' control polygons, so Add Anchor Points on such a side leaves how it shares out the content as it was, and a top object whose `.ai` mesh saves its sides' curves in pieces draws as the file's drawn art (`a_split_curve_keeps_the_top_object_patch`, `a_top_object_side_of_many_arcs_stays_on_them` and three more in `live::tests`, `envelope_top_object_keeps_its_look_when_anchors_are_added`, `a_top_object_whose_top_is_one_curve_split_in_thirds_opens_live`). Existing top-object envelopes with such sides redraw (#7097). Scores and estimates unchanged. | | 2026-11-12 | minor | Object › Rasterize with the Grayscale colour model makes a grayscale image (one gray channel, with alpha over a transparent background), so the Properties panel says Grayscale and a fill color colorizes it; Bitmap makes a 1-bit image (a TIFF), or a black-and-white gray image with alpha over a transparent background, since 1-bit gray with alpha is no format readers take. Both were RGB images of gray pixels (#6969). Scores and estimates unchanged. |