#!/usr/bin/env python3 """Build SD card fonts from a declarative YAML config. Reads sd-fonts.yaml, downloads any missing source fonts, runs fontconvert_sdcard.py in parallel for each family, and optionally generates the fonts.json manifest. Usage: # Generate fonts (output in ./output/) python3 build-sd-fonts.py # Generate fonts + manifest python3 build-sd-fonts.py --manifest --base-url "http://localhost:8000/" # Custom config / output paths python3 build-sd-fonts.py --config my-fonts.yaml --output-dir dist/ # Generate only specific families python3 build-sd-fonts.py --only Literata,IBMPlexMono # Stream child process output for debugging python3 build-sd-fonts.py --verbose # Override the per-family timeout (default: 600s) python3 build-sd-fonts.py --timeout 1200 """ import argparse import os import shutil import subprocess import sys import tempfile import threading import time import socket import urllib.request from concurrent.futures import ProcessPoolExecutor, as_completed from pathlib import Path import yaml SCRIPT_DIR = Path(__file__).parent FONTCONVERT = SCRIPT_DIR / "fontconvert_sdcard.py" EPDFONTS_DIR = SCRIPT_DIR.parent # lib/EpdFont DEFAULT_CONFIG = SCRIPT_DIR / "sd-fonts.yaml" DEFAULT_OUTPUT = SCRIPT_DIR / "output" DOWNLOAD_DIR = SCRIPT_DIR / "downloaded_fonts" INSTANCE_DIR = SCRIPT_DIR / "instanced_fonts" DEFAULT_FALLBACK_FONT = EPDFONTS_DIR / "builtinFonts/source/NotoSans/NotoSans-Regular.ttf" _orig_getaddrinfo = socket.getaddrinfo def _ipv4_only_getaddrinfo(*args, **kwargs): """getaddrinfo variant that drops AAAA records (IPv4 only).""" return [ai for ai in _orig_getaddrinfo(*args, **kwargs) if ai[0] == socket.AF_INET] def download_font(url: str, dest: Path, retries: int = 3) -> Path: """Download a font file if not already cached. Returns the local path. Some sources (e.g. mirrors.ctan.org) are round-robin redirectors that land on a different mirror each request; a mirror may advertise an IPv6 address a host without an IPv6 route cannot reach ([Errno 101] Network is unreachable). Retry on failure, forcing IPv4 resolution after the first attempt. """ if dest.exists(): return dest dest.parent.mkdir(parents=True, exist_ok=True) print(f" Downloading {dest.name}...") last_err = None for attempt in range(1, retries + 1): force_ipv4 = attempt > 1 if force_ipv4: socket.getaddrinfo = _ipv4_only_getaddrinfo try: urllib.request.urlretrieve(url, dest) break except Exception as e: # noqa: BLE001 - reported via RuntimeError below last_err = e dest.unlink(missing_ok=True) if attempt < retries: print(f" Attempt {attempt} failed ({e}); retrying (IPv4-only)...") finally: if force_ipv4: socket.getaddrinfo = _orig_getaddrinfo else: raise RuntimeError(f"Failed to download {url}: {last_err}") from last_err size_kb = dest.stat().st_size / 1024 print(f" Downloaded {dest.name} ({size_kb:.0f} KB)") return dest def extract_static_instance(source_path: Path, axes: dict, family_name: str, style_name: str) -> Path: """Use fonttools instancer to pin variable font axes, producing a static TTF. Caches the result in INSTANCE_DIR//