build-fonts.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. """字体子集化 + 许可证据归档 + manifest 字体块生成(批次 4 / 规格 11)。
  4. 三段职责,一条命令产出可复核的交付物:
  5. 1. 子集化:中文开源字体单字重 10-18MB,直出会拖垮首屏(规格 11「中文交付约束」)。
  6. 子集文本来自 tools/charset.json(驾驶舱静态用字),**不含运行时动态文本**——
  7. 所以子集字体必须保留系统回退链,子集外汉字落到系统字体(规格 11 Q14)。
  8. 2. 许可证据归档:白名单字族的上游 OFL 文本随交付包归档(规格 11「白名单为证据确证制」)。
  9. OFL 本身要求再分发时附带许可证,归档进产物是合规动作而非额外负担。
  10. 3. 版本/名称取证:字体内嵌 name 表里的名称与版本是**事实**,不靠手抄,写进 manifest。
  11. 用法:
  12. set FONTTOOLS_LIBS=<fontTools 安装目录>
  13. set FONT_UPSTREAM_DIR=<上游字体目录>
  14. python tools/build-fonts.py
  15. 输出:
  16. public/cockpit/assets/fonts/*.woff2
  17. public/cockpit/assets/fonts/licenses/*.txt
  18. tools/font-assets.json (合并进 manifest.json 的 fonts 块)
  19. """
  20. import hashlib
  21. import io
  22. import json
  23. import os
  24. import shutil
  25. import sys
  26. FT_LIBS = os.environ.get('FONTTOOLS_LIBS')
  27. if FT_LIBS:
  28. sys.path.insert(0, FT_LIBS)
  29. from fontTools import subset # noqa: E402
  30. from fontTools.ttLib import TTFont # noqa: E402
  31. from fontTools.varLib import instancer # noqa: E402
  32. HERE = os.path.dirname(os.path.abspath(__file__))
  33. ROOT = os.path.abspath(os.path.join(HERE, '..', '..', '..'))
  34. UPSTREAM = os.environ['FONT_UPSTREAM_DIR']
  35. OUT_DIR = os.path.join(ROOT, 'public', 'cockpit', 'assets', 'fonts')
  36. LIC_DIR = os.path.join(OUT_DIR, 'licenses')
  37. with open(os.path.join(HERE, 'charset.json'), encoding='utf-8') as f:
  38. SUBSET_TEXT = json.load(f)['subsetText']
  39. # ---------------------------------------------------------------------------
  40. # 白名单字族(规格 11 三族 + 维护者 2026-10-19 裁定扩展的三族)
  41. # 每条都必须有归档的许可证据才能进入可解析集合——这是授权门禁的事实来源。
  42. #
  43. # version / holder **不手写**:holder 取自归档许可证文本的首行 Copyright,
  44. # version 取自字体二进制 name 表(probe_version)。手写过一次就写错了(Rajdhani
  45. # 实际 1.201 被写成 2.000、Share Tech Mono / Crimson Text 权利人张冠李戴),
  46. # 所以这里只留「去哪取证」的指针,不留抄来的值。
  47. # ---------------------------------------------------------------------------
  48. FAMILIES = [
  49. dict(
  50. id='ofl-source-han-sans', family='Source Han Sans SC', label='思源黑体 Source Han Sans SC',
  51. licenseFile='OFL-source-han-sans.txt', srcLicense='OFL-source-han-sans.txt',
  52. sourceUrl='https://github.com/adobe-fonts/source-han-sans',
  53. versionFont=None, # 本批不嵌入,从维护者字体目录取证(见 versionProbe)
  54. versionProbe=r'E:\_Design\字体\中文\SourceHanSansSC-Regular-2.otf',
  55. embedded=False, note='白名单内;本批无消费点(设计稿正文为 Noto Sans SC),不嵌入以避免死资源',
  56. ),
  57. dict(
  58. id='ofl-source-han-serif', family='Source Han Serif CN', label='思源宋体 Source Han Serif CN',
  59. licenseFile='OFL-source-han-serif.txt', srcLicense='OFL-source-han-serif.txt',
  60. sourceUrl='https://github.com/adobe-fonts/source-han-serif',
  61. versionFont=None,
  62. versionProbe=r'E:\_Design\字体\中文\SourceHanSerifCN-Regular-1.otf',
  63. embedded=False, note='白名单内;设计稿未使用宋体,不嵌入以避免死资源',
  64. ),
  65. dict(
  66. id='ofl-crimson-text', family='Crimson Text', label='Crimson Text',
  67. licenseFile='OFL-crimson-text.txt', srcLicense='OFL-crimson-text.txt',
  68. sourceUrl='https://github.com/google/fonts/tree/main/ofl/crimsontext',
  69. versionFont=None,
  70. versionProbe=r'E:\_Design\字体\英文\CrimsonText-Regular-5.ttf',
  71. embedded=False, note='白名单内;设计稿未使用衬线西文,不嵌入以避免死资源',
  72. ),
  73. dict(
  74. id='ofl-noto-sans-sc', family='Noto Sans SC', label='Noto Sans SC(思源黑体同源发行)',
  75. licenseFile='OFL-noto-sans-sc.txt', srcLicense='OFL-noto-sans-sc.txt',
  76. sourceUrl='https://github.com/google/fonts/tree/main/ofl/notosanssc',
  77. versionFont='NotoSansSC.ttf',
  78. embedded=True, note='设计稿正文实际字族(913 处),经维护者裁定扩展入白名单',
  79. ),
  80. dict(
  81. id='ofl-rajdhani', family='Rajdhani', label='Rajdhani',
  82. licenseFile='OFL-rajdhani.txt', srcLicense='OFL-rajdhani.txt',
  83. sourceUrl='https://github.com/google/fonts/tree/main/ofl/rajdhani',
  84. versionFont='Rajdhani-Regular.ttf',
  85. embedded=True, note='设计稿数字字族(163 处),经维护者裁定扩展入白名单',
  86. ),
  87. dict(
  88. id='ofl-share-tech-mono', family='Share Tech Mono', label='Share Tech Mono',
  89. licenseFile='OFL-share-tech-mono.txt', srcLicense='OFL-share-tech-mono.txt',
  90. sourceUrl='https://github.com/google/fonts/tree/main/ofl/sharetechmono',
  91. versionFont='ShareTechMono-Regular.ttf',
  92. embedded=True, note='设计稿数字/等宽字族(134 处),经维护者裁定扩展入白名单',
  93. ),
  94. dict(
  95. id='ofl-logo-sc-unbounded-sans', family='LogoSC Unbounded Sans', label='标小智无界黑 LogoSC Unbounded Sans',
  96. licenseFile='OFL-logo-sc-unbounded-sans.txt', srcLicense='OFL-logo-sc-unbounded-sans.txt',
  97. sourceUrl='https://github.com/maoken-fonts/unbounded-sans',
  98. versionFont=None,
  99. versionProbe=r'E:\_Design\字体\中文\LogoSCUnboundedSans-Regular-2.ttf',
  100. embedded=False, note='曾用于顶栏菜单(第五轮);维护者第七轮否决——设计稿菜单实为系统字常规体。'
  101. '证据保留在归档,暂无消费点不嵌入(避免死资源);许可证证据取自字体二进制内嵌 OFL 文本',
  102. ),
  103. ]
  104. # 实际嵌入的字体文件条目。只嵌「有消费点」的字族:正文字体与数字/等宽字体。
  105. JOBS = [
  106. dict(key='font.body.regular', licenseId='ofl-noto-sans-sc', family='Noto Sans SC Subset',
  107. src='NotoSansSC.ttf', vary={'wght': 400}, out='noto-sans-sc-400.woff2',
  108. weight=400, style='normal', display='正文', core=True),
  109. dict(key='font.body.bold', licenseId='ofl-noto-sans-sc', family='Noto Sans SC Subset',
  110. src='NotoSansSC.ttf', vary={'wght': 700}, out='noto-sans-sc-700.woff2',
  111. weight=700, style='normal', display='正文/加粗', core=True),
  112. dict(key='font.num.regular', licenseId='ofl-rajdhani', family='Rajdhani Subset',
  113. src='Rajdhani-Regular.ttf', vary=None, out='rajdhani-400.woff2',
  114. weight=400, style='normal', display='数字/常规', core=False),
  115. dict(key='font.num.bold', licenseId='ofl-rajdhani', family='Rajdhani Subset',
  116. src='Rajdhani-Bold.ttf', vary=None, out='rajdhani-700.woff2',
  117. weight=700, style='normal', display='数字/加粗', core=False),
  118. dict(key='font.num.mono', licenseId='ofl-share-tech-mono', family='Share Tech Mono Subset',
  119. src='ShareTechMono-Regular.ttf', vary=None, out='share-tech-mono-400.woff2',
  120. weight=400, style='normal', display='数字/等宽', core=False),
  121. # 票 17 第七轮:font.title(无界黑)撤销嵌入——维护者否决菜单粗体观感,设计稿菜单为
  122. # 系统字常规体 + 切图底板;标题是切图不需要字体。槽位与 @font-face 一并移除,避免死资源。
  123. ]
  124. def make_options():
  125. opts = subset.Options()
  126. opts.flavor = 'woff2' # woff2 体积最小,现代浏览器全支持
  127. opts.layout_features = ['*'] # 保留排版特性,避免中文字形错排
  128. opts.notdef_outline = True
  129. opts.desubroutinize = False
  130. opts.drop_tables += ['DSIG'] # 子集化后签名必然失效,保留会误导
  131. return opts
  132. def name_records(font):
  133. """字体内嵌 name 表:名称与版本是事实,直接取自二进制而非手抄。"""
  134. out = {}
  135. for rec in font['name'].names:
  136. if rec.nameID in (1, 5) and rec.platformID == 3:
  137. try:
  138. out[rec.nameID] = rec.toUnicode()
  139. except Exception:
  140. pass
  141. return out
  142. def probe_version(font_file):
  143. """从字体二进制取版本号(name ID 5)。支持上游目录文件名或绝对路径;取不到返回空串,不猜。"""
  144. if not font_file:
  145. return ''
  146. path = font_file if os.path.isabs(font_file) else os.path.join(UPSTREAM, font_file)
  147. if not os.path.exists(path):
  148. return ''
  149. ft = TTFont(path, lazy=True)
  150. try:
  151. return name_records(ft).get(5, '')
  152. finally:
  153. ft.close()
  154. def probe_holder(license_path):
  155. """从归档的许可证文本取权利人。
  156. OFL 文本的 Copyright 行会在 70 列左右换行(Adobe 的尤其如此),
  157. 只读第一行会截断成「Copyright 2014-2025 Adobe ..., with Reserved Font」。
  158. 这里读到空行为止,把续行拼回来。
  159. """
  160. with open(license_path, encoding='utf-8', errors='replace') as f:
  161. lines = [ln.rstrip('\n') for ln in f]
  162. for i, line in enumerate(lines):
  163. if line.strip().lower().startswith('copyright'):
  164. parts = [line.strip()]
  165. for nxt in lines[i + 1:]:
  166. if not nxt.strip():
  167. break
  168. parts.append(nxt.strip())
  169. return ' '.join(parts)
  170. return ''
  171. def build(job):
  172. src_path = os.path.join(UPSTREAM, job['src'])
  173. font = TTFont(src_path)
  174. meta = name_records(font)
  175. if job['vary']:
  176. font = instancer.instantiateVariableFont(font, job['vary'], inplace=True)
  177. opts = make_options()
  178. subsetter = subset.Subsetter(options=opts)
  179. subsetter.populate(text=SUBSET_TEXT)
  180. subsetter.subset(font)
  181. buf = io.BytesIO()
  182. subset.save_font(font, buf, opts)
  183. font.close()
  184. data = buf.getvalue()
  185. with open(os.path.join(OUT_DIR, job['out']), 'wb') as f:
  186. f.write(data)
  187. return {
  188. 'key': job['key'],
  189. 'licenseId': job['licenseId'],
  190. 'family': job['family'],
  191. 'weight': job['weight'],
  192. 'style': job['style'],
  193. 'display': job['display'],
  194. 'path': 'assets/fonts/' + job['out'],
  195. 'format': 'woff2',
  196. 'bytes': len(data),
  197. 'hash': 'sha256:' + hashlib.sha256(data).hexdigest(),
  198. 'sourceFont': job['src'],
  199. 'sourceFontName': meta.get(1, ''),
  200. 'sourceFontVersion': meta.get(5, ''),
  201. 'subsetTextChars': len(set(SUBSET_TEXT)),
  202. 'core': job['core'],
  203. }
  204. def archive_licenses():
  205. os.makedirs(LIC_DIR, exist_ok=True)
  206. records = []
  207. for fam in FAMILIES:
  208. src = os.path.join(UPSTREAM, fam['srcLicense'])
  209. dst = os.path.join(LIC_DIR, fam['licenseFile'])
  210. shutil.copyfile(src, dst)
  211. blob = open(dst, 'rb').read()
  212. records.append({
  213. 'id': fam['id'],
  214. 'family': fam['family'],
  215. 'label': fam['label'],
  216. # 版本与权利人均为取证值:版本来自字体二进制,权利人来自许可证文本首行
  217. 'version': probe_version(fam.get('versionProbe') or fam['versionFont']),
  218. 'versionSource': fam.get('versionProbe') or fam['versionFont'] or 'unavailable',
  219. 'holder': probe_holder(dst),
  220. 'spdx': 'OFL-1.1',
  221. 'covers': ['web-embedding', 'commercial-use'],
  222. 'sourceUrl': fam['sourceUrl'],
  223. 'evidence': 'assets/fonts/licenses/' + fam['licenseFile'],
  224. 'evidenceHash': 'sha256:' + hashlib.sha256(blob).hexdigest(),
  225. 'evidenceBytes': len(blob),
  226. 'embedded': fam['embedded'],
  227. 'note': fam['note'],
  228. })
  229. return records
  230. def main():
  231. os.makedirs(OUT_DIR, exist_ok=True)
  232. licenses = archive_licenses()
  233. entries = [build(j) for j in JOBS]
  234. report = {
  235. 'note': '由 tools/build-fonts.py 生成;合并进 public/cockpit/assets/manifest.json 的 fonts 块。',
  236. 'subsetTextChars': len(set(SUBSET_TEXT)),
  237. 'licenses': licenses,
  238. 'entries': entries,
  239. }
  240. with open(os.path.join(HERE, 'font-assets.json'), 'w', encoding='utf-8') as f:
  241. json.dump(report, f, ensure_ascii=False, indent=2)
  242. for lic in licenses:
  243. print('lic %-26s %-24s %s' % (lic['id'], lic['family'], 'embedded' if lic['embedded'] else 'evidence-only'))
  244. print()
  245. for r in entries:
  246. print('%-18s %-24s %8.1f KB %s' % (r['key'], r['family'], r['bytes'] / 1024, r['path']))
  247. print('embedded total: %.1f KB' % (sum(r['bytes'] for r in entries) / 1024))
  248. if __name__ == '__main__':
  249. main()