#!/usr/bin/env python3 """Print metric-override fallback @font-face blocks for a web font. The blocks make a local system font (Arial, Roboto) occupy the same space as the web font, so switching from the fallback to the web font on load does not shift layout (CLS). Usage: python3 font-metrics.py [--weight 500] [--name Montserrat] [--weights "400 800"] Requires Python 3 with fonttools and brotli: pip install fonttools brotli """ import argparse import sys from fontTools.ttLib import TTFont # Advance-width metrics of common fallback fonts, measured over the same # character set as the web font (a-z A-Z 0-9 space), normalised to unitsPerEm. # The ascent/descent/lineGap values are not used: only the average width matters # for size-adjust. Keys are the local() src names. FALLBACKS = { 'Arial': {'local': 'Arial', 'suffix': '', 'avg': 0.5742885044642857}, 'Roboto': {'local': 'Roboto', 'suffix': ' Android', 'avg': 0.5608646453373016}, } CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ' def web_metrics(path, weight): font = TTFont(path) if weight is not None and 'fvar' in font: from fontTools.varLib import instancer font = instancer.instantiateVariableFont(font, {'wght': weight}, inplace=False) head = font['head'] hhea = font['hhea'] cmap = font.getBestCmap() hmtx = font['hmtx'] upm = head.unitsPerEm widths = [hmtx[cmap[ord(c)]][0] / upm for c in CHARS if ord(c) in cmap] return { 'ascent': hhea.ascent / upm, 'descent': abs(hhea.descent) / upm, 'lineGap': hhea.lineGap / upm, 'avg': sum(widths) / len(widths), } def family_name(path): font = TTFont(path, lazy=True) for id_ in (16, 1): name = font['name'].getDebugName(id_) if name: return name.split()[0] return 'WebFont' def pct(value): text = f'{value:.2f}'.rstrip('0').rstrip('.') return f'{text}%' def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('webfont', help='path to the web font file (woff2/ttf/otf)') parser.add_argument('--weight', type=int, default=500, help='weight to measure variable fonts at (default: 500)') parser.add_argument('--name', default=None, help='font family name used in the fallback family names') parser.add_argument('--weights', default='400 800', help='font-weight value for the fallback rules (default: "400 800")') args = parser.parse_args() web = web_metrics(args.webfont, args.weight) name = args.name or family_name(args.webfont) for key, fb in FALLBACKS.items(): size_adjust = web['avg'] / fb['avg'] if not size_adjust: print(f'warning: empty width for {key}', file=sys.stderr) continue print('@font-face {') print(f' ascent-override: {pct(web["ascent"] / size_adjust * 100)};') print(f' descent-override: {pct(web["descent"] / size_adjust * 100)};') print(f" font-family: '{name} Fallback{fb['suffix']}';") print(f' font-weight: {args.weights};') print(f' line-gap-override: {pct(web["lineGap"] / size_adjust * 100)};') print(f' size-adjust: {pct(size_adjust * 100)};') print(f" src: local('{fb['local']}');") print('}') print() if __name__ == '__main__': main()