The PDF → Markdown engine, in the open
The converter on the PDF to Markdown page is not a service that
happens to be free. It is a TypeScript library running in your browser, published as
@mindmax/pdf-markdown under Apache-2.0, and it is the same code that runs inside MindMax when
you open a PDF there. This page says what it does, what it deliberately does not do, and what
stops it from quietly getting worse.
| Package | @mindmax/pdf-markdown v0.1.0 |
| Source | github.com/woodpeng/mindmax-pdf-markdown |
| Licence | Apache-2.0 — the engine, not the MindMax app; see where the licence stops |
| Runtime dependencies | 0 |
| Shipped size | 25.1 KB gzipped |
| Runs on | Node 18+ and any browser; no fs, no window |
| Guards | 22 documents, 170 tests, 97.3% statements |
What it rebuilds
Copying text out of a PDF gets you the words in the order the producer wrote them to the file, which is rarely the order a person reads them. This library works from the other direction: it takes the text items pdf.js reports, with their positions, and treats the layout as the source of structure.
- Reading order. Two-column papers are read left column first. Text that wraps across a page break stays one paragraph, and a paragraph broken for a column is joined back together.
- Headings. Levels come from type size, weight and numbering (
3.1,第X章,Chapter 4), refined by the PDF's own bookmark outline when it has one, and by the tagged structure tree when it does not. - Tables. Rows and columns are recovered from where the text sits — gutters, alignment, repeated column starts — and written as Markdown tables rather than shredded prose.
- Lists and links. Bullets become
-, numbered items keep their numbers, and a link that a word points to inside the PDF is restored. Where the PDF stores no link annotation, nothing is invented. - Page furniture. Running headers, footers, page numbers and watermarks are removed. A
tagged PDF marks these as
/Artifactand that is used first; geometry is the fallback for the majority of PDFs that ship no tags at all.
The string that comes out is also the string everything anchors into. buildPdfTextMap records
where every page starts and where every text item landed in it, so a reader can highlight a
sentence in the PDF and get the same sentence back — by offset, not by searching for the text
again and hoping it appears once.
What it refuses to do
| Refuses | Why |
|---|---|
| OCR a scanned page | A scan has no text layer. Anything called OCR here would be a model call, a bill and a different product. The library reports that the page is empty instead. |
| Recover equations | A PDF stores positioned glyphs, not formulas. x² arrives as x2. The library detects pages where this happened and says how many, rather than pretending the LaTeX is recoverable without a model. |
| Carry images across | Figures stay in the PDF. Extracting them is a different job with a different failure mode. |
| Upload anything | The package makes no network call. In a browser the file does not leave the tab. |
These are the four things people ask for most, so they are the four things stated plainly. A converter that claims two of them and does neither is worse than one that says where it stops.
How a page becomes Markdown
| Step | What happens |
|---|---|
| 1. Collect | One pdf.js pass per page gathers text items, their transforms, and the /Artifact flags on tagged PDFs. |
| 2. Drop furniture | Artifact-tagged runs are blanked, then geometric repeats across pages (headers, footers, page numbers) are removed. Items keep their indices, so offsets stay valid. |
| 3. Rebuild lines | Glyphs are grouped into lines by baseline, and lines into paragraphs by spacing and indentation. Hyphenated words across a break are rejoined. |
| 4. Order the page | Column gutters are detected and the page is read column by column; a full-width block splits the page into bands. |
| 5. Classify blocks | Headings from size and numbering, lists from markers, tables from geometry, footnotes from size and rules. |
| 6. Emit | Markdown, plus the map of what came from the PDF and what the assembler wrote. Nothing here reformats text to look tidier, because that would move every offset. |
Two ways to call it
Most callers want a document. Some want the structural text and the mapping, and those two should not share one API that does both badly.
import { convertPdfToMarkdown } from '@mindmax/pdf-markdown';
// You already have an opened pdf.js document.
const result = convertPdfToMarkdown(pdfDocument);
if (result.ok) {
result.markdown; // the .md file
result.links; // how many hyperlinks were restored
result.textLayerIssues; // pages with equations or unmapped glyphs, or null
} else {
result.reason; // 'scanned' | 'empty'
}
import { buildPdfTextMap } from '@mindmax/pdf-markdown';
// You want the canonical text, not a file.
const map = buildPdfTextMap(pagesOfTextItems);
map.text; // the Markdown-shaped text every anchor addresses
map.pages; // per page: where it starts, and each item's range inside it
CANONICAL_TEXT_VERSION is exported for the same reason those offsets exist: if you store them,
you need to know when a version bump could move them. It changes only when map.text changes for
the same input — Markdown rendering can change without bumping it.
Nothing above imports pdf.js. The Pdf*Like types are structural, so a browser build, a Node
legacy build and a test stand-in all satisfy them, and the package never pulls a second copy of
pdf.js into your bundle.
Why the corpus matters
The repository carries 22 real documents and the exact Markdown each one produced. Sixteen are CC-BY, five are US federal works, one is an official Chinese government document — every one is redistributable, so the snapshots can live in the repository and the tests run anywhere.
When a PDF converts badly, the fix is not "adjust the heuristic and hope". The document joins the corpus with a failing assertion describing what is wrong; the test is red; the code changes until it is green; the case stays forever. That is the whole contribution model, and it is why a bug report with a document attached is worth more than a long description of one.
The results table is that corpus with its numbers: what each document produced, how many assertions check it, and the defects still open. It is rendered from the JSON the test run writes, so there is no hand-typed figure to go stale.
Who maintains it
The MindMax team maintains the engine in public, and MindMax itself depends on the published package rather than a private copy, so a fix for the app is a release here first. The licence covers this engine, not the app around it; the open-source page states that line once, with the commitments the team makes on security, governance and continuity.
Trying it, and contributing
The shortest path to seeing the output is the converter itself: it runs
this library in your browser, with no install and no account. A bare playground
takes a PDF and prints the Markdown, with nothing else around it; its source is the repository's
examples/browser directory.
Two contribution rules matter more than the rest. A change that moves the output of an existing corpus document needs a reason written in the diff, and a change that fixes one document must not break another — CI runs every document on every pull request. Everything else is in CONTRIBUTING.md.
If you only want to report a bad conversion, the corpus case template asks for the one thing that makes it fixable: the document, or a page from it you have the right to share.