# PDF tool: compress, merge, split, rotate, stamp and protect: build brief

Assembled by AgentGid's starter kit builder from recipe `pdf-tool` v0.3 (https://agentgid.com/build/kits/pdf-tool/). Data snapshot: 2026-10-12.
Constraints: commercial use; free parts only; crediting authors is fine.
Recipe status: test-built on 2026-10-12 (2 builds) with exactly these parts. The fixes from the last build are in this version, which has not been built again as a whole.
Licenses and prices change. Before you ship, open the linked AgentGid pages and confirm.

## What to build

A Python command line tool with one command per job: `info`, `merge`, `split`, `pages` (keep, delete, reorder), `rotate`, `compress`, `stamp` (watermark or page numbers) and `protect`/`unlock`. Every command writes a new file and checks it against the original.

## Definition of done

- [ ] `python -m pdftool make-fixtures` writes the test PDFs into `tests/fixtures/`: a 3-page text file with known text, a photo file with 300-dpi images, a file with a form field, a file with bookmarks, a rotated page and a password-protected file.
- [ ] `merge` joins files in the given order and keeps bookmarks and form fields (a test reads both back with pypdf).
- [ ] `split` writes one file per range (`1-3,5,7-`), `pages` keeps, deletes or reorders pages (`3,1,2`), `rotate` turns chosen pages by 90, 180 or 270 degrees relative to their current rotation.
- [ ] `compress` on the photo file at the default 150 dpi makes it at least 60% smaller; page count, page sizes and the extracted text are unchanged, and each page rendered before and after differs by less than 8 on average (0 to 255 scale).
- [ ] `compress` never hands back a bigger file: when the result is not smaller, it copies the input byte for byte to `<name>-compress.pdf` and says so.
- [ ] `stamp` writes a diagonal watermark or page numbers in Latin and Cyrillic; extracting the text of a stamped page gives the stamp back letter for letter, and a stamped rotated page shows the stamp upright.
- [ ] `protect` sets a password, `unlock` removes it with the right password, and a wrong password ends with a clear message and exit code 2.
- [ ] Every result passes `pikepdf.Pdf.open(out, password=...).check_pdf_syntax() == []` (the password only for encrypted results), and no command ever writes over its input file.
- [ ] Tests pass with `python -m pytest`.

Left for the person, because no check can do it:

- Open one compressed file of your own in your usual viewer and printer before relying on the default quality: 150 dpi suits screens and office printers, not print shops.

## Before you start

These files are already in this folder. Keep them, and never let a scaffolder overwrite them:

- `AGENTS.md`: this brief.
- `CLAUDE.md`: one line that imports AGENTS.md for Claude Code.
- `.mcp.json`: the MCP servers listed under "Tools you have".
- `CREDITS.md`: the license record. Add to it as you add files.
- `kit.json`: this kit as data (parts, versions, checks). `install.txt`: the install commands below. `first-prompt.txt`: the message the person starts you with.

You may do these without asking:

- Install the packages in the Stack section.
- Download the files linked under Assets, from the links given.
- Read the documentation this brief links to.
- Use `git` without asking.

Stop and ask the person for:

- an account, a key or a payment;
- a runtime dependency, asset source or API that this brief does not list;
- a check that still fails after two real fixes (say what you tried).

## Stack

Use these parts and versions. Ask before swapping one.

| Part | Choice | Version | Kind |
|---|---|---|---|
| Runtime | Python 3.12 | - | runtime |
| PDF engine | pikepdf | 10.17.0 | Python package |
| Merging | pypdf | 6.20.1 | Python package |
| Image recompression | Pillow | 12.3.0 | Python package |
| Stamps and test files | ReportLab | 5.0.1 | Python package |
| Page rendering | pypdfium2 | 5.14.0 | Python package |
| Also | pytest (unit tests) | 9.1.1 | Python package |

Install exactly these versions (the same lines are in `install.txt`):

