Embedding CJK Fonts for Multilingual Map Labels

A GIS report that crosses East Asia cannot rely on the renderer finding the right glyphs by luck. Chinese, Japanese, and Korean labels draw from tens of thousands of codepoints, and if WeasyPrint has no embedded font covering them the PDF fills with tofu — the empty boxes that signal a missing glyph. Worse, because of Han unification, the same codepoint should look subtly different depending on locale, so a Japanese place name rendered with a Simplified Chinese glyph is technically legible but visibly wrong to a native reader. This guide is a focused technique within the Typography Mapping for Multi-Language Spatial Data workflow, covering pan-CJK font selection, @font-face embedding, lang-driven glyph selection, fallback stacks, and installation in a headless container.

Per-script font selection for multilingual labels A mixed-script label string enters a language-tagging stage, then splits by script to a Latin font, a CJK font, and a symbol font, all embedded into a single PDF output. mixed label Latin + Han + ° lang tagging lang="ja" / "zh" / "ko" Latin font Source Sans CJK font Noto Sans CJK symbol font Noto Sans Symbols PDF fonts embedded

Prerequisites

  • Python 3.10+ with weasyprint>=60.0 installed (pip install weasyprint)
  • Noto Sans CJK or Source Han Sans font files (.otf / .ttc), one per regional weight you need
  • Label data already tagged with a locale (zh-Hans, zh-Hant, ja, ko) from the attribute source
  • Familiarity with font stacks and script coverage from Typography Mapping for Multi-Language Spatial Data

Step 1: Choose a Pan-CJK Font Family

Noto Sans CJK and Source Han Sans are the same typeface under two names, published by Google and Adobe respectively. Both ship region-specific builds — NotoSansCJKsc (Simplified Chinese), NotoSansCJKtc (Traditional Chinese), NotoSansCJKjp (Japanese), and NotoSansCJKkr (Korean) — plus a combined .ttc. For a report that mixes locales on one map, prefer the per-region OTFs so lang can select the correct Han variant.

Text
NotoSansCJKsc-Regular.otf   → labels tagged zh-Hans
NotoSansCJKtc-Regular.otf   → labels tagged zh-Hant
NotoSansCJKjp-Regular.otf   → labels tagged ja
NotoSansCJKkr-Regular.otf   → labels tagged ko

Region-specific files keep the embedded subset smaller than the 40+ MB combined .ttc, which matters when you later trim the PDF as described in Subsetting WOFF2 Fonts to Shrink Report PDFs.

Step 2: Declare @font-face for WeasyPrint

WeasyPrint embeds a font only when it can resolve the file, either through the system font cache or an explicit @font-face with a local url(). Register each regional face with a shared family name distinguished by an explicit unicode-range is unnecessary here — instead give each region its own family so lang styling can target it.

CSS
@font-face {
  font-family: "Noto Sans CJK SC";
  src: url("fonts/NotoSansCJKsc-Regular.otf") format("opentype");
  font-weight: 400;
}
@font-face {
  font-family: "Noto Sans CJK JP";
  src: url("fonts/NotoSansCJKjp-Regular.otf") format("opentype");
  font-weight: 400;
}
@font-face {
  font-family: "Noto Sans CJK KR";
  src: url("fonts/NotoSansCJKkr-Regular.otf") format("opentype");
  font-weight: 400;
}

Pass the stylesheet’s base URL to WeasyPrint so the relative url() paths resolve. When you feed HTML as a string, set base_url to the directory that contains fonts/, or WeasyPrint cannot locate the OTFs and silently falls back to tofu.

Step 3: Tag Labels with lang Attributes

Han unification means one codepoint, several correct shapes. The lang attribute lets the font engine pick the regional glyph, and it also feeds the :lang() CSS selector that binds each label to its regional family.

HTML
<span class="map-label" lang="ja">東京都</span>
<span class="map-label" lang="zh-Hans">北京市</span>
<span class="map-label" lang="ko">서울특별시</span>
CSS
.map-label:lang(ja)      { font-family: "Noto Sans CJK JP", sans-serif; }
.map-label:lang(zh-Hans) { font-family: "Noto Sans CJK SC", sans-serif; }
.map-label:lang(ko)      { font-family: "Noto Sans CJK KR", sans-serif; }

