mirror of https://github.com/vuejs/vitepress
scripts/subsetFonts.py now regenerates the woff2 subsets and fonts.css from the ranges in scripts/fontSubsets.json (pinned Inter release, byte-reproducible builds): - a new lazily-loaded `symbols` subset covers every glyph of the full font that the existing subsets skipped (arrows, math, geometric shapes, enclosed alphanumerics, keyboard symbols, ...); U+2318 and U+21A9 live in the latin subset instead as the theme's chrome uses them - Inter4CJK faces are generated as the subset ranges minus East Asian Ambiguous punctuation/symbols so that CJK fonts render those in CJK documents, and now also apply to Korean - the base typography variables move into fonts.css next to the faces; webfont mode no longer declares the Inter4CJK override (there is no such family on Google Fonts), and the redundant `font-optical-sizing: auto` is gone - fix: the `:lang(zh, ja, ko)` selector list syntax is not supported in Chrome and silently matched nothing inside the forgiving `:where()`, so the CJK font handling and `line-break: strict` never applied there Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>pull/5335/head
parent
c2be0bf936
commit
705c1be0f8
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"cyrillic-ext": "U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F",
|
||||||
|
"cyrillic": "U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116",
|
||||||
|
"greek-ext": "U+1F00-1FFF",
|
||||||
|
"greek": "U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF",
|
||||||
|
"vietnamese": "U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB",
|
||||||
|
"latin-ext": "U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF",
|
||||||
|
"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+20AC,U+2122,U+2191,U+2193,U+21A9,U+2212,U+2215,U+2318,U+FEFF,U+FFFD",
|
||||||
|
"symbols": "U+02CD,U+02D8-02D9,U+02DB,U+0302,U+0306-0307,U+030A,U+030C,U+030F,U+0313,U+0315,U+031B,U+0326-0328,U+032C,U+0337-0338,U+0342-0343,U+0346-036F,U+0E3F,U+1DC0-1DF5,U+1DFC-1DFF,U+2070-2071,U+2074-208E,U+2090-209C,U+20DB-20DE,U+20E8,U+20F0,U+2100-2101,U+2103,U+2105-2106,U+2109,U+2117,U+211E-2121,U+2126,U+212A-212B,U+212E,U+2132,U+213B,U+214D,U+2150-217F,U+2183-2186,U+2189,U+2190,U+2192,U+2194-2199,U+21AA,U+21B0-21B1,U+21B3-21B5,U+21BA-21BB,U+21D0,U+21D2,U+21D4,U+21DE-21DF,U+21E4-21E5,U+21E7,U+21EA,U+2202,U+2205-2206,U+220F,U+2211,U+221A,U+221E,U+222B,U+2236,U+2248,U+2260,U+2264-2265,U+2295-2298,U+2303-2305,U+2325-2327,U+232B,U+2380,U+2387,U+238B,U+23CE-23CF,U+2423,U+2460-2468,U+24B6-24CF,U+24EA,U+25A0-25A2,U+25AA,U+25B2-25B3,U+25B6-25B7,U+25BA-25BD,U+25C0-25C1,U+25C4-25C7,U+25CA-25CB,U+25CF,U+25E6,U+25EF,U+2600,U+2605-2606,U+263C,U+2661,U+2665,U+266A-266B,U+26A0,U+2713,U+2717,U+2756,U+2764,U+2780-2788,U+27EF,U+27F5-27FA,U+2913,U+2A38,U+2B06,U+2B12-2B13,U+2B1C,U+2B24,U+2E18,U+A92E,U+1F12F-1F149,U+1F16A-1F16B,U+1F850,U+1F852",
|
||||||
|
"cjkExclusions": "U+007E,U+00B7,U+02C4,U+02C7,U+02C9-02CB,U+02CD,U+02D0,U+02D8-02D9,U+02DB,U+02DD,U+02DF,U+2013-2015,U+2018-2019,U+201C-201D,U+2026,U+203B,U+2074,U+207F,U+2081-2084,U+2103,U+2105,U+2109,U+2121,U+2126,U+212B,U+2153-2154,U+215B-215E,U+2160-216B,U+2170-2179,U+2189,U+2190,U+2192,U+2194-2199,U+21D2,U+21D4,U+21E7,U+2202,U+220F,U+2211,U+221A,U+221E,U+222B,U+2236,U+2248,U+2260,U+2264-2265,U+2295,U+2460-2468,U+24B6-24CF,U+25A0-25A1,U+25B2-25B3,U+25B6-25B7,U+25BC-25BD,U+25C0-25C1,U+25C6-25C7,U+25CB,U+25CF,U+25EF,U+2605-2606,U+2661,U+2665,U+266A,U+2B1C,U+1F130-1F149"
|
||||||
|
}
|
||||||
@ -0,0 +1,214 @@
|
|||||||
|
#!/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';
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
CJK_BASE_VAR = """\
|
||||||
|
[lang]:where(:lang(zh), :lang(ja), :lang(ko)) {
|
||||||
|
--vp-font-family-base:
|
||||||
|
'Inter4CJK', -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 Inter4CJK 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 Inter4CJK 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 = """\
|
||||||
|
/* Inter4CJK 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("Inter4CJK", 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()
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in new issue