```sh
uv venv --seed --python 3.12 .venv && source .venv/bin/activate   # uv fetches Python 3.12 if it is missing; --seed puts pip into the venv, so the pip line below installs there and not into the system Python
# without uv: python3.12 -m venv .venv && source .venv/bin/activate
python --version   # must print 3.12.x
pip install pikepdf==10.17.0 pypdf==6.20.1 pillow==12.3.0 reportlab==5.0.1 pypdfium2==5.14.0 pytest==9.1.1
```

Checks that apply to this kit:

- pikepdf 10.17.0 requires Python 3.11 or newer; the kit uses 3.12.

## Rules for this project

- Each command is a function that takes input paths and options and returns the output path plus a report; the command line only calls these functions.
- Never overwrite an input file. Write `<name>-<command>.pdf` next to it or into `--out`.
- Keep the encryption of an encrypted input: save its results with `encryption=True` (same passwords and permissions); a plain save silently writes them unencrypted.
- Refuse to change a file whose permissions forbid changes (`pdf.allow.modify_other` or `modify_assembly` is False) and say why: pikepdf opens a file that has only an owner password, and a plain save silently drops its restrictions.

## Assets and licenses

Add a row to CREDITS.md for every file you bring into the project, at the moment you add it.

### Stamp font: Google Fonts, Inter

- Font file: https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf (856 KB)
- License text to keep next to it: https://raw.githubusercontent.com/google/fonts/main/ofl/inter/OFL.txt
- It is a variable font: browsers use its default weight unless you set one.
- Specimen: https://fonts.google.com/specimen/Inter
- Designer: Rasmus Andersson. License of this font: OFL-1.1.
- Source terms: Commercial use OK; No credit needed.
- AgentGid's rules for Google Fonts: Keep the license file with font files you bundle in an app. Respect Reserved Font Names when you modify a font.
- License page: https://github.com/google/fonts (AgentGid read it on Oct 10, 2026). Details: https://agentgid.com/assets/google-fonts/

## Tools you have

These MCP servers are in the config file listed under "Before you start".

- Context7: Up-to-date library docs for the agent
- AgentGid: Live data for choosing tools, assets and APIs while building

If a server's tools are missing from your session, either the person has not approved them yet (Claude Code holds the servers of a project `.mcp.json` as "Pending approval" until the person accepts them), or the config was added after the session started. Ask the person to approve the servers or restart the agent in this folder; until then, use the links in this brief.

## Plan

Work one step at a time. Do not start a step until the previous one meets its "Done when" lines.

### 1. Scaffold

- Create the virtual environment with Python 3.12 explicitly, as the install lines of the Stack section show, and check `python --version` inside it. If Python 3.12 is missing, `uv python install 3.12` gets it.
- Install the packages from the Stack section.
- `git init`, with a `.gitignore` that holds `.venv/`, `out/` and `.check/`, and a README.md that says how to run each command.
- Lay out `pdftool/` with one module per command group (`pages.py`, `merge.py`, `compress.py`, `stamp.py`, `secure.py`, `checks.py`) and a `__main__.py` command line. Put `pythonpath = ["."]` under `[tool.pytest.ini_options]` in pyproject.toml.
- Put `Inter[opsz,wght].ttf` into `pdftool/fonts/` with its license file.
- Done when:
  - `python -m pdftool --help` lists the commands and `python -m pytest` runs.
  - The kit files listed under "Before you start" are still there.

### 2. Test files and checks

