diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 80280dc254..671f639327 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -11548,7 +11548,7 @@ fn two_leaves(doc: &mut Document) { /// A symbol instance written as its art (its isolation is a group's), a blend and an intertwine, /// each written as a group of its art, wrap area type below them by their bounds after reopening, /// as they did before saving, not by the members of that art: the text wrap options go on an -/// unpainted rectangle of the bounds, the group's last member, and the group, which keeps the +/// unpainted rectangle of the bounds, the group's first member, and the group, which keeps the /// object's name, isolation, lock and visibility, has none. A positive offset, a negative offset /// with Invert Wrap, and hidden and locked. #[test] @@ -11613,10 +11613,10 @@ fn live_art_written_as_a_group_keeps_wrapping_by_its_bounds() { (Some("Thing"), None, visible, locked, thing.isolate), "{name}" ); - let last = g.children().unwrap().last().unwrap(); - assert!(matches!(last.kind, NodeKind::Path { guide: false, .. }) && last.appearance.items.is_empty(), "{name}: {text_out}"); - assert_eq!((last.visible, last.locked), (visible, locked), "{name}"); - let b = last.geometric_bounds().unwrap(); + let first = &g.children().unwrap()[0]; + assert!(matches!(first.kind, NodeKind::Path { guide: false, .. }) && first.appearance.items.is_empty(), "{name}: {text_out}"); + assert_eq!((first.visible, first.locked), (visible, locked), "{name}"); + let b = first.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), "{name}: {b:?} {want:?}"); } @@ -11638,14 +11638,17 @@ fn live_art_written_as_a_group_keeps_wrapping_by_its_bounds() { } /// The rectangle that keeps a blend written as its art wrapping by its bounds counts toward the -/// object limit; without room for it, the options are left out with the limit's warning, and the -/// group does not take them. An instance whose art holds area type keeps its options on the group, -/// with no rectangle, so that type is not wrapped by the instance's own bounds after reopening; -/// hidden area type, which is not wrapped, and area type written as its outlines do not count. +/// object limit, ahead of the art: one object short, the last step is left out with the limit's +/// warning; without room for the rectangle, the options are left out with the warning, and the +/// group does not take them. In an instance whose art holds area type, shown, hidden, in a hidden +/// group or written as its outlines, the rectangle is the group's first member, below that type: +/// area type below the instance wraps by its bounds after reopening, and the type in its art is not +/// wrapped by them. #[test] -fn wrap_bounds_of_live_art_fit_the_object_limit_and_skip_art_with_area_type() { +fn wrap_bounds_of_live_art_fit_the_object_limit_and_leave_area_type_in_the_art_unwrapped() { use vectorcraft_doc::{TextKind, TextObject, TextRun}; - // A blend alone, without a symbol definition, so the rectangle is the one object left out. + // A blend alone, without a symbol definition, so the objects are the group, the rectangle and + // the steps. let mut doc = Document::new(300.0, 200.0); let keys = vec![ Arc::new(square(&mut doc, Rect::new(60.0, 80.0, 70.0, 90.0), Color::gray(0.5))), @@ -11663,12 +11666,20 @@ fn wrap_bounds_of_live_art_fit_the_object_limit_and_skip_art_with_area_type() { 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}"); + assert_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{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[0].name.as_deref(), Some("Thing"), "{text_out}"); - assert_eq!(top[0].children().map(Vec::len), Some(steps), "only the art: {text_out}"); + same_wraps(&wraps(top), &[(None, 4.5, false)]); + assert_eq!((top[0].name.as_deref(), top[0].extras().wrap), (Some("Thing"), None), "{text_out}"); + let members = top[0].children().unwrap(); + assert_eq!(members.len(), steps, "the rectangle and all but the last step: {text_out}"); + assert!(matches!(members[0].kind, NodeKind::Path { guide: false, .. }) && members[0].appearance.items.is_empty(), "{text_out}"); + let out = export_within(&doc, &opts(), read - steps - 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(); + assert!(wraps(back.layers[0].children().unwrap()).is_empty(), "{text_out}"); // Area type in the symbol's art, shown or hidden, of `words`, in a hidden group with `in_hidden`. let make = |shown: bool, words: &'static str, in_hidden: bool| { @@ -11691,38 +11702,37 @@ fn wrap_bounds_of_live_art_fit_the_object_limit_and_skip_art_with_area_type() { wrapped(named(n, "Thing"), 4.5, false) } }; - // Hidden type, or type in a hidden group, is not wrapped, and type of spaces alone is written - // as its outlines, which are not type: the rectangle keeps the bounds. + // Type of spaces alone is written as its outlines, which are not type. for (what, shown, words, in_hidden) in - [("hidden", false, "leaf", false), ("in a hidden group", true, "leaf", true), ("spaces", true, " ", false)] + [("shown", true, "leaf", false), ("hidden", false, "leaf", false), ("in a hidden group", true, "leaf", true), ("spaces", true, " ", false)] { let doc = over_area_type(make(shown, words, in_hidden)); let text_out = program(&doc); + assert_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{what}: {text_out}"); let back = read_back(&doc); let (before, after) = (area_wrap_region(&doc), area_wrap_region(&back)); + assert_eq!(before.len(), 1, "{what}"); assert!(same_region(&before, &after), "{what}\n{before:?}\n{after:?}\n{text_out}"); let g = &back.layers[0].children().unwrap()[1]; + assert!(matches!(g.kind, NodeKind::Group { clip: false, .. }), "{what}: {text_out}"); assert_eq!(g.extras().wrap, None, "{what}: {text_out}"); - let last = g.children().unwrap().last().unwrap(); - assert!(matches!(last.kind, NodeKind::Path { guide: false, .. }) && last.appearance.items.is_empty(), "{what}: {text_out}"); + let first = &g.children().unwrap()[0]; + assert!(matches!(first.kind, NodeKind::Path { guide: false, .. }) && first.appearance.items.is_empty(), "{what}: {text_out}"); + let mut resolved = back.clone(); + vectorcraft_doc::text::resolve_wrap(None, &mut resolved); + let mut inner = vec![]; + resolved.layers[0].children().unwrap()[1].walk(&mut |m| { + if let NodeKind::Text(t) = &m.kind { + inner.push((matches!(t.kind, TextKind::Area { .. }), m.visible, t.wrap.len())); + } + }); + let want: &[(bool, bool, usize)] = match what { + "shown" | "in a hidden group" => &[(true, true, 0)], + "hidden" => &[(true, false, 0)], + _ => &[], + }; + assert_eq!(inner, want, "{what}: {text_out}"); } - - let doc = over_area_type(make(true, "leaf", false)); - let text_out = program(&doc); - assert_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{text_out}"); - let back = read_back(&doc); - let g = &back.layers[0].children().unwrap()[1]; - assert!(matches!(g.kind, NodeKind::Group { clip: false, .. }), "{text_out}"); - assert_eq!(g.extras().wrap, Some(vectorcraft_doc::TextWrap { offset: 4.5, invert: false }), "{text_out}"); - let mut resolved = back.clone(); - vectorcraft_doc::text::resolve_wrap(None, &mut resolved); - let mut inner = vec![]; - resolved.layers[0].children().unwrap()[1].walk(&mut |m| { - if let NodeKind::Text(t) = &m.kind { - inner.push((matches!(t.kind, TextKind::Area { .. }), t.wrap.len())); - } - }); - assert_eq!(inner, [(true, 0)], "{text_out}"); } /// A blend with no keys, written as an empty group, has no bounds for a rectangle: its group keeps @@ -11743,8 +11753,8 @@ fn live_art_without_bounds_keeps_its_text_wrap_on_the_group() { /// A symbol instance written as its art, whose art holds an envelope of area type beside a square, /// wraps area type below it by its bounds after reopening. The envelope there is written as its art, -/// and the type written for it as a live envelope and taken back does not count as shown area type: -/// the group has no text wrap options, and its last member is the rectangle of the bounds. +/// after the type written for it as a live envelope is taken back: the group has no text wrap +/// options, and its first member is the rectangle of the bounds. #[test] fn an_envelope_of_area_type_in_written_art_leaves_the_wrap_on_the_bounds() { use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; @@ -11776,8 +11786,8 @@ fn an_envelope_of_area_type_in_written_art_leaves_the_wrap_on_the_bounds() { let mut live = 0; g.walk(&mut |m| live += usize::from(matches!(m.kind, NodeKind::Envelope { .. }))); assert_eq!(live, 0, "{text_out}"); - let last = g.children().unwrap().last().unwrap(); - assert!(matches!(last.kind, NodeKind::Path { guide: false, .. }) && last.appearance.items.is_empty(), "{text_out}"); + let first = &g.children().unwrap()[0]; + assert!(matches!(first.kind, NodeKind::Path { guide: false, .. }) && first.appearance.items.is_empty(), "{text_out}"); } /// A clipping path inside art cut to a clipping object is written as its fills and its strokes, diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index e4c3ca8ef0..1b48697e32 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -289,7 +289,8 @@ 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 + /// The type just written as outlines, or the symbol instance or live object just written as a + /// group of its art, 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`]). @@ -320,11 +321,6 @@ pub(super) struct Writer<'a> { series_tag: Option, /// Where the `Go` lines of the graph being written start in [`Self::body`]. graph_tags: Vec, - /// How many hidden objects the object being written is, or is in ([`Self::node`]). - unshown: u32, - /// For each area type written as type, not as its outlines ([`Self::text`]), in the order - /// written: [`Self::unshown`] as it was written. - area_written: Vec, } impl<'a> Writer<'a> { @@ -405,8 +401,6 @@ impl<'a> Writer<'a> { in_graph: false, series_tag: None, graph_tags: vec![], - unshown: 0, - area_written: vec![], } } @@ -685,17 +679,8 @@ impl<'a> Writer<'a> { } } - /// One object: its state before it, its transparency, and its name and opacity mask after it - /// ([`Self::write_node`]), counted in [`Self::unshown`] while it is written when it is hidden. + /// One object: its state before it, its transparency, and its name and opacity mask after it. fn node(&mut self, n: &Node) { - let hidden = u32::from(!n.visible); - self.unshown += hidden; - self.write_node(n); - self.unshown -= hidden; - } - - /// One object, written by [`Self::node`]. - fn write_node(&mut self, n: &Node) { let tag = self.series_tag.take(); if let NodeKind::Layer { .. } = n.kind { return self.write_layer(n, &[]); @@ -790,7 +775,7 @@ impl<'a> Writer<'a> { return; } let state = self.state; - let mut area_type = false; + let moved = self.wrap_on_bounds(n, state); // The canvas places an instance inside a symbol's art where the outer instance puts // it (9-slice guides, stains and patterns included), and this art doesn't move with it. self.wrote_fixed |= self.in_symbol; @@ -826,20 +811,16 @@ impl<'a> Writer<'a> { art.visible = true; art.locked = false; art.name = None; - let from = self.area_written.len(); self.nest += 1; self.drawn += 1; self.node(&art); self.drawn -= 1; self.nest -= 1; - area_type = self.shown_area_type(from); } else { self.warn(SYMBOL_LIMIT); } } - if !area_type { - self.wrap_on_bounds(n, state); - } + self.wrap_moved = moved; self.end_group("U", xy); } NodeKind::Envelope { .. } if self.envelope(n, xy) => {} @@ -912,7 +893,7 @@ impl<'a> Writer<'a> { } /// Live object `n`, of transparency `xy`, as a group of its art, with `warning` when it is given. - /// With `bounds`, `n`'s text wrap options go on the rectangle of its bounds, the group's last + /// With `bounds`, `n`'s text wrap options go on the rectangle of its bounds, the group's first /// member ([`Self::wrap_on_bounds`]); without it, in the group's dictionary. fn drawn_art(&mut self, n: &Node, xy: Xy, warning: Option<&str>, bounds: bool) { if !self.open_group("u") { @@ -922,10 +903,9 @@ impl<'a> Writer<'a> { if let Some(w) = warning { self.warn(w); } - let mut area_type = false; + let moved = bounds && self.wrap_on_bounds(n, state); if self.nest < MAX_NEST { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); - let from = self.area_written.len(); self.nest += 1; self.drawn += 1; for c in g.children().map(Vec::as_slice).unwrap_or_default() { @@ -933,43 +913,28 @@ impl<'a> Writer<'a> { } self.drawn -= 1; self.nest -= 1; - area_type = self.shown_area_type(from); - } - if bounds && !area_type { - self.wrap_on_bounds(n, state); } + self.wrap_moved = moved; self.end_group("U", xy); } - /// Was area type written as type ([`Self::text`]) in the art written since `from` in - /// [`Self::area_written`], shown wherever the art is shown? Written as a group without text - /// wrap options, such art has that type wrapped by the objects above it in the group; hidden - /// type, and type written as its outlines, are not wrapped - /// ([`vectorcraft_doc::text::resolve_wrap`]). The entries since `from` are replaced by the - /// fewest hidden objects any of them was in, which is all a group around this one asks. - fn shown_area_type(&mut self, from: usize) -> bool { - let fewest = self.area_written.get(from..).and_then(|w| w.iter().min().copied()); - self.area_written.truncate(from); - self.area_written.extend(fewest); - fewest == Some(self.unshown) - } - - /// After the members of the group that object `n` is written as, when the group's dictionary - /// would hold `n`'s text wrap options: the rectangle of `n`'s bounds with them, the group's last - /// member ([`Self::wrap_bounds`]), then lock and hidden state `state` again. The group's - /// dictionary leaves the options out, so the group wraps type by `n`'s bounds, as `n` does - /// ([`vectorcraft_doc::text::resolve_wrap`]), not by its members. When `n` has no finite bounds, - /// nothing, and the group's dictionary keeps the options. - fn wrap_on_bounds(&mut self, n: &Node, state: Option<(bool, bool)>) { - let Some(w) = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look) else { return }; + /// In the group that object `n` is written as, when the group's dictionary would hold `n`'s text + /// wrap options: the rectangle of `n`'s bounds with them ([`Self::wrap_bounds`]), then lock and + /// hidden state `state` again. True when the group's dictionary is to leave the options out + /// ([`Self::wrap_moved`]), so the group wraps type by `n`'s bounds, as `n` does + /// ([`vectorcraft_doc::text::resolve_wrap`]), not by its members. Written before the group's + /// members, the rectangle is below any area type among them and does not wrap it. False, + /// writing nothing, when `n` has no finite bounds, and the group's dictionary keeps the options. + fn wrap_on_bounds(&mut self, n: &Node, state: Option<(bool, bool)>) -> bool { + let Some(w) = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look) else { return false }; if finite_bounds(n).is_none() { - return; + return false; } self.wrap_bounds(n, w); - self.wrap_moved = true; if let Some((locked, hidden)) = state { self.state(locked, hidden); } + true } /// `n`'s `/ArtDictionary`, after it, when it has a name (`AI10_ArtUID`, see [`Self::take_id`]), @@ -1552,9 +1517,6 @@ impl<'a> Writer<'a> { // are read only from a state block after it, as a group's are. self.set_object_xy(xy.object()); texts::stub(&mut self.body, story, frame, t, self.to_art); - if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) { - self.area_written.push(self.unshown); - } if xy.isolate || xy.knockout != Knockout::Neutral { self.state_block(xy); } @@ -1622,7 +1584,7 @@ impl<'a> Writer<'a> { self.node(c); } // Type wraps by its bounds. - self.wrap_on_bounds(n, state); + self.wrap_moved = self.wrap_on_bounds(n, state); if let Some((locked, hidden)) = state { self.state(locked, hidden); } diff --git a/crates/eps/src/ai_editing/writer/envelopes.rs b/crates/eps/src/ai_editing/writer/envelopes.rs index 2e673497db..09c6a33550 100644 --- a/crates/eps/src/ai_editing/writer/envelopes.rs +++ b/crates/eps/src/ai_editing/writer/envelopes.rs @@ -374,7 +374,6 @@ impl Writer<'_> { 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()); - let area = self.area_written.len(); self.nest += 1; self.hidden += 1; // The group before the dictionary: the grid, or the contents while they are edited. @@ -416,10 +415,6 @@ impl Writer<'_> { // wrap options stay in the art's dictionary. self.state(n.locked, !n.visible); self.drawn_art(n, xy, None, false); - // Area type in the contents is not shown area type for a group around the envelope - // ([`Self::shown_area_type`]): the reader does not wrap type inside an envelope, and type in - // lines taken out below is not written. - self.area_written.truncate(area); if !opened || outlines || self.left_out_since(charges) { let lines = self.body.split_off(start); self.give_back(charges, &lines); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b83066a56a..f5ac9a0436 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1792,7 +1792,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 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`). - A symbol instance written as its art, and a blend, mesh, repeat, placed document, intertwine or envelope written as a group of its art, likewise still wraps area type by its bounds when the file is opened, as it did before saving, not by the members of that art: the group ends with the same unpainted rectangle, which takes the object's lock and visibility, and the group keeps the object's name and transparency without options of its own. Art that holds shown area type written as type keeps the options on its group, and without room for the rectangle within the object limit the options are left out with the limit's warning. This is VectorCraft's own round-trip rule (`crates/eps/src/ai_editing/tests.rs` `live_art_written_as_a_group_keeps_wrapping_by_its_bounds`, `wrap_bounds_of_live_art_fit_the_object_limit_and_skip_art_with_area_type`). + A symbol instance written as its art, and a blend, mesh, repeat, placed document, intertwine or envelope written as a group of its art, likewise still wraps area type by its bounds when the file is opened, as it did before saving, not by the members of that art: the group starts with the same unpainted rectangle, which takes the object's lock and visibility, and the group keeps the object's name and transparency without options of its own. Area type in that art is above the rectangle, so the rectangle does not wrap it. The rectangle takes its place in the object limit before the art; without room for it, the options are left out with the limit's warning. An object without finite bounds keeps the options on its group. This is VectorCraft's own round-trip rule (`crates/eps/src/ai_editing/tests.rs` `live_art_written_as_a_group_keeps_wrapping_by_its_bounds`, `wrap_bounds_of_live_art_fit_the_object_limit_and_leave_area_type_in_the_art_unwrapped`, `live_art_without_bounds_keeps_its_text_wrap_on_the_group`). 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`).