Embedding Subset Fonts to Shrink Badge PDF Size

Symptom Statement Link to this section

A single badge PDF that should weigh 30–80 KB comes off the generator at 4–12 MB, a merged 5,000-badge batch balloons past a gigabyte, and the whole run crawls: the PDF routing workflows delivery step times out on upload, email attachments bounce over size limits, and the print spooler drips one bloated file at a time. This page is the runbook for that exact symptom — badge PDFs an order of magnitude larger than they should be — and it is the size-discipline complement to the font and glyph embedding stage that guarantees coverage. The tell is diagnostic and immediate: run pdffonts badge.pdf and every face reports its full multi-megabyte outline table, or pdfimages/a stream dump shows the same font bytes repeated once per page. The fix is not to stop embedding — that would reintroduce the blank-name failure — but to embed only the glyphs each badge draws, once per batch, compressed.

Three levers that shrink an embedded-font badge PDF from megabytes to kilobytes A batch of badges enters carrying full embedded fonts, shown as a large before bar. Three sequential levers reduce it. Lever one, subsetting, keeps only the glyph outlines the badges actually draw and discards the rest of each face. Lever two, deduplication, collapses identical font streams repeated across a merged batch into a single shared stream referenced by every page. Lever three, stream compression, deflates the remaining font and content streams. The output is a small after bar representing a print-safe PDF a fraction of the original size, with the same glyph coverage. Before ~8 MB full fonts, per page 1 · Subset keep drawn glyphs only 2 · Deduplicate one shared stream 3 · Compress deflate streams After ~60 KB same coverage

Root Cause Analysis Link to this section

Four concrete faults inflate a badge PDF. They stack, so a bloated file usually has more than one, but each has its own signature and its own fix.

  • Full-font embedding. The generator embeds the entire face — every glyph, hinting table, and language system — when a badge draws perhaps forty distinct characters. A CJK face carries tens of thousands of outlines; embedding all of them per document is the single largest source of bloat.
  • No subsetting. Subsetting is either disabled at the canvas layer or bypassed by a font path that ReportLab treats as opaque, so nothing prunes the face down to the drawn glyph set even when embedding is otherwise correct.
  • Duplicate font streams across a merged batch. Each per-badge PDF embeds its own copy of the face, and a naive merge concatenates them, so a 5,000-badge batch carries up to 5,000 identical font streams instead of one shared resource.
  • Uncompressed streams. Font and content streams are written as raw, undeflated bytes. PDF supports FlateDecode on every stream; skipping it leaves a large, highly-compressible blob uncompressed on disk and on the wire to the printer.

Symptom-to-Resolution Matrix Link to this section

Full-Font Embedding Link to this section

Symptoms

  • Every badge is multiple megabytes regardless of how short the name is.
  • pdffonts badge.pdf shows the full glyph count for each face, not a subset tag.

Root cause. The complete face is written into the document even though only the drawn glyphs are referenced.

Fix

  1. Compute the exact codepoints the badge draws before rendering — the same required set the coverage check in font and glyph embedding produces.
  2. Subset each face to that codepoint set with fontTools Subsetter and embed the subset, not the source file.
  3. Verify the reduction with pdffonts, which tags a subset face with a six-letter prefix (for example ABCDEF+NotoSans).

No Subsetting at the Canvas Layer Link to this section

Symptoms

  • Files stay large even after switching fonts; the size tracks the face, not the content.
  • ReportLab’s subset behavior was disabled or overridden in the canvas configuration.

Root cause. ReportLab subsets TrueType faces on embed by default, but that default can be turned off, and pre-subsetting with fontTools then embedding the reduced face is the deterministic path.

Fix

  1. Leave ReportLab’s default TrueType subsetting on, or pre-subset with fontTools and register the reduced .ttf/.otf.
  2. Never register the full source face when a subset covers the batch’s required codepoints.
  3. Treat the subset as a build artifact keyed by the batch’s codepoint set so identical batches reuse it.

