Add scripts
This commit is contained in:
292
xheight.py
Normal file
292
xheight.py
Normal file
@@ -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.")
|
||||
Reference in New Issue
Block a user