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());