Skip to content

Instantly share code, notes, and snippets.

@remy
Created September 14, 2026 09:05
Show Gist options
  • Select an option

  • Save remy/e178c21675f13439d7fd74595ef45215 to your computer and use it in GitHub Desktop.

Select an option

Save remy/e178c21675f13439d7fd74595ef45215 to your computer and use it in GitHub Desktop.
A few font modifiying tools
#!/usr/bin/env python3
"""Add tabular figures (tnum) to a wght-only variable font.
Tabular width tracks the width of `zero` at every weight, so the new glyphs
reuse zero's HVAR entry and stay correct across the whole axis.
"""
import sys
from fontTools.ttLib import TTFont
from fontTools.ttLib.tables import otTables as ot
from fontTools.ttLib.tables._g_l_y_f import Glyph, GlyphComponent
from fontTools.ttLib.tables.TupleVariation import TupleVariation
from fontTools.varLib.varStore import VarStoreInstancer
DIGITS = "0123456789"
ARGS_ARE_XY_VALUES = 0x0002
def width_at(font, glyph, loc):
"""Advance width of `glyph` at a normalised location."""
adv = font["hmtx"][glyph][0]
hvar = font["HVAR"].table
idx = hvar.AdvWidthMap.mapping[glyph]
inst = VarStoreInstancer(hvar.VarStore, font["fvar"].axes, loc)
return adv + inst[idx]
def add_feature(gsub, tag, mapping):
"""Append a single-substitution feature. Assumes `tag` sorts last."""
sub = ot.SingleSubst()
sub.mapping = mapping
lookup = ot.Lookup()
lookup.LookupType, lookup.LookupFlag = 1, 0
lookup.SubTable, lookup.SubTableCount = [sub], 1
gsub.LookupList.Lookup.append(lookup)
gsub.LookupList.LookupCount = len(gsub.LookupList.Lookup)
lookup_idx = len(gsub.LookupList.Lookup) - 1
feat = ot.Feature()
feat.FeatureParams = None
feat.LookupListIndex, feat.LookupCount = [lookup_idx], 1
rec = ot.FeatureRecord()
rec.FeatureTag, rec.Feature = tag, feat
gsub.FeatureList.FeatureRecord.append(rec)
gsub.FeatureList.FeatureCount = len(gsub.FeatureList.FeatureRecord)
feat_idx = len(gsub.FeatureList.FeatureRecord) - 1
for sr in gsub.ScriptList.ScriptRecord:
langsyses = [sr.Script.DefaultLangSys] + [
r.LangSys for r in sr.Script.LangSysRecord
]
for ls in langsyses:
if ls is None:
continue
ls.FeatureIndex.append(feat_idx)
ls.FeatureCount = len(ls.FeatureIndex)
def build(src, dst):
font = TTFont(src)
cmap = font.getBestCmap()
glyf, gvar, hmtx = font["glyf"], font["gvar"], font["hmtx"]
names = {d: cmap[ord(d)] for d in DIGITS}
zero = names["0"]
w0_def = hmtx[zero][0]
w0_max = width_at(font, zero, {"wght": 1.0})
order = font.getGlyphOrder()
mapping = {}
for d in DIGITS:
base = names[d]
tab = base + ".tnum"
wd_def = hmtx[base][0]
wd_max = width_at(font, base, {"wght": 1.0})
dx_def = round((w0_def - wd_def) / 2)
dx_max = round((w0_max - wd_max) / 2)
comp = GlyphComponent()
comp.glyphName, comp.x, comp.y = base, dx_def, 0
comp.flags = ARGS_ARE_XY_VALUES
g = Glyph()
g.numberOfContours = -1
g.components = [comp]
glyf.glyphs[tab] = g
order.append(tab)
hmtx.metrics[tab] = (w0_def, hmtx[base][1] + dx_def)
# component offset + 4 phantom points; advance rides on HVAR
gvar.variations[tab] = [
TupleVariation(
{"wght": (0, 1, 1)},
[(dx_max - dx_def, 0), (0, 0), (round(w0_max - w0_def), 0), (0, 0), (0, 0)],
)
]
mapping[base] = tab
font.setGlyphOrder(order)
font["maxp"].numGlyphs = len(order)
hvar = font["HVAR"].table
for tab in mapping.values():
hvar.AdvWidthMap.mapping[tab] = hvar.AdvWidthMap.mapping[zero]
gdef = font["GDEF"].table
if gdef.GlyphClassDef is not None:
for base, tab in mapping.items():
gdef.GlyphClassDef.classDefs[tab] = gdef.GlyphClassDef.classDefs.get(base, 1)
add_feature(font["GSUB"].table, "tnum", mapping)
font.save(dst)
print(f"{dst}: tnum added ({len(mapping)} glyphs, tab width {w0_def}\u2192{w0_max})")
if __name__ == "__main__":
build(sys.argv[1], sys.argv[2])
#!/usr/bin/env python3
"""Add an XHGT (x-height) variation axis to a wght-only variable font."""
import sys, unicodedata, copy
from fontTools.ttLib import TTFont
from fontTools.ttLib.tables._f_v_a_r import Axis
from fontTools.ttLib.tables.TupleVariation import TupleVariation
from fontTools.varLib import instancer
XH = 618 # default x-height
A = 632 # top of lowercase overshoot zone
B = 800 # ascender / cap height: offset returns to zero here
XMIN, XMAX = 520, 700
TAG = "XHGT"
AXIS_NAME = "X-Height"
def offset(y, s):
"""Vertical displacement for a point at y, for x-height scale factor s."""
if y <= 0:
return 0
if y <= A:
return y * (s - 1)
if y < B:
return A * (s - 1) * (B - y) / (B - A)
return 0
def target_glyphs(font):
"""Lowercase letters, plus any glyph composed of them."""
cmap = font.getBestCmap()
names = set()
for cp, name in cmap.items():
try:
if unicodedata.category(chr(cp)) == "Ll":
names.add(name)
except ValueError:
pass
glyf = font["glyf"]
changed = True
while changed: # pull in composites whose base is lowercase
changed = False
for name in font.getGlyphOrder():
if name in names:
continue
g = glyf[name]
if g.isComposite() and any(c.glyphName in names for c in g.components):
names.add(name)
changed = True
return names & set(font.getGlyphOrder())
def deltas_for(glyf, name, s):
"""Per-point (dx, dy) deltas, including the 4 phantom points."""
g = glyf[name]
if g.numberOfContours == 0:
return None
if g.isComposite():
out = []
for c in g.components:
# base sits on the baseline and inherits its own variation;
# anything raised above the lowercase zone rides the x-height
dy = offset(A, s) if c.y > XH * 0.5 else 0
out.append((0, round(dy)))
return out + [(0, 0)] * 4
coords, _, _ = g.getCoordinates(glyf)
return [(0, round(offset(y, s))) for _, y in coords] + [(0, 0)] * 4
def sub(a, b):
return [(x1 - x2, y1 - y2) for (x1, y1), (x2, y2) in zip(a, b)]
def build(src, dst, family_suffix="XH"):
font = TTFont(src)
fvar = font["fvar"]
wmax = next(a for a in fvar.axes if a.axisTag == "wght").maxValue
bold = instancer.instantiateVariableFont(TTFont(src), {"wght": wmax})
glyf, glyf_b = font["glyf"], bold["glyf"]
gvar = font["gvar"]
names = target_glyphs(font)
s_up, s_dn = XMAX / XH, XMIN / XH
for name in sorted(names):
d_up = deltas_for(glyf, name, s_up)
if d_up is None:
continue
d_dn = deltas_for(glyf, name, s_dn)
b_up = deltas_for(glyf_b, name, s_up)
b_dn = deltas_for(glyf_b, name, s_dn)
vs = gvar.variations.setdefault(name, [])
vs.append(TupleVariation({TAG: (0, 1, 1)}, d_up))
vs.append(TupleVariation({TAG: (-1, -1, 0)}, d_dn))
if b_up is not None and len(b_up) == len(d_up):
vs.append(TupleVariation({TAG: (0, 1, 1), "wght": (0, 1, 1)}, sub(b_up, d_up)))
vs.append(TupleVariation({TAG: (-1, -1, 0), "wght": (0, 1, 1)}, sub(b_dn, d_dn)))
# fvar
ax = Axis()
ax.axisTag = TAG
ax.minValue, ax.defaultValue, ax.maxValue = XMIN, XH, XMAX
ax.axisNameID = font["name"].addMultilingualName({"en": AXIS_NAME}, font)
fvar.axes.append(ax)
for inst in fvar.instances:
inst.coordinates[TAG] = XH
# avar: identity map for the new axis
if "avar" in font:
font["avar"].segments[TAG] = {}
# STAT
if "STAT" in font:
st = font["STAT"].table
from fontTools.ttLib.tables.otTables import AxisRecord
rec = AxisRecord()
rec.AxisTag = TAG
rec.AxisNameID = ax.axisNameID
rec.AxisOrdering = len(st.DesignAxisRecord.Axis)
st.DesignAxisRecord.Axis.append(rec)
st.DesignAxisCount = len(st.DesignAxisRecord.Axis)
# rename so it does not collide with stock Victor Mono
if family_suffix:
nm = font["name"]
for rec in nm.names:
if rec.nameID in (1, 3, 4, 6, 16):
v = str(rec)
if rec.nameID == 6:
v = v.replace("VictorMono", f"VictorMono{family_suffix}", 1)
else:
v = v.replace("Victor Mono", f"Victor Mono {family_suffix}", 1)
rec.string = v
font.save(dst)
print(f"{dst}: {len(names)} glyphs given {TAG} deltas")
if __name__ == "__main__":
build(sys.argv[1], sys.argv[2])
set -e
LATIN="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD"
LATEXT="U+0100-02AF,U+0304,U+0308,U+0329,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF"
for f in NotoSerif NotoSerif-Italic; do
# 1. latin only, both axes
pyftsubset /mnt/user-data/uploads/$f.woff2 --output-file=$f.latin.woff2 --flavor=woff2 --unicodes="$LATIN" --layout-features='kern,liga,calt,ccmp,locl,mark,mkmk,frac,onum,tnum' --no-hinting --desubroutinize 2>/dev/null
# 2. latin + latin-ext, both axes
pyftsubset /mnt/user-data/uploads/$f.woff2 --output-file=$f.latinext.woff2 --flavor=woff2 --unicodes="$LATIN,$LATEXT" --layout-features='kern,liga,calt,ccmp,locl,mark,mkmk,frac,onum,tnum' --no-hinting --desubroutinize 2>/dev/null
# 3. drop wdth axis first, then subset latin
fonttools varLib.instancer /mnt/user-data/uploads/$f.woff2 wdth=100 -o $f.nowdth.ttf 2>/dev/null
pyftsubset $f.nowdth.ttf --output-file=$f.latin.nowdth.woff2 --flavor=woff2 --unicodes="$LATIN" --layout-features='kern,liga,calt,ccmp,locl,mark,mkmk' --no-hinting 2>/dev/null
pyftsubset $f.nowdth.ttf --output-file=$f.latinext.nowdth.woff2 --flavor=woff2 --unicodes="$LATIN,$LATEXT" --layout-features='kern,liga,calt,ccmp,locl,mark,mkmk' --no-hinting 2>/dev/null
# 4. also clamp weight range 300-700
fonttools varLib.instancer $f.nowdth.ttf wght=300:700 -o $f.clamp.ttf 2>/dev/null
pyftsubset $f.clamp.ttf --output-file=$f.latin.clamp.woff2 --flavor=woff2 --unicodes="$LATIN" --layout-features='kern,liga,calt,ccmp,locl,mark,mkmk' --no-hinting 2>/dev/null
done
ls -la *.woff2 | awk '{printf "%-42s %8.1f KB\n", $9, $5/1024}'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment