Attempt 1
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
out
|
||||
mutated
|
||||
184
build.py
Executable file
184
build.py
Executable file
@@ -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()
|
||||
48
scripts/scale.py
Normal file
48
scripts/scale.py
Normal file
@@ -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.")
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user