#1009: data.fill and text.fit in smaller helpers (Sonar)

This commit is contained in:
bflatastic
2026-10-24 13:01:48 +07:00
parent 5376e4d25d
commit 35fada1161
2 changed files with 194 additions and 147 deletions
+137 -93
View File
@@ -3,6 +3,7 @@
//! 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 figcraft_doc::NodeId;
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
@@ -48,89 +49,18 @@ fn fill(s: &mut Session, p: &Value) -> Result<Value> {
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 formats = formats(p)?;
let column = formats.iter().flatten().map(|f| f.1).fold(width, f64::max);
let grid = Grid { src, base, column, gap };
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)?;
}
if let Some(made) = copy_row(s, &grid, (i, row), fmt.as_ref(), &mut dy, &mut missing)? {
ids.push(made);
}
ids.push((copy, name));
}
}
Ok(ids)
@@ -139,28 +69,142 @@ fn fill(s: &mut Session, p: &Value) -> Result<Value> {
// 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);
}
export_copies(s, &ids, &dir, (&format, num_or(p, "scale", 1.0)), &mut out);
}
Ok(out)
}
/// A size the copies are made in: its name, width and height.
type Format = (String, f64, f64);
/// `formats`: each row once per size, the copy resized so its auto layout reflows (`None`: the
/// frame's own size).
fn formats(p: &Value) -> Result<Vec<Option<Format>>> {
match get(p, "formats") {
None | Some(Value::Null) => Ok(vec![None]),
Some(Value::Array(a)) if !a.is_empty() && a.len() <= 12 => a.iter().map(format_of).collect(),
Some(_) => Err(bad(CMD, "formats: [{name?, width, height}] (1 to 12 sizes)")),
}
}
/// One `formats` entry, named `<width>x<height>` when it has no name.
fn format_of(f: &Value) -> Result<Option<Format>> {
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")),
}
}
/// Where the copies go: the frame repeated, its name, the column width and the gap.
struct Grid {
src: NodeId,
base: String,
column: f64,
gap: f64,
}
/// Row `i` in format `fmt`: duplicate the frame (rows go right; a row's formats stack downwards
/// from `dy`), name the copy and fill its layers. The copy and its name.
fn copy_row(
s: &mut Session,
g: &Grid,
(i, row): (usize, &Value),
fmt: Option<&Format>,
dy: &mut f64,
missing: &mut Vec<String>,
) -> Result<Option<(NodeId, String)>> {
let dx = (g.column + g.gap) * (i as f64 + 1.0);
let dup = s.execute("edit.duplicate", &json!({"ids": [g.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 { return Ok(None) };
let name = fields.get("name").and_then(Value::as_str).map_or_else(|| format!("{} {}", g.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 + g.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") {
fill_layers(s, copy, (key, value), missing)?;
}
Ok(Some((copy, name)))
}
/// Give every layer named `key` in `copy` the row's `value`: its text, fill, visibility and fit.
/// A key no layer has goes on `missing` (once).
fn fill_layers(s: &mut Session, copy: NodeId, (key, value): (&str, &Value), missing: &mut Vec<String>) -> Result<()> {
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.iter().any(|m| m == key) {
missing.push(key.to_string());
}
return Ok(());
}
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)?;
}
Ok(())
}
/// Export each copy into `dir` as `<name>.<format>` at `scale`, adding `files` (and `errors`) to
/// `out`.
fn export_copies(s: &mut Session, ids: &[(NodeId, String)], dir: &str, (format, scale): (&str, f64), out: &mut Value) {
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": scale, "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);
}
}
/// A cell as text: strings as they are, numbers and booleans as written, null as empty.
fn text_of(v: &Value) -> String {
match v {
+57 -54
View File
@@ -65,45 +65,8 @@ fn fit(s: &mut Session, p: &Value) -> Result<Value> {
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, bool)| 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, but never below the floor; text that fits
// keeps its exact size.
let mut k = if k < 1.0 { ((size * k * 10.0).floor() / 10.0).max(min) / size } else { 1.0 };
let mut m = measure(t, k, width);
let mut orphan = orphans && m.2;
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.2;
tries += 1;
}
if orphan {
nbsp = true;
}
}
let k = largest_fit(t, width, (size, min), &fits);
let (k, nbsp) = if orphans { avoid_orphan(t, k, width, (size, min)) } else { (k, false) };
let n = doc.get_mut(id)?;
let NodeKind::Text(t) = &mut n.kind else { continue };
let count = t.char_count();
@@ -114,23 +77,63 @@ fn fit(s: &mut Session, p: &Value) -> Result<Value> {
st.font_size = ((st.font_size * k * 10.0).round() / 10.0).max(floor).clamp(1.0, 5000.0)
});
}
// Same character count, so style runs keep their ranges.
// Only inside the last paragraph: a space before a hard break joins nothing on the last line.
let para = t.characters.rfind('\n').map_or(0, |i| i + 1);
let joined = if nbsp && let Some(i) = t.characters.trim_end().get(para..).and_then(|p| p.rfind(' ')).map(|i| para + i) {
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);
}
let after = if nbsp { keep_together(t, width, &fits) } else { 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.2}));
}
Ok(json!({"fitted": out}))
}
/// The largest factor in [min/size, 1] at which `t` fits: a binary search to a tenth of a pixel,
/// then rounded down to a whole-tenth size (which reads better in the panel) but never below the
/// floor. Text that fits keeps its exact size.
fn largest_fit(t: &TextData, width: f64, (size, min): (f64, f64), fits: &dyn Fn((usize, f64, bool)) -> bool) -> f64 {
let (mut lo, mut hi) = (min / size, 1.0);
if fits(measure(t, 1.0, width)) {
return 1.0;
}
if fits(measure(t, lo, width)) {
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;
}
}
}
((size * lo * 10.0).floor() / 10.0).max(min) / size
}
/// Up to 8% smaller than `k` (not below the floor) may pull a word down to an orphaned last line.
/// The factor, and whether an orphan is left (to be kept together with a no-break space).
fn avoid_orphan(t: &TextData, mut k: f64, width: f64, (size, min): (f64, f64)) -> (f64, bool) {
let mut orphan = measure(t, k, width).2;
let mut tries = 0;
while orphan && tries < 8 && size * k * 0.99 >= min {
k *= 0.99;
orphan = measure(t, k, width).2;
tries += 1;
}
(k, orphan)
}
/// Keep the last two words together with a no-break space, unless that breaks the limit (an
/// orphan is better). Same character count, so style runs keep their ranges. The final layout.
fn keep_together(t: &mut TextData, width: f64, fits: &dyn Fn((usize, f64, bool)) -> bool) -> (usize, f64, bool) {
// Only inside the last paragraph: a space before a hard break joins nothing on the last line.
let para = t.characters.rfind('\n').map_or(0, |i| i + 1);
let Some(i) = t.characters.trim_end().get(para..).and_then(|p| p.rfind(' ')).map(|i| para + i) else {
return measure(t, 1.0, width);
};
t.characters.replace_range(i..i + 1, "\u{a0}");
let after = measure(t, 1.0, width);
if fits(after) {
return after;
}
t.characters.replace_range(i..i + '\u{a0}'.len_utf8(), " ");
measure(t, 1.0, width)
}