mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
Merge #475: CLI: --export writes the file type its extension names
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user