feat: add SD card font support with on-device download and web management
Add a complete SD card font subsystem that enables users to install and use custom fonts beyond the three built-in families. This combines the back-end firmware support (#1327) with the font configuration, build pipeline, CI distribution, and user-facing management UI (#1392). Core font system: - Custom .cpfont binary format (v4) with multi-style support (regular, bold, italic, bold-italic) packed into a single file per size - On-demand glyph loading from SD card with two-pass prewarm rendering to bulk-read glyphs per page, achieving near-flash performance for Latin text (~697ms vs ~681ms) and viable CJK rendering (~32% slower) - Persistent advance cache for layout measurement without SD I/O - Overflow ring buffer for glyph cache misses during rendering - Memory-conscious design: only advance tables kept in RAM; glyph bitmaps, kern tables, and ligatures loaded on demand from SD Font management: - On-device WiFi download from GitHub Releases with manifest-based discovery, install/update detection, and progress UI - Web interface font upload, listing, and deletion via /fonts page - Manual SD card copy to /fonts/ or /.fonts/ directories - Font selection integrated into Settings > Reader > Font Family Build pipeline: - Declarative YAML config (sd-fonts.yaml) as single source of truth for the 17-family font library (serif, sans, mono, accessibility) - Python converter (fontconvert_sdcard.py) for TTF/OTF to .cpfont with FreeType rasterization, class-based kerning, and ligature extraction - Parallel build orchestrator with variable font instance extraction - CI workflow publishing versioned + stable releases to a dedicated crosspoint-fonts repository with auto-incrementing revision tags - Centralized version constants (cpfont_version.py) shared across build tooling and CI, with firmware headers as manual sync points Additional fixes: - CJK characters no longer get hyphens inserted at line breaks - Advance table eliminates 30+ second stalls during CJK section indexing for paragraphs with >512 unique codepoints Closes #930 Co-authored-by: Zach Nelson <zach@zdnelson.com> Co-authored-by: Justin <itsthisjustin@users.noreply.github.com> Co-authored-by: jpirnay <jens@pirnay.com> Co-authored-by: mcrosson <kemonine@kemonine.info>
This commit is contained in:
committed by
Zach Nelson
co-authored by
Zach Nelson
Justin
jpirnay
mcrosson
parent
29fd29f537
commit
7993b2bb97
Executable
+255
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a fonts.json manifest from a directory of .cpfont files.
|
||||
|
||||
Scans the input directory (flat or nested by family) for .cpfont files, reads
|
||||
their binary headers to extract style metadata, and produces a JSON manifest
|
||||
suitable for device-initiated font downloads.
|
||||
|
||||
Usage:
|
||||
python3 scripts/generate-font-manifest.py \
|
||||
--input lib/EpdFont/scripts/output \
|
||||
--base-url "https://example.com/fonts/" \
|
||||
--output dist/fonts.json
|
||||
|
||||
The input directory may be flat (all .cpfont files in one dir) or nested
|
||||
(family subdirectories). Family names are derived from filenames using the
|
||||
convention <FamilyName>_<size>.cpfont.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Import canonical version constants from the shared file in lib/EpdFont/scripts/
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib" / "EpdFont" / "scripts"))
|
||||
from cpfont_version import CPFONT_VERSION, FONTS_MANIFEST_VERSION
|
||||
|
||||
# --- .cpfont binary format constants ---
|
||||
# Global header: 8s magic, H version, H flags, B styleCount, 19x reserved
|
||||
GLOBAL_HEADER_FORMAT = "<8sHHB19x"
|
||||
GLOBAL_HEADER_SIZE = struct.calcsize(GLOBAL_HEADER_FORMAT) # 32 bytes
|
||||
|
||||
# Style TOC entry: B styleId, 3x pad, I intervalCount, I glyphCount, ...
|
||||
# We only need the first byte (styleId) from each 32-byte entry.
|
||||
STYLE_TOC_ENTRY_SIZE = 32
|
||||
STYLE_TOC_ENTRY_FORMAT = "<B31x"
|
||||
|
||||
CPFONT_MAGIC = b"CPFONT\x00\x00"
|
||||
|
||||
STYLE_NAMES = {0: "regular", 1: "bold", 2: "italic", 3: "bolditalic"}
|
||||
|
||||
# Family descriptions can be loaded from the sd-fonts.yaml config
|
||||
# (via --descriptions-from) or fall back to the family name.
|
||||
FAMILY_DESCRIPTIONS: dict[str, str] = {}
|
||||
|
||||
|
||||
def load_descriptions_from_yaml(yaml_path: Path) -> dict[str, str]:
|
||||
"""Load family descriptions from sd-fonts.yaml config."""
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print("WARNING: pyyaml not installed, cannot load descriptions from YAML", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
with open(yaml_path) as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
return {f["name"]: f["description"] for f in config.get("families", []) if "description" in f}
|
||||
|
||||
|
||||
def read_cpfont_styles(filepath: Path) -> list[str]:
|
||||
"""Read style names from a .cpfont file's binary header."""
|
||||
with open(filepath, "rb") as f:
|
||||
header_data = f.read(GLOBAL_HEADER_SIZE)
|
||||
if len(header_data) < GLOBAL_HEADER_SIZE:
|
||||
print(f" WARNING: {filepath.name} too small, skipping", file=sys.stderr)
|
||||
return []
|
||||
|
||||
magic, version, _flags, style_count = struct.unpack(
|
||||
GLOBAL_HEADER_FORMAT, header_data
|
||||
)
|
||||
|
||||
if magic != CPFONT_MAGIC:
|
||||
print(
|
||||
f" WARNING: {filepath.name} bad magic {magic!r}, skipping",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return []
|
||||
|
||||
if version != CPFONT_VERSION:
|
||||
print(
|
||||
f" WARNING: {filepath.name} version {version} != {CPFONT_VERSION}, skipping",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return []
|
||||
|
||||
styles = []
|
||||
for _ in range(style_count):
|
||||
toc_data = f.read(STYLE_TOC_ENTRY_SIZE)
|
||||
if len(toc_data) < STYLE_TOC_ENTRY_SIZE:
|
||||
break
|
||||
(style_id,) = struct.unpack(STYLE_TOC_ENTRY_FORMAT, toc_data)
|
||||
name = STYLE_NAMES.get(style_id, f"unknown({style_id})")
|
||||
styles.append(name)
|
||||
|
||||
return styles
|
||||
|
||||
|
||||
def parse_filename(filename: str) -> tuple[str, str] | None:
|
||||
"""Parse '<FamilyName>_<size>.cpfont' into (family, size_str).
|
||||
|
||||
Returns None if the filename doesn't match the expected pattern.
|
||||
"""
|
||||
if not filename.endswith(".cpfont"):
|
||||
return None
|
||||
stem = filename[: -len(".cpfont")]
|
||||
parts = stem.rsplit("_", 1)
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
family, size_str = parts
|
||||
if not size_str.isdigit():
|
||||
return None
|
||||
return family, size_str
|
||||
|
||||
|
||||
def scan_cpfont_files(input_dir: Path) -> dict[str, list[Path]]:
|
||||
"""Scan input directory for .cpfont files, grouped by family name.
|
||||
|
||||
Handles both flat and nested directory layouts.
|
||||
"""
|
||||
families: dict[str, list[Path]] = {}
|
||||
|
||||
for path in sorted(input_dir.rglob("*.cpfont")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
parsed = parse_filename(path.name)
|
||||
if parsed is None:
|
||||
print(f" WARNING: skipping {path.name} (unexpected filename format)", file=sys.stderr)
|
||||
continue
|
||||
family_name = parsed[0]
|
||||
families.setdefault(family_name, []).append(path)
|
||||
|
||||
return families
|
||||
|
||||
|
||||
def build_manifest(
|
||||
families: dict[str, list[Path]], base_url: str
|
||||
) -> dict:
|
||||
"""Build the manifest dict from discovered font families."""
|
||||
manifest_families = []
|
||||
|
||||
for family_name in sorted(families.keys()):
|
||||
files = families[family_name]
|
||||
|
||||
# Read styles from the first file (all files in a family have the
|
||||
# same styles since they're generated from the same source fonts).
|
||||
styles = read_cpfont_styles(files[0]) if files else []
|
||||
|
||||
# Get description
|
||||
description = FAMILY_DESCRIPTIONS.get(family_name)
|
||||
if description is None:
|
||||
print(
|
||||
f" WARNING: no description for family '{family_name}', "
|
||||
f"consider adding one to FAMILY_DESCRIPTIONS in {__file__}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
description = family_name
|
||||
|
||||
file_entries = []
|
||||
for filepath in sorted(files, key=lambda p: p.name):
|
||||
file_entries.append(
|
||||
{
|
||||
"name": filepath.name,
|
||||
"size": filepath.stat().st_size,
|
||||
}
|
||||
)
|
||||
|
||||
manifest_families.append(
|
||||
{
|
||||
"name": family_name,
|
||||
"description": description,
|
||||
"styles": styles,
|
||||
"files": file_entries,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"version": FONTS_MANIFEST_VERSION,
|
||||
"baseUrl": base_url,
|
||||
"families": manifest_families,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate fonts.json manifest from .cpfont files"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input",
|
||||
required=True,
|
||||
help="Directory containing .cpfont files (flat or nested by family)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
required=True,
|
||||
help="URL prefix for font downloads (device concatenates baseUrl + filename)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
required=True,
|
||||
help="Output path for fonts.json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--descriptions-from",
|
||||
default=None,
|
||||
help="Path to sd-fonts.yaml to load family descriptions (default: use family name)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
input_dir = Path(args.input)
|
||||
if not input_dir.is_dir():
|
||||
print(f"ERROR: {input_dir} is not a directory", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Ensure base URL ends with /
|
||||
base_url = args.base_url
|
||||
if not base_url.endswith("/"):
|
||||
base_url += "/"
|
||||
|
||||
# Load descriptions from YAML config if provided
|
||||
global FAMILY_DESCRIPTIONS
|
||||
if args.descriptions_from:
|
||||
desc_path = Path(args.descriptions_from)
|
||||
if desc_path.exists():
|
||||
FAMILY_DESCRIPTIONS = load_descriptions_from_yaml(desc_path)
|
||||
print(f"Loaded {len(FAMILY_DESCRIPTIONS)} descriptions from {desc_path}")
|
||||
else:
|
||||
print(f"WARNING: {desc_path} not found, using family names as descriptions", file=sys.stderr)
|
||||
|
||||
print(f"Scanning {input_dir} for .cpfont files...")
|
||||
families = scan_cpfont_files(input_dir)
|
||||
|
||||
if not families:
|
||||
print("ERROR: no .cpfont files found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Found {len(families)} font families:")
|
||||
for name, files in sorted(families.items()):
|
||||
print(f" {name}: {len(files)} files")
|
||||
|
||||
manifest = build_manifest(families, base_url)
|
||||
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
print(f"Wrote {output_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user