Font & Glyph Embedding: Rendering Every Name the Print Farm Will Ever See

An attendee named 王梓涵, Zoë Müller, or Николай renders perfectly on the developer’s laptop and prints as a row of hollow boxes — tofu — on the badge stock, because the machine that rasterized the PDF resolved the font from its own system libraries instead of from bytes carried inside the document. Font and glyph embedding is the stage of the Badge Generation & Template Sync pipeline that closes that gap: it guarantees that every codepoint appearing in an attendee’s name is backed by an actual glyph outline embedded in the badge PDF, so the raster image processor on the print farm renders byte-for-byte what the layout engine intended, on hardware you will never see and cannot configure. The failure this stage exists to prevent is silent: a badge that looks correct in every preview and only reveals its blank name field at the printer, minutes before a queue of people reaches the desk.

This is a rendering-correctness boundary, not a design-system concern. It owns the policy of which font faces are embedded, which Unicode ranges are subset into the file, and the fallback order that resolves a codepoint the primary face lacks — the machinery that makes CJK, Arabic, Cyrillic, accented Latin, and the occasional emoji survive the trip to a networked printer. Font selection per attendee tier and the binding of a face to a template region belong upstream to the badge layout architecture; the mapping of a registration field into the display string this stage renders belongs to dynamic field mapping. This page defines the contract, the deterministic embed-and-verify implementation, and the observability an on-call engineer needs when a batch of names comes back blank. Its two focused runbooks — fixing missing glyphs for non-Latin attendee names and embedding subset fonts to shrink badge PDF size — handle the two incidents this stage produces most often.

Font and glyph embedding: from display string to a print-safe embedded, subset PDF A resolved display string enters and is decomposed into its required codepoints. Stage 1, coverage analysis, reads each candidate font's cmap table via fontTools and partitions the codepoints into those covered by the primary face and those it lacks. Stage 2, fallback resolution, walks an ordered fallback chain to bind each uncovered codepoint to the first face that supplies it; any codepoint no face in the chain covers is flagged as an unresolved tofu risk and diverted along a dashed reject branch to a quarantine that blocks the print run. Stage 3, subset and embed, keeps only the used glyph ranges from each resolved face, registers the faces with ReportLab, and writes the outlines into the PDF content stream. The output is a print-safe PDF whose glyphs render identically on any raster image processor because nothing is resolved from the printer's system fonts. glyph resolved · embed unresolved → quarantine required codepoints uncovered set resolved faces subset outlines Display string 王梓涵 · Zoë 1 · Coverage analysis fontTools cmap read covered vs missing 2 · Fallback resolution ordered chain walk unresolved → tofu risk 3 · Subset + embed keep used ranges registerFont · write stream Print-safe PDF → raster image proc. block run → coverage report + trace_id Quarantine missing-glyph hold

Scope Boundary Link to this section

This stage stays deterministic and cheap only because its remit is narrow. Anything outside the left column is delegated and must not leak into the embedding path.

In-Scope (this stage owns) Out-of-Scope (delegated to adjacent stages)
Reading cmap coverage for each candidate face and building a GlyphCoverageReport Choosing which font family a tier or template region uses — owned by badge layout architecture
Walking the fallback chain to resolve every codepoint to an embedded face Producing the display string from registration fields — owned by dynamic field mapping
Subsetting each face to the used ranges and embedding the outlines Dispatching the finished PDF to a printer or delivery queue — owned by PDF routing workflows
Registering faces with ReportLab and mapping family → weight/style Positioning glyphs, wrapping, and clip-to-bleed geometry — owned by badge layout architecture
Flagging unresolved codepoints and blocking the run before print Replaying a quarantined batch after the fix — owned by PDF routing workflows

The stage holds no per-attendee state beyond the batch it is embedding, performs no network calls, and writes only PDF font resources. It takes a resolved display string plus a FontPolicy and emits either a fully-embedded content stream or a coverage report naming exactly which codepoints could not be backed by a glyph — which is what lets the same batch be re-run identically once the missing face is added.

Data Contract & Schema Link to this section