Duplicate Font Streams Across a Merged Batch Link to this section

Symptoms

  • Individual badges are reasonable but the merged batch PDF is enormous.
  • A stream dump shows the same font bytes repeated once per page.

Root cause. Each page embeds its own copy of the face and the merge does not share font resources.

Fix

  1. Subset every page against the batch-wide union of required codepoints so all pages reference one identical stream.
  2. Deduplicate identical font streams by content hash during the merge, collapsing N copies to one shared object.
  3. Prefer a merge tool that shares resources (for example pikepdf/qpdf object streams) over naive concatenation.

Uncompressed Streams Link to this section

Symptoms

  • The file is large but a gzip of it shrinks dramatically, proving the internal streams are raw.
  • Object streams and content streams show no FlateDecode filter.

Root cause. Streams were written without deflate compression.

Fix

  1. Enable stream compression at write time (canvas.Canvas(..., pageCompression=1) in ReportLab).
  2. Run a post-pass with pikepdf saving with compress_streams=True and object-stream generation.
  3. Confirm the internal streams carry FlateDecode rather than relying on transport-layer gzip alone.

Minimal Working Implementation Link to this section

A self-contained size pipeline: subset_face reduces a real face to the drawn codepoints with fontTools, dedupe collapses identical streams across a batch by content hash, and the verification block proves — with no font file needed — that dedupe and compression shrink the embedded payload while coverage is unchanged. The subset_face function is the real fontTools call; the assertions exercise the dedupe and compression accounting that turn a per-page multi-megabyte batch into a shared, deflated one.

PYTHON
import io
import zlib
import hashlib
from fontTools import subset
from fontTools.ttLib import TTFont as FTFont


def subset_face(path: str, codepoints: set[int]) -> bytes:
    """Reduce a face to only the drawn codepoints and return the subset bytes."""
    options = subset.Options()
    options.desubroutinize = True          # flatten CFF subrs for smaller output
    options.notdef_outline = True          # keep .notdef so unresolved cps are visible
    subsetter = subset.Subsetter(options=options)
    subsetter.populate(unicodes=sorted(codepoints))
    font = FTFont(path)
    subsetter.subset(font)
    out = io.BytesIO()
    font.save(out)
    return out.getvalue()


def dedupe(streams: dict[str, bytes]) -> tuple[dict[str, bytes], dict[str, str]]:
    """Collapse identical font streams: return unique-by-hash bytes + a ref map."""
    canonical: dict[str, bytes] = {}       # content hash -> bytes (stored once)
    refs: dict[str, str] = {}              # page/font id -> content hash
    for font_id, data in streams.items():
        digest = hashlib.sha256(data).hexdigest()
        canonical.setdefault(digest, data)
        refs[font_id] = digest
    return canonical, refs


def total_bytes(streams: dict[str, bytes]) -> int:
    return sum(len(b) for b in streams.values())


if __name__ == "__main__":
    # A subset face stands in as a ~4 KB repetitive glyph stream.
    subset_stream = b"glyf-outline-bytes" * 256

    # Naive merge: a 200-badge batch embeds the identical subset once per page.
    naive = {f"page{i}:NotoSans": subset_stream for i in range(200)}
    canonical, refs = dedupe(naive)

    # 200 identical streams collapse to exactly one shared object.
    assert len(canonical) == 1
    assert len(refs) == 200
    # Deduped payload is orders of magnitude smaller than the naive concatenation.
    assert total_bytes(canonical) * 50 < total_bytes(naive)

    # Uncompressed font streams are highly compressible: FlateDecode is free size.
    assert len(zlib.compress(subset_stream)) < len(subset_stream)

    # Subset discipline: a Latin-only badge needs a tiny fraction of a full face.
    full_face_ranges = set(range(0x0000, 0x10000))
    drawn = {ord(c) for c in "Ada Lovelace · VIP"}
    assert len(drawn) < len(full_face_ranges) // 100

    print("OK: subset + dedupe + compression reduce embedded size, coverage intact")

