pdfcraft/apps/printcraft-cli/src/main.rs

509 lines
25 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.

//! printcraft-cli — headless PrintCraft.
//!
//! ```text
//! printcraft-cli info <file.pdf> [--password PW] document summary as JSON
//! printcraft-cli render <file.pdf> --page N [--dpi 96] --out x.pam
//! printcraft-cli text <file.pdf> [--page N] extracted text (pages separated by form feeds)
//! printcraft-cli edit <in.pdf> --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1]
//! [--insert-blank 1] [--title T] [--author A] [--full]
//! printcraft-cli combine <a.pdf> <b.pdf> … --out combined.pdf
//! printcraft-cli extract <in.pdf> --pages 1,3,5 --out out.pdf
//! printcraft-cli split <in.pdf> (--every N | --before 3,7) [--out-dir DIR]
//! printcraft-cli check <files or dirs…> [--timeout 20] [--dpi 36] [--json out.json]
//! printcraft-cli tools automation tools and their JSON Schemas
//! printcraft-cli run <tool> [key=value …] [--root DIR] [--out image.png]
//! printcraft-cli run --script steps.json [--root DIR] [{"tool": "doc_open", "args": {…}}, …]
//! printcraft-cli mcp [--root DIR] MCP server on stdin/stdout (opt-in)
//! printcraft-cli ui --control FILE <method> [key=value …] [--out shot.png]
//! drive a running app started with --control FILE
//! ```
//!
//! `run` and `mcp` drive the same tool table (`printcraft-automation`). In `run`, values parse as
//! JSON when they can (`pages=[1,3]`, `degrees=90`) and are strings otherwise. A script runs its
//! steps in one session, so `doc_open` returns id 1, the next document id 2, and so on.
//!
//! The MCP server never starts on its own: it runs only when this command is launched (normally
//! by an agent configured to use it), talks only over stdio, and exits when stdin closes.
//!
//! `check` is the robustness harness: every file is opened, inspected and fully rendered in a
//! *separate child process* with a wall-clock timeout, so hangs, panics and aborts in any
//! dependency are observed and reported instead of taking the harness down.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode, Stdio};
use std::sync::Arc;
use std::time::{Duration, Instant};
use printcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind, inspect};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let result =
match args.first().map(String::as_str) {
Some("info") => info(&args[1..]),
Some("render") => render(&args[1..]),
Some("text") => text(&args[1..]),
Some("edit") => edit(&args[1..]),
Some("combine") => combine(&args[1..]),
Some("extract") => extract(&args[1..]),
Some("split") => split(&args[1..]),
Some("check") => check(&args[1..]),
Some("check-one") => check_one(&args[1..]),
Some("tools") => tools(),
Some("run") => run(&args[1..]),
Some("ui") => ui(&args[1..]),
#[cfg(feature = "mcp")]
Some("mcp") => mcp(&args[1..]),
Some("--version") => {
println!("printcraft-cli {}", env!("CARGO_PKG_VERSION"));
println!("Discord: {} (help and feedback)", printcraft_engine::links::DISCORD);
println!("Web: {}", printcraft_engine::links::APP_PAGE);
println!("Source: {}", printcraft_engine::links::GITHUB);
Ok(())
}
_ => Err("usage: printcraft-cli <info|render|text|edit|combine|extract|split|check|tools|run|mcp|ui> … (see source header for options)\nhelp and feedback: https://discord.gg/artcraft"
.into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("printcraft-cli: {e}");
ExitCode::FAILURE
}
}
}
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
}
fn positional(args: &[String]) -> Vec<&str> {
let mut out = Vec::new();
let mut skip = false;
for a in args {
if skip {
skip = false;
continue;
}
if a.starts_with("--") {
skip = true;
continue;
}
out.push(a.as_str());
}
out
}
fn read(path: &str) -> Result<Arc<Vec<u8>>, String> {
std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
}
fn info(args: &[String]) -> Result<(), String> {
let path = *positional(args).first().ok_or("info: missing file")?;
let info = inspect(read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
let json = serde_json::json!({
"file": path,
"pdf_version": info.pdf_version,
"file_size": info.file_size,
"pages": info.pages.len(),
"first_page_pt": info.pages.first().map(|p| [p.width, p.height]),
"title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator,
"encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript,
"bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(),
"links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(),
"page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::<Vec<_>>(),
"warnings": info.warnings,
});
println!("{}", serde_json::to_string_pretty(&json).unwrap_or_default());
Ok(())
}
fn text(args: &[String]) -> Result<(), String> {
let path = *positional(args).first().ok_or("text: missing file")?;
let mut r = PageRenderer::new(read(path)?, RenderConfig { password: flag(args, "--password").map(Arc::from), ..Default::default() });
let pages: Vec<usize> = match flag(args, "--page") {
Some(p) => vec![p.parse::<usize>().map_err(|_| "bad --page")?.saturating_sub(1)],
None => (0..r.page_count()).collect(),
};
for (n, p) in pages.iter().enumerate() {
let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 });
if let Some(e) = out.error {
eprintln!("page {}: {e}", p + 1);
continue;
}
if n > 0 {
println!("\u{c}");
}
println!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default());
}
Ok(())
}
/// 1-based page list ("1,3,5") → 0-based indices.
fn page_list(s: &str) -> Result<Vec<usize>, String> {
s.split(',').map(|p| p.trim().parse::<usize>().ok().filter(|n| *n > 0).map(|n| n - 1).ok_or(format!("bad page number {p:?}"))).collect()
}
/// Apply page and metadata edits through the engine and save (incrementally unless `--full`).
fn edit(args: &[String]) -> Result<(), String> {
use printcraft_engine::{Edit, Session};
let path = *positional(args).first().ok_or("edit: missing file")?;
let out = flag(args, "--out").ok_or("edit: missing --out")?;
let mut session = Session::new();
let id = session.open(path, Some(path.to_string()), read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
let mut edits = Vec::new();
let mut i = 0;
while i < args.len() {
let value = args.get(i + 1).map(String::as_str).unwrap_or("");
match args[i].as_str() {
"--rotate" => {
let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?;
edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees: deg.parse().map_err(|_| "bad degrees")? });
}
"--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }),
"--move" => {
let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?;
let to: usize = to.parse().map_err(|_| "bad target")?;
edits.push(Edit::MovePages { pages: page_list(pages)?, to: to.saturating_sub(1) });
}
"--insert-blank" => {
let at: usize = value.parse().map_err(|_| "bad position")?;
edits.push(Edit::InsertBlankPage { at: at.saturating_sub(1), width: 612.0, height: 792.0 });
}
"--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }),
"--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }),
_ => {}
}
i += 1;
}
for e in edits {
session.apply(id, e).map_err(|e| e.to_string())?;
}
let bytes = if args.iter().any(|a| a == "--full") { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?;
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}"))
}
fn file_stem(path: &str) -> String {
Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string())
}
fn combine(args: &[String]) -> Result<(), String> {
let out = flag(args, "--out").ok_or("combine: missing --out")?;
let inputs = positional(args);
if inputs.len() < 2 {
return Err("combine: give at least two input files".into());
}
let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::<Result<Vec<_>, String>>()?;
let bytes = printcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?;
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}"))
}
fn extract(args: &[String]) -> Result<(), String> {
let path = *positional(args).first().ok_or("extract: missing file")?;
let out = flag(args, "--out").ok_or("extract: missing --out")?;
let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?;
let mut session = printcraft_engine::Session::new();
let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?;
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}"))
}
fn split(args: &[String]) -> Result<(), String> {
use printcraft_engine::SplitBy;
let path = *positional(args).first().ok_or("split: missing file")?;
let by = match (flag(args, "--every"), flag(args, "--before")) {
(Some(n), None) => SplitBy::PageCount(n.parse().map_err(|_| "bad --every")?),
(None, Some(list)) => SplitBy::Before(page_list(list)?),
_ => return Err("split: give either --every N or --before PAGES".into()),
};
let dir = PathBuf::from(flag(args, "--out-dir").unwrap_or("."));
let mut session = printcraft_engine::Session::new();
let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
let stem = file_stem(path);
for (a, b, bytes) in session.split(id, &by).map_err(|e| e.to_string())? {
let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") });
std::fs::write(&name, bytes.as_slice()).map_err(|e| format!("{}: {e}", name.display()))?;
println!("{}", name.display());
}
Ok(())
}
fn render(args: &[String]) -> Result<(), String> {
let path = *positional(args).first().ok_or("render: missing file")?;
let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?;
let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?;
let out = flag(args, "--out").ok_or("render: missing --out (.pam)")?;
let mut r = PageRenderer::new(read(path)?, RenderConfig { password: flag(args, "--password").map(Arc::from), ..Default::default() });
let p = r.render(RenderRequest { page: page.saturating_sub(1), kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 });
if let Some(e) = p.error {
return Err(e);
}
// PAM (netpbm RGB_ALPHA) keeps this tool dependency-free; convert with any image tool.
let mut f = std::fs::File::create(out).map_err(|e| e.to_string())?;
write!(f, "P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).map_err(|e| e.to_string())?;
f.write_all(&p.rgba).map_err(|e| e.to_string())?;
eprintln!("rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis);
Ok(())
}
/// Child-process body for `check`: prints one JSON line.
fn check_one(args: &[String]) -> Result<(), String> {
let path = *positional(args).first().ok_or("check-one: missing file")?;
let dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?;
let start = Instant::now();
let bytes = read(path)?;
let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) {
Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0),
Ok(info) => {
let mut r = PageRenderer::new(bytes, RenderConfig::default());
let mut failed = Vec::new();
for i in 0..info.pages.len().min(500) {
let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 });
if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 }).error {
failed.push(format!("p{} text: {e}", i + 1));
}
if let Some(e) = p.error {
failed.push(format!("p{}: {e}", i + 1));
}
}
let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() };
(status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len())
}
};
// `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net,
// so a panic in cos shows up as a crash of this process.
if args.iter().any(|a| a == "--edit") {
edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?);
}
let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings,
"ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::<String>() });
println!("{line}");
Ok(())
}
/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both
/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs.
fn edit_round_trip(bytes: &[u8]) {
use printcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return };
for num in doc.object_numbers().into_iter().take(20_000) {
if let Ok(o) = doc.try_get(num)
&& let Object::Stream(s) = &*o
{
let _ = s.decoded();
}
}
if let Some(root) = doc.root() {
let _ = doc.update_dict(root, |d| d.set(b"PrintCraftFuzz".to_vec(), Object::Bool(true)));
}
let classic = SaveOptions { object_streams: false, ..SaveOptions::default() };
for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)]
.into_iter()
.flatten()
{
if let Ok(again) = Document::open(Arc::new(out)) {
let _ = again.root().map(|r| again.get(r));
}
}
}
fn short(s: &str) -> String {
s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::<Vec<_>>().join("-").to_lowercase()
}
fn collect(paths: &[&str]) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
while let Some(p) = stack.pop() {
if p.is_dir() {
if let Ok(rd) = std::fs::read_dir(&p) {
stack.extend(rd.flatten().map(|e| e.path()));
}
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
out.push(p);
}
}
out.sort();
out
}
fn check(args: &[String]) -> Result<(), String> {
let files = collect(&positional(args));
if files.is_empty() {
return Err("check: no PDF files found".into());
}
let timeout = Duration::from_secs(flag(args, "--timeout").unwrap_or("20").parse().map_err(|_| "bad --timeout")?);
let dpi = flag(args, "--dpi").unwrap_or("36").to_string();
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let mut results = Vec::new();
let mut counts: std::collections::BTreeMap<String, usize> = Default::default();
for (n, f) in files.iter().enumerate() {
let r = run_child(&exe, f, &dpi, timeout);
let status = r["status"].as_str().unwrap_or("?").to_string();
*counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1;
if status != "ok" {
eprintln!(
"[{}/{}] {status:<28} {} {}",
n + 1,
files.len(),
f.display(),
r["detail"].as_str().unwrap_or("").chars().take(160).collect::<String>()
);
}
results.push(r);
}
eprintln!("\nchecked {} files: {:?}", files.len(), counts);
if let Some(out) = flag(args, "--json") {
std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?;
}
Ok(())
}
fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value {
let file_s = file.to_string_lossy().to_string();
let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn();
let mut child = match child {
Ok(c) => c,
Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }),
};
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => {
let mut out = String::new();
if let Some(mut so) = child.stdout.take() {
use std::io::Read;
let _ = so.read_to_string(&mut out);
}
return match serde_json::from_str::<serde_json::Value>(out.trim()) {
Ok(v) => v,
Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }),
};
}
Ok(None) if start.elapsed() > timeout => {
let _ = child.kill();
let _ = child.wait();
return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) });
}
Ok(None) => std::thread::sleep(Duration::from_millis(20)),
Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }),
}
}
}
// ---- automation --------------------------------------------------------------------------------
fn automation(args: &[String]) -> Result<printcraft_automation::Automation, String> {
let a = printcraft_automation::Automation::new();
match flag(args, "--root") {
Some(root) => a.with_root(root).map_err(|e| format!("--root {root}: {e}")),
None => Ok(a),
}
}
fn tools() -> Result<(), String> {
let list: Vec<serde_json::Value> = printcraft_automation::tools()
.iter()
.map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema }))
.collect();
println!("{}", serde_json::to_string_pretty(&list).unwrap_or_default());
Ok(())
}
/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised).
fn print_output(content: Vec<printcraft_automation::Content>, out: Option<&str>) -> Result<(), String> {
for c in content {
match c {
printcraft_automation::Content::Json(v) => println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()),
printcraft_automation::Content::Png { data, width, height } => match out {
Some(path) => {
std::fs::write(path, &data).map_err(|e| format!("{path}: {e}"))?;
println!("{}", serde_json::json!({ "image": path, "width": width, "height": height }));
}
None => println!(
"{}",
serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
),
},
}
}
Ok(())
}
fn run(args: &[String]) -> Result<(), String> {
let mut auto = automation(args)?;
if let Some(script) = flag(args, "--script") {
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
let steps: Vec<serde_json::Value> = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?;
for (i, step) in steps.iter().enumerate() {
let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?;
let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?;
print_output(content, step["out"].as_str())?;
}
return Ok(());
}
let tool = *positional(args).first().ok_or("run: missing tool name (see `printcraft-cli tools`)")?;
let mut obj = serde_json::Map::new();
for kv in positional(args).iter().skip(1) {
let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?;
let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()));
obj.insert(k.to_string(), value);
}
let content = auto.call(tool, &serde_json::Value::Object(obj)).map_err(|e| e.to_string())?;
print_output(content, flag(args, "--out"))
}
#[cfg(feature = "mcp")]
fn mcp(args: &[String]) -> Result<(), String> {
let mut server = printcraft_automation::mcp::McpServer::new(automation(args)?);
eprintln!("printcraft-cli: MCP server on stdio (protocol {}); close stdin to stop", printcraft_automation::mcp::PROTOCOL_VERSIONS[0]);
server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| e.to_string())
}
// ---- UI control channel client -----------------------------------------------------------------
/// One request to a running app's control channel (`printcraft --control FILE`).
fn ui(args: &[String]) -> Result<(), String> {
use std::io::{BufRead, BufReader, Write as _};
let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `printcraft --control FILE`)")?;
let info: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?).map_err(|e| format!("{file}: {e}"))?;
let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?;
let token = info["token"].as_str().ok_or(format!("{file}: no token"))?;
let pos = positional(args);
let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?;
let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") };
let mut params = serde_json::Map::new();
for kv in pos.iter().skip(1) {
let (k, v) = kv.split_once('=').ok_or(format!("ui: expected key=value, got {kv:?}"))?;
params.insert(k.to_string(), serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())));
}
let stream = std::net::TcpStream::connect(("127.0.0.1", port as u16))
.map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?;
stream.set_read_timeout(Some(Duration::from_secs(40))).map_err(|e| e.to_string())?;
let mut write = stream.try_clone().map_err(|e| e.to_string())?;
let mut lines = BufReader::new(stream).lines();
let mut rpc = |id: u64, method: &str, params: serde_json::Value| -> Result<serde_json::Value, String> {
writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| e.to_string())?;
let line = lines.next().ok_or("the app closed the connection")?.map_err(|e| e.to_string())?;
let reply: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?;
match reply.get("error") {
Some(e) => Err(e["message"].as_str().unwrap_or("error").to_string()),
None => Ok(reply["result"].clone()),
}
};
rpc(1, "auth", serde_json::json!({ "token": token }))?;
let mut result = rpc(2, &method, serde_json::Value::Object(params))?;
if let (Some(out), Some(data)) = (flag(args, "--out"), result.get("png_base64").and_then(|d| d.as_str())) {
use base64::Engine as _;
let png = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?;
std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?;
result["png_base64"] = serde_json::json!(format!("written to {out}"));
}
println!("{}", serde_json::to_string_pretty(&result).unwrap_or_default());
Ok(())
}