Files
designcraft/apps/designcraft-cli/src/main.rs
T

467 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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]
//! # --page, --scale and --pdf-options apply to the exports that follow them
//! 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]
//! # run a command script (crates/engine/src/script.rs): `$N.path` references
//! designcraft-cli app [--port PORT] COMMAND [JSON] # run a command in the running app (designcraft --control PORT)
//! designcraft-cli app [--port PORT] --method METHOD [JSON] # any control-channel method (ui.screenshot, ui.render, …)
//! designcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio (docs/mcp.md)
//! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document
//! designcraft-cli bench FILE [--runs N] # the same measurements on one document
//! designcraft-cli validate-pdf FILE.pdf # run the built-in PDF/X-4 conformance checks
//! designcraft-cli links # Discord, website, app page and GitHub links
//! designcraft-cli --version # print the version
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::process::ExitCode;
/// `println!` that ends the program quietly when stdout is closed (`designcraft-cli commands |
/// head`) instead of panicking with "failed printing to stdout: Broken pipe (os error 32)".
macro_rules! outln {
($($arg:tt)*) => {{
use std::io::Write as _;
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
$crate::stdout_failed(e);
}
}};
}
mod perf;
use designcraft_engine::Session;
use serde_json::{Value, json};
/// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure).
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version";
/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md.
const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
Runs a command script from FILE (stdin when FILE is `-` or missing), headless or, with
--connect PORT, in the running app (designcraft --control PORT).
Script text, one step per line:
command.id {"param": 1} also `command.id={json}`, or `command.id` alone
{"command": "command.id", "params": {...}} JSON lines, or a JSON array of them
# comment, // comment skipped, like blank lines
UTF-8 with or without a byte order mark; LF or CRLF line endings.
References to earlier results (in string parameters):
"$N.path" the value at `path` in the result of step N. N is the 0-based step index:
comments and blank lines are not steps. `$last` is the previous step.
Paths go through result fields and array indices: "$1.story", "$3.ids.0".
"${N.path}" inside a longer string, replaced by the value's text: "Frame ${0.id}".
Text parameters: "\n" starts a new paragraph, "\u2028" is a forced line break, "\t" a tab.
Most commands ignore parameters they do not know instead of rejecting them: check names
with `designcraft-cli describe COMMAND`.
Options:
--in FILE / --sample open a document (.designcraft or .idml) or the sample first
--save OUT save as .designcraft after the script succeeds
--export OUT export after the script succeeds (.pdf, .idml, .epub, .png, .jpg); repeatable
--keep-going run every step, recording failures as {"error": ...} results
Prints {"completed": n, "results": [...]}, plus failedIndex / failedCommand / error and a
non-zero exit when a step fails."#;
/// The links line under the usage block.
fn usage_footer() -> String {
format!(
"\nCommunity: {} · {} · {}",
designcraft_engine::links::DISCORD,
designcraft_engine::links::APP_PAGE,
designcraft_engine::links::GITHUB
)
}
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as
/// ripgrep does; any other write error is reported.
fn stdout_failed(e: std::io::Error) -> ! {
if e.kind() == std::io::ErrorKind::BrokenPipe {
std::process::exit(0);
}
eprintln!("designcraft-cli: can't write to stdout: {e}");
std::process::exit(1);
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("--version" | "-V" | "version") => {
outln!("designcraft-cli {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
Some("run") => report(run(&args[1..])),
Some("commands") => {
let s = Session::new();
let filter = args.get(1).map(|f| f.to_lowercase());
let list: Vec<Value> = serde_json::to_value(s.commands())
.ok()
.and_then(|v| v.as_array().cloned())
.unwrap_or_default()
.into_iter()
.filter(|c| filter.as_ref().is_none_or(|f| c.to_string().to_lowercase().contains(f.as_str())))
.collect();
outln!("{}", serde_json::to_string_pretty(&list).unwrap_or_default());
ExitCode::SUCCESS
}
Some("describe") => report(describe(args.get(1).map(String::as_str))),
Some("script") => report(script(&args[1..])),
Some("app") => report(app(&args[1..])),
Some("mcp") => report(mcp(&args[1..])),
Some("perf") => report(perf::perf(&args[1..])),
Some("bench") => report(perf::bench(&args[1..])),
Some("validate-pdf") => report(validate_pdf(args.get(1).map(String::as_str))),
Some("links") => {
use designcraft_engine::links::*;
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}");
ExitCode::SUCCESS
}
// Asking for help is not an error: it goes to stdout and succeeds. An unknown command
// still prints the same usage to stderr and fails.
Some("--help" | "-h" | "help") => {
outln!("{USAGE}");
outln!("{}", usage_footer());
ExitCode::SUCCESS
}
_ => {
eprintln!("{USAGE}");
eprintln!("{}", usage_footer());
ExitCode::FAILURE
}
}
}
fn report(r: Result<(), String>) -> ExitCode {
match r {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("designcraft-cli: {e}");
ExitCode::FAILURE
}
}
}
fn validate_pdf(path: Option<&str>) -> Result<(), String> {
let path = path.ok_or("usage: designcraft-cli validate-pdf FILE.pdf")?;
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let issues = designcraft_pdf::check_pdfx4(&bytes);
let valid = issues.is_empty();
let report = json!({"path": path, "standard": "PDF/X-4", "valid": valid, "issues": issues});
outln!("{}", serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?);
if valid { Ok(()) } else { Err(format!("{path} failed PDF/X-4 validation")) }
}
/// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app
/// started with `designcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr.
fn mcp(args: &[String]) -> Result<(), String> {
use designcraft_mcp::{Backend, Headless, Remote, Server, control_addr};
let mut connect: Option<String> = None;
let mut sample = false;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?),
"--sample" => sample = true,
other => return Err(format!("unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample])")),
}
}
let backend: Box<dyn Backend> = match connect {
Some(c) => {
let addr = control_addr(&c);
Box::new(
Remote::connect(&addr)
.map_err(|e| format!("cannot connect to the DesignCraft app at {addr}: {e} (start it with `designcraft --control PORT`)"))?,
)
}
None => {
let mut h = Headless::with_document();
if sample {
h.session.execute("file.newSample", &json!({})).map_err(|e| e.to_string())?;
}
Box::new(h)
}
};
eprintln!("designcraft-cli: MCP server on stdio ({})", backend.describe());
let stdin = std::io::stdin();
let stdout = std::io::stdout();
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
}
/// The first `--page`, `--scale` or `--pdf-options` with no `--export` or `--all-pages` after it.
/// They apply to the exports that follow them, so one after the last export would do nothing.
fn option_after_last_export(args: &[String]) -> Option<&str> {
let mut unused = None;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--page" | "--scale" | "--pdf-options" => {
unused = unused.or(Some(a.as_str()));
it.next();
}
"--export" | "--all-pages" => {
unused = None;
it.next();
}
"--in" | "--cmd" => {
it.next();
}
_ => {}
}
}
unused
}
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"
));
}
let mut s = Session::new();
let mut page = 0usize;
let mut scale = 1.0f64;
let mut it = args.iter();
let mut opened = false;
let mut pdf_opts = json!({});
// Earlier --cmd results, for `$N.path` references.
let mut results: Vec<Value> = Vec::new();
while let Some(a) = it.next() {
let mut val = || it.next().cloned().ok_or_else(|| format!("{a} needs a value"));
match a.as_str() {
"--in" => {
let p = val()?;
s.execute("file.open", &json!({"path": p})).map_err(|e| e.to_string())?;
opened = true;
}
"--sample" => {
s.execute("file.newSample", &json!({})).map_err(|e| e.to_string())?;
opened = true;
}
"--cmd" => {
if !opened {
s.execute("file.new", &json!({})).map_err(|e| e.to_string())?;
opened = true;
}
let c = val()?;
let (id, p) = c.split_once('=').unwrap_or((&c, "{}"));
let p: Value = serde_json::from_str(p).map_err(|e| format!("--cmd {id}: {e}"))?;
let p = designcraft_engine::script::resolve(&p, &results).map_err(|e| format!("--cmd {id}: {e}"))?;
let r = s.execute(id, &p).map_err(|e| e.to_string())?;
results.push(r.clone());
if !r.is_null() {
outln!("{}", serde_json::to_string(&r).unwrap_or_default());
}
}
"--page" => page = val()?.parse().map_err(|_| "bad --page")?,
"--scale" => scale = val()?.parse().map_err(|_| "bad --scale")?,
"--pdf-options" => {
pdf_opts = serde_json::from_str(&val()?).map_err(|e| format!("--pdf-options: {e}"))?;
}
"--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());
}
} else {
export(&mut s, &out, page, scale)?;
}
}
"--all-pages" => {
let dir = val()?;
let n = s.doc().map_err(|e| e.to_string())?.doc.page_count();
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
for p in 0..n {
export(&mut s, &format!("{dir}/page-{:03}.png", p + 1), p, scale)?;
}
}
other => return Err(format!("unknown option {other}")),
}
}
Ok(())
}
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() };
designcraft_engine::atomic::write_atomic(std::path::Path::new(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(())
}
/// `describe ID`: one command's documentation.
fn describe(id: Option<&str>) -> Result<(), String> {
let id = id.ok_or("usage: designcraft-cli describe COMMAND")?;
let s = Session::new();
let all = serde_json::to_value(s.commands()).map_err(|e| e.to_string())?;
let c = all.as_array().and_then(|a| a.iter().find(|c| c["id"] == id)).ok_or_else(|| {
let near: Vec<String> = all
.as_array()
.into_iter()
.flatten()
.filter_map(|c| c["id"].as_str())
.filter(|c| c.split('.').next() == id.split('.').next())
.map(str::to_string)
.collect();
format!("no command `{id}`{}", if near.is_empty() { String::new() } else { format!(" (similar: {})", near.join(", ")) })
})?;
// Enablement depends on a live document and selection: not meaningful here.
let mut c = c.clone();
if let Some(o) = c.as_object_mut() {
o.remove("enabled");
o.remove("disabled_reason");
}
outln!("{}", serde_json::to_string_pretty(&c).unwrap_or_default());
Ok(())
}
/// `script`: run a command script headless (or in the running app with `--connect`).
fn script(args: &[String]) -> Result<(), String> {
use designcraft_mcp::{Backend, Headless, Remote, control_addr};
let mut file: Option<String> = None;
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 keep_going = false;
let mut it = args.iter();
while let Some(a) = it.next() {
let mut val = || it.next().cloned().ok_or_else(|| format!("{a} needs a value"));
match a.as_str() {
"--in" => {
let p = val()?;
let cmd = if p.to_lowercase().ends_with(".idml") { "file.openIdml" } else { "file.open" };
setup.push((cmd.into(), json!({"path": p})));
}
"--sample" => setup.push(("file.newSample".into(), json!({}))),
"--connect" => connect = Some(val()?),
"--save" => save = Some(val()?),
"--export" => export.push(val()?),
"--keep-going" => keep_going = true,
"--help" | "-h" => {
outln!("{SCRIPT_HELP}");
return Ok(());
}
f if !f.starts_with("--") && file.is_none() => file = Some(f.to_string()),
other => return Err(format!("unknown script option `{other}`")),
}
}
let text = match file.as_deref() {
None | Some("-") => {
let mut t = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut t).map_err(|e| e.to_string())?;
t
}
Some(f) => std::fs::read_to_string(f).map_err(|e| format!("{f}: {e}"))?,
};
let steps = designcraft_engine::script::parse(&text)?;
let mut backend: Box<dyn Backend> = match &connect {
Some(c) => {
let addr = control_addr(c);
Box::new(
Remote::connect(&addr)
.map_err(|e| format!("cannot connect to the DesignCraft app at {addr}: {e} (start it with `designcraft --control PORT`)"))?,
)
}
None => Box::new(Headless::with_document()),
};
let b = &mut *backend;
let exec = |b: &mut dyn Backend, id: &str, p: Value| b.call("engine.execute", json!({"command": id, "params": p}));
for (c, p) in &setup {
exec(b, c, p.clone())?;
}
let report = if keep_going {
// Run every step; failures become {"error": …} results.
let mut results: Vec<Value> = Vec::new();
for st in &steps {
let r = designcraft_engine::script::resolve(&st.params, &results).and_then(|p| exec(b, &st.command, p));
results.push(r.unwrap_or_else(|e| json!({"error": e, "command": st.command})));
}
designcraft_engine::script::Report { results, failed: None }
} else {
designcraft_engine::script::run(&steps, |id, p| exec(b, id, p))
};
if report.failed.is_none() {
if let Some(out) = &save {
exec(b, "file.saveAs", json!({"path": out}))?;
}
for out in &export {
let lower = out.to_lowercase();
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 {
// PNG / JPEG of the first page.
b.call("app.export", p)?;
}
}
}
outln!("{}", serde_json::to_string_pretty(&report.to_json()).unwrap_or_default());
match report.failed {
Some((i, c, e)) => Err(format!("step {i} ({c}) failed: {e}")),
None => Ok(()),
}
}
/// `app`: one command or control-channel method in the running app.
fn app(args: &[String]) -> Result<(), String> {
use designcraft_mcp::{Backend, Remote, control_addr};
let mut port = "7979".to_string();
let mut method: Option<String> = None;
let mut rest: Vec<String> = Vec::new();
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--port" | "--connect" => port = it.next().cloned().ok_or("--port needs a value")?,
"--method" => method = Some(it.next().cloned().ok_or("--method needs a value")?),
_ => rest.push(a.clone()),
}
}
let addr = control_addr(&port);
let mut r = Remote::connect(&addr)
.map_err(|e| format!("cannot connect to the DesignCraft app at {addr}: {e} (start it with `designcraft --control {port}`)"))?;
let json_arg =
|s: Option<&String>| -> Result<Value, String> { s.map_or(Ok(json!({})), |t| serde_json::from_str(t).map_err(|e| format!("bad JSON: {e}"))) };
let out = match method {
Some(m) => r.call(&m, json_arg(rest.first())?)?,
None => {
let cmd = rest.first().ok_or("usage: designcraft-cli app [--port PORT] COMMAND [JSON]")?;
r.call("engine.execute", json!({"command": cmd, "params": json_arg(rest.get(1))?}))?
}
};
outln!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
Ok(())
}