diff --git a/src/veilrender/fonts.py b/src/veilrender/fonts.py index bc3d21f..5b4d7f8 100644 --- a/src/veilrender/fonts.py +++ b/src/veilrender/fonts.py @@ -2,29 +2,134 @@ from __future__ import annotations +import fnmatch +import json import logging +import os 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", + +@dataclass(frozen=True) +class _GitHubFontSpec: + owner: str + repo: str + path: str + glob: str + 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( + "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]] = {} +_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. 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] + + try: + 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 = 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 + 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] + return (fallback_filename, spec.fallback_url) + + FONT_ALIASES: dict[str, list[str]] = { "cjk": ["noto-sans-sc", "noto-sans-tc", "noto-sans-jp", "noto-sans-kr"], "i18n": [ @@ -50,11 +155,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)