<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Confileo Engineering]]></title><description><![CDATA[Notes from building Confileo — a free, Arabic-first toolkit of 79 PDF, image and document tools. I write about what broke along the way: right-to-left text in P]]></description><link>https://confileo-eng.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Confileo Engineering</title><link>https://confileo-eng.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 25 Sep 2026 08:30:06 GMT</lastBuildDate><atom:link href="https://confileo-eng.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[The Arabic PDF bug was never in my code — it was the library version]]></title><description><![CDATA[In my last post I wrote about why Arabic comes out of PDF extraction in reverse word order. A commenter asked the obvious follow-up question, and it is the one I want to answer properly:

Did you end ]]></description><link>https://confileo-eng.hashnode.dev/the-arabic-pdf-bug-was-never-in-my-code-it-was-the-library-version</link><guid isPermaLink="true">https://confileo-eng.hashnode.dev/the-arabic-pdf-bug-was-never-in-my-code-it-was-the-library-version</guid><category><![CDATA[pdf]]></category><category><![CDATA[Python]]></category><category><![CDATA[i18n]]></category><category><![CDATA[debugging]]></category><dc:creator><![CDATA[Mahmoud Daghash]]></dc:creator><pubDate>Tue, 01 Sep 2026 15:12:08 GMT</pubDate><content:encoded><![CDATA[<p>In my <a href="https://dev.to/support_confileo_ce7442eb/why-arabic-text-comes-out-backwards-when-you-extract-it-from-a-pdf-and-how-to-fix-it-548g">last post</a> I wrote about why Arabic comes out of PDF extraction in reverse word order. A commenter asked the obvious follow-up question, and it is the one I want to answer properly:</p>
<blockquote>
<p>Did you end up running a full bidi pass, or approximating with run-level heuristics? That mixed-direction case is usually where the heuristics fall apart.</p>
</blockquote>
<p>Neither. And that turned out to be the whole point.</p>
<h2>What I thought the problem was</h2>
<p>The naive mental model goes like this: the PDF stores glyphs in the order they were painted, painting for RTL text runs right-to-left, so extraction hands you a line that reads last-word-first. Therefore you must reconstruct logical order yourself — which means implementing the Unicode Bidirectional Algorithm (UAX #9) over the positioned glyphs.</p>
<p>That is a genuinely hard thing to do, for a reason the commenter named exactly: UAX #9 needs a base paragraph direction, and the PDF never stored one. You have to infer it from the script of the runs. Get it wrong on a line like <code>المبلغ: 128 SAR</code> and your "fix" flips the Latin and the digits too. You have turned one bug into two, and the second one only shows up on invoices and CVs — which is to say, on almost every document a user actually cares about.</p>
<p>I wrote a chunk of that logic. It half worked, which is worse than not working, because half-working code makes you write more of it.</p>
<h2>What the problem actually was</h2>
<p>Then I ran the same PDF through the same code on a different machine and got correct output.</p>
<p>The extraction library was handing me visual order on one machine and logical order on the other. Same file, same code, different dependency version.</p>
<p><strong>LibreOffice.</strong> Version 7.1 (2021), which is what a lot of stable server distributions still ship, reverses Arabic on PDF import — it gives you paint order. A modern LibreOffice extracts logical order directly. The server was on 7.1. My laptop was not.</p>
<p><strong>PyMuPDF.</strong> Same class of bug, different library. Below 1.24, Arabic comes back visual and reversed. From 1.24 onward it comes back logical.</p>
<p>So the correct amount of bidi reconstruction code is <strong>zero</strong>. Both libraries already do it, in the versions where they do it. Everything I had written was scrambling text that had already arrived in the right order, on exactly the machines where the library was correct — which is why it looked like an intermittent bug and cost me months.</p>
<h2>The trap that hid it for weeks</h2>
<p>Here is the part worth stealing, because it is what made this so hard to see.</p>
<p>The pipeline had two engines: LibreOffice as primary, and <code>pdf2docx</code> (which sits on PyMuPDF) as a fallback. Sensible design. Then PyMuPDF 1.26.5 removed <code>Rect.get_area()</code>, a method <code>pdf2docx</code> 0.5.8 still calls:</p>
<pre><code class="language-python"># pdf2docx 0.5.8, somewhere in layout analysis
if bbox.get_area() / page_area &gt; threshold:
</code></pre>
<p><code>AttributeError</code>. The fallback engine crashed on import-time-adjacent code paths, the orchestrator caught it, and everything silently fell through to the other engine.</p>
<p>Nothing was logged as broken because nothing <em>was</em> broken from the caller's point of view — output came back, tests passed, files converted. But I could no longer tell which engine had produced any given file, so I was debugging Arabic ordering against an engine that had not run in weeks.</p>
<p>The fix was a two-line shim rather than pinning PyMuPDF backwards:</p>
<pre><code class="language-python">def _ensure_pdf2docx_compat():
    """pdf2docx 0.5.8 calls Rect.get_area(), removed in PyMuPDF 1.26.
    Restore it rather than pinning back to a version that returns visual order."""
    import fitz
    for cls in (fitz.Rect, fitz.IRect):
        if not hasattr(cls, 'get_area'):
            cls.get_area = lambda self: abs(self.width * self.height)
</code></pre>
<p>The general lesson: <strong>a silent fallback is a debugging tarpit.</strong> If your orchestrator can switch engines without telling you, the first thing to add is not a retry, it is a log line naming which engine produced the output.</p>
<h2>One infrastructure footgun on the way</h2>
<p>Upgrading LibreOffice on a server usually means dropping a newer build somewhere and repointing the binary:</p>
<pre><code class="language-bash">ln -sf /opt/libreoffice26.2/program/soffice /usr/bin/soffice
</code></pre>
<p><code>ln -sf</code> will happily create that symlink when the target does not exist yet. You get a dangling link, LibreOffice stops working entirely, and the error you get back has nothing to do with symlinks. Confirm the binary is there <strong>before</strong> repointing, and restart the service afterwards — a long-running process that resolved <code>soffice</code> at startup will keep using the old path until it does.</p>
<h2>What is left after you delete the reversal code</h2>
<p>Not nothing, but much less than you would expect. Two things still need doing.</p>
<p><strong>Normalise presentation forms.</strong> Some PDFs encode Arabic using the Unicode presentation-form blocks (U+FB50–FDFF, U+FE70–FEFF) — the pre-shaped initial/medial/final glyph variants — instead of base letters. The text is technically correct and will look fine, but it is not searchable, will not match a query typed normally, and behaves badly in Word. NFKC folds them back:</p>
<pre><code class="language-python">import unicodedata
text = unicodedata.normalize('NFKC', text)
</code></pre>
<p>Note that this is the <strong>only</strong> normalisation you want here. NFKC is safe for this because the presentation forms have canonical decompositions to their base letters. Do not go further and start stripping diacritics; you will damage Quranic text and vowelised teaching material.</p>
<p><strong>Mark direction in the output format.</strong> For DOCX, logical order in the string is not enough — Word needs to be told the run is RTL, or it renders correctly-ordered text with left-aligned paragraph flow, which looks subtly wrong to a native reader:</p>
<pre><code class="language-xml">&lt;w:rPr&gt;
  &lt;w:rtl/&gt;
  &lt;w:rFonts w:cs="Arabic Typesetting"/&gt;
&lt;/w:rPr&gt;
&lt;w:pPr&gt;
  &lt;w:bidi/&gt;
&lt;/w:pPr&gt;
</code></pre>
<p><code>w:bidi</code> on the paragraph, <code>w:rtl</code> on the run, and a complex-script font via <code>w:cs</code> — miss the last one and Word substitutes something that breaks the cursive joins.</p>
<h2>The takeaway</h2>
<p>If you are debugging reversed RTL text in an extraction pipeline, check your library versions before you write a single line of bidi logic. The odds are high that the layer below you already solved it and you are about to un-solve it.</p>
<p>And add a version matrix to your i18n tests. Not a version <em>floor</em> — a matrix. This class of bug is invisible in a test suite that only ever runs one version of the thing doing the extracting, and it will reappear the day someone deploys to a distribution that pins an older package.</p>
<p>I write these up as I hit them while building <a href="https://confileo.com/">Confileo</a>, a free PDF toolkit where Arabic support is the point rather than an afterthought. If you have a PDF that still comes out wrong, I would genuinely like to see it — the interesting bugs are always in the files, not in the spec.</p>
<p><em>This article was originally published on <a href="https://dev.to/support_confileo_ce7442eb/the-arabic-pdf-bug-was-never-in-my-code-it-was-the-library-version-8a9">DEV Community</a>.</em></p>
]]></content:encoded></item></channel></rss>