---
name: pdf_processing_automation
description: "Manipulate PDFs one file or a small batch at a time with pypdf and pdfplumber: merge, split, rotate, reorder, extract text and tables, fill AcroForm fields, watermark, and compress; use when a PDF needs a concrete edit or extraction and the job fits in a script, not a processing pipeline."
metadata: { "includeInPrompt": true }
---

# PDF Processing Automation

## Purpose

Do the everyday PDF jobs directly: join invoices into one file, pull pages 3 to 5 out as a separate file, fix a sideways scan rotation, pull the text or a table out of a report, stamp DRAFT on each page, fill the fields in a form PDF, or shrink a file before sending it. Each job is one input file or a short list of files, one output file, and a check that the output page count and content match the request.

Scope line: document-processing-pipeline is document processing at scale (ingest, OCR, PII redaction, archival). This skill owns everyday single-file and small-batch PDF manipulation: merge, split, rotate, reorder, extract text and tables, fill forms, watermark, compress, with the libraries pypdf and pdfplumber.

Verified at drafting time (2026-10-13): pypdf 6.20.1 and pdfplumber 0.11.10 on PyPI, both installed in a clean venv and exercised. Method names quoted below were confirmed by introspection on the installed packages: pypdf `PdfReader`, `PdfWriter`, `writer.add_page`, `writer.append`, `page.extract_text`, `page.rotate`, `page.merge_page`, `reader.get_fields`, `writer.update_page_form_field_values`, `page.compress_content_streams`; pdfplumber `pdfplumber.open`, `page.extract_text`, `page.extract_tables`. Re-check versions and names before relying on them in a new environment.

## Workflow

1. Inspect before editing. Run the `info` command from the tool in step 3 on every input. Record page count, per-page rotation, whether the file is encrypted, and the form field names if any. Most bad outputs come from a wrong assumption at this step: a file with 4 pages when you expected 3, a page already rotated 90 degrees, or a form with no fillable fields.

2. Pick the library by job.
   - pypdf for structural work: merge, split, rotate, reorder, watermark by page overlay, form field updates, and content stream compression.
   - pdfplumber for reading work where layout matters: text with positions and table extraction. Its table finder depends on ruling lines and text alignment in the source file, so results vary by document.
   - Neither library does OCR. If `extract_text` returns empty or near-empty text for a page that visibly has words, the file is a scanned image. Stop and route it through OCR first; that work belongs to document-processing-pipeline.

3. Start from the tool below. Save it as pdf_tool.py, then run it. It covers make-fixture, merge, split, rotate, reorder, extract-text, info, and compress.

