# Read Guide — pdfplumber default + vision escalation

The READ route of `pdf`. The **default is the pdfplumber recipe** (§3.1) — text and tables, fast,
offline, no daemon or model call required, and covers the overwhelming majority of "just read this
PDF" requests. **Only escalate to vision** (§3.3) when you actually need visual understanding the
text path can't give you: scanned / image-only PDFs, chart and diagram interpretation, heavy layout
where reading order matters, or when pdfplumber returns `(cid:NNN)` glyphs (no text layer). When
vision is needed, a **vision-capable model (e.g. M3) reads a rendered PNG directly with the Read
tool**; `read_pdf_vision.py` (native Matrix vision) is the fallback for text-only models (e.g. M2.7) — see
§3.3. Other scenarios — coordinates, page rasters, embedded images, decryption, metadata — are
inline cookbook recipes (5–10 lines of Python or a single CLI invocation).

> **For WRITE / FILL / MUTATE routes, see [`../SKILL.md`](../SKILL.md).** This file only covers
> READ.

For the `read_pdf_vision.py` reference (parameters, JSON schema, internal chunking, time budget,
error matrix), see [`vision-guide.md`](vision-guide.md).

---

## 1. Scope and When to Use

This skill covers the everyday PDF reading chores an LLM agent is most often asked to perform:

- **extracting text or tables from any text-native PDF** (papers, reports, prospectuses, manuals,
  court filings — anything generated by Word / LaTeX / Markdown-to-PDF) via the pdfplumber recipe —
  the default route, fast, offline, no daemon required
- **understanding a layout-heavy / chart-heavy / scanned PDF** using native Matrix vision — only
  when the text path is insufficient (scans / `(cid:NNN)` glyphs, charts whose values matter, dense
  multi-column layout where reading order is broken)
- inspecting a PDF (page count, metadata, encryption status, text, tables, embedded images)
- coordinate-aware extraction (character positions, region-clipped text)
- rasterising pages to PNG for visual inspection or pre-staging
- decrypting a password-protected PDF before any of the above
- summarising or quoting from existing PDF content

Use a different approach when:

- the user wants to **generate / style / fill / reformat** a PDF — see [`../SKILL.md`](../SKILL.md)
  for the WRITE routes (CREATE / REFORMAT / FILL)
- the user only wants to read text from a single image (no PDF involved) — call any vision tool
  directly, no PDF library required
- the user wants to **mutate** an existing PDF (merge / split / rotate / crop / encrypt; **annotate
  / sign / replace text via overlay**) — see [`advanced-reference.md`](advanced-reference.md) (qpdf
  / pypdf / pdftk for page-level ops; pypdf + reportlab for annotations, signatures, and
  text-overlay replacement in §5)

---

## 2. Decision Tree

**Do not open the raw `.pdf` with the Read tool.** Read returns binary garbage that wastes context
and tells you nothing useful. Always go through one of the routes below. (This forbids the _raw PDF_
only — reading a page that you have **rendered to PNG** with the Read tool is the preferred vision
path on a vision-capable model; see §3.3.)

```
What does the user want from this PDF?
|
+-- Default: text or tables from any text-native PDF
|       → pdfplumber recipe                                       -> §3.1
|   (fast, offline, no daemon — covers the vast majority of cases)
|
+-- pdfplumber returned `(cid:NNN)` glyphs / empty text
|       → vision route                                            -> §3.3
|   (no text layer; the PDF is scanned or image-only)
|
+-- Charts / diagrams whose values matter, dense visual layout
|       → vision route                                            -> §3.3
|   (vision-capable model reads a rendered PNG directly;
|    read_pdf_vision.py / native Matrix vision is the text-only-model fallback)
|
+-- Coordinate-aware extraction ---------> pdfplumber recipe       -> §3.1
|   (character positions, within_bbox region clip)
|
+-- Page count / metadata / encryption --> pdfinfo                 -> §4.1
|   (one-line probe before the heavier modes)
|
+-- Pull embedded raster images ---------> pdfimages from poppler  -> §4.1
|
+-- Render pages to PNG (no model call) -> pypdfium2 / pdf2image  -> §3.2
|                                          OR scripts/render/page_rasterize.py -> §4.3
|
+-- Decrypt a password-protected PDF ----> pypdf or qpdf           -> §4.2
    (run before any other route)
```

