95 lines
3.4 KiB
Python
95 lines
3.4 KiB
Python
#!/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 <webfont.woff2> [--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()
|