<?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: li Lu</title>
    <description>The latest articles on DEV Community by li Lu (@li_lu_8925b64a9636130a26d).</description>
    <link>https://dev.to/li_lu_8925b64a9636130a26d</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%2F4048669%2Fbb0405a5-12dc-4f7e-a3c8-334e20660137.jpg</url>
      <title>DEV Community: li Lu</title>
      <link>https://dev.to/li_lu_8925b64a9636130a26d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/li_lu_8925b64a9636130a26d"/>
    <language>en</language>
    <item>
      <title>Translating a scanned PDF is a graphics problem, not a translation problem</title>
      <dc:creator>li Lu</dc:creator>
      <pubDate>Thu, 13 Aug 2026 11:39:05 +0000</pubDate>
      <link>https://dev.to/li_lu_8925b64a9636130a26d/translating-a-scanned-pdf-is-a-graphics-problem-not-a-translation-problem-1d0p</link>
      <guid>https://dev.to/li_lu_8925b64a9636130a26d/translating-a-scanned-pdf-is-a-graphics-problem-not-a-translation-problem-1d0p</guid>
      <description>&lt;p&gt;Every "translate your PDF" tool works beautifully until you feed it a scan.&lt;/p&gt;

&lt;p&gt;Then you get back a wall of reflowed text. The table is gone. The stamp is floating over a paragraph. The two-column layout became one column. For a digital PDF this doesn't happen, because the text is &lt;em&gt;in&lt;/em&gt; the file and you can swap it in place. For a scan there is no text — there are pixels that look like text.&lt;/p&gt;

&lt;p&gt;I spent a while building a pipeline for this, and the thing that surprised me is how little of the work is translation. Translation is one API call. The hard parts are all graphics:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Erase the original ink from the bitmap without destroying what's underneath.&lt;/li&gt;
&lt;li&gt;Re-typeset the translation into the space it left — usually more text than you started with.&lt;/li&gt;
&lt;li&gt;Do both fast enough that a long document isn't a coffee break.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's roughly what each of those involves.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The mask is the whole game
&lt;/h2&gt;

&lt;p&gt;To erase text you need an inpainting model (I use &lt;a href="https://github.com/advimman/lama" rel="noopener noreferrer"&gt;LaMa&lt;/a&gt;) and a binary mask saying which pixels to remove. The mask is where all the quality lives.&lt;/p&gt;

&lt;p&gt;The obvious mask is the OCR bounding box, filled solid. Don't do this. Official documents are exactly the case where the box is full of things you want to keep: table rules, the underline of a signature line, the edge of a seal, background texture. Fill the box and you erase all of it, and the inpainter cheerfully hallucinates blank paper in its place.&lt;/p&gt;

&lt;p&gt;What works better is masking only the ink. Otsu-threshold inside the box, and take the dark class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;roi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cvtColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img_bgr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;y0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;y1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;x1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COLOR_BGR2GRAY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strokes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;roi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                           &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;THRESH_BINARY_INV&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;THRESH_OTSU&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This breaks on white-on-black headers — Otsu has no idea which class is ink. The fix is cheap and has been reliable: the border of a text bbox is almost always background, so if the border mostly landed in the "stroke" class, flip it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;border&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concatenate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;strokes&lt;/span&gt;&lt;span class="p"&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="n"&gt;strokes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:],&lt;/span&gt; &lt;span class="n"&gt;strokes&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;strokes&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;border&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;127&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# light text on dark background
&lt;/span&gt;    &lt;span class="n"&gt;strokes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;strokes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then dilate a little so anti-aliased edges get taken too. Leftover anti-aliasing shows up as a grey ghost of the original word, which looks worse than a slightly-too-large mask.&lt;/p&gt;

&lt;p&gt;One more thing worth doing: don't send the whole page to the GPU. Merge nearby boxes, crop each group with a context margin, and inpaint the crops. LaMa needs surrounding context to reconstruct texture, but it needs &lt;em&gt;local&lt;/em&gt; context — a modest margin does as well as the full page, and you move a fraction of the bytes.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The translation doesn't fit
&lt;/h2&gt;

&lt;p&gt;German is long. So is Spanish, relative to English. You will routinely get a translated paragraph 30% longer than the source, and it has to go back into a box of fixed size.&lt;/p&gt;

