From 8547a9fca4b3ac2feab74ee7ccff321e76363ba0 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:42:58 +0200 Subject: [PATCH 1/2] CLI: --export writes the file type its extension names (#432) --export knew .pdf, .epub, .idml and .designcraft and saved a PNG of the page under every other name: out.html, out.txt, OUT.PDF, and out.designcraft from `script`. The exit status was 0. .html and .htm now run file.exportHtml, .rtf and .txt run file.exportText (the story being edited or of the selected frame), and extensions match in any case. A name with another extension is an error before anything runs; one with no extension is still a PNG. `run` and `script` share one list, and `script` saves a copy for .designcraft. Co-Authored-By: Claude Opus 5.5 --- apps/designcraft-cli/src/main.rs | 109 ++++++++++++++++++++---------- apps/designcraft-cli/tests/cli.rs | 84 +++++++++++++++++++++++ docs/agents.md | 4 ++ 3 files changed, 160 insertions(+), 37 deletions(-) diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index b1819c35..54f57b77 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -1,8 +1,9 @@ //! Headless DesignCraft. //! //! ```text -//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub] [--all-pages DIR] +//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub|.html|.rtf|.txt] [--all-pages DIR] //! # --page, --scale and --pdf-options apply to the exports that follow them +//! # .rtf and .txt hold the story being edited or of the selected frame //! designcraft-cli commands [FILTER] # list commands (JSON), optionally only ids/labels/menus containing FILTER //! designcraft-cli describe ID # one command: label, menu, shortcut, parameters //! designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going] @@ -186,12 +187,48 @@ fn option_after_last_export(args: &[String]) -> Option<&str> { unused } +/// What `--export OUT` writes. The extension of `OUT` picks it, in any case. +enum Export { + /// A page as PNG or JPEG. + Image, + Pdf, + /// The document itself. + Document, + /// What this command writes to a `path`. + Command(&'static str), +} + +impl Export { + /// A name with another extension is an error: it used to get a PNG of the page. One with no + /// extension still does (`--export /dev/stdout`). + fn of(out: &str) -> Result { + let ext = std::path::Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_lowercase); + Ok(match ext.as_deref() { + None | Some("png" | "jpg" | "jpeg") => Export::Image, + Some("pdf") => Export::Pdf, + Some("designcraft") => Export::Document, + Some("idml") => Export::Command("file.exportIdml"), + Some("epub") => Export::Command("file.exportEpub"), + Some("html" | "htm") => Export::Command("file.exportHtml"), + // The story being edited or of the selected frame. + Some("rtf" | "txt") => Export::Command("file.exportText"), + Some(_) => return Err(format!("--export {out}: unknown file type (.png, .jpg, .pdf, .designcraft, .idml, .epub, .html, .rtf, .txt)")), + }) + } +} + fn run(args: &[String]) -> Result<(), String> { if let Some(opt) = option_after_last_export(args) { return Err(format!( "{opt} has no --export or --all-pages after it; options apply to the exports that follow them, so put it before its --export" )); } + // Nothing runs when one of the exports cannot be written. + for a in args.windows(2) { + if a[0] == "--export" { + Export::of(&a[1])?; + } + } let mut s = Session::new(); let mut page = 0usize; let mut scale = 1.0f64; @@ -234,19 +271,25 @@ fn run(args: &[String]) -> Result<(), String> { } "--export" => { let out = val()?; - if out.ends_with(".epub") { - let r = s.execute("file.exportEpub", &json!({"path": out})).map_err(|e| e.to_string())?; - eprintln!("wrote {out} ({} bytes)", r["bytes"]); - } else if out.ends_with(".pdf") { - let mut p = pdf_opts.clone(); - p["path"] = json!(out); - let r = s.execute("file.exportPdf", &p).map_err(|e| e.to_string())?; - eprintln!("wrote {out} ({} pages, {} bytes)", r["pages"], r["bytes"]); - for w in r["warnings"].as_array().into_iter().flatten() { - eprintln!("warning: {}", w.as_str().unwrap_or_default()); + match Export::of(&out)? { + Export::Image => export(&mut s, &out, page, scale)?, + Export::Pdf => { + let mut p = pdf_opts.clone(); + p["path"] = json!(out); + let r = s.execute("file.exportPdf", &p).map_err(|e| e.to_string())?; + eprintln!("wrote {out} ({} pages, {} bytes)", r["pages"], r["bytes"]); + for w in r["warnings"].as_array().into_iter().flatten() { + eprintln!("warning: {}", w.as_str().unwrap_or_default()); + } + } + Export::Document => { + s.execute("file.saveAs", &json!({"path": out})).map_err(|e| e.to_string())?; + eprintln!("saved {out}"); + } + Export::Command(cmd) => { + let r = s.execute(cmd, &json!({"path": out})).map_err(|e| e.to_string())?; + eprintln!("wrote {out} ({} bytes)", r["bytes"]); } - } else { - export(&mut s, &out, page, scale)?; } } "--all-pages" => { @@ -263,24 +306,16 @@ fn run(args: &[String]) -> Result<(), String> { Ok(()) } +/// A page as PNG, or as JPEG for `.jpg` and `.jpeg`. fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), String> { - if out.to_ascii_lowercase().ends_with(".idml") { - let r = s.execute("file.exportIdml", &json!({"path": out})).map_err(|e| e.to_string())?; - eprintln!("wrote {out} ({} bytes)", r["bytes"]); - return Ok(()); - } - if out.ends_with(".designcraft") { - s.execute("file.saveAs", &json!({"path": out})).map_err(|e| e.to_string())?; - eprintln!("saved {out}"); - return Ok(()); - } let st = s.doc().map_err(|e| e.to_string())?; let mut r = designcraft_render::Renderer::new(); let t = std::time::Instant::now(); let img = r .render_page(&st.doc, &s.cache, page, scale, true, &designcraft_render::RenderOptions { printing_only: true, ..Default::default() }) .ok_or("no such page")?; - let bytes = if out.ends_with(".jpg") || out.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() }; + let lower = out.to_lowercase(); + let bytes = if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() }; std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?; eprintln!("wrote {out} ({}×{}, {:.1} ms, {} glyphs)", img.width, img.height, t.elapsed().as_secs_f64() * 1000.0, r.stats.glyphs); Ok(()) @@ -319,7 +354,7 @@ fn script(args: &[String]) -> Result<(), String> { let mut setup: Vec<(String, Value)> = Vec::new(); let mut connect: Option = None; let mut save: Option = None; - let mut export: Vec = Vec::new(); + let mut export: Vec<(Export, String)> = Vec::new(); let mut keep_going = false; let mut it = args.iter(); while let Some(a) = it.next() { @@ -333,7 +368,10 @@ fn script(args: &[String]) -> Result<(), String> { "--sample" => setup.push(("file.newSample".into(), json!({}))), "--connect" => connect = Some(val()?), "--save" => save = Some(val()?), - "--export" => export.push(val()?), + "--export" => { + let out = val()?; + export.push((Export::of(&out)?, out)); + } "--keep-going" => keep_going = true, f if !f.starts_with("--") && file.is_none() => file = Some(f.to_string()), other => return Err(format!("unknown script option `{other}`")), @@ -378,19 +416,16 @@ fn script(args: &[String]) -> Result<(), String> { if let Some(out) = &save { exec(b, "file.saveAs", json!({"path": out}))?; } - for out in &export { - let lower = out.to_lowercase(); + for (kind, out) in &export { let p = json!({"path": out}); - if lower.ends_with(".pdf") { - exec(b, "file.exportPdf", p)?; - } else if lower.ends_with(".idml") { - exec(b, "file.exportIdml", p)?; - } else if lower.ends_with(".epub") { - exec(b, "file.exportEpub", p)?; - } else { + match kind { // PNG / JPEG of the first page. - b.call("app.export", p)?; - } + Export::Image => b.call("app.export", p)?, + Export::Pdf => exec(b, "file.exportPdf", p)?, + // A copy: the document in the app stays the file it is (`--save` is Save As). + Export::Document => exec(b, "file.saveACopy", p)?, + Export::Command(cmd) => exec(b, cmd, p)?, + }; } } outln!("{}", serde_json::to_string_pretty(&report.to_json()).unwrap_or_default()); diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index 1a06d13b..4e749a0c 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -35,6 +35,90 @@ fn script_from_stdin_with_references_saves_and_exports() { let _ = std::fs::remove_dir_all(&dir); } +/// `--export` wrote a PNG of the page under every name it did not know: `out.html`, `out.txt` and +/// `OUT.PDF` were page images, and so was `out.designcraft` from `script` (#432). +#[test] +fn export_writes_the_file_type_its_extension_names() { + let frame = r#"{"rect": [72, 72, 400, 200], "content": "text", "text": "Hello"}"#; + let types: [(&str, &[u8]); 13] = [ + // No extension: a PNG, as before. + ("a", b"\x89PNG"), + ("a.png", b"\x89PNG"), + ("a.jpg", b"\xFF\xD8"), + ("A.JPEG", b"\xFF\xD8"), + ("a.pdf", b"%PDF"), + ("A.PDF", b"%PDF"), + ("a.designcraft", b"PK"), + ("a.idml", b"PK"), + ("a.epub", b"PK"), + ("a.html", b""), + ("a.htm", b""), + ("a.rtf", b"{\\rtf"), + ("a.txt", b"Hello"), + ]; + for mode in ["run", "script"] { + let dir = std::env::temp_dir().join(format!("dc-cli-types-{mode}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let script = dir.join("page.dcs"); + std::fs::write(&script, format!("frame.create {frame}\n")).unwrap(); + let mut args: Vec = match mode { + "run" => vec!["run".into(), "--cmd".into(), format!("frame.create={frame}")], + _ => vec!["script".into(), script.to_str().unwrap().into()], + }; + for (name, _) in types { + args.extend(["--export".into(), dir.join(name).to_str().unwrap().into()]); + } + let out = cli().args(&args).output().unwrap(); + assert!(out.status.success(), "{mode}: {}", String::from_utf8_lossy(&out.stderr)); + for (name, start) in types { + let bytes = std::fs::read(dir.join(name)).unwrap(); + assert!(bytes.starts_with(start), "{mode} --export {name} wrote {:?}", String::from_utf8_lossy(&bytes[..bytes.len().min(16)])); + } + let _ = std::fs::remove_dir_all(&dir); + } +} + +/// A file type `--export` does not write is an error, before anything runs or is written. +#[test] +fn export_to_an_unknown_file_type_is_an_error() { + let dir = std::env::temp_dir().join(format!("dc-cli-unknown-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (png, doc, unknown) = (dir.join("a.png"), dir.join("a.designcraft"), dir.join("a.xyz")); + let (png, doc, unknown) = (png.to_str().unwrap(), doc.to_str().unwrap(), unknown.to_str().unwrap()); + for args in [["run", "--sample", "--export", png, "--export", unknown], ["script", "-", "--save", doc, "--export", unknown]] { + let out = cli().args(args).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{args:?}: {err}"); + assert!(err.contains(unknown) && err.contains(".html"), "{args:?}: {err}"); + } + assert!(std::fs::read_dir(&dir).unwrap().next().is_none(), "nothing is written when a file type is unknown"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// With `--connect` the exports happen in the running app. `--export copy.designcraft` saves a +/// copy there: Save As would make the copy the file the open document saves to from then on. +#[test] +fn script_export_of_the_document_in_the_running_app_saves_a_copy() { + use std::io::{BufRead, BufReader}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port().to_string(); + // Stands in for the app: answers every request and keeps what was asked. + let app = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut reply = stream.try_clone().unwrap(); + let mut asked = Vec::new(); + for line in BufReader::new(stream).lines() { + let request: serde_json::Value = serde_json::from_str(&line.unwrap()).unwrap(); + asked.push((request["params"]["command"].clone(), request["params"]["params"]["path"].clone())); + writeln!(reply, r#"{{"ok": true, "result": {{}}}}"#).unwrap(); + } + asked + }); + let out = cli().args(["script", "-", "--connect", &port, "--export", "copy.designcraft"]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(app.join().unwrap(), [(serde_json::json!("file.saveACopy"), serde_json::json!("copy.designcraft"))]); +} + #[test] fn failing_script_exits_nonzero_with_the_step() { let mut child = cli().args(["script", "-"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); diff --git a/docs/agents.md b/docs/agents.md index 0dd23f06..83e50fe7 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -45,6 +45,10 @@ same references, and MCP's `batch` tool takes `commands` or `script` text with t `--all-pages` that follow them, so one run can write several sizes (`run --sample --scale 0.5 --export small.png --scale 2 --export large.png`); one with no export after it is an error. +`--export` writes the file type its extension names: `.png` or `.jpg` (a page), `.pdf`, `.designcraft`, `.idml`, +`.epub`, `.html`, and `.rtf` or `.txt` (the story being edited or of the selected frame). A name with no extension +is a PNG of the page; another extension is an error, and nothing runs. + ## Data merge `data.source.select {path, sheet?}` links a CSV/TSV/`.xlsx` file to the document (`data.source.update` re-reads it, From 40e8dbc6fb358a12cdd5099acef10d8552107d87 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:55:20 +0200 Subject: [PATCH 2/2] CLI: --pdf-options that is no JSON object is an error `run --pdf-options '[1]' --export a.pdf` panicked when the PDF's path was added to the options (exit status 101). The value is now checked where it is read. Co-Authored-By: Claude Opus 5.5 --- apps/designcraft-cli/src/main.rs | 4 ++++ apps/designcraft-cli/tests/cli.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 54f57b77..94a1a11e 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -268,6 +268,10 @@ fn run(args: &[String]) -> Result<(), String> { "--scale" => scale = val()?.parse().map_err(|_| "bad --scale")?, "--pdf-options" => { pdf_opts = serde_json::from_str(&val()?).map_err(|e| format!("--pdf-options: {e}"))?; + // The export adds its path to them. + if !pdf_opts.is_object() { + return Err("--pdf-options: expected a JSON object".into()); + } } "--export" => { let out = val()?; diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index 4e749a0c..90fa062c 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -185,6 +185,19 @@ fn closed_stdout_ends_quietly() { assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status); } +/// `--pdf-options` that was JSON but no object (`[1]`) panicked when the PDF's path was added to it. +#[test] +fn run_pdf_options_that_are_no_object_are_an_error() { + let pdf = std::env::temp_dir().join(format!("dc-cli-options-{}.pdf", std::process::id())); + for options in ["[1]", "\"all\"", "1", "null"] { + let out = cli().args(["run", "--sample", "--pdf-options", options, "--export", pdf.to_str().unwrap()]).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{options}: {err}"); + assert!(err.contains("--pdf-options") && !err.contains("panicked"), "{options}: {err}"); + } + assert!(!pdf.exists()); +} + /// `--scale`, `--page` and `--pdf-options` apply to the exports after them; one with no export /// after it was silently ignored. #[test]