From 98d061df10c1ab150b7dba84bfa1416967a1a193 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 12:28:00 -0700 Subject: [PATCH 1/3] M4: text put on a path takes the path's fill and effects text.onPath, and the Design panel's Put text on path button that runs it, kept the text layer's own fill and effects when the text went onto a path. The help center's Guide to text says that the vector object's fill and effects are transferred to the text layer. The text now takes the path layer's fills, with their fill style and variable bindings, when one of them shows, and the path layer's effects, with their effect style and bindings, when it has any. The text's own bindings on those fields are dropped. When the path has no visible fill or no effects, the text keeps its own. --- crates/engine/src/cmd/text.rs | 18 ++++++++-- crates/engine/src/draw_tools_tests.rs | 49 +++++++++++++++++++++++++++ docs/mcp.md | 2 ++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a2f76115..2998885f 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -126,7 +126,9 @@ fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result { } /// Put a text layer on a path: the text takes the path layer's place, geometry and transform, and -/// the path layer is removed. +/// the path layer is removed. The path's effects move to the text when it has any, and its fills +/// when one of them shows, each with its style and variable bindings; otherwise the text keeps +/// its own. fn on_path(s: &mut Session, p: &Value) -> Result { const CMD: &str = "text.onPath"; let doc = s.doc()?; @@ -144,6 +146,10 @@ fn on_path(s: &mut Session, p: &Value) -> Result { } let (parent, index) = (pn.parent.ok_or_else(|| bad(CMD, "pages have no path"))?, doc.index_in_parent(path)); let (transform, w, h) = (pn.transform, pn.width, pn.height); + let fills = pn.fills.iter().any(|f| f.visible).then(|| (pn.fills.clone(), pn.styles.fill.clone())); + let effects = (!pn.effects.is_empty() || pn.styles.effect.is_some()).then(|| (pn.effects.clone(), pn.styles.effect.clone())); + let moved = |k: &str| (fills.is_some() && (k == "fill" || k.starts_with("fills/"))) || (effects.is_some() && k.starts_with("effects/")); + let bound: Vec<(String, VariableId)> = pn.bound.iter().filter(|(k, _)| moved(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); let mut tp = TextPath::new(&geom); tp.start = num(p, "start").filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0); tp.flip = boolean(p, "flip").unwrap_or(false); @@ -153,6 +159,14 @@ fn on_path(s: &mut Session, p: &Value) -> Result { let n = doc.get_mut(text)?; n.transform = transform; (n.width, n.height) = (w, h); + n.bound.retain(|k, _| !moved(k)); + n.bound.extend(bound); + if let Some((fills, style)) = fills { + (n.fills, n.styles.fill) = (fills, style); + } + if let Some((effects, style)) = effects { + (n.effects, n.styles.effect) = (effects, style); + } if let NodeKind::Text(t) = &mut n.kind { t.path = Some(Box::new(tp)); } @@ -165,7 +179,7 @@ pub fn specs() -> Vec { spec!( "text.onPath", "Text on path", - "{text? (text layer), path? (vector or shape layer; default: the selected text and other layer), start? (0..1 along the path), flip?} the text follows the path and the path layer is removed → {id}", + "{text? (text layer), path? (vector or shape layer; default: the selected text and other layer), start? (0..1 along the path), flip?} the text follows the path and takes its effects and its fills when one shows, and the path layer is removed → {id}", true, on_path ), diff --git a/crates/engine/src/draw_tools_tests.rs b/crates/engine/src/draw_tools_tests.rs index d67b0af4..c989a7b7 100644 --- a/crates/engine/src/draw_tools_tests.rs +++ b/crates/engine/src/draw_tools_tests.rs @@ -79,6 +79,55 @@ fn svg_text_paths_import_as_text_on_a_path() { assert_eq!(t.align_h, figcraft_doc::TextAlignH::Center); } +/// A text layer with its own green fill bound to `ink` and a layer blur, and a curve; the +/// variables `ink` and `shadow` exist. +fn text_and_curve(s: &mut Session, path_props: Value) -> (NodeId, NodeId) { + let t = id(s.execute("text.create", &json!({"text": "Around", "x": 0, "y": 0})).unwrap()); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100, "props": path_props})).unwrap()); + s.execute("variable.createCollection", &json!({"name": "Paint"})).unwrap(); + for (name, value) in [("ink", "#00FF00"), ("shadow", "#FF000080")] { + s.execute("variable.create", &json!({"collection": "Paint", "name": name, "type": "color", "value": value})).unwrap(); + } + s.execute("variable.bind", &json!({"ids": [t], "field": "fills/0/color", "variable": "ink"})).unwrap(); + s.execute("effect.add", &json!({"ids": [t], "type": "layerBlur"})).unwrap(); + (t, v) +} + +#[test] +fn text_put_on_a_filled_path_takes_its_fill_and_effects() { + let mut s = Session::new(); + let (t, v) = text_and_curve(&mut s, json!({"fill": "#FF0000"})); + let brand = s.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": "#3366FF"})).unwrap()["id"].clone(); + s.execute("style.apply", &json!({"ids": [v], "style": brand})).unwrap(); + s.execute("effect.add", &json!({"ids": [v], "type": "dropShadow"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [v], "field": "effects/0/color", "variable": "shadow"})).unwrap(); + let path = s.doc().unwrap().node(v).unwrap().clone(); + let text = s.doc().unwrap().node(t).unwrap().clone(); + assert!(path.styles.fill.is_some() && path.bound.contains_key("effects/0/color")); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + // "the vector object's fill and effects are automatically transferred to the text layer" + assert_eq!((&n.fills, &n.styles.fill), (&path.fills, &path.styles.fill), "the path's fill and its style"); + assert_eq!((&n.effects, &n.styles.effect), (&path.effects, &path.styles.effect), "the path's effects"); + assert_eq!(n.bound, path.bound, "the path's bindings replace the text's fill binding"); + s.execute("edit.undo", &json!({})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!((&n.fills, &n.styles.fill), (&text.fills, &text.styles.fill), "undo gives the text its own fill back"); + assert_eq!((&n.effects, &n.styles.effect), (&text.effects, &text.styles.effect), "undo gives the text its own effects back"); + assert_eq!(n.bound, text.bound, "undo gives the text its own fill binding back"); +} + +#[test] +fn text_put_on_a_stroke_only_path_keeps_its_own_fill_and_effects() { + let mut s = Session::new(); + let (t, v) = text_and_curve(&mut s, json!({"fills": []})); + let text = s.doc().unwrap().node(t).unwrap().clone(); + assert!(!text.fills.is_empty() && !text.effects.is_empty()); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!((&n.fills, &n.effects, &n.bound), (&text.fills, &text.effects, &text.bound), "a path with no fill or effects to give"); +} + #[test] fn pattern_fills_tile_their_source_live() { let mut s = Session::new(); diff --git a/docs/mcp.md b/docs/mcp.md index 8361286d..65e3669e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -919,6 +919,8 @@ click or drag across regions with `pointer` to merge, add `alt` to remove; `shap - **Text on a path**: `text.onPath {text?, path?, start?, flip?}` (default: the selected text layer and the other selected layer) puts a text layer on a vector's or shape's path: the text takes the path layer's place, transform and box, and the path layer is removed (`edit.undo` brings it back). + The text also takes the path layer's effects when it has any, and its fills when one of them + shows, each with its style and variable bindings; otherwise the text keeps its own. The baseline follows the path from `start` (0..1 of its length); the text's horizontal alignment places it there (left starts, centre centres, right ends at it); `flip` runs it on the other side the other way. Glyphs past an open path's end are hidden; a closed path (ellipse, rectangle) wraps From 949d6c2830344162fb302baa558dff906b821c8b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 13:51:32 -0700 Subject: [PATCH 2/3] M4: text on a path keeps its effects when the path's effect style has none A path linked to an effect style with no effects gave the text an empty effect list and the style link. The text now takes the path's effects only when there are any. --- crates/engine/src/cmd/text.rs | 2 +- crates/engine/src/draw_tools_tests.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 2998885f..0334a72a 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -147,7 +147,7 @@ fn on_path(s: &mut Session, p: &Value) -> Result { let (parent, index) = (pn.parent.ok_or_else(|| bad(CMD, "pages have no path"))?, doc.index_in_parent(path)); let (transform, w, h) = (pn.transform, pn.width, pn.height); let fills = pn.fills.iter().any(|f| f.visible).then(|| (pn.fills.clone(), pn.styles.fill.clone())); - let effects = (!pn.effects.is_empty() || pn.styles.effect.is_some()).then(|| (pn.effects.clone(), pn.styles.effect.clone())); + let effects = (!pn.effects.is_empty()).then(|| (pn.effects.clone(), pn.styles.effect.clone())); let moved = |k: &str| (fills.is_some() && (k == "fill" || k.starts_with("fills/"))) || (effects.is_some() && k.starts_with("effects/")); let bound: Vec<(String, VariableId)> = pn.bound.iter().filter(|(k, _)| moved(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); let mut tp = TextPath::new(&geom); diff --git a/crates/engine/src/draw_tools_tests.rs b/crates/engine/src/draw_tools_tests.rs index c989a7b7..d33a48ff 100644 --- a/crates/engine/src/draw_tools_tests.rs +++ b/crates/engine/src/draw_tools_tests.rs @@ -128,6 +128,20 @@ fn text_put_on_a_stroke_only_path_keeps_its_own_fill_and_effects() { assert_eq!((&n.fills, &n.effects, &n.bound), (&text.fills, &text.effects, &text.bound), "a path with no fill or effects to give"); } +#[test] +fn text_put_on_a_path_with_an_empty_effect_style_keeps_its_own_effects() { + let mut s = Session::new(); + let (t, v) = text_and_curve(&mut s, json!({"fills": []})); + let none = s.execute("style.create", &json!({"type": "effect", "name": "None", "value": []})).unwrap()["id"].clone(); + s.execute("style.apply", &json!({"ids": [v], "style": none})).unwrap(); + let path = s.doc().unwrap().node(v).unwrap().clone(); + assert!(path.effects.is_empty() && path.styles.effect.is_some()); + let text = s.doc().unwrap().node(t).unwrap().clone(); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!((&n.effects, &n.styles.effect), (&text.effects, &text.styles.effect), "a path whose effect style draws no effects"); +} + #[test] fn pattern_fills_tile_their_source_live() { let mut s = Session::new(); From 0978b44385c2908f2573dcec5fedc84b9090dcd0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 14:02:50 -0700 Subject: [PATCH 3/3] M4: text on a path takes a fill binding made through `fills` The bindings that move with the path's fills and effects are now found with list_binding, which reads `fills` as the first fill's color, as style_sets in cmd/styles.rs does. --- crates/engine/src/cmd/text.rs | 6 +++++- crates/engine/src/draw_tools_tests.rs | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 0334a72a..813a6036 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -148,7 +148,11 @@ fn on_path(s: &mut Session, p: &Value) -> Result { let (transform, w, h) = (pn.transform, pn.width, pn.height); let fills = pn.fills.iter().any(|f| f.visible).then(|| (pn.fills.clone(), pn.styles.fill.clone())); let effects = (!pn.effects.is_empty()).then(|| (pn.effects.clone(), pn.styles.effect.clone())); - let moved = |k: &str| (fills.is_some() && (k == "fill" || k.starts_with("fills/"))) || (effects.is_some() && k.starts_with("effects/")); + let moved = |k: &str| match super::lists::list_binding(k) { + Some((super::lists::BoundList::Fills, ..)) => fills.is_some(), + Some((super::lists::BoundList::Effects, ..)) => effects.is_some(), + _ => false, + }; let bound: Vec<(String, VariableId)> = pn.bound.iter().filter(|(k, _)| moved(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); let mut tp = TextPath::new(&geom); tp.start = num(p, "start").filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0); diff --git a/crates/engine/src/draw_tools_tests.rs b/crates/engine/src/draw_tools_tests.rs index d33a48ff..726ee884 100644 --- a/crates/engine/src/draw_tools_tests.rs +++ b/crates/engine/src/draw_tools_tests.rs @@ -142,6 +142,20 @@ fn text_put_on_a_path_with_an_empty_effect_style_keeps_its_own_effects() { assert_eq!((&n.effects, &n.styles.effect), (&text.effects, &text.styles.effect), "a path whose effect style draws no effects"); } +#[test] +fn text_put_on_a_path_takes_a_fill_binding_made_through_fills() { + let mut s = Session::new(); + let (t, v) = text_and_curve(&mut s, json!({"fill": "#FF0000"})); + s.execute("variable.bind", &json!({"ids": [v], "field": "fills", "variable": "shadow"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [t], "field": "fills", "variable": "ink"})).unwrap(); + let path = s.doc().unwrap().node(v).unwrap().clone(); + assert!(path.bound.contains_key("fills")); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!(n.bound, path.bound, "the path's `fills` binding replaces the text's"); + assert_eq!(n.fills, path.fills); +} + #[test] fn pattern_fills_tile_their_source_live() { let mut s = Session::new();