Merge pull request #7363 from dotCooCoo/fix/stained-instance-export

Symbols: a stained instance keeps its stain in SVG, PDF and the other exports
This commit is contained in:
BFlat
2026-11-14 13:42:39 +07:00
committed by GitHub
17 changed files with 259 additions and 59 deletions
+3 -45
View File
@@ -114,51 +114,9 @@ fn strip_brushes(n: &mut Node) {
}
}
/// Mix every solid paint under `n` towards `color` by `amount` (0..1) — the Symbol Stainer.
pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
let amount = amount.clamp(0.0, 1.0);
for it in &mut n.appearance.items {
let p = match it {
AppearanceItem::Fill(f) => &mut f.paint,
AppearanceItem::Stroke(s) => &mut s.paint,
};
if let Paint::Solid { color: c, .. } = p {
*p = Paint::solid(c.lerp(color, amount));
}
}
if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
for p in [&mut r.style.fill, &mut r.style.stroke] {
if let Paint::Solid { color: c, .. } = p {
*p = Paint::solid(c.lerp(color, amount));
}
}
}
}
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
tint_node(Arc::make_mut(c), color, amount);
}
}
}
/// The stain instance `inst` puts on its symbol's art: its visible fill colour and that fill's
/// opacity (how far the art mixes towards it). `None` for an unstained instance, including one
/// whose fill has an opacity of 0, so its art keeps its paints and their swatch links.
pub fn stain(inst: &Node) -> Option<(Color, f32)> {
let f = inst.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)?;
Some((f.paint.color()?, f.opacity))
}
/// A symbol's art for instance `inst`: with the instance's own appearance changes to it (a dynamic
/// symbol's, [`vectorcraft_doc::symbol_overrides`]), stained by the instance's fill ([`stain`]).
pub fn instance_art(art: &Node, inst: &Node) -> Node {
let mut a = vectorcraft_doc::symbol_overrides::dynamic_art(art, inst);
if let Some((c, amount)) = stain(inst) {
tint_node(&mut a, &c, amount);
}
a
}
/// The Symbol Stainer's mix, an instance's stain and its stained art, from
/// [`vectorcraft_doc::symbol_overrides`], which the effects that reshape an instance also read.
pub use vectorcraft_doc::symbol_overrides::{instance_art, stain, tint_node};
/// The solid colours of the fills and strokes under `nodes`, most used first (then in the order
/// they first appear): the colours Brush Options offers as Hue Shift's key colour, the first being
+1 -1
View File
@@ -683,7 +683,7 @@ impl<'a> Scene<'a> {
{
// Symbols holding instances of symbols are written in full for each instance,
// empty or not: every instance's art counts against one budget for the export.
let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n);
let count = art.count_with_masks() as u64;
// Counted as `place_symbol_art_unclipped` places it: no clip groups.
let most = doc.symbol_slice_map(symbol, *xf).map_or(count, |m| m.most_placed_unclipped(&art, doc));
+15
View File
@@ -1395,6 +1395,21 @@ fn crop_keeps_an_instance_whose_art_reaches_the_region() {
assert!(!kinds(&export(&d, &o).unwrap()).is_empty());
}
/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it.
#[test]
fn a_stained_instance_is_written_in_its_stained_color() {
let mut d = doc_with(|d| {
let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((30.0, 30.0)) });
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
vec![n]
});
let art = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) });
let out = export(&d, &opts(&d)).unwrap();
let colors: Vec<Option<String>> = entities(&out).iter().map(|e| get(e, 420).map(str::to_string)).collect();
assert!(!colors.is_empty() && colors.iter().all(|c| c.as_deref() == Some("255")), "{colors:?}");
}
#[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
+50 -4
View File
@@ -2,14 +2,15 @@
//! symbol keeps appearance changes of its own to objects in the symbol's art, while the art stays
//! the symbol's. Editing the symbol reshapes every instance and each keeps its changes; Reset drops
//! them. The changes are stored on the instance ([`crate::node::NodeExtras::symbol_overrides`]) and
//! applied wherever an instance is drawn or exported ([`dynamic_art`]).
//! applied wherever an instance is drawn or exported ([`dynamic_art`]), and an instance's fill
//! stains the art after them ([`instance_art`]).
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_color::{BlendMode, Color, Paint};
use crate::{Node, NodeKind};
use crate::{AppearanceItem, Node, NodeKind};
/// Deepest object an override may name: groups nested this deep in the symbol's art.
pub const MAX_PATH_DEPTH: usize = 64;
@@ -150,6 +151,52 @@ pub fn dynamic_art(art: &Node, inst: &Node) -> Node {
a
}
/// Mix every solid paint under `n` towards `color` by `amount` (0..1): the Symbol Stainer.
pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
let amount = amount.clamp(0.0, 1.0);
for it in &mut n.appearance.items {
let p = match it {
AppearanceItem::Fill(f) => &mut f.paint,
AppearanceItem::Stroke(s) => &mut s.paint,
};
if let Paint::Solid { color: c, .. } = p {
*p = Paint::solid(c.lerp(color, amount));
}
}
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
for p in [&mut r.style.fill, &mut r.style.stroke] {
if let Paint::Solid { color: c, .. } = p {
*p = Paint::solid(c.lerp(color, amount));
}
}
}
}
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
tint_node(Arc::make_mut(c), color, amount);
}
}
}
/// The stain instance `inst` puts on its symbol's art: its visible fill color and that fill's
/// opacity (how far the art mixes towards it). `None` for an unstained instance, including one
/// whose fill has an opacity of 0, so its art keeps its paints and their swatch links.
pub fn stain(inst: &Node) -> Option<(Color, f32)> {
let f = inst.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)?;
Some((f.paint.color()?, f.opacity))
}
/// A symbol's art for instance `inst`, as the canvas draws it: with the instance's own appearance
/// changes to it ([`dynamic_art`]), stained by the instance's fill ([`stain`]).
pub fn instance_art(art: &Node, inst: &Node) -> Node {
let mut a = dynamic_art(art, inst);
if let Some((c, amount)) = stain(inst) {
tint_node(&mut a, &c, amount);
}
a
}
/// Keep the overrides that name an object of `art` (a symbol's new art), dropping the rest.
pub fn retain_parts(overrides: &mut Vec<SymbolOverride>, art: &Node) {
overrides.retain(|o| part(art, &o.path).is_some());
@@ -159,7 +206,6 @@ pub fn retain_parts(overrides: &mut Vec<SymbolOverride>, art: &Node) {
mod tests {
use super::*;
use crate::{Appearance, NodeId};
use vectorcraft_color::Color;
use vectorcraft_geom::{Rect, shapes};
fn square(id: u64) -> Arc<Node> {
+3 -2
View File
@@ -523,13 +523,14 @@ fn color_mask_keys<'a>(n: &'a Node, out: &mut Vec<&'a str>) {
/// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in
/// `d` (named by their content), and a symbol instance stands for its symbol's art: `symbol_art`
/// when given, otherwise the symbol's art with the instance's own appearance changes.
/// when given, otherwise the symbol's art with the instance's own appearance changes, stained by
/// the instance's fill ([`vectorcraft_doc::symbol_overrides::instance_art`]).
pub fn adjust_in_document(d: &mut Document, n: &Node, symbol_art: Option<Node>) -> Option<Node> {
let art;
let src = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => {
let symbol = symbol_art
.or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n)));
.or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::instance_art(&s.art, n)));
art = crate::reshape::as_art(Some(d), n, symbol.as_ref())?;
&art
}
+3 -1
View File
@@ -148,9 +148,11 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
if let Some(m) = moved_type_group(n) {
return Some(bake_pieces(d, m));
}
// A symbol instance's art as the canvas draws it: with its own appearance changes, stained
// by its fill.
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => {
d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n))
d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::instance_art(&s.art, n))
}
_ => None,
};
+40
View File
@@ -59,6 +59,46 @@ fn object_effects_become_geometry_and_raster_effects_stay() {
assert_eq!(m.appearance.effects[0].id, "stylize.dropShadow");
}
/// The fill colors of the paths under the layers of `d`, in paint order.
fn path_fills(d: &Document) -> Vec<Color> {
let mut out = vec![];
d.walk(|n| {
if matches!(n.kind, NodeKind::Path { .. })
&& let Some(c) = n.appearance.fill_paint().color()
{
out.push(c);
}
});
out
}
/// A symbol instance half stained red is baked in its stained colors, as the canvas draws it:
/// moved by a Transform effect, and recolored by a color adjustment (Invert Colors) after the
/// stain.
#[test]
fn a_stained_instance_is_baked_in_its_stained_colors() {
let blue = Color::rgb8(30, 90, 220);
let art = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(blue), Paint::None, 0.0));
let mut inst = Node::new(NodeId(50), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: vectorcraft_geom::Affine::translate((20.0, 20.0)) });
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 vectorcraft_doc::AppearanceItem::Fill(f) = &mut inst.appearance.items[0] {
f.opacity = 0.5;
}
let stained = vectorcraft_doc::symbol_overrides::instance_art(&art, &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": 30.0}), stained), (crate::addons::color::INVERT, json!({}), inverted)] {
let mut n = inst.clone();
n.appearance.effects.push(fx(id, params));
let mut d = doc_with(n);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art.clone()) });
let fills = path_fills(&bake_document(&d).unwrap());
assert_eq!(fills.iter().map(Color::to_hex).collect::<Vec<_>>(), [want.to_hex()], "{id}");
}
}
#[test]
fn a_baked_transform_moves_a_placed_gradient_with_the_path() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
+14
View File
@@ -2067,3 +2067,17 @@ fn colr_glyphs_of_outlined_type_are_vector_art() {
assert!(rasters(&text).is_empty(), "no images: {text}");
assert!(text.contains("1 0 0 Xa"), "the red layer: {text}");
}
/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it.
#[test]
fn a_stained_instance_is_written_in_its_stained_color() {
let mut doc = Document::new(200.0, 100.0);
let art = shape(&mut doc, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), red(), Paint::None);
doc.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) });
let mut n = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((50.0, 30.0)) });
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
let l = doc.layers[0].id;
doc.insert(Some(l), 0, n).unwrap();
let text = String::from_utf8(write(&doc).bytes).unwrap();
assert!(text.contains("0 0 1 Xa") && !text.contains("1 0 0 Xa"), "{text}");
}
+1 -1
View File
@@ -297,7 +297,7 @@ impl<'a> Writer<'a> {
self.warn(LIVE);
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
&& let Some(mut art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf)
&& let Some(mut art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n), *xf)
{
art.visible = true;
self.nest += 1;
+1 -1
View File
@@ -511,7 +511,7 @@ impl<'a> Scene<'a> {
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
&& let Some(art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf)
&& let Some(art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n), *xf)
{
self.nest += 1;
self.node(&art, true);
+14
View File
@@ -1390,6 +1390,20 @@ fn an_instance_whose_art_reaches_the_region_is_written() {
assert!(page.contains("1 0 0 rg"), "{page}");
}
/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it.
#[test]
fn a_stained_instance_is_written_in_its_stained_color() {
let mut d = doc_with(|d| {
let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((50.0, 50.0)) });
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
vec![n]
});
let art = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) });
let page = page_of(&d, &opts(&d));
assert!(page.contains("0 0 1 rg") && !page.contains("1 0 0 rg"), "{page}");
}
#[test]
fn an_instance_whose_brush_art_reaches_the_region_is_written() {
// The symbol's art: a line from (0, 40) to (0, 60) with a 1 pt red stroke painted by a 20 pt
+1 -1
View File
@@ -217,7 +217,7 @@ impl<'a> Scene<'a> {
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
&& let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n)
&& let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n)
&& let Some(art) =
if self.clips { self.doc.place_symbol_art(symbol, &art, *xf) } else { self.doc.place_symbol_art_unclipped(symbol, &art, *xf) }
{
+16
View File
@@ -399,6 +399,22 @@ fn an_instance_whose_art_reaches_the_artboard_is_written() {
assert!(k.contains(&39), "a brush fills the art: {k:?}");
}
/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it,
/// in EMF (clipped) and in WMF (unclipped).
#[test]
fn a_stained_instance_is_written_in_its_stained_color() {
let mut inst = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((30.0, 30.0)) });
inst.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
let mut d = doc_with(100.0, 100.0, vec![inst]);
let art = path(shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), red(), Paint::None, 0.0);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) });
for clips in [true, false] {
let (ops, _) = crate::scene::Scene::new(&d, d.artboards[0].rect, clips).run();
let colors: Vec<[u8; 3]> = fills(&ops).into_iter().map(|(_, rgb)| rgb).collect();
assert_eq!(colors, [[0, 0, 255]], "clips: {clips}");
}
}
/// A pattern `name` repeating `art` every point.
fn point_tiles(name: &str, art: Node) -> PatternDef {
let mut def = PatternDef::new(name, vec![Arc::new(art)]);
+4 -3
View File
@@ -336,14 +336,15 @@ impl Exporter<'_> {
// instances multiply.
// Placing through 9-slice guides needs room for the most objects it can make
// (`NineSliceMap::most_placed`), and the objects it makes count.
// A dynamic symbol's instance shows its own appearance changes.
// A dynamic symbol's instance shows its own appearance changes, and the instance's
// fill stains its art, as on the canvas.
let doc = self.doc;
if let Some(sym) = doc.symbols.iter().find(|x| &x.name == symbol) {
let changed;
let art: &Node = if n.extras().symbol_overrides.is_empty() {
let art: &Node = if n.extras().symbol_overrides.is_empty() && vectorcraft_doc::symbol_overrides::stain(n).is_none() {
&sym.art
} else {
changed = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
changed = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n);
&changed
};
let count = art.count() as u64;
+65
View File
@@ -271,6 +271,71 @@ fn a_scaled_nine_slice_instance_keeps_its_corners() {
assert!(close(boxes[2], Rect::new(105.0, 85.0, 135.0, 105.0), 0.01), "{boxes:?}");
}
/// An instance of symbol `name` placed by `xf`, its fill red at `opacity` (shown when `visible`).
fn stained_instance(name: &str, xf: Affine, opacity: f32, visible: bool) -> Node {
let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf });
n.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into();
n.appearance.items.retain(AppearanceItem::is_fill);
if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] {
(f.opacity, f.visible) = (opacity, visible);
}
n
}
/// The fill colors of `d`'s leaves, in paint order.
fn leaf_fills(d: &Document) -> Vec<String> {
leaves(d).iter().filter_map(|n| n.appearance.fill_paint().color()).map(|c| c.to_hex()).collect()
}
/// A stained instance is written in the colors the canvas draws it in
/// ([`vectorcraft_brush::instance_art`]): on its own, with a Transform effect that moves it, inside
/// a group, after a dynamic symbol's own fill change, and placed through 9-slice guides. A hidden
/// fill or one at 0% opacity leaves the art's colors as they are.
#[test]
fn a_stained_instance_keeps_its_stain() {
let blue = Color::rgb8(30, 90, 220);
let art = || Node::group(NodeId(0), vec![Arc::new(rect_node(Rect::new(0.0, 0.0, 100.0, 100.0), blue))]);
let canvas = |n: &Node| leaf_fills_of(&vectorcraft_brush::instance_art(&art(), n));
fn leaf_fills_of(n: &Node) -> Vec<String> {
let mut d = doc(10.0, 10.0);
add(&mut d, n.clone());
leaf_fills(&d)
}
let write = |n: Node, nine_slice: bool| {
let mut d = doc(300.0, 200.0);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: true, name: "Panel".into(), art: Arc::new(art()) });
if nine_slice {
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
}
add(&mut d, n);
leaf_fills(&roundtrip(&d))
};
let half = stained_instance("Panel", Affine::translate((20.0, 20.0)), 0.5, true);
let want = canvas(&half);
assert_ne!(want, [blue.to_hex()], "the stain changes the color");
assert_eq!(write(half.clone(), false), want, "on its own");
let mut moved = half.clone();
moved.appearance.effects.push(vectorcraft_doc::Effect {
id: "distort.transform".into(),
params: serde_json::json!({"moveH": 30.0}),
visible: true,
});
assert_eq!(write(moved, false), want, "with a geometry effect");
assert_eq!(write(Node::group(NodeId(0), vec![Arc::new(half.clone())]), false), want, "in a group");
let mut scaled = half.clone();
scaled.kind = NodeKind::SymbolInstance { symbol: "Panel".into(), xf: Affine::translate((20.0, 20.0)) * Affine::scale_non_uniform(2.0, 1.5) };
assert_eq!(write(scaled, true), want, "through 9-slice guides");
let mut changed = half.clone();
changed.extras_mut().symbol_overrides =
vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(Paint::solid(Color::rgb(0.0, 1.0, 0.0))), ..Default::default() }];
let changed_want = canvas(&changed);
assert_ne!(changed_want, want, "the part's own fill is stained");
assert_eq!(write(changed, false), changed_want, "after a dynamic symbol's change");
for (opacity, visible) in [(0.0, true), (0.5, false)] {
assert_eq!(write(stained_instance("Panel", Affine::IDENTITY, opacity, visible), false), [blue.to_hex()], "unstained");
}
}
/// 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
+27
View File
@@ -272,6 +272,33 @@ fn instances_the_def_cant_stand_for_get_their_own_art() {
assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 24.0, 0.002);
}
/// An instance half stained red whose Transform effect moves it 60 pt right is written in its
/// stained colors where the canvas draws it, on its own and inside a group.
#[test]
fn a_stained_instance_with_a_geometry_effect_keeps_its_stain() {
for grouped in [false, true] {
let mut d = symbol_doc(0.0, &[]);
let mut stained = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "My Mark".into(), xf: Affine::translate((40.0, 60.0)) });
stained.appearance = Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 0.0).into();
stained.appearance.items.retain(|i| i.is_fill());
if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut stained.appearance.items[0] {
f.opacity = 0.5;
}
stained.appearance.effects.push(vectorcraft_doc::Effect {
id: "distort.transform".into(),
params: serde_json::json!({"moveH": 60.0}),
visible: true,
});
let n = if grouped { Node::group(d.alloc_id(), vec![Arc::new(stained)]) } else { stained };
let l = d.layers[0].id;
d.insert(Some(l), usize::MAX, n).unwrap();
let svg = export(&d, &ExportOptions::default());
let blue = Color::rgb8(30, 90, 220).to_hex().to_ascii_lowercase();
assert!(!svg.to_ascii_lowercase().contains(&blue), "the symbol's own blue is stained: {svg}");
assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 24.0, 0.003);
}
}
#[test]
fn pattern_paints_in_symbols_stay_on_the_page() {
let mut d = Document::new(200.0, 120.0);
+1
View File
@@ -940,6 +940,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Dynamic symbol appearance follows what an object draws** (VectorCraft's own rule): `symbol.instance.setAppearance` refuses a fill or stroke for an object of the art that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a nested symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect) with an error that names the key and the object, and the call changes nothing. Opacity, blend and `null`, which takes a stored change back, are taken for every object. Tests: `crates/engine/src/tests/dynamic_symbols.rs`.
- Symbol editing mode keeps every instance in step: instances of a dynamic symbol inside other symbols' art, pattern tiles, graph designs, stored brushes and opacity masks keep their appearance changes on the objects an edit reorders and drop the changes to objects it deletes. The view does not draw the instance the edit was entered from. Area type in the symbol's art takes the text wrap that moving a wrap object gives it. Editing another symbol at a place that fails keeps the current edit open, and only the editing layer or art on it can be isolated. Tests: `crates/engine/src/tests/symboledit.rs`, `isolation::symbol_editing_does_not_draw_the_instance_it_was_entered_from`, `panels::symbols::tests::double_clicking_a_symbol_in_the_panel_edits_it_in_either_view`, `the_panel_menus_edit_symbol_edits_the_instances_symbol_or_the_panels`, `the_control_bars_edit_symbol_edits_the_selected_instances_symbol`, `chrome::tests::the_control_bar_exits_symbol_editing_mode`.
- **Dynamic symbol instances on the canvas** ([Symbols, archived copy](https://web.archive.org/web/20240206075757/https://helpx.adobe.com/illustrator/using/symbols.html)): each instance of a dynamic symbol can have an appearance of its own while it shares the symbol's shape. The rest of this entry is VectorCraft's own rules. A Direct Selection click on an instance of a dynamic symbol selects the path, compound path or type object of the symbol's art under the pointer and outlines it (`symbol.instance.selectPart`); the Swatches and Color panels, the Control bar, the Eyedropper and the other fill and stroke controls then change that object's fill or stroke (solid, none, gradient or pattern) for this instance only, each as one undo step, and `transparency.set` changes its opacity and blend the same way; the symbol's art and its other instances stay as they were. Alt or Shift picks the instance whole. An Eyedropper source that is not a path, compound path or type, a click on any other object of the art (a group, image, blend or nested instance) and the commands that change the selected objects (moving, Delete, Group, Hide, Lock, Arrange, Expand, the gradient annotator, isolate/knockout on Transparency, the Appearance panel's rows and others) are refused with a status message. Copy, Export Selection, Fit to Selection, Liquify, Isolate, Add to Library and Save Selection use the whole instance (`docs/mcp.md`), and another selection tool selects the instance whole again. Tests: `crates/engine/src/tests/dynamic_symbols.rs` (`gradients_and_patterns_on_a_picked_object_are_taken`, `opacity_and_blend_on_a_picked_object_go_through_transparency_set`), `direct::tests::a_click_on_a_dynamic_instance_picks_the_object_under_the_pointer`.
- **Stained symbol instances in export** ([the help page](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=136), Stain symbol instances): an instance colored with the Symbol Stainer is exported in the colors the canvas draws it in. The mix is VectorCraft's own rule: after a dynamic symbol's own changes, the instance's visible fill mixes the solid fills and strokes of the symbol's art, type included, toward the fill's color by the fill's opacity; a hidden fill or one at 0% leaves the art's colors as they are. SVG, PDF (printing to PDF included), EPS, legacy `.ai`, DXF, EMF and WMF write the stained colors for an instance on its own, in a group, placed through 9-slice guides or reshaped by its geometry effects, and a color adjustment on the instance recolors the stained art. Tests: `bake::tests::a_stained_instance_is_baked_in_its_stained_colors` (vectorcraft-effects), `tests::a_stained_instance_keeps_its_stain` (vectorcraft-pdf), `symbols_layers::a_stained_instance_with_a_geometry_effect_keeps_its_stain` (vectorcraft-svg), and `a_stained_instance_is_written_in_its_stained_color` in vectorcraft-eps (EPS and legacy `.ai`), vectorcraft-cad and vectorcraft-metafile (EMF and WMF).
- **9-slice guides on the canvas** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): while a symbol with Enable Guides for 9-Slice Scaling on is edited in symbol editing mode, the canvas draws its two horizontal and two vertical guides as dotted lines across its art, with any tool and with ruler guides hidden. With the Selection tool the pointer near a guide shows the Move cursor, and dragging the guide moves it (`symbol.options`'s `nineSliceGuides`) as one undo step; Escape during the drag puts it back. VectorCraft's own rules: a guide within 4 screen pixels takes the press before the art and the bounding box; it may pass the art's edges (see the next entry) and stops short of the opposite guide; the drag does not snap. Tests: `nineslice::tests`, `symbol_nine_slice::tests::the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it`, `a_moved_guide_passes_the_arts_edges_and_stops_short_of_the_opposite_guide`, `canvas::tests::the_edited_symbols_nine_slice_guides_are_drawn_and_dragged_on_the_canvas`.
- **9-slice guides outside the art** ([the help page](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), Use 9-slice scaling section): a guide may lie outside the symbol's art, set by dragging it past the art's edge in symbol editing mode, by `symbol.options`'s `nineSliceGuides` or by a Symbol Options field below 0% or above 100%, and a scaled instance still scales, by the cells its art falls in. VectorCraft's own rules: each guide lies within 16 times the art's width or height of its edge (`-16..16` as a fraction, `-1600..1600` in percent) and the two guides of an axis keep their order; a guide past an edge of the art cuts the art at that edge, so the outer cell on its side holds no art; with both guides of an axis past opposite edges the art scales as a whole along it; with both past the same edge the art lies in the outer cell at the other edge and keeps its size, from that edge of the instance, or shrinks to the instance's width or height when the instance is smaller than the art; Symbol Options shows and keeps the guides unrounded. Native files keep the guides exactly; SVG and `.ai` write such an instance as its placed art and keep no guides. Tests: `symbol_nine_slice::tests::guides_outside_the_art_are_accepted_in_order_and_within_the_limit`, `art_scales_by_the_cells_it_falls_in_when_guides_lie_outside_it`, `the_map_with_guides_outside_the_art_is_continuous_and_monotone` in `crates/doc`, `guides_outside_the_art_are_set_by_command_and_scale_by_the_cells_that_hold_art`, `a_guide_dragged_past_the_arts_edge_lies_outside_it`, `guides_outside_the_art_survive_saving_and_reopening_a_native_file` in `crates/engine`, `nineslice::tests::a_guide_outside_the_art_is_taken_and_dragged_past_the_arts_edge`, `a_nine_slice_instance_with_a_guide_outside_its_art_is_written_as_drawn` (SVG and `.ai`).
- **Delete Symbol asks first** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Alt-click Delete](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): the Symbols panel's Delete Symbol button and menu item ask "Delete the selected symbol?" with Yes and No for a symbol no instance uses. For a symbol with placed instances they ask with Expand Instances (the instances become ordinary art), Delete Instances and Cancel; either Instances button deletes the symbol in one undo step. Alt-click on the button deletes an unused symbol without asking and still asks about one with instances. A question left open while another document became active deletes nothing. `symbol.delete` itself does not ask. Tests: `panels::symbols::tests::delete_symbol_asks_first_and_alt_click_deletes_an_unused_symbol_at_once`, `the_in_use_question_expands_or_deletes_the_instances`, `a_stale_delete_symbol_question_deletes_nothing`, `the_delete_symbol_questions_show_their_buttons`.