Files
figcraft/apps/figcraft-cli/src/files.rs
T
Gert Pellin eb07717783 M0.9: trim mixed edge spaces and dots, document the name limits (#1154)
Review: " . .name. . " kept inner dots at the edges; trim spaces and dots
in one pass. docs/cli.md and safe_file_name's comment give the 100 and
200 character limits.
2026-10-22 21:43:52 +02:00

526 lines
23 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.
//! The file subcommands: info, export, convert, render and run.
use std::path::Path;
use std::process::ExitCode;
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
use figcraft_engine::Session;
use serde_json::{Value, json};
use crate::{CliError, CliResult};
pub fn load(path: &str) -> CliResult<Document> {
load_noted(path).map(|(d, _)| d)
}
/// The document with the loader's notes (repairs of a damaged file, or import warnings).
fn load_noted(path: &str) -> CliResult<(Document, Vec<String>)> {
figcraft_engine::cmd::file::load(path).map_err(|e| CliError::Failed(format!("can't open {path}: {e}")))
}
fn ext(path: &str) -> String {
Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
}
fn image_format(e: &str) -> Option<ExportFormat> {
match e {
"png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" => ExportFormat::from_ext(e),
_ => None,
}
}
/// Writes through [`figcraft_engine::file_access`] when automation roots are in force, so a
/// confined run can only write inside a write root (#833).
///
/// Unconfined it writes with `std::fs::write`, as it always did. `file_access::write` stages the
/// bytes and renames them into place, which **replaces** a symbolic link at `path` instead of
/// writing through it to its target -- a change of behaviour for anyone whose output path is a
/// link, and nothing to do with confinement. Inside a root the staged write is the right one:
/// `Roots::check` has already refused a link that leads out of the root.
fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?;
}
if figcraft_engine::file_access::current().is_some() {
return figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string()));
}
std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}")))
}
fn page_named(doc: &Document, name: Option<&str>) -> CliResult<NodeId> {
match name {
None => doc.first_open_page().or_else(|| doc.first_page()).ok_or_else(|| CliError::Failed("the document has no pages".into())),
Some(n) => doc
.pages
.iter()
.copied()
.find(|p| doc.node(*p).is_some_and(|pn| pn.name == n))
.or_else(|| doc.pages.iter().copied().find(|p| doc.node(*p).is_some_and(|pn| pn.name.eq_ignore_ascii_case(n))))
.ok_or_else(|| {
let names: Vec<String> = doc.pages.iter().filter_map(|p| doc.node(*p).map(|n| n.name.clone())).collect();
CliError::Failed(format!("no page named `{n}` (pages: {})", names.join(", ")))
}),
}
}
fn find_node(doc: &Document, page: NodeId, q: &str) -> CliResult<NodeId> {
if let Ok(id) = q.parse::<NodeId>()
&& doc.contains(id)
{
return Ok(id);
}
doc.find_by_name(q, Some(page))
.into_iter()
.find(|i| doc.node(*i).is_some_and(|n| n.name == q))
.or_else(|| doc.find_by_name(q, None).into_iter().find(|i| doc.node(*i).is_some_and(|n| n.name == q)))
.or_else(|| doc.find_by_name(q, Some(page)).into_iter().next())
.ok_or_else(|| CliError::Failed(format!("no layer with id or name `{q}`")))
}
fn children(doc: &Document, page: NodeId) -> Vec<NodeId> {
doc.node(page).map(|p| p.children.clone()).unwrap_or_default()
}
/// Render a page's content (with the page background) at `scale`, shrinking to fit 16384 px.
pub fn render_page(doc: &Document, page: NodeId, scale: f64) -> CliResult<figcraft_render::Pixmap> {
let b = doc.page_content_bounds(page).ok_or_else(|| CliError::Failed("the page is empty: nothing to render".into()))?;
let side = b.width().max(b.height()).max(1e-6);
let scale = scale.min(16_384.0 / side);
let view = figcraft_render::View {
origin: (b.x0, b.y0),
scale,
width: (b.width() * scale).ceil().max(1.0) as u32,
height: (b.height() * scale).ceil().max(1.0) as u32,
};
figcraft_render::render_view(doc, page, &view, &Default::default()).ok_or_else(|| CliError::Failed("render failed".into()))
}
/// Encode a render as the format extension `e` names, tagged with `doc`'s colour profile.
fn encode(doc: &Document, pix: &figcraft_render::Pixmap, e: &str) -> CliResult<Vec<u8>> {
let format = ExportFormat::from_ext(e).unwrap_or_default();
figcraft_render::encode_export(pix, format, doc.color_profile).map_err(CliError::Failed)
}
/// Write `doc` to `out`, choosing the format by extension.
pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
let e = ext(out);
let bytes = match e.as_str() {
"figcraft" => {
let thumb = figcraft_engine::cmd::file::thumbnail(doc);
figcraft_format::save_bytes(doc, thumb.as_deref()).map_err(|e| CliError::Failed(e.to_string()))?
}
"json" => {
let v = figcraft_format::to_json(doc).map_err(|e| CliError::Failed(e.to_string()))?;
serde_json::to_vec_pretty(&v).map_err(|e| CliError::Failed(e.to_string()))?
}
"svg" => figcraft_svg::try_export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
.map_err(|e| CliError::Failed(e.to_string()))?
.into_bytes(),
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
"fig" => fig_bytes(doc)?,
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig)"))),
};
write(out, &bytes)
}
/// `doc` as a `.fig` file (experimental), printing what was left out.
fn fig_bytes(doc: &Document) -> CliResult<Vec<u8>> {
let (bytes, warnings) = figcraft_engine::cmd::file::fig_bytes(doc).map_err(|e| CliError::Failed(e.to_string()))?;
for w in warnings {
eprintln!("note: {w}");
}
Ok(bytes)
}
/// A page as PDF: its top-level frames one page each, or (without frames) everything on one page.
fn page_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> CliResult<Vec<u8>> {
let r = if doc.top_frames(page).iter().any(|f| doc.node(*f).is_some_and(|n| n.visible)) {
figcraft_engine::cmd::file::frames_pdf(doc, page, print).map(|(bytes, _)| bytes)
} else {
figcraft_engine::cmd::file::export_bytes_with(
doc,
&children(doc, page),
&ExportSetting { format: ExportFormat::Pdf, print: *print, ..Default::default() },
1.0,
)
};
r.map_err(|e| CliError::Failed(e.to_string()))
}
pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
let (doc, notes) = load_noted(path)?;
let pages: Vec<Value> = doc
.pages
.iter()
.filter_map(|p| {
let pn = doc.node(*p)?;
let nodes = doc.subtree(*p).len().saturating_sub(1);
let frames: Vec<Value> = pn
.children
.iter()
.filter_map(|c| doc.node(*c))
.filter(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }))
.map(|n| json!({"id": n.id, "name": n.name, "type": n.kind.type_name(), "width": n.width, "height": n.height}))
.collect();
Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames}))
})
.collect();
let missing = figcraft_engine::fonts::missing(&doc);
let v = json!({
"file": path,
"name": doc.name,
"nodes": doc.node_count(),
"pages": pages,
"styles": doc.styles.len(),
"variables": doc.variables.variables.len(),
"images": doc.images.len(),
"notes": notes,
"missingFonts": missing.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::<Vec<_>>(),
});
if as_json {
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
return Ok(ExitCode::SUCCESS);
}
for n in &notes {
eprintln!("note: {n}");
}
println!(
"{} — {} ({} nodes, {} styles, {} variables, {} images)",
path,
doc.name,
doc.node_count(),
doc.styles.len(),
doc.variables.variables.len(),
doc.images.len()
);
for p in &pages {
println!("page {} \"{}\": {} nodes, {} top-level", p["id"], p["name"].as_str().unwrap_or(""), p["nodes"], p["topLevel"]);
for f in p["frames"].as_array().into_iter().flatten() {
println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]);
}
}
for m in &missing {
let layers = if m.layers == 1 { "text layer" } else { "text layers" };
println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers);
}
Ok(ExitCode::SUCCESS)
}
/// The file name a layer exports to: the app's rule ([`figcraft_engine::cmd::file::safe_file_name`],
/// so `/` becomes `_` too), without leading or trailing spaces and dots, at most 100 characters.
fn sanitize(name: &str) -> String {
let s = figcraft_engine::cmd::file::safe_file_name(name);
let s = s.trim_matches(|c: char| c == '.' || c.is_whitespace()).to_string();
if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() }
}
/// What `export` exports and how.
pub struct Target {
pub format: Option<String>,
pub scale: f64,
pub page: Option<String>,
pub node: Option<String>,
/// PDF only.
pub print: PrintSettings,
}
pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
let doc = load(file)?;
// `.fig`: the whole file.
if t.format.as_deref().map_or(ext(out) == "fig", |f| f.eq_ignore_ascii_case("fig")) {
write(out, &fig_bytes(&doc)?)?;
println!("{out}");
return Ok(ExitCode::SUCCESS);
}
let page = page_named(&doc, t.page.as_deref())?;
let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print);
let fmt = match t.format.as_deref() {
Some(f) => {
image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig)")))?
}
None => image_format(&ext(out)).unwrap_or(ExportFormat::Png),
};
let target = node.map(|q| find_node(&doc, page, q)).transpose()?;
let single = image_format(&ext(out)).is_some();
let bytes_of = |ids: &[NodeId]| {
let setting = ExportSetting { format: fmt, print: *print, ..Default::default() };
figcraft_engine::cmd::file::export_bytes_with(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))
};
if single {
let bytes = match target {
Some(t) => bytes_of(&[t])?,
None if fmt == ExportFormat::Pdf => page_pdf(&doc, page, print)?,
None => bytes_of(&children(&doc, page))?,
};
write(out, &bytes)?;
println!("{out}");
return Ok(ExitCode::SUCCESS);
}
figcraft_engine::file_access::create_dir_all(out).map_err(|e| CliError::Failed(e.to_string()))?;
let ids = match target {
Some(t) => vec![t],
None => {
let kids = children(&doc, page);
let frames: Vec<NodeId> = kids.iter().copied().filter(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())).collect();
if frames.is_empty() { kids } else { frames }
}
};
if ids.is_empty() {
return Err(CliError::Failed("the page is empty: nothing to export".into()));
}
let mut used = std::collections::BTreeSet::new();
for id in ids {
let base = sanitize(doc.node(id).map(|n| n.name.as_str()).unwrap_or("layer"));
let mut name = base.clone();
let mut k = 2;
while !used.insert(name.to_lowercase()) {
name = format!("{base} {k}");
k += 1;
}
let path = Path::new(out).join(format!("{name}.{}", fmt.ext())).to_string_lossy().into_owned();
write(&path, &bytes_of(&[id])?)?;
println!("{path}");
}
Ok(ExitCode::SUCCESS)
}
pub fn convert(input: &str, out: &str) -> CliResult<ExitCode> {
let doc = load(input)?;
let page = doc
.current_page
.filter(|p| doc.pages.contains(p))
.or_else(|| doc.first_page())
.ok_or_else(|| CliError::Failed("the document has no pages".into()))?;
write_doc(&doc, page, out)?;
println!("{out}");
Ok(ExitCode::SUCCESS)
}
pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResult<ExitCode> {
let doc = load(file)?;
let page = page_named(&doc, page)?;
let e = ext(out);
if !matches!(e.as_str(), "png" | "jpg" | "jpeg" | "webp") {
return Err(CliError::Usage(format!("render writes .png, .jpg or .webp, not `{out}`")));
}
let pix = render_page(&doc, page, scale)?;
write(out, &encode(&doc, &pix, &e)?)?;
println!("{out} ({}×{})", pix.width(), pix.height());
Ok(ExitCode::SUCCESS)
}
/// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines.
pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
let steps: Vec<Value> = match serde_json::from_str::<Value>(text) {
Ok(Value::Array(a)) => a,
Ok(Value::Object(o)) if o.contains_key("commands") => {
o.get("commands").and_then(Value::as_array).cloned().ok_or("`commands` must be an array")?
}
Ok(v @ Value::Object(_)) => vec![v],
Ok(_) => return Err("a script is an array of {\"command\", \"params\"} objects".into()),
Err(_) => text
.lines()
.enumerate()
.filter(|(_, l)| !l.trim().is_empty() && !l.trim_start().starts_with("//"))
.map(|(i, l)| serde_json::from_str::<Value>(l).map_err(|e| format!("line {}: {e}", i + 1)))
.collect::<Result<_, _>>()?,
};
steps
.into_iter()
.enumerate()
.map(|(i, s)| {
let cmd = s.get("command").and_then(Value::as_str).ok_or_else(|| format!("step {i}: `command` (a string) is required"))?;
let params = match s.get("params") {
None | Some(Value::Null) => json!({}),
Some(p @ Value::Object(_)) => p.clone(),
Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")),
};
Ok((cmd.to_string(), params))
})
.collect()
}
pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult<ExitCode> {
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let mut s = match input {
Some(path) => {
let mut s = Session::empty();
s.execute("file.open", &json!({"path": path})).map_err(|e| CliError::Failed(format!("can't open {path}: {e}")))?;
s
}
None => Session::new(),
};
// One undo step and all-or-nothing, the way the same commands behave through `command.batch`
// (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and
// a step that fails takes the whole script back instead of leaving the earlier steps applied.
// The result lines are held back until the script is through, so none of them reports work that
// was rolled back.
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
let mut failed_at = 0;
let outcome = s.grouped(&format!("run {script}"), |s| {
for (i, (cmd, params)) in steps.iter().enumerate() {
failed_at = i;
let v = s.batch_step(&results, i, cmd, params)?;
s.ui_requests.clear();
results.push(v);
}
Ok(())
});
if let Err(e) = outcome {
let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str());
let i = failed_at;
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true}));
eprintln!("error: step {i} ({cmd}) failed: {e}");
eprintln!("the script's document changes were rolled back");
return Ok(ExitCode::from(1));
}
for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() {
println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v}));
}
if let Some(out) = out {
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
if ext(out) == "figcraft" {
// Through the engine, so the version history is kept.
write(out, &figcraft_engine::cmd::file::file_bytes(ds).map_err(|e| CliError::Failed(e.to_string()))?)?;
} else {
write_doc(&ds.doc, ds.page, out)?;
}
eprintln!("wrote {out}");
}
Ok(ExitCode::SUCCESS)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn script_formats() {
assert_eq!(parse_script(r#"[{"command":"a"},{"command":"b","params":{"x":1}}]"#).unwrap().len(), 2);
assert_eq!(parse_script(r#"{"commands":[{"command":"a"}]}"#).unwrap().len(), 1);
assert_eq!(parse_script("{\"command\":\"a\"}\n\n// note\n{\"command\":\"b\"}\n").unwrap().len(), 2);
assert!(parse_script("[{\"params\":{}}]").is_err());
assert!(parse_script("[{\"command\":\"a\",\"params\":3}]").is_err());
assert!(parse_script("not json\n").is_err());
assert!(parse_script("42").is_err());
}
#[test]
fn sanitizes_names() {
assert_eq!(sanitize("Home / Hero"), "Home _ Hero");
assert_eq!(sanitize("../.."), "_");
assert_eq!(sanitize(""), "layer");
// The same characters the app's export keeps (#1154): only what file systems refuse goes.
assert_eq!(sanitize("Homepage - desktop (live + verbeteringen)"), "Homepage - desktop (live + verbeteringen)");
assert_eq!(sanitize("Q&A, 50% #1 [v2] {x} ~é"), "Q&A, 50% #1 [v2] {x} ~é");
assert_eq!(sanitize("a\\b:c*d?e\"f<g>h|i\tj"), "a_b_c_d_e_f_g_h_i_j");
assert_eq!(sanitize(" .hidden. "), "hidden");
assert_eq!(sanitize(" . .name. . "), "name", "spaces and dots at the edges, mixed");
assert_eq!(sanitize(&"x".repeat(300)).chars().count(), 100);
}
#[test]
fn default_page_skips_leading_divider() {
let mut doc = Document::new("divider");
let page = doc.first_page().unwrap();
doc.create(page, figcraft_doc::NodeKind::Frame, "First", 0.0, 0.0, 40.0, 30.0).unwrap();
let divider = doc.add_page("- Divider");
doc.pages.retain(|p| *p != divider);
doc.pages.insert(0, divider);
assert!(doc.is_divider(divider));
assert_eq!(page_named(&doc, None).unwrap(), page);
assert_eq!(page_named(&doc, Some("- Divider")).unwrap(), divider);
let pm = render_page(&doc, page_named(&doc, None).unwrap(), 1.0).unwrap();
assert_eq!((pm.width(), pm.height()), (40, 30));
assert!(render_page(&doc, divider, 1.0).is_err());
}
#[test]
fn export_depth_errors_preserve_conversion_destinations() {
let mut doc = Document::new("export depth");
let page = doc.first_page().unwrap();
let root = doc.create(page, figcraft_doc::NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
let mut parent = root;
for _ in 1..257 {
parent = doc.create(parent, figcraft_doc::NodeKind::Group, "group", 0.0, 0.0, 32.0, 32.0).unwrap();
}
doc.create(parent, figcraft_doc::NodeKind::Rectangle, "leaf", 0.0, 0.0, 8.0, 8.0).unwrap();
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("figcraft-cli-export-depth-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
for ext in ["svg", "pdf"] {
let path = dir.join(format!("previous.{ext}"));
std::fs::write(&path, b"previous export").unwrap();
let CliError::Failed(error) = write_doc(&doc, page, path.to_str().unwrap()).unwrap_err() else {
panic!("depth overflow must be an operation failure");
};
assert!(error.contains("maximum export depth of 256"), "{ext}: {error}");
assert!(error.contains("select an inner layer"), "{ext}: {error}");
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
std::fs::remove_dir_all(dir).unwrap();
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("figcraft-cli-{tag}-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
dir
}
/// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step
/// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it
/// as "no parent" and the layer landed on the page — with every step reporting success.
#[test]
fn a_script_puts_a_layer_in_a_layer_an_earlier_step_made() {
let dir = temp_dir("run-step");
let script = dir.join("script.json");
std::fs::write(
&script,
r#"[
{"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
{"command": "node.create", "params": {"type": "rectangle", "name": "child", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}}
]"#,
)
.unwrap();
let out = dir.join("out.figcraft");
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
let mut s = Session::empty();
s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
let doc = s.doc().unwrap();
let page = s.page().unwrap();
let top = doc.node(page).unwrap().children.clone();
assert_eq!(top.len(), 1, "only the frame is top level, not the rectangle too");
assert_eq!(doc.node(top[0]).unwrap().name, "Card");
let inner = doc.node(top[0]).unwrap().children.clone();
assert_eq!(inner.len(), 1, "the rectangle went inside the frame");
assert_eq!(doc.node(inner[0]).unwrap().name, "child");
std::fs::remove_dir_all(dir).unwrap();
}
/// A step that fails takes the whole script back: nothing is written, and the failure says so.
#[test]
fn a_failed_step_leaves_no_output() {
let dir = temp_dir("run-rollback");
let script = dir.join("script.json");
std::fs::write(
&script,
r#"[
{"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
{"command": "node.set", "params": {"ids": [99999], "props": {"name": "nope"}}}
]"#,
)
.unwrap();
let out = dir.join("out.figcraft");
// The failure is the script's, not the call's: the CLI reports it and exits non-zero.
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
assert!(!out.exists(), "a script that failed writes nothing");
std::fs::remove_dir_all(dir).unwrap();
}
}