From 5695823937004fb99c1c0d01dddd2525d2dbb22c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 02:06:18 -0800 Subject: [PATCH] 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.