**Default rule.** Reach for the **pdfplumber recipe (§3.1)** first on any PDF. It is the fastest
path, runs offline with no daemon, and covers the overwhelming majority of text-native documents
(papers, reports, prospectuses, manuals, court filings — anything generated by Word / LaTeX /
Markdown-to-PDF). **Escalate to `read_pdf_vision.py` (§3.3) only when the text path is
insufficient**: pdfplumber returns `(cid:NNN)` glyphs or empty strings (scanned / image-only PDF),
the user explicitly asks for chart values or visual layout description, or the document's reading
order is so broken that text extraction gives unusable output. Use `pdfinfo` (§4.1) as a one-line
probe before deciding anything heavy.

**Vision is a targeted tool, not a default.** Vision takes ~1 min per 10 pages and, on the native Matrix path,
costs upstream LLM calls. Don't pay any of that for a PDF whose text layer is fine. **And when
vision is warranted, pick the path by model capability:** a vision-capable model (e.g. M3 — it can
natively accept image input) should rasterise the page(s) to PNG (§3.2) and read them with the Read
tool directly — no daemon, no MCP, no upstream LLM call. `read_pdf_vision.py` (§3.3) is the fallback
for text-only models (e.g. M2.7) that cannot see images.

---

## 3. Library Cookbook

### 3.1 pdfplumber — text and tables, faithful layout (default route)

The default for any text-native PDF. Fast, offline, no daemon required. Try this first on any PDF
the user hands you — only escalate to vision (§3.3) if the output looks wrong (`(cid:NNN)` glyphs,
empty strings, garbled multi-column reading order) or the user explicitly needs charts / scans
interpreted.

```python
import pdfplumber

with pdfplumber.open("invoice.pdf") as src_doc:
    for pg in src_doc.pages:
        body = pg.extract_text()
        for tbl in pg.extract_tables():
            for row in tbl:
                print(row)
```

For coordinate-aware extraction (character positions, region-clipped text — used heavily by form
workflows that need to read a stamped header or pull text from a known label region):

```python
with pdfplumber.open("invoice.pdf") as src_doc:
    pg = src_doc.pages[0]
    for ch in pg.chars[:5]:
        print(ch["text"], ch["x0"], ch["top"])
    region_text = pg.within_bbox((100, 100, 400, 200)).extract_text()
```

### 3.2 pypdfium2 / pdf2image — rasterise pages

A side route, not part of the read flow. Reach for it when you need PNGs of the pages — to show the
user a preview, to pre-stage frames for §3.3 vision, or to feed pages into any other image tool.
`pypdfium2` is the fastest way to render a page to a PIL image:

```python
import pypdfium2 as pdfium

src_doc = pdfium.PdfDocument("payslip.pdf")
for i, pg in enumerate(src_doc):
    pg.render(scale=2.0).to_pil().save(f"payslip_{i + 1}.png")
```

`pdf2image` (poppler wrapper) gives the same output at slightly slower throughput but with the same
dependency you already need for `read_pdf_vision.py` (§3.3):

```python
from pdf2image import convert_from_path

images = convert_from_path("payslip.pdf", dpi=200)
for i, img in enumerate(images):
    img.save(f"payslip_{i + 1}.png")
```

For a ready-to-run batch CLI that also caps the longer side in pixels (useful before sending to
vision or any other model), see §4.3 (`scripts/render/page_rasterize.py`).

### 3.3 vision — rendered-page reading (escalation route, only when text fails)

> **Model-capability gate — check the running model BEFORE reaching for `read_pdf_vision.py`.** This
> native Matrix wrapper exists only because text-only models (e.g. M2.7) cannot see images, so rendered pages
> had to be shipped to an upstream native Matrix vision. **If the model running this skill is multimodal /
> vision-capable (e.g. M3 — it can natively accept image input), do NOT use the native Matrix route:**
> rasterise the target page(s) to PNG (§3.2 `pypdfium2` / `pdftoppm`, or
> `scripts/render/page_rasterize.py`) and read the PNG(s) directly with the **Read tool** — one page
> per file for chart / financial-table pages. This is faster, runs offline, and costs no upstream
> LLM call. Fall back to `read_pdf_vision.py` (below) **only** when the running model is text-only
> and cannot accept image input, or when native page reading is otherwise unavailable.

