How PDF Extract Text is built
PDF Extract Text pulls the plain text out of a PDF, page by page, entirely in the browser. This post is about the two pieces of that pipeline worth writing up: pdf.js’s text-content API, and reconstructing readable text from what it gives you.
Tech used
pdf.js’s getTextContent() and the TextItem API
The tool uses pdf.js (pdfjs-dist), Mozilla’s PDF renderer and parser — the same engine behind Firefox’s built-in PDF viewer — loaded lazily via a dynamic import() so it never touches the initial page bundle. Parsing itself runs in a Worker pdf.js manages internally (GlobalWorkerOptions.workerSrc), so the actual PDF-parsing work is already off the main thread without this tool needing to write its own Worker wrapper.
The API surface that matters here is page.getTextContent(). It does not return lines or paragraphs. It returns a flat array of TextItem objects — one per contiguous run of glyphs the PDF’s content stream drew with a single text-showing operator — each carrying:
str: the run’s text.transform: a 6-element affine matrix ([a, b, c, d, e, f]) whosee/fare the run’s x/y position in PDF device space.width/height: the run’s rendered size.hasEOL: a boolean pdf.js sets when the next run starts a new line.
Two things fall out of this that a naive items.map(i => i.str).join('') gets wrong. First, two runs that are visually on the same line but separated by a gap (a tab stop, a column boundary, a font change mid-sentence) have no space character between them in str, so words merge: "HelloWorld" instead of "Hello World". Second — the less obvious one — pdf.js represents “end of line” as an item whose own str is "". It carries no visible text at all; the only thing it contributes is the hasEOL flag. Concatenating .str values naively therefore doesn’t just fail to add a line break — it silently drops the line break signal entirely, so the last word of one line gets glued directly onto the first word of the next.
The fix (joinTextItems in src/utils/pdfTextEngine.ts) walks the item list tracking the previous item’s end-x and y:
- Start a new line when the current item has
hasEOL: true, or — belt and braces — when the y position jumps by more than half a line height even without that flag (some content streams position runs without ever setting it). - Within a line, insert a space when the gap between where the previous run visually ends (
x + width) and where the next run starts is wider than roughly 18% of the font height. Real inter-word gaps are comfortably wider than intra-word kerning, so a threshold proportional to font size (rather than a fixed pixel count) tells them apart across different font sizes and zoom levels without needing to know the exact width of a space glyph in whatever font the PDF used.
Standard font metrics and CMaps
Building a two-page test fixture with pdf-lib (drawing individual words at computed x-offsets, rather than one string per line, specifically to exercise the spacing logic above) surfaced a real accuracy gotcha: pdf.js prints a console warning — “Ensure that the standardFontDataUrl API parameter is provided” — and without it, falls back to approximate glyph widths for any of the 14 standard PDF fonts (Helvetica, Times, Courier, …) that aren’t embedded in the PDF, which is extremely common. Those approximate widths throw off exactly the x-gap measurements the spacing heuristic above depends on.
The fix is to give pdf.js standardFontDataUrl (accurate metrics for the standard fonts) and cMapUrl + cMapPacked: true (character maps for CID-keyed fonts, which many PDFs use to encode non-Latin text — including Japanese and Chinese). Both data sets ship inside the pdfjs-dist package; this tool copies them verbatim into public/pdf-extract-text/pdfjs/{standard_fonts,cmaps}/ as same-origin static files pdf.js fetches on demand, rather than reaching for a CDN.
Implementation & operational notes
- No extra Worker layer. pdf.js already parses off the main thread via its own worker, so this tool has no
src/workers/at all — wrapping an already-off-main-thread library in a second worker would add indirection for no benefit. - Detecting a PDF with no text layer. A scanned page or photo saved as a PDF of images has an empty (or near-empty)
getTextContent()result. Rather than ship a near-empty.txtfile silently, the tool sums non-whitespace characters across all pages and, below a small threshold, shows a plain “no text found” notice instead of a download. It does not attempt OCR — recognizing text inside an image is a different, much heavier problem than reading text a PDF already contains. - Test split by what’s actually testable where.
joinTextItemsandbuildCombinedTextare pure functions, unit-tested directly against syntheticTextItem-shaped fixtures (11 cases covering same-line gaps, zero-gap split words,hasEOL, a y-jump withouthasEOL, three-plus consecutive line breaks collapsing to one blank line, and non-textTextMarkedContententries). The parts that actually call into pdf.js (getDocument,getTextContent) are excluded from the unit-coverage gate and instead verified end-to-end with Playwright, against three real generated PDF fixtures: a two-page text PDF (words drawn individually with deliberate gaps, to genuinely exercise the spacing logic rather than relying on a single already-spaced string per line), a PDF containing only an embedded image and no text operators at all (the “scanned PDF” path), and a password-encrypted PDF. - Errors are
AppErrorcodes, not raw messages. Following the house pattern (AppError/resolveErrorMessage), pdf.js’sPasswordExceptionandInvalidPDFExceptionare mapped to stable codes the UI resolves to a localized string per locale, so a raw English pdf.js error message never reaches the user.
Try it / source
- Try it: Extract PDF Text
- Source: github.com/GeppettoAndRomero/pdf-extract-text