Generate the lang value from the locale column of your attribute table so the tagging is data-driven, not hand-maintained. This is exactly the kind of per-row conditional binding covered in Iterating Through Shapefile Attributes in ReportLab, applied here to font selection instead of table cells.

Step 4: Build Per-Script Fallback Stacks

A single label often mixes scripts: Tōkyō 東京 (35.6°N). The Latin, Han, and degree-symbol glyphs must each resolve to a covering font. Order the stack Latin-first, CJK-second, symbol-last so the browser-style fallback walks the list per glyph.

CSS
:root {
  --stack-jp: "Source Sans 3", "Noto Sans CJK JP", "Noto Sans Symbols", sans-serif;
  --stack-sc: "Source Sans 3", "Noto Sans CJK SC", "Noto Sans Symbols", sans-serif;
}

.map-label:lang(ja) { font-family: var(--stack-jp); }

With a Latin font first, Roman characters use the crisper Latin design rather than the CJK font’s wider proportional Latin glyphs, while Han characters fall through to the CJK font because the Latin font does not cover them. The symbol font catches the degree sign and arrows that neither of the first two fonts includes.

Step 5: Install the Fonts in the Docker Image

Headless renders in CI fail differently from your workstation: the container has no fonts unless you add them. Copy the files into a font directory and refresh the cache so fontconfig — which WeasyPrint consults — can find them.

DOCKERFILE
FROM python:3.12-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
      libpango-1.0-0 libpangocairo-1.0-0 libharfbuzz0b fontconfig \
    && rm -rf /var/lib/apt/lists/*

# Add the CJK OTFs and rebuild the font cache
COPY fonts/NotoSansCJK*.otf /usr/share/fonts/opentype/noto-cjk/
RUN fc-cache -f -v && fc-list | grep -i "cjk"

RUN pip install --no-cache-dir weasyprint

fc-cache -f rebuilds the index so the very first render finds the glyphs; skipping it is a frequent cause of a container that works on the second run but tofus on the first. The Pango and HarfBuzz packages are WeasyPrint’s text-shaping dependencies and must be present for CJK shaping to work at all.

Step 6: Verify Glyph Coverage in the PDF

Render, then confirm the fonts are embedded and cover the label codepoints.

Bash
pdffonts multilingual_map.pdf
# name                     type   emb sub
# NotoSansCJKjp-Regular    CID    yes yes    ← emb=yes means embedded

Key Parameters / Configuration Reference

Spec Value Notes
Font family Noto Sans CJK / Source Han Sans Same typeface, two publishers
Regional builds sc / tc / jp / kr One per Han-unification locale
@font-face src format opentype Match the actual file type (otf)
base_url fonts directory Required when rendering HTML from a string
lang values zh-Hans / zh-Hant / ja / ko Drives :lang() and glyph variant selection
Fallback order Latin → CJK → symbol Per-glyph fallback walks the stack
Docker font dir /usr/share/fonts/opentype/ Run fc-cache -f after copying

Common Pitfalls

  • Relying on the system font by name only. WeasyPrint on a slim container has no CJK font installed, so a bare font-family: "Noto Sans CJK JP" yields tofu. Either embed via @font-face url() or install the file and rebuild the cache.

  • Wrong base_url for string-rendered HTML. Relative url("fonts/...") paths resolve against base_url, not the working directory. Pass the correct base or the fonts silently fail to load.

  • One combined family for all locales. Using a single .ttc without lang tags renders every label with one region’s Han shapes, so Japanese names get Simplified Chinese glyphs. Keep per-region families and tag the labels.

  • Missing text-shaping libraries. Without libpango and libharfbuzz, CJK ligatures and vertical metrics break even when the font is present. Install them in the same image, as covered in the container setup for Typography Mapping for Multi-Language Spatial Data.

Verification

Assert every label codepoint is covered by an embedded font before publishing, using fontTools to read the font’s character map.

Python
from fontTools.ttLib import TTFont

def assert_coverage(font_path: str, labels: list[str]) -> None:
    """Fail if any label character is missing from the font's cmap."""
    cmap = TTFont(font_path).getBestCmap()
    for label in labels:
        missing = [ch for ch in label if ord(ch) not in cmap]
        assert not missing, f"{font_path} missing {missing!r} for {label!r}"

assert_coverage("fonts/NotoSansCJKjp-Regular.otf", ["東京都", "35.6°N"])

Run this as a preflight gate so a label containing an uncovered rare character fails fast instead of shipping a tofu box into a published map.