.ai export: opacity mask art keeps its live effects in the editing data

An object in an opacity mask's art is written with its art style, as on
the layers, so a mask whose art has Offset Path opens from the editing
data with the effect live and draws the masked object as it did. This
covers a hidden art root and masks inside another mask's art.

Geometry effects on mask art that no art style holds, such as Roughen,
are applied to the art written, in the masks of layers, symbols and
pattern tiles. Before, the mask's art was written without them.
This commit is contained in:
dotCooCoo
2026-11-13 13:08:24 -08:00
parent c9b2119552
commit 185ba12b8c
5 changed files with 268 additions and 13 deletions
+103 -2
View File
@@ -186,6 +186,7 @@
//! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with
//! the version numbers of the newest files users sent ([`private_data`]).
use std::collections::HashSet;
use std::fmt::Write;
use std::sync::Arc;
@@ -385,9 +386,15 @@ fn splice(text: &[u8], payloads: &[Vec<u8>]) -> Vec<u8> {
}
/// `doc` with its geometry effects and group paint baked ([`vectorcraft_effects::bake_document`]),
/// in its symbols' art and its patterns' tile art too (which instances and pattern fills are
/// written as); `None` when nothing changes.
/// in its symbols' art, its patterns' tile art (which instances and pattern fills are written as)
/// and its opacity masks' art too ([`baked_masks`]); `None` when nothing changes.
fn baked(doc: &Document) -> Option<Document> {
let art = baked_art(doc);
baked_masks(art.as_ref().unwrap_or(doc)).or(art)
}
/// [`baked`] for the layers, symbols and patterns, leaving opacity masks' art as it is.
fn baked_art(doc: &Document) -> Option<Document> {
let baked = vectorcraft_effects::bake_document(doc);
let d = baked.as_ref().unwrap_or(doc);
let needs = vectorcraft_effects::needs_bake;
@@ -416,6 +423,100 @@ fn baked(doc: &Document) -> Option<Document> {
Some(out)
}
/// `doc` with the art of the opacity masks in its layers', symbols' and patterns' art baked as
/// [`baked_art`] bakes the layers. Each round bakes the art of the outermost masks whose art needs
/// it, and the next round the masks inside that art, at most [`writer::MAX_NEST`] rounds (the
/// writer leaves masks nested deeper out). Baked art keeps its id, by which the writer finds the
/// art as it was before baking ([`writer::styled_objects`]). `None` when no mask's art needs baking.
fn baked_masks(doc: &Document) -> Option<Document> {
let mut out: Option<Document> = None;
// The masks' art baked so far, by id: it isn't baked again.
let mut done = HashSet::new();
for _ in 0..writer::MAX_NEST {
let d = out.as_ref().unwrap_or(doc);
let roots = || d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.patterns.iter().flat_map(|p| p.art.iter()));
let mut arts = vec![];
for n in roots() {
mask_arts(n, &done, &mut arts);
}
if arts.is_empty() {
break;
}
// Each art baked as a layer of a copy of the document, as [`baked_art`] bakes symbols.
let mut copy = d.clone();
copy.layers = arts.iter().map(|a| Arc::new(Node::group(vectorcraft_doc::NodeId(0), vec![a.clone()]))).collect();
let Some(mut next) = vectorcraft_effects::bake_document(&copy) else { break };
let layers = std::mem::replace(&mut next.layers, d.layers.clone());
let mut baked = layers.iter().map(|l| l.children().and_then(|c| c.first().cloned()));
for l in next.layers.iter_mut() {
if let Some(n) = with_baked_masks(l, &done, &mut baked) {
*l = n;
}
}
for s in next.symbols.iter_mut() {
if let Some(n) = with_baked_masks(&s.art, &done, &mut baked) {
s.art = n;
}
}
for p in next.patterns.iter_mut() {
for a in p.art.iter_mut() {
if let Some(n) = with_baked_masks(a, &done, &mut baked) {
*a = n;
}
}
}
done.extend(arts.iter().map(|a| a.id));
out = Some(next);
}
out
}
/// Does [`baked_masks`] bake the art of mask `m` in this round?
fn bakes_mask_art(m: &vectorcraft_doc::OpacityMask, done: &HashSet<vectorcraft_doc::NodeId>) -> bool {
!done.contains(&m.art.id) && vectorcraft_effects::needs_bake(&m.art)
}
/// The art of the opacity masks in `n` that [`baked_masks`] bakes in this round, in the order
/// [`with_baked_masks`] puts them back: a mask's art before the object's members, and the masks
/// inside art that isn't baked.
fn mask_arts(n: &Node, done: &HashSet<vectorcraft_doc::NodeId>, out: &mut Vec<Arc<Node>>) {
if let Some(m) = n.extras().mask.as_ref() {
if bakes_mask_art(m, done) {
out.push(m.art.clone());
} else {
mask_arts(&m.art, done, out);
}
}
for c in n.children().into_iter().flatten() {
mask_arts(c, done, out);
}
}
/// `n` with the art of its opacity masks taken from `baked` in the order [`mask_arts`] lists it;
/// `None` when nothing in it changes.
fn with_baked_masks(n: &Arc<Node>, done: &HashSet<vectorcraft_doc::NodeId>, baked: &mut dyn Iterator<Item = Option<Arc<Node>>>) -> Option<Arc<Node>> {
let art =
n.extras().mask.as_ref().and_then(|m| if bakes_mask_art(m, done) { baked.next().flatten() } else { with_baked_masks(&m.art, done, baked) });
let members: Vec<Option<Arc<Node>>> = n.children().map(|c| c.iter().map(|c| with_baked_masks(c, done, baked)).collect()).unwrap_or_default();
if art.is_none() && members.iter().all(Option::is_none) {
return None;
}
let mut m = (**n).clone();
if let Some(a) = art
&& let Some(mask) = m.extras_if_any().and_then(|e| e.mask.as_mut())
{
mask.art = a;
}
if let Some(children) = m.children_mut() {
for (c, new) in children.iter_mut().zip(members) {
if let Some(new) = new {
*c = new;
}
}
}
Some(Arc::new(m))
}
/// The editing data of `doc` as a `.ai` file's pages carry it: the program compressed with
/// Zstandard after `%AI24_ZStandard_Data`, its header as the `AIMetaData`, and the version numbers
/// that go with that form; and what it couldn't hold.
+139 -4
View File
@@ -6538,6 +6538,132 @@ fn effects_in_a_styled_objects_mask_are_warned_about() {
assert!(shown(&out.program).contains("\n1 (Anon 1) XW\n"));
}
/// A 200 × 100 document of an image whose opacity mask doesn't clip, of the art `art` makes.
fn masked_image(art: impl FnOnce(&mut Document) -> Node) -> Document {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let mut im = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), Affine::new([60.0, 0.0, 0.0, 40.0, 10.0, 10.0]), None);
let art = art(&mut doc);
im.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: false, invert: false, disabled: false, linked: true }.into());
doc.insert(Some(layer), usize::MAX, im).unwrap();
doc
}
/// The opacity mask art of the first image in `doc` that has one.
fn image_mask_art(doc: &Document) -> Arc<Node> {
let mut art = None;
doc.walk(|n| {
if matches!(n.kind, NodeKind::Image(_))
&& art.is_none()
&& let Some(m) = n.extras().mask.as_ref()
{
art = Some(m.art.clone());
}
});
art.expect("an image with an opacity mask")
}
/// How many of the artboard's pixels `a` and `b` draw differently.
fn pixels_apart(a: &Document, b: &Document) -> usize {
let (a, b) = (vectorcraft_testkit::raster::render_artboard(a), vectorcraft_testkit::raster::render_artboard(b));
assert_eq!((a.width, a.height), (b.width, b.height));
(0..a.height).flat_map(|y| (0..a.width).map(move |x| (x, y))).filter(|&(x, y)| a.pixel(x, y) != b.pixel(x, y)).count()
}
/// An opacity mask's art keeps its live effects as an art style: an image masked by a black path
/// with Offset Path (hiding the image under it) reads back with the effect live on the mask's path,
/// alone or as a member of a group of two, and draws as it did.
#[test]
fn a_masked_image_with_live_offset_path_round_trips() {
let offset = fx("path.offsetPath", serde_json::json!({"offset": 8.0, "joins": "miter", "miterLimit": 4.0}));
let black = solid(Color::rgb(0.0, 0.0, 0.0));
for members in [1, 2] {
let made = |effects: Vec<vectorcraft_doc::Effect>| {
masked_image(|doc| {
let grown = styled_rect(doc, "Grown", Rect::new(60.0, 30.0, 110.0, 70.0), black.clone(), Paint::None, effects);
if members == 1 {
return grown;
}
let plain = square(doc, Rect::new(130.0, 30.0, 170.0, 70.0), Color::rgb(0.0, 0.0, 0.0));
group(doc, vec![grown, plain], false)
})
};
let doc = made(vec![offset.clone()]);
assert!(pixels_apart(&doc, &made(vec![])) > 0, "{members}: the effect draws nothing");
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{members}: {:?}", out.warnings);
let back = read_back(&doc);
let art = image_mask_art(&back);
let grown = if members == 1 {
art.clone()
} else {
assert!(art.appearance.effects.is_empty(), "the group took the effect: {:?}", art.appearance.effects);
let children = art.children().expect("a group of the mask's art");
assert_eq!(children.len(), 2);
assert!(children[1].appearance.effects.is_empty());
children[0].clone()
};
assert!(matches!(grown.kind, NodeKind::Path { .. }), "{members}: {:?}", grown.kind);
let [e] = grown.appearance.effects.as_slice() else { panic!("{members}: {:?}", grown.appearance.effects) };
assert_eq!(e.id, "path.offsetPath");
assert_eq!(e.params.get("offset").and_then(serde_json::Value::as_f64), Some(8.0), "{:?}", e.params);
assert_eq!(pixels_apart(&doc, &back), 0, "{members}");
}
}
/// The art of a mask keeps Offset Path live when its root is hidden (the canvas draws it all the
/// same) and when it is the art of a mask inside another mask's art (that clips).
#[test]
fn hidden_and_nested_mask_art_keep_live_offset_path() {
let offset = fx("path.offsetPath", serde_json::json!({"offset": 6.0}));
let grown = |doc: &mut Document, effects, gray: f32| {
styled_rect(doc, "Grown", Rect::new(60.0, 30.0, 110.0, 70.0), solid(Color::rgb(gray, gray, gray)), Paint::None, effects)
};
let hidden = |effects| {
masked_image(|doc| {
let mut art = grown(doc, effects, 0.0);
art.visible = false;
art
})
};
let nested = |effects| {
masked_image(|doc| {
let mut outer = square(doc, Rect::new(20.0, 15.0, 180.0, 85.0), Color::rgb(0.0, 0.0, 0.0));
let inner = grown(doc, effects, 1.0);
outer.extras_mut().mask = Some(OpacityMask { art: Arc::new(inner), clip: true, invert: false, disabled: false, linked: true }.into());
outer
})
};
for (case, made) in [("hidden", &hidden as &dyn Fn(Vec<vectorcraft_doc::Effect>) -> Document), ("nested", &nested)] {
let doc = made(vec![offset.clone()]);
assert!(pixels_apart(&doc, &made(vec![])) > 0, "{case}: the effect draws nothing");
let back = read_back(&doc);
let mut art = image_mask_art(&back);
if case == "nested" {
art = art.extras().mask.as_ref().expect("the mask inside the mask's art").art.clone();
}
let ids: Vec<&str> = art.appearance.effects.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, ["path.offsetPath"], "{case}");
assert_eq!(pixels_apart(&doc, &back), 0, "{case}");
}
}
/// A geometry effect on an opacity mask's art that no art style holds (Roughen) is applied to the
/// art written, so the mask reads back drawing what it drew, but for pixels on its edge that the
/// written points' rounding moves.
#[test]
fn a_masks_art_with_an_effect_no_style_holds_is_written_as_it_draws() {
let rough = fx("distort.roughen", serde_json::json!({"size": 6.0, "relative": false, "points": "corner"}));
let black = solid(Color::rgb(0.0, 0.0, 0.0));
let rect = |doc: &mut Document, effects| styled_rect(doc, "Rough", Rect::new(60.0, 30.0, 140.0, 70.0), black.clone(), Paint::None, effects);
let doc = masked_image(|doc| rect(doc, vec![rough]));
assert!(pixels_apart(&doc, &masked_image(|doc| rect(doc, vec![]))) > 0, "the effect draws nothing");
let back = read_back(&doc);
assert!(image_mask_art(&back).appearance.effects.is_empty());
let apart = pixels_apart(&doc, &back);
assert!(apart <= 4, "{apart} pixels differ");
}
/// On a large canvas no art style is written (the reader scales the styles of a large canvas):
/// objects with effects are written as their vector art with their geometry effects applied, with a
/// warning.
@@ -7625,8 +7751,8 @@ fn a_style_that_isnt_written_defines_no_gradient() {
assert!(p.contains("%%BeginSetup\n0 Bn\n"), "{p}");
}
/// An object in a plain group, a sublayer or a clipping group by a path keeps its style there. One
/// in a symbol's art, in an opacity mask or in art cut to a clipping object other than a path is
/// An object in a plain group, a sublayer, a clipping group by a path or an opacity mask keeps its
/// style there. One in a symbol's art or in art cut to a clipping object other than a path is
/// written as its drawn look, with a warning.
#[test]
fn where_a_styled_object_keeps_its_style() {
@@ -7683,8 +7809,17 @@ fn where_a_styled_object_keeps_its_style() {
n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }.into());
put(&mut doc, n);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
assert!(!shown(&out.program).contains("(Anon 1) XW"));
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
let back = read_back(&doc);
let mut art = None;
back.walk(|n| {
if let Some(m) = n.extras().mask.as_ref() {
art = Some(m.art.clone());
}
});
let art = art.expect("the opacity mask");
assert_eq!(art.name.as_deref(), Some("Inside"));
assert_eq!(art.appearance.effects.len(), 1);
// In art cut to a clipping object other than a path: type.
let mut doc = Document::new(200.0, 100.0);
let t = vectorcraft_doc::TextObject::point(
+15 -5
View File
@@ -47,7 +47,7 @@ pub(super) use variables::{ACTIVE, BINDINGS, LIMITS, VALUES};
/// How deep symbols, live objects and pattern tiles may nest in one another (deeper art is left
/// out). Groups and layers need no limit of their own: a document is never deeper than
/// [`vectorcraft_doc::MAX_DEPTH`].
const MAX_NEST: u32 = 8;
pub(super) const MAX_NEST: u32 = 8;
pub(super) const TYPE: &str = "type in opacity masks, pattern tiles, symbols and art cut to a clipping object, mirrored type, area type and type on a path that is turned or unevenly scaled or whose frame or path has several parts, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation or hold a frame written as outlines, type of sizes past what the reader reads, stories past what the reader reads (1,048,576 bytes a story, 16,777,216 bytes in all, and the size of text document it reads), and type with inline graphics, ruby, gradient or pattern paint, or fills, strokes, effects or an opacity mask of the object's own, is written as its outlines in the editing data for other apps";
pub(super) const PATTERNS: &str = "pattern fills whose tiles aren't a grid or whose art reaches past the tile, pattern fills in another pattern's tile or in a symbol, and pattern fills nested more than 8 deep are written as their tiles cut to the shape in the editing data for other apps";
@@ -200,6 +200,9 @@ pub(super) struct Writer<'a> {
/// How many parts of the data written on `%_` lines (opacity masks' art, patterns' tiles) the
/// art being written is in.
hidden: u32,
/// The `hidden` and `nest` values at which the objects of the opacity mask's art being written
/// are written: objects there may be written with their art styles.
mask_level: Option<(u32, u32)>,
/// The image just written was written as a placed file.
placed: bool,
/// The type just written was written as type, not as its outlines.
@@ -289,6 +292,7 @@ impl<'a> Writer<'a> {
canvas_origin: origin,
payloads: vec![],
hidden: 0,
mask_level: None,
placed: false,
typed: false,
bound: vec![],
@@ -613,10 +617,10 @@ impl<'a> Writer<'a> {
if self.in_symbol {
self.wrote_fixed |= symbols::redrawn(n);
}
// On the layers, outside art cut to a clipping object: with its art style, when it has one.
// On the layers or in an opacity mask's art, outside art cut to a clipping object: with its
// art style, when it has one.
if !self.in_look
&& self.hidden == 0
&& self.nest == 0
&& ((self.hidden == 0 && self.nest == 0) || self.mask_level == Some((self.hidden, self.nest)))
&& self.clip.is_none()
&& !self.in_symbol
&& let Some(orig) = self.styled.get(&n.id).cloned()
@@ -843,21 +847,27 @@ impl<'a> Writer<'a> {
};
// A plain group of two or more objects, or of none, is the group the art is read into. A group
// of one object is written as a group, because the reader makes one shown object the art.
// A group that may be written with its art style is written as one object.
let plain = matches!(art.kind, NodeKind::Group { clip: false, .. })
&& Xy::of(art) == Xy::PLAIN
&& art.visible
&& !art.locked
&& art.name.as_deref().is_none_or(str::is_empty)
&& art.extras().mask.is_none();
&& art.extras().mask.is_none()
&& !self.styled.contains_key(&art.id);
let members = match art.children() {
Some(c) if plain && c.len() != 1 => c.as_slice(),
_ => std::slice::from_ref(art),
};
self.nest += 1;
self.hidden += 1;
// The art's objects (not those inside its symbols, live objects or pattern tiles, which are
// deeper) may be written with their art styles.
let level = self.mask_level.replace((self.hidden, self.nest));
for c in members {
self.node(c);
}
self.mask_level = level;
self.hidden -= 1;
self.nest -= 1;
let lines = std::mem::replace(&mut self.body, outer);
+10 -2
View File
@@ -39,8 +39,9 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize {
doc.layers.iter().map(|l| count(l)).sum::<usize>() + doc.symbols.iter().map(|s| count(&s.art)).sum::<usize>()
}
/// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]),
/// by id: paths and compound paths with points and groups, with live effects, linked to a graphic
/// The objects of `doc`'s layers and of their opacity masks' art that may be written with an art
/// style ([`Writer::styled_object`]), by id (a mask's art root shown, as [`Writer::mask`] writes
/// it): paths and compound paths with points and groups, with live effects, linked to a graphic
/// style or (paths and compound paths) with a brush on a stroke, and embedded images with one Warp
/// ([`warped_image`]), without Opacity & Mask Define
/// Knockout Shape (the reader takes an object's transparency
@@ -52,6 +53,13 @@ pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap<NodeId, A
if candidate(n) {
out.insert(n.id, n.clone());
}
if let Some(m) = n.extras().mask.as_ref() {
let mut art = m.art.clone();
if !art.visible {
Arc::make_mut(&mut art).visible = true;
}
walk(std::slice::from_ref(&art), out);
}
// A group's members are written with a style of their own only when the group is written
// without one. Baking a container with effects of its own changes its members, which
// keep their ids: they are written as baking left them.
+1
View File
@@ -1594,6 +1594,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 defines every symbol in its editing copy with its art, registration point and place in the Symbols panel's order, and writes its instances as symbol instances, which VectorCraft's `.ai` reader opens as symbols and instances again (`symbols_and_their_instances_are_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the instances written as their art).
A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps envelopes made with a warp, a mesh or a top object editable: its editing copy holds each envelope's contents, shape, Fidelity, Envelope Options and Edit Contents state in the form VectorCraft's `.ai` envelope reader reads, beside its distorted art, so the file opens with the same live envelope, Edit Contents still switching between the contents and the envelope ([Edit the contents of envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/edit-the-contents-of-envelopes.html)). Envelopes of type, envelopes in opacity masks, pattern tiles or another envelope's contents, and envelopes the reader would open with another shape or other settings (a Fidelity that isn't a whole number, bounds margins kept with Distort Appearance off), and envelopes after the first 256 in a file are written as their distorted art, with a warning (`envelopes_are_read_back_live` and `envelopes_the_reader_cannot_open_live_are_written_as_their_art` in `crates/eps/src/ai_editing/tests.rs`).
A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style when the writer supports their appearance and effects (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose).
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).
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.