#!/usr/bin/env python3 """Re-vendor the web fonts the SPA uses into `frontend/public/fonts/`. The upstream `index.html` linked Google Fonts, so every page load fetched a stylesheet from `fonts.googleapis.com` and font files from `fonts.gstatic.com`. That is a runtime Internet dependency and a third party learning when the story is being read, both of which the local-only requirement rules out (acceptance tests A01, H01 and H11). This script downloads the same faces once, at development time, and writes `frontend/src/styles/fonts.css` pointing at the local copies. It needs the Internet; the application never does. Run it only when a family, weight, or subset needs to change, and commit what it produces. python3 frontend/tools/vendor_fonts.py Variable fonts are requested on purpose: one file per family per subset covers every weight the design uses, instead of one file per weight. """ import re import sys import urllib.request from pathlib import Path # Chrome's UA: the Google Fonts CSS API serves woff2 only to a client it # believes supports it, and returns older formats to anything it doesn't # recognize. UA = ( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/140.0.0.0 Safari/537.36" ) CSS_URL = ( "https://fonts.googleapis.com/css2" "?family=Cinzel:wght@400..900" "&family=Crimson+Pro:ital,wght@0,200..900;1,200..900" "&family=Inter:wght@100..900" "&display=swap" ) # Latin and Latin Extended cover the alphabets this interface and its prose are # written in. Greek/Cyrillic/Vietnamese subsets are skipped rather than # vendored unused; text in them falls back to the system stack in # `tokens.css`. Add the subset here if that stops being acceptable. SUBSETS = ("latin", "latin-ext") ROOT = Path(__file__).resolve().parent.parent FONT_DIR = ROOT / "public" / "fonts" CSS_OUT = ROOT / "src" / "styles" / "fonts.css" BLOCK_RE = re.compile( r"/\* (?P[a-z-]+) \*/\s*@font-face \{(?P.*?)\}", re.S ) def _field(body: str, name: str) -> str: match = re.search(rf"{name}:\s*(.*?);", body, re.S) if not match: raise SystemExit(f"no {name} in @font-face block:\n{body}") return match.group(1).strip() def _get(url: str) -> bytes: request = urllib.request.Request(url, headers={"User-Agent": UA}) with urllib.request.urlopen(request, timeout=60) as response: return response.read() def main() -> int: css = _get(CSS_URL).decode() FONT_DIR.mkdir(parents=True, exist_ok=True) faces = [] for block in BLOCK_RE.finditer(css): subset = block.group("subset") if subset not in SUBSETS: continue body = block.group("body") family = _field(body, "font-family").strip("'\"") style = _field(body, "font-style") url = re.search(r"url\((.*?)\)", _field(body, "src")).group(1) slug = family.lower().replace(" ", "-") name = f"{slug}-{style}-{subset}.woff2" (FONT_DIR / name).write_bytes(_get(url)) faces.append( { "family": family, "style": style, "weight": _field(body, "font-weight"), "range": _field(body, "unicode-range"), "file": name, "source": url, } ) if not faces: raise SystemExit("no @font-face blocks matched the requested subsets") lines = [ "/* Generated by frontend/tools/vendor_fonts.py — do not hand-edit.", "", " Self-hosted so the SPA makes no request to fonts.googleapis.com or", " fonts.gstatic.com at runtime. See the script for why, and for how to", " change a family, weight, or subset.", "", " Files live in frontend/public/fonts/ and are served by the app itself", " at /fonts/..., which is what the `font-src 'self'` CSP in", " backend/app/main.py allows.", "*/", "", ] for face in faces: lines += [ f"/* {face['family']} {face['style']} — from {face['source']} */", "@font-face {", f" font-family: '{face['family']}';", f" font-style: {face['style']};", f" font-weight: {face['weight']};", " font-display: swap;", f" src: url('/fonts/{face['file']}') format('woff2');", f" unicode-range: {face['range']};", "}", "", ] CSS_OUT.write_text("\n".join(lines)) total = sum((FONT_DIR / f["file"]).stat().st_size for f in faces) print(f"vendored {len(faces)} faces, {total / 1024:.0f} KiB, into {FONT_DIR}") print(f"wrote {CSS_OUT}") return 0 if __name__ == "__main__": sys.exit(main())