Fix #3089: a stroked line with an effect expands to a stroked line, then Expand outlines it

Squashed from #4169 onto main.
This commit is contained in:
Taha-Mahmoodi
2026-10-29 18:54:59 +07:00
committed by bflatastic
parent 3cfdb58b90
commit b41599f657
38 changed files with 377 additions and 51 deletions
+5 -4
View File
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand Appearance",
["Object"],
None,
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
can_expand,
expand_appearance
),
@@ -471,7 +471,7 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> {
/// fills or strokes, effects, transparency of a fill or stroke, a brush), a group or layer with
/// fills, strokes or effects of its own or such members, other objects with effects, or type with
/// fills or strokes of its own?
fn expandable(n: &Node) -> bool {
pub(crate) fn expandable(n: &Node) -> bool {
match &n.kind {
NodeKind::Path { clipping: true, .. } | NodeKind::Path { guide: true, .. } => false,
NodeKind::Path { .. } | NodeKind::Compound { .. } => !n.appearance.is_basic() || vectorcraft_brush::has_brush(n),
@@ -582,8 +582,9 @@ fn fresh_ids_listed(d: &mut Document, c: &mut Node, seen: &mut std::collections:
}
/// Expand Appearance of `id` (and, recursively, of a group's or layer's members): every fill and
/// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects
/// are baked, raster effects become an embedded image, and the object keeps its transparency.
/// stroke becomes an object of its own (a stroke a path stroked by it alone, brushes as their art),
/// geometry effects are baked, raster effects become an embedded image, and the object keeps its
/// transparency.
pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
let Some(n) = d.node(id).cloned() else { return };
let mut listed = vec![];
+16 -4
View File
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand…",
["Object"],
None,
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
has_selection,
expand
),
@@ -49,16 +49,25 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand Options",
[],
None,
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to)",
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus)",
has_selection,
|s, _| {
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
Ok(json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient}))
Ok(json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient, "appearance": has_appearance(s)}))
}
),
]
}
/// Has the selection appearance attributes that Expand Appearance expands (effects, several fills
/// or strokes, a fill or stroke with its own opacity, blend mode or effects, a brush)? Object ›
/// Expand… is dimmed in the menus for them until Expand Appearance has turned them into plain art
/// (#3089); `object.expand` itself still bakes them.
pub fn has_appearance(s: &Session) -> bool {
let (Ok(st), Ok(roots)) = (s.doc(), selected_roots(s)) else { return false };
roots.iter().filter_map(|r| st.doc.node(*r)).any(super::effectcmd::expandable)
}
/// Every placed document in `roots` of `d` (at any depth) replaced by an editable copy of the art it
/// shows, keeping its id and place; the resources the art uses join `d` (a name `d` uses for
/// something else gets a free variant).
@@ -186,7 +195,7 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
let to = GradientTo::parse(p)?;
let from = s.doc()?.history.undo.len();
let rev0 = s.doc()?.revision;
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
// Placed documents first: their art (type, strokes, gradients) expands below.
if object && e.placed {
let roots = selected_roots(s)?;
@@ -210,6 +219,9 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
}
if object && e.effects {
let _ = run_raw(s, "effect.expandAppearance", &json!({}));
// What is left to expand is in the baked art: Expand Appearance leaves strokes strokes
// (#3089), a group's own as stroked paths among its members, which Stroke outlines below.
e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
}
if object && e.envelope {
let roots = selected_roots(s)?;
+13 -10
View File
@@ -218,7 +218,8 @@ fn an_images_stroke_with_outline_object_draws_exports_and_expands_along_its_fram
});
}
assert_eq!((images, frames), (1, 1));
// Expand Appearance leaves the stroke's outline (a filled path) below the image, in one step.
// Expand Appearance leaves the frame, stroked 4 pt, below the image, in one step (the stroke
// stays a stroke for Object › Expand to outline, #3089).
let undo = s.doc().unwrap().history.undo.len();
run(&mut s, "effect.expandAppearance", json!({}));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
@@ -226,9 +227,11 @@ fn an_images_stroke_with_outline_object_draws_exports_and_expands_along_its_fram
let kids = n.children().unwrap();
assert_eq!(kids.len(), 2, "{n:?}");
assert!(matches!(kids[1].kind, NodeKind::Image(_)), "{n:?}");
let outline = kids[0].visual_bounds().unwrap();
assert!((outline.x0 - 48.0).abs() < 0.01 && (outline.x1 - 82.0).abs() < 0.01, "{outline:?}");
assert_eq!(kids[0].appearance.fill_paint(), Paint::solid(Color::BLACK));
let frame = kids[0].visual_bounds().unwrap();
assert!((frame.x0 - 48.0).abs() < 0.01 && (frame.x1 - 82.0).abs() < 0.01, "{frame:?}");
assert_eq!(kids[0].appearance.stroke_paint(), Paint::solid(Color::BLACK));
assert_eq!(kids[0].appearance.stroke_width(), 4.0);
assert!(kids[0].appearance.fill_paint().is_none());
}
/// Click with the Selection tool at (x, y) on a 100% view: what is selected afterwards.
@@ -308,13 +311,13 @@ fn group_appearance_round_trips_and_expands() {
let ch = n.children().unwrap();
assert_eq!(ch.len(), 4, "fill, two members, stroke");
// Each is a group of one path per member (sharing the item's opacity and blend mode); the
// stroke's paths are its outlines, filled.
// stroke's paths are stroked by it (#3089).
let first = |n: &Node| n.children().unwrap()[0].appearance.items[0].is_fill();
assert!(first(&ch[0]) && first(&ch[3]) && ch[0].children().unwrap().len() == 2);
let outline = ch[3].children().unwrap()[0].geometric_bounds().unwrap();
assert!((outline.width() - 41.0).abs() < 0.1, "{outline:?}");
// Moved down 50 pt; the outlined 1 pt stroke reaches half a point beyond.
assert!((n.geometric_bounds().unwrap().y0 - 69.5).abs() < 1e-6);
assert!(first(&ch[0]) && !first(&ch[3]) && ch[0].children().unwrap().len() == 2);
let stroked = ch[3].children().unwrap()[0].visual_bounds().unwrap();
assert!((stroked.width() - 41.0).abs() < 0.1, "{stroked:?}");
// Moved down 50 pt; the 1 pt stroke reaches half a point beyond.
assert!((n.visual_bounds().unwrap().y0 - 69.5).abs() < 1e-6);
}
#[test]
+78 -8
View File
@@ -69,9 +69,14 @@ fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() {
let ch = n.children().expect("a group of the fills and the stroke");
assert_eq!(ch.len(), 3);
assert!(ch.iter().all(|c| matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && c.appearance.items.len() == 1));
// In paint order: red fill, the outlined stroke (a filled ring 10 pt out), the green fill.
assert!(ch.iter().all(|c| c.appearance.items[0].is_fill() && c.appearance.items[0].effects().is_empty()));
// In paint order: red fill, the stroke on the path offset 10 pt out (still a 4 pt stroke, which
// Object › Expand would outline, #3089), the green fill.
assert!(ch.iter().all(|c| c.appearance.items[0].effects().is_empty()));
assert!(ch[0].appearance.items[0].is_fill() && !ch[1].appearance.items[0].is_fill() && ch[2].appearance.items[0].is_fill());
assert_eq!(ch[1].appearance.stroke_width(), 4.0);
let ring = ch[1].geometric_bounds().unwrap();
assert!((ring.width() - 120.0).abs() < 0.5, "{ring:?}");
let ring = ch[1].visual_bounds().unwrap();
assert!((ring.width() - 124.0).abs() < 0.5, "{ring:?}");
assert!((ch[2].opacity - 0.5).abs() < 1e-6, "the fill's opacity is the path's own");
assert!(ch[2].appearance.is_basic());
@@ -149,11 +154,11 @@ fn expanded_art_keeps_the_random_transforms_the_canvas_drew() {
art = (*art.children().unwrap()[0]).clone();
assert_eq!((&r["ids"][0], &r["ids"][1]), (&json!(id.0), &json!(art.id.0)), "{case}");
}
// No object keeps an effect, and in the copies cases no object keeps a stroke: every copy
// is expanded.
// No object keeps an effect, and in the copies cases every object has a basic appearance:
// every copy is expanded (its stroke a stroked path of its own, #3089).
art.walk(&mut |n| {
let stroked = case.ends_with("copies") && n.appearance.stroke().is_some_and(|st| !st.paint.is_none());
assert!(n.appearance.effects.is_empty() && !stroked, "{case}: {n:?}");
let unexpanded = case.ends_with("copies") && !n.appearance.is_basic();
assert!(n.appearance.effects.is_empty() && !unexpanded, "{case}: {n:?}");
});
}
assert_eq!(differ, RANDOM_CASES.map(|case| (case, 0)));
@@ -276,8 +281,13 @@ fn a_targeted_layer_with_a_fill_expands_into_art_among_its_members() {
let ch = l.children().unwrap();
assert_eq!(ch.len(), 2, "the square, then the layer's stroke above it");
assert_eq!(ch[0].id, a);
// The layer's stroke is outlined: a filled path.
assert!(ch[1].appearance.items.iter().all(|i| i.is_fill()));
// The layer's stroke stays a stroke, on a path of its own (#3089).
let mut six = 0;
ch[1].walk(&mut |n| {
assert!(n.appearance.items.iter().all(|i| !i.is_fill()), "{n:?}");
six += usize::from(n.appearance.stroke_width() == 6.0);
});
assert!(six > 0, "the layer's 6 pt stroke: {:?}", ch[1]);
let after = render(&s);
assert!(differing(&before, &after, 8) < 200, "{} pixels differ", differing(&before, &after, 8));
}
@@ -331,3 +341,63 @@ fn a_blurred_layer_keeps_its_image_as_its_only_member() {
let ch = l.children().unwrap();
assert!(ch.len() == 1 && matches!(ch[0].kind, NodeKind::Image(_)));
}
/// A selected open curve with no fill, stroked in blue with `stroke` (stroke.set's params) and
/// given a Zig Zag (#3089).
fn zig_zag_curve(s: &mut Session, stroke: Value) -> NodeId {
let id = NodeId(run(s, "path.create", json!({"d": "M 30 100 C 70 40 130 160 170 100"}))["id"].as_u64().unwrap());
run(s, "paint.setFill", json!({"none": true}));
run(s, "paint.setStroke", json!({"color": "#0000ff"}));
run(s, "stroke.set", stroke);
run(s, "effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 8, "ridges": 5, "relative": false}}));
id
}
#[test]
fn a_stroke_stays_a_stroke_with_its_dashes_arrowheads_alignment_and_width_profile() {
for stroke in [
json!({"weight": 4, "cap": "round", "join": "round", "dash": [10, 5]}),
json!({"weight": 3, "startArrow": "Arrow", "endArrow": "Circle"}),
json!({"weight": 8, "profile": "lens"}),
json!({"weight": 6, "align": "inside"}),
] {
let mut s = session();
let id = zig_zag_curve(&mut s, stroke.clone());
let was = node(&s, id).appearance.stroke().unwrap().clone();
let before = render(&s);
run(&mut s, "effect.expandAppearance", json!({}));
// The zig-zag is baked into the path, which keeps the very same stroke and no fill: no outline.
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::Path { .. }), "{stroke}: a path, not a group: {:?}", n.kind);
assert!(n.appearance.effects.is_empty() && n.appearance.is_basic(), "{stroke}");
assert_eq!(n.appearance.stroke(), Some(&was), "{stroke}: the stroke's attributes stay");
assert!(n.appearance.fill().is_none_or(|f| f.paint.is_none()), "{stroke}: no fill");
assert!(n.path_data().unwrap().anchors().count() > 4, "{stroke}: the zig-zag is baked");
let after = render(&s);
assert!(differing(&before, &after, 8) < 40, "{stroke}: {} pixels differ", differing(&before, &after, 8));
assert!(!expandable(&s), "{stroke}: nothing left for Expand Appearance");
}
}
#[test]
fn several_strokes_become_stroked_paths_in_appearance_order() {
let mut s = session();
let id = square(&mut s);
// [red fill, 4 pt blue stroke, 12 pt green stroke] with a Zig Zag on the object.
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "stroke.set", json!({"item": 2, "weight": 12}));
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#00ff00"}));
run(&mut s, "effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 6, "ridges": 4, "relative": false}}));
let items = node(&s, id).appearance.items.clone();
let before = render(&s);
run(&mut s, "effect.expandAppearance", json!({}));
let n = node(&s, id);
let ch = n.children().expect("a group of the fill and the two strokes");
assert_eq!(ch.len(), 3);
for (c, item) in ch.iter().zip(&items) {
assert!(matches!(c.kind, NodeKind::Path { .. }) && c.appearance.items.len() == 1, "{:?}", c.kind);
assert_eq!(&c.appearance.items[0], item, "each piece keeps its own fill or stroke, in appearance order");
}
let after = render(&s);
assert!(differing(&before, &after, 8) < 60, "{} pixels differ", differing(&before, &after, 8));
}
+103 -4
View File
@@ -254,7 +254,10 @@ fn a_pattern_fill_becomes_its_tiles_clipped_by_the_object() {
let mut s = session();
pattern_rect(&mut s, Rect::new(25.0, 25.0, 85.0, 65.0), false);
// Fill expands the pattern; Expand Gradient To has no gradient fill to apply to.
assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": true, "fill": true, "stroke": false, "gradient": false}));
assert_eq!(
run(&mut s, "object.expand.info", json!({})),
json!({"object": true, "fill": true, "stroke": false, "gradient": false, "appearance": false})
);
let before = render(&s);
let undo = s.doc().unwrap().history.undo.len();
run(&mut s, "object.expand", json!({}));
@@ -276,7 +279,10 @@ fn a_pattern_fill_becomes_its_tiles_clipped_by_the_object() {
assert_eq!(differing(&before, &render(&s), 2), 0, "looks the same");
// The tiles are the pattern's art as it is (a stroked live rectangle that Object and Stroke can
// expand).
assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": true, "fill": false, "stroke": true, "gradient": false}));
assert_eq!(
run(&mut s, "object.expand.info", json!({})),
json!({"object": true, "fill": false, "stroke": true, "gradient": false, "appearance": false})
);
run(&mut s, "edit.undo", json!({}));
assert!(matches!(selected(&s).appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Dots"));
// Fill off: the pattern stays live.
@@ -905,7 +911,10 @@ fn fill_off_leaves_the_fill_live_and_stroke_off_keeps_the_stroke() {
fn defaults_match_the_dialog_and_info_tells_what_applies() {
let mut s = session();
gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), white_black(), true);
assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": true, "fill": true, "stroke": true, "gradient": true}));
assert_eq!(
run(&mut s, "object.expand.info", json!({})),
json!({"object": true, "fill": true, "stroke": true, "gradient": true, "appearance": false})
);
let r = run(&mut s, "object.expand", json!({}));
assert_eq!(r["ids"].as_array().unwrap().len(), 1);
// Object, Fill and Stroke on; 255 objects: the live rectangle, its stroke and its gradient.
@@ -916,7 +925,10 @@ fn defaults_match_the_dialog_and_info_tells_what_applies() {
assert_eq!(strips.len(), 255);
assert!(children[1].appearance.stroke_paint().is_none(), "outlined stroke");
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Expand");
assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": false, "fill": false, "stroke": false, "gradient": false}));
assert_eq!(
run(&mut s, "object.expand.info", json!({})),
json!({"object": false, "fill": false, "stroke": false, "gradient": false, "appearance": false})
);
assert!(s.execute("object.expand", &json!({})).is_err(), "nothing left to expand");
// Bad options are refused before anything changes.
run(&mut s, "edit.undo", json!({}));
@@ -924,3 +936,90 @@ fn defaults_match_the_dialog_and_info_tells_what_applies() {
assert!(s.execute("object.expand", &json!({"steps": 0})).is_err());
assert!(matches!(selected(&s).kind, NodeKind::Path { live: Some(_), .. }));
}
/// A selected open line with a 2 pt black stroke, no fill and a Zig Zag effect (#3089).
fn zig_zag_line(s: &mut Session) -> NodeId {
let id = NodeId(run(s, "path.create", json!({"d": "M 20 100 L 180 100"}))["id"].as_u64().unwrap());
run(s, "paint.setFill", json!({"none": true}));
run(s, "paint.setStroke", json!({"color": "#000000"}));
run(s, "stroke.set", json!({"weight": 2}));
run(s, "effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 10, "ridges": 6, "relative": false}}));
id
}
#[test]
fn expand_info_says_when_expand_appearance_comes_first() {
let mut s = session();
zig_zag_line(&mut s);
assert_eq!(run(&mut s, "object.expand.info", json!({}))["appearance"], json!(true), "Zig Zag is an appearance attribute");
// Expand Appearance bakes the zig-zag into the line, which keeps its stroke and gets no fill.
run(&mut s, "effect.expandAppearance", json!({}));
let line = selected(&s);
assert!(matches!(line.kind, NodeKind::Path { .. }), "a path: {:?}", line.kind);
assert!(line.path_data().unwrap().anchors().count() > 6, "the zig-zag is baked");
assert!(line.appearance.effects.is_empty());
assert!(!line.appearance.stroke_paint().is_none(), "still a stroked line");
assert!(line.appearance.fill().is_none_or(|f| f.paint.is_none()), "no fill");
assert_eq!(run(&mut s, "object.expand.info", json!({}))["appearance"], json!(false));
// Then Expand outlines the stroke into a filled shape.
run(&mut s, "object.expand", json!({}));
let outline = selected(&s);
assert!(!outline.appearance.fill_paint().is_none(), "a filled outline");
assert!(outline.appearance.stroke_paint().is_none());
}
/// The paths under `n` (itself included): (how many have a stroke, how many are filled black).
fn strokes_and_black_fills(n: &Node) -> (usize, usize) {
let (mut stroked, mut black) = (0, 0);
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
stroked += usize::from(!c.appearance.stroke_paint().is_none());
black += usize::from(c.appearance.fill_paint() == Paint::solid(Color::BLACK));
}
});
(stroked, black)
}
#[test]
fn object_expand_outlines_a_stroked_line_with_an_effect_in_one_step() {
let mut s = session();
zig_zag_line(&mut s);
let undo = s.doc().unwrap().history.undo.len();
run(&mut s, "object.expand", json!({}));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
let n = selected(&s);
assert!(n.appearance.effects.is_empty(), "the zig-zag is baked");
assert_eq!(strokes_and_black_fills(&n), (0, 1), "the stroke is outlined: {n:?}");
}
#[test]
fn object_expand_outlines_the_stroke_a_groups_appearance_expands_to() {
// A group of two red squares without strokes; the group has a black stroke and a Transform
// effect of its own, so Stroke has nothing to outline until Expand Appearance has run.
let mut s = session();
let a = NodeId(run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40}))["id"].as_u64().unwrap());
let b = NodeId(run(&mut s, "shape.rectangle", json!({"x": 100, "y": 20, "width": 40, "height": 40}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut s, "paint.setStroke", json!({"none": true}));
run(&mut s, "object.group", json!({}));
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 0, "weight": 2, "color": "#000000"}));
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"moveV": 50}}));
assert_eq!(run(&mut s, "object.expand.info", json!({}))["stroke"], json!(false), "no stroked path yet");
run(&mut s, "object.expand", json!({}));
let g = selected(&s);
assert!(g.appearance.items.is_empty() && g.appearance.effects.is_empty(), "the appearance is expanded");
let (stroked, black) = strokes_and_black_fills(&g);
assert_eq!(stroked, 0, "no stroke is left: {g:?}");
assert_eq!(black, 2, "each square's copy of the group's stroke is outlined: {g:?}");
}
#[test]
fn expand_info_counts_two_fills_but_not_a_plain_rectangle() {
let mut s = session();
run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 60}));
assert_eq!(run(&mut s, "object.expand.info", json!({}))["appearance"], json!(false), "a basic appearance");
run(&mut s, "appearance.addFill", json!({}));
assert_eq!(run(&mut s, "object.expand.info", json!({}))["appearance"], json!(true), "two fills");
}