From 397495b5d1deebc1b781baf9a88209d840f35c21 Mon Sep 17 00:00:00 2001 From: Peng Ding Date: Wed, 23 Sep 2026 20:01:54 -0500 Subject: [PATCH 1/2] fix: resolve font URLs dynamically via GitHub API Replace hardcoded jsDelivr font URLs with GitHub Contents API resolution. Fonts are defined by (owner, repo, path, glob) and the actual filename is discovered at runtime, making downloads resilient to upstream file renames and directory reorganizations. Fixes noto-color-emoji 404 (file moved and renamed), arabic/thai/ devanagari filename changes ([wdth,wght] axis added), and jsDelivr 403 errors on large font files by switching to raw.githubusercontent. Resolved URLs are cached in-memory for the process lifetime. Falls back to hardcoded raw GitHub URLs if the API is unreachable. --- src/veilrender/fonts.py | 113 +++++++++++++++++++++++---- src/veilrender/routes/fonts_route.py | 33 ++++---- 2 files changed, 118 insertions(+), 28 deletions(-) diff --git a/src/veilrender/fonts.py b/src/veilrender/fonts.py index bc3d21f..d83edf4 100644 --- a/src/veilrender/fonts.py +++ b/src/veilrender/fonts.py @@ -2,29 +2,114 @@ from __future__ import annotations +import fnmatch +import json import logging import subprocess import urllib.request +from dataclasses import dataclass from pathlib import Path from veilrender.config import settings logger = logging.getLogger(__name__) -_JSDELIVR = "https://cdn.jsdelivr.net/gh" - -FONT_REGISTRY: dict[str, str] = { - "noto-sans-sc": f"{_JSDELIVR}/google/fonts@main/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf", - "noto-sans-tc": f"{_JSDELIVR}/google/fonts@main/ofl/notosanstc/NotoSansTC%5Bwght%5D.ttf", - "noto-sans-jp": f"{_JSDELIVR}/google/fonts@main/ofl/notosansjp/NotoSansJP%5Bwght%5D.ttf", - "noto-sans-kr": f"{_JSDELIVR}/google/fonts@main/ofl/notosanskr/NotoSansKR%5Bwght%5D.ttf", - "noto-color-emoji": f"{_JSDELIVR}/googlefonts/noto-emoji@main/fonts/NotoColorEmoji.ttf", - "noto-sans-arabic": f"{_JSDELIVR}/google/fonts@main/ofl/notosansarabic/NotoSansArabic%5Bwght%5D.ttf", - "noto-sans-thai": f"{_JSDELIVR}/google/fonts@main/ofl/notosansthai/NotoSansThai%5Bwght%5D.ttf", - "noto-sans-devanagari": f"{_JSDELIVR}/google/fonts@main/ofl/notosansdevanagari/NotoSansDevanagari%5Bwght%5D.ttf", - "lxgw-wenkai": f"{_JSDELIVR}/lxgw/LxgwWenKai@main/fonts/TTF/LXGWWenKai-Regular.ttf", +_RAW_GH = "https://raw.githubusercontent.com" + + +@dataclass(frozen=True) +class _GitHubFontSpec: + owner: str + repo: str + path: str + glob: str + fallback_url: str + + +def _gf(path: str, glob: str, fallback_filename: str) -> _GitHubFontSpec: + """Shorthand for google/fonts specs.""" + return _GitHubFontSpec( + "google", + "fonts", + f"ofl/{path}", + glob, + f"{_RAW_GH}/google/fonts/main/ofl/{path}/{fallback_filename}", + ) + + +FONT_REGISTRY: dict[str, _GitHubFontSpec] = { + "noto-sans-sc": _gf("notosanssc", "NotoSansSC*.ttf", "NotoSansSC%5Bwght%5D.ttf"), + "noto-sans-tc": _gf("notosanstc", "NotoSansTC*.ttf", "NotoSansTC%5Bwght%5D.ttf"), + "noto-sans-jp": _gf("notosansjp", "NotoSansJP*.ttf", "NotoSansJP%5Bwght%5D.ttf"), + "noto-sans-kr": _gf("notosanskr", "NotoSansKR*.ttf", "NotoSansKR%5Bwght%5D.ttf"), + "noto-color-emoji": _gf( + "notocoloremoji", "NotoColorEmoji*.ttf", "NotoColorEmoji-Regular.ttf" + ), + "noto-sans-arabic": _gf( + "notosansarabic", "NotoSansArabic*.ttf", "NotoSansArabic%5Bwdth%2Cwght%5D.ttf" + ), + "noto-sans-thai": _gf( + "notosansthai", "NotoSansThai*.ttf", "NotoSansThai%5Bwdth%2Cwght%5D.ttf" + ), + "noto-sans-devanagari": _gf( + "notosansdevanagari", + "NotoSansDevanagari*.ttf", + "NotoSansDevanagari%5Bwdth%2Cwght%5D.ttf", + ), + "lxgw-wenkai": _GitHubFontSpec( + "lxgw", + "LxgwWenKai", + "fonts/TTF", + "LXGWWenKai-Regular.ttf", + f"{_RAW_GH}/lxgw/LxgwWenKai/main/fonts/TTF/LXGWWenKai-Regular.ttf", + ), } +_resolved_url_cache: dict[str, tuple[str, str]] = {} + + +def _resolve_github_font_url(name: str, spec: _GitHubFontSpec) -> tuple[str, str]: + """Resolve a font's download URL via GitHub Contents API. + + Returns: + (filename, url) tuple. Uses cached result if available. + """ + if name in _resolved_url_cache: + return _resolved_url_cache[name] + + api_url = ( + f"https://api.github.com/repos/{spec.owner}/{spec.repo}/contents/{spec.path}" + ) + try: + resp = urllib.request.urlopen(api_url, timeout=10) + entries = json.loads(resp.read()) + for entry in entries: + if entry.get("type") == "file" and fnmatch.fnmatch( + entry["name"], spec.glob + ): + filename = entry["name"] + url = f"{_RAW_GH}/{spec.owner}/{spec.repo}/main/{spec.path}/{filename}" + result = (filename, url) + _resolved_url_cache[name] = result + return result + logger.warning( + "No file matching %s in %s/%s/%s", + spec.glob, + spec.owner, + spec.repo, + spec.path, + ) + except Exception: + logger.debug( + "GitHub API lookup failed for %s, using fallback URL", name, exc_info=True + ) + + fallback_filename = spec.fallback_url.rsplit("/", 1)[-1] + result = (fallback_filename, spec.fallback_url) + _resolved_url_cache[name] = result + return result + + FONT_ALIASES: dict[str, list[str]] = { "cjk": ["noto-sans-sc", "noto-sans-tc", "noto-sans-jp", "noto-sans-kr"], "i18n": [ @@ -50,11 +135,11 @@ def _resolve_entries(font_specs: list[str]) -> list[tuple[str, str]]: for name in FONT_ALIASES[spec]: if name not in seen: seen.add(name) - entries.append((f"{name}.ttf", FONT_REGISTRY[name])) + entries.append(_resolve_github_font_url(name, FONT_REGISTRY[name])) elif spec in FONT_REGISTRY: if spec not in seen: seen.add(spec) - entries.append((f"{spec}.ttf", FONT_REGISTRY[spec])) + entries.append(_resolve_github_font_url(spec, FONT_REGISTRY[spec])) elif spec.startswith(("http://", "https://")): filename = spec.rsplit("/", 1)[-1].split("?")[0] or "custom-font.ttf" if filename not in seen: diff --git a/src/veilrender/routes/fonts_route.py b/src/veilrender/routes/fonts_route.py index 4715eab..00dbc07 100644 --- a/src/veilrender/routes/fonts_route.py +++ b/src/veilrender/routes/fonts_route.py @@ -15,7 +15,7 @@ from veilrender._vendor.httpserver import App, Request, Response from veilrender.config import settings -from veilrender.fonts import FONT_REGISTRY +from veilrender.fonts import FONT_REGISTRY, _resolve_github_font_url logger = logging.getLogger(__name__) @@ -31,13 +31,13 @@ ".woff2": "font/woff2", } -_KNOWN_FONTS: dict[str, str] = { - "NotoColorEmoji.ttf": FONT_REGISTRY["noto-color-emoji"], - "noto-sans-sc.ttf": FONT_REGISTRY["noto-sans-sc"], - "noto-sans-tc.ttf": FONT_REGISTRY["noto-sans-tc"], - "noto-sans-jp.ttf": FONT_REGISTRY["noto-sans-jp"], - "noto-sans-kr.ttf": FONT_REGISTRY["noto-sans-kr"], -} +_KNOWN_FONT_KEYS = [ + "noto-color-emoji", + "noto-sans-sc", + "noto-sans-tc", + "noto-sans-jp", + "noto-sans-kr", +] _FONTSOURCE_BASE = "https://cdn.jsdelivr.net/npm/@fontsource" _FONTSOURCE_CACHE = Path(settings.font_dir) / "fontsource" @@ -62,14 +62,19 @@ def _find_font(filename: str) -> Path | None: def _download_font(filename: str) -> Path | None: """Download a known font on-demand, cache in font_dir.""" - url = _KNOWN_FONTS.get(filename) - if not url: + resolved_url: str | None = None + for key in _KNOWN_FONT_KEYS: + spec = FONT_REGISTRY[key] + resolved_name, resolved = _resolve_github_font_url(key, spec) + if resolved_name == filename: + resolved_url = resolved + break + + if not resolved_url: return None - # Proxy-style mirror: prefixes the full URL, e.g. - # VEILRENDER_FONT_MIRROR=https://ghfast.top → ghfast.top/https://cdn.jsdelivr.net/... if settings.font_mirror: - url = f"{settings.font_mirror}/{url}" + resolved_url = f"{settings.font_mirror}/{resolved_url}" font_dir = Path(settings.font_dir) font_dir.mkdir(parents=True, exist_ok=True) @@ -80,7 +85,7 @@ def _download_font(filename: str) -> Path | None: logger.info("On-demand font download: %s", filename) try: - resp = urllib.request.urlopen(url, timeout=60) + resp = urllib.request.urlopen(resolved_url, timeout=60) tmp = dest.with_suffix(".tmp") tmp.write_bytes(resp.read()) tmp.rename(dest) From dabc2f49e12fb4cc894e95b98aa5b8485d67aab1 Mon Sep 17 00:00:00 2001 From: Peng Ding Date: Wed, 23 Sep 2026 20:15:49 -0500 Subject: [PATCH 2/2] fix: use download_url from API, batch dir lookups, don't cache fallbacks - Use entry["download_url"] from GitHub API instead of constructing raw URLs manually (avoids hardcoding branch name and URL encoding) - Batch API calls via _fetch_github_dir() cached per directory path, so fonts sharing the same repo directory use a single API call - Support GITHUB_TOKEN env var for authenticated API requests (5000/hr vs 60/hr unauthenticated) - Don't cache fallback results so transient API failures are retried on subsequent calls --- src/veilrender/fonts.py | 46 +++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/src/veilrender/fonts.py b/src/veilrender/fonts.py index d83edf4..5b4d7f8 100644 --- a/src/veilrender/fonts.py +++ b/src/veilrender/fonts.py @@ -5,6 +5,7 @@ import fnmatch import json import logging +import os import subprocess import urllib.request from dataclasses import dataclass @@ -14,8 +15,6 @@ logger = logging.getLogger(__name__) -_RAW_GH = "https://raw.githubusercontent.com" - @dataclass(frozen=True) class _GitHubFontSpec: @@ -26,6 +25,9 @@ class _GitHubFontSpec: fallback_url: str +_RAW_GH = "https://raw.githubusercontent.com" + + def _gf(path: str, glob: str, fallback_filename: str) -> _GitHubFontSpec: """Shorthand for google/fonts specs.""" return _GitHubFontSpec( @@ -66,29 +68,47 @@ def _gf(path: str, glob: str, fallback_filename: str) -> _GitHubFontSpec: } _resolved_url_cache: dict[str, tuple[str, str]] = {} +_dir_listing_cache: dict[str, list[dict]] = {} + + +def _fetch_github_dir(owner: str, repo: str, path: str) -> list[dict]: + """Fetch directory listing from GitHub Contents API, cached per directory.""" + cache_key = f"{owner}/{repo}/{path}" + if cache_key in _dir_listing_cache: + return _dir_listing_cache[cache_key] + + api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}" + headers = {"Accept": "application/vnd.github.v3+json"} + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(api_url, headers=headers) + resp = urllib.request.urlopen(req, timeout=10) + entries = json.loads(resp.read()) + _dir_listing_cache[cache_key] = entries + return entries def _resolve_github_font_url(name: str, spec: _GitHubFontSpec) -> tuple[str, str]: """Resolve a font's download URL via GitHub Contents API. Returns: - (filename, url) tuple. Uses cached result if available. + (filename, url) tuple. API results are cached; fallback results + are not cached so the API is retried on subsequent calls. """ if name in _resolved_url_cache: return _resolved_url_cache[name] - api_url = ( - f"https://api.github.com/repos/{spec.owner}/{spec.repo}/contents/{spec.path}" - ) try: - resp = urllib.request.urlopen(api_url, timeout=10) - entries = json.loads(resp.read()) + entries = _fetch_github_dir(spec.owner, spec.repo, spec.path) for entry in entries: if entry.get("type") == "file" and fnmatch.fnmatch( entry["name"], spec.glob ): filename = entry["name"] - url = f"{_RAW_GH}/{spec.owner}/{spec.repo}/main/{spec.path}/{filename}" + url = entry.get("download_url") or ( + f"{_RAW_GH}/{spec.owner}/{spec.repo}/main/{spec.path}/{filename}" + ) result = (filename, url) _resolved_url_cache[name] = result return result @@ -101,13 +121,13 @@ def _resolve_github_font_url(name: str, spec: _GitHubFontSpec) -> tuple[str, str ) except Exception: logger.debug( - "GitHub API lookup failed for %s, using fallback URL", name, exc_info=True + "GitHub API lookup failed for %s, using fallback URL", + name, + exc_info=True, ) fallback_filename = spec.fallback_url.rsplit("/", 1)[-1] - result = (fallback_filename, spec.fallback_url) - _resolved_url_cache[name] = result - return result + return (fallback_filename, spec.fallback_url) FONT_ALIASES: dict[str, list[str]] = {