Merge pull request #7830 from dotCooCoo/fix/a-symbol-instance-written-as-its-art-reads-back

.ai save: live art written as a group keeps wrapping type by its bounds
This commit is contained in:
BFlat
2026-11-15 20:45:41 +07:00
committed by GitHub
4 changed files with 374 additions and 16 deletions
+327 -1
View File
@@ -1038,7 +1038,8 @@ fn nested_symbol_instances_stop_at_the_symbol_art_limit() {
/// The documents [`the_writer_counts_objects_as_the_reader_reads_them`] writes: groups, compound
/// paths, guides, sublayers, clipping groups and layers, painted clipping paths, opacity masks,
/// gradients, pattern definitions and tiles, images, type, symbol definitions and instances,
/// objects with art styles, objects bound to variables and live envelopes.
/// objects with art styles, objects bound to variables, live envelopes, and the rectangles of the
/// bounds of wrapped objects written as their art.
fn counted() -> Vec<(&'static str, Document)> {
vec![
("sample", sample()),
@@ -1054,9 +1055,28 @@ fn counted() -> Vec<(&'static str, Document)> {
("blend", blend_doc()),
("brushes", brushes_document()),
("envelopes", envelopes_document()),
("wrap bounds", wrap_bounds_doc()),
]
}
/// Area type below a wrapped symbol instance written as its art and wrapped type written as its
/// outlines, each with the rectangle of its bounds.
fn wrap_bounds_doc() -> Document {
let mut doc = over_area_type(|doc| {
two_leaves(doc);
let mut n = instance(doc, "Leaf", Affine::new([3.0, 0.0, 0.0, 3.0, 150.0, 100.0]));
n.isolate = true;
wrapped(n, 4.5, false)
});
let mut label = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Ill", 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 label = wrapped(text_node(&mut doc, label), -2.0, true);
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, label).unwrap();
doc
}
/// A blend of two squares 20 pt apart with a step every 10 pt, written as its steps.
fn blend_doc() -> Document {
let mut doc = Document::new(200.0, 100.0);
@@ -11374,6 +11394,42 @@ fn text_wrap_options_of_type_and_envelopes_are_read_back() {
same_wraps(&wraps(top), &[(Some("Bent".into()), 2.5, true)]);
}
/// The wrap shapes that `doc`'s first area type on its first layer takes from the objects above it,
/// in the type's own space: the bounds of each one's subpaths, and its options.
fn area_wrap_region(doc: &Document) -> Vec<(Vec<[f64; 4]>, f64, bool)> {
use vectorcraft_doc::TextKind;
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 parts = s
.path
.subpaths
.iter()
.map(|sp| {
let b = PathData::new(vec![sp.clone()]).bounds().unwrap_or_default();
[b.x0, b.y0, b.x1, b.y1]
})
.collect();
(parts, s.wrap.offset, s.wrap.invert)
})
.collect()
}
/// `before` and `after` hold as many wrap shapes, each with as many subpaths, with bounds within
/// `1e-3`, and the same options.
fn same_region(before: &[(Vec<[f64; 4]>, f64, bool)], after: &[(Vec<[f64; 4]>, f64, bool)]) -> bool {
let close =
|b: &[[f64; 4]], a: &[[f64; 4]]| b.len() == a.len() && b.iter().zip(a).all(|(b, a)| b.iter().zip(a).all(|(x, y)| (x - y).abs() < 1e-3));
before.len() == after.len() && before.iter().zip(after).all(|(b, a)| close(&b.0, &a.0) && (b.1 - a.1).abs() < 1e-6 && b.2 == a.2)
}
/// 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.
@@ -11464,6 +11520,276 @@ fn type_written_as_outlines_keeps_wrapping_by_its_bounds() {
assert_eq!(top[1].name.as_deref(), Some("Label"), "{text_out}");
}
/// A 300 × 200 document of area type "Body" filling the page, with `top` above it.
fn over_area_type(make: impl FnOnce(&mut Document) -> Node) -> Document {
use vectorcraft_doc::{TextKind, TextObject, TextRun};
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 top = make(&mut doc);
for n in [body, top] {
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
doc
}
/// Symbol "Leaf": two squares in opposite corners of the 20 × 20 square around the origin, apart.
fn two_leaves(doc: &mut Document) {
let a = square(doc, Rect::new(-10.0, -10.0, -6.0, -6.0), LEAF);
let b = square(doc, Rect::new(6.0, 6.0, 10.0, 10.0), LEAF);
let art = group(doc, vec![a, b], false);
doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(art) });
}
/// 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 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]
fn live_art_written_as_a_group_keeps_wrapping_by_its_bounds() {
let instance_of = |offset: f64, invert: bool, visible: bool, locked: bool| {
move |doc: &mut Document| {
two_leaves(doc);
let mut n = instance(doc, "Leaf", Affine::new([3.0, 0.0, 0.0, 3.0, 150.0, 100.0]));
n.isolate = true;
(n.visible, n.locked) = (visible, locked);
wrapped(named(n, "Thing"), offset, invert)
}
};
let blend = |doc: &mut Document| {
let keys = vec![
Arc::new(square(doc, Rect::new(60.0, 80.0, 70.0, 90.0), Color::gray(0.5))),
Arc::new(square(doc, Rect::new(220.0, 110.0, 230.0, 120.0), Color::gray(0.5))),
];
let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Distance(60.0), ..Default::default() };
wrapped(named(Node::new(doc.alloc_id(), NodeKind::Blend { children: keys, spec: Box::new(spec) }), "Thing"), 4.5, false)
};
let intertwine = |doc: &mut Document| {
let a = Arc::new(square(doc, Rect::new(60.0, 60.0, 80.0, 80.0), Color::gray(0.5)));
let b = Arc::new(square(doc, Rect::new(200.0, 120.0, 220.0, 140.0), Color::gray(0.5)));
wrapped(named(Node::new(doc.alloc_id(), NodeKind::Intertwine { children: vec![a, b] }), "Thing"), 2.0, true)
};
let repeat = |doc: &mut Document| {
let a = Arc::new(square(doc, Rect::new(100.0, 80.0, 110.0, 90.0), Color::gray(0.5)));
let spec = vectorcraft_doc::RepeatSpec::grid(vec![a], 8.0, 12.0);
wrapped(named(Node::new(doc.alloc_id(), NodeKind::Repeat(spec)), "Thing"), 1.5, false)
};
// A Fidelity that isn't a whole number: written as its distorted art.
let envelope = |doc: &mut Document| {
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions};
let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] };
wrapped(named(envelope_node(doc, kind, 50.5, false, EnvelopeOptions::NEW), "Thing"), -3.0, true)
};
let docs = [
("instance", over_area_type(instance_of(4.5, false, true, false)), 4.5, false, true, false),
("instance, inverted", over_area_type(instance_of(-2.0, true, true, false)), -2.0, true, true, false),
("instance, hidden and locked", over_area_type(instance_of(3.0, false, false, true)), 3.0, false, false, true),
("blend", over_area_type(blend), 4.5, false, true, false),
("intertwine", over_area_type(intertwine), 2.0, true, true, false),
("repeat", over_area_type(repeat), 1.5, false, true, false),
("envelope", over_area_type(envelope), -3.0, true, true, false),
];
for (name, doc, offset, invert, visible, locked) in docs {
let thing = &doc.layers[0].children().unwrap()[1];
let want = thing.geometric_bounds().unwrap();
let text_out = program(&doc);
let back = read_back(&doc);
let (before, after) = (area_wrap_region(&doc), area_wrap_region(&back));
assert_eq!(before.len(), usize::from(visible), "{name}");
assert!(same_region(&before, &after), "{name}\n{before:?}\n{after:?}\n{text_out}");
assert_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{name}: {text_out}");
let top = back.layers[0].children().unwrap();
same_wraps(&wraps(top), &[(None, offset, invert)]);
let g = &top[1];
assert!(matches!(g.kind, NodeKind::Group { clip: false, .. }), "{name}: {text_out}");
assert_eq!(
(g.name.as_deref(), g.extras().wrap, g.visible, g.locked, g.isolate),
(Some("Thing"), None, visible, locked, thing.isolate),
"{name}"
);
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:?}");
}
// An envelope written live opens live and wraps by its bounds, its options its own.
let live = |doc: &mut Document| {
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions};
let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] };
wrapped(named(envelope_node(doc, kind, 50.0, false, EnvelopeOptions::NEW), "Thing"), -3.0, true)
};
let doc = over_area_type(live);
let text_out = program(&doc);
let back = read_back(&doc);
let (before, after) = (area_wrap_region(&doc), area_wrap_region(&back));
assert_eq!(before.len(), 1);
assert!(same_region(&before, &after), "{before:?}\n{after:?}\n{text_out}");
let top = back.layers[0].children().unwrap();
assert!(matches!(top[1].kind, NodeKind::Envelope { .. }), "{text_out}");
same_wraps(&wraps(top), &[(Some("Thing".into()), -3.0, true)]);
}
/// The rectangle that keeps a blend written as its art wrapping by its bounds counts toward the
/// 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_leave_area_type_in_the_art_unwrapped() {
use vectorcraft_doc::{TextKind, TextObject, TextRun};
// 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))),
Arc::new(square(&mut doc, Rect::new(220.0, 110.0, 230.0, 120.0), Color::gray(0.5))),
];
let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Distance(60.0), ..Default::default() };
let thing = wrapped(named(Node::new(doc.alloc_id(), NodeKind::Blend { children: keys, spec: Box::new(spec) }), "Thing"), 4.5, false);
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, thing).unwrap();
let steps = export_ai_editing(&doc, &opts()).unwrap();
let steps = crate::ai_alone(&steps.program).unwrap().0.layers[0].children().unwrap()[0].children().unwrap().len() - 1;
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_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{text_out}");
let (back, _) = crate::ai_alone(&out.program).unwrap();
let top = back.layers[0].children().unwrap();
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| {
move |doc: &mut Document| {
let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "", style(2.0, Color::BLACK));
t.runs = vec![TextRun::new(words, style(2.0, Color::BLACK))];
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(-10.0, -10.0, -6.0, -6.0)) };
t.xf = Affine::IDENTITY;
let mut t = text_node(doc, t);
t.visible = shown;
if in_hidden {
t = group(doc, vec![t], false);
t.visible = false;
}
let s = square(doc, Rect::new(6.0, 6.0, 10.0, 10.0), LEAF);
let art = group(doc, vec![t, s], false);
doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(art) });
let mut n = instance(doc, "Leaf", Affine::new([3.0, 0.0, 0.0, 3.0, 150.0, 100.0]));
n.isolate = true;
wrapped(named(n, "Thing"), 4.5, false)
}
};
// Type of spaces alone is written as its outlines, which are not type.
for (what, shown, words, in_hidden) in
[("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 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}");
}
}
/// A blend with no keys, written as an empty group, has no bounds for a rectangle: its group keeps
/// the text wrap options, and they read back.
#[test]
fn live_art_without_bounds_keeps_its_text_wrap_on_the_group() {
let mut doc = Document::new(300.0, 200.0);
let spec = vectorcraft_doc::BlendSpec::default();
let thing = wrapped(named(Node::new(doc.alloc_id(), NodeKind::Blend { children: vec![], spec: Box::new(spec) }), "Thing"), 4.5, true);
assert_eq!(thing.geometric_bounds(), None);
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, thing).unwrap();
let text_out = program(&doc);
assert_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{text_out}");
let back = read_back(&doc);
same_wraps(&wraps(back.layers[0].children().unwrap()), &[(Some("Thing".into()), 4.5, true)]);
}
/// 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,
/// 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};
use vectorcraft_doc::{TextKind, TextObject, TextRun};
let doc = over_area_type(|doc| {
let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] };
let mut envelope = envelope_node(doc, kind, 50.0, false, EnvelopeOptions::NEW);
let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "", style(10.0, Color::BLACK));
t.runs = vec![TextRun::new("bent", style(10.0, Color::BLACK))];
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(60.0, 30.0, 140.0, 70.0)) };
t.xf = Affine::IDENTITY;
let t = text_node(doc, t);
let NodeKind::Envelope { content, .. } = &mut envelope.kind else { unreachable_envelope() };
content.push(Arc::new(t));
let s = square(doc, Rect::new(250.0, 150.0, 260.0, 160.0), LEAF);
let art = group(doc, vec![envelope, s], false);
doc.symbols.push(Symbol { dynamic: false, name: "Bent".into(), art: Arc::new(art) });
let mut n = instance(doc, "Bent", Affine::IDENTITY);
n.isolate = true;
wrapped(named(n, "Thing"), 4.5, false)
});
let text_out = program(&doc);
let back = read_back(&doc);
let (before, after) = (area_wrap_region(&doc), area_wrap_region(&back));
assert_eq!(before.len(), 1);
assert!(same_region(&before, &after), "{before:?}\n{after:?}\n{text_out}");
let g = &back.layers[0].children().unwrap()[1];
assert_eq!(g.extras().wrap, None, "{text_out}");
let mut live = 0;
g.walk(&mut |m| live += usize::from(matches!(m.kind, NodeKind::Envelope { .. })));
assert_eq!(live, 0, "{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,
/// 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.
+43 -13
View File
@@ -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`]).
@@ -773,6 +774,8 @@ impl<'a> Writer<'a> {
if !self.open_group("u") {
return;
}
let state = self.state;
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;
@@ -817,12 +820,13 @@ impl<'a> Writer<'a> {
self.warn(SYMBOL_LIMIT);
}
}
self.wrap_moved = moved;
self.end_group("U", xy);
}
NodeKind::Envelope { .. } if self.envelope(n, xy) => {}
NodeKind::Envelope { .. } => self.drawn_art(n, xy, Some(envelopes::ENVELOPES)),
NodeKind::Envelope { .. } => self.drawn_art(n, xy, Some(envelopes::ENVELOPES), true),
NodeKind::Blend { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) | NodeKind::Intertwine { .. } => {
self.drawn_art(n, xy, Some(LIVE));
self.drawn_art(n, xy, Some(LIVE), true);
}
// Ruler guides are kept in the document's native copy only.
NodeKind::RulerGuide { .. } => {}
@@ -889,13 +893,17 @@ impl<'a> Writer<'a> {
}
/// Live object `n`, of transparency `xy`, as a group of its art, with `warning` when it is given.
fn drawn_art(&mut self, n: &Node, xy: Xy, warning: Option<&str>) {
/// 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") {
return;
}
let state = self.state;
if let Some(w) = warning {
self.warn(w);
}
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);
self.nest += 1;
@@ -906,9 +914,29 @@ impl<'a> Writer<'a> {
self.drawn -= 1;
self.nest -= 1;
}
self.wrap_moved = moved;
self.end_group("U", xy);
}
/// 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 false;
}
self.wrap_bounds(n, w);
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`]),
/// is bound to variables ([`Self::bound_variables`]; without a name, it gets an XML id and an
/// empty `AIArtName`), has text wrap options ([`Self::text_wrap`]), has an opacity mask
@@ -1555,23 +1583,20 @@ 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;
}
// Type wraps by its bounds.
self.wrap_moved = self.wrap_on_bounds(n, state);
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
/// An unpainted closed rectangle at the geometric bounds of type, a symbol instance or a live
/// object `n`, the region its text wraps around ([`vectorcraft_doc::text::resolve_wrap`]), 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 };
let Some(b) = finite_bounds(n) else { return };
self.state(n.locked, !n.visible);
let objects = self.objects;
self.plain_object(&shapes::rectangle(b), FillRule::NonZero, None, None, Xy::PLAIN);
@@ -1760,6 +1785,11 @@ impl<'a> Writer<'a> {
}
}
/// `n`'s geometric bounds, when they are finite.
fn finite_bounds(n: &Node) -> Option<vectorcraft_geom::Rect> {
n.geometric_bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()))
}
/// The gradient of paint `p`, when it has one.
fn gradient(p: Option<&Paint>) -> Option<&vectorcraft_color::GradientPaint> {
match p {
@@ -411,9 +411,10 @@ impl Writer<'_> {
self.hidden -= 1;
self.nest -= 1;
// The distorted art, which takes the envelope's lock, visibility, transparency, name and
// opacity mask.
// opacity mask. The reader opens the envelope live, wrapping type by its bounds, so its text
// wrap options stay in the art's dictionary.
self.state(n.locked, !n.visible);
self.drawn_art(n, xy, None);
self.drawn_art(n, xy, None, false);
if !opened || outlines || self.left_out_since(charges) {
let lines = self.body.split_off(start);
self.give_back(charges, &lines);
+1
View File
@@ -1801,6 +1801,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 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`).