
Pdfvision
- 3 installs
- 12 repo stars
- Updated August 2, 2026
- yamadashy/pdfvision
Extracts text, metadata, layout, image boxes, optional OCR, and rendered page PNGs from any PDF via the pdfvision CLI, with per-page density signals to catch silent extraction failures.
About
Runs the pdfvision CLI to structure-extract PDFs (text, layout, geometry, OCR, page renders) with content-hash caching and per-page quality signals. A developer uses it when an input PDF needs reliable structured extraction, including scanned pages and find-then-zoom keyword search.
- Density Overview flags silent failures like glyph-index garbage and rasterized pages
- Opt-in flags for OCR, layout, image boxes, geometry, render, and bbox search
Pdfvision by the numbers
- 3 all-time installs (skills.sh)
- Ranked #541 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yamadashy/pdfvision --skill pdfvisionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 12 |
| Last updated | August 2, 2026 |
| Repository | yamadashy/pdfvision ↗ |
What it does
Extracts text, metadata, layout, image boxes, optional OCR, and rendered page PNGs from any PDF via the pdfvision CLI, with per-page density signals to catch silent extraction failures.
Files
pdfvision
pdfvision extracts text + metadata + per-page density signals from any PDF, with opt-in OCR (--ocr), layout reconstruction (--layout), geometry-driven anomaly detection (pages[].warnings[] when --layout is on), image bounding boxes (--image-boxes), per-text-item geometry (--geometry), PNG rendering (--render, optionally sized with --render-scale and cropped with --render-region), and per-page text search with bbox (--search, hits ride into --render-region for one-pipeline find-then-zoom). Cached by content hash, so the second read of the same PDF returns in ~30 ms.
Prerequisite
npx pdfvision --versionRequires Node.js >= 22.13. Install globally with npm install -g pdfvision if used repeatedly.
Always run `npx pdfvision --help` once before reaching for non-obvious flags — the flag set evolves (OCR, remote URLs, layout, geometry, image-boxes, render output) and the help text is the source of truth for what the installed version supports.
Quick reference
# Local PDF, markdown to stdout (per-page sections + density Overview table)
npx pdfvision /path/to/doc.pdf
# Fetch a PDF over http(s) — downloads to cache, then extracts
npx pdfvision --remote https://example.org/paper.pdf
# Page subset
npx pdfvision doc.pdf -p 1-5
npx pdfvision doc.pdf -p 1,3,5
# Programmatic / structured consumers
npx pdfvision doc.pdf -f json
npx pdfvision doc.pdf -f xml # tag-shaped, some LLMs locate <page> faster than JSON keys
npx pdfvision doc.pdf -f toon --geometry # same schema, ~40% fewer tokens on span/array-heavy output
# Image-flattened / scanned page — two options:
npx pdfvision scan.pdf --ocr -f json # tesseract.js OCR
npx pdfvision scan.pdf --render --render-output ./images # PNG for vision LLM
# Smaller / larger raster for vision-model payload
npx pdfvision slides.pdf --render --render-scale 1 # half-size PNG (default scale is 2)
npx pdfvision tiny.pdf --render --render-scale 3 # higher-detail PNG
# Zoom into a specific region on one page (PDF points, top-left origin)
npx pdfvision doc.pdf -p 3 --render --render-region 100,200,300,150
# Find a string with bbox of every hit — pipe match.bbox into --render-region for visual zoom
npx pdfvision report.pdf --search "revenue" --json
npx pdfvision paper.pdf --search "GPT" --search "transformer" --json # multi-query (each match carries queryIndex)
# Wipe the on-disk cache
npx pdfvision --clear-cacheFormat choice (markdown / json / xml / toon) does not change the cache slot — the structured payload is shared and only re-formatted on output.
toon (Token-Oriented Object Notation) is a lossless, schema-aware re-encoding of the same DocumentResult as -f json, tuned for tight LLM token budgets. Its win is concentrated in uniform-array-heavy output: --geometry (spans) drops ~40–48% of tokens versus the pretty-printed JSON because spans collapse into a CSV-like tabular form that names fields once. On plain text-body extraction the saving is negligible (free text doesn't compress), and on --layout (nested, non-uniform blocks) -f xml is usually more compact than toon. Reach for toon specifically when handing span/geometry-dense output to an LLM; otherwise json / xml remain the defaults. Decode back to the JSON data model with the @toon-format/toon package, so programmatic consumers lose nothing.
Picking the right flags
The default extraction is enough for most native-text PDFs (papers, exports from Word / Pages / Markdown tooling). Reach for opt-ins only when the default isn't enough.
| Goal | Flag | When to reach for it |
|---|---|---|
| Reconstruct reading order, find headings | --layout | Multi-column papers, slides where the agent must process blocks in order |
| Know where images sit on the page | --image-boxes | Bbox overlay on rendered PNG, figure detection |
| Per-glyph bbox + fontSize | --geometry | Heading detection by font-size, custom layout heuristics |
| Page is an image — get text from raster | --ocr + --ocr-lang | coverage: 0% in the Overview, or nonPrintableRatio >= 0.05 (text exists but is glyph-index garbage; see below). For non-English text, language order matters — primary language goes first (jpn+eng for Japanese-dominant, eng+jpn for English-dominant). Full lang combinations and confidence semantics in references/ocr.md. |
| Hand the page to a vision model | --render + --render-output <dir> | Multimodal flows. Density Overview already flagged the page as low-text |
| Shrink / enlarge the rendered PNG | --render-scale <n> (default 2, bounds (0, 4]) | 1×: half-size payload, fine for most agentic-vision dispatch. 3×+: capture chart / fine-print detail |
| Zoom into a sub-rectangle of one page | --render-region <x,y,w,h> | Agent already saw a suspect block via --layout / warnings[] and only wants the visual confirmation of that bbox, not the whole page. PDF points, top-left origin, single-page only (errors if --pages resolves to multiple). Composes with --render-scale |
| Find every occurrence of a string with bbox | --search <query> (repeatable; --search-regex / --search-case-sensitive modifiers) | The agent's "where does this term appear?" question. Returns pages[N].matches[*] with span-level bbox so the bbox feeds straight into --render-region for a follow-up visual zoom — one-pipeline find-then-zoom, no second pass. Literal substring by default, case-insensitive, NFKC-aware (so "fi" matches the U+FB01 ligature). Also searches OCR text when --ocr is on (match carries source: 'ocr'). |
| Skip the on-disk cache | --no-cache | Forced re-extraction. Default behaviour is cache-on |
Detecting silent failures with the density Overview
When result.pages.length > 1, the markdown output starts with an Overview table that reports Chars / Images / Coverage / Size per page (plus NonPrint when any page has non-zero non-printable ratio, and Blocks when --layout was on). The JSON / XML output carries the same data in overview[] with field names charCount / imageCount / textCoverage / nonPrintableRatio / nonPrintableCount / width / height / quality — use the field names directly when grepping or filtering in code. Use the Overview before scrolling the body.
One-shot dispatch: pages[].quality
Each page (and each overview row) carries a derived quality field that classifies the page from the raw signals so agents don't have to reimplement the threshold logic:
quality.nativeTextStatus:ok— usable native text.unusable_glyph_indices—nonPrintableRatio >= 0.05. Text is binary garbage even thoughcharCountlooks healthy. Fall back to--renderor--ocr.empty_but_visual_content— no native text, but the page carries images or non-blank pixels. Re-run with--ocr(or read the rendered PNG via--render).empty— no text, no detected visual content. Likely a genuinely blank page (or a render failure — combine withvisualStatusbelow).quality.visualStatus(present only when--renderor--ocrran):ok— renderer drew real content.blank— page came out effectively blank against its own dominant background. Render-pipeline failure or genuinely blank page.
pdfvision deliberately stops at observation: it does not recommend an action. The action is the agent's call based on the two statuses + the raw signals below.
Raw signals (the inputs to quality)
textCoverage: 0(rendered ascoverage: 0%in markdown) +imageCount > 0→ the page body is a rasterised image. The text stream is empty. Re-run with--ocror--render.nonPrintableRatio >= 0.05→ pdf.js fell back to raw glyph indices because the PDF's fonts lack a ToUnicode CMap (common with Hebrew, older CJK, custom symbol fonts).textreads as full coverage but is binary garbage. Values>= 0.3are pathological;< 0.01is normal. The raw count is innonPrintableCount— when the 3dp ratio rounds to 0 the count still tells you whether any non-printable code points slipped through (useful for "is there ANY garbage in this page?" filters).charCount: 0butimageCount: 0→ genuinely blank page (separator, end matter).- Sudden drop in
textCoverageon a single page in an otherwise text-dense doc → that page is likely a figure / scan / chart. Inspect with--render. renderContentRatio <= 0.001(when--renderor--ocrwas on) → the rasterised page came out blank against its own dominant background. Likely a render-pipeline failure (pdf.js + @napi-rs/canvas can't decode JPEG2000 image streams, or the font has no resolvable glyphs). The ratio is background-aware — dark book covers and beige scan paper don't false-trip it. OCR on this page returnsconfidence: 0not because OCR failed but because the input was a near-uniform image.
The density signal is the reason to prefer pdfvision over reading a PDF directly — silent failures (empty text that looks fine to a downstream consumer, or full text that is actually NUL bytes) become visible up front.
Caching
- Cache root:
<os-tmp>/pdfvision/<content-sha>/— macOS/var/folders/.../T/pdfvision/, Linux/tmp/pdfvision/. Override withPDFVISION_CACHE_DIR=/path. - Keyed by PDF content hash + flag combination. Same PDF + same flags → ~30 ms on the second call. Different flags (e.g. add
--layoutlater) → different slot, fresh extraction. - Wipe everything (cached extractions, rendered PNGs, downloaded remote PDFs, OCR traineddata) with
npx pdfvision --clear-cache.
Typical agent flow
Inherit the user's scope first. If the user already named a specific page or range ("page 2", "chapter 3", "the last few pages"), pass -p from step 1 — the density Overview works per page, so there's no need to scan a 100-page doc when the user pointed at page 2. Only run unscoped when the user genuinely asked about the whole document. Sections with conventional locations also help: "abstract" → -p 1, "conclusion" → -p <last-few>, "TOC" → -p 1-3.
Pick a format that matches the consumer. If the consumer is the LLM itself reading text inline (the typical "user asks me to read this PDF" case), the markdown default is already optimal — no flag needed. Switch to -f json only when a downstream programmatic step needs structured field access (overview[], pages[].layout, pages[].ocr, etc.). XML when the LLM downstream parses tags more reliably than nested JSON. -f toon when the consumer is an LLM and the output is span/geometry-dense (--geometry) and token budget is tight — same schema, ~40% fewer tokens there (see the format note under Quick reference for where it does and doesn't help).
1. Run npx pdfvision doc.pdf (add -p <range> per the scope note, and -f json only when you'll consume structured fields) — gets text + density Overview for the selected pages. 2. Read the density signals (the markdown Overview table, or overview[] / pages[].textCoverage / imageCount / charCount in JSON) to find low-coverage pages. 3. For low-coverage pages: re-run with --ocr if text is needed, or --render if a vision model will look at the rasterised page. 4. For structured / multi-column docs: re-run with --layout (and --image-boxes when figure positions matter). 5. Zoom into a specific block when `--layout` flags one. If pages[].warnings[] fires on a blockIndex, or layout.blocks[i] looks suspicious (overlapping bboxes, a chart you want a vision model to read), re-run with --pages <N> --render --render-region <x,y,w,h> using that block's bbox. The PNG comes back cropped to just the region (xywh × --render-scale = pixel dims), avoiding a full-page raster the model has to ignore most of. 6. Locate a keyword and pipe straight into zoom. When the user's question is "find where X is mentioned" (a model name, a number, a heading), run --search "X" --json to get pages[N].matches[*] with span-level bbox of every hit. Each match knows its page and bbox, so the same loop closes: --pages <m.page> --render --render-region <m.bbox.x>,<m.bbox.y>,<m.bbox.width>,<m.bbox.height> zooms onto the match. Repeat --search for multi-term searches (each match carries queryIndex). --search-regex for patterns, --search-case-sensitive when default insensitive-recall is too lossy. 7. Cache means steps 3–6 only re-pay the cost of the new flag combination on the affected page subset, not the whole extract.
When to read references/
The base of this file already covers daily extraction. Open a reference file only in one of these specific cases — they are not always-on context, do not load speculatively.
Each entry is tagged as mandatory (read before producing the deliverable; this file doesn't carry enough on its own for the case) or escalation (read only if the basic guidance above isn't enough for the situation).
| Read this file | Gate | When |
|---|---|---|
references/structured-output.md | mandatory when you're consuming --layout, --image-boxes, --geometry, --ocr, or any other structured JSON / XML field whose schema isn't fully described in this file. SKILL.md only names the flags — the field-by-field shape lives in the reference. | Programmatic consumers of -f json / -f xml. Covers DocumentResult / PageResult / LayoutBlock / ImageBox / TextSpan / PageOcr schemas and coordinate-system semantics. |
references/ocr.md | escalation for the easy cases (English-only, expected confidence). Mandatory when the user's text is non-English (lang ordering affects results), confidence is unexpectedly low, or the tesseract.js install / stderr is misbehaving. | Lang code combinations, primary-language ordering, traineddata cache, install diagnostics, troubleshooting (low confidence, blank PNG, stderr noise). |
OCR reference
Detail on the --ocr flag — when to reach for it, multi-language behaviour, confidence semantics, install / cache requirements, and troubleshooting. Read this when running --ocr on non-English text, when the confidence comes back unexpectedly low, or when tesseract.js install needs diagnosing.
For the basic flow ("page is image-flattened, run --ocr -f json"), the top-level SKILL.md is enough.
When to run OCR
The trigger is the density Overview, not the page content itself. Look for:
coverage: 0%(or near-zero) withimageCount > 0— page body is rasterisedtextis empty or garbled (a few stray characters liker rv) — PDF font tables are broken- The whole document looks fine but one page comes back empty — likely a slide / figure / scan
pdfvision never auto-triggers OCR. The agent decides per page after reading the density signal. OCR cost is ~0.5–2 s per page (CPU-bound) plus a one-time worker boot of a few seconds; running it on every page of a 100-page paper is rarely worth it.
Lang codes and ordering
--ocr-lang takes the tesseract.js plus-separated form: one or more 3-letter (or chi_sim style) codes joined with +.
npx pdfvision doc.pdf --ocr --ocr-lang eng # English only (default)
npx pdfvision doc.pdf --ocr --ocr-lang eng+jpn # English + Japanese
npx pdfvision doc.pdf --ocr --ocr-lang chi_sim # Simplified Chinese
npx pdfvision doc.pdf --ocr --ocr-lang eng+chi_sim+chi_traOrder matters. Tesseract treats the first language as the primary recogniser; later languages act as additional candidate dictionaries. eng+jpn favours English glyph recognition and falls back to Japanese; jpn+eng does the opposite. Empirically:
- For mostly-Japanese slides with English headers / labels:
jpn+eng - For English documentation with sparse Japanese terms:
eng+jpn - When unsure, run both and compare
confidenceandtext
pdfvision normalises whitespace in the lang string before keying the cache ( eng + jpn and eng+jpn share a slot) but preserves order — eng+jpn and jpn+eng are genuinely different recognisers and intentionally land in different cache slots.
The echoed pages[].ocr.lang returns the whitespace-normalised, order-preserved form ('eng+jpn', not ' eng + jpn ').
Confidence semantics
pages[].ocr.confidence is 0..1 (rounded to 3dp). Tesseract reports 0..100 internally; pdfvision divides by 100 to match the existing textCoverage convention.
Rough interpretation, treat as heuristic:
>= 0.8— high confidence, OCR text is usable as-is for most agent purposes0.5–0.8— usable but verify on important entities (numbers, names, code identifiers)< 0.5— partial recognition. Either wrong--ocr-lang, low-resolution scan, or stylised typography. Compare with the rendered PNG via--renderbefore trusting the text.
A confidence: 0 with an empty ocr.text usually means the rasterise step produced a blank page (see "Troubleshooting" below) rather than OCR genuinely finding nothing. Check `pages[].renderContentRatio` first: when it's <= 0.001 the render came out blank and OCR had nothing to work with — distinguish that from a real OCR miss before reporting "no text".
Output shape
interface PageOcr {
text: string; // trimmed of trailing whitespace, line breaks preserved
confidence: number; // 0..1, page-level mean
lang: string; // whitespace-normalised, order-preserved
}pages[].text (pdfjs-derived) is never overwritten by OCR — both signals coexist on the same page object so the agent can diff and decide. A scanned PDF typically shows empty text with populated ocr.text; a mixed-content PDF shows native text in text and an alternative OCR-derived reading in ocr.text (useful sanity check for ambiguous glyphs).
In XML output, OCR surfaces as <ocr lang="..." confidence="...">...</ocr>. Self-closing <ocr lang="..." confidence="0"/> means OCR ran and produced no text — distinct from the tag being absent (OCR wasn't requested).
Install requirements
tesseract.js is declared in optionalDependencies. Default npm install pdfvision pulls it in (~30 MB worker bundle); npm install --omit=optional skips it.
When --ocr is requested without tesseract.js installed, pdfvision throws:
--ocr requires the optional dependency "tesseract.js" (not installed).
Install it with: npm install tesseract.jsOther import-time errors (broken native binding, transitive syntax error) surface the real error message, not the install hint — so the agent can diagnose without false leads.
Traineddata cache
Tesseract downloads per-language *.traineddata files (~10–15 MB each) on first use:
eng.traineddata≈ 10 MBjpn.traineddata≈ 13 MBchi_sim.traineddata≈ 16 MB
pdfvision points tesseract.js at <cache-root>/ocr-data/ (POSIX 0700) so:
- The data lands under pdfvision's own cache hierarchy (consistent perms, single place)
npx pdfvision --clear-cachewipes traineddata alongside extraction caches- The download happens once per language; subsequent runs are offline
First --ocr invocation against a new language takes a few extra seconds for the download. Subsequent invocations of the same language are instant on the boot step (still ~1–2 s for the worker init).
Troubleshooting
Benign stderr noise on the first --ocr run
When --ocr boots tesseract.js for the first time in a session, you may see stderr lines like:
Error opening data file ./.traineddata
Failed loading language ''These are harmless pre-load probes from tesseract.js's internal boot sequence, not fatal errors. The recogniser then honors the --ocr-lang you actually passed. Confirm by checking pages[].ocr.confidence in the JSON output — if it's > 0 and pages[].ocr.text is populated, OCR succeeded. Do not interpret these stderr lines as a reason to abort.
"OCR ran but text is empty and confidence: 0"
Most likely the rasterise step produced a blank page, not an actual OCR failure. Common cause: the PDF uses an image format pdfjs + @napi-rs/canvas can't decode (notably JPEG2000 / JPX, common in Internet Archive scans). Verify by:
npx pdfvision doc.pdf -p <page> --render --render-output /tmp/dbg
# Inspect /tmp/dbg/<contentFingerprint>/page-<n>.png — if it's blank, OCR has
# nothing to chew on. (pdfvision namespaces the output by a per-PDF
# fingerprint so two different PDFs sharing a --render-output dir don't
# overwrite each other.)This is a known limitation tracked separately from OCR. Workaround: source a different copy of the PDF, or pre-decode the JPX stream with a wasm decoder before invoking pdfvision.
"Confidence is moderate but text has obvious garbage"
Most often a --ocr-lang mismatch — the page contains a language not listed in the spec, or the order is wrong (Japanese-dominant page run with eng+jpn instead of jpn+eng). Try the alternative ordering and compare.
Second most common: low resolution. pdfvision renders at 2× by default. For genuinely fine print, render to PNG manually at higher scale and feed through tesseract.js directly via the library API (the CLI doesn't currently expose a scale flag for OCR).
"OCR is slow — N pages × M seconds is unbearable"
- Restrict the page range:
-p <range>to OCR only the pages that need it (use the density Overview to pick). - The single worker is reused across pages within one invocation, so a 10-page OCR run pays the boot cost once and per-page cost N times. Splitting across invocations would re-pay the boot cost on each.
- pdfvision's page-level parallelism does not apply to OCR (single worker by design). Spawning multiple workers would multiply memory by ~30 MB / language without a meaningful win.
"I want OCR to overwrite text so my downstream consumer doesn't have to choose"
By design, no. The agent / downstream is the one to decide which signal to use. If a consumer wants a single field, it can pick at consumption time:
const effectiveText = page.text || page.ocr?.text || '';Keeping both signals available means a sanity check (compare native vs OCR for ambiguity) is always possible.
Examples
# Japanese slide deck, eng-dominant titles
npx pdfvision slides.pdf --ocr --ocr-lang jpn+eng -f json
# English paper with embedded Chinese citations
npx pdfvision paper.pdf --ocr --ocr-lang eng+chi_sim -f json
# Scanned book, English only
npx pdfvision scan.pdf -p 1-20 --ocr -f json | \
jq '.pages[] | {page, conf: .ocr.confidence, head: .ocr.text[0:120]}'Structured output schema
Reference for -f json, -f xml, and -f toon consumers. Read this when an agent or tooling consumes the structured payload programmatically and needs to know every field, its shape, and its coordinate convention.
The shape of -f json is the DocumentResult interface exported by the pdfvision package. -f xml carries the same data as <document> / <page> / nested tags, and -f toon carries it as Token-Oriented Object Notation. All three are isomorphic to the same DocumentResult — pick whichever is easier for the consumer to parse (toon is the most token-frugal on span/array-heavy output; see "TOON output shape" below).
DocumentResult (top level)
interface DocumentResult {
file: string; // path the CLI was invoked with (or cache path for --remote)
totalPages: number; // total in the source PDF, not in the selection
metadata: DocumentMetadata; // title / author / subject / creator (all string | null)
overview?: PageOverview[]; // per-page density summary; present iff pages.length > 1
pages: PageResult[]; // one entry per selected page, in page-number order
}file is patched on cache hit to the current invocation's path, so a downstream consumer sees a meaningful path even when the cached entry came from a different invocation that touched the same content hash.
PageOverview (density summary)
interface PageOverview {
page: number;
charCount: number;
imageCount: number; // raster image draws (XObject + inline + mask), per drawn instance
textCoverage: number; // 0..1, fraction of page area covered by text glyph bboxes
nonPrintableRatio: number; // 0..1, fraction of `text` that is NUL / control / noncharacter
nonPrintableCount: number; // raw count — stays discriminable when the 3dp ratio rounds to 0
renderContentRatio?: number; // 0..1, fraction of pixels differing from the page's dominant background (present iff --render or --ocr)
quality: PageQuality; // derived classification — see below
warningCount?: number; // mirror of pages[N].warnings.length, omitted when --layout off or no rule fired
matchCount?: number; // mirror of pages[N].matches.length; present-with-0 means "search ran, no hit"
width: number; // PDF user-space points
height: number;
}overview[] is the first thing to inspect for silent-failure detection. The quality field gives a one-shot classification; the raw signals below let agents combine signals their own way:
imageCount > 0 && textCoverage ≈ 0→ image-flattened page; the text stream is empty.nonPrintableRatio >= 0.05→ ToUnicode CMap missing; the text stream is full of raw glyph indices (NUL + control chars) even thoughtextCoveragelooks fine. Native text is unusable; fall back to--renderor--ocr. Maps toquality.nativeTextStatus === 'unusable_glyph_indices'.renderContentRatio <= 0.001→ rasterised page is effectively blank against its own dominant background (only meaningful when--renderor--ocrwas on). Background-aware so dark covers and beige scans don't false-trip it. Catches render-pipeline failures pdfvision can't otherwise surface: pdf.js + @napi-rs/canvas can't decode JPEG2000 image streams (common in Internet Archive scans), and PDFs whose fonts have no resolvable glyphs draw nothing. When OCR runs against this,confidence: 0is not an OCR miss — the input was a near-uniform image. Maps toquality.visualStatus === 'blank'.
PageResult (per page)
interface PageResult {
page: number;
text: string; // NFKC-normalized unless --no-normalize
rawText?: string; // pre-normalization text — only present when normalization changed it
charCount: number;
imageCount: number;
textCoverage: number;
nonPrintableRatio: number; // NUL / control / noncharacter ratio in `text`
nonPrintableCount: number; // raw count alongside the ratio
renderContentRatio?: number; // pixel fraction differing from the page's dominant background (present iff --render or --ocr)
quality: PageQuality; // derived per-page classification — agent-side dispatch lives on this field
width: number;
height: number;
image?: string; // absolute PNG path — present iff --render
renderRegion?: { x, y, width, height }; // echoed back when --render-region was set; lets consumers tell crop vs full
spans?: TextSpan[]; // present iff --geometry
layout?: PageLayout; // present iff --layout
imageBoxes?: ImageBox[]; // present iff --image-boxes
ocr?: PageOcr; // present iff --ocr
warnings?: PageWarning[]; // present iff --layout, omitted when no rule fired on the page
matches?: SearchMatch[]; // present iff --search; empty array means "search ran, no hit on this page"
}
interface PageQuality {
nativeTextStatus:
| 'ok' // usable native text
| 'unusable_glyph_indices' // nonPrintableRatio >= 0.05 — fall back to --ocr / --render
| 'empty_but_visual_content' // no native text but the page has images / non-blank pixels
| 'empty'; // no text, no detected visual content
visualStatus?: // present iff --render or --ocr triggered a raster
| 'ok' // renderContentRatio > 0.001 — renderer drew real content
| 'blank'; // renderContentRatio <= 0.001 — effectively blank against the page's own background
}text is the pdfjs-derived text stream. ocr.text (when --ocr is on) is the OCR result alongside, never overwriting `text` — consumers diff or pick whichever signal looks better for the page.
quality is pure observation, not recommendation: pdfvision tells the agent what it saw, the agent picks what to do next.
Layout (--layout)
interface PageLayout {
blocks: LayoutBlock[]; // in approximate reading order (multi-column aware)
}
interface LayoutBlock {
text: string; // line texts joined with \n
x: number; y: number; width: number; height: number;
lines: LayoutLine[];
role?: 'heading'; // heuristic heading classification — see `level`
level?: 1 | 2 | 3; // present iff role === 'heading': 1=title, 2=section, 3=subsection candidate
repeated?: boolean; // chrome (running header / footer / page number / watermark) detected across pages
}
interface LayoutLine {
text: string;
x: number; y: number; width: number; height: number;
fontSize: number; // most common fontSize across the spans in this line
}Multi-column reading order: blocks[] reads top-to-bottom of the left column before the right column. Standalone level-1 / level-2 headings act as column separators; level-3 candidates stay inside their column so subsection breaks don't scramble reading order. Block clustering is still heuristic — table cells may merge into a single block.
Heading levels (role === 'heading')
role is set when a block is classified as a heading; level ranks the visual hierarchy:
level: 1— paper / page title (fontSize ≥ 1.40× body median).level: 2— section heading (≥ 1.25× under the legacy rule, or ≥ 1.15× with structural support: short and either standalone or locally larger than neighbours). Catches the typical LaTeX 12pt-over-10pt section style.level: 3— subsection candidate (≥ 1.08×, single short line, locally larger than same-column neighbours). Lower confidence; the kind of heading ResNet's3.1.and3.4.use.
Pick a slice that matches the use case:
- Title-only:
role === 'heading' && level === 1. - High precision (sections only):
role === 'heading' && level <= 2. - Recall-oriented (include subsections): all
role === 'heading'.
Headings can co-occur with repeated: true (a doc title in a running header is still a heading); when chunking body content, filter repeated: true first.
Image boxes (--image-boxes)
interface ImageBox {
x: number; y: number; width: number; height: number;
}One entry per drawn instance — a tiled hero image yields multiple entries. imageCount === imageBoxes.length is an invariant on every page. Form XObject CTM tracking ensures images drawn inside a form land at the correct page-space position.
Spans (--geometry)
interface TextSpan {
text: string; // normalized by default (disable with --no-normalize)
x: number; y: number; // top-left in PDF points
width: number; height: number;
fontSize: number; // max of horizontal / vertical text-matrix scales
fontName?: string; // pdf.js internal name e.g. "g_d0_f1"
}Whitespace-only spans are filtered out — pdf.js emits a span per positioned space, which would double the array length without adding information.
OCR (--ocr)
interface PageOcr {
text: string; // OCR-derived text, trimmed
confidence: number; // 0..1 (rounded to 3dp). Tesseract reports 0..100 internally; pdfvision normalises.
lang: string; // canonicalised lang spec — whitespace-trimmed, order preserved
}lang echoes the caller's --ocr-lang after whitespace normalization but preserves token order. eng+jpn and jpn+eng produce different recognisers (tesseract treats the first language as primary) and therefore land in different cache slots and different lang echoes.
Coordinate system
All coordinates (spans, layout blocks, image boxes, renderRegion) use a top-down origin in PDF user-space points: (0, 0) at the top-left of the page, y grows downward. This matches the rendered PNG convention, so a consumer can overlay any of the geometry signals onto image (when --render is on) without flipping.
To map PDF points onto rendered PNG pixels:
const sx = image.width / page.width;
const sy = image.height / page.height;
const pixelBox = { x: box.x * sx, y: box.y * sy, width: box.width * sx, height: box.height * sy };Rendering: --render-scale and --render-region
Both flags only have effect when --render (or --ocr, which internally rasterises) is on.
- `--render-scale <n>`: multiplier in pixels-per-point. Default
2(≈144 DPI on a letter page). Bounds(0, 4]. Smaller values shrink the vision-model payload; larger values capture finer detail (chart labels, small typography). - `--render-region <x,y,w,h>`: render only the given sub-rectangle of one page instead of the full page. PDF points, top-left origin, same coord system as
imageBoxes/layout.blocks. Composes orthogonally with--render-scale: a 400×300pt region at scale 3 produces a 1200×900px PNG. V1 is strictly single-page (errors if--pagesresolves to anything but exactly one page), rejects regions that fall outside the page bounds, and rejects rotated pages (page.rotate !== 0— pdfvision's existing geometry is in unrotated MediaBox coordinates and the rotation fix is a multi-file refactor still pending). The xywh tuple is part of the cache key and the on-disk filename (page-N_x<x>_y<y>_w<w>_h<h>.png), so multiple regions per page coexist. Echoed back onPageResult.renderRegionso consumers can tell a cropped image from a full-page one without inspecting the filename.
Typical agent flow: extract with --layout, find a suspect block in layout.blocks[i] (or get its index out of warnings[i].blockIndex), then re-run with --pages <N> --render --render-region <x,y,w,h> using blocks[i]'s bbox to zoom in.
Warnings (--layout)
interface PageWarning {
code: 'text_overlap' | 'near_bottom_edge' | 'body_near_repeated_chrome' | 'off_page';
severity: 'warning' | 'error';
message: string;
blockIndex?: number; // 0-based into pages[N].layout.blocks
otherBlockIndex?: number; // for pair-wise rules (text_overlap, body_near_repeated_chrome)
}Emitted only when --layout is on. Each entry pins to a specific block (or block pair) and describes what looks visually off — overlapping text, off-page bbox, body crowding a detected running header/footer. Same observational posture as quality: pdfvision tells the agent what it saw; the agent decides whether to surface, re-OCR, or zoom in via --render-region <blocks[blockIndex].x>,....
Search (--search)
interface SearchMatch {
page: number; // 1-based, mirrors PageResult.page
query: string; // verbatim source query
queryIndex?: number; // 0-based into the search array; omitted for single-query calls
bbox: { x, y, width, height }; // union bbox of contributing spans; feed straight into --render-region
boxes: { x, y, width, height }[]; // per-span bboxes (V1: one entry for single-span matches)
text: string; // matched substring in the same form as pages[].text (NFKC when normalize is on)
source: 'native' | 'ocr'; // native = precise span bbox; ocr = page-level bbox (V1 limit)
context?: string; // surrounding line text for human / LLM readability
}Emitted only when --search is passed. Each query occurrence becomes one match — three hits of "foo" on page 5 yield three entries with page: 5.
One-pipeline find-then-zoom: a match's bbox is in the same coord system as --render-region, so the agent loop is:
pdfvision doc.pdf --search "revenue" --json
# pick a match m from pages[N].matches[*]
pdfvision doc.pdf -p <m.page> --render --render-region <m.bbox.x>,<m.bbox.y>,<m.bbox.width>,<m.bbox.height>Semantics:
- literal substring by default (regex chars in the query are escaped). Pass
--search-regexto opt into JavaScript regular expressions. - case-insensitive by default (recall-oriented). Pass
--search-case-sensitivefor exact-case matching. - NFKC-aware in literal mode when
--normalizeis on (default) —"fi"finds"fi"(U+FB01 ligature) PDFs that external grep would miss, same fold for fullwidth Latin / CJK compatibility forms. - Regex queries are NOT normalized — NFKC can turn compatibility punctuation into regex metacharacters (silent overmatch or syntax break). Regex users get the literal codepoints they typed against the normalized document text and own the asymmetry.
- Multi-query via repeating
--search(orsearch: string[]in library). Each match carriesqueryIndexso the agent can demultiplex which query produced it. - OCR text is searched too when `--ocr` is on. OCR-derived matches come back with
source: 'ocr'and a page-levelbbox(V1: per-word OCR bbox from tesseractdata.words[]not plumbed yet — the source-tag lets consumers disambiguate from precise native matches).
V1 native matching is single-span only. A query straddling two pdf.js spans (e.g. "Hello World" where Hello and World are different spans) won't match. Most short queries hit single spans because pdf.js groups by font run; multi-span matching is a follow-up.
pages[].matches is present-with-`[]` when --search ran but the page had no hits — distinct from the field being absent entirely (search wasn't requested). The same posture extends to the overview, which gains a matchCount mirror field with the same present-with-0 semantics.
XML output shape
-f xml mirrors the JSON shape one-for-one:
<document file="..." totalPages="14">
<metadata>
<title>...</title>
<author>...</author>
</metadata>
<overview>
<page no="1" charCount="..." imageCount="..." textCoverage="..." nonPrintableRatio="..." width="..." height="..."/>
...
</overview>
<pages>
<page no="1" charCount="..." imageCount="..." textCoverage="..." nonPrintableRatio="..." width="..." height="..." image="...">
<spans>
<span text="..." x="..." y="..." width="..." height="..." fontSize="..." fontName="..."/>
...
</spans>
<layout>
<block x="..." y="..." width="..." height="..." role="heading" repeated="true">
<line x="..." y="..." width="..." height="..." fontSize="...">...</line>
...
</block>
...
</layout>
<imageBoxes>
<imageBox x="..." y="..." width="..." height="..."/>
...
</imageBoxes>
<text>
...page text body...
</text>
<rawText>
...pre-normalization text, when normalization changed it...
</rawText>
<ocr lang="eng" confidence="0.91">
...OCR text...
</ocr>
</page>
...
</pages>
</document>Empty <layout/>, <imageBoxes/>, and <ocr/> (self-closing) mean "the pass ran and found nothing", which is distinct from the tag being absent (the pass wasn't requested).
TOON output shape
-f toon is the same DocumentResult re-encoded as Token-Oriented Object Notation: YAML-style indentation for nested objects, plus a CSV-like tabular form for uniform object arrays that declares the field names once in a [N]{fields}: header and then streams one comma-delimited row per element. Optional fields that are unset are omitted (not emitted as null), so the field set matches -f json exactly.
file: /path/doc.pdf
totalPages: 14
metadata:
title: ...
overview[2]:
- page: 1
charCount: 40
quality:
nativeTextStatus: ok
width: 612
height: 792
- page: 2
...
pages[2]:
- page: 1
text: "line one\nline two"
charCount: 40
spans[2]{text,x,y,width,height,fontSize,fontName}:
pdfvision headers fixture,50,27.18,108.38,10,10,g_d0_f1
Body of page 1,50,194.36,134.54,20,20,g_d0_f1
layout:
blocks[2]:
- text: ...
lines[1]{text,x,y,width,height,fontSize}:
...Decode back to the DocumentResult data model with the @toon-format/toon package (decode(toonString)). Where the win lands: spans[] (--geometry), overview[], imageBoxes[], and per-block lines[] all tabularize, so geometry/span-dense output is ~40–48% fewer tokens than the pretty-printed JSON. Free text bodies and the non-uniform layout.blocks[] (optional role / level / repeated per block) do not tabularize — for layout-dominant output -f xml is usually more compact than toon.
Library API (Node.js consumers)
If the consumer is itself a Node.js process, prefer the library API over invoking the CLI:
import { processDocument } from 'pdfvision';
const result = await processDocument('./doc.pdf', {
pages: '1-3',
layout: true,
imageBoxes: true,
ocr: true,
ocrLang: 'eng+jpn',
});
// `result` is a typed DocumentResult — no JSON.parse, no string formatting.
for (const page of result.pages) {
if (page.ocr) console.log(page.ocr.text);
}processFile() returns the formatted string output (markdown / json / xml / toon). processDocument() returns the structured object directly.
Exported types: DocumentResult, DocumentMetadata, PageOverview, PageResult, PageQuality, PageWarning, SearchMatch, LayoutBlock, LayoutLine, PageLayout, ImageBox, RenderRegion, TextSpan, PageOcr, OutputFormat, ProcessDocumentOptions, ProcessOptions.