android / Build + lint + test (push) Successful in 4m19s
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
173 lines
7.5 KiB
Python
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())
|