mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 10:56:48 -06:00
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.
526 lines
23 KiB
Rust
526 lines
23 KiB
Rust
//! 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 ¬es {
|
||
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();
|
||
}
|
||
}
|