- `make-fixtures` draws the test PDFs with ReportLab: known text on every page, a 2480x1754 photo-like image (noise over a gradient, so it does not compress by itself) placed at 300 dpi, a text field `fullname` with a value (`canvas.acroForm.textfield`), two bookmarks (`bookmarkPage` + `addOutlineEntry`), one page with `/Rotate 90`, and a copy saved by pikepdf with `encryption=pikepdf.Encryption(owner='test', user='test', R=6, allow=pikepdf.Permissions(modify_assembly=True))`: with pikepdf's default permissions the tool's own rule would refuse to split or merge it. Add one file with only an owner password and restricted permissions, to test that refusal.
- `checks.py` holds what every command reuses: page count and sizes, extracted text per page (pypdf), `check_pdf_syntax()`, and a picture comparison that renders each page with pypdfium2 at 72 dpi and takes the mean absolute difference with Pillow's `ImageChops.difference`.
- Done when: The fixtures open in pypdfium2 and the checks pass when a file is compared with itself.

### 3. Pages: split, select, reorder, rotate

- Parse page ranges in one place (`1-3,5,7-`, 1-based, `-` up to the end) and reject pages that do not exist with a message that names the page count.
- Use pikepdf for these, and work inside a fresh copy of the source: open it, delete the pages you do not keep and re-append the kept page objects of the same file in the new order, then save under the new name. Copying pages into a new `Pdf` with `pages.extend()` drops the form fields (PageCopyWarning). Drop bookmarks and form fields that pointed at deleted pages.
- Keep every source `Pdf` open until the output is saved: pages of a closed or garbage-collected source turn into `destroyed` objects and saving fails with a ValueError.
- `rotate` uses `page.rotate(angle, relative=True)`.
- Done when: Tests cover ranges, reordering, deleting and rotating, and the extracted text of each output page matches the source page it came from.

### 4. Merge

- Merge with pypdf's `PdfWriter.append(path)`, one call per input, then `write`. In our check it kept both the bookmarks and the form fields. pikepdf's `pages.extend` dropped the form fields (it warns with PageCopyWarning), and `Pdf.add_pages_from` kept the fields but dropped the bookmarks.

  ```python
  from pypdf import PdfWriter
  w = PdfWriter()
  for p in inputs:
      w.append(p)
  w.write(out)
  ```

- Refuse an encrypted input without its password and say which file it is. With the password, decrypt it in memory with pikepdf (`pikepdf.open(path, password=...)`, `save()` to a `BytesIO`) and give pypdf that copy: pypdf cannot read AES-encrypted files without the extra package `cryptography`, which the Stack does not include.
- Done when: A test merges the bookmark file and the form file and reads back the bookmarks of the bookmark file and the field `fullname` with its value.

### 5. Compress

- Lossless first, always: save with `compress_streams=True`, `object_stream_mode=pikepdf.ObjectStreamMode.generate` and `recompress_flate=True`.
- Then images, unless `--lossless`: for each image find the largest size it is drawn at on any page, and scale it down to `--dpi` (default 150) at that size; write it as JPEG at `--quality` (default 75). Use `page.get_images()` (pikepdf 10.17 marks `page.images` as deprecated, and the new call also finds images inside form XObjects). An image used on several pages is one object: handle each `objgen` once. For the drawn size, walk each content stream with `pikepdf.parse_content_stream`, track the matrix through `q`, `Q` and `cm`, read it at each `Do`, and recurse into form XObjects with their `/Matrix`.

  ```python
  raw.write(jpeg_bytes, filter=Name.DCTDecode)
  raw.Width, raw.Height = img.width, img.height
  raw.ColorSpace = Name.DeviceRGB   # or DeviceGray for 'L' images
  raw.BitsPerComponent = 8
  if '/DecodeParms' in raw: del raw.DecodeParms
  ```

- Leave alone: image masks (`/ImageMask true`; `get_images()` does not list them at all, so code that needs them must read `/Resources/XObject` itself), 1-bit images, indexed and CMYK colour, JPEG 2000 and JBIG2, and any image already at or below the target resolution. Keep an existing `/SMask` as it is.
- Compare the result with the input (page count, sizes, text, pictures). If the file did not get smaller, copy the input byte for byte to the output name and say so in the report.
- Read first: `https://pikepdf.readthedocs.io/en/latest/topics/images.html`
- Done when: The photo fixture shrinks by at least 60% at 150 dpi and passes every check; the text fixture never grows.