&lt;p&gt;You also lost the line breaks — OCR gives you text, not the original wrapping, so you have to re-wrap, and the wrap depends on the size you pick. It's a 2D fit with no closed form. Binary search:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;max_fit_scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;fits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
    &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FLOOR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;fits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt;                  &lt;span class="c1"&gt;# even the floor overflows
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;fits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fits()&lt;/code&gt; is a dry run of the real wrap on a scratch page, so the test and the render can't disagree. That sounds obvious and I did not do it that way at first; having a cheap approximate &lt;code&gt;fits()&lt;/code&gt; produced a class of bug where the fit search was confident and the renderer overflowed anyway.&lt;/p&gt;

&lt;p&gt;Two things I'd pass on:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shrink per paragraph, not per line.&lt;/strong&gt; If each line finds its own best scale, a paragraph where line 3 happens to be dense renders line 3 smaller than lines 2 and 4. Every line individually fits, and it reads as broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep a floor and treat hitting it as a signal, not a fallback.&lt;/strong&gt; A region that can't fit even at half size usually isn't a fitting problem — it's OCR having merged two blocks upstream. Shrinking to unreadability hides the actual bug.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Making it not slow
&lt;/h2&gt;

&lt;p&gt;Per page the stages are: analyze (local, fast), translate (LLM API), inpaint (GPU API), render (local, fast).&lt;/p&gt;

&lt;p&gt;The useful observation is that translate and inpaint touch completely different inputs — one needs text, the other needs pixels — so they have no reason to be sequential:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;analyze
    ├─ translate (API)   ─┐  independent: text vs pixels
    └─ inpaint   (GPU)   ─┘  run concurrently
render
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Page latency becomes &lt;code&gt;analyze + max(translate, inpaint) + render&lt;/code&gt; instead of the sum. Both middle stages are network-bound, so this is close to free.&lt;/p&gt;

&lt;p&gt;You can push it further. Translation only needs the OCR text, which exists before you rasterise anything — so translating &lt;em&gt;before&lt;/em&gt; the page bitmap is in memory means the memory-heavy pixel stage never sits blocked on a network call. On a long document that's the difference between comfortable memory use and an OOM.&lt;/p&gt;

&lt;p&gt;One LLM-engineering note while I'm here: batch whole paragraphs into one request with index-tagged segments in and strict JSON out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Indices in the payload, not just array order. If the model drops a segment you want that one paragraph to fall back to its source text — not every subsequent paragraph to shift up by one and land in the wrong box. That failure mode is silent, and it is genuinely miserable to debug from a rendered PDF.&lt;/p&gt;




&lt;h2&gt;
  
  
  What makes this problem domain hard
&lt;/h2&gt;

&lt;p&gt;If you're considering building in this space, the difficulty is not where it looks like it is. Some things that are harder than they appear, none of them specific to my implementation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OCR errors are unrecoverable downstream.&lt;/strong&gt; If OCR merges two table rows because the separator is faint, no amount of good rendering fixes it. Quality is capped upstream, and most of what feels like a "layout bug" is an OCR bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writing systems are not interchangeable.&lt;/strong&gt; Vertical Japanese needs a different layout model, not a rotated one. Arabic needs cursive joining and RTL to survive three format conversions in a row. Every script you add is real work, not a font swap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every failure is visual.&lt;/strong&gt; You cannot unit-test "this looks right." Building a way to eyeball page-level diffs quickly matters more than it should.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general lesson, if there is one: for scanned documents, translation quality is rarely the bottleneck. Getting the ink off the page cleanly, and putting new ink back so that it belongs there, is where the perceived quality comes from. A mediocre translation in the right place looks better than a great translation in a wall of reflowed text.&lt;/p&gt;




&lt;p&gt;The pipeline described here runs at &lt;a href="https://tryreglyph.com" rel="noopener noreferrer"&gt;tryreglyph.com&lt;/a&gt; if you want to throw a scan at it. Happy to go deeper in the comments — the masking step in particular, I'd like to hear how other people have approached it.&lt;/p&gt;

</description>
      <category>python</category>
      <category>computervision</category>
      <category>opencv</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
