1
0

Unify script, fix incorrect x-height

- The script has been unified and documentation has been added
- The x-height was previously incorrectly calculated; caps and ascender
  are now also correct
- Added instructions to set up Mac environment
- Bumped version number
This commit is contained in:
2026-03-05 20:47:45 +01:00
parent 34014562a0
commit 641c89bc1a
12 changed files with 379 additions and 613 deletions

View File

@@ -15,7 +15,6 @@ To get to the final result, I decided to use the variable font and work on it. T
## Project structure
- `src`: Newsreader variable font TTFs
- `scripts`: FontForge Python scripts applied during the build
- `build.py`: The build script to generate Readerly
- `LICENSE`: The OFL license
- `COPYRIGHT`: Copyright information, later embedded in font
@@ -40,9 +39,15 @@ flatpak install flathub org.fontforge.FontForge
```
### macOS preparation
```
On macOS, if you're using the built-in version of Python (via Xcode), you may need to first add a folder to your `PATH` to make `font-line` available, like:
```bash
echo 'export PATH="$HOME/Library/Python/3.9/bin:$PATH"' >> ~/.zshrc
brew install fontforge
python3 -m pip install --user -U fonttools
brew unlink python3 # ensure that python3 isn't linked via Homebrew
pip3 install fonttools font-line
source ~/.zshrc
```
## Building
@@ -57,44 +62,4 @@ To customize the font family name, disable old-style kerning, or skip outline fi
python3 build.py --customize
```
The build script (`build.py`) uses `fontTools` and FontForge to transform the Newsreader variable fonts into Readerly. Each step is described below.
#### Step 1: Instancing
The Newsreader variable font supports two axes: optical size (`opsz`) and weight (`wght`). Using `fontTools.instancer`, the variable fonts are pinned to specific axis values to produce static TTFs. A small optical size (`opsz=9`) is used as the starting point because it produces tighter, more compact letterforms that resemble Bookerly's proportions.
Variant configuration (in `build.py`):
- Regular: wght=450, opsz=9
- Bold: wght=650, opsz=9
- Italic: wght=450, opsz=9
- BoldItalic: wght=650, opsz=9
#### Step 2: Scaling, condensing, and overlap removal
Three transforms are applied in sequence via FontForge:
- **Vertical scaling** (`scale.py`): Lowercase glyphs are scaled up vertically (and slightly horizontally) to increase the x-height, bringing it closer to Bookerly's proportions.
- **Horizontal condensing** (`condense.py`): All glyphs are narrowed slightly to match Bookerly's more compact character widths.
- **Overlap removal** (`overlaps.py`): Overlapping contours are merged into clean, unified outlines and winding direction is corrected. Variable fonts commonly use overlapping paths to aid interpolation between weights. After instancing, these overlaps remain. While desktop renderers handle this fine, e-readers like Kobo apply synthetic font weight scaling that can cause visible artifacts (gaps, blobs, uneven strokes) when contours overlap. Merging the overlaps into single paths prevents these rendering issues.
#### Step 3: Metrics, naming, version, and copyright
Several metadata scripts are applied via FontForge:
- **Vertical metrics** (`metrics.py`): Measures design landmarks (cap height, ascender, x-height, descender) from actual glyph bounding boxes and sets OS/2 Typo metrics to the ink boundaries. Enables `USE_TYPO_METRICS`.
- **Line height** (`lineheight.py`): Overrides Win/hhea metrics to control line spacing and selection box height. Values are expressed as multiples of the font's UPM (units per em) — the coordinate grid that all glyph measurements are defined in (Newsreader uses 2000 UPM). A line height of 1.0x UPM means lines are spaced exactly one em apart, with an 80/20 ascender/descender split. The selection box height (1.32x UPM) controls the highlighted area when selecting text.
- **Renaming** (`rename.py`): Rewrites all SFNT name table entries from Newsreader to Readerly, and sets the correct PS weight string and OS/2 weight class for each variant.
- **Version** (`version.py`): Sets the font version and `head.fontRevision` from `./VERSION`.
- **Copyright** (`license.py`): Sets the copyright notice from `./COPYRIGHT`.
#### Step 4: Export
The final fonts are exported from FontForge as TTF. Outline fixes remove overlaps and zero-area contours that can cause missing glyphs on macOS; you can disable them via `--customize`. The build supports optional old-style kern tables, but this is off by default because it has no effect on device tests. As a final post-export step, `build.py` normalizes the OS/2 style flags and `head.macStyle` with fontTools so Bold/Italic variants link correctly on Kobo.
#### TTF cleanup (manual exports)
Some FontForge exports emit 12 point contours in the `glyf` table. macOS can treat these as invalid and skip the glyph entirely (for example, `m` or italic `u`). The build pipeline removes these zero-area contours automatically. If you manually export a TTF from an SFD and see missing glyphs, run:
```
python3 cleanup_ttf.py out/ttf/Readerly-Regular.ttf
```
The build script (`build.py`) uses `fontTools` and FontForge to transform the Newsreader variable fonts into Readerly. Configuration and step-by-step details live in the header comments of `build.py`.

View File

@@ -1 +1 @@
1.1
1.2

391
build.py
View File

@@ -22,13 +22,26 @@ import textwrap
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# CONFIGURATION
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#
# Most of these values are safe to tweak. The --customize flag only toggles
# a small subset at runtime (family name, old-style kerning, 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
# - 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_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")
OUT_SFD_DIR = os.path.join(OUT_DIR, "sfd") # generated FontForge sources
OUT_TTF_DIR = os.path.join(OUT_DIR, "ttf") # generated TTFs
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")
@@ -39,16 +52,49 @@ with open(os.path.join(ROOT_DIR, "VERSION")) as _vf:
with open(os.path.join(ROOT_DIR, "COPYRIGHT")) as _cf:
COPYRIGHT_TEXT = _cf.read().strip()
DEFAULT_FAMILY = "Readerly"
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 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
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -146,16 +192,317 @@ def build_per_font_script(open_path, save_path, steps):
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 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, old_kern=True):
@@ -349,9 +696,9 @@ def _build(tmp_dir, family=DEFAULT_FAMILY, old_kern=True, outline_fix=True):
# 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"))
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")
@@ -370,11 +717,11 @@ def _build(tmp_dir, family=DEFAULT_FAMILY, old_kern=True, outline_fix=True):
# 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"))
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")

View File

@@ -1,83 +0,0 @@
#!/usr/bin/env python3
"""
Remove zero-area contours from a TTF.
Some FontForge exports emit 12 point contours that macOS can treat as
invalid and skip the glyph entirely. This script removes those contours
in-place.
"""
import sys
def clean_ttf_degenerate_contours(ttf_path):
try:
from fontTools.ttLib import TTFont
from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates
except Exception as exc:
raise SystemExit(f"ERROR: fontTools is required ({exc})")
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)
font.close()
return removed_total
def main():
if len(sys.argv) != 2:
raise SystemExit("Usage: python3 cleanup_ttf.py path/to/font.ttf")
ttf_path = sys.argv[1]
removed = clean_ttf_degenerate_contours(ttf_path)
print(f"Cleaned {removed} zero-area contour(s): {ttf_path}")
if __name__ == "__main__":
main()

View File

@@ -1,31 +0,0 @@
"""
FontForge: Condense all glyphs horizontally
────────────────────────────────────────────
Applies a horizontal scale to all glyphs, reducing set width.
Run inside FontForge (or via build.py which sets `f` before running this).
"""
import fontforge
import psMat
f = fontforge.activeFont()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# CONFIGURATION
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SCALE_X = 0.95
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# APPLY
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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%}")
print("Done.")

View File

@@ -1,21 +0,0 @@
"""
FontForge: Set copyright information
─────────────────────────────────────
Sets the copyright notice from COPYRIGHT_TEXT injected by build.py.
Run inside FontForge (or via build.py which sets `f` and `COPYRIGHT_TEXT` before running this).
"""
import fontforge
f = fontforge.activeFont()
# 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]}")
print("Done.")

View File

@@ -1,58 +0,0 @@
"""
FontForge: Adjust line height
──────────────────────────────
Sets vertical metrics to control line spacing and selection box height.
- Typo: controls line spacing (via USE_TYPO_METRICS)
- Win/hhea: controls selection box height and clipping
Run inside FontForge (or via build.py which sets `f` before running this).
"""
import fontforge
f = fontforge.activeFont()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# CONFIGURATION
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Line height (Typo) as a multiple of UPM.
LINE_HEIGHT = 1.0
# Selection box height (Win/hhea) as a multiple of UPM.
SELECTION_HEIGHT = 1.32
# Ascender share of the line/selection height.
ASCENDER_RATIO = 0.80
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# APPLY
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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}")
print("Done.")

View File

@@ -1,161 +0,0 @@
"""
FontForge: Set Vertical Metrics
───────────────────────────────
Measures design landmarks, sets OS/2 Typo metrics to the ink boundaries,
and enables USE_TYPO_METRICS. Win/hhea are set to initial values here
but will be overridden by lineheight.py.
Run inside FontForge (File → Execute Script → paste, or fontforge -script).
"""
import fontforge
f = fontforge.activeFont()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# HELPERS
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# STEP 1 — Measure design landmarks
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
print("─── Design landmarks ───\n")
cap_h, cap_c = measure_chars("HIOXE", axis="top")
asc_h, asc_c = measure_chars("bdfhkl", axis="top")
xht_h, xht_c = measure_chars("xzouv", 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}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# STEP 2 — Full-font bounding-box scan
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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})")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# STEP 3 — Set OS/2 Typo to ink boundaries
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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
# Win/hhea set to same initial values; lineheight.py 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
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# STEP 4 — USE_TYPO_METRICS (fsSelection bit 7)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# STEP 5 — Report
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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)}")
print("\nDone.")

View File

@@ -1,28 +0,0 @@
"""
FontForge: Remove overlapping contours
───────────────────────────────────────
Merges overlapping contours into clean outlines for all glyphs.
Also corrects winding direction, which can get flipped after overlap removal.
This fixes rendering issues on devices (e.g. Kobo) that struggle with
overlapping paths, especially when applying synthetic bold/weight scaling.
Run inside FontForge (or via build.py which sets `f` before running this).
"""
import fontforge
f = fontforge.activeFont()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# APPLY
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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")
print("Done.")

View File

@@ -1,87 +0,0 @@
"""
FontForge: Update font name metadata
─────────────────────────────────────
Replaces Newsreader references with the target family name in all name table
entries and font-level properties.
FAMILY is injected by build.py before this script runs (defaults to "Readerly").
Run inside FontForge (or via build.py which sets `f` and `FAMILY` before running this).
"""
import fontforge
f = fontforge.activeFont()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# CONFIGURATION
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# FAMILY is injected by build.py; default if run standalone
if "FAMILY" not in dir():
FAMILY = "Readerly"
# Map style suffixes to display names, PS weight strings, and OS/2 weight classes
STYLE_MAP = {
"Regular": ("Regular", "Book", 400),
"Bold": ("Bold", "Bold", 700),
"Italic": ("Italic", "Book", 400),
"BoldItalic": ("Bold Italic", "Bold", 700),
}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# DETECT STYLE
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 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))
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# UPDATE FONT PROPERTIES
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# UPDATE SFNT NAME TABLE
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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}")
print("Done.")

View File

@@ -1,53 +0,0 @@
"""
FontForge: Scale lowercase glyphs vertically
─────────────────────────────────────────────
Applies a vertical scale to lowercase glyphs only, 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
import unicodedata
f = fontforge.activeFont()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# CONFIGURATION
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SCALE_X = 1.03
SCALE_Y = 1.08
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# APPLY
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
mat = psMat.scale(SCALE_X, SCALE_Y)
# Select only lowercase glyphs
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%}")
print("Done.")

View File

@@ -1,24 +0,0 @@
"""
FontForge: Set font version
────────────────────────────
Sets the font version from a VERSION variable injected by build.py.
Run inside FontForge (or via build.py which sets `f` and `VERSION` before running this).
"""
import fontforge
f = fontforge.activeFont()
# VERSION is injected by build.py before this script runs
# e.g. VERSION = "1.0"
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)}")
print("Done.")