# PDF

> Read, extract (text/tables), create, merge/split/rotate, watermark, encrypt, fill, and render-to-image .pdf files. Use whenever the user uploads a .pdf or asks to produce, edit, or pull data out of one.

- Skill: `hkuds/pdf` (Agent Skill)
- Install (CLI): `npx skillmds@latest add hkuds/pdf`
- Raw SKILL.md: https://api.skillmd.com/api/skills/hkuds/pdf/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: HKUDS (https://skillmd.com/u/hkuds)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/hkuds/pdf

---


# PDF

Work PDFs in the sandbox with preinstalled Python libs. Pick the library by task:

- **Extract** text/tables/layout/word-coordinates → `pdfplumber`; quick raw text or page ops → `pypdf`.
- **Merge / split / rotate / crop / watermark / encrypt / metadata** → `pypdf`.
- **Fill forms** → `pypdf` (fillable AcroForm fields) or annotation overlay (flat forms).
- **Create from scratch** → `reportlab`.

Use `exec` with complete Python source (`language: python`). Prefer creating,
reopening, and validating the PDF in one call; later calls can revise the same
relative filename. Follow the turn's **User workspace** instructions for
locating inputs, output boundaries, and presenting the finished file.
Preserve an explicitly requested quantity (such as 500 words) and verify the count in the output before finishing. If execution fails or the artifact is missing, diagnose stderr/root cause and change strategy; do not retry identical code or reduce the requested scope without asking.

## Extract text and tables (pdfplumber)

```python
import pdfplumber

with pdfplumber.open("in.pdf") as pdf:
    for i, page in enumerate(pdf.pages, 1):
        print(f"--- page {i} ---")
        print(page.extract_text() or "")  # layout-aware text
        for t in page.extract_tables():  # list of tables; each is list[row]
            for row in t:
                print(row)
```

Tables → Excel (one worksheet per table):
```python
import pdfplumber
from openpyxl import Workbook

workbook = Workbook()
workbook.remove(workbook.active)
table_number = 0
with pdfplumber.open("in.pdf") as pdf:
    for page_number, page in enumerate(pdf.pages, 1):
        for table in page.extract_tables():
            if not table:
                continue
            table_number += 1
            sheet = workbook.create_sheet(f"p{page_number}_table{table_number}"[:31])
            for row in table:
                sheet.append([cell or "" for cell in row])
if table_number:
    workbook.save("tables.xlsx")
```

Messy tables: pass strategies, or crop a region with `page.within_bbox((x0, top, x1, bottom))` first:
```python
ts = {
    "vertical_strategy": "lines",
    "horizontal_strategy": "lines",
    "snap_tolerance": 3,
    "intersection_tolerance": 15,
}
page.extract_tables(ts)
```

For very large PDFs where you only need raw text, `pypdf`'s `page.extract_text()` is lighter.

## Scanned / image-only PDFs (be honest)

If `extract_text()` returns empty or garbage (e.g. `(cid:NN)` runs) the page is scanned. **No OCR engine (tesseract) is installed and network is off**, so you cannot recover that text. Say so plainly and stop — do not fabricate content or attempt `pip install`.

## Merge / split / rotate / crop / metadata (pypdf)

```python
from pypdf import PdfReader, PdfWriter

# Merge
w = PdfWriter()
for f in ["a.pdf", "b.pdf"]:
    for p in PdfReader(f).pages:
        w.add_page(p)
w.write("merged.pdf")

# Split: one file per page
r = PdfReader("in.pdf")
for i, p in enumerate(r.pages, 1):
    w = PdfWriter()
    w.add_page(p)
    w.write(f"page_{i}.pdf")

# Rotate page 0 by 90 degrees clockwise
r = PdfReader("in.pdf")
w = PdfWriter()
r.pages[0].rotate(90)
w.add_page(r.pages[0])
w.write("rotated.pdf")
```

- **Metadata**: `PdfReader("in.pdf").metadata` (`.title`, `.author`, ...).
- **Crop**: set `page.mediabox.left/bottom/right/top` (points, origin y=0 at bottom).
- **Encrypt**: `w = PdfWriter(clone_from=PdfReader("in.pdf")); w.encrypt("userpw", "ownerpw"); w.write("enc.pdf")`.
- **Decrypt**: `r = PdfReader("enc.pdf"); r.decrypt("pw")` if `r.is_encrypted`, then read/copy pages.

Watermark (stamp one page over every page):
```python
from pypdf import PdfReader, PdfWriter

wm = PdfReader("stamp.pdf").pages[0]
r = PdfReader("in.pdf")
w = PdfWriter()
for p in r.pages:
    p.merge_page(wm)
    w.add_page(p)
w.write("stamped.pdf")
```

## Create PDFs (reportlab)

Flowing document (preferred for text/reports/tables — handles pagination):
```python
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib import colors
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle

styles = getSampleStyleSheet()
story = [
    Paragraph("Report Title", styles["Title"]),
    Spacer(1, 12),
    Paragraph("Body text. " * 20, styles["Normal"]),
]
data = [["Product", "Q1", "Q2"], ["Widgets", "120", "135"]]
tbl = Table(data)
tbl.setStyle(
    TableStyle(
        [
            ("BACKGROUND", (0, 0), (-1, 0), colors.grey),
            ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
            ("GRID", (0, 0), (-1, -1), 0.5, colors.black),
        ]
    )
)
story += [Spacer(1, 12), tbl]
SimpleDocTemplate("out.pdf", pagesize=letter).build(story)
```

Absolute placement (labels at fixed coordinates): use `canvas.Canvas("out.pdf", pagesize=letter)`, `c.drawString(x, y, "...")` (origin bottom-left, points), `c.showPage()` per page, `c.save()`.

