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.

Decision flow for resolving a non-Latin name into embedded glyphs A display name enters the decision flow. The first decision asks whether the name contains a right-to-left script such as Arabic or Hebrew; if yes, it is reshaped and reordered into visual order with arabic_reshaper and the bidi algorithm before continuing, otherwise it continues directly. The next stage runs coverage analysis, testing each codepoint against the primary face's cmap and then the fallback chain. The following decision asks whether every codepoint resolved to some embedded face. If yes, the resolved faces are subset and embedded and the badge prints correctly. If no, the run is blocked and diverted along a dashed branch to quarantine with a coverage report naming the missing codepoints, so the operator can add the missing face and replay rather than printing a blank name. Display name محمد · 田中 RTL script? reshape + bidi Coverage check cmap + chain All resolved? Subset + embed badge prints clean yes no Quarantine + coverage report add missing face → replay

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 cmap for U+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.pdf lists 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

  1. Confirm the gap directly against the font’s cmap: ord("田") in FTFont("/fonts/NotoSans-Regular.ttf").getBestCmap() returns False.
  2. Add a face that does cover the script (for example a NotoSansSC for Han) and append it to the fallback chain.
  3. 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

  1. Define an ordered chain — compact Latin primary first, then per-script faces — and resolve each codepoint to the first face that supplies it.
  2. 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.
  3. 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

  1. Reshape Arabic into contextual joined forms with arabic_reshaper.reshape(name).
  2. Reorder the reshaped string into visual order with get_display() from the bidi algorithm before passing it to drawString.
  3. 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.pdf shows the face with emb=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

  1. Register the face with ReportLab’s TTFont before the first drawString so it is embedded, not merely named.
  2. Confirm every face reports emb=yes in pdffonts output as a release gate on the batch.
  3. 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.

PYTHON
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.

  1. 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())". A False confirms coverage, not corrupt input.
  2. Confirm embedding on a shipped file. pdffonts /var/spool/badges/day1.pdf — any face with emb=no is the substitution bug; every face with a box is the coverage bug.
  3. 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.
  4. 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:

BASH
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.