This commit is contained in:
aiongg
2026-10-25 02:21:19 +00:00
3 changed files with 99 additions and 2 deletions
+20 -2
View File
@@ -126,7 +126,9 @@ fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
}
/// 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<Value> {
const CMD: &str = "text.onPath";
let doc = s.doc()?;
@@ -144,6 +146,14 @@ fn on_path(s: &mut Session, p: &Value) -> Result<Value> {
}
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<Value> {
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<CommandSpec> {
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
),
+77
View File
@@ -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();
+2
View File
@@ -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