---
title: "Where is the text?"
url: https://sharadbapat.com/experiments/where-is-the-text/
description: "Every word a born-digital PDF draws, with its box, read straight from the file with no model. 99.55% of 1.7M held-out reference words found."
published: 2026-09-27
author: Sharad Bapat
---

# Where is the text?

A born-digital PDF already records every character it draws and exactly where. I built a small reader that reports every word with its box, straight from the file with no model, and a side-by-side view to check it against the page.

[Try the demo](https://sharadbapat.com/experiments/where-is-the-text/demo/): drop in a PDF and see every word boxed on its page.

[Code on GitHub](https://github.com/sharad-bapat/wordbox)

- **Made:** Sep 2026
- **Built with:** Rust, compiled to WebAssembly; pdf.js draws the page
- **Status:** Working; there's a demo

## The question

[Scan or text?](https://sharadbapat.com/experiments/scan-or-text/) tells a pipeline whether a PDF needs OCR. When it doesn't, the next job is to get the text out and know where each word sits on the page, so a value can be traced back to the spot it came from. For a born-digital file that's already recorded: every glyph, its font, its size and its position. The question was whether a small reader, with no model and no rendering, could report every word with its box as accurately as the standard tools, and say when a page's text layer can't be trusted.

## How it works

It reads each page's drawing instructions and follows the text. Every character code goes through its font to Unicode text, and through the text and page matrices to its place on the page, after the crop box and rotation. The text comes from the file's own records, in a fixed order: the font's ToUnicode map, then its encoding, then the tables inside an embedded TrueType font. A glyph none of them explain is reported as unmapped, and nothing is guessed.

Glyphs become words and lines by fixed spacing rules: a space, a gap wider than 0.15 of the font size, or a new baseline. Text drawn by annotations and form fields is included and flagged, and so is text outside the visible page. Nothing is grouped by meaning. There are no paragraphs, columns or reading order, only what's drawn and where.

Each page also gets a verdict. It's garbled when at least half its glyphs don't decode to real characters, and invisible when at least half are drawn invisibly, the way an OCR layer sits on a scan. Otherwise it's text, or none. The whole thing is about 2,000 lines of Rust, and the WebAssembly build is 375 KB, 150 KB compressed.

## How I tested it

I tuned the rules on 659 files: the 375 source PDFs of [ContractNLI](https://stanfordnlp.github.io/contract-nli/) (Koreeda and Manning, 2021; CC BY 4.0) and 284 from [govdocs1](https://digitalcorpora.org/corpora/file-corpora/files/) thread 001. Then I froze them by a hash of the source and ran them once on 242 files nobody had looked at, govdocs1 thread 002.

The reference is where PyMuPDF 1.24.9 and pdfplumber 0.11.9 agree: the same word, with left and right edges within a point of each other. Neither tool is treated as the truth on its own. Where they disagree I checked a sample by hand, and most of it was pdfplumber running space-less lines into one word or re-sorting overlapping letters.

For the verdict I built broken copies of 60 contracts. Each font's ToUnicode map was replaced, so the pages look the same but the text layer says something else: private-use characters, control characters, or the real text moved one letter along. Every broken page renders pixel-identical to its clean copy, and half the set was held out with the files.

## Results

- **99.55%**: of 1.73M held-out reference words found
- **99.9%**: of those within 1 pt on both edges and the baseline

The verdict was right on every held-out clean, private-use and control-character page, 128 of each. The 51 real held-out pages it called garbled were all checked, and PyMuPDF turns the same pages into strings of control characters. On words the two reference tools disagree about, wordbox has 94.6% of the ones only PyMuPDF finds and 3.2% of the ones only pdfplumber finds.

## Speed and size

|  | Per page | Size |
| --- | --- | --- |
| wordbox, native | 0.72 ms | 636 KB |
| wordbox, WebAssembly in Node | 1.47 ms | 375 KB |
| PyMuPDF 1.24.9 | 1.50 ms | 44.5 MB |
| pdf.js 6.3, in Node | 5.05 ms | 1.7 MB |
| pdfplumber 0.11.9 | 70.4 ms | 9.2 MB, plus dependencies |

I measured on 61 of the held-out files (1,733 pages), in process, with each tool starting from the file's bytes in memory. The WebAssembly build was faster than pdf.js on all 61, and its output matched the native build's byte for byte. pdf.js returns runs of text with a position, not words with boxes, so this compares extraction time only. The output was identical across repeated runs.

## Limits

Text moved one letter along reads as usable text. Catching it would take a word list, which means judging language, and this tool deliberately doesn't. Slanted text gets an upright box around each word. A form that asks viewers to redraw its fields from their current values can show different text from the stored drawing, and wordbox reports the stored drawing. Right-to-left text comes out in drawing order, vertical writing isn't handled, and older compressed Type1 fonts and the built-in Symbol encoding are only partly decoded.

The demo bundles pdf.js to draw the page, but not its CJK maps or standard fonts. Glyph shapes for fonts a file doesn't embed can look different, but the boxes come from the file and don't depend on them.

The WebAssembly build missed my 250 KB target. The glyph-name and standard-width tables take most of it, and they stay as they were when the rules were frozen.

## What didn't work

Three bugs came with the parser I copied from Scan or text. It skipped page content stored as a reference to an array, searched to the end of the file for every object (one file took 11.8 seconds, and now takes 0.1), and unpacked compressed objects in hash-map order, so a duplicated object could resolve differently from one run to the next. That last one showed only as a small count that changed between two runs.

One embedded font had a truncated cmap table, and my first reader dropped the whole table instead of the one segment it couldn't read. I also let a font program override an explicit /Encoding whenever the font was flagged symbolic, and one file lost 42,000 characters to that before I put the explicit encoding first.

Building the reference took three tries at coordinates. PyMuPDF reports its media box and crop box in different orientations, pdfplumber's frame shifts when a page's media box doesn't start at the origin, and the two tools size boxes by different ascent rules. The last one showed up as pages where every word matched on text and failed on position.

One finding about the other tools: when a ToUnicode map points at control characters, PyMuPDF throws those entries away and falls back to the encoding. So a broken text layer can look fine in PyMuPDF, while pdfplumber and wordbox report what the file says.

## Credits

- ContractNLI (Koreeda and Manning, 2021), CC BY 4.0, and govdocs1 from Digital Corpora (Garfinkel et al., 2009), public domain, for the files. None of them is served here. The four samples in the demo are made-up files generated for this page.
- [pdf.js](https://mozilla.github.io/pdf.js/) by Mozilla (Apache-2.0) draws the page in the demo, and its licence is bundled with it.
- PyMuPDF and pdfplumber for the reference. The encodings and glyph list come from the PDF specification and Adobe's glyph list by way of pdfminer.six, and the Macintosh glyph names from fontTools.
