# PDF to Markdown library for TypeScript, open source

URL: https://mindmax.me/open-source/pdf-markdown/
Last verified: 2026-11-09

> The engine behind MindMax&#x27;s PDF conversion is published as @mindmax/pdf-markdown under Apache-2.0. What it rebuilds, what it refuses to do, and what keeps it honest.

## The PDF → Markdown engine, in the open

The converter on [the PDF to Markdown page](https://mindmax.me/tools/pdf-to-markdown/) 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[​](https://mindmax.me/#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&#x27;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 `/Artifact` and 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[​](https://mindmax.me/#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[​](https://mindmax.me/#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[​](https://mindmax.me/#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 &#x27;@mindmax/pdf-markdown&#x27;;

// 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; // &#x27;scanned&#x27; | &#x27;empty&#x27;

}

`

`

import { buildPdfTextMap } from &#x27;@mindmax/pdf-markdown&#x27;;

// 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&#x27;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[​](https://mindmax.me/#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](https://mindmax.me/open-source/pdf-markdown/benchmark/) 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[​](https://mindmax.me/#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](https://mindmax.me/open-source/) states that line
once, with the commitments the team makes on security, governance and continuity.

## Trying it, and contributing[​](https://mindmax.me/#trying-it-and-contributing)

The shortest path to seeing the output is [the converter itself](https://mindmax.me/tools/pdf-to-markdown/): it runs
this library in your browser, with no install and no account. A [bare playground](https://woodpeng.github.io/mindmax-pdf-markdown/)
takes a PDF and prints the Markdown, with nothing else around it; its source is the repository&#x27;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](https://github.com/woodpeng/mindmax-pdf-markdown/blob/main/CONTRIBUTING.md).

If you only want to report a bad conversion, the
[corpus case template](https://github.com/woodpeng/mindmax-pdf-markdown/issues/new?template=corpus_case.yml)
asks for the one thing that makes it fixable: the document, or a page from it you have the right
to share.

- [What it rebuilds](https://mindmax.me/#what-it-rebuilds)
- [What it refuses to do](https://mindmax.me/#what-it-refuses-to-do)
- [How a page becomes Markdown](https://mindmax.me/#how-a-page-becomes-markdown)
- [Two ways to call it](https://mindmax.me/#two-ways-to-call-it)
- [Why the corpus matters](https://mindmax.me/#why-the-corpus-matters)
- [Who maintains it](https://mindmax.me/#who-maintains-it)
- [Trying it, and contributing](https://mindmax.me/#trying-it-and-contributing)
