<h1 align="center">pdfops</h1>
<p align="center">
<b>Fast PDF tools for AI agents.</b><br>
One binary, 25 tools, JSON in and out. Works as a CLI, an MCP server and a Rust library.
</p>
<p align="center">
<a href="https://github.com/KayZedd/pdfops/actions/workflows/ci.yml"><img src="https://github.com/KayZedd/pdfops/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://crates.io/crates/pdfops"><img src="https://img.shields.io/crates/v/pdfops.svg" alt="crates.io"></a>
<a href="https://www.npmjs.com/package/pdfops"><img src="https://img.shields.io/npm/v/pdfops.svg" alt="npm"></a>
<a href="https://github.com/KayZedd/pdfops/releases/latest"><img src="https://img.shields.io/github/v/release/KayZedd/pdfops.svg" alt="release"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license"></a>
</p>
```sh
$ pdfops tables invoice.pdf --format markdown # tables as tables, not as a blob of text
$ pdfops redact contract.pdf --text "Jan Kowalski" -o safe.pdf # removed from the file, then verified
$ pdfops text scan.pdf --ocr --ocr-lang pol+eng # OCR only where there is no text layer
$ pdfops stamp offer.pdf --image signature.png --x 380 --y 690 -o signed.pdf
```
## Why pdfops
- **Built for agents.** Every command returns one JSON document, errors say what to do next, and
text can be read under a character budget with a resume point.
- **Everything in one place.** Reading, layout, tables, OCR, rendering, page surgery, stamping,
redaction, forms, encryption and creation: 25 tools behind one schema.
- **Nothing to set up.** A single binary with no PDF libraries, no Python and no runtime. Only OCR
needs an extra program, and pdfops can fetch the language data itself.
- **Fast.** Files open in milliseconds whatever their size, and page work runs on all cores. See the
[benchmarks](#benchmarks).
- **Redaction you can trust.** Content is deleted from the page, not covered, and the result is
checked by a second interpreter before anything is written.
## Quick start
As an MCP server, with nothing installed beforehand:
```json
{
"mcpServers": {
"pdfops": { "command": "npx", "args": ["-y", "pdfops", "mcp"] }
}
}
```
For Claude Code: `claude mcp add pdfops -- npx -y pdfops mcp`.
Tools are named `pdf_info`, `pdf_text`, `pdf_redact` and so on, and take the same arguments as the
CLI. Relative paths resolve against the server's working directory.
### Install
| npm | `npm install -g pdfops` or `npx pdfops` | Node 18+ |
| Prebuilt, via cargo | `cargo binstall pdfops` | [cargo-binstall](https://github.com/cargo-bins/cargo-binstall) |
| From source | `cargo install pdfops` | Rust 1.92+ |
| Manual | [download an archive](https://github.com/KayZedd/pdfops/releases/latest) and put `pdfops` on your `PATH` | nothing |
The npm package is a small launcher: on first run it downloads the binary for your platform from
the GitHub release, checks it against the published SHA-256 sum and caches it. Prebuilt binaries
cover Linux and macOS on x86-64 and ARM64, and Windows on x86-64.
OCR additionally needs the `tesseract` program; nothing else does. Language data is fetched on
request, without administrator rights:
```sh
pdfops ocr-langs # is tesseract there, which languages can be used
pdfops ocr-install --lang pol+eng # download language data into the user's data directory
pdfops ocr-install --engine # install tesseract itself where that needs no password
```
## Tools
| **Read** | `info` | Page count, page size, metadata, encryption, outline and form summary |
| | `text` | Text page by page, with a character budget and optional OCR fallback |
| | `search` | Find text or a regex, returns pages and snippets |
| | `layout` | Bounding box, font and size of every line or word |
| | `tables` | Tables as rows of cells, Markdown or CSV |
| | `outline` | Bookmarks with target pages |
| | `render` | Pages to PNG, to look at charts, scans and layout |
| | `images` | The images drawn on pages, as files |
| | `ocr` | Recognised text of scanned pages |
| **Build** | `create` | A new PDF from Markdown |
| | `merge` | Several PDFs into one |
| | `pages` | Keep, reorder, duplicate or delete pages |
| | `split` | Split by page count or by ranges |
| **Edit** | `rotate` | Rotate pages by multiples of 90 degrees |
| | `stamp` | Watermark, header, footer, page numbers, an image such as a signature, or a QR code |
| | `replace` | Replace text in place, in the document's own font where possible |
| | `redact` | Remove text, images and drawings in areas or matching text, then verify |
| | `set-meta` | Title, author, subject, keywords, creator |
| | `compress` | Shrink: lossless by default, optionally re-encoding and downscaling images |
| **Forms** | `forms` | Fields with their types, values and options |
| | `fill` | Fill fields by name |
| **Protect** | `encrypt` | AES-256 passwords and permissions |
| | `decrypt` | Remove password protection |
| **Setup** | `ocr-langs` | Whether tesseract is installed and which languages are usable |
| | `ocr-install` | Download OCR language data, optionally install tesseract |
Run `pdfops <command> --help` for the options of each.
## Examples
```sh
$ pdfops info manual.pdf
{"encrypted":false,"file":"manual.pdf","form_fields":0,
"metadata":{"created":"2026-06-30T09:07:46+00:00","producer":"GPL Ghostscript 10.07.1"},
"outline_entries":645,"page_size_pt":{"height":792.0,"width":595.0},"pages":357,"pdf_version":"1.3",
"size_bytes":1386723,"uniform_page_size":true}
$ pdfops search manual.pdf "calling convention" --max-results 1 --context 40
{"file":"manual.pdf","matches":[{"match":"Calling Convention","page":12,
"snippet":"... 10.5.1 The Pascal Calling Convention ..."}],"query":"calling convention",
"total_matches":17,"unreadable_pages":[]}
```
Reading:
```sh
pdfops text manual.pdf --pages 12- --max-chars 4000 # resume_at_page says where to continue
pdfops text scan.pdf --ocr --ocr-lang pol+eng # OCR only the pages that have no text
pdfops tables report.pdf --pages 4 --format markdown
pdfops layout report.pdf --pages 4 --level words # bbox, font and size per word
pdfops render report.pdf --pages 1-3 --dpi 150 -o out/ # look at charts and layout
pdfops images report.pdf -o images/
```
Building and page work:
```sh
pdfops create notes.md -o notes.pdf
pdfops merge a.pdf b.pdf -o merged.pdf
pdfops pages in.pdf --keep "3,1,5-" -o out.pdf
pdfops split in.pdf --every 10 -o parts/
```
Editing:
```sh
pdfops stamp in.pdf --text "Poufne · {page}/{pages}" --position footer -o out.pdf
pdfops stamp in.pdf --image signature.png --x 380 --y 690 --width 140 --pages last -o out.pdf
pdfops stamp in.pdf --qr "https://example.com/doc/42" --anchor bottom-right -o out.pdf
pdfops redact in.pdf --text "Jan Kowalski" --text "\d{11}" --regex -o redacted.pdf
pdfops redact in.pdf --rect "2:100,200,300,220" -o redacted.pdf
pdfops replace in.pdf --find "2025" --with "2026" -o out.pdf
pdfops compress in.pdf --max-image-edge 1600 --image-quality 70 -o small.pdf
```
Forms and protection:
```sh
pdfops forms form.pdf
pdfops fill form.pdf --set name="Ada Lovelace" --set agree=true -o filled.pdf
pdfops encrypt in.pdf --owner-password secret --deny-copy -o locked.pdf
```
### Conventions
- **Output.** Every command prints one JSON document on stdout. Errors go to stderr as
`{"error": "..."}` with exit status 1. Add `--pretty` to indent.
- **Writing.** Commands that write take `-o`. It may be the input file: output goes through a
temporary file.
- **Pages** are 1-based and comma separated: `3`, `2-5`, `7-` (to the end), `-4` (from the start),
`5-2` (descending), `last`, `odd`, `even`, `all`.
- **Positions** are in points with the origin at the top-left corner of the page as displayed, y
growing downwards. `layout` reports them, `stamp --x/--y` and `redact --rect` accept them, and a
pixel of `render --dpi 72` is exactly one point.
## How it compares
| Text extraction | ✓ | ✓ | ✓ | ✓ | – | ✓ |
| Word positions and fonts | ✓ | ✓ | – | ✓ | – | positions |
| Tables | ✓ | ✓ | – | ✓ | – | – |
| Render pages | ✓ | ✓ | – | ✓ | – | ✓ |
| OCR | ✓ | ✓ | – | – | – | – |
| Merge, split, reorder, rotate | ✓ | ✓ | ✓ | – | ✓ | partly |
| Text, image and QR stamps | ✓ | ✓ | by overlay | – | by overlay | – |
| Redaction that removes content | ✓ | ✓ | – | – | – | – |
| Redaction verified before writing | ✓ | – | – | – | – | – |
| Replace text in place | ✓ | – | – | – | – | – |
| Fill forms | ✓ | ✓ | ✓ | – | – | – |
| Encrypt and decrypt | ✓ | ✓ | ✓ | – | ✓ | – |
| Create from Markdown | ✓ | from HTML | – | – | – | – |
| Built-in MCP server and tool schemas | ✓ | – | – | – | – | – |
| JSON from every command | ✓ | library | library | library | partly | – |
| Runtime needed | none | Python | Python | Python | none | none |
| License | MIT | AGPL or commercial | BSD | MIT | Apache-2.0 | GPL |
PyMuPDF is the closest in scope and is an excellent library; it is written in C, needs Python, and
its AGPL license matters if you ship it. pdfops trades some breadth for a single MIT-licensed
binary whose tools an agent can call directly.
## Benchmarks
Best of 3 whole-process runs, start-up included, since that is what one tool call costs an agent.
Document: a 357 page, 1.4 MB manual; `images` on a 4.6 MB manual with pictures; forms on a one page
form. Machine: 4 core Intel i5-4460. Versions: poppler 26.08, qpdf 12.4, PyMuPDF 1.28, pypdf 6.19,
pdfplumber 0.11, tesseract 5.5. The fastest entry of each row is bold.
| `info` | **5 ms** | `pdfinfo` 13 ms | 211 ms | 454 ms | 371 ms |
| `text`, all pages | **483 ms** | `pdftotext` 541 ms | 641 ms | 4146 ms | 34.5 s |
| `search`, all pages | **430 ms** | - | 627 ms | - | - |
| `layout`, every word with its box | **548 ms** | - | 758 ms | - | 33.0 s |
| `tables`, 50 pages | **52 ms** | - | 4422 ms | - | 4133 ms |
| `outline` | **20 ms** | - | 201 ms | 353 ms | - |
| `render`, 20 pages at 150 dpi | **195 ms** | `pdftoppm` 5962 ms | 1229 ms | - | - |
| `ocr`, one page (render, then tesseract) | 2338 ms | `tesseract` **1511 ms** | - | - | - |
| `images`, all embedded images | **391 ms** | `pdfimages` 3897 ms | 675 ms | 5232 ms | - |
| `create`, 100 sections of Markdown | **10 ms** | - | - | - | - |
| `merge`, three copies | **123 ms** | `qpdf` 254 ms | 1183 ms | 3825 ms | - |
| `pages`, keep 10 | **21 ms** | `qpdf` 122 ms | 204 ms | 419 ms | - |
| `split`, one file per page | **54 ms** | `pdfseparate` over 60 s | 877 ms | 2492 ms | - |
| `rotate`, all pages | **47 ms** | `qpdf` 152 ms | 252 ms | 1309 ms | - |
| `stamp`, text on every page | **33 ms** | - | 610 ms | - | - |
| `stamp`, QR code on every page | **180 ms** | - | - | - | - |
| `redact`, a word on every page | 2144 ms | - | **1532 ms** | - | - |
| `replace`, a word on every page | **1736 ms** | - | - | - | - |
| `set-meta` | **60 ms** | - | 269 ms | 1416 ms | - |
| `compress` | **65 ms** | `qpdf` 274 ms | 1109 ms | - | - |
| `encrypt`, AES-256 | **49 ms** | `qpdf` 160 ms | 253 ms | 1715 ms | - |
| `decrypt` | **76 ms** | `qpdf` 178 ms | 397 ms | 2418 ms | - |
| `forms`, list fields | **15 ms** | - | 391 ms | 236 ms | - |
| `fill`, one field | **7 ms** | - | 509 ms | 433 ms | - |
Reproduce with `scripts/bench.py` on any document.
Why it is fast: `info`, `layout`, `tables`, `render`, `images` and `ocr` read objects on demand, so
opening a file costs a few milliseconds whatever its size. Text extraction, layout, tables,
rendering, splitting, image export and OCR run on all cores. Page operations copy only the objects
the selected pages reach, so output size and time follow the selection, not the source.
## Using it without MCP
`pdfops tools` prints `[{"name", "description", "inputSchema"}]` for every command. Pass these to
any function calling API, then run the call through the CLI or the library.
```rust
let result = pdfops::tools::call(
"pdf_text",
serde_json::json!({"input": "report.pdf", "pages": "1-3"}),
)?;
```
Typed entry points live in `pdfops::ops`, for example `pdfops::ops::read::text(TextArgs { .. })`.
## Behaviour worth knowing
- **Text outside Latin-1.** `stamp`, `fill`, `replace` and `create` embed a subset of a font that
has the glyphs: the one given with `stamp --font`, otherwise one found on the system. Latin-1 text uses the built-in
Helvetica and embeds nothing. Text is placed glyph by glyph: scripts that need shaping or
right-to-left layout (Arabic, Hebrew, Indic) will not come out right.
- **Redaction** deletes what is under the areas from the page content: glyphs, image pixels
(including scanned pages), drawings lying wholly inside an area, and annotations with the form
values they show. Text around it does not move. The result is read back by a second, independent
interpreter, and nothing is written unless every area is empty. When a page is built in a way that
cannot be rewritten with certainty (inline images, JPEG 2000 or CMYK JPEG under an area), the
command fails instead of guessing. It does not rewrite document metadata, bookmarks or attached
files, and it removes the accessibility structure tree, which can repeat page text.
- **Replace** writes the new text with the codes the document's own font already uses for those
characters on that page, so style is kept exactly. If the font (usually a subset) lacks a needed
glyph, another font writes just those words, at the same size, position and colour, and the result
says so. Lines are not re-flowed: a longer replacement runs into what follows, and `overflow_pt`
reports by how much. Text inside form fields and annotations is not edited.
- **Tables** drawn with ruling lines are read cell by cell and are reliable. Tables without lines
are inferred from column alignment (`detected_by: alignment`) and deserve a look before trusting.
- **Create** understands headings, emphasis, links, nested lists, quotes, code blocks, tables, rules
and local images. HTML inside the Markdown is ignored; there is no HTML or CSS engine.
- **Bookmarks.** `merge`, `pages` and `split` keep the bookmarks whose target page is in the output.
They land at the top of that page. Named destinations used by links are not carried over.
- **Forms when merging.** Fields of the second and later inputs are renamed `doc2.<name>`,
`doc3.<name>`, so equal names do not share a value.
- **Lossy compression** is opt-in through `--image-quality` and `--max-image-edge`. It re-encodes
8-bit grey and RGB images as JPEG. Masks, palette images, CMYK and 1-bit scans are left untouched,
and the output is never larger than the input.
- **Images** are the ones a page actually draws, including inline images and images inside forms.
JPEG and JPEG 2000 are written as stored; everything else (Flate, LZW, CCITT fax, JBIG2, palette,
masks) is decoded to PNG.
- **OCR** quality and languages are tesseract's. Recognised text is returned, not written into the
PDF. When a language is missing, the error names the `ocr-install` call that fixes it, so an agent
can recover on its own. Data comes from the `tessdata_fast` repository (`--best` for the larger
models) and is kept in `~/.local/share/pdfops/tessdata`, or `PDFOPS_TESSDATA` if set. Installing
tesseract itself uses the system package manager; where that needs a password, the command to
run is returned instead.
- **Signatures.** Any change to a signed PDF invalidates its digital signatures.
## Development
```sh
cargo test # under a second; fixtures are generated in memory
cargo test -- --ignored # OCR test, needs tesseract with a language pack
cargo clippy --all-targets -- -D warnings
cargo fmt --check
scripts/bench.py --help # regenerate the benchmark table
```
Built on [lopdf](https://github.com/J-F-Liu/lopdf) (object model),
[pdf-extract](https://github.com/jrmuizel/pdf-extract) (text),
[hayro](https://github.com/LaurenzV/hayro) (rendering, positions and image decoding),
[subsetter](https://github.com/typst/subsetter) (font embedding) and
[pulldown-cmark](https://github.com/pulldown-cmark/pulldown-cmark) (Markdown).
## License
MIT