data.fill and text.fit: fill copies of a frame from rows of data

- data.fill {id, rows, gap?, formats?, dir?, format?, scale?}: repeats a
  frame once per row in one undo step; each row key names layers in the
  copy (text, or {text?, fill?, visible?, fit?}). `formats` makes each row
  once per size (feed, story, square), the copy resized so its auto layout
  reflows. With `dir`, each copy is exported under a unique file name; a
  failed export is reported in `errors` and keeps the copies.
- text.fit {ids?, maxLines?, minSize?, orphans?}: the largest font size,
  down to a floor, at which fixed-width text stays within N lines and its
  box; avoids a one-word last line; `fits` comes from the final text.
- Plugins can't call data.fill: it runs other commands inside, past the
  plugin policy, like command.batch.
This commit is contained in:
vetlucasmartins
2026-10-21 19:54:47 +01:00
parent 28de91d9ae
commit 196e3432dd
11 changed files with 579 additions and 0 deletions
+2
View File
@@ -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() {
+171
View File
@@ -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<CommandSpec> {
vec![spec!(
"data.fill",
"Fill copies with data",
"{id (the frame to repeat), rows: [{<layer name>: 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 <name>-<format name>; e.g. feed 1080×1350, story 1080×1920, square 1080×1080), dir? (export each copy there as <name>.<format>), 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<Value> {
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<Option<(String, f64, f64)>> = 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::<Result<_>>()?,
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<String> = 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", &params)?;
}
}
ids.push((copy, name));
}
}
Ok(ids)
})?;
let mut out = json!({"ids": ids.iter().map(|(i, _)| i.0).collect::<Vec<_>>(), "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::<String>::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(),
}
}
+142
View File
@@ -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<NodeId> = 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<String> = 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<NodeId> = 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<String> = 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<String> = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
let lower: std::collections::HashSet<String> = 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())));
}
+6
View File
@@ -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;
+2
View File
@@ -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.
+1
View File
@@ -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"})),
+115
View File
@@ -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<CommandSpec> {
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::<String>().split_whitespace().count()
});
(l.lines.len(), l.height, last)
}
fn fit(s: &mut Session, p: &Value) -> Result<Value> {
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}))
}
+104
View File
@@ -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::<String>();
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<NodeId> = 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}");
}
}
+2
View File
@@ -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"
+2
View File
@@ -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"
+32
View File
@@ -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 "<frame> <n>"). Copies go in a row to the right, `gap` apart (default 100). With `dir`, each
copy is exported there as `<name>.<format>` (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 `<name>-<format name>` (default `<width>x<height>`), 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