239 lines
12 KiB
Python
239 lines
12 KiB
Python
"""
|
||
FontForge: Set Vertical Metrics
|
||
───────────────────────────────
|
||
Follows the Google Fonts vertical-metrics methodology:
|
||
|
||
• OS/2 Typo → controls line spacing (with USE_TYPO_METRICS / fsSelection bit 7)
|
||
• OS/2 Win → clipping boundary on Windows (must cover every glyph)
|
||
• hhea → line spacing + clipping on macOS/iOS
|
||
|
||
Run inside FontForge (File → Execute Script → paste, or fontforge -script).
|
||
"""
|
||
|
||
import fontforge
|
||
import math
|
||
|
||
f = fontforge.activeFont()
|
||
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
# CONFIGURATION
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
# Desired line height as a multiple of UPM.
|
||
# 1.0 = no extra leading (glyphs may touch between lines)
|
||
# 1.2 = 120% — a solid default for body text
|
||
# 1.25 = matches the CSS default for most browsers
|
||
# 1.5 = generous (double-spaced feel)
|
||
LINE_HEIGHT = 1.0
|
||
|
||
# Extra padding on Win and hhea metrics, as a fraction of UPM.
|
||
# Prevents clipping of glyphs that sit right at the bounding-box edge.
|
||
# 0.0 = trust the bounding boxes exactly
|
||
# 0.01 = 1% pad (conservative)
|
||
# 0.02 = 2% pad (safe for hinting artefacts / composites)
|
||
CLIP_MARGIN = 0.01
|
||
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
# 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): # skip empty glyphs (space, CR, …)
|
||
return bb
|
||
return None
|
||
|
||
|
||
def measure_chars(chars, *, axis="top"):
|
||
"""
|
||
Measure a set of reference characters.
|
||
axis="top" → return the highest yMax (ascenders, cap height)
|
||
axis="bottom" → return the lowest yMin (descenders)
|
||
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 — Compute the three metric sets
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
upm = f.em
|
||
|
||
# Design top = tallest Latin ascender (fall back to cap height)
|
||
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.)."
|
||
)
|
||
|
||
# ── OS/2 Typo metrics ────────────────────────────────────────────────────────
|
||
# These define line spacing when USE_TYPO_METRICS is on.
|
||
# Strategy: ascender and descender sit at the design's actual ink boundaries;
|
||
# lineGap absorbs all extra leading. This keeps the text vertically
|
||
# centred on the line, which matters for UI / web layout.
|
||
|
||
typo_ascender = int(round(design_top))
|
||
typo_descender = int(round(design_bot)) # negative
|
||
typo_extent = typo_ascender - typo_descender # total ink span (positive)
|
||
desired_lh = int(round(upm * LINE_HEIGHT))
|
||
typo_linegap = max(0, desired_lh - typo_extent)
|
||
|
||
# ── OS/2 Win metrics ─────────────────────────────────────────────────────────
|
||
# Clipping boundaries on Windows. Must cover every glyph or Windows clips them.
|
||
# usWinDescent is a *positive* distance below the baseline (unlike Typo/hhea).
|
||
|
||
margin = int(math.ceil(upm * CLIP_MARGIN))
|
||
win_ascent = int(math.ceil(max(font_ymax, design_top))) + margin
|
||
win_descent = int(math.ceil(max(abs(font_ymin), abs(design_bot)))) + margin
|
||
|
||
# ── hhea metrics ──────────────────────────────────────────────────────────────
|
||
# macOS/iOS always uses hhea for *both* line spacing and clipping (it ignores
|
||
# USE_TYPO_METRICS). To keep line height consistent across platforms, we fold
|
||
# the Typo lineGap into hhea ascent/descent so hhea_lineGap can be 0.
|
||
# Then we take the max with the font bbox to also prevent Mac clipping.
|
||
|
||
half_gap = typo_linegap // 2
|
||
extra = typo_linegap - 2 * half_gap # +1 rounding remainder → ascent side
|
||
|
||
spacing_asc = typo_ascender + half_gap + extra
|
||
spacing_dsc = typo_descender - half_gap # more negative
|
||
|
||
hhea_ascent = max(spacing_asc, int(math.ceil(font_ymax)) + margin)
|
||
hhea_descent = min(spacing_dsc, int(math.floor(font_ymin)) - margin) # negative
|
||
hhea_linegap = 0
|
||
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
# STEP 4 — Apply to font
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
# FontForge's own ascent / descent (used for UPM split in the head table)
|
||
f.ascent = typo_ascender
|
||
f.descent = abs(typo_descender)
|
||
|
||
# OS/2 table
|
||
f.os2_typoascent = typo_ascender
|
||
f.os2_typodescent = typo_descender
|
||
f.os2_typolinegap = typo_linegap
|
||
f.os2_winascent = win_ascent
|
||
f.os2_windescent = win_descent
|
||
|
||
# hhea table
|
||
f.hhea_ascent = hhea_ascent
|
||
f.hhea_descent = hhea_descent
|
||
f.hhea_linegap = hhea_linegap
|
||
|
||
# USE_TYPO_METRICS — fsSelection bit 7
|
||
# FontForge exposes this differently across versions. We try three known paths.
|
||
typo_metrics_set = False
|
||
|
||
# Method 1: dedicated boolean (FontForge ≥ 2020-ish)
|
||
if hasattr(f, "os2_use_typo_metrics"):
|
||
f.os2_use_typo_metrics = True
|
||
typo_metrics_set = True
|
||
|
||
# Method 2: direct fsSelection manipulation (if exposed)
|
||
if not typo_metrics_set and hasattr(f, "os2_fsselection"):
|
||
f.os2_fsselection |= (1 << 7)
|
||
typo_metrics_set = True
|
||
|
||
# Method 3: via the OS/2 version (some builds gate the flag on version ≥ 4)
|
||
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("⚠ 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 + typo_linegap
|
||
hhea_line = hhea_ascent - hhea_descent + hhea_linegap
|
||
win_total = win_ascent + win_descent
|
||
|
||
print("\n─── Applied metrics ───\n")
|
||
print(f" UPM: {upm}\n")
|
||
|
||
print(f" {'':18s} {'Ascender':>9s} {'Descender':>10s} {'LineGap':>8s} {'Total':>6s}")
|
||
print(f" {'─'*18} {'─'*9} {'─'*10} {'─'*8} {'─'*6}")
|
||
print(f" {'OS/2 Typo':18s} {typo_ascender:>9d} {typo_descender:>10d} {typo_linegap:>8d} {typo_line:>6d}")
|
||
print(f" {'hhea':18s} {hhea_ascent:>9d} {hhea_descent:>10d} {hhea_linegap:>8d} {hhea_line:>6d}")
|
||
print(f" {'OS/2 Win':18s} {win_ascent:>9d} {'-'+str(win_descent):>10s} {'n/a':>8s} {win_total:>6d}")
|
||
|
||
print(f"\n Effective line height: {typo_line} ({typo_line/upm:.2f}× UPM)")
|
||
print(f" Design ink span: {typo_extent} ({typo_extent/upm:.2f}× UPM)")
|
||
print(f" Clipping headroom: +{win_ascent - int(round(font_ymax))} above, "
|
||
f"+{win_descent - int(round(abs(font_ymin)))} below")
|
||
|
||
if cap_h is not None:
|
||
print(f"\n Cap height: {int(cap_h)}")
|
||
if xht_h is not None:
|
||
print(f" x-height: {int(xht_h)}")
|
||
|
||
print("\nDone. Review in Font Info → OS/2 and Font Info → General.")
|