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
+12 -9
View File
@@ -14,7 +14,8 @@
//! and [`container_keeping_type`] list the other conditions). The renderer still draws its outlines.
//!
//! Object → Expand Appearance goes further ([`expand_leaf`]): every fill and stroke becomes an
//! object of its own, strokes turned into filled art by the caller (their outlines or brush art).
//! 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::sync::Arc;
@@ -440,15 +441,17 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
Some(m)
}
/// Turns one stroke of a path (the path after its geometry effects, its fill rule, the stroke
/// without geometry effects) into filled art for [`expand_leaf`]: its outline filled with its
/// paint, or its brush art. `None` when it paints nothing.
/// Turns one brushed stroke of a path (the path after its geometry effects, its fill rule, the
/// stroke without geometry effects) into its brush art for [`expand_leaf`]. `None` when it paints
/// nothing.
pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, &StrokeLayer) -> Option<Node>;
/// Object → Expand Appearance of a path or compound path `n`: its geometry effects applied, every
/// visible fill becomes a copy of the path painted by that fill alone and every visible stroke the
/// filled art `stroke_art` makes of it (each with its fill's or stroke's opacity and blend mode as
/// its own), in paint order, as the members of a group that keeps
/// visible fill becomes a copy of the path painted by that fill alone and every visible stroke a
/// copy stroked by that stroke alone (its weight, caps, joins, dashes, arrowheads, alignment and
/// width profile stay stroke attributes, which Object → Expand or Outline Stroke outline later,
/// #3089), or for a brushed stroke the brush art `stroke_art` makes of it; each with its fill's or
/// stroke's opacity and blend mode as its own, in paint order, as the members of a group that keeps
/// `n`'s id, name, transparency, opacity mask and remaining (raster) effects. A single piece
/// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and
/// effects are dropped. `None` for other kinds of objects.
@@ -466,8 +469,8 @@ pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option<
clear_item_effects(&mut it);
it.effects_mut().retain(|e| e.visible);
let p = match &it {
AppearanceItem::Stroke(st) => stroke_art(d, &ig, rule, st),
AppearanceItem::Fill(_) => Some(piece(d, n, ig, rule, it)),
AppearanceItem::Stroke(st) if st.brush.is_some() => stroke_art(d, &ig, rule, st),
_ => Some(piece(d, n, ig, rule, it)),
};
pieces.extend(p.map(hoist_transparency));
}
+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");
}
+57 -1
View File
@@ -24,9 +24,20 @@ const OPTIONS: [(&str, &str); 3] = [("object", "Object"), ("fill", "Fill"), ("st
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Expand").into(), body, confirm, min_width: 260.0, ..DialogSpec::FORM };
/// Open Expand for the selection, every option on (as `object.expand {}` does).
/// Why Object › Expand… is dimmed: the selection has appearance attributes, which Expand Appearance
/// turns into plain art first (#3089).
pub(crate) fn appearance_first() -> &'static str {
tl!("Expand Appearance first (Object › Expand Appearance)")
}
/// Open Expand for the selection, every option on (as `object.expand {}` does). A selection with
/// appearance attributes (effects, several fills or strokes…) refuses with [`appearance_first`]:
/// the menu item is dimmed for it.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let info = app.session.execute("object.expand.info", &json!({})).map_err(|e| e.to_string())?;
if info["appearance"] == json!(true) {
return Err(appearance_first().into());
}
let mut fields = json!({"object": true, "fill": true, "stroke": true, "gradient": "objects", "steps": DEFAULT_STEPS});
for (key, _) in OPTIONS {
fields[format!("__{key}")] = info[key].clone();
@@ -169,4 +180,49 @@ mod tests {
let st = app.session.doc().unwrap();
assert!(matches!(st.doc.node(st.selection.objects[0]).unwrap().kind, NodeKind::Path { live: None, .. }));
}
/// A line with a stroke and a Zig Zag effect is selected (#3089).
fn zig_zag_line(app: &mut VectorcraftApp) {
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("path.create", json!({"d": "M 20 100 L 180 100"})).unwrap();
app.run("paint.setFill", json!({"none": true})).unwrap();
app.run("paint.setStroke", json!({"color": "#000000"})).unwrap();
app.run("effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 10, "ridges": 6, "relative": false}})).unwrap();
}
#[test]
fn expand_waits_for_expand_appearance_on_a_line_with_an_effect() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
zig_zag_line(&mut app);
assert!(!crate::menus::enabled(&app, "ui.expandDialog"), "Expand… is dimmed for the Zig Zag effect");
assert!(crate::menus::enabled(&app, "effect.expandAppearance"));
// A shortcut or the command palette doesn't open it either; the status bar says why.
let before = app.session.doc().unwrap().doc.clone();
crate::menus::invoke(&mut app, "object.expand", json!({}));
assert!(app.ui.dialog.is_none());
assert_eq!(app.ui.status, appearance_first());
assert_eq!(app.run("ui.expandDialog", json!({})).unwrap_err(), appearance_first());
assert_eq!(app.session.doc().unwrap().doc, before, "the line is left as it was");
// Expand Appearance first: the line stays a stroked line, and Expand… is available for it.
crate::menus::invoke(&mut app, "effect.expandAppearance", json!({}));
let st = app.session.doc().unwrap();
let line = st.doc.node(st.selection.objects[0]).unwrap();
assert!(line.appearance.effects.is_empty() && !line.appearance.stroke_paint().is_none());
assert!(crate::menus::enabled(&app, "ui.expandDialog"));
crate::menus::invoke(&mut app, "object.expand", json!({}));
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND));
}
#[test]
fn expand_is_dimmed_for_two_fills_but_not_for_a_plain_rectangle() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 60})).unwrap();
assert!(crate::menus::enabled(&app, "ui.expandDialog"), "a basic appearance");
app.run("appearance.addFill", json!({})).unwrap();
assert!(!crate::menus::enabled(&app, "ui.expandDialog"), "two fills");
// Nothing selected: dimmed as before.
app.run("select.none", json!({})).unwrap();
assert!(!crate::menus::enabled(&app, "ui.expandDialog"));
}
}
+2
View File
@@ -5547,4 +5547,6 @@ axis Y Axis Y ос
Save Brush Library… Запази библиотека с четки…
@msg no empty artboards to delete няма празни работни области за изтриване
Scale Presets Пресети за мащаб
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Първо разширете външния вид (Обект › Разширяване на външния вид)
Live preview Преглед на живо
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5636,4 +5636,6 @@ axis Y Axis osa Y
Scale Presets Přednastavení měřítka
@msg a {width}×{height} screenshot's byte count overflows počet bajtů snímku {width}×{height} přeteče
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot okno {what} ({points} bodů × {scale} DPI) nemůže určit velikost snímku
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Nejprve rozložte vzhled (Objekt › Rozložit vzhled)
Live preview Živý náhled
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5562,4 +5562,6 @@ axis Y Axis Y-akse
Save Brush Library… Gem penselbibliotek…
@msg no empty artboards to delete ingen tomme tegneflader at slette
Scale Presets Skaleringsforudindstillinger
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Udvid først udseendet (Objekt › Udvid udseende)
Live preview Direkte forhåndsvisning
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5218,4 +5218,6 @@ axis Y Axis Y-Achse
Scale Presets Skalierungsvorgaben
@msg a {width}×{height} screenshot's byte count overflows die Byteanzahl eines {width}×{height}-Screenshots läuft über
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot die {what} des Fensters ({points} Punkte × {scale} DPI) kann keinen Screenshot bemessen
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Wandeln Sie zuerst das Aussehen um (Objekt › Aussehen umwandeln)
Live preview Live-Vorschau
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5588,4 +5588,6 @@ axis Y Axis Άξονας Y
Scale Presets Προεπιλογές κλίμακας
@msg a {width}×{height} screenshot's byte count overflows το πλήθος byte ενός στιγμιότυπου {width}×{height} υπερχειλίζει
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot το {what} του παραθύρου ({points} σημεία × {scale} DPI) δεν μπορεί να καθορίσει μέγεθος στιγμιότυπου
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Επεκτείνετε πρώτα την εμφάνιση (Αντικείμενο › Επέκταση εμφάνισης)
Live preview Ζωντανή προεπισκόπηση
Can't render this file because it contains an unexpected character in line 2071 and column 19.
+2
View File
@@ -5578,4 +5578,6 @@ axis Y Axis Eje Y
Scale Presets Ajustes predefinidos de escala
@msg a {width}×{height} screenshot's byte count overflows el número de bytes de una captura de {width}×{height} se desborda
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot el {what} de la ventana ({points} puntos × {scale} DPI) no puede dimensionar una captura
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Primero expande la apariencia (Objeto › Expandir apariencia)
Live preview Vista previa en directo
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5667,4 +5667,6 @@ axis Y Axis Y-akseli
Scale Presets Skaalauksen esiasetukset
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} kuvakaappauksen tavumäärä ylittää rajan
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot ikkunan {what} ({points} pistettä × {scale} DPI) ei voi määrittää kuvakaappauksen kokoa
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Laajenna ensin ulkoasu (Objekti › Laajenna ulkoasu)
Live preview Reaaliaikainen esikatselu
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5635,4 +5635,6 @@ axis Y Axis Axe Y
Scale Presets Préréglages de mise à l'échelle
@msg a {width}×{height} screenshot's byte count overflows le nombre d'octets d'une capture {width}×{height} dépasse la limite
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot la {what} de la fenêtre ({points} points × {scale} DPI) ne permet pas de dimensionner une capture
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Décomposez d’abord l’aspect (Objet › Décomposer l’aspect)
Live preview Aperçu en direct
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5622,4 +5622,6 @@ axis Y Axis Y tengely
Scale Presets Méretezési beállítások
@msg a {width}×{height} screenshot's byte count overflows a(z) {width}×{height} képernyőkép bájtszáma túlcsordul
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot az ablak {what} ({points} pont × {scale} DPI) nem tud képernyőkép-méretet adni
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Előbb bontsd ki a megjelenést (Objektum › Megjelenés kibontása)
Live preview Élő előnézet
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5562,4 +5562,6 @@ axis Y Axis Sumbu Y
Save Brush Library… Simpan pustaka kuas…
@msg no empty artboards to delete tidak ada artboard kosong untuk dihapus
Scale Presets Preset skala
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Perluas tampilan terlebih dahulu (Objek › Perluas tampilan)
Live preview Pratinjau langsung
Can't render this file because it contains an unexpected character in line 3864 and column 19.
+2
View File
@@ -5252,4 +5252,6 @@ axis Y Axis Asse Y
Scale Presets Preimpostazioni di scala
@msg a {width}×{height} screenshot's byte count overflows il numero di byte di uno screenshot {width}×{height} va in overflow
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot la {what} della finestra ({points} punti × {scale} DPI) non può dimensionare uno screenshot
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Espandi prima l’aspetto (Oggetto › Espandi aspetto)
Live preview Anteprima dal vivo
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5314,4 +5314,6 @@ axis Y Axis Y 軸
Scale Presets 拡大縮小のプリセット
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} のスクリーンショットのバイト数がオーバーフローします
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot ウィンドウの {what}({points} ポイント × {scale} DPI)ではスクリーンショットのサイズを決められません
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) 先にアピアランスを分割してください(オブジェクト › アピアランスを分割)
Live preview ライブプレビュー
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5550,6 +5550,10 @@ axis Y Axis Y ღერძი
@msg the document has no brushes to save დოკუმენტში შესანახი ფუნჯები არ არის
@msg no empty artboards to delete წასაშლელი ცარიელი სამუშაო დაფები არ არის
Scale Presets მასშტაბის წინასწარ დაყენებული მნიშვნელობები
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} ეკრანის სურათის ბაიტების რაოდენობა გადაივსო
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot ფანჯრის {what} ({points} პუნქტი × {scale} DPI) ეკრანის სურათის ზომას ვერ განსაზღვრავს
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) ჯერ გაშალეთ იერსახე (ობიექტი › იერსახის გაშლა)
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} ეკრანის ანაბეჭდის ბაიტების რაოდენობა გადაივსო
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot ფანჯრის {what} ({points} პუნქტი × {scale} DPI) ეკრანის ანაბეჭდის ზომას ვერ განსაზღვრავს
Live preview ცოცხალი წინასწარი ნახვა
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5215,4 +5215,6 @@ axis Y Axis Y축
Scale Presets 크기 조절 사전 설정
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} 스크린샷의 바이트 수가 넘칩니다
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot 창의 {what}({points}포인트 × {scale} DPI)로는 스크린샷 크기를 정할 수 없습니다
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) 먼저 모양새를 확장하세요 (개체 › 모양새 확장)
Live preview 실시간 미리 보기
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5606,4 +5606,6 @@ axis Y Axis Y-as
Save Brush Library… Penseelbibliotheek opslaan…
@msg no empty artboards to delete geen lege tekengebieden om te verwijderen
Scale Presets Schaalvoorinstellingen
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Breid eerst het uiterlijk uit (Object › Uiterlijk uitbreiden)
Live preview Live voorbeeld
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5490,4 +5490,6 @@ axis Y Axis Oś Y
Scale Presets Ustawienia wstępne skali
@msg a {width}×{height} screenshot's byte count overflows liczba bajtów zrzutu ekranu {width}×{height} przekracza limit
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot {what} okna ({points} punktów × {scale} DPI) nie pozwala ustalić rozmiaru zrzutu ekranu
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Najpierw rozszerz wygląd (Obiekt › Rozszerz wygląd)
Live preview Podgląd na żywo
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5553,4 +5553,6 @@ axis Y Axis Eixo Y
Scale Presets Predefinições de escala
@msg a {width}×{height} screenshot's byte count overflows a contagem de bytes de uma captura {width}×{height} transborda
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot o {what} da janela ({points} pontos × {scale} DPI) não pode dimensionar uma captura
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Expanda a aparência primeiro (Objeto › Expandir Aparência)
Live preview Visualização ao vivo
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -5248,6 +5248,8 @@ axis Y Axis Ось Y
Scale Presets Наборы масштаба
@msg a {width}×{height} screenshot's byte count overflows количество байтов снимка {width}×{height} переполняется
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot {what} окна ({points} пунктов × {scale} DPI) не позволяет задать размер снимка
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Сначала разберите оформление (Объект › Разобрать оформление)
Live preview Просмотр в реальном времени
# Rows the other catalogs had and Russian lacked: document presets, color harmony rules and
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5660,4 +5660,6 @@ axis Y Axis Y-axel
Scale Presets Skalningsförval
@msg a {width}×{height} screenshot's byte count overflows byteantalet för en skärmbild på {width}×{height} överstiger gränsen
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot fönstrets {what} ({points} punkter × {scale} DPI) kan inte bestämma en skärmbilds storlek
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Expandera utseendet först (Objekt › Expandera utseende)
Live preview Direkt förhandsgranskning
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -5593,4 +5593,6 @@ axis Y Axis Y ekseni
Scale Presets Ölçek ön ayarları
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} ekran görüntüsünün bayt sayısı taşıyor
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot pencerenin {what} ({points} nokta × {scale} DPI) bir ekran görüntüsünü boyutlandıramaz
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Önce görünümü genişletin (Nesne › Görünümü Genişlet)
Live preview Canlı önizleme
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -5232,4 +5232,6 @@ axis Y Axis Вісь Y
Scale Presets Набори масштабу
@msg a {width}×{height} screenshot's byte count overflows кількість байтів знімка {width}×{height} переповнюється
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot {what} вікна ({points} пунктів × {scale} DPI) не дозволяє задати розмір знімка
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Спочатку розберіть оформлення (Об’єкт › Розібрати оформлення)
Live preview Перегляд у реальному часі
Can't render this file because it contains an unexpected character in line 749 and column 40.
+2
View File
@@ -5234,4 +5234,6 @@ axis Y Axis Trục Y
Scale Presets Cài đặt sẵn tỉ lệ
@msg a {width}×{height} screenshot's byte count overflows số byte của ảnh chụp {width}×{height} bị tràn
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot {what} của cửa sổ ({points} điểm × {scale} DPI) không thể định cỡ ảnh chụp
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) Hãy mở rộng diện mạo trước (Đối tượng › Mở rộng diện mạo)
Live preview Xem trước trực tiếp
Can't render this file because it contains an unexpected character in line 4084 and column 10.
+2
View File
@@ -5475,4 +5475,6 @@ axis Y Axis Y 轴
Scale Presets 缩放预设
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} 截图的字节数溢出
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot 窗口的{what}({points} 点 × {scale} DPI)无法确定截图大小
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) 请先扩展外观(对象 › 扩展外观)
Live preview 实时预览
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5271,4 +5271,6 @@ axis Y Axis Y 軸
Scale Presets 縮放預設集
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} 螢幕擷取畫面的位元組數溢出
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot 視窗的{what}({points} 點 × {scale} DPI)無法決定螢幕擷取畫面的大小
# Object › Expand… dimmed until Expand Appearance (#3089).
Expand Appearance first (Object › Expand Appearance) 請先展開外觀(物件 › 展開外觀)
Live preview 即時預覽
Can't render this file because it contains an unexpected character in line 6 and column 47.
+12
View File
@@ -152,12 +152,24 @@ fn render_row(app: &VectorcraftApp, ui: &mut egui::Ui, row: &Row, checks: bool,
}
None => ui.add_enabled(row.enabled, egui::Button::new(text).shortcut_text(sc)),
};
let r = match (row.enabled, disabled_hint(app, id)) {
(false, Some(why)) => r.on_disabled_hover_text(why),
_ => r,
};
if r.clicked() {
*clicked = row.target().map(|target| with_held_keys(target, ui.input(|i| i.modifiers)));
ui.close();
}
}
/// Why a dimmed menu item is dimmed, for the ones that say.
fn disabled_hint(app: &VectorcraftApp, id: &str) -> Option<&'static str> {
match id {
"ui.expandDialog" if vectorcraft_engine::cmd::expand::has_appearance(&app.session) => Some(crate::dialogs::expand::appearance_first()),
_ => None,
}
}
/// What the Home screen remembers when it opens (`app.home`): the active document and the
/// document count. When either changes, the Home screen gives way to the document.
pub(crate) fn home_key(app: &VectorcraftApp) -> (Option<u64>, usize) {
+4 -1
View File
@@ -233,7 +233,10 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
id if id.starts_with(crate::panels::symbols::USER_SLOT) => crate::panels::symbols::user_library(app, id).is_some(),
"ui.saveBrushLibrary" => crate::panels::brushes::has_brushes(app),
id if id.starts_with(crate::panels::brushes::USER_SLOT) => crate::panels::brushes::user_library(app, id).is_some(),
"ui.expandDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
// Dimmed for appearance attributes, which Expand Appearance expands first (#3089).
"ui.expandDialog" => {
app.session.active().is_some_and(|d| !d.selection.is_empty()) && !vectorcraft_engine::cmd::expand::has_appearance(&app.session)
}
"ui.spotColors" => app.session.active().is_some(),
"ui.menuDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"ui.simplifyAuto" | "ui.simplifyMoreOptions" => crate::dialogs::simplify_slider::showing(app),
+1 -1
View File
@@ -814,7 +814,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.expandDialog",
"Expand…",
"",
"{} open Expand for the selection (dialog `expand`, fields object, fill, stroke (all on; one the selection has nothing for is disabled, see object.expand.info), gradient: objects|mesh, steps 1..1000 (255)): OK runs object.expand with them as one undo step",
"{} open Expand for the selection (dialog `expand`, fields object, fill, stroke (all on; one the selection has nothing for is disabled, see object.expand.info), gradient: objects|mesh, steps 1..1000 (255)): OK runs object.expand with them as one undo step; dimmed, and an error, while the selection has an appearance effect.expandAppearance expands (object.expand.info's appearance)",
),
(
"attributes.openUrl",
+5 -2
View File
@@ -1,6 +1,6 @@
# Control protocol
> **Last updated:** 2026-10-25 · **Change:** brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726).
> **Last updated:** 2026-10-25 · **Change:** Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726).
`vectorcraft --control <port>` listens on `127.0.0.1:<port>` (loopback only). The port must be a number
from 1 to 65535 (`--control=<port>` works too), given once; otherwise the app prints an error and exits
@@ -415,7 +415,9 @@ opens the `expand` dialog for the selection. Fields: `object`, `fill`, `stroke`
`__stroke` say which the selection has something for, as `object.expand.info` does, and the others are disabled),
`gradient` (`objects` or `mesh`) and `steps` (1–1000, default 255), which are enabled when `fill` is on and
`__gradient` says the selection has a gradient fill (`object.expand.info`'s `gradient`). `ui.dialog.confirm` runs `object.expand` with them as
one undo step.
one undo step. While the selection has an appearance Expand Appearance expands (effects, several fills or strokes, a
brush: `object.expand.info`'s `appearance`), the menu item is dimmed with a tooltip to use Object → Expand Appearance
first, and `ui.expandDialog` refuses with that message (#3089).
Attributes panel: `window.panel {panel: "attributes"}` (Cmd+F11) shows it; its controls run `attributes.set`,
`path.reverse {reversed}` and `path.setFillRule {rule}`, and its Browser button runs the UI command
@@ -947,6 +949,7 @@ hidden tab (whose frames the browser pauses) answers when it is shown again.
| 2026-10-25 | minor | Panel docking on craft-ui's layout: `ui.docking`, the floating and dock parameters, native floating windows. |
| 2026-10-24 | minor | Documented the brush library panel commands (`window.brushLibrary`, `.other`, `ui.saveBrushLibrary`, `window.userBrushLibrary1`…`10`). |
| 2026-10-24 | minor | Documented `ui.screenshot`'s 64 MiB inline-PNG reply cap: with `path` the file is still written and `pngBase64` is omitted when over the cap; a path-less `data: true` request fails before encoding. |
| 2026-10-25 | minor | Documented that Object → Expand… is dimmed, and `ui.expandDialog` refuses, while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089). |
| 2026-10-25 | minor | Documented that Transform Each opens on the values it was last confirmed with and that its Copy button confirms with `copy: true` (#1732). |
| 2026-10-23 | minor | Documented the symbol library panel commands (`window.symbolLibrary`, `.other`, `ui.saveSymbolLibrary`, `window.userSymbolLibrary1`…`10`). |
| 2026-10-13 | minor | Documented count, pending-event and per-frame request budgets (#1709). |
+14 -5
View File
@@ -2275,7 +2275,8 @@ is edited the panel lists only an `<Opacity Mask>` entry and the document tab sa
`effect.expandAppearance {ids?, target?}` (Object → Expand Appearance, enabled when a selected or targeted object's appearance isn't basic)
turns appearances into plain objects in one undo step: each visible fill becomes a copy of the path painted by that
fill alone and each stroke its outline filled with its paint (a brushed stroke: its brush art), grouped in paint order
fill alone and each stroke a copy of the path stroked by that stroke alone, its weight, caps, joins, dashes, arrowheads,
alignment and width profile kept as stroke attributes (a brushed stroke: its brush art), grouped in paint order
under the object's id; each piece takes its fill's or stroke's opacity and blend mode, and the group keeps the
object's transparency and opacity mask. Geometry effects are baked; raster effects become an embedded image at the
document's raster effects resolution (shadows and outer glows under the art; a blur, feather or inner glow replaces
@@ -2284,6 +2285,12 @@ is outlined; type that has no fills or strokes of its own and whose only effects
type, with the image under it. A group's or layer's own fills and strokes become objects among its members, and its
members are expanded too. The result is `{ids}`; when an image in the art can't be decoded, the images made from
raster effects do not show it, and the result also carries the `warning` that Flatten Transparency gives.
A path's strokes stay strokes, as the
[Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help
page's two steps imply (Expand Appearance first, then Expand, whose Stroke option outlines strokes): `object.expand`
or `object.path.outlineStroke` outline them (#3089). Object → Expand… in the menus is dimmed while the selection has
an appearance Expand Appearance expands (`object.expand.info`'s `appearance`); `object.expand` itself still bakes
effects and outlines strokes in one step.
```json
{"name":"run_command","arguments":{"command":"effect.expandAppearance","params":{"ids":[12]}}}
@@ -2400,7 +2407,8 @@ an instance when the effects in the symbol's art, or the instance's own, paint o
`object.expand {object?, fill?, stroke?, gradient?, steps?}` (Object → Expand…) turns the selection into plain art in
one undo step. `object` (on by default) outlines type, turns live shapes into paths and bakes effects; `stroke` (on)
outlines strokes into filled paths; `fill` (on) expands gradient fills: with `gradient: "objects"` (the default) into
outlines strokes into filled paths, the stroked paths that baking effects leaves included (a group's own stroke among
them, #3089); `fill` (on) expands gradient fills: with `gradient: "objects"` (the default) into
`steps` (1–1000, default 255) solid objects (rectangles across a linear gradient, each from its band to the far end so
no seams show; concentric ellipses for a radial one, largest first; just the bands when stops are translucent), with
`gradient: "mesh"` into a gradient mesh that paints the gradient (columns or rings where its colour changes, sharp at
@@ -2426,9 +2434,10 @@ or a geometry effect on a group or type, which the canvas draws turned or scaled
and those of the object shape the outline of the clip group, the gradient fits that outline and the tiles reach its
box, as the canvas paints the fill; the expanded art does not keep them. With `fill: false` gradient and pattern fills
stay live. `object.expand.info` answers which options have something to expand in the selection
(`{object, fill, stroke, gradient}`; `gradient` says whether Fill has a gradient fill, which Expand Gradient To
applies to), and `object.mesh.create` on a gradient-filled object colours the mesh points as the
gradient paints them, with the colors Object › Expand gives them.
(`{object, fill, stroke, gradient, appearance}`; `gradient` says whether Fill has a gradient fill, which Expand
Gradient To applies to; `appearance` whether the selection has appearance attributes Expand Appearance expands first,
for which Object → Expand… is dimmed in the menus), and `object.mesh.create` on a gradient-filled object colours the
mesh points as the gradient paints them, with the colors Object › Expand gives them.
```json
{"name":"run_command","arguments":{"command":"object.expand","params":{"stroke":false,"gradient":"mesh"}}}
File diff suppressed because one or more lines are too long
+4 -1
View File
File diff suppressed because one or more lines are too long