char/iocgn-mono

iosevka variant inspired by Simple Köln-Bonn

git clone https://git.t4t.associates/char/iocgn-mono

Charlotte Somsimplify pipeline even more (less python!! :D)2ba32a9

main
12.4 KiB266 linesraw
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):
27    a = segment[0] + (segment[1] - segment[0]) * t
28    if len(segment) == 2:
29        return (segment[0], a), (a, segment[1])
30    b = segment[1] + (segment[2] - segment[1]) * t
31    m = a + (b - a) * t
32    return (segment[0], a, m), (m, b, segment[2])
33
34
35def length(segment):
36    if len(segment) == 2:
37        return abs(segment[1] - segment[0])
38    return calcQuadraticArcLength(*[(p.real, p.imag) for p in segment])
39
40
41def parameter_at_distance(segment, distance):
42    lo, hi = 0.0, 1.0
43    for _ in range(18):
44        t = (lo + hi) / 2
45        if length(split(segment, t)[0]) < distance:
46            lo = t
47        else:
48            hi = t
49    return (lo + hi) / 2
50
51
52class Contours(BasePen):
53    def __init__(self, glyph_set):
54        super().__init__(glyph_set)
55        self.contours = []
56
57    def _moveTo(self, p):
58        self.start = self.current = complex(*p)
59        self.segments = []
60
61    def _lineTo(self, p):
62        p = complex(*p)
63        if p != self.current:
64            self.segments.append((self.current, p))
65        self.current = p
66
67    def _qCurveToOne(self, control, p):
68        p = complex(*p)
69        self.segments.append((self.current, complex(*control), p))
70        self.current = p
71
72    def _closePath(self):
73        if self.current != self.start:
74            self.segments.append((self.current, self.start))
75        self.contours.append(self.segments)
76
77
78def canonical_contour(segments):
79    # An actual extremum avoids arbitrary Boolean contour starts without changing winding.
80    candidates = []
81    for i, segment in enumerate(segments):
82        candidates.append((segment[0], i, 0))
83        if len(segment) == 3:
84            a, b, c = (p.imag for p in segment)
85            if a - 2 * b + c:
86                t = (a - b) / (a - 2 * b + c)
87                if 0 < t < 1:
88                    candidates.append((split(segment, t)[0][-1], i, t))
89    peak = min(candidates, key=lambda item: (-item[0].imag, item[0].real))
90    span = 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.
92    ties = [item for item in candidates if peak[0].imag - item[0].imag <= 1
93            and abs(item[0].real - peak[0].real) > span / 2]
94    _, i, t = min([peak, *ties], key=lambda item: item[0].real)
95    if t:
96        before, after = split(segments[i], t)
97        return [after] + segments[i + 1:] + segments[:i] + [before]
98    return segments[i:] + segments[:i]
99
100
101def contour_parameterization(segments):
102    lengths = [length(segment) for segment in segments]
103    total = sum(lengths)
104    if total <= 0:
105        raise ValueError('Degenerate contour')
106    knots = [0]
107    for size in lengths:
108        knots.append(knots[-1] + size / total)
109    knots[-1] = 1
110    return lengths, knots
111
112
113def contour_samples(segments, lengths, knots):
114    samples = []
115    for u in (i / 64 for i in range(64)):
116        i = min(bisect_right(knots, u) - 1, len(segments) - 1)
117        fraction = (u - knots[i]) / (knots[i + 1] - knots[i])
118        t = parameter_at_distance(segments[i], fraction * lengths[i])
119        samples.append(split(segments[i], t)[0][-1])
120    return samples
121
122
123def compatible_glyphs(contours, reference=0, align_phase=False):
124    contours = [[canonical_contour(c) for c in master] for master in contours]
125    if len({len(master) for master in contours}) != 1:
126        raise ValueError('Masters have different contour topology')
127    parameters = [[contour_parameterization(c) for c in master] for master in contours]
128    samples = [[contour_samples(c, *p) for c, p in zip(master, params)]
129               for master, params in zip(contours, parameters)]
130    count = len(contours[0])
131    for master in sorted(range(len(contours)), key=lambda i: abs(i - reference)):
132        if master == reference:
133            continue
134        order = min(permutations(range(count)), key=lambda order: sum(
135            abs(a - b) ** 2 for i, j in enumerate(order)
136            for a, b in zip(samples[reference][i], samples[master][j])))
137        contours[master] = [contours[master][i] for i in order]
138        parameters[master] = [parameters[master][i] for i in order]
139        if not align_phase:
140            continue
141        for index, source_index in enumerate(order):
142            neighbor = master + 1 if master < reference else master - 1
143            target = contour_samples(contours[neighbor][index], *parameters[neighbor][index])
144            source = samples[master][source_index]
145            phase = min(range(len(source)), key=lambda phase: sum(
146                abs(a - source[(i + phase) % len(source)]) ** 2 for i, a in enumerate(target)))
147            if not phase:
148                continue
149            segments = contours[master][index]
150            lengths, knots = parameters[master][index]
151            u = phase / len(source)
152            i = min(bisect_right(knots, u) - 1, len(segments) - 1)
153            t = parameter_at_distance(segments[i], (u - knots[i]) / (knots[i + 1] - knots[i]) * lengths[i])
154            if t:
155                before, after = split(segments[i], t)
156                segments = [after] + segments[i + 1:] + segments[:i] + [before]
157            else:
158                segments = segments[i:] + segments[:i]
159            contours[master][index] = segments
160            parameters[master][index] = contour_parameterization(segments)
161    pens = [TTGlyphPen(None) for _ in contours]
162    for outlines, params in zip(zip(*contours), zip(*parameters)):
163        knots = sorted({u for _, source_knots in params for u in source_knots})
164        # Near-identical knots otherwise create sub-unit, rounding-only segments.
165        knots = [u for i, u in enumerate(knots) if i == 0 or u - knots[i - 1] > 1e-8]
166        for segments, (lengths, source_knots), pen in zip(outlines, params, pens):
167            pen.moveTo((segments[0][0].real, segments[0][0].imag))
168            for lo, hi in zip(knots, knots[1:]):
169                i = min(bisect_right(source_knots, (lo + hi) / 2) - 1, len(segments) - 1)
170                ts = []
171                for u in (lo, hi):
172                    fraction = (u - source_knots[i]) / (source_knots[i + 1] - source_knots[i])
173                    ts.append(0 if fraction <= 1e-8 else 1 if fraction >= 1 - 1e-8 else
174                              parameter_at_distance(segments[i], fraction * lengths[i]))
175                start, end = ts
176                section = split(segments[i], end)[0]
177                if start:
178                    section = split(section, start / end)[1]
179                control = section[1] if len(section) == 3 else (section[0] + section[-1]) / 2
180                point = section[-1]
181                pen.qCurveTo((control.real, control.imag), (point.real, point.imag))
182            pen.closePath()
183    glyphs = [pen.glyph() for pen in pens]
184    signatures = [(tuple(g.endPtsOfContours), tuple(int(flag) & 1 for flag in g.flags)) for g in glyphs]
185    if any(signature != signatures[0] for signature in signatures):
186        raise ValueError('Curve subdivision produced different point structures')
187    return glyphs
188
189
190def main():
191    output = ROOT / 'build/variable'
192    output.mkdir(parents=True, exist_ok=True)
193    paths = [ROOT / 'build/native' / f'iocgnMono-{style}.ttf' for style, _ in MASTERS]
194    masters = [output / f'{style}.ttf' for style, _ in MASTERS]
195    with ExitStack() as stack:
196        fonts = [stack.enter_context(TTFont(path, recalcTimestamp=False)) for path in paths]
197        if any(font.getGlyphOrder() != fonts[0].getGlyphOrder() for font in fonts):
198            raise ValueError('Masters have different glyph order')
199        sets = [font.getGlyphSet() for font in fonts]
200        ascii_names = {name for cp, name in fonts[0].getBestCmap().items() if cp < 128}
201        symbol_names = {name for cp, name in fonts[0].getBestCmap().items() if cp in CELL_GRAPHICS | ARROWS}
202        for name in fonts[0].getGlyphOrder():
203            if fonts[0]['glyf'][name].isComposite():
204                components = [[component.glyphName for component in font['glyf'][name].components] for font in fonts]
205                if any(parts != components[0] for parts in components):
206                    raise ValueError(f'{name}: masters have different components')
207                continue
208            if name in symbol_names:
209                # Boolean outlines can rotate their start point as stroke widths change.
210                for font in fonts:
211                    glyph = font['glyf'][name]
212                    overlap = glyph.flags[0] & OVERLAP_SIMPLE if glyph.numberOfContours else 0
213                    start = 0
214                    for end in getattr(glyph, 'endPtsOfContours', ()):
215                        points = list(glyph.coordinates[start:end + 1])
216                        flags = glyph.flags[start:end + 1]
217                        pivot = min((i for i, flag in enumerate(flags) if flag & 1),
218                                    key=lambda i: (-points[i][1], points[i][0]))
219                        glyph.coordinates[start:end + 1] = points[pivot:] + points[:pivot]
220                        glyph.flags[start:end + 1] = flags[pivot:] + flags[:pivot]
221                        start = end + 1
222                    if overlap:
223                        # The overlap flag belongs to the glyph's first point, not to a contour's.
224                        for i in range(1, len(glyph.flags)):
225                            glyph.flags[i] &= 0xFF ^ OVERLAP_SIMPLE
226                        glyph.flags[0] |= OVERLAP_SIMPLE
227                signatures = [(font['glyf'][name].numberOfContours,
228                               tuple(getattr(font['glyf'][name], 'endPtsOfContours', ())),
229                               tuple(int(flag) & 1 for flag in getattr(font['glyf'][name], 'flags', ())))
230                              for font in fonts]
231                if all(signature == signatures[0] for signature in signatures):
232                    continue
233            outlines = []
234            for gs in sets:
235                pen = Contours(gs)
236                gs[name].draw(pen)
237                outlines.append(pen.contours)
238            try:
239                # Preserve approved ASCII interpolation; new accents may need phase alignment.
240                glyphs = compatible_glyphs(outlines, reference=1, align_phase=name not in ascii_names)
241            except ValueError as error:
242                raise ValueError(f'{name}: {error}') from error
243            for font, glyph in zip(fonts, glyphs):
244                font['glyf'][name] = glyph
245                glyph.recalcBounds(font['glyf'])
246                advance = font['hmtx'][name][0]
247                font['hmtx'][name] = (advance, getattr(glyph, 'xMin', 0))
248        for font, path in zip(fonts, masters):
249            for name in font.getGlyphOrder():
250                glyph = font['glyf'][name]
251                if glyph.isComposite():
252                    glyph.recalcBounds(font['glyf'])
253                    font['hmtx'][name] = (font['hmtx'][name][0], glyph.xMin)
254            font.save(path)
255    sha = lambda path: hashlib.sha256(path.read_bytes()).hexdigest()
256    manifest = {'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    print('Prepared four compatible upright weight masters.')
263
264
265if __name__ == '__main__':
266    main()