diff --git a/crates/engine/src/datafill.rs b/crates/engine/src/datafill.rs index 865276a7..6b8376ef 100644 --- a/crates/engine/src/datafill.rs +++ b/crates/engine/src/datafill.rs @@ -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 { 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 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 = 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)?; - } + 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 { // 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); - } + 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>> { + 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 `x` when it has no name. +fn format_of(f: &Value) -> Result> { + 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, +) -> Result> { + 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) -> 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", ¶ms)?; + } + Ok(()) +} + +/// Export each copy into `dir` as `.` 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::::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 { diff --git a/crates/engine/src/textfit.rs b/crates/engine/src/textfit.rs index 26d59e93..4ab6c50e 100644 --- a/crates/engine/src/textfit.rs +++ b/crates/engine/src/textfit.rs @@ -65,45 +65,8 @@ fn fit(s: &mut Session, p: &Value) -> Result { 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 { 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) +}