```python
#!/usr/bin/env python3
"""Small-batch PDF tool built on pypdf. No OCR, no image recompression.

Usage:
    python3 pdf_tool.py make-fixture OUT.pdf "Page one text" "Page two text"
    python3 pdf_tool.py merge OUT.pdf IN1.pdf IN2.pdf [IN3.pdf ...]
    python3 pdf_tool.py split IN.pdf OUT_DIR
    python3 pdf_tool.py rotate IN.pdf OUT.pdf --page 0 --angle 90
    python3 pdf_tool.py reorder IN.pdf OUT.pdf --order 2,0,1
    python3 pdf_tool.py extract-text IN.pdf
    python3 pdf_tool.py info IN.pdf
    python3 pdf_tool.py compress IN.pdf OUT.pdf

Page numbers on the command line are 0-based. Angles must be a multiple
of 90. Requires pypdf (tested with 6.20.1).
"""
import argparse
import os
import sys

from pypdf import PdfReader, PdfWriter
from pypdf.generic import DecodedStreamObject, DictionaryObject, NameObject


def make_fixture(args):
    writer = PdfWriter()
    for text in args.texts:
        page = writer.add_blank_page(width=612, height=792)
        escaped = text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
        content = DecodedStreamObject()
        content.set_data(f"BT /F1 18 Tf 72 720 Td ({escaped}) Tj ET".encode())
        font = DictionaryObject()
        font.update({
            NameObject("/Type"): NameObject("/Font"),
            NameObject("/Subtype"): NameObject("/Type1"),
            NameObject("/BaseFont"): NameObject("/Helvetica"),
        })
        fonts = DictionaryObject()
        fonts[NameObject("/F1")] = font
        resources = DictionaryObject()
        resources[NameObject("/Font")] = fonts
        page[NameObject("/Resources")] = resources
        page[NameObject("/Contents")] = content
    with open(args.out, "wb") as fh:
        writer.write(fh)
    print(f"wrote {args.out} pages={len(args.texts)}")
    return 0


def merge(args):
    writer = PdfWriter()
    total = 0
    for path in args.inputs:
        reader = PdfReader(path)
        for page in reader.pages:
            writer.add_page(page)
            total += 1
    with open(args.out, "wb") as fh:
        writer.write(fh)
    print(f"wrote {args.out} pages={total}")
    return 0


def split(args):
    reader = PdfReader(args.input)
    os.makedirs(args.out_dir, exist_ok=True)
    base = os.path.splitext(os.path.basename(args.input))[0]
    for i, page in enumerate(reader.pages):
        writer = PdfWriter()
        writer.add_page(page)
        out = os.path.join(args.out_dir, f"{base}-p{i}.pdf")
        with open(out, "wb") as fh:
            writer.write(fh)
        print(f"wrote {out} pages=1")
    print(f"split {args.input} into {len(reader.pages)} files")
    return 0


def rotate(args):
    if args.angle % 90 != 0:
        print("angle must be a multiple of 90", file=sys.stderr)
        return 2
    reader = PdfReader(args.input)
    if not 0 <= args.page < len(reader.pages):
        print(f"page {args.page} out of range 0..{len(reader.pages) - 1}", file=sys.stderr)
        return 2
    writer = PdfWriter()
    for i, page in enumerate(reader.pages):
        if i == args.page:
            page.rotate(args.angle)
        writer.add_page(page)
    with open(args.out, "wb") as fh:
        writer.write(fh)
    print(f"wrote {args.out} pages={len(reader.pages)} rotated_page={args.page} angle={args.angle}")
    return 0


def reorder(args):
    order = [int(x) for x in args.order.split(",") if x.strip() != ""]
    reader = PdfReader(args.input)
    if sorted(order) != list(range(len(reader.pages))):
        print(f"order must be a permutation of 0..{len(reader.pages) - 1}", file=sys.stderr)
        return 2
    writer = PdfWriter()
    for idx in order:
        writer.add_page(reader.pages[idx])
    with open(args.out, "wb") as fh:
        writer.write(fh)
    print(f"wrote {args.out} pages={len(order)} order={order}")
    return 0


def extract_text(args):
    reader = PdfReader(args.input)
    for i, page in enumerate(reader.pages):
        text = page.extract_text() or ""
        print(f"--- page {i} ---")
        print(text)
    return 0


def info(args):
    reader = PdfReader(args.input)
    print(f"file={args.input} pages={len(reader.pages)} encrypted={reader.is_encrypted}")
    for i, page in enumerate(reader.pages):
        print(f"page {i}: rotation={page.rotation} mediabox={list(page.mediabox)}")
    fields = reader.get_fields()
    print(f"form_fields={sorted(fields.keys()) if fields else []}")
    return 0


def compress(args):
    reader = PdfReader(args.input)
    writer = PdfWriter()
    for page in reader.pages:
        added = writer.add_page(page)
        added.compress_content_streams()
    with open(args.out, "wb") as fh:
        writer.write(fh)
    print(f"in_bytes={os.path.getsize(args.input)} out_bytes={os.path.getsize(args.out)}")
    print(f"wrote {args.out} pages={len(reader.pages)}")
    return 0


def main():
    ap = argparse.ArgumentParser()
    sub = ap.add_subparsers(dest="cmd", required=True)

    p = sub.add_parser("make-fixture")
    p.add_argument("out")
    p.add_argument("texts", nargs="+")
    p.set_defaults(fn=make_fixture)

    p = sub.add_parser("merge")
    p.add_argument("out")
    p.add_argument("inputs", nargs="+")
    p.set_defaults(fn=merge)

    p = sub.add_parser("split")
    p.add_argument("input")
    p.add_argument("out_dir")
    p.set_defaults(fn=split)

    p = sub.add_parser("rotate")
    p.add_argument("input")
    p.add_argument("out")
    p.add_argument("--page", type=int, default=0)
    p.add_argument("--angle", type=int, default=90)
    p.set_defaults(fn=rotate)

    p = sub.add_parser("reorder")
    p.add_argument("input")
    p.add_argument("out")
    p.add_argument("--order", required=True, help="comma list, 0-based, e.g. 2,0,1")
    p.set_defaults(fn=reorder)

    p = sub.add_parser("extract-text")
    p.add_argument("input")
    p.set_defaults(fn=extract_text)

    p = sub.add_parser("info")
    p.add_argument("input")
    p.set_defaults(fn=info)

    p = sub.add_parser("compress")
    p.add_argument("input")
    p.add_argument("out")
    p.set_defaults(fn=compress)

    args = ap.parse_args()
    return args.fn(args)


if __name__ == "__main__":
    sys.exit(main())
```

