Subsetting WOFF2 Fonts to Shrink Report PDFs
A pan-CJK font is enormous: Noto Sans CJK carries tens of thousands of glyphs and weighs 40 MB or more per weight. Embed a couple of those into a WeasyPrint PDF and the file balloons past what an email gateway or document portal will accept, even though the report itself uses only a few hundred distinct characters. Subsetting solves this by stripping the font down to exactly the glyphs the report renders, and fontTools pyftsubset with WOFF2 output does it in one command. This guide is a focused technique within the Typography Mapping for Multi-Language Spatial Data workflow, and it is the natural follow-up to Embedding CJK Fonts for Multilingual Map Labels, which gets the full fonts working before you trim them.
Prerequisites
- Python 3.10+ with
fonttools>=4.40andbrotliinstalled (pip install fonttools brotli) —brotliis required for WOFF2 output weasyprint>=60.0for the embedding step (pip install weasyprint)- The full source fonts already working, per Embedding CJK Fonts for Multilingual Map Labels
- The report’s label data available as strings so you can compute the exact codepoint set
Step 1: Collect the Used Codepoints
Subsetting is only safe if the codepoint set truly covers everything the report renders. Build it from the actual label data — map labels, table cells, headings, and any static boilerplate — not from a guessed range.
from __future__ import annotations
from collections.abc import Iterable
def collect_codepoints(strings: Iterable[str], always: str = "") -> set[int]:
"""Return the set of Unicode codepoints used across all report text."""
used: set[int] = {ord(ch) for text in strings for ch in text}
used |= {ord(ch) for ch in always} # force-include boilerplate glyphs
return used
labels = ["東京都", "北京市", "35.6°N 139.7°E", "Sheet 1 of 3"]
BOILERPLATE = "0123456789.,:-–—°′″NSEW ()/%"
codepoints = collect_codepoints(labels, always=BOILERPLATE)
# Serialise as U+XXXX tokens for pyftsubset
unicodes = ",".join(f"U+{cp:04X}" for cp in sorted(codepoints))
Always force-include the numeric and punctuation glyphs used by coordinate readouts and page furniture. These come from a fixed template, not the variable label data, so they are easy to omit and produce tofu in the least-expected place — a page number.
Step 2: Run pyftsubset to WOFF2
pyftsubset accepts the codepoint list and emits a trimmed font. Set --flavor=woff2 for the compressed container and keep the layout tables the shaping engine needs.
pyftsubset NotoSansCJKjp-Regular.otf \
--unicodes="U+0030-0039,U+6771,U+4EAC,U+90FD,U+00B0,U+004E,U+0053" \
--flavor=woff2 \
--layout-features='*' \
--output-file=subset/NotoSansCJKjp-subset.woff2
Drive it from Python so the --unicodes list is generated, never hand-typed:
import subprocess
def subset_font(src: str, unicodes: str, out: str) -> None:
"""Subset a font to WOFF2 covering exactly the given codepoints."""
subprocess.run(
["pyftsubset", src,
f"--unicodes={unicodes}",
"--flavor=woff2",
"--layout-features=*",
f"--output-file={out}"],
check=True,
)
subset_font("NotoSansCJKjp-Regular.otf", unicodes,
"subset/NotoSansCJKjp-subset.woff2")
Keep --layout-features='*' so kerning, vertical metrics, and CJK positioning features survive; stripping them with an aggressive subset can misalign stacked or vertical labels. If you also pass --unicodes-file, you can feed a superset codepoint list shared across every report the template can produce.
Step 3: Embed the Subset in WeasyPrint
Point @font-face at the WOFF2 subset with format("woff2"). WeasyPrint decompresses it via its Brotli dependency and embeds only the trimmed glyph set into the PDF.
@font-face {
font-family: "Noto Sans CJK JP";
src: url("subset/NotoSansCJKjp-subset.woff2") format("woff2");
font-weight: 400;
}
.map-label:lang(ja) { font-family: "Noto Sans CJK JP", sans-serif; }
from weasyprint import HTML
HTML(filename="report.html", base_url=".").write_pdf("report_subset.pdf")
The base_url must resolve the subset/ path exactly as in Embedding CJK Fonts for Multilingual Map Labels; the only change from the full-font setup is swapping the url() target and the format hint. The same WeasyPrint pipeline covered in WeasyPrint PDF Rendering Pipeline needs no other adjustment.
Step 4: Measure the Size Reduction
Quantify the win so the subset step earns its place in the pipeline. Render both variants and compare bytes.
from pathlib import Path
def report_saving(full_pdf: str, subset_pdf: str) -> None:
full = Path(full_pdf).stat().st_size
sub = Path(subset_pdf).stat().st_size
pct = (1 - sub / full) * 100
print(f"full={full/1_000_000:.1f} MB subset={sub/1_000:.0f} KB saved={pct:.1f}%")
report_saving("report_full.pdf", "report_subset.pdf")
# full=41.6 MB subset=310 KB saved=99.3%
Key Parameters / Configuration Reference
| Parameter | Value | Effect |
|---|---|---|
--unicodes |
U+XXXX,U+YYYY-ZZZZ |
Exact codepoints or ranges to keep |
--unicodes-file |
path | Reads codepoints from a file — good for a shared superset |
--flavor |
woff2 |
Emits a Brotli-compressed WOFF2 container |
--layout-features |
* |
Keeps all OpenType layout features (kerning, vertical) |
--desubroutinize |
flag | Can shrink CFF fonts further at a small speed cost |
format() hint |
woff2 |
Tells WeasyPrint how to decode the @font-face source |
brotli package |
required | Without it, --flavor=woff2 fails |
Common Pitfalls
-
Subset misses a rare character. A place name with an uncommon Han glyph absent from the codepoint set renders as tofu. Regenerate the subset per dataset, or subset against a superset list covering every value the pipeline can emit.
-
Forgetting boilerplate glyphs. Digits, the degree sign, and slash come from the static template, not the label data. Force-include them or a coordinate readout loses characters.
-
brotlinot installed.pyftsubset --flavor=woff2fails without thebrotlipackage, and WeasyPrint cannot decode the WOFF2 without it either. Install it alongsidefonttools. -
Stripping layout features on vertical labels. An over-aggressive subset that drops OpenType features breaks vertical CJK typesetting and kerning. Keep
--layout-features='*'unless you have measured that a narrower set is safe.
Verification
Confirm the subset still covers every codepoint the report uses before trusting it in production.
from fontTools.ttLib import TTFont
def assert_subset_covers(woff2_path: str, required: set[int]) -> None:
"""Fail if the subset dropped any codepoint the report needs."""
font = TTFont(woff2_path) # fontTools reads WOFF2 directly
cmap = set(font.getBestCmap().keys())
missing = required - cmap
assert not missing, f"subset missing {[hex(c) for c in sorted(missing)]}"
assert_subset_covers("subset/NotoSansCJKjp-subset.woff2", codepoints)
Wire this assertion into CI immediately after the subset step so a template change that introduces a new glyph fails the build rather than shipping a broken label. Pair it with the file-size check from Step 4 to catch a subset that silently reverted to the full glyph set.
Related
- Embedding CJK Fonts for Multilingual Map Labels — the upstream step that gets the full CJK fonts rendering before this guide trims them
- Typography Mapping for Multi-Language Spatial Data — parent guide covering the whole multilingual typography stack
- WeasyPrint PDF Rendering Pipeline — the render pipeline that embeds these subset fonts into the final PDF
- Parent section: Typography Mapping for Multi-Language Spatial Data