Use variable fonts as source of truth, tweaks
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
out
|
out
|
||||||
src_processed
|
src_processed
|
||||||
|
sample
|
||||||
17
README.md
17
README.md
@@ -10,12 +10,13 @@ To accomplish this, I wanted to start from the 9pt font, which I exported. Then,
|
|||||||
|
|
||||||
## Project structure
|
## 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
|
- `./scripts`: FontForge Python scripts applied during the build
|
||||||
- `scale.py`: scales lowercase glyphs vertically to increase x-height
|
- `scale.py`: scales lowercase glyphs vertically to increase x-height
|
||||||
- `metrics.py`: sets vertical metrics (OS/2 Typo, Win, hhea)
|
- `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
|
- `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)
|
- `./out`: final TTF fonts (generated)
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
@@ -24,9 +25,15 @@ To accomplish this, I wanted to start from the 9pt font, which I exported. Then,
|
|||||||
python3 build.py
|
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`)
|
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`
|
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
113
build.py
@@ -4,9 +4,9 @@ Readerly Build Script
|
|||||||
─────────────────────
|
─────────────────────
|
||||||
Orchestrates the full font build pipeline:
|
Orchestrates the full font build pipeline:
|
||||||
|
|
||||||
1. Copies ./src/*.sfd → ./src_processed/
|
1. Instances variable fonts into static TTFs (fontTools.instancer)
|
||||||
2. Applies vertical scale (scale.py)
|
2. Applies vertical scale (scale.py) via FontForge
|
||||||
3. Applies vertical metrics (metrics.py)
|
3. Applies vertical metrics, line height, rename (metrics.py, lineheight.py, rename.py)
|
||||||
4. Exports to TTF with old-style kern table → ./out/
|
4. Exports to TTF with old-style kern table → ./out/
|
||||||
|
|
||||||
Uses the Flatpak version of FontForge.
|
Uses the Flatpak version of FontForge.
|
||||||
@@ -31,6 +31,21 @@ SCRIPTS_DIR = os.path.join(ROOT_DIR, "scripts")
|
|||||||
|
|
||||||
FLATPAK_APP = "org.fontforge.FontForge"
|
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
|
# HELPERS
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
@@ -63,24 +78,25 @@ def run_fontforge_script(script_text):
|
|||||||
sys.exit(1)
|
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
|
Build a FontForge Python script that opens a font file, runs the given
|
||||||
step scripts (which expect `f` to be the active font), saves, and closes.
|
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`
|
Each step is a (label, script_body) tuple. The script_body should use `f`
|
||||||
as the font variable.
|
as the font variable.
|
||||||
"""
|
"""
|
||||||
parts = [
|
parts = [
|
||||||
f'import fontforge',
|
f'import fontforge',
|
||||||
f'f = fontforge.open({sfd_path!r})',
|
f'f = fontforge.open({open_path!r})',
|
||||||
f'print("\\nOpened: " + f.fontname + "\\n")',
|
f'print("\\nOpened: " + f.fontname + "\\n")',
|
||||||
]
|
]
|
||||||
for label, body in steps:
|
for label, body in steps:
|
||||||
parts.append(f'print("── {label} ──\\n")')
|
parts.append(f'print("── {label} ──\\n")')
|
||||||
parts.append(body)
|
parts.append(body)
|
||||||
parts.append(f'f.save({sfd_path!r})')
|
parts.append(f'f.save({save_path!r})')
|
||||||
parts.append(f'print("\\nSaved: {sfd_path}\\n")')
|
parts.append(f'print("\\nSaved: {save_path}\\n")')
|
||||||
parts.append('f.close()')
|
parts.append('f.close()')
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
@@ -123,43 +139,77 @@ def main():
|
|||||||
print(" Readerly Build")
|
print(" Readerly Build")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
# Step 1: Copy src → src_processed
|
# Step 1: Instance variable fonts into static TTFs
|
||||||
print("\n── Step 1: Copy sources to ./src_processed ──\n")
|
print("\n── Step 1: Instance variable fonts ──\n")
|
||||||
if os.path.exists(MUTATED_DIR):
|
if os.path.exists(MUTATED_DIR):
|
||||||
shutil.rmtree(MUTATED_DIR)
|
shutil.rmtree(MUTATED_DIR)
|
||||||
shutil.copytree(SRC_DIR, MUTATED_DIR)
|
os.makedirs(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.")
|
|
||||||
|
|
||||||
# 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")
|
print("\n── Step 2: Scale lowercase ──\n")
|
||||||
|
|
||||||
scale_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "scale.py"))
|
scale_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "scale.py"))
|
||||||
|
|
||||||
for sfd_name in sfd_files:
|
clear_code = "\n".join(
|
||||||
sfd_path = os.path.join(MUTATED_DIR, sfd_name)
|
f'if {g!r} in f:\n'
|
||||||
print(f"Scaling: {sfd_name}")
|
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),
|
("Scaling Y", scale_code),
|
||||||
])
|
])
|
||||||
run_fontforge_script(script)
|
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")
|
print("\n── Step 3: Apply metrics and rename ──\n")
|
||||||
|
|
||||||
metrics_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "metrics.py"))
|
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"))
|
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:
|
for name in variant_names:
|
||||||
sfd_path = os.path.join(MUTATED_DIR, sfd_name)
|
sfd_path = os.path.join(MUTATED_DIR, f"{name}.sfd")
|
||||||
print(f"Processing: {sfd_name}")
|
print(f"Processing: {name}")
|
||||||
print("-" * 40)
|
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),
|
("Setting vertical metrics", metrics_code),
|
||||||
|
("Adjusting line height", lineheight_code),
|
||||||
|
("Setting fontname for rename", set_fontname),
|
||||||
("Updating font names", rename_code),
|
("Updating font names", rename_code),
|
||||||
])
|
])
|
||||||
run_fontforge_script(script)
|
run_fontforge_script(script)
|
||||||
@@ -168,10 +218,9 @@ def main():
|
|||||||
print("\n── Step 4: Export to TTF ──\n")
|
print("\n── Step 4: Export to TTF ──\n")
|
||||||
os.makedirs(OUT_DIR, exist_ok=True)
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
for sfd_name in sfd_files:
|
for name in variant_names:
|
||||||
sfd_path = os.path.join(MUTATED_DIR, sfd_name)
|
sfd_path = os.path.join(MUTATED_DIR, f"{name}.sfd")
|
||||||
ttf_name = sfd_name.replace(".sfd", ".ttf")
|
ttf_path = os.path.join(OUT_DIR, f"{name}.ttf")
|
||||||
ttf_path = os.path.join(OUT_DIR, ttf_name)
|
|
||||||
|
|
||||||
script = build_export_script(sfd_path, ttf_path)
|
script = build_export_script(sfd_path, ttf_path)
|
||||||
run_fontforge_script(script)
|
run_fontforge_script(script)
|
||||||
|
|||||||
58
scripts/lineheight.py
Normal file
58
scripts/lineheight.py
Normal 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.")
|
||||||
@@ -19,13 +19,6 @@ f = fontforge.activeFont()
|
|||||||
# CONFIGURATION
|
# 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.
|
# Extra padding on Win and hhea metrics, as a fraction of UPM.
|
||||||
# Prevents clipping of glyphs that sit right at the bounding-box edge.
|
# Prevents clipping of glyphs that sit right at the bounding-box edge.
|
||||||
# 0.0 = trust the bounding boxes exactly
|
# 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")
|
asc_h, asc_c = measure_chars("bdfhkl", axis="top")
|
||||||
xht_h, xht_c = measure_chars("xzouv", axis="top")
|
xht_h, xht_c = measure_chars("xzouv", axis="top")
|
||||||
dsc_h, dsc_c = measure_chars("gpqyj", axis="bottom")
|
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),
|
for label, val, ch in [("Cap height", cap_h, cap_c),
|
||||||
("Ascender", asc_h, asc_c),
|
("Ascender", asc_h, asc_c),
|
||||||
|
("Accent top", acc_h, acc_c),
|
||||||
("x-height", xht_h, xht_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:
|
if val is not None:
|
||||||
print(f" {label:12s} {int(val):>6} ('{ch}')")
|
print(f" {label:12s} {int(val):>6} ('{ch}')")
|
||||||
else:
|
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
|
# lineGap absorbs all extra leading. This keeps the text vertically
|
||||||
# centred on the line, which matters for UI / web layout.
|
# centred on the line, which matters for UI / web layout.
|
||||||
|
|
||||||
desired_lh = int(round(upm * LINE_HEIGHT))
|
typo_ascender = int(round(design_top))
|
||||||
ink_ascender = int(round(design_top))
|
typo_descender = int(round(design_bot)) # negative
|
||||||
ink_descender = int(round(design_bot)) # negative
|
typo_extent = typo_ascender - typo_descender # total ink span (positive)
|
||||||
ink_extent = ink_ascender - ink_descender # total ink span (positive)
|
typo_linegap = 0
|
||||||
|
|
||||||
if ink_extent <= desired_lh:
|
# ── Win / hhea metrics ───────────────────────────────────────────────────────
|
||||||
# Ink fits within desired line height — use ink boundaries, gap absorbs rest
|
# Clipping boundaries. Based on the design ascender/descender with a small
|
||||||
typo_ascender = ink_ascender
|
# margin. Accented capitals and stacked diacritics may clip, but line
|
||||||
typo_descender = ink_descender
|
# height stays tight on all platforms.
|
||||||
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.
|
|
||||||
|
|
||||||
margin = int(math.ceil(upm * CLIP_MARGIN))
|
margin = int(math.ceil(upm * CLIP_MARGIN))
|
||||||
|
|
||||||
win_ascent = int(math.ceil(design_top)) + margin
|
win_ascent = int(math.ceil(design_top)) + margin
|
||||||
win_descent = int(math.ceil(abs(design_bot))) + margin
|
win_descent = int(math.ceil(abs(design_bot))) + margin
|
||||||
|
|
||||||
# ── hhea metrics ──────────────────────────────────────────────────────────────
|
hhea_ascent = win_ascent
|
||||||
# macOS/iOS always uses hhea for *both* line spacing and clipping (it ignores
|
hhea_descent = -win_descent # negative
|
||||||
# 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_linegap = 0
|
hhea_linegap = 0
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
# STEP 4 — Apply to font
|
# STEP 4 — Apply to font
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
# FontForge's own ascent / descent (used for UPM split in the head table)
|
# Keep f.ascent / f.descent at their current values — they define UPM
|
||||||
f.ascent = typo_ascender
|
# (f.ascent + f.descent = f.em) and must not be changed.
|
||||||
f.descent = abs(typo_descender)
|
|
||||||
|
|
||||||
# OS/2 table
|
# OS/2 table
|
||||||
f.os2_typoascent = typo_ascender
|
f.os2_typoascent = typo_ascender
|
||||||
|
|||||||
@@ -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 (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
|
|
||||||
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.")
|
|
||||||
BIN
src/Newsreader-Italic-VariableFont_opsz,wght.ttf
Normal file
BIN
src/Newsreader-Italic-VariableFont_opsz,wght.ttf
Normal file
Binary file not shown.
BIN
src/Newsreader-VariableFont_opsz,wght.ttf
Normal file
BIN
src/Newsreader-VariableFont_opsz,wght.ttf
Normal file
Binary file not shown.
16245
src/Readerly-Bold.sfd
16245
src/Readerly-Bold.sfd
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
16806
src/Readerly-Italic.sfd
16806
src/Readerly-Italic.sfd
File diff suppressed because one or more lines are too long
16232
src/Readerly-Regular.sfd
16232
src/Readerly-Regular.sfd
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user