Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 119 additions & 14 deletions src/veilrender/fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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:
Expand Down
33 changes: 19 additions & 14 deletions src/veilrender/routes/fonts_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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"
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Loading