1
0

3 Commits

Author SHA1 Message Date
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 133 additions and 12 deletions

View File

@@ -1 +1 @@
1.4 1.5

143
build.py
View File

@@ -91,16 +91,30 @@ ASCENDER_RATIO = 0.8
# Step 4: ttfautohint options (hinting for Kobo's FreeType renderer) # Step 4: ttfautohint options (hinting for Kobo's FreeType renderer)
# - Kobo uses FreeType grayscale, so the 1st char of --stem-width-mode # - Kobo uses FreeType grayscale, so the 1st char of --stem-width-mode
# (gray) is the one that matters. n=natural, q=quantized, s=strong. # matters: n=natural (least distortion), q=quantized, s=strong.
# - Remaining two chars are for GDI and DirectWrite (not used on Kobo). # - x-height snapping is disabled to avoid inconsistent glyph heights.
# - Other options are left at ttfautohint defaults; uncomment to override.
AUTOHINT_OPTS = [ AUTOHINT_OPTS = [
"--no-info", "--no-info",
"--stem-width-mode=qss", "--stem-width-mode=nss",
# "--hinting-range-min=8", "--increase-x-height=0",
# "--hinting-range-max=50", '--x-height-snapping-exceptions=-',
# "--hinting-limit=200", ]
"--increase-x-height=14",
# 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). # Explicit kern pairs: (left_glyph, right_glyph, kern_value_in_units).
@@ -752,6 +766,79 @@ def add_kern_pairs(ttf_path):
print(f" Added {count} kern pair(s) to GPOS") 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): def autohint_ttf(ttf_path):
"""Run ttfautohint to add proper TrueType hinting. """Run ttfautohint to add proper TrueType hinting.
@@ -766,16 +853,34 @@ def autohint_ttf(ttf_path):
hinting, which may handle sub-baseline overshoots more gracefully. hinting, which may handle sub-baseline overshoots more gracefully.
The resulting bytecode is baked into the font, so FreeType uses The resulting bytecode is baked into the font, so FreeType uses
the TrueType interpreter instead of falling back to auto-hinting. 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"): if not shutil.which("ttfautohint"):
print(" [warn] ttfautohint not found, skipping", file=sys.stderr) print(" [warn] ttfautohint not found, skipping", file=sys.stderr)
return 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" tmp_path = ttf_path + ".autohint.tmp"
result = subprocess.run( result = subprocess.run(
["ttfautohint"] + AUTOHINT_OPTS + [ttf_path, tmp_path], ["ttfautohint"] + opts + [ttf_path, tmp_path],
capture_output=True, text=True, capture_output=True, text=True,
) )
if os.path.exists(ctrl_path):
os.remove(ctrl_path)
if result.returncode != 0: if result.returncode != 0:
print(f" [warn] ttfautohint failed: {result.stderr.strip()}", file=sys.stderr) print(f" [warn] ttfautohint failed: {result.stderr.strip()}", file=sys.stderr)
if os.path.exists(tmp_path): if os.path.exists(tmp_path):
@@ -783,7 +888,10 @@ def autohint_ttf(ttf_path):
return return
os.replace(tmp_path, ttf_path) 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 +923,9 @@ KOBOFIX_URL = (
def _download_kobofix(dest): def _download_kobofix(dest):
"""Download kobofix.py if not already cached.""" """Download kobofix.py if not already cached."""
if os.path.isfile(dest):
print(f" Using cached kobofix.py")
return
import urllib.request import urllib.request
print(f" Downloading kobofix.py ...") print(f" Downloading kobofix.py ...")
urllib.request.urlretrieve(KOBOFIX_URL, dest) urllib.request.urlretrieve(KOBOFIX_URL, dest)
@@ -857,9 +968,18 @@ def main():
family = DEFAULT_FAMILY family = DEFAULT_FAMILY
outline_fix = True 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: if "--customize" in sys.argv:
print() 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_input = input(" Apply outline fixes (remove overlaps + zero-area cleanup)? [Y/n]: ").strip().lower()
outline_fix = outline_input not in ("n", "no") outline_fix = outline_input not in ("n", "no")
@@ -977,6 +1097,7 @@ def _build(tmp_dir, family=DEFAULT_FAMILY, outline_fix=True):
clean_ttf_degenerate_contours(ttf_path) clean_ttf_degenerate_contours(ttf_path)
fix_ttf_style_flags(ttf_path, style_suffix) fix_ttf_style_flags(ttf_path, style_suffix)
add_kern_pairs(ttf_path) add_kern_pairs(ttf_path)
apply_glyph_y_ceiling(ttf_path)
autohint_ttf(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