Install and smoke test:

    pip install pypdf pdfplumber
    python3 pdf_tool.py make-fixture a.pdf "Alpha page one" "Bravo page two"
    python3 pdf_tool.py make-fixture b.pdf "Charlie page three"
    python3 pdf_tool.py merge merged.pdf a.pdf b.pdf
    python3 pdf_tool.py extract-text merged.pdf

Real output from the drafting run, pypdf 6.20.1: the fixtures wrote 2 pages and 1 page, merge wrote merged.pdf with 3 pages, and extract-text printed Alpha page one, Bravo page two, and Charlie page three, one per page, in order. Split of merged.pdf produced 3 files with 1 page each. Rotate with page 0 and angle 90 produced a file where info reported page 0 rotation=90 and pages 1 and 2 at rotation=0. Reorder with order 2,0,1 produced a file whose extracted text ran Charlie page three, Alpha page one, Bravo page two.

4. Write to a new file, never in place. Every command in the tool takes an explicit output path separate from the input. Keep the input untouched until the output passes the checks in step 9. A failed run then costs a rerun, not a lost original.

5. Extract tables with pdfplumber when the job needs rows and columns. pypdf text extraction returns a text stream; it does not reconstruct a table grid.

```python
import pdfplumber

with pdfplumber.open("input.pdf") as pdf:
    for i, page in enumerate(pdf.pages):
        print(f"--- page {i} ---")
        print(page.extract_text() or "")
        for table in page.extract_tables():
            for row in table:
                print(row)
```

On the drafting fixture this printed the same three page texts as pypdf and returned an empty table list for every page, because the fixture pages hold a single line of text with no table structure. On real documents, check the first table by eye against the page: pdfplumber infers cell boundaries from lines and text positions, so borderless tables, merged cells, and multi-line cell text can come back split or joined wrong. Report the page and the mismatch instead of silently cleaning it up.

6. Watermark by overlaying a stamp page with `merge_page`. The stamp is a one-page PDF that holds the mark. pypdf merges the stamp content onto each target page; page count does not change.

```python
from pypdf import PdfReader, PdfWriter

stamp = PdfReader("stamp.pdf").pages[0]
reader = PdfReader("input.pdf")
writer = PdfWriter()
for page in reader.pages:
    page.merge_page(stamp)
    writer.add_page(page)
with open("watermarked.pdf", "wb") as fh:
    writer.write(fh)
```

In the drafting run, overlaying the one-page fixture b.pdf onto both pages of a.pdf produced a 2-page file whose extracted text combined both strings per page (page 0 read "Alpha page oneCharlie page three"). That confirms the overlay lands on every page. For a placed mark instead of a full-page overlay, position and size the stamp page before merging, or build the stamp at the target page size with the mark in the right spot. pypdf does not draw new text by itself; creating a text stamp needs a content stream (the make-fixture code in step 3 shows the pattern) or a stamp PDF produced elsewhere.

