Command line¶
The distillpdf command has two faces. Point it at a source PDF and it converts —
HTML, Markdown, plain text, a searchable PDF, or a durable .dpdf model. Point it at a
.dpdf model followed by a verb and it becomes an agent shell over that document:
info, toc, read, find, search, embed, tables, figures, ocr-status.
The two faces share one entry point. python -m distillpdf … is equivalent to the
distillpdf … console script in every example below.
$ distillpdf paper.pdf -o paper.html # convert (PDF input)
$ distillpdf case.dpdf toc # query (.dpdf input + verb)
$ python -m distillpdf paper.pdf -o paper.html # same as the first line
Routing is by the first argument: a .dpdf first positional followed by a known verb
goes to the document shell. Anything else (including a .dpdf typo with no verb) falls
through to the converter, which reports a normal per-file error.
Converting a PDF¶
$ distillpdf paper.pdf # HTML to stdout (images inline)
$ distillpdf paper.pdf -o paper.html # HTML to a file
$ distillpdf paper.pdf -o paper.md # Markdown (inferred from the .md extension)
$ distillpdf paper.pdf --markdown # Markdown to stdout
$ distillpdf *.pdf -o out/ # batch: out/<name>.html per input
Output format and where it goes¶
The format is resolved in this order: --text wins, then --markdown, then an output
path ending in .md / .markdown infers Markdown, otherwise HTML.
The destination follows -o / --output:
- No
-o, single input — streams to stdout. - No
-o, multiple inputs — writes<stem><ext>beside each source PDF. -o <file>, single input — writes exactly that file.-o <dir>/or an existing directory, or multiple inputs — treats-oas a directory and writes<stem><ext>into it (creating it if needed).
When writing a file, distillpdf prints distillpdf: wrote <path> to stderr. Per-input
errors (malformed PDF, unreadable path) are reported to stderr and the command continues
with the next input, exiting non-zero if any input failed.
All converter options¶
| Option | Effect |
|---|---|
pdf (positional, one or more) |
Input PDF file(s). Globs are expanded by your shell. |
-o, --output |
Output file (single input) or directory (batch). Default: stdout for one PDF, <name>.<ext> beside each for many. |
--markdown, --md |
Emit Markdown (also inferred from a .md output path). |
--text |
Emit plain text instead of HTML. |
--toc |
Print the table of contents (indented text) instead of the document. |
--section NAME |
Emit only the named section. Errors if no section matches. |
--mode {section,page} |
Structure: section-first (default) or page-first. |
--image-mode {embed,external,drop} |
Figures: inline data: URIs, an external img/ folder, or placeholder text. Default: embed for HTML, external for Markdown. |
--no-toc |
Omit the auto table-of-contents nav from the output. |
--ocr |
OCR scanned / image-only pages first (see OCR below). |
--ocr-engine ENGINE |
OCR engine or tier. Default: fast. |
--remove-raster |
With --ocr and a PDF output: reflow to clean text + cropped figures and drop the page raster. |
--list-ocr-engines |
List available OCR engines and exit. |
--version |
Print distillpdf <version> and exit. |
--image-mode defaults differ by format
Leave --image-mode unset and each format picks its own default: HTML embeds images
inline, Markdown writes an external img/ folder. Pass the flag only to override.
A few worked examples:
$ distillpdf paper.pdf --mode page # page-first HTML
$ distillpdf paper.pdf -o p.html --image-mode external # lean HTML + an img/ folder
$ distillpdf paper.pdf --image-mode drop # replace images with placeholders
$ distillpdf paper.pdf --no-toc # omit the table-of-contents nav
$ distillpdf paper.pdf --text # plain text
$ distillpdf paper.pdf --toc # print the TOC
$ distillpdf paper.pdf --section abstract # just one section
See Rendering HTML & Markdown for what each mode and image mode produces, and Raw extraction for the text-only path.
Distilling to a .dpdf¶
A .dpdf output path distills the durable document model instead of converting. The
asset profile is fixed to the .dpdf default (figures); --image-mode does not apply
here.
$ distillpdf case.pdf -o case.dpdf # distill one PDF
$ distillpdf *.pdf -o models/ # batch: models/<stem>.dpdf per input
For a single input the .dpdf path is the output file; for a batch, -o names a
directory and each input writes <stem>.dpdf into it. See
The .dpdf document model for what a model contains and
the .dpdf format reference for the on-disk layout.
Experimental format
The .dpdf schema is schema_version 0 and not yet stabilized across releases.
Treat distilled models as a working cache you can regenerate from the source PDF, not
a long-term archive format.
OCR for scanned PDFs¶
--ocr OCRs the scanned / image-only pages of each input before producing output. The
output format follows the same -o rules, but the default is a searchable PDF rather
than HTML:
-oends in.pdf→ a searchable PDF.-oends in.md/.markdown, or--markdownis given → OCR'd Markdown.-oends in.html/.htm→ OCR'd HTML.- otherwise → a searchable PDF written as
<stem>.searchable.pdfbeside the source (.searchable.html/.searchable.mdfor those formats).
$ distillpdf scan.pdf --ocr # → scan.searchable.pdf (fast tier, bundled)
$ distillpdf scan.pdf --ocr --remove-raster # reflow to clean text + figures, smaller file
$ distillpdf scan.pdf --ocr -o out.html # OCR'd HTML (use a .md path for Markdown)
$ distillpdf scan.pdf --ocr --ocr-engine accurate # granite-docling (needs a runtime you install)
$ distillpdf --list-ocr-engines # show engines: name, tier, bundled, offline
Before running, distillpdf counts the scanned pages. If none are detected it prints
no scanned/image-only pages detected; nothing to OCR and moves on. Otherwise it shows a
progress bar and a line like
OCR'ing 12 scanned page(s) [tesseract] -> scan.searchable.pdf.
--ocr-engine takes a tier (fast = the bundled Tesseract build, no extra or
download; accurate = granite-docling, which needs a heavier model runtime you install
yourself — there is no single [ocr] extra) or a specific backend name. --list-ocr-engines prints each engine's name, tier, and whether it is
bundled, offline, and currently available:
$ distillpdf --list-ocr-engines
name tier bundled offline avail description
tesseract fast True True True …
granite-docling accurate False False False …
--remove-raster only applies to --ocr with a PDF output: instead of keeping the
original scan with a hidden text layer, it reflows pages to clean text plus cropped
figures and drops the page raster, producing a much smaller file.
For installing the accurate tier and choosing a runtime, see the dedicated
OCR for scanned PDFs guide and the OCR runtime setup page.
Querying a .dpdf (the agent shell)¶
When the first argument is a .dpdf followed by a verb, distillpdf becomes a document
shell. The shell is built for agents: every listing emits ids that thread into the next
call, read carries navigation breadcrumbs and bounds its output with resumable
truncation, and find reports a coverage line so "not found" is a measured statement.
--json is available on every verb and mirrors the human output for structured
callers.
$ distillpdf case.dpdf info # pages, sections, tables, OCR state, assets
$ distillpdf case.dpdf toc # section tree: ids → read targets
$ distillpdf case.dpdf read sec-methods # one section as markdown + breadcrumbs
$ distillpdf case.dpdf find "fls. 249" --pages xii-xv # scoped lexical search
$ distillpdf case.dpdf embed # build the semantic index (BAAI/bge-m3)
$ distillpdf case.dpdf search "limits on liability" # semantic search; ids thread into read
If the file is not a readable .dpdf, the shell says what it looks like and reminds you
how to make one: make one with distillpdf <file.pdf> -o <file>.dpdf.
info¶
distillpdf case.dpdf info prints a roll-up: schema version and the distillpdf release
that wrote it, the source file's sha256 prefix and generation time, counts of pages /
sections / blocks / tables / figures / footnotes, sectioned coverage, OCR state, the asset
profile, and the embedding spaces (or none (run embed to enable semantic search)).
$ distillpdf case.dpdf info
case.pdf (schema v0, distillpdf 0.0.33)
source sha256: a1b2c3d4… generated 2026-06-05T…
pages: 318 sections: 24 blocks: 1840
tables: 6 figures: 11 footnotes: 92
sectioned coverage: 96% (12 unsectioned)
OCR: none (born-digital text layer)
embeddings: none (run `embed` to enable semantic search)
toc¶
The section tree: each row is <id> <title> (page span), indented by level. The id is a
read target.
$ distillpdf case.dpdf toc
sec-intro Introduction (p1-3)
sec-methods Methods (p4-9)
sec-results Results (p10-15)
tables / figures¶
Kind indexes. Each row is <id> <label> p<page> — <caption>, with a page-label suffix
(e.g. p13 (xiii)) when the page carries a /PageLabels label. The id threads into
read and find --kind.
$ distillpdf case.dpdf tables
b0421 Table 1 p13 — Summary of measured parameters
$ distillpdf case.dpdf figures --json
{ "figures": [ … ] }
read¶
read renders a target as Markdown. The target is a section id, a block id, or a
--pages range — exactly one of them.
$ distillpdf case.dpdf read sec-methods # a section
$ distillpdf case.dpdf read b0421 # a single block
$ distillpdf case.dpdf read --pages xii-xv # a page range (labels or physical)
| Option | Effect |
|---|---|
target (positional, optional) |
A section id or a block id. |
--pages N-M |
Read a page range instead. Physical numbers or page labels on either side (e.g. xii-xv). |
--max-chars N |
Bound the output (default 6000); truncation is resumable. |
--from BLOCK-ID |
Resume reading at this block id. |
--json |
Structured output. |
Output is bounded to --max-chars (default 6000), always showing at least one block so a
single oversized block still makes progress. When it stops short, read prints an honest,
resumable truncation line naming exactly where it stopped and how to continue:
$ distillpdf case.dpdf read sec-methods
## Methods
…
showing blocks b0410–b0431 of b0410–b0498; continue: read sec-methods --from b0432
prev: sec-intro · next: sec-results · parent: —
The last line is the navigation breadcrumb (prev · next · parent); a missing neighbour
shows as an em dash. Reading by --pages has no breadcrumb. A --pages token is tried
whole first, so a label that itself contains - (e.g. A-1) resolves as a single page
before being read as a range. An unresolvable page or unknown id is an error, never a
silent empty result; an unknown id suggests the closest section ids.
find¶
Lexical search over block text — the exact-phrase primitive. Case-insensitive substring by
default, or a regex with --regex.
$ distillpdf case.dpdf find "limitation of liability"
$ distillpdf case.dpdf find "fls\.\s*\d+" --regex
$ distillpdf case.dpdf find "indemnity" --section sec-methods --context 2
| Option | Effect |
|---|---|
query (positional) |
Substring (default) or regex (with --regex). |
--regex |
Treat the query as a regex (case-insensitive). |
--section ID |
Scope to a section id. |
--kind KIND |
Scope to a block kind (table, figure, …). |
--pages N-M |
Scope to a page range (physical or labels). |
--context N |
Also list N neighbouring block ids per hit. |
--limit N |
Max matches to print (default 20); never silently truncated. |
--semantic |
Run semantic search instead — an alias for search (see below). |
--space ID |
With --semantic, the embedding space id (default: the first). |
--json |
Structured output. |
Each hit is <block-id> [<section>] p<page> <snippet>, the matched span wrapped in
«…». The run always ends with a coverage line — how many blocks across how many
pages were searched, and which pages have no text — so a zero-match result is a measured
statement, not a shrug:
$ distillpdf case.dpdf find "fls. 249" --pages xii-xv
b0007 [sec-front] p13 (xiii) …per «fls. 249» of the…
searched 41 blocks across 4 pages
When there are more matches than --limit, find says how many it held back and how to
see them (narrow with --section/--pages or raise --limit) rather than cutting silently.
find --semantic is a convenience alias that routes straight to search with the same
query (the lexical scope flags do not apply to a vector query).
embed¶
embed derives chunks from the blocks, embeds them with BAAI/bge-m3, and writes the
vectors back into the .dpdf as an embedding space (saving the file). This needs the
optional ONNX runtime; a missing dependency prints the exact install line and exits
non-zero — never a silent no-op.
$ distillpdf case.dpdf embed
distillpdf: embedded 412 chunks into space 'e1' (BAAI/bge-m3, dim 1024, backend …)
| Option | Effect |
|---|---|
--space ID |
Embedding space id to write (default: e1). |
--cache-dir DIR |
Where the bge-m3 weights are cached (default: ~/.cache/fastembed or FASTEMBED_CACHE_PATH; HF_HOME is also honoured). |
--batch-size N |
Chunks per embedding batch (default 32). |
--json |
Structured output. |
search¶
Semantic search over the chunk embeddings (run embed first). Ranks chunks by cosine
similarity to the query and prints the top-k, whose block ids thread into read.
$ distillpdf case.dpdf search "limits on liability"
semantic search over 412 chunks (model BAAI/bge-m3, space e1)
c0033 score=0.7421 [sec-terms] p22-23
…the supplier's aggregate liability under this agreement shall not exceed…
blocks: b0512 b0513
| Option | Effect |
|---|---|
query (positional) |
Natural-language query. |
--k N |
How many chunks to return (default 8). |
--space ID |
Embedding space id (default: the first). |
--json |
Structured output. |
A header line states how many chunks (and which model / space) were searched. If the space
is stale — the document's blocks changed since embedding — it prints a WARNING and
suggests refreshing with embed. A missing space or missing runtime is an actionable
error, not a crash.
See Searching documents for how lexical and semantic search compare and when to reach for each.
ocr-status¶
Per-page OCR decisions and per-pass outcomes recorded in the model. A born-digital
document prints no OCR passes recorded.
$ distillpdf case.dpdf ocr-status
per-page decisions:
p4: ocr active: pass-1
per-pass outcomes:
pass engine page outcome legible conf
pass-1 tesseract 4 ok 1832 0.91
Notes¶
--jsonon any shell verb produces the structured form of the same data — use it from scripts and agents instead of parsing the human layout.- Both faces share the
distillpdf/python -m distillpdfentry point; see the Python API for the library calls these commands wrap, and the design notes for why the agent shell is shaped the way it is. - Source: github.com/kkollsga/distillpdf.