From d2b5ce6fa9f249e3cf87dff5f26611f67418b902 Mon Sep 17 00:00:00 2001 From: Nico Verbruggen Date: Mon, 2 Mar 2026 01:14:23 +0100 Subject: [PATCH] Add scripts --- script.py => metrics.py | 0 xheight.py | 292 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+) rename script.py => metrics.py (100%) create mode 100644 xheight.py diff --git a/script.py b/metrics.py similarity index 100% rename from script.py rename to metrics.py diff --git a/xheight.py b/xheight.py new file mode 100644 index 0000000..031cac7 --- /dev/null +++ b/xheight.py @@ -0,0 +1,292 @@ +""" +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 (0–1) 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 + +for g in targets: + gname = g.glyphname + + # ── 1. 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() + + # ── 3. Uniform scale from origin (0, baseline) ────────────────────────── + g.transform(mat) + + # ── 4. Stroke-weight compensation ──────────────────────────────────────── + if weight_delta != 0: + try: + g.changeWeight(weight_delta, "auto", "auto") + g.correctDirection() + weight_ok += 1 + except Exception as e: + weight_err += 1 + errors.append((gname, str(e))) + + # ── 5. 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)) + +print(f" Scaled {len(targets)} glyphs.") +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.")