mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
1716 lines
61 KiB
Python
1716 lines
61 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import statistics
|
|
import sys
|
|
import tarfile
|
|
import urllib.parse
|
|
import urllib.request
|
|
import zipfile
|
|
from concurrent.futures import ProcessPoolExecutor
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Union
|
|
|
|
from fontTools import unicodedata as ftunicodedata
|
|
from fontTools.pens.recordingPen import DecomposingRecordingPen
|
|
from fontTools.subset import Options as SubsetOptions
|
|
from fontTools.subset import Subsetter
|
|
from fontTools.ttLib import TTFont
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
PACKAGE_ROOT = REPO_ROOT / "packages" / "fonts"
|
|
FILES_DIR = PACKAGE_ROOT / "files"
|
|
CSS_DIR = PACKAGE_ROOT / "css"
|
|
MANIFEST_PATH = PACKAGE_ROOT / "manifest.json"
|
|
TOOL_ROOT = Path(__file__).resolve().parent
|
|
LOCK_PATH = TOOL_ROOT / "fonts.lock.json"
|
|
|
|
SPDX_CSS_HEADER = "/* SPDX-License-Identifier: AGPL-3.0-or-later */"
|
|
GENERATED_CSS_HEADER = "/* Generated by tools/fonts/build_fonts.py. Do not edit by hand. */"
|
|
|
|
CHUNK_TARGET_BYTES = 64 * 1024
|
|
MIN_STANDALONE_BLOCK = 256
|
|
|
|
PLEX_GASP = {8: 10, 65535: 15}
|
|
CJK_GASP = {7: 10, 65535: 15}
|
|
|
|
IDENTITY_NAME_IDS = (1, 3, 4, 6, 16, 17, 18, 20, 21, 22, 25)
|
|
RENAME_NAME_IDS = IDENTITY_NAME_IDS
|
|
NO_PLEX_NAME_IDS = IDENTITY_NAME_IDS
|
|
|
|
MUTABLE_TABLES = frozenset({"gasp", "name", "head"})
|
|
CRITICAL_TABLES = (
|
|
"glyf",
|
|
"loca",
|
|
"fpgm",
|
|
"prep",
|
|
"cvt ",
|
|
"cmap",
|
|
"OS/2",
|
|
"hhea",
|
|
"hmtx",
|
|
"maxp",
|
|
"post",
|
|
"GDEF",
|
|
"GPOS",
|
|
"GSUB",
|
|
)
|
|
HEAD_CHECKSUM_SLICE = slice(8, 12)
|
|
|
|
HINTING_TABLES = ("fpgm", "prep", "cvt ")
|
|
|
|
METRIC_FIELDS: tuple[tuple[str, str], ...] = (
|
|
("head", "unitsPerEm"),
|
|
("head", "macStyle"),
|
|
("hhea", "ascender"),
|
|
("hhea", "descender"),
|
|
("hhea", "lineGap"),
|
|
("hhea", "caretSlopeRise"),
|
|
("hhea", "caretSlopeRun"),
|
|
("OS/2", "sTypoAscender"),
|
|
("OS/2", "sTypoDescender"),
|
|
("OS/2", "sTypoLineGap"),
|
|
("OS/2", "usWinAscent"),
|
|
("OS/2", "usWinDescent"),
|
|
("OS/2", "sxHeight"),
|
|
("OS/2", "sCapHeight"),
|
|
("OS/2", "usWeightClass"),
|
|
("OS/2", "fsSelection"),
|
|
("vhea", "ascent"),
|
|
("vhea", "descent"),
|
|
("vhea", "lineGap"),
|
|
)
|
|
|
|
UPRIGHT_WEIGHTS = (
|
|
"Thin",
|
|
"ExtraLight",
|
|
"Light",
|
|
"Regular",
|
|
"Text",
|
|
"Medium",
|
|
"SemiBold",
|
|
"Bold",
|
|
)
|
|
ITALIC_WEIGHTS = (
|
|
"ThinItalic",
|
|
"ExtraLightItalic",
|
|
"LightItalic",
|
|
"Italic",
|
|
"TextItalic",
|
|
"MediumItalic",
|
|
"SemiBoldItalic",
|
|
"BoldItalic",
|
|
)
|
|
|
|
CSS_WEIGHTS: dict[str, int] = {
|
|
"Thin": 100,
|
|
"ExtraLight": 200,
|
|
"Light": 300,
|
|
"Regular": 400,
|
|
"Text": 450,
|
|
"Medium": 500,
|
|
"SemiBold": 600,
|
|
"Bold": 700,
|
|
"ExtraBold": 800,
|
|
}
|
|
|
|
|
|
def css_weight(weight: str) -> int:
|
|
upright = (
|
|
weight[: -len("Italic")] if weight.endswith("Italic") and weight != "Italic" else weight
|
|
)
|
|
if upright == "Italic":
|
|
upright = "Regular"
|
|
return CSS_WEIGHTS[upright]
|
|
|
|
|
|
def css_style(weight: str) -> str:
|
|
return "italic" if weight.endswith("Italic") else "normal"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NpmSource:
|
|
package: str
|
|
version: str
|
|
|
|
@property
|
|
def label(self) -> str:
|
|
return f"{self.package}@{self.version}"
|
|
|
|
def provenance(self) -> dict[str, str]:
|
|
return {"type": "npm", "package": self.package, "version": self.version}
|
|
|
|
def fetch(self, cache: Path) -> Path:
|
|
root = cache / f"npm-{self.package.replace('/', '-').lstrip('@')}-{self.version}"
|
|
marker = root / ".complete"
|
|
meta_url = f"https://registry.npmjs.org/{self.package}/{self.version}"
|
|
if not marker.exists():
|
|
meta = json.loads(_http_get(meta_url))
|
|
tarball = _http_get(meta["dist"]["tarball"])
|
|
_check_integrity(meta["dist"]["integrity"], tarball, self.label)
|
|
_extract_tar(tarball, root)
|
|
marker.write_text(hashlib.sha256(tarball).hexdigest() + "\n")
|
|
return root / "package"
|
|
|
|
def artifact_sha256(self, cache: Path) -> str:
|
|
root = cache / f"npm-{self.package.replace('/', '-').lstrip('@')}-{self.version}"
|
|
return (root / ".complete").read_text().strip()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GithubReleaseSource:
|
|
repo: str
|
|
tag: str
|
|
asset: str
|
|
|
|
@property
|
|
def label(self) -> str:
|
|
return f"{self.repo}@{self.tag}:{self.asset}"
|
|
|
|
def provenance(self) -> dict[str, str]:
|
|
return {
|
|
"type": "github-release",
|
|
"repo": self.repo,
|
|
"tag": self.tag,
|
|
"asset": self.asset,
|
|
}
|
|
|
|
def _root(self, cache: Path) -> Path:
|
|
slug = re.sub(r"[^A-Za-z0-9]+", "-", f"{self.repo}-{self.tag}").strip("-")
|
|
return cache / f"gh-{slug}"
|
|
|
|
def fetch(self, cache: Path) -> Path:
|
|
root = self._root(cache)
|
|
marker = root / ".complete"
|
|
if not marker.exists():
|
|
url = (
|
|
f"https://github.com/{self.repo}/releases/download/"
|
|
f"{urllib.parse.quote(self.tag, safe='/')}/{self.asset}"
|
|
)
|
|
blob = _http_get(url)
|
|
_extract_zip(blob, root)
|
|
marker.write_text(hashlib.sha256(blob).hexdigest() + "\n")
|
|
return root
|
|
|
|
def artifact_sha256(self, cache: Path) -> str:
|
|
return (self._root(cache) / ".complete").read_text().strip()
|
|
|
|
|
|
Source = Union[NpmSource, GithubReleaseSource]
|
|
|
|
SOURCES: dict[str, Source] = {
|
|
"plex-sans": NpmSource("@ibm/plex-sans", "1.1.0"),
|
|
"plex-mono": NpmSource("@ibm/plex-mono", "2.5.0"),
|
|
"plex-sans-arabic": NpmSource("@ibm/plex-sans-arabic", "1.1.0"),
|
|
"plex-sans-devanagari": NpmSource("@ibm/plex-sans-devanagari", "1.1.0"),
|
|
"plex-sans-hebrew": NpmSource("@ibm/plex-sans-hebrew", "1.1.0"),
|
|
"plex-sans-thai-looped": NpmSource("@ibm/plex-sans-thai-looped", "1.1.0"),
|
|
"plex-sans-jp": NpmSource("@ibm/plex-sans-jp", "3.0.0"),
|
|
"plex-sans-sc": NpmSource("@ibm/plex-sans-sc", "1.1.0"),
|
|
"plex-sans-tc": NpmSource("@ibm/plex-sans-tc", "1.1.1"),
|
|
"plex-sans-kr": GithubReleaseSource(
|
|
"IBM/plex", "@ibm/[email protected]", "ibm-plex-sans-kr.zip"
|
|
),
|
|
}
|
|
|
|
|
|
REBUILD = "rebuild"
|
|
SUBSET = "subset"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Family:
|
|
out_dir: str
|
|
prefix: str
|
|
weights: tuple[str, ...]
|
|
source: str
|
|
src_subdir: str
|
|
mode: str
|
|
gasp: dict[int, int]
|
|
css_family: str
|
|
stylesheet: str
|
|
|
|
def source_files(self) -> list[str]:
|
|
return [f"{self.prefix}-{w}.woff2" for w in self.weights]
|
|
|
|
|
|
LATIN_WEIGHTS = UPRIGHT_WEIGHTS + ITALIC_WEIGHTS
|
|
PLEX_WOFF2 = "fonts/complete/woff2"
|
|
|
|
LATIN_CORE_STYLESHEETS = ("fluxer-sans.css", "fluxer-mono.css")
|
|
|
|
ALWAYS_BUNDLED_LATIN_FAMILIES: tuple[str, ...] = ("Fluxer Sans", "Fluxer Mono")
|
|
|
|
FAMILIES: tuple[Family, ...] = (
|
|
Family(
|
|
"FluxerSans",
|
|
"IBMPlexSans",
|
|
LATIN_WEIGHTS,
|
|
"plex-sans",
|
|
PLEX_WOFF2,
|
|
REBUILD,
|
|
PLEX_GASP,
|
|
"Fluxer Sans",
|
|
"fluxer-sans.css",
|
|
),
|
|
Family(
|
|
"FluxerMono",
|
|
"IBMPlexMono",
|
|
LATIN_WEIGHTS,
|
|
"plex-mono",
|
|
PLEX_WOFF2,
|
|
REBUILD,
|
|
PLEX_GASP,
|
|
"Fluxer Mono",
|
|
"fluxer-mono.css",
|
|
),
|
|
Family(
|
|
"FluxerSansArabic",
|
|
"IBMPlexSansArabic",
|
|
UPRIGHT_WEIGHTS,
|
|
"plex-sans-arabic",
|
|
PLEX_WOFF2,
|
|
REBUILD,
|
|
PLEX_GASP,
|
|
"Fluxer Sans Arabic",
|
|
"script-arabic.css",
|
|
),
|
|
Family(
|
|
"FluxerSansDevanagari",
|
|
"IBMPlexSansDevanagari",
|
|
UPRIGHT_WEIGHTS,
|
|
"plex-sans-devanagari",
|
|
PLEX_WOFF2,
|
|
REBUILD,
|
|
PLEX_GASP,
|
|
"Fluxer Sans Devanagari",
|
|
"script-devanagari.css",
|
|
),
|
|
Family(
|
|
"FluxerSansHebrew",
|
|
"IBMPlexSansHebrew",
|
|
UPRIGHT_WEIGHTS,
|
|
"plex-sans-hebrew",
|
|
PLEX_WOFF2,
|
|
REBUILD,
|
|
PLEX_GASP,
|
|
"Fluxer Sans Hebrew",
|
|
"script-hebrew.css",
|
|
),
|
|
Family(
|
|
"FluxerSansThaiLooped",
|
|
"IBMPlexSansThaiLooped",
|
|
UPRIGHT_WEIGHTS,
|
|
"plex-sans-thai-looped",
|
|
PLEX_WOFF2,
|
|
REBUILD,
|
|
PLEX_GASP,
|
|
"Fluxer Sans Thai Looped",
|
|
"script-thai-looped.css",
|
|
),
|
|
Family(
|
|
"FluxerSansJP",
|
|
"IBMPlexSansJP",
|
|
UPRIGHT_WEIGHTS,
|
|
"plex-sans-jp",
|
|
f"{PLEX_WOFF2}/hinted",
|
|
SUBSET,
|
|
CJK_GASP,
|
|
"Fluxer Sans JP",
|
|
"script-jp.css",
|
|
),
|
|
Family(
|
|
"FluxerSansSC",
|
|
"IBMPlexSansSC",
|
|
UPRIGHT_WEIGHTS,
|
|
"plex-sans-sc",
|
|
f"{PLEX_WOFF2}/hinted",
|
|
SUBSET,
|
|
CJK_GASP,
|
|
"Fluxer Sans SC",
|
|
"script-sc.css",
|
|
),
|
|
Family(
|
|
"FluxerSansTC",
|
|
"IBMPlexSansTC",
|
|
UPRIGHT_WEIGHTS,
|
|
"plex-sans-tc",
|
|
f"{PLEX_WOFF2}/hinted",
|
|
SUBSET,
|
|
CJK_GASP,
|
|
"Fluxer Sans TC",
|
|
"script-tc.css",
|
|
),
|
|
Family(
|
|
"FluxerSansKR",
|
|
"IBMPlexSansKR",
|
|
UPRIGHT_WEIGHTS,
|
|
"plex-sans-kr",
|
|
"ibm-plex-sans-kr/fonts/complete/woff2/unhinted",
|
|
SUBSET,
|
|
PLEX_GASP,
|
|
"Fluxer Sans KR",
|
|
"script-kr.css",
|
|
),
|
|
)
|
|
|
|
FAMILY_BY_DIR = {family.out_dir: family for family in FAMILIES}
|
|
|
|
KANA_OWNED_BY_JP = frozenset(range(0x3040, 0x3100))
|
|
|
|
WITHHELD_CODEPOINTS: dict[str, frozenset[int]] = {
|
|
"FluxerSansSC": KANA_OWNED_BY_JP,
|
|
"FluxerSansTC": KANA_OWNED_BY_JP,
|
|
}
|
|
|
|
SOURCE_EXPECTATIONS: dict[str, dict[str, object]] = {
|
|
"FluxerSansKR": {
|
|
"sfntVersion": "\x00\x01\x00\x00",
|
|
"tables": {"fpgm": 682, "prep": 329, "cvt ": 46},
|
|
"numGlyphs": 12283,
|
|
"reference_file": "IBMPlexSansKR-Regular.woff2",
|
|
"reference_glyf": 2161193,
|
|
},
|
|
}
|
|
|
|
ROOT_VARIABLES_TEMPLATE = """:root {{
|
|
\t--font-sans:
|
|
\t\t{sans};
|
|
\t--font-mono: 'Fluxer Mono', 'Menlo', 'Monaco', 'Courier New', monospace;
|
|
}}
|
|
"""
|
|
|
|
LOCALE_FALLBACKS_COMMENT = ""
|
|
|
|
SCRIPT_FAMILY_ORDER: tuple[str, ...] = (
|
|
"Fluxer Sans Arabic",
|
|
"Fluxer Sans Hebrew",
|
|
"Fluxer Sans Devanagari",
|
|
"Fluxer Sans Thai Looped",
|
|
"Fluxer Sans SC",
|
|
"Fluxer Sans TC",
|
|
"Fluxer Sans JP",
|
|
"Fluxer Sans KR",
|
|
)
|
|
|
|
GENERIC_UI_FACES: tuple[str, ...] = ("system-ui", "-apple-system", "BlinkMacSystemFont")
|
|
|
|
|
|
LOCALE_FALLBACKS: tuple[tuple[tuple[str, ...], str, tuple[str, ...]], ...] = (
|
|
(
|
|
("ja",),
|
|
"Fluxer Sans JP",
|
|
("Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", "Meiryo"),
|
|
),
|
|
(("ko",), "Fluxer Sans KR", ("Malgun Gothic", "Apple SD Gothic Neo")),
|
|
(("zh-CN", "zh-Hans"), "Fluxer Sans SC", ("PingFang SC", "Microsoft YaHei")),
|
|
(("zh-Hant-TW", "zh-TW"), "Fluxer Sans TC", ("PingFang TC", "Microsoft JhengHei")),
|
|
(("zh-HK", "zh-Hant-HK"), "Fluxer Sans TC", ("PingFang HK", "Microsoft JhengHei")),
|
|
(("ar",), "Fluxer Sans Arabic", ("Segoe UI", "Geeza Pro", "Arabic Typesetting")),
|
|
(("he",), "Fluxer Sans Hebrew", ("Segoe UI", "Arial Hebrew")),
|
|
(("hi", "mr", "ne", "sa"), "Fluxer Sans Devanagari", ("Nirmala UI", "Noto Sans Devanagari")),
|
|
(("th",), "Fluxer Sans Thai Looped", ("Leelawadee UI", "Thonburi")),
|
|
)
|
|
|
|
|
|
def font_stack(preferred: str | None, os_faces: tuple[str, ...]) -> list[str]:
|
|
scripts = [preferred] if preferred else []
|
|
scripts += [family for family in SCRIPT_FAMILY_ORDER if family != preferred]
|
|
return [
|
|
quote_family("Fluxer Sans"),
|
|
*(quote_family(family) for family in scripts),
|
|
*GENERIC_UI_FACES,
|
|
*(quote_family(face) for face in os_faces),
|
|
"sans-serif",
|
|
]
|
|
|
|
|
|
def quote_family(name: str) -> str:
|
|
return f"'{name}'"
|
|
|
|
|
|
def wrap_font_stack(entries: list[str], indent: str, width: int = 118) -> str:
|
|
lines: list[str] = []
|
|
current = ""
|
|
for index, entry in enumerate(entries):
|
|
piece = entry if index == len(entries) - 1 else f"{entry},"
|
|
candidate = f"{current} {piece}" if current else piece
|
|
if current and len(indent) + len(candidate) > width:
|
|
lines.append(current)
|
|
current = piece
|
|
else:
|
|
current = candidate
|
|
if current:
|
|
lines.append(current)
|
|
return f"\n{indent}".join(lines)
|
|
|
|
|
|
BASE_UI_FACES: tuple[str, ...] = ("Segoe UI", "Roboto")
|
|
|
|
|
|
def render_variables_stylesheet() -> str:
|
|
stack = wrap_font_stack(font_stack(None, BASE_UI_FACES), "\t\t")
|
|
body = ROOT_VARIABLES_TEMPLATE.format(sans=stack)
|
|
return "\n".join([SPDX_CSS_HEADER, GENERATED_CSS_HEADER, "", body])
|
|
|
|
|
|
def render_locale_fallbacks_stylesheet() -> str:
|
|
lines = [SPDX_CSS_HEADER, GENERATED_CSS_HEADER, "", LOCALE_FALLBACKS_COMMENT]
|
|
for langs, preferred, os_faces in LOCALE_FALLBACKS:
|
|
selector = ",\n".join(f":root:lang({lang})" for lang in langs)
|
|
stack = wrap_font_stack(font_stack(preferred, os_faces), "\t\t")
|
|
lines.append(f"{selector} {{\n\t--font-sans:\n\t\t{stack};\n}}")
|
|
return "\n".join([*lines, ""])
|
|
|
|
|
|
def _http_get(url: str) -> bytes:
|
|
request = urllib.request.Request(url, headers={"User-Agent": "fluxer-font-build"})
|
|
with urllib.request.urlopen(request) as response: # noqa: S310 - pinned https URLs
|
|
return response.read()
|
|
|
|
|
|
def _check_integrity(integrity: str, blob: bytes, label: str) -> None:
|
|
algo, _, encoded = integrity.partition("-")
|
|
actual = base64.b64encode(hashlib.new(algo, blob).digest()).decode()
|
|
if actual != encoded:
|
|
raise SystemExit(f"integrity mismatch for {label}: expected {encoded}, got {actual}")
|
|
|
|
|
|
def _extract_tar(blob: bytes, root: Path) -> None:
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
root.mkdir(parents=True)
|
|
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as archive:
|
|
for member in archive.getmembers():
|
|
_extract_member(archive, member, member.name, root, member.isdir())
|
|
|
|
|
|
def _extract_zip(blob: bytes, root: Path) -> None:
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
root.mkdir(parents=True)
|
|
with zipfile.ZipFile(io.BytesIO(blob)) as archive:
|
|
for info in archive.infolist():
|
|
_extract_member(archive, info, info.filename, root, info.is_dir())
|
|
|
|
|
|
def _extract_member(archive: object, member: object, name: str, root: Path, is_dir: bool) -> None:
|
|
dest = (root / name).resolve()
|
|
if not str(dest).startswith(str(root.resolve())):
|
|
raise SystemExit(f"refusing to extract outside the cache: {name}")
|
|
if is_dir:
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
return
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
reader = (
|
|
archive.extractfile(member)
|
|
if isinstance(archive, tarfile.TarFile)
|
|
else archive.open(member)
|
|
)
|
|
if reader is None:
|
|
return
|
|
with reader:
|
|
dest.write_bytes(reader.read())
|
|
|
|
|
|
def fluxer_name(value: str) -> str:
|
|
return value.replace("IBM Plex ", "Fluxer ").replace("IBMPlex", "Fluxer")
|
|
|
|
|
|
def _name_map(font: TTFont) -> dict[tuple[int, int, int, int], str]:
|
|
return {
|
|
(r.nameID, r.platformID, r.platEncID, r.langID): r.toUnicode() for r in font["name"].names
|
|
}
|
|
|
|
|
|
def build_font(src: Path, gasp: dict[int, int]) -> bytes:
|
|
font = TTFont(src, lazy=True, recalcTimestamp=False, recalcBBoxes=False)
|
|
font["gasp"].gaspRange = dict(gasp)
|
|
for record in font["name"].names:
|
|
if record.nameID in RENAME_NAME_IDS:
|
|
record.string = fluxer_name(record.toUnicode())
|
|
font.flavor = "woff2"
|
|
buffer = io.BytesIO()
|
|
font.save(buffer)
|
|
font.close()
|
|
return buffer.getvalue()
|
|
|
|
|
|
def check_rebuild(src: Path, produced: bytes, gasp: dict[int, int]) -> None:
|
|
upstream = TTFont(src, lazy=True)
|
|
built = TTFont(io.BytesIO(produced), lazy=True)
|
|
try:
|
|
up_tags = {t for t in upstream.keys() if t != "GlyphOrder"}
|
|
new_tags = {t for t in built.keys() if t != "GlyphOrder"}
|
|
if up_tags != new_tags:
|
|
raise SystemExit(
|
|
f"{src.name}: table set changed: "
|
|
f"+{sorted(new_tags - up_tags)} -{sorted(up_tags - new_tags)}"
|
|
)
|
|
for tag in sorted(up_tags - MUTABLE_TABLES):
|
|
if upstream.reader[tag] != built.reader[tag]:
|
|
raise SystemExit(f"{src.name}: table {tag!r} changed but must not")
|
|
for tag in CRITICAL_TABLES:
|
|
if tag in up_tags and upstream.reader[tag] != built.reader[tag]:
|
|
raise SystemExit(f"{src.name}: critical table {tag!r} changed")
|
|
|
|
up_head = upstream.reader["head"]
|
|
new_head = built.reader["head"]
|
|
if len(up_head) != len(new_head):
|
|
raise SystemExit(f"{src.name}: head length changed")
|
|
if (
|
|
up_head[: HEAD_CHECKSUM_SLICE.start] != new_head[: HEAD_CHECKSUM_SLICE.start]
|
|
or up_head[HEAD_CHECKSUM_SLICE.stop :] != new_head[HEAD_CHECKSUM_SLICE.stop :]
|
|
):
|
|
raise SystemExit(f"{src.name}: head changed outside checkSumAdjustment")
|
|
|
|
if built["gasp"].gaspRange != gasp:
|
|
raise SystemExit(f"{src.name}: gasp is {built['gasp'].gaspRange}, expected {gasp}")
|
|
if built["gasp"].version != 1:
|
|
raise SystemExit(f"{src.name}: gasp version is {built['gasp'].version}, expected 1")
|
|
|
|
up_names = _name_map(upstream)
|
|
new_names = _name_map(built)
|
|
if set(up_names) != set(new_names):
|
|
raise SystemExit(f"{src.name}: name record set changed")
|
|
for key, value in up_names.items():
|
|
expected = fluxer_name(value) if key[0] in RENAME_NAME_IDS else value
|
|
if new_names[key] != expected:
|
|
raise SystemExit(
|
|
f"{src.name}: name {key} is {new_names[key]!r}, expected {expected!r}"
|
|
)
|
|
check_no_reserved_name(built, src.name)
|
|
finally:
|
|
upstream.close()
|
|
built.close()
|
|
|
|
|
|
def check_no_reserved_name(font: TTFont, label: str) -> None:
|
|
for record in font["name"].names:
|
|
if record.nameID in NO_PLEX_NAME_IDS and "Plex" in record.toUnicode():
|
|
raise SystemExit(
|
|
f"{label}: reserved font name 'Plex' still present in name ID {record.nameID}"
|
|
)
|
|
|
|
|
|
def subset_options() -> SubsetOptions:
|
|
options = SubsetOptions()
|
|
options.hinting = True # keep glyph instructions; fpgm/prep/cvt pass through
|
|
options.legacy_kern = True
|
|
options.layout_features = ["*"] # CJK needs vert/vrt2/halt/palt/trad/smpl
|
|
options.layout_scripts = ["*"]
|
|
options.name_IDs = ["*"] # the OFL rename lives in the name table
|
|
options.name_legacy = True
|
|
options.name_languages = ["*"]
|
|
options.notdef_outline = True
|
|
options.glyph_names = False # post 3.0; names are not rendering data
|
|
options.recalc_bounds = False # keep upstream head/glyf bounding boxes
|
|
options.recalc_timestamp = False # keep upstream head.modified
|
|
options.recalc_max_context = False
|
|
options.recalc_average_width = False # keep OS/2.xAvgCharWidth
|
|
options.prune_unicode_ranges = False # keep OS/2.ulUnicodeRange
|
|
options.prune_codepage_ranges = False
|
|
options.drop_tables = []
|
|
options.passthrough_tables = True # e.g. `meta`, which the subsetter cannot slice
|
|
options.ignore_missing_unicodes = True
|
|
return options
|
|
|
|
|
|
def subset_face(sfnt: bytes, codepoints: tuple[int, ...]) -> bytes:
|
|
font = TTFont(io.BytesIO(sfnt), lazy=True, recalcTimestamp=False, recalcBBoxes=False)
|
|
subsetter = Subsetter(options=subset_options())
|
|
subsetter.populate(unicodes=codepoints)
|
|
subsetter.subset(font)
|
|
font.flavor = "woff2"
|
|
buffer = io.BytesIO()
|
|
font.save(buffer)
|
|
font.close()
|
|
return buffer.getvalue()
|
|
|
|
|
|
def glyph_signature(glyph_set: object, glyf: object, name: str) -> tuple:
|
|
pen = DecomposingRecordingPen(glyph_set)
|
|
glyph_set[name].draw(pen)
|
|
glyph = glyf[name]
|
|
program = getattr(glyph, "program", None)
|
|
bytecode = bytes(program.getBytecode()) if program is not None else b""
|
|
box = (glyph.xMin, glyph.yMin, glyph.xMax, glyph.yMax) if glyph.numberOfContours != 0 else ()
|
|
return (tuple(pen.value), box, bytecode)
|
|
|
|
|
|
def metric_signature(font: TTFont) -> dict[str, int]:
|
|
values: dict[str, int] = {}
|
|
for table, field_name in METRIC_FIELDS:
|
|
if table not in font:
|
|
continue
|
|
value = getattr(font[table], field_name, None)
|
|
if value is None:
|
|
continue
|
|
values[f"{table.strip()}.{field_name}"] = int(value)
|
|
return values
|
|
|
|
|
|
def check_subset(
|
|
parent: TTFont, chunk_bytes: bytes, requested: tuple[int, ...], label: str
|
|
) -> dict:
|
|
chunk = TTFont(io.BytesIO(chunk_bytes), lazy=True)
|
|
try:
|
|
parent_tags = {t for t in parent.keys() if t != "GlyphOrder"}
|
|
chunk_tags = {t for t in chunk.keys() if t != "GlyphOrder"}
|
|
if parent_tags != chunk_tags:
|
|
raise SystemExit(
|
|
f"{label}: table set changed: "
|
|
f"+{sorted(chunk_tags - parent_tags)} -{sorted(parent_tags - chunk_tags)}"
|
|
)
|
|
for tag in HINTING_TABLES + ("gasp",):
|
|
if parent.reader[tag] != chunk.reader[tag]:
|
|
raise SystemExit(f"{label}: table {tag!r} must pass through unchanged")
|
|
if metric_signature(parent) != metric_signature(chunk):
|
|
raise SystemExit(f"{label}: vertical metrics changed")
|
|
check_no_reserved_name(chunk, label)
|
|
if not (chunk["name"].getDebugName(6) or "").startswith("Fluxer"):
|
|
raise SystemExit(f"{label}: PostScript name is not a Fluxer name")
|
|
|
|
chunk_cmap = chunk.getBestCmap()
|
|
expected = {cp for cp in requested if cp in parent.getBestCmap()}
|
|
if set(chunk_cmap) != expected:
|
|
missing = sorted(expected - set(chunk_cmap))[:4]
|
|
extra = sorted(set(chunk_cmap) - expected)[:4]
|
|
raise SystemExit(f"{label}: cmap drifted from the plan (-{missing} +{extra})")
|
|
|
|
parent_cmap = parent.getBestCmap()
|
|
parent_glyf = parent["glyf"]
|
|
chunk_glyf = chunk["glyf"]
|
|
parent_set = parent.getGlyphSet()
|
|
chunk_set = chunk.getGlyphSet()
|
|
parent_hmtx = parent["hmtx"]
|
|
chunk_hmtx = chunk["hmtx"]
|
|
parent_vmtx = parent["vmtx"] if "vmtx" in parent else None
|
|
chunk_vmtx = chunk["vmtx"] if "vmtx" in chunk else None
|
|
for cp in sorted(expected):
|
|
name = parent_cmap[cp]
|
|
chunk_name = chunk_cmap[cp]
|
|
if glyph_signature(parent_set, parent_glyf, name) != glyph_signature(
|
|
chunk_set, chunk_glyf, chunk_name
|
|
):
|
|
raise SystemExit(f"{label}: outline changed for U+{cp:04X} ({name})")
|
|
if parent_hmtx[name] != chunk_hmtx[chunk_name]:
|
|
raise SystemExit(f"{label}: horizontal metrics changed for U+{cp:04X} ({name})")
|
|
if parent_vmtx is not None and parent_vmtx[name] != chunk_vmtx[chunk_name]:
|
|
raise SystemExit(f"{label}: vertical metrics changed for U+{cp:04X} ({name})")
|
|
return {
|
|
"codepoints": sorted(chunk_cmap),
|
|
"hinting": {tag: len(chunk.reader[tag]) for tag in HINTING_TABLES},
|
|
"metrics": metric_signature(chunk),
|
|
}
|
|
finally:
|
|
chunk.close()
|
|
|
|
|
|
def plan_chunks(codepoints: set[int], target: int) -> list[tuple[int, ...]]:
|
|
if target < 1:
|
|
raise SystemExit(f"chunk target must be positive, got {target}")
|
|
by_block: dict[str, list[int]] = {}
|
|
for cp in sorted(codepoints):
|
|
by_block.setdefault(ftunicodedata.block(cp), []).append(cp)
|
|
|
|
chunks: list[tuple[int, ...]] = []
|
|
pending: list[int] = []
|
|
|
|
def flush() -> None:
|
|
if pending:
|
|
chunks.append(tuple(pending))
|
|
pending.clear()
|
|
|
|
for _, members in sorted(by_block.items(), key=lambda item: item[1][0]):
|
|
if len(members) < MIN_STANDALONE_BLOCK:
|
|
pending.extend(members)
|
|
if len(pending) >= target:
|
|
flush()
|
|
continue
|
|
flush()
|
|
parts = max(1, round(len(members) / target)) if len(members) > target else 1
|
|
size = -(-len(members) // parts)
|
|
for start in range(0, len(members), size):
|
|
chunks.append(tuple(members[start : start + size]))
|
|
flush()
|
|
if not chunks:
|
|
raise SystemExit("chunk plan is empty")
|
|
return chunks
|
|
|
|
|
|
TRANSFER_SAMPLES: dict[str, dict[str, str]] = {
|
|
"FluxerSansJP": {
|
|
"mixed script": "語",
|
|
"display name": "田中太郎",
|
|
"sentence": "これは日本語のサンプルテキストです",
|
|
},
|
|
"FluxerSansSC": {
|
|
"mixed script": "文",
|
|
"display name": "小明",
|
|
"sentence": "这是简体中文的示例文本",
|
|
},
|
|
"FluxerSansTC": {
|
|
"mixed script": "文",
|
|
"display name": "小明",
|
|
"sentence": "這是繁體中文的示例文本",
|
|
},
|
|
"FluxerSansKR": {
|
|
"mixed script": "한",
|
|
"display name": "김민준",
|
|
"sentence": "이것은 한국어 샘플 텍스트입니다",
|
|
},
|
|
}
|
|
|
|
SLICING_DECISION: dict[str, dict[str, float]] = {
|
|
"FluxerSansJP": {"minMixedScriptRatio": 20.0, "maxFullCoverageRatio": 1.25},
|
|
"FluxerSansSC": {"minMixedScriptRatio": 20.0, "maxFullCoverageRatio": 1.30},
|
|
"FluxerSansTC": {"minMixedScriptRatio": 20.0, "maxFullCoverageRatio": 1.50},
|
|
"FluxerSansKR": {"minMixedScriptRatio": 2.0, "maxFullCoverageRatio": 1.25},
|
|
}
|
|
|
|
|
|
def transfer_table(chunks: list[tuple[int, set[int]]], unsliced: int, family_dir: str) -> dict:
|
|
scenarios = {}
|
|
for label, text in TRANSFER_SAMPLES[family_dir].items():
|
|
wanted = {ord(c) for c in text}
|
|
hit = [(size, covered) for size, covered in chunks if covered & wanted]
|
|
fetched = sum(size for size, _ in hit)
|
|
scenarios[label] = {
|
|
"bytes": fetched,
|
|
"chunks": len(hit),
|
|
"ratio": round(unsliced / fetched, 3) if fetched else None,
|
|
}
|
|
full = sum(size for size, _ in chunks)
|
|
scenarios["full coverage"] = {
|
|
"bytes": full,
|
|
"chunks": len(chunks),
|
|
"ratio": round(unsliced / full, 3) if full else None,
|
|
}
|
|
return {"unslicedBytes": unsliced, "scenarios": scenarios}
|
|
|
|
|
|
def check_slicing_decision(family_dir: str, transfer: dict) -> list[str]:
|
|
bars = SLICING_DECISION.get(family_dir)
|
|
if bars is None:
|
|
return []
|
|
failures = []
|
|
mixed = transfer["scenarios"]["mixed script"]["ratio"]
|
|
if mixed is None or mixed < bars["minMixedScriptRatio"]:
|
|
failures.append(
|
|
f"{family_dir}: slicing wins only {mixed}x on the mixed-script case, below the "
|
|
f"{bars['minMixedScriptRatio']}x bar that justifies it."
|
|
)
|
|
full_ratio = transfer["scenarios"]["full coverage"]["ratio"]
|
|
regression = 1 / full_ratio if full_ratio else float("inf")
|
|
if regression > bars["maxFullCoverageRatio"]:
|
|
failures.append(
|
|
f"{family_dir}: full coverage now costs {regression:.3f}x the unsliced face, past the "
|
|
f"{bars['maxFullCoverageRatio']}x bar"
|
|
)
|
|
return failures
|
|
|
|
|
|
def codepoint_ranges(codepoints: list[int]) -> list[tuple[int, int]]:
|
|
ranges: list[tuple[int, int]] = []
|
|
for cp in codepoints:
|
|
if ranges and cp == ranges[-1][1] + 1:
|
|
ranges[-1] = (ranges[-1][0], cp)
|
|
else:
|
|
ranges.append((cp, cp))
|
|
return ranges
|
|
|
|
|
|
def format_unicode_range(codepoints: list[int]) -> str:
|
|
parts = []
|
|
for start, end in codepoint_ranges(sorted(codepoints)):
|
|
parts.append(f"U+{start:04X}" if start == end else f"U+{start:04X}-{end:04X}")
|
|
return ", ".join(parts)
|
|
|
|
|
|
def parse_unicode_range(value: str) -> set[int]:
|
|
result: set[int] = set()
|
|
for token in value.split(","):
|
|
token = token.strip()
|
|
if not token:
|
|
continue
|
|
match = re.fullmatch(r"[Uu]\+([0-9A-Fa-f]+)(?:-([0-9A-Fa-f]+))?", token)
|
|
if not match:
|
|
raise ValueError(f"unparseable unicode-range token {token!r}")
|
|
start = int(match.group(1), 16)
|
|
end = int(match.group(2), 16) if match.group(2) else start
|
|
result.update(range(start, end + 1))
|
|
return result
|
|
|
|
|
|
def calibrate_chunk_size(sfnt: bytes, codepoints: list[int], target_bytes: int) -> tuple[int, str]:
|
|
small, large = 300, 900
|
|
if len(codepoints) <= large:
|
|
return len(codepoints), "single chunk (family smaller than one probe)"
|
|
middle = len(codepoints) // 2
|
|
probe_small = tuple(codepoints[middle : middle + small])
|
|
probe_large = tuple(codepoints[middle : middle + large])
|
|
size_small = len(subset_face(sfnt, probe_small))
|
|
size_large = len(subset_face(sfnt, probe_large))
|
|
rate = (size_large - size_small) / (large - small)
|
|
overhead = size_small - rate * small
|
|
if rate <= 0:
|
|
raise SystemExit("calibration produced a non-positive bytes-per-codepoint rate")
|
|
budget = int((target_bytes - overhead) / rate)
|
|
budget = max(MIN_STANDALONE_BLOCK, budget)
|
|
note = (
|
|
f"{rate:.1f} B/codepoint marginal, {overhead / 1024:.1f} KiB intercept "
|
|
f"-> {budget} codepoints per chunk"
|
|
)
|
|
return budget, note
|
|
|
|
|
|
def sha256_bytes(blob: bytes) -> str:
|
|
return hashlib.sha256(blob).hexdigest()
|
|
|
|
|
|
@dataclass
|
|
class FaceResult:
|
|
family: str
|
|
weight: str
|
|
source_sha256: str
|
|
hinting: dict[str, int]
|
|
metrics: dict[str, int]
|
|
files: list[dict] = field(default_factory=list)
|
|
|
|
|
|
def check_source_expectations(family: Family, src_dir: Path) -> None:
|
|
expectations = SOURCE_EXPECTATIONS.get(family.out_dir)
|
|
if expectations is None:
|
|
return
|
|
path = src_dir / str(expectations["reference_file"])
|
|
font = TTFont(path, lazy=True)
|
|
try:
|
|
if font.sfntVersion != expectations["sfntVersion"]:
|
|
raise SystemExit(
|
|
f"{path.name}: sfntVersion {font.sfntVersion!r}, "
|
|
f"expected {expectations['sfntVersion']!r} (TrueType outlines)"
|
|
)
|
|
for tag, size in expectations["tables"].items(): # type: ignore[union-attr]
|
|
if tag not in font:
|
|
raise SystemExit(f"{path.name}: hinting table {tag!r} is missing upstream")
|
|
if len(font.reader[tag]) != size:
|
|
raise SystemExit(
|
|
f"{path.name}: {tag!r} is {len(font.reader[tag])} B upstream, expected {size} B"
|
|
)
|
|
if font["maxp"].numGlyphs != expectations["numGlyphs"]:
|
|
raise SystemExit(
|
|
f"{path.name}: numGlyphs {font['maxp'].numGlyphs}, expected {expectations['numGlyphs']}"
|
|
)
|
|
if "glyf" in font and len(font.reader["glyf"]) != expectations["reference_glyf"]:
|
|
raise SystemExit(
|
|
f"{path.name}: glyf is {len(font.reader['glyf'])} B, "
|
|
f"expected {expectations['reference_glyf']} B"
|
|
)
|
|
finally:
|
|
font.close()
|
|
|
|
|
|
def face_cmap(path: Path) -> set[int]:
|
|
font = TTFont(path, lazy=True)
|
|
try:
|
|
return set(font.getBestCmap())
|
|
finally:
|
|
font.close()
|
|
|
|
|
|
def renamed_sfnt(src: Path, gasp: dict[int, int]) -> bytes:
|
|
produced = build_font(src, gasp)
|
|
check_rebuild(src, produced, gasp)
|
|
font = TTFont(io.BytesIO(produced), lazy=True, recalcTimestamp=False, recalcBBoxes=False)
|
|
font.flavor = None
|
|
buffer = io.BytesIO()
|
|
font.save(buffer)
|
|
font.close()
|
|
return buffer.getvalue()
|
|
|
|
|
|
def build_face(job: dict) -> FaceResult:
|
|
family = FAMILY_BY_DIR[job["family"]]
|
|
src = Path(job["src"])
|
|
weight = job["weight"]
|
|
source_bytes = src.read_bytes()
|
|
result = FaceResult(
|
|
family=family.out_dir,
|
|
weight=weight,
|
|
source_sha256=sha256_bytes(source_bytes),
|
|
hinting={},
|
|
metrics={},
|
|
)
|
|
stage = Path(job["stage"]) / family.out_dir
|
|
stage.mkdir(parents=True, exist_ok=True)
|
|
|
|
if family.mode == REBUILD:
|
|
produced = build_font(src, family.gasp)
|
|
check_rebuild(src, produced, family.gasp)
|
|
font = TTFont(io.BytesIO(produced), lazy=True)
|
|
try:
|
|
result.hinting = {tag: len(font.reader[tag]) for tag in HINTING_TABLES}
|
|
result.metrics = metric_signature(font)
|
|
covered = sorted(font.getBestCmap())
|
|
finally:
|
|
font.close()
|
|
name = f"{family.out_dir}-{weight}.woff2"
|
|
(stage / name).write_bytes(produced)
|
|
result.files.append(
|
|
{
|
|
"path": f"{family.out_dir}/{name}",
|
|
"sha256": sha256_bytes(produced),
|
|
"bytes": len(produced),
|
|
"codepoints": len(covered),
|
|
"unicodeRange": None
|
|
if family.stylesheet in LATIN_CORE_STYLESHEETS
|
|
else format_unicode_range(covered),
|
|
"chunk": None,
|
|
}
|
|
)
|
|
return result
|
|
|
|
sfnt = renamed_sfnt(src, family.gasp)
|
|
parent = TTFont(io.BytesIO(sfnt), lazy=True)
|
|
try:
|
|
result.hinting = {tag: len(parent.reader[tag]) for tag in HINTING_TABLES}
|
|
result.metrics = metric_signature(parent)
|
|
parent_cmap = set(parent.getBestCmap())
|
|
for index, planned in enumerate(job["plan"]):
|
|
requested = tuple(cp for cp in planned if cp in parent_cmap)
|
|
if not requested:
|
|
continue
|
|
name = f"{family.out_dir}-{weight}.{index:03d}.woff2"
|
|
label = f"{family.out_dir}/{name}"
|
|
produced = subset_face(sfnt, requested)
|
|
info = check_subset(parent, produced, requested, label)
|
|
if info["hinting"] != result.hinting:
|
|
raise SystemExit(f"{label}: hinting table sizes drifted from the parent face")
|
|
(stage / name).write_bytes(produced)
|
|
result.files.append(
|
|
{
|
|
"path": label,
|
|
"sha256": sha256_bytes(produced),
|
|
"bytes": len(produced),
|
|
"codepoints": len(info["codepoints"]),
|
|
"unicodeRange": format_unicode_range(info["codepoints"]),
|
|
"chunk": index,
|
|
}
|
|
)
|
|
finally:
|
|
parent.close()
|
|
return result
|
|
|
|
|
|
def resolve_sources(cache: Path, families: tuple[Family, ...]) -> dict[str, Path]:
|
|
roots: dict[str, Path] = {}
|
|
for key in dict.fromkeys(f.source for f in families):
|
|
source = SOURCES[key]
|
|
print(f" fetching {source.label}", flush=True)
|
|
roots[key] = source.fetch(cache)
|
|
return roots
|
|
|
|
|
|
def render_face_rule(family: Family, entry: dict) -> str:
|
|
weight = entry["weight"]
|
|
lines = [
|
|
"@font-face {",
|
|
f"\tfont-family: '{family.css_family}';",
|
|
f"\tsrc: url('../files/{entry['path']}') format('woff2');",
|
|
f"\tfont-weight: {css_weight(weight)};",
|
|
f"\tfont-style: {css_style(weight)};",
|
|
"\tfont-display: swap;",
|
|
]
|
|
if entry["unicodeRange"]:
|
|
lines.append(f"\tunicode-range: {entry['unicodeRange']};")
|
|
lines.append("}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def chunk_major(family: Family, entries: list[dict]) -> list[dict]:
|
|
order = {weight: index for index, weight in enumerate(family.weights)}
|
|
return sorted(
|
|
entries,
|
|
key=lambda entry: (
|
|
entry["chunk"] if entry["chunk"] is not None else -1,
|
|
order[entry["weight"]],
|
|
),
|
|
)
|
|
|
|
|
|
def render_stylesheets(entries: list[dict]) -> dict[str, str]:
|
|
by_family: dict[str, list[dict]] = {}
|
|
for entry in entries:
|
|
by_family.setdefault(entry["family"], []).append(entry)
|
|
|
|
by_stylesheet: dict[str, list[str]] = {}
|
|
for family in FAMILIES:
|
|
for entry in chunk_major(family, by_family[family.out_dir]):
|
|
by_stylesheet.setdefault(family.stylesheet, []).append(render_face_rule(family, entry))
|
|
|
|
sheets: dict[str, str] = {}
|
|
for name, rules in by_stylesheet.items():
|
|
sheets[name] = "\n".join([SPDX_CSS_HEADER, GENERATED_CSS_HEADER, "", *rules, ""])
|
|
|
|
sheets["variables.css"] = render_variables_stylesheet()
|
|
sheets["locale-fallbacks.css"] = render_locale_fallbacks_stylesheet()
|
|
return sheets
|
|
|
|
|
|
def render_manifest(entries: list[dict], chunking: dict) -> str:
|
|
families = []
|
|
for family in FAMILIES:
|
|
faces = [
|
|
{
|
|
"file": entry["path"],
|
|
"weight": css_weight(entry["weight"]),
|
|
"style": css_style(entry["weight"]),
|
|
"unicodeRange": entry["unicodeRange"],
|
|
"bytes": entry["bytes"],
|
|
}
|
|
for entry in chunk_major(family, [e for e in entries if e["family"] == family.out_dir])
|
|
]
|
|
families.append(
|
|
{
|
|
"id": family.out_dir,
|
|
"cssFamily": family.css_family,
|
|
"stylesheet": f"css/{family.stylesheet}",
|
|
"mode": family.mode,
|
|
"latinCore": family.stylesheet in LATIN_CORE_STYLESHEETS,
|
|
"chunks": chunking.get(family.out_dir, {}).get("chunks", 1),
|
|
"faces": faces,
|
|
}
|
|
)
|
|
payload = {
|
|
"_comment": (
|
|
"Generated by tools/fonts/build_fonts.py. Do not edit by hand. "
|
|
"Paths are relative to packages/fonts/."
|
|
),
|
|
"entrypoints": {
|
|
"latinCore": {
|
|
family.css_family: f"css/{family.stylesheet}"
|
|
for family in FAMILIES
|
|
if family.stylesheet in LATIN_CORE_STYLESHEETS
|
|
},
|
|
"variables": "css/variables.css",
|
|
"localeFallbacks": "css/locale-fallbacks.css",
|
|
"scripts": {
|
|
family.css_family: f"css/{family.stylesheet}"
|
|
for family in FAMILIES
|
|
if family.stylesheet not in LATIN_CORE_STYLESHEETS
|
|
},
|
|
},
|
|
"families": families,
|
|
}
|
|
return json.dumps(payload, indent="\t", sort_keys=False) + "\n"
|
|
|
|
|
|
def size_distribution(sizes: list[int]) -> str:
|
|
if not sizes:
|
|
return "no files"
|
|
ordered = sorted(sizes)
|
|
return (
|
|
f"min {ordered[0] / 1024:.0f} KiB, median {statistics.median(ordered) / 1024:.0f} KiB, "
|
|
f"p95 {ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))] / 1024:.0f} KiB, "
|
|
f"max {ordered[-1] / 1024:.0f} KiB"
|
|
)
|
|
|
|
|
|
def build(cache: Path, dry_run: bool, workers: int) -> int:
|
|
print(f"cache: {cache}")
|
|
cache.mkdir(parents=True, exist_ok=True)
|
|
roots = resolve_sources(cache, FAMILIES)
|
|
stage = cache / "stage"
|
|
if stage.exists():
|
|
shutil.rmtree(stage)
|
|
stage.mkdir(parents=True)
|
|
|
|
print("\nplanning chunks")
|
|
plans: dict[str, list[tuple[int, ...]]] = {}
|
|
chunking: dict[str, dict] = {}
|
|
family_codepoints: dict[str, int] = {}
|
|
for family in FAMILIES:
|
|
src_dir = roots[family.source] / family.src_subdir
|
|
for name in family.source_files():
|
|
if not (src_dir / name).is_file():
|
|
raise SystemExit(f"missing upstream file: {src_dir / name}")
|
|
check_source_expectations(family, src_dir)
|
|
union: set[int] = set()
|
|
for name in family.source_files():
|
|
union |= face_cmap(src_dir / name)
|
|
union -= WITHHELD_CODEPOINTS.get(family.out_dir, frozenset())
|
|
family_codepoints[family.out_dir] = len(union)
|
|
if family.mode != SUBSET:
|
|
continue
|
|
reference = src_dir / f"{family.prefix}-Regular.woff2"
|
|
sfnt = renamed_sfnt(reference, family.gasp)
|
|
budget, note = calibrate_chunk_size(sfnt, sorted(union), CHUNK_TARGET_BYTES)
|
|
plan = plan_chunks(union, budget)
|
|
plans[family.out_dir] = plan
|
|
chunking[family.out_dir] = {
|
|
"targetBytes": CHUNK_TARGET_BYTES,
|
|
"codepointsPerChunk": budget,
|
|
"chunks": len(plan),
|
|
"codepoints": len(union),
|
|
}
|
|
print(f" {family.out_dir}: {len(union)} codepoints, {note}, {len(plan)} chunks")
|
|
|
|
jobs = [
|
|
{
|
|
"family": family.out_dir,
|
|
"weight": weight,
|
|
"src": str(
|
|
roots[family.source] / family.src_subdir / f"{family.prefix}-{weight}.woff2"
|
|
),
|
|
"stage": str(stage),
|
|
"plan": plans.get(family.out_dir, []),
|
|
}
|
|
for family in FAMILIES
|
|
for weight in family.weights
|
|
]
|
|
|
|
print(f"\nbuilding {len(jobs)} faces on {workers} worker(s)")
|
|
results: list[FaceResult] = []
|
|
if workers <= 1:
|
|
for job in jobs:
|
|
results.append(build_face(job))
|
|
print(f" {job['family']}-{job['weight']}", flush=True)
|
|
else:
|
|
with ProcessPoolExecutor(max_workers=workers) as pool:
|
|
for result in pool.map(build_face, jobs):
|
|
results.append(result)
|
|
print(f" {result.family}-{result.weight}: {len(result.files)} file(s)", flush=True)
|
|
|
|
order = {
|
|
(family.out_dir, weight): (i, j)
|
|
for i, family in enumerate(FAMILIES)
|
|
for j, weight in enumerate(family.weights)
|
|
}
|
|
results.sort(key=lambda r: order[(r.family, r.weight)])
|
|
|
|
entries: list[dict] = []
|
|
for result in results:
|
|
for item in result.files:
|
|
entries.append({**item, "family": result.family, "weight": result.weight})
|
|
|
|
sheets = render_stylesheets(entries)
|
|
manifest = render_manifest(entries, chunking)
|
|
|
|
lock: dict[str, object] = {
|
|
"_comment": (
|
|
"Generated by tools/fonts/build_fonts.py. Do not edit by hand. "
|
|
"Run `python3 tools/fonts/build_fonts.py --verify` to check the tree against it."
|
|
),
|
|
"gasp": {
|
|
"plex": {str(k): v for k, v in PLEX_GASP.items()},
|
|
"cjk": {str(k): v for k, v in CJK_GASP.items()},
|
|
},
|
|
"chunking": {"targetBytes": CHUNK_TARGET_BYTES, "families": chunking},
|
|
"sources": {},
|
|
"families": {},
|
|
"stylesheets": {},
|
|
"files": {},
|
|
}
|
|
for key in dict.fromkeys(f.source for f in FAMILIES):
|
|
source = SOURCES[key]
|
|
lock["sources"][key] = {**source.provenance(), "sha256": source.artifact_sha256(cache)}
|
|
|
|
for family in FAMILIES:
|
|
faces = {}
|
|
for result in results:
|
|
if result.family != family.out_dir:
|
|
continue
|
|
faces[result.weight] = {
|
|
"sourceFile": f"{family.prefix}-{result.weight}.woff2",
|
|
"sourceSha256": result.source_sha256,
|
|
"hinting": result.hinting,
|
|
"metrics": result.metrics,
|
|
"files": len(result.files),
|
|
"codepoints": sum(item["codepoints"] for item in result.files),
|
|
}
|
|
lock["families"][family.out_dir] = {
|
|
"mode": family.mode,
|
|
"source": family.source,
|
|
"srcSubdir": family.src_subdir,
|
|
"cssFamily": family.css_family,
|
|
"stylesheet": f"css/{family.stylesheet}",
|
|
"latinCore": family.stylesheet in LATIN_CORE_STYLESHEETS,
|
|
"gasp": {str(k): v for k, v in family.gasp.items()},
|
|
"codepoints": family_codepoints[family.out_dir],
|
|
"faces": faces,
|
|
}
|
|
if family.out_dir in TRANSFER_SAMPLES:
|
|
unsliced = (
|
|
(roots[family.source] / family.src_subdir / f"{family.prefix}-Regular.woff2")
|
|
.stat()
|
|
.st_size
|
|
)
|
|
regular = [
|
|
(item["bytes"], parse_unicode_range(item["unicodeRange"]))
|
|
for result in results
|
|
if result.family == family.out_dir and result.weight == "Regular"
|
|
for item in result.files
|
|
]
|
|
transfer = transfer_table(regular, unsliced, family.out_dir)
|
|
for failure in check_slicing_decision(family.out_dir, transfer):
|
|
raise SystemExit(failure)
|
|
lock["families"][family.out_dir]["transfer"] = transfer
|
|
|
|
for name, text in sorted(sheets.items()):
|
|
blob = text.encode()
|
|
lock["stylesheets"][f"css/{name}"] = {"sha256": sha256_bytes(blob), "bytes": len(blob)}
|
|
manifest_blob = manifest.encode()
|
|
lock["stylesheets"]["manifest.json"] = {
|
|
"sha256": sha256_bytes(manifest_blob),
|
|
"bytes": len(manifest_blob),
|
|
}
|
|
for entry in entries:
|
|
lock["files"][f"files/{entry['path']}"] = {
|
|
"sha256": entry["sha256"],
|
|
"bytes": entry["bytes"],
|
|
}
|
|
|
|
print("\nper-family results")
|
|
print(f" {'family':24} {'files':>6} {'upstream':>10} {'shipped':>10} distribution")
|
|
total_before = 0
|
|
total_after = 0
|
|
for family in FAMILIES:
|
|
src_dir = roots[family.source] / family.src_subdir
|
|
before = sum((src_dir / name).stat().st_size for name in family.source_files())
|
|
sizes = [entry["bytes"] for entry in entries if entry["family"] == family.out_dir]
|
|
after = sum(sizes)
|
|
total_before += before
|
|
total_after += after
|
|
print(
|
|
f" {family.out_dir:24} {len(sizes):>6} {before / 1048576:>9.2f}M {after / 1048576:>9.2f}M "
|
|
f"{size_distribution(sizes)}"
|
|
)
|
|
print(
|
|
f" {'TOTAL':24} {len(entries):>6} {total_before / 1048576:>9.2f}M {total_after / 1048576:>9.2f}M"
|
|
)
|
|
|
|
changed, unchanged, created, removed = _diff_tree(entries, sheets, manifest)
|
|
print(f"\nsummary: {created} new, {changed} changed, {unchanged} unchanged, {removed} removed")
|
|
if dry_run:
|
|
print("dry run: nothing written")
|
|
return 0
|
|
|
|
_write_tree(entries, sheets, manifest, stage)
|
|
LOCK_PATH.write_text(json.dumps(lock, indent="\t", sort_keys=False) + "\n")
|
|
print(f"wrote {PACKAGE_ROOT.relative_to(REPO_ROOT)}/ and {LOCK_PATH.relative_to(REPO_ROOT)}")
|
|
return 0
|
|
|
|
|
|
def _tracked_outputs(sheets: dict[str, str], manifest: str) -> dict[str, bytes]:
|
|
outputs: dict[str, bytes] = {}
|
|
for name, text in sheets.items():
|
|
outputs[f"css/{name}"] = text.encode()
|
|
outputs["manifest.json"] = manifest.encode()
|
|
return outputs
|
|
|
|
|
|
def _diff_tree(
|
|
entries: list[dict], sheets: dict[str, str], manifest: str
|
|
) -> tuple[int, int, int, int]:
|
|
changed = unchanged = created = 0
|
|
expected: set[str] = set()
|
|
for entry in entries:
|
|
rel = f"files/{entry['path']}"
|
|
expected.add(rel)
|
|
dest = PACKAGE_ROOT / rel
|
|
if not dest.is_file():
|
|
created += 1
|
|
elif sha256_bytes(dest.read_bytes()) == entry["sha256"]:
|
|
unchanged += 1
|
|
else:
|
|
changed += 1
|
|
for rel, blob in _tracked_outputs(sheets, manifest).items():
|
|
expected.add(rel)
|
|
dest = PACKAGE_ROOT / rel
|
|
if not dest.is_file():
|
|
created += 1
|
|
elif dest.read_bytes() == blob:
|
|
unchanged += 1
|
|
else:
|
|
changed += 1
|
|
removed = len(_existing_generated() - expected)
|
|
return changed, unchanged, created, removed
|
|
|
|
|
|
def _existing_generated() -> set[str]:
|
|
found: set[str] = set()
|
|
if FILES_DIR.is_dir():
|
|
found |= {str(p.relative_to(PACKAGE_ROOT)) for p in FILES_DIR.rglob("*.woff2")}
|
|
if CSS_DIR.is_dir():
|
|
found |= {str(p.relative_to(PACKAGE_ROOT)) for p in CSS_DIR.rglob("*.css")}
|
|
if MANIFEST_PATH.is_file():
|
|
found.add("manifest.json")
|
|
return found
|
|
|
|
|
|
def _write_tree(entries: list[dict], sheets: dict[str, str], manifest: str, stage: Path) -> None:
|
|
expected: set[str] = set()
|
|
for entry in entries:
|
|
rel = f"files/{entry['path']}"
|
|
expected.add(rel)
|
|
dest = PACKAGE_ROOT / rel
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(stage / entry["path"], dest)
|
|
for rel, blob in _tracked_outputs(sheets, manifest).items():
|
|
expected.add(rel)
|
|
dest = PACKAGE_ROOT / rel
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
dest.write_bytes(blob)
|
|
for rel in sorted(_existing_generated() - expected):
|
|
(PACKAGE_ROOT / rel).unlink()
|
|
for directory in sorted(FILES_DIR.rglob("*"), reverse=True):
|
|
if directory.is_dir() and not any(directory.iterdir()):
|
|
directory.rmdir()
|
|
|
|
|
|
FACE_RULE = re.compile(
|
|
r"@font-face\s*\{(?P<body>[^}]*)\}",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
|
|
def parse_stylesheet(text: str) -> list[dict[str, str]]:
|
|
faces = []
|
|
for match in FACE_RULE.finditer(text):
|
|
body = match.group("body")
|
|
declarations = {}
|
|
for declaration in body.split(";"):
|
|
name, _, value = declaration.partition(":")
|
|
name = name.strip()
|
|
if name:
|
|
declarations[name] = value.strip()
|
|
url = re.search(r"url\('([^']+)'\)", declarations.get("src", ""))
|
|
faces.append(
|
|
{
|
|
"family": declarations.get("font-family", "").strip("'"),
|
|
"url": url.group(1) if url else "",
|
|
"weight": declarations.get("font-weight", ""),
|
|
"style": declarations.get("font-style", ""),
|
|
"display": declarations.get("font-display", ""),
|
|
"unicodeRange": declarations.get("unicode-range", ""),
|
|
}
|
|
)
|
|
return faces
|
|
|
|
|
|
def check_variable_families(text: str) -> list[str]:
|
|
allowed = set(ALWAYS_BUNDLED_LATIN_FAMILIES)
|
|
allowed |= {f.css_family for f in FAMILIES if f.stylesheet not in LATIN_CORE_STYLESHEETS}
|
|
shipped = {f.css_family for f in FAMILIES}
|
|
failures: list[str] = []
|
|
for match in re.finditer(r"(--font-[a-z0-9-]+)\s*:(?P<value>[^;]*);", text):
|
|
variable = match.group(1)
|
|
for name in re.findall(r"'([^']+)'", match.group("value")):
|
|
if name in shipped and name not in allowed:
|
|
failures.append(
|
|
f"packages/fonts/css/variables.css: {variable} names {name!r}, but importing "
|
|
f"variables.css does not bring that family's @font-face rules with it. Declare "
|
|
f"the variable in the consumer that actually imports its stylesheet instead."
|
|
)
|
|
return failures
|
|
|
|
|
|
def verify() -> int:
|
|
if not LOCK_PATH.is_file():
|
|
raise SystemExit(f"missing {LOCK_PATH}; run the build first")
|
|
lock = json.loads(LOCK_PATH.read_text())
|
|
failures: list[str] = []
|
|
|
|
locked_files = set(lock["files"])
|
|
on_disk = (
|
|
{f"files/{p.relative_to(FILES_DIR)}" for p in FILES_DIR.rglob("*.woff2")}
|
|
if FILES_DIR.is_dir()
|
|
else set()
|
|
)
|
|
for extra in sorted(on_disk - locked_files):
|
|
failures.append(f"font present in the tree but absent from fonts.lock.json: {extra}")
|
|
for missing in sorted(locked_files - on_disk):
|
|
failures.append(f"font recorded in fonts.lock.json but missing from the tree: {missing}")
|
|
|
|
strays = (
|
|
sorted(p.relative_to(PACKAGE_ROOT) for p in PACKAGE_ROOT.rglob("*.otf"))
|
|
if PACKAGE_ROOT.is_dir()
|
|
else []
|
|
)
|
|
if strays:
|
|
failures.append(f"{len(strays)} unreferenced .otf file(s) present, e.g. {strays[0]}")
|
|
|
|
referenced: dict[str, list[str]] = {}
|
|
declared_ranges: dict[str, str] = {}
|
|
for rel, recorded in lock["stylesheets"].items():
|
|
path = PACKAGE_ROOT / rel
|
|
if not path.is_file():
|
|
failures.append(f"missing generated file: packages/fonts/{rel}")
|
|
continue
|
|
blob = path.read_bytes()
|
|
if sha256_bytes(blob) != recorded["sha256"]:
|
|
failures.append(f"packages/fonts/{rel}: sha256 does not match fonts.lock.json")
|
|
if not rel.endswith(".css"):
|
|
continue
|
|
text = blob.decode()
|
|
if "?v=" in text:
|
|
failures.append(f"packages/fonts/{rel}: contains a ?v= cache-bust token")
|
|
for face in parse_stylesheet(text):
|
|
if not face["url"].startswith("../files/"):
|
|
failures.append(
|
|
f"packages/fonts/{rel}: url {face['url']!r} is not a relative ../files/ path"
|
|
)
|
|
continue
|
|
target = f"files/{face['url'][len('../files/') :]}"
|
|
if not (PACKAGE_ROOT / target).is_file():
|
|
failures.append(
|
|
f"packages/fonts/{rel} references a file that does not exist: {target}"
|
|
)
|
|
referenced.setdefault(target, []).append(rel)
|
|
if face["display"] != "swap":
|
|
failures.append(
|
|
f"packages/fonts/{rel}: {target} declares font-display {face['display']!r}"
|
|
)
|
|
previous = declared_ranges.get(target)
|
|
if previous is not None and previous != face["unicodeRange"]:
|
|
failures.append(f"{target}: declared with two different unicode-range values")
|
|
declared_ranges[target] = face["unicodeRange"]
|
|
|
|
for orphan in sorted(locked_files - set(referenced)):
|
|
failures.append(f"font is shipped but no stylesheet claims it: {orphan}")
|
|
|
|
for rel, rendered in (
|
|
("css/variables.css", render_variables_stylesheet()),
|
|
("css/locale-fallbacks.css", render_locale_fallbacks_stylesheet()),
|
|
):
|
|
path = PACKAGE_ROOT / rel
|
|
if path.is_file() and path.read_text() != rendered:
|
|
failures.append(f"packages/fonts/{rel}: does not match what build_fonts.py would emit")
|
|
|
|
variables_css = PACKAGE_ROOT / "css" / "variables.css"
|
|
if variables_css.is_file():
|
|
base_chain = re.search(r"--font-sans:(?P<value>[^;]*);", variables_css.read_text())
|
|
if base_chain is None:
|
|
failures.append("packages/fonts/css/variables.css: no --font-sans declaration")
|
|
else:
|
|
value = base_chain.group("value")
|
|
for family in FAMILIES:
|
|
if family.stylesheet in LATIN_CORE_STYLESHEETS:
|
|
continue
|
|
if f"'{family.css_family}'" not in value:
|
|
failures.append(
|
|
f"packages/fonts/css/variables.css: base --font-sans does not name "
|
|
f"{family.css_family!r}, so its unicode-range gate can never fire"
|
|
)
|
|
failures.extend(check_variable_families(variables_css.read_text()))
|
|
|
|
if MANIFEST_PATH.is_file():
|
|
manifest = json.loads(MANIFEST_PATH.read_text())
|
|
manifest_sheets = [manifest["entrypoints"]["variables"]]
|
|
manifest_sheets.append(manifest["entrypoints"]["localeFallbacks"])
|
|
manifest_sheets.extend(manifest["entrypoints"]["latinCore"].values())
|
|
manifest_sheets.extend(manifest["entrypoints"]["scripts"].values())
|
|
manifest_files: set[str] = set()
|
|
for entry in manifest["families"]:
|
|
manifest_sheets.append(entry["stylesheet"])
|
|
manifest_files |= {f"files/{face['file']}" for face in entry["faces"]}
|
|
for sheet in sorted(set(manifest_sheets)):
|
|
if not (PACKAGE_ROOT / sheet).is_file():
|
|
failures.append(f"manifest.json names a stylesheet that does not exist: {sheet}")
|
|
if manifest_files != locked_files:
|
|
failures.append(
|
|
f"manifest.json lists {len(manifest_files)} faces but fonts.lock.json lists "
|
|
f"{len(locked_files)}"
|
|
)
|
|
|
|
per_family_ranges: dict[str, list[tuple[str, set[int]]]] = {}
|
|
for rel in sorted(locked_files):
|
|
path = PACKAGE_ROOT / rel
|
|
if not path.is_file():
|
|
continue
|
|
blob = path.read_bytes()
|
|
recorded = lock["files"][rel]
|
|
if recorded["sha256"] != sha256_bytes(blob):
|
|
failures.append(f"{rel}: sha256 does not match fonts.lock.json")
|
|
continue
|
|
if recorded["bytes"] != len(blob):
|
|
failures.append(f"{rel}: byte count does not match fonts.lock.json")
|
|
family_dir = rel.split("/")[1]
|
|
family = FAMILY_BY_DIR.get(family_dir)
|
|
if family is None:
|
|
failures.append(f"{rel}: no Family entry claims this directory")
|
|
continue
|
|
locked_family = lock["families"][family.out_dir]
|
|
weight = _weight_from_filename(family, Path(rel).name)
|
|
if weight is None:
|
|
failures.append(f"{rel}: filename does not match any weight of {family.out_dir}")
|
|
continue
|
|
locked_face = locked_family["faces"][weight]
|
|
|
|
font = TTFont(io.BytesIO(blob), lazy=True)
|
|
try:
|
|
gasp = font["gasp"].gaspRange if "gasp" in font else None
|
|
expected_gasp = {int(k): v for k, v in locked_family["gasp"].items()}
|
|
if gasp != expected_gasp:
|
|
failures.append(f"{rel}: gasp is {gasp}, expected {expected_gasp}")
|
|
if "gasp" in font and font["gasp"].version != 1:
|
|
failures.append(f"{rel}: gasp version is {font['gasp'].version}, expected 1")
|
|
for tag, size in locked_face["hinting"].items():
|
|
if tag not in font:
|
|
failures.append(f"{rel}: hinting table {tag!r} is missing")
|
|
elif len(font.reader[tag]) != size:
|
|
failures.append(
|
|
f"{rel}: {tag!r} is {len(font.reader[tag])} B, expected {size} B"
|
|
)
|
|
if metric_signature(font) != locked_face["metrics"]:
|
|
failures.append(f"{rel}: vertical metrics do not match fonts.lock.json")
|
|
try:
|
|
check_no_reserved_name(font, rel)
|
|
except SystemExit as exc:
|
|
failures.append(str(exc))
|
|
if not (font["name"].getDebugName(6) or "").startswith("Fluxer"):
|
|
failures.append(f"{rel}: PostScript name is not a Fluxer name")
|
|
covered = set(font.getBestCmap())
|
|
finally:
|
|
font.close()
|
|
|
|
declared = declared_ranges.get(rel)
|
|
if declared is None:
|
|
continue
|
|
if declared == "":
|
|
if family.stylesheet not in LATIN_CORE_STYLESHEETS:
|
|
failures.append(
|
|
f"{rel}: no unicode-range declared, but {family.stylesheet} is lazy"
|
|
)
|
|
continue
|
|
try:
|
|
claimed = parse_unicode_range(declared)
|
|
except ValueError as exc:
|
|
failures.append(f"{rel}: {exc}")
|
|
continue
|
|
if claimed != covered:
|
|
failures.append(
|
|
f"{rel}: unicode-range claims {len(claimed)} codepoints but the font encodes "
|
|
f"{len(covered)} (+{len(claimed - covered)} phantom, -{len(covered - claimed)} unreachable)"
|
|
)
|
|
per_family_ranges.setdefault(f"{family.out_dir}/{weight}", []).append((rel, claimed))
|
|
|
|
for key, chunks in sorted(per_family_ranges.items()):
|
|
family_dir, weight = key.rsplit("/", 1)
|
|
seen: dict[int, str] = {}
|
|
overlapped = False
|
|
for rel, claimed in chunks:
|
|
for cp in claimed:
|
|
if cp in seen:
|
|
failures.append(f"U+{cp:04X} is claimed by both {seen[cp]} and {rel}")
|
|
overlapped = True
|
|
break
|
|
seen[cp] = rel
|
|
if overlapped:
|
|
break
|
|
locked_total = lock["families"][family_dir]["faces"][weight]["codepoints"]
|
|
if not overlapped and len(seen) != locked_total:
|
|
failures.append(
|
|
f"{family_dir}/{weight}: the {len(chunks)} chunk(s) cover {len(seen)} codepoints, "
|
|
f"but fonts.lock.json records {locked_total}"
|
|
)
|
|
|
|
transfer_report: list[str] = []
|
|
for family_dir in sorted(TRANSFER_SAMPLES):
|
|
recorded = lock["families"].get(family_dir, {}).get("transfer")
|
|
if recorded is None:
|
|
failures.append(f"{family_dir}: fonts.lock.json records no transfer table")
|
|
continue
|
|
chunks = [
|
|
(lock["files"][rel]["bytes"], claimed)
|
|
for rel, claimed in per_family_ranges.get(f"{family_dir}/Regular", [])
|
|
]
|
|
if not chunks:
|
|
failures.append(f"{family_dir}: no Regular chunks to measure the transfer table on")
|
|
continue
|
|
measured = transfer_table(chunks, recorded["unslicedBytes"], family_dir)
|
|
if measured != recorded:
|
|
failures.append(
|
|
f"{family_dir}: the transfer table recomputed from the shipped tree does not match "
|
|
f"fonts.lock.json ({measured} vs {recorded})"
|
|
)
|
|
failures.extend(check_slicing_decision(family_dir, measured))
|
|
row = " ".join(
|
|
f"{label} {case['bytes']:,} B/{case['chunks']}c ({case['ratio']}x)"
|
|
for label, case in measured["scenarios"].items()
|
|
)
|
|
transfer_report.append(f" {family_dir:18} unsliced {recorded['unslicedBytes']:,} B {row}")
|
|
|
|
if failures:
|
|
print(f"FAIL ({len(failures)} problem(s)):")
|
|
for line in failures:
|
|
print(f" - {line}")
|
|
return 1
|
|
total = sum(entry["bytes"] for entry in lock["files"].values())
|
|
print(
|
|
f"OK: {len(locked_files)} WOFF2 files ({total / 1048576:.1f} MiB) and "
|
|
f"{len(lock['stylesheets'])} generated stylesheets match fonts.lock.json; "
|
|
"gasp, rename, hinting, metric and unicode-range invariants hold."
|
|
)
|
|
print("\nbytes fetched per sliced family (ratio = unsliced face / fetched):")
|
|
for line in transfer_report:
|
|
print(line)
|
|
return 0
|
|
|
|
|
|
def _weight_from_filename(family: Family, filename: str) -> str | None:
|
|
stem = filename[: -len(".woff2")]
|
|
if not stem.startswith(f"{family.out_dir}-"):
|
|
return None
|
|
remainder = stem[len(family.out_dir) + 1 :]
|
|
weight = remainder.split(".")[0]
|
|
return weight if weight in family.weights else None
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Build the font package in packages/fonts/.")
|
|
parser.add_argument(
|
|
"--verify",
|
|
action="store_true",
|
|
help="offline: check the checked-in tree against fonts.lock.json and re-assert invariants",
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="fetch and build, report the diff, but write nothing",
|
|
)
|
|
parser.add_argument(
|
|
"--cache-dir",
|
|
type=Path,
|
|
default=Path(__file__).resolve().parent / ".cache",
|
|
help="where upstream downloads are kept (default: tools/fonts/.cache, git-ignored)",
|
|
)
|
|
parser.add_argument(
|
|
"--workers",
|
|
type=int,
|
|
default=min(8, os.cpu_count() or 1),
|
|
help="parallel face builds (default: min(8, cpu count); 1 disables the pool)",
|
|
)
|
|
args = parser.parse_args()
|
|
if args.verify:
|
|
if args.dry_run:
|
|
parser.error("--verify and --dry-run are mutually exclusive")
|
|
return verify()
|
|
return build(args.cache_dir.resolve(), args.dry_run, max(1, args.workers))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|