323 lines
12 KiB
Python
Executable File
323 lines
12 KiB
Python
Executable File
#!/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 SFD and TTF → ./out/sfd/ and ./out/ttf/
|
|
|
|
Uses FontForge (detected automatically).
|
|
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")
|
|
OUT_DIR = os.path.join(ROOT_DIR, "out")
|
|
OUT_SFD_DIR = os.path.join(OUT_DIR, "sfd")
|
|
OUT_TTF_DIR = os.path.join(OUT_DIR, "ttf")
|
|
SCRIPTS_DIR = os.path.join(ROOT_DIR, "scripts")
|
|
|
|
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"
|
|
|
|
VARIANT_STYLES = [
|
|
# (style_suffix, source_vf, wght, opsz)
|
|
("Regular", REGULAR_VF, 450, 9),
|
|
("Bold", REGULAR_VF, 550, 9),
|
|
("Italic", ITALIC_VF, 450, 9),
|
|
("BoldItalic", ITALIC_VF, 550, 9),
|
|
]
|
|
|
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
# 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 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, old_kern=True):
|
|
"""Build a FontForge script that opens an .sfd and exports to TTF."""
|
|
if old_kern:
|
|
flags_line = 'flags = ("opentype", "old-kern", "no-FFTM-table", "winkern")'
|
|
else:
|
|
flags_line = 'flags = ("opentype", "no-FFTM-table")'
|
|
return textwrap.dedent(f"""\
|
|
import fontforge
|
|
|
|
f = fontforge.open({sfd_path!r})
|
|
print("Exporting: " + f.fontname)
|
|
|
|
{flags_line}
|
|
f.generate({ttf_path!r}, flags=flags)
|
|
|
|
print(" -> " + {ttf_path!r})
|
|
f.close()
|
|
""")
|
|
|
|
|
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
# MAIN
|
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
def main():
|
|
print("=" * 60)
|
|
print(" Readerly Build")
|
|
print("=" * 60)
|
|
|
|
ff_cmd = find_fontforge()
|
|
print(f" FontForge: {' '.join(ff_cmd)}")
|
|
|
|
family = DEFAULT_FAMILY
|
|
old_kern = False
|
|
|
|
if "--customize" in sys.argv:
|
|
print()
|
|
family = input(f" Font family name [{DEFAULT_FAMILY}]: ").strip() or DEFAULT_FAMILY
|
|
old_kern_input = input(" Export with old-style kerning? [y/N]: ").strip().lower()
|
|
old_kern = old_kern_input in ("y", "yes")
|
|
|
|
print()
|
|
print(f" Family: {family}")
|
|
print(f" Old kern: {'yes' if old_kern 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, old_kern=old_kern)
|
|
finally:
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
|
|
|
|
def _build(tmp_dir, family=DEFAULT_FAMILY, old_kern=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 = load_script_as_function(os.path.join(SCRIPTS_DIR, "scale.py"))
|
|
condense_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "condense.py"))
|
|
overlap_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "overlaps.py"))
|
|
|
|
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}")
|
|
|
|
script = build_per_font_script(ttf_path, sfd_path, [
|
|
("Scaling Y", scale_code),
|
|
("Condensing X", condense_code),
|
|
("Removing overlaps", overlap_code),
|
|
])
|
|
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 = load_script_as_function(os.path.join(SCRIPTS_DIR, "metrics.py"))
|
|
lineheight_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "lineheight.py"))
|
|
rename_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "rename.py"))
|
|
version_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "version.py"))
|
|
license_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "license.py"))
|
|
|
|
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_SFD_DIR, exist_ok=True)
|
|
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")
|
|
|
|
# Copy final SFD to out/sfd/
|
|
shutil.copy2(sfd_path, os.path.join(OUT_SFD_DIR, f"{name}.sfd"))
|
|
print(f" -> {OUT_SFD_DIR}/{name}.sfd")
|
|
|
|
# Export TTF
|
|
script = build_export_script(sfd_path, ttf_path, old_kern=old_kern)
|
|
run_fontforge_script(script)
|
|
|
|
print("\n" + "=" * 60)
|
|
print(" Build complete!")
|
|
print(f" SFD fonts are in: {OUT_SFD_DIR}/")
|
|
print(f" TTF fonts are in: {OUT_TTF_DIR}/")
|
|
print("=" * 60)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|