PDF Toolkit skill for Claude Code

Inspect first, then extract, merge, fill or generate. Claude inspects every PDF before touching it, checking for a text layer, encryption, form fields and XFA, then picks the right library for the job. Tables come out as clean CSV rows with totals checked, merges keep outlines, forms get filled using their exact field names, and new documents are built with reportlab. Every output is reopened and verified rather than assumed.

  • namepdf-toolkit
  • size210 lines · ≈2,200 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Reads, extracts, and manipulates PDF files in Python — inspection of text layers, forms, and encryption; text and table extraction with pdfplumber; merging, splitting, rotating, and encrypting with pypdf or qpdf; filling AcroForm fields; and generating new PDFs with reportlab. Use when the user shares a .pdf to read or summarize, wants tables pulled into CSV, needs PDFs merged, split, compressed, or password-protected, has a fillable form to complete, or wants a report or invoice generated as PDF.

Try asking

  • Pull the transaction table out of these bank statement PDFs into one CSV
  • Merge these five PDFs in order and password-protect the result
  • Fill this fillable PDF form with the applicant data from applicants.json

Use it when

  • A PDF needs to be read, summarized, or searched
  • Tables inside PDFs must become CSV or spreadsheet rows
  • PDFs need merging, splitting, rotating, or encrypting
  • A fillable form must be completed or a PDF report generated

Skip it when

  • Pages are scanned images without a text layer — use ocr-scans instead
  • Sensitive content must be removed for release — use document-redaction instead

How to check the result

Re-run the inspector on each output to confirm page count, encryption and form values, render a page to PNG for a visual check, and compare extracted totals with the printed ones.

The common mistake it prevents

Assuming text extraction works on every page, when scanned pages silently return empty strings and the summary or CSV quietly misses half the document.

~/.claude/skills/pdf-toolkit/SKILL.md

--- name: pdf-toolkit description: Reads, extracts, and manipulates PDF files in Python — inspection of text layers, forms, and encryption; text and table extraction with… license: MIT ---

Done well means: you inspected the file before choosing a tool, extracted content with its structure intact (tables as rows, reading order preserved), wrote output that opens cleanly in Acrobat, Preview, and browsers, and verified the result by re-reading it rather than assuming the write succeeded.

Setup

pip install "pypdf>=5" pdfplumber reportlab     # core trio
pip install pymupdf                             # optional: fast rendering, search, true redaction
brew install qpdf poppler                       # optional CLIs (apt: qpdf poppler-utils)
TaskDefault toolWhy
Inspect, merge, split, rotate, encrypt, fill formspypdfPure Python, stable API
Text with layout, words with coordinates, tablespdfplumberCharacter-level geometry
Fast text across thousands of pages, render pages to PNG, searchPyMuPDF (import pymupdf)C speed; AGPL license — check before shipping in a product
Repair, linearize, lossless page surgery from shellqpdfRepairs and handles malformed files
Create new PDFsreportlab (platypus for flowing documents)Mature, precise layout
Scanned pages with no textOCR — see ocr-scansText extraction returns nothing
Remove sensitive contentSee document-redactionDrawing boxes does not remove text

Step 1: always inspect first

python3 scripts/pdf_inspect.py input.pdf
python3 scripts/pdf_inspect.py input.pdf --password "$PDF_PASSWORD" --json > info.json

It reports page count, PDF version, encryption, metadata, which pages lack a text layer (scanned or blank — route to OCR), AcroForm/XFA fields with current values, attachments, document JavaScript, page sizes, and rotation. Exits 1 if the file cannot be opened or decrypted. Decide the approach from this output, not from the file name.

Extract text

import pdfplumber

with pdfplumber.open("input.pdf") as pdf:
    for page in pdf.pages:
        text = page.extract_text(layout=False) or ""
        print(f"--- page {page.page_number} ---\n{text}")
  • Multi-column layouts: crop each column (page.crop((x0, top, x1, bottom))) and extract separately, or use page.extract_words() and sort by (top, x0) within columns.
  • Headers/footers: crop them out by y-coordinate rather than regex-deleting lines.
  • Quick shell alternative: pdftotext -layout input.pdf out.txt (poppler).
  • Ligatures and odd spacing (fi, T h e) come from the PDF’s font encoding; normalize with unicodedata.normalize("NFKC", text) and tune pdfplumber x_tolerance.
  • Empty text on a page that visibly has text means it is an image: OCR it.

Extract tables

import csv, pdfplumber

settings = {"vertical_strategy": "lines", "horizontal_strategy": "lines"}
with pdfplumber.open("statement.pdf") as pdf, open("rows.csv", "w", newline="") as fh:
    out = csv.writer(fh)
    for page in pdf.pages:
        for table in page.extract_tables(table_settings=settings):
            for row in table:
                out.writerow([(c or "").strip() for c in row])
