char/iocgn-mono

iosevka variant inspired by Simple Köln-Bonn

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

Charlotte Somtweak lowercase y specifically for oblique font9f76a66

main
9.6 KiB188 linesraw
1"""Build a ten-degree oblique from the compatible upright weight masters."""
2
3from contextlib import ExitStack
4import hashlib
5import json
6from math import radians, tan
7from pathlib import Path
8
9from fontTools.misc.roundTools import otRound
10from fontTools.misc.transform import Transform
11from fontTools.pens.boundsPen import BoundsPen
12from fontTools.pens.transformPen import TransformPen
13from fontTools.ttLib import TTFont
14
15from masters import ARROWS, CELL_GRAPHICS, MASTERS
16
17ROOT = Path(__file__).resolve().parent.parent
18ANGLE = 10
19QUOTE_SPREAD = 16
20SHEAR = tan(radians(ANGLE))
21
22
23def bounds(glyph_sets, name, transform):
24    boxes = []
25    for glyphs in glyph_sets:
26        pen = BoundsPen(glyphs)
27        glyphs[name].draw(TransformPen(pen, transform))
28        if pen.bounds:
29            boxes.append(pen.bounds)
30    if boxes:
31        return (min(b[0] for b in boxes), min(b[1] for b in boxes),
32                max(b[2] for b in boxes), max(b[3] for b in boxes))
33
34
35def name_oblique(font, style, weight):
36    subfamily = 'Oblique' if style == 'Regular' else f'{style} Oblique'
37    legacy_family = 'iocgn Mono' if weight in (400, 700) else f'iocgn Mono {style}'
38    legacy_style = 'Bold Italic' if weight == 700 else 'Italic'
39    postscript = f'iocgnMono-{subfamily.replace(" ", "")}'
40    version = font['head'].fontRevision
41    values = {1: legacy_family, 2: legacy_style, 3: f'{postscript}-{version:.3f}',
42              4: f'iocgn Mono {subfamily}', 6: postscript, 16: 'iocgn Mono',
43              17: subfamily, 21: 'iocgn Mono', 22: subfamily, 25: 'iocgnMonoOblique'}
44    for record in font['name'].names:
45        if record.nameID in values:
46            font['name'].setName(values[record.nameID], record.nameID,
47                                 record.platformID, record.platEncID, record.langID)
48    for name_id, value in values.items():
49        font['name'].setName(value, name_id, 3, 1, 0x409)
50    # Legacy Windows families use Italic for style linking, even for an oblique design.
51    font['OS/2'].fsSelection = (font['OS/2'].fsSelection & ~0x61) | 0x201 | (0x20 if weight == 700 else 0)
52    font['head'].macStyle = 2 | (1 if weight == 700 else 0)
53    font['post'].italicAngle = -ANGLE
54    font['hhea'].caretSlopeRise = 1000
55    font['hhea'].caretSlopeRun = otRound(1000 * SHEAR)
56    font['hhea'].caretOffset = 0
57
58
59def main():
60    output = ROOT / 'build/oblique'
61    output.mkdir(parents=True, exist_ok=True)
62    paths = [ROOT / 'build/variable' / f'{style}.ttf' for style, _ in MASTERS]
63    masters = [output / f'{style}.ttf' for style, _ in MASTERS]
64    report = {'angle': ANGLE, 'quote_spread': QUOTE_SPREAD, 'narrowed': {}, 'accent_shifts': {}, 'sources': {
65        str(path.relative_to(ROOT)): hashlib.sha256(path.read_bytes()).hexdigest()
66        for path in [Path(__file__), *paths]}}
67    with ExitStack() as stack:
68        fonts = [stack.enter_context(TTFont(path, recalcTimestamp=False)) for path in paths]
69        glyph_sets = [font.getGlyphSet() for font in fonts]
70        order = fonts[0].getGlyphOrder()
71        cmap = fonts[0].getBestCmap()
72        # A little extra gap keeps double quotes distinct under small-size grayscale rendering.
73        for font in fonts:
74            quote = font['glyf'][cmap[ord('"')]]
75            start = 0
76            for end in quote.endPtsOfContours:
77                xs = [quote.coordinates[i][0] for i in range(start, end + 1)]
78                shift = QUOTE_SPREAD if (min(xs) + max(xs)) / 2 > 250 else -QUOTE_SPREAD
79                for i in range(start, end + 1):
80                    x, y = quote.coordinates[i]
81                    quote.coordinates[i] = (x + shift, y)
82                start = end + 1
83        punctuation = {name for cp, name in cmap.items() if chr(cp) in "'\"`.,:;!?()[]{}<>/\\|*~_^+-=" or 0x2010 <= cp <= 0x203A}
84        upright_symbols = {cmap[cp] for cp in CELL_GRAPHICS | ARROWS}
85        transforms = {}
86        for name in order:
87            # Preserve arrow directions and terminal cell joins under oblique selection.
88            if name in upright_symbols:
89                transforms[name] = Transform()
90                continue
91            pivot = 262.5
92            if name in punctuation:
93                box = bounds([glyph_sets[1]], name, Transform())
94                if box:
95                    pivot = (box[1] + box[3]) / 2
96            scale = 1.0
97            # Bound y's overhang so its diagonal stays broad but clears a preceding underscore.
98            left = -40 if name == cmap[ord('y')] else 14
99            transform = Transform(scale, 0, SHEAR, 1, (1 - scale) * 250 - SHEAR * pivot, 0)
100            box = bounds(glyph_sets, name, transform)
101            if box and box[2] - box[0] > 486 - left:
102                lo, hi = 0.0, 1.0
103                for _ in range(24):
104                    scale = (lo + hi) / 2
105                    transform = Transform(scale, 0, SHEAR, 1, (1 - scale) * 250 - SHEAR * pivot, 0)
106                    box = bounds(glyph_sets, name, transform)
107                    if box[2] - box[0] <= 486 - left:
108                        lo = scale
109                    else:
110                        hi = scale
111                scale = lo
112                transform = Transform(scale, 0, SHEAR, 1, (1 - scale) * 250 - SHEAR * pivot, 0)
113                box = bounds(glyph_sets, name, transform)
114                report['narrowed'][name] = scale
115            if box:
116                shift = max(left - box[0], min(0, 486 - box[2]))
117                transform = Transform(*transform[:4], transform.dx + shift, 0)
118            transforms[name] = transform
119        accented = {name for name in order if fonts[0]['glyf'][name].isComposite()}
120        # An accented letter must keep exactly the same base as its unaccented form.
121        for name in accented:
122            base = fonts[0]['glyf'][name].components[0].glyphName
123            transforms[name] = transforms[base]
124            report['narrowed'].pop(name, None)
125        for font in fonts:
126            for name in order:
127                glyph = font['glyf'][name]
128                transform = transforms[name]
129                if glyph.isComposite():
130                    for component in glyph.components:
131                        child, original = component.getComponentInfo()
132                        matrix = transform.transform(original).transform(transforms[child].inverse())
133                        component.transform = ((matrix.xx, matrix.xy), (matrix.yx, matrix.yy))
134                        component.x, component.y = otRound(matrix.dx), otRound(matrix.dy)
135                        # The conjugated offsets are in parent coordinates.
136                        component.flags = (component.flags & ~0x0800) | 0x1000
137                elif glyph.numberOfContours:
138                    glyph.coordinates.transform(((transform.xx, transform.xy), (transform.yx, transform.yy)))
139                    glyph.coordinates.translate((transform.dx, transform.dy))
140                    glyph.coordinates.toInt()
141            # Apply each glyph's optical correction to both sides of mark attachment.
142            for lookup in font['GPOS'].table.LookupList.Lookup:
143                for table in lookup.SubTable:
144                    if lookup.LookupType == 4:
145                        groups = ((table.MarkCoverage.glyphs, table.MarkArray.MarkRecord, 'MarkAnchor'),
146                                  (table.BaseCoverage.glyphs, table.BaseArray.BaseRecord, 'BaseAnchor'))
147                    elif lookup.LookupType == 6:
148                        groups = ((table.Mark1Coverage.glyphs, table.Mark1Array.MarkRecord, 'MarkAnchor'),
149                                  (table.Mark2Coverage.glyphs, table.Mark2Array.Mark2Record, 'Mark2Anchor'))
150                    else:
151                        raise ValueError(f'Unsupported positioning lookup: {lookup.LookupType}')
152                    for names, records, field in groups:
153                        for name, record in zip(names, records):
154                            anchors = getattr(record, field)
155                            if not isinstance(anchors, list):
156                                anchors = [anchors]
157                            for anchor in anchors:
158                                if anchor:
159                                    anchor.XCoordinate, anchor.YCoordinate = map(otRound, transforms[name].transformPoint(
160                                        (anchor.XCoordinate, anchor.YCoordinate)))
161        glyph_sets = [font.getGlyphSet() for font in fonts]
162        for name in accented:
163            count = len(fonts[0]['glyf'][name].components)
164            for index in range(1, count):
165                boxes = []
166                for font, glyphs in zip(fonts, glyph_sets):
167                    child, matrix = font['glyf'][name].components[index].getComponentInfo()
168                    boxes.append(bounds([glyphs], child, matrix))
169                left, right = min(b[0] for b in boxes), max(b[2] for b in boxes)
170                shift = otRound(max(14 - left, min(0, 486 - right)))
171                if shift:
172                    report['accent_shifts'][name] = shift
173                    for font in fonts:
174                        font['glyf'][name].components[index].x += shift
175        for font, path, (style, weight) in zip(fonts, masters, MASTERS):
176            for name in order:
177                glyph = font['glyf'][name]
178                glyph.recalcBounds(font['glyf'])
179                font['hmtx'][name] = (font['hmtx'][name][0], getattr(glyph, 'xMin', 0))
180            name_oblique(font, style, weight)
181            font.save(path)
182    report['masters'] = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in masters}
183    (output / 'manifest.json').write_text(json.dumps(report, indent=2, sort_keys=True) + '\n')
184    print('Prepared four ten-degree oblique weight masters.')
185
186
187if __name__ == '__main__':
188    main()