diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index cf068da1ef..0048cb945f 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -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]) -> Vec { } /// `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 { + 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 { 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 { 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 { + let mut out: Option = 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(©) 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) -> 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, out: &mut Vec>) { + 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, done: &HashSet, baked: &mut dyn Iterator>>) -> Option> { + 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>> = 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. diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index d998ff8e3d..68681abf51 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -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 { + 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| { + 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) -> 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( diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 7db5d11896..ce937c9048 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -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); diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index fe3e68ce24..163d70381c 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -39,8 +39,9 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { doc.layers.iter().map(|l| count(l)).sum::() + doc.symbols.iter().map(|s| count(&s.art)).sum::() } -/// 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