Back to News & Insights
JavaScript September 21, 2026 · 6 min read

OCR that looked like it worked

Five bugs in a browser OCR pipeline, every one of which produced a plausible success instead of an error. Measured before and after, with the code paths named.

OCR that looked like it worked

For months the OCR on this site returned a file. It took a believable four or five seconds, reported no error, and handed back a PDF of the right page count. The text layer inside it was empty.

Nobody complained, because there was nothing to complain about. A searchable PDF with no searchable text looks exactly like a searchable PDF until you press Ctrl+F. It took a benchmark harness with a ground-truth word list to notice, and what it found was one number that explained everything:

| File | "Searchable PDF" output | "Text only" output | |---|---|---| | scan-150dpi-5p.pdf | 0.0% word recall | 100.0% word recall |

Recognition was perfect. Everything downstream of it was broken. Below are the five faults, in the order they had to be peeled back, because each one hid the next. A crash that only happened on good scans

Three-hundred-dpi scans failed on page one. Hundred-and-fifty-dpi scans went all the way through. That is backwards from every intuition about "large files are harder", and the reason is a code path that only large pages reach.

pdf.js renders through a canvas factory, and its default is DOMCanvasFactory, which calls document.createElement('canvas'). This code runs in a Web Worker, where document does not exist. But the default factory is not reached on every render. It is reached through ImageResizer, which engages once a page exceeds MINIMAGEDIM, 2048 pixels. A 300 dpi A4 page is 2481 x 3507. A 150 dpi page is 1240 x 1754, under the line, and never touches that path at all.

So the bug was invisible at the resolution anybody would use for a quick test, and the threshold depends on the machine, which means "works on my machine" was not a figure of speech here. It was literally true and completely useless.

The fix is a canvas factory built on OffscreenCanvas, which a worker does have, injected where pdf.js expects its own. The text layer was never written

The code read the recognised words from result.data.words. In tesseract.js v7 that field does not exist. Words live at data.blocks[].paragraphs[].lines[].words[], and the old flat array is gone.

result.words was undefined, so (result.words || []) gave an empty array, so the guard never fired, so no text layer was drawn, so no error was raised. The failure path and the legitimate path are identical: "this scan contained no recognisable words" is a perfectly normal outcome for a blank page, and the code reported it the same way. The field exists, and it is null on purpose

Reading from data.blocks instead of data.words did not fix it. Recall stayed at 0%. data.blocks was there in the result object, and its value was null.

The reason is in tesseract.js/src/worker-script/constants/defaultOutput.js: blocks is off by default. You have to ask for it:

This is the one worth the article. A field that is absent tells you that you are on the wrong version or the wrong path, and you go and read the types. A field that is present and null tells you that recognition ran and found nothing, which is a normal answer to a normal question. There is nothing to grep for, no stack trace, no deprecation warning. It reads as working code returning a disappointing result.

Two hours went into checking the scan quality, the render resolution, and the language data before anybody suspected the output flags. The scan was fine the whole time. Polish disappeared, and so did five other languages

With words finally reaching the page, page.drawText() wrote them into the PDF. Called without an explicit font, pdf-lib falls back to a standard font with WinAnsi encoding, and WinAnsi cannot represent most of what the language dropdown offered.

Out of ąćęłńóśźż, exactly one character survived: ó. For Russian, Japanese, Chinese, Arabic and Hindi, every single word threw an encoding exception. The exceptions went into an empty catch, so the pages came out clean and wordless.

Six of the twelve languages in the dropdown could not produce a text layer at all, while the interface advertised "multi-language recognition, including languages with diacritics". Polish, the one language the author actually needed, was not even in the list.

The fix is a real embedded font, registered through fontkit and subset into the output. The language list then got cut to the eleven that the embedded font provably encodes, verified by a round trip: write the words, read the PDF back, compare. Chinese, Japanese and Hindi came back as NUL bytes and were removed. Arabic survives the round trip but its accuracy is unmeasured, so it stays out until it is measured. The error message blamed the user

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation