| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275 |
- #!/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=<fontTools 安装目录>
- 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()
|