## Summary * Previous `fontconvert_sdcard.py` requires that the intervals are named (i.e. listed in `INTERVAL_PRESETS`, ex. "latin1"), this change will also allow them to be unnamed (ex. "(0x2100-0x214F)") for a more flexible usage. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_
922 lines
37 KiB
Python
Executable File
922 lines
37 KiB
Python
Executable File
#!/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 re
|
|
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), (0xFB00, 0xFB06)],
|
|
"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)],
|
|
}
|
|
|
|
# Regex for parsing unnamed hex range intervals: (0xSTART-0xEND)
|
|
_HEX_RANGE_PATTERN = re.compile(r'^\(0x([0-9a-fA-F]+)-0x([0-9a-fA-F]+)\)$')
|
|
|
|
def parse_hex_range(s: str) -> tuple[int, int] | None:
|
|
match = _HEX_RANGE_PATTERN.fullmatch(s)
|
|
if not match:
|
|
return None
|
|
|
|
start_hex, end_hex = match.groups()
|
|
start, end = int(start_hex, 16), int(end_hex, 16)
|
|
|
|
# Validating Unicode range bounds.
|
|
if start > end or end > 0x10FFFF:
|
|
return None
|
|
return start, end
|
|
|
|
|
|
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()
|
|
unnamed_interval = parse_hex_range(name)
|
|
if name not in INTERVAL_PRESETS and unnamed_interval is None:
|
|
print(f"Error: unknown interval preset '{name}'", file=sys.stderr)
|
|
print(f"Available presets: {', '.join(sorted(INTERVAL_PRESETS.keys()))}", file=sys.stderr)
|
|
print("You can also specify unnamed hex ranges like (0x2100-0x214F)", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if unnamed_interval is not None:
|
|
all_intervals.append(unnamed_interval)
|
|
else:
|
|
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()
|