One font stack cannot serve two rendering engines

Press · No. 086

Shipped

press 0.3.0 split the brand’s font stacks into per-engine profiles. Migrating a consumer that renders through WeasyPrint measured a regression the previous single-stack model would have shipped: the browser-tuned chain put Inter and 'Helvetica Neue' ahead of Arial, and headlines came out as Helvetica Neue Heavy Condensed, a visibly narrower face on every page. That was measured with pdffonts, not assumed. Under a fontconfig profile the same document renders Arial-Bold, Georgia-Bold and Menlo-Bold, exactly as it always had.

The general problem is worth more than the fix. A font-family list is not a portable declaration. It’s a set of instructions to one specific font-matching implementation, and the two implementations you’re most likely to ship through disagree about almost every entry in a typical stack. This guide builds the two tools that make that visible: one that predicts which face will win before you render, and one that tells you which face actually did.

Why the same CSS resolves differently

Chromium resolves -apple-system to the platform UI font and stops. Everything after it in your stack is depth for other platforms, and on macOS it is never consulted at all. That’s why browser-tuned stacks get long: extra entries are free, so you keep adding them.

A PDF renderer does not work that way. WeasyPrint’s own documentation is direct about it: “Pango always uses Fontconfig to access fonts, even on Windows and macOS,” and the features page lists a conforming CSS font matching algorithm as not supported, noting that “Currently font-family is passed as-is to Pango.” So the browser-only keywords resolve to nothing, get skipped, and the first family that actually exists on the machine wins. In a long browser-tuned stack, that is rarely the one you meant.

The standard spelling for the platform UI font is system-ui and the ui-serif / ui-monospace family; -apple-system and BlinkMacSystemFont are older vendor keywords that predate it and are still what most real stacks carry. Neither spelling means anything to Fontconfig.

To follow along you need Fontconfig (fc-list), WeasyPrint, and poppler (pdffonts). On macOS that’s brew install fontconfig poppler plus pip install weasyprint. If importing WeasyPrint then fails with cannot load library 'libgobject-2.0-0', it can’t see the Homebrew libraries, and DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib is the fix. Pass it on the command itself, as in env DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib python3 render.py …, rather than exporting it from your shell: System Integrity Protection strips DYLD_* variables whenever a protected binary such as /bin/bash is executed, so an export silently fails to reach any script you launch.

Declare the profiles

The brand’s type intent is one thing: a system sans for structure. The chain that achieves that intent is a property of the engine, so it belongs in the data, not in each consumer’s head. Save this as fonts.json:

{
  "default_profile": "browser",
  "profiles": {
    "browser": {
      "note": "Chromium/WebKit. -apple-system wins on macOS and the rest is depth for other platforms, so a long chain costs nothing.",
      "display_stack": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, 'Helvetica Neue', Arial, sans-serif"
    },
    "fontconfig": {
      "note": "WeasyPrint and anything else going through Pango. The chain is walked for real, so every extra face is one that can win. Deliberately shallower.",
      "display_stack": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
    }
  }
}

The loader has one rule worth stating out loud. Save it as profiles.py:

#!/usr/bin/env python3
"""The brand's type intent is one thing; the chain that achieves it is per-engine."""
import json
import sys
from pathlib import Path


class UnknownProfile(Exception):
    pass


def load_profile(name=None, path="fonts.json"):
    raw = json.loads(Path(path).read_text())
    key = name or raw["default_profile"]
    profile = raw["profiles"].get(key)
    if profile is None:
        # NEVER fall back to the default here. A target that asked for
        # `fontconfig` and quietly got the browser chain is the exact bug this
        # mechanism exists to prevent, and it would render wrong in silence.
        known = ", ".join(sorted(raw["profiles"]))
        raise UnknownProfile(f'unknown font profile "{key}" (known: {known})')
    return profile


if __name__ == "__main__":
    name = sys.argv[1] if len(sys.argv) > 1 else None
    try:
        print(load_profile(name)["display_stack"])
    except UnknownProfile as err:
        print(f"error: {err}", file=sys.stderr)
        raise SystemExit(2)

An unknown profile name raises instead of defaulting. A consumer that asked for the PDF chain and quietly received the browser one renders wrong and reports nothing, which is the failure this whole mechanism exists to prevent.

Predict which face will win

Before rendering anything, walk the stack the way the engine will. Save this as audit.py:

#!/usr/bin/env python3
"""Which family in a CSS font stack will a fontconfig engine actually pick?"""
import subprocess
import sys

# Keywords a browser resolves to a real face but fontconfig has never heard of.
# They are not "missing fonts" you can install; they are engine features.
BROWSER_ONLY = {
    "-apple-system", "blinkmacsystemfont", "-webkit-system-font",
}
# CSS generic families. A stack that reaches one of these is fine by definition,
# so they end the walk rather than counting as absent.
GENERIC = {
    "serif", "sans-serif", "monospace", "cursive", "fantasy",
    "system-ui", "ui-serif", "ui-sans-serif", "ui-monospace", "ui-rounded",
}


def installed_families():
    """Every family fontconfig can actually resolve, lowercased."""
    out = subprocess.run(
        ["fc-list", ":", "family"], capture_output=True, text=True, check=True
    ).stdout
    families = set()
    for line in out.splitlines():
        for name in line.split(","):
            if name.strip():
                families.add(name.strip().casefold())
    return families


