Files
figcraft/crates/engine/src/datafill.rs
T
vetlucasmartins 196e3432dd 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.
2026-10-21 19:54:47 +01:00

172 lines
9.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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(),
}
}