From ba567301238412764caaaecf3bf44e732d2b62aa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 17:34:27 -0800 Subject: [PATCH] .ai editing data: opacity mask art in symbols and pattern tiles keeps live effects Objects in an opacity mask's art inside a symbol definition or a pattern tile are now written with their art styles, as mask art on the layers already is. A symbol or pattern whose mask art has Offset Path opens with the effect live in its definition, and its instances and pattern fills draw as before. Each definition is written with the styled objects of its own masks only, so art placed as copies inside it is not matched to another definition's original. - Mask art linked to a graphic style opens linked to it: the reader's style linking now goes into opacity masks' art. - Links in pattern tiles count toward the lost-link warning, the warning is checked after unused symbols and swatch-only patterns are written, and links in art left out at the object limit are no longer counted as kept. --- crates/eps/src/ai_editing.rs | 11 +- crates/eps/src/ai_editing/tests.rs | 289 ++++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 33 ++- crates/eps/src/ai_editing/writer/paints.rs | 12 +- crates/eps/src/ai_editing/writer/styles.rs | 42 ++- crates/eps/src/ai_editing/writer/symbols.rs | 3 + crates/eps/src/import/ai/read/document.rs | 10 +- docs/file-format-parity.md | 6 +- docs/target-app-parity.md | 1 + 9 files changed, 382 insertions(+), 25 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 3df902b97f..f6196165c2 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -270,6 +270,7 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re // The objects that may keep their effects in an art style, as they are before baking (not on // a large canvas, whose scale the styles' strokes and effects would need). let styled = writer::styled_objects(doc); + let (symbol_styled, pattern_styled) = writer::styled_definitions(doc); let own_styles = doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles; let links = writer::style_links(doc); // Live geometry effects as their result, as the PDF part draws them. @@ -307,16 +308,14 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re w.reserve_swatch_gradients(); if scale == 1.0 { w.styled = styled; + (w.symbol_styled, w.pattern_styled) = (symbol_styled, pattern_styled); w.panel_styles(); - } else if !styled.is_empty() || own_styles || links > 0 { + } else if !styled.is_empty() || !symbol_styled.is_empty() || !pattern_styled.is_empty() || own_styles || links > 0 { w.warnings.push(writer::LARGE_CANVAS_STYLES.to_string()); } for layer in &doc.layers { w.layer(layer); } - if scale == 1.0 { - w.lost_links(links); - } // The symbols no instance uses too; their art adds definitions. w.symbols(); // The brushes, once the layers have said whether an object is written with one. @@ -324,6 +323,10 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re // The palette first: its gradient and pattern swatches add definitions. let mut palette = String::new(); w.palette(&mut palette); + // Once every definition is written: their opacity masks' art keeps links too. + if scale == 1.0 { + w.lost_links(links); + } let library = w.variable_library(); let first = to_art.transform_rect_bbox(board); let art = doc diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a01f631638..7bbf761927 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -6751,6 +6751,159 @@ fn hidden_and_nested_mask_art_keep_live_offset_path() { } } +/// An image 60 × 40 points at (10, 10) whose opacity mask, of `art`, doesn't clip. +fn masked_image_node(doc: &mut Document, art: Node) -> Node { + let im = image_node(doc, ImageBlob::png(rgb_png()), (3, 2), Affine::new([60.0, 0.0, 0.0, 40.0, 10.0, 10.0]), None); + with_mask(im, art) +} + +/// A red square from (10, 10) to (70, 50) whose opacity mask, of `art`, doesn't clip. +fn masked_square(doc: &mut Document, art: Node) -> Node { + let sq = square(doc, Rect::new(10.0, 10.0, 70.0, 50.0), Color::rgb(1.0, 0.0, 0.0)); + with_mask(sq, art) +} + +/// `n` with an opacity mask of `art` that doesn't clip. +fn with_mask(mut n: Node, art: Node) -> Node { + n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: false, invert: false, disabled: false, linked: true }.into()); + n +} + +/// The opacity mask art of the first object with one in `n`, its masks' art included. +fn mask_art_in(n: &Node) -> Option> { + let mut art = None; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { + if art.is_none() + && let Some(mask) = m.extras().mask.as_ref() + { + art = Some(mask.art.clone()); + } + }); + art +} + +/// The Offset Path distance of `n`'s one live effect, when that is all it has. +fn offset_path_of(n: &Node) -> Option { + match n.appearance.effects.as_slice() { + [e] if e.id == "path.offsetPath" => e.params.get("offset").and_then(serde_json::Value::as_f64), + _ => None, + } +} + +/// The opacity mask art of an image in a symbol's art keeps its live effects as an art style: a +/// black path with Offset Path reads back with the effect live on the definition's mask art, when +/// the mask's art root is hidden and when the symbol is used inside another symbol's art too, and +/// both instances draw as they did. +#[test] +fn a_symbols_mask_art_keeps_live_offset_path() { + let offset = fx("path.offsetPath", serde_json::json!({"offset": 6.0})); + let made = |effects: Vec, case: &str| { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut grown = styled_rect(&mut doc, "Grown", Rect::new(25.0, 20.0, 45.0, 40.0), solid(Color::BLACK), Paint::None, effects); + grown.visible = case != "hidden"; + let im = masked_image_node(&mut doc, grown); + doc.symbols.push(Symbol { dynamic: false, name: "Picture".into(), art: Arc::new(im) }); + let mut used = "Picture"; + if case == "nested" { + let inner = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Picture".into(), xf: Affine::translate((5.0, 0.0)) }); + let dot = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::rgb(1.0, 0.0, 0.0)); + let frame = group(&mut doc, vec![inner, dot], false); + doc.symbols.push(Symbol { dynamic: false, name: "Frame".into(), art: Arc::new(frame) }); + used = "Frame"; + } + for x in [0.0, 100.0] { + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: used.into(), xf: Affine::translate((x, 20.0)) }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + } + doc + }; + for case in ["shown", "hidden", "nested"] { + let doc = made(vec![offset.clone()], case); + assert!(pixels_apart(&doc, &made(vec![], case)) > 0, "{case}: the effect draws nothing"); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{case}: {:?}", out.warnings); + let back = read_back(&doc); + let picture = back.symbols.iter().find(|s| s.name == "Picture").unwrap_or_else(|| panic!("{case}: {:?}", back.symbols)); + let art = mask_art_in(&picture.art).unwrap_or_else(|| panic!("{case}: no masked image in {:?}", picture.art.kind)); + assert_eq!(offset_path_of(&art), Some(6.0), "{case}: {:?}", art.appearance.effects); + assert_eq!(pixels_apart(&doc, &back), 0, "{case}"); + } +} + +/// The opacity mask art of a square in a pattern's tile keeps its live effects as an art style: the +/// tile's black path with Offset Path reads back with the effect live, and the pattern fill draws as +/// it did. +#[test] +fn a_pattern_tiles_mask_art_keeps_live_offset_path() { + let offset = fx("path.offsetPath", serde_json::json!({"offset": 6.0})); + let made = |effects: Vec| { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let grown = styled_rect(&mut doc, "Grown", Rect::new(25.0, 20.0, 45.0, 40.0), solid(Color::BLACK), Paint::None, effects); + let sq = masked_square(&mut doc, grown); + let tile = Rect::new(0.0, 0.0, 80.0, 60.0); + doc.patterns.push(vectorcraft_doc::PatternDef { tile, ..vectorcraft_doc::PatternDef::new("Masked", vec![Arc::new(sq)]) }); + let fill = Paint::Pattern { pattern: "Masked".into(), xf: Affine::IDENTITY }; + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 160.0, 60.0)), Appearance::basic(fill, Paint::None, 0.0)); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + doc + }; + let doc = made(vec![offset]); + assert!(pixels_apart(&doc, &made(vec![])) > 0, "the effect draws nothing"); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + let def = back.pattern("Masked").unwrap_or_else(|| panic!("{:?}", back.patterns.iter().map(|p| &p.name).collect::>())); + let art = def.art.iter().find_map(|a| mask_art_in(a)).expect("a masked image in the tile"); + assert_eq!(offset_path_of(&art), Some(6.0), "{:?}", art.appearance.effects); + assert_eq!(pixels_apart(&doc, &back), 0); +} + +/// Each opacity mask's art keeps its own art style: masks on the layers, in a symbol's art and in a +/// pattern's tile with different Offset Path distances read back with their own distances, and a +/// symbol's mask art with an effect no art style holds (Roughen) reads back without a live effect. +#[test] +fn mask_art_styles_stay_with_their_own_art() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let masked = |doc: &mut Document, effect: vectorcraft_doc::Effect| { + let grown = styled_rect(doc, "Grown", Rect::new(25.0, 20.0, 45.0, 40.0), solid(Color::BLACK), Paint::None, vec![effect]); + masked_image_node(doc, grown) + }; + let by = |d: f64| fx("path.offsetPath", serde_json::json!({"offset": d})); + let on_layer = masked(&mut doc, by(3.0)); + doc.insert(Some(layer), usize::MAX, on_layer).unwrap(); + let picture = masked(&mut doc, by(6.0)); + doc.symbols.push(Symbol { dynamic: false, name: "Picture".into(), art: Arc::new(picture) }); + let rough = masked(&mut doc, fx("distort.roughen", serde_json::json!({"size": 6.0, "relative": false, "points": "corner"}))); + doc.symbols.push(Symbol { dynamic: false, name: "Rough".into(), art: Arc::new(rough) }); + for (symbol, x) in [("Picture", 100.0), ("Rough", 120.0)] { + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf: Affine::translate((x, 40.0)) }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + } + let grown = styled_rect(&mut doc, "Grown", Rect::new(25.0, 20.0, 45.0, 40.0), solid(Color::BLACK), Paint::None, vec![by(9.0)]); + let tile = masked_square(&mut doc, grown); + doc.patterns.push(vectorcraft_doc::PatternDef { + tile: Rect::new(0.0, 0.0, 80.0, 60.0), + ..vectorcraft_doc::PatternDef::new("Masked", vec![Arc::new(tile)]) + }); + let fill = Paint::Pattern { pattern: "Masked".into(), xf: Affine::IDENTITY }; + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 60.0, 80.0, 100.0)), Appearance::basic(fill, Paint::None, 0.0)); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let back = read_back(&doc); + let symbol_art = |name: &str| { + let s = back.symbols.iter().find(|s| s.name == name).unwrap_or_else(|| panic!("{name}: {:?}", back.symbols)); + mask_art_in(&s.art).unwrap_or_else(|| panic!("{name}: no masked image")) + }; + assert_eq!(offset_path_of(&image_mask_art(&back)), Some(3.0)); + assert_eq!(offset_path_of(&symbol_art("Picture")), Some(6.0)); + let tile = back.pattern("Masked").and_then(|p| p.art.iter().find_map(|a| mask_art_in(a))).expect("a masked image in the tile"); + assert_eq!(offset_path_of(&tile), Some(9.0)); + let rough = symbol_art("Rough"); + assert!(rough.appearance.effects.is_empty(), "{:?}", rough.appearance.effects); +} + /// 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. @@ -6890,6 +7043,142 @@ fn a_filled_even_odd_object_is_written_as_its_drawn_look() { assert_eq!(n.extras().graphic_style, None); } +/// Links to graphic styles in pattern tiles count toward the warning about lost links: a tile's +/// object linked to a style, which is written without its link, warns; and so does an object on the +/// layers that loses its link while a tile's opacity mask art keeps its own. +#[test] +fn pattern_tile_style_links_count_toward_lost_links() { + let made = |linked_tile: bool| { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(25.0, 20.0, 45.0, 40.0)), ap.clone()); + linked.extras_mut().graphic_style = Some(7); + let art = if linked_tile { + linked + } else { + // An even-odd path is written without its link (see the test above). + let mut lost = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(100.0, 10.0, 140.0, 40.0)), ap); + lost.extras_mut().graphic_style = Some(7); + if let NodeKind::Path { rule, .. } = &mut lost.kind { + *rule = FillRule::EvenOdd; + } + put(&mut doc, lost); + masked_square(&mut doc, linked) + }; + doc.patterns.push(vectorcraft_doc::PatternDef { + tile: Rect::new(0.0, 0.0, 80.0, 60.0), + ..vectorcraft_doc::PatternDef::new("Linked", vec![Arc::new(art)]) + }); + let fill = Paint::Pattern { pattern: "Linked".into(), xf: Affine::IDENTITY }; + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 50.0, 80.0, 100.0)), Appearance::basic(fill, Paint::None, 0.0)); + put(&mut doc, filled); + doc + }; + for linked_tile in [true, false] { + let doc = made(linked_tile); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{linked_tile}: {:?}", out.warnings); + } + let back = read_back(&made(false)); + let tile = back.pattern("Linked").and_then(|p| p.art.iter().find_map(|a| mask_art_in(a))).expect("a masked square in the tile"); + assert!(tile.extras().graphic_style.is_some(), "the tile's mask art keeps its link"); +} + +/// An opacity mask's art linked to a graphic style reads back linked to it. +#[test] +fn opacity_mask_art_keeps_its_graphic_style_link() { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(25.0, 20.0, 45.0, 40.0)), ap); + linked.extras_mut().graphic_style = Some(7); + let sq = masked_square(&mut doc, linked); + put(&mut doc, sq); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + let art = mask_art_in(&back.layers[0]).expect("a masked square"); + let id = art.extras().graphic_style.expect("the mask's art keeps its link"); + assert_eq!(back.graphic_style_by_id(id).map(|g| g.name.as_str()), Some("Solid")); +} + +/// A link written in a pattern tile's opacity mask art that the object limit then leaves out with +/// the rest of the tile is not counted as kept: at every limit where the style isn't written, the +/// export warns that the link is lost. +#[test] +fn mask_art_links_left_out_at_the_object_limit_are_reported_lost() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(25.0, 20.0, 45.0, 40.0)), ap); + linked.extras_mut().graphic_style = Some(7); + let mut art = vec![Arc::new(masked_square(&mut doc, linked))]; + for i in 0..6 { + let x = 2.0 + 12.0 * f64::from(i); + art.push(Arc::new(square(&mut doc, Rect::new(x, 52.0, x + 8.0, 58.0), Color::rgb(0.0, 0.0, 1.0)))); + } + doc.patterns.push(vectorcraft_doc::PatternDef { tile: Rect::new(0.0, 0.0, 80.0, 60.0), ..vectorcraft_doc::PatternDef::new("Linked", art) }); + let fill = Paint::Pattern { pattern: "Linked".into(), xf: Affine::IDENTITY }; + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 60.0)), Appearance::basic(fill, Paint::None, 0.0)); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let mut left_out = 0; + for max in 1..40 { + let out = export_within(&doc, &opts(), max).unwrap(); + let text = String::from_utf8_lossy(&out.program).into_owned(); + if !text.contains("(Solid) XW") { + left_out += 1; + assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{max}: {:?}", out.warnings); + } + } + assert!(left_out > 0 && left_out < 39, "{left_out}"); +} + +/// The links of opacity masks' art in a symbol no instance uses and in a pattern only the Swatches +/// panel holds are written with those definitions, after the layers, and count as kept: no warning +/// about lost links. +#[test] +fn mask_art_links_in_unused_definitions_are_not_reported_lost() { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let linked = |doc: &mut Document| { + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(25.0, 20.0, 45.0, 40.0)), ap.clone()); + n.extras_mut().graphic_style = Some(7); + masked_square(doc, n) + }; + let unused = linked(&mut doc); + doc.symbols.push(Symbol { dynamic: false, name: "Unused".into(), art: Arc::new(unused) }); + let tile = linked(&mut doc); + doc.patterns.push(vectorcraft_doc::PatternDef { + tile: Rect::new(0.0, 0.0, 80.0, 60.0), + ..vectorcraft_doc::PatternDef::new("Swatch only", vec![Arc::new(tile)]) + }); + let swatch = Swatch { + name: "Swatch only".into(), + paint: Paint::Pattern { pattern: "Swatch only".into(), xf: Affine::IDENTITY }, + global: false, + spot: false, + }; + swatches_plus(&mut doc, vec![swatch]); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + let symbol = back.symbols.iter().find(|s| s.name == "Unused").and_then(|s| mask_art_in(&s.art)).expect("the unused symbol's mask art"); + assert!(symbol.extras().graphic_style.is_some(), "the symbol's mask art keeps its link"); + let tile = back.pattern("Swatch only").and_then(|p| p.art.iter().find_map(|a| mask_art_in(a))).expect("the swatch's mask art"); + assert!(tile.extras().graphic_style.is_some(), "the pattern's mask art keeps its link"); +} + /// A compound path filled by the non-zero rule keeps its effects, its gradient fill and its link to /// a graphic style. #[test] diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 98b027f79d..8df30c69bc 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -28,7 +28,7 @@ mod variables; pub(super) use brushes::BRUSHES; #[cfg(test)] pub(super) use envelopes::ENVELOPES; -pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects}; +pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_definitions, styled_objects}; #[cfg(test)] pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, WARPED_IMAGES, same_appearance}; @@ -133,6 +133,10 @@ struct Charges { kept_objects: usize, kept_symbol_nodes: u64, left_out: usize, + /// The objects written with the graphic style they are linked to, and those of them in the + /// definitions written. + links: usize, + kept_links: usize, } pub(super) struct Writer<'a> { @@ -171,6 +175,8 @@ pub(super) struct Writer<'a> { definition_objects: usize, /// The objects of symbol art in those definitions ([`Self::symbol_nodes`]). definition_symbol_nodes: u64, + /// The objects in those definitions written with the graphic style they are linked to. + definition_links: usize, /// The most objects written ([`Self::take_objects`]): the most the reader reads, /// [`crate::import::MAX_EDITING_OBJECTS`]. pub(super) max_objects: usize, @@ -262,6 +268,12 @@ pub(super) struct Writer<'a> { /// The objects that may be written with an art style, as they were before baking, by id /// ([`styles::styled_objects`]). pub(super) styled: HashMap>, + /// The objects of the opacity masks' art in each symbol's art and each pattern's tile art that + /// may be written with an art style, by the symbol's or pattern's name + /// ([`styles::styled_definitions`]). [`Self::define_symbol`] and [`Self::define_pattern`] write + /// a definition's art with its own as [`Self::styled`]. + pub(super) symbol_styled: HashMap, + pub(super) pattern_styled: HashMap, /// The art styles written ([`styles`]). styles: styles::Styles, /// An object's drawn look is being written: no object in it is written with a style, and its @@ -350,6 +362,8 @@ impl<'a> Writer<'a> { symbol_scales: HashMap::new(), root_entries: None, styled: HashMap::new(), + symbol_styled: HashMap::new(), + pattern_styled: HashMap::new(), styles: styles::Styles::default(), in_look: false, drawn: 0, @@ -361,6 +375,7 @@ impl<'a> Writer<'a> { objects: 0, left_out: 0, definition_objects: 0, + definition_links: 0, definition_symbol_nodes: 0, max_objects: crate::import::MAX_EDITING_OBJECTS, last_samples: None, @@ -416,6 +431,8 @@ impl<'a> Writer<'a> { kept_objects: self.definition_objects, kept_symbol_nodes: self.definition_symbol_nodes, left_out: self.left_out, + links: self.styles.links_written, + kept_links: self.definition_links, } } @@ -429,15 +446,18 @@ impl<'a> Writer<'a> { fn keep_since(&mut self, c: Charges) { self.definition_objects = c.kept_objects + (self.objects - c.objects); self.definition_symbol_nodes = c.kept_symbol_nodes + (self.symbol_nodes - c.symbol_nodes); + self.definition_links = c.kept_links + (self.styles.links_written - c.links); } - /// Art written since `c` as `lines` is not used: the objects and symbol art charged since are - /// given back, except those of the definitions written since, and the images `lines` marks + /// Art written since `c` as `lines` is not used: the objects and symbol art charged since, and + /// the objects counted as written with their graphic style, are given back, except those of the + /// definitions written since, and the images `lines` marks /// are emptied (the others keep their indices). An image whose whole copy is among them is no /// longer written whole ([`Self::whole_images`]). fn give_back(&mut self, c: Charges, lines: &str) { self.objects = c.objects + (self.definition_objects - c.kept_objects); self.symbol_nodes = c.symbol_nodes + (self.definition_symbol_nodes - c.kept_symbol_nodes); + self.styles.links_written = c.links + (self.definition_links - c.kept_links); let mut emptied = HashSet::new(); for index in lines.split(PAYLOAD).skip(1).step_by(2) { if let Some((i, p)) = index.parse::().ok().and_then(|i| Some((i, self.payloads.get_mut(i)?))) { @@ -655,12 +675,11 @@ impl<'a> Writer<'a> { if n.extras().graph.is_some() && !self.in_look { self.warn(GRAPHS); } - // 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. + // On the layers or in an opacity mask's art (a symbol's and a pattern tile's included), + // 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.mask_level == Some((self.hidden, self.nest))) + && ((self.hidden == 0 && self.nest == 0 && !self.in_symbol) || self.mask_level == Some((self.hidden, self.nest))) && self.clip.is_none() - && !self.in_symbol && let Some(orig) = self.styled.get(&n.id).cloned() && self.check_left(&orig) && let Some(name) = self.object_style(n, &orig) diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index d0a90ce86a..e2702b5714 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -483,6 +483,8 @@ impl Writer<'_> { self.gs.replace(Xy::OPAQUE), (self.clip.take(), std::mem::take(&mut self.shown)), self.block_end, + // The tile's masks' objects are written with their art styles, those of no other art. + (std::mem::replace(&mut self.styled, self.pattern_styled.get(name).cloned().unwrap_or_default()), self.mask_level.take()), ); // `Bg` places a gradient in a pattern's tile as on the canvas, mirrored top to bottom: its // placement is worked out in the tile's space turned y up. @@ -505,7 +507,15 @@ impl Writer<'_> { self.hidden -= 1; self.nest -= 1; let art; - (art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, (self.clip, self.shown), self.block_end) = saved; + ( + art, + (self.to_art, self.grad_space, self.canvas), + self.state, + self.gs, + (self.clip, self.shown), + self.block_end, + (self.styled, self.mask_level), + ) = saved; let art = std::mem::replace(&mut self.body, art); self.defining.pop(); if self.left_out_since(before) { diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index a8a2da677c..50aa4df931 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -26,7 +26,8 @@ pub(in crate::ai_editing) const PANEL_EMPTY: &str = pub(in crate::ai_editing) const WARPED_IMAGES: &str = "images with a Warp after the first 256 envelopes and images with a Warp are written as their drawn look, without their Warp, in the editing data for other apps"; pub(in crate::ai_editing) const STYLE_LINKS: &str = "links to graphic styles are left out of the editing data for other apps on type, images, symbol instances, knockout shapes and other objects that aren't written with their graphic style"; -/// How many objects of `doc`'s layers, opacity masks and symbols are linked to a graphic style. +/// How many objects of `doc`'s layers, opacity masks, symbols and pattern tiles are linked to a +/// graphic style. pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { fn count(n: &Node) -> usize { // Ruler guides are not written as objects. @@ -37,7 +38,9 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { let mask = n.extras().mask.as_ref().map_or(0, |m| count(&m.art)); own + mask + n.children().map_or(0, |c| c.iter().map(|m| count(m)).sum()) } - doc.layers.iter().map(|l| count(l)).sum::() + doc.symbols.iter().map(|s| count(&s.art)).sum::() + doc.layers.iter().map(|l| count(l)).sum::() + + doc.symbols.iter().map(|s| count(&s.art)).sum::() + + doc.patterns.iter().flat_map(|p| p.art.iter()).map(|a| count(a)).sum::() } /// The objects of `doc`'s layers and of their opacity masks' art that may be written with an art @@ -48,10 +51,30 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { /// Knockout Shape (the reader takes an object's transparency /// from its look, which doesn't carry it). A group's members are paths, compound paths or such /// groups, none with effects or anything baking changes. -pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap> { - fn walk(nodes: &[Arc], out: &mut HashMap>) { +pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> StyledMap { + styled_in(&doc.layers, true) +} + +/// The objects that may be written with an art style, by id, as they are before baking. +pub(in crate::ai_editing) type StyledMap = HashMap>; + +/// The objects of the opacity masks' art in each of `doc`'s symbols' art, by symbol name, and in +/// each of its patterns' tile art, by pattern name, that may be written with an art style, as +/// [`styled_objects`] finds them on the layers. A definition's own objects outside its masks' art +/// are not among them, and definitions without such objects are left out. +pub(in crate::ai_editing) fn styled_definitions(doc: &Document) -> (HashMap, HashMap) { + let symbols = doc.symbols.iter().map(|s| (s.name.clone(), styled_in(std::slice::from_ref(&s.art), false))); + let patterns = doc.patterns.iter().map(|p| (p.name.clone(), styled_in(&p.art, false))); + let kept = |(_, m): &(String, StyledMap)| !m.is_empty(); + (symbols.filter(kept).collect(), patterns.filter(kept).collect()) +} + +/// The objects of `nodes` that may be written with an art style ([`styled_objects`]): with `own`, +/// `nodes` and their members as well as their opacity masks' art, else their masks' art only. +fn styled_in(nodes: &[Arc], own: bool) -> StyledMap { + fn walk(nodes: &[Arc], own: bool, out: &mut StyledMap) { for n in nodes { - if candidate(n) { + if own && candidate(n) { out.insert(n.id, n.clone()); } if let Some(m) = n.extras().mask.as_ref() { @@ -59,7 +82,7 @@ pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap HashMap { let doc = self.doc; // A new document's styles are what the reader gives a file without a style list; they are // still written when an object is linked to one of them, so that it keeps its link. - let linked = self.styled.values().any(|n| n.extras().graphic_style.is_some()); + let definitions = self.symbol_styled.values().chain(self.pattern_styled.values()).flat_map(HashMap::values); + let linked = self.styled.values().chain(definitions).any(|n| n.extras().graphic_style.is_some()); if !linked && doc.graphic_styles == Document::new(1.0, 1.0).graphic_styles { return; } diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs index 0c8d2c7d3b..4d4bb2acba 100644 --- a/crates/eps/src/ai_editing/writer/symbols.rs +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -252,6 +252,8 @@ impl Writer<'_> { self.root_entries.take(), std::mem::take(&mut self.wrote_pattern), (std::mem::take(&mut self.wrote_fixed), std::mem::replace(&mut self.inner_scale, 1.0)), + // The art's masks' objects are written with their art styles, those of no other art. + (std::mem::replace(&mut self.styled, self.symbol_styled.get(name).cloned().unwrap_or_default()), self.mask_level.take()), ); // Gradients' matrices and instances' matrices are written about the canvas point of art // space's origin, y up; `Bg` places a gradient on the art as it lies in the symbol's space. @@ -309,6 +311,7 @@ impl Writer<'_> { self.root_entries, self.wrote_pattern, (self.wrote_fixed, self.inner_scale), + (self.styled, self.mask_level), ) = saved; self.doc = page_doc; (self.in_look, self.drawn) = (in_look, drawn); diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index ba6a8bd2de..df51a99393 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1393,8 +1393,9 @@ impl<'a> Reader<'a> { } /// Link the objects of `links` among `nodes` to their graphic style's id, each only when it has its -/// style's appearance and transparency (`styles`, whose ids are 1, 2, … in order). A path, which -/// has no contents, takes the style's contents slot. +/// style's appearance and transparency (`styles`, whose ids are 1, 2, … in order), their members +/// and their opacity masks' art included. A path, which has no contents, takes the style's contents +/// slot. fn link_styles(nodes: &mut [Arc], links: &HashMap, styles: &[GraphicStyle], depth: usize) { if depth > MAX_DEPTH { return; @@ -1413,6 +1414,11 @@ fn link_styles(nodes: &mut [Arc], links: &HashMap, styles: &[ n.extras_mut().graphic_style = Some(*id); } } + if n.extras().mask.is_some() + && let Some(m) = Arc::make_mut(n).extras_mut().mask.as_mut() + { + link_styles(std::slice::from_mut(&mut m.art), links, styles, depth + 1); + } if n.children().is_some_and(|c| !c.is_empty()) && let Some(c) = Arc::make_mut(n).children_mut() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index bf52925f5c..76b51a1ced 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -106,7 +106,9 @@ private editing data. VectorCraft: Round Corners, Scribble, Convert to Shape, Offset Path, Outline Object or Outline Stroke, and groups with Pathfinder › Add. Groups keep them when their members are paths, compound paths or such groups, without effects of their own; when the group's effects change its members' outlines, a member's strokes are plain lines, and its fills are not pattern - fills written as tiles or gradients that fit their object. The look holds the object's vector art, without the shadow, glow or feather. Other objects with + fills written as tiles or gradients that fit their object. The look holds the object's vector art, without the shadow, glow or feather. + This holds for objects on the layers and in opacity masks' art, the masks' art in a symbol's art and in a pattern's tile + included; a symbol's or a pattern's other objects are written as described next. Other objects with live effects are written as their vector art with their geometry effects applied, and their other effects, such as Drop Shadow, the glows and Feather, are left out with a warning. A path, compound path or group linked to a graphic style names it when it has the style's appearance and the style is written; other links to graphic styles, those on a styled group's members included, are @@ -270,7 +272,7 @@ The app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri A run of type in the editing data whose baseline direction is 1 opens with Standard Vertical Roman Alignment (https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) on, which is VectorCraft's own mapping: its half-width characters stand upright in vertical type, and horizontal type keeps the mark with no change to its layout. A run whose baseline direction is 2 or 3 (Tate-chu-yoko), or that names none in the run, its character style or the document's defaults, opens with it off. The scripting reference lists 1, 2 and 3 as the Standard, vertical rotated and Tate-chu-yoko baseline directions (https://web.archive.org/web/20170621180804/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2015_3/Illustrator%20VBScript%20Reference.pdf, page 256). A Scatter brush in the editing data whose last 15 numbers start with 1 comes into the Brushes panel in Tints. Each brush art section is read without its frame: an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt), closed, or open with its first corner again at the end. A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group. -An object in a pattern's art or in a brush's art takes the file's art styles in the same way as an object on a layer: with a style VectorCraft reads, it comes in as itself with its effects live, not as the look it draws. An object in a pattern's art with a style in the file's style list keeps that graphic style's id. An art styles section inside a symbol's, a pattern's or a brush's art is skipped. +An object in a pattern's art or in a brush's art takes the file's art styles in the same way as an object on a layer: with a style VectorCraft reads, it comes in as itself with its effects live, not as the look it draws. An object in a pattern's art with a style in the file's style list keeps that graphic style's id, as does an object in an opacity mask's art. An art styles section inside a symbol's, a pattern's or a brush's art is skipped. Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets) are read as Windows-1252 unless `legacyNameEncoding` (`convert --legacy-name-encoding`) picks `macintosh`, `windows-949`, `shift_jis`, `gbk` or `big5`; the open result's warnings list the first three such names as read and count the rest. The text values of data sets are read in the same encoding. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cad5851e98..d55e45e52c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1740,6 +1740,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 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 envelopes and images with a Warp 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`). + Opacity masks in a symbol's art and in a pattern's tile are written the same way, so a symbol or pattern whose mask art has Offset Path opens with the effect live in its definition, and its instances and pattern fills draw as they did; each definition's mask art keeps its own style, and mask art linked to a graphic style opens still linked to it (`a_symbols_mask_art_keeps_live_offset_path`, `a_pattern_tiles_mask_art_keeps_live_offset_path`, `mask_art_styles_stay_with_their_own_art` and `opacity_mask_art_keeps_its_graphic_style_link` 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`).