From 7d76b35710df5119ea5de802e9f985cf3e83f02d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 07:48:03 -0800 Subject: [PATCH] .ai save: type written as outlines keeps wrapping by its bounds Type that the editing copy writes as its outlines (mirrored type, type in a clip or symbol, type the text document can't hold) put its Text Wrap Options on the group of glyph outlines. The reader then wrapped area type around the glyph shapes instead of the type's bounds, so the wrap region changed when the file was opened. The group now ends with an unpainted closed rectangle at the type's geometric bounds, with the type's lock and hidden state, that carries the wrap options; the group keeps the type's name and has no options of its own. The `.ai` reader already reads an unpainted path with its own options (`text_wrap` in crates/eps/src/import/ai/read/document.rs), and wrap resolution descends into a group without options. The rectangle counts toward the object limit; without room for it the options are left out with the limit's warning. Type without finite bounds gets no rectangle, as it had no wrap region before saving. --- crates/eps/src/ai_editing/tests.rs | 90 +++++++++++++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 34 ++++++++++- docs/target-app-parity.md | 1 + 3 files changed, 122 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index ce64e56fd5..c864dc9529 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -10584,6 +10584,96 @@ fn text_wrap_options_of_type_and_envelopes_are_read_back() { same_wraps(&wraps(top), &[(Some("Bent".into()), 2.5, true)]); } +/// Type written as its outlines wraps area type below it by its bounds, as it did before saving, +/// not by its glyphs: its text wrap options go on an unpainted rectangle of its bounds, the last +/// member of the outlines' group, which keeps the type's name and has no options of its own. +/// Mirrored point type with a positive offset, a negative offset with Invert Wrap, hidden and +/// locked, type of only white space, and no room for the rectangle within the object limit. +#[test] +fn type_written_as_outlines_keeps_wrapping_by_its_bounds() { + use vectorcraft_doc::{TextKind, TextObject, TextRun}; + type Region = Vec<([f64; 4], f64, bool)>; + // The wrap shapes of the area type, in its own space: each one's bounds and options. + let region = |doc: &Document| -> Region { + let mut doc = doc.clone(); + vectorcraft_doc::text::resolve_wrap(None, &mut doc); + let top = doc.layers[0].children().unwrap(); + let Some(NodeKind::Text(t)) = top.iter().map(|n| &n.kind).find(|k| matches!(k, NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }))) + else { + panic!("no area type"); + }; + t.wrap + .iter() + .map(|s| { + let b = s.path.bounds().unwrap_or_default(); + ([b.x0, b.y0, b.x1, b.y1], s.wrap.offset, s.wrap.invert) + }) + .collect() + }; + let make = |text: &str, offset: f64, invert: bool, visible: bool, locked: bool| { + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let mut body = TextObject::point(vectorcraft_geom::Point::ZERO, "", style(10.0, Color::BLACK)); + body.runs = vec![TextRun::new("one two three four five six seven eight nine ten eleven twelve", style(10.0, Color::BLACK))]; + body.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 290.0, 190.0)) }; + body.xf = Affine::IDENTITY; + let body = named(text_node(&mut doc, body), "Body"); + let mut label = TextObject::point(vectorcraft_geom::Point::ZERO, text, style(60.0, Color::BLACK)); + // Mirrored, so written as its outlines. + label.xf = Affine::new([-1.0, 0.0, 0.0, 1.0, 150.0, 120.0]); + let mut label = wrapped(named(text_node(&mut doc, label), "Label"), offset, invert); + (label.visible, label.locked) = (visible, locked); + for n in [body, label] { + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + doc + }; + for (text, offset, invert, visible, locked) in + [("Ill", 4.5, false, true, false), ("Ill", -2.0, true, true, false), ("Ill", 3.0, false, false, true), (" ", 3.0, false, true, false)] + { + let doc = make(text, offset, invert, visible, locked); + let bounds = doc.layers[0].children().unwrap()[1].geometric_bounds(); + let text_out = program(&doc); + let back = read_back(&doc); + let (before, after) = (region(&doc), region(&back)); + let same = before.len() == after.len() + && before + .iter() + .zip(&after) + .all(|(b, a)| b.0.iter().zip(a.0).all(|(x, y)| (x - y).abs() < 1e-3) && (b.1 - a.1).abs() < 1e-6 && b.2 == a.2); + assert!(same, "{text:?}\n{before:?}\n{after:?}\n{text_out}"); + let top = back.layers[0].children().unwrap(); + let group = &top[1]; + assert!(matches!(group.kind, NodeKind::Group { clip: false, .. }), "{text_out}"); + assert_eq!((group.name.as_deref(), group.extras().wrap, group.visible, group.locked), (Some("Label"), None, visible, locked)); + if let Some(want) = bounds { + assert_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{text_out}"); + same_wraps(&wraps(top), &[(None, offset, invert)]); + let last = group.children().unwrap().last().unwrap(); + assert!(matches!(last.kind, NodeKind::Path { guide: false, .. }) && last.appearance.items.is_empty(), "{text_out}"); + assert_eq!((last.visible, last.locked), (visible, locked)); + let b = last.geometric_bounds().unwrap(); + assert!([b.x0 - want.x0, b.y0 - want.y0, b.x1 - want.x1, b.y1 - want.y1].iter().all(|d| d.abs() < 1e-3), "{b:?} {want:?}"); + } else { + assert!(wraps(top).is_empty(), "{text_out}"); + } + } + // The rectangle counts toward the object limit. Without room for it, the type's wrap options + // are left out with the limit's warning, and the glyphs do not take them. + let doc = make("Ill", 4.5, false, true, false); + let read = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap(); + let all = export_within(&doc, &opts(), read).unwrap(); + assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", all.warnings); + let out = export_within(&doc, &opts(), read - 1).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings); + let text_out = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert!(!text_out.contains("(AI11TextWrapOffset)"), "{text_out}"); + let (back, _) = crate::ai_alone(&out.program).unwrap(); + let top = back.layers[0].children().unwrap(); + assert!(wraps(top).is_empty(), "{text_out}"); + assert_eq!(top[1].name.as_deref(), Some("Label"), "{text_out}"); +} + /// A clipping path inside art cut to a clipping object is written as its fills and its strokes, /// two objects: its text wrap options are written once, whether it has both or only strokes, and /// with its strokes when the region outside leaves nothing of its fills. diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 6361efe5ff..b9d7229186 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -9,7 +9,7 @@ use vectorcraft_color::swatch::{REGISTRATION, Swatch}; use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer, TextWrap}; use vectorcraft_effects::stroke; -use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath}; +use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath, shapes}; use vectorcraft_pathops::BoolOp; use crate::ai_ops::{self, unit}; @@ -271,6 +271,9 @@ pub(super) struct Writer<'a> { /// brushed stroke's pieces) the art being written is in. Text wrap options are written only /// outside them, since only the document's own objects wrap type. drawn: u32, + /// The type just written as outlines has its text wrap options on the rectangle of its bounds + /// ([`Self::wrap_bounds`]), not in the group's dictionary. + wrap_moved: bool, /// The brushes written ([`brushes`]). brush_set: brushes::Brushes, /// Brushes may be written; false on a large canvas, where they are left out. @@ -350,6 +353,7 @@ impl<'a> Writer<'a> { styles: styles::Styles::default(), in_look: false, drawn: 0, + wrap_moved: false, brush_set: brushes::Brushes::default(), brushes_live: true, deform: false, @@ -829,7 +833,8 @@ impl<'a> Writer<'a> { /// deep, or its art doesn't fit. fn dictionary(&mut self, n: &Node) -> bool { let name = n.name.as_deref().filter(|s| !s.is_empty()); - let wrap = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look); + let moved = std::mem::take(&mut self.wrap_moved); + let wrap = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look && !moved); let placed = std::mem::take(&mut self.placed); let typed = std::mem::take(&mut self.typed); let bound = self.bound_variables(n, placed, typed); @@ -1404,7 +1409,8 @@ impl<'a> Writer<'a> { /// ([`vectorcraft_effects::outline_text`]): each run's glyphs with their underline and /// strikethrough, its color glyphs, and the object's own fills and strokes; then its inline /// graphics as their symbols' art where the layout places them, as the PDF page draws them; - /// the group has the type's transparency `xy`. + /// last, with text wrap options, the rectangle of its bounds ([`Self::wrap_bounds`]). The group + /// has the type's transparency `xy`. fn outlined_text(&mut self, n: &Node, xy: Xy) { if !self.open_group("u") { return; @@ -1460,12 +1466,34 @@ impl<'a> Writer<'a> { for c in over { self.node(c); } + // Type wraps by its bounds: its text wrap options go on a rectangle of its bounds, the + // group's last member, and the group's dictionary leaves them out. + if let Some(w) = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look) { + self.wrap_bounds(n, w); + self.wrap_moved = true; + } if let Some((locked, hidden)) = state { self.state(locked, hidden); } self.end_group("U", xy); } + /// An unpainted closed rectangle at the geometric bounds of type `n`, the region its text wraps + /// around, with `n`'s lock and hidden state and text wrap options `w` in its dictionary. Nothing + /// when `n` has no finite bounds, or without room for it ([`Self::take_objects`]). + fn wrap_bounds(&mut self, n: &Node, w: TextWrap) { + let Some(b) = n.geometric_bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) else { return }; + self.state(n.locked, !n.visible); + let objects = self.objects; + self.plain_object(&shapes::rectangle(b), FillRule::NonZero, None, None, Xy::PLAIN); + if self.objects == objects { + return; + } + self.body.push_str("%_/ArtDictionary :\n"); + self.text_wrap(w); + self.body.push_str("%_;\n%_\n"); + } + // ---------- color ---------- /// Set the fill (or with `stroke`, the stroke) to paint `p`: a spot or global swatch by its diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..bf26476e53 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1703,6 +1703,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps the document's variables, data sets and the bindings of objects to variables in its editing copy, in the form the variable library reader above reads, so they come back when the file is opened. Bindings to layers, to type written as outlines, to graphs and to linked images written as other objects, data set values of graphs, and variables, data sets, bindings and values past the writer's limits are left out with a warning (#6024; `crates/eps/src/ai_editing/tests.rs` `variables_data_sets_and_bindings_are_read_back`; `crates/eps/src/ai_editing/writer/variables.rs` lists what is left out). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps each wrap object's Text Wrap Options in its editing copy: the Offset, positive or negative, and Invert Wrap ([the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/wrap-or-unwrap-text-around-objects.html)), in the form the `.ai` reader reads them (`text_wrap` in `crates/eps/src/import/ai/read/document.rs`), so they come back when the file is opened. An offset past the -1000 to 1000 pt the reader keeps is written as the nearer end of that range. On a large canvas the offset is written at the art's scale and comes back at its size. Art the writer draws for an object, such as a blend's steps or a symbol instance written as its art, has only that object's options, and a clipping path written as its fills and its strokes has its options once (`crates/eps/src/ai_editing/tests.rs` `text_wrap_options_are_written_and_read_back`, `drawn_art_keeps_only_its_objects_text_wrap`, `text_wrap_offsets_on_a_large_canvas_come_back_at_their_size`, `text_wrap_options_of_type_and_envelopes_are_read_back`, `a_clipping_path_written_as_its_paint_keeps_one_text_wrap`). + Type written as its outlines in the editing copy still wraps area type by its bounds, not by its glyphs, when the file is opened: the outlines' group ends with an unpainted rectangle at the type's bounds that carries its Text Wrap Options, in the form the `.ai` reader reads a path's options (`text_wrap` in `crates/eps/src/import/ai/read/document.rs`), and the group keeps the type's name (`crates/eps/src/ai_editing/tests.rs` `type_written_as_outlines_keeps_wrapping_by_its_bounds`). In a `.ai` or EPS opened from its editing data, type has its text object's opacity, blend mode and knockout shape flag, whether it is made from the file's text document or taken from the page, and type made from the text document takes its text object's lock. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning. `.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`). A `.ai` or EPS graphic style library, whose editing data holds the styles and their list but no layers, opens with those styles in list order. Other Library… loads another document's graphic styles ([Apply graphic styles](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/apply-graphic-styles.html)) and lists such a library's styles in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the styles of its list in place of the default ones (`crates/eps/src/import/tests_native.rs`, `cmd::fileio::tests_aiimport::a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles`).