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.
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
FlateDecodeon 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.pdfshows 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
- Compute the exact codepoints the badge draws before rendering — the same required set the coverage check in font and glyph embedding produces.
- Subset each face to that codepoint set with fontTools
Subsetterand embed the subset, not the source file. - Verify the reduction with
pdffonts, which tags a subset face with a six-letter prefix (for exampleABCDEF+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
subsetbehavior 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
- Leave ReportLab’s default TrueType subsetting on, or pre-subset with fontTools and register the reduced
.ttf/.otf. - Never register the full source face when a subset covers the batch’s required codepoints.
- 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
- Subset every page against the batch-wide union of required codepoints so all pages reference one identical stream.
- Deduplicate identical font streams by content hash during the merge, collapsing N copies to one shared object.
- 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
gzipof it shrinks dramatically, proving the internal streams are raw. - Object streams and content streams show no
FlateDecodefilter.
Root cause. Streams were written without deflate compression.
Fix
- Enable stream compression at write time (
canvas.Canvas(..., pageCompression=1)in ReportLab). - Run a post-pass with
pikepdfsaving withcompress_streams=Trueand object-stream generation. - Confirm the internal streams carry
FlateDecoderather 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.
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.
- Confirm it is fonts, not images.
pdffonts /var/spool/badges/day1.pdf— a full glyph count with noABCDEF+subset prefix is the full-font-embed bug; if fonts look subset, checkpdfimages -listfor stray raster assets instead. - 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. - Check compression.
qpdf --check day1.pdfand confirm content/font streams useFlateDecode; a file that shrinks hard undergzip -9was written uncompressed internally. - 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:
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.
Related Link to this section
- Font & Glyph Embedding — the parent stage that produces the required-codepoint set this fix subsets against and owns the coverage contract.
- Fixing Missing Glyphs for Non-Latin Attendee Names — the sibling runbook for the opposite failure, when a face is too small and names blank rather than the file being too big.
- PDF Routing Workflows — the delivery and spooling stage whose timeouts and attachment limits an oversized badge PDF violates.
- Badge Layout Architecture — owns the template geometry and font-family selection that determine which glyphs each badge draws.