Skip to content

Commit 83a84b7

Browse files
Add fix for "ghosting" serifs
1 parent 19e915f commit 83a84b7

3 files changed

Lines changed: 99 additions & 30 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.4
1+
1.5

build.py

Lines changed: 98 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -90,20 +90,21 @@
9090
ASCENDER_RATIO = 0.8
9191

9292
# Step 4: ttfautohint options (hinting for Kobo's FreeType renderer)
93-
# - Kobo uses FreeType grayscale, so the 1st char of --stem-width-mode
94-
# (gray) is the one that matters. n=natural, q=quantized, s=strong.
95-
# - Remaining two chars are for GDI and DirectWrite (not used on Kobo).
96-
# - Other options are left at ttfautohint defaults; uncomment to override.
97-
AUTOHINT_CTRL = os.path.join(SRC_DIR, "ttfautohint-ctrl.txt")
93+
# - Kobo uses FreeType grayscale on e-ink, where gray pixels are very
94+
# visible. Strong mode (s) snaps stems to integer pixels, minimising
95+
# gray anti-aliasing at the cost of some shape distortion.
96+
# - Other options are left at ttfautohint defaults.
9897
AUTOHINT_OPTS = [
9998
"--no-info",
100-
"--stem-width-mode=nss",
101-
# "--hinting-range-min=8",
102-
# "--hinting-range-max=50",
103-
# "--hinting-limit=200",
104-
"--increase-x-height=14",
99+
"--stem-width-mode=qss",
105100
]
106101

102+
# Serif shelf detection: points within this distance (in font units)
103+
# inward from a blue zone edge get `left`/`right` direction hints so
104+
# ttfautohint creates explicit segments instead of interpolating them.
105+
# This fixes gray ghosting at serif feet (bottom) and tops (e.g. w, v).
106+
SERIF_SHELF_INSET = 160 # how far the shelf can be from the blue zone
107+
107108
# Explicit kern pairs: (left_glyph, right_glyph, kern_value_in_units).
108109
# Negative values tighten spacing. These are added on top of any existing
109110
# kerning from the source variable font.
@@ -753,6 +754,58 @@ def resolve(name):
753754
print(f" Added {count} kern pair(s) to GPOS")
754755

755756

757+
def _generate_serif_ctrl(ttf_path):
758+
"""Generate ttfautohint control instructions for serif shelf points.
759+
760+
Scans the font for glyph points near (but not on) blue zone edges
761+
— the serif "shelves" that ttfautohint doesn't detect as segments.
762+
Without explicit hints these get interpolated to fractional pixel
763+
positions, causing gray ghosting on e-ink.
764+
765+
Bottom shelves (near baseline y=0) get `left` direction hints.
766+
Top shelves (near x-height) get `right` direction hints.
767+
"""
768+
from fontTools.ttLib import TTFont
769+
font = TTFont(ttf_path)
770+
glyf = font["glyf"]
771+
os2 = font["OS/2"]
772+
x_height = getattr(os2, "sxHeight", None) or 0
773+
774+
# Blue zone edges: (zone_y, tolerance, inset, direction)
775+
# - tolerance: how close a glyph's "flat edge" must be to zone_y
776+
# - inset: how far inward the shelf can be from the flat edge
777+
# - direction: left = bottom shelf, right = top shelf
778+
zones = [
779+
# (zone_y, tolerance, inset, direction)
780+
# tolerance: how close a glyph's flat edge must be to count
781+
# inset: how far the shelf can be from the zone
782+
# Shelf points are between zone_y±inset and zone_y±tolerance.
783+
(0, 0, SERIF_SHELF_INSET, "left"),
784+
]
785+
if x_height:
786+
zones.append((x_height, 25, SERIF_SHELF_INSET, "right"))
787+
788+
lines = []
789+
for name in sorted(glyf.keys()):
790+
g = glyf[name]
791+
if not g.numberOfContours or g.numberOfContours <= 0:
792+
continue
793+
coords = g.coordinates
794+
ys = set(c[1] for c in coords)
795+
for zone_y, tolerance, inset, direction in zones:
796+
# Glyph must have a point near the blue zone edge
797+
has_edge = any(abs(y - zone_y) <= tolerance for y in ys)
798+
if not has_edge:
799+
continue
800+
for i, (x, y) in enumerate(coords):
801+
if direction == "left" and 0 < y <= inset:
802+
lines.append(f"{name} left {i}")
803+
elif direction == "right" and zone_y - inset <= y < zone_y - tolerance:
804+
lines.append(f"{name} right {i}")
805+
font.close()
806+
return "\n".join(lines)
807+
808+
756809
def autohint_ttf(ttf_path):
757810
"""Run ttfautohint to add proper TrueType hinting.
758811
@@ -767,27 +820,45 @@ def autohint_ttf(ttf_path):
767820
hinting, which may handle sub-baseline overshoots more gracefully.
768821
The resulting bytecode is baked into the font, so FreeType uses
769822
the TrueType interpreter instead of falling back to auto-hinting.
823+
824+
Additionally generates per-font control instructions for serif
825+
shelf points that the auto-hinter would otherwise interpolate.
770826
"""
771827
if not shutil.which("ttfautohint"):
772828
print(" [warn] ttfautohint not found, skipping", file=sys.stderr)
773829
return
774830

775-
tmp_path = ttf_path + ".autohint.tmp"
831+
# Generate control instructions for this specific font's points
832+
ctrl_text = _generate_serif_ctrl(ttf_path)
833+
ctrl_path = ttf_path + ".ctrl.tmp"
834+
ctrl_count = 0
776835
opts = list(AUTOHINT_OPTS)
777-
if os.path.isfile(AUTOHINT_CTRL) and os.path.getsize(AUTOHINT_CTRL) > 0:
778-
opts += [f"--control-file={AUTOHINT_CTRL}"]
836+
if ctrl_text:
837+
with open(ctrl_path, "w") as f:
838+
f.write(ctrl_text)
839+
opts += [f"--control-file={ctrl_path}"]
840+
ctrl_count = ctrl_text.count("\n") + 1
841+
842+
tmp_path = ttf_path + ".autohint.tmp"
779843
result = subprocess.run(
780844
["ttfautohint"] + opts + [ttf_path, tmp_path],
781845
capture_output=True, text=True,
782846
)
847+
848+
if os.path.exists(ctrl_path):
849+
os.remove(ctrl_path)
850+
783851
if result.returncode != 0:
784852
print(f" [warn] ttfautohint failed: {result.stderr.strip()}", file=sys.stderr)
785853
if os.path.exists(tmp_path):
786854
os.remove(tmp_path)
787855
return
788856

789857
os.replace(tmp_path, ttf_path)
790-
print(f" Autohinted with ttfautohint")
858+
hint_msg = "Autohinted with ttfautohint"
859+
if ctrl_count:
860+
hint_msg += f" ({ctrl_count} serif control hints)"
861+
print(f" {hint_msg}")
791862

792863

793864
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -819,6 +890,9 @@ def check_ttfautohint():
819890

820891
def _download_kobofix(dest):
821892
"""Download kobofix.py if not already cached."""
893+
if os.path.isfile(dest):
894+
print(f" Using cached kobofix.py")
895+
return
822896
import urllib.request
823897
print(f" Downloading kobofix.py ...")
824898
urllib.request.urlretrieve(KOBOFIX_URL, dest)
@@ -861,9 +935,18 @@ def main():
861935
family = DEFAULT_FAMILY
862936
outline_fix = True
863937

938+
# --name "Foo" sets the family name directly
939+
if "--name" in sys.argv:
940+
idx = sys.argv.index("--name")
941+
if idx + 1 < len(sys.argv):
942+
family = sys.argv[idx + 1]
943+
else:
944+
print("ERROR: --name requires a value", file=sys.stderr)
945+
sys.exit(1)
946+
864947
if "--customize" in sys.argv:
865948
print()
866-
family = input(f" Font family name [{DEFAULT_FAMILY}]: ").strip() or DEFAULT_FAMILY
949+
family = input(f" Font family name [{family}]: ").strip() or family
867950
outline_input = input(" Apply outline fixes (remove overlaps + zero-area cleanup)? [Y/n]: ").strip().lower()
868951
outline_fix = outline_input not in ("n", "no")
869952

src/ttfautohint-ctrl.txt

Lines changed: 0 additions & 14 deletions
This file was deleted.

0 commit comments

Comments
 (0)