//! 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 { 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##"Curved words"##; 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); } #[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 = 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 of vectors, PDF without failing. let svg = figcraft_svg::export_svg(doc, &[r], &figcraft_svg::SvgOptions::default()).unwrap(); assert!(svg.contains("