Field Notes · Arabic breaks silently, part two

The Arabic fix everyone recommends is now the thing breaking your Arabic

I set out to prove that Arabic needs reshaping before it is drawn. The render proved the opposite, and the reversal turns out to be the useful part.

By Syamjith NK · Abu Dhabi · 19 August 2026 · updated 21 August

Arabic is cursive and bidirectional. Every letter changes shape depending on its neighbours, and the order the characters are stored in is not the order they appear on screen. For years the standard fix in Python has been two libraries — arabic_reshaper to join the letters, python-bidi to reorder them — applied before the text is drawn. Search for “render Arabic Pillow” and that is what you will find, everywhere.

I built a benchmark to demonstrate it. It demonstrated the opposite.

Not everywhere, though, and that qualifier is the whole piece. There are two Python stacks in the world, they need opposite advice, and the current release of Pillow is both of them depending on a shared library you did not install on purpose. So before anything else, the line that tells you which half you are in:

from PIL import features; features.check("raqm")
True → do not reshape. False → you must.

Run it in the environment that will actually render, not on your laptop. The rest of this is what happens on each side of that line, and why the check has to be at runtime.

Four paths, one word

The word is مرحبا بكمwelcome. Same string, same font, four rendering paths:

Four renderings of the Arabic phrase marhaban bikum. Modern engine without preprocessing is correct; modern engine with arabic_reshaper and bidi is mangled; no shaping engine without preprocessing is mangled; no shaping engine with preprocessing is correct.

Rows 1 and 2 are the same renderer. The only difference is that row 2 applies the recommended recipe — and row 2 is the broken one.

Why

This Pillow has Raqm, which means HarfBuzz and FriBiDi, which means it already performs the shaping and the bidi reorder itself. Feeding it text that has already been shaped and already been reordered makes it do both a second time. Two rights make a wrong.

Specifically: arabic_reshaper replaces each letter with its baked presentation form (U+FEXX), and python-bidi reverses the string into visual order. Then FriBiDi reverses it again, so the words come out backwards, and HarfBuzz cannot join U+FEXX forms the way it joins the base codepoints, so the letters come apart. Disconnected and reversed, from two libraries that each did their job correctly.

So the question is not “does Arabic need reshaping”. It is:

Does this rendering path already do complex-text layout?
Yes → reshaping breaks it. No → reshaping is required.

The recipe was correct when it was written. Text stacks caught up; the advice did not.

Measured

Three Arabic fonts, five strings, four paths, scored against a verified-correct reference on shape similarity:

Rendering pathIdenticalRecognisableBroken
Modern engine, text as-is1500
Modern engine + reshaper + bidi0114
No shaping engine, text as-is0015
No shaping engine + reshaper + bidi096

The last row is the interesting one. On a renderer with no shaping, the recipe is a partial rescue — plain Arabic comes back legible, but digits, embedded Latin and diacritics still fail. Positioning combining marks and resolving bidirectional runs needs real shaping regardless of what you pre-process. Treating the recipe as a fix rather than a patch is how those cases slip through.

Which side of the line are you on? Not the one your version number suggests

The first version of this piece said “Pillow 12 links Raqm” and left readers to conclude that a recent install means a shaping stack. A reader pushed back on exactly that. They were right, and the correction is more interesting than the original claim.

pip install pillow gets you 12.3.0 either way. Whether it shapes Arabic depends on a C library that is not in your requirements file:

Stock python:3.13-slimPillowcheck(“raqm”)HarfBuzzFriBiDi
as shipped12.3.0False
after apt-get install libfribidi012.3.0True14.2.11.0.16

Same container, same interpreter, same already-installed wheel. No pip, no reinstall. One 25 KB shared library decides which of two opposite pieces of advice is correct for you.

The mechanism is in Pillow’s own 8.2.0 release notes: binary wheels carry a statically linked libraqm that links against FriBiDi at runtime. Raqm rides along inside the wheel; FriBiDi has to be found on the host when Pillow loads. If it is not there, complex-text layout switches itself off with no error and no warning. On macOS I checked what the wheel actually ships: HarfBuzz and FreeType, no FriBiDi. My own machine only answers True because Homebrew installed libfribidi as a dependency of libass and mpv — which is to say, by accident. macOS ships none in /usr/lib.

A slim container, a minimal CI image, an Alpine build, a frozen app bundle, a Mac without Homebrew: all land on the far side of the line, on the current wheel. Which is why the check has to run at runtime, in the environment that renders. Your laptop is not evidence about your container.

Two smaller things worth having in the same place. Passing layout_engine=ImageFont.Layout.RAQM explicitly is worth doing, but be clear what it buys: it documents intent, it does not guarantee shaping. Without Raqm, Pillow falls back to basic layout with a warning and carries on. And on python-bidi, the widely repeated warning that from bidi.algorithm import get_display broke in the Rust rewrite is not true as of 0.6.11 — the legacy path still works and emits no deprecation warning. What actually moved is upper_is_rtl, which the new top-level bidi.get_display rejects and the old shim still accepts. Snippets fail on the keyword argument, not the import, which sends people hunting in the wrong place.

One more assumption of my own, checked rather than argued: the benchmark produces its “no shaping” half by asking a Raqm-enabled Pillow for Layout.BASIC. Is that the same as a Pillow with no Raqm at all? I hashed every rendering in both containers above. Ten of ten identical to the byte, so yes. The same run confirms that ImageFont.truetype() with no layout_engine argument gives you RAQM when Raqm is present and BASIC when it is not — which is what makes features.check("raqm") a valid predictor of what will happen to your string, rather than a statement about how Pillow was compiled.

The recipe also deletes your tashkeel

That hash check turned up something I had not gone looking for. Two different strings came out with the same hash: مرحبا بكم and مَرْحَبًا بِكُمْ. They render to identical pixels after preprocessing because arabic_reshaper throws the harakat away — sixteen characters in, nine out, seven diacritics gone. No error, no warning, no return value to inspect.

It is the documented default (delete_harakat is True) and it is one keyword away from not happening:

ArabicReshaper({"delete_harakat": False}).reshape(text)

Almost nobody sets it, because almost nobody knows it is there. Every tutorial that recommends the recipe calls the bare module-level reshape(). And this lands awkwardly next to part one, whose finding is always add tashkeel before sending Arabic to a speech engine. Run the standard rendering recipe over the same text and it silently removes exactly what part one tells you to add. Qur’anic text, children’s readers, and any name whose vowels disambiguate it are where that stops being cosmetic.

A separate trap, in the fonts

While testing I rendered في عام 2026 and the year came out as empty boxes. Checking the font tables rather than guessing: SF Arabic and Geeza Pro contain no Latin letters or digits at all.

The same Arabic sentence containing the year 2026 rendered in two fonts. IBM Plex Sans Arabic shows the digits correctly; SF Arabic renders them as empty notdef boxes.

This is the same failure mode as the numerals problem in the previous piece: the output looks entirely plausible to anyone not reading the part that broke. An Arabic caption with a date, a price or a brand name in it will ship looking fine.

To be precise about when this bites, because it does not bite everywhere: in a native macOS app you will never see it, since Core Text silently falls back to another font for the missing glyphs. It bites when you bypass font fallback, which is what every raster pipeline does — Pillow, a caption burner, a synthetic data generator. You hand one font file to one draw call and there is nothing behind it to fall back to.

What I got wrong, and how

Twice, and both times only rendering it caught the error.

First the premise: I was so confident reshaping was required that I made it the reference the others were scored against — so the correct renderings scored worst. Then the metric: my similarity measure compared position as well as shape, so a visually identical render scored zero because the two engines place glyphs a few pixels apart. Normalising to the ink bounding box fixed it, and reporting three bands instead of pass / fail is what exposed the partial-rescue result.

If there is a lesson beyond Arabic, it is that a benchmark inherits the assumptions of whoever wrote it, and the only reliable way to find them is to look at the output instead of the score.

The data

The rendering harness and the per-case results are published as ArShape on Hugging Face, alongside ArNum-TTS from part one. The images are not shipped — they regenerate in seconds from system fonts.

The container experiment, the hash comparison and the tashkeel case are in RAQM_CONDITION.md and verify_no_raqm.py in the same dataset, with the exact docker run lines. The verifier is about forty lines and it is the part I would want if I were reading this rather than writing it: it answers the question for your environment rather than mine.

Part three: Your Arabic PDF is fine. What reads it is not.

The practical version

  • Check whether your renderer does complex-text layout, at runtime, in the environment that renders. In Pillow: PIL.features.check("raqm"). Do not infer it from your Pillow version, and do not infer your container from your laptop — the same wheel answers both ways.
  • True → pass Arabic through untouched. Do not reshape. 15 of 15 correct; reshaping takes that to 14 of 15 broken.
  • False → reshape, and treat it as a partial rescue rather than a fix. 9 of 15 recognisable, 6 still broken: digits, embedded Latin and diacritics need real shaping regardless of what you pre-process.
  • If you reshape and the text is vocalised, pass {"delete_harakat": False}, or your tashkeel is silently deleted.
  • Pass layout_engine=ImageFont.Layout.RAQM to document intent, while remembering it falls back rather than fails.
  • Check your font actually contains every character you are about to draw. Raster pipelines have no font fallback behind them.
  • Assert it in CI. All of the above is one line and it is the kind of thing that changes under you when a base image is rebuilt.

Updated 21 August 2026. The version published on 19 August stated the finding without its condition, and implied that a recent Pillow means a shaping stack. A reader pointed that out; they were right. The measurements are unchanged — what changed is that the condition now leads, and it is measured rather than asserted: the container experiment, the byte-exactness check on the 2x2, the tashkeel deletion and the python-bidi API detail are all new here and all reproducible from the commands in the dataset. Original text preserved in the dataset history.