"""Cut the embedded web fonts from their upstream releases.
Downloads go to build/fonts/ and are checked against their SHA-256. Each
face becomes two WOFF2 files in assets/fonts/: a core that every page needs
and an extension the browser fetches only for a page with its characters.
assets/fonts.css gets the @font-face rules, with each file's unicode-range
read from the file itself. Needs fontTools with brotli, which make fonts
brings through uv.
"""
import hashlib
import os
import sys
import urllib.request
import zipfile
USAGE = """Usage:
subset.py [-C DIR]
subset.py -h | --help
subset.py -V | --version
Options:
-h --help Show this screen.
-V --version Show the version.
-C DIR Change to DIR first, the repo root [default: .].
"""
VERSION = "1"
STIX = "https://raw.githubusercontent.com/stipub/stixfonts/v2.13b171/fonts/static_otf/"
IOSEVKA = "https://github.com/be5invis/Iosevka/releases/download/v34.9.1/PkgTTF-Unhinted-Iosevka-34.9.1.zip"
SOURCES = {
"STIXTwoText-Regular.otf": (STIX + "STIXTwoText-Regular.otf", "c4864ca6ec071c2d31d0d8309001faa1ee3517fffb53a31a405a697b71f52ca1"),
"STIXTwoText-Italic.otf": (STIX + "STIXTwoText-Italic.otf", "cd64481fd10b69469074a3a8172fb5a22cb4116941d8aceafb8c77756b563df8"),
"STIXTwoText-Bold.otf": (STIX + "STIXTwoText-Bold.otf", "7ef76c666a6704f76ed3fa27bcdda55b36e558b5c2c93b49b03d854db96bdeb5"),
"STIXTwoText-BoldItalic.otf": (STIX + "STIXTwoText-BoldItalic.otf", "90e88f2ac79d6c00a5ce2d8446bfdf9457aed6afc1159cde33262979d82c8586"),
"iosevka.zip": (IOSEVKA, "46ab28e2948dc83b7a3e5e8792daf7e0528c76410bc5a6471cbd06339b34ad5f"),
}
# The page stacks name the installed font first and these families second,
# so a machine with the font never fetches a file.
FACES = [
("STIX Two Text web", "normal", 400, "STIXTwoText-Regular.otf", "STIXTwoText-Regular"),
("STIX Two Text web", "italic", 400, "STIXTwoText-Italic.otf", "STIXTwoText-Italic"),
("STIX Two Text web", "normal", 700, "STIXTwoText-Bold.otf", "STIXTwoText-Bold"),
("STIX Two Text web", "italic", 700, "STIXTwoText-BoldItalic.otf", "STIXTwoText-BoldItalic"),
("Iosevka web", "normal", 400, "iosevka.zip:Iosevka-Regular.ttf", "Iosevka-Regular"),
("Iosevka web", "italic", 400, "iosevka.zip:Iosevka-Italic.ttf", "Iosevka-Italic"),
("Iosevka web", "normal", 700, "iosevka.zip:Iosevka-Bold.ttf", "Iosevka-Bold"),
("Iosevka web", "italic", 700, "iosevka.zip:Iosevka-BoldItalic.ttf", "Iosevka-BoldItalic"),
]
def ranges(spec):
out = set()
for part in spec.split():
lo, _, hi = part.partition("-")
out.update(range(int(lo, 16), int(hi or lo, 16) + 1))
return out
# The core is Latin with its punctuation, box drawing for directory trees
# in code blocks, and the signs the program's own pages use: folds, the
# copy button's marks and the link to a repo's forge.
CORE = ranges("""
0000-00FF 0131 0152-0153 02BB-02BC 02C6 02DA 02DC 0304 0308 0329
2000-206F 20AC 2122 2190-2193 2212 2215 FEFF FFFD
2500-257F 25B8 25BE 2713 2717 2934 29C9
""")
# Iosevka covers far more than a reader of code needs, about 8 MB a face:
# its extension keeps European scripts and the symbol blocks code and
# terminal output use. STIX Two's extension is the whole rest of the font.
IOSEVKA_EXT = ranges("""
0100-052F 1E00-1EFF 2070-209F 20A0-20CF 2100-218F 2190-21FF
2200-23FF 2400-243F 2460-24FF 2500-25FF 2600-27BF 27C0-2BFF
""")
def usage(code):
print(USAGE, end="", file=sys.stderr if code else sys.stdout)
sys.exit(code)
def fail(code, msg):
print(f"E{code}: {msg}", file=sys.stderr)
sys.exit(code)
def fetch(name):
url, want = SOURCES[name]
p = os.path.join("build/fonts", name)
if not os.path.exists(p):
os.makedirs("build/fonts", exist_ok=True)
print(f"fetching {url}", file=sys.stderr)
urllib.request.urlretrieve(url, p + ".part")
os.rename(p + ".part", p)
with open(p, "rb") as f:
got = hashlib.sha256(f.read()).hexdigest()
if got != want:
fail(74, f"{p}: sha256 {got}, want {want}")
return p
def source(spec):
name, _, member = spec.partition(":")
p = fetch(name)
if not member:
return p
out = os.path.join("build/fonts", member)
if not os.path.exists(out):
with zipfile.ZipFile(p) as z, open(out, "wb") as f:
f.write(z.read(member))
return out
# span writes cps as a unicode-range. An extension's range may bridge a gap
# of up to gap code points the font lacks, which keeps the rule short; a
# page with such a character fetches the file once and falls back. It never
# bridges a code point in keep, the ones the core asks for: two faces of one
# family must not claim the same character, and a sign of the program's
# own that the font lacks, eg Iosevka's missing ⤴, must not fetch the
# extension on every page.
def span(cps, keep=set(), gap=0):
out, cps = [], sorted(cps)
lo = prev = cps[0]
for c in cps[1:] + [None]:
if c is not None and c - prev <= gap + 1 and not keep.intersection(range(prev + 1, c)):
prev = c
continue
out.append(f"U+{lo:X}" if lo == prev else f"U+{lo:X}-{prev:X}")
if c is not None:
lo = prev = c
return ", ".join(out)
def cut(src, out, cps, mono):
from fontTools import subset
from fontTools.ttLib import TTFont
opts = subset.Options()
opts.flavor = "woff2"
# Mono text sets font-variant-ligatures: none, so ligatures are dead
# weight there.
if mono:
opts.layout_features = [f for f in opts.layout_features if f not in ("liga", "clig", "calt", "dlig")]
# The source's timestamp, not the time of the cut, so a rerun makes
# the same bytes.
font = TTFont(src, recalcTimestamp=False)
have = set(font.getBestCmap())
s = subset.Subsetter(opts)
s.populate(unicodes=cps & have)
s.subset(font)
font.flavor = "woff2"
font.save(out)
return set(TTFont(out).getBestCmap())
def main(argv):
root = "."
args = list(argv)
while args:
a = args.pop(0)
if a in ("-h", "--help"):
usage(0)
elif a in ("-V", "--version"):
print(VERSION)
return
elif a == "-C" and args:
root = args.pop(0)
elif a.startswith("-C") and len(a) > 2:
root = a[2:]
else:
print(f"E22: unknown argument: {a}", file=sys.stderr)
usage(22)
os.chdir(root)
css = ["/* Made by assets/fonts/subset.py; edit that, not this. */\n"]
for family, style, weight, spec, base in FACES:
src = source(spec)
mono = family.startswith("Iosevka")
core = cut(src, f"assets/fonts/{base}.woff2", CORE, mono)
ext = cut(src, f"assets/fonts/{base}-ext.woff2", (IOSEVKA_EXT if mono else ranges("0000-10FFFF")) - core, mono)
for name, rng in ((base, span(core)), (base + "-ext", span(ext, CORE, 64))):
css.append(
"@font-face {\n"
f' font-family: "{family}";\n'
f" font-style: {style};\n"
f" font-weight: {weight};\n"
" font-display: swap;\n"
f' src: url("fonts/{name}.woff2") format("woff2");\n'
f" unicode-range: {rng};\n"
"}\n"
)
with open("assets/fonts.css", "w") as f:
f.write("".join(css))
if __name__ == "__main__":
main(sys.argv[1:])