Embedding is driven by two Pydantic v2 models. FontPolicy is the declarative input — the primary face, the ordered fallback chain, the Unicode ranges permitted into the subset, and the codepoints a batch is contractually required to render. GlyphCoverageReport is the deterministic output — the verdict that gates the print run. Both use strict=True so a codepoint that arrives as a string instead of an integer fails loudly at the boundary rather than silently coercing into the wrong glyph.

PYTHON
from pydantic import BaseModel, Field, field_validator, ConfigDict
from pydantic_core import PydanticCustomError


class FontFace(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")

    family: str = Field(..., min_length=1, max_length=64)
    path: str = Field(..., pattern=r".+\.(ttf|otf)$")
    weight: str = Field("regular", pattern=r"^(regular|bold|italic|bold_italic)$")
    subset: bool = True


class FontPolicy(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")

    primary: FontFace
    fallback_chain: list[FontFace] = Field(default_factory=list)
    subset_ranges: list[tuple[int, int]]
    required_codepoints: list[int] = Field(default_factory=list)

    @field_validator("subset_ranges")
    @classmethod
    def ordered_ranges(cls, ranges: list[tuple[int, int]]) -> list[tuple[int, int]]:
        for lo, hi in ranges:
            if lo > hi or lo < 0 or hi > 0x10FFFF:
                raise PydanticCustomError("bad_range", "Range {lo}-{hi} is invalid", {"lo": lo, "hi": hi})
        return ranges

    @field_validator("required_codepoints", mode="before")
    @classmethod
    def coerce_chars(cls, cps: list) -> list[int]:
        # Accept literal characters ("é") or integer codepoints; normalize to ints.
        return [ord(c) if isinstance(c, str) else c for c in cps]


class GlyphCoverageReport(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")

    family: str
    total_required: int
    resolved: dict[int, str]          # codepoint -> family that supplies its glyph
    unresolved: list[int]             # codepoints no face in the chain covers
    subset_ranges_used: list[tuple[int, int]]

    @property
    def is_print_safe(self) -> bool:
        return not self.unresolved and len(self.resolved) == self.total_required

Each field earns its place. subset_ranges bounds what a subsetter is allowed to keep so an accidental full-font embed cannot slip through — the size discipline is detailed in embedding subset fonts to shrink badge PDF size. required_codepoints is the actual set of codepoints the current batch of names contains, computed before any rendering, so coverage is checked against real data rather than an assumed alphabet. resolved records which face backs each codepoint — the audit trail that tells you why a badge printed the way it did. unresolved is the only field that matters at print time: a non-empty list means is_print_safe is False and the run must be held. extra="forbid" guarantees a typo in an upstream policy key fails here instead of being ignored.

Deterministic Implementation Link to this section

Coverage analysis is pure and side-effect free: given each face’s cmap (its codepoint-to-glyph table, read once with fontTools) and the required codepoints, it partitions them across the fallback chain and produces the report. Keeping it pure is what makes the print-safety verdict reproducible in a unit test without a real font file or a rendering surface.

PYTHON
from fontTools.ttLib import TTFont as FTFont


def face_codepoints(path: str) -> set[int]:
    """Read the best Unicode cmap subtable and return the covered codepoints."""
    with FTFont(path, lazy=True) as ft:
        return set(ft.getBestCmap().keys())


def build_coverage(policy: FontPolicy, cmaps: dict[str, set[int]]) -> GlyphCoverageReport:
    """Resolve every required codepoint against the primary face, then the chain.

    `cmaps` maps family -> covered codepoints. Injecting it (rather than reading
    files here) keeps the resolver deterministic and unit-testable.
    """
    chain = [policy.primary, *policy.fallback_chain]
    required = sorted(set(policy.required_codepoints))
    resolved: dict[int, str] = {}
    unresolved: list[int] = []

    for cp in required:
        for face in chain:
            if cp in cmaps.get(face.family, set()):
                resolved[cp] = face.family
                break
        else:
            unresolved.append(cp)  # no face in the chain supplies this glyph

    return GlyphCoverageReport(
        family=policy.primary.family,
        total_required=len(required),
        resolved=resolved,
        unresolved=unresolved,
        subset_ranges_used=policy.subset_ranges,
    )

Registration and embedding is the impure half. ReportLab embeds a TTFont as a subset by default — only the glyphs actually drawn are written into the content stream — so registering the primary face and each fallback face, then mapping the family to its weights with registerFontFamily, is enough to get an embedded, subset badge. The important discipline is to register before the first drawString and to draw each codepoint with the face build_coverage assigned it, never with whatever canvas.setFont happened to leave active.

PYTHON
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont as RLFont


def register_policy(policy: FontPolicy) -> None:
    """Register the primary and fallback faces with ReportLab for embedding.

    ReportLab subsets TrueType faces on embed by default, so no full font is
    written unless subsetting is explicitly disabled at the canvas layer.
    """
    for face in (policy.primary, *policy.fallback_chain):
        pdfmetrics.registerFont(RLFont(face.family, face.path, subfontIndex=0))

    # Map the family name to its weight/style variants so bold/italic resolve.
    variants = {f.weight: f.family for f in (policy.primary, *policy.fallback_chain)}
    pdfmetrics.registerFontFamily(
        policy.primary.family,
        normal=variants.get("regular", policy.primary.family),
        bold=variants.get("bold", policy.primary.family),
        italic=variants.get("italic", policy.primary.family),
        boldItalic=variants.get("bold_italic", policy.primary.family),
    )


if __name__ == "__main__":
    # Verification: coverage is computed against real names, and an unresolved
    # codepoint makes the report refuse the run — no rendering surface needed.
    policy = FontPolicy(
        primary=FontFace(family="NotoSans", path="/fonts/NotoSans-Regular.ttf"),
        fallback_chain=[FontFace(family="NotoSansSC", path="/fonts/NotoSansSC-Regular.otf")],
        subset_ranges=[(0x0000, 0x024F), (0x4E00, 0x9FFF)],
        required_codepoints=list("Zoë 王梓涵"),  # coerced to ints by the validator
    )

    # Synthetic cmaps: Latin+accents in the primary, Han in the fallback.
    cmaps = {
        "NotoSans": set(range(0x0000, 0x0250)),
        "NotoSansSC": set(range(0x4E00, 0xA000)),
    }
    report = build_coverage(policy, cmaps)
    assert report.total_required == len(set("Zoë 王梓涵"))
    assert report.resolved[ord("ë")] == "NotoSans"
    assert report.resolved[ord("王")] == "NotoSansSC"
    assert report.is_print_safe, report.unresolved

    # Drop the Han fallback: the same names must now refuse to print.
    broken = build_coverage(policy.model_copy(update={"fallback_chain": []}), cmaps)
    assert ord("王") in broken.unresolved
    assert not broken.is_print_safe
    print("OK:", report.model_dump())

Production Debugging & Observability Link to this section

Because embedding produces no visible error — a missing glyph renders as a legal box, not an exception — the coverage report is the telemetry. Every batch emits one structured event carrying the codepoints it could not resolve, so a name that will print blank is caught in logs long before it reaches the badge stock. Index these events by trace_id to correlate a quarantined batch back to its dynamic field mapping source and forward to the print receipt.

JSON
{
  "level": "warning",
  "event": "glyph_unresolved",
  "trace_id": "badge-9f2c1a7e",
  "batch_id": "reg-2026-day1-chunk-14",
  "primary_family": "NotoSans",
  "unresolved_codepoints": ["U+0E01", "U+1F600"],
  "sample_names": ["ก้อง", "Alex 😀"],
  "fallback_chain": ["NotoSans", "NotoSansSC", "NotoSansThai"]
}

In a Datadog or ELK pipeline, index by name: event as the monitored dimension (a rise in glyph_unresolved means a registration source introduced a script no face in the chain covers), unresolved_codepoints to identify exactly which font to add, fallback_chain to confirm the deployed policy matches the intended one, and batch_id to scope the replay. A quarantined batch retains its resolved display strings alongside the unresolved set and is held for replay through the now-complete chain — nothing is silently printed blank. An alert on count(event:glyph_unresolved) > 0 during an active print window is the earliest signal that a name will otherwise reach the desk unreadable; a slower-burning alert on embedded-font stream growth catches a subsetting regression before it inflates spool time.

Performance & Memory Constraints Link to this section

Embedding is CPU-bound on cmap reads and subset computation, and memory-bound on the font tables held per open face, not I/O-bound. The constraints below are the ones that bite during a large render.

Component Constraint Mitigation
fontTools TTFont load A full parse of a large CJK face (10–20 MB) holds every table in memory per open handle Open with lazy=True, read only cmap, and close the handle; cache the codepoint set per family, not the TTFont object
Coverage recomputation Re-reading cmap per badge across a 20k manifest is redundant CPU Compute face_codepoints once per face per batch and reuse; the required-codepoint set is the only per-batch variable
ReportLab subset table Each embedded subset carries its own glyph and hmtx tables; a merged batch can duplicate them Deduplicate identical subsets across chunks — see embedding subset fonts to shrink badge PDF size
Fallback chain depth A long chain multiplies cmap lookups per codepoint Order the chain by expected script frequency so the common case resolves at the primary face; cap the chain length
Glyph cache (GIL) cmap parsing is a CPU-bound C loop that holds the GIL during a burst Scale render workers process-per-core, not threads; pre-warm the per-family codepoint cache at worker start

Incident Triage Checklist Link to this section

When badges come back blank or a batch trips the missing-glyph alert, work these steps in order. Target MTTR is under fifteen minutes; every step is non-destructive until the final replay.

  1. Confirm the symptom class. Blank name field = missing glyph; wrong-looking glyph or wrong width = font substituted at the RIP because the face was not embedded. Grep the batch log: grep glyph_unresolved app.log | jq -r '.unresolved_codepoints[]' | sort -u.
  2. Identify the missing script. Feed the codepoints back to Python: python -c "print([hex(0x0E01)])" maps U+0E01 to Thai — you now know which face the chain is missing.
  3. Verify the deployed policy. redis-cli GET fontpolicy:active | jq '.fallback_chain' and confirm the fallback face for that script is actually listed and its path resolves on the render host: ls -l /fonts/NotoSansThai-Regular.ttf.
  4. Confirm embedding, not just presence. python -c "from pikepdf import Pdf; print([f for f in Pdf.open('badge.pdf').Root.AcroForm] if False else 'check /FontFile2')" — or simply pdffonts badge.pdf and confirm every listed font shows emb=yes. A no in that column is the substitution bug.
  5. Add the face and replay. Deploy the missing font, append it to fallback_chain, and hand the held batch to the PDF routing workflows replay path. Because coverage is recomputed on replay, a batch that still has unresolved codepoints re-quarantines rather than printing blank.

Frequently Asked Questions Link to this section

Why do names look correct in the PDF preview but print as boxes? Because your PDF viewer falls back to a system font for the missing codepoints, while the print farm’s raster image processor does not — it renders only what is embedded. The preview is lying to you. The only reliable check is a coverage report against the actual names plus pdffonts confirming every face shows emb=yes; never trust a visual preview to prove a badge is print-safe.

Do I need a fallback chain if my primary font is a large Noto family? Almost always yes. No single face covers every script an international attendee list will contain — a font with full CJK, Arabic, Thai, and emoji coverage in one file effectively does not exist for badge use. An ordered fallback chain lets a compact Latin primary handle the common case and hands the rare scripts to dedicated faces, which also keeps the subset smaller than one monolithic face would.

Does embedding a subset lose glyphs the printer might need? No, as long as the subset is computed from the batch’s actual required codepoints rather than a guessed range. The subset carries exactly the glyphs drawn in that document; the printer never needs a glyph the document does not reference. The risk is not subsetting too tightly but computing the required set from the wrong data — which is why coverage is checked against the real names before the subset is written.


Up: Badge Generation & Template Sync — the pipeline this embedding stage sits inside.