Table looks likeSettings
Ruled grid lines"lines" / "lines" (default)
No lines, aligned columns"text" / "text", tune "snap_tolerance", "join_tolerance"
Mixed"explicit_vertical_lines": [x positions] from page.extract_words() analysis

Debug visually: page.to_image(resolution=100).debug_tablefinder(settings).save("dbg.png") and view the PNG. Tables that span pages repeat headers — drop duplicate header rows after the first page. Validate totals (sum the amount column and compare with the printed total).

Merge, split, rotate, reorder

from pypdf import PdfReader, PdfWriter

# Merge
w = PdfWriter()
for path in ["cover.pdf", "report.pdf", "appendix.pdf"]:
    w.append(path)                       # keeps outlines; pages=(start, stop) to take a range
w.write("combined.pdf")

# Split into single pages
r = PdfReader("combined.pdf")
for i, page in enumerate(r.pages, start=1):
    one = PdfWriter(); one.add_page(page); one.write(f"page-{i:03}.pdf")

# Extract a range and rotate
w = PdfWriter()
for page in r.pages[4:10]:
    w.add_page(page)
w.pages[0].rotate(90)                    # multiples of 90, clockwise
w.write("excerpt.pdf")

PdfMerger was removed in pypdf 5; use PdfWriter.append. qpdf equivalents:

qpdf --empty --pages cover.pdf report.pdf appendix.pdf -- combined.pdf
qpdf combined.pdf --pages . 5-10 -- excerpt.pdf
qpdf --check input.pdf                       # structural validation
qpdf input.pdf --linearize web.pdf           # fast web view

Encrypt, decrypt, metadata

w = PdfWriter(clone_from="report.pdf")
w.add_metadata({"/Title": "Q3 Report", "/Author": "Finance"})
w.encrypt(user_password="open-me", owner_password="owner-secret", algorithm="AES-256")
w.write("report-protected.pdf")

Use AES-256; RC4 options exist only for legacy readers. Owner-password “permissions” (no print/copy) are advisory — readers can ignore them. Removing a password you were given: qpdf --password=XXX --decrypt in.pdf out.pdf. Never attempt to crack passwords on files the user is not entitled to open.

To strip metadata, write a new file without copying /Info and XMP, then check with the inspector; remember that document-level metadata is separate from text content.

Fill forms

from pypdf import PdfReader, PdfWriter

r = PdfReader("form.pdf")
print({k: v.get("/FT") for k, v in (r.get_fields() or {}).items()})   # discover exact names

w = PdfWriter(clone_from=r)
w.set_need_appearances_writer(True)          # ask viewers to render field appearances
for page in w.pages:
    w.update_page_form_field_values(page, {"applicant_name": "Jane Doe", "date": "2026-03-02"},
                                    auto_regenerate=False)
w.write("form-filled.pdf")
  • Field names must match exactly, including hierarchy (parent.child).
  • Checkboxes take their export value (often /Yes or /On) — read it from the field’s /_States_ or appearance dictionary; True does not work.
  • Pass flatten=True to update_page_form_field_values when the output must not be editable, then verify values are visible in a viewer without form support.
  • XFA forms (flagged by the inspector) are not supported by pypdf; they need Adobe tools or conversion. Tell the user rather than producing a silently blank form.

Create PDFs (reportlab platypus)

from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle

styles = getSampleStyleSheet()
doc = SimpleDocTemplate("invoice.pdf", pagesize=A4, leftMargin=18*mm, rightMargin=18*mm,
                        title="Invoice 1043", author="Acme Ltd")
rows = [["Item", "Qty", "Amount"], ["Consulting", "10", "1,200.00"], ["", "Total", "1,200.00"]]
table = Table(rows, colWidths=[100*mm, 25*mm, 35*mm], repeatRows=1)
table.setStyle(TableStyle([
    ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f0f0f0")),
    ("ALIGN", (1, 1), (-1, -1), "RIGHT"),
    ("LINEABOVE", (0, -1), (-1, -1), 0.75, colors.black),
]))
doc.build([Paragraph("Invoice 1043", styles["Title"]), Spacer(1, 6*mm), table])
  • Built-in fonts (Helvetica, Times) cover Latin-1 only. For other scripts register a TTF: pdfmetrics.registerFont(TTFont("NotoSans", "NotoSans-Regular.ttf")) and use it in styles.
  • Paragraph takes a mini-markup; escape &, <, > in user data (xml.sax.saxutils.escape).
  • For HTML/CSS-driven designs, rendering HTML with a headless browser or WeasyPrint may be simpler; for Markdown sources see markdown-publishing.

Gotchas

  • Extraction order is the content-stream order, not visual order; always eyeball a page.
  • Page indices are 0-based in pypdf, 1-based in qpdf and pdfplumber’s page_number.
  • /Rotate on a page changes display, not coordinates; pdfplumber coordinates follow the unrotated page.
  • Merging PDFs with identically named form fields makes them share values; rename fields or flatten before merging.
  • Large files: iterate pages and write incrementally; avoid holding every page’s text in memory for 10k-page documents.
  • Malformed PDFs: qpdf input.pdf repaired.pdf often fixes cross-reference errors that make pypdf fail.

