Find out what is actually installed
Engines are optional and are found when you run the tool. This reports what it found, which version, and what is missing, so a machine can decide whether a job is even possible before starting it.
PyPIApache-2.0nothing AGPL or GPL
Every common PDF chore. One safe CLI. No copyleft underneath.
Install pdf-tooling; the command is pdftooling.
# Install (published on PyPI)
uv tool install pdf-tooling
# or: pip install pdf-toolingLinux and macOS. macOS 15 or later on Apple silicon; Intel Macs are not supported.
A dry run plans the whole merge across three files. Then ls is the check, and nothing was written.
Published on PyPI as pdf-tooling. There is no Homebrew formula, no system package, no container image yet. Every command pdftooling --help prints is shipped. See Commands.
§01 / Safety
Four guarantees. Each one is checked by the test suite, not just promised here.
It plans and reports, and writes nothing, anywhere. Run it first on anything you are not sure about. There is no half-written file to clean up afterwards, because nothing was written.
Output files are never replaced unless you pass --force. A bulk run that would destroy files is refused with exit 5 instead of asking first, so a script and a person get the same answer. There is no prompt for an automated caller to satisfy by accident.
Every write goes to a temporary file next to the target, is flushed to disk, then moved into place in one step the system cannot leave half-done. Your input files are never changed unless you ask for --in-place, which saves a .bak copy first.
Anything typed on the command line is visible to other users on the machine and is saved in your shell history, so a password is never accepted there. There is no --password, no --user-password and no --owner-password. Pass it in a file or on stdin instead.
Permission bits are a request, not a lock. Nothing enforces them. Encryption protects the content; the bits on their own protect nothing.
§02 / Architecture
Most tools ask you to trust a long dependency list. This one is built so you do not have to. Every PDF engine it uses sits behind 8 adapter modules, small files whose only job is to talk to one engine. Just 1 more file, adapters/subprocess_util.py, may start another program, and nothing else in the codebase is allowed to. That is 9 files in total between you and every engine, so reading all of them takes an afternoon rather than a project. Writing to your disk is fenced off the same way, and tests/test_import_boundaries.py fails the build the day either fence comes down.
The diagram below shows how that is arranged. You do not need any of it to use the tool.
src/pdf_tooling/cli/The only layer that may import the CLI framework.
Parses flags, builds one frozen OperationPlan, picks a renderer.
src/pdf_tooling/ops/Plain functions, no framework, working only through the ports below. A filesystem refusal is never decided here.
No I/O except through Safety and Ports.
Ops reaches Safety and Ports in parallel.
src/pdf_tooling/safety/The only place that writes to disk: one file, not one package. Both write allowlists are empty.
Write-to-temp, fsync, atomic rename. The --dry-run gate.
src/pdf_tooling/ports/typing.Protocol definitions only. Importing every port module loads no engine.
StructureEngine · RasterEngine · ComposeEngine · TextEngine · OcrEngine · OfficeConverter
src/pdf_tooling/adapters/The only package that may import an engine library. subprocess_util.py is the only module in the tree that may spawn a process.
Eight modules serve the six ports above. The ninth file is the one allowed to start a program, not an engine backer.
Enforced boundary
tests/test_import_boundaries.pyabove: no engine imports, no starting programs, no writes outside the one file allowed to · below: every third-party engine
pypdf · pypdfium2 · pikepdf · reportlab · pdfplumber · Tesseract · LibreOffice
Not this codebase. Permissive licences only, checked in CI.
src/pdf_tooling/output/stdout is the payload; stderr is everything else.
What Ops returns, rendered as table, json or ndjson.
▣ marks a file that is the only one allowed to do its job. Each boundary rule is enforced by the test named beside it, and the rail runs one way: down.
| Layer | Path | The rule | Enforced by |
|---|---|---|---|
| L1 | src/pdf_tooling/cli/ | The only layer that may import the CLI framework. | test_no_typer_or_click_import_below_l1 |
| L2 | src/pdf_tooling/ops/ | Plain functions, no framework, working only through the ports below. A filesystem refusal is never decided here. | test_no_ops_module_names_a_filesystem_tier_refusal_class |
| L3 | src/pdf_tooling/safety/ | The only place that writes to disk: one file, not one package. Both write allowlists are empty. | test_write_calls_inside_safety_are_confined_to_the_chokepoint · test_both_allowlists_are_empty |
| L4 | src/pdf_tooling/ports/ | typing.Protocol definitions only. Importing every port module loads no engine. | test_importing_the_port_layer_loads_no_engine |
| L5 | src/pdf_tooling/adapters/ | The only package that may import an engine library. subprocess_util.py is the only module in the tree that may spawn a process. | test_no_engine_library_is_imported_outside_adapters · test_nothing_outside_the_chokepoint_can_spawn |
| L6 | src/pdf_tooling/output/ | stdout is the payload; stderr is everything else. | README.md ## Output contract |
Your files
Kill the process, lose the disk, pull the power: elsewhere the usual result is a half-written file where your document used to be. Here every step happens on a scratch file next to yours, and your own file is touched once, at the very end, by a rename the operating system cannot leave half-finished. Nothing else in the run can reach it. Only one file in the whole codebase is allowed to write anything (src/pdf_tooling/safety/atomic.py), which is why this holds for every command, not just the ones someone remembered to make safe.
Refuses an existing target without --force. Exit 5.
Stops before touching the filesystem at all.
A temp file beside the target, on the same filesystem, which is what makes the rename atomic.
Write, flush, fsync.
--in-place only.
your file changes here
Atomic. The only place the two tracks touch.
Anything fails at steps ① through ⑤: the temp file is deleted, your file is untouched, and the process exits with a non-zero code. A hard kill (SIGKILL) cannot be handled: it can leave the hidden temp file behind, and your file is still untouched.
Everything before the crossing runs on a scratch file, on the same filesystem as the target; only the last station touches your file, and it does so atomically.
§03 / Commands
Everything you would otherwise assemble from several separate tools, with one way to say which pages you mean. Every command that takes page numbers uses the same range grammar: 1-3,5,9-, even, odd, first, last, -1, !5. You learn it once. This list is checked against pdftooling --help on every release, so it cannot drift from the tool you installed.
Report each port's resolved adapter, version, and OS-aware install hint for what is missing.
Page count/sizes, encryption + permission bits, metadata, producer, font list, signature presence, per-page rotation.
Report the tool, runtime and engine versions.
Concatenate PDFs; per-input page selection via path:range; outline entry per source file.
One PDF into many, by fixed chunk size, explicit ranges, per page, or at top-level bookmarks.
Write the selected pages to a new PDF, in the order given.
Write everything except the selected pages.
Rotate the selected pages by a multiple of 90°, absolute or relative.
Rewrite page order from an explicit sequence; duplicates allowed, order preserved.
Structural recovery of a damaged/malformed PDF via libqpdf's recovery parser.
Rewrite for byte-serving ("fast web view").
Report the permission bits and encryption algorithm of an encrypted PDF.
Read the document information dictionary + XMP.
Write/clear document information fields and XMP.
PDF to PNG/JPEG/TIFF/WEBP at a chosen DPI or pixel width.
Images to PDF. JPEG inputs embed as DCTDecode streams (byte-preserving, no re-encode).
Text (v1) to PDF. Markdown/HTML behind the [html] extra (Phase 2).
Extract text: fast path (pdfium) or layout-aware (pdfplumber) with per-block geometry.
Detect and extract tables to CSV/JSON.
Shrink: libqpdf object streams plus stream recompression (lossless), optional Pillow image downsample/recompress (lossy).
Apply AES-256 (or RC4-128 with --legacy) with user/owner passwords and a permission set.
Remove encryption given the correct password.
Overlay/underlay generated text across the selected pages.
Overlay/underlay an existing PDF page onto the selected pages.
Add a Tesseract text layer over untouched pixels: a text-only PDF generated per page and merged.
Office to PDF via headless LibreOffice.
Run pdftooling --help for the whole tree.
§04 / Examples
Four real sessions, captured from the tool rather than written for the page.
Built for agents, not only for people
An agent driving a command-line tool has to guess twice: whether the output it just read means what it thinks, and whether the thing it asked for actually happened. Both guesses are removed here. Every read path emits a declared schema_version and turns to JSON on its own when stdout is not a terminal. Every failure is a structured object whose code is the same number the process exits with, taken from a table that is part of the public API, so a script can branch on a number instead of matching English text. And --dry-run reports exactly what a real run would do while writing nothing, anywhere, so an agent can plan, check, then act, instead of hoping.
Engines are optional and are found when you run the tool. This reports what it found, which version, and what is missing, so a machine can decide whether a job is even possible before starting it.
No flag was passed. Output is JSON because stdout is not a terminal; the same command is human-readable when you run it yourself and parseable when a script runs it.
One row per input, each with its own exit code and byte counts. Nothing is written until every input has been read and the destination has been checked.
Run it again and it refuses rather than overwriting. The refusal is a structured object with a stable code, not a message you have to parse, and not a silent overwrite.
§05 / Exit Codes
7 exit codes that mean the same thing in every command, plus the shapes that -o table / -o json / -o ndjson render into. Both are public API, both are checked by a test, and neither is typed by hand on this page.
| Code | Name | Meaning |
|---|---|---|
| 0 | OK | Success, including an empty-but-valid report. A --dry-run mirrors the code the real run would return, so it is not always 0. |
| 1 | FAILURE | The operation ran and failed: corrupt input, engine error, unwritable destination. |
| 2 | USAGE | Bad invocation: unknown flag, mutually exclusive flags, malformed page range, unknown subcommand. |
| 3 | ENGINE_MISSING | A required engine or binary is unavailable. The message always carries an install hint. |
| 4 | NO_INPUT | Valid invocation, nothing to act on. |
| 5 | REFUSED | A safety gate declined. |
| 6 | AUTH | Password required, incorrect, or of the wrong kind. |
| Add a code; never renumber one. Breaking this table is a major version bump, not a patch. | ||
Errors are the one deliberate difference between the two. With -o table an error is a one-line message on stderr, but with -o json or -o ndjson it is an object on stdout, so a script reading stdout never has to also read stderr to find out that a run failed.
§06 / Licensing
Apache-2.0, and every engine underneath it is BSD, MIT, Apache or MPL. No AGPL or GPL code is reachable from anything that runs.
This is checked by a CI job and a test, not promised in a README. The licence gate fails the build if any AGPL, GPL or LGPL package would ship, and a test reads every file under src/ for forbidden imports and subprocess calls out to other programs. That is the realistic leak a package-level licence scan cannot see on its own.
These numbers come from the same generated inventory the full list publishes. A default install pulls in 21 packages, the optional [html] extra adds 10 more, 31 packages in total.
pikepdf bundles libqpdf under MPL-2.0, which is copyleft file by file. It is used unmodified, as a dependency, which is what makes it Apache-compatible. It is documented in NOTICE and the full THIRD_PARTY_LICENSES inventory.
No package in the default install carries a GPL-family licence, and the one row in the whole inventory that does is optional and offers MPL 1.1 as an alternative: pyphen 0.18.1 enters only through the optional [html] extra, tri-licensed GPLv2+, LGPLv2+ or MPL 1.1, whichever you choose.
All 31 packages, with licenses, tiers and roles →
Full inventory: THIRD_PARTY_LICENSES.
Production posture
Three promises, and how to check each one.
schema_version change, so a script you write today keeps working across every minor and patch release.Get it
The same install this page opened with. Copy it, run it, and see the rest for yourself.
uv tool install pdf-toolingApache-2.0. The structured output shapes and the exit-code table are public API. Bugs and fixes live in GitHub issues and the changelog. Contributions are welcome, under the DCO.
the exit code matches the payload