Skip to content

On renderers that already shape (Pillow + Raqm), reshape() + get_display() corrupts the text #102

Description

@genviz-ai

Not a bug in the reshaping itself — it is correct for its intended target. This is about the
usage the README and effectively every tutorial recommend, which silently produces malformed
Arabic on any renderer that already does complex-text layout. Pillow has linked Raqm/HarfBuzz
by default for some time now, so this path is common and getting more so.

What happens

reshape() maps base letters to Unicode presentation forms (U+FE70–U+FEFF) and get_display()
reorders to visual order. HarfBuzz then applies its own shaping and the bidi algorithm to that
already-shaped, already-reordered string. The work is done twice.

from PIL import Image, ImageDraw, ImageFont, features
import arabic_reshaper
from bidi.algorithm import get_display

print(features.check("raqm"))   # True

TEXT = "مرحبا بكم"               # "welcome"
FONT = "/System/Library/Fonts/SFArabic.ttf"

def render(s, path):
    img = Image.new("L", (700, 160), 255)
    ImageDraw.Draw(img).text((30, 40), s, font=ImageFont.truetype(FONT, 64), fill=0)
    img.save(path)

render(TEXT, "plain.png")                                      # correct
render(get_display(arabic_reshaper.reshape(TEXT)), "pre.png")  # corrupted
rendered
no preprocessing مرحبا بكم
reshape() + get_display() مكب ابحرم

The intermediate string is 8 of 9 characters in presentation forms, in visual order:

U+FEE2 U+FEDC U+FE91 U+0020 U+FE8E U+FE92 ...

Letters come out disconnected and the word order is reversed.

Scope

5 strings × 3 fonts (SF Arabic, Geeza Pro, IBM Plex Sans Arabic) = 15 renders per path, scored by
shape-invariant IoU against a reference and graded in three bands rather than pass/fail:

path no preprocessing with reshape() + get_display()
Pillow with Raqm 15/15 identical 0/15 identical — 14 broken, 1 recognisable
Pillow without Raqm 0/15 — 15 broken 0/15 identical — 9 recognisable, 6 still broken

Two things follow, and the second surprised me:

  1. With Raqm, the preprocessing takes a path that is 100% correct and breaks all of it.
  2. Without Raqm, the preprocessing is a partial rescue, not a fix — it moves 9 of 15 from
    broken to merely recognisable and leaves 6 broken. A pass/fail score would have reported this
    as "works", which is how the recipe keeps its reputation.

So the right question is which renderer you have, not whether Arabic "needs" reshaping.

Detection is one line:

from PIL import features
needs_reshaping = not features.check("raqm")

Why it goes unnoticed

The output still looks like Arabic to anyone who does not read it, so it passes review. It matters
especially for synthetic training data: text rendered this way looks plausible at a glance, and
a model trained on it learns malformed letterforms.

Suggested fix

A note in the README that the recipe applies to renderers without complex-text shaping, with the
features.check("raqm") test. Happy to open a PR against the README if that would help.

Data

Full matrix, per-string results and the rendered images, CC BY 4.0:
https://huggingface.co/datasets/syamjithnk/arshape

Environment: Pillow 12.2.0, arabic-reshaper 3.0.0, python-bidi, macOS, Python 3.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions