Local, private PDF to Markdown for agents: geometry-first parsing, OCR only where needed.
A PDF decompiler, not an image reader. PDF → Markdown in the browser or Node, in milliseconds for born-digital pages — with on-device OCR spent only on the pixels the text layer can't explain.
Try it in your browser → — drop any PDF; it never leaves the tab.
import { decompile } from 'pdffr';
const { markdown, stats } = await decompile(file);
// stats.firstOutputMs ≈ 150ms for a typical report; the file never leaves the tab
npx pdffr report.pdf -o report.md
Every PDF→Markdown tool sits at one of two extremes:
| Approach | Speed | Quality | Problem |
|---|---|---|---|
Text-layer extraction (pdfminer, pdf.js getTextContent) | instant | poor | PDF has no paragraphs, headings, tables or reading order — you get a soup of positioned strings |
| Render + OCR / vision model (Textract, LlamaParse, VLMs) | slow, paid, cloud | high | Rasterizes a page that was already digital, then asks a model to re-read pixels the file could have told it exactly |
~80% of real-world PDFs are born-digital: every glyph's exact coordinates, size and font are already in the file. pdffr treats PDF as what it is — a drawing program — and decompiles the drawing back into structure:
**bold**/*italic*/<sup>, math fonts and sub/superscripts transliterated to $LaTeX$, rotated text re-framed upright, running header/footer stripping, hyphenation repair. No rasterization. Milliseconds per page.pdffr/node runs the identical pipeline on the server or the command line, with @napi-rs/canvas standing in for the DOM.npm install pdffr pdfjs-dist tesseract.js
# Node / CLI additionally:
npm install @napi-rs/canvas
pdfjs-dist and tesseract.js are peer dependencies; @napi-rs/canvas is an optional peer used only by the Node entry.
import { decompile, warmOcr, setPdfWorkerSrc } from 'pdffr';
// Bundled apps: point pdf.js at its worker. Without this, pdffr falls back to the jsdelivr build.
setPdfWorkerSrc(new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).href);
// Optional: pre-load OCR workers while the user is still choosing a file.
warmOcr();
const result = await decompile(file, {
ocr: true, // escalate unexplained ink to on-device OCR (default true)
lang: 'eng', // tesseract language(s): 'deu', 'eng+ara', 'chi_sim', …
concurrency: 4, // pages decompiled in parallel
onPage(page, md) {
// streams: first the native pass, then again as OCR regions land
render(page, md);
},
onEvent(e) {
// every trace line, page (re)emit, and stats update
if (e.type === 'trace') console.log(e.kind, e.msg);
},
});
result.markdown; // the whole document
result.pages[0].blocks; // typed blocks: heading | para | math | list | table
result.stats; // firstOutputMs, nativeDoneMs, totalMs, ocrRegions, nativeChars, ...
import { decompileFile, terminateOcr } from 'pdffr/node';
const { markdown } = await decompileFile('invoice.pdf', { lang: 'deu' });
await terminateOcr(); // let the process exit once the tesseract workers are done
pdffr scan.pdf # markdown on stdout, progress on stderr
pdffr scan.pdf -o scan.md --lang eng+fra
pdffr paper.pdf --no-ocr -q # native text only, silent
pdffr paper.pdf --json --pages 1-3 # per-page markdown, typed blocks and stats as JSON
decompile(input, options?) → Promise<DecompileResult> — input is an ArrayBuffer, Uint8Array, Blob or File. Options: ocr, lang, concurrency, pool, onPage, onEvent, pdfWorkerSrc.decompileFile(path, options?) — Node only.warmOcr(lang?) / terminateOcr() — pre-load or shut down the shared tesseract pool.ocrPool(lang?) — the shared OcrPool; pass your own via options.pool to control worker count.runPipeline(buffer, emit, { ocr, concurrency, escalate }) — the streaming core, if you want raw events.blocksToMarkdown(blocks) — render typed blocks yourself.setPdfWorkerSrc(url) — configure pdf.js's worker.Types: Block, ListItem, Run, Region, Rules, PageState, Stats, PipelineEvent.
| Package | What it is |
|---|---|
pdffr-mcp | MCP server for Claude Desktop / Claude Code / Cursor / any agent: pdf_to_markdown, pdf_outline, pdf_tables — listed on the MCP Registry |
pdffr-langchain | LangChain.js document loader — one Markdown Document per page |
pdffr-llamaindex | LlamaIndex.TS reader — one Markdown Document per page |
langchain-pdffr (PyPI) | Python: pdffr.convert() and a LangChain PdffrLoader, driving the CLI (needs Node 20+) |
from langchain_pdffr import PdffrLoader
docs = PdffrLoader("report.pdf").load() # one Markdown Document per page
{ "mcpServers": { "pdffr": { "command": "npx", "args": ["-y", "pdffr-mcp"] } } }
getTextContent ─► runs (x, y, w, h, size, bold, italic, math font, rotation)
│
getOperatorList ─► exact bitmap rects + ruling lines (CTM walk), font resolution
│
suspicious? (bitmaps, or thin coverage)
│ no │ yes
▼ ▼
structure pass render page once (print intent)
ink mask − native glyph boxes = residual
regions = bitmap rects ∪ residual components
large regions split along their ink
─► OCR pool (2×/3× upsampling for small crops,
second read of doubtful words,
text-plausibility gate)
─► OCR runs join the same structure pass
Structure pass: rotated runs re-framed upright (a dominant rotation turns the whole page; a minority is a sidebar group) → buildLines (math spans → LaTeX) → orderRuns (XY-cut: tall prose gutter → vertical cut; largest whitespace band → horizontal cut; ruled and aligned tables detected first as atomic boxes) → toBlocks (headings, lists with nesting, paragraphs by leading, display math, tables, furniture stripping) → markdown.
npm install
npm run dev
The playground in demo/ shows each page with the engine's decisions drawn on it — text it read straight from the file, regions it sent to OCR and what came back — beside the decompiled document. It opens on a sample report; drop any PDF onto it. Four canonical samples ship with it: a born-digital report (headings, bold runs, a list, a table, a two-column page, running header and page numbers), a full-page scan of the same report, a mixed document with a scanned insert inside native text, and one page each of a ruled table, a rotated sidebar and equations.
docs/benchmark.md compares pdffr with pdf-parse, raw pdf.js text and pdf2md on the four samples — time, and how much of the reference structure (headings, table rows, list items, reading order) each tool reproduces. Regenerate with npm run bench; add a cloud parser to bench/run.mjs if you have a key.
docs/benchmark.md — reproducible comparison against other open-source PDF tools.docs/architecture.md — the pipeline, the render-diff oracle, the shared IR, and every heuristic with its threshold and rationale.CONTRIBUTING.md — layout of the code, the one rule for new heuristics, how to add a test.CHANGELOG.mdnpm test # vitest: unit tests + Node end-to-end runs on the sample PDFs
npm run typecheck
npm run format
npm run build # library to dist/, demo to dist-demo/
CI runs typecheck, format check, tests and the build on every push.
Early. It is accurate on the documents it was built against (reports, Word exports with screenshots, scans, two-column layouts, ruled tables, rotated sidebars, simple equations) and will have gaps on others. What it handles today:
$…$ and display $$…$$ LaTeX.Also: letter-spaced headings, label columns (**KSA-UAE tension** — paragraph layouts become headings over their paragraphs), card/lane layouts, fractions drawn with a bar, multi-line display math, paragraphs cut by a page break, bold recovered from OCR stroke weight.
Known limitations:
aligned blocks are not reconstructed.x2 with no italic or math font) is not recognised as math.Bug reports with a PDF attached are the fastest way to improve it.
MIT © Amer Sarhan
Source-derived launch command. Check the maintainer’s required arguments and credentials before running:
npx -y pdffr-mcpMerge this template into ~/Library/Application Support/Claude/claude_desktop_config.json. Keep existing servers. Add any arguments, credentials, and permissions required by the maintainer; this template has not been install-tested.
{
"mcpServers": {
"io-github-amersarhan-pdffr-mcp": {
"command": "npx",
"args": [
"-y",
"pdffr-mcp"
]
}
}
}Restart Claude Desktop completely for changes to take effect. Confirm the server appears connected in the client’s tool list, then try a read-only example from its documentation.
Claude Desktop setup referencepdffr works with any MCP-compatible client. Copy the config snippet from the Configuration section above and add it to the file shown for your client, then restart the application.
~/Library/Application Support/Claude/claude_desktop_config.jsonRestart Claude Desktop completely for changes to take effect.~/.cursor/mcp.jsonRestart Cursor for changes to take effect..vscode/mcp.jsonReload VS Code window for changes to take effect.~/.codeium/windsurf/mcp_config.jsonRestart Windsurf for changes to take effect..mcp.jsonSave at the project root, then start Claude Code in that project and review the MCP server approval prompt. Keep real credentials out of shared files.