You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
vitepress/scripts/subsetFonts.py

248 lines
9.5 KiB

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["fonttools>=4.63", "brotli"]
# ///
"""Regenerate the default theme's Inter webfont subsets.
Reads the subset definitions from scripts/fontSubsets.json, downloads the
pinned Inter release (cached in node_modules/.cache/inter), verifies that
every non-PUA codepoint of the full font is covered by some subset, writes
the woff2 files into src/client/theme-default/fonts, and regenerates the
@font-face declarations in the theme's fonts.css.
Usage: scripts/subsetFonts.py
"""
import io
import json
import sys
import urllib.request
import zipfile
from pathlib import Path
from fontTools import subset
from fontTools.ttLib import TTFont
INTER_VERSION = "4.1"
ROOT = Path(__file__).resolve().parent.parent
SUBSETS_JSON = ROOT / "scripts" / "fontSubsets.json"
FONTS_DIR = ROOT / "src" / "client" / "theme-default" / "fonts"
FONTS_CSS = ROOT / "src" / "client" / "theme-default" / "styles" / "fonts.css"
CACHE_DIR = ROOT / "node_modules" / ".cache" / "inter"
STYLES = {
"italic": ("InterVariable-Italic.ttf", "italic"),
"roman": ("InterVariable.ttf", "normal"),
}
# Inter maps its internal alternate glyphs into the Private Use Area. These
# are intentionally not shipped: they are not real text, and declaring PUA
# unicode-ranges would let Inter shadow icon fonts that live there.
PUA = range(0xE000, 0xF8FF + 1)
BASE_VAR = """\
:root {
--vp-font-family-base:
'Inter', -apple-system, BlinkMacSystemFont, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
}
"""
# The 'Inter Core' faces leave the `cjkExclusions` characters uncovered so that
# they fall through to the next family in the stack, which therefore must be
# an explicit CJK font: a Latin system font (or `sans-serif`) would claim most
# of the excluded punctuation before the language-aware system fallback ever
# got a chance to pick the right CJK font. The names below cover the defaults
# of macOS / Windows / Linux (fonts-noto-cjk); families that are not installed
# are simply skipped, and sites can splice a CJK webfont between 'Inter Core'
# and the system families. Bare zh means Hans per BCP 47 likely subtags, and
# with no Traditional Chinese rule it also covers zh-Hant/zh-TW/zh-HK/zh-MO
# pages. If Hant handling is ever requested, add after the zh rule (same
# specificity, so the later rule wins; the region tags must be enumerated
# because `:lang(zh-Hant)` cannot match e.g. `lang="zh-TW"`):
# [lang]:where(:lang(zh-Hant), :lang(zh-TW), :lang(zh-HK), :lang(zh-MO)) {
# --vp-font-family-base:
# 'Inter Core', 'PingFang TC', 'Microsoft JhengHei', 'Noto Sans CJK TC',
# ...;
# }
# (Hong Kong variants also exist: 'PingFang HK' / 'Noto Sans CJK HK'.)
CJK_BASE_VAR = """\
[lang]:where(:lang(zh)) {
--vp-font-family-base:
'Inter Core', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC',
-apple-system, BlinkMacSystemFont, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
}
[lang]:where(:lang(ja)) {
--vp-font-family-base:
'Inter Core', 'Hiragino Sans', 'Meiryo', 'Yu Gothic', 'Noto Sans CJK JP',
-apple-system, BlinkMacSystemFont, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
}
[lang]:where(:lang(ko)) {
--vp-font-family-base:
'Inter Core', 'Apple SD Gothic Neo', 'Malgun Gothic', 'Noto Sans CJK KR',
-apple-system, BlinkMacSystemFont, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
}
"""
# used instead of everything else in this file when `useWebFonts` is enabled
# (only the content between the markers survives) - see
# src/node/plugins/webFontsPlugin.ts. There is no 'Inter Core' on Google Fonts,
# so CJK documents use the regular Inter there.
WEBFONT_IMPORT = f"""\
/* webfont-marker-begin */
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
{BASE_VAR}\
/* webfont-marker-end */
"""
# The `cjkExclusions` key of the json lists characters that have both a
# Western (proportional) and an East Asian (em-square) form with no
# encoding-level distinction - mostly East Asian Ambiguous punctuation and
# symbols (UAX #11). The generated 'Inter Core' faces reuse the same font files
# but leave these out of their unicode-ranges so that CJK fonts render them
# in CJK documents. References:
# https://www.unicode.org/L2/L2014/14006-sv-western-vs-cjk.pdf
# https://www.unicode.org/L2/L2018/18013-svs-proposal.pdf
# https://www.unicode.org/L2/L2018/18073-svs-proposal.pdf
# https://www.unicode.org/L2/L2023/23212r-quotes-svs-proposal.pdf
# https://github.com/w3c/clreq/blob/gh-pages/local.css
# & U+2015 (used like U+2014 in Japanese), U+203B (Japanese reference mark),
# U+007E (zh's request; both forms are fine in ja because it is unused there)
CJK_COMMENT = """\
/* 'Inter Core' reuses the files above, but leaves out characters that should
be rendered by CJK fonts in CJK documents - see scripts/subsetFonts.py */
"""
def parse_ranges(value: str) -> set[int]:
cps: set[int] = set()
for part in value.split(","):
lo, _, hi = part.strip().removeprefix("U+").partition("-")
cps.update(range(int(lo, 16), int(hi or lo, 16) + 1))
return cps
def format_ranges(cps: set[int]) -> str:
ranges: list[tuple[int, int]] = []
start = prev = -2
for cp in sorted(cps):
if cp != prev + 1:
if start >= 0:
ranges.append((start, prev))
start = cp
prev = cp
ranges.append((start, prev))
return ", ".join(
f"U+{lo:04X}" if lo == hi else f"U+{lo:04X}-{hi:04X}" for lo, hi in ranges
)
def download_release() -> Path:
release_dir = CACHE_DIR / f"Inter-{INTER_VERSION}"
zip_path = CACHE_DIR / f"Inter-{INTER_VERSION}.zip"
if not zip_path.exists():
url = f"https://github.com/rsms/inter/releases/download/v{INTER_VERSION}/Inter-{INTER_VERSION}.zip"
print(f"downloading {url}")
CACHE_DIR.mkdir(parents=True, exist_ok=True)
with urllib.request.urlopen(url) as res:
zip_path.write_bytes(res.read())
with zipfile.ZipFile(zip_path) as zf:
for file, _ in STYLES.values():
if not (release_dir / file).exists():
zf.extract(file, release_dir)
return release_dir
def check_coverage(release: Path, subsets: dict[str, str]) -> None:
covered = set().union(*(parse_ranges(v) for v in subsets.values()))
ok = True
for file, _ in STYLES.values():
cmap = set(TTFont(release / file).getBestCmap())
missing = sorted(cp for cp in cmap - covered if cp not in PUA)
if missing:
ok = False
print(f"{file}: {len(missing)} codepoints not covered by any subset:")
print(" " + ", ".join(f"U+{cp:04X}" for cp in missing))
if not ok:
sys.exit("add the missing codepoints to a subset in scripts/fontSubsets.json")
def build_subsets(release: Path, subsets: dict[str, str]) -> None:
for style, (file, _) in STYLES.items():
for name, value in subsets.items():
options = subset.Options()
options.flavor = "woff2"
options.layout_features = [*options.layout_features, "pnum", "tnum"]
font = subset.load_font(release / file, options)
subsetter = subset.Subsetter(options)
subsetter.populate(unicodes=parse_ranges(value))
subsetter.subset(font)
buf = io.BytesIO()
subset.save_font(font, buf, options)
out = FONTS_DIR / f"inter-{style}-{name}.woff2"
changed = not out.exists() or out.read_bytes() != buf.getvalue()
if changed:
out.write_bytes(buf.getvalue())
print(
f"{out.name:36} {font['maxp'].numGlyphs:4} glyphs "
f"{buf.tell() / 1024:6.1f} KB{'' if changed else ' (unchanged)'}"
)
def face(family: str, css_style: str, file: str, ranges: str) -> str:
return (
"@font-face {\n"
f" font-family: {family};\n"
f" font-style: {css_style};\n"
" font-weight: 100 900;\n"
" font-display: swap;\n"
f" src: url('../fonts/{file}') format('woff2');\n"
f" unicode-range: {ranges};\n"
"}\n"
)
def write_css(subsets: dict[str, str], cjk_exclusions: set[int]) -> None:
inter = []
cjk = []
for style, (_, css_style) in STYLES.items():
for name, value in subsets.items():
file = f"inter-{style}-{name}.woff2"
inter.append(face("Inter", css_style, file, value.replace(",", ", ")))
cjk_cps = parse_ranges(value) - cjk_exclusions
if cjk_cps:
cjk.append(
face("'Inter Core'", css_style, file, format_ranges(cjk_cps))
)
FONTS_CSS.write_text(
f"{WEBFONT_IMPORT}\n"
"/* Generated by scripts/subsetFonts.py from scripts/fontSubsets.json */\n\n"
+ "\n".join(inter)
+ f"\n{CJK_COMMENT}\n"
+ "\n".join(cjk)
+ f"\n{BASE_VAR}\n{CJK_BASE_VAR}"
)
def main() -> None:
subsets: dict[str, str] = json.loads(SUBSETS_JSON.read_text())
cjk_exclusions = parse_ranges(subsets.pop("cjkExclusions"))
release = download_release()
check_coverage(release, subsets)
build_subsets(release, subsets)
write_css(subsets, cjk_exclusions)
print(f"\nwrote {len(subsets) * len(STYLES)} subsets and {FONTS_CSS.relative_to(ROOT)}")
if __name__ == "__main__":
main()