### Non-Latin text (Chinese / Japanese / Korean, Cyrillic, …)

reportlab's built-in fonts (Helvetica/Times/Courier) carry **zero CJK glyphs**, so any 中文/日本語/한국어 renders as empty boxes (□) baked permanently into the PDF. reportlab never auto-discovers system fonts — you MUST register a font that has the glyphs and set it on every style. **Whenever the document may contain non-Latin text, register a CJK font first** (it also covers Latin, so it is safe to use as the only font):

```python
import os
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont


def register_cjk_font(name="CJK"):
    # TrueType ONLY — reportlab cannot embed CFF/OpenType outlines, so a .otf
    # like Noto Sans CJK fails with "postscript outlines are not supported".
    for path in [
        "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",  # Linux sandbox (fonts-wqy-zenhei)
        "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
        "/System/Library/Fonts/STHeiti Light.ttc",  # macOS
        "/System/Library/Fonts/Hiragino Sans GB.ttc",
        "/System/Library/Fonts/Supplemental/Songti.ttc",
        "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
        "C:/Windows/Fonts/msyh.ttc",  # Windows
    ]:
        if os.path.exists(path):
            try:
                pdfmetrics.registerFont(TTFont(name, path, subfontIndex=0))
                return name
            except Exception:
                continue
    raise RuntimeError("No CJK-capable TrueType font found — do not emit tofu; say so.")


font = register_cjk_font()
styles = getSampleStyleSheet()
for s in styles.byName.values():  # make the CJK font the default everywhere
    s.fontName = font
# Tables don't read the stylesheet — set the font in the TableStyle too:
#   ("FONTNAME", (0, 0), (-1, -1), font)
# Canvas: c.setFont(font, size) before every drawString.
```

If `register_cjk_font` raises (no font on the host), do **not** ship a tofu PDF — tell the user the sandbox lacks a CJK font instead of producing garbage.

Gotcha: even with a good font, reportlab still needs markup for subscripts/superscripts. In `Paragraph` use `Paragraph("H<sub>2</sub>O", styles["Normal"])`, `x<super>2</super>`.

Markdown/HTML → PDF needs an external converter (`soffice`/`pandoc`) that is usually absent — `command -v soffice` / `command -v pandoc` and degrade to building the PDF directly with reportlab if neither is present.

## Fill forms (pypdf)

First detect whether the PDF has real fillable (AcroForm) fields:
```python
from pypdf import PdfReader

fields = PdfReader("form.pdf").get_fields()
print("fillable" if fields else "flat (no fields)")
```

**Fillable** — inspect field names/types, then fill and write:
```python
from pypdf import PdfReader, PdfWriter

r = PdfReader("form.pdf")
for name, f in r.get_fields().items():
    print(name, f.get("/FT"), f.get("/_States_"))  # /Tx text, /Btn checkbox/radio, /Ch choice

w = PdfWriter(clone_from=r)
values = {"first_name": "Bart", "agree": "/Yes"}  # checkbox/radio: use its on-state, NOT True/False
for page in w.pages:
    w.update_page_form_field_values(page, values, auto_regenerate=False)
w.set_need_appearances_writer(True)  # force viewers to render the values
w.write("filled.pdf")
```
Checkbox/radio values are on-state strings, not booleans — read the field's `/_States_` (e.g. `/Yes`, `/On`); `/Off` clears it.

**Flat form (no fields)** — overlay text with `FreeText` annotations at PDF coordinates. Get real coordinates from the layout with pdfplumber instead of guessing:
```python
import pdfplumber

with pdfplumber.open("form.pdf") as pdf:
    pg = pdf.pages[0]
    for wd in pg.extract_words():  # each has x0, top, x1, bottom (TOP-left origin!)
        print(wd["text"], wd["x0"], wd["top"])
    for rc in pg.rects:  # small squares are likely checkboxes
        print("rect", rc["x0"], rc["top"], rc["x1"], rc["bottom"])
```
pdfplumber `top` is measured from the page top; pypdf rects are bottom-left, so convert: `pdf_y = page_height - top`. Place text just right of the matching label:
```python
from pypdf import PdfReader, PdfWriter
from pypdf.annotations import FreeText

r = PdfReader("form.pdf")
w = PdfWriter()
w.append(r)
h = float(r.pages[0].mediabox.height)
top = 700  # pdfplumber 'top' of the label's row
w.add_annotation(
    page_number=0,
    annotation=FreeText(
        text="Smith",
        rect=(255, h - top - 14, 720, h - top),  # (x0, y0, x1, y1)
        font="Helvetica",
        font_size="10pt",
        font_color="000000",
        border_color=None,
        background_color=None,
    ),
)
w.write("filled.pdf")
```
Verify: re-open the output and re-read `get_fields()` values (fillable) or re-extract text (overlay) to confirm the values landed.

## Page → image rendering (PyMuPDF)

`PyMuPDF` (imported as `fitz`, preinstalled) rasterizes pages — useful to inspect a PDF visually or to hand a page to an image-capable step. No external tools needed (poppler / pdf2image are absent; don't reach for them).

```python
import fitz  # PyMuPDF

doc = fitz.open("in.pdf")
for i, page in enumerate(doc, 1):
    page.get_pixmap(dpi=150).save(f"page_{i}.png")  # higher dpi = sharper + larger
```

`fitz` also extracts text (`page.get_text()`) and can render a sub-region via `page.get_pixmap(clip=fitz.Rect(x0, y0, x1, y1))`. It does **not** OCR — a rendered scanned page is still just pixels (see Scanned PDFs above).