### 6. Stamp

- Draw the stamp with ReportLab on a one-page PDF the size of the page as it is displayed: the media box's width and height, swapped when the page's `/Rotate` is 90 or 270. Use the TTF font registered through `pdfmetrics.registerFont(TTFont(...))`, and do not call `setPageRotation`.
- Lay it over each chosen page with `page.add_overlay(stamp.pages[0], rect=pikepdf.Rectangle(page.mediabox))`. qpdf turns the stamp so it stays upright on a rotated page. What matters is the size of the stamp page: drawn at the media box's portrait size, it is shrunk to about 70% on a landscape-rotated page.
- Two modes: `--text "ЧЕРНОВИК"` (diagonal, 30% opacity with `setFillAlpha`) and `--page-numbers` (`{n} / {total}` at the bottom centre).
- Done when: Text extracted from a stamped page contains the stamp letter for letter, in Latin and Cyrillic, and the rotated fixture shows the stamp upright and full size when rendered, with the page number at the bottom of the page as displayed.

### 7. Protect and unlock

- `protect` saves with `encryption=pikepdf.Encryption(owner=..., user=..., R=6, allow=pikepdf.Permissions(modify_assembly=True))` (AES-256). Without `allow=`, pikepdf's default permissions forbid page assembly, and the tool's own rule would then refuse to split or merge the files it protected. `unlock` opens with `password=` and saves without encryption; a wrong password raises `pikepdf.PasswordError`: catch it and exit with code 2.
- Read passwords from a prompt or from the environment variables `PDFTOOL_PASSWORD` (to open an input) and `PDFTOOL_NEW_PASSWORD` (for `protect`, used as user and owner password), never from a command-line argument that shows up in the shell history. For `merge`, one password opens every encrypted input; an input it does not open is refused by name.
- Done when: A test protects the text fixture, fails to open it without a password, unlocks it with the right one and gets the same text back.

### 8. Verify

- Commit, make a clean copy of the repository in a temporary folder (`git clone . /tmp/check`, or a git-ignored `.check/` inside the project if you may not write outside it) and follow only the README there: install, make the fixtures, run the tests.
- Run every command once by hand on a real PDF of the person's if they give one, and write the sizes before and after into PROGRESS.md.
- Done when: Every line under Definition of done is true, and anything that could not be verified is named in PROGRESS.md.

## How to check your work

- After each command, run the shared checks on its output: syntax, page count, text and picture difference.
- Look at one rendered page after every change to compress or stamp, not only at the numbers.

## Known traps

