.ai save: live art written as a group keeps wrapping type 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, wrapped
area type below it by its bounds before saving but by the members of
that art after reopening, because the group carried the text wrap
options. The group now ends with an unpainted rectangle at the object's
bounds that carries the options, as type written as outlines does, and
the group keeps the object's name, transparency, lock and visibility
without options of its own. Art holding shown area type written as
type keeps the options on its group; area type in an envelope does not
count. An object without finite bounds keeps the options on its group.
Without room for the rectangle within the object limit, the options are
left out with the limit's warning.
This commit is contained in:
dotCooCoo
2026-11-15 00:46:34 -08:00
parent df984b2a02
commit 5de563faf1
4 changed files with 407 additions and 16 deletions
+317 -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);
@@ -11085,6 +11105,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.
@@ -11175,6 +11231,266 @@ 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 last 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 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();
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; 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.
#[test]
fn wrap_bounds_of_live_art_fit_the_object_limit_and_skip_art_with_area_type() {
use vectorcraft_doc::{TextKind, TextObject, TextRun};
// A blend alone, without a symbol definition, so the rectangle is the one object left out.
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!(!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[0].name.as_deref(), Some("Thing"), "{text_out}");
assert_eq!(top[0].children().map(Vec::len), Some(steps), "only the art: {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)
}
};
// 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.
for (what, shown, words, in_hidden) in
[("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);
let back = read_back(&doc);
let (before, after) = (area_wrap_region(&doc), area_wrap_region(&back));
assert!(same_region(&before, &after), "{what}\n{before:?}\n{after:?}\n{text_out}");
let g = &back.layers[0].children().unwrap()[1];
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 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
/// 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,
/// 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.
#[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 last = g.children().unwrap().last().unwrap();
assert!(matches!(last.kind, NodeKind::Path { guide: false, .. }) && last.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.
+81 -13
View File
@@ -308,6 +308,11 @@ pub(super) struct Writer<'a> {
series_tag: Option<String>,
/// Where the `Go` lines of the graph being written start in [`Self::body`].
graph_tags: Vec<usize>,
/// 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<u32>,
}
impl<'a> Writer<'a> {
@@ -385,6 +390,8 @@ impl<'a> Writer<'a> {
in_graph: false,
series_tag: None,
graph_tags: vec![],
unshown: 0,
area_written: vec![],
}
}
@@ -658,8 +665,17 @@ impl<'a> Writer<'a> {
}
}
/// One object: its state before it, its transparency, and its name and opacity mask after it.
/// 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.
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, &[]);
@@ -754,6 +770,8 @@ impl<'a> Writer<'a> {
if !self.open_group("u") {
return;
}
let state = self.state;
let mut area_type = false;
// 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;
@@ -789,21 +807,26 @@ 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.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 { .. } => {}
@@ -870,15 +893,20 @@ 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 last
/// 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 mut area_type = false;
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() {
@@ -886,10 +914,45 @@ 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.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 };
if finite_bounds(n).is_none() {
return;
}
self.wrap_bounds(n, w);
self.wrap_moved = true;
if let Some((locked, hidden)) = state {
self.state(locked, hidden);
}
}
/// `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
@@ -1470,6 +1533,9 @@ 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);
}
@@ -1536,23 +1602,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_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);
@@ -1741,6 +1804,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 {
@@ -374,6 +374,7 @@ 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.
@@ -411,9 +412,14 @@ 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);
// 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);
+1
View File
@@ -1772,6 +1772,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`).
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`).