From 0c3bc39009e0b0a3f6920943da1d2ce9ed822504 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:13:21 -0800 Subject: [PATCH 1/2] .ai save: type in an envelope stays editable An envelope whose contents hold type was written to the editing data as its distorted art, so the type opened as outlines. The type is now written as type in the envelope's contents, in the form the .ai reader opens as a live envelope of type (TypedEnvelope in crates/eps/src/import/ai/envelope.rs), with Edit Contents off or on. An envelope whose type is threaded, has text wrap options or is written as its outlines (mirrored type, type in a symbol's art) is still written as its distorted art, with the warning. A record taken back for the object limit or for outlined type also takes its stories out of the text document. The writer charges one more object for each text object in the dictionary's contents, where the reader reads a copy of it. --- crates/eps/src/ai_editing/tests.rs | 191 +++++++++++++++++- crates/eps/src/ai_editing/writer.rs | 27 ++- crates/eps/src/ai_editing/writer/envelopes.rs | 49 ++++- crates/eps/src/ai_editing/writer/texts.rs | 27 +++ crates/eps/src/import.rs | 4 +- docs/target-app-parity.md | 3 +- 6 files changed, 275 insertions(+), 26 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 967c982ad4..1f47e7276c 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -9687,7 +9687,8 @@ fn an_envelope_is_written_in_the_order_the_reader_reads_it() { } /// Envelopes the reader would open in another shape, or not at all, are written as their distorted -/// art with a warning, and open as that art: an envelope of type, one in a turned frame, a warp drawn +/// art with a warning, and open as that art: an envelope of type with text wrap options, one of +/// mirrored type (written as its outlines), one in a turned frame, a warp drawn /// without its style's net, a mesh the reader would lay out the other way round, a top object /// whose sides don't pair up, a Fidelity that isn't a whole number and bounds margins kept with /// Distort Appearance off. Other live objects keep their own warning. @@ -9702,10 +9703,24 @@ fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { f(&mut doc.node_mut(id).unwrap().kind); doc }; - let typed = change(&|k| { + let type_node = |id: u64| { + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(50.0, 50.0), "Type", vectorcraft_doc::CharStyle::default()); + Node::new(vectorcraft_doc::NodeId(id), NodeKind::Text(Box::new(t))) + }; + let wrapped_type = change(&|k| { if let NodeKind::Envelope { content, .. } = k { - let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(50.0, 50.0), "Type", vectorcraft_doc::CharStyle::default()); - content.push(Arc::new(Node::new(vectorcraft_doc::NodeId(9_000), NodeKind::Text(Box::new(t))))); + let mut t = type_node(9_000); + t.extras_mut().wrap = Some(vectorcraft_doc::TextWrap { offset: 6.0, invert: false }); + content.push(Arc::new(t)); + } + }); + let mirrored_type = change(&|k| { + if let NodeKind::Envelope { content, .. } = k { + let mut t = type_node(9_001); + if let NodeKind::Text(t) = &mut t.kind { + t.xf = Affine::new([-1.0, 0.0, 0.0, 1.0, 50.0, 50.0]); + } + content.push(Arc::new(t)); } }); let turned = change(&|k| { @@ -9749,7 +9764,8 @@ fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { } }); let cases = [ - ("type", typed), + ("type with text wrap options", wrapped_type), + ("mirrored type, written as its outlines", mirrored_type), ("turned", turned), ("old warp", old_warp), ("sideways mesh", sideways), @@ -9774,6 +9790,171 @@ fn transpose(points: Vec, rows: usize, cols: usize) -> Vec Node { + let mut envelope = envelope_node(doc, kind, 50.0, editing, vectorcraft_doc::live::EnvelopeOptions::NEW); + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(104.0, 62.0), "Type", style(24.0, Color::rgb(0.0, 0.0, 1.0))); + let text = text_node(doc, t); + let NodeKind::Envelope { content, .. } = &mut envelope.kind else { unreachable_envelope() }; + content.push(Arc::new(text)); + envelope +} + +/// A 200 × 100 document of `envelope` alone. +fn alone(envelope: Node) -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + doc +} + +/// `doc` written, then read back through its editing copy beside a page that draws as `doc` does, +/// its live objects as their art: the objects on its first layer, the writer's warnings and the +/// reader's notes. +fn read_with_page(doc: &Document) -> (Vec>, Vec, Vec) { + let (p, warnings) = private_data(doc, &opts()).unwrap(); + let mut page = doc.clone(); + let ids: Vec = page.layers[0].children().into_iter().flatten().map(|n| n.id).collect(); + for id in ids { + if let Some(n) = page.node_mut(id) { + *n = vectorcraft_effects::expand_live_deep(Some(doc), n); + } + } + let (read, notes, layers) = crate::layered_ai(&p.data, page, vec![], false, false, 1.0); + assert!(layers, "{notes:?}"); + (read.layers[0].children().cloned().unwrap_or_default(), warnings, notes) +} + +/// The type in `nodes` and their groups, with whether it shows. +fn type_in(nodes: &[Arc]) -> Vec<(String, bool)> { + let mut out = vec![]; + for n in nodes { + match &n.kind { + NodeKind::Text(t) => out.push((t.plain_text(), n.visible)), + NodeKind::Group { children, .. } => out.extend(type_in(children)), + _ => {} + } + } + out +} + +/// An envelope whose contents hold type keeps the type as type: a warp, a mesh and a top object +/// envelope, with Edit Contents off and on, open as the same live envelope with the type shown +/// and editable in its contents beside the rest of them, no copy of the type left on the layer, +/// and again when saved as they opened. +#[test] +fn an_envelope_of_type_is_read_back_live_with_its_type() { + for (name, kind) in live_envelope_kinds() { + for editing in [false, true] { + let what = format!("{name}, editing {editing}"); + let mut doc = Document::new(200.0, 100.0); + let mut envelope = typed_envelope_node(&mut doc, kind.clone(), editing); + for round in ["saved", "saved again"] { + doc = alone(envelope); + let (top, warnings, notes) = read_with_page(&doc); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES || w == writer::TYPE), "{what}, {round}: {warnings:?}"); + assert!( + !notes.iter().any(|n| n == crate::import::ENVELOPE_TYPE_DRAWN || n == crate::import::ENVELOPE_DRAWN), + "{what}, {round}: {notes:?}" + ); + let [back] = top.as_slice() else { panic!("{what}, {round}: {top:#?} {notes:?}") }; + let NodeKind::Envelope { content, kind: got, editing: e, fidelity, .. } = &back.kind else { + panic!("{what}, {round}: {back:#?} {notes:?}") + }; + assert_eq!((*e, *fidelity), (editing, 50.0), "{what}, {round}"); + assert!(same_envelope_kind(&kind, got), "{what}, {round}: {got:?}"); + assert_eq!(back.name.as_deref(), Some("Bent"), "{what}, {round}"); + assert_eq!(type_in(content), [("Type".to_string(), true)], "{what}, {round}"); + let colors: Vec> = + content.iter().filter(|c| !matches!(c.kind, NodeKind::Text(_))).map(|c| c.appearance.fill_paint().color()).collect(); + assert_eq!(colors, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{what}, {round}"); + envelope = (**back).clone(); + } + } + } +} + +/// An envelope of type is written live with room for every object the reader reads of it, the +/// copy of its type it reads in the envelope's dictionary included. Written as its art, it takes +/// its type's story out of the text document with it: where its record doesn't fit within the +/// object limit, and where other type in its contents is written as its outlines (mirrored type). +/// The file then has no text document. +#[test] +fn an_envelope_of_type_written_as_its_art_leaves_no_story() { + use vectorcraft_doc::live::EnvelopeKind; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + for editing in [false, true] { + let mut doc = Document::new(200.0, 100.0); + let envelope = typed_envelope_node(&mut doc, kind.clone(), editing); + let doc = alone(envelope); + let program = export_ai_editing(&doc, &opts()).unwrap().program; + let all = String::from_utf8_lossy(&program).replace("\r\n", "\n"); + assert!(all.contains("AIDeformOptionsKey") && all.contains("%AI11_BeginTextDocument"), "editing {editing}: {all}"); + let read = crate::import::objects_read(&program).unwrap(); + let out = export_within(&doc, &opts(), read).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "editing {editing}: {:?}", out.warnings); + let out = export_within(&doc, &opts(), read - 1).unwrap(); + let short = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "editing {editing}: {:?}", out.warnings); + assert!(!short.contains("AIDeformOptionsKey") && !short.contains("%AI11_BeginTextDocument"), "editing {editing}: {short}"); + } + + let mut doc = Document::new(200.0, 100.0); + let mut envelope = typed_envelope_node(&mut doc, kind, false); + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(50.0, 40.0), "Back", style(12.0, Color::BLACK)); + t.xf = Affine::new([-1.0, 0.0, 0.0, 1.0, 50.0, 40.0]); + let mirrored = text_node(&mut doc, t); + let NodeKind::Envelope { content, .. } = &mut envelope.kind else { unreachable_envelope() }; + content.push(Arc::new(mirrored)); + let out = export_ai_editing(&alone(envelope), &opts()).unwrap(); + let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert!(out.warnings.iter().any(|w| w == writer::ENVELOPES) && !out.warnings.iter().any(|w| w == writer::TYPE), "{:?}", out.warnings); + assert!(!text.contains("AIDeformOptionsKey") && !text.contains("%AI11_BeginTextDocument"), "{text}"); +} + +/// Type in an envelope that is a symbol's art, or a frame of a thread, keeps the envelope written +/// as its art: the reader would open its type as drawn art. +#[test] +fn an_envelope_of_type_in_a_symbol_or_a_thread_is_written_as_its_art() { + use vectorcraft_doc::live::EnvelopeKind; + use vectorcraft_doc::{TextKind, TextObject}; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + let mut doc = Document::new(200.0, 100.0); + let art = typed_envelope_node(&mut doc, kind.clone(), false); + doc.symbols.push(Symbol { dynamic: false, name: "Bent".into(), art: Arc::new(art) }); + let placed = instance(&mut doc, "Bent", Affine::translate((10.0, 5.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, placed).unwrap(); + let (p, warnings) = private_data(&doc, &opts()).unwrap(); + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let (back, notes) = crate::ai_alone(&p.data).unwrap(); + let symbol = back.symbols.iter().find(|s| s.name == "Bent").unwrap_or_else(|| panic!("{:?} {notes:?}", back.symbols)); + let mut live = 0; + symbol.art.walk(&mut |n| live += usize::from(matches!(n.kind, NodeKind::Envelope { .. }))); + assert_eq!(live, 0, "{notes:?}"); + + let mut doc = Document::new(200.0, 100.0); + let mut envelope = envelope_node(&mut doc, kind, 50.0, false, vectorcraft_doc::live::EnvelopeOptions::NEW); + let area = |r: Rect, s: &str| { + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, s, style(10.0, Color::BLACK)); + (t.kind, t.xf) = (TextKind::Area { frame: shapes::rectangle(r) }, Affine::IDENTITY); + t + }; + let first = text_node(&mut doc, area(Rect::new(50.0, 30.0, 150.0, 70.0), "One two three four")); + let second = text_node(&mut doc, area(Rect::new(10.0, 85.0, 190.0, 95.0), "")); + doc.text_threads.push(vec![first.id, second.id]); + let NodeKind::Envelope { content, .. } = &mut envelope.kind else { unreachable_envelope() }; + content.push(Arc::new(first)); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + doc.insert(Some(layer), usize::MAX, second).unwrap(); + let text = program(&doc); + assert!(!text.contains("AIDeformOptionsKey"), "{text}"); + let warnings = export_ai_editing(&doc, &opts()).unwrap().warnings; + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); +} + /// An envelope with its contents in an opacity mask's art is written as its art there, and an /// envelope in a group stays live. #[test] diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 879eb9d9f5..3e4f23bda9 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -204,6 +204,12 @@ pub(super) struct Writer<'a> { /// The `hidden` and `nest` values at which the objects of the opacity mask's art being written /// are written: objects there may be written with their art styles. mask_level: Option<(u32, u32)>, + /// The `hidden` and `nest` values at which the objects of the envelope contents being written + /// are written ([`Self::envelope`]): type there may be written as type. With them, whether the + /// contents are in the envelope's dictionary, where the reader reads a copy of each text object. + contents_level: Option<(u32, u32, bool)>, + /// How many text objects were written as their outlines ([`Self::text`]). + outlined: usize, /// The image just written was written as a placed file. placed: bool, /// The type just written was written as type, not as its outlines. @@ -298,6 +304,8 @@ impl<'a> Writer<'a> { payloads: vec![], hidden: 0, mask_level: None, + contents_level: None, + outlined: 0, placed: false, typed: false, bound: vec![], @@ -1284,9 +1292,10 @@ impl<'a> Writer<'a> { // ---------- type ---------- /// Type `n`: a text object stub naming its story in the text document ([`texts`]) when the - /// text document can hold it, outside opacity masks, pattern tiles, symbols' art and art cut to - /// a clipping object; else its outlines ([`Self::outlined_text`]). A frame of threaded area type - /// names the story of its thread, which its first frame holds. + /// text document can hold it, on the layers or in an envelope's contents, outside opacity + /// masks, pattern tiles, symbols' art and art cut to a clipping object; else its outlines + /// ([`Self::outlined_text`]). A frame of threaded area type names the story of its thread, + /// which its first frame holds. fn text(&mut self, n: &Node, xy: Xy) { let NodeKind::Text(t) = &n.kind else { return }; // Dynamic Text is written as the sizes its layout draws the type at. @@ -1294,13 +1303,16 @@ impl<'a> Writer<'a> { let t = fitted.as_deref().unwrap_or(&**t); let doc = self.doc; let thread = doc.text_threads.iter().find(|th| th.contains(&n.id)); + let contents = self.contents_level.filter(|(hidden, nest, _)| (*hidden, *nest) == (self.hidden, self.nest)); // Type that can't be a stub is written as its outlines, which count their own objects. - if self.hidden > 0 || self.clip.is_some() || self.in_symbol || (thread.is_none() && !texts::holds(n, t)) { + if (self.hidden > 0 && contents.is_none()) || self.clip.is_some() || self.in_symbol || (thread.is_none() && !texts::holds(n, t)) { + self.outlined += 1; return self.outlined_text(n, xy); } - // The stub's objects ([`texts::stub_objects`], [`Self::take_objects`]); without room for - // them, the type is left out before its story goes into the text document. - let stub = texts::stub_objects(t); + // The stub's objects ([`texts::stub_objects`], [`Self::take_objects`]), and the copy the + // reader reads of it in an envelope's dictionary; without room for them, the type is left + // out before its story goes into the text document. + let stub = texts::stub_objects(t) + usize::from(contents.is_some_and(|(.., copied)| copied)); if !self.take_objects(stub) { return; } @@ -1314,6 +1326,7 @@ impl<'a> Writer<'a> { let Some(((story, frame), lost)) = placed else { // The text document can't hold it: the outlines count their own objects. self.objects -= stub; + self.outlined += 1; return self.outlined_text(n, xy); }; self.typed = true; diff --git a/crates/eps/src/ai_editing/writer/envelopes.rs b/crates/eps/src/ai_editing/writer/envelopes.rs index dcd7274291..bb8273acf6 100644 --- a/crates/eps/src/ai_editing/writer/envelopes.rs +++ b/crates/eps/src/ai_editing/writer/envelopes.rs @@ -19,14 +19,14 @@ use vectorcraft_doc::live::{ Coons, EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint, PreserveShape, WARP_NET, envelope_content_boxes, envelope_grid, envelope_stroke_margin, warp_grid, }; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::{Affine, PathData}; use super::{MAX_NEST, Writer, Xy, hide}; use crate::import::{DEFORM_PLUGIN, EnvelopeGridPoint as GridPoint, envelope_outline, envelope_upright, warp_number}; use crate::ps; -pub(in crate::ai_editing) const ENVELOPES: &str = "envelopes of type, envelopes without contents, envelopes in opacity masks, pattern tiles, art cut to a clipping object or other envelopes' contents, envelopes on the layers of a large canvas or in a turned or mirrored frame, warps drawn without their style's net, meshes and top objects that the data would open in another shape, envelopes with a Fidelity that isn't a whole number, envelopes that map from a box other than their contents' bounds with or without strokes or keep bounds margins with Distort Appearance off, envelopes after the first 256, and envelopes nested more than 8 deep are written as their art in the editing data for other apps"; +pub(in crate::ai_editing) const ENVELOPES: &str = "envelopes whose type is threaded, has text wrap options or is written as its outlines, envelopes without contents, envelopes in opacity masks, pattern tiles, art cut to a clipping object or other envelopes' contents, envelopes on the layers of a large canvas or in a turned or mirrored frame, warps drawn without their style's net, meshes and top objects that the data would open in another shape, envelopes with a Fidelity that isn't a whole number, envelopes that map from a box other than their contents' bounds with or without strokes or keep bounds margins with Distort Appearance off, envelopes after the first 256, and envelopes nested more than 8 deep are written as their art in the editing data for other apps"; /// The most rows or columns of an envelope's grid written (Make with Mesh's own limit, and the /// reader's). @@ -57,6 +57,15 @@ fn holds_type(n: &Node) -> bool { matches!(n.kind, NodeKind::Text(_)) || n.children().is_some_and(|c| c.iter().any(|k| holds_type(k))) } +/// Is all the type in `n`, and in anything in it, type the reader can make an envelope's contents +/// of: none of it is a frame of threaded type or has text wrap options. +fn type_reads_back(n: &Node, doc: &Document) -> bool { + match &n.kind { + NodeKind::Text(_) => n.extras().wrap.is_none() && !doc.text_threads.iter().any(|th| th.contains(&n.id)), + _ => n.children().is_none_or(|c| c.iter().all(|k| type_reads_back(k, doc))), + } +} + /// The record of envelope `n` (its parts given), or `None` where the reader would open it in /// another shape than it has, or not as an envelope. fn record( @@ -72,7 +81,7 @@ fn record( // Distort Appearance on. let whole = (0.0..=100.0).contains(&fidelity) && fidelity.fract() == 0.0; let margin_kept = !options.distort_appearance && options.bounds_margin != [0.0; 4]; - if content.is_empty() || frame != Affine::IDENTITY || !whole || margin_kept || content.iter().any(|c| holds_type(c)) { + if content.is_empty() || frame != Affine::IDENTITY || !whole || margin_kept { return None; } let mut keys = String::new(); @@ -282,17 +291,24 @@ fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, impl Writer<'_> { /// Envelope `n`, of transparency `xy`, as a live envelope (see the module's form), outside opacity - /// masks' art, pattern tiles, art cut to a clipping object and the layers of a large canvas. False, - /// writing nothing, where it is written as its art instead: where the reader would open it in - /// another shape, or some of it is left out for the object limit ([`Self::take_objects`]). + /// masks' art, pattern tiles, art cut to a clipping object and the layers of a large canvas. Type + /// in its contents is written as type, which the reader makes the envelope's contents again. + /// False, writing nothing, where it is written as its art instead: where the reader would open it + /// in another shape, or with its type as drawn art (threaded type, type with text wrap options, + /// type written as its outlines), or some of it is left out for the object limit + /// ([`Self::take_objects`]). pub(super) fn envelope(&mut self, n: &Node, xy: Xy) -> bool { let NodeKind::Envelope { content, kind, fidelity, editing, options, frame } = &n.kind else { return false }; if self.hidden > 0 || self.clip.is_some() || self.nest >= MAX_NEST || !self.symbols_live || self.envelopes_left == 0 { return false; } + if content.iter().any(|c| holds_type(c)) && !content.iter().all(|c| type_reads_back(c, self.doc)) { + return false; + } let Some(record) = record(n, content, kind, *fidelity, *editing, options, **frame) else { return false }; let start = self.body.len(); let (state, gs, charges, bound) = (self.state, self.gs, self.charges(), self.bound.len()); + let (outlined, texts, warnings) = (self.outlined, self.texts.written(), self.warnings.len()); self.nest += 1; self.hidden += 1; // The group before the dictionary: the grid, or the contents while they are edited. @@ -300,7 +316,7 @@ impl Writer<'_> { let opened = self.open_group("u"); if opened { if *editing { - self.contents(content); + self.contents(content, false); } else { self.grid(record.grid.as_ref()); } @@ -318,23 +334,31 @@ impl Writer<'_> { if *editing { self.grid(record.grid.as_ref()); } else if self.open_group("u") { - self.contents(content); + self.contents(content, true); self.end_group("U", Xy::PLAIN); } let block = std::mem::replace(&mut self.body, outer); hide(&block, &mut self.body); (self.state, self.gs, self.block_end) = (at_block, None, usize::MAX); let _ = writeln!(self.body, "%_X+\n%_ (AIDeformArtKey) ,\n%_{} /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW", record.style); + // Type in the contents written as its outlines would open as drawn art, not as type. + let outlines = self.outlined != outlined; self.hidden -= 1; self.nest -= 1; // The distorted art, which takes the envelope's lock, visibility, transparency, name and // opacity mask. self.state(n.locked, !n.visible); self.drawn_art(n, xy, None); - if !opened || self.left_out_since(charges) { + if !opened || outlines || self.left_out_since(charges) { let lines = self.body.split_off(start); self.give_back(charges, &lines); self.bound.truncate(bound); + // The stories of the type in the lines taken out go with them. + self.texts.take_back(texts); + // The warnings of art written as its art again are that art's. + if !self.left_out_since(charges) { + self.warnings.truncate(warnings); + } (self.state, self.gs, self.block_end) = (state, gs, usize::MAX); return false; } @@ -343,11 +367,14 @@ impl Writer<'_> { true } - /// An envelope's contents. - fn contents(&mut self, content: &[Arc]) { + /// An envelope's contents, where type may be written as type ([`Self::text`]): `copied` when + /// they are in its dictionary, where the reader reads a copy of each text object. + fn contents(&mut self, content: &[Arc], copied: bool) { + let level = self.contents_level.replace((self.hidden, self.nest, copied)); for c in content { self.node(c); } + self.contents_level = level; } /// An envelope's grid, when it has one, as a gray gradient mesh (`/Mesh X!` … `/End X!`): its diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 6bc3c63b09..ec565c141d 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -604,6 +604,15 @@ fn place_on(segments: &[[f64; 8]], f: f64) -> f64 { segments.len() as f64 } +/// Where the text document ended at some point ([`TextDoc::written`]). +#[derive(Clone, Copy)] +pub(in crate::ai_editing) struct TextMark { + at: (usize, usize, usize, usize, usize), + values: usize, + lost: bool, + planned: usize, +} + /// The text document being written. #[derive(Default)] pub(in crate::ai_editing) struct TextDoc { @@ -997,6 +1006,24 @@ impl TextDoc { self.threaded.get(&id).copied() } + /// Where the text document written so far ends, for [`Self::take_back`]. + pub(in crate::ai_editing) fn written(&self) -> TextMark { + TextMark { at: self.mark(), values: self.doc_values, lost: self.lost, planned: self.planned.len() } + } + + /// Take the frames, stories and fonts added since `m` out again, with what they counted. Where + /// a thread was planned since, its frames name its story, so nothing is taken out. + pub(in crate::ai_editing) fn take_back(&mut self, m: TextMark) { + if self.planned.len() != m.planned { + return; + } + let (frames, stories, bytes, text, fonts) = m.at; + self.frames.truncate(frames); + self.stories.truncate(stories); + self.fonts.truncate(fonts); + (self.doc_bytes, self.text_bytes, self.doc_values, self.lost) = (bytes, text, m.values, m.lost); + } + /// Whether a thread written since the last call has attributes the text document leaves out. pub(in crate::ai_editing) fn take_lost(&mut self) -> bool { std::mem::take(&mut self.lost) diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index bdb3b9cf50..942e7f3046 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -82,8 +82,6 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; -#[cfg(test)] -pub(crate) use ai::ENVELOPE_DRAWN; pub use ai::NameEncoding; #[cfg(test)] pub(crate) use ai::objects_read; @@ -92,6 +90,8 @@ pub(crate) use ai::{ MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept, envelope_outline, envelope_upright, read_art_style, read_brush_object, read_style_list, style_moves_with, symbol_art_member, warp_number, }; +#[cfg(test)] +pub(crate) use ai::{ENVELOPE_DRAWN, ENVELOPE_TYPE_DRAWN}; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES, record_version}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; pub(crate) use native::{MAX_TEXT, scale_document}; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..6cd8e6155e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1638,7 +1638,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur SVG import reads each `` and `` as the ellipse VectorCraft draws: four smooth anchors on the axes, starting at the right, with handles of the standard length, so an ellipse exported as `` comes back with the anchors it was written from and draws the same (`circles_and_ellipses_import_as_the_ellipse_drawn` in `crates/svg/tests/it/basic_shapes.rs`, `filled_ellipse_on_the_artboard_edge_survives_visually` in `prop_svg.rs`). SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps defines every symbol in its editing copy with its art, registration point and place in the Symbols panel's order, and writes its instances as symbol instances, which VectorCraft's `.ai` reader opens as symbols and instances again (`symbols_and_their_instances_are_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the instances written as their art). - A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps envelopes made with a warp, a mesh or a top object editable: its editing copy holds each envelope's contents, shape, Fidelity, Envelope Options and Edit Contents state in the form VectorCraft's `.ai` envelope reader reads, beside its distorted art, so the file opens with the same live envelope, Edit Contents still switching between the contents and the envelope ([Edit the contents of envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/edit-the-contents-of-envelopes.html)). Envelopes of type, envelopes in opacity masks, pattern tiles or another envelope's contents, and envelopes the reader would open with another shape or other settings (a Fidelity that isn't a whole number, bounds margins kept with Distort Appearance off), and envelopes after the first 256 in a file are written as their distorted art, with a warning (`envelopes_are_read_back_live` and `envelopes_the_reader_cannot_open_live_are_written_as_their_art` in `crates/eps/src/ai_editing/tests.rs`). + A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps envelopes made with a warp, a mesh or a top object editable: its editing copy holds each envelope's contents, shape, Fidelity, Envelope Options and Edit Contents state in the form VectorCraft's `.ai` envelope reader reads, beside its distorted art, so the file opens with the same live envelope, Edit Contents still switching between the contents and the envelope ([Edit the contents of envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/edit-the-contents-of-envelopes.html)). Envelopes whose type is threaded, has text wrap options or is written as its outlines, envelopes in opacity masks, pattern tiles or another envelope's contents, and envelopes the reader would open with another shape or other settings (a Fidelity that isn't a whole number, bounds margins kept with Distort Appearance off), and envelopes after the first 256 in a file are written as their distorted art, with a warning (`envelopes_are_read_back_live` and `envelopes_the_reader_cannot_open_live_are_written_as_their_art` in `crates/eps/src/ai_editing/tests.rs`). + An envelope whose contents hold type keeps the type editable in that `.ai`: the editing copy writes the type as type in the envelope's contents, in the form the `.ai` reader opens as a live envelope of type (`TypedEnvelope` in `crates/eps/src/import/ai/envelope.rs`), so the file opens with the type shown and editable in the envelope's contents, with Edit Contents off or on. An envelope of type in a symbol's art, and one whose type is threaded, has text wrap options or is written as its outlines (mirrored type, for example), is written as its distorted art, as before (`an_envelope_of_type_is_read_back_live_with_its_type`, `an_envelope_of_type_written_as_its_art_leaves_no_story` and `an_envelope_of_type_in_a_symbol_or_a_thread_is_written_as_its_art` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style when the writer supports their appearance and effects (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). The same holds for the objects of an opacity mask's art, a mask inside another mask's art and a hidden art root included, so a mask whose art has Offset Path opens drawing the image it masks as it did, with the effect live; geometry effects that no art style holds on a mask's art, such as Roughen, are written applied to the art, which opens drawing what it drew (`a_masked_image_with_live_offset_path_round_trips`, `hidden_and_nested_mask_art_keep_live_offset_path` and `a_masks_art_with_an_effect_no_style_holds_is_written_as_it_draws` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines). From 43761d98ca43d2cc7d090b11bca4b90cd690498c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 19:49:34 -0800 Subject: [PATCH 2/2] Docs: one paragraph for envelopes kept live in an .ai save --- docs/target-app-parity.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a3d2943b4c..873e644262 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1742,9 +1742,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur SVG import reads each `` and `` as the ellipse VectorCraft draws: four smooth anchors on the axes, starting at the right, with handles of the standard length, so an ellipse exported as `` comes back with the anchors it was written from and draws the same (`circles_and_ellipses_import_as_the_ellipse_drawn` in `crates/svg/tests/it/basic_shapes.rs`, `filled_ellipse_on_the_artboard_edge_survives_visually` in `prop_svg.rs`). SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps defines every symbol in its editing copy with its art, registration point and place in the Symbols panel's order, and writes its instances as symbol instances, which VectorCraft's `.ai` reader opens as symbols and instances again (`symbols_and_their_instances_are_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the instances written as their art). - A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps envelopes made with a warp, a mesh or a top object editable: its editing copy holds each envelope's contents, shape, Fidelity, Envelope Options and Edit Contents state in the form VectorCraft's `.ai` envelope reader reads, beside its distorted art, so the file opens with the same live envelope, Edit Contents still switching between the contents and the envelope ([Edit the contents of envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/edit-the-contents-of-envelopes.html)). Envelopes whose type is threaded, has text wrap options or is written as its outlines, envelopes in opacity masks, pattern tiles or another envelope's contents, and envelopes the reader would open with another shape or other settings (a Fidelity that isn't a whole number, bounds margins kept with Distort Appearance off), and envelopes after the first 256 in a file are written as their distorted art, with a warning (`envelopes_are_read_back_live` and `envelopes_the_reader_cannot_open_live_are_written_as_their_art` in `crates/eps/src/ai_editing/tests.rs`). + A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps envelopes made with a warp, a mesh or a top object editable: its editing copy holds each envelope's contents, shape, Fidelity, Envelope Options and Edit Contents state in the form VectorCraft's `.ai` envelope reader reads, beside its distorted art, so the file opens with the same live envelope, Edit Contents still switching between the contents and the envelope ([Edit the contents of envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/edit-the-contents-of-envelopes.html)). Envelopes whose type is threaded, has text wrap options or is written as its outlines, envelopes in opacity masks, pattern tiles or another envelope's contents, and envelopes the reader would open with another shape or other settings (a Fidelity that isn't a whole number, bounds margins kept with Distort Appearance off), and envelopes after the first 256 envelopes and images with a Warp in a file are written as their distorted art, with a warning (`envelopes_are_read_back_live` and `envelopes_the_reader_cannot_open_live_are_written_as_their_art` in `crates/eps/src/ai_editing/tests.rs`). An envelope whose contents hold type keeps the type editable in that `.ai`: the editing copy writes the type as type in the envelope's contents, in the form the `.ai` reader opens as a live envelope of type (`TypedEnvelope` in `crates/eps/src/import/ai/envelope.rs`), so the file opens with the type shown and editable in the envelope's contents, with Edit Contents off or on. An envelope of type in a symbol's art, and one whose type is threaded, has text wrap options or is written as its outlines (mirrored type, for example), is written as its distorted art, as before (`an_envelope_of_type_is_read_back_live_with_its_type`, `an_envelope_of_type_written_as_its_art_leaves_no_story` and `an_envelope_of_type_in_a_symbol_or_a_thread_is_written_as_its_art` in `crates/eps/src/ai_editing/tests.rs`). - A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps envelopes made with a warp, a mesh or a top object editable: its editing copy holds each envelope's contents, shape, Fidelity, Envelope Options and Edit Contents state in the form VectorCraft's `.ai` envelope reader reads, beside its distorted art, so the file opens with the same live envelope, Edit Contents still switching between the contents and the envelope ([Edit the contents of envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/edit-the-contents-of-envelopes.html)). Envelopes of type, envelopes in opacity masks, pattern tiles or another envelope's contents, and envelopes the reader would open with another shape or other settings (a Fidelity that isn't a whole number, bounds margins kept with Distort Appearance off), and envelopes after the first 256 envelopes and images with a Warp in a file are written as their distorted art, with a warning (`envelopes_are_read_back_live` and `envelopes_the_reader_cannot_open_live_are_written_as_their_art` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style when the writer supports their appearance and effects (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). The same holds for the objects of an opacity mask's art, a mask inside another mask's art and a hidden art root included, so a mask whose art has Offset Path opens drawing the image it masks as it did, with the effect live; geometry effects that no art style holds on a mask's art, such as Roughen, are written applied to the art, which opens drawing what it drew (`a_masked_image_with_live_offset_path_round_trips`, `hidden_and_nested_mask_art_keep_live_offset_path` and `a_masks_art_with_an_effect_no_style_holds_is_written_as_it_draws` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines).