mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
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:
@@ -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() {
|
||||
|
||||
@@ -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", ¶ms)?;
|
||||
}
|
||||
}
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -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())));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"})),
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user