#!/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, 16: 7, 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": NpmSource("@ibm/plex-sans-thai", "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/plex-sans-kr@1.1.0", "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( "FluxerSansThai", "IBMPlexSansThai", UPRIGHT_WEIGHTS, "plex-sans-thai", PLEX_WOFF2, REBUILD, PLEX_GASP, "Fluxer Sans Thai", "script-thai.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") UNCHAINED_SCRIPT_FAMILIES: tuple[str, ...] = ("Fluxer Sans Thai",) 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
[^}]*)\}", 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