Merge pull request #7319 from dotCooCoo/fix/ai-wrap-options-saved

.ai save: the editing copy keeps text wrap Offset and Invert Wrap
This commit is contained in:
BFlat
2026-11-14 07:33:50 +07:00
committed by GitHub
5 changed files with 281 additions and 10 deletions
+230
View File
@@ -9793,3 +9793,233 @@ fn envelopes_document() -> Document {
}
doc
}
// ---------- text wrap ----------
/// `n` with text wrap options `offset` and `invert`.
fn wrapped(mut n: Node, offset: f64, invert: bool) -> Node {
n.extras_mut().wrap = Some(vectorcraft_doc::TextWrap { offset, invert });
n
}
/// The text wrap options of `nodes` and of the objects they hold, in order, each with its object's
/// name.
fn wraps(nodes: &[Arc<Node>]) -> Vec<(Option<String>, f64, bool)> {
let mut out = vec![];
for n in nodes {
n.walk(&mut |m| {
if let Some(w) = m.extras().wrap {
out.push((m.name.clone(), w.offset, w.invert));
}
});
}
out
}
/// `got` and `want` name the same objects with the same Invert Wrap, and offsets within `1e-6`.
fn same_wraps(got: &[(Option<String>, f64, bool)], want: &[(Option<String>, f64, bool)]) {
let close = got.len() == want.len() && got.iter().zip(want).all(|(g, w)| g.0 == w.0 && g.2 == w.2 && (g.1 - w.1).abs() < 1e-6);
assert!(close, "{got:?}\n{want:?}");
}
/// The dictionary entries of text wrap options `offset` (as written) and `invert`.
fn wrap_entries(offset: &str, invert: bool) -> String {
format!("%_{offset} /Real (AI11TextWrapOffset) ,\n%_{} /Bool (AI11TextWrapInvert) ,\n", u8::from(invert))
}
/// Text wrap options are written in each object's dictionary and read back: a positive and a
/// negative offset, Invert Wrap on and off, the default options, an object without them, a named
/// object with an opacity mask, a group, a compound path and a clipping group's clipping path. An
/// offset that isn't finite is written as the default 6 pt, and one past the -1000 to 1000 pt the
/// reader keeps as the nearer end of that range.
#[test]
fn text_wrap_options_are_written_and_read_back() {
let mut doc = Document::new(400.0, 200.0);
let layer = doc.layers[0].id;
let plain = |doc: &mut Document, x: f64| square(doc, Rect::new(x, 10.0, x + 20.0, 30.0), Color::gray(0.5));
let a = wrapped(plain(&mut doc, 10.0), 12.5, false);
let b = wrapped(plain(&mut doc, 40.0), -4.25, true);
let c = wrapped(plain(&mut doc, 70.0), 6.0, false);
let d = plain(&mut doc, 100.0);
let mut e = named(wrapped(plain(&mut doc, 130.0), 3.0, true), "Badge");
let mask_art = plain(&mut doc, 130.0);
e.extras_mut().mask = Some(Box::new(OpacityMask::new(mask_art, false)));
let members = vec![plain(&mut doc, 160.0), plain(&mut doc, 190.0)];
let f = wrapped(group(&mut doc, members, false), -2.0, false);
let outer = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(220.0, 10.0, 260.0, 50.0)), Appearance::default());
let inner = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(230.0, 20.0, 250.0, 40.0)), Appearance::default());
let mut ring = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::EvenOdd });
ring.appearance = Appearance::basic(solid(Color::gray(0.5)), Paint::None, 0.0).into();
let g = wrapped(ring, 7.0, true);
let clip = wrapped(clipping(&mut doc, shapes::rectangle(Rect::new(280.0, 10.0, 320.0, 50.0))), 5.0, false);
let clipped = plain(&mut doc, 290.0);
let h = group(&mut doc, vec![clip, clipped], true);
for n in [a, b, c, d, e, f, g, h] {
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
assert_eq!(doc.layers[0].children().unwrap()[2].extras().wrap, Some(vectorcraft_doc::TextWrap::default()));
let text = program(&doc);
for entries in
[wrap_entries("12.5", false), wrap_entries("-4.25", true), wrap_entries("6", false), wrap_entries("-2", false), wrap_entries("7", true)]
{
assert!(text.contains(&entries), "{entries}\n{text}");
}
assert_eq!(text.matches("(AI11TextWrapOffset)").count(), 7, "{text}");
assert!(!text.contains("AI11TextWrapPath"), "{text}");
let back = read_back(&doc);
same_wraps(&wraps(back.layers[0].children().unwrap()), &wraps(doc.layers[0].children().unwrap()));
let kept: Vec<_> = back.layers[0].children().unwrap().iter().map(|n| (n.extras().wrap, n.extras().mask.is_some())).collect();
let want: Vec<_> = doc.layers[0].children().unwrap().iter().map(|n| (n.extras().wrap, n.extras().mask.is_some())).collect();
assert_eq!(kept, want);
// An offset that isn't finite, and offsets past the range the reader keeps.
let mut doc = Document::new(200.0, 100.0);
let odd = wrapped(plain(&mut doc, 10.0), f64::NAN, true);
let far = wrapped(plain(&mut doc, 40.0), 1e300, false);
let far_in = wrapped(plain(&mut doc, 70.0), -1000.5, false);
let layer = doc.layers[0].id;
for n in [odd, far, far_in] {
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
let text = program(&doc);
for entries in [wrap_entries("6", true), wrap_entries("1000", false), wrap_entries("-1000", false)] {
assert!(text.contains(&entries), "{entries}\n{text}");
}
same_wraps(&wraps(read_back(&doc).layers[0].children().unwrap()), &[(None, 6.0, true), (None, 1000.0, false), (None, -1000.0, false)]);
}
/// The art drawn for an object (a blend's steps, a stained symbol instance's art) is written
/// without the text wrap options of the objects it is made from, which wrap no type; the object
/// keeps its own. A symbol's art and a live instance keep theirs.
#[test]
fn drawn_art_keeps_only_its_objects_text_wrap() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let keys = vec![
Arc::new(wrapped(square(&mut doc, Rect::new(10.0, 10.0, 12.0, 12.0), Color::gray(0.5)), 2.0, true)),
Arc::new(wrapped(square(&mut doc, Rect::new(30.0, 10.0, 32.0, 12.0), Color::gray(0.5)), 2.0, true)),
];
let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Distance(10.0), ..Default::default() };
let blend = wrapped(Node::new(doc.alloc_id(), NodeKind::Blend { children: keys, spec: Box::new(spec) }), 8.0, false);
let art = wrapped(square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF), 4.0, true);
doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(art) });
let mut stained = instance(&mut doc, "Leaf", Affine::translate((50.0, 10.0)));
stained.appearance = Appearance::basic(solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into();
let live = wrapped(instance(&mut doc, "Leaf", Affine::translate((80.0, 10.0))), -3.0, false);
for n in [blend, stained, live] {
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
let back = read_back(&doc);
same_wraps(&wraps(back.layers[0].children().unwrap()), &[(None, 8.0, false), (None, -3.0, false)]);
assert!(matches!(back.layers[0].children().unwrap()[2].kind, NodeKind::SymbolInstance { .. }));
same_wraps(&wraps(&[back.symbols[0].art.clone()]), &[(None, 4.0, true)]);
// An object kept with an art style: its options go with the object, not its drawn look.
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let shadow = fx("stylize.dropShadow", serde_json::json!({"x": 6.0, "y": 4.0, "blur": 8.0}));
let shadowed = styled_rect(&mut doc, "Shadowed", Rect::new(10.0, 10.0, 60.0, 50.0), solid(Color::gray(0.5)), Paint::None, vec![shadow]);
doc.insert(Some(layer), usize::MAX, wrapped(shadowed, 9.0, true)).unwrap();
let text = program(&doc);
assert!(text.contains("(Anon 1) XW") && text.matches("(AI11TextWrapOffset)").count() == 1, "{text}");
let back = read_back(&doc);
same_wraps(&wraps(back.layers[0].children().unwrap()), &[(Some("Shadowed".into()), 9.0, true)]);
}
/// On a large canvas the offsets are written at the art's scale (1/10) and read back at their size,
/// an instance written as its art keeps its own options, and a symbol's art, defined at its own
/// size, keeps its offset as it is.
#[test]
fn text_wrap_offsets_on_a_large_canvas_come_back_at_their_size() {
let mut doc = Document::new(20_000.0, 20_000.0);
let layer = doc.layers[0].id;
let a = wrapped(square(&mut doc, Rect::new(1000.0, 1000.0, 1200.0, 1200.0), Color::gray(0.5)), -12.34, true);
let b = wrapped(square(&mut doc, Rect::new(1400.0, 1000.0, 1600.0, 1200.0), Color::gray(0.5)), 250.0, false);
let art = wrapped(square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), LEAF), 4.0, true);
doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(art) });
let one = wrapped(instance(&mut doc, "Leaf", Affine::translate((2000.0, 1000.0))), -3.0, false);
for n in [a, b, one] {
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
let text = program(&doc);
for entries in [wrap_entries("-1.234", true), wrap_entries("25", false), wrap_entries("-0.3", false), wrap_entries("4", true)] {
assert!(text.contains(&entries), "{entries}\n{text}");
}
let want = [(None, -12.34, true), (None, 250.0, false), (None, -3.0, false)];
let symbol = [(None, 4.0, true)];
// From the editing data alone.
let back = read_back(&doc);
same_wraps(&wraps(back.layers[0].children().unwrap()), &want);
same_wraps(&wraps(&[back.symbols[0].art.clone()]), &symbol);
// Beside a page of the artboard's size in points.
let (p, _) = private_data(&doc, &opts()).unwrap();
let (read, notes, layers) = crate::layered_ai(&p.data, Document::new(20_000.0, 20_000.0), vec![], false, true, 1.389);
assert!(layers, "{notes:?}");
same_wraps(&wraps(read.layers[0].children().unwrap()), &want);
same_wraps(&wraps(&[read.symbols[0].art.clone()]), &symbol);
}
/// Type and envelopes keep their own text wrap options: point type, each frame of threaded area
/// type (one with options, one without, in either order) and an envelope kept live.
#[test]
fn text_wrap_options_of_type_and_envelopes_are_read_back() {
use vectorcraft_doc::{TextKind, TextObject, TextRun};
for first_wraps in [true, false] {
let mut doc = Document::new(300.0, 100.0);
let layer = doc.layers[0].id;
let st = style(10.0, Color::BLACK);
let mut first = TextObject::point(vectorcraft_geom::Point::ZERO, "", st.clone());
first.runs = vec![TextRun::new("One two three ", st.clone())];
first.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 40.0)) };
first.xf = Affine::IDENTITY;
let mut second = first.clone();
second.runs = vec![TextRun::new("four five", st.clone())];
second.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(110.0, 10.0, 190.0, 40.0)) };
let (mut first, mut second) = (named(text_node(&mut doc, first), "First"), named(text_node(&mut doc, second), "Second"));
let frame = if first_wraps { &mut first } else { &mut second };
frame.extras_mut().wrap = Some(vectorcraft_doc::TextWrap { offset: 3.5, invert: true });
doc.text_threads.push(vec![first.id, second.id]);
let label = TextObject::point(vectorcraft_geom::Point::new(210.0, 30.0), "Label", st);
let label = wrapped(named(text_node(&mut doc, label), "Label"), -1.5, false);
for n in [first, second, label] {
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
let back = read_back(&doc);
assert_eq!(back.text_threads.len(), 1, "the frames stay type");
let frame = if first_wraps { "First" } else { "Second" };
same_wraps(&wraps(back.layers[0].children().unwrap()), &[(Some(frame.into()), 3.5, true), (Some("Label".into()), -1.5, false)]);
}
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let kinds = live_envelope_kinds();
let (_, arc) = kinds.iter().find(|(n, _)| *n == "arc").unwrap();
let bent = wrapped(envelope_node(&mut doc, arc.clone(), 50.0, false, vectorcraft_doc::live::EnvelopeOptions::NEW), 2.5, true);
doc.insert(Some(layer), usize::MAX, bent).unwrap();
let back = read_back(&doc);
let top = back.layers[0].children().unwrap();
assert!(matches!(top[0].kind, NodeKind::Envelope { .. }));
same_wraps(&wraps(top), &[(Some("Bent".into()), 2.5, true)]);
}
/// 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.
#[test]
fn a_clipping_path_written_as_its_paint_keeps_one_text_wrap() {
for fill in [solid(Color::gray(0.5)), Paint::None] {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let mut cutter = clipping(&mut doc, shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)));
cutter.appearance = Appearance::basic(fill, solid(Color::BLACK), 2.0).into();
let cutter = wrapped(cutter, 5.0, true);
let member = square(&mut doc, Rect::new(30.0, 30.0, 120.0, 60.0), Color::rgb(1.0, 0.0, 0.0));
let inner = group(&mut doc, vec![cutter, member], true);
let frame = square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), Color::gray(0.9));
let frame = group(&mut doc, vec![frame], false);
let outer = group(&mut doc, vec![frame, inner], true);
doc.insert(Some(layer), usize::MAX, outer).unwrap();
let text = program(&doc);
assert_eq!(text.matches("(AI11TextWrapOffset)").count(), 1, "{text}");
same_wraps(&wraps(read_back(&doc).layers[0].children().unwrap()), &[(None, 5.0, true)]);
}
}
+44 -6
View File
@@ -7,7 +7,7 @@ use std::sync::Arc;
use vectorcraft_color::cms::Cms;
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};
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_pathops::BoolOp;
@@ -263,6 +263,10 @@ pub(super) struct Writer<'a> {
/// An object's drawn look is being written: no object in it is written with a style, and its
/// effects are the style's.
in_look: bool,
/// How many parts of art drawn for an object (a live object's art, a symbol instance's art, 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 brushes written ([`brushes`]).
brush_set: brushes::Brushes,
/// Brushes may be written; false on a large canvas, where they are left out.
@@ -329,6 +333,7 @@ impl<'a> Writer<'a> {
styled: HashMap::new(),
styles: styles::Styles::default(),
in_look: false,
drawn: 0,
brush_set: brushes::Brushes::default(),
brushes_live: true,
deform: false,
@@ -700,7 +705,9 @@ impl<'a> Writer<'a> {
art.locked = false;
art.name = None;
self.nest += 1;
self.drawn += 1;
self.node(&art);
self.drawn -= 1;
self.nest -= 1;
} else {
self.warn(SYMBOL_LIMIT);
@@ -744,9 +751,11 @@ impl<'a> Writer<'a> {
if self.nest < MAX_NEST {
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
self.nest += 1;
self.drawn += 1;
for c in g.children().map(Vec::as_slice).unwrap_or_default() {
self.node(c);
}
self.drawn -= 1;
self.nest -= 1;
}
self.end_group("U", xy);
@@ -754,10 +763,12 @@ impl<'a> Writer<'a> {
/// `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 an opacity mask (`AI9_Mask`, see [`Self::mask`]), or is a symbol's
/// art (its symbol's entries first, see [`Self::define_symbol`]).
/// empty `AIArtName`), has text wrap options ([`Self::text_wrap`]), has an opacity mask
/// (`AI9_Mask`, see [`Self::mask`]), or is a symbol's art (its symbol's entries first, see
/// [`Self::define_symbol`]).
fn dictionary(&mut self, n: &Node) {
let name = n.name.as_deref().filter(|s| !s.is_empty());
let wrap = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look);
let placed = std::mem::take(&mut self.placed);
let typed = std::mem::take(&mut self.typed);
let bound = self.bound_variables(n, placed, typed);
@@ -771,7 +782,7 @@ impl<'a> Writer<'a> {
Some((id, _)) if *id == n.id => self.root_entries.take().map(|(_, e)| e),
_ => None,
};
if name.is_none() && mask.is_none() && bound.is_empty() && entries.is_none() {
if name.is_none() && mask.is_none() && bound.is_empty() && entries.is_none() && wrap.is_none() {
return;
}
let start = self.body.len();
@@ -792,17 +803,32 @@ impl<'a> Writer<'a> {
self.bound.push((n.id, id, bound));
}
}
if let Some(w) = wrap {
self.text_wrap(w);
}
// A mask whose art doesn't fit is left out, and with nothing else in it the dictionary too.
if let Some(m) = mask
&& !self.mask(m)
&& !has_id
&& !has_entries
&& wrap.is_none()
{
return self.body.truncate(start);
}
self.body.push_str("%_;\n%_\n");
}
/// Text wrap options, as entries of their object's dictionary: the offset in art space's points
/// (`AI11TextWrapOffset`, `/Real`; a value that isn't finite is written as the default 6 pt,
/// and one past the -1000 to 1000 pt the reader keeps as the nearer end of that range) and
/// Invert Wrap (`AI11TextWrapInvert`, `/Bool`, 1 or 0), as the reader reads them back.
fn text_wrap(&mut self, w: TextWrap) {
let offset = if w.offset.is_finite() { w.offset.clamp(-1000.0, 1000.0) } else { TextWrap::default().offset };
self.body.push_str("%_");
ps::push_num(&mut self.body, offset);
let _ = writeln!(self.body, " /Real (AI11TextWrapOffset) ,\n%_{} /Bool (AI11TextWrapInvert) ,", u8::from(w.invert));
}
/// An `/ArtDictionary` whose `AI10_ArtUID` is `name` as an XML id.
fn xml_id(&mut self, name: &str) {
let id = self.take_id(name);
@@ -1006,12 +1032,18 @@ impl<'a> Writer<'a> {
}
/// A container's members, the first one's outline cutting the others: its fills painted
/// behind them (cut to the region outside too), its strokes over them, not cut by it.
/// behind them (cut to the region outside too), its strokes over them, not cut by it. Its text
/// wrap options are written once: with its fills, or without fills with its strokes.
fn clip_contents(&mut self, children: &[Arc<Node>]) {
let Some((first, rest)) = children.split_first() else { return };
// Nothing to clip by hides the clipped art.
let Some((outline, rule)) = vectorcraft_effects::clip_outline(first) else { return };
let paint = first.clip_paint();
let mut paint = first.clip_paint();
if let (Some(_), Some(stroke)) = (&paint.fill, &mut paint.stroke)
&& stroke.extras().wrap.is_some()
{
stroke.extras_mut().wrap = None;
}
let outer = self.clip.clone();
let region = self.cut(&PathData::from_bezpath(&outline), rule);
self.clip = Some(region);
@@ -1101,9 +1133,11 @@ impl<'a> Writer<'a> {
return true;
}
self.nest += 1;
self.drawn += 1;
for piece in &art {
self.node(piece);
}
self.drawn -= 1;
if let Some((locked, hidden)) = state {
self.state(locked, hidden);
}
@@ -1148,9 +1182,11 @@ impl<'a> Writer<'a> {
let region = self.cut(path, rule);
self.clip = Some(region);
self.nest += 1;
self.drawn += 1;
for t in &tiles {
self.node(t);
}
self.drawn -= 1;
if let Some((locked, hidden)) = state {
self.state(locked, hidden);
}
@@ -1343,7 +1379,9 @@ impl<'a> Writer<'a> {
art_node.transform(t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol), true);
(art_node.visible, art_node.locked, art_node.name) = (true, false, None);
self.nest += 1;
self.drawn += 1;
self.node(&art_node);
self.drawn -= 1;
self.nest -= 1;
}
}
+5 -3
View File
@@ -204,8 +204,10 @@ impl Writer<'_> {
if sym.dynamic || doc.symbol_export_type(name) != SymbolExportType::default() || doc.symbol_nine_slice(name).is_some() {
self.warn(SYMBOL_OPTIONS);
}
// The art's effects are its own, not those of a drawn look that first places it.
// The art's effects are its own, not those of a drawn look that first places it, and its
// text wrap options are its own, not left out with the drawn art that first places it.
let in_look = std::mem::take(&mut self.in_look);
let drawn = std::mem::take(&mut self.drawn);
self.defining_symbols.push(name.to_string());
// The objects of this definition and of those it defines first.
let before = self.charges();
@@ -222,7 +224,7 @@ impl Writer<'_> {
self.defining_symbols.pop();
self.keep_since(before);
self.warn(SYMBOL_COUNT);
self.in_look = in_look;
(self.in_look, self.drawn) = (in_look, drawn);
return false;
}
// The group that holds the art is an object of the definition ([`Self::take_objects`]);
@@ -308,7 +310,7 @@ impl Writer<'_> {
(self.wrote_fixed, self.inner_scale),
) = saved;
self.doc = page_doc;
self.in_look = in_look;
(self.in_look, self.drawn) = (in_look, drawn);
let art = std::mem::replace(&mut self.body, art);
self.defining_symbols.pop();
if self.left_out_since(art_start) {
+1 -1
View File
@@ -22,7 +22,7 @@ Ordinary File > Save offers Save As when its own location refuses writes with a
| | Illustrator | VectorCraft | Notes |
|---|---:|---:|---|
| Formats opened or placed | ~30 | 31 | missing: FreeHand, CorelDRAW, DWG, PS |
| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, symbols, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) |
| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, symbols, artboards, transparency, opacity masks, clipping, text wrap options) or as legacy version 7 (layers and groups, one artboard per file) |
| Formats exported | ~16 | 18 | missing: DWG; extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD |
| **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting |
+1
View File
@@ -1624,6 +1624,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The same holds for the objects of an opacity mask's art, a mask inside another mask's art and a hidden art root included, so a mask whose art has Offset Path opens drawing the image it masks as it did, with the effect live; geometry effects that no art style holds on a mask's art, such as Roughen, are written applied to the art, which opens drawing what it drew (`a_masked_image_with_live_offset_path_round_trips`, `hidden_and_nested_mask_art_keep_live_offset_path` and `a_masks_art_with_an_effect_no_style_holds_is_written_as_it_draws` in `crates/eps/src/ai_editing/tests.rs`).
A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines).
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`).
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`).