7. Fill forms only when fields exist. Run `info` first. If form_fields prints an empty list, the PDF has no AcroForm fields to fill; the values would have nowhere to go, and the usual cause is a form that was printed to PDF or built as flat layout. When fields do exist, load the file with `writer.append(reader)`, then call `writer.update_page_form_field_values(page, {field_name: value})` for the pages that hold the fields, then write to a new file. Both method names were confirmed by introspection at drafting time; the drafting fixture had no fields, so the fill path itself was not exercised end to end. Test the fill on a copy, reopen the output, and read the field values back with `reader.get_fields()` before delivering it.

8. Compress with expectations set correctly. The compress command calls `compress_content_streams()` on each page after it is added to the writer. Calling it on a reader page before adding fails with "Page must be part of a PdfWriter"; the order in the tool is deliberate. This compresses content streams and can drop duplicate objects on write. It does not recompress embedded images, which are usually most of a large PDF file size. On the tiny drafting fixture the output grew, from 1284 bytes in to 1365 bytes out, because a 3-page text file has almost nothing to save. On image-heavy files expect little change from pypdf alone and say so; image recompression needs a different tool and a quality decision.

9. Verify the output before delivering it. Reopen the output and check:
   - Page count matches the arithmetic of the job: merge sums the inputs, split produces one file per page, rotate and reorder keep the count.
   - Extracted text on the first and last pages matches the expected source pages, in the expected order.
   - Rotation values from `info` match the request on the pages that were meant to change, and only those pages.
   - For a form fill, the read-back field values match the values supplied.
   - File size is recorded for compress jobs, with the before and after numbers stated plainly, including when the output is larger.

10. Handle encrypted and damaged files explicitly. If `info` reports encrypted=True, you need the password before any edit; pypdf can open a password-protected file with `reader.decrypt(password)` when the password is known. If PdfReader raises on open, the file is damaged or not a PDF at all. Report the error text and the file name, and stop. Do not loop through repair guesses on a file the user needs intact.

## Output Contract

A PDF job delivered with this skill includes:

- The output file path, and the input file paths it came from, with the input files left unchanged.
- The operation performed, stated as verb, pages, and settings: for example, merged 2 files into 3 pages, rotated page 0 by 90 degrees, reordered to 2,0,1.
- Verification numbers from the reopened output: page count, rotation per changed page, and extracted text samples from the first and last pages.
- For extraction jobs, the text or tables in the requested format, with any page that returned empty text flagged as a likely scan that needs OCR.
- For compress jobs, the input and output byte counts, both numbers, even when the output is larger.
- For form jobs, the field names found, the values written, and the read-back result.

## Operating Rules

1. Never edit a PDF in place. Write a new output file and keep the input until the output verifies.
2. Page numbers in pypdf and in the tool are 0-based. Confirm the target page by extracted text before rotating, removing, or overlaying it, because a 1-based instruction from a person maps to a different page in code.
3. pypdf and pdfplumber do not do OCR. Empty extracted text on a page with visible words means a scanned image; route it to OCR instead of reporting the page as blank.
4. Table extraction is layout dependent. Check at least one extracted table against the visual page and report mismatches; do not present inferred cell boundaries as exact.
5. Form filling depends on AcroForm fields being present in the file. Check `reader.get_fields()` first; an empty result means there is nothing to fill, whatever the page looks like.
6. Compression through pypdf covers content streams and duplicate objects, not image recompression. State the before and after sizes and do not promise a smaller file.
7. Rotation angles must be a multiple of 90. A request for an arbitrary angle is a page rendering job, not a metadata rotation, and it does not belong in this tool.
8. Verify every output by reopening it. A writer that returned without an exception can still produce a file with pages in the wrong order.
9. Quote library versions you actually installed. API names in this area change between major versions; introspect or check the installed package docs before using a method this skill does not list.
10. Keep batches small. If the job grows to hundreds of files, folder watching, OCR, or redaction, it has crossed into document-processing-pipeline territory; say so and hand it over instead of looping this tool over a directory tree.
