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
+331
@@ -0,0 +1,331 @@
|
||||
#!/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
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
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"
|
||||
|
||||
|
||||
def download_font(url: str, dest: Path) -> Path:
|
||||
"""Download a font file if not already cached. Returns the local path."""
|
||||
if dest.exists():
|
||||
return dest
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
print(f" Downloading {dest.name}...")
|
||||
try:
|
||||
urllib.request.urlretrieve(url, dest)
|
||||
except Exception as e:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise RuntimeError(f"Failed to download {url}: {e}") from e
|
||||
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/<family>/<style>_<axes>_<mtime>.ttf.
|
||||
Returns the path to the static font file.
|
||||
"""
|
||||
from fontTools.varLib.instancer import instantiateVariableFont
|
||||
from fontTools.ttLib import TTFont
|
||||
|
||||
mtime = int(source_path.stat().st_mtime)
|
||||
axis_key = "_".join(f"{k}{v}" for k, v in sorted(axes.items()))
|
||||
cache_name = f"{style_name}_{axis_key}_{mtime}.ttf"
|
||||
cached = INSTANCE_DIR / family_name / cache_name
|
||||
|
||||
if cached.exists():
|
||||
return cached
|
||||
|
||||
# Clean old cached instances for this style
|
||||
cached.parent.mkdir(parents=True, exist_ok=True)
|
||||
for old in cached.parent.glob(f"{style_name}_*.ttf"):
|
||||
old.unlink()
|
||||
|
||||
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)
|
||||
font = TTFont(str(source_path))
|
||||
try:
|
||||
instantiateVariableFont(font, axes)
|
||||
font.save(str(tmp_path))
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
finally:
|
||||
font.close()
|
||||
tmp_path.replace(cached)
|
||||
|
||||
return cached
|
||||
|
||||
|
||||
def resolve_font_path(style_spec: dict, family_name: str, style_name: str) -> Path:
|
||||
"""Resolve a style spec (path or url) to a local font file path.
|
||||
|
||||
If 'variable' key is present, extracts a static instance via fonttools
|
||||
instancer after resolving the source file.
|
||||
"""
|
||||
if "path" in style_spec:
|
||||
resolved = EPDFONTS_DIR / style_spec["path"]
|
||||
if not resolved.exists():
|
||||
raise FileNotFoundError(f"{family_name}/{style_name}: {resolved} not found")
|
||||
elif "url" in style_spec:
|
||||
url = style_spec["url"]
|
||||
# Derive a stable filename from the URL
|
||||
filename = url.rsplit("/", 1)[-1]
|
||||
dest = DOWNLOAD_DIR / family_name / filename
|
||||
resolved = download_font(url, dest)
|
||||
else:
|
||||
raise ValueError(f"{family_name}/{style_name}: must have 'path' or 'url'")
|
||||
|
||||
# If variable font axes are specified, extract a static instance
|
||||
if "variable" in style_spec:
|
||||
resolved = extract_static_instance(
|
||||
resolved, style_spec["variable"], family_name, style_name
|
||||
)
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
def build_family(family: dict, output_base: Path) -> tuple[str, bool, str]:
|
||||
"""Build a single font family. Returns (name, success, message)."""
|
||||
name = family["name"]
|
||||
output_dir = output_base / name
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
styles = family.get("styles", {})
|
||||
intervals = family["intervals"]
|
||||
sizes = ",".join(str(s) for s in family["sizes"])
|
||||
|
||||
# Resolve all font file paths (downloads as needed)
|
||||
try:
|
||||
resolved_styles = {}
|
||||
for style_name, style_spec in styles.items():
|
||||
resolved_styles[style_name] = resolve_font_path(style_spec, name, style_name)
|
||||
except (FileNotFoundError, RuntimeError) as e:
|
||||
return name, False, str(e)
|
||||
|
||||
# Build the fontconvert_sdcard.py command
|
||||
cmd = [sys.executable, str(FONTCONVERT)]
|
||||
|
||||
multi_style = len(resolved_styles) > 1 or "regular" not in resolved_styles
|
||||
has_any_multi = any(k in resolved_styles for k in ("regular", "bold", "italic", "bolditalic"))
|
||||
|
||||
if has_any_multi and len(resolved_styles) > 1:
|
||||
# Multi-style mode
|
||||
for style_name, font_path in resolved_styles.items():
|
||||
cmd.extend([f"--{style_name}", str(font_path)])
|
||||
else:
|
||||
# Single-style mode
|
||||
style_name = next(iter(resolved_styles))
|
||||
font_path = resolved_styles[style_name]
|
||||
cmd.append(str(font_path))
|
||||
cmd.extend(["--style", style_name])
|
||||
|
||||
cmd.extend(["--intervals", intervals])
|
||||
cmd.extend(["--sizes", sizes])
|
||||
cmd.extend(["--name", name])
|
||||
cmd.extend(["--output-dir", str(output_dir) + "/"])
|
||||
|
||||
if family.get("force_autohint", False):
|
||||
cmd.append("--force-autohint")
|
||||
|
||||
# Run fontconvert_sdcard.py
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return name, False, result.stderr.strip() or f"Exit code {result.returncode}"
|
||||
return name, True, ""
|
||||
except subprocess.TimeoutExpired:
|
||||
return name, False, "Timed out after 600s"
|
||||
except Exception as e:
|
||||
return name, False, str(e)
|
||||
|
||||
|
||||
def generate_manifest(
|
||||
config_path: Path, output_base: Path, base_url: str, manifest_path: Path
|
||||
):
|
||||
"""Generate fonts.json manifest from config + built output.
|
||||
|
||||
Uses the standalone generate-font-manifest.py as a subprocess so
|
||||
descriptions come from the YAML config via --descriptions-from.
|
||||
"""
|
||||
manifest_script = SCRIPT_DIR.parent.parent.parent / "scripts" / "generate-font-manifest.py"
|
||||
|
||||
if not base_url.endswith("/"):
|
||||
base_url += "/"
|
||||
|
||||
cmd = [
|
||||
sys.executable, str(manifest_script),
|
||||
"--input", str(output_base),
|
||||
"--base-url", base_url,
|
||||
"--output", str(manifest_path),
|
||||
]
|
||||
|
||||
if config_path.exists():
|
||||
cmd.extend(["--descriptions-from", str(config_path)])
|
||||
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f"ERROR: Manifest generation failed:\n{result.stderr}", file=sys.stderr)
|
||||
return
|
||||
print(result.stdout, end="")
|
||||
print(f"Manifest written: {manifest_path}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Build SD card fonts from YAML config")
|
||||
parser.add_argument(
|
||||
"--config", default=str(DEFAULT_CONFIG), help="Path to font families YAML config"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir", default=str(DEFAULT_OUTPUT), help="Output directory for .cpfont files"
|
||||
)
|
||||
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("--base-url", default="", help="Base URL for manifest (required with --manifest)")
|
||||
parser.add_argument(
|
||||
"--manifest-output", default=None, help="Manifest output path (default: <output-dir>/fonts.json)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--jobs", "-j", type=int, default=None,
|
||||
help="Max parallel jobs (default: number of families)"
|
||||
)
|
||||
parser.add_argument("--clean", action="store_true", help="Clean output directory before building")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.manifest and not args.base_url:
|
||||
parser.error("--base-url is required when using --manifest")
|
||||
|
||||
# Load config
|
||||
config_path = Path(args.config)
|
||||
if not config_path.exists():
|
||||
print(f"ERROR: Config not found: {config_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with open(config_path) as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
families = config.get("families", [])
|
||||
if not families:
|
||||
print("ERROR: No families defined in config", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Filter if --only specified
|
||||
if args.only:
|
||||
only_names = set(args.only.split(","))
|
||||
families = [f for f in families if f["name"] in only_names]
|
||||
missing = only_names - {f["name"] for f in families}
|
||||
if missing:
|
||||
print(f"WARNING: families not found in config: {', '.join(missing)}", file=sys.stderr)
|
||||
if not families:
|
||||
print("ERROR: no matching families after --only filter", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
output_base = Path(args.output_dir)
|
||||
|
||||
if args.clean and output_base.exists():
|
||||
print(f"Cleaning {output_base}...")
|
||||
shutil.rmtree(output_base)
|
||||
|
||||
output_base.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download phase (sequential — avoids hammering servers)
|
||||
print(f"\n=== Resolving {len(families)} font families ===\n")
|
||||
for family in families:
|
||||
for style_name, style_spec in family.get("styles", {}).items():
|
||||
if "url" in style_spec:
|
||||
try:
|
||||
resolve_font_path(style_spec, family["name"], style_name)
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Build phase (parallel)
|
||||
max_workers = args.jobs or len(families)
|
||||
print(f"\n=== Building {len(families)} families ({max_workers} parallel jobs) ===\n")
|
||||
|
||||
failed = []
|
||||
with ProcessPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(build_family, family, output_base): family["name"]
|
||||
for family in families
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
name, success, message = future.result()
|
||||
if success:
|
||||
# Count output files
|
||||
family_dir = output_base / name
|
||||
count = len(list(family_dir.glob("*.cpfont")))
|
||||
size = sum(f.stat().st_size for f in family_dir.glob("*.cpfont"))
|
||||
print(f" OK: {name} ({count} files, {size / 1024 / 1024:.1f} MB)")
|
||||
else:
|
||||
print(f" FAILED: {name}: {message}", file=sys.stderr)
|
||||
failed.append(name)
|
||||
|
||||
# Summary
|
||||
print("\n=== Summary ===\n")
|
||||
total_files = len(list(output_base.rglob("*.cpfont")))
|
||||
total_size = sum(f.stat().st_size for f in output_base.rglob("*.cpfont"))
|
||||
print(f"Total: {total_files} .cpfont files ({total_size / 1024 / 1024:.1f} MB)")
|
||||
|
||||
if failed:
|
||||
print(f"\nFailed families: {', '.join(failed)}", file=sys.stderr)
|
||||
|
||||
# Manifest
|
||||
if args.manifest:
|
||||
manifest_path = Path(args.manifest_output) if args.manifest_output else output_base / "fonts.json"
|
||||
generate_manifest(config_path, output_base, args.base_url, manifest_path)
|
||||
|
||||
if failed:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,15 @@
|
||||
# Canonical version constants for the .cpfont binary format and font manifest.
|
||||
#
|
||||
# These are the single source of truth for the build tooling. The CI workflow
|
||||
# (release-fonts.yml) and both Python scripts (fontconvert_sdcard.py,
|
||||
# generate-font-manifest.py) read from here.
|
||||
#
|
||||
# The firmware C++ headers (SdCardFont.h, FontDownloadActivity.h) carry their
|
||||
# own copies — those must be bumped manually when the firmware is updated to
|
||||
# support a new version.
|
||||
|
||||
# .cpfont binary format version. Bump when the on-disk struct layout changes.
|
||||
CPFONT_VERSION = 4
|
||||
|
||||
# JSON manifest schema version. Bump when the manifest shape changes.
|
||||
FONTS_MANIFEST_VERSION = 1
|
||||
Executable
+898
@@ -0,0 +1,898 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate .cpfont binary files for SD card font loading.
|
||||
|
||||
Outputs binary .cpfont files containing glyph metadata and uncompressed
|
||||
2-bit bitmaps, matching the EpdFontData/EpdGlyph/EpdUnicodeInterval struct
|
||||
layout on the ESP32-C3 (little-endian, RISC-V).
|
||||
|
||||
Usage:
|
||||
# Single file with specific presets
|
||||
python fontconvert_sdcard.py \\
|
||||
--intervals latin-ext,greek,cyrillic \\
|
||||
--size 14 --style regular \\
|
||||
NotoSans-Regular.ttf \\
|
||||
-o NotoSansExt_14.cpfont
|
||||
|
||||
# All 4 sizes at once
|
||||
python fontconvert_sdcard.py \\
|
||||
--intervals cjk \\
|
||||
--sizes 12,14,16,18 --style regular \\
|
||||
NotoSansCJKsc-Regular.otf \\
|
||||
--output-dir NotoSansCJK/
|
||||
|
||||
"""
|
||||
|
||||
import freetype
|
||||
import struct
|
||||
import sys
|
||||
import os
|
||||
import math
|
||||
import argparse
|
||||
from collections import namedtuple
|
||||
|
||||
from fontTools.ttLib import TTFont
|
||||
|
||||
from cpfont_version import CPFONT_VERSION
|
||||
|
||||
# --- Unicode interval presets ---
|
||||
|
||||
INTERVAL_PRESETS = {
|
||||
"ascii": [(0x0020, 0x007E)],
|
||||
"latin1": [(0x0080, 0x00FF)],
|
||||
"latin-ext": [(0x0020, 0x007E), (0x0080, 0x00FF), (0x0100, 0x024F),
|
||||
(0x1E00, 0x1EFF), (0x2000, 0x206F)],
|
||||
"greek": [(0x0370, 0x03FF), (0x1F00, 0x1FFF)],
|
||||
"cyrillic": [(0x0400, 0x04FF), (0x0500, 0x052F)],
|
||||
"georgian": [(0x10A0, 0x10FF), (0x2D00, 0x2D2F)],
|
||||
"armenian": [(0x0530, 0x058F)],
|
||||
"ethiopic": [(0x1200, 0x137F), (0x1380, 0x139F), (0x2D80, 0x2DDF)],
|
||||
"vietnamese": [(0x01A0, 0x01B0), (0x1EA0, 0x1EF9)],
|
||||
"punctuation": [(0x2000, 0x206F)],
|
||||
"cjk": [(0x3000, 0x303F), (0x3040, 0x309F), (0x30A0, 0x30FF),
|
||||
(0x4E00, 0x9FFF), (0xF900, 0xFAFF), (0xFF00, 0xFFEF)],
|
||||
"hangul": [(0xAC00, 0xD7AF), (0x1100, 0x11FF), (0x3130, 0x318F)],
|
||||
"cherokee": [(0x13A0, 0x13FF), (0xAB70, 0xABBF)],
|
||||
"tifinagh": [(0x2D30, 0x2D7F)],
|
||||
# Symbol blocks commonly seen in scifi/popsci/literary fiction.
|
||||
|
||||
"symbols": [(0x2070, 0x209F), (0x20A0, 0x20CF), (0x2150, 0x218F),
|
||||
(0x2190, 0x21FF), (0x2200, 0x22FF), (0x2500, 0x257F),
|
||||
(0x25A0, 0x25FF), (0x2600, 0x26FF), (0x2700, 0x27BF)],
|
||||
# Composite preset for English-language literary fiction including scifi/popsci.
|
||||
# Greek for physics terms, math operators, miscellaneous symbols (♪♫♬), dingbats.
|
||||
"reading": [(0x0020, 0x024F), (0x0300, 0x036F), (0x0370, 0x03FF),
|
||||
(0x0400, 0x04FF), (0x1E00, 0x1EFF), (0x2000, 0x206F),
|
||||
(0x2070, 0x209F), (0x20A0, 0x20CF), (0x2150, 0x218F),
|
||||
(0x2190, 0x21FF), (0x2200, 0x22FF), (0x2500, 0x257F),
|
||||
(0x25A0, 0x25FF), (0x2600, 0x26FF), (0x2700, 0x27BF),
|
||||
(0xFB00, 0xFB06)],
|
||||
# Matches the built-in font intervals from fontconvert.py exactly
|
||||
"builtin": [(0x0000, 0x007F), (0x0080, 0x00FF), (0x0100, 0x017F),
|
||||
(0x01A0, 0x01A1), (0x01AF, 0x01B0), (0x01C4, 0x021F),
|
||||
(0x0300, 0x036F), (0x0400, 0x04FF),
|
||||
(0x1EA0, 0x1EF9), (0x2000, 0x206F), (0x20A0, 0x20CF),
|
||||
(0x2070, 0x209F), (0x2190, 0x21FF), (0x2200, 0x22FF),
|
||||
(0xFB00, 0xFB06)],
|
||||
}
|
||||
|
||||
|
||||
def resolve_intervals(preset_str):
|
||||
"""Resolve comma-separated preset names into a merged, sorted, deduplicated interval list."""
|
||||
all_intervals = []
|
||||
for name in preset_str.split(","):
|
||||
name = name.strip().lower()
|
||||
if name not in INTERVAL_PRESETS:
|
||||
print(f"Error: unknown interval preset '{name}'", file=sys.stderr)
|
||||
print(f"Available presets: {', '.join(sorted(INTERVAL_PRESETS.keys()))}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
all_intervals.extend(INTERVAL_PRESETS[name])
|
||||
|
||||
# Always add replacement character
|
||||
all_intervals.append((0xFFFD, 0xFFFD))
|
||||
|
||||
# Sort and merge overlapping/adjacent intervals
|
||||
all_intervals.sort()
|
||||
merged = []
|
||||
for start, end in all_intervals:
|
||||
if merged and start <= merged[-1][1] + 1:
|
||||
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
||||
else:
|
||||
merged.append((start, end))
|
||||
return merged
|
||||
|
||||
|
||||
GlyphProps = namedtuple("GlyphProps", [
|
||||
"width", "height", "advance_x", "left", "top", "data_length", "data_offset", "code_point"
|
||||
])
|
||||
|
||||
# Intermediate data from rasterizing one font style
|
||||
StyleRasterData = namedtuple("StyleRasterData", [
|
||||
"style_id", # 0=regular, 1=bold, 2=italic, 3=bolditalic
|
||||
"intervals", # validated intervals [(start, end), ...]
|
||||
"all_glyphs", # [(GlyphProps, packed_bytes), ...]
|
||||
"total_bitmap_size", # int
|
||||
"advanceY", "ascender", "descender",
|
||||
"kern_left_classes", "kern_right_classes", "kern_matrix",
|
||||
"kern_left_class_count", "kern_right_class_count",
|
||||
"ligature_pairs",
|
||||
])
|
||||
|
||||
|
||||
def norm_floor(val):
|
||||
return int(math.floor(val / (1 << 6)))
|
||||
|
||||
|
||||
def norm_ceil(val):
|
||||
return int(math.ceil(val / (1 << 6)))
|
||||
|
||||
|
||||
# Fixed-point (fp4) output conventions (must match EpdFontData.h / fp4 namespace):
|
||||
#
|
||||
# advanceX 12.4 unsigned fixed-point (uint16_t).
|
||||
# 12 integer bits, 4 fractional bits = 1/16-pixel resolution.
|
||||
# Encoded from FreeType's 16.16 linearHoriAdvance.
|
||||
#
|
||||
# kernMatrix 4.4 signed fixed-point (int8_t).
|
||||
# 4 integer bits, 4 fractional bits = 1/16-pixel resolution.
|
||||
# Range: -8.0 to +7.9375 pixels.
|
||||
# Encoded from font design-unit kerning values.
|
||||
#
|
||||
# Both share 4 fractional bits so the renderer can add them directly into a
|
||||
# single int32_t accumulator and defer rounding until pixel placement.
|
||||
|
||||
def fp4_from_ft16_16(val):
|
||||
"""Convert FreeType 16.16 fixed-point to 12.4 fixed-point with rounding."""
|
||||
return (val + (1 << 11)) >> 12
|
||||
|
||||
def fp4_from_design_units(du, scale):
|
||||
"""Convert a font design-unit value to 4.4 fixed-point, clamped to int8_t.
|
||||
|
||||
Multiplies by scale (ppem / units_per_em) and shifts into 4 fractional
|
||||
bits. The result is rounded to nearest and clamped to [-128, 127].
|
||||
"""
|
||||
raw = round(du * scale * 16)
|
||||
return max(-128, min(127, raw))
|
||||
|
||||
|
||||
# Standard Unicode ligature codepoints for known input sequences.
|
||||
# Used as a fallback when the GSUB substitute glyph has no cmap entry.
|
||||
STANDARD_LIGATURE_MAP = {
|
||||
(0x66, 0x66): 0xFB00, # ff
|
||||
(0x66, 0x69): 0xFB01, # fi
|
||||
(0x66, 0x6C): 0xFB02, # fl
|
||||
(0x66, 0x66, 0x69): 0xFB03, # ffi
|
||||
(0x66, 0x66, 0x6C): 0xFB04, # ffl
|
||||
(0x17F, 0x74): 0xFB05, # long-s + t
|
||||
(0x73, 0x74): 0xFB06, # st
|
||||
}
|
||||
|
||||
|
||||
def _extract_pairpos_subtable(subtable, glyph_to_cp, raw_kern):
|
||||
"""Extract kerning from a PairPos subtable (Format 1 or 2)."""
|
||||
if subtable.Format == 1:
|
||||
# Individual pairs
|
||||
for i, coverage_glyph in enumerate(subtable.Coverage.glyphs):
|
||||
if coverage_glyph not in glyph_to_cp:
|
||||
continue
|
||||
pair_set = subtable.PairSet[i]
|
||||
for pvr in pair_set.PairValueRecord:
|
||||
if pvr.SecondGlyph not in glyph_to_cp:
|
||||
continue
|
||||
xa = 0
|
||||
if hasattr(pvr, 'Value1') and pvr.Value1:
|
||||
xa = getattr(pvr.Value1, 'XAdvance', 0) or 0
|
||||
if xa != 0:
|
||||
key = (coverage_glyph, pvr.SecondGlyph)
|
||||
raw_kern[key] = raw_kern.get(key, 0) + xa
|
||||
elif subtable.Format == 2:
|
||||
# Class-based pairs — iterate by class, not by glyph, to avoid
|
||||
# O(glyphs²) explosion for CJK fonts with many requested glyphs.
|
||||
class_def1 = subtable.ClassDef1.classDefs if subtable.ClassDef1 else {}
|
||||
class_def2 = subtable.ClassDef2.classDefs if subtable.ClassDef2 else {}
|
||||
coverage_set = set(subtable.Coverage.glyphs)
|
||||
|
||||
# Build reverse mappings: class_id -> list of glyph names
|
||||
left_by_class = {} # only glyphs in coverage AND glyph_to_cp
|
||||
for glyph in glyph_to_cp:
|
||||
if glyph not in coverage_set:
|
||||
continue
|
||||
c1 = class_def1.get(glyph, 0)
|
||||
left_by_class.setdefault(c1, []).append(glyph)
|
||||
|
||||
right_by_class = {} # all glyphs in glyph_to_cp
|
||||
for glyph in glyph_to_cp:
|
||||
c2 = class_def2.get(glyph, 0)
|
||||
right_by_class.setdefault(c2, []).append(glyph)
|
||||
|
||||
# Iterate class pairs (typically << glyph pairs)
|
||||
for c1, class1_rec in enumerate(subtable.Class1Record):
|
||||
if c1 not in left_by_class:
|
||||
continue
|
||||
for c2, c2_rec in enumerate(class1_rec.Class2Record):
|
||||
xa = 0
|
||||
if hasattr(c2_rec, 'Value1') and c2_rec.Value1:
|
||||
xa = getattr(c2_rec.Value1, 'XAdvance', 0) or 0
|
||||
if xa == 0:
|
||||
continue
|
||||
if c2 not in right_by_class:
|
||||
continue
|
||||
for lg in left_by_class[c1]:
|
||||
for rg in right_by_class[c2]:
|
||||
key = (lg, rg)
|
||||
raw_kern[key] = raw_kern.get(key, 0) + xa
|
||||
|
||||
|
||||
def extract_kerning_fonttools(font_path, codepoints, ppem):
|
||||
"""Extract kerning pairs from a font file using fonttools.
|
||||
|
||||
Returns dict of {(leftCp, rightCp): pixel_adjust} for the given
|
||||
codepoints. Values are scaled from font design units to integer
|
||||
pixels at ppem.
|
||||
"""
|
||||
font = TTFont(font_path)
|
||||
units_per_em = font['head'].unitsPerEm
|
||||
cmap = font.getBestCmap() or {}
|
||||
|
||||
# Build glyph_name -> [codepoints] map (preserves aliases where multiple
|
||||
# codepoints share a glyph, e.g. space/nbsp)
|
||||
glyph_to_cps = {}
|
||||
for cp in codepoints:
|
||||
gname = cmap.get(cp)
|
||||
if gname:
|
||||
glyph_to_cps.setdefault(gname, []).append(cp)
|
||||
# Flat dict for membership checks and subtable extraction (uses keys only)
|
||||
glyph_to_cp = glyph_to_cps
|
||||
|
||||
# Collect raw kerning values in font design units
|
||||
raw_kern = {} # (left_glyph_name, right_glyph_name) -> design_units
|
||||
|
||||
# 1. Legacy kern table
|
||||
if 'kern' in font:
|
||||
for subtable in font['kern'].kernTables:
|
||||
if hasattr(subtable, 'kernTable'):
|
||||
for (lg, rg), val in subtable.kernTable.items():
|
||||
if lg in glyph_to_cp and rg in glyph_to_cp:
|
||||
raw_kern[(lg, rg)] = raw_kern.get((lg, rg), 0) + val
|
||||
|
||||
# 2. GPOS 'kern' feature
|
||||
if 'GPOS' in font:
|
||||
gpos = font['GPOS'].table
|
||||
kern_lookup_indices = set()
|
||||
if gpos.FeatureList:
|
||||
for fr in gpos.FeatureList.FeatureRecord:
|
||||
if fr.FeatureTag == 'kern':
|
||||
kern_lookup_indices.update(fr.Feature.LookupListIndex)
|
||||
for li in kern_lookup_indices:
|
||||
lookup = gpos.LookupList.Lookup[li]
|
||||
for st in lookup.SubTable:
|
||||
actual = st
|
||||
# Unwrap Extension (lookup type 9) wrappers
|
||||
if lookup.LookupType == 9 and hasattr(st, 'ExtSubTable'):
|
||||
actual = st.ExtSubTable
|
||||
if hasattr(actual, 'Format'):
|
||||
_extract_pairpos_subtable(actual, glyph_to_cp, raw_kern)
|
||||
|
||||
font.close()
|
||||
|
||||
# Scale design-unit kerning values to 4.4 fixed-point pixels.
|
||||
# Expand glyph aliases: if multiple codepoints share a glyph, emit kern
|
||||
# pairs for all codepoint combinations.
|
||||
scale = ppem / units_per_em
|
||||
result = {} # (leftCp, rightCp) -> 4.4 fixed-point adjust
|
||||
for (lg, rg), du in raw_kern.items():
|
||||
adjust = fp4_from_design_units(du, scale)
|
||||
if adjust != 0:
|
||||
for lcp in glyph_to_cps[lg]:
|
||||
for rcp in glyph_to_cps[rg]:
|
||||
result[(lcp, rcp)] = adjust
|
||||
return result
|
||||
|
||||
|
||||
def derive_kern_classes(kern_map):
|
||||
"""Derive class-based kerning from a pair map.
|
||||
|
||||
Returns (kern_left_classes, kern_right_classes, kern_matrix,
|
||||
kern_left_class_count, kern_right_class_count) where:
|
||||
- kern_left_classes: sorted list of (codepoint, classId) tuples
|
||||
- kern_right_classes: sorted list of (codepoint, classId) tuples
|
||||
- kern_matrix: flat list of int8 values (left_class_count * right_class_count)
|
||||
- kern_left_class_count: number of distinct left classes
|
||||
- kern_right_class_count: number of distinct right classes
|
||||
"""
|
||||
if not kern_map:
|
||||
return [], [], [], 0, 0
|
||||
|
||||
all_left_cps = {lcp for lcp, _ in kern_map}
|
||||
all_right_cps = {rcp for _, rcp in kern_map}
|
||||
|
||||
sorted_right_cps = sorted(all_right_cps)
|
||||
sorted_left_cps = sorted(all_left_cps)
|
||||
|
||||
# Group left codepoints by identical adjustment row
|
||||
left_profile_to_class = {}
|
||||
left_class_map = {}
|
||||
left_class_id = 1
|
||||
for lcp in sorted(all_left_cps):
|
||||
row = tuple(kern_map.get((lcp, rcp), 0) for rcp in sorted_right_cps)
|
||||
if row not in left_profile_to_class:
|
||||
left_profile_to_class[row] = left_class_id
|
||||
left_class_id += 1
|
||||
left_class_map[lcp] = left_profile_to_class[row]
|
||||
|
||||
# Group right codepoints by identical adjustment column
|
||||
right_profile_to_class = {}
|
||||
right_class_map = {}
|
||||
right_class_id = 1
|
||||
for rcp in sorted(all_right_cps):
|
||||
col = tuple(kern_map.get((lcp, rcp), 0) for lcp in sorted_left_cps)
|
||||
if col not in right_profile_to_class:
|
||||
right_profile_to_class[col] = right_class_id
|
||||
right_class_id += 1
|
||||
right_class_map[rcp] = right_profile_to_class[col]
|
||||
|
||||
kern_left_class_count = left_class_id - 1
|
||||
kern_right_class_count = right_class_id - 1
|
||||
|
||||
if kern_left_class_count > 255 or kern_right_class_count > 255:
|
||||
print(f"WARNING: kerning class count exceeds uint8_t range "
|
||||
f"(left={kern_left_class_count}, right={kern_right_class_count}), "
|
||||
f"dropping kerning for this style",
|
||||
file=sys.stderr)
|
||||
return ([], [], [], 0, 0)
|
||||
|
||||
# Build the class x class matrix
|
||||
kern_matrix = [0] * (kern_left_class_count * kern_right_class_count)
|
||||
for (lcp, rcp), adjust in kern_map.items():
|
||||
lc = left_class_map[lcp] - 1
|
||||
rc = right_class_map[rcp] - 1
|
||||
kern_matrix[lc * kern_right_class_count + rc] = adjust
|
||||
|
||||
# Build sorted class entry lists
|
||||
kern_left_classes = sorted(left_class_map.items())
|
||||
kern_right_classes = sorted(right_class_map.items())
|
||||
|
||||
return (kern_left_classes, kern_right_classes, kern_matrix,
|
||||
kern_left_class_count, kern_right_class_count)
|
||||
|
||||
|
||||
def extract_ligatures_fonttools(font_path, codepoints):
|
||||
"""Extract ligature substitution pairs from a font file using fonttools.
|
||||
|
||||
Returns list of (packed_pair, ligature_codepoint) for the given codepoints.
|
||||
Multi-character ligatures are decomposed into chained pairs.
|
||||
"""
|
||||
font = TTFont(font_path)
|
||||
cmap = font.getBestCmap() or {}
|
||||
|
||||
# Build glyph_name -> codepoint and codepoint -> glyph_name maps
|
||||
glyph_to_cp = {}
|
||||
cp_to_glyph = {}
|
||||
for cp, gname in cmap.items():
|
||||
glyph_to_cp[gname] = cp
|
||||
cp_to_glyph[cp] = gname
|
||||
|
||||
# Collect raw ligature rules: (sequence_of_codepoints) -> ligature_codepoint
|
||||
raw_ligatures = {} # tuple of codepoints -> ligature codepoint
|
||||
|
||||
if 'GSUB' in font:
|
||||
gsub = font['GSUB'].table
|
||||
|
||||
LIGATURE_FEATURES = ('liga', 'rlig')
|
||||
liga_lookup_indices = set()
|
||||
if gsub.FeatureList:
|
||||
for fr in gsub.FeatureList.FeatureRecord:
|
||||
if fr.FeatureTag in LIGATURE_FEATURES:
|
||||
liga_lookup_indices.update(fr.Feature.LookupListIndex)
|
||||
|
||||
for li in liga_lookup_indices:
|
||||
lookup = gsub.LookupList.Lookup[li]
|
||||
for st in lookup.SubTable:
|
||||
actual = st
|
||||
# Unwrap Extension (lookup type 7) wrappers
|
||||
if lookup.LookupType == 7 and hasattr(st, 'ExtSubTable'):
|
||||
actual = st.ExtSubTable
|
||||
# LigatureSubst is lookup type 4
|
||||
if not hasattr(actual, 'ligatures'):
|
||||
continue
|
||||
for first_glyph, ligature_list in actual.ligatures.items():
|
||||
if first_glyph not in glyph_to_cp:
|
||||
continue
|
||||
first_cp = glyph_to_cp[first_glyph]
|
||||
for lig in ligature_list:
|
||||
component_cps = []
|
||||
valid = True
|
||||
for comp_glyph in lig.Component:
|
||||
if comp_glyph not in glyph_to_cp:
|
||||
valid = False
|
||||
break
|
||||
component_cps.append(glyph_to_cp[comp_glyph])
|
||||
if not valid:
|
||||
continue
|
||||
seq = tuple([first_cp] + component_cps)
|
||||
if lig.LigGlyph in glyph_to_cp:
|
||||
lig_cp = glyph_to_cp[lig.LigGlyph]
|
||||
elif seq in STANDARD_LIGATURE_MAP:
|
||||
lig_cp = STANDARD_LIGATURE_MAP[seq]
|
||||
else:
|
||||
seq_str = ', '.join(f'U+{cp:04X}' for cp in seq)
|
||||
print(f"ligatures: WARNING: dropping ligature ({seq_str}) -> "
|
||||
f"glyph '{lig.LigGlyph}': output glyph has no cmap entry "
|
||||
f"and input sequence is not in STANDARD_LIGATURE_MAP",
|
||||
file=sys.stderr)
|
||||
continue
|
||||
raw_ligatures[seq] = lig_cp
|
||||
|
||||
font.close()
|
||||
|
||||
# Filter: only keep ligatures where all input and output codepoints are
|
||||
# in our generated glyph set
|
||||
codepoints_set = set(codepoints)
|
||||
filtered = {}
|
||||
for seq, lig_cp in raw_ligatures.items():
|
||||
if lig_cp not in codepoints_set:
|
||||
continue
|
||||
if all(cp in codepoints_set for cp in seq):
|
||||
filtered[seq] = lig_cp
|
||||
|
||||
# Decompose into chained pairs
|
||||
pairs = []
|
||||
# First pass: collect all 2-codepoint ligatures
|
||||
two_char = {seq: lig_cp for seq, lig_cp in filtered.items() if len(seq) == 2}
|
||||
for seq, lig_cp in two_char.items():
|
||||
packed = (seq[0] << 16) | seq[1]
|
||||
pairs.append((packed, lig_cp))
|
||||
|
||||
# Second pass: decompose 3+ codepoint ligatures into chained pairs
|
||||
for seq, lig_cp in filtered.items():
|
||||
if len(seq) < 3:
|
||||
continue
|
||||
prefix = seq[:-1]
|
||||
last_cp = seq[-1]
|
||||
if prefix in filtered:
|
||||
intermediate_cp = filtered[prefix]
|
||||
packed = (intermediate_cp << 16) | last_cp
|
||||
pairs.append((packed, lig_cp))
|
||||
else:
|
||||
print(f"ligatures: skipping {len(seq)}-char ligature "
|
||||
f"({', '.join(f'U+{cp:04X}' for cp in seq)}) -> U+{lig_cp:04X}: "
|
||||
f"no intermediate ligature for prefix", file=sys.stderr)
|
||||
|
||||
# Sort by packed pair key — on-device lookup uses binary search
|
||||
pairs.sort(key=lambda p: p[0])
|
||||
return pairs
|
||||
|
||||
|
||||
def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=False):
|
||||
"""Rasterize all glyphs for one font style. Returns StyleRasterData."""
|
||||
style_names = {0: "regular", 1: "bold", 2: "italic", 3: "bolditalic"}
|
||||
style_label = style_names.get(style_id, str(style_id))
|
||||
|
||||
face = freetype.Face(fontfile)
|
||||
load_flags = freetype.FT_LOAD_RENDER
|
||||
if force_autohint:
|
||||
load_flags |= freetype.FT_LOAD_FORCE_AUTOHINT
|
||||
|
||||
def load_glyph(code_point):
|
||||
glyph_index = face.get_char_index(code_point)
|
||||
if glyph_index > 0:
|
||||
face.load_glyph(glyph_index, load_flags)
|
||||
return face
|
||||
return None
|
||||
|
||||
# Validate intervals: remove codepoints not present in the font
|
||||
print(f" [{style_label}] Validating intervals against font...", file=sys.stderr)
|
||||
validated_intervals = []
|
||||
for i_start, i_end in intervals:
|
||||
start = i_start
|
||||
for code_point in range(i_start, i_end + 1):
|
||||
f = load_glyph(code_point)
|
||||
if f is None:
|
||||
if start < code_point:
|
||||
validated_intervals.append((start, code_point - 1))
|
||||
start = code_point + 1
|
||||
if start <= i_end:
|
||||
validated_intervals.append((start, i_end))
|
||||
|
||||
intervals = validated_intervals
|
||||
total_glyphs = sum(end - start + 1 for start, end in intervals)
|
||||
print(f" [{style_label}] Validated: {len(intervals)} intervals, {total_glyphs} glyphs", file=sys.stderr)
|
||||
|
||||
# Set font size at 150 DPI (matching fontconvert.py)
|
||||
face.set_char_size(size << 6, size << 6, 150, 150)
|
||||
|
||||
# Rasterize all glyphs
|
||||
total_bitmap_size = 0
|
||||
all_glyphs = []
|
||||
|
||||
for i_start, i_end in intervals:
|
||||
for code_point in range(i_start, i_end + 1):
|
||||
f = load_glyph(code_point)
|
||||
if f is None:
|
||||
glyph = GlyphProps(0, 0, 0, 0, 0, 0, total_bitmap_size, code_point)
|
||||
all_glyphs.append((glyph, b''))
|
||||
continue
|
||||
|
||||
bitmap = f.glyph.bitmap
|
||||
|
||||
# Build 4-bit greyscale bitmap (same logic as fontconvert.py)
|
||||
pixels4g = []
|
||||
px = 0
|
||||
for i, v in enumerate(bitmap.buffer):
|
||||
x = i % bitmap.width
|
||||
if x % 2 == 0:
|
||||
px = (v >> 4)
|
||||
else:
|
||||
px = px | (v & 0xF0)
|
||||
pixels4g.append(px)
|
||||
px = 0
|
||||
if x == bitmap.width - 1 and bitmap.width % 2 > 0:
|
||||
pixels4g.append(px)
|
||||
px = 0
|
||||
|
||||
# Downsample to 2-bit bitmap
|
||||
pixels2b = []
|
||||
px = 0
|
||||
pitch = (bitmap.width // 2) + (bitmap.width % 2)
|
||||
for y in range(bitmap.rows):
|
||||
for x in range(bitmap.width):
|
||||
px = px << 2
|
||||
bm = pixels4g[y * pitch + (x // 2)]
|
||||
bm = (bm >> ((x % 2) * 4)) & 0xF
|
||||
|
||||
if bm >= 12:
|
||||
px += 3
|
||||
elif bm >= 8:
|
||||
px += 2
|
||||
elif bm >= 4:
|
||||
px += 1
|
||||
|
||||
if (y * bitmap.width + x) % 4 == 3:
|
||||
pixels2b.append(px)
|
||||
px = 0
|
||||
if (bitmap.width * bitmap.rows) % 4 != 0:
|
||||
px = px << (4 - (bitmap.width * bitmap.rows) % 4) * 2
|
||||
pixels2b.append(px)
|
||||
|
||||
packed = bytes(pixels2b)
|
||||
glyph = GlyphProps(
|
||||
width=bitmap.width,
|
||||
height=bitmap.rows,
|
||||
advance_x=fp4_from_ft16_16(f.glyph.linearHoriAdvance),
|
||||
left=f.glyph.bitmap_left,
|
||||
top=f.glyph.bitmap_top,
|
||||
data_length=len(packed),
|
||||
data_offset=total_bitmap_size,
|
||||
code_point=code_point,
|
||||
)
|
||||
total_bitmap_size += len(packed)
|
||||
all_glyphs.append((glyph, packed))
|
||||
|
||||
# Get font metrics from pipe character (same heuristic as fontconvert.py)
|
||||
load_glyph(ord('|'))
|
||||
|
||||
advanceY = norm_ceil(face.size.height)
|
||||
ascender = norm_ceil(face.size.ascender)
|
||||
descender = norm_floor(face.size.descender)
|
||||
|
||||
print(f" [{style_label}] Metrics: advanceY={advanceY}, ascender={ascender}, descender={descender}", file=sys.stderr)
|
||||
print(f" [{style_label}] Bitmap: {total_bitmap_size} bytes ({total_bitmap_size / 1024:.1f} KB)", file=sys.stderr)
|
||||
|
||||
# --- Extract kerning and ligatures ---
|
||||
ppem = size * 150.0 / 72.0
|
||||
all_cps = set(g.code_point for g, _ in all_glyphs)
|
||||
|
||||
kern_map = extract_kerning_fonttools(fontfile, all_cps, ppem)
|
||||
print(f" [{style_label}] Kerning: {len(kern_map)} pairs extracted", file=sys.stderr)
|
||||
|
||||
(kern_left_classes, kern_right_classes, kern_matrix,
|
||||
kern_left_class_count, kern_right_class_count) = derive_kern_classes(kern_map)
|
||||
|
||||
if kern_map:
|
||||
matrix_size = kern_left_class_count * kern_right_class_count
|
||||
entries_size = (len(kern_left_classes) + len(kern_right_classes)) * 3
|
||||
print(f" [{style_label}] Kerning classes: {kern_left_class_count} left, {kern_right_class_count} right, "
|
||||
f"{matrix_size + entries_size} bytes", file=sys.stderr)
|
||||
|
||||
ligature_pairs = extract_ligatures_fonttools(fontfile, all_cps)
|
||||
if len(ligature_pairs) > 255:
|
||||
print(f" [{style_label}] WARNING: {len(ligature_pairs)} ligature pairs exceeds uint8_t max (255), truncating",
|
||||
file=sys.stderr)
|
||||
ligature_pairs = ligature_pairs[:255]
|
||||
print(f" [{style_label}] Ligatures: {len(ligature_pairs)} pairs", file=sys.stderr)
|
||||
|
||||
return StyleRasterData(
|
||||
style_id=style_id,
|
||||
intervals=intervals,
|
||||
all_glyphs=all_glyphs,
|
||||
total_bitmap_size=total_bitmap_size,
|
||||
advanceY=advanceY,
|
||||
ascender=ascender,
|
||||
descender=descender,
|
||||
kern_left_classes=kern_left_classes,
|
||||
kern_right_classes=kern_right_classes,
|
||||
kern_matrix=kern_matrix,
|
||||
kern_left_class_count=kern_left_class_count,
|
||||
kern_right_class_count=kern_right_class_count,
|
||||
ligature_pairs=ligature_pairs,
|
||||
)
|
||||
|
||||
|
||||
# --- Binary packing helpers ---
|
||||
|
||||
# EpdGlyph struct: 16 bytes, little-endian
|
||||
GLYPH_STRUCT_FORMAT = "<BBHhhH2xI"
|
||||
assert struct.calcsize(GLYPH_STRUCT_FORMAT) == 16
|
||||
|
||||
|
||||
def pack_style_sections(sd):
|
||||
"""Pack one StyleRasterData into binary section bytearrays.
|
||||
Returns (intervals_data, glyphs_data, kern_left, kern_right, kern_matrix, ligatures, bitmaps)."""
|
||||
intervals_data = bytearray()
|
||||
offset = 0
|
||||
for i_start, i_end in sd.intervals:
|
||||
intervals_data += struct.pack("<III", i_start, i_end, offset)
|
||||
offset += i_end - i_start + 1
|
||||
|
||||
glyphs_data = bytearray()
|
||||
for glyph, packed in sd.all_glyphs:
|
||||
glyphs_data += struct.pack(GLYPH_STRUCT_FORMAT,
|
||||
glyph.width, glyph.height, glyph.advance_x,
|
||||
glyph.left, glyph.top,
|
||||
glyph.data_length, glyph.data_offset)
|
||||
|
||||
kern_left_data = bytearray()
|
||||
for cp, cls in sd.kern_left_classes:
|
||||
kern_left_data += struct.pack("<HB", cp, cls)
|
||||
|
||||
kern_right_data = bytearray()
|
||||
for cp, cls in sd.kern_right_classes:
|
||||
kern_right_data += struct.pack("<HB", cp, cls)
|
||||
|
||||
kern_matrix_data = bytearray()
|
||||
if sd.kern_matrix:
|
||||
kern_matrix_data = bytearray(struct.pack(f"<{len(sd.kern_matrix)}b", *sd.kern_matrix))
|
||||
|
||||
ligature_data = bytearray()
|
||||
for packed_pair, lig_cp in sd.ligature_pairs:
|
||||
ligature_data += struct.pack("<II", packed_pair, lig_cp)
|
||||
|
||||
bitmap_data = bytearray()
|
||||
for glyph, packed in sd.all_glyphs:
|
||||
bitmap_data += packed
|
||||
assert len(bitmap_data) == sd.total_bitmap_size
|
||||
|
||||
return (intervals_data, glyphs_data, kern_left_data, kern_right_data,
|
||||
kern_matrix_data, ligature_data, bitmap_data)
|
||||
|
||||
|
||||
def style_sections_total_size(sections):
|
||||
"""Total byte size of all sections returned by pack_style_sections()."""
|
||||
return sum(len(s) for s in sections)
|
||||
|
||||
|
||||
# --- File writers ---
|
||||
|
||||
def generate_cpfont_multistyle(style_fonts, size, intervals, output_path,
|
||||
force_autohint=False):
|
||||
"""Generate a multi-style v4 .cpfont file.
|
||||
|
||||
style_fonts: dict of {style_id: fontfile_path} e.g. {0: "Regular.ttf", 2: "Italic.ttf"}
|
||||
"""
|
||||
MAGIC = b"CPFONT\x00\x00"
|
||||
HEADER_SIZE = 32
|
||||
STYLE_TOC_ENTRY_SIZE = 32
|
||||
flags = 1 # always 2-bit greyscale
|
||||
style_count = len(style_fonts)
|
||||
|
||||
# Rasterize each style
|
||||
raster_data = {} # style_id -> StyleRasterData
|
||||
for style_id in sorted(style_fonts.keys()):
|
||||
fontfile = style_fonts[style_id]
|
||||
print(f" Rasterizing style {style_id}...", file=sys.stderr)
|
||||
raster_data[style_id] = rasterize_font_style(
|
||||
fontfile, size, intervals, style_id=style_id,
|
||||
force_autohint=force_autohint)
|
||||
|
||||
# Pack binary sections for each style
|
||||
packed_sections = {} # style_id -> tuple of section bytearrays
|
||||
for style_id, sd in raster_data.items():
|
||||
packed_sections[style_id] = pack_style_sections(sd)
|
||||
|
||||
# Calculate data offsets (after header + TOC)
|
||||
data_start = HEADER_SIZE + style_count * STYLE_TOC_ENTRY_SIZE
|
||||
current_offset = data_start
|
||||
|
||||
style_offsets = {} # style_id -> absolute file offset
|
||||
for style_id in sorted(packed_sections.keys()):
|
||||
style_offsets[style_id] = current_offset
|
||||
current_offset += style_sections_total_size(packed_sections[style_id])
|
||||
|
||||
# Build global header
|
||||
# V4 header: magic(8) + version(2) + flags(2) + styleCount(1) + reserved(19) = 32
|
||||
header = struct.pack("<8sHHB19s", MAGIC, CPFONT_VERSION, flags, style_count, bytes(19))
|
||||
assert len(header) == HEADER_SIZE
|
||||
|
||||
# Build style TOC entries
|
||||
# Each entry: styleId(1) + pad(3) + intervalCount(4) + glyphCount(4) +
|
||||
# advanceY(1) + ascender(2) + descender(2) + kernL(2) + kernR(2) +
|
||||
# kernLCls(1) + kernRCls(1) + ligCount(1) + dataOffset(4) + reserved(4) = 32
|
||||
STYLE_TOC_FORMAT = "<B3xIIBhhHHBBBI4x"
|
||||
assert struct.calcsize(STYLE_TOC_FORMAT) == STYLE_TOC_ENTRY_SIZE
|
||||
|
||||
toc_data = bytearray()
|
||||
for style_id in sorted(raster_data.keys()):
|
||||
sd = raster_data[style_id]
|
||||
if sd.advanceY > 255:
|
||||
print(f"ERROR: advanceY ({sd.advanceY}) exceeds uint8 range for "
|
||||
f"style {style_id} size {size}. This likely means the font "
|
||||
f"size is too large for this format.",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
toc_data += struct.pack(STYLE_TOC_FORMAT,
|
||||
style_id,
|
||||
len(sd.intervals), len(sd.all_glyphs),
|
||||
sd.advanceY, sd.ascender, sd.descender,
|
||||
len(sd.kern_left_classes), len(sd.kern_right_classes),
|
||||
sd.kern_left_class_count, sd.kern_right_class_count,
|
||||
len(sd.ligature_pairs),
|
||||
style_offsets[style_id])
|
||||
|
||||
# Write output
|
||||
os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else ".", exist_ok=True)
|
||||
total_file_size = 0
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(header)
|
||||
f.write(toc_data)
|
||||
for style_id in sorted(packed_sections.keys()):
|
||||
for section in packed_sections[style_id]:
|
||||
f.write(section)
|
||||
total_file_size = f.tell()
|
||||
|
||||
# Print summary
|
||||
print(f" Output: {output_path} (v4, {style_count} styles)", file=sys.stderr)
|
||||
print(f" Header+TOC: {HEADER_SIZE + len(toc_data)} bytes", file=sys.stderr)
|
||||
for style_id in sorted(raster_data.keys()):
|
||||
sd = raster_data[style_id]
|
||||
secs = packed_sections[style_id]
|
||||
style_names = {0: "regular", 1: "bold", 2: "italic", 3: "bolditalic"}
|
||||
sname = style_names.get(style_id, str(style_id))
|
||||
ssize = style_sections_total_size(secs)
|
||||
print(f" {sname}: {len(sd.all_glyphs)} glyphs, {len(sd.intervals)} intervals, "
|
||||
f"{ssize} bytes", file=sys.stderr)
|
||||
print(f" Total: {total_file_size} bytes ({total_file_size / 1024 / 1024:.2f} MB)", file=sys.stderr)
|
||||
return total_file_size
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate .cpfont files for SD card font loading.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=f"Available interval presets: {', '.join(sorted(INTERVAL_PRESETS.keys()))}"
|
||||
)
|
||||
|
||||
# Font file (positional, optional for multi-style mode)
|
||||
parser.add_argument("fontfile", nargs="?", default=None,
|
||||
help="Path to the font file (single-style mode).")
|
||||
parser.add_argument("--intervals", dest="intervals",
|
||||
help="Comma-separated interval presets (e.g., 'latin-ext,greek,cyrillic').")
|
||||
parser.add_argument("--size", type=int, dest="size",
|
||||
help="Single font size to generate.")
|
||||
parser.add_argument("--sizes", dest="sizes",
|
||||
help="Comma-separated sizes (e.g., '12,14,16,18').")
|
||||
parser.add_argument("--style", dest="style", default="regular",
|
||||
choices=["regular", "bold", "italic", "bolditalic"],
|
||||
help="Font style for single-style mode (default: regular).")
|
||||
parser.add_argument("--name", dest="name",
|
||||
help="Font family name for output filenames (default: derived from font filename).")
|
||||
parser.add_argument("--force-autohint", dest="force_autohint", action="store_true",
|
||||
help="Force FreeType auto-hinter instead of native font hinting.")
|
||||
parser.add_argument("-o", "--output", dest="output",
|
||||
help="Output file path (for single-size mode).")
|
||||
parser.add_argument("--output-dir", dest="output_dir",
|
||||
help="Output directory for multi-size mode.")
|
||||
parser.add_argument("--list-presets", action="store_true",
|
||||
help="List available interval presets and exit.")
|
||||
|
||||
# Multi-style mode: per-style font file arguments (generates v4 .cpfont)
|
||||
parser.add_argument("--regular", dest="font_regular",
|
||||
help="Font file for regular style (enables multi-style v4 mode).")
|
||||
parser.add_argument("--bold", dest="font_bold",
|
||||
help="Font file for bold style.")
|
||||
parser.add_argument("--italic", dest="font_italic",
|
||||
help="Font file for italic style.")
|
||||
parser.add_argument("--bolditalic", dest="font_bolditalic",
|
||||
help="Font file for bold-italic style.")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list_presets:
|
||||
print("Available interval presets:")
|
||||
for name, ranges in sorted(INTERVAL_PRESETS.items()):
|
||||
total = sum(e - s + 1 for s, e in ranges)
|
||||
print(f" {name:15s} {len(ranges)} range(s), ~{total} codepoints")
|
||||
sys.exit(0)
|
||||
|
||||
# Detect multi-style mode
|
||||
style_fonts = {}
|
||||
if args.font_regular:
|
||||
style_fonts[0] = args.font_regular
|
||||
if args.font_bold:
|
||||
style_fonts[1] = args.font_bold
|
||||
if args.font_italic:
|
||||
style_fonts[2] = args.font_italic
|
||||
if args.font_bolditalic:
|
||||
style_fonts[3] = args.font_bolditalic
|
||||
|
||||
is_multistyle = len(style_fonts) > 0
|
||||
fontfile = args.fontfile
|
||||
|
||||
# Require --intervals
|
||||
if not args.intervals:
|
||||
print("Error: --intervals is required (e.g., --intervals latin-ext,greek,cyrillic)", file=sys.stderr)
|
||||
print(f"Available presets: {', '.join(sorted(INTERVAL_PRESETS.keys()))}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
intervals = resolve_intervals(args.intervals)
|
||||
|
||||
# Determine sizes
|
||||
if args.sizes:
|
||||
sizes = [int(s.strip()) for s in args.sizes.split(",")]
|
||||
elif args.size:
|
||||
sizes = [args.size]
|
||||
else:
|
||||
print("Error: --size or --sizes is required", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Validate early: single-style mode requires a font file
|
||||
if not is_multistyle and not fontfile:
|
||||
print("Error: fontfile is required in single-style mode", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Determine font name
|
||||
if args.name:
|
||||
font_name = args.name
|
||||
elif is_multistyle:
|
||||
# Derive from the regular font file
|
||||
ref_file = style_fonts[min(style_fonts.keys())]
|
||||
base = os.path.splitext(os.path.basename(ref_file))[0]
|
||||
for suffix in ["-Regular", "-Bold", "-Italic", "-BoldItalic",
|
||||
"-regular", "-bold", "-italic", "-bolditalic"]:
|
||||
if base.endswith(suffix):
|
||||
base = base[:-len(suffix)]
|
||||
break
|
||||
font_name = base
|
||||
else:
|
||||
base = os.path.splitext(os.path.basename(fontfile))[0]
|
||||
for suffix in ["-Regular", "-Bold", "-Italic", "-BoldItalic",
|
||||
"-regular", "-bold", "-italic", "-bolditalic"]:
|
||||
if base.endswith(suffix):
|
||||
base = base[:-len(suffix)]
|
||||
break
|
||||
font_name = base
|
||||
|
||||
if not is_multistyle:
|
||||
# Single font file provided: wrap as a single-style v4 font
|
||||
style_map = {"regular": 0, "bold": 1, "italic": 2, "bolditalic": 3}
|
||||
style_fonts[style_map[args.style]] = fontfile
|
||||
|
||||
# Always generate v4 format
|
||||
if args.output and len(sizes) != 1:
|
||||
print("Error: --output can only be used with a single size", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
output_dir = args.output_dir if args.output_dir else f"{font_name}/"
|
||||
total_size = 0
|
||||
for sz in sizes:
|
||||
if args.output and len(sizes) == 1:
|
||||
output_path = args.output
|
||||
else:
|
||||
filename = f"{font_name}_{sz}.cpfont"
|
||||
output_path = os.path.join(output_dir, filename)
|
||||
print(f"Generating {output_path} (size {sz}, {len(style_fonts)} style(s), v4)...", file=sys.stderr)
|
||||
total_size += generate_cpfont_multistyle(
|
||||
style_fonts, sz, intervals, output_path,
|
||||
force_autohint=args.force_autohint)
|
||||
print(f"\nTotal: {len(sizes)} files, {total_size / 1024 / 1024:.2f} MB", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,211 @@
|
||||
# SD Card Font Families for CrossPoint Reader
|
||||
#
|
||||
# This file is the single source of truth for which fonts are generated,
|
||||
# how they're sourced, and how they're described in the download manifest.
|
||||
#
|
||||
# Adding a new font family = adding a block here. No code changes needed.
|
||||
#
|
||||
# Fields:
|
||||
# name: Output family name (used in filenames and on-device UI)
|
||||
# description: Human-readable description (shown in download UI and manifest)
|
||||
# intervals: Comma-separated Unicode interval presets for fontconvert_sdcard.py
|
||||
# sizes: Point sizes to generate
|
||||
# force_autohint: (optional) Force FreeType auto-hinter instead of native hinting
|
||||
# styles: Map of style name -> font source
|
||||
# path: relative to lib/EpdFont (for committed fonts)
|
||||
# url: download URL (for fonts not in the repo)
|
||||
#
|
||||
# Variable fonts:
|
||||
# Some fonts (Bitter, Inter, Alegreya) are distributed as variable fonts.
|
||||
# freetype-py can't set variable font axis values, so the build script
|
||||
# uses fonttools.instancer to extract static instances automatically.
|
||||
#
|
||||
# To use a variable font, add a 'variable' key to the style spec with
|
||||
# axis values to pin:
|
||||
#
|
||||
# styles:
|
||||
# regular: {url: "https://...Font[wght].ttf", variable: {wght: 400}}
|
||||
# bold: {url: "https://...Font[wght].ttf", variable: {wght: 700}}
|
||||
#
|
||||
# Extracted static fonts are cached in instanced_fonts/ (gitignored).
|
||||
# Requires fonttools: pip install -r requirements.txt
|
||||
|
||||
families:
|
||||
# ── Serif ──────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Literata
|
||||
description: "Screen-optimized serif (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Regular.ttf"}
|
||||
bold: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Bold.ttf"}
|
||||
italic: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-Italic.ttf"}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/googlefonts/literata/main/fonts/ttf/Literata-BoldItalic.ttf"}
|
||||
|
||||
- name: SourceSerif4
|
||||
description: "Adobe transitional serif (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-Regular.ttf"}
|
||||
bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-Bold.ttf"}
|
||||
italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-It.ttf"}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-serif/release/TTF/SourceSerif4-BoldIt.ttf"}
|
||||
|
||||
- name: NotoSerifExtended
|
||||
description: "Serif (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Regular.ttf"}
|
||||
bold: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Bold.ttf"}
|
||||
italic: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif-Italic/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-Italic.ttf"}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/notofonts/NotoSerif-Italic/main/fonts/ttf/unhinted/instance_ttf/NotoSerif-BoldItalic.ttf"}
|
||||
|
||||
- name: Merriweather
|
||||
description: "Warm serif for long-form reading (Latin, Cyrillic)"
|
||||
intervals: latin-ext,cyrillic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Regular.ttf"}
|
||||
bold: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Bold.ttf"}
|
||||
italic: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-Italic.ttf"}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/SorkinType/Merriweather/master/fonts/ttf/Merriweather-BoldItalic.ttf"}
|
||||
|
||||
- name: Lora
|
||||
description: "Calligraphic serif for literary reading (Latin, Cyrillic)"
|
||||
intervals: latin-ext,cyrillic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Regular.ttf"}
|
||||
bold: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Bold.ttf"}
|
||||
italic: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-Italic.ttf"}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/cyrealtype/Lora-Cyrillic/main/fonts/ttf/Lora-BoldItalic.ttf"}
|
||||
|
||||
- name: GentiumBookPlus
|
||||
description: "Scholarly serif with wide Unicode coverage (Latin, Greek, Cyrillic, IPA)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Regular.ttf"}
|
||||
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Bold.ttf"}
|
||||
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-Italic.ttf"}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/gentiumbookplus/GentiumBookPlus-BoldItalic.ttf"}
|
||||
|
||||
- name: IBMPlexSerif
|
||||
description: "Professional serif (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Regular.ttf"}
|
||||
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Bold.ttf"}
|
||||
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-Italic.ttf"}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexserif/IBMPlexSerif-BoldItalic.ttf"}
|
||||
|
||||
- name: Bitter
|
||||
description: "Slab serif designed for e-ink (Latin, Cyrillic)"
|
||||
intervals: latin-ext,cyrillic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter%5Bwght%5D.ttf", variable: {wght: 400}}
|
||||
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter%5Bwght%5D.ttf", variable: {wght: 700}}
|
||||
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", variable: {wght: 400}}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/bitter/Bitter-Italic%5Bwght%5D.ttf", variable: {wght: 700}}
|
||||
|
||||
# ── Sans-serif ─────────────────────────────────────────────────────────
|
||||
|
||||
- name: NotoSansExtended
|
||||
description: "Sans-serif (Latin, Greek, Cyrillic, Georgian, Armenian, Ethiopic)"
|
||||
intervals: latin-ext,greek,cyrillic,georgian,armenian,ethiopic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {path: "builtinFonts/source/NotoSans/NotoSans-Regular.ttf"}
|
||||
bold: {path: "builtinFonts/source/NotoSans/NotoSans-Bold.ttf"}
|
||||
italic: {path: "builtinFonts/source/NotoSans/NotoSans-Italic.ttf"}
|
||||
bolditalic: {path: "builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf"}
|
||||
|
||||
- name: Inter
|
||||
description: "Modern sans-serif (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", variable: {wght: 400, opsz: 14}}
|
||||
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", variable: {wght: 700, opsz: 14}}
|
||||
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter-Italic%5Bopsz%2Cwght%5D.ttf", variable: {wght: 400, opsz: 14}}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter-Italic%5Bopsz%2Cwght%5D.ttf", variable: {wght: 700, opsz: 14}}
|
||||
|
||||
- name: SourceSans3
|
||||
description: "Adobe humanist sans-serif (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-Regular.ttf"}
|
||||
bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-Bold.ttf"}
|
||||
italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-It.ttf"}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-sans/release/TTF/SourceSans3-BoldIt.ttf"}
|
||||
|
||||
- name: IBMPlexSans
|
||||
description: "IBM corporate sans-serif (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Regular.ttf"}
|
||||
bold: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Bold.ttf"}
|
||||
italic: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-Italic.ttf"}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/IBM/plex/master/packages/plex-sans/fonts/complete/ttf/IBMPlexSans-BoldItalic.ttf"}
|
||||
|
||||
- name: Alegreya
|
||||
description: "Calligraphic serif/display (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya%5Bwght%5D.ttf", variable: {wght: 400}}
|
||||
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya%5Bwght%5D.ttf", variable: {wght: 700}}
|
||||
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya-Italic%5Bwght%5D.ttf", variable: {wght: 400}}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/alegreya/Alegreya-Italic%5Bwght%5D.ttf", variable: {wght: 700}}
|
||||
|
||||
# ── Monospace ──────────────────────────────────────────────────────────
|
||||
|
||||
- name: IBMPlexMono
|
||||
description: "Monospace for code and technical reading (Latin, Greek, Cyrillic)"
|
||||
intervals: latin-ext,greek,cyrillic
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Regular.ttf"}
|
||||
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Bold.ttf"}
|
||||
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Italic.ttf"}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-BoldItalic.ttf"}
|
||||
|
||||
- name: SourceCodePro
|
||||
description: "Adobe monospace with excellent hinting (Latin)"
|
||||
intervals: latin-ext
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-Regular.ttf"}
|
||||
bold: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-Bold.ttf"}
|
||||
italic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-It.ttf"}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-BoldIt.ttf"}
|
||||
|
||||
# ── Accessibility ──────────────────────────────────────────────────────
|
||||
|
||||
- name: AtkinsonHyperlegibleNext
|
||||
description: "Accessibility font for low vision (Latin)"
|
||||
intervals: latin-ext
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Regular.ttf"}
|
||||
bold: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Bold.ttf"}
|
||||
italic: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-Italic.ttf"}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/main/fonts/ttf/AtkinsonHyperlegibleNext-BoldItalic.ttf"}
|
||||
|
||||
- name: LexicaUltralegible
|
||||
description: "Accessibility font for low vision / dyslexia (Latin)"
|
||||
intervals: latin-ext
|
||||
sizes: [12, 14, 16, 18]
|
||||
styles:
|
||||
regular: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Regular.ttf"}
|
||||
bold: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Bold.ttf"}
|
||||
italic: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-Italic.ttf"}
|
||||
bolditalic: {url: "https://raw.githubusercontent.com/jacobxperez/lexica-ultralegible/main/fonts/ttf/LexicaUltralegible-BoldItalic.ttf"}
|
||||
|
||||
Reference in New Issue
Block a user