diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f011238d..1d73cc4c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -109,6 +109,8 @@ fn registry() -> &'static Registry { list.extend(crate::online::specs()); list.extend(crate::invite_links::specs()); list.extend(crate::templates::specs()); + list.extend(crate::datafill::specs()); + list.extend(crate::textfit::specs()); let mut index = HashMap::new(); for (i, s) in list.iter().enumerate() { if index.insert(s.id, i).is_some() { diff --git a/crates/engine/src/datafill.rs b/crates/engine/src/datafill.rs new file mode 100644 index 00000000..d0226936 --- /dev/null +++ b/crates/engine/src/datafill.rs @@ -0,0 +1,171 @@ +//! `data.fill`: one frame repeated per row of data (a carousel, a set of posts, cards from a +//! list): each copy gets its row's texts, colours and visibility on the layers named by the row's +//! keys, in one undo step, and optionally each copy exported as a file. Agents make a series with +//! one call instead of a duplicate, a lookup and an edit per layer and an export per copy. + +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::*; +use crate::{Result, Session, bad, spec}; + +const CMD: &str = "data.fill"; +const MAX_ROWS: usize = 500; + +pub fn specs() -> Vec { + vec![spec!( + "data.fill", + "Fill copies with data", + "{id (the frame to repeat), rows: [{: text | {text?, fill? (#rrggbb), visible?, fit? (true or text.fit's {maxLines?, minSize?, orphans?}: size the text to its box)}, name? (the copy's name)}] (every layer with that name in the copy; up to 500 rows), gap? (between copies, placed in a row to the right; default 100), formats? ([{name?, width, height}]: each row once per size, the copy resized so its auto layout reflows and named -; e.g. feed 1080×1350, story 1080×1920, square 1080×1080), dir? (export each copy there as .), format? (png|jpg|webp|svg|pdf; default png), scale? (default 1)} one undo step → {ids (the copies, in row order), missing (keys no layer is named), files? (paths written, one per copy: same file names get a numbered suffix), errors? ([{id, path, error}] for exports that failed; the copies stay)}", + false, + fill + )] +} + +/// A file name from a layer name: letters, digits, spaces, `-` and `_` kept, the rest `-`. +fn file_name(name: &str) -> String { + let s: String = name.chars().take(80).map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '-' }).collect(); + let s = s.trim().to_string(); + if s.is_empty() { "copy".into() } else { s } +} + +fn fill(s: &mut Session, p: &Value) -> Result { + let src = id(p, "id").ok_or_else(|| bad(CMD, "`id` (the frame to repeat) required"))?; + let rows = match get(p, "rows") { + Some(Value::Array(a)) if a.iter().all(Value::is_object) && a.len() <= MAX_ROWS => a.clone(), + _ => return Err(bad(CMD, "rows: an array of objects (up to 500)")), + }; + let gap = num_or(p, "gap", 100.0); + if !gap.is_finite() { + return Err(bad(CMD, "gap: a number")); + } + let format = string(p, "format").unwrap_or_else(|| "png".into()).to_ascii_lowercase(); + if !["png", "jpg", "webp", "svg", "pdf"].contains(&format.as_str()) { + return Err(bad(CMD, "format: png|jpg|webp|svg|pdf")); + } + let (width, base) = { + let doc = s.doc()?; + let n = doc.node(src).ok_or_else(|| bad(CMD, "no such layer"))?; + (n.width, n.name.clone()) + }; + // `formats`: each row once per size, the copy resized so its auto layout reflows. + let formats: Vec> = match get(p, "formats") { + None | Some(Value::Null) => vec![None], + Some(Value::Array(a)) if !a.is_empty() && a.len() <= 12 => a + .iter() + .map(|f| { + let (w, h) = (f.get("width").and_then(Value::as_f64), f.get("height").and_then(Value::as_f64)); + let name = f.get("name").and_then(Value::as_str).unwrap_or_default().trim().to_string(); + match (w, h) { + (Some(w), Some(h)) if w.is_finite() && h.is_finite() && (1.0..=20_000.0).contains(&w) && (1.0..=20_000.0).contains(&h) => { + Ok(Some((if name.is_empty() { format!("{}x{}", w as i64, h as i64) } else { name }, w, h))) + } + _ => Err(bad(CMD, "formats: [{name?, width, height}] with sizes from 1 to 20000")), + } + }) + .collect::>()?, + Some(_) => return Err(bad(CMD, "formats: [{name?, width, height}] (1 to 12 sizes)")), + }; + let column = formats.iter().flatten().map(|f| f.1).fold(width, f64::max); + let mut missing: Vec = Vec::new(); + let ids = s.grouped("Fill copies with data", |s| { + let mut ids = Vec::with_capacity(rows.len() * formats.len()); + for (i, row) in rows.iter().enumerate() { + let mut dy = 0.0; + for fmt in &formats { + // Rows go right; a row's formats stack downwards. + let dx = (column + gap) * (i as f64 + 1.0); + let dup = s.execute("edit.duplicate", &json!({"ids": [src], "dx": dx, "dy": dy}))?; + let copy = dup["ids"].get(0).and_then(id_of).ok_or_else(|| bad(CMD, "the frame couldn't be duplicated"))?; + let Some(fields) = row.as_object() else { continue }; + let name = fields.get("name").and_then(Value::as_str).map_or_else(|| format!("{base} {}", i + 1), str::to_string); + let name = match fmt { + Some((suffix, w, h)) => { + s.execute("node.set", &json!({"id": copy, "props": {"width": w, "height": h}}))?; + dy += h + gap; + format!("{name}-{suffix}") + } + None => name, + }; + s.execute("node.set", &json!({"id": copy, "props": {"name": name}}))?; + for (key, value) in fields.iter().filter(|(k, _)| k.as_str() != "name") { + let doc = s.doc()?; + let targets: Vec<_> = + doc.subtree(copy).into_iter().filter(|t| *t != copy && doc.node(*t).is_some_and(|n| n.name == *key)).collect(); + if targets.is_empty() { + if !missing.contains(key) { + missing.push(key.clone()); + } + continue; + } + let (text, fill, visible, fit) = match value { + Value::Object(o) => ( + o.get("text").map(text_of), + o.get("fill").and_then(Value::as_str), + o.get("visible").and_then(Value::as_bool), + o.get("fit").cloned().filter(|f| !matches!(f, Value::Bool(false) | Value::Null)), + ), + v => (Some(text_of(v)), None, None, None), + }; + let mut texts = Vec::new(); + for t in targets { + let is_text = s.doc()?.node(t).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Text(_))); + if let Some(text) = text.as_ref().filter(|_| is_text) { + s.execute("text.set", &json!({"id": t, "characters": text}))?; + } + if is_text { + texts.push(t.0); + } + if let Some(f) = fill { + s.execute("node.set", &json!({"id": t, "props": {"fills": [f]}}))?; + } + if let Some(v) = visible { + s.execute("node.set", &json!({"id": t, "props": {"visible": v}}))?; + } + } + // `fit: true` or `{maxLines?, minSize?, orphans?}`: size the text to its box. + if let Some(f) = fit.filter(|_| !texts.is_empty()) { + let mut params = if f.is_object() { f } else { json!({}) }; + params["ids"] = json!(texts); + s.execute("text.fit", ¶ms)?; + } + } + ids.push((copy, name)); + } + } + Ok(ids) + })?; + let mut out = json!({"ids": ids.iter().map(|(i, _)| i.0).collect::>(), "missing": missing}); + // Exports after the edits landed: a failed edit writes no files. A failed export keeps the + // copies (one undo step removes them) and is reported with the files that were written. + if let Some(dir) = string(p, "dir") { + let (mut files, mut errors, mut used) = (Vec::new(), Vec::new(), Vec::::new()); + for (copy, name) in &ids { + // One file per copy: same or same-looking names get " 2", " 3"… (case-insensitively, + // as macOS and Windows file systems compare names). + let base = file_name(name); + let stem = + (1..).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !used.contains(&n.to_lowercase())).unwrap_or(base); + used.push(stem.to_lowercase()); + let path = std::path::Path::new(&dir).join(format!("{stem}.{format}")).to_string_lossy().to_string(); + match s.execute("file.export", &json!({"ids": [copy.0], "format": format, "scale": num_or(p, "scale", 1.0), "path": path})) { + Ok(_) => files.push(path), + Err(e) => errors.push(json!({"id": copy.0, "path": path, "error": e.to_string()})), + } + } + out["files"] = json!(files); + if !errors.is_empty() { + out["errors"] = json!(errors); + } + } + Ok(out) +} + +/// A cell as text: strings as they are, numbers and booleans as written, null as empty. +fn text_of(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Null => String::new(), + v => v.to_string(), + } +} diff --git a/crates/engine/src/datafill_tests.rs b/crates/engine/src/datafill_tests.rs new file mode 100644 index 00000000..bbb5d4b6 --- /dev/null +++ b/crates/engine/src/datafill_tests.rs @@ -0,0 +1,142 @@ +//! `data.fill`: a frame repeated per row with that row's texts, fills and visibility, in one undo +//! step, optionally exported. + +use crate::Session; +use crate::components::tests::{ex, idv}; +use figcraft_doc::*; +use serde_json::json; + +/// A 1080×1350 "Post" with "Title" and "Price" texts and a "Badge" rectangle. Returns the frame. +fn post(s: &mut Session) -> NodeId { + let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"}))); + ex(s, "text.create", json!({"text": "Title", "x": 80, "y": 80, "parent": f, "props": {"name": "Title"}})); + ex(s, "text.create", json!({"text": "R$ 0", "x": 80, "y": 1200, "parent": f, "props": {"name": "Price"}})); + ex(s, "shape.rectangle", json!({"x": 80, "y": 40, "width": 200, "height": 32, "parent": f, "props": {"name": "Badge", "fill": "#3DDC97"}})); + f +} + +fn text(s: &Session, frame: NodeId, name: &str) -> String { + let doc = s.doc().unwrap(); + doc.subtree(frame) + .into_iter() + .filter_map(|i| doc.node(i)) + .find(|n| n.name == name) + .and_then(|n| match &n.kind { + NodeKind::Text(t) => Some(t.characters.clone()), + _ => None, + }) + .unwrap_or_default() +} + +#[test] +fn each_row_fills_a_copy_in_one_undo_step() { + let mut s = Session::new(); + let f = post(&mut s); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "rows": [ + {"name": "Slide 1", "Title": "Site em 7 dias", "Price": 5500}, + {"name": "Slide 2", "Title": "WhatsApp com IA", "Badge": {"visible": false}}, + {"Title": "Agenda sozinha", "Badge": {"fill": "#7C5CFF"}, "Subtitle": "nope"} + ]}), + ); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 3); + assert_eq!(r["missing"], json!(["Subtitle"])); + assert_eq!(text(&s, ids[0], "Title"), "Site em 7 dias"); + assert_eq!(text(&s, ids[0], "Price"), "5500"); + assert_eq!(text(&s, ids[2], "Title"), "Agenda sozinha"); + assert_eq!(text(&s, f, "Title"), "Title", "the source frame is untouched"); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(ids[0]).unwrap().name, "Slide 1"); + assert_eq!(doc.node(ids[2]).unwrap().name, "Post 3"); + // Copies sit in a row to the right, 100 apart. + let x = |i: NodeId| doc.node(i).unwrap().transform.as_coeffs()[4]; + assert_eq!((x(ids[0]), x(ids[1])), (1180.0, 2360.0)); + let badge = |i: NodeId| doc.subtree(i).into_iter().filter_map(|k| doc.node(k)).find(|n| n.name == "Badge").cloned().unwrap(); + assert!(!badge(ids[1]).visible && badge(ids[0]).visible); + assert_eq!(badge(ids[2]).fills[0].solid_color().map(|c| c.to_hex()), Some("7C5CFF".into())); + // One undo removes every copy. + ex(&mut s, "edit.undo", json!({})); + assert!(ids.iter().all(|i| !s.doc().unwrap().contains(*i))); +} + +#[test] +fn copies_export_to_a_folder_and_bad_input_is_refused() { + let mut s = Session::new(); + let f = post(&mut s); + let dir = std::env::temp_dir().join(format!("figcraft-datafill-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "rows": [{"name": "Post/1: hero", "Title": "A"}, {"Title": "B"}], "dir": dir.to_string_lossy(), "scale": 0.25}), + ); + let files: Vec = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); + assert_eq!(files.len(), 2); + assert!(files[0].ends_with("Post-1- hero.png"), "{files:?}"); + assert!(files.iter().all(|p| std::fs::metadata(p).is_ok_and(|m| m.len() > 0))); + let _ = std::fs::remove_dir_all(&dir); + for bad in [json!({"id": f}), json!({"id": f, "rows": [1, 2]}), json!({"id": f, "rows": [], "format": "gif"}), json!({"id": 999_999, "rows": []})] + { + assert!(s.execute("data.fill", &bad).is_err(), "{bad}"); + } +} + +#[test] +fn each_row_comes_in_every_format() { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"}))); + ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"})); + ex(&mut s, "node.set", json!({"id": f, "props": {"sizingV": "fixed", "padding": 80}})); + let t = idv(&ex(&mut s, "text.create", json!({"text": "Título", "x": 0, "y": 0, "parent": f, "props": {"name": "Title", "fontSize": 96}}))); + ex(&mut s, "node.set", json!({"id": t, "props": {"sizingH": "fill", "textAutoResize": "height"}})); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "formats": [ + {"name": "feed", "width": 1080, "height": 1350}, {"name": "story", "width": 1080, "height": 1920}, {"width": 1080, "height": 1080} + ], "rows": [{"name": "capa", "Title": "Site em 7 dias"}, {"name": "prova", "Title": "Lighthouse 100"}]}), + ); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 6); + let doc = s.doc().unwrap(); + let names: Vec = ids.iter().map(|i| doc.node(*i).unwrap().name.clone()).collect(); + assert_eq!(names, ["capa-feed", "capa-story", "capa-1080x1080", "prova-feed", "prova-story", "prova-1080x1080"]); + let size = |i: NodeId| (doc.node(i).unwrap().width, doc.node(i).unwrap().height); + assert_eq!(size(ids[1]), (1080.0, 1920.0)); + assert_eq!(size(ids[2]), (1080.0, 1080.0)); + assert_eq!(text(&s, ids[4], "Title"), "Lighthouse 100"); + // Formats of a row stack downwards; rows go right without overlapping. + let at = |i: NodeId| (doc.node(i).unwrap().transform.as_coeffs()[4], doc.node(i).unwrap().transform.as_coeffs()[5]); + assert_eq!(at(ids[0]).0, at(ids[1]).0); + assert!(at(ids[1]).1 >= at(ids[0]).1 + 1350.0); + assert!(at(ids[3]).0 >= at(ids[0]).0 + 1080.0); + for bad in [json!([]), json!([{"width": 0, "height": 10}]), json!("feed")] { + assert!(s.execute("data.fill", &json!({"id": f, "rows": [{}], "formats": bad})).is_err(), "{bad}"); + } +} + +#[test] +fn review_export_names_are_unique_and_failures_are_reported() { + let mut s = Session::new(); + let f = post(&mut s); + let dir = std::env::temp_dir().join(format!("figcraft-datafill-names-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "rows": [{"name": "Post"}, {"name": "post"}, {"name": "a/b"}, {"name": "a:b"}], "dir": dir.to_string_lossy(), "scale": 0.1}), + ); + let files: Vec = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); + let lower: std::collections::HashSet = files.iter().map(|f| f.to_lowercase()).collect(); + assert_eq!(lower.len(), 4, "one file per copy: {files:?}"); + assert!(files.iter().all(|p| std::fs::metadata(p).is_ok()), "{files:?}"); + let _ = std::fs::remove_dir_all(&dir); + // A folder that can't be written: the copies stay and every failure is reported. + let r = ex(&mut s, "data.fill", json!({"id": f, "rows": [{"name": "x"}], "dir": "/nonexistent-figcraft-dir/out"})); + assert_eq!(r["ids"].as_array().unwrap().len(), 1); + assert_eq!(r["errors"].as_array().map(Vec::len), Some(1), "{r}"); + assert!(s.doc().unwrap().contains(NodeId(r["ids"][0].as_u64().unwrap()))); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..1690d0a9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -22,6 +22,7 @@ pub mod codegen; pub mod collab; pub mod component_codegen; pub mod components; +pub mod datafill; pub mod dev_resources; pub mod devcompare; pub mod devinspect; @@ -59,6 +60,7 @@ pub mod synthetic; pub mod synthetic_ds; pub mod templates; pub mod textedit; +pub mod textfit; pub mod textpreview; pub mod tools; pub mod vectoredit; @@ -1643,6 +1645,8 @@ mod cursor_tests; #[cfg(test)] mod dark_page_text_tests; #[cfg(test)] +mod datafill_tests; +#[cfg(test)] mod design_paste_tests; #[cfg(test)] mod dev_resources_tests; @@ -1867,6 +1871,8 @@ mod text_style_picker_tests; #[cfg(test)] mod text_tail_tests; #[cfg(test)] +mod textfit_tests; +#[cfg(test)] mod theme_pages_tests; #[cfg(test)] mod tiff_tests; diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index be997079..be222293 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -111,6 +111,8 @@ const DENIED: &[&str] = &[ "edit.redo", "edit.repeat", "command.batch", + // Runs other commands inside (duplicate, edit, export), past this policy, like a batch. + "data.fill", // Network sessions, online files and their recovery copies on disk. "collab.", // Opens files in new windows. diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index 657b6825..7ff16cf3 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -169,6 +169,7 @@ fn files_dialogs_history_and_plugins_are_off_limits() { ("plugin.remove", json!({"id": "org.test.deny"})), ("edit.undo", json!({})), ("command.batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": target}}]})), + ("data.fill", json!({"id": a, "rows": [{}], "dir": dir.to_string_lossy()})), ("variable.export", json!({"format": "css", "path": target})), ("codeConnect.export", json!({"path": target})), ("codeConnect.import", json!({"path": "/etc/passwd"})), diff --git a/crates/engine/src/textfit.rs b/crates/engine/src/textfit.rs new file mode 100644 index 00000000..f0cf3bba --- /dev/null +++ b/crates/engine/src/textfit.rs @@ -0,0 +1,115 @@ +//! `text.fit`: text sized to fit its box, for posts and cards whose copy changes (a carousel +//! filled from data, a translated headline). The largest font size, down to a floor, at which the +//! text stays within `maxLines` lines and its box's height; then a last line that is a single word +//! (an orphan) is avoided by shrinking a little more, else by keeping the last two words together +//! with a no-break space. Every style run scales by the same factor, so mixed sizes keep their +//! proportions. + +use figcraft_doc::*; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::*; +use crate::{Result, Session, bad, spec}; + +const CMD: &str = "text.fit"; + +pub fn specs() -> Vec { + vec![spec!( + "text.fit", + "Fit text to its box", + "{ids? (text layers; default the selection), maxLines? (default: no limit but the box height), minSize? (smallest font size, px; default 60% of the current), orphans? (avoid a one-word last line; default true)} needs a fixed width (fixed or auto-height text) → {fitted: [{id, fontSize, lines, fits, orphan}]}", + true, + fit + )] +} + +/// The layout of `t` with every run's font size times `k`, at `width`: (line count, height, +/// words on the last line). +fn measure(t: &TextData, k: f64, width: f64) -> (usize, f64, usize) { + let mut t = t.clone(); + let n = t.char_count(); + t.restyle(0, n, |s| s.font_size = (s.font_size * k).clamp(1.0, 5000.0)); + let l = figcraft_text::layout(&t, Some(width), None); + let last = l.lines.last().map_or(0, |line| { + t.characters.chars().skip(line.char_start).take(line.char_end.saturating_sub(line.char_start)).collect::().split_whitespace().count() + }); + (l.lines.len(), l.height, last) +} + +fn fit(s: &mut Session, p: &Value) -> Result { + let ids = req_targets(CMD, s, p)?; + let max_lines = num(p, "maxLines").filter(|v| v.is_finite() && *v >= 1.0).map(|v| v.min(1000.0) as usize); + let orphans = boolean(p, "orphans").unwrap_or(true); + let mut out = Vec::new(); + let doc = s.doc_mut()?; + for id in ids { + let n = doc.node(id).ok_or_else(|| bad(CMD, format!("no layer {id}")))?; + let NodeKind::Text(t) = &n.kind else { return Err(bad(CMD, format!("{id} is not text"))) }; + if t.auto_resize == TextAutoResize::WidthAndHeight { + return Err(bad(CMD, format!("{id} grows to its text: give it a fixed width (auto height or fixed) first"))); + } + let (width, height_cap) = (n.width, if t.auto_resize == TextAutoResize::None { n.height } else { f64::INFINITY }); + let size = t.style.font_size.max(1.0); + let min = num(p, "minSize").filter(|v| v.is_finite() && *v >= 1.0).unwrap_or(size * 0.6).min(size); + let fits = |m: (usize, f64, usize)| max_lines.is_none_or(|ml| m.0 <= ml) && m.1 <= height_cap + 0.5; + // The largest factor in [min/size, 1] that fits: binary search to a tenth of a pixel. + let (mut lo, mut hi) = (min / size, 1.0); + let k = if fits(measure(t, 1.0, width)) { + 1.0 + } else if !fits(measure(t, lo, width)) { + lo + } else { + for _ in 0..24 { + let mid = (lo + hi) / 2.0; + if fits(measure(t, mid, width)) { + lo = mid + } else { + hi = mid + } + if (hi - lo) * size < 0.1 { + break; + } + } + lo + }; + // Whole-tenth sizes read better in the panel. + let mut k = ((size * k * 10.0).floor() / 10.0) / size; + let mut m = measure(t, k, width); + let mut orphan = orphans && m.0 > 1 && m.2 == 1; + let mut nbsp = false; + if orphan { + // Up to 8% smaller (not below the floor) may pull a word down to the last line. + let mut tries = 0; + while orphan && tries < 8 && size * k * 0.99 >= min { + k *= 0.99; + m = measure(t, k, width); + orphan = m.0 > 1 && m.2 == 1; + tries += 1; + } + if orphan { + nbsp = true; + } + } + let n = doc.get_mut(id)?; + let NodeKind::Text(t) = &mut n.kind else { continue }; + let count = t.char_count(); + t.restyle(0, count, |st| st.font_size = ((st.font_size * k * 10.0).round() / 10.0).clamp(1.0, 5000.0)); + // Same character count, so style runs keep their ranges. + let joined = if nbsp && let Some(i) = t.characters.trim_end().rfind(' ') { + t.characters.replace_range(i..i + 1, "\u{a0}"); + Some(i) + } else { + None + }; + let mut after = measure(t, 1.0, width); + // Keeping the last words together must not break the limit: an orphan is better. + if let Some(i) = joined.filter(|_| !fits(after)) { + t.characters.replace_range(i..i + '\u{a0}'.len_utf8(), " "); + after = measure(t, 1.0, width); + } + // Reported from the final text (rounded sizes, the no-break space), which callers rely on. + out.push(json!({"id": id.0, "fontSize": (t.style.font_size * 10.0).round() / 10.0, "lines": after.0, "fits": fits(after), "orphan": orphans && after.0 > 1 && after.2 == 1})); + } + Ok(json!({"fitted": out})) +} diff --git a/crates/engine/src/textfit_tests.rs b/crates/engine/src/textfit_tests.rs new file mode 100644 index 00000000..8d0a2ee5 --- /dev/null +++ b/crates/engine/src/textfit_tests.rs @@ -0,0 +1,104 @@ +//! `text.fit`: the largest size that fits the box and a line limit, orphans avoided, and +//! data.fill sizing each copy's text. + +use crate::Session; +use crate::components::tests::{ex, idv}; +use figcraft_doc::*; +use serde_json::json; + +/// A fixed-width (auto-height) headline of `width` px at 64px. Returns its id. +fn headline(s: &mut Session, text: &str, width: f64) -> NodeId { + let t = idv(&ex(s, "text.create", json!({"text": text, "x": 0, "y": 0, "props": {"name": "Headline", "fontSize": 64}}))); + ex(s, "node.set", json!({"id": t, "props": {"width": width, "textAutoResize": "height"}})); + t +} + +fn size(s: &Session, id: NodeId) -> f64 { + match &s.doc().unwrap().node(id).unwrap().kind { + NodeKind::Text(t) => t.style.font_size, + _ => 0.0, + } +} + +#[test] +fn text_shrinks_to_its_line_limit_and_no_further() { + let mut s = Session::new(); + let t = headline(&mut s, "Seu site e seu WhatsApp com IA, prontos em 7 dias", 600.0); + let r = ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": 2})); + let f = &r["fitted"][0]; + assert_eq!(f["lines"], 2, "{r}"); + assert!(f["fits"].as_bool().unwrap()); + let fitted = size(&s, t); + assert!((64.0 * 0.6..64.0).contains(&fitted), "{fitted}"); + // The largest that fits: a pixel more breaks the limit. + ex(&mut s, "node.set", json!({"id": t, "props": {"fontSize": fitted + 1.0}})); + let again = ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": 2})); + assert!((again["fitted"][0]["fontSize"].as_f64().unwrap() - fitted).abs() <= 0.2, "{again}"); + // Text that already fits keeps its size; one undo restores the original. + let short = headline(&mut s, "7 dias", 600.0); + ex(&mut s, "text.fit", json!({"ids": [short], "maxLines": 1})); + assert_eq!(size(&s, short), 64.0); + // A floor that can't fit reports it instead of going below (fit never grows text). + ex(&mut s, "node.set", json!({"id": t, "props": {"fontSize": 64}})); + let r = ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": 1, "minSize": 50})); + assert_eq!(r["fitted"][0]["fits"], false); + assert_eq!(size(&s, t), 50.0); + // Text that grows to its content has no box to fit. + let auto = idv(&ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0}))); + assert!(s.execute("text.fit", &json!({"ids": [auto]})).is_err()); +} + +#[test] +fn a_one_word_last_line_is_avoided() { + let mut s = Session::new(); + // At 64px in 520px this breaks with "1s." alone on the last line. + let t = headline(&mut s, "Lighthouse 100, abre em menos de 1s.", 520.0); + let r = ex(&mut s, "text.fit", json!({"ids": [t]})); + assert_eq!(r["fitted"][0]["orphan"], false, "{r}"); + let doc = s.doc().unwrap(); + let NodeKind::Text(tx) = &doc.node(t).unwrap().kind else { panic!() }; + let l = figcraft_text::layout(tx, Some(520.0), None); + let last = l.lines.last().unwrap(); + let words = tx.characters.chars().skip(last.char_start).take(last.char_end - last.char_start).collect::(); + assert!(l.lines.len() == 1 || words.split_whitespace().count() > 1 || words.contains('\u{a0}'), "{words:?}"); +} + +#[test] +fn data_fill_sizes_each_copys_text() { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"}))); + let t = idv(&ex(&mut s, "text.create", json!({"text": "Curto", "x": 80, "y": 80, "parent": f, "props": {"name": "Headline", "fontSize": 96}}))); + ex(&mut s, "node.set", json!({"id": t, "props": {"width": 920, "textAutoResize": "height"}})); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "rows": [ + {"Headline": {"text": "Curto", "fit": {"maxLines": 2}}}, + {"Headline": {"text": "Landing page e atendente de WhatsApp com IA, prontos em sete dias úteis", "fit": {"maxLines": 2}}} + ]}), + ); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + let head = |s: &Session, frame: NodeId| s.doc().unwrap().node(frame).unwrap().children[0]; + assert_eq!(size(&s, head(&s, ids[0])), 96.0, "short copy keeps its size"); + assert!(size(&s, head(&s, ids[1])) < 96.0, "long copy shrinks"); + assert_eq!(size(&s, t), 96.0, "the source is untouched"); +} + +#[test] +fn review_fits_is_reported_from_the_final_text() { + let mut s = Session::new(); + for (text, width, lines) in [ + ("Seu site e seu WhatsApp com IA, prontos em 7 dias", 600.0, 2.0), + ("Lighthouse 100, abre em menos de 1s.", 520.0, 2.0), + ("Curto", 300.0, 1.0), + ] { + let t = headline(&mut s, text, width); + let r = ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": lines})); + let f = &r["fitted"][0]; + let doc = s.doc().unwrap(); + let NodeKind::Text(tx) = &doc.node(t).unwrap().kind else { panic!() }; + let real = figcraft_text::layout(tx, Some(width), None).lines.len(); + assert_eq!(f["lines"].as_u64().unwrap() as usize, real, "{r}"); + assert_eq!(f["fits"].as_bool().unwrap(), real as f64 <= lines, "{r}"); + } +} diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..1e6799a9 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -2171,6 +2171,8 @@ status = "draft — needs native review" "text.removeFromPath" = "Quitar texto del trazado" "text.rescanFonts" = "Volver a buscar fuentes instaladas" "text.set" = "Establecer texto" +"data.fill" = "Rellenar copias con datos" +"text.fit" = "Ajustar texto a su caja" "text.setFeatures" = "Funciones OpenType" "text.setVariations" = "Ejes de fuente variable" "text.sizeDown" = "Reducir tamaño de fuente" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index d8f857e0..9a816e2c 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -2155,6 +2155,8 @@ status = "draft — needs native review" "text.preview" = "Visualizar alteração de texto" "text.removeFromPath" = "Remover texto do caminho" "text.set" = "Definir texto" +"data.fill" = "Preencher cópias com dados" +"text.fit" = "Ajustar texto à caixa" "text.setFeatures" = "Recursos OpenType" "text.setVariations" = "Eixos de fonte variável" "text.sizeDown" = "Diminuir tamanho da fonte" diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..5af124f9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -330,6 +330,38 @@ tips; headless, it only sets the preference. {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"export","arguments":{"format":"png","path":"dashboard.png"}}} ``` +### Filling copies with data + +`data.fill {id, rows, gap?, dir?, format?, scale?}` repeats a frame once per row (a carousel, a set +of posts, a card per item) in one undo step: each key of a row names layers in the copy, and a +string (or number) sets their text, `{text?, fill?, visible?}` sets more; `name` names the copy +(default " "). Copies go in a row to the right, `gap` apart (default 100). With `dir`, each +copy is exported there as `.` (png by default) after the edits land. Returns `{ids, +missing (keys no layer is named), files?}`. + +Copy that changes length per row can size itself: `{"Headline": {"text": "…", "fit": {"maxLines": 2}}}` runs +`text.fit` on those layers after the text is set. + +`formats: [{name?, width, height}]` makes every row once per size: the copy is resized so its auto layout +reflows, named `-` (default `x`), and placed under the row's previous +format. Text `fit` runs after the resize. Use case: one call turns a 1080×1350 post into feed, story +(1080×1920) and square (1080×1080) files for each slide of a carousel; the frame must be auto layout (fixed- +position children keep their place). + +`text.fit {ids?, maxLines?, minSize?, orphans?}` gives a text layer with a fixed width (auto height or fixed size) +the largest font size, down to `minSize` (default 60% of the current), at which it stays within `maxLines` lines +and its box's height. Every style run scales by the same factor. A one-word last line (an orphan) is avoided by +shrinking up to 8% more, else by keeping the last two words together with a no-break space. Text never grows. +Returns `{fitted: [{id, fontSize, lines, fits, orphan}]}`. Use case: a carousel slide "Lighthouse 100. Loads in under +1s." at 96px broke into three lines with "1s." alone; with `fit: {maxLines: 3}` it is two balanced lines. + +```json +{"command": "data.fill", "params": {"id": 80, "dir": "out/carousel", "scale": 1, "rows": [ + {"name": "01-cover", "Headline": "Your website and AI chat assistant in 7 days", "Badge": {"visible": true}}, + {"name": "02-proof", "Headline": "Lighthouse 100, loads in under a second", "Badge": {"visible": false}} +]}} +``` + ### Sections, handoff and arrow keys `run_command` with `object.wrapInSection {ids?, name?}` (Wrap in new section: layers on the page or