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

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
out
src_processed
src_processed
sample

View File

@@ -10,12 +10,13 @@ To accomplish this, I wanted to start from the 9pt font, which I exported. Then,
## Project structure
- `./src`: source .sfd font files (Newsreader 9pt, renamed to Readerly)
- `./src`: Newsreader variable font TTFs (source of truth)
- `./scripts`: FontForge Python scripts applied during the build
- `scale.py`: scales lowercase glyphs vertically to increase x-height
- `metrics.py`: sets vertical metrics (OS/2 Typo, Win, hhea)
- `lineheight.py`: adjusts OS/2 Typo metrics to control line spacing
- `rename.py`: updates font name metadata from Newsreader to Readerly
- `./src_processed`: intermediate .sfd files after processing (generated)
- `./src_processed`: intermediate files after instancing/processing (generated)
- `./out`: final TTF fonts (generated)
## Building
@@ -24,9 +25,15 @@ To accomplish this, I wanted to start from the 9pt font, which I exported. Then,
python3 build.py
```
This uses the Flatpak version of FontForge to:
This uses `fontTools.instancer` and the Flatpak version of FontForge to:
1. Copy `./src` to `./src_processed`
1. Instance the variable fonts into static TTFs at configured axis values (opsz, wght)
2. Scale lowercase glyphs (configurable in `scripts/scale.py`)
3. Set vertical metrics and update font names
3. Set vertical metrics, adjust line height, and update font names
4. Export to TTF with old-style kerning in `./out`
Variant configuration (in `build.py`):
- Regular: wght=400, opsz=9
- Bold: wght=550, opsz=9
- Italic: wght=400, opsz=9
- BoldItalic: wght=550, opsz=9

113
build.py
View File

@@ -4,9 +4,9 @@ Readerly Build Script
─────────────────────
Orchestrates the full font build pipeline:
1. Copies ./src/*.sfd → ./src_processed/
2. Applies vertical scale (scale.py)
3. Applies vertical metrics (metrics.py)
1. Instances variable fonts into static TTFs (fontTools.instancer)
2. Applies vertical scale (scale.py) via FontForge
3. Applies vertical metrics, line height, rename (metrics.py, lineheight.py, rename.py)
4. Exports to TTF with old-style kern table → ./out/
Uses the Flatpak version of FontForge.
@@ -31,6 +31,21 @@ SCRIPTS_DIR = os.path.join(ROOT_DIR, "scripts")
FLATPAK_APP = "org.fontforge.FontForge"
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")
VARIANTS = [
# (output_name, source_vf, wght, opsz)
("Readerly-Regular", REGULAR_VF, 430, 9),
("Readerly-Bold", REGULAR_VF, 550, 9),
("Readerly-Italic", ITALIC_VF, 430, 9),
("Readerly-BoldItalic", ITALIC_VF, 550, 9),
]
# Glyphs to clear — stacked diacritics that inflate head.yMax far beyond
# the design ascender. Aringacute (Ǻ/ǻ) is the sole outlier at 2268 units.
CLEAR_GLYPHS = ["Aringacute", "aringacute"]
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# HELPERS
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -63,24 +78,25 @@ def run_fontforge_script(script_text):
sys.exit(1)
def build_per_font_script(sfd_path, steps):
def build_per_font_script(open_path, save_path, steps):
"""
Build a FontForge Python script that opens an .sfd file, runs the given
step scripts (which expect `f` to be the active font), saves, and closes.
Build a FontForge Python script that opens a font file, runs the given
step scripts (which expect `f` to be the active font), saves as .sfd,
and closes.
Each step is a (label, script_body) tuple. The script_body should use `f`
as the font variable.
"""
parts = [
f'import fontforge',
f'f = fontforge.open({sfd_path!r})',
f'f = fontforge.open({open_path!r})',
f'print("\\nOpened: " + f.fontname + "\\n")',
]
for label, body in steps:
parts.append(f'print("── {label} ──\\n")')
parts.append(body)
parts.append(f'f.save({sfd_path!r})')
parts.append(f'print("\\nSaved: {sfd_path}\\n")')
parts.append(f'f.save({save_path!r})')
parts.append(f'print("\\nSaved: {save_path}\\n")')
parts.append('f.close()')
return "\n".join(parts)
@@ -123,43 +139,77 @@ def main():
print(" Readerly Build")
print("=" * 60)
# Step 1: Copy src → src_processed
print("\n── Step 1: Copy sources to ./src_processed ──\n")
# Step 1: Instance variable fonts into static TTFs
print("\n── Step 1: Instance variable fonts ──\n")
if os.path.exists(MUTATED_DIR):
shutil.rmtree(MUTATED_DIR)
shutil.copytree(SRC_DIR, MUTATED_DIR)
sfd_files = sorted(f for f in os.listdir(MUTATED_DIR) if f.endswith(".sfd"))
for f in sfd_files:
print(f" Copied: {f}")
print(f" {len(sfd_files)} font(s) ready.")
os.makedirs(MUTATED_DIR)
# Step 2: Apply vertical scale to lowercase glyphs
for name, vf_path, wght, opsz in VARIANTS:
ttf_out = os.path.join(MUTATED_DIR, f"{name}.ttf")
print(f" Instancing {name} (wght={wght}, opsz={opsz})")
cmd = [
sys.executable, "-m", "fontTools", "varLib.instancer",
vf_path,
"-o", ttf_out,
f"wght={wght}",
f"opsz={opsz}",
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.stdout:
print(result.stdout, end="")
if result.returncode != 0:
print(f"\nERROR: instancer failed for {name}", file=sys.stderr)
if result.stderr:
print(result.stderr, file=sys.stderr)
sys.exit(1)
variant_names = [name for name, _, _, _ in VARIANTS]
print(f" {len(VARIANTS)} font(s) instanced.")
# 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"))
for sfd_name in sfd_files:
sfd_path = os.path.join(MUTATED_DIR, sfd_name)
print(f"Scaling: {sfd_name}")
clear_code = "\n".join(
f'if {g!r} in f:\n'
f' f[{g!r}].clear()\n'
f' print(" Cleared: {g}")'
for g in CLEAR_GLYPHS
)
script = build_per_font_script(sfd_path, [
for name in variant_names:
ttf_path = os.path.join(MUTATED_DIR, f"{name}.ttf")
sfd_path = os.path.join(MUTATED_DIR, f"{name}.sfd")
print(f"Scaling: {name}")
script = build_per_font_script(ttf_path, sfd_path, [
("Clearing problematic glyphs", clear_code),
("Scaling Y", scale_code),
])
run_fontforge_script(script)
# Step 3: Apply metrics and rename
# 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"))
rename_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "rename.py"))
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"))
for sfd_name in sfd_files:
sfd_path = os.path.join(MUTATED_DIR, sfd_name)
print(f"Processing: {sfd_name}")
for name in variant_names:
sfd_path = os.path.join(MUTATED_DIR, f"{name}.sfd")
print(f"Processing: {name}")
print("-" * 40)
script = build_per_font_script(sfd_path, [
# Set fontname so rename.py can detect the correct style suffix
set_fontname = f'f.fontname = {name!r}'
script = build_per_font_script(sfd_path, sfd_path, [
("Setting vertical metrics", metrics_code),
("Adjusting line height", lineheight_code),
("Setting fontname for rename", set_fontname),
("Updating font names", rename_code),
])
run_fontforge_script(script)
@@ -168,10 +218,9 @@ def main():
print("\n── Step 4: Export to TTF ──\n")
os.makedirs(OUT_DIR, exist_ok=True)
for sfd_name in sfd_files:
sfd_path = os.path.join(MUTATED_DIR, sfd_name)
ttf_name = sfd_name.replace(".sfd", ".ttf")
ttf_path = os.path.join(OUT_DIR, ttf_name)
for name in variant_names:
sfd_path = os.path.join(MUTATED_DIR, f"{name}.sfd")
ttf_path = os.path.join(OUT_DIR, f"{name}.ttf")
script = build_export_script(sfd_path, ttf_path)
run_fontforge_script(script)

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.")

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long