From a98b8007616177d32197fe9a666942e5e7428045 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 22:31:48 -0800 Subject: [PATCH 1/6] Export: live effects in symbol art apply in PDF, EPS, print and DXF PDF, print, EPS and DXF wrote a symbol instance by placing its symbol's art as stored, so live effects inside that art were missing: a square moved 100 pt by a Transform effect stayed in place in the file while the canvas drew it moved. bake_document_placing_symbols replaces each instance of a symbol whose art (or the art of a symbol nested in it) needs baking by that art placed as the instance places it, then baked. The art is placed before its effects apply, as on the canvas, so an instance that scales its art does not scale what its effects do. A dynamic instance's own paint changes apply before baking. Nesting stops at 32 levels and the placed art at 16,000,000 objects per export; past them an instance stays an instance. DXF places 9-slice cells unclipped, as its scene does. The symbols' own art, SVG and the .ai editing data are unchanged, and HPGL already baked each placed instance. --- crates/cad/src/lib.rs | 3 +- crates/cad/src/tests.rs | 23 +++++ crates/effects/src/bake.rs | 139 ++++++++++++++++++++++++++++++- crates/effects/src/bake/tests.rs | 107 ++++++++++++++++++++++++ crates/effects/src/lib.rs | 4 +- crates/effects/src/reshape.rs | 2 +- crates/eps/src/lib.rs | 2 +- crates/eps/src/tests.rs | 17 ++++ crates/pdf/src/export/entry.rs | 5 +- crates/pdf/src/print.rs | 2 +- crates/pdf/src/tests.rs | 20 +++++ crates/pdf/src/tests/print.rs | 23 +++++ docs/target-app-parity.md | 1 + 13 files changed, 337 insertions(+), 11 deletions(-) diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 66a6a36baa..d459f95b40 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -428,7 +428,8 @@ pub fn export(doc: &Document, opts: &DxfOptions) -> Result { if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) { return Err("the export region is not finite".into()); } - let baked = vectorcraft_effects::bake_document(doc); + // Symbols' art is placed without clipping, as the scene places it. + let baked = vectorcraft_effects::bake_document_placing_symbols(doc, false); let doc = baked.as_ref().unwrap_or(doc); Ok(scene::Scene::new(doc, opts).run()) } diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 96c037a4a7..fe1e909743 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -1395,6 +1395,29 @@ fn crop_keeps_an_instance_whose_art_reaches_the_region() { assert!(!kinds(&export(&d, &o).unwrap()).is_empty()); } +#[test] +fn symbol_art_effects_are_written_where_they_draw() { + // A line and the symbol's line below it, each moved 100 pt right by a Transform effect, start + // at the same x. + let moved = |mut n: Node| { + n.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + n + }; + let mut d = doc_with(|d| { + let line = path(d, shapes::line((10.0, 10.0).into(), (20.0, 10.0).into()), Paint::None, Paint::solid(Color::BLACK), 1.0); + vec![moved(line), Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY })] + }); + let art = path(&mut d, shapes::line((10.0, 40.0).into(), (20.0, 40.0).into()), Paint::None, Paint::solid(Color::BLACK), 1.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(moved(art)) }); + let starts: Vec = entities(&export(&d, &opts(&d)).unwrap()).iter().map(|e| all(e, 10).into_iter().fold(f64::INFINITY, f64::min)).collect(); + assert_eq!(starts.len(), 2, "{starts:?}"); + assert!((starts[0] - starts[1]).abs() < 1e-6, "{starts:?}"); +} + #[test] fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() { // Five levels of 100 instances of the level below, ending in an empty symbol: 10^10 empty diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 7e42147443..d0cf98af26 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -17,7 +17,8 @@ //! object of its own, a stroke a path stroked by it alone (brushed strokes their brush art, made by //! the caller). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; use std::sync::Arc; use vectorcraft_doc::color::Paint; @@ -26,7 +27,7 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2}; use crate::distort::{transform_move, transform_offsets}; use crate::group::{has_own_paint, paints}; -use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; +use crate::reshape::{as_art, dressed, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; use crate::{ GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape, @@ -113,13 +114,105 @@ thread_local! { /// The objects of symbol art that [`bake_document`] has baked for instances inside the art of /// other instances, counted against [`crate::SYMBOL_ART_MAX_NODES`]. static NESTED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// While [`bake_document_placing_symbols`] bakes: the symbols whose instances become their + /// placed art ([`placed_symbol_art`]). + static PLACING: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + /// The objects of symbol art that [`placed_symbol_art`] has placed for one export, counted + /// against [`vectorcraft_doc::MAX_SYMBOL_NODES`]. + static PLACED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The symbols whose instances [`bake_document_placing_symbols`] replaces by their placed art, and +/// how that art is placed through 9-slice guides. +struct Placing { + /// The names of the symbols whose art draws something that baking changes, directly or through + /// the instances of other such symbols in it ([`drawn_symbols`]). + symbols: HashSet, + /// Whether 9-slice cells are clipped ([`Document::place_symbol_art`]) or not + /// ([`Document::place_symbol_art_unclipped`]). + clip_slices: bool, +} + +/// The names of the symbols of `d` whose art needs baking ([`needs_bake`]), and of those whose art +/// holds an instance of one of them at any depth. +fn drawn_symbols(d: &Document) -> HashSet { + let mut out: HashSet = d.symbols.iter().filter(|s| needs_bake(&s.art)).map(|s| s.name.clone()).collect(); + if out.is_empty() { + return out; + } + let uses: Vec<(&str, Vec)> = d + .symbols + .iter() + .filter(|s| !out.contains(&s.name)) + .map(|s| { + let mut names = vec![]; + s.art.walk(&mut |c| { + if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { + names.push(symbol.clone()); + } + }); + (s.name.as_str(), names) + }) + .collect(); + loop { + let before = out.len(); + for (name, names) in &uses { + if !out.contains(*name) && names.iter().any(|x| out.contains(x)) { + out.insert((*name).to_string()); + } + } + if out.len() == before { + return out; + } + } +} + +/// Does `n`'s subtree hold an instance of a symbol that [`bake_document_placing_symbols`] replaces +/// by its placed art? False outside it. +fn holds_placed_symbol(n: &Node) -> bool { + fn walk(n: &Node, symbols: &HashSet) -> bool { + matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbols.contains(symbol)) + || n.children().is_some_and(|ch| ch.iter().any(|c| walk(c, symbols))) + } + PLACING.with(|p| p.borrow().as_ref().is_some_and(|p| walk(n, &p.symbols))) +} + +/// Symbol instance `n`, while [`bake_document_placing_symbols`] bakes and its symbol is one it +/// places, as the art it draws: the symbol's art with the instance's own appearance changes +/// ([`vectorcraft_doc::symbol_overrides::dynamic_art`]), placed by the instance as the exporters +/// place it, in a group with the instance's id, name, visibility, transparency, opacity mask and +/// raster effects. The art is placed before its effects are baked, as the canvas draws it, so an +/// instance that scales its art does not scale what its effects do. `None` for other objects, +/// for instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and once the art +/// placed for one export would pass [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects; those +/// instances stay as they are. +fn placed_symbol_art(d: &Document, n: &Node) -> Option { + let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; + let clip = PLACING.with(|p| p.borrow().as_ref().filter(|p| p.symbols.contains(symbol)).map(|p| p.clip_slices))?; + if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH { + return None; + } + let sym = d.symbols.iter().find(|s| s.name == *symbol)?; + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); + let most = match d.symbol_slice_map(symbol, *xf) { + Some(m) if clip => m.most_placed(&art, d), + Some(m) => m.most_placed_unclipped(&art, d), + None => art.count_with_masks() as u64, + }; + let spent = PLACED_NODES.with(|c| c.get()).saturating_add(most); + if spent > vectorcraft_doc::MAX_SYMBOL_NODES { + return None; + } + PLACED_NODES.with(|c| c.set(spent)); + let placed = if clip { d.place_symbol_art(symbol, &art, *xf) } else { d.place_symbol_art_unclipped(symbol, &art, *xf) }?; + Some(dressed(Node::group(n.id, vec![Arc::new(placed)]), n)) } /// `n` with its geometry effects evaluated (`None` = unchanged). fn bake_node(d: &mut Document, n: &Node) -> Option { // A cancelled export leaves the rest of the document as it is: the caller drops the result. // A ruler guide draws nothing and stays as it is, last in its layer. - if n.is_ruler_guide() || !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() { + if n.is_ruler_guide() || !(needs_bake(n) || holds_placed_symbol(n)) || vectorcraft_doc::cancel::is_cancelled() { return None; } // Colour adjustments: the object recoloured (inside it too), then baked as it is. @@ -176,6 +269,14 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { } return Some(baked.unwrap_or(m)); } + // Symbol instances whose art draws live effects, when the caller asks for them: the art + // placed, then baked. + if let Some(m) = placed_symbol_art(d, n) { + INSTANCE_DEPTH.with(|c| c.set(c.get() + 1)); + let baked = bake_node(d, &m); + INSTANCE_DEPTH.with(|c| c.set(c.get() - 1)); + return Some(baked.unwrap_or(m)); + } // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its // patterns: each copy an object of its own, so its gradient fits it rather than spanning // every copy and its strokes and patterns can differ from the others'. @@ -693,8 +794,38 @@ fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node, outline_strokes: /// resources of its placed documents' art added ([`Document::with_placed_art`]); `None` when /// there is nothing to do (export it as is). pub fn bake_document(doc: &Document) -> Option { + bake(doc, None) +} + +/// [`bake_document`], and every symbol instance whose symbol's art draws something baking changes +/// (live geometry effects, color adjustments and the rest [`needs_bake`] counts, in its own art or +/// in the art of the symbols it places) replaced by that art as the instance places it, baked +/// ([`placed_symbol_art`]), so the art draws as on the canvas wherever the instance is. For +/// exporters that write each instance's art in its place (PDF, print, EPS, DXF), rather than +/// writing each symbol once. `clip_slices`: whether 9-slice cells are clipped +/// ([`Document::place_symbol_art`]) or not ([`Document::place_symbol_art_unclipped`]), as the +/// exporter places other instances. The symbols' own art is left as it is. +pub fn bake_document_placing_symbols(doc: &Document, clip_slices: bool) -> Option { + bake(doc, Some(clip_slices)) +} + +/// Restores what [`bake`] changes in the thread's baking state when it ends. +struct Restore(Option>, u64); + +impl Drop for Restore { + fn drop(&mut self) { + PLACING.with(|p| *p.borrow_mut() = self.0.take()); + PLACED_NODES.with(|c| c.set(self.1)); + } +} + +/// [`bake_document`], or with `placing` [`bake_document_placing_symbols`] with that `clip_slices`. +fn bake(doc: &Document, placing: Option) -> Option { let placed = doc.with_placed_art(); - if !placed.layers.iter().any(|l| needs_bake(l)) { + let placing = placing.map(|clip_slices| Placing { symbols: drawn_symbols(&placed), clip_slices }).filter(|p| !p.symbols.is_empty()); + // A bake inside another one (of art an effect makes) places symbols only when it asks to. + let _restore = Restore(PLACING.with(|p| p.replace(placing.map(Rc::new))), PLACED_NODES.with(|c| c.replace(0))); + if !placed.layers.iter().any(|l| needs_bake(l) || holds_placed_symbol(l)) { return match placed { std::borrow::Cow::Owned(d) => Some(d), std::borrow::Cow::Borrowed(_) => None, diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 2bcc026340..72d210f6f3 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -1025,3 +1025,110 @@ fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() { // Three squares of their own, not one path spanning all three. assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]); } + +/// A 10 × 10 square at (10, 40), with id `id`, that a Transform effect moves 100 pt right. +fn moved_square(id: u64) -> Node { + let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::default_art()); + n.appearance.effects.push(fx("distort.transform", json!({"moveH": 100.0}))); + n +} + +fn instance(id: u64, symbol: &str, xf: vectorcraft_geom::Affine) -> Node { + Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: symbol.into(), xf }) +} + +fn add_symbol(d: &mut Document, name: &str, art: Node) { + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); +} + +fn bounds(d: &Document, id: u64) -> Rect { + d.node(NodeId(id)).and_then(|n| n.geometric_bounds()).unwrap_or_else(|| panic!("node {id} has bounds")) +} + +fn near_rect(a: Rect, b: Rect) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6) +} + +/// Each instance draws its symbol's art with the art's effects applied where the instance places +/// it: an instance that doubles the art's size moves it the effect's 100 pt, as the canvas draws +/// it, not 200 pt. Plain baking leaves the instances and the symbols' art as they are. +#[test] +fn placing_symbols_bakes_their_art_where_each_instance_places_it() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "S", Affine::IDENTITY)); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, instance(901, "S", Affine::translate((0.0, 100.0)) * Affine::scale(2.0))).unwrap(); + add_symbol(&mut d, "S", moved_square(800)); + assert!(bake_document(&d).is_none(), "nothing in the layers needs baking"); + let out = bake_document_placing_symbols(&d, true).unwrap(); + assert!(matches!(out.node(NodeId(900)).unwrap().kind, NodeKind::Group { .. })); + assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 40.0, 120.0, 50.0)), "{:?}", bounds(&out, 900)); + assert!(near_rect(bounds(&out, 901), Rect::new(120.0, 180.0, 140.0, 200.0)), "{:?}", bounds(&out, 901)); + assert_eq!(out.symbols[0].art.appearance.effects.len(), 1, "the symbol's art keeps its effect"); +} + +/// An instance of a symbol whose art holds an instance of another symbol with effects draws that +/// art with its effects too. +#[test] +fn placing_symbols_reaches_the_art_of_nested_instances() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); + add_symbol(&mut d, "Inner", moved_square(800)); + add_symbol(&mut d, "Outer", Node::group(NodeId(801), vec![Arc::new(instance(802, "Inner", Affine::translate((0.0, 100.0))))])); + let out = bake_document_placing_symbols(&d, true).unwrap(); + assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 140.0, 120.0, 150.0)), "{:?}", bounds(&out, 900)); +} + +/// A symbol whose art holds an instance of itself is placed until the depth limit, and the export +/// ends. +#[test] +fn placing_a_symbol_that_holds_itself_stops() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Loop", Affine::IDENTITY)); + add_symbol( + &mut d, + "Loop", + Node::group(NodeId(801), vec![Arc::new(moved_square(800)), Arc::new(instance(802, "Loop", Affine::translate((0.0, 1.0))))]), + ); + let out = bake_document_placing_symbols(&d, true).unwrap(); + let mut lefts = vec![]; + out.node(NodeId(900)).unwrap().walk(&mut |n| lefts.extend(n.path_data().and_then(|p| p.bounds()).map(|b| b.x0))); + assert!(lefts.len() > 1 && lefts.iter().all(|x| (x - 110.0).abs() < 1e-6), "{lefts:?}"); + let mut depth = 0; + let mut n = out.node(NodeId(900)).unwrap(); + while let Some(next) = n.children().and_then(|ch| ch.last()) { + n = next; + depth += 1; + } + assert!(depth <= 3 * (vectorcraft_doc::depth::MAX_DEPTH + 1), "{depth}"); +} + +/// A dynamic symbol instance's own paint for an object of the art applies before the object's +/// effects are baked: a fill whose own Transform effect makes the square a group of pieces still +/// paints in the instance's red, and its sibling keeps the symbol's paint. +#[test] +fn placing_symbols_keeps_dynamic_instances_changes() { + use vectorcraft_geom::Affine; + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let mut square = Node::path(NodeId(800), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::default_art()); + if let Some(AppearanceItem::Fill(f)) = square.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { + f.effects.push(fx("distort.transform", json!({"moveH": 100.0}))); + } + let sibling = Node::path(NodeId(801), shapes::rectangle(Rect::new(10.0, 60.0, 20.0, 70.0)), Appearance::default_art()); + let mut inst = instance(900, "D", Affine::IDENTITY); + inst.extras_mut().symbol_overrides = vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(red.clone()), ..Default::default() }]; + let mut d = doc_with(inst); + add_symbol(&mut d, "D", Node::group(NodeId(802), vec![Arc::new(square), Arc::new(sibling)])); + d.symbols[0].dynamic = true; + let out = bake_document_placing_symbols(&d, true).unwrap(); + let mut fills = vec![]; + out.node(NodeId(900)).unwrap().walk(&mut |n| { + if n.path_data().is_some() + && let Some(b) = n.geometric_bounds() + { + fills.push((b.x0.round(), b.y0.round(), n.appearance.fill_paint())); + } + }); + assert!(fills.contains(&(110.0, 40.0, red)), "{fills:?}"); + assert!(fills.contains(&(10.0, 60.0, Paint::solid(Color::WHITE))), "{fills:?}"); +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 6316a7a273..fb11a64161 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -68,7 +68,9 @@ pub use adjust::{ ADJUSTMENTS, CHANNELS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment, is_channel, }; -pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake}; +pub use bake::{ + StrokeArt, bake_appearance, bake_document, bake_document_placing_symbols, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake, +}; pub use clip::{WarpSeams, clip_outline, knockout_silhouette, warp_pieces_translucent}; pub use group::{ OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder, diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 8c829d9e0b..88df54fe5b 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -845,7 +845,7 @@ fn as_art_opts( /// `art`, the art made of `n`, with `n`'s name, visibility, transparency, opacity mask and /// remaining (raster) effects. -fn dressed(mut art: Node, n: &Node) -> Node { +pub(crate) fn dressed(mut art: Node, n: &Node) -> Node { art.name = n.name.clone(); art.visible = n.visible; art.opacity = n.opacity; diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 49b64f4bce..2dbadf4fea 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -230,7 +230,7 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb } // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); - let baked = vectorcraft_effects::bake_document(doc); + let baked = vectorcraft_effects::bake_document_placing_symbols(doc, true); let doc = baked.as_ref().unwrap_or(doc); let page = scene::Scene::new(doc, opts).run(); let mut setting_notes = vec![]; diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index c98cb9689a..22591bbe2b 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -1409,6 +1409,23 @@ fn an_instance_whose_brush_art_reaches_the_region_is_written() { assert!(page.contains("1 0 0 rg"), "{page}"); } +#[test] +fn symbol_art_effects_are_written_where_they_draw() { + // The symbol's square at (10, 40), which a Transform effect moves 100 pt right, is drawn from + // x = 110 and not from x = 10. + let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY })]); + let mut art = path(&mut d, shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(art) }); + let page = page_of(&d, &opts(&d)); + let starts: Vec = page.lines().filter(|l| l.ends_with(" m")).filter_map(|l| l.split_whitespace().next()?.parse().ok()).collect(); + assert!(!starts.is_empty() && starts.iter().all(|x| (110.0..=120.0).contains(x)), "{starts:?}\n{page}"); +} + #[test] fn level_preview_and_overprint_ids_read_back() { for l in Level::ALL { diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index eabba52d9e..96dc97a6f8 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -45,8 +45,9 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let set = &opts.settings; set.check()?; vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; - // Live geometry effects export as their result; raster effects are reported below. - let baked = vectorcraft_effects::bake_document(doc); + // Live geometry effects export as their result, in symbols' art too; raster effects are + // reported below. + let baked = vectorcraft_effects::bake_document_placing_symbols(doc, true); // Baking stops early when cancelled, leaving a half-baked copy that must not be written. vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; let doc = baked.as_ref().unwrap_or(doc); diff --git a/crates/pdf/src/print.rs b/crates/pdf/src/print.rs index 12c71756c8..57d30e327d 100644 --- a/crates/pdf/src/print.rs +++ b/crates/pdf/src/print.rs @@ -183,7 +183,7 @@ pub fn keep_layers(nodes: &mut Vec>, which: PrintLayers) { /// only the layers that print, overprints in composite output as [`PrintAdvanced::overprints`] /// says. pub fn printed_document<'a>(doc: &'a Document, set: &PrintSettings) -> Cow<'a, Document> { - let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned); + let mut doc = vectorcraft_effects::bake_document_placing_symbols(doc, true).map_or(Cow::Borrowed(doc), Cow::Owned); let d = doc.to_mut(); keep_layers(&mut d.layers, set.print_layers); composite_overprints(d, set); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index d63aaedb35..d888d7b4b1 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -246,6 +246,26 @@ fn an_instance_whose_brush_art_reaches_the_page_is_written() { assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(-15.0, 30.0, 5.0, 70.0), 0.01), "{:?}", l[0].geometric_bounds()); } +/// The live effects in a symbol's art apply where the instance places the art, as on the canvas: +/// the symbol's square at (10, 40), which a Transform effect moves 100 pt right, is written at +/// (110, 40). +#[test] +fn symbol_art_effects_are_written_where_they_draw() { + let mut d = doc(200.0, 100.0); + let mut square = rect_node(Rect::new(10.0, 40.0, 20.0, 50.0), Color::rgb(1.0, 0.0, 0.0)); + square.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(square) }); + add(&mut d, Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY })); + let out = roundtrip(&d); + let l = leaves(&out); + assert_eq!(l.len(), 1); + assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(110.0, 40.0, 120.0, 50.0), 0.01), "{:?}", l[0].geometric_bounds()); +} + /// An instance of a symbol with 9-slice guides, scaled to 200 × 150, is written as the canvas /// draws it: its 10 × 10 corner square stays 10 × 10 and its 10 × 10 center square becomes 30 × 20. #[test] diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 5c5e1eb2c0..cefc255db9 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -451,6 +451,29 @@ fn a_symbol_instances_brush_art_keeps_its_artboard_printed() { assert!(p[0].1.iter().any(|(_, paint)| rgb8(paint) == [255, 0, 0]), "{p:?}"); } +#[test] +fn symbol_art_effects_print_where_they_draw() { + // A red square and the symbol's blue square, each moved 100 pt right by a Transform effect, + // print at the same distance from the page's left edge. + let moved = |mut n: Node| { + n.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: json!({"moveH": 100.0}), visible: true }); + n + }; + let mut d = doc(200.0, 100.0, &[]); + let (layer, id) = (d.default_layer().unwrap(), d.alloc_id()); + let red = Node::path(id, shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Appearance::basic(rgb(1.0, 0.0, 0.0), Paint::None, 0.0)); + d.insert(Some(layer), 0, moved(red)).unwrap(); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::basic(rgb(0.0, 0.0, 1.0), Paint::None, 0.0)); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(moved(art)) }); + let id = d.alloc_id(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Moved".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let p = pages(&job(&d, json!({})).bytes); + assert_eq!(p.len(), 1); + let left = |c: [u8; 3]| p[0].1.iter().find(|(_, paint)| rgb8(paint) == c).map(|(r, _)| r.x0); + let (red, blue) = (left([255, 0, 0]), left([0, 0, 255])); + assert!(red.zip(blue).is_some_and(|(r, b)| (r - b).abs() < 0.05), "{p:?}"); +} + #[test] fn copies_collate_and_reverse() { let mut d = diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..06b745eec8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1552,6 +1552,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page describes effects on one fill or stroke, and the help page describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. + Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art and a dynamic symbol instance's own paint changes are kept, a symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`). The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case). Object › Expand Appearance keeps each stroke of a path a stroke (weight, caps, joins, dashes, arrowheads, alignment and width profile) on a copy of the path with the geometry effects baked in, and Object › Expand… is dimmed, with a tooltip, while the selection has appearance attributes, so a stroked line with Zig Zag becomes a stroked zig-zag line and only Expand then outlines it, the two steps the [Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help page describes (#3089). Brushed strokes still become their brush art; `object.expand` still bakes and outlines in one step for agents. From 897b4f0ecf22197a26f4bbadf0f04b041fd5f24f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 23:02:02 -0800 Subject: [PATCH 2/6] Export: placed symbol art counts its baked objects and drawn depth PDF, print, EPS and DXF exports now check the art of each placed symbol instance after its effects are baked. The objects it holds, including the copies a Transform effect makes, opacity mask art and the art of instances placed inside it (counted once), count against the export's 16,000,000-object budget. An instance whose baked art passes the budget, or would draw deeper than the 128 levels a document may draw, stays an instance. - doc: DrawnHeights measures how deep an object outside the layers draws, symbols and patterns it uses included. - effects: the bake tracks how deep each baked object sits, and a rejected candidate releases what it and the instances in it charged. --- crates/doc/src/depth.rs | 58 +++++++++++++++++++++++ crates/effects/src/bake.rs | 81 ++++++++++++++++++++++++-------- crates/effects/src/bake/tests.rs | 72 ++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 4 files changed, 193 insertions(+), 20 deletions(-) diff --git a/crates/doc/src/depth.rs b/crates/doc/src/depth.rs index 63b7e0ce7c..c846dfc1c6 100644 --- a/crates/doc/src/depth.rs +++ b/crates/doc/src/depth.rs @@ -251,6 +251,45 @@ fn undrawable(heights: &Heights, used: Def, depth: usize) -> bool { heights.get(&used).is_some_and(|h| h.is_none_or(|h| depth + h > MAX_DRAWN_DEPTH)) } +/// How deep the art of a document's symbols and patterns draws, to measure objects that use them +/// outside the document's layers ([`Self::deepest`]). +#[derive(Clone, Debug, Default)] +pub struct DrawnHeights { + symbols: HashMap, + patterns: HashMap, +} + +impl DrawnHeights { + /// The heights of `doc`'s symbols and patterns, as [`Document::too_deep`] counts them: the art + /// is 1 deep, and what it uses is counted under it. A symbol or pattern that uses itself, or + /// draws deeper than [`MAX_DRAWN_DEPTH`], has none. + pub fn of(doc: &Document) -> Self { + let mut out = Self::default(); + for ((pattern, name), h) in heights(doc) { + if let Some(h) = h { + let map = if pattern { &mut out.patterns } else { &mut out.symbols }; + map.insert(name.to_string(), h); + } + } + out + } + + /// The deepest `n`, placed `depth` deep, draws: its own objects and the art of their opacity + /// masks, and under the objects the art of the symbols and patterns they use. A use of a + /// symbol or pattern without a height ([`Self::of`]) counts as the object alone. `n`'s own + /// nesting may pass [`MAX_DEPTH`]. Without recursion. + pub fn deepest(&self, n: &Node, depth: usize) -> usize { + let mut out = depth; + let mut stack = vec![(n, depth)]; + while let Some((n, d)) = stack.pop() { + let used = uses(n).filter_map(|(pattern, name)| if pattern { self.patterns.get(name) } else { self.symbols.get(name) }).max(); + out = out.max(d + used.copied().unwrap_or(0)); + stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| (&**c, d + 1))); + } + out + } +} + /// Take out of `nodes` (each `depth` deep) the uses `bad` picks (given the depth of the object): /// an instance goes, a pattern paint becomes no paint. Returns whether anything changed. For trees /// within [`MAX_DEPTH`]. @@ -623,4 +662,23 @@ mod tests { d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "none")])); assert!(!d.too_deep(None) && !d.limit_depth()); } + + #[test] + fn drawn_heights_measure_objects_outside_the_layers() { + // s0 holds s1 two groups deep, which holds s2, which holds an instance of nothing known: + // s2 draws 3 deep, s1 6 and s0 9. + let d = symbols(3, 2, "none"); + let heights = DrawnHeights::of(&d); + let on_layer = instance(1, "s0"); + assert_eq!(heights.deepest(&on_layer, 2), 2 + 9); + // An object's own nesting and its mask's art count past MAX_DEPTH. + let mut deep = Node::group(NodeId(5), vec![nested(MAX_DEPTH + 10)]); + deep.extras_mut().mask = Some(Box::new(OpacityMask::new((*instance(6, "s2")).clone(), true))); + assert_eq!(heights.deepest(&deep, 1), 1 + 1 + MAX_DEPTH + 10); + let shallow = Node::group(NodeId(5), vec![instance(7, "s1")]); + assert_eq!(heights.deepest(&shallow, 100), 101 + 6); + // A symbol that holds itself has no height, so its instance counts alone. + let looped = DrawnHeights::of(&symbols(2, 1, "s0")); + assert_eq!(looped.deepest(&instance(1, "s0"), 2), 2); + } } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 9a4c940f23..6e313eb26f 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -117,9 +117,20 @@ thread_local! { /// While [`bake_document_placing_symbols`] bakes: the symbols whose instances become their /// placed art ([`placed_symbol_art`]). static PLACING: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; - /// The objects of symbol art that [`placed_symbol_art`] has placed for one export, counted - /// against [`vectorcraft_doc::MAX_SYMBOL_NODES`]. + /// The objects in the baked art of the instances that [`bake_node`] has replaced by their + /// placed art for one export ([`placed_symbol_art`]), counted against [`Placing::most_nodes`]. static PLACED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// How deep the object [`bake_node`] is baking sits in the tree it returns: a top-level layer + /// is 1 deep. + static LEVEL: std::cell::Cell = const { std::cell::Cell::new(1) }; +} + +/// `f` run for an object one level deeper ([`LEVEL`]) than the one being baked. +fn one_deeper(f: impl FnOnce() -> T) -> T { + LEVEL.with(|c| c.set(c.get() + 1)); + let out = f(); + LEVEL.with(|c| c.set(c.get().saturating_sub(1))); + out } /// The symbols whose instances [`bake_document_placing_symbols`] replaces by their placed art, and @@ -131,6 +142,27 @@ struct Placing { /// Whether 9-slice cells are clipped ([`Document::place_symbol_art`]) or not /// ([`Document::place_symbol_art_unclipped`]). clip_slices: bool, + /// Most objects the baked art of all placed instances may hold together: + /// [`vectorcraft_doc::MAX_SYMBOL_NODES`] for an export. + most_nodes: u64, + /// How deep the document's symbols and patterns draw, for the instances and pattern paints + /// left in placed art. + heights: vectorcraft_doc::depth::DrawnHeights, +} + +/// Can `baked`, the baked art of a placed instance, take the instance's place [`LEVEL`] deep, +/// when the art placed for the export so far holds `spent` objects? `Some` with the objects +/// placed then, when its objects (opacity masks' art included) fit within +/// [`Placing::most_nodes`] and nothing in it, the symbols and patterns it uses included, draws +/// deeper than [`vectorcraft_doc::depth::MAX_DRAWN_DEPTH`]. +fn placed_fits(baked: &Node, spent: u64) -> Option { + PLACING.with(|p| { + let p = p.borrow(); + let p = p.as_ref()?; + let spent = spent.saturating_add(baked.count_with_masks() as u64); + let deepest = p.heights.deepest(baked, LEVEL.with(|c| c.get())); + (spent <= p.most_nodes && deepest <= vectorcraft_doc::depth::MAX_DRAWN_DEPTH).then_some(spent) + }) } /// The names of the symbols of `d` whose art needs baking ([`needs_bake`]), and of those whose art @@ -183,12 +215,13 @@ fn holds_placed_symbol(n: &Node) -> bool { /// place it, in a group with the instance's id, name, visibility, transparency, opacity mask and /// raster effects. The art is placed before its effects are baked, as the canvas draws it, so an /// instance that scales its art does not scale what its effects do. `None` for other objects, -/// for instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and once the art -/// placed for one export would pass [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects; those -/// instances stay as they are. +/// for instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and when the art +/// as placed, before its effects are baked, would already take the art placed for the export +/// past [`Placing::most_nodes`]; those instances stay as they are. [`bake_node`] checks the baked +/// art with [`placed_fits`]. fn placed_symbol_art(d: &Document, n: &Node) -> Option { let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; - let clip = PLACING.with(|p| p.borrow().as_ref().filter(|p| p.symbols.contains(symbol)).map(|p| p.clip_slices))?; + let (clip, most_nodes) = PLACING.with(|p| p.borrow().as_ref().filter(|p| p.symbols.contains(symbol)).map(|p| (p.clip_slices, p.most_nodes)))?; if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH { return None; } @@ -199,11 +232,9 @@ fn placed_symbol_art(d: &Document, n: &Node) -> Option { Some(m) => m.most_placed_unclipped(&art, d), None => art.count_with_masks() as u64, }; - let spent = PLACED_NODES.with(|c| c.get()).saturating_add(most); - if spent > vectorcraft_doc::MAX_SYMBOL_NODES { + if PLACED_NODES.with(|c| c.get()).saturating_add(most) > most_nodes { return None; } - PLACED_NODES.with(|c| c.set(spent)); let placed = if clip { d.place_symbol_art(symbol, &art, *xf) } else { d.place_symbol_art_unclipped(symbol, &art, *xf) }?; Some(dressed(Node::group(n.id, vec![Arc::new(placed)]), n)) } @@ -272,12 +303,20 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { return Some(baked.unwrap_or(m)); } // Symbol instances whose art draws live effects, when the caller asks for them: the art - // placed, then baked. + // placed, then baked. When the baked art passes the export's budget, or would draw too deep + // where the instance is, it is dropped with the art placed for the instances inside it, and + // the instance stays as it is. if let Some(m) = placed_symbol_art(d, n) { + let spent = PLACED_NODES.with(|c| c.get()); INSTANCE_DEPTH.with(|c| c.set(c.get() + 1)); let baked = bake_node(d, &m); INSTANCE_DEPTH.with(|c| c.set(c.get() - 1)); - return Some(baked.unwrap_or(m)); + let baked = baked.unwrap_or(m); + if let Some(now) = placed_fits(&baked, spent) { + PLACED_NODES.with(|c| c.set(now)); + return Some(baked); + } + PLACED_NODES.with(|c| c.set(spent)); } // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its // patterns: each copy an object of its own, so its gradient fits it rather than spanning @@ -300,7 +339,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { return bake_leaf(d, n); } let mut m = n.clone(); - let baked: Vec> = ch.iter().map(|c| bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone())).collect(); + let baked: Vec> = ch.iter().map(|c| one_deeper(|| bake_node(d, c)).map(Arc::new).unwrap_or_else(|| c.clone())).collect(); if let Some(ch) = m.children_mut() { *ch = baked; } @@ -319,7 +358,7 @@ fn bake_pieces(d: &mut Document, mut m: Node) -> Node { .into_iter() .map(|c| { let c = Arc::unwrap_or_clone(c); - let mut c = bake_node(d, &c).unwrap_or(c); + let mut c = one_deeper(|| bake_node(d, &c)).unwrap_or(c); fresh_ids(d, &mut c, &mut seen); Arc::new(c) }) @@ -808,25 +847,29 @@ pub fn bake_document(doc: &Document) -> Option { /// ([`Document::place_symbol_art`]) or not ([`Document::place_symbol_art_unclipped`]), as the /// exporter places other instances. The symbols' own art is left as it is. pub fn bake_document_placing_symbols(doc: &Document, clip_slices: bool) -> Option { - bake(doc, Some(clip_slices)) + bake(doc, Some((clip_slices, vectorcraft_doc::MAX_SYMBOL_NODES))) } /// Restores what [`bake`] changes in the thread's baking state when it ends. -struct Restore(Option>, u64); +struct Restore(Option>, u64, usize); impl Drop for Restore { fn drop(&mut self) { PLACING.with(|p| *p.borrow_mut() = self.0.take()); PLACED_NODES.with(|c| c.set(self.1)); + LEVEL.with(|c| c.set(self.2)); } } -/// [`bake_document`], or with `placing` [`bake_document_placing_symbols`] with that `clip_slices`. -fn bake(doc: &Document, placing: Option) -> Option { +/// [`bake_document`], or with `placing` [`bake_document_placing_symbols`] with that `clip_slices` +/// and [`Placing::most_nodes`]. +fn bake(doc: &Document, placing: Option<(bool, u64)>) -> Option { let placed = doc.with_placed_art(); - let placing = placing.map(|clip_slices| Placing { symbols: drawn_symbols(&placed), clip_slices }).filter(|p| !p.symbols.is_empty()); + let placing = placing.map(|(clip_slices, most_nodes)| (drawn_symbols(&placed), clip_slices, most_nodes)).filter(|p| !p.0.is_empty()).map( + |(symbols, clip_slices, most_nodes)| Placing { symbols, clip_slices, most_nodes, heights: vectorcraft_doc::depth::DrawnHeights::of(&placed) }, + ); // A bake inside another one (of art an effect makes) places symbols only when it asks to. - let _restore = Restore(PLACING.with(|p| p.replace(placing.map(Rc::new))), PLACED_NODES.with(|c| c.replace(0))); + let _restore = Restore(PLACING.with(|p| p.replace(placing.map(Rc::new))), PLACED_NODES.with(|c| c.replace(0)), LEVEL.with(|c| c.replace(1))); if !placed.layers.iter().any(|l| needs_bake(l) || holds_placed_symbol(l)) { return match placed { std::borrow::Cow::Owned(d) => Some(d), diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index b0ebfc4eba..f72637b6f6 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -1172,3 +1172,75 @@ fn placing_symbols_keeps_dynamic_instances_changes() { assert!(fills.contains(&(110.0, 40.0, red)), "{fills:?}"); assert!(fills.contains(&(10.0, 60.0, Paint::solid(Color::WHITE))), "{fills:?}"); } + +fn is_instance(d: &Document, id: u64) -> bool { + d.node(NodeId(id)).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })) +} + +/// The art placed for an export is counted as baked: the copies an effect makes count, so an +/// instance whose baked art passes what is left of the budget stays an instance, and the budget +/// holds the baked art of the instances placed, exactly. +#[test] +fn placing_symbols_counts_the_baked_art() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "S", Affine::IDENTITY)); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, instance(901, "S", Affine::translate((0.0, 100.0)))).unwrap(); + add_symbol(&mut d, "S", gradient_copies(800)); + let all = bake(&d, Some((true, u64::MAX))).unwrap(); + let each = all.node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); + assert!(each > 2, "the instance's group, and the copies as objects of their own: {each}"); + assert_eq!(all.node(NodeId(901)).map(|n| n.count_with_masks() as u64), Some(each)); + let fits = bake(&d, Some((true, 2 * each))).unwrap(); + assert!(!is_instance(&fits, 900) && !is_instance(&fits, 901)); + let short = bake(&d, Some((true, 2 * each - 1))).unwrap(); + assert_eq!(usize::from(is_instance(&short, 900)) + usize::from(is_instance(&short, 901)), 1, "one instance stays as it is"); +} + +/// The baked art of an instance placed inside another's art is counted once: a budget of exactly +/// the outer instance's baked art places both. +#[test] +fn placing_symbols_counts_nested_art_once() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); + add_symbol(&mut d, "Inner", gradient_copies(800)); + add_symbol(&mut d, "Outer", Node::group(NodeId(801), vec![Arc::new(instance(802, "Inner", Affine::IDENTITY))])); + let all = bake(&d, Some((true, u64::MAX))).unwrap(); + let total = all.node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); + let exact = bake(&d, Some((true, total))).unwrap(); + assert!(!is_instance(&exact, 900) && !is_instance(&exact, 802), "both are placed within {total}"); + let short = bake(&d, Some((true, total - 1))).unwrap(); + assert!(is_instance(&short, 900), "the outer instance stays as it is"); +} + +/// `leaf` in `levels` groups, with ids from `id` up. +fn in_groups(id: u64, levels: u64, leaf: Node) -> Node { + (0..levels).fold(leaf, |n, i| Node::group(NodeId(id + i), vec![Arc::new(n)])) +} + +/// Placed art is drawn no deeper than the document may draw: an instance whose art's effects add +/// a level past it stays an instance, and the instances around it are placed. +#[test] +fn placing_symbols_keeps_the_baked_art_within_the_drawn_depth() { + use vectorcraft_doc::depth::{DrawnHeights, MAX_DRAWN_DEPTH}; + use vectorcraft_geom::Affine; + // The instance on the layer is 2 deep; A to D each hold the next 31 objects down, and E's + // square is 2 deep in its art: 2 + 4 × 31 + 2 = MAX_DRAWN_DEPTH. + let mut d = doc_with(instance(900, "A", Affine::IDENTITY)); + for (i, (name, next)) in [("A", "B"), ("B", "C"), ("C", "D"), ("D", "E")].into_iter().enumerate() { + let base = 1000 * (i as u64 + 1); + add_symbol(&mut d, name, in_groups(base, 30, instance(base + 100, next, Affine::IDENTITY))); + } + add_symbol(&mut d, "E", in_groups(5000, 1, gradient_copies(5100))); + assert!(!d.too_deep(None)); + assert_eq!(DrawnHeights::of(&d).deepest(&d.layers[0], 1), MAX_DRAWN_DEPTH); + let out = bake_document_placing_symbols(&d, true).unwrap(); + // E's square becomes a group of copies, a level past the cap: E's instance stays. + assert!(is_instance(&out, 4100), "E's instance stays as it is"); + assert!(!is_instance(&out, 900) && !is_instance(&out, 3100), "A to D are placed"); + assert!(DrawnHeights::of(&out).deepest(&out.layers[0], 1) <= MAX_DRAWN_DEPTH); + // Without the chain above it, E's art is placed. + let mut shallow = doc_with(instance(900, "E", Affine::IDENTITY)); + add_symbol(&mut shallow, "E", in_groups(5000, 1, gradient_copies(5100))); + assert!(!is_instance(&bake_document_placing_symbols(&shallow, true).unwrap(), 900)); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c5d7314319..a5365c758c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1561,7 +1561,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page describes effects on one fill or stroke, and the help page describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. - Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art and a dynamic symbol instance's own paint changes are kept, a symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`). + Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art and a dynamic symbol instance's own paint changes are kept, a symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`). The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case). Object › Expand Appearance keeps each stroke of a path a stroke (weight, caps, joins, dashes, arrowheads, alignment and width profile) on a copy of the path with the geometry effects baked in, and Object › Expand… is dimmed, with a tooltip, while the selection has appearance attributes, so a stroked line with Zig Zag becomes a stroked zig-zag line and only Expand then outlines it, the two steps the [Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help page describes (#3089). Brushed strokes still become their brush art; `object.expand` still bakes and outlines in one step for agents. From 5695823937004fb99c1c0d01dddd2525d2dbb22c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 02:06:18 -0800 Subject: [PATCH 3/6] Export: placed symbol art keeps its instance's stain When PDF, print, EPS and DXF export place a symbol's art to bake its effects, the art now carries the stain the instance's fill gives it, as it did before the art was placed. The art is stained first and its effects apply to the stained colors. --- crates/effects/src/bake.rs | 6 +++--- crates/effects/src/bake/tests.rs | 36 ++++++++++++++++++++++++++++++++ crates/pdf/src/tests.rs | 25 ++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 6e313eb26f..7f7da8e27b 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -210,8 +210,8 @@ fn holds_placed_symbol(n: &Node) -> bool { } /// Symbol instance `n`, while [`bake_document_placing_symbols`] bakes and its symbol is one it -/// places, as the art it draws: the symbol's art with the instance's own appearance changes -/// ([`vectorcraft_doc::symbol_overrides::dynamic_art`]), placed by the instance as the exporters +/// places, as the art it draws: the symbol's art with the instance's own appearance changes and +/// its stain ([`vectorcraft_doc::symbol_overrides::instance_art`]), placed by the instance as the exporters /// place it, in a group with the instance's id, name, visibility, transparency, opacity mask and /// raster effects. The art is placed before its effects are baked, as the canvas draws it, so an /// instance that scales its art does not scale what its effects do. `None` for other objects, @@ -226,7 +226,7 @@ fn placed_symbol_art(d: &Document, n: &Node) -> Option { return None; } let sym = d.symbols.iter().find(|s| s.name == *symbol)?; - let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); + let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n); let most = match d.symbol_slice_map(symbol, *xf) { Some(m) if clip => m.most_placed(&art, d), Some(m) => m.most_placed_unclipped(&art, d), diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index f72637b6f6..be72b4df6e 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -1173,6 +1173,42 @@ fn placing_symbols_keeps_dynamic_instances_changes() { assert!(fills.contains(&(10.0, 60.0, Paint::solid(Color::WHITE))), "{fills:?}"); } +/// An instance whose fill stains its symbol's art places the art in its stained colors, with the +/// art's effects applied after the stain: a Transform moves the stained square and an Invert +/// inverts the stained color. +#[test] +fn placing_symbols_keeps_the_instances_stain() { + use vectorcraft_geom::Affine; + let blue = Color::rgb8(30, 90, 220); + let mut inst = instance(900, "Mark", Affine::IDENTITY); + inst.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + inst.appearance.items.retain(|i| i.is_fill()); + if let AppearanceItem::Fill(f) = &mut inst.appearance.items[0] { + f.opacity = 0.5; + } + let square = + Node::path(NodeId(800), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::basic(Paint::solid(blue), Paint::None, 0.0)); + let stained = vectorcraft_doc::symbol_overrides::instance_art(&square, &inst).appearance.fill_paint().color().unwrap(); + assert_ne!(stained.to_hex(), blue.to_hex()); + let [r, g, b] = stained.to_rgb(); + let inverted = Color::rgb(1.0 - r, 1.0 - g, 1.0 - b); + for (id, params, want) in [("distort.transform", json!({"moveH": 100.0}), stained), (crate::addons::color::INVERT, json!({}), inverted)] { + let mut art = square.clone(); + art.appearance.effects.push(fx(id, params)); + let mut d = doc_with(inst.clone()); + add_symbol(&mut d, "Mark", art); + let out = bake_document_placing_symbols(&d, true).unwrap(); + assert!(!is_instance(&out, 900), "{id}: the instance is placed"); + let mut fills = vec![]; + out.node(NodeId(900)).unwrap().walk(&mut |n| { + if n.path_data().is_some() { + fills.extend(n.appearance.fill_paint().color().map(|c| c.to_hex())); + } + }); + assert_eq!(fills, [want.to_hex()], "{id}"); + } +} + fn is_instance(d: &Document, id: u64) -> bool { d.node(NodeId(id)).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })) } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 1b415d3804..5e9796b9b4 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -356,6 +356,31 @@ fn a_stained_instance_keeps_its_stain() { } } +/// A stained instance of a symbol whose art has a live effect is written in its stained colors, +/// where the effect draws the art. +#[test] +fn a_stained_instance_keeps_its_stain_when_its_art_has_effects() { + let blue = Color::rgb8(30, 90, 220); + let square = rect_node(Rect::new(10.0, 40.0, 20.0, 50.0), blue); + let half = stained_instance("Moved", Affine::IDENTITY, 0.5, true); + let want = vectorcraft_brush::instance_art(&square, &half).appearance.fill_paint().color().unwrap().to_hex(); + assert_ne!(want, blue.to_hex(), "the stain changes the color"); + let mut art = square; + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + let mut d = doc(200.0, 100.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(art) }); + add(&mut d, half); + let out = roundtrip(&d); + let l = leaves(&out); + assert_eq!(l.len(), 1); + assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(110.0, 40.0, 120.0, 50.0), 0.01), "{:?}", l[0].geometric_bounds()); + assert_eq!(leaf_fills(&out), [want]); +} + /// The placed art of all the 9-slice instances in one export holds at most `MAX_SYMBOL_NODES` /// objects, opacity-mask art included, as on the canvas: of two instances whose art placed through /// the guides holds about 9,009,000 objects each (a gradient square in all nine cells, each copy diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2c5814996f..a6266f7bb3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1572,7 +1572,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page describes effects on one fill or stroke, and the help page describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. - Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art and a dynamic symbol instance's own paint changes are kept, a symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`). + Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art, a dynamic symbol instance's own paint changes and the stain an instance's fill gives its art are kept, and the art is stained before its effects apply. A symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_keeps_the_instances_stain`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`; PDF `a_stained_instance_keeps_its_stain_when_its_art_has_effects`). The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case). Object › Expand Appearance keeps each stroke of a path a stroke (weight, caps, joins, dashes, arrowheads, alignment and width profile) on a copy of the path with the geometry effects baked in, and Object › Expand… is dimmed, with a tooltip, while the selection has appearance attributes, so a stroked line with Zig Zag becomes a stroked zig-zag line and only Expand then outlines it, the two steps the [Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help page describes (#3089). Brushed strokes still become their brush art; `object.expand` still bakes and outlines in one step for agents. From b59ec11b3297a41829b982cde63a7e52241bc941 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 02:45:49 -0800 Subject: [PATCH 4/6] Export: symbol art placement skips instances the export leaves out Hidden instances, instances in hidden groups, on template layers, and on hidden layers or layers that do not print when the exporter leaves those out are no longer placed for PDF, print, EPS and DXF. Their art was baked and counted toward the 16,000,000-object limit for placed symbol art, so it could leave a visible instance written without its effects. bake_document_placing_symbols takes a WrittenLayers value: PDF passes the layers its settings write, EPS writes layers that do not print but not hidden ones, and DXF writes both. Print removes the layers that do not print before baking. --- crates/cad/src/lib.rs | 3 +- crates/effects/src/bake.rs | 86 +++++++++++++++++++++++++------- crates/effects/src/bake/tests.rs | 75 +++++++++++++++++++++++----- crates/effects/src/lib.rs | 3 +- crates/eps/src/lib.rs | 3 +- crates/pdf/src/export/entry.rs | 8 ++- crates/pdf/src/print.rs | 13 +++-- docs/target-app-parity.md | 2 +- 8 files changed, 153 insertions(+), 40 deletions(-) diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index d459f95b40..bb752267aa 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -429,7 +429,8 @@ pub fn export(doc: &Document, opts: &DxfOptions) -> Result { return Err("the export region is not finite".into()); } // Symbols' art is placed without clipping, as the scene places it. - let baked = vectorcraft_effects::bake_document_placing_symbols(doc, false); + let baked = + vectorcraft_effects::bake_document_placing_symbols(doc, false, vectorcraft_effects::WrittenLayers { hidden: true, non_printing: true }); let doc = baked.as_ref().unwrap_or(doc); Ok(scene::Scene::new(doc, opts).run()) } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 0ce8fb8aef..12f967922d 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -124,6 +124,9 @@ thread_local! { /// How deep the object [`bake_node`] is baking sits in the tree it returns: a top-level layer /// is 1 deep. static LEVEL: std::cell::Cell = const { std::cell::Cell::new(1) }; + /// Whether [`bake_node`] is baking inside an object the export does not write ([`unwritten`]), + /// where no instance is placed. + static UNWRITTEN: std::cell::Cell = const { std::cell::Cell::new(false) }; } /// `f` run for an object one level deeper ([`LEVEL`]) than the one being baked. @@ -149,6 +152,30 @@ struct Placing { /// How deep the document's symbols and patterns draw, for the instances and pattern paints /// left in placed art. heights: vectorcraft_doc::depth::DrawnHeights, + /// The hidden layers and layers that do not print that the export writes. + layers: WrittenLayers, +} + +/// The layers an exporter writes besides the shown, printing ones +/// ([`bake_document_placing_symbols`]). Template layers are never written. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct WrittenLayers { + /// Hidden layers (written turned off, as PDF layers and DXF write them). + pub hidden: bool, + /// Layers that do not print. + pub non_printing: bool, +} + +/// Does the export leave `n` and everything in it out, while [`bake_document_placing_symbols`] +/// bakes? A hidden object other than a layer, a template layer, and a hidden layer or a layer +/// that does not print when the export leaves those out ([`WrittenLayers`]). +fn unwritten(n: &Node) -> bool { + PLACING.with(|p| { + p.borrow().as_ref().is_some_and(|p| match n.kind { + NodeKind::Layer { template, printable, .. } => template || !(printable || p.layers.non_printing) || !(n.visible || p.layers.hidden), + _ => !n.visible, + }) + }) } /// Can `baked`, the baked art of a placed instance, take the instance's place [`LEVEL`] deep, @@ -201,13 +228,14 @@ fn drawn_symbols(d: &Document) -> HashSet { } /// Does `n`'s subtree hold an instance of a symbol that [`bake_document_placing_symbols`] replaces -/// by its placed art? False outside it. +/// by its placed art? False outside it, and for objects the export does not write ([`unwritten`]). fn holds_placed_symbol(n: &Node) -> bool { fn walk(n: &Node, symbols: &HashSet) -> bool { - matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbols.contains(symbol)) - || n.children().is_some_and(|ch| ch.iter().any(|c| walk(c, symbols))) + !unwritten(n) + && (matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbols.contains(symbol)) + || n.children().is_some_and(|ch| ch.iter().any(|c| walk(c, symbols)))) } - PLACING.with(|p| p.borrow().as_ref().is_some_and(|p| walk(n, &p.symbols))) + !UNWRITTEN.with(|c| c.get()) && PLACING.with(|p| p.borrow().as_ref().is_some_and(|p| walk(n, &p.symbols))) } /// Symbol instance `n`, while [`bake_document_placing_symbols`] bakes and its symbol is one it @@ -216,14 +244,15 @@ fn holds_placed_symbol(n: &Node) -> bool { /// place it, in a group with the instance's id, name, visibility, transparency, opacity mask and /// raster effects. The art is placed before its effects are baked, as the canvas draws it, so an /// instance that scales its art does not scale what its effects do. `None` for other objects, -/// for instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and when the art +/// for instances the export does not write ([`unwritten`], hidden or inside such an object), for +/// instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and when the art /// as placed, before its effects are baked, would already take the art placed for the export /// past [`Placing::most_nodes`]; those instances stay as they are. [`bake_node`] checks the baked /// art with [`placed_fits`]. fn placed_symbol_art(d: &Document, n: &Node) -> Option { let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; let (clip, most_nodes) = PLACING.with(|p| p.borrow().as_ref().filter(|p| p.symbols.contains(symbol)).map(|p| (p.clip_slices, p.most_nodes)))?; - if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH { + if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH || UNWRITTEN.with(|c| c.get()) || unwritten(n) { return None; } let sym = d.symbols.iter().find(|s| s.name == *symbol)?; @@ -242,6 +271,13 @@ fn placed_symbol_art(d: &Document, n: &Node) -> Option { /// `n` with its geometry effects evaluated (`None` = unchanged). fn bake_node(d: &mut Document, n: &Node) -> Option { + // Inside an object the export leaves out, effects are baked but no instance is placed. + if !UNWRITTEN.with(|c| c.get()) && unwritten(n) { + UNWRITTEN.with(|c| c.set(true)); + let out = bake_node(d, n); + UNWRITTEN.with(|c| c.set(false)); + return out; + } // A cancelled export leaves the rest of the document as it is: the caller drops the result. // A ruler guide draws nothing and stays as it is, last in its layer. if n.is_ruler_guide() || !(needs_bake(n) || holds_placed_symbol(n)) || vectorcraft_doc::cancel::is_cancelled() { @@ -848,31 +884,47 @@ pub fn bake_document(doc: &Document) -> Option { /// exporters that write each instance's art in its place (PDF, print, EPS, DXF), rather than /// writing each symbol once. `clip_slices`: whether 9-slice cells are clipped /// ([`Document::place_symbol_art`]) or not ([`Document::place_symbol_art_unclipped`]), as the -/// exporter places other instances. The symbols' own art is left as it is. -pub fn bake_document_placing_symbols(doc: &Document, clip_slices: bool) -> Option { - bake(doc, Some((clip_slices, vectorcraft_doc::MAX_SYMBOL_NODES))) +/// exporter places other instances. `layers`: the hidden layers and layers that do not print +/// that the exporter writes. Instances the export does not write (hidden ones, those in hidden +/// objects, on template layers, and on the layers `layers` leaves out) stay as they are. The +/// symbols' own art is left as it is. +pub fn bake_document_placing_symbols(doc: &Document, clip_slices: bool, layers: WrittenLayers) -> Option { + bake(doc, Some((clip_slices, vectorcraft_doc::MAX_SYMBOL_NODES, layers))) } /// Restores what [`bake`] changes in the thread's baking state when it ends. -struct Restore(Option>, u64, usize); +struct Restore(Option>, u64, usize, bool); impl Drop for Restore { fn drop(&mut self) { PLACING.with(|p| *p.borrow_mut() = self.0.take()); PLACED_NODES.with(|c| c.set(self.1)); LEVEL.with(|c| c.set(self.2)); + UNWRITTEN.with(|c| c.set(self.3)); } } -/// [`bake_document`], or with `placing` [`bake_document_placing_symbols`] with that `clip_slices` -/// and [`Placing::most_nodes`]. -fn bake(doc: &Document, placing: Option<(bool, u64)>) -> Option { +/// [`bake_document`], or with `placing` [`bake_document_placing_symbols`] with that `clip_slices`, +/// [`Placing::most_nodes`] and [`Placing::layers`]. +fn bake(doc: &Document, placing: Option<(bool, u64, WrittenLayers)>) -> Option { let placed = doc.with_placed_art(); - let placing = placing.map(|(clip_slices, most_nodes)| (drawn_symbols(&placed), clip_slices, most_nodes)).filter(|p| !p.0.is_empty()).map( - |(symbols, clip_slices, most_nodes)| Placing { symbols, clip_slices, most_nodes, heights: vectorcraft_doc::depth::DrawnHeights::of(&placed) }, - ); + let placing = placing + .map(|(clip_slices, most_nodes, layers)| (drawn_symbols(&placed), clip_slices, most_nodes, layers)) + .filter(|p| !p.0.is_empty()) + .map(|(symbols, clip_slices, most_nodes, layers)| Placing { + symbols, + clip_slices, + most_nodes, + heights: vectorcraft_doc::depth::DrawnHeights::of(&placed), + layers, + }); // A bake inside another one (of art an effect makes) places symbols only when it asks to. - let _restore = Restore(PLACING.with(|p| p.replace(placing.map(Rc::new))), PLACED_NODES.with(|c| c.replace(0)), LEVEL.with(|c| c.replace(1))); + let _restore = Restore( + PLACING.with(|p| p.replace(placing.map(Rc::new))), + PLACED_NODES.with(|c| c.replace(0)), + LEVEL.with(|c| c.replace(1)), + UNWRITTEN.with(|c| c.replace(false)), + ); if !placed.layers.iter().any(|l| needs_bake(l) || holds_placed_symbol(l)) { return match placed { std::borrow::Cow::Owned(d) => Some(d), diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index be72b4df6e..ed7699651d 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -1100,7 +1100,7 @@ fn placing_symbols_bakes_their_art_where_each_instance_places_it() { d.insert(Some(layer), 0, instance(901, "S", Affine::translate((0.0, 100.0)) * Affine::scale(2.0))).unwrap(); add_symbol(&mut d, "S", moved_square(800)); assert!(bake_document(&d).is_none(), "nothing in the layers needs baking"); - let out = bake_document_placing_symbols(&d, true).unwrap(); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); assert!(matches!(out.node(NodeId(900)).unwrap().kind, NodeKind::Group { .. })); assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 40.0, 120.0, 50.0)), "{:?}", bounds(&out, 900)); assert!(near_rect(bounds(&out, 901), Rect::new(120.0, 180.0, 140.0, 200.0)), "{:?}", bounds(&out, 901)); @@ -1115,7 +1115,7 @@ fn placing_symbols_reaches_the_art_of_nested_instances() { let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); add_symbol(&mut d, "Inner", moved_square(800)); add_symbol(&mut d, "Outer", Node::group(NodeId(801), vec![Arc::new(instance(802, "Inner", Affine::translate((0.0, 100.0))))])); - let out = bake_document_placing_symbols(&d, true).unwrap(); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 140.0, 120.0, 150.0)), "{:?}", bounds(&out, 900)); } @@ -1130,7 +1130,7 @@ fn placing_a_symbol_that_holds_itself_stops() { "Loop", Node::group(NodeId(801), vec![Arc::new(moved_square(800)), Arc::new(instance(802, "Loop", Affine::translate((0.0, 1.0))))]), ); - let out = bake_document_placing_symbols(&d, true).unwrap(); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); let mut lefts = vec![]; out.node(NodeId(900)).unwrap().walk(&mut |n| lefts.extend(n.path_data().and_then(|p| p.bounds()).map(|b| b.x0))); assert!(lefts.len() > 1 && lefts.iter().all(|x| (x - 110.0).abs() < 1e-6), "{lefts:?}"); @@ -1160,7 +1160,7 @@ fn placing_symbols_keeps_dynamic_instances_changes() { let mut d = doc_with(inst); add_symbol(&mut d, "D", Node::group(NodeId(802), vec![Arc::new(square), Arc::new(sibling)])); d.symbols[0].dynamic = true; - let out = bake_document_placing_symbols(&d, true).unwrap(); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); let mut fills = vec![]; out.node(NodeId(900)).unwrap().walk(&mut |n| { if n.path_data().is_some() @@ -1197,7 +1197,7 @@ fn placing_symbols_keeps_the_instances_stain() { art.appearance.effects.push(fx(id, params)); let mut d = doc_with(inst.clone()); add_symbol(&mut d, "Mark", art); - let out = bake_document_placing_symbols(&d, true).unwrap(); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); assert!(!is_instance(&out, 900), "{id}: the instance is placed"); let mut fills = vec![]; out.node(NodeId(900)).unwrap().walk(&mut |n| { @@ -1209,6 +1209,9 @@ fn placing_symbols_keeps_the_instances_stain() { } } +/// Every layer written, as DXF writes them (template layers are never written). +const ALL: WrittenLayers = WrittenLayers { hidden: true, non_printing: true }; + fn is_instance(d: &Document, id: u64) -> bool { d.node(NodeId(id)).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })) } @@ -1223,13 +1226,13 @@ fn placing_symbols_counts_the_baked_art() { let layer = d.layers[0].id; d.insert(Some(layer), 0, instance(901, "S", Affine::translate((0.0, 100.0)))).unwrap(); add_symbol(&mut d, "S", gradient_copies(800)); - let all = bake(&d, Some((true, u64::MAX))).unwrap(); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); let each = all.node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); assert!(each > 2, "the instance's group, and the copies as objects of their own: {each}"); assert_eq!(all.node(NodeId(901)).map(|n| n.count_with_masks() as u64), Some(each)); - let fits = bake(&d, Some((true, 2 * each))).unwrap(); + let fits = bake(&d, Some((true, 2 * each, ALL))).unwrap(); assert!(!is_instance(&fits, 900) && !is_instance(&fits, 901)); - let short = bake(&d, Some((true, 2 * each - 1))).unwrap(); + let short = bake(&d, Some((true, 2 * each - 1, ALL))).unwrap(); assert_eq!(usize::from(is_instance(&short, 900)) + usize::from(is_instance(&short, 901)), 1, "one instance stays as it is"); } @@ -1241,14 +1244,60 @@ fn placing_symbols_counts_nested_art_once() { let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); add_symbol(&mut d, "Inner", gradient_copies(800)); add_symbol(&mut d, "Outer", Node::group(NodeId(801), vec![Arc::new(instance(802, "Inner", Affine::IDENTITY))])); - let all = bake(&d, Some((true, u64::MAX))).unwrap(); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); let total = all.node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); - let exact = bake(&d, Some((true, total))).unwrap(); + let exact = bake(&d, Some((true, total, ALL))).unwrap(); assert!(!is_instance(&exact, 900) && !is_instance(&exact, 802), "both are placed within {total}"); - let short = bake(&d, Some((true, total - 1))).unwrap(); + let short = bake(&d, Some((true, total - 1, ALL))).unwrap(); assert!(is_instance(&short, 900), "the outer instance stays as it is"); } +/// Instances the export does not write are not placed and do not count against the budget: with +/// room for one instance's baked art, hidden instances, an instance in a hidden group, one on a +/// template layer, one on a layer that does not print and one on a hidden layer (when the export +/// leaves those layers out) come first and stay as they are, and the visible instance after them +/// is placed. An export that writes those layers places their instances first. +#[test] +fn placing_symbols_skips_instances_the_export_leaves_out() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "S", Affine::IDENTITY)); + add_symbol(&mut d, "S", gradient_copies(800)); + let each = bake(&d, Some((true, u64::MAX, ALL))).unwrap().node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); + let layer = d.layers[0].id; + let mut hidden = instance(901, "S", Affine::IDENTITY); + hidden.visible = false; + let mut group = Node::group(NodeId(902), vec![Arc::new(instance(903, "S", Affine::IDENTITY))]); + group.visible = false; + for (i, n) in [hidden, group].into_iter().enumerate() { + d.insert(Some(layer), i, n).unwrap(); + } + let mut left_out = vec![]; + for (id, template, printable, visible) in [(910, true, true, true), (920, false, false, true), (930, false, true, false)] { + let mut l = Node::layer(NodeId(id), &format!("L{id}"), vectorcraft_doc::LayerColor::Preset(0)); + if let NodeKind::Layer { template: t, printable: p, .. } = &mut l.kind { + *t = template; + *p = printable; + } + l.visible = visible; + if let Some(ch) = l.children_mut() { + ch.push(Arc::new(instance(id + 1, "S", Affine::IDENTITY))); + } + left_out.push(Arc::new(l)); + } + d.layers.splice(0..0, left_out); + let out = bake(&d, Some((true, each, WrittenLayers::default()))).unwrap(); + assert!(!is_instance(&out, 900), "the visible instance is placed within {each}"); + for id in [901, 903, 911, 921, 931] { + assert!(is_instance(&out, id), "{id} stays as it is"); + } + // An export that writes the layers that do not print, or the hidden layers, places that + // layer's instance first; the template layer's instance is never placed. + for (layers, first) in [(WrittenLayers { non_printing: true, hidden: false }, 921), (WrittenLayers { non_printing: false, hidden: true }, 931)] { + let out = bake(&d, Some((true, each, layers))).unwrap(); + assert!(!is_instance(&out, first) && is_instance(&out, 900) && is_instance(&out, 911), "{layers:?}"); + } +} + /// `leaf` in `levels` groups, with ids from `id` up. fn in_groups(id: u64, levels: u64, leaf: Node) -> Node { (0..levels).fold(leaf, |n, i| Node::group(NodeId(id + i), vec![Arc::new(n)])) @@ -1270,7 +1319,7 @@ fn placing_symbols_keeps_the_baked_art_within_the_drawn_depth() { add_symbol(&mut d, "E", in_groups(5000, 1, gradient_copies(5100))); assert!(!d.too_deep(None)); assert_eq!(DrawnHeights::of(&d).deepest(&d.layers[0], 1), MAX_DRAWN_DEPTH); - let out = bake_document_placing_symbols(&d, true).unwrap(); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); // E's square becomes a group of copies, a level past the cap: E's instance stays. assert!(is_instance(&out, 4100), "E's instance stays as it is"); assert!(!is_instance(&out, 900) && !is_instance(&out, 3100), "A to D are placed"); @@ -1278,5 +1327,5 @@ fn placing_symbols_keeps_the_baked_art_within_the_drawn_depth() { // Without the chain above it, E's art is placed. let mut shallow = doc_with(instance(900, "E", Affine::IDENTITY)); add_symbol(&mut shallow, "E", in_groups(5000, 1, gradient_copies(5100))); - assert!(!is_instance(&bake_document_placing_symbols(&shallow, true).unwrap(), 900)); + assert!(!is_instance(&bake_document_placing_symbols(&shallow, true, ALL).unwrap(), 900)); } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 2b5d561021..3d74ac4fe0 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -69,7 +69,8 @@ pub use adjust::{ is_channel, }; pub use bake::{ - StrokeArt, bake_appearance, bake_document, bake_document_placing_symbols, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake, + StrokeArt, WrittenLayers, bake_appearance, bake_document, bake_document_placing_symbols, bake_type_appearance, expand_art, expand_leaf, + fresh_ids, needs_bake, }; pub use clip::{WarpSeams, clip_outline, knockout_silhouette, warp_pieces_translucent}; pub use group::{ diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 2dbadf4fea..534778286a 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -230,7 +230,8 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb } // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); - let baked = vectorcraft_effects::bake_document_placing_symbols(doc, true); + let baked = + vectorcraft_effects::bake_document_placing_symbols(doc, true, vectorcraft_effects::WrittenLayers { hidden: false, non_printing: true }); let doc = baked.as_ref().unwrap_or(doc); let page = scene::Scene::new(doc, opts).run(); let mut setting_notes = vec![]; diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 96dc97a6f8..6cf8677269 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -47,7 +47,13 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; // Live geometry effects export as their result, in symbols' art too; raster effects are // reported below. - let baked = vectorcraft_effects::bake_document_placing_symbols(doc, true); + // Only the layers this export writes place their instances' art (see `ex.non_printing` and + // `ex.layers` below). + let layers = vectorcraft_effects::WrittenLayers { + hidden: set.writes_layers() && !doc.page_knockout, + non_printing: set.include_non_printing || set.create_layers, + }; + let baked = vectorcraft_effects::bake_document_placing_symbols(doc, true, layers); // Baking stops early when cancelled, leaving a half-baked copy that must not be written. vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; let doc = baked.as_ref().unwrap_or(doc); diff --git a/crates/pdf/src/print.rs b/crates/pdf/src/print.rs index 57d30e327d..2a3cbf98af 100644 --- a/crates/pdf/src/print.rs +++ b/crates/pdf/src/print.rs @@ -183,11 +183,14 @@ pub fn keep_layers(nodes: &mut Vec>, which: PrintLayers) { /// only the layers that print, overprints in composite output as [`PrintAdvanced::overprints`] /// says. pub fn printed_document<'a>(doc: &'a Document, set: &PrintSettings) -> Cow<'a, Document> { - let mut doc = vectorcraft_effects::bake_document_placing_symbols(doc, true).map_or(Cow::Borrowed(doc), Cow::Owned); - let d = doc.to_mut(); - keep_layers(&mut d.layers, set.print_layers); - composite_overprints(d, set); - doc + // The layers that do not print are left out first, so the art placed for symbols is only + // the art that prints; the layers kept are shown. + let mut kept = doc.clone(); + keep_layers(&mut kept.layers, set.print_layers); + let layers = vectorcraft_effects::WrittenLayers { hidden: false, non_printing: true }; + let mut d = vectorcraft_effects::bake_document_placing_symbols(&kept, true, layers).unwrap_or(kept); + composite_overprints(&mut d, set); + Cow::Owned(d) } /// Overprinting fills and strokes in composite output: discarded (they knock out) or simulated diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e9f587d0e4..766f58d944 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1575,7 +1575,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page describes effects on one fill or stroke, and the help page describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. - Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art, a dynamic symbol instance's own paint changes and the stain an instance's fill gives its art are kept, and the art is stained before its effects apply. A symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_keeps_the_instances_stain`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`; PDF `a_stained_instance_keeps_its_stain_when_its_art_has_effects`). + Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art, a dynamic symbol instance's own paint changes and the stain an instance's fill gives its art are kept, and the art is stained before its effects apply. A symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Instances the export leaves out (hidden ones, those in hidden groups, on template layers, and on hidden layers or layers that do not print when the export leaves those out) are not placed and do not count toward that limit. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_keeps_the_instances_stain`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`, `placing_symbols_skips_instances_the_export_leaves_out`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`; PDF `a_stained_instance_keeps_its_stain_when_its_art_has_effects`). A raster effect (Drop Shadow, Outer Glow, Gaussian Blur and the other effects that paint pixels) on a symbol instance's topmost fill draws on each filled shape of the symbol's art, and one on its topmost stroke draws on each stroked shape, after any geometry effects listed before it, under the same rules as geometry effects: only a visible fill or stroke with an opacity above 0 counts, a group's own fills and strokes in the art take the effect, and an instance inside the art passes it on. Each shape gets its own shadow or glow, which is VectorCraft's own rule. The canvas, Use Preview Bounds and the drawn bounds, SVG and PDF export draw it, and Object › Expand Appearance is enabled for such an instance and makes the effect an image at the document's raster effects resolution, in one undo step (engine `expand::a_symbol_instance_fill_shadow_shadows_its_filled_art`, `a_symbol_instance_stroke_shadow_shadows_only_its_stroked_art`, `a_symbol_instance_fill_shadow_follows_the_fill_offset_before_it`, `a_nested_symbol_instance_takes_the_outer_fill_shadow`, `export_draws_a_symbol_instance_fill_shadow`). Existing documents with a raster effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case). From 134b8b33a1ecff129705c3995d764125f55550fa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 02:59:02 -0800 Subject: [PATCH 5/6] Export: placed symbol art keeps its instance's link An instance whose symbol art has live effects is replaced in the export by a group holding the baked art. That group now takes the instance's attributes as well, so a PDF export keeps the instance's URL as a link annotation over the art where its effects draw it. --- crates/effects/src/bake.rs | 8 +++++--- crates/pdf/src/tests/links.rs | 25 +++++++++++++++++++++++-- docs/target-app-parity.md | 2 +- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 12f967922d..f191f8ce89 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -241,8 +241,8 @@ fn holds_placed_symbol(n: &Node) -> bool { /// Symbol instance `n`, while [`bake_document_placing_symbols`] bakes and its symbol is one it /// places, as the art it draws: the symbol's art with the instance's own appearance changes and /// its stain ([`vectorcraft_doc::symbol_overrides::instance_art`]), placed by the instance as the exporters -/// place it, in a group with the instance's id, name, visibility, transparency, opacity mask and -/// raster effects. The art is placed before its effects are baked, as the canvas draws it, so an +/// place it, in a group with the instance's id, name, visibility, transparency, opacity mask, +/// raster effects and attributes (its URL among them). The art is placed before its effects are baked, as the canvas draws it, so an /// instance that scales its art does not scale what its effects do. `None` for other objects, /// for instances the export does not write ([`unwritten`], hidden or inside such an object), for /// instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and when the art @@ -266,7 +266,9 @@ fn placed_symbol_art(d: &Document, n: &Node) -> Option { return None; } let placed = if clip { d.place_symbol_art(symbol, &art, *xf) } else { d.place_symbol_art_unclipped(symbol, &art, *xf) }?; - Some(dressed(Node::group(n.id, vec![Arc::new(placed)]), n)) + let mut g = dressed(Node::group(n.id, vec![Arc::new(placed)]), n); + g.extras_mut().attrs = n.extras().attrs.clone(); + Some(g) } /// `n` with its geometry effects evaluated (`None` = unchanged). diff --git a/crates/pdf/src/tests/links.rs b/crates/pdf/src/tests/links.rs index c23854439f..f4792e7fa0 100644 --- a/crates/pdf/src/tests/links.rs +++ b/crates/pdf/src/tests/links.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use craft_pdf::syntax::Pdf; use craft_pdf::syntax::object::{Array, Dict, Name}; use vectorcraft_color::Color; -use vectorcraft_doc::{Document, Node, NodeId}; -use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Affine, Rect, shapes}; use super::{add, doc, rect_node}; use crate::*; @@ -89,6 +89,27 @@ fn hidden_and_unprinted_objects_have_no_links_and_clipping_trims_them() { assert_eq!(links(&export(&d, &PdfOptions::default()).unwrap()), vec![Vec::<(String, [f32; 4])>::new()]); } +/// A linked symbol instance whose art has a live effect links over the art where the effect draws it. +#[test] +fn a_linked_instance_keeps_its_link_when_its_art_has_effects() { + let mut art = rect_node(Rect::new(10.0, 40.0, 20.0, 50.0), Color::rgb(0.0, 0.0, 1.0)); + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + let mut d = doc(200.0, 100.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(art) }); + let mut instance = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY }); + instance.edit_attrs(|a| a.url = "https://example.com/symbol".into()); + add(&mut d, instance); + let pages = links(&export(&d, &PdfOptions::default()).unwrap()); + let [(uri, rect)] = pages[0].as_slice() else { panic!("one link: {pages:?}") }; + assert_eq!(uri, "https://example.com/symbol"); + // x 110…120 after the 100 pt move; y up: 100 − 50 to 100 − 40. + assert!(near(*rect, [110.0, 50.0, 120.0, 60.0]), "{rect:?}"); +} + #[test] fn each_page_links_its_own_artboard_objects() { let mut d = doc(200.0, 100.0); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 766f58d944..69a13ddc57 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1575,7 +1575,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page describes effects on one fill or stroke, and the help page describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. - Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art, a dynamic symbol instance's own paint changes and the stain an instance's fill gives its art are kept, and the art is stained before its effects apply. A symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Instances the export leaves out (hidden ones, those in hidden groups, on template layers, and on hidden layers or layers that do not print when the export leaves those out) are not placed and do not count toward that limit. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_keeps_the_instances_stain`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`, `placing_symbols_skips_instances_the_export_leaves_out`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`; PDF `a_stained_instance_keeps_its_stain_when_its_art_has_effects`). + Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art, a dynamic symbol instance's own paint changes and the stain an instance's fill gives its art are kept, and the art is stained before its effects apply. A linked instance's PDF link covers its art where the effects draw it. A symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Instances the export leaves out (hidden ones, those in hidden groups, on template layers, and on hidden layers or layers that do not print when the export leaves those out) are not placed and do not count toward that limit. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_keeps_the_instances_stain`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`, `placing_symbols_skips_instances_the_export_leaves_out`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`; PDF `a_stained_instance_keeps_its_stain_when_its_art_has_effects`, `a_linked_instance_keeps_its_link_when_its_art_has_effects`). A raster effect (Drop Shadow, Outer Glow, Gaussian Blur and the other effects that paint pixels) on a symbol instance's topmost fill draws on each filled shape of the symbol's art, and one on its topmost stroke draws on each stroked shape, after any geometry effects listed before it, under the same rules as geometry effects: only a visible fill or stroke with an opacity above 0 counts, a group's own fills and strokes in the art take the effect, and an instance inside the art passes it on. Each shape gets its own shadow or glow, which is VectorCraft's own rule. The canvas, Use Preview Bounds and the drawn bounds, SVG and PDF export draw it, and Object › Expand Appearance is enabled for such an instance and makes the effect an image at the document's raster effects resolution, in one undo step (engine `expand::a_symbol_instance_fill_shadow_shadows_its_filled_art`, `a_symbol_instance_stroke_shadow_shadows_only_its_stroked_art`, `a_symbol_instance_fill_shadow_follows_the_fill_offset_before_it`, `a_nested_symbol_instance_takes_the_outer_fill_shadow`, `export_draws_a_symbol_instance_fill_shadow`). Existing documents with a raster effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case). From 10382a2f37de53ec5fa152c505b2725964d256d1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:51:14 -0800 Subject: [PATCH 6/6] Export: symbol art dropped from the budget still counts toward it An instance whose baked art passed the export's placed symbol art budget, or drew too deep, gave its count back, so any number of instances could each bake their art in full and be dropped without the work counting. - Art baked and then dropped is now counted with the placed art against the budget, both before more art is baked and when baked art is checked. - While an instance's art bakes, the art's objects before baking are held in the budget, so instances nested in it are placed only within what is left. Documents whose placed art fits the budget export as before. --- crates/effects/src/bake.rs | 45 +++++++++++------- crates/effects/src/bake/tests.rs | 78 ++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 3 files changed, 108 insertions(+), 17 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index f191f8ce89..7374754537 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -121,6 +121,10 @@ thread_local! { /// The objects in the baked art of the instances that [`bake_node`] has replaced by their /// placed art for one export ([`placed_symbol_art`]), counted against [`Placing::most_nodes`]. static PLACED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// The objects in the baked art of the instances that [`bake_node`] baked for one export and + /// then left as they are ([`placed_fits`] refused the art), counted with [`PLACED_NODES`] + /// against [`Placing::most_nodes`] before more art is baked. Never given back. + static DROPPED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; /// How deep the object [`bake_node`] is baking sits in the tree it returns: a top-level layer /// is 1 deep. static LEVEL: std::cell::Cell = const { std::cell::Cell::new(1) }; @@ -179,17 +183,18 @@ fn unwritten(n: &Node) -> bool { } /// Can `baked`, the baked art of a placed instance, take the instance's place [`LEVEL`] deep, -/// when the art placed for the export so far holds `spent` objects? `Some` with the objects -/// placed then, when its objects (opacity masks' art included) fit within +/// when the art placed for the export so far holds `spent` objects and the art baked for +/// instances left as they are holds `dropped` ([`DROPPED_NODES`])? `Some` with the +/// objects placed then, when its objects (opacity masks' art included) fit with those within /// [`Placing::most_nodes`] and nothing in it, the symbols and patterns it uses included, draws /// deeper than [`vectorcraft_doc::depth::MAX_DRAWN_DEPTH`]. -fn placed_fits(baked: &Node, spent: u64) -> Option { +fn placed_fits(baked: &Node, spent: u64, dropped: u64) -> Option { PLACING.with(|p| { let p = p.borrow(); let p = p.as_ref()?; let spent = spent.saturating_add(baked.count_with_masks() as u64); let deepest = p.heights.deepest(baked, LEVEL.with(|c| c.get())); - (spent <= p.most_nodes && deepest <= vectorcraft_doc::depth::MAX_DRAWN_DEPTH).then_some(spent) + (spent.saturating_add(dropped) <= p.most_nodes && deepest <= vectorcraft_doc::depth::MAX_DRAWN_DEPTH).then_some(spent) }) } @@ -246,10 +251,12 @@ fn holds_placed_symbol(n: &Node) -> bool { /// instance that scales its art does not scale what its effects do. `None` for other objects, /// for instances the export does not write ([`unwritten`], hidden or inside such an object), for /// instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and when the art -/// as placed, before its effects are baked, would already take the art placed for the export -/// past [`Placing::most_nodes`]; those instances stay as they are. [`bake_node`] checks the baked -/// art with [`placed_fits`]. -fn placed_symbol_art(d: &Document, n: &Node) -> Option { +/// as placed, before its effects are baked, would already take the art placed for the export, +/// with the art baked for instances left as they are ([`DROPPED_NODES`]), past +/// [`Placing::most_nodes`]; those instances stay as they are. `Some` with the objects in the art +/// as placed, before its effects are baked. [`bake_node`] checks the baked art with +/// [`placed_fits`]. +fn placed_symbol_art(d: &Document, n: &Node) -> Option<(Node, u64)> { let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; let (clip, most_nodes) = PLACING.with(|p| p.borrow().as_ref().filter(|p| p.symbols.contains(symbol)).map(|p| (p.clip_slices, p.most_nodes)))?; if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH || UNWRITTEN.with(|c| c.get()) || unwritten(n) { @@ -262,13 +269,13 @@ fn placed_symbol_art(d: &Document, n: &Node) -> Option { Some(m) => m.most_placed_unclipped(&art, d), None => art.count_with_masks() as u64, }; - if PLACED_NODES.with(|c| c.get()).saturating_add(most) > most_nodes { + if PLACED_NODES.with(|c| c.get()).saturating_add(DROPPED_NODES.with(|c| c.get())).saturating_add(most) > most_nodes { return None; } let placed = if clip { d.place_symbol_art(symbol, &art, *xf) } else { d.place_symbol_art_unclipped(symbol, &art, *xf) }?; let mut g = dressed(Node::group(n.id, vec![Arc::new(placed)]), n); g.extras_mut().attrs = n.extras().attrs.clone(); - Some(g) + Some((g, most)) } /// `n` with its geometry effects evaluated (`None` = unchanged). @@ -346,18 +353,22 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { // Symbol instances whose art draws live effects, when the caller asks for them: the art // placed, then baked. When the baked art passes the export's budget, or would draw too deep // where the instance is, it is dropped with the art placed for the instances inside it, and - // the instance stays as it is. - if let Some(m) = placed_symbol_art(d, n) { + // the instance stays as it is. While the art bakes, its objects before baking are held in + // the budget, so the instances inside it are placed only within what is left; art baked and + // then dropped still counts against the budget for the instances after it. + if let Some((m, most)) = placed_symbol_art(d, n) { let spent = PLACED_NODES.with(|c| c.get()); + PLACED_NODES.with(|c| c.set(spent.saturating_add(most))); INSTANCE_DEPTH.with(|c| c.set(c.get() + 1)); let baked = bake_node(d, &m); INSTANCE_DEPTH.with(|c| c.set(c.get() - 1)); let baked = baked.unwrap_or(m); - if let Some(now) = placed_fits(&baked, spent) { + if let Some(now) = placed_fits(&baked, spent, DROPPED_NODES.with(|c| c.get())) { PLACED_NODES.with(|c| c.set(now)); return Some(baked); } PLACED_NODES.with(|c| c.set(spent)); + DROPPED_NODES.with(|c| c.set(c.get().saturating_add(baked.count_with_masks() as u64))); } // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its // patterns: each copy an object of its own, so its gradient fits it rather than spanning @@ -895,14 +906,15 @@ pub fn bake_document_placing_symbols(doc: &Document, clip_slices: bool, layers: } /// Restores what [`bake`] changes in the thread's baking state when it ends. -struct Restore(Option>, u64, usize, bool); +struct Restore(Option>, u64, u64, usize, bool); impl Drop for Restore { fn drop(&mut self) { PLACING.with(|p| *p.borrow_mut() = self.0.take()); PLACED_NODES.with(|c| c.set(self.1)); - LEVEL.with(|c| c.set(self.2)); - UNWRITTEN.with(|c| c.set(self.3)); + DROPPED_NODES.with(|c| c.set(self.2)); + LEVEL.with(|c| c.set(self.3)); + UNWRITTEN.with(|c| c.set(self.4)); } } @@ -924,6 +936,7 @@ fn bake(doc: &Document, placing: Option<(bool, u64, WrittenLayers)>) -> Option> = (0..5) + .map(|i| Arc::new(Node::path(NodeId(810 + i), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()))) + .collect(); + art.push(Arc::new(instance(802, "Inner", Affine::IDENTITY))); + let outer = Node::group(NodeId(801), art); + // The outer instance's group around its art, with the inner instance left as it is. + let around = 1 + outer.count_with_masks() as u64; + add_symbol(&mut d, "Outer", outer); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + let inner = all.node(NodeId(802)).map_or(0, |n| n.count_with_masks() as u64); + assert!(inner > 2 && inner <= around, "the inner art alone fits: {inner} <= {around}"); + let out = bake(&d, Some((true, around, ALL))).unwrap(); + assert!(!is_instance(&out, 900) && is_instance(&out, 802), "the outer instance is placed around the inner one"); +} + +/// Art baked for an instance and then left out, because it passes the budget, still counts: an +/// instance after it whose art would fit in the budget alone stays as it is, and a budget with +/// room for both places both. +#[test] +fn placing_symbols_counts_the_art_it_drops() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(901, "Small", Affine::IDENTITY)); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, instance(900, "Big", Affine::IDENTITY)).unwrap(); + add_symbol(&mut d, "Big", gradient_copies(800)); + add_symbol(&mut d, "Small", moved_square(810)); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + let count = |id: u64| all.node(NodeId(id)).map_or(0, |n| n.count_with_masks() as u64); + let (big, small) = (count(900), count(901)); + assert!(small < big, "{small} < {big}"); + let short = bake(&d, Some((true, big - 1, ALL))).unwrap(); + assert!(is_instance(&short, 900), "the big art passes {}", big - 1); + assert!(is_instance(&short, 901), "the big art's {big} objects count, so the small art does not fit"); + let both = bake(&d, Some((true, big + small, ALL))).unwrap(); + assert!(!is_instance(&both, 900) && !is_instance(&both, 901)); +} + /// Instances the export does not write are not placed and do not count against the budget: with /// room for one instance's baked art, hidden instances, an instance in a hidden group, one on a /// template layer, one on a layer that does not print and one on a hidden layer (when the export @@ -1329,3 +1374,36 @@ fn placing_symbols_keeps_the_baked_art_within_the_drawn_depth() { add_symbol(&mut shallow, "E", in_groups(5000, 1, gradient_copies(5100))); assert!(!is_instance(&bake_document_placing_symbols(&shallow, true, ALL).unwrap(), 900)); } + +/// Art baked for an instance and then left out because it draws too deep still counts when the +/// baked art of a later instance is checked: that art fits before its effects add copies, but +/// not once they do. +#[test] +fn placing_symbols_counts_art_dropped_for_depth_against_later_baked_art() { + use vectorcraft_geom::Affine; + // The chain of placing_symbols_keeps_the_baked_art_within_the_drawn_depth, then F, whose + // square becomes a group of copies once baked. + let mut d = doc_with(instance(900, "A", Affine::IDENTITY)); + for (i, (name, next)) in [("A", "B"), ("B", "C"), ("C", "D"), ("D", "E")].into_iter().enumerate() { + let base = 1000 * (i as u64 + 1); + add_symbol(&mut d, name, in_groups(base, 30, instance(base + 100, next, Affine::IDENTITY))); + } + add_symbol(&mut d, "E", in_groups(5000, 1, gradient_copies(5100))); + add_symbol(&mut d, "F", gradient_copies(6000)); + let layer = d.layers[0].id; + d.insert(Some(layer), 1, instance(901, "F", Affine::IDENTITY)).unwrap(); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + assert!(is_instance(&all, 4100) && !is_instance(&all, 900) && !is_instance(&all, 901)); + let count = |d: &Document, id: u64| d.node(NodeId(id)).map_or(0, |n| n.count_with_masks() as u64); + let (a, f) = (count(&all, 900), count(&all, 901)); + // E's baked art, as placed where nothing above it draws deep. + let mut shallow = doc_with(instance(4100, "E", Affine::IDENTITY)); + add_symbol(&mut shallow, "E", in_groups(5000, 1, gradient_copies(5100))); + let e = count(&bake(&shallow, Some((true, u64::MAX, ALL))).unwrap(), 4100); + assert!(f > 2, "F's copies are objects of their own: {f}"); + let short = bake(&d, Some((true, a + e + f - 1, ALL))).unwrap(); + assert!(!is_instance(&short, 900) && is_instance(&short, 4100), "A to D are placed, E is not"); + assert!(is_instance(&short, 901), "E's dropped {e} objects count, so F's baked {f} do not fit"); + let room = bake(&d, Some((true, a + e + f, ALL))).unwrap(); + assert!(!is_instance(&room, 900) && !is_instance(&room, 901)); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 69a13ddc57..0ebb9b8ae3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1575,7 +1575,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page describes effects on one fill or stroke, and the help page describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. - Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art, a dynamic symbol instance's own paint changes and the stain an instance's fill gives its art are kept, and the art is stained before its effects apply. A linked instance's PDF link covers its art where the effects draw it. A symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Instances the export leaves out (hidden ones, those in hidden groups, on template layers, and on hidden layers or layers that do not print when the export leaves those out) are not placed and do not count toward that limit. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_keeps_the_instances_stain`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`, `placing_symbols_skips_instances_the_export_leaves_out`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`; PDF `a_stained_instance_keeps_its_stain_when_its_art_has_effects`, `a_linked_instance_keeps_its_link_when_its_art_has_effects`). + Live effects inside a symbol's art (geometry effects, color adjustments and the rest that export bakes) are written where each instance places the art in PDF, print, EPS and DXF, as the canvas draws them: the art is placed first and its effects are applied after, so an instance that scales its art does not scale the distance a Transform effect moves it. Symbols nested in the art, a dynamic symbol instance's own paint changes and the stain an instance's fill gives its art are kept, and the art is stained before its effects apply. A linked instance's PDF link covers its art where the effects draw it. A symbol whose art holds itself stops at the nesting limit, and the symbols' own art keeps its effects. An instance stays an instance in these exports when the objects its art makes once baked (the copies a Transform effect makes included) would take the export's placed symbol art past 16,000,000 objects, or when its baked art would draw deeper than the 128 levels a document may draw; these are VectorCraft's own limits. Art baked for an instance that then stays an instance still counts toward that limit, and while an instance's art bakes, the art's own objects are held in the limit for the instances nested in it. Instances the export leaves out (hidden ones, those in hidden groups, on template layers, and on hidden layers or layers that do not print when the export leaves those out) are not placed and do not count toward that limit. Before, these exports wrote the art without its effects (effects `bake::placing_symbols_bakes_their_art_where_each_instance_places_it`, `placing_symbols_reaches_the_art_of_nested_instances`, `placing_a_symbol_that_holds_itself_stops`, `placing_symbols_keeps_dynamic_instances_changes`, `placing_symbols_keeps_the_instances_stain`, `placing_symbols_counts_the_baked_art`, `placing_symbols_counts_nested_art_once`, `placing_symbols_holds_the_outer_art_while_it_bakes`, `placing_symbols_counts_the_art_it_drops`, `placing_symbols_counts_art_dropped_for_depth_against_later_baked_art`, `placing_symbols_keeps_the_baked_art_within_the_drawn_depth`, `placing_symbols_skips_instances_the_export_leaves_out`; doc `depth::tests::drawn_heights_measure_objects_outside_the_layers`; PDF, print, EPS and DXF `symbol_art_effects_are_written_where_they_draw`, `symbol_art_effects_print_where_they_draw`; PDF `a_stained_instance_keeps_its_stain_when_its_art_has_effects`, `a_linked_instance_keeps_its_link_when_its_art_has_effects`). A raster effect (Drop Shadow, Outer Glow, Gaussian Blur and the other effects that paint pixels) on a symbol instance's topmost fill draws on each filled shape of the symbol's art, and one on its topmost stroke draws on each stroked shape, after any geometry effects listed before it, under the same rules as geometry effects: only a visible fill or stroke with an opacity above 0 counts, a group's own fills and strokes in the art take the effect, and an instance inside the art passes it on. Each shape gets its own shadow or glow, which is VectorCraft's own rule. The canvas, Use Preview Bounds and the drawn bounds, SVG and PDF export draw it, and Object › Expand Appearance is enabled for such an instance and makes the effect an image at the document's raster effects resolution, in one undo step (engine `expand::a_symbol_instance_fill_shadow_shadows_its_filled_art`, `a_symbol_instance_stroke_shadow_shadows_only_its_stroked_art`, `a_symbol_instance_fill_shadow_follows_the_fill_offset_before_it`, `a_nested_symbol_instance_takes_the_outer_fill_shadow`, `export_draws_a_symbol_instance_fill_shadow`). Existing documents with a raster effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case).