#!/usr/bin/env python3 """ Readerly Build Script ───────────────────── Orchestrates the full font build pipeline: 1. Instances variable fonts into static TTFs (fontTools.instancer) 2. Applies vertical scale (scale.py) via FontForge 3. Applies vertical metrics, line height, rename (metrics.py, lineheight.py, rename.py) 4. Exports to TTF → ./out/ttf/ 5. Post-processes TTFs: style flags, kern pairs, autohinting Uses FontForge (detected automatically). Run with: python3 build.py """ import os import shutil import subprocess import sys import textwrap # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # CONFIGURATION # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # # Most of these values are safe to tweak. The --customize flag only toggles # a small subset at runtime (family name, outline fixes). # # Quick reference (what each knob does): # - REGULAR_VF / ITALIC_VF: input variable fonts from ./src # - DEFAULT_FAMILY: default output family name # - VARIANT_STYLES: (style, source VF, wght, opsz) pins for instancing # - SCALE_LOWER_X/Y: lowercase-only scale (x-height tuning) # - CONDENSE_X: horizontal condense for all glyphs # - LINE_HEIGHT: Typo line height (default line spacing) # - SELECTION_HEIGHT: Win/hhea selection box height and clipping # - ASCENDER_RATIO: ascender share of total height # - KERN_PAIRS: explicit GPOS kern pairs (for devices without ligatures) # - STYLE_MAP: naming/weight metadata per style ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) SRC_DIR = os.path.join(ROOT_DIR, "src") OUT_DIR = os.path.join(ROOT_DIR, "out") OUT_TTF_DIR = os.path.join(OUT_DIR, "ttf") # generated TTFs OUT_KF_DIR = os.path.join(OUT_DIR, "kf") # Kobo (KF) variants REGULAR_VF = os.path.join(SRC_DIR, "Newsreader-VariableFont_opsz,wght.ttf") ITALIC_VF = os.path.join(SRC_DIR, "Newsreader-Italic-VariableFont_opsz,wght.ttf") with open(os.path.join(ROOT_DIR, "VERSION")) as _vf: FONT_VERSION = _vf.read().strip() with open(os.path.join(ROOT_DIR, "COPYRIGHT")) as _cf: COPYRIGHT_TEXT = _cf.read().strip() DEFAULT_FAMILY = "Readerly" # default if --customize not used VARIANT_STYLES = [ # (style_suffix, source_vf, wght, opsz) # opsz=9 is intentionally small to tighten letterforms for e-readers. ("Regular", REGULAR_VF, 450, 9), ("Bold", REGULAR_VF, 650, 9), ("Italic", ITALIC_VF, 450, 9), ("BoldItalic", ITALIC_VF, 650, 9), ] # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # INLINE FONTFORGE SCRIPT CONFIG # (Migrated from ./scripts for readability and single-file builds.) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # # Step 2: Scaling + overlap cleanup # - SCALE_LOWER_* affects lowercase only (x-height tuning). # - CONDENSE_X narrows all glyphs to match Bookerly-like widths. # Scale lowercase glyphs vertically (and slightly widen). SCALE_LOWER_X = 1.03 SCALE_LOWER_Y = 1.08 # Condense all glyphs horizontally. CONDENSE_X = 0.95 # Step 3: Vertical metrics + line spacing (relative to UPM) # - LINE_HEIGHT drives OS/2 Typo metrics (default line spacing) # - SELECTION_HEIGHT drives Win/hhea metrics (selection box + clipping) # - ASCENDER_RATIO splits the total height between ascender/descender LINE_HEIGHT = 1.0 SELECTION_HEIGHT = 1.3 ASCENDER_RATIO = 0.8 # Step 4: ttfautohint options (hinting for Kobo's FreeType renderer) # - Kobo uses FreeType grayscale, so the 1st char of --stem-width-mode # matters: n=natural (least distortion), q=quantized, s=strong. # - x-height snapping is disabled to avoid inconsistent glyph heights. AUTOHINT_OPTS = [ "--no-info", "--stem-width-mode=nss", "--increase-x-height=0", '--x-height-snapping-exceptions=-', ] # Baseline alignment: deepen the bottom anti-aliasing of non-serifed # glyphs via hinting-only touch deltas (no outline changes). This # shifts their bottom points down during rasterization so they produce # more gray below the baseline, visually matching serifed characters. # Each entry is (shift_px, ppem_min, ppem_max). Shifts are in pixels # (multiples of 1/8, max 1.0). Set to empty list to disable. BASELINE_HINT_SHIFTS = [ (0.125, 6, 53), ] # Per-glyph Y ceiling: cap the top of specific glyphs to reduce # oversized or awkward serifs. (glyph_name, max_y) # Points above max_y are clamped down to max_y. GLYPH_Y_CEILING = [ ("u", 1062), # flatten tiny top serif tips to platform level ] # Explicit kern pairs: (left_glyph, right_glyph, kern_value_in_units). # Negative values tighten spacing. These are added on top of any existing # kerning from the source variable font. KERN_PAIRS = [ ("f", "i", -100), # this emulates the `fi` ligature ] # Step 3: Naming and style metadata (used by the rename step) STYLE_MAP = { "Regular": ("Regular", "Book", 400), "Bold": ("Bold", "Bold", 700), "Italic": ("Italic", "Book", 400), "BoldItalic": ("Bold Italic", "Bold", 700), } # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # HELPERS # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ FONTFORGE_CMD = None def find_fontforge(): """Detect FontForge on the system. Returns a command list.""" global FONTFORGE_CMD if FONTFORGE_CMD is not None: return FONTFORGE_CMD # 1. fontforge on PATH (native install, Homebrew, Windows, etc.) if shutil.which("fontforge"): FONTFORGE_CMD = ["fontforge"] return FONTFORGE_CMD # 2. Flatpak (Linux) if shutil.which("flatpak"): result = subprocess.run( ["flatpak", "info", "org.fontforge.FontForge"], capture_output=True, ) if result.returncode == 0: FONTFORGE_CMD = [ "flatpak", "run", "--command=fontforge", "org.fontforge.FontForge", ] return FONTFORGE_CMD # 3. macOS app bundle mac_paths = [ "/Applications/FontForge.app/Contents/MacOS/FontForge", "/Applications/FontForge.app/Contents/Resources/opt/local/bin/fontforge", ] for mac_path in mac_paths: if os.path.isfile(mac_path): FONTFORGE_CMD = [mac_path] return FONTFORGE_CMD print( "ERROR: FontForge not found.\n" "Install it via your package manager, Flatpak, or from https://fontforge.org", file=sys.stderr, ) sys.exit(1) def run_fontforge_script(script_text): """Run a Python script inside FontForge.""" cmd = find_fontforge() + ["-lang=py", "-script", "-"] result = subprocess.run( cmd, input=script_text, capture_output=True, text=True, ) if result.stdout: print(result.stdout, end="") if result.stderr: # FontForge prints various info/warnings to stderr; filter noise for line in result.stderr.splitlines(): if line.startswith("Copyright") or line.startswith(" License") or \ line.startswith(" Version") or line.startswith(" Based on") or \ line.startswith(" with many parts") or \ "pkg_resources is deprecated" in line or \ "Invalid 2nd order spline" in line: continue print(f" [stderr] {line}", file=sys.stderr) if result.returncode != 0: print(f"\nERROR: FontForge script exited with code {result.returncode}", file=sys.stderr) sys.exit(1) def build_per_font_script(open_path, save_path, steps): """ Build a FontForge Python script that opens a font file, runs the given step scripts (which expect `f` to be the active font), saves as .sfd, and closes. Each step is a (label, script_body) tuple. The script_body should use `f` as the font variable. """ parts = [ f'import fontforge', f'f = fontforge.open({open_path!r})', f'print("\\nOpened: " + f.fontname + "\\n")', ] for label, body in steps: parts.append(f'print("── {label} ──\\n")') parts.append(body) parts.append(f'f.save({save_path!r})') parts.append(f'print("\\nSaved: {save_path}\\n")') parts.append('f.close()') return "\n".join(parts) def ff_scale_lowercase_script(): """FontForge script: scale lowercase glyphs vertically.""" return textwrap.dedent(f"""\ import psMat import unicodedata # Scale lowercase glyphs only, from glyph origin. SCALE_X = {SCALE_LOWER_X} SCALE_Y = {SCALE_LOWER_Y} mat = psMat.scale(SCALE_X, SCALE_Y) f.selection.none() count = 0 for g in f.glyphs(): if g.unicode < 0: continue try: cat = unicodedata.category(chr(g.unicode)) except (ValueError, OverflowError): continue if cat == "Ll" or g.unicode in (0x00AA, 0x00BA): f.selection.select(("more",), g.glyphname) count += 1 f.transform(mat, ("round",)) print(f" Scaled {{count}} lowercase glyphs by X={{SCALE_X:.0%}}, Y={{SCALE_Y:.0%}}") """) def ff_condense_script(): """FontForge script: condense all glyphs horizontally.""" return textwrap.dedent(f"""\ import psMat SCALE_X = {CONDENSE_X} mat = psMat.scale(SCALE_X, 1.0) f.selection.all() f.transform(mat, ("round",)) count = sum(1 for g in f.glyphs() if g.isWorthOutputting()) print(f" Condensed {{count}} glyphs by X={{SCALE_X:.0%}}") """) def ff_remove_overlaps_script(): """FontForge script: merge overlapping contours and fix direction.""" return textwrap.dedent("""\ f.selection.all() f.removeOverlap() f.correctDirection() count = sum(1 for g in f.glyphs() if g.isWorthOutputting()) print(f" Removed overlaps and corrected direction for {count} glyphs") """) def ff_metrics_script(): """FontForge script: measure landmarks and set OS/2 Typo metrics.""" return textwrap.dedent("""\ def _bbox(name): # Return bounding box (xmin, ymin, xmax, ymax) or None. if name in f and f[name].isWorthOutputting(): bb = f[name].boundingBox() if bb != (0, 0, 0, 0): return bb return None def measure_chars(chars, *, axis="top"): # Measure a set of reference characters. # axis="top" -> return the highest yMax # axis="bottom" -> return the lowest yMin # Returns (value, display_char) or (None, None). idx = 3 if axis == "top" else 1 pick = max if axis == "top" else min hits = [] for ch in chars: name = fontforge.nameFromUnicode(ord(ch)) bb = _bbox(name) if bb is not None: hits.append((bb[idx], ch)) if not hits: return None, None return pick(hits, key=lambda t: t[0]) def scan_font_extremes(): # Walk every output glyph; return (yMax, yMin, max_name, min_name). y_max, y_min = 0, 0 max_nm, min_nm = None, None for g in f.glyphs(): if not g.isWorthOutputting(): continue bb = g.boundingBox() if bb == (0, 0, 0, 0): continue if bb[3] > y_max: y_max, max_nm = bb[3], g.glyphname if bb[1] < y_min: y_min, min_nm = bb[1], g.glyphname return y_max, y_min, max_nm, min_nm print("─── Design landmarks ───\\n") cap_h, cap_c = measure_chars("HIOX", axis="top") asc_h, asc_c = measure_chars("bdfhkl", axis="top") xht_h, xht_c = measure_chars("xuvw", axis="top") dsc_h, dsc_c = measure_chars("gpqyj", axis="bottom") for label, val, ch in [ ("Cap height", cap_h, cap_c), ("Ascender", asc_h, asc_c), ("x-height", xht_h, xht_c), ("Descender", dsc_h, dsc_c), ]: if val is not None: print(f" {label:12s} {int(val):>6} ('{ch}')") else: print(f" {label:12s} {'N/A':>6}") print("\\n─── Full font scan ───\\n") font_ymax, font_ymin, ymax_name, ymin_name = scan_font_extremes() print(f" Highest glyph: {int(font_ymax):>6} ({ymax_name})") print(f" Lowest glyph: {int(font_ymin):>6} ({ymin_name})") upm = f.em design_top = asc_h if asc_h is not None else cap_h design_bot = dsc_h # negative value if design_top is None or design_bot is None: raise SystemExit( "ERROR: Could not measure ascender/cap-height or descender.\\n" " Make sure your font contains basic Latin glyphs (H, b, p, etc.)." ) typo_ascender = int(round(design_top)) typo_descender = int(round(design_bot)) f.os2_typoascent = typo_ascender f.os2_typodescent = typo_descender f.os2_typolinegap = 0 if hasattr(f, "os2_xheight") and xht_h is not None: f.os2_xheight = int(round(xht_h)) if hasattr(f, "os2_capheight") and cap_h is not None: f.os2_capheight = int(round(cap_h)) # Win/hhea set to same initial values; lineheight step overrides these. f.os2_winascent = typo_ascender f.os2_windescent = abs(typo_descender) f.hhea_ascent = typo_ascender f.hhea_descent = typo_descender f.hhea_linegap = 0 typo_metrics_set = False if hasattr(f, "os2_use_typo_metrics"): f.os2_use_typo_metrics = True typo_metrics_set = True if not typo_metrics_set and hasattr(f, "os2_fsselection"): f.os2_fsselection |= (1 << 7) typo_metrics_set = True if not typo_metrics_set: if hasattr(f, "os2_version") and f.os2_version < 4: f.os2_version = 4 if not typo_metrics_set: print(" WARNING: Could not set USE_TYPO_METRICS programmatically.") print(" -> In Font Info -> OS/2 -> Misc, tick 'USE_TYPO_METRICS'.\\n") typo_line = typo_ascender - typo_descender print(f"\\n─── Applied metrics ───\\n") print(f" UPM: {upm}") print(f" Typo: {typo_ascender} / {typo_descender} (ink span: {typo_line}, {typo_line/upm:.2f}x UPM)") if cap_h is not None: print(f" Cap height: {int(cap_h)}") if xht_h is not None: print(f" x-height: {int(xht_h)}") """) def ff_lineheight_script(): """FontForge script: set line height and selection box metrics.""" return textwrap.dedent(f"""\ # Line height (Typo) as a multiple of UPM. LINE_HEIGHT = {LINE_HEIGHT} # Selection box height (Win/hhea) as a multiple of UPM. SELECTION_HEIGHT = {SELECTION_HEIGHT} # Ascender share of the line/selection height. ASCENDER_RATIO = {ASCENDER_RATIO} upm = f.em # OS/2 Typo — controls line spacing typo_total = int(round(upm * LINE_HEIGHT)) typo_asc = int(round(typo_total * ASCENDER_RATIO)) typo_dsc = typo_asc - typo_total # negative f.os2_typoascent = typo_asc f.os2_typodescent = typo_dsc f.os2_typolinegap = 0 # Win/hhea — controls selection box height and clipping sel_total = int(round(upm * SELECTION_HEIGHT)) sel_asc = int(round(sel_total * ASCENDER_RATIO)) sel_dsc = sel_total - sel_asc f.hhea_ascent = sel_asc f.hhea_descent = -sel_dsc f.hhea_linegap = 0 f.os2_winascent = sel_asc f.os2_windescent = sel_dsc print(f" Typo: {{typo_asc}} / {{typo_dsc}} / gap 0 (line height: {{typo_total}}, {LINE_HEIGHT:.2f}x UPM)") print(f" hhea: {{sel_asc}} / {{-sel_dsc}} / gap 0 (selection: {{sel_total}}, {SELECTION_HEIGHT:.2f}x UPM)") print(f" Win: {{sel_asc}} / {{sel_dsc}}") """) def ff_rename_script(): """FontForge script: update font name metadata.""" style_map = repr(STYLE_MAP) return textwrap.dedent(f"""\ # FAMILY is injected by build.py; default if run standalone. if "FAMILY" not in dir(): FAMILY = "Readerly" STYLE_MAP = {style_map} # Determine style from the current fontname (e.g. "Readerly-BoldItalic") style_suffix = f.fontname.split("-")[-1] if "-" in f.fontname else "Regular" style_display, ps_weight, os2_weight = STYLE_MAP.get( style_suffix, (style_suffix, "Book", 400) ) f.fontname = f"{{FAMILY}}-{{style_suffix}}" f.familyname = FAMILY f.fullname = f"{{FAMILY}} {{style_display}}" f.weight = ps_weight f.os2_weight = os2_weight # Set head.macStyle for style linking if supported by FontForge if hasattr(f, "macstyle"): macstyle = f.macstyle macstyle &= ~((1 << 0) | (1 << 1)) if "Bold" in style_suffix: macstyle |= (1 << 0) if "Italic" in style_suffix: macstyle |= (1 << 1) f.macstyle = macstyle lang = "English (US)" f.appendSFNTName(lang, "Family", FAMILY) f.appendSFNTName(lang, "SubFamily", style_display) f.appendSFNTName(lang, "Fullname", f"{{FAMILY}} {{style_display}}") f.appendSFNTName(lang, "PostScriptName", f"{{FAMILY}}-{{style_suffix}}") f.appendSFNTName(lang, "Preferred Family", FAMILY) f.appendSFNTName(lang, "Preferred Styles", style_display) f.appendSFNTName(lang, "Compatible Full", f"{{FAMILY}} {{style_display}}") f.appendSFNTName(lang, "UniqueID", f"{{FAMILY}} {{style_display}}") # Clear Newsreader-specific entries f.appendSFNTName(lang, "Trademark", "") f.appendSFNTName(lang, "Manufacturer", "") f.appendSFNTName(lang, "Designer", "") f.appendSFNTName(lang, "Vendor URL", "") f.appendSFNTName(lang, "Designer URL", "") count = 0 for _name in f.sfnt_names: count += 1 print(f" Updated {{count}} name entries for {{FAMILY}} {{style_display}}") print(f" PS weight: {{ps_weight}}, OS/2 usWeightClass: {{os2_weight}}") """) def ff_version_script(): """FontForge script: set font version.""" return textwrap.dedent("""\ # VERSION is injected by build.py before this script runs. version_str = "Version " + VERSION f.version = VERSION f.sfntRevision = float(VERSION) f.appendSFNTName("English (US)", "Version", version_str) print(f" Version set to: {version_str}") print(f" head.fontRevision set to: {float(VERSION)}") """) def ff_license_script(): """FontForge script: set copyright.""" return textwrap.dedent("""\ # COPYRIGHT_TEXT is injected by build.py before this script runs. lang = "English (US)" f.copyright = COPYRIGHT_TEXT f.appendSFNTName(lang, "Copyright", COPYRIGHT_TEXT) print(f" Copyright: {COPYRIGHT_TEXT.splitlines()[0]}") """) def build_export_script(sfd_path, ttf_path): """Build a FontForge script that opens an .sfd and exports to TTF.""" return textwrap.dedent(f"""\ import fontforge f = fontforge.open({sfd_path!r}) print("Exporting: " + f.fontname) flags = ("opentype", "no-FFTM-table") f.generate({ttf_path!r}, flags=flags) print(" -> " + {ttf_path!r}) f.close() """) def clean_ttf_degenerate_contours(ttf_path): """Remove zero-area contours (<=2 points) from a TTF in-place.""" try: from fontTools.ttLib import TTFont from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates except Exception: print(" [warn] Skipping cleanup: fontTools not available", file=sys.stderr) return font = TTFont(ttf_path) glyf = font["glyf"] # type: ignore[index] removed_total = 0 modified = set() for name in font.getGlyphOrder(): glyph = glyf[name] # type: ignore[index] if glyph.isComposite(): continue end_pts = getattr(glyph, "endPtsOfContours", None) if not end_pts: continue coords = glyph.coordinates flags = glyph.flags new_coords = [] new_flags = [] new_end_pts = [] start = 0 removed = 0 for end in end_pts: count = end - start + 1 if count <= 2: removed += 1 else: new_coords.extend(coords[start:end + 1]) new_flags.extend(flags[start:end + 1]) new_end_pts.append(len(new_coords) - 1) start = end + 1 if removed: removed_total += removed modified.add(name) glyph.coordinates = GlyphCoordinates(new_coords) glyph.flags = new_flags glyph.endPtsOfContours = new_end_pts glyph.numberOfContours = len(new_end_pts) if removed_total: glyph_set = font.getGlyphSet() for name in modified: glyph = glyf[name] # type: ignore[index] if hasattr(glyph, "recalcBounds"): glyph.recalcBounds(glyph_set) if hasattr(glyf, "recalcBounds"): glyf.recalcBounds(glyph_set) # type: ignore[attr-defined] font.save(ttf_path) print(f" Cleaned {removed_total} zero-area contour(s)") font.close() def fix_ttf_style_flags(ttf_path, style_suffix): """Normalize OS/2 fsSelection and head.macStyle for style linking.""" try: from fontTools.ttLib import TTFont except Exception: print(" [warn] Skipping style flag fix: fontTools not available", file=sys.stderr) return font = TTFont(ttf_path) os2 = font["OS/2"] head = font["head"] fs_sel = os2.fsSelection fs_sel &= ~((1 << 0) | (1 << 5) | (1 << 6)) if style_suffix == "Regular": fs_sel |= (1 << 6) if "Italic" in style_suffix: fs_sel |= (1 << 0) if "Bold" in style_suffix: fs_sel |= (1 << 5) os2.fsSelection = fs_sel macstyle = 0 if "Bold" in style_suffix: macstyle |= (1 << 0) if "Italic" in style_suffix: macstyle |= (1 << 1) head.macStyle = macstyle font.save(ttf_path) font.close() print(f" Normalized style flags for {style_suffix}") def add_kern_pairs(ttf_path): """Prepend explicit kern pairs to the GPOS kern table. Devices that don't support OpenType ligatures (e.g. some e-readers) fall back to individual glyphs. Without kern pairs, combinations like 'fi' render with a visible gap. Pairs are inserted at the front of the first PairPos subtable so they take priority even on renderers that truncate the kern list. """ if not KERN_PAIRS: return try: from fontTools.ttLib import TTFont from fontTools.ttLib.tables.otTables import PairValueRecord, ValueRecord except Exception: print(" [warn] Skipping kern pairs: fontTools not available", file=sys.stderr) return font = TTFont(ttf_path) gpos = font.get("GPOS") if gpos is None: font.close() print(" [warn] No GPOS table, skipping kern pairs", file=sys.stderr) return cmap = font.getBestCmap() # Map glyph names: try cmap first, fall back to glyph order glyph_order = set(font.getGlyphOrder()) def resolve(name): # If it's a single character, look up via cmap if len(name) == 1: cp = ord(name) if cp in cmap: return cmap[cp] # Otherwise treat as a glyph name if name in glyph_order: return name return None # Build resolved pairs pairs = [] for left, right, value in KERN_PAIRS: l = resolve(left) r = resolve(right) if l and r: pairs.append((l, r, value)) else: print(f" [warn] Kern pair {left}+{right}: glyph not found", file=sys.stderr) if not pairs: font.close() return # Find the first Format 1 (individual pairs) PairPos subtable in kern pair_pos = None for lookup in gpos.table.LookupList.Lookup: if lookup.LookupType == 2: # PairPos for subtable in lookup.SubTable: if subtable.Format == 1: pair_pos = subtable break if pair_pos: break if pair_pos is None: font.close() print(" [warn] No Format 1 PairPos subtable found, skipping kern pairs", file=sys.stderr) return count = 0 for left_glyph, right_glyph, value in pairs: # Find or create the PairSet for the left glyph try: idx = pair_pos.Coverage.glyphs.index(left_glyph) except ValueError: # Left glyph not in coverage — add it pair_pos.Coverage.glyphs.append(left_glyph) from fontTools.ttLib.tables.otTables import PairSet ps = PairSet() ps.PairValueRecord = [] ps.PairValueCount = 0 pair_pos.PairSet.append(ps) pair_pos.PairSetCount = len(pair_pos.PairSet) idx = len(pair_pos.Coverage.glyphs) - 1 pair_set = pair_pos.PairSet[idx] # Remove existing pair for same right glyph pair_set.PairValueRecord = [ pvr for pvr in pair_set.PairValueRecord if pvr.SecondGlyph != right_glyph ] # Prepend new pair so it appears first pvr = PairValueRecord() pvr.SecondGlyph = right_glyph vr = ValueRecord() vr.XAdvance = value pvr.Value1 = vr pair_set.PairValueRecord.insert(0, pvr) pair_set.PairValueCount = len(pair_set.PairValueRecord) count += 1 font.save(ttf_path) font.close() print(f" Added {count} kern pair(s) to GPOS") def apply_glyph_y_ceiling(ttf_path): """Clamp glyph points above a Y ceiling down to the ceiling value.""" if not GLYPH_Y_CEILING: return from fontTools.ttLib import TTFont font = TTFont(ttf_path) glyf = font["glyf"] modified = [] for glyph_name, max_y in GLYPH_Y_CEILING: g = glyf.get(glyph_name) if not g or not g.numberOfContours or g.numberOfContours <= 0: continue coords = g.coordinates clamped = 0 for j in range(len(coords)): if coords[j][1] > max_y: coords[j] = (coords[j][0], max_y) clamped += 1 if clamped: modified.append(f"{glyph_name}({clamped}pts)") if modified: font.save(ttf_path) print(f" Clamped Y ceiling: {', '.join(modified)}") font.close() def _generate_baseline_shift_ctrl(ttf_path): """Generate touch deltas to deepen bottom anti-aliasing of non-serifed glyphs. For lowercase glyphs without a flat baseline (no serif foot), shifts the bottom-most points down during rasterization. Uses graduated shifts from BASELINE_HINT_SHIFTS — stronger at small ppem sizes where alignment is most noticeable. No outline changes. """ if not BASELINE_HINT_SHIFTS: return "" from fontTools.ttLib import TTFont font = TTFont(ttf_path) glyf = font["glyf"] cmap = font.getBestCmap() lines = [] for char in "abcdefghijklmnopqrstuvwxyz": code = ord(char) if code not in cmap: continue name = cmap[code] g = glyf[name] if not g.numberOfContours or g.numberOfContours <= 0: continue coords = g.coordinates ys = set(c[1] for c in coords) if 0 in ys: continue # has serif baseline bottom_pts = [i for i, (x, y) in enumerate(coords) if y <= 0] if not bottom_pts: continue pts_str = ", ".join(str(p) for p in bottom_pts) for shift_px, ppem_min, ppem_max in BASELINE_HINT_SHIFTS: shift = -abs(shift_px) lines.append( f"{name} touch {pts_str} yshift {shift:.3f} @ {ppem_min}-{ppem_max}" ) font.close() return "\n".join(lines) def autohint_ttf(ttf_path): """Run ttfautohint to add proper TrueType hinting. Kobo uses FreeType for font rasterization. Without embedded hints, FreeType's auto-hinter computes "blue zones" from the outlines. When a glyph (e.g. italic 't') has a curved tail that dips just below the baseline, the auto-hinter snaps that edge up to y=0 — shifting the entire glyph upward relative to its neighbors. This is most visible at small sizes. ttfautohint replaces FreeType's built-in auto-hinter with its own hinting, which may handle sub-baseline overshoots more gracefully. The resulting bytecode is baked into the font, so FreeType uses the TrueType interpreter instead of falling back to auto-hinting. Additionally generates per-font touch deltas to deepen the baseline anti-aliasing of non-serifed glyphs. """ if not shutil.which("ttfautohint"): print(" [warn] ttfautohint not found, skipping", file=sys.stderr) return # Generate control instructions for this specific font's points ctrl_text = _generate_baseline_shift_ctrl(ttf_path) ctrl_path = ttf_path + ".ctrl.tmp" ctrl_count = 0 opts = list(AUTOHINT_OPTS) if ctrl_text: with open(ctrl_path, "w") as f: f.write(ctrl_text) opts += [f"--control-file={ctrl_path}"] ctrl_count = ctrl_text.count("\n") + 1 tmp_path = ttf_path + ".autohint.tmp" result = subprocess.run( ["ttfautohint"] + opts + [ttf_path, tmp_path], capture_output=True, text=True, ) if os.path.exists(ctrl_path): os.remove(ctrl_path) if result.returncode != 0: print(f" [warn] ttfautohint failed: {result.stderr.strip()}", file=sys.stderr) if os.path.exists(tmp_path): os.remove(tmp_path) return os.replace(tmp_path, ttf_path) hint_msg = "Autohinted with ttfautohint" if ctrl_count: hint_msg += f" ({ctrl_count} serif control hints)" print(f" {hint_msg}") # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # MAIN # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ def check_ttfautohint(): """Verify ttfautohint is installed before starting the build.""" if shutil.which("ttfautohint"): return print( "ERROR: ttfautohint not found.\n" "\n" "ttfautohint is required for proper rendering on Kobo e-readers.\n" "Install it with:\n" " macOS/Bazzite: brew install ttfautohint\n" " Debian/Ubuntu: sudo apt install ttfautohint\n" " Fedora: sudo dnf install ttfautohint\n" " Arch: sudo pacman -S ttfautohint\n", file=sys.stderr, ) sys.exit(1) KOBOFIX_URL = ( "https://raw.githubusercontent.com/nicoverbruggen/kobo-font-fix/main/kobofix.py" ) def _download_kobofix(dest): """Download kobofix.py if not already cached.""" if os.path.isfile(dest): print(f" Using cached kobofix.py") return import urllib.request print(f" Downloading kobofix.py ...") urllib.request.urlretrieve(KOBOFIX_URL, dest) print(f" Saved to {dest}") def _run_kobofix(kobofix_path, variant_names): """Run kobofix.py --preset kf on built TTFs, move KF_ files to out/kf/.""" ttf_files = [os.path.join(OUT_TTF_DIR, f"{n}.ttf") for n in variant_names] cmd = [sys.executable, kobofix_path, "--preset", "kf"] + ttf_files result = subprocess.run(cmd, capture_output=True, text=True) if result.stdout: print(result.stdout, end="") if result.returncode != 0: print("\nERROR: kobofix.py failed", file=sys.stderr) if result.stderr: print(result.stderr, file=sys.stderr) sys.exit(1) os.makedirs(OUT_KF_DIR, exist_ok=True) import glob moved = 0 for kf_file in glob.glob(os.path.join(OUT_TTF_DIR, "KF_*.ttf")): dest = os.path.join(OUT_KF_DIR, os.path.basename(kf_file)) shutil.move(kf_file, dest) moved += 1 print(f" Moved {moved} KF font(s) to {OUT_KF_DIR}/") def main(): print("=" * 60) print(" Readerly Build") print("=" * 60) ff_cmd = find_fontforge() print(f" FontForge: {' '.join(ff_cmd)}") check_ttfautohint() print(f" ttfautohint: {shutil.which('ttfautohint')}") family = DEFAULT_FAMILY outline_fix = True # --name "Foo" sets the family name directly if "--name" in sys.argv: idx = sys.argv.index("--name") if idx + 1 < len(sys.argv): family = sys.argv[idx + 1] else: print("ERROR: --name requires a value", file=sys.stderr) sys.exit(1) if "--customize" in sys.argv: print() family = input(f" Font family name [{family}]: ").strip() or family outline_input = input(" Apply outline fixes (remove overlaps + zero-area cleanup)? [Y/n]: ").strip().lower() outline_fix = outline_input not in ("n", "no") print() print(f" Family: {family}") print(f" Outline fix: {'yes' if outline_fix else 'no'}") print() tmp_dir = os.path.join(ROOT_DIR, "tmp") if os.path.exists(tmp_dir): shutil.rmtree(tmp_dir) os.makedirs(tmp_dir) try: _build(tmp_dir, family=family, outline_fix=outline_fix) finally: shutil.rmtree(tmp_dir, ignore_errors=True) def _build(tmp_dir, family=DEFAULT_FAMILY, outline_fix=True): variants = [(f"{family}-{style}", vf, wght, opsz) for style, vf, wght, opsz in VARIANT_STYLES] variant_names = [name for name, _, _, _ in variants] # Step 1: Instance variable fonts into static TTFs print("\n── Step 1: Instance variable fonts ──\n") for name, vf_path, wght, opsz in variants: ttf_out = os.path.join(tmp_dir, f"{name}.ttf") print(f" Instancing {name} (wght={wght}, opsz={opsz})") cmd = [ sys.executable, "-m", "fontTools.varLib.instancer", vf_path, f"wght={wght}", f"opsz={opsz}", "-o", ttf_out, ] result = subprocess.run(cmd, capture_output=True, text=True) if result.stdout: print(result.stdout, end="") if result.returncode != 0: print(f"\nERROR: instancer failed for {name}", file=sys.stderr) if result.stderr: print(result.stderr, file=sys.stderr) sys.exit(1) print(f" {len(variants)} font(s) instanced.") # Step 2: Apply vertical scale (opens TTF, saves as SFD) print("\n── Step 2: Scale lowercase ──\n") scale_code = ff_scale_lowercase_script() condense_code = ff_condense_script() overlap_code = ff_remove_overlaps_script() for name in variant_names: ttf_path = os.path.join(tmp_dir, f"{name}.ttf") sfd_path = os.path.join(tmp_dir, f"{name}.sfd") print(f"Scaling: {name}") steps = [ ("Scaling Y", scale_code), ("Condensing X", condense_code), ] if outline_fix: steps.append(("Removing overlaps", overlap_code)) script = build_per_font_script(ttf_path, sfd_path, steps) run_fontforge_script(script) # Step 3: Apply metrics and rename (opens SFD, saves as SFD) print("\n── Step 3: Apply metrics and rename ──\n") metrics_code = ff_metrics_script() lineheight_code = ff_lineheight_script() rename_code = ff_rename_script() version_code = ff_version_script() license_code = ff_license_script() for name in variant_names: sfd_path = os.path.join(tmp_dir, f"{name}.sfd") print(f"Processing: {name}") print("-" * 40) # Set fontname so rename.py can detect the correct style suffix set_fontname = f'f.fontname = {name!r}' set_family = f'FAMILY = {family!r}' set_version = f'VERSION = {FONT_VERSION!r}' set_license = f'COPYRIGHT_TEXT = {COPYRIGHT_TEXT!r}' script = build_per_font_script(sfd_path, sfd_path, [ ("Setting vertical metrics", metrics_code), ("Adjusting line height", lineheight_code), ("Setting fontname for rename", set_fontname), ("Updating font names", set_family + "\n" + rename_code), ("Setting version", set_version + "\n" + version_code), ("Setting license", set_license + "\n" + license_code), ]) run_fontforge_script(script) # Step 4: Export to out/sfd and out/ttf print("\n── Step 4: Export ──\n") os.makedirs(OUT_TTF_DIR, exist_ok=True) for name in variant_names: sfd_path = os.path.join(tmp_dir, f"{name}.sfd") ttf_path = os.path.join(OUT_TTF_DIR, f"{name}.ttf") style_suffix = name.split("-")[-1] if "-" in name else "Regular" # Export TTF script = build_export_script(sfd_path, ttf_path) run_fontforge_script(script) if outline_fix: clean_ttf_degenerate_contours(ttf_path) fix_ttf_style_flags(ttf_path, style_suffix) add_kern_pairs(ttf_path) apply_glyph_y_ceiling(ttf_path) autohint_ttf(ttf_path) # Step 5: Generate Kobo (KF) variants via kobofix.py print("\n── Step 5: Generate Kobo (KF) variants ──\n") kobofix_path = os.path.join(tmp_dir, "kobofix.py") _download_kobofix(kobofix_path) _run_kobofix(kobofix_path, variant_names) print("\n" + "=" * 60) print(" Build complete!") print(f" TTF fonts are in: {OUT_TTF_DIR}/") print(f" KF fonts are in: {OUT_KF_DIR}/") print("=" * 60) if __name__ == "__main__": main()