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.
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:
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 path | Identical | Recognisable | Broken |
|---|---|---|---|
| Modern engine, text as-is | 15 | 0 | 0 |
| Modern engine + reshaper + bidi | 0 | 1 | 14 |
| No shaping engine, text as-is | 0 | 0 | 15 |
| No shaping engine + reshaper + bidi | 0 | 9 | 6 |
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-slim | Pillow | check(“raqm”) | HarfBuzz | FriBiDi |
|---|---|---|---|---|
| as shipped | 12.3.0 | False | — | — |
after apt-get install libfribidi0 | 12.3.0 | True | 14.2.1 | 1.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.
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.RAQMto 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.