<?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>I tested 6 ways to translate a scanned PDF without losing the formatting</title>
      <dc:creator>li Lu</dc:creator>
      <pubDate>Mon, 24 Aug 2026 12:14:55 +0000</pubDate>
      <link>https://dev.to/li_lu_8925b64a9636130a26d/i-tested-6-ways-to-translate-a-scanned-pdf-without-losing-the-formatting-b74</link>
      <guid>https://dev.to/li_lu_8925b64a9636130a26d/i-tested-6-ways-to-translate-a-scanned-pdf-without-losing-the-formatting-b74</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Disclosure up front: I build one of the tools in this list (Reglyph). I've&lt;br&gt;
tried to be concrete enough about the others that the comparison is useful&lt;br&gt;
anyway — every claim below is something you can reproduce in five minutes with&lt;br&gt;
one scanned page.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Translate this PDF" is a solved problem — until the PDF is a scan.&lt;/p&gt;

&lt;p&gt;A digital PDF has text inside it. A tool can swap that text in place and the&lt;br&gt;
layout survives. A &lt;strong&gt;scanned&lt;/strong&gt; PDF has no text at all, just a photograph of a&lt;br&gt;
page. Most translation tools handle this by OCR-ing the page, translating the&lt;br&gt;
text, and giving you back… a plain text dump. The table is gone. The stamp is&lt;br&gt;
gone. The two-column layout is one long column.&lt;/p&gt;

&lt;p&gt;That matters because scans are exactly the documents where layout matters:&lt;br&gt;
birth certificates, transcripts, contracts, invoices, technical manuals. A&lt;br&gt;
reflowed wall of text is often unusable for the purpose you needed the&lt;br&gt;
translation for.&lt;/p&gt;

&lt;p&gt;I ran the same test document — a scanned certificate with a table, a seal, and&lt;br&gt;
a signature line — through six approaches. Here's what each one actually does.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Google Translate (document upload)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does with a scan:&lt;/strong&gt; accepts the upload, OCRs it, and returns the&lt;br&gt;
translation as reflowed text. On image-only PDFs you often get an HTML-ish page&lt;br&gt;
or a text-layer overlay; the visual layout of the original is not reconstructed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Free, instant, fine for "just tell me what this says"&lt;/li&gt;
&lt;li&gt;❌ Tables collapse, images/stamps disappear, no output that looks like the
original document&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use it when:&lt;/strong&gt; you only need to understand the content, not present it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. DocTranslator / online doc translators
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does with a scan:&lt;/strong&gt; these tools shine on &lt;em&gt;digital&lt;/em&gt; Office/PDF files,&lt;br&gt;
where they genuinely preserve formatting by editing the file's own text runs.&lt;br&gt;
Feed them a scan and there are no text runs to edit — you get OCR-then-reflow,&lt;br&gt;
same failure mode as above, sometimes with a "this file appears to be scanned"&lt;br&gt;
warning, sometimes silently.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Great for .docx and born-digital PDFs&lt;/li&gt;
&lt;li&gt;❌ The formatting-preservation promise quietly stops applying at scans&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use it when:&lt;/strong&gt; your PDF was exported from Word, not from a scanner.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. DeepL (PDF upload)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does with a scan:&lt;/strong&gt; best-in-class translation quality, and its PDF&lt;br&gt;
handling re-typesets digital PDFs impressively well. Scanned/image-only PDFs&lt;br&gt;
are the weak spot — depending on plan and settings you either get a rejection&lt;br&gt;
or an OCR-and-reflow result. Layout reconstruction of a bitmap page isn't what&lt;br&gt;
it's built for.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ The translation itself is excellent&lt;/li&gt;
&lt;li&gt;❌ Scans are either unsupported or come back reflowed; page limits on free
tier&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use it when:&lt;/strong&gt; translation nuance matters more than the page looking like
the original.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. OCR + Word, by hand
&lt;/h2&gt;

&lt;p&gt;The DIY route: run OCR (Acrobat, ABBYY, or &lt;code&gt;ocrmypdf&lt;/code&gt;), open the result in&lt;br&gt;
Word, translate chunk by chunk (or with Word's built-in translator), then fix&lt;br&gt;
the layout manually.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Full control; can reach genuinely faithful results&lt;/li&gt;
&lt;li&gt;❌ It's &lt;em&gt;work&lt;/em&gt;. A 10-page certificate packet with tables took me well over an
hour, and OCR errors in table cells mean you're also proofreading a layout
you didn't design&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use it when:&lt;/strong&gt; one high-stakes document, and you're billing someone for the
time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. A vision LLM (GPT-4o / Gemini / Claude)
&lt;/h2&gt;

&lt;p&gt;Screenshot a page, ask the model to translate it. The translations are good and&lt;br&gt;
the model &lt;em&gt;understands&lt;/em&gt; the layout — it can even output a Markdown table that&lt;br&gt;
mirrors the original.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Surprisingly strong comprehension of messy scans; great for Q&amp;amp;A about a
document&lt;/li&gt;
&lt;li&gt;❌ Output is text/Markdown, not a document. Nobody will accept a Markdown
birth certificate. Multi-page = manual loop, and hallucinated digits are a
real risk on numbers-heavy pages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use it when:&lt;/strong&gt; you need to &lt;em&gt;interrogate&lt;/em&gt; a document, not reproduce it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Reglyph (the one I built)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://tryreglyph.com" rel="noopener noreferrer"&gt;Reglyph&lt;/a&gt; exists specifically for the scan case. It&lt;br&gt;
OCRs the page, &lt;em&gt;erases the original text from the image&lt;/em&gt; with an inpainting&lt;br&gt;
model, translates the text, and re-typesets the translation back into the&lt;br&gt;
original positions. The output is a PDF that still looks like your document:&lt;br&gt;
tables, columns, seals, signature lines and numbers stay where they were.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Layout survives on scans and even phone photos; 19 languages both
directions, including CJK, Arabic (proper RTL + cursive joining) and
Devanagari&lt;/li&gt;
&lt;li&gt;❌ Machine translation quality, not human-translator quality — for certified
translations you still need a human to review; it's a web app, no offline
mode&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; 5 free pages every day, no credit card.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The actual decision table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Born-digital PDF or .docx&lt;/td&gt;
&lt;td&gt;DocTranslator-style tools or DeepL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scan, and you just need the gist&lt;/td&gt;
&lt;td&gt;Google Translate or a vision LLM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scan, and the output must look like the original&lt;/td&gt;
&lt;td&gt;Reglyph, or OCR + Word by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One high-stakes page, unlimited time&lt;/td&gt;
&lt;td&gt;OCR + Word, then human review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Asking questions &lt;em&gt;about&lt;/em&gt; a foreign-language scan&lt;/td&gt;
&lt;td&gt;Vision LLM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern behind the whole list: tools preserve formatting by editing text&lt;br&gt;
that's already in the file. The moment the "text" is pixels, preservation has&lt;br&gt;
to be rebuilt from scratch — erase ink, re-typeset, keep the geometry — and&lt;br&gt;
almost nothing does that. That gap is why every roundup of "PDF translators&lt;br&gt;
that keep formatting" quietly tests on digital PDFs only.&lt;/p&gt;

&lt;p&gt;If you've got a scanned document workflow that beats any row of this table,&lt;br&gt;
I'd genuinely like to hear it — especially for right-to-left targets, which is&lt;br&gt;
where most pipelines (including ours, for a long time) fall over first.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tools</category>
      <category>ai</category>
      <category>translation</category>
    </item>
    <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>
