//! pdfcraft-cli — headless PdfCraft. //! //! Usage: `pdfcraft-cli --help`, which prints `USAGE` below. //! //! `run` and `mcp` drive the same tool table (`pdfcraft-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. A step's //! `"out"` (like `--out`) saves its image there. With `--root DIR`, every path a tool or step //! names, `"out"` included, must be inside DIR, and relative paths resolve inside it. //! //! 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)] #[cfg(feature = "mcp")] mod automation_args; mod batch; 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 pdfcraft_engine::export::ImageFormat; use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind, inspect}; /// The `mcp` lines of [`USAGE`]: only a build with the `mcp` feature has the command. #[cfg(feature = "mcp")] macro_rules! mcp_usage { () => { "\ pdfcraft-cli mcp [--root DIR] [--compact] [--automation-read-root DIR] [--automation-write-root DIR] MCP server on stdin/stdout (opt-in) --compact lists a core set of tools plus tool_search and tool_call --automation-read-root / --automation-write-root confine the files the agent's tools read (open, combine, import, fonts, certificates…) and write (save, export, extract, split, reports…) to DIR, links followed; a root left out grants none of its access. --root, when also given, applies as well " }; } #[cfg(not(feature = "mcp"))] macro_rules! mcp_usage { () => { "" }; } mod check; mod commands; mod options; mod run; mod ui; use check::{audit, check, check_one}; use commands::{combine, edit, extract, info, render, split, text}; use options::{OptionSpec, flag, validate_options}; #[cfg(feature = "mcp")] use run::mcp; use run::{automation, print_output, run, tools}; use ui::ui; /// The full usage, printed by `--help` (also after a command, e.g. `render --help`). const USAGE: &str = concat!( "\ pdfcraft-cli info [--password PW] document summary as JSON (includes any declared PDF/A or PDF/UA level) pdfcraft-cli render --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam) pdfcraft-cli text [--page N] extracted text (pages separated by form feeds) pdfcraft-cli edit --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1] [--insert-blank 1] [--title T] [--author A] [--full] (options run in order; page numbers refer to the document after the earlier options) pdfcraft-cli combine … --out combined.pdf pdfcraft-cli extract --pages 1,3,5-8 --out out.pdf (pages and ranges, in the order given) pdfcraft-cli split (--every N | --before 3,7) [--out-dir DIR] pdfcraft-cli check [--timeout 20] [--dpi 36] [--json out.json] pdfcraft-cli audit [--password PW] find faux redactions (fails when found) pdfcraft-cli batch --steps convert,ocr,optimize --out-dir DIR [--recursive] [--root DIR] [--stop-on-error] [--ppi 150] [--quality 60] [--dpi 300] [--language en] [--ocr-all-pages] pdfcraft-cli tools automation tools and their JSON Schemas pdfcraft-cli run [key=value …] [--root DIR] [--out image.png] pdfcraft-cli run --script steps.json [--root DIR] [--report] [{\"tool\": \"doc_open\", \"args\": {…}}, …]; --report prints one JSON document for the run ", mcp_usage!(), "\ pdfcraft-cli ui --control FILE [key=value …] [--out shot.png] drive a running app started with --control FILE help and feedback: https://discord.gg/artcraft" ); fn main() -> ExitCode { // `std::env::args` panics on an argument that is not UTF-8 ("called `Result::unwrap()` on an // `Err` value"), which a Latin-1 file name is, and it panics while collecting them: even // `--help` ended that way. Such a name cannot be passed on either, since the paths here are // `str`, so it is a usage error (photocraft#1108). let mut args: Vec = Vec::new(); for arg in std::env::args_os().skip(1) { match arg.into_string() { Ok(a) => args.push(a), Err(bad) => { let _ = writeln!( std::io::stderr().lock(), "pdfcraft-cli: `{}` is not valid UTF-8, so it cannot be used as an argument", bad.to_string_lossy() ); return ExitCode::FAILURE; } } } // ` --help` too; only right after the command, so a later `-h` (a value) still reaches it. let command = match args.get(1).map(String::as_str) { Some("--help" | "-h") => Some("--help"), _ => args.first().map(String::as_str), }; let result = match command { Some("--help" | "-h" | "help") => stdout_line(format_args!("{USAGE}")), None => Err(format!("usage:\n{USAGE}").into()), 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("audit") => audit(&args[1..]), Some("tools") => tools(), Some("run") => run(&args[1..]), Some("batch") => batch::run(&args[1..]), Some("ui") => ui(&args[1..]), #[cfg(feature = "mcp")] Some("mcp") => mcp(&args[1..]), #[cfg(not(feature = "mcp"))] Some("mcp") => Err("mcp: this build leaves out the MCP server (built without the `mcp` feature)".into()), Some("--version") => version(), Some(other) => Err(format!("unknown command '{other}'; see pdfcraft-cli --help").into()), }; match result { Ok(()) => ExitCode::SUCCESS, Err(CliError::Stdout(e)) if e.kind() == std::io::ErrorKind::BrokenPipe => ExitCode::SUCCESS, Err(e) => { let _ = writeln!(std::io::stderr().lock(), "pdfcraft-cli: {e}"); ExitCode::FAILURE } } } // Keep stdout errors typed: a closed pipe is normal, but file and command errors still fail. #[derive(Debug)] enum CliError { Message(String), Stdout(std::io::Error), } impl From for CliError { fn from(message: String) -> Self { Self::Message(message) } } impl From<&str> for CliError { fn from(message: &str) -> Self { Self::Message(message.to_string()) } } impl std::fmt::Display for CliError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Message(message) => f.write_str(message), Self::Stdout(error) => write!(f, "stdout: {error}"), } } } fn stdout_line(line: std::fmt::Arguments<'_>) -> Result<(), CliError> { let mut stdout = std::io::stdout().lock(); writeln!(stdout, "{line}").and_then(|()| stdout.flush()).map_err(CliError::Stdout) } fn version() -> Result<(), CliError> { stdout_line(format_args!("pdfcraft-cli {}", pdfcraft_engine::build_info::version()))?; stdout_line(format_args!("Discord: {} (help and feedback)", pdfcraft_engine::links::DISCORD))?; stdout_line(format_args!("Web: {}", pdfcraft_engine::links::APP_PAGE))?; stdout_line(format_args!("Source: {}", pdfcraft_engine::links::GITHUB))?; Ok(()) }