Escalate to the vision route **only when the §3.1 pdfplumber path is insufficient**:

- pdfplumber returned `(cid:NNN)` glyphs or empty strings → no text layer (scanned / image-only PDF)
- the user explicitly asks for chart values, diagram interpretation, or visual layout description
  that text extraction can't give them
- multi-column or magazine-style layout where pdfplumber's reading order is broken enough to be
  unusable

> **Mandatory-escalation cases.** Two scenarios are not optional — pdfplumber output may _look_ fine
> but is **always wrong** on these layouts. Use vision **per page** (`--pages N`, never a range):
>
> 1. **Pages with charts / diagrams / info-graphics whose values matter.** Chart axis labels are
>    rasterised pixels, not text; pdfplumber returns the surrounding caption but cannot read the
>    bars. If the user is going to quote a number from a chart, vision is mandatory.
> 2. **Complex financial / regulatory tables** — balance sheets, income statements, cash-flow
>    statements, debt maturity schedules, capitalisation tables, prospectus league tables. These use
>    multi-level headers, merged cells, dotted leader lines, footnoted sub-totals, and side-by-side
>    mini-tables. `extract_text()` returns scrambled fragments (labels separated from numbers,
>    sub-totals mis-attributed); `extract_tables()` requires a clean grid these layouts never have.
>    Treat as chart pages.
>
> Per-page invocation matters because the script's stitch-and-grow chunker would otherwise pack 4–8
> neighbour pages into one tall image, diluting the model's attention and mis-attributing values to
> the wrong page.

The script wraps:

- `pdf2image` rendering of selected pages
- stitch-and-grow chunking under a byte ceiling (~3 MB; the upstream Matrix vision API rejects images near
  5 MB)
- HTTP POST to `http://127.0.0.1:5321/mavis/api/matrix/tool/call` per chunk
- envelope unwrap from `{code, results: [{description, ...}]}`

```bash
python3 -m scripts.read_pdf_vision --input report.pdf --pages 1-30
python3 -m scripts.read_pdf_vision --input slides.pdf --json
```

Vision needs local-runtime running with authenticated native Matrix tools, takes ~1 min per 10 pages, and
costs upstream LLM calls — none of which is worth paying when pdfplumber would have worked. Confirm
pdfplumber output is unusable before reaching for this script.

For per-flag reference, JSON output schema, internal chunking, time budget (≈1 min per 10 pages),
and the local-runtime / 502 / 413 error matrix, see [`docs/vision-guide.md`](docs/vision-guide.md).

**If vision itself fails (502 / 413 / `Cannot connect to local runtime` / `401 auth failed` /
persistent JSON / timeout):** on a vision-capable model, just rasterise the page(s) to PNG (§3.2)
and read them with the Read tool — that path does not touch the MCP at all. On a text-only model,
return to the §3.1 pdfplumber path for whatever text you can recover, and tell the user the visual
content (charts / scans) could not be interpreted because the native Matrix vision is unavailable. Don't
retry-stall.

---

## 4. CLI Cookbook

### 4.1 poppler-utils — text, preview, image, and metadata

```bash
# Plain text (preserves visual layout)
pdftotext -layout invoice.pdf invoice.txt

# Pages 1-3 only
pdftotext -f 1 -l 3 invoice.pdf snippet.txt

# Render pages to PNG (no model call)
pdftoppm -png -r 300 invoice.pdf preview      # writes preview-1.png, preview-2.png, ...

# Pull every embedded raster image out of the PDF (native resolution,
# preserves the original JPEG / PNG encoding inside the PDF)
pdfimages -all invoice.pdf images/img         # writes images/img-000.png, etc.

# Page count, metadata, page sizes, encryption status
pdfinfo invoice.pdf
```

