char/iocgn-mono
iosevka variant inspired by Simple Köln-Bonn
git clone https://git.t4t.associates/char/iocgn-mono
2ba32a9
main
1"""Prepare compatible upright weight masters from the approved static endpoints.""" 2 3from bisect import bisect_right 4from contextlib import ExitStack 5import hashlib 6from itertools import permutations 7import json 8from pathlib import Path 9import tomllib 10 11import fontTools 12from fontTools .misc .bezierTools import calcQuadraticArcLength 13from fontTools .pens .basePen import BasePen 14from fontTools .pens .ttGlyphPen import TTGlyphPen 15from fontTools .ttLib import TTFont 16from fontTools .ttLib .tables ._g_l_y_f import flagOverlapSimple as OVERLAP_SIMPLE 17 18ROOT = Path (__file__ ).resolve ().parent .parent 19CONFIG = tomllib .loads ((ROOT / 'build-plan.toml' ).read_text ()) 20ARROWS = {ord (char )for char in CONFIG ['terminal' ]['arrows' ]} 21CELL_GRAPHICS = {cp for first ,last in CONFIG ['terminal' ]['ranges' ]for cp in range (first ,last + 1 )} 22MASTERS = (('ExtraLight' ,200 ), ('Regular' ,400 ), ('Bold' ,700 ), ('ExtraBold' ,800 )) 23STATIC_STYLES = (('Medium' ,500 ), ('SemiBold' ,600 )) 24 25 26def split (segment ,t ): 27a = segment [0 ]+ (segment [1 ]- segment [0 ])* t 28if len (segment )== 2 : 29return (segment [0 ],a ), (a ,segment [1 ]) 30b = segment [1 ]+ (segment [2 ]- segment [1 ])* t 31m = a + (b - a )* t 32return (segment [0 ],a ,m ), (m ,b ,segment [2 ]) 33 34 35def length (segment ): 36if len (segment )== 2 : 37return abs (segment [1 ]- segment [0 ]) 38return calcQuadraticArcLength (* [(p .real ,p .imag )for p in segment ]) 39 40 41def parameter_at_distance (segment ,distance ): 42lo ,hi = 0.0 ,1.0 43for _ in range (18 ): 44t = (lo + hi )/ 2 45if length (split (segment ,t )[0 ])< distance : 46lo = t 47else : 48hi = t 49return (lo + hi )/ 2 50 51 52class Contours (BasePen ): 53def __init__ (self ,glyph_set ): 54super ().__init__ (glyph_set ) 55self .contours = [] 56 57def _moveTo (self ,p ): 58self .start = self .current = complex (* p ) 59self .segments = [] 60 61def _lineTo (self ,p ): 62p = complex (* p ) 63if p != self .current : 64self .segments .append ((self .current ,p )) 65self .current = p 66 67def _qCurveToOne (self ,control ,p ): 68p = complex (* p ) 69self .segments .append ((self .current ,complex (* control ),p )) 70self .current = p 71 72def _closePath (self ): 73if self .current != self .start : 74self .segments .append ((self .current ,self .start )) 75self .contours .append (self .segments ) 76 77 78def canonical_contour (segments ): 79# An actual extremum avoids arbitrary Boolean contour starts without changing winding. 80candidates = [] 81for i ,segment in enumerate (segments ): 82candidates .append ((segment [0 ],i ,0 )) 83if len (segment )== 3 : 84a ,b ,c = (p .imag for p in segment ) 85if a - 2 * b + c : 86t = (a - b )/ (a - 2 * b + c ) 87if 0 < t < 1 : 88candidates .append ((split (segment ,t )[0 ][- 1 ],i ,t )) 89peak = min (candidates ,key = lambda item : (- item [0 ].imag ,item [0 ].real )) 90span = max (point .real for point ,_ ,_ in candidates )- min (point .real for point ,_ ,_ in candidates ) 91# Rounding can swap separated accent tips, but round tops need their true extremum. 92ties = [item for item in candidates if peak [0 ].imag - item [0 ].imag <= 1 93and abs (item [0 ].real - peak [0 ].real )> span / 2 ] 94_ ,i ,t = min ([peak ,* ties ],key = lambda item :item [0 ].real ) 95if t : 96before ,after = split (segments [i ],t ) 97return [after ]+ segments [i + 1 :]+ segments [:i ]+ [before ] 98return segments [i :]+ segments [:i ] 99 100 101def contour_parameterization (segments ): 102lengths = [length (segment )for segment in segments ] 103total = sum (lengths ) 104if total <= 0 : 105raise ValueError ('Degenerate contour' ) 106knots = [0 ] 107for size in lengths : 108knots .append (knots [- 1 ]+ size / total ) 109knots [- 1 ]= 1 110return lengths ,knots 111 112 113def contour_samples (segments ,lengths ,knots ): 114samples = [] 115for u in (i / 64 for i in range (64 )): 116i = min (bisect_right (knots ,u )- 1 ,len (segments )- 1 ) 117fraction = (u - knots [i ])/ (knots [i + 1 ]- knots [i ]) 118t = parameter_at_distance (segments [i ],fraction * lengths [i ]) 119samples .append (split (segments [i ],t )[0 ][- 1 ]) 120return samples 121 122 123def compatible_glyphs (contours ,reference = 0 ,align_phase = False ): 124contours = [[canonical_contour (c )for c in master ]for master in contours ] 125if len ({len (master )for master in contours })!= 1 : 126raise ValueError ('Masters have different contour topology' ) 127parameters = [[contour_parameterization (c )for c in master ]for master in contours ] 128samples = [[contour_samples (c ,* p )for c ,p in zip (master ,params )] 129for master ,params in zip (contours ,parameters )] 130count = len (contours [0 ]) 131for master in sorted (range (len (contours )),key = lambda i :abs (i - reference )): 132if master == reference : 133continue 134order = min (permutations (range (count )),key = lambda order :sum ( 135abs (a - b )** 2 for i ,j in enumerate (order ) 136for a ,b in zip (samples [reference ][i ],samples [master ][j ]))) 137contours [master ]= [contours [master ][i ]for i in order ] 138parameters [master ]= [parameters [master ][i ]for i in order ] 139if not align_phase : 140continue 141for index ,source_index in enumerate (order ): 142neighbor = master + 1 if master < reference else master - 1 143target = contour_samples (contours [neighbor ][index ],* parameters [neighbor ][index ]) 144source = samples [master ][source_index ] 145phase = min (range (len (source )),key = lambda phase :sum ( 146abs (a - source [(i + phase )% len (source )])** 2 for i ,a in enumerate (target ))) 147if not phase : 148continue 149segments = contours [master ][index ] 150lengths ,knots = parameters [master ][index ] 151u = phase / len (source ) 152i = min (bisect_right (knots ,u )- 1 ,len (segments )- 1 ) 153t = parameter_at_distance (segments [i ], (u - knots [i ])/ (knots [i + 1 ]- knots [i ])* lengths [i ]) 154if t : 155before ,after = split (segments [i ],t ) 156segments = [after ]+ segments [i + 1 :]+ segments [:i ]+ [before ] 157else : 158segments = segments [i :]+ segments [:i ] 159contours [master ][index ]= segments 160parameters [master ][index ]= contour_parameterization (segments ) 161pens = [TTGlyphPen (None )for _ in contours ] 162for outlines ,params in zip (zip (* contours ),zip (* parameters )): 163knots = sorted ({u for _ ,source_knots in params for u in source_knots }) 164# Near-identical knots otherwise create sub-unit, rounding-only segments. 165knots = [u for i ,u in enumerate (knots )if i == 0 or u - knots [i - 1 ]> 1e-8 ] 166for segments , (lengths ,source_knots ),pen in zip (outlines ,params ,pens ): 167pen .moveTo ((segments [0 ][0 ].real ,segments [0 ][0 ].imag )) 168for lo ,hi in zip (knots ,knots [1 :]): 169i = min (bisect_right (source_knots , (lo + hi )/ 2 )- 1 ,len (segments )- 1 ) 170ts = [] 171for u in (lo ,hi ): 172fraction = (u - source_knots [i ])/ (source_knots [i + 1 ]- source_knots [i ]) 173ts .append (0 if fraction <= 1e-8 else 1 if fraction >= 1 - 1e-8 else 174parameter_at_distance (segments [i ],fraction * lengths [i ])) 175start ,end = ts 176section = split (segments [i ],end )[0 ] 177if start : 178section = split (section ,start / end )[1 ] 179control = section [1 ]if len (section )== 3 else (section [0 ]+ section [- 1 ])/ 2 180point = section [- 1 ] 181pen .qCurveTo ((control .real ,control .imag ), (point .real ,point .imag )) 182pen .closePath () 183glyphs = [pen .glyph ()for pen in pens ] 184signatures = [(tuple (g .endPtsOfContours ),tuple (int (flag )& 1 for flag in g .flags ))for g in glyphs ] 185if any (signature != signatures [0 ]for signature in signatures ): 186raise ValueError ('Curve subdivision produced different point structures' ) 187return glyphs 188 189 190def main (): 191output = ROOT / 'build/variable' 192output .mkdir (parents = True ,exist_ok = True ) 193paths = [ROOT / 'build/native' / f'iocgnMono- { style } .ttf' for style ,_ in MASTERS ] 194masters = [output / f' { style } .ttf' for style ,_ in MASTERS ] 195with ExitStack ()as stack : 196fonts = [stack .enter_context (TTFont (path ,recalcTimestamp = False ))for path in paths ] 197if any (font .getGlyphOrder ()!= fonts [0 ].getGlyphOrder ()for font in fonts ): 198raise ValueError ('Masters have different glyph order' ) 199sets = [font .getGlyphSet ()for font in fonts ] 200ascii_names = {name for cp ,name in fonts [0 ].getBestCmap ().items ()if cp < 128 } 201symbol_names = {name for cp ,name in fonts [0 ].getBestCmap ().items ()if cp in CELL_GRAPHICS | ARROWS } 202for name in fonts [0 ].getGlyphOrder (): 203if fonts [0 ]['glyf' ][name ].isComposite (): 204components = [[component .glyphName for component in font ['glyf' ][name ].components ]for font in fonts ] 205if any (parts != components [0 ]for parts in components ): 206raise ValueError (f' { name } : masters have different components' ) 207continue 208if name in symbol_names : 209# Boolean outlines can rotate their start point as stroke widths change. 210for font in fonts : 211glyph = font ['glyf' ][name ] 212overlap = glyph .flags [0 ]& OVERLAP_SIMPLE if glyph .numberOfContours else 0 213start = 0 214for end in getattr (glyph ,'endPtsOfContours' , ()): 215points = list (glyph .coordinates [start :end + 1 ]) 216flags = glyph .flags [start :end + 1 ] 217pivot = min ((i for i ,flag in enumerate (flags )if flag & 1 ), 218key = lambda i : (- points [i ][1 ],points [i ][0 ])) 219glyph .coordinates [start :end + 1 ]= points [pivot :]+ points [:pivot ] 220glyph .flags [start :end + 1 ]= flags [pivot :]+ flags [:pivot ] 221start = end + 1 222if overlap : 223# The overlap flag belongs to the glyph's first point, not to a contour's. 224for i in range (1 ,len (glyph .flags )): 225glyph .flags [i ]&= 0xFF ^ OVERLAP_SIMPLE 226glyph .flags [0 ]|= OVERLAP_SIMPLE 227signatures = [(font ['glyf' ][name ].numberOfContours , 228tuple (getattr (font ['glyf' ][name ],'endPtsOfContours' , ())), 229tuple (int (flag )& 1 for flag in getattr (font ['glyf' ][name ],'flags' , ()))) 230for font in fonts ] 231if all (signature == signatures [0 ]for signature in signatures ): 232continue 233outlines = [] 234for gs in sets : 235pen = Contours (gs ) 236gs [name ].draw (pen ) 237outlines .append (pen .contours ) 238try : 239# Preserve approved ASCII interpolation; new accents may need phase alignment. 240glyphs = compatible_glyphs (outlines ,reference = 1 ,align_phase = name not in ascii_names ) 241except ValueError as error : 242raise ValueError (f' { name } : { error } ' )from error 243for font ,glyph in zip (fonts ,glyphs ): 244font ['glyf' ][name ]= glyph 245glyph .recalcBounds (font ['glyf' ]) 246advance = font ['hmtx' ][name ][0 ] 247font ['hmtx' ][name ]= (advance ,getattr (glyph ,'xMin' ,0 )) 248for font ,path in zip (fonts ,masters ): 249for name in font .getGlyphOrder (): 250glyph = font ['glyf' ][name ] 251if glyph .isComposite (): 252glyph .recalcBounds (font ['glyf' ]) 253font ['hmtx' ][name ]= (font ['hmtx' ][name ][0 ],glyph .xMin ) 254font .save (path ) 255sha = lambda path :hashlib .sha256 (path .read_bytes ()).hexdigest () 256manifest = {'weights' :dict (MASTERS ), 257'advances' : [0 ,500 ],'fontTools' :fontTools .__version__ , 258'compatibility' :'Contour matching, top-extremum alignment, phase alignment for new Latin outlines and shared arc-length curve subdivision; no polygon tracing.' , 259'sources' : {str (path .relative_to (ROOT )):sha (path )for path in [Path (__file__ ),* paths ]}, 260'masters' : {path .name :sha (path )for path in masters }} 261 (output / 'manifest.json' ).write_text (json .dumps (manifest ,indent = 2 )+ '\n' ) 262'Prepared four compatible upright weight masters.' ) 263 264 265if __name__ == '__main__' : 266main ()