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.
Prerequisites
- Python 3.10+ with
weasyprint>=60.0installed (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.
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.
@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.
<span class="map-label" lang="ja">東京都</span>
<span class="map-label" lang="zh-Hans">北京市</span>
<span class="map-label" lang="ko">서울특별시</span>
.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.
: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.
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.
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_urlfor string-rendered HTML. Relativeurl("fonts/...")paths resolve againstbase_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
.ttcwithoutlangtags 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
libpangoandlibharfbuzz, 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.
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.
Related
- Typography Mapping for Multi-Language Spatial Data — parent guide covering script coverage, bidirectional text, and font stacks across the whole report
- Subsetting WOFF2 Fonts to Shrink Report PDFs — trims the large CJK fonts embedded here down to only the glyphs the report uses
- Iterating Through Shapefile Attributes in ReportLab — data-driven per-row rendering that supplies the locale tags this technique relies on
- Parent section: Typography Mapping for Multi-Language Spatial Data