PDF skill
An Apache-2.0 toolkit for reading, composing, transforming, and filling PDF files. Written from scratch on top of permissively-licensed open-source libraries (pypdf, pdfplumber, pypdfium2, reportlab, pdf-lib, qpdf) so this can be embedded in commercial projects without special agreement.
Route the task
Pick the sub-guide by the verb of the request.
| Task | Path | Read |
|---|---|---|
| Pull text / tables / metadata / images out of an existing PDF | Extract | extract.md |
| Combine, carve, rotate, crop, watermark, encrypt, or shrink | Transform | transform.md |
| Build a PDF that doesn't exist yet (report, invoice, certificate) | Compose | compose.md |
| Fill a form (AcroForm or scanned) | Interactive | interactive.md |
| Scanned / image-only PDF (no selectable text) | Extract → OCR | extract.md §5 |
If a task mixes several of these, follow the order: probe → plan → extract or compose → validate.
Every path starts with a probe. scripts/survey.py returns page count,
whether the file is encrypted, whether it has an AcroForm, and whether
page 1 looks like a scan.
First install
DSH environment note: the original text targets MiMo's bundled runtime (
MIMO_PYTHON/MIMO_QPDF). DSH has no such runtime — use the systempythonoruv runinstead. Replace every"$MIMO_PYTHON"withpythonand"$MIMO_QPDF"withqpdf(install qpdf yourself if the scripts need it).
Python-only path (all BSD / MIT / Apache) — covers 95% of tasks:
python3 -m pip install --upgrade pypdf pdfplumber pypdfium2 reportlab Pillow
Add these external binaries only when you actually need them:
# qpdf — merge/split/encrypt/repair, Apache-2.0
brew install qpdf # macOS
apt-get install -y qpdf # Debian / Ubuntu
# Tesseract — OCR for scanned PDFs, Apache-2.0
brew install tesseract
python3 -m pip install pytesseract pdf2image
apt-get install -y tesseract-ocr
# Poppler — pdftotext / pdftoppm / pdfimages, GPL-2.0
# Optional. Only install if you accept a GPL dependency at CLI level.
brew install poppler
apt-get install -y poppler-utils
Every script under scripts/ uses argparse. Exit codes:
0 OK · 1 runtime failure · 2 bad arguments · 3 validation failure
(apply_values.py / overlay_text.py; sanity_check.py reports findings
with exit 1).
Any single script can be lifted into another project — none imports from a
shared framework.
One-command triage
scripts/survey.py path/to/file.pdf --pretty
Sample output:
{
"path": "/abs/path/file.pdf",
"page_count": 12,
"is_locked": false,
"form_field_count": 34,
"looks_scanned": false,
"metadata": {"Title": "...", "Author": "...", "Producer": "..."}
}
Route by the flags:
is_locked: true→ unlock first (qpdf --password=… --decrypt). Almost every reader library refuses locked files.form_field_count > 0→ widgets path ininteractive.md§1.form_field_count == 0AND you need to fill it → overlay path ininteractive.md§2.looks_scanned: true→ skip pypdf text extraction, go straight to OCR (extract.md§5).
Which library for which task
| Task | Preferred | Reason | Fallback |
|---|---|---|---|
| Plain text | pdftotext -layout |
fastest, keeps columns | pypdf |
| Positioned text | pdfplumber |
char-level bboxes | pypdfium2.get_text |
| Tables | pdfplumber |
tunable table_settings |
pandas over manual CSV |
| Page → image | pypdfium2 |
Apache/BSD, no GPL | pdftoppm (GPL) |
| Merge / carve / rotate | pypdf |
pure Python | qpdf --pages (faster on huge files) |
| Encrypt / repair / linearise | qpdf |
handles broken input | pypdf (basic encrypt only) |
| Compose from scratch | reportlab |
mature, BSD | pdf-lib in Node |
| Fill AcroForm | pypdf.update_page_form_field_values |
preserves widget appearances | pdf-lib in Node |
| Overlay on non-fillable | reportlab + pypdf.merge_page |
two-layer merge, see interactive.md | — |
Common gotchas
- PDF origin is bottom-left, image origin is top-left. Every "off by a
few points" bug is one of these two systems misapplied. Coordinate
conversion is in one place:
interactive.md§2.c. pypdf.extract_text()returns nothing for scans. That's not a bug — there's no text stream. Use thelooks_scannedflag and route to OCR.- Unicode subscripts / superscripts render as black rectangles in
reportlab because Helvetica/Times/Courier don't ship those glyphs. Use
<sub>/<super>XML inParagraph, or move the pen manually on canvas. Seecompose.md§5. - CJK text renders as black boxes when the font never registered.
reportlab does not consult the OS font system; a bad font name/path (思源黑体,
PingFang, Noto on machines that lack it) plus a swallowed exception means a
silent Helvetica fallback — and Helvetica has no CJK glyphs. Resolve fonts
with the ladder in
compose.md§4 (resolve_cjk_font()); the terminal fallback is the built-in CID font, never Helvetica. - XFA forms are not AcroForms. If
probe_fields.pyreturns[]on a PDF that clearly has widgets in Adobe Reader, it's XFA — flatten it in Acrobat first. writer.encrypt(pw)in pypdf uses RC4 by default. For real AES-256, passalgorithm="AES-256", or useqpdf --encrypt … 256 --.
What's next
Open the sub-guide from the routing table and work through it end to end. Each sub-guide has a Validation section at the bottom describing how to confirm the result.