<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jakub Wietrzyk</title>
    <description>The latest articles on DEV Community by Jakub Wietrzyk (@jaaaco).</description>
    <link>https://dev.to/jaaaco</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F597593%2Ff25e3ac9-826f-49d4-ad1f-c01030fe13bc.png</url>
      <title>DEV Community: Jakub Wietrzyk</title>
      <link>https://dev.to/jaaaco</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jaaaco"/>
    <language>en</language>
    <item>
      <title>OCR that looked like it worked</title>
      <dc:creator>Jakub Wietrzyk</dc:creator>
      <pubDate>Mon, 21 Sep 2026 11:53:37 +0000</pubDate>
      <link>https://dev.to/jaaaco/ocr-that-looked-like-it-worked-na5</link>
      <guid>https://dev.to/jaaaco/ocr-that-looked-like-it-worked-na5</guid>
      <description>&lt;p&gt;For months the OCR on this site returned a file. It took a believable four or five&lt;br&gt;
seconds, reported no error, and handed back a PDF of the right page count. The text&lt;br&gt;
layer inside it was empty.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;"Searchable PDF" output&lt;/th&gt;
&lt;th&gt;"Text only" output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;scan-150dpi-5p.pdf&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0% word recall&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100.0% word recall&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

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

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

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

&lt;p&gt;The fix is a canvas factory built on &lt;code&gt;OffscreenCanvas&lt;/code&gt;, which a worker does have,&lt;br&gt;
injected where pdf.js expects its own.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. The text layer was never written
&lt;/h2&gt;

&lt;p&gt;With the crash gone, 300 dpi scans completed. They still had no text.&lt;/p&gt;

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

&lt;p&gt;What made this survive so long was the shape of the guard around it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// draw the invisible text layer&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Reading from &lt;code&gt;data.blocks&lt;/code&gt; instead of &lt;code&gt;data.words&lt;/code&gt; did not fix it. Recall stayed at 0%.&lt;br&gt;
&lt;code&gt;data.blocks&lt;/code&gt; was there in the result object, and its value was &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The reason is in &lt;code&gt;tesseract.js/src/worker-script/constants/defaultOutput.js&lt;/code&gt;: &lt;strong&gt;&lt;code&gt;blocks&lt;/code&gt;&lt;br&gt;
is off by default.&lt;/strong&gt; You have to ask for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recognize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Two hours went into checking the scan quality, the render resolution, and the language&lt;br&gt;
data before anybody suspected the output flags. The scan was fine the whole time.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Polish disappeared, and so did five other languages
&lt;/h2&gt;

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

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

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

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

&lt;p&gt;The final indignity. The error handler classified any message containing the word&lt;br&gt;
&lt;code&gt;read&lt;/code&gt; as a file problem, and the message coming out of the broken worker was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cannot read properties of undefined
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a bug in our code told the person using it that their PDF was damaged, and advised&lt;br&gt;
them to re-scan a document that was perfectly fine.&lt;/p&gt;
&lt;h2&gt;
  
  
  After
&lt;/h2&gt;

&lt;p&gt;Same corpus, same browser, measured on production:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;scan-clean-300dpi-3p.pdf&lt;/td&gt;
&lt;td&gt;crash on page 1&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;100.0% recall&lt;/strong&gt;, 2.0s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;scan-150dpi-5p.pdf&lt;/td&gt;
&lt;td&gt;0.0% recall&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;100.0% recall&lt;/strong&gt;, 5.0s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;scan-300dpi-10p.pdf&lt;/td&gt;
&lt;td&gt;crash on page 1&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;100.0% recall&lt;/strong&gt;, 5.0s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The page copy went to the bin along with the bugs. &lt;code&gt;/ocr&lt;/code&gt; claimed multi-language support&lt;br&gt;
it did not have, and the homepage FAQ promised the whole site works with the network&lt;br&gt;
off, which is not true for OCR: the engine and language data are fetched on first use.&lt;/p&gt;
&lt;h2&gt;
  
  
  The thread running through all five
&lt;/h2&gt;

&lt;p&gt;Not one of these raised an error. Every one produced a plausible success:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a factory that is only reached above a size threshold, so the bug is resolution-dependent and machine-dependent&lt;/li&gt;
&lt;li&gt;a renamed field read through &lt;code&gt;|| []&lt;/code&gt;, so a missing structure reads as an empty one&lt;/li&gt;
&lt;li&gt;an output that is &lt;strong&gt;off by default&lt;/strong&gt; and comes back as &lt;code&gt;null&lt;/code&gt;, indistinguishable from "found nothing"&lt;/li&gt;
&lt;li&gt;exceptions thrown per word into an empty &lt;code&gt;catch&lt;/code&gt;, so a total failure looks like a blank page&lt;/li&gt;
&lt;li&gt;an error classifier matching on a substring, turning an internal bug into a user's fault&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical lesson is not "write more tests", because a unit test would have mocked&lt;br&gt;
the very thing that was lying. What caught it was an end-to-end harness running the&lt;br&gt;
real site in a real browser against documents whose correct answer was known in advance.&lt;br&gt;
Word recall against ground truth is a number that cannot be satisfied by code that&lt;br&gt;
merely finishes.&lt;/p&gt;

&lt;p&gt;One more, learned the hard way while fixing this: a late benchmark run from localhost&lt;br&gt;
finished after the production run and overwrote the results file with pre-fix numbers.&lt;br&gt;
It was caught only because somebody read the numbers by hand. That file is the source&lt;br&gt;
of a published page, so mixing origins is the same thing as publishing false data. The&lt;br&gt;
harness now compares the origin recorded in the file and aborts &lt;strong&gt;before&lt;/strong&gt; measuring,&lt;br&gt;
rather than discovering the mismatch fifteen minutes later at write time.&lt;/p&gt;

&lt;p&gt;The corpus, the harness and the measurements are in the repository, and the current&lt;br&gt;
numbers are on the &lt;a href="https://pdf.techsource.pro/blog/pdf-compression-benchmarks" rel="noopener noreferrer"&gt;benchmarks page&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Who writes this site
&lt;/h2&gt;

&lt;p&gt;Worth saying plainly, because it is the same lesson wearing different clothes. Most of&lt;br&gt;
the writing here is drafted by an agent: it harvests the search phrases, writes the&lt;br&gt;
page and opens the commit, and a person reviews before anything ships. This post-mortem&lt;br&gt;
was drafted the same way, from the commit history and the benchmark output, then edited&lt;br&gt;
by hand.&lt;/p&gt;

&lt;p&gt;That arrangement only survives because of a guard built on the same idea as the harness&lt;br&gt;
above. The generator is handed the measured figures and nothing else, and the script&lt;br&gt;
that runs it throws the draft away if the body quotes a percentage outside that set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invented&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;quoted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;allowedNumbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invented&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`quotes percentages that were never measured: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;invented&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It rejects "up to N" phrasing too, because that is the shape a number takes when it has&lt;br&gt;
stopped reporting and started selling.&lt;/p&gt;

&lt;p&gt;The reason for the guard is the reason for this whole article. A language model will&lt;br&gt;
produce a plausible statistic exactly the way the OCR pipeline produced a plausible&lt;br&gt;
PDF: quickly, with no error, and indistinguishable from the real thing right up until&lt;br&gt;
somebody checks it against ground truth. Same failure mode, same fix. Measure the&lt;br&gt;
output, and refuse to ship what you did not measure.&lt;/p&gt;

</description>
      <category>ocr</category>
      <category>benchmarks</category>
      <category>privacy</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
