1
0

Use variable fonts as source of truth, tweaks

This commit is contained in:
2026-03-02 03:26:15 +01:00
parent 04374e77c5
commit cdcc0942c7
12 changed files with 171 additions and 66491 deletions

58
scripts/lineheight.py Normal file
View File

@@ -0,0 +1,58 @@
"""
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

@@ -19,13 +19,6 @@ 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
@@ -93,11 +86,15 @@ 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")
acc_h, acc_c = measure_chars("\u00c0\u00c1\u00c2\u00c3\u00c4\u00c5\u00c8\u00c9\u00ca\u00cb", axis="top")
acd_h, acd_c = measure_chars("\u00c7\u015e\u0162", axis="bottom")
for label, val, ch in [("Cap height", cap_h, cap_c),
("Ascender", asc_h, asc_c),
("Accent top", acc_h, acc_c),
("x-height", xht_h, xht_c),
("Descender", dsc_h, dsc_c)]:
("Descender", dsc_h, dsc_c),
("Accent bot", acd_h, acd_c)]:
if val is not None:
print(f" {label:12s} {int(val):>6} ('{ch}')")
else:
@@ -135,57 +132,31 @@ if design_top is None or design_bot is None:
# lineGap absorbs all extra leading. This keeps the text vertically
# centred on the line, which matters for UI / web layout.
desired_lh = int(round(upm * LINE_HEIGHT))
ink_ascender = int(round(design_top))
ink_descender = int(round(design_bot)) # negative
ink_extent = ink_ascender - ink_descender # total ink span (positive)
typo_ascender = int(round(design_top))
typo_descender = int(round(design_bot)) # negative
typo_extent = typo_ascender - typo_descender # total ink span (positive)
typo_linegap = 0
if ink_extent <= desired_lh:
# Ink fits within desired line height — use ink boundaries, gap absorbs rest
typo_ascender = ink_ascender
typo_descender = ink_descender
typo_linegap = desired_lh - ink_extent
else:
# Ink exceeds desired line height — cap to UPM, split proportionally
ratio = ink_ascender / ink_extent
typo_ascender = int(round(desired_lh * ratio))
typo_descender = typo_ascender - desired_lh # negative
typo_linegap = 0
typo_extent = typo_ascender - typo_descender
# ── OS/2 Win metrics ─────────────────────────────────────────────────────────
# Clipping boundaries on Windows. Based on the design ascender/descender
# (not the full font bbox, which can be inflated by stacked diacritics like
# Aringacute). A small margin prevents clipping of hinting artefacts.
# ── Win / hhea metrics ───────────────────────────────────────────────────────
# Clipping boundaries. Based on the design ascender/descender with a small
# margin. Accented capitals and stacked diacritics may clip, but line
# height stays tight on all platforms.
margin = int(math.ceil(upm * CLIP_MARGIN))
win_ascent = int(math.ceil(design_top)) + margin
win_descent = int(math.ceil(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.
# Based on design ascender/descender, not the full font bbox.
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(design_top)) + margin)
hhea_descent = min(spacing_dsc, int(math.floor(design_bot)) - margin) # negative
hhea_ascent = win_ascent
hhea_descent = -win_descent # 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)
# Keep f.ascent / f.descent at their current values — they define UPM
# (f.ascent + f.descent = f.em) and must not be changed.
# OS/2 table
f.os2_typoascent = typo_ascender

View File

@@ -1,304 +0,0 @@
"""
FontForge: Adjust x-height / cap-height ratio
═══════════════════════════════════════════════
Scales all lowercase glyphs (including full Latin Extended) to hit a target
x-height ratio, with optional stroke-weight compensation and proportional
sidebearing adjustment.
Run inside FontForge (File → Execute Script → paste, or fontforge -script).
After running:
1. Visually inspect a handful of glyphs (a e g l ö ñ)
2. Re-run set_metrics.py to recalculate vertical metrics
3. Review and regenerate kerning (Metrics → Auto Kern or manual review)
"""
import fontforge
import psMat
import math
import unicodedata
f = fontforge.activeFont()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# CONFIGURATION
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Target x-height / cap-height ratio.
# Bookerly ≈ 0.71, Georgia ≈ 0.70, Times New Roman ≈ 0.65
TARGET_RATIO = 0.71
# Stroke-weight compensation.
# Uniform scaling makes stems thicker by the same factor. This reverses it.
# 1.0 = full compensation (stems restored to original thickness)
# 0.5 = half compensation (split the difference — often looks best)
# 0.0 = no compensation (accept thicker stems)
WEIGHT_COMPENSATION = 0.75
# Sidebearing strategy after scaling.
# "proportional" — bearings scale with the glyph (wider set, correct feel)
# "preserve" — keep original bearings (tighter, may look cramped)
BEARING_MODE = "proportional"
# Safety: preview what would happen without changing anything.
DRY_RUN = False
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# HELPERS
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _measure_top(chars):
"""Return the highest yMax among the given reference characters."""
best = None
for ch in chars:
name = fontforge.nameFromUnicode(ord(ch))
if name in f and f[name].isWorthOutputting():
bb = f[name].boundingBox()
if bb != (0, 0, 0, 0):
y = bb[3]
if best is None or y > best:
best = y
return best
def _measure_stem_width():
"""
Estimate vertical-stem width from lowercase 'l'.
For sans-serif fonts the bbox width of 'l' ≈ the stem.
For serif fonts it includes serifs, so we take ~60% as an estimate.
The WEIGHT_COMPENSATION factor lets the user tune this.
"""
for ch in "li":
name = fontforge.nameFromUnicode(ord(ch))
if name in f and f[name].isWorthOutputting():
bb = f[name].boundingBox()
bbox_w = bb[2] - bb[0]
if bbox_w > 0:
return bbox_w
return None
def _is_lowercase_glyph(glyph):
"""
Return True if this glyph should be treated as lowercase.
Covers:
• Unicode category Ll (Letter, lowercase) — a-z, à, é, ñ, ø, ß, …
• A few special cases that live at x-height but aren't Ll
"""
if glyph.unicode < 0:
return False
try:
cat = unicodedata.category(chr(glyph.unicode))
except (ValueError, OverflowError):
return False
if cat == "Ll":
return True
# Catch x-height symbols that should scale with lowercase:
# ª (U+00AA, Lo) — feminine ordinal indicator
# º (U+00BA, Lo) — masculine ordinal indicator
if glyph.unicode in (0x00AA, 0x00BA):
return True
return False
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# STEP 1 — Measure the font
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
print("─── Measuring ───\n")
cap_height = _measure_top("HIOXE")
x_height = _measure_top("xzouv")
if cap_height is None or x_height is None:
raise SystemExit(
"ERROR: Cannot measure cap height or x-height.\n"
" Make sure the font has basic Latin glyphs (H, x, etc.)."
)
current_ratio = x_height / cap_height
scale_factor = (TARGET_RATIO * cap_height) / x_height
print(f" Cap height: {int(cap_height)}")
print(f" x-height: {int(x_height)}")
print(f" Current ratio: {current_ratio:.4f}")
print(f" Target ratio: {TARGET_RATIO}")
print(f" Scale factor: {scale_factor:.4f} ({(scale_factor - 1) * 100:+.1f}%)")
if abs(scale_factor - 1.0) < 0.005:
raise SystemExit("\nFont is already at (or very near) the target ratio. Nothing to do.")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# STEP 2 — Stem-width measurement (for weight compensation)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
stem_bbox_w = _measure_stem_width()
weight_delta = 0
if WEIGHT_COMPENSATION > 0 and stem_bbox_w:
# For a serif font the bbox includes serifs; the true stem is narrower.
# We use 55% of bbox width as a rough stem estimate. The WEIGHT_COMPENSATION
# factor (01) provides further control.
estimated_stem = stem_bbox_w * 0.55
raw_thickening = estimated_stem * (scale_factor - 1.0)
weight_delta = -(raw_thickening * WEIGHT_COMPENSATION)
print(f"\n Stem bbox ('l'): {stem_bbox_w:.0f}")
print(f" Est. stem width: {estimated_stem:.0f}")
print(f" Weight delta: {weight_delta:.1f} (compensation = {WEIGHT_COMPENSATION:.0%})")
elif WEIGHT_COMPENSATION > 0:
print("\n ⚠ Could not measure stem width — skipping weight compensation.")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# STEP 3 — Collect target glyphs
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
targets = []
for g in f.glyphs():
if g.isWorthOutputting() and _is_lowercase_glyph(g):
targets.append(g)
targets.sort(key=lambda g: g.unicode)
print(f"\n─── Target glyphs: {len(targets)} ───\n")
# Show a readable sample
sample = [g for g in targets if g.unicode < 0x0180] # Basic Latin + Supplement
if sample:
line = " "
for g in sample:
line += chr(g.unicode)
if len(line) > 76:
print(line)
line = " "
if line.strip():
print(line)
extended = len(targets) - len(sample)
if extended > 0:
print(f" … plus {extended} extended glyphs (Latin Extended, Cyrillic, etc.)")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# STEP 4 — Apply transforms
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
if DRY_RUN:
print("\n ★ DRY RUN — no changes made. Set DRY_RUN = False to apply.\n")
raise SystemExit(0)
print(f"\n─── Applying (scale ×{scale_factor:.4f}) ───\n")
mat = psMat.scale(scale_factor)
errors = []
weight_ok = 0
weight_err = 0
skipped_composites = 0
for g in targets:
gname = g.glyphname
# ── 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
orig_bb = g.boundingBox()
# ── 3. Uniform scale from origin (0, baseline) ──────────────────────────
g.transform(mat)
# ── 4. Stroke-weight compensation ────────────────────────────────────────
if weight_delta != 0:
try:
g.changeWeight(weight_delta)
g.correctDirection()
weight_ok += 1
except Exception as e:
weight_err += 1
errors.append((gname, str(e)))
# ── 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))
g.right_side_bearing = int(round(orig_rsb * scale_factor))
else:
# Restore original bearings → glyph is same width, just taller.
g.left_side_bearing = int(round(orig_lsb))
g.right_side_bearing = int(round(orig_rsb))
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:
print(f"\n Glyphs with changeWeight errors (review manually):")
for nm, err in errors[:20]:
print(f" {nm}: {err}")
if len(errors) > 20:
print(f" … and {len(errors) - 20} more.")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# STEP 5 — Verify & update OS/2 fields
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
new_xh = _measure_top("xzouv")
new_ratio = new_xh / cap_height if new_xh else None
# Update the OS/2 sxHeight field (informational, used by some renderers)
if hasattr(f, "os2_xheight") and new_xh:
f.os2_xheight = int(round(new_xh))
# If the font records cap height in OS/2 sCapHeight, keep it consistent
if hasattr(f, "os2_capheight") and cap_height:
f.os2_capheight = int(round(cap_height))
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# STEP 6 — Report
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
print("\n─── Results ───\n")
print(f" Cap height: {int(cap_height)} (unchanged)")
print(f" Old x-height: {int(x_height)}")
print(f" New x-height: {int(new_xh) if new_xh else 'N/A'}")
print(f" Old ratio: {current_ratio:.4f}")
print(f" New ratio: {new_ratio:.4f}" if new_ratio else " New ratio: N/A")
print(f" Target was: {TARGET_RATIO}")
# Check how ascenders shifted
asc_h = _measure_top("bdfhkl")
if asc_h:
over = asc_h - cap_height
if over > 2:
print(f"\n Ascenders now sit at {int(asc_h)}, which is {int(over)} units above cap height.")
print(f" This is normal and common in many typefaces.")
else:
print(f"\n Ascenders at {int(asc_h)} (≈ cap height).")
print("\n─── Next steps ───\n")
print(" 1. Inspect glyphs: a e g l o ö ñ ß — look for weight/shape issues")
print(" 2. Run set_metrics.py to recalculate vertical metrics")
print(" 3. Regenerate kerning (Metrics → Auto Kern, or review manually)")
print(" 4. If weight looks off, adjust WEIGHT_COMPENSATION and re-run")
print(" (Ctrl+Z to undo all changes first)\n")
print("Done.")