Use upstream variant to get in sync

This commit is contained in:
jpirnay
2026-05-11 13:05:43 +02:00
parent 5bdd026c96
commit 461a277925
+97 -75
View File
@@ -1,6 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations
"""Build SD card fonts from a declarative YAML config. """Build SD card fonts from a declarative YAML config.
Reads sd-fonts.yaml, downloads any missing source fonts, runs Reads sd-fonts.yaml, downloads any missing source fonts, runs
@@ -19,13 +17,22 @@ Usage:
# Generate only specific families # Generate only specific families
python3 build-sd-fonts.py --only Literata,IBMPlexMono 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 argparse
import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
import urllib.parse import tempfile
import threading
import time
import urllib.request import urllib.request
from concurrent.futures import ProcessPoolExecutor, as_completed from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path from pathlib import Path
@@ -35,8 +42,7 @@ import yaml
SCRIPT_DIR = Path(__file__).parent SCRIPT_DIR = Path(__file__).parent
FONTCONVERT = SCRIPT_DIR / "fontconvert_sdcard.py" FONTCONVERT = SCRIPT_DIR / "fontconvert_sdcard.py"
EPDFONTS_DIR = SCRIPT_DIR.parent # lib/EpdFont EPDFONTS_DIR = SCRIPT_DIR.parent # lib/EpdFont
PRIMARY_CONFIG = SCRIPT_DIR / "sd-fonts.yaml" DEFAULT_CONFIG = SCRIPT_DIR / "sd-fonts.yaml"
DEFAULT_CONFIG = PRIMARY_CONFIG
DEFAULT_OUTPUT = SCRIPT_DIR / "output" DEFAULT_OUTPUT = SCRIPT_DIR / "output"
DOWNLOAD_DIR = SCRIPT_DIR / "downloaded_fonts" DOWNLOAD_DIR = SCRIPT_DIR / "downloaded_fonts"
INSTANCE_DIR = SCRIPT_DIR / "instanced_fonts" INSTANCE_DIR = SCRIPT_DIR / "instanced_fonts"
@@ -81,22 +87,36 @@ def extract_static_instance(source_path: Path, axes: dict, family_name: str, sty
old.unlink() old.unlink()
print(f" Extracting static instance: {family_name}/{style_name} ({axis_key})") print(f" Extracting static instance: {family_name}/{style_name} ({axis_key})")
# Atomic write: save to a temp file first, then rename. A crash or save()
# exception would otherwise leave a corrupt `cached` file that future runs
# would happily reuse via the `cached.exists()` check above.
tmp_fd, tmp_name = tempfile.mkstemp(suffix=".ttf", dir=cached.parent)
os.close(tmp_fd)
tmp_path = Path(tmp_name)
# Keep separate handles for the source variable font and the static # Keep separate handles for the source variable font and the static
# instance: instantiateVariableFont with default inplace=False returns a # instance: instantiateVariableFont with default inplace=False returns a
# *new* TTFont, so rebinding `font` would otherwise strand the source's # *new* TTFont, so rebinding `font` would otherwise strand the source's
# file handle open until GC runs. # file handle open until GC runs.
# #
# updateFontNames=True keeps the saved name table accurate; optimize=False # updateFontNames=True — rewrite the name table so the saved font
# skips a gvar pass that's wasted when every axis is fully pinned. # reports its weight/style accurately rather
# than retaining the variable-font names.
# optimize=False — skip the gvar interpolation optimisation;
# fully pinning every axis drops gvar anyway,
# so the work would be wasted.
source_font = TTFont(str(source_path)) source_font = TTFont(str(source_path))
try: try:
font = instantiateVariableFont(source_font, axes, updateFontNames=True, optimize=False) font = instantiateVariableFont(source_font, axes, updateFontNames=True, optimize=False)
try: try:
font.save(str(cached)) font.save(str(tmp_path))
finally: finally:
font.close() font.close()
except Exception:
tmp_path.unlink(missing_ok=True)
raise
finally: finally:
source_font.close() source_font.close()
tmp_path.replace(cached)
return cached return cached
@@ -113,11 +133,6 @@ def resolve_font_path(style_spec: dict, family_name: str, style_name: str) -> Pa
raise FileNotFoundError(f"{family_name}/{style_name}: {resolved} not found") raise FileNotFoundError(f"{family_name}/{style_name}: {resolved} not found")
elif "url" in style_spec: elif "url" in style_spec:
url = style_spec["url"] url = style_spec["url"]
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(
f"{family_name}/{style_name}: invalid URL scheme '{parsed.scheme or '<empty>'}'"
)
# Derive a stable filename from the URL # Derive a stable filename from the URL
filename = url.rsplit("/", 1)[-1] filename = url.rsplit("/", 1)[-1]
dest = DOWNLOAD_DIR / family_name / filename dest = DOWNLOAD_DIR / family_name / filename
@@ -134,35 +149,23 @@ def resolve_font_path(style_spec: dict, family_name: str, style_name: str) -> Pa
return resolved return resolved
def build_family(family: dict, output_base: Path, incremental: bool = False, timeout: int = 1200) -> tuple[str, bool, str]: def _stream_pipe(pipe, prefix: str, dest: list[str]):
"""Read lines from a pipe, print with prefix, and accumulate into dest."""
for line in pipe:
dest.append(line)
print(f" [{prefix}] {line}", end="", flush=True)
def build_family(
family: dict, output_base: Path, verbose: bool = False, timeout: int = 600
) -> tuple[str, bool, str]:
"""Build a single font family. Returns (name, success, message).""" """Build a single font family. Returns (name, success, message)."""
name = family["name"] name = family["name"]
output_dir = output_base / name output_dir = output_base / name
if output_dir.exists():
if incremental:
output_dir.mkdir(parents=True, exist_ok=True)
else:
shutil.rmtree(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
else:
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
styles = family.get("styles", {}) styles = family.get("styles", {})
intervals = family["intervals"] intervals = family["intervals"]
if incremental and output_dir.exists():
existing_sizes = set()
for file_path in output_dir.glob("*.cpfont"):
stem = file_path.stem
if stem.startswith(f"{name}_"):
try:
existing_sizes.add(int(stem.split("_")[-1]))
except ValueError:
pass
sizes_list = [s for s in family["sizes"] if s not in existing_sizes]
if not sizes_list:
return name, True, ""
sizes = ",".join(str(s) for s in sizes_list)
else:
sizes = ",".join(str(s) for s in family["sizes"]) sizes = ",".join(str(s) for s in family["sizes"])
# Resolve all font file paths (downloads as needed) # Resolve all font file paths (downloads as needed)
@@ -199,25 +202,58 @@ def build_family(family: dict, output_base: Path, incremental: bool = False, tim
cmd.append("--force-autohint") cmd.append("--force-autohint")
# Run fontconvert_sdcard.py # Run fontconvert_sdcard.py
start = time.monotonic()
try: try:
if verbose:
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
stdout_lines: list[str] = []
stderr_lines: list[str] = []
t_out = threading.Thread(
target=_stream_pipe, args=(proc.stdout, name, stdout_lines)
)
t_err = threading.Thread(
target=_stream_pipe, args=(proc.stderr, f"{name}/err", stderr_lines)
)
t_out.start()
t_err.start()
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
elapsed = time.monotonic() - start
return name, False, f"Timed out after {elapsed:.0f}s"
finally:
t_out.join()
t_err.join()
if proc.returncode != 0:
err = "".join(stderr_lines).strip()
return name, False, err or f"Exit code {proc.returncode}"
return name, True, ""
else:
result = subprocess.run( result = subprocess.run(
cmd, cmd, capture_output=True, text=True, timeout=timeout,
capture_output=True,
text=True,
timeout=timeout,
) )
if result.returncode != 0: if result.returncode != 0:
return name, False, result.stderr.strip() or f"Exit code {result.returncode}" return name, False, result.stderr.strip() or f"Exit code {result.returncode}"
return name, True, "" return name, True, ""
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired as e:
return name, False, "Timed out after 1200s" elapsed = time.monotonic() - start
tail = ""
captured = getattr(e, "stderr", None) or getattr(e, "stdout", None)
if captured:
lines = captured.strip().splitlines()
tail = "\n Last output:\n" + "\n".join(f" | {l}" for l in lines[-20:])
return name, False, f"Timed out after {elapsed:.0f}s{tail}"
except Exception as e: except Exception as e:
return name, False, str(e) return name, False, str(e)
def generate_manifest( def generate_manifest(
families_config: list[dict], output_base: Path, base_url: str, manifest_path: Path, config_path: Path, output_base: Path, base_url: str, manifest_path: Path
config_path: Path,
): ):
"""Generate fonts.json manifest from config + built output. """Generate fonts.json manifest from config + built output.
@@ -256,7 +292,6 @@ def main():
parser.add_argument( parser.add_argument(
"--output-dir", default=str(DEFAULT_OUTPUT), help="Output directory for .cpfont files" "--output-dir", default=str(DEFAULT_OUTPUT), help="Output directory for .cpfont files"
) )
parser.add_argument("--skip", action="store_true", help="Skip font generation completely (for testing manifest generation)")
parser.add_argument("--only", help="Comma-separated family names to build (default: all)") parser.add_argument("--only", help="Comma-separated family names to build (default: all)")
parser.add_argument("--manifest", action="store_true", help="Also generate fonts.json manifest") parser.add_argument("--manifest", action="store_true", help="Also generate fonts.json manifest")
parser.add_argument("--base-url", default="", help="Base URL for manifest (required with --manifest)") parser.add_argument("--base-url", default="", help="Base URL for manifest (required with --manifest)")
@@ -265,22 +300,19 @@ def main():
) )
parser.add_argument( parser.add_argument(
"--jobs", "-j", type=int, default=None, "--jobs", "-j", type=int, default=None,
help="Max parallel jobs (default: 4)" help="Max parallel jobs (default: number of families)"
)
parser.add_argument(
"--timeout", type=int, default=1200,
help="Timeout in seconds for each fontconvert process (default: 1200)"
) )
parser.add_argument("--clean", action="store_true", help="Clean output directory before building") parser.add_argument("--clean", action="store_true", help="Clean output directory before building")
parser.add_argument( parser.add_argument(
"--incremental", action="store_true", "--verbose", "-v", action="store_true",
help="Create only missing files and preserve already generated outputs" help="Stream child process output in real time (useful for debugging timeouts)"
)
parser.add_argument(
"--timeout", type=int, default=600,
help="Per-family timeout in seconds (default: 600)"
) )
args = parser.parse_args() args = parser.parse_args()
# The assets copy is expected to be a link to PRIMARY_CONFIG.
# This script reads PRIMARY_CONFIG directly.
if args.manifest and not args.base_url: if args.manifest and not args.base_url:
parser.error("--base-url is required when using --manifest") parser.error("--base-url is required when using --manifest")
@@ -290,7 +322,6 @@ def main():
print(f"ERROR: Config not found: {config_path}", file=sys.stderr) print(f"ERROR: Config not found: {config_path}", file=sys.stderr)
sys.exit(1) sys.exit(1)
output_base = Path(args.output_dir)
with open(config_path) as f: with open(config_path) as f:
config = yaml.safe_load(f) config = yaml.safe_load(f)
@@ -299,21 +330,18 @@ def main():
print("ERROR: No families defined in config", file=sys.stderr) print("ERROR: No families defined in config", file=sys.stderr)
sys.exit(1) sys.exit(1)
failed = False
if not args.skip:
# Filter if --only specified # Filter if --only specified
if args.only: if args.only:
only_names = {token.strip().lower() for token in args.only.split(",") if token.strip()} only_names = set(args.only.split(","))
families = [f for f in families if f["name"].lower() in only_names] families = [f for f in families if f["name"] in only_names]
missing = only_names - {f["name"].lower() for f in families} missing = only_names - {f["name"] for f in families}
if missing: if missing:
print(f"WARNING: families not found in config: {', '.join(sorted(missing))}", file=sys.stderr) print(f"WARNING: families not found in config: {', '.join(missing)}", file=sys.stderr)
if not families: if not families:
print("ERROR: no matching families after --only filter", file=sys.stderr) print("ERROR: no matching families after --only filter", file=sys.stderr)
sys.exit(1) sys.exit(1)
if args.clean and args.incremental: output_base = Path(args.output_dir)
parser.error("--clean cannot be used with --incremental")
if args.clean and output_base.exists(): if args.clean and output_base.exists():
print(f"Cleaning {output_base}...") print(f"Cleaning {output_base}...")
@@ -333,25 +361,19 @@ def main():
sys.exit(1) sys.exit(1)
# Build phase (parallel) # Build phase (parallel)
requested_workers = 4 if args.jobs is None else max(1, args.jobs) max_workers = args.jobs or len(families)
max_workers = min(requested_workers, len(families)) verbose = args.verbose
print(f"\n=== Building {len(families)} families ({max_workers} parallel jobs) ===\n") timeout = args.timeout
print(f"\n=== Building {len(families)} families ({max_workers} parallel jobs, timeout {timeout}s) ===\n")
failed = [] failed = []
with ProcessPoolExecutor(max_workers=max_workers) as executor: with ProcessPoolExecutor(max_workers=max_workers) as executor:
futures = { futures = {
executor.submit(build_family, family, output_base, args.incremental, args.timeout): family["name"] executor.submit(build_family, family, output_base, verbose, timeout): family["name"]
for family in families for family in families
} }
for future in as_completed(futures): for future in as_completed(futures):
name = futures[future]
success = False
message = ""
try:
name, success, message = future.result() name, success, message = future.result()
except Exception as e:
success = False
message = str(e)
if success: if success:
# Count output files # Count output files
family_dir = output_base / name family_dir = output_base / name
@@ -374,7 +396,7 @@ def main():
# Manifest # Manifest
if args.manifest: if args.manifest:
manifest_path = Path(args.manifest_output) if args.manifest_output else output_base / "fonts.json" manifest_path = Path(args.manifest_output) if args.manifest_output else output_base / "fonts.json"
generate_manifest(families, output_base, args.base_url, manifest_path, config_path) generate_manifest(config_path, output_base, args.base_url, manifest_path)
if failed: if failed:
sys.exit(1) sys.exit(1)