diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a2f76115..813a6036 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,14 @@ 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()).then(|| (pn.effects.clone(), pn.styles.effect.clone())); + 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); tp.flip = boolean(p, "flip").unwrap_or(false); @@ -153,6 +163,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 +183,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 39fc545f..f4041d60 100644 --- a/crates/engine/src/draw_tools_tests.rs +++ b/crates/engine/src/draw_tools_tests.rs @@ -222,6 +222,83 @@ 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 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 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(); diff --git a/docs/mcp.md b/docs/mcp.md index 53ca6b7a..b4a726cc 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