1
0

4 Commits

Author SHA1 Message Date
437b102a99 Update documentation 2026-03-30 23:05:15 +02:00
e3b8fea2a5 Update hinting again 2026-03-29 00:21:54 +01:00
83a84b7941 Add fix for "ghosting" serifs 2026-03-28 19:53:32 +01:00
19e915f4e1 Revert to natural hinting w/ control instructions 2026-03-28 18:59:53 +01:00
3 changed files with 137 additions and 15 deletions

View File

@@ -1 +1 @@
1.4
1.5

150
build.py
View File

@@ -5,10 +5,11 @@ Readerly Build Script
Orchestrates the full font build pipeline:
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)
2. Scales, condenses, and cleans up overlaps via FontForge
3. Applies vertical metrics, line height, rename, version, and license via FontForge
4. Exports to TTF → ./out/ttf/
5. Post-processes TTFs: style flags, kern pairs, autohinting
5. Post-processes TTFs: style flags, kern pairs, glyph Y ceilings, autohinting
6. Generates Kobo (KF) variants via kobofix.py → ./out/kf/
Uses FontForge (detected automatically).
Run with: python3 build.py
@@ -91,16 +92,30 @@ ASCENDER_RATIO = 0.8
# Step 4: ttfautohint options (hinting for Kobo's FreeType renderer)
# - Kobo uses FreeType grayscale, so the 1st char of --stem-width-mode
# (gray) is the one that matters. n=natural, q=quantized, s=strong.
# - Remaining two chars are for GDI and DirectWrite (not used on Kobo).
# - Other options are left at ttfautohint defaults; uncomment to override.
# matters: n=natural (least distortion), q=quantized, s=strong.
# - x-height snapping is disabled to avoid inconsistent glyph heights.
AUTOHINT_OPTS = [
"--no-info",
"--stem-width-mode=qss",
# "--hinting-range-min=8",
# "--hinting-range-max=50",
# "--hinting-limit=200",
"--increase-x-height=14",
"--stem-width-mode=nss",
"--increase-x-height=0",
'--x-height-snapping-exceptions=-',
]
# Baseline alignment: deepen the bottom anti-aliasing of non-serifed
# glyphs via hinting-only touch deltas (no outline changes). This
# shifts their bottom points down during rasterization so they produce
# more gray below the baseline, visually matching serifed characters.
# Each entry is (shift_px, ppem_min, ppem_max). Shifts are in pixels
# (multiples of 1/8, max 1.0). Set to empty list to disable.
BASELINE_HINT_SHIFTS = [
(0.125, 6, 53),
]
# Per-glyph Y ceiling: cap the top of specific glyphs to reduce
# oversized or awkward serifs. (glyph_name, max_y)
# Points above max_y are clamped down to max_y.
GLYPH_Y_CEILING = [
("u", 1062), # flatten tiny top serif tips to platform level
]
# Explicit kern pairs: (left_glyph, right_glyph, kern_value_in_units).
@@ -752,6 +767,79 @@ def add_kern_pairs(ttf_path):
print(f" Added {count} kern pair(s) to GPOS")
def apply_glyph_y_ceiling(ttf_path):
"""Clamp glyph points above a Y ceiling down to the ceiling value."""
if not GLYPH_Y_CEILING:
return
from fontTools.ttLib import TTFont
font = TTFont(ttf_path)
glyf = font["glyf"]
modified = []
for glyph_name, max_y in GLYPH_Y_CEILING:
g = glyf.get(glyph_name)
if not g or not g.numberOfContours or g.numberOfContours <= 0:
continue
coords = g.coordinates
clamped = 0
for j in range(len(coords)):
if coords[j][1] > max_y:
coords[j] = (coords[j][0], max_y)
clamped += 1
if clamped:
modified.append(f"{glyph_name}({clamped}pts)")
if modified:
font.save(ttf_path)
print(f" Clamped Y ceiling: {', '.join(modified)}")
font.close()
def _generate_baseline_shift_ctrl(ttf_path):
"""Generate touch deltas to deepen bottom anti-aliasing of non-serifed glyphs.
For lowercase glyphs without a flat baseline (no serif foot), shifts
the bottom-most points down during rasterization. Uses graduated
shifts from BASELINE_HINT_SHIFTS — stronger at small ppem sizes
where alignment is most noticeable. No outline changes.
"""
if not BASELINE_HINT_SHIFTS:
return ""
from fontTools.ttLib import TTFont
font = TTFont(ttf_path)
glyf = font["glyf"]
cmap = font.getBestCmap()
lines = []
for char in "abcdefghijklmnopqrstuvwxyz":
code = ord(char)
if code not in cmap:
continue
name = cmap[code]
g = glyf[name]
if not g.numberOfContours or g.numberOfContours <= 0:
continue
coords = g.coordinates
ys = set(c[1] for c in coords)
if 0 in ys:
continue # has serif baseline
bottom_pts = [i for i, (x, y) in enumerate(coords) if y <= 0]
if not bottom_pts:
continue
pts_str = ", ".join(str(p) for p in bottom_pts)
for shift_px, ppem_min, ppem_max in BASELINE_HINT_SHIFTS:
shift = -abs(shift_px)
lines.append(
f"{name} touch {pts_str} yshift {shift:.3f} @ {ppem_min}-{ppem_max}"
)
font.close()
return "\n".join(lines)
def autohint_ttf(ttf_path):
"""Run ttfautohint to add proper TrueType hinting.
@@ -766,16 +854,34 @@ def autohint_ttf(ttf_path):
hinting, which may handle sub-baseline overshoots more gracefully.
The resulting bytecode is baked into the font, so FreeType uses
the TrueType interpreter instead of falling back to auto-hinting.
Additionally generates per-font touch deltas to deepen the
baseline anti-aliasing of non-serifed glyphs.
"""
if not shutil.which("ttfautohint"):
print(" [warn] ttfautohint not found, skipping", file=sys.stderr)
return
# Generate control instructions for this specific font's points
ctrl_text = _generate_baseline_shift_ctrl(ttf_path)
ctrl_path = ttf_path + ".ctrl.tmp"
ctrl_count = 0
opts = list(AUTOHINT_OPTS)
if ctrl_text:
with open(ctrl_path, "w") as f:
f.write(ctrl_text)
opts += [f"--control-file={ctrl_path}"]
ctrl_count = ctrl_text.count("\n") + 1
tmp_path = ttf_path + ".autohint.tmp"
result = subprocess.run(
["ttfautohint"] + AUTOHINT_OPTS + [ttf_path, tmp_path],
["ttfautohint"] + opts + [ttf_path, tmp_path],
capture_output=True, text=True,
)
if os.path.exists(ctrl_path):
os.remove(ctrl_path)
if result.returncode != 0:
print(f" [warn] ttfautohint failed: {result.stderr.strip()}", file=sys.stderr)
if os.path.exists(tmp_path):
@@ -783,7 +889,10 @@ def autohint_ttf(ttf_path):
return
os.replace(tmp_path, ttf_path)
print(f" Autohinted with ttfautohint")
hint_msg = "Autohinted with ttfautohint"
if ctrl_count:
hint_msg += f" ({ctrl_count} serif control hints)"
print(f" {hint_msg}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -815,6 +924,9 @@ KOBOFIX_URL = (
def _download_kobofix(dest):
"""Download kobofix.py if not already cached."""
if os.path.isfile(dest):
print(f" Using cached kobofix.py")
return
import urllib.request
print(f" Downloading kobofix.py ...")
urllib.request.urlretrieve(KOBOFIX_URL, dest)
@@ -857,9 +969,18 @@ def main():
family = DEFAULT_FAMILY
outline_fix = True
# --name "Foo" sets the family name directly
if "--name" in sys.argv:
idx = sys.argv.index("--name")
if idx + 1 < len(sys.argv):
family = sys.argv[idx + 1]
else:
print("ERROR: --name requires a value", file=sys.stderr)
sys.exit(1)
if "--customize" in sys.argv:
print()
family = input(f" Font family name [{DEFAULT_FAMILY}]: ").strip() or DEFAULT_FAMILY
family = input(f" Font family name [{family}]: ").strip() or family
outline_input = input(" Apply outline fixes (remove overlaps + zero-area cleanup)? [Y/n]: ").strip().lower()
outline_fix = outline_input not in ("n", "no")
@@ -977,6 +1098,7 @@ def _build(tmp_dir, family=DEFAULT_FAMILY, outline_fix=True):
clean_ttf_degenerate_contours(ttf_path)
fix_ttf_style_flags(ttf_path, style_suffix)
add_kern_pairs(ttf_path)
apply_glyph_y_ceiling(ttf_path)
autohint_ttf(ttf_path)

BIN
screenshot.png Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 192 KiB