Verification

  • Re-open every output with scripts/pdf_inspect.py: expected page count, encryption state, metadata, and form values.
  • Spot-check visually: render a page (pdftoppm -png -r 80 -f 1 -l 1 out.pdf preview) and view it.
  • For extraction, compare a sample against the visible page and check numeric totals; report pages that needed OCR or produced empty output.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/pdf_inspect.py 119 lines · 4.4 KB
#!/usr/bin/env python3
"""Inspect a PDF before working on it: pages, encryption, metadata, text layer,
form fields, images, attachments, and active content.

Requires: pip install "pypdf>=5"
Usage:
  python3 pdf_inspect.py file.pdf
  python3 pdf_inspect.py file.pdf --password secret --json

Exit codes: 0 = inspected, 1 = could not open/decrypt, 2 = missing dependency.
"""
import argparse
import json
import sys

try:
    from pypdf import PdfReader
    from pypdf.errors import PdfReadError
except ImportError:
    print("error: pypdf is required: pip install 'pypdf>=5'", file=sys.stderr)
    sys.exit(2)

SCANNED_THRESHOLD = 25  # characters of extractable text per page below which a page is "image-only"


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("pdf")
    ap.add_argument("--password", default=None)
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    try:
        reader = PdfReader(args.pdf)
        encrypted = reader.is_encrypted
        if encrypted:
            if reader.decrypt(args.password or "") == 0:
                print("error: encrypted and the password is missing or wrong (use --password)", file=sys.stderr)
                return 1
    except (PdfReadError, OSError, ValueError) as exc:
        print(f"error: cannot open {args.pdf}: {exc}", file=sys.stderr)
        return 1

    meta = {k.lstrip("/"): str(v) for k, v in (reader.metadata or {}).items()}
    pages = []
    for i, page in enumerate(reader.pages, start=1):
        try:
            text = page.extract_text() or ""
        except Exception:  # noqa: BLE001 - malformed content streams
            text = ""
        try:
            n_images = len(page.images)
        except Exception:  # noqa: BLE001
            n_images = -1
        box = page.mediabox
        pages.append({
            "page": i,
            "chars": len(text.strip()),
            "images": n_images,
            "size_pt": [round(float(box.width)), round(float(box.height))],
            "rotation": page.rotation,
            "annotations": len(page.get("/Annots") or []),
        })

    fields = reader.get_fields() or {}
    root = reader.trailer["/Root"]
    names = root.get("/Names") or {}
    report = {
        "file": args.pdf,
        "pdf_version": reader.pdf_header,
        "pages": len(reader.pages),
        "encrypted": encrypted,
        "metadata": meta,
        "text_layer": {
            "pages_with_text": sum(p["chars"] >= SCANNED_THRESHOLD for p in pages),
            "image_only_pages": [p["page"] for p in pages if p["chars"] < SCANNED_THRESHOLD],
        },
        "form": {
            "acroform": "/AcroForm" in root,
            "xfa": "/AcroForm" in root and "/XFA" in root["/AcroForm"],
            "fields": {name: {"type": str(f.get("/FT")), "value": str(f.get("/V"))} for name, f in fields.items()},
        },
        "attachments": list(reader.attachments.keys()),
        "javascript": "/JavaScript" in names or "/OpenAction" in root,
        "outline_entries": len(reader.outline) if reader.outline else 0,
        "page_details": pages,
    }

    if args.json:
        print(json.dumps(report, indent=2, default=str))
        return 0

    print(f"{report['file']}  ({report['pdf_version']}, {report['pages']} pages, encrypted={encrypted})")
    for k, v in meta.items():
        print(f"  meta {k}: {v}")
    tl = report["text_layer"]
    print(f"  text layer: {tl['pages_with_text']}/{report['pages']} pages have extractable text")
    if tl["image_only_pages"]:
        shown = tl["image_only_pages"][:20]
        more = "..." if len(tl["image_only_pages"]) > 20 else ""
        print(f"  pages without a text layer (blank, or scanned and needing OCR): {shown}{more}")
    form = report["form"]
    print(f"  form: acroform={form['acroform']} xfa={form['xfa']} fields={len(form['fields'])}")
    for name, f in list(form["fields"].items())[:30]:
        print(f"    - {name} [{f['type']}] = {f['value']}")
    if report["attachments"]:
        print(f"  attachments: {report['attachments']}")
    if report["javascript"]:
        print("  active content: document JavaScript or OpenAction present")
    sizes = {tuple(p["size_pt"]) for p in pages}
    print(f"  page sizes (pt): {sorted(sizes)[:5]}")
    rotated = [p["page"] for p in pages if p["rotation"]]
    if rotated:
        print(f"  rotated pages: {rotated[:20]}")
    return 0


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

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc