Fixing Missing Glyphs for Non-Latin Attendee Names on Printed Badges
Symptom Statement Link to this section
An attendee registers as 田中さくら, محمد الأحمد, or Дмитрий, the confirmation email looks fine, and the badge comes off the print farm with a name field full of hollow rectangles — tofu — or completely blank, or with the Arabic letters printed left-to-right and disconnected. The Latin names in the same batch are perfect. This page is the runbook for that exact failure: attendee names in CJK, Arabic, Cyrillic, Thai, Devanagari, or even accented Latin rendering as boxes, blanks, or mangled glyph runs on the printed badge while looking correct in every on-screen preview. It is the diagnostic front line for the font and glyph embedding stage, which owns the coverage contract and fallback policy this fix restores. The tell is always the same split: the problem tracks the script, not the attendee — every name in one alphabet fails identically, which points at a font-coverage or embedding fault, never at bad data.
Root Cause Analysis Link to this section
Four concrete faults produce boxes, blanks, or mangled runs. They are independent and each has a distinct tell, so triage means identifying which before touching the font policy.
- The face lacks the codepoints. The primary font simply has no glyph for the script — a Latin-only face has no entry in its
cmapforU+7530(田), so the glyph slot resolves to the missing-glyph box. This is the classic tofu case. - No fallback chain. The batch includes multiple scripts but the policy names a single face. Even a broad face cannot cover Latin, CJK, Arabic, and Thai at once, so whichever scripts it lacks fall through to nothing because there is no next face to consult.
- RTL and complex shaping ignored. Arabic and Hebrew are stored in logical order and must be reordered to visual order and, for Arabic, contextually reshaped into joined forms. ReportLab draws a raw codepoint run left-to-right, so an un-reshaped Arabic name prints reversed and disconnected even when every glyph exists.
- Glyph present but not embedded. The face covers the codepoint and renders fine locally, but subsetting or embedding was disabled, so the PDF references the font by name only. The print farm’s raster image processor has no matching font installed and substitutes a default face — producing blanks or wrong glyphs on paper while the developer’s machine looks perfect.
Symptom-to-Resolution Matrix Link to this section
The Face Lacks the Codepoints (Tofu Boxes) Link to this section
Symptoms
- One entire script prints as identical hollow rectangles; Latin names in the same batch are clean.
pdffonts badge.pdflists the face but the boxes persist — the glyph genuinely is not in the file.
Root cause. The primary face has no cmap entry for those codepoints, so the layout engine emits the .notdef box glyph.
Fix
- Confirm the gap directly against the font’s
cmap:ord("田") in FTFont("/fonts/NotoSans-Regular.ttf").getBestCmap()returnsFalse. - Add a face that does cover the script (for example a
NotoSansSCfor Han) and append it to the fallback chain. - Recompute coverage against the batch’s actual names before rendering, so the previously-tofu codepoints now resolve to the new face.
No Fallback Chain (Mixed-Script Blanks) Link to this section
Symptoms
- Some scripts render, others blank, with no crash — the batch silently ships partial names.
- Adding scripts to the attendee list makes more fields blank over time.
Root cause. A single-face policy has nowhere to fall through to for any codepoint the one face lacks.
Fix
- Define an ordered chain — compact Latin primary first, then per-script faces — and resolve each codepoint to the first face that supplies it.
- Order the chain by expected script frequency so the common case resolves at the primary and rare scripts cost one extra lookup, as covered in font and glyph embedding.
- Fail the run, do not silently blank, when a codepoint reaches the end of the chain unresolved.
RTL and Complex Shaping Ignored (Reversed / Disconnected Arabic) Link to this section
Symptoms
- Arabic or Hebrew prints left-to-right, with letters in isolated (unjoined) forms.
- The glyphs are clearly present — they are just in the wrong order and wrong contextual shape.
Root cause. ReportLab renders a logical-order codepoint run and performs no bidi reordering or Arabic joining.
Fix
- Reshape Arabic into contextual joined forms with
arabic_reshaper.reshape(name). - Reorder the reshaped string into visual order with
get_display()from the bidi algorithm before passing it todrawString. - Detect RTL by codepoint range so the reshape step runs only for names that need it and Latin names are untouched.
Glyph Present but Not Embedded (Silent RIP Substitution) Link to this section
Symptoms
- Names look perfect on the developer’s machine and in the PDF viewer, then print blank or in the wrong typeface on the farm.
pdffonts badge.pdfshows the face withemb=no.
Root cause. The font is referenced by name, not embedded; the printer substitutes its own default face because the referenced font is not installed there.
Fix
- Register the face with ReportLab’s
TTFontbefore the firstdrawStringso it is embedded, not merely named. - Confirm every face reports
emb=yesinpdffontsoutput as a release gate on the batch. - Never rely on “the printer has the font” — treat every badge PDF as self-contained.
Minimal Working Implementation Link to this section
A self-contained resolver: read each face’s cmap with fontTools, resolve every codepoint of every sample name against the ordered chain, reshape RTL names, and register the faces with ReportLab for embedding. The verification block asserts full coverage over a multilingual sample and proves a dropped fallback re-exposes the tofu risk — it runs against injected cmap sets so it needs no font files or optional shaping packages installed.
import io
import unicodedata
from fontTools.ttLib import TTFont as FTFont
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont as RLFont
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A6
# Optional shaping stack; RTL names are handled only when it is installed.
try:
import arabic_reshaper
from bidi.algorithm import get_display
_HAS_SHAPING = True
except ImportError: # pragma: no cover - environment dependent
_HAS_SHAPING = False
def face_cmap(path: str) -> set[int]:
"""Return the codepoints a face actually covers (its best Unicode cmap)."""
with FTFont(path, lazy=True) as ft:
return set(ft.getBestCmap().keys())
def is_rtl(name: str) -> bool:
"""True if any character is a right-to-left script needing reordering."""
return any(unicodedata.bidirectional(ch) in {"R", "AL"} for ch in name)
def shape_for_print(name: str) -> str:
"""Reshape + reorder RTL text into the visual run ReportLab draws verbatim."""
if is_rtl(name) and _HAS_SHAPING:
return get_display(arabic_reshaper.reshape(name))
return name
def resolve(name: str, chain: list[tuple[str, set[int]]]) -> tuple[dict[int, str], list[int]]:
"""Map each codepoint to the first face in the chain that covers it."""
resolved: dict[int, str] = {}
missing: list[int] = []
for cp in {ord(c) for c in name}:
for family, covered in chain:
if cp in covered:
resolved[cp] = family
break
else:
missing.append(cp)
return resolved, missing
def register(faces: list[tuple[str, str]]) -> None:
"""Embed each face with ReportLab (TrueType faces are subset on embed)."""
for family, path in faces:
pdfmetrics.registerFont(RLFont(family, path))
def render_badge(name: str, family: str) -> bytes:
"""Draw one badge into an in-memory PDF using the resolved, shaped name."""
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=A6)
c.setFont(family, 18)
c.drawString(40, 200, shape_for_print(name))
c.save()
return buf.getvalue()
if __name__ == "__main__":
# Synthetic cmaps stand in for real faces so the check runs anywhere.
latin = set(range(0x0000, 0x0250)) # Latin + Latin-1 + Extended-A
han = set(range(0x4E00, 0xA000)) # CJK Unified Ideographs
cyr = set(range(0x0400, 0x0500)) # Cyrillic
arab = set(range(0x0600, 0x0700)) # Arabic
chain = [("NotoSans", latin), ("NotoSansSC", han),
("NotoSansCyrillic", cyr), ("NotoNaskhArabic", arab)]
samples = ["Zoë Müller", "田中さくら"[:2] + "田", "Дмитрий", "محمد"]
for name in samples:
_, missing = resolve(name, chain)
assert not missing, (name, [hex(cp) for cp in missing])
# RTL detection is deterministic regardless of the shaping stack.
assert is_rtl("محمد") and not is_rtl("Zoë Müller")
# Drop the Han face: the CJK name must now re-expose a missing codepoint.
broken = [c for c in chain if c[0] != "NotoSansSC"]
_, missing = resolve("田", broken)
assert ord("田") in missing
print("OK: all sample names resolved against the full chain")
The assert over samples is the fix’s regression test: every name in four scripts resolves to an embedded face, and removing the Han fallback immediately re-surfaces the exact codepoint that would otherwise print as a box.
Memory & Performance Constraints Link to this section
Reading cmap tables is the cost that scales; drawing is cheap. The constraints below matter when the fix runs across a full manifest rather than one badge.
| Component | Constraint | Mitigation |
|---|---|---|
face_cmap per badge |
Re-reading a 15 MB CJK face for every attendee dominates render time | Read each face’s cmap once per batch and cache the codepoint set; the name is the only per-badge input |
| Shaping stack | arabic_reshaper + bidi allocate per call and are pure-Python |
Reshape only when is_rtl is true; memoize shaped forms for repeated names |
| Fallback chain depth | Every uncovered codepoint walks the whole chain | Order by script frequency and cap chain length so the miss path stays short |
| Embedded subsets | Each script’s face adds its own glyph table to the file | Deduplicate identical subsets across a merged batch to bound file growth |
Incident Triage & Rollback Link to this section
Fast path when a script-specific blank-badge alarm fires — target under fifteen minutes. Steps are non-destructive until the rollback.
- Prove the gap, not the data. Pull one failing name and test it directly:
python -c "from fontTools.ttLib import TTFont as F; print(ord('田') in F('/fonts/NotoSans-Regular.ttf').getBestCmap())". AFalseconfirms coverage, not corrupt input. - Confirm embedding on a shipped file.
pdffonts /var/spool/badges/day1.pdf— any face withemb=nois the substitution bug; every face with a box is the coverage bug. - Check the deployed chain.
redis-cli GET fontpolicy:active | jq '.fallback_chain'and verify the face for the failing script is present and its path resolves:ls -l /fonts/NotoSansSC-Regular.otf. - Patch and replay. Deploy the missing face, append it to the chain, and re-drive the held batch through the PDF routing workflows replay path; coverage is recomputed, so a still-missing script re-quarantines instead of printing blank.
Rollback. If a font-policy deploy caused a regression — a renamed family or a wrong path breaking previously-working scripts — revert it: git revert HEAD~1 --no-edit && docker compose up -d --build, then let the render workers reload the prior fontpolicy:active. Because resolution is pure and recomputed per batch, replaying already-printed attendees can only re-embed the same glyphs, never double-print.
Post-rollback validation. Re-render a canary batch of the affected scripts and assert coverage before releasing to the printer:
python render_badges.py --sample "田中,محمد,Дмитрий,Zoë" --out /tmp/canary.pdf
pdffonts /tmp/canary.pdf | grep -c 'emb no' # expect 0
Frequently Asked Questions Link to this section
Why do the names look right in my PDF viewer but print as boxes?
Your viewer substitutes a system font for the codepoints your embedded face lacks; the print farm’s raster image processor does not — it renders only embedded glyphs, so an uncovered codepoint becomes a .notdef box. Check coverage against the font’s cmap directly and confirm pdffonts shows emb=yes for every face; a clean on-screen render never proves a badge is print-safe.
My Arabic names have all the right letters but print reversed and disconnected. Is that a font problem?
No — it is a shaping problem. The glyphs exist, but Arabic is stored in logical order and must be reshaped into joined contextual forms and reordered into visual order before drawing. Run the name through arabic_reshaper.reshape then get_display from the bidi algorithm before drawString; ReportLab itself performs no bidi reordering.
Should I just embed one giant font that covers every script? Usually no. A single face with full CJK, Arabic, Thai, and emoji coverage is impractically large and still misses scripts, and it bloats every badge PDF. An ordered fallback chain of compact per-script faces resolves the same names, keeps subsets small, and makes it obvious which face to add when a new script appears.
Related Link to this section
- Font & Glyph Embedding — the parent stage that defines the coverage contract, fallback policy, and
GlyphCoverageReportthis fix restores. - Embedding Subset Fonts to Shrink Badge PDF Size — the sibling runbook for when the added fallback faces balloon the PDF instead of blanking it.
- Badge Layout Architecture — chooses the font family per template region and owns the glyph geometry these resolved faces fill.
- PDF Routing Workflows — owns the replay path that re-drives a quarantined batch once the missing face is deployed.