Files
minstrel/tools/vendor-fonts.py
T
bvandeusenandClaude Opus 5 b52a00df66
android / Build + lint + test (push) Successful in 4m19s
fix(android): bundle the typefaces instead of fetching them at runtime
Typography.kt resolved Fraunces, Inter and JetBrains Mono through the Play
Services font provider, which fetches them over the network on first use.
Same rule-164 problem the web client had, with a second failure mode on
top: the provider is absent entirely on devices without Play Services, so
the app fell back to the platform default and stopped looking like
Minstrel — quietly, with no error.

The five static instances now live in res/font, vendored by the same
tools/vendor-fonts.py that produces the web bundle. Both clients draw
from one list of faces so they cannot drift apart. Cost is ~0.86 MB of
APK; the runtime path is removed rather than kept as a fallback — the
ui-text-google-fonts dependency, its version-catalog entry and the
provider certificate hashes in font_certs.xml are all gone.

Two things about fetching TTFs that are worth writing down, because both
fail by succeeding:

Google Fonts picks the format from the User-Agent, and there is no
parameter to ask for one. A modern UA gets woff2, which res/font cannot
load. The obvious "use an old UA" fix gets EOT — an IE-only format that
downloads happily, has a plausible size, and is entirely useless here. An
Android 4.4 UA is what actually yields TrueType.

css2 also collapses a multi-weight request to 400 for legacy clients, so
asking for Medium silently returns Regular: a valid TrueType file that
renders at the wrong weight everywhere. Each weight is therefore fetched
on its own URL, and the script now asserts OS/2 usWeightClass on every
download — that field is the only thing distinguishing the two files.

Verified before wiring: all five carry TrueType magic, the 400/500 pairs
differ, and their usWeightClass reads 400/400/500/500/400 as declared
beside them in the FontFamily.

Not covered: there is no guard for this on the Android side. The web
equivalent is asserted by no-external-assets.test.ts, but the Android
tree has no source-inspection test pattern to follow and no way to
falsify one without a local Gradle run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 14:13:57 -04:00

173 lines
7.5 KiB
Python

