This document explains how right-to-left (RTL) text is rendered in
generated reports, the guarantees the code provides, and how to add
support for another RTL language. It complements the docstrings in
_report_locales.py, which is the single module
responsible for locale-aware text handling (see upstream issue
#478).
Persian and Arabic are written right-to-left in a cursive script whose letters change shape depending on their neighbours (joining), and reports mix in left-to-right content: Latin place names, numbers, percentages, units, parentheses and URLs. Two different rendering stacks are involved:
-
PDF text (fpdf2 + uharfbuzz): fpdf2 applies the Unicode Bidirectional Algorithm (UBA) and HarfBuzz shapes each directional run, so it needs logical-order text plus correct configuration (direction, script, language).
-
Matplotlib figures: this depends on the Matplotlib version, so text is always passed through
mpl_text(), which dispatches onmatplotlib_applies_complex_text_layout():- 3.11 and later route layout through libraqm, which shapes and applies the UBA just as fpdf2 does, so they receive logical-order text (wrapped, but otherwise untouched).
- Before 3.11 Matplotlib performed neither shaping nor
bidirectional reordering, so text must be converted before drawing:
Arabic letters replaced by their joined presentation forms
(
arabic_reshaper) and characters reordered into display order, viaprepare_mpl_text().
Applying
prepare_mpl_text()on a libraqm-backed Matplotlib reverses the text a second time —خانههاrenders asاههناخ, with glyphs falling back to isolated forms — so never hand its output straight to Matplotlib. Native shaping also positions Arabic diacritics better than presentation-form substitution can, which is visible in thematplotlib_arabicbaseline.
_report_locales.LOCALE_PROFILES declares what the rendering code needs
to know about each language:
| field | example (Persian) | purpose |
|---|---|---|
code |
fa |
BCP 47 language code (matches language_code in the languages worksheet) |
name |
Persian |
language name used in the workbook and region configuration |
direction |
rtl |
base paragraph direction |
script |
arab |
ISO 15924/OpenType script tag passed to HarfBuzz |
hb_language |
fa |
HarfBuzz language tag (enables language-specific font rules) |
primary_font |
Vazirmatn |
font family registered with fpdf2/Matplotlib |
fallback_fonts |
('dejavu',) |
fpdf2 fallback fonts for missing glyphs |
wrap_mode |
WORD |
fpdf2 wrap mode |
requires_arabic_shaping |
True |
whether Matplotlib text must be reshaped |
get_locale_profile() resolves a language to its profile. Languages
without a registered profile fall back to the hints in the fonts
worksheet of _report_configuration.xlsx, where Align == 'Right' has
historically marked RTL languages — existing workbook-only
configurations therefore keep working unchanged.
configure_pdf_text_shaping() (called from prepare_pdf_fonts) enables
fpdf2 text shaping explicitly. For RTL locales the base paragraph
direction, script and language are passed to fpdf2/HarfBuzz instead of
relying on per-string auto-detection, which would give a left-to-right
base direction to any string that happens to start with a Latin letter
or a digit and mis-order the rest of the line. The Text shaping
column of the fonts worksheet can disable the shaping engine for a
language (it defaults to enabled).
The zero-width non-joiner (ZWNJ, U+200C) is meaningful Persian
orthography (میروم, خانهها, برنامهریزی): it prevents letters
from joining without adding a visible space. It must never be
removed, replaced by a space, or converted to another character.
fpdf2 (2.8.7) applies UBA rule X9 literally and removes
"boundary-neutral" characters — including ZWNJ — from the text it sends
to HarfBuzz, which silently re-joined Persian words in PDFs. Importing
_report_locales runs a feature probe
(_fpdf_drops_joining_controls()) and — only when the installed fpdf2
still loses the characters — installs
_install_fpdf_joining_control_preservation(), a small idempotent shim
that re-injects ZWNJ/ZWJ into fpdf2's resolved character stream (each
takes the embedding level of the character it follows, so directional
runs are unaffected). A future fpdf2 release that preserves these
characters itself is left unpatched; the probe and the regression test
(TestFpdfJoiningControlPreservation) are then the places from which
to retire the shim.
mpl_text(text, profile, wrap_width=None, rewrap=False) is the single
entry point, used by every figure text path (map colorbar labels, tick
labels, north arrow, scalebar, overlay legends, threshold labels, policy
rating labels and the access-profile radar chart). It wraps the logical
text and then, only where Matplotlib does not do its own complex text
layout, applies prepare_mpl_text().
prepare_mpl_text(text, profile, wrap_width=None, rewrap=False) is that
unconditional logical-to-display transform. It remains directly
testable, and is what the pre-3.11 path uses. Its pipeline keeps text in
logical order for as long as possible:
- Wrap logical text (optional). Wrapping happens before shaping
so line breaks fall between logical words; words are never broken
internally (ZWNJ compounds stay intact — ZWNJ is not whitespace and
is never a break point). Text that already contains newlines is
respected unless
rewrap=True. - NFC-normalise each line (composes decomposed letter+mark pairs;
ZWNJ is unaffected). No other rewriting of translated content is
performed — in particular Arabic diacritics (harakat) and tatweel are
preserved (
arabic_reshaper's default configuration would delete harakat; ours does not), and no NLP-style normalisers are used. - Shape each line with
arabic_reshaper(joined presentation forms, lam-alef ligatures; ZWNJ passes through and still breaks joining). Scripts that do not require shaping are not reshaped. - Reorder each line separately into display order using fpdf2's UBA
implementation for level resolution (so figures and PDFs resolve
directions identically, including the paired-bracket rules), plus:
rule L2 applied cluster-wise so combining marks travel with their
base character; rule L3 so marks follow their base in drawing order
(as Matplotlib requires); and rule L4 bracket mirroring — necessary
because
python-bidi0.6.x does not mirror brackets, which made(GHSCI)render as)GHSCI(. - Lines are joined with newlines, so multi-line RTL text reads top-to-bottom.
Left-to-right text without Arabic characters is returned unchanged, so English and other LTR reports are byte-for-byte identical.
The legacy helper mpl_reshape() in _utils.py delegates to this
pipeline, inferring the locale from the text content. It is the
unconditional transform, so its result must not be handed to a
libraqm-backed Matplotlib; use mpl_text() instead.
pdf_template_setup() adapts the PDF templates for RTL locales using
named, documented transformations in _utils.py:
mirror_template_alignment()— left-aligned/justified template elements become right-aligned;apply_rtl_element_shifts()— the shifts declared inRTL_ELEMENT_SHIFTSreposition individual elements whose placement is directional (e.g. the 'Low' annotation of the 1000 Cities plot and the study region legend swatches), each with a documented purpose.
The templates themselves are unchanged.
Fonts are configured in the fonts worksheet of
process/configuration/_report_configuration.xlsx (one row per style:
'', b, i, bi). Persian and Arabic use
Vazirmatn (bundled under
process/configuration/fonts/vazirmatn-v33.003/), which covers Arabic
presentation forms, harakat, Persian digits, ZWNJ and Latin text;
DejaVu Sans Condensed is the fallback. Matplotlib uses the same font
file via get_and_setup_font().
- Add the translation column to the
languagesworksheet of_report_configuration.xlsx(keep ZWNJ and diacritics exactly as the translator wrote them; do not "clean" the text). - Add rows to the
fontsworksheet for the language (font files, andAlign = Rightfor backward compatibility). Bundle or reference a font that covers the script. - Register a
LocaleProfilein_report_locales.LOCALE_PROFILESwith the language's code, direction, script, HarfBuzz language tag, fonts and wrap mode. (Without this the workbookAlignfallback still yields correct rendering, but the explicit profile also provides the script/language tags to HarfBuzz.) - Add the language to a region configuration under
reporting: languages:and generate a report; a not-yet-validated translation can be tested withgenerate_report_for_language(r, language, validate_language=False). - Extend
process/tests/test_rtl_rendering.pywith representative strings for the language and regenerate the visual baselines (GHSCI_UPDATE_VISUAL_BASELINES=1), then inspect them before committing.
# from the process directory (inside the Docker container):
python -m unittest -v tests/test_rtl_rendering.pyThe suite covers ZWNJ preservation, joining forms, bidirectional
ordering (numbers, brackets, URLs, mixed Arabic/English), logical
wrapping, LTR invariance, fpdf2 shaping configuration, the template
layout transformations, and a visual regression fixture that renders
representative Persian/Arabic Matplotlib figures through the report
render path (mpl_text()) and compares them against the committed
baselines in process/tests/baselines/rtl/ (set
GHSCI_UPDATE_VISUAL_BASELINES=1 to regenerate after an intentional
change). The comparison asserts both the whole-image mean absolute
difference and a changed-pixel ratio, so corruption confined to a single
text line cannot hide in the page's white space. An English control is
rendered alongside, so a change affecting all text can be told apart
from one specific to right-to-left handling.
TestMatplotlibComplexTextLayout additionally asserts, at the glyph
level, that the text actually handed to Matplotlib is shaped and ordered
correctly on the installed version -- necessary because
prepare_mpl_text() is bypassed where Matplotlib does its own layout, so
tests of that function alone no longer describe what gets drawn.
PDF pages are not rasterised: TestPDFPageGeneration exercises the
fpdf2 path end to end and asserts a usable document results, while the
correctness of the text itself is asserted at the text level by
TestPDFShapingConfiguration, TestFpdfJoiningControlPreservation,
TestArabicJoining and TestBidiOrdering. Those state why the output
is right, rather than only that pixels are unchanged, and avoid a
rasteriser dependency (the image has none: its GDAL build lacks the PDF
driver, and poppler/ghostscript are not installed).
phrases['locale']combines the language code with the study region's country code (e.g.fa_ESfor a Persian report about a Spanish city); for such mixed locales Babel may fall back to Latin digits or untranslated unit names in generated numbers. Translated phrases (which carry their own Persian/Arabic digits) are unaffected.- The north-arrow label is centred just outside the map frame; long
words (e.g.
شمال) can be slightly clipped on some figures. This pre-existing placement issue affects wide labels in any language. python-bidiremains installed for backward compatibility but is no longer used by the rendering path (fpdf2's UBA implementation is used for both PDFs and figures so they cannot disagree).