//! printcraft-cli — headless PrintCraft. //! //! ```text //! printcraft-cli info [--password PW] document summary as JSON //! printcraft-cli render --page N [--dpi 96] --out x.pam //! printcraft-cli text [--page N] extracted text (pages separated by form feeds) //! printcraft-cli edit --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1] //! [--insert-blank 1] [--title T] [--author A] [--full] //! printcraft-cli combine … --out combined.pdf //! printcraft-cli extract --pages 1,3,5 --out out.pdf //! printcraft-cli split (--every N | --before 3,7) [--out-dir DIR] //! printcraft-cli check [--timeout 20] [--dpi 36] [--json out.json] //! printcraft-cli tools automation tools and their JSON Schemas //! printcraft-cli run [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 [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 = 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 … (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>, 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, "xfa": info.xfa.map(|x| match x { printcraft_render::Xfa::Static => "static", printcraft_render::Xfa::Dynamic => "dynamic" }), "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::>(), "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 = match flag(args, "--page") { Some(p) => vec![p.parse::().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, String> { s.split(',').map(|p| p.trim().parse::().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::, 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::() }); 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::>().join("-").to_lowercase() } fn collect(paths: &[&str]) -> Vec { let mut out = Vec::new(); let mut stack: Vec = 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 = 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::() ); } 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::(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 { 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 = 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, 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::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 { 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(()) }