"""Build a ten-degree oblique from the compatible upright weight masters.""" from contextlib import ExitStack import hashlib import json from math import radians, tan from pathlib import Path from fontTools.misc.roundTools import otRound from fontTools.misc.transform import Transform from fontTools.pens.boundsPen import BoundsPen from fontTools.pens.transformPen import TransformPen from fontTools.ttLib import TTFont from masters import ARROWS, CELL_GRAPHICS, MASTERS ROOT = Path(__file__).resolve().parent.parent ANGLE = 10 QUOTE_SPREAD = 16 SHEAR = tan(radians(ANGLE)) def bounds(glyph_sets, name, transform): boxes = [] for glyphs in glyph_sets: pen = BoundsPen(glyphs) glyphs[name].draw(TransformPen(pen, transform)) if pen.bounds: boxes.append(pen.bounds) if boxes: return (min(b[0] for b in boxes), min(b[1] for b in boxes), max(b[2] for b in boxes), max(b[3] for b in boxes)) def name_oblique(font, style, weight): subfamily = 'Oblique' if style == 'Regular' else f'{style} Oblique' legacy_family = 'iocgn Mono' if weight in (400, 700) else f'iocgn Mono {style}' legacy_style = 'Bold Italic' if weight == 700 else 'Italic' postscript = f'iocgnMono-{subfamily.replace(" ", "")}' version = font['head'].fontRevision values = {1: legacy_family, 2: legacy_style, 3: f'{postscript}-{version:.3f}', 4: f'iocgn Mono {subfamily}', 6: postscript, 16: 'iocgn Mono', 17: subfamily, 21: 'iocgn Mono', 22: subfamily, 25: 'iocgnMonoOblique'} for record in font['name'].names: if record.nameID in values: font['name'].setName(values[record.nameID], record.nameID, record.platformID, record.platEncID, record.langID) for name_id, value in values.items(): font['name'].setName(value, name_id, 3, 1, 0x409) # Legacy Windows families use Italic for style linking, even for an oblique design. font['OS/2'].fsSelection = (font['OS/2'].fsSelection & ~0x61) | 0x201 | (0x20 if weight == 700 else 0) font['head'].macStyle = 2 | (1 if weight == 700 else 0) font['post'].italicAngle = -ANGLE font['hhea'].caretSlopeRise = 1000 font['hhea'].caretSlopeRun = otRound(1000 * SHEAR) font['hhea'].caretOffset = 0 def main(): output = ROOT / 'build/oblique' output.mkdir(parents=True, exist_ok=True) paths = [ROOT / 'build/variable' / f'{style}.ttf' for style, _ in MASTERS] masters = [output / f'{style}.ttf' for style, _ in MASTERS] report = {'angle': ANGLE, 'quote_spread': QUOTE_SPREAD, 'narrowed': {}, 'accent_shifts': {}, 'sources': { str(path.relative_to(ROOT)): hashlib.sha256(path.read_bytes()).hexdigest() for path in [Path(__file__), *paths]}} with ExitStack() as stack: fonts = [stack.enter_context(TTFont(path, recalcTimestamp=False)) for path in paths] glyph_sets = [font.getGlyphSet() for font in fonts] order = fonts[0].getGlyphOrder() cmap = fonts[0].getBestCmap() # A little extra gap keeps double quotes distinct under small-size grayscale rendering. for font in fonts: quote = font['glyf'][cmap[ord('"')]] start = 0 for end in quote.endPtsOfContours: xs = [quote.coordinates[i][0] for i in range(start, end + 1)] shift = QUOTE_SPREAD if (min(xs) + max(xs)) / 2 > 250 else -QUOTE_SPREAD for i in range(start, end + 1): x, y = quote.coordinates[i] quote.coordinates[i] = (x + shift, y) start = end + 1 punctuation = {name for cp, name in cmap.items() if chr(cp) in "'\"`.,:;!?()[]{}<>/\\|*~_^+-=" or 0x2010 <= cp <= 0x203A} upright_symbols = {cmap[cp] for cp in CELL_GRAPHICS | ARROWS} transforms = {} for name in order: # Preserve arrow directions and terminal cell joins under oblique selection. if name in upright_symbols: transforms[name] = Transform() continue pivot = 262.5 if name in punctuation: box = bounds([glyph_sets[1]], name, Transform()) if box: pivot = (box[1] + box[3]) / 2 scale = 1.0 # Bound y's overhang so its diagonal stays broad but clears a preceding underscore. left = -40 if name == cmap[ord('y')] else 14 transform = Transform(scale, 0, SHEAR, 1, (1 - scale) * 250 - SHEAR * pivot, 0) box = bounds(glyph_sets, name, transform) if box and box[2] - box[0] > 486 - left: lo, hi = 0.0, 1.0 for _ in range(24): scale = (lo + hi) / 2 transform = Transform(scale, 0, SHEAR, 1, (1 - scale) * 250 - SHEAR * pivot, 0) box = bounds(glyph_sets, name, transform) if box[2] - box[0] <= 486 - left: lo = scale else: hi = scale scale = lo transform = Transform(scale, 0, SHEAR, 1, (1 - scale) * 250 - SHEAR * pivot, 0) box = bounds(glyph_sets, name, transform) report['narrowed'][name] = scale if box: shift = max(left - box[0], min(0, 486 - box[2])) transform = Transform(*transform[:4], transform.dx + shift, 0) transforms[name] = transform accented = {name for name in order if fonts[0]['glyf'][name].isComposite()} # An accented letter must keep exactly the same base as its unaccented form. for name in accented: base = fonts[0]['glyf'][name].components[0].glyphName transforms[name] = transforms[base] report['narrowed'].pop(name, None) for font in fonts: for name in order: glyph = font['glyf'][name] transform = transforms[name] if glyph.isComposite(): for component in glyph.components: child, original = component.getComponentInfo() matrix = transform.transform(original).transform(transforms[child].inverse()) component.transform = ((matrix.xx, matrix.xy), (matrix.yx, matrix.yy)) component.x, component.y = otRound(matrix.dx), otRound(matrix.dy) # The conjugated offsets are in parent coordinates. component.flags = (component.flags & ~0x0800) | 0x1000 elif glyph.numberOfContours: glyph.coordinates.transform(((transform.xx, transform.xy), (transform.yx, transform.yy))) glyph.coordinates.translate((transform.dx, transform.dy)) glyph.coordinates.toInt() # Apply each glyph's optical correction to both sides of mark attachment. for lookup in font['GPOS'].table.LookupList.Lookup: for table in lookup.SubTable: if lookup.LookupType == 4: groups = ((table.MarkCoverage.glyphs, table.MarkArray.MarkRecord, 'MarkAnchor'), (table.BaseCoverage.glyphs, table.BaseArray.BaseRecord, 'BaseAnchor')) elif lookup.LookupType == 6: groups = ((table.Mark1Coverage.glyphs, table.Mark1Array.MarkRecord, 'MarkAnchor'), (table.Mark2Coverage.glyphs, table.Mark2Array.Mark2Record, 'Mark2Anchor')) else: raise ValueError(f'Unsupported positioning lookup: {lookup.LookupType}') for names, records, field in groups: for name, record in zip(names, records): anchors = getattr(record, field) if not isinstance(anchors, list): anchors = [anchors] for anchor in anchors: if anchor: anchor.XCoordinate, anchor.YCoordinate = map(otRound, transforms[name].transformPoint( (anchor.XCoordinate, anchor.YCoordinate))) glyph_sets = [font.getGlyphSet() for font in fonts] for name in accented: count = len(fonts[0]['glyf'][name].components) for index in range(1, count): boxes = [] for font, glyphs in zip(fonts, glyph_sets): child, matrix = font['glyf'][name].components[index].getComponentInfo() boxes.append(bounds([glyphs], child, matrix)) left, right = min(b[0] for b in boxes), max(b[2] for b in boxes) shift = otRound(max(14 - left, min(0, 486 - right))) if shift: report['accent_shifts'][name] = shift for font in fonts: font['glyf'][name].components[index].x += shift for font, path, (style, weight) in zip(fonts, masters, MASTERS): for name in order: glyph = font['glyf'][name] glyph.recalcBounds(font['glyf']) font['hmtx'][name] = (font['hmtx'][name][0], getattr(glyph, 'xMin', 0)) name_oblique(font, style, weight) font.save(path) report['masters'] = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in masters} (output / 'manifest.json').write_text(json.dumps(report, indent=2, sort_keys=True) + '\n') print('Prepared four ten-degree oblique weight masters.') if __name__ == '__main__': main()