mirror of https://github.com/vuejs/vitepress
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.
248 lines
9.5 KiB
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()
|