#!/usr/bin/env python3
"""Download the fonts from Google Fonts and vendor them into the repo.
Covers BOTH clients — the web bundle (woff2, subsetted) and the Android app
(static ttf, whole-font) — because they draw from the same three families and
letting them drift is how one of them quietly stops matching the other.
Run by hand when the font set changes, never at build or run time:
python3 tools/vendor-fonts.py
The app must render itself with no outbound network, so nothing here may
happen while it is running. This script fetches once, writes the woff2 files
and a stylesheet that points at them by RELATIVE url, and everything is
committed. `web/static/` is copied verbatim into the SvelteKit build, which
Go then embeds — so the fonts travel inside the binary.
Relative urls in the stylesheet (`./Inter-latin.woff2`, not `/fonts/...`)
are deliberate: a CSS url() resolves against the stylesheet's own address, so
the whole directory keeps working when the app is served under a base path.
Subsets are kept as Google slices them, with their `unicode-range` intact.
That is not a size compromise — the browser downloads only the ranges a page
actually uses, so vendoring every subset costs repository bytes rather than
request bytes, and a library full of non-English artist names renders instead
of falling back mid-list.
"""
import re
import struct
import sys
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "web/static/fonts"
ANDROID_OUT = ROOT / "android/app/src/main/res/font"
# Matches the faces the design system actually permits: two weights only, 400
# and 500 (never 600/700), and Fraunces' optical-size axis across its range.
FAMILIES = (
"family=Fraunces:opsz,wght@9..144,400;9..144,500"
"&family=Inter:wght@400;500"
"&family=JetBrains+Mono:wght@400;500"
)
CSS_URL = f"https://fonts.googleapis.com/css2?{FAMILIES}&display=swap"
# The UA is load-bearing, not cosmetic: Google Fonts serves a different FORMAT
# per client, and there is no parameter to ask for one directly.
# modern Chrome -> woff2 (what the browser wants)
# old Android -> ttf (what Android's res/font requires)
# MSIE 6 -> eot (an IE-only format; the obvious "old UA" choice
# and completely useless here — it downloads and
# looks plausible until you check the magic bytes)
UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
UA_TTF = ("Mozilla/5.0 (Linux; U; Android 4.4.2; en-us) AppleWebKit/534.30 "
"(KHTML, like Gecko) Version/4.0 Mobile Safari/534.30")
# Android res/font resource names: lowercase, digits and underscore only.
# Each entry is one static weight — Compose selects by the FontWeight declared
# alongside it in Typography.kt, so the file must genuinely be that instance.
# css2 collapses a multi-weight request to 400 for legacy clients, so each
# weight is fetched on its own URL.
ANDROID_FACES = [
("fraunces_regular", "Fraunces:opsz,wght@9..144,400", 400),
("fraunces_medium", "Fraunces:opsz,wght@9..144,500", 500),
("inter_regular", "Inter:wght@400", 400),
("inter_medium", "Inter:wght@500", 500),
("jetbrains_mono_regular", "JetBrains+Mono:wght@400", 400),
]
TTF_MAGIC = (b"\x00\x01\x00\x00", b"true", b"OTTO")
def weight_class(data):
"""Read OS/2 usWeightClass out of a TrueType file.
Worth the twenty lines: css2 silently collapses a multi-weight request to
400 for legacy clients, so asking for Medium and getting Regular is a real
and quiet failure. The file downloads, has valid TrueType magic, and
renders — just at the wrong weight, everywhere, forever. This is the only
field that actually distinguishes them.
"""
count = struct.unpack(">H", data[4:6])[0]
for i in range(count):
off = 12 + i * 16
if data[off:off + 4] == b"OS/2":
table = struct.unpack(">I", data[off + 8:off + 12])[0]
return struct.unpack(">H", data[table + 4:table + 6])[0]
return None
def fetch(url, timeout=30, ttf=False):
req = urllib.request.Request(url, headers={"User-Agent": UA_TTF if ttf else UA})
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read()
def vendor_android():
"""Fetch static TTFs for the Android client's res/font."""
ANDROID_OUT.mkdir(parents=True, exist_ok=True)
for old in ANDROID_OUT.glob("*.ttf"):
old.unlink()
total = 0
for name, spec, want_weight in ANDROID_FACES:
css = fetch(f"https://fonts.googleapis.com/css2?family={spec}", ttf=True).decode()
url = re.search(r"url\((https://[^)]+)\)", css)
if not url:
sys.exit(f"no font url for {spec}")
data = fetch(url.group(1), ttf=True)
# Assert the FORMAT, because every wrong one still downloads happily
# and only fails later, on a device, as a silently missing typeface.
if not data.startswith(TTF_MAGIC):
sys.exit(f"{name}: expected TrueType, got magic {data[:4].hex()} "
f"(eot/woff means the UA negotiation broke)")
got = weight_class(data)
if got != want_weight:
sys.exit(f"{name}: wanted weight {want_weight}, file reports {got} "
f"(css2 collapsed the request to a single weight)")
(ANDROID_OUT / f"{name}.ttf").write_bytes(data)
total += len(data)
print(f" {name + '.ttf':<30} {len(data)/1024:7.1f} KB weight {got}")
print(f"{len(ANDROID_FACES)} ttf, {total/1024/1024:.2f} MB total -> "
f"{ANDROID_OUT.relative_to(ROOT)}")
def main():
css = fetch(CSS_URL).decode()
# Google emits a comment naming each subset before its @font-face block.
blocks = re.split(r"(?=/\*\s*[a-z0-9-]+\s*\*/)", css)
OUT.mkdir(parents=True, exist_ok=True)
for old in OUT.glob("*.woff2"):
old.unlink()
out_css, seen, total = [], {}, 0
for block in blocks:
subset = re.search(r"/\*\s*([a-z0-9-]+)\s*\*/", block)
family = re.search(r"font-family:\s*'([^']+)'", block)
weight = re.search(r"font-weight:\s*([^;]+);", block)
url = re.search(r"url\((https://[^)]+\.woff2)\)", block)
if not (subset and family and url):
continue
w = weight.group(1).strip().replace(" ", "-") if weight else "400"
name = f"{family.group(1).replace(' ', '')}-{w}-{subset.group(1)}.woff2"
if name not in seen:
data = fetch(url.group(1))
(OUT / name).write_bytes(data)
seen[name] = len(data)
total += len(data)
out_css.append(block.replace(url.group(1), f"./{name}"))
header = (
"/* Generated by tools/vendor-fonts.py — do not edit by hand.\n"
" *\n"
" * Vendored from Google Fonts and served from our own origin: the app\n"
" * has to render with no outbound network, so a font provider link is\n"
" * not an option. Urls are relative so this keeps working under a base\n"
" * path. unicode-range is preserved, so the browser still fetches only\n"
" * the subsets a page needs.\n"
" */\n"
)
(OUT / "fonts.css").write_text(header + "".join(out_css))
for n, size in sorted(seen.items()):
print(f" {n:<44} {size/1024:7.1f} KB")
print(f"{len(seen)} files, {total/1024/1024:.2f} MB total -> {OUT.relative_to(ROOT)}")
print()
vendor_android()
if __name__ == "__main__":
sys.exit(main())