Pinning Fonts and Locale in Map Render Containers
Bundle the fonts into the image, set the locale explicitly, and assert at start-up that every requested family resolved to itself — because a render node that silently substitutes a font produces plausible tiles in which different labels have been suppressed, and nothing in the pipeline reports it.
Core Algorithm and Workflow
Four environment properties change rendered output without changing any code, and fonts are by far the worst of them.
A font substitution does not merely look slightly different. Label bounding boxes are computed from font metrics, and the collision resolver described in Label Collision Avoidance Algorithms consumes those boxes. Wider glyphs make labels collide that previously fitted, so a different set of labels is suppressed. The map that results is internally consistent, visually reasonable, and names different places than the reference.
The remedy is to make the environment part of the artefact:
- Bundle fonts at build time and rebuild the font cache in the same layer.
- Pin the font files and the fontconfig version in whatever lockfile the image uses.
- Set the locale explicitly so number and date formatting in labels does not follow the host.
- Assert at start-up that each requested family resolved to itself, and exit if not.
- Diff a reference tile set on every image build.
Production-Ready Python Implementation
import locale
import os
import subprocess
def assert_locale(expected: str = "C.UTF-8") -> None:
"""Fail fast if the host locale would change how labels are formatted."""
current = os.environ.get("LC_ALL") or os.environ.get("LANG") or ""
if not current.startswith(expected.split(".")[0]):
raise RuntimeError(
f"locale is {current!r}, expected {expected!r}: number formatting "
f"in labels will differ from the reference environment")
# Also pin it for anything that reads the C library directly.
locale.setlocale(locale.LC_ALL, expected)
def assert_fonts(required_families: list) -> dict:
"""Confirm each family resolves to itself rather than to a substitute."""
resolved, problems = {}, []
for family in required_families:
out = subprocess.run(
["fc-match", "-f", "%{family}", family],
capture_output=True, text=True, check=True).stdout.strip()
resolved[family] = out
# fc-match always returns something; equality is the only real test.
if family.lower() not in out.lower():
problems.append(f"{family!r} resolved to {out!r}")
if problems:
raise RuntimeError("font substitution detected: " + "; ".join(problems))
return resolved
The comment on fc-match is the crux. The resolver never fails — it returns its best match — so the presence of output proves nothing. Comparing the requested family against the resolved one is the only test that distinguishes “the font is installed” from “something was returned”, and it is the check that a missing-font bug will otherwise evade for months.
Performance Tuning and Cartographic Best Practices
- Bundle at build time, never mount at runtime. A mounted font directory means the same image tag renders differently depending on the host, which defeats pinning entirely. A few megabytes per family is a cheap price for an environment fully described by the tag.
- Rebuild the font cache in the same layer. Copying font files without running
fc-cacheleaves them invisible to the resolver, and the symptom is identical to not having copied them. - Register families with the renderer explicitly. Mapnik and QGIS both maintain their own font registries; a font visible to fontconfig is not automatically visible to them.
- Set
LC_ALL, not justLANG.LC_NUMERICinherited from the host is enough to turn “1,000 m” into “1.000 m” on a scale bar. - Diff reference tiles on every image build. A dozen tiles covering dense labelling, a graticule and a scale bar will catch every environment drift this page describes, and it runs in seconds.
Integration and Next Steps
Start-up assertions belong in the render worker’s boot path, before it accepts any work, so a drifted host removes itself from the pool rather than producing subtly different tiles. The reference-tile diff belongs in the image build pipeline, alongside the regression approach described in Automated Cartographic Design Fundamentals — and it is what makes an engine migration measurable, as set out in Mapnik vs MapLibre Native for Headless Server-Side Rendering.
Frequently Asked Questions
Why is a font substitution so damaging?
Because label bounding boxes are computed from font metrics and the collision resolver consumes them. A substituted family whose glyphs are a few per cent wider makes labels collide that previously fitted, so the resolver suppresses a different set — and the tile that results names different places. It is not a cosmetic difference in type appearance; it is a change to the map’s content, arrived at through a mechanism nobody inspects when a village goes missing.
Does the locale really affect a map?
Wherever a number or a date is formatted into a label, yes. A scale bar reading “1,000 m” on one host and “1.000 m” on another is the obvious case; graticule coordinate labels and dated margin credits are the quieter ones. Setting LC_ALL explicitly costs a single line in the image and removes a class of difference that is otherwise invisible until two hosts’ output is compared side by side.
Should fonts be installed at build time or mounted at runtime?
Build time, without exception. A mounted font directory makes the image non-reproducible: the same tag renders differently depending on what the host happened to mount, which defeats the point of pinning anything else. Bundling costs a few megabytes per family and buys a rendering environment that the image tag fully describes — which is what makes a reference-tile comparison meaningful at all.
How do I detect a substitution rather than a missing font?
By comparing the requested family against the resolved one. fc-match and its equivalents never fail; they return the closest available match, so the presence of a result proves nothing at all. Querying each required family and asserting that the resolver returned the same name is a handful of lines, and it is the only check that distinguishes a correctly installed font from a plausible-looking substitute.
Related
- Mapnik vs MapLibre Native for Headless Server-Side Rendering — where the reference tile set earns its keep.
- Typography Rules for Maps — the metrics a substitution changes.
Back to Headless Rendering Engines