Files
figcraft/crates/engine/src/draw_tools_tests.rs
T
dotCooCoo 0978b44385 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.
2026-10-24 14:02:50 -07:00

211 lines
11 KiB
Rust

//! Drawing extras: text on a path (commands, resize, undo, SVG round trip) and pattern fills
//! (tiles render live from their source, copies keep their own source, exports).
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_color::PaintKind;
use figcraft_doc::{NodeId, NodeKind};
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn text_path(s: &Session, t: NodeId) -> Option<figcraft_doc::TextPath> {
match &s.doc().unwrap().node(t)?.kind {
NodeKind::Text(td) => td.path.as_deref().cloned(),
_ => None,
}
}
/// The RGBA of `node` rendered at 1x, at (`x`, `y`) of its box.
fn pixel(s: &Session, node: NodeId, x: u32, y: u32) -> [u8; 4] {
let pix = figcraft_render::render_node(s.doc().unwrap(), node, 1.0).unwrap();
let i = ((y * pix.width() + x) * 4) as usize;
let d = pix.data();
[d[i], d[i + 1], d[i + 2], d[i + 3]]
}
#[test]
fn text_goes_onto_a_path_and_off_again() {
let mut s = Session::new();
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})).unwrap());
// Without a path selected it says what's missing.
assert!(s.execute("text.onPath", &json!({"ids": [t]})).is_err());
let (vxf, vw) = s.doc().map(|d| d.node(v).map(|n| (n.transform, n.width))).unwrap().unwrap();
let r = s.execute("text.onPath", &json!({"ids": [t, v], "start": 0.25})).unwrap();
assert_eq!(id(r), t);
let doc = s.doc().unwrap();
assert!(doc.node(v).is_none(), "the path layer became the text's path");
let n = doc.node(t).unwrap();
assert_eq!((n.transform, n.width), (vxf, vw), "in the path's place");
assert_eq!(s.selection(), vec![t]);
let tp = text_path(&s, t).unwrap();
assert_eq!((tp.start, tp.flip), (0.25, false));
s.execute("text.pathOptions", &json!({"ids": [t], "flip": true, "start": 7})).unwrap();
let tp = text_path(&s, t).unwrap();
assert_eq!((tp.start, tp.flip), (1.0, true), "start clamps into 0..1");
// Resizing scales the path with the box.
let w = s.doc().unwrap().node(t).unwrap().width;
s.execute("transform.resize", &json!({"ids": [t], "width": w * 2.0})).unwrap();
let bb = figcraft_geom::Shape::bounding_box(&text_path(&s, t).unwrap().path());
assert!((bb.width() - w * 2.0).abs() < 1.0, "{bb:?}");
s.execute("text.removeFromPath", &json!({"ids": [t]})).unwrap();
assert!(text_path(&s, t).is_none());
for _ in 0..4 {
s.execute("edit.undo", &json!({})).unwrap();
}
assert!(s.doc().unwrap().node(v).is_some() && text_path(&s, t).is_none(), "undo brings the path back");
}
#[test]
fn svg_text_paths_import_as_text_on_a_path() {
// SVG text is parsed with Inter standing in for every family (CI has no fonts).
if figcraft_text::find_face("Inter", "Regular").is_none() {
eprintln!("no Inter; skipping");
return;
}
let mut s = Session::new();
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><path id="p" d="M10 80 Q100 0 190 80" fill="none"/><text font-size="12"><textPath href="#p" startOffset="50%" text-anchor="middle">Curved words</textPath></text></svg>"##;
let doc = s.doc_mut().unwrap();
let page = doc.current_page.unwrap();
figcraft_svg::import_svg(doc, svg, page, 0.0, 0.0, "curve").unwrap();
let texts: Vec<_> = s.doc().unwrap().nodes().filter_map(|n| n.kind.text().cloned()).collect();
let t = texts.iter().find(|t| t.characters == "Curved words").expect("a text layer");
let tp = t.path.as_ref().expect("on a path");
assert!((tp.start - 0.5).abs() < 0.01, "{}", tp.start);
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();
let tile = id(s.execute("shape.rectangle", &json!({"x": 500, "y": 0, "width": 10, "height": 10})).unwrap());
s.execute("fill.set", &json!({"ids": [tile], "color": "#FF0000"})).unwrap();
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
s.execute("fill.set", &json!({"ids": [r], "color": "#FFFFFF"})).unwrap();
s.execute("fill.add", &json!({"ids": [r]})).unwrap();
assert!(s.execute("fill.pattern", &json!({"ids": [r], "source": r})).is_err(), "not itself");
s.execute("fill.pattern", &json!({"ids": [r], "source": tile, "spacing": [10, 10]})).unwrap();
assert!(matches!(&s.doc().unwrap().node(r).unwrap().fills[1].kind, PaintKind::Pattern(p) if p.source == tile.0 && p.spacing == (10.0, 10.0)));
assert_eq!(pixel(&s, r, 5, 5), [255, 0, 0, 255]);
assert_eq!(pixel(&s, r, 15, 5), [255, 255, 255, 255], "the gap shows the fill below");
assert_eq!(pixel(&s, r, 45, 45), [255, 0, 0, 255]);
// Live: recolouring the source recolours the tiles.
s.execute("fill.set", &json!({"ids": [tile], "color": "#0000FF"})).unwrap();
assert_eq!(pixel(&s, r, 5, 5), [0, 0, 255, 255]);
// Half-shifted rows.
s.execute("fill.pattern", &json!({"ids": [r], "source": tile, "spacing": [10, 10], "tile": "horizontalHex"})).unwrap();
assert_eq!(pixel(&s, r, 15, 25), [0, 0, 255, 255]);
assert_eq!(pixel(&s, r, 5, 25), [255, 255, 255, 255]);
// A copy of the source together with the layer tiles the copied source.
s.execute("object.group", &json!({"ids": [tile, r]})).unwrap();
s.execute("edit.duplicate", &json!({})).unwrap();
let doc = s.doc().unwrap();
let sources: Vec<u64> = doc
.nodes()
.flat_map(|n| n.fills.iter())
.filter_map(|p| match &p.kind {
PaintKind::Pattern(pt) => Some(pt.source),
_ => None,
})
.collect();
assert_eq!(sources.len(), 2);
assert_ne!(sources[0], sources[1], "each copy tiles its own source");
// Exports: SVG as a <pattern> of vectors, PDF without failing.
let svg = figcraft_svg::export_svg(doc, &[r], &figcraft_svg::SvgOptions::default()).unwrap();
assert!(svg.contains("<pattern") && svg.contains("fill=\"url(#pattern"), "{svg}");
assert!(figcraft_pdf::export_nodes_pdf(doc, &[r]).is_ok());
}
#[test]
fn a_pattern_of_itself_draws_nothing_instead_of_recursing() {
let mut s = Session::new();
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap());
// Hand-written (the command refuses this): the layer tiles itself.
s.execute("fill.set", &json!({"ids": [r], "paint": {"type": "pattern", "source": r.0, "scale": 0.25}})).unwrap();
let doc = s.doc().unwrap();
assert!(figcraft_render::render_node(doc, r, 1.0).is_some());
assert!(figcraft_svg::export_svg(doc, &[r], &figcraft_svg::SvgOptions::default()).is_some());
assert!(figcraft_pdf::export_nodes_pdf(doc, &[r]).is_ok());
}