`pdfimages -all` is the fastest way to dump original embedded images at their native resolution —
preferred over `pdftoppm` when the PDF embeds high-resolution photos and you want them back at full
quality rather than re-rasterised at a fixed DPI.

`pdfinfo` is the cheapest way to confirm page count and encryption status before deciding which read
route to take.

### 4.2 qpdf — decrypt and repair (read-related)

```bash
# Drop the password to write a clear copy, then run any read recipe
qpdf --password=pw --decrypt encrypted.pdf clear.pdf
pdftotext -layout clear.pdf -                   # for example

# Repair a damaged / cross-reference-broken PDF in place
qpdf --check broken.pdf
qpdf --replace-input broken.pdf
```

For Python-side decryption (no `qpdf` install needed):

```python
from pypdf import PdfReader

src_doc = PdfReader("encrypted.pdf")
if src_doc.is_encrypted:
    src_doc.decrypt("user-password")
text = src_doc.pages[0].extract_text()
```

### 4.3 In-skill scripts

| Script                                                                           | Purpose                                                        | When to invoke                                                                          |
| -------------------------------------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `python3 -m scripts.read_pdf_vision`                                             | native Matrix vision (§3.3)                                    | escalation only — text path (§3.1) returned `(cid:NNN)` / empty, or charts/scans matter |
| `python3 -m scripts.render.page_rasterize <pdf> <dir> --max-edge 1200 --dpi 200` | Batch rasterise pages to PNG with optional max-edge downsizing | Pre-stage pages for vision or human inspection                                          |

---

## 5. Cross-route workflows — read then write

These chains feed READ output straight into the WRITE routes (CREATE / REFORMAT / FILL / MUTATE).
Match the user's intent to one of the rows below; do not improvise an in-house generator on top of
the read recipes.

| User intent                                                          | Read step                                                                                                                                                                                                                                                                        | Write step                                                               |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Restyle an existing PDF into our design system                       | pdfplumber recipe (§3.1) to pull text and tables; only add `read_pdf_vision.py` (§3.3) on top if visual layout / cover design / charts must be carried over                                                                                                                      | `bash scripts/make.sh reformat` (REFORMAT route)                         |
| Fill values into a PDF form whose structure you don't yet know       | `pypdf.PdfReader.get_form_text_fields()` to probe AcroForm metadata, then pdfplumber recipe (§3.1) for visible field labels and any flat text; escalate to `read_pdf_vision.py` (§3.3) only for forms with no AcroForm metadata and no readable text labels (pure visual layout) | `bash scripts/make.sh fill probe` then AcroForm or visual-overlay branch |
| Build a new PDF inspired by an existing one (palette, cover, layout) | `read_pdf_vision.py` (§3.3) on the reference — design cues are inherently visual and pdfplumber can't extract palette / cover / spatial composition                                                                                                                              | CREATE route — encode cues in the HTML (CSS variables + cover archetype) |
| Translate a PDF while preserving layout                              | pdfplumber recipe (§3.1) per page for the text; only fall through to `read_pdf_vision.py` (§3.3) when the source is scanned or has unrecoverable reading order                                                                                                                   | `templates/translate-preserve-layout` (REFORMAT route)                   |
| Verify a PDF you just generated (post-write sanity check)            | pdfplumber recipe (§3.1) on the output PDF                                                                                                                                                                                                                                       | n/a — the loop is write → read                                           |

For mutation routes (merge / split / rotate / crop / watermark / encrypt / decrypt; annotate / sign
/ replace text via overlay), see [`advanced-reference.md`](advanced-reference.md).

---

## 6. Reference Index

| File                                 | Purpose                                                                                                                                                |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [`vision-guide.md`](vision-guide.md) | `read_pdf_vision.py` reference — flags, JSON schema, internal chunking, time budget, error matrix                                                      |
| `scripts/read_pdf_vision.py`         | native Matrix vision via local-runtime HTTP — escalation route for charts / scans / broken text layer (only wrapped read script; pdfplumber is the §3.1 default) |
| `scripts/render/page_rasterize.py`   | Batch raster pages to PNG with optional max-edge downsizing                                                                                            |
| `scripts/lib/cli_utils.py`           | Shared CLI helpers (emit / warn / fail / make_parser)                                                                                                  |
| `scripts/_pdf_read_lib.py`           | vision-side helpers (page-spec parsing, output spill, argparse)                                                                                        |