- pikepdf's `pages.extend()` from another file drops interactive form fields (it warns with PageCopyWarning); `Pdf.add_pages_from()` keeps the fields but not the bookmarks. pypdf's `PdfWriter.append()` kept both. (measured with pikepdf 10.17.0, pypdf 6.20.1 and reportlab 5.0.1, Oct 12, 2026)
- Keep a source `Pdf` open until the file you copy its pages into is saved. If the source is closed or garbage-collected first, its pages become `destroyed` objects and the save fails. (measured with pikepdf 10.17.0, pypdf 6.20.1 and reportlab 5.0.1, Oct 12, 2026)
- In pikepdf 10.17 `page.images` prints a DeprecationWarning; `page.get_images()` replaces it and also finds images nested inside form XObjects. (measured with pikepdf 10.17.0, pypdf 6.20.1 and reportlab 5.0.1, Oct 12, 2026)
- There is no `Pdf.check()`: the syntax check is `pdf.check_pdf_syntax()`, which returns a list of problems (empty when the file is fine). (measured with pikepdf 10.17.0, pypdf 6.20.1 and reportlab 5.0.1, Oct 12, 2026)
- After replacing an image's data with JPEG you must also set `Width`, `Height`, `ColorSpace` and `BitsPerComponent` and delete `DecodeParms`; otherwise the old values describe new data and viewers draw garbage. (pikepdf docs and our check)
- ReportLab's built-in fonts (Helvetica and the rest) have no Cyrillic: "Черновик" comes out as eight black boxes. Register a TTF font with Cyrillic, such as Inter[opsz,wght].ttf. (measured with reportlab 5.0.1, Oct 12, 2026)
- pypdf reads AES-encrypted PDFs (the AES-256 files `protect` makes) only with the extra package `cryptography`; without it, `decrypt()` raises "DependencyError: cryptography>=3.1 is required for AES algorithm". (measured with pikepdf 10.17.0, pypdf 6.20.1 and reportlab 5.0.1, Oct 12, 2026)
- `pikepdf.Encryption(...)` without `allow=` uses `pikepdf.Permissions()`, whose `modify_assembly` is False: the file forbids inserting, deleting and rotating pages. (measured with pikepdf 10.17.0, pypdf 6.20.1 and reportlab 5.0.1, Oct 12, 2026)
- `add_overlay` on a page with `/Rotate 90` keeps the stamp upright but fits it into the page's box as stored, which is landscape when the page shows portrait: a portrait-sized stamp is scaled to 70.7% and centred. Draw the stamp at the displayed size and pass `rect=Rectangle(page.mediabox)`. ReportLab's `setPageRotation(90)` swaps the stamp page's box too, so text drawn at portrait coordinates falls off it. (measured with pikepdf 10.17.0, pypdf 6.20.1 and reportlab 5.0.1, Oct 12, 2026)
- A PDF that has only an owner password (restrictions, no password to open) opens in pikepdf without any password, and a plain save writes it without the restrictions. (measured with pikepdf 10.17.0, pypdf 6.20.1 and reportlab 5.0.1, Oct 12, 2026)

## Warnings you can ignore

- PageCopyWarning from pikepdf if anything still copies pages with `pages.extend()`: switch that code to pypdf's `append`, as the plan says.

## Limits

- Change only PDFs you have the right to change. Removing a password or a restriction from someone else's document can break the law or a contract.
- The stamp font is embedded in stamped files; its OFL license allows that. Keep the license file in the repository.
- Never read or print the contents of `.env` files, and never commit keys.

## When you need something that is not listed

Do not pick a library, asset source or API from memory. Ask the AgentGid MCP server first:

- `find_tools(goal)` for libraries, APIs and agents that fit a task.
- `find_assets(category, query, free_commercial_only=true)` for media sources and their license terms. It knows sources such as Kenney or Pixabay, not single packs, models or fonts.
- `get_api(api)` for prices, free tiers and limits of entries in AgentGid's API catalog. Media sources are not in it: use `find_assets`.
- `check_status(names)` when a service seems down.

Then tell the person what you want to add and why, cite the AgentGid URL, and wait for a yes before you add a runtime dependency.

## When this brief is wrong

Keep a list of what in this brief was wrong, missing or out of date, with the line and what worked instead (in PROGRESS.md). When you finish, send it to AgentGid's editors with `report_kit_issue(recipe="pdf-tool", version="0.3", text=...)` on the AgentGid MCP server: only they read it. Leave out keys, personal data and the person's code.

## Sources

- https://www.python.org/downloads/
- https://agentgid.com/tools/pikepdf/
- https://pypi.org/project/pikepdf/
- https://agentgid.com/tools/pypdf/
- https://agentgid.com/tools/pillow/
- https://agentgid.com/tools/reportlab/
- https://pypi.org/project/reportlab/
- https://agentgid.com/assets/google-fonts/
- https://fonts.google.com/specimen/Inter
- https://agentgid.com/tools/pypdfium2/
- https://pypi.org/project/pypdfium2/
- https://agentgid.com/mcp/servers/io-github-upstash-context7/
- https://agentgid.com/connect/
- https://pikepdf.readthedocs.io/en/latest/topics/images.html
- https://pypi.org/pypi/pikepdf/json
