#!/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())