Skip to content

PyPIApache-2.0nothing AGPL or GPL

pdf-tooling

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-tooling

Linux and macOS. macOS 15 or later on Apple silicon; Intel Macs are not supported.

bash · pdftoolingexit 0
pdftooling merge jul.pdf aug.pdf sep.pdf -O q3.pdf --dry-run
input output exit code message bytes before duration ms
------- ------ --------- ----------------- ------------ -----------
jul.pdf q3.pdf 0 12 pages selected 10253 0
aug.pdf q3.pdf 0 9 pages selected 7848 0
sep.pdf q3.pdf 0 11 pages selected 9452 0
ls q3.pdf
ls: cannot access 'q3.pdf': No such file or directory

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

What happens to your files

Four guarantees. Each one is checked by the test suite, not just promised here.

--dry-run is pure

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.

Nothing is overwritten

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 is atomic, inputs untouched

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.

A password is never a command-line value

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

Small enough that you can check it yourself

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.

  1. Layer L1src/pdf_tooling/cli/

    CLI

    The only layer that may import the CLI framework.

    Parses flags, builds one frozen OperationPlan, picks a renderer.

  2. Layer L2src/pdf_tooling/ops/

    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.

  3. Layer L3src/pdf_tooling/safety/

    Safety

    ▣ 1 FILE

    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.

  4. Layer L4src/pdf_tooling/ports/

    Ports

    6 ports

    typing.Protocol definitions only. Importing every port module loads no engine.

    StructureEngine · RasterEngine · ComposeEngine · TextEngine · OcrEngine · OfficeConverter

  5. Layer L5src/pdf_tooling/adapters/

    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.

    ▣ 8 adapter modules▣ +1 spawn file: subprocess_util.py
  6. Enforced boundary

    tests/test_import_boundaries.py

    above: no engine imports, no starting programs, no writes outside the one file allowed to · below: every third-party engine

  7. Engines

    pypdf · pypdfium2 · pikepdf · reportlab · pdfplumber · Tesseract · LibreOffice

    Not this codebase. Permissive licences only, checked in CI.

  8. Layer L6src/pdf_tooling/output/

    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.

Show the tests that enforce this
Every layer, its rule, and the test that enforces it.
LayerPathThe ruleEnforced by
L1src/pdf_tooling/cli/The only layer that may import the CLI framework.test_no_typer_or_click_import_below_l1
L2src/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
L3src/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
L4src/pdf_tooling/ports/typing.Protocol definitions only. Importing every port module loads no engine.test_importing_the_port_layer_loads_no_engine
L5src/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
L6src/pdf_tooling/output/stdout is the payload; stderr is everything else.README.md ## Output contract

Your files

A run that dies halfway leaves your file untouched

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.

  1. Plan / refuse

    Refuses an existing target without --force. Exit 5.

  2. Dry-run gate

    Stops before touching the filesystem at all.

  3. Temp file

    A temp file beside the target, on the same filesystem, which is what makes the rename atomic.

  4. Write

    Write, flush, fsync.

  5. .bak copy

    --in-place only.

  6. your file changes here

    os.replace()

    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

26 commands, one grammar

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.

diagnostics · 3

doctor

Report each port's resolved adapter, version, and OS-aware install hint for what is missing.

info

Page count/sizes, encryption + permission bits, metadata, producer, font list, signature presence, per-page rotation.

version

Report the tool, runtime and engine versions.

structure · 11

merge

Concatenate PDFs; per-input page selection via path:range; outline entry per source file.

split

One PDF into many, by fixed chunk size, explicit ranges, per page, or at top-level bookmarks.

extract

Write the selected pages to a new PDF, in the order given.

delete

Write everything except the selected pages.

rotate

Rotate the selected pages by a multiple of 90°, absolute or relative.

reorder

Rewrite page order from an explicit sequence; duplicates allowed, order preserved.

repair

Structural recovery of a damaged/malformed PDF via libqpdf's recovery parser.

linearize

Rewrite for byte-serving ("fast web view").

permissions

Report the permission bits and encryption algorithm of an encrypted PDF.

meta get

Read the document information dictionary + XMP.

meta set

Write/clear document information fields and XMP.

raster · 1

rasterize

PDF to PNG/JPEG/TIFF/WEBP at a chosen DPI or pixel width.

compose · 2

compose

Images to PDF. JPEG inputs embed as DCTDecode streams (byte-preserving, no re-encode).

create

Text (v1) to PDF. Markdown/HTML behind the [html] extra (Phase 2).

text · 2

text

Extract text: fast path (pdfium) or layout-aware (pdfplumber) with per-block geometry.

