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
+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");
}