Merge #475: CLI: --export writes the file type its extension names

This commit is contained in:
aiongg
2026-10-11 12:23:53 +00:00
3 changed files with 177 additions and 37 deletions
+76 -37
View File
@@ -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<Self, String> {
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<String> = None;
let mut save: Option<String> = None;
let mut export: Vec<String> = 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());
+97
View File
@@ -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"<!DOCTYPE html>"),
("a.htm", b"<!DOCTYPE html>"),
("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<String> = 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]
+4
View File
@@ -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,