def audit(stack, families):
    """Walk the stack the way the engine will, and report the first real hit."""
    rows, winner = [], None
    for raw in stack.split(","):
        name = raw.strip().strip("'\"")
        key = name.casefold()
        if key in BROWSER_ONLY:
            state = "browser-only"
        elif key in GENERIC:
            state = "generic"
        elif key in families:
            state = "present"
        else:
            state = "absent"
        if winner is None and state in ("present", "generic"):
            winner = name
        rows.append((name, state))
    return rows, winner


if __name__ == "__main__":
    families = installed_families()
    rows, winner = audit(sys.argv[1], families)
    for name, state in rows:
        mark = "->" if name == winner else "  "
        print(f"  {mark} {name:<22} {state}")
    print(f"\nfontconfig will render: {winner}")

The membership test is deliberately built on fc-list rather than fc-match, for a reason covered in the gotchas below.

Run it against both profiles:

python3 audit.py "$(python3 profiles.py browser)"
echo
python3 audit.py "$(python3 profiles.py fontconfig)"
     -apple-system          browser-only
     BlinkMacSystemFont     browser-only
     Segoe UI               absent
     Inter                  absent
     Roboto                 absent
  -> Helvetica Neue         present
     Arial                  present
     sans-serif             generic

fontconfig will render: Helvetica Neue

     -apple-system          browser-only
     BlinkMacSystemFont     browser-only
     Segoe UI               absent
  -> Arial                  present
     sans-serif             generic

fontconfig will render: Arial

Five of the browser profile’s eight entries are unreachable on this machine, and the sixth is the accident. Your own output will differ depending on what you have installed, which is the point: the same stack has a different answer on every box.

Measure what actually came out

A prediction you never check is a guess with better formatting. Render both profiles and read the file. Save this as render.py:

#!/usr/bin/env python3
"""Render one headline through WeasyPrint using a named font profile."""
import sys
from weasyprint import HTML
from profiles import load_profile

profile_name, out = sys.argv[1], sys.argv[2]
stack = load_profile(profile_name)["display_stack"]

HTML(string=f"""
<style>
  h1 {{ font-family: {stack}; font-weight: 800; font-size: 42px; }}
</style>
<h1>Weekly training brief</h1>
""").write_pdf(out)
print(f"{out}  <-  {profile_name} profile")
python3 render.py browser browser.pdf
python3 render.py fontconfig fontconfig.pdf

for f in browser fontconfig; do
  printf '%-12s %s\n' "$f" "$(pdffonts $f.pdf | awk 'NR==3 {print $1}')"
done
browser.pdf  <-  browser profile
fontconfig.pdf  <-  fontconfig profile
browser      WMHEWB+Helvetica-Neue-Bold
fontconfig   CFDMQT+Arial-Bold

pdffonts “lists the fonts used in a Portable Document Format (PDF) file”, giving the font name “exactly as given in the PDF file (potentially including a subset prefix)”. That six-letter prefix is a subset tag and depends on which glyphs got embedded, so it may well read differently on your machine; the part after the + is what you’re checking.

The prediction and the measurement agree, and the two profiles disagree with each other. That second fact is what makes the first one worth anything. If both profiles had produced Arial-Bold you would have no way to tell a working profile split from a probe that measures nothing, so keep a case in the loop that you expect to come out different. press did the same thing when migrating for real: alongside the byte-identical results it ran a control that forced Helvetica Neue, confirmed the face changed, and only then treated the identical results as meaningful.

Gotchas

fc-match will tell you a font is installed when it isn’t. The obvious way to check a stack is to ask Fontconfig about each name, and the obvious tool gives the wrong answer: per its man page, fc-match “matches pattern (empty pattern by default) using the normal fontconfig matching rules to find the best font available.” Best available, always. Symptom: you check every family in your stack, each one returns a real font, and you conclude the stack resolves fine. On the machine this guide was written on, fc-match Inter confidently reports Verdana, and fc-match ui-monospace does too. The escape is to enumerate instead of match: fc-list : family, split on commas, and test membership, which is exactly what installed_families() above does.

Depth is robustness in one engine and risk in the other. Adding fallbacks feels strictly defensive, and in a browser it is, because the first entry wins on the platform you tuned for and the rest is insurance for platforms you didn’t. Under Fontconfig the same list is a list of things that can beat your intent, and the more you add the likelier one of them is installed. Symptom: a stack grows over a few PRs, nothing changes in the browser, and a PDF quietly restyles itself. The escape is to stop treating the stack as one value: keep the deep chain for the browser, and hand the PDF engine a deliberately shallow one.

A profile that falls back silently is worse than no profiles. Once you have named profiles, the tempting kindness is to default when a name isn’t recognized, so a typo doesn’t break a build. Symptom: a consumer requests font-config, receives the browser chain, renders the wrong face, and every check stays green because nothing failed. The escape is the two lines in load_profile above: an unknown name raises and lists the known ones. A build that stops is cheaper than a document nobody notices is wrong.

Sources

Changelog

  • feat(press): per-engine font profiles (0.3.0) (#119) (74d9b36)