diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c28c84 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +out +mutated \ No newline at end of file diff --git a/build.py b/build.py new file mode 100755 index 0000000..779ea06 --- /dev/null +++ b/build.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +Readerly Build Script +───────────────────── +Orchestrates the full font build pipeline: + + 1. Copies ./src/*.sfd → ./mutated/ + 2. Applies vertical scale (scale.py) + 3. Applies vertical metrics (metrics.py) + 4. Exports to TTF with old-style kern table → ./out/ + +Uses the Flatpak version of FontForge. +Run with: python3 build.py +""" + +import os +import shutil +import subprocess +import sys +import textwrap + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# CONFIGURATION +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) +SRC_DIR = os.path.join(ROOT_DIR, "src") +MUTATED_DIR = os.path.join(ROOT_DIR, "mutated") +OUT_DIR = os.path.join(ROOT_DIR, "out") +SCRIPTS_DIR = os.path.join(ROOT_DIR, "scripts") + +FLATPAK_APP = "org.fontforge.FontForge" + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# HELPERS +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +def run_fontforge_script(script_text): + """Run a Python script inside FontForge via flatpak.""" + result = subprocess.run( + [ + "flatpak", "run", "--command=fontforge", FLATPAK_APP, + "-lang=py", "-script", "-", + ], + 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(sfd_path, steps): + """ + Build a FontForge Python script that opens an .sfd file, runs the given + step scripts (which expect `f` to be the active font), saves, 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({sfd_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({sfd_path!r})') + parts.append(f'print("\\nSaved: {sfd_path}\\n")') + parts.append('f.close()') + return "\n".join(parts) + + +def load_script_as_function(script_path): + """ + Read a script file and adapt it from using fontforge.activeFont() to + using a pre-opened font variable `f`. + """ + with open(script_path) as fh: + code = fh.read() + # Replace activeFont() call — the font is already open as `f` + code = code.replace("fontforge.activeFont()", "f") + return code + + +def build_export_script(sfd_path, ttf_path): + """Build a FontForge script that opens an .sfd and exports to TTF with old-style kern.""" + return textwrap.dedent(f"""\ + import fontforge + + f = fontforge.open({sfd_path!r}) + print("Exporting: " + f.fontname) + + # Generate TTF with old-style kern table and Windows-compatible kern pairs + flags = ("opentype", "old-kern", "no-FFTM-table", "winkern") + f.generate({ttf_path!r}, flags=flags) + + print(" -> " + {ttf_path!r}) + f.close() + """) + + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# MAIN +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +def main(): + print("=" * 60) + print(" Readerly Build") + print("=" * 60) + + # Step 1: Copy src → mutated + print("\n── Step 1: Copy sources to ./mutated ──\n") + if os.path.exists(MUTATED_DIR): + shutil.rmtree(MUTATED_DIR) + shutil.copytree(SRC_DIR, MUTATED_DIR) + sfd_files = sorted(f for f in os.listdir(MUTATED_DIR) if f.endswith(".sfd")) + for f in sfd_files: + print(f" Copied: {f}") + print(f" {len(sfd_files)} font(s) ready.") + + # Step 2: Apply vertical scale to all glyphs + print("\n── Step 2: Vertical scale ──\n") + + scale_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "scale.py")) + + for sfd_name in sfd_files: + sfd_path = os.path.join(MUTATED_DIR, sfd_name) + print(f"Scaling: {sfd_name}") + + script = build_per_font_script(sfd_path, [ + ("Scaling Y", scale_code), + ]) + run_fontforge_script(script) + + # Step 3: Apply metrics.py to each font + print("\n── Step 3: Apply vertical metrics ──\n") + + metrics_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "metrics.py")) + + for sfd_name in sfd_files: + sfd_path = os.path.join(MUTATED_DIR, sfd_name) + print(f"Processing: {sfd_name}") + print("-" * 40) + + script = build_per_font_script(sfd_path, [ + ("Setting vertical metrics", metrics_code), + ]) + run_fontforge_script(script) + + # Step 4: Export to TTF + print("\n── Step 4: Export to TTF ──\n") + os.makedirs(OUT_DIR, exist_ok=True) + + for sfd_name in sfd_files: + sfd_path = os.path.join(MUTATED_DIR, sfd_name) + ttf_name = sfd_name.replace(".sfd", ".ttf") + ttf_path = os.path.join(OUT_DIR, ttf_name) + + script = build_export_script(sfd_path, ttf_path) + run_fontforge_script(script) + + print("\n" + "=" * 60) + print(" Build complete!") + print(f" TTF fonts are in: {OUT_DIR}/") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/scripts/scale.py b/scripts/scale.py new file mode 100644 index 0000000..6a2b83c --- /dev/null +++ b/scripts/scale.py @@ -0,0 +1,48 @@ +""" +FontForge: Scale all glyphs vertically +─────────────────────────────────────── +Applies a vertical scale to all glyphs from glyph origin, +matching the Transform dialog with all options checked: + + - Transform All Layers + - Transform Guide Layer Too + - Transform Width Too + - Transform kerning classes too + - Transform simple positioning features & kern pairs + - Round To Int + +Run inside FontForge (or via build.py which sets `f` before running this). +""" + +import fontforge +import psMat + +f = fontforge.activeFont() + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# CONFIGURATION +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +SCALE_X = 1.0 +SCALE_Y = 1.0 + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# APPLY +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +mat = psMat.scale(SCALE_X, SCALE_Y) + +# Select all glyphs +f.selection.all() + +# Transform with all options enabled. +# FontForge flag names: +# partialTrans — transform selected points only (we don't want this) +# round — Round To Int +# The font-level transform handles layers, widths, kerning, and positioning +# when called on the full selection. +f.transform(mat, ("round",)) + +count = sum(1 for g in f.glyphs() if g.isWorthOutputting()) +print(f" Scaled {count} glyphs by X={SCALE_X:.0%}, Y={SCALE_Y:.0%}") +print("Done.") diff --git a/scripts/xheight.py b/scripts/xheight.py index 031cac7..2d7fccf 100644 --- a/scripts/xheight.py +++ b/scripts/xheight.py @@ -197,21 +197,24 @@ mat = psMat.scale(scale_factor) errors = [] weight_ok = 0 weight_err = 0 +skipped_composites = 0 for g in targets: gname = g.glyphname - # ── 1. Store original metrics ──────────────────────────────────────────── + # ── 1. Skip composites ──────────────────────────────────────────────────── + # Composite glyphs (é = e + accent) reference base glyphs that we're + # already scaling. The composite automatically picks up the scaled base. + # Decomposing would flatten the references and double-scale the outlines. + if g.references: + skipped_composites += 1 + continue + + # ── 2. Store original metrics ──────────────────────────────────────────── orig_lsb = g.left_side_bearing orig_rsb = g.right_side_bearing orig_width = g.width - - # ── 2. Decompose composites ────────────────────────────────────────────── - # Prevents double-scaling: if 'é' references 'e' and we scale both, - # the 'e' outlines inside 'é' would be scaled twice. - # Decomposing first means every glyph owns its outlines directly. - if g.references: - g.unlinkRef() + orig_bb = g.boundingBox() # ── 3. Uniform scale from origin (0, baseline) ────────────────────────── g.transform(mat) @@ -219,14 +222,22 @@ for g in targets: # ── 4. Stroke-weight compensation ──────────────────────────────────────── if weight_delta != 0: try: - g.changeWeight(weight_delta, "auto", "auto") + g.changeWeight(weight_delta) g.correctDirection() weight_ok += 1 except Exception as e: weight_err += 1 errors.append((gname, str(e))) - # ── 5. Fix sidebearings / advance width ────────────────────────────────── + # ── 5. Fix baseline shift ──────────────────────────────────────────────── + # changeWeight can shift outlines off the baseline. If the glyph + # originally sat on y=0, nudge it back. + new_bb = g.boundingBox() + if orig_bb[1] == 0 and new_bb[1] != 0: + shift = -new_bb[1] + g.transform(psMat.translate(0, shift)) + + # ── 6. Fix sidebearings / advance width ────────────────────────────────── if BEARING_MODE == "proportional": # Scale bearings by the same factor → glyph is proportionally wider. g.left_side_bearing = int(round(orig_lsb * scale_factor)) @@ -236,7 +247,8 @@ for g in targets: g.left_side_bearing = int(round(orig_lsb)) g.right_side_bearing = int(round(orig_rsb)) -print(f" Scaled {len(targets)} glyphs.") +scaled_count = len(targets) - skipped_composites +print(f" Scaled {scaled_count} glyphs (skipped {skipped_composites} composites).") if weight_delta != 0: print(f" Weight compensation: {weight_ok} OK, {weight_err} errors.") if errors: