9090ASCENDER_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.
9897AUTOHINT_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+
756809def 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
820891def _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
0 commit comments