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.
- name
pdf-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.
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)
| Task | Default tool | Why |
|---|---|---|
| Inspect, merge, split, rotate, encrypt, fill forms | pypdf | Pure Python, stable API |
| Text with layout, words with coordinates, tables | pdfplumber | Character-level geometry |
| Fast text across thousands of pages, render pages to PNG, search | PyMuPDF (import pymupdf) | C speed; AGPL license — check before shipping in a product |
| Repair, linearize, lossless page surgery from shell | qpdf | Repairs and handles malformed files |
| Create new PDFs | reportlab (platypus for flowing documents) | Mature, precise layout |
| Scanned pages with no text | OCR — see ocr-scans | Text extraction returns nothing |
| Remove sensitive content | See document-redaction | Drawing 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 usepage.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 withunicodedata.normalize("NFKC", text)and tune pdfplumberx_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 like | Settings |
|---|---|
| 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
/Yesor/On) — read it from the field’s/_States_or appearance dictionary;Truedoes not work. - Pass
flatten=Truetoupdate_page_form_field_valueswhen 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. Paragraphtakes 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. /Rotateon 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.pdfoften 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
#!/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.