145 lines
5.6 KiB
Python
145 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate the aspect-adjusted Montserrat webfonts used by the site.
|
|
|
|
The design calls for the typeface to be 110% as tall and 85% as wide as
|
|
the original Montserrat. CSS cannot do this: `font-stretch` only *selects*
|
|
a width variant from a family, it never scales, and Montserrat has no
|
|
width axis (its variable build only has `wght`). So the adjustment is baked
|
|
into the font files themselves.
|
|
|
|
Scaling a font's aspect ratio means changing the relationship between the
|
|
glyph *outlines* and the *advance widths*. If you scale the shapes but not
|
|
the advances, the browser still reserves the original width and every line
|
|
looks loosely spaced and wraps too early. So this script scales, in order:
|
|
|
|
1. every glyph outline coordinate (x * 0.85, y * 1.10)
|
|
2. every composite glyph component offset (e.g. the accent on "a-umlaut")
|
|
3. every advance width and side bearing (x * 0.85) <- affects layout
|
|
4. every vertical metric (ascent, descent, capHeight, xHeight) (y * 1.10)
|
|
|
|
Non-uniform scaling invalidates TrueType hinting, so the hinting programs
|
|
and the pixel-metric tables that reference the old coordinates are dropped.
|
|
|
|
Usage:
|
|
pip install "fonttools[woff]" # or: python3 -m venv .venv && ...
|
|
python3 tools/rescale-aspect.py
|
|
|
|
Pristine originals are cached in tools/_originals/ (git-ignored) and
|
|
downloaded from a PINNED Fontsource version, so re-running always yields
|
|
byte-identical output. The cache is disposable: delete it any time.
|
|
"""
|
|
|
|
import sys
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from fontTools.ttLib import TTFont
|
|
from fontTools.ttLib.tables.ttProgram import Program
|
|
except ImportError:
|
|
sys.exit('fontTools is missing. Install it with: pip install "fonttools[woff]"')
|
|
|
|
# ---- design parameters ----------------------------------------------------
|
|
SCALE_X = 0.85 # width -> 85%
|
|
SCALE_Y = 1.10 # height -> 110%
|
|
|
|
# Only the weights the site actually uses. Each unused weight is a file
|
|
# nobody downloads, so there is no reason to ship it.
|
|
WEIGHTS = (400, 700)
|
|
|
|
# Pinned so the output never silently changes under you.
|
|
FONTSOURCE_VERSION = "5.3.0"
|
|
CDN = f"https://cdn.jsdelivr.net/fontsource/fonts/montserrat@{FONTSOURCE_VERSION}"
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
CACHE = ROOT / "tools" / "_originals"
|
|
OUT = ROOT / "site" / "fonts"
|
|
|
|
# Tables whose contents are tied to the old coordinate space.
|
|
STALE_TABLES = ("hdmx", "LTSH", "VDMX", "gasp")
|
|
|
|
|
|
def fetch_original(weight: int) -> Path:
|
|
"""Download the pristine woff2 into the cache if it isn't there yet."""
|
|
CACHE.mkdir(parents=True, exist_ok=True)
|
|
dest = CACHE / f"montserrat-latin-{weight}-normal.woff2"
|
|
if not dest.exists():
|
|
url = f"{CDN}/latin-{weight}-normal.woff2"
|
|
print(f" downloading {url}")
|
|
with urllib.request.urlopen(url) as response:
|
|
dest.write_bytes(response.read())
|
|
return dest
|
|
|
|
|
|
def transform(src: Path, dst: Path) -> None:
|
|
font = TTFont(src)
|
|
glyf = font["glyf"]
|
|
order = font.getGlyphOrder()
|
|
|
|
# 1 + 2. outlines and composite offsets
|
|
for name in order:
|
|
g = glyf[name]
|
|
if g.isComposite():
|
|
# Every level's offset is scaled; nesting composes correctly
|
|
# because (0.85*a + 0.85*b) == 0.85 * (a + b).
|
|
for comp in g.components:
|
|
comp.x = round(comp.x * SCALE_X)
|
|
comp.y = round(comp.y * SCALE_Y)
|
|
elif g.numberOfContours > 0:
|
|
# Affine-scaling every point of a quadratic outline keeps it a
|
|
# valid quadratic, so no curve conversion is needed.
|
|
coords = g.coordinates
|
|
for i in range(len(coords)):
|
|
x, y = coords[i]
|
|
coords[i] = (x * SCALE_X, y * SCALE_Y)
|
|
g.program = Program() # hinting no longer matches the coordinates
|
|
|
|
# 3. horizontal metrics -- this is the part that makes layout reflow
|
|
hmtx = font["hmtx"]
|
|
for name in order:
|
|
advance, lsb = hmtx[name]
|
|
hmtx[name] = (round(advance * SCALE_X), round(lsb * SCALE_X))
|
|
|
|
# 4. vertical metrics, so the line box grows to match the taller glyphs
|
|
hhea, os2 = font["hhea"], font["OS/2"]
|
|
for table, attr in ((hhea, "ascent"), (hhea, "descent"), (hhea, "lineGap"),
|
|
(os2, "sTypoAscender"), (os2, "sTypoDescender"),
|
|
(os2, "sTypoLineGap"), (os2, "usWinAscent"),
|
|
(os2, "usWinDescent"),
|
|
(os2, "sCapHeight"), (os2, "sxHeight")):
|
|
value = getattr(table, attr, None)
|
|
if value is not None:
|
|
setattr(table, attr, round(value * SCALE_Y))
|
|
|
|
for table in STALE_TABLES:
|
|
if table in font:
|
|
del font[table]
|
|
|
|
font.recalcBBoxes = True
|
|
font.save(dst)
|
|
font.close()
|
|
|
|
|
|
def report(path: Path) -> None:
|
|
f = TTFont(path)
|
|
cmap = f.getBestCmap()
|
|
r = f["glyf"][cmap[ord("R")]]
|
|
umlaut = f["glyf"][cmap[ord("ä")]]
|
|
print(f" {path.name} ({path.stat().st_size:,} bytes)")
|
|
print(f" 'R' advance={f['hmtx']['R'][0]:4d} capHeight={f['OS/2'].sCapHeight}"
|
|
f" xHeight={f['OS/2'].sxHeight}")
|
|
print(f" umlauts still composite: {umlaut.isComposite()}"
|
|
f" glyphs: {f['maxp'].numGlyphs}")
|
|
f.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print(f"Montserrat {FONTSOURCE_VERSION} -> height {SCALE_Y:.0%}, width {SCALE_X:.0%}\n")
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
for weight in WEIGHTS:
|
|
original = fetch_original(weight)
|
|
target = OUT / f"montserrat-latin-{weight}-aspect.woff2"
|
|
transform(original, target)
|
|
report(target)
|
|
print("\nDone. Point your @font-face src: at the *-aspect.woff2 files.")
|