#!/usr/bin/env python # -*- coding: utf-8 -*- """字体子集化 + 许可证据归档 + manifest 字体块生成(批次 4 / 规格 11)。 三段职责,一条命令产出可复核的交付物: 1. 子集化:中文开源字体单字重 10-18MB,直出会拖垮首屏(规格 11「中文交付约束」)。 子集文本来自 tools/charset.json(驾驶舱静态用字),**不含运行时动态文本**—— 所以子集字体必须保留系统回退链,子集外汉字落到系统字体(规格 11 Q14)。 2. 许可证据归档:白名单字族的上游 OFL 文本随交付包归档(规格 11「白名单为证据确证制」)。 OFL 本身要求再分发时附带许可证,归档进产物是合规动作而非额外负担。 3. 版本/名称取证:字体内嵌 name 表里的名称与版本是**事实**,不靠手抄,写进 manifest。 用法: set FONTTOOLS_LIBS= set FONT_UPSTREAM_DIR=<上游字体目录> python tools/build-fonts.py 输出: public/cockpit/assets/fonts/*.woff2 public/cockpit/assets/fonts/licenses/*.txt tools/font-assets.json (合并进 manifest.json 的 fonts 块) """ import hashlib import io import json import os import shutil import sys FT_LIBS = os.environ.get('FONTTOOLS_LIBS') if FT_LIBS: sys.path.insert(0, FT_LIBS) from fontTools import subset # noqa: E402 from fontTools.ttLib import TTFont # noqa: E402 from fontTools.varLib import instancer # noqa: E402 HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, '..', '..', '..')) UPSTREAM = os.environ['FONT_UPSTREAM_DIR'] OUT_DIR = os.path.join(ROOT, 'public', 'cockpit', 'assets', 'fonts') LIC_DIR = os.path.join(OUT_DIR, 'licenses') with open(os.path.join(HERE, 'charset.json'), encoding='utf-8') as f: SUBSET_TEXT = json.load(f)['subsetText'] # --------------------------------------------------------------------------- # 白名单字族(规格 11 三族 + 维护者 2026-10-19 裁定扩展的三族) # 每条都必须有归档的许可证据才能进入可解析集合——这是授权门禁的事实来源。 # # version / holder **不手写**:holder 取自归档许可证文本的首行 Copyright, # version 取自字体二进制 name 表(probe_version)。手写过一次就写错了(Rajdhani # 实际 1.201 被写成 2.000、Share Tech Mono / Crimson Text 权利人张冠李戴), # 所以这里只留「去哪取证」的指针,不留抄来的值。 # --------------------------------------------------------------------------- FAMILIES = [ dict( id='ofl-source-han-sans', family='Source Han Sans SC', label='思源黑体 Source Han Sans SC', licenseFile='OFL-source-han-sans.txt', srcLicense='OFL-source-han-sans.txt', sourceUrl='https://github.com/adobe-fonts/source-han-sans', versionFont=None, # 本批不嵌入,从维护者字体目录取证(见 versionProbe) versionProbe=r'E:\_Design\字体\中文\SourceHanSansSC-Regular-2.otf', embedded=False, note='白名单内;本批无消费点(设计稿正文为 Noto Sans SC),不嵌入以避免死资源', ), dict( id='ofl-source-han-serif', family='Source Han Serif CN', label='思源宋体 Source Han Serif CN', licenseFile='OFL-source-han-serif.txt', srcLicense='OFL-source-han-serif.txt', sourceUrl='https://github.com/adobe-fonts/source-han-serif', versionFont=None, versionProbe=r'E:\_Design\字体\中文\SourceHanSerifCN-Regular-1.otf', embedded=False, note='白名单内;设计稿未使用宋体,不嵌入以避免死资源', ), dict( id='ofl-crimson-text', family='Crimson Text', label='Crimson Text', licenseFile='OFL-crimson-text.txt', srcLicense='OFL-crimson-text.txt', sourceUrl='https://github.com/google/fonts/tree/main/ofl/crimsontext', versionFont=None, versionProbe=r'E:\_Design\字体\英文\CrimsonText-Regular-5.ttf', embedded=False, note='白名单内;设计稿未使用衬线西文,不嵌入以避免死资源', ), dict( id='ofl-noto-sans-sc', family='Noto Sans SC', label='Noto Sans SC(思源黑体同源发行)', licenseFile='OFL-noto-sans-sc.txt', srcLicense='OFL-noto-sans-sc.txt', sourceUrl='https://github.com/google/fonts/tree/main/ofl/notosanssc', versionFont='NotoSansSC.ttf', embedded=True, note='设计稿正文实际字族(913 处),经维护者裁定扩展入白名单', ), dict( id='ofl-rajdhani', family='Rajdhani', label='Rajdhani', licenseFile='OFL-rajdhani.txt', srcLicense='OFL-rajdhani.txt', sourceUrl='https://github.com/google/fonts/tree/main/ofl/rajdhani', versionFont='Rajdhani-Regular.ttf', embedded=True, note='设计稿数字字族(163 处),经维护者裁定扩展入白名单', ), dict( id='ofl-share-tech-mono', family='Share Tech Mono', label='Share Tech Mono', licenseFile='OFL-share-tech-mono.txt', srcLicense='OFL-share-tech-mono.txt', sourceUrl='https://github.com/google/fonts/tree/main/ofl/sharetechmono', versionFont='ShareTechMono-Regular.ttf', embedded=True, note='设计稿数字/等宽字族(134 处),经维护者裁定扩展入白名单', ), dict( id='ofl-logo-sc-unbounded-sans', family='LogoSC Unbounded Sans', label='标小智无界黑 LogoSC Unbounded Sans', licenseFile='OFL-logo-sc-unbounded-sans.txt', srcLicense='OFL-logo-sc-unbounded-sans.txt', sourceUrl='https://github.com/maoken-fonts/unbounded-sans', versionFont=None, versionProbe=r'E:\_Design\字体\中文\LogoSCUnboundedSans-Regular-2.ttf', embedded=False, note='曾用于顶栏菜单(第五轮);维护者第七轮否决——设计稿菜单实为系统字常规体。' '证据保留在归档,暂无消费点不嵌入(避免死资源);许可证证据取自字体二进制内嵌 OFL 文本', ), ] # 实际嵌入的字体文件条目。只嵌「有消费点」的字族:正文字体与数字/等宽字体。 JOBS = [ dict(key='font.body.regular', licenseId='ofl-noto-sans-sc', family='Noto Sans SC Subset', src='NotoSansSC.ttf', vary={'wght': 400}, out='noto-sans-sc-400.woff2', weight=400, style='normal', display='正文', core=True), dict(key='font.body.bold', licenseId='ofl-noto-sans-sc', family='Noto Sans SC Subset', src='NotoSansSC.ttf', vary={'wght': 700}, out='noto-sans-sc-700.woff2', weight=700, style='normal', display='正文/加粗', core=True), dict(key='font.num.regular', licenseId='ofl-rajdhani', family='Rajdhani Subset', src='Rajdhani-Regular.ttf', vary=None, out='rajdhani-400.woff2', weight=400, style='normal', display='数字/常规', core=False), dict(key='font.num.bold', licenseId='ofl-rajdhani', family='Rajdhani Subset', src='Rajdhani-Bold.ttf', vary=None, out='rajdhani-700.woff2', weight=700, style='normal', display='数字/加粗', core=False), dict(key='font.num.mono', licenseId='ofl-share-tech-mono', family='Share Tech Mono Subset', src='ShareTechMono-Regular.ttf', vary=None, out='share-tech-mono-400.woff2', weight=400, style='normal', display='数字/等宽', core=False), # 票 17 第七轮:font.title(无界黑)撤销嵌入——维护者否决菜单粗体观感,设计稿菜单为 # 系统字常规体 + 切图底板;标题是切图不需要字体。槽位与 @font-face 一并移除,避免死资源。 ] def make_options(): opts = subset.Options() opts.flavor = 'woff2' # woff2 体积最小,现代浏览器全支持 opts.layout_features = ['*'] # 保留排版特性,避免中文字形错排 opts.notdef_outline = True opts.desubroutinize = False opts.drop_tables += ['DSIG'] # 子集化后签名必然失效,保留会误导 return opts def name_records(font): """字体内嵌 name 表:名称与版本是事实,直接取自二进制而非手抄。""" out = {} for rec in font['name'].names: if rec.nameID in (1, 5) and rec.platformID == 3: try: out[rec.nameID] = rec.toUnicode() except Exception: pass return out def probe_version(font_file): """从字体二进制取版本号(name ID 5)。支持上游目录文件名或绝对路径;取不到返回空串,不猜。""" if not font_file: return '' path = font_file if os.path.isabs(font_file) else os.path.join(UPSTREAM, font_file) if not os.path.exists(path): return '' ft = TTFont(path, lazy=True) try: return name_records(ft).get(5, '') finally: ft.close() def probe_holder(license_path): """从归档的许可证文本取权利人。 OFL 文本的 Copyright 行会在 70 列左右换行(Adobe 的尤其如此), 只读第一行会截断成「Copyright 2014-2025 Adobe ..., with Reserved Font」。 这里读到空行为止,把续行拼回来。 """ with open(license_path, encoding='utf-8', errors='replace') as f: lines = [ln.rstrip('\n') for ln in f] for i, line in enumerate(lines): if line.strip().lower().startswith('copyright'): parts = [line.strip()] for nxt in lines[i + 1:]: if not nxt.strip(): break parts.append(nxt.strip()) return ' '.join(parts) return '' def build(job): src_path = os.path.join(UPSTREAM, job['src']) font = TTFont(src_path) meta = name_records(font) if job['vary']: font = instancer.instantiateVariableFont(font, job['vary'], inplace=True) opts = make_options() subsetter = subset.Subsetter(options=opts) subsetter.populate(text=SUBSET_TEXT) subsetter.subset(font) buf = io.BytesIO() subset.save_font(font, buf, opts) font.close() data = buf.getvalue() with open(os.path.join(OUT_DIR, job['out']), 'wb') as f: f.write(data) return { 'key': job['key'], 'licenseId': job['licenseId'], 'family': job['family'], 'weight': job['weight'], 'style': job['style'], 'display': job['display'], 'path': 'assets/fonts/' + job['out'], 'format': 'woff2', 'bytes': len(data), 'hash': 'sha256:' + hashlib.sha256(data).hexdigest(), 'sourceFont': job['src'], 'sourceFontName': meta.get(1, ''), 'sourceFontVersion': meta.get(5, ''), 'subsetTextChars': len(set(SUBSET_TEXT)), 'core': job['core'], } def archive_licenses(): os.makedirs(LIC_DIR, exist_ok=True) records = [] for fam in FAMILIES: src = os.path.join(UPSTREAM, fam['srcLicense']) dst = os.path.join(LIC_DIR, fam['licenseFile']) shutil.copyfile(src, dst) blob = open(dst, 'rb').read() records.append({ 'id': fam['id'], 'family': fam['family'], 'label': fam['label'], # 版本与权利人均为取证值:版本来自字体二进制,权利人来自许可证文本首行 'version': probe_version(fam.get('versionProbe') or fam['versionFont']), 'versionSource': fam.get('versionProbe') or fam['versionFont'] or 'unavailable', 'holder': probe_holder(dst), 'spdx': 'OFL-1.1', 'covers': ['web-embedding', 'commercial-use'], 'sourceUrl': fam['sourceUrl'], 'evidence': 'assets/fonts/licenses/' + fam['licenseFile'], 'evidenceHash': 'sha256:' + hashlib.sha256(blob).hexdigest(), 'evidenceBytes': len(blob), 'embedded': fam['embedded'], 'note': fam['note'], }) return records def main(): os.makedirs(OUT_DIR, exist_ok=True) licenses = archive_licenses() entries = [build(j) for j in JOBS] report = { 'note': '由 tools/build-fonts.py 生成;合并进 public/cockpit/assets/manifest.json 的 fonts 块。', 'subsetTextChars': len(set(SUBSET_TEXT)), 'licenses': licenses, 'entries': entries, } with open(os.path.join(HERE, 'font-assets.json'), 'w', encoding='utf-8') as f: json.dump(report, f, ensure_ascii=False, indent=2) for lic in licenses: print('lic %-26s %-24s %s' % (lic['id'], lic['family'], 'embedded' if lic['embedded'] else 'evidence-only')) print() for r in entries: print('%-18s %-24s %8.1f KB %s' % (r['key'], r['family'], r['bytes'] / 1024, r['path'])) print('embedded total: %.1f KB' % (sum(r['bytes'] for r in entries) / 1024)) if __name__ == '__main__': main()