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.

Subsetting a large CJK font to a small WOFF2 for embedding A large 40 megabyte font and a codepoint set both feed a subsetter, which outputs a small WOFF2 font that is embedded into a PDF that is much smaller than the unsubsetted version. full font ≈ 40 MB, 40k glyphs codepoint set used chars only pyftsubset flavor=woff2 WOFF2 ≈ 90 KB PDF small & portable

Prerequisites

  • Python 3.10+ with fonttools>=4.40 and brotli installed (pip install fonttools brotli) — brotli is required for WOFF2 output
  • weasyprint>=60.0 for 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.

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

Bash
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:

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

CSS
@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; }
Python
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.

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

  • brotli not installed. pyftsubset --flavor=woff2 fails without the brotli package, and WeasyPrint cannot decode the WOFF2 without it either. Install it alongside fonttools.

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

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