1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
//! Command line interface. Every command prints one JSON document on stdout;
//! failures print `{"error": ...}` on stderr and exit with status 1.
use std::process::ExitCode;
use anyhow::Result;
use clap::{Parser, Subcommand};
use serde_json::{Value, json};
use crate::ops::{
annotate, assemble, create, edit, forms, layout, ocr, read, redact, render, scan, sign,
};
use crate::{mcp, tools};
#[derive(Parser)]
#[command(
name = "pdfops",
version,
about = "Fast PDF operations with JSON output, built for AI agents"
)]
pub struct Cli {
#[command(subcommand)]
command: Command,
/// Indent the JSON output
#[arg(long, global = true)]
pretty: bool,
/// Print a line of JSON for every finished page or file while working; the result is the last line
#[arg(long, global = true)]
stream: bool,
/// Memory a command may hold, in MiB; 0 for no limit
#[arg(long, global = true, env = "PDFOPS_MAX_MEMORY", default_value_t = 4096)]
max_memory: usize,
/// Seconds a command may run; 0 for no limit
#[arg(long, global = true, env = "PDFOPS_TIMEOUT", default_value_t = 300)]
timeout: u64,
}
#[derive(Subcommand)]
enum Command {
/// Page count, page size, metadata, encryption, outline and form summary of a PDF
Info(read::InfoArgs),
/// Extract text page by page, with an optional character budget
Text(read::TextArgs),
/// Find text or a regular expression and return matching pages with context
Search(read::SearchArgs),
/// Text with positions: bounding box, font and size of every line or word
Layout(layout::LayoutArgs),
/// Extract tables as rows of cells, Markdown or CSV
Tables(layout::TablesArgs),
/// Recognise text on scanned pages with OCR (needs the tesseract program)
Ocr(ocr::OcrArgs),
/// Show whether tesseract is installed and which OCR languages can be used
OcrLangs(ocr::OcrLangsArgs),
/// Download OCR language data (no administrator rights needed), optionally install tesseract itself
OcrInstall(ocr::OcrInstallArgs),
/// List the outline (bookmarks / table of contents) with target pages
Outline(read::OutlineArgs),
/// List annotations: highlights, comments, links and other markup, with their positions
Annotations(annotate::AnnotationsArgs),
/// Inspect a PDF for scripts, automatic actions, attached files, disguised content and hidden text
Scan(scan::ScanArgs),
/// Render pages to PNG files, e.g. to look at scans, charts or layout
Render(render::RenderArgs),
/// Extract the images drawn on pages to files (JPEG and JPEG 2000 as stored, the rest as PNG)
Images(render::ImagesArgs),
/// Create a PDF from Markdown: headings, lists, tables, code, links and images
Create(create::CreateArgs),
/// Concatenate several PDFs into one
Merge(assemble::MergeArgs),
/// Keep, reorder, duplicate or delete pages
Pages(assemble::PagesArgs),
/// Split a PDF into several files, by page count or by page ranges
Split(assemble::SplitArgs),
/// Rotate pages by a multiple of 90 degrees
Rotate(edit::RotateArgs),
/// Draw text (watermark, header, footer, page numbers), an image such as a signature, or a QR code
Stamp(edit::StampArgs),
/// Add a highlight, underline, strike-out, box, note or link, on found text or on an area
Annotate(annotate::AnnotateArgs),
/// Permanently remove text, images and drawings in given areas or matching given text, then verify
Redact(redact::RedactArgs),
/// Replace text in place, written in the document's own font where it has the glyphs
Replace(redact::ReplaceArgs),
/// Remove scripts, automatic and risky actions, attachments, XFA and media, then verify by scanning
Sanitize(scan::SanitizeArgs),
/// Set title, author, subject, keywords or creator
SetMeta(edit::SetMetaArgs),
/// Shrink a PDF: lossless by default, optionally re-encoding and downscaling images
Compress(edit::CompressArgs),
/// Protect a PDF with AES-256 passwords and permissions
Encrypt(edit::EncryptArgs),
/// Remove password protection, given the password
Decrypt(edit::DecryptArgs),
/// Sign a PDF digitally with a certificate and its private key
Sign(sign::SignArgs),
/// Check the digital signatures of a PDF: who signed, and whether it changed since
Signatures(sign::SignaturesArgs),
/// List form fields with their types, current values and options
Forms(forms::FormsArgs),
/// Fill form fields by name
Fill(forms::FillArgs),
/// Run a Model Context Protocol server on stdio exposing every command as a tool
Mcp {
/// Confine all file access to this directory; relative paths resolve against it
#[arg(long)]
root: Option<std::path::PathBuf>,
},
/// Run one tool with JSON arguments from stdin; the MCP server runs each call this way
#[command(hide = true)]
Call {
/// Tool name, e.g. pdf_text
name: String,
#[arg(long)]
root: Option<std::path::PathBuf>,
},
/// Print JSON tool definitions (name, description, input schema) for function calling
Tools,
}
fn run(command: Command, limits: (usize, u64)) -> Result<Option<Value>> {
Ok(Some(match command {
Command::Info(a) => read::info(a)?,
Command::Text(a) => {
let raw = a.raw;
if raw && crate::progress::enabled() {
anyhow::bail!("raw text cannot be streamed: every streamed line is JSON");
}
let value = read::text(a)?;
if raw {
println!("{}", read::raw_text(&value));
return Ok(None);
}
value
}
Command::Search(a) => read::search(a)?,
Command::Layout(a) => layout::layout(a)?,
Command::Tables(a) => layout::tables(a)?,
Command::Ocr(a) => ocr::ocr(a)?,
Command::OcrLangs(a) => ocr::ocr_langs(a)?,
Command::OcrInstall(a) => ocr::ocr_install(a)?,
Command::Outline(a) => read::outline(a)?,
Command::Annotations(a) => annotate::annotations(a)?,
Command::Scan(a) => scan::scan(a)?,
Command::Render(a) => render::render(a)?,
Command::Images(a) => render::images(a)?,
Command::Create(a) => create::create(a)?,
Command::Merge(a) => assemble::merge(a)?,
Command::Pages(a) => assemble::pages(a)?,
Command::Split(a) => assemble::split(a)?,
Command::Rotate(a) => edit::rotate(a)?,
Command::Stamp(a) => edit::stamp(a)?,
Command::Annotate(a) => annotate::annotate(a)?,
Command::Redact(a) => redact::redact(a)?,
Command::Replace(a) => redact::replace(a)?,
Command::Sanitize(a) => scan::sanitize(a)?,
Command::SetMeta(a) => edit::set_meta(a)?,
Command::Compress(a) => edit::compress(a)?,
Command::Encrypt(a) => edit::encrypt(a)?,
Command::Decrypt(a) => edit::decrypt(a)?,
Command::Sign(a) => sign::sign(a)?,
Command::Signatures(a) => sign::signatures(a)?,
Command::Forms(a) => forms::forms(a)?,
Command::Fill(a) => forms::fill(a)?,
Command::Mcp { root } => {
if let Some(root) = &root {
crate::sandbox::set_root(root)?;
}
mcp::serve(root.as_deref(), limits.0, limits.1)?;
return Ok(None);
}
Command::Call { name, root } => {
if let Some(root) = &root {
crate::sandbox::set_root(root)?;
}
let args = serde_json::from_reader(std::io::stdin().lock())?;
// Not a tool: the part of `scan` that it runs away from itself.
if name == scan::HIDDEN_TEXT_CALL {
return Ok(Some(scan::hidden_text_call(args)?));
}
tools::call(&name, args)?
}
Command::Tools => Value::Array(tools::definitions()),
}))
}
pub fn main() -> ExitCode {
let cli = Cli::parse();
// Parser panics are reported as JSON errors below, not as backtraces.
std::panic::set_hook(Box::new(|_| {}));
// Streamed output is one JSON document per line, so the result cannot be indented.
let pretty = cli.pretty && !cli.stream;
if cli.stream {
crate::progress::enable();
}
let limits = (cli.max_memory, cli.timeout);
// The server itself is long-lived and small; its limits apply to each call it starts.
if !matches!(cli.command, Command::Mcp { .. }) {
crate::limits::set_max_memory(limits.0);
crate::limits::set_timeout(limits.1);
scan::isolate_rendering();
}
let outcome = std::panic::catch_unwind(|| run(cli.command, limits)).unwrap_or_else(|_| {
Err(anyhow::anyhow!(
"internal error: the PDF could not be processed"
))
});
match outcome {
Ok(None) => ExitCode::SUCCESS,
Ok(Some(mut value)) => {
// One command per process, so every rebuilt file was an input of this one.
tools::note_repairs(&mut value, |_| true);
let text = if pretty {
serde_json::to_string_pretty(&value)
} else {
serde_json::to_string(&value)
};
println!("{}", text.expect("JSON value serialises"));
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("{}", json!({"error": format!("{e:#}")}));
ExitCode::FAILURE
}
}
}