---

## 7. Troubleshooting

| Symptom                                                                   | Likely cause                                     | First thing to try                                                                                                                                                                                                                                                                                                                                |
| ------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pdfplumber` returns `(cid:NNN)` instead of letters                       | scanned PDF (no text layer)                      | escalate to `read_pdf_vision.py` (§3.3) — this is the canonical reason to leave the §3.1 default                                                                                                                                                                                                                                                  |
| `pdfplumber` returns empty strings or garbled multi-column text           | broken reading order or image-only PDF           | escalate to `read_pdf_vision.py` (§3.3)                                                                                                                                                                                                                                                                                                           |
| `pypdf` / `pdfplumber` raises `file has not been decrypted`               | password-protected                               | call `reader.decrypt("password")` (§4.2) or `qpdf --decrypt` first                                                                                                                                                                                                                                                                                |
| `qpdf` complains about cross-references                                   | corrupt PDF                                      | `qpdf --replace-input broken.pdf` (in-place repair)                                                                                                                                                                                                                                                                                               |
| `pdftoppm: command not found` / `Unable to get page count`                | poppler missing                                  | `brew install poppler` (also bundles `pdftotext` / `pdfimages` / `pdfinfo`)                                                                                                                                                                                                                                                                       |
| `pypdfium2` import fails                                                  | wheel not installed                              | `pip3 install --user pypdfium2`                                                                                                                                                                                                                                                                                                                   |
| `read_pdf_vision.py`: `502 Bad Gateway` / `413 payload too large`         | vision chunk too big                             | rerun with `--max-bytes 2000000` (or 1500000), reduce `--pages`, or drop `--dpi 100`                                                                                                                                                                                                                                                              |
| `read_pdf_vision.py`: `gemini analysis failed` / `unexpected end of JSON` | upstream LLM hiccup                              | retry the same command unchanged                                                                                                                                                                                                                                                                                                                  |
| `read_pdf_vision.py`: `Cannot connect to local runtime at port 5321`      | local-runtime is down or the port could not be discovered | on a vision-capable model, skip native Matrix vision — rasterise the page(s) to PNG (§3.2) and read them with the Read tool; otherwise restart the MiniMax Code app, and if still down return to `pdfplumber` (§3.1) for text-only output and tell the user the visual content (charts / scans) could not be interpreted because the native Matrix vision is unavailable |
| `read_pdf_vision.py`: `auth failed` / `401`                               | Matrix backend token expired or environment mismatch | verify the managed login token matches the managed Matrix host, or set `MATRIX_TOKEN` for a custom `MATRIX_BASE_URL`; if not recoverable in this turn, return to `pdfplumber` (§3.1) text-only output                                                                                                                                          |
| `read_pdf_vision.py` runs > 10 min on 100+ pages                          | PDF too large for vision                         | the pdfplumber recipe (§3.1) is the default anyway — only run vision in narrow page slices when truly needed                                                                                                                                                                                                                                      |

---

## 8. Environment

**These dependencies are mandatory** — `pdfplumber` is the default read route (§3.1), so install
everything below up front even if you only plan to use vision today. Don't wait until `pip3` errors
mid-task.

```bash
pip3 install --user pdfplumber pdf2image pillow pypdf pypdfium2
brew install poppler                            # pdftoppm + pdftotext + pdfimages + pdfinfo
```

`read_pdf_vision.py` additionally requires local-runtime to be running with authenticated native
Matrix tools. Managed Matrix hosts use the Mavis login token; custom `MATRIX_BASE_URL` hosts
require `MATRIX_TOKEN` in the runtime environment.
Details: [`vision-guide.md`](vision-guide.md).

`qpdf` (§4.2) is optional — pure-Python decryption via `pypdf` covers the same ground unless you
need batch CLI throughput.
