PDF Processing skill for Codex
Get clean text and tables out of any PDF. Codex classifies the PDF before touching it (born-digital, scanned, form, encrypted), then picks pdfplumber for text and tables or pypdf and qpdf for lossless page surgery. Table extraction is tuned against a rendered debug overlay instead of guessed, and results are checked against totals printed in the document.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- pdf-processing Installs to ~/.agents/skills/pdf-processing/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install pdf-processing The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project pdf-processing Installs to .agents/skills/pdf-processing/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/pdf-processing.zipunzip -o pdf-processing.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) pdf-processing PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $pdf-processing, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Reads and restructures existing PDFs with pypdf, pdfplumber and qpdf - extracts text in reading order, pulls tables into CSV or DataFrames, merges, splits, rotates, decrypts and reorders pages without losing quality. Use when the user hands over a PDF and asks to get text or tables out of it, combine or split files, fix sideways pages, pull specific page ranges, or batch-process a folder of PDFs.
Use it when
- You need text or tables out of a born-digital PDF
- Files must be merged, split, reordered or rotated without quality loss
- A folder of PDFs needs the same extraction run in batch
- A PDF opens with an owner password and you have the right to process it
Use something else when
- Pages are scanned images with no text layer: use ocr-documents
- You need to fill or read form field values: use pdf-forms
- You are producing a new PDF from data or HTML: use pdf-generation
How to check the result
Run qpdf --check on outputs, confirm page counts, and compare a table total or three random pages of extracted text against a rendered PNG of the same page.
The mistake it prevents
Using PdfWriter() with add_page to edit a file silently drops its bookmarks and form fields; clone the reader instead.
Done well means: the output text or table matches what a human sees on the page, page operations are lossless (no re-rendering, no rasterising), and every claim about the result was checked against the file, not assumed from the code.
First, classify the PDF
Every decision depends on what kind of PDF it is. Inspect before writing any pipeline:
qpdf --check in.pdf # structural damage, encryption
qpdf --show-npages in.pdf
pdffonts in.pdf | head # poppler; empty list => no text layer
pdftotext -f 1 -l 1 -layout in.pdf - | head -40
from pypdf import PdfReader
r = PdfReader("in.pdf")
print("pages:", len(r.pages), "encrypted:", r.is_encrypted)
print("meta:", r.metadata)
print("form fields:", bool(r.get_fields()))
sample = r.pages[0].extract_text() or ""
print("chars on p1:", len(sample.strip()))
| Finding | Meaning | Route |
|---|---|---|
| Text extracts, fonts listed | Born-digital PDF | Continue here |
| No text, no fonts, pages are images | Scanned | Hand off to $ocr-documents first |
Text is garbled ((cid:12), mojibake) | Font without ToUnicode map | Try pdftotext; if still bad, OCR the page |
get_fields() non-empty | AcroForm | Field values live in the form, not the text: use $pdf-forms |
is_encrypted true | Needs password or only owner-locked | r.decrypt("") first; ask the user for a password otherwise |
Never try to remove a password the user does not own or was not given. Owner-locked files
(empty user password) open with decrypt(""); that is normal, not a bypass.
Tool choice
| Task | Default | Why |
|---|---|---|
| Merge, split, rotate, reorder, decrypt | pypdf in code, qpdf on the CLI | Lossless object copy, keeps fonts and links |
| Plain text, reading order | pdfplumber or pdftotext -layout | Better spacing than pypdf’s plain mode |
| Tables | pdfplumber extract_tables with tuned settings | Explicit control over line vs text strategies |
| Word coordinates, cropping regions | pdfplumber extract_words, page.crop | Gives x0/top/x1/bottom per word |
| Render a page to PNG (to look at it) | pdfplumber page.to_image() or pdftoppm -r 150 | Uses pypdfium2 / poppler |
Install when missing: pip install pypdf pdfplumber (needs network; in a Codex sandbox with
network off, ask for approval or check whether the project already vendors them). Read
AGENTS.md for the project’s package manager and use it (uv, poetry, pip-tools).
Workflow
- [ ] 1. Classify the PDF (table above) and note page count
- [ ] 2. Render 1-2 representative pages to PNG and look at them
- [ ] 3. Run the operation on a small page range first
- [ ] 4. Run on the full file / folder, streaming page by page
- [ ] 5. Verify output (counts, spot checks, qpdf --check)
- [ ] 6. Report what was produced and any pages that need human review
Text extraction
import pdfplumber
with pdfplumber.open("in.pdf") as pdf:
for i, page in enumerate(pdf.pages, start=1):
text = page.extract_text(x_tolerance=1.5, y_tolerance=3) or ""
print(f"--- page {i} ---\n{text}")
- Multi-column layouts: crop each column and extract separately, otherwise lines from
both columns interleave.
left = page.crop((0, 0, page.width / 2, page.height)). - Headers and footers: crop them out by y-coordinate rather than regex-deleting lines
afterwards (
page.crop((0, 60, page.width, page.height - 50))). - pypdf alternative with column-preserving spacing:
page.extract_text(extraction_mode="layout"). - Hyphenated line breaks (
infor-\nmation) and ligatures (fi) need a post-pass:unicodedata.normalize("NFKC", text)fixes ligatures.
Table extraction
Start by drawing what pdfplumber detects; tune settings until the overlay matches.
import csv
import pdfplumber
settings = {
"vertical_strategy": "lines", # ruled tables; use "text" for whitespace-aligned
"horizontal_strategy": "lines",
"snap_tolerance": 3,
"intersection_tolerance": 5,
}
with pdfplumber.open("statement.pdf") as pdf:
page = pdf.pages[2]
page.to_image(resolution=120).debug_tablefinder(settings).save("p3-tables.png")
rows = []
for table in page.extract_tables(settings):
rows.extend(table)
with open("p3.csv", "w", newline="", encoding="utf-8") as f:
csv.writer(f).writerows(rows)
| Symptom | Fix |
|---|---|
| No tables found on a table without ruling lines | vertical_strategy and horizontal_strategy = "text" |
| Cells merged across columns | Lower snap_tolerance; add explicit_vertical_lines=[x1, x2, ...] from word x0 values |
| A table spans pages | Extract per page, drop repeated header rows, then concatenate |
| Wrapped cell text split into two rows | Merge rows whose first column is None or empty into the previous row |
Numbers as strings with 1,234.50 or (45.00) | Normalise after extraction; parentheses mean negative in finance tables |
Page operations (lossless)
from pypdf import PdfReader, PdfWriter
# Merge whole files, or ranges: pages=(start, stop) is zero-based, stop exclusive
w = PdfWriter()
w.append("cover.pdf")
w.append("report.pdf", pages=(0, 10))
w.append("appendix.pdf", outline_item="Appendix") # adds a bookmark
w.write("combined.pdf")
# Split into single pages
r = PdfReader("combined.pdf")
for n, page in enumerate(r.pages, start=1):
single = PdfWriter()
single.add_page(page)
single.write(f"page-{n:03d}.pdf")
# Rotate pages 2 and 5 clockwise (multiples of 90 only) and reorder
r = PdfReader("scan.pdf")
w = PdfWriter(clone_from=r)
for idx in (1, 4):
w.pages[idx].rotate(90)
w.write("fixed.pdf")
Equivalent qpdf commands, preferred for one-off CLI jobs and very large files:
qpdf --empty --pages a.pdf b.pdf 1-3 -- merged.pdf
qpdf in.pdf --pages . 5-1 -- reversed.pdf # reverse pages 1-5
qpdf in.pdf --rotate=+90:2,5 out.pdf
qpdf --split-pages=10 in.pdf chunk-%d.pdf # 10 pages per file
qpdf --decrypt --password='given-by-user' in.pdf plain.pdf
Bookmark editing, compression and linearisation belong to $pdf-merge-split.
Batch processing
- Process page by page and close files (
with pdfplumber.open(...)); pdfplumber caches layout objects per page, so callpage.flush_cache()(orpage.close()) in long loops. - One bad file must not kill the batch: catch
pypdf.errors.PdfReadErrorper file, log the filename, continue, and list failures in the final report. - Write outputs to a separate folder; never overwrite inputs in place.
Gotchas
PdfWriter()plusadd_pagedrops document-level things (outline, named destinations, form fields). UsePdfWriter(clone_from=reader)when editing a single file.- Rotation via
/Rotateis a viewer instruction; text extraction coordinates stay in the unrotated space. Extract first, or account forpage.rotation. extract_text()returnsNone-ish or empty on image-only pages; check length per page and flag those pages instead of silently emitting blanks.- Page labels (i, ii, 1, 2) differ from physical indexes. Confirm which one the user means
by “page 3”;
r.page_labelsshows the mapping. - Merging files with identically named form fields makes them share values. Flatten or
rename fields first (
$pdf-forms). - Incremental-update PDFs can contain old, “deleted” content. Visible text removal is not
redaction: use
$document-redaction.
Verification
qpdf --check out.pdf && qpdf --show-npages out.pdf
pdftoppm -f 1 -l 1 -r 80 -png out.pdf preview # look at the first page
- Page count of the output equals the sum you expected.
- For extraction: compare 3 random pages of extracted text against a rendered PNG.
- For tables: row count and a column total match a figure printed in the PDF.
- If a check fails, re-render the failing page with
debug_tablefinderand adjust settings rather than patching the output by hand.
Report template
**Input:** statement-2025-09.pdf (14 pages, born-digital, not encrypted)
**Output:** tables/statement.csv (412 rows), text/statement.txt
**Method:** pdfplumber 0.11, lines strategy, header row de-duplicated across pages 3-12
**Checks:** closing balance in CSV (18,204.55) equals PDF page 14; qpdf --check clean
**Needs review:** page 7 is a scanned insert with no text layer (route to OCR) What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "PDF Processing"
short_description: "Get clean text and tables out of any PDF"
default_prompt: "$pdf-processing Pull every table out of these bank statement PDFs into one CSV" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.