The assertions are the fix’s regression test: dedupe collapses a 200-page batch’s font payload to a single shared stream, deflate proves the raw stream was needlessly large, and the codepoint count confirms a badge references a hundredth of a full face — the exact three levers that take an 8 MB file to tens of kilobytes without dropping a glyph.

Memory & Performance Constraints Link to this section

Subsetting and hashing are CPU-bound; the risk is doing them per badge instead of per batch. The constraints below bite on a full manifest.

Component Constraint Mitigation
subset_face per badge Re-subsetting a large face 5,000 times dominates render CPU Subset once against the batch-wide codepoint union and reuse the artifact for every page
fontTools TTFont load Loading a 15 MB source face to subset holds all tables in memory Subset once per batch; cache the subset bytes keyed by the codepoint set
Dedupe hashing Hashing every page’s stream is O(bytes) per page Hash the subset once and reuse the digest; pages sharing a subset never re-hash
Merge object model pikepdf/qpdf holds the merged object graph in memory Stream-merge in bounded chunks; write object streams so shared fonts collapse
Compression CPU Deflating every stream adds CPU on the write path Compress once at final save; avoid double-compressing already-deflated streams

Incident Triage & Rollback Link to this section

Fast path when a size or delivery-timeout alarm fires — target under fifteen minutes. Steps are non-destructive until the rollback.

  1. Confirm it is fonts, not images. pdffonts /var/spool/badges/day1.pdf — a full glyph count with no ABCDEF+ subset prefix is the full-font-embed bug; if fonts look subset, check pdfimages -list for stray raster assets instead.
  2. Detect duplicate streams. Dump object sizes: qpdf --show-object=trailer --json day1.pdf | jq '.objects | length' against page count; a font-object count that scales with pages confirms un-shared streams.
  3. Check compression. qpdf --check day1.pdf and confirm content/font streams use FlateDecode; a file that shrinks hard under gzip -9 was written uncompressed internally.
  4. Rebuild with the levers. Re-render the batch with a batch-wide subset, shared streams, and pageCompression=1, then re-drive delivery through the PDF routing workflows queue.

Rollback. If a subsetting change dropped a glyph and reintroduced blanks, revert the font-build step — git revert HEAD~1 --no-edit && docker compose up -d --build — which restores full-face embedding: larger files, but correct badges. Size is a throughput problem; a missing glyph is a reprint problem, so roll back toward coverage first and re-optimize once the glyph set is right. Because subsetting is a pure function of the codepoint set, re-running is always safe.

Post-rollback validation. Prove the rebuilt file is both small and complete before releasing:

BASH
python build_badges.py --batch day1 --out /tmp/day1.pdf
test "$(stat -c%s /tmp/day1.pdf)" -lt 5000000        # under 5 MB for the batch
pdffonts /tmp/day1.pdf | grep -c 'emb no'            # expect 0 — still fully embedded

Frequently Asked Questions Link to this section

Will subsetting drop a glyph and bring back the blank-name problem? Not if the subset is built from the badges’ actual drawn codepoints rather than a guessed range. The subset keeps exactly the glyphs referenced in the document, so coverage is identical to the full face for those names. The failure mode is subsetting against the wrong codepoint set, which is why the subset should consume the same required-codepoint set the coverage check produces, and why pdffonts must still show emb=yes after the reduction.

Why is my merged batch huge when each individual badge is small? Because a naive merge concatenates each page’s own copy of the font instead of sharing one. Subset every page against the batch-wide union of codepoints so all pages reference an identical stream, then deduplicate those streams by content hash during the merge — or use a merge tool that shares font resources through object streams. One shared face replaces N copies.

Should I compress the PDF or just gzip it on the wire? Compress the streams inside the PDF with FlateDecode; transport gzip alone leaves the file large on disk, in the print spooler, and in any cache. Internal compression means the spooler and every downstream stage handle a small file, not just the HTTP hop. Enable it at write time and confirm the streams carry the filter rather than relying on the transport layer.