Created
September 14, 2026 09:05
-
-
Save remy/e178c21675f13439d7fd74595ef45215 to your computer and use it in GitHub Desktop.
A few font modifiying tools
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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]) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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]) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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