diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index b1819c35..94a1a11e 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; @@ -231,22 +268,32 @@ 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()?; - 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 +310,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 +358,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 +372,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 +420,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..90fa062c 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(); @@ -101,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] 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,