tables

Detect and extract tables to CSV/JSON.

optimize · 1

compress

Shrink: libqpdf object streams plus stream recompression (lossless), optional Pillow image downsample/recompress (lossy).

crypto · 2

encrypt

Apply AES-256 (or RC4-128 with --legacy) with user/owner passwords and a permission set.

decrypt

Remove encryption given the correct password.

overlay · 3

watermark

Overlay/underlay generated text across the selected pages.

stamp

Overlay/underlay an existing PDF page onto the selected pages.

ocr

Add a Tesseract text layer over untouched pixels: a text-only PDF generated per page and merged.

external · 1

convert

Office to PDF via headless LibreOffice.

Run pdftooling --help for the whole tree.

§04 / Examples

What it looks like to use it

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.

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.

bash · pdftoolingexit 0
pdftooling doctor -o table
port adapter available version kind
--------------- ---------- --------- -------- --------------
StructureEngine pypdf yes 6.16.2 python-package
RasterEngine pypdfium2 yes 5.13.0 python-package
ComposeEngine reportlab yes 5.0.1 python-package
TextEngine pdfplumber yes 0.11.10 python-package
OcrEngine tesseract yes 5.5.0 system-binary
OfficeConverter soffice yes 26.2.5.2 system-binary

Inspect a document, machine-first

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.

bash · pdftoolingexit 0
pdftooling info report.pdf | jq .documents[0]
{
"path": "report.pdf",
"size_bytes": 6695,
"page_count": 12,
"pdf_version": "1.3",
"encrypted": false,
"permissions": [],
"linearized": false,
"has_signature": false
}

Do the work

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.

bash · pdftoolingexit 0
pdftooling merge report.pdf appendix.pdf -O book.pdf -o table
input output exit code message bytes after
------------ -------- --------- ----------------- -----------
report.pdf book.pdf 0 12 pages selected 8777
appendix.pdf book.pdf 0 4 pages selected 8777

Fail in a way a script can act on

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.

bash · pdftoolingexit 5
pdftooling merge report.pdf appendix.pdf -O book.pdf
{
"schema_version": 1,
"error": {
"code": 5,
"kind": "refused",
"message": "book.pdf exists; pass --force to overwrite it",
"path": "book.pdf"
}
}
echo $?
5

the exit code matches the payload

§05 / Exit Codes

Two contracts a script can rely on

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.

The exit-code contract: 7 codes, uniform across every verb.
CodeNameMeaning
0OKSuccess, including an empty-but-valid report. A --dry-run mirrors the code the real run would return, so it is not always 0.
1FAILUREThe operation ran and failed: corrupt input, engine error, unwritable destination.
2USAGEBad invocation: unknown flag, mutually exclusive flags, malformed page range, unknown subcommand.
3ENGINE_MISSINGA required engine or binary is unavailable. The message always carries an install hint.
4NO_INPUTValid invocation, nothing to act on.
5REFUSEDA safety gate declined.
6AUTHPassword required, incorrect, or of the wrong kind.
Add a code; never renumber one. Breaking this table is a major version bump, not a patch.
Two exceptions on code 0
Exception: when an engine has the last word
convert and ocr hand the operand to an out-of-process binary (soffice or tesseract), and a --dry-run may not start either. Every previewed item for those commands carries engine_verified: false in its detail, so the preview never reads as an unqualified success.
Exception: whether a password is correct
A preview can tell whether a password source exists, so it predicts that much. It cannot tell whether the password is correct without trying it, and a preview must never become a way to test passwords. Every previewed item that names a password source carries password_verified: false in its detail.

The output contract

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.

-o table
An aligned, plain-text table for a person reading a terminal.
-o json
One object, carrying schema_version.
-o ndjson
One object per item, one per line; each line carries its own schema_version.

§06 / Licensing

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

What you can depend on

Three promises, and how to check each one.

Frozen contract
The structured output shapes and the exit-code table are public API from v1.0.0. Breaking either takes a new major version and a schema_version change, so a script you write today keeps working across every minor and patch release.
How it reaches you
Releases are built and published by CI from a tag, over OIDC. That means the workflow holds no upload token at all, so nothing can be published from a developer's machine. Both files PyPI serves carry signed provenance, so you can check that what you installed was built from this repository.
When something's wrong
Open an issue. The command, the flags, the input and what you expected is enough to act on. Every fix that ships is in the changelog, written by the commit that made it.

Get it

One command, and nothing hidden underneath it

The same install this page opened with. Copy it, run it, and see the rest for yourself.

uv tool install pdf-tooling

Apache-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.