Noto’s per-script fonts ship without Basic Latin — a Thai ‘!’ renders as tofu, and fontTools’ cmap catches it before your eyes do

I was batch-rendering localized marketing banners with Pillow, one Noto font
per script: Noto Sans for Latin and Cyrillic, the CJK collection for Japanese,
Korean and Chinese, and the per-script files for Thai, Hebrew, Arabic and
Devanagari. The Thai output ended in a tofu box. The Thai text itself was
perfect — the missing glyph was the exclamation mark.

TL;DR — Debian’s NotoSansThai-Bold.ttf and NotoSansHebrew-Bold.ttf
contain only their own script: no Basic Latin at all, so !, 7, 8 and
all render as .notdef. NotoSansArabic does include Latin punctuation, so
testing one script font proves nothing about the others. Scan every string
against the actual character map with fontTools before rendering.

The setup

A Python script composites text onto PNG images with Pillow
(ImageDraw.text), picking a font file per language:

FONTS = {
    "th": "/usr/share/fonts/truetype/noto/NotoSansThai-Bold.ttf",
    "he": "/usr/share/fonts/truetype/noto/NotoSansHebrew-Bold.ttf",
    "ar": "/usr/share/fonts/truetype/noto/NotoSansArabic-Bold.ttf",
}

Pillow was built with libraqm, so shaping and right-to-left layout are
correct. The strings are short marketing captions written by translators —
sentences like « เรียนสูตรคูณให้สนุก! » (« learn your times tables, have fun! »).

What I expected

That any Noto font covers at least ASCII punctuation. An exclamation mark is
about as universal as characters get, and the Arabic caption — which also ends
in ! — rendered fine, which reinforced the assumption.

What actually happens

Pillow renders the missing glyph as the font’s .notdef box, silently — no
exception, no warning. The Thai caption displayed perfectly up to the last
character, then a tofu rectangle:

Two Thai text lines rendered with NotoSansThai-Bold: the first ends with a tofu box where the Latin exclamation mark should be; the second, using only Thai characters and Thai digits, renders cleanly.

Checking the fonts’ character maps explains it: the Debian-packaged per-script
Noto fonts are subsets. NotoSansThai-Bold.ttf has no U+0021 (!), no
ASCII digits, no U+2026 (). Same for NotoSansHebrew-Bold.ttf. But
NotoSansArabic-Bold.ttf does carry Latin punctuation — coverage varies
per script file, so a spot check on one font tells you nothing about the rest.

The fix

Two parts. First, detect the problem mechanically instead of eyeballing
rendered output — the cmap is the source of truth:

import json
from fontTools.ttLib import TTFont

def missing_chars(text: str, font_path: str, font_number: int = -1) -> list[str]:
    cmap = set(TTFont(font_path, fontNumber=font_number).getBestCmap().keys())
    return [ch for ch in text if ord(ch) > 32 and ord(ch) not in cmap]

for lang, path in FONTS.items():
    for text in captions[lang]:
        bad = missing_chars(text, path)
        if bad:
            print(lang, repr(text), "->", [hex(ord(c)) for c in bad])

Second, fix the strings rather than the renderer. Mixed-font fallback inside
one Pillow text() call does not exist, and hand-splitting runs breaks
right-to-left layout. For my captions the content-level fix was better anyway:
Thai has its own digits ( U+0E57, U+0E58 — in the font), and dropping
the exclamation mark reads more naturally in both Thai and Hebrew than the
Latin ! did. Re-run the cmap check after editing; mine asserts emptiness so
a future translation can’t reintroduce the problem.

Why it works

The cmap table maps codepoints to glyphs; a codepoint absent from it can only
ever produce .notdef, whatever the shaping engine does. Checking it with
fontTools is exact and costs milliseconds, unlike rendering-based checks that
need a reference tofu image to compare against. The ord(ch) > 32 guard
skips spaces and control characters, which shaping handles without glyphs.

What I did not test

Only the Debian fonts-noto-* packages (the hinted TTFs); Google’s upstream
per-script releases may subset differently. The CJK .ttc collection and
NotoSans-Bold.ttf covered everything my Latin, Cyrillic, Greek and CJK
strings needed, so I never exercised fallback there. I also did not measure
whether pango or a browser engine would have papered over this with system
font fallback — Pillow definitely does not.

Facts

context: batch-rendering localized banners with Pillow, one Noto font per script
problem: NotoSansThai/NotoSansHebrew lack Basic Latin, so '!' and digits render as tofu, silently
solution: scan strings against TTFont(path).getBestCmap() with fontTools; fix strings (Thai digits, drop '!')
verified_on: 2026-08-28
applies_to: [Debian fonts-noto packages, Pillow 11 + libraqm]
does_not_apply_to: [renderers with system font fallback (browsers, pango)]

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Retour en haut