feat: 建立可复现字体包构建链
This commit is contained in:
+24
-2
@@ -305,6 +305,24 @@ DOCX 字体源总量调整为 48 MiB,与已验证的原生中文 TrueType 资
|
||||
权限校验。字体二进制、独立安装包和 Docker 分发尚未进入仓库;下一步
|
||||
FP3 只处理独立 NSIS 字体包、Docker 可选挂载及其安装/升级/卸载门禁。
|
||||
|
||||
阶段 12D-FP3-A 已建立独立 `packages/font-pack-builder` 和首个冻结字体包
|
||||
配方 `mdtp-serif-sc@1.0.0`。主仓库只保存配方、OFL-1.1 许可证、上游提交
|
||||
与派生说明,不保存约 41.37 MiB 字体二进制;发布人员从 Git 忽略的受信任
|
||||
源目录提供 Regular 400、Bold 700 两组静态 TTF/WOFF2。构建器会校验
|
||||
普通文件、路径包含关系、大小、SHA-256、WOFF2/TrueType 格式、字体内部
|
||||
家族、字重、Web/DOCX 元数据一致性及文档嵌入权限,再原子组装版本化
|
||||
`font-pack.json` 目录。相同版本内容一致时重复构建保持幂等,内容不同时
|
||||
拒绝覆盖;字体包只匹配 `FandolSong` 与 `Mdpdf Fandol Song`,不会通过
|
||||
`SimSun` 等通用别名错误替换仿宋、黑体、楷体或外部主题字体。
|
||||
|
||||
统一命令为 `npm run build:font-pack` 和 `npm run verify:font-pack`,默认
|
||||
产物写入被 Git 忽略的 `output/font-packs/`。真实资产构建结果为
|
||||
41,371,798 字节,两字形面均为 `MdTP Serif SC`、`fsType=0`、可安装嵌入;
|
||||
应用层验收确认政企红头标准文件同时取得两组 WOFF2 和静态 TTF,其余三个
|
||||
字体面继续使用主题安全降级。全仓 461 项测试、类型检查和生产构建通过。
|
||||
本阶段未修改 NSIS 或 Docker;下一步 FP3-B 实现独立用户级字体包安装、
|
||||
升级、卸载和主安装器同目录可选入口。
|
||||
|
||||
## 2. 已完成
|
||||
|
||||
### 2.1 项目骨架
|
||||
@@ -1193,6 +1211,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
||||
资源哈希与容量门禁、确定性版本选择和逻辑字体面匹配;应用层已将同一
|
||||
解析结果用于主题 CSS 的 WOFF2 和 DOCX 的静态 TrueType,发行资产与
|
||||
安装逻辑留在 FP3。
|
||||
- `packages/font-pack-builder` 负责从仓库外冻结资源构建版本化字体包,
|
||||
校验输入哈希、字体格式、内部元数据、嵌入权限、同版本不可变性并生成
|
||||
可供注册器再次验证的构建报告;字体二进制和产物目录均不进入 Git。
|
||||
- `packages/application/src/docx-export-service.ts` 负责 DOCX capability、
|
||||
并发排队、总超时、取消、跨端转换编排、错误码和耗时诊断。
|
||||
- `apps/desktop/src/desktop-application-controller.ts` 负责 Electron
|
||||
@@ -1257,8 +1278,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
||||
资源双重校验和字体候选匹配;
|
||||
- 阶段 12D-FP2:已完成 Preview/PDF WOFF2 与 DOCX 静态 TrueType 的
|
||||
同源字体解析、跨端受限资源入口、缓存指纹、降级诊断和真实字体包验证;
|
||||
下一步 FP3 实现独立 NSIS 字体包与 Docker 可选挂载,最终由 R4 完成
|
||||
14 套主题深度视觉回归;
|
||||
- 阶段 12D-FP3-A:已完成独立字体包构建器、冻结配方、许可证与来源追踪、
|
||||
同版本不可变门禁和真实资产构建;下一步 FP3-B 实现独立 NSIS 字体包,
|
||||
FP3-C 再实现 Docker 可选挂载,最终由 R4 完成 14 套主题深度视觉回归;
|
||||
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
|
||||
|
||||
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 可选字体包构建
|
||||
|
||||
本目录只保存字体包配方、许可证和来源说明,不保存字体二进制。主程序、
|
||||
Desktop 安装包和 Docker 主镜像不会因为这些配方增加字体体积。
|
||||
|
||||
## 源目录
|
||||
|
||||
发布人员需要在 Git 忽略的本地目录中准备配方声明的冻结资源。默认目录为:
|
||||
|
||||
```text
|
||||
.local/font-pack-sources/mdtp-serif-sc/1.0.0/
|
||||
├── MdTPSerifSC-Regular.ttf
|
||||
├── MdTPSerifSC-Regular.woff2
|
||||
├── MdTPSerifSC-Bold.ttf
|
||||
└── MdTPSerifSC-Bold.woff2
|
||||
```
|
||||
|
||||
构建器不会联网下载、转换或自动替换字体。每个输入文件都必须与配方声明的
|
||||
字节数和 SHA-256 完全一致;TTF 还必须是无需运行时规范化的原生静态
|
||||
TrueType,并具有可安装或可编辑的文档嵌入权限。
|
||||
|
||||
## 构建与验证
|
||||
|
||||
```powershell
|
||||
npm run build:font-pack
|
||||
npm run verify:font-pack
|
||||
```
|
||||
|
||||
也可以显式指定源目录:
|
||||
|
||||
```powershell
|
||||
npm run build:font-pack -- --source-root D:/font-pack-sources/mdtp-serif-sc/1.0.0
|
||||
```
|
||||
|
||||
默认运行时字体包根目录为 `output/font-packs/root`,构建报告写入
|
||||
`output/font-packs/build-report.json`,两者均被 Git 忽略。输出布局为:
|
||||
|
||||
```text
|
||||
root/<pack-id>/<semver>/font-pack.json
|
||||
```
|
||||
|
||||
同一 ID 和版本的内容完全相同时,重复构建保持幂等;只要任一文件发生
|
||||
变化,构建器就会拒绝覆盖。发行内容变化必须提升字体包 SemVer。
|
||||
|
||||
当前 `mdtp-serif-sc@1.0.0` 只替换 `FandolSong` 与
|
||||
`Mdpdf Fandol Song` 的 400/700 字重,不匹配 `SimSun` 等通用别名,避免
|
||||
错误替换仿宋、黑体、楷体或普通外部主题字体。
|
||||
@@ -0,0 +1,94 @@
|
||||
Copyright 2012 Google Inc. All Rights Reserved.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,19 @@
|
||||
# MdTP Serif SC 字体来源说明
|
||||
|
||||
本字体包的 Regular 400 与 Bold 700 字形面派生自 Noto Serif SC 可变字体。
|
||||
|
||||
- 上游项目:Noto CJK
|
||||
- 上游仓库:<https://github.com/notofonts/noto-cjk>
|
||||
- Google Fonts 源提交:`985fa52c81c1d6692ccdd82bc3656e8fb932fd89`
|
||||
- 上游源文件:`google-fonts/NotoSerifSC[wght].ttf`
|
||||
- 上游源 SHA-256:`050080d9255a86808f2945bffac582b31ef32bc36411ce29563b4961670c66f9`
|
||||
- 许可证:SIL Open Font License 1.1
|
||||
|
||||
发行字形面使用 fontTools 4.60.1 从 `wght` 轴固定生成,并将主字体家族改名
|
||||
为 `MdTP Serif SC`、PostScript 前缀改名为 `MdTPSerifSC`。改名用于明确
|
||||
标识派生版本,并避免派生字体占用上游字体名称。字形轮廓与字符覆盖未做
|
||||
删减;`OS/2.fsType` 为 0,允许安装和文档嵌入。
|
||||
|
||||
字体包构建器不从网络下载字体,也不在主程序安装包中包含这些二进制。
|
||||
发布人员必须将配方声明的四个冻结资源放入受信任源目录;构建器会同时
|
||||
校验文件大小、SHA-256、格式、字体内部家族、字重与嵌入权限。
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"recipeVersion": 1,
|
||||
"manifestVersion": 1,
|
||||
"id": "mdtp-serif-sc",
|
||||
"version": "1.0.0",
|
||||
"name": "MdTP 中文宋体兼容包",
|
||||
"description": "为政企公文主题提供经过 Word/WPS 验证的原生静态中文宋体 Regular 与 Bold 字形面。",
|
||||
"internalFamily": "MdTP Serif SC",
|
||||
"license": "OFL-1.1",
|
||||
"licenseSource": "OFL-NotoSerifSC.txt",
|
||||
"licensePath": "OFL-NotoSerifSC.txt",
|
||||
"licenseBytes": 4350,
|
||||
"licenseSha256": "5e0da210fb04058a8c0087985d2d456b931c2579811a49655721d3cf0c36b6d6",
|
||||
"noticeSource": "SOURCE.md",
|
||||
"noticePath": "SOURCE.md",
|
||||
"compatibility": {
|
||||
"minimumAppVersion": "0.6.0"
|
||||
},
|
||||
"faces": [
|
||||
{
|
||||
"id": "serif-regular",
|
||||
"targets": ["FandolSong", "Mdpdf Fandol Song"],
|
||||
"weight": 400,
|
||||
"style": "normal",
|
||||
"web": {
|
||||
"source": "MdTPSerifSC-Regular.woff2",
|
||||
"path": "fonts/MdTPSerifSC-Regular.woff2",
|
||||
"bytes": 5732996,
|
||||
"sha256": "b1887c69d28ffba5a9f5eb5f8240c12264ea4b8e807f27ef45e308fb53b8e3ac"
|
||||
},
|
||||
"docx": {
|
||||
"source": "MdTPSerifSC-Regular.ttf",
|
||||
"path": "fonts/MdTPSerifSC-Regular.ttf",
|
||||
"bytes": 14854872,
|
||||
"sha256": "3bc4775662cfdb8bcbd59ddfc529852b7f72ca4906f339950ee9d7a44b844942"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "serif-bold",
|
||||
"targets": ["FandolSong", "Mdpdf Fandol Song"],
|
||||
"weight": 700,
|
||||
"style": "normal",
|
||||
"web": {
|
||||
"source": "MdTPSerifSC-Bold.woff2",
|
||||
"path": "fonts/MdTPSerifSC-Bold.woff2",
|
||||
"bytes": 5925732,
|
||||
"sha256": "cce565f7504b22c9c08bbcb342f178ab2f730a202a76913afe77a5bd49125961"
|
||||
},
|
||||
"docx": {
|
||||
"source": "MdTPSerifSC-Bold.ttf",
|
||||
"path": "fonts/MdTPSerifSC-Bold.ttf",
|
||||
"bytes": 14853848,
|
||||
"sha256": "f0bcd279ae061180e12ea0b7f2715de75bede67411373816495b35ade989b154"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+17
@@ -2199,6 +2199,10 @@
|
||||
"resolved": "packages/docx-theme-engine",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@md-to-pdf/font-pack-builder": {
|
||||
"resolved": "packages/font-pack-builder",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@md-to-pdf/font-pack-registry": {
|
||||
"resolved": "packages/font-pack-registry",
|
||||
"link": true
|
||||
@@ -10023,6 +10027,19 @@
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
},
|
||||
"packages/font-pack-builder": {
|
||||
"name": "@md-to-pdf/font-pack-builder",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@md-to-pdf/docx-engine": "0.1.0",
|
||||
"@md-to-pdf/font-pack-registry": "0.1.0",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
},
|
||||
"packages/font-pack-registry": {
|
||||
"name": "@md-to-pdf/font-pack-registry",
|
||||
"version": "0.1.0",
|
||||
|
||||
+5
-3
@@ -9,7 +9,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop",
|
||||
"build": "npm run build:web-runtime && npm run build -w @md-to-pdf/font-pack-builder && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop",
|
||||
"build:web-runtime": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/web",
|
||||
"dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n markdown-echarts,core,semantic-document,docx-theme-engine,font-pack-registry,docx-engine,renderer,application,preview-engine,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:semantic-document\" \"npm:dev:docx-theme-engine\" \"npm:dev:font-pack-registry\" \"npm:dev:docx-engine\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:preview-engine\" \"npm:dev:server\" \"npm:dev:web\"",
|
||||
"dev:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts",
|
||||
@@ -26,6 +26,8 @@
|
||||
"dev:desktop": "npm run dev -w @md-to-pdf/desktop",
|
||||
"desktop:dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"",
|
||||
"desktop:package": "npm run package -w @md-to-pdf/desktop",
|
||||
"build:font-pack": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/font-pack-builder && node packages/font-pack-builder/dist/cli.js build",
|
||||
"verify:font-pack": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/font-pack-builder && node packages/font-pack-builder/dist/cli.js verify",
|
||||
"theme:import-typora": "node scripts/import-typora-theme.mjs",
|
||||
"verify:docx-reference": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:pandoc -w @md-to-pdf/docx-engine",
|
||||
"verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine",
|
||||
@@ -34,8 +36,8 @@
|
||||
"verify:docx-theme-styles": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/desktop",
|
||||
"verify:docx-acceptance": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop && npm run verify:pandoc -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine && npm run verify:docx-http -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop -- desktop-docx-save.test.ts",
|
||||
"verify:docx-http": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run verify:docx-http -w @md-to-pdf/server",
|
||||
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/document-visual-diff && npm run test -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/font-pack-registry && npm run test -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop",
|
||||
"typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run typecheck -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-theme-engine && npm run typecheck -w @md-to-pdf/document-visual-diff && npm run typecheck -w @md-to-pdf/font-pack-registry && npm run typecheck -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop"
|
||||
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/document-visual-diff && npm run test -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/font-pack-registry && npm run test -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/font-pack-builder && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop",
|
||||
"typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run typecheck -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-theme-engine && npm run typecheck -w @md-to-pdf/document-visual-diff && npm run typecheck -w @md-to-pdf/font-pack-registry && npm run typecheck -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run typecheck -w @md-to-pdf/font-pack-builder && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@md-to-pdf/font-pack-builder",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@md-to-pdf/docx-engine": "0.1.0",
|
||||
"@md-to-pdf/font-pack-registry": "0.1.0",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
writeFile
|
||||
} from "node:fs/promises";
|
||||
import {
|
||||
dirname,
|
||||
isAbsolute,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
sep
|
||||
} from "node:path";
|
||||
import { prepareDocxFonts, type PreparedDocxFont } from "@md-to-pdf/docx-engine";
|
||||
import {
|
||||
discoverFontPacks,
|
||||
fontPackManifestSchema,
|
||||
readInstalledFontPackAsset,
|
||||
type FontPackManifest,
|
||||
type InstalledFontPack
|
||||
} from "@md-to-pdf/font-pack-registry";
|
||||
import { fontPackRecipeSchema, type FontPackRecipe } from "./recipe.js";
|
||||
|
||||
const MAXIMUM_RECIPE_BYTES = 256 * 1024;
|
||||
const MAXIMUM_NOTICE_BYTES = 256 * 1024;
|
||||
|
||||
export interface BuildFontPackOptions {
|
||||
recipePath: string;
|
||||
sourceRoot: string;
|
||||
outputRoot: string;
|
||||
reportPath?: string;
|
||||
}
|
||||
|
||||
export interface VerifyFontPackOptions {
|
||||
root: string;
|
||||
appVersion: string;
|
||||
packId: string;
|
||||
packVersion: string;
|
||||
}
|
||||
|
||||
export interface FontPackBuildReport {
|
||||
recipeSha256?: string;
|
||||
packId: string;
|
||||
packVersion: string;
|
||||
outputDirectory: string;
|
||||
unchanged: boolean;
|
||||
packFingerprint: string;
|
||||
registryFingerprint: string;
|
||||
totalBytes: number;
|
||||
faces: Array<{
|
||||
id: string;
|
||||
targets: string[];
|
||||
weight: number;
|
||||
style: "normal" | "italic";
|
||||
family: string;
|
||||
subfamily: string;
|
||||
postscriptName?: string;
|
||||
permission: "installable" | "editable";
|
||||
webSha256: string;
|
||||
docxSha256: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function sha256(content: Uint8Array | string) {
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
function normalizeText(content: string) {
|
||||
return content.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
||||
}
|
||||
|
||||
function isContainedPath(root: string, target: string) {
|
||||
const path = relative(root, target);
|
||||
return path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path);
|
||||
}
|
||||
|
||||
async function trustedRoot(root: string) {
|
||||
const requested = resolve(root);
|
||||
const status = await lstat(requested);
|
||||
if (status.isSymbolicLink() || !status.isDirectory()) {
|
||||
throw new Error(`目录不是受信任的普通目录:${requested}`);
|
||||
}
|
||||
return realpath(requested);
|
||||
}
|
||||
|
||||
async function readTrustedFile(root: string, path: string, maximumBytes: number) {
|
||||
const candidate = resolve(root, ...path.split("/"));
|
||||
if (!isContainedPath(root, candidate)) {
|
||||
throw new Error(`资源越过受信任目录:${path}`);
|
||||
}
|
||||
const linkStatus = await lstat(candidate);
|
||||
if (linkStatus.isSymbolicLink() || !linkStatus.isFile()) {
|
||||
throw new Error(`资源不是普通文件:${path}`);
|
||||
}
|
||||
const realCandidate = await realpath(candidate);
|
||||
if (!isContainedPath(root, realCandidate)) {
|
||||
throw new Error(`资源真实路径越过受信任目录:${path}`);
|
||||
}
|
||||
const fileStatus = await stat(realCandidate);
|
||||
if (fileStatus.size > maximumBytes) {
|
||||
throw new Error(`资源超过容量限制:${path}`);
|
||||
}
|
||||
return new Uint8Array(await readFile(realCandidate));
|
||||
}
|
||||
|
||||
async function readRecipe(recipePath: string) {
|
||||
const absolutePath = resolve(recipePath);
|
||||
const status = await lstat(absolutePath);
|
||||
if (
|
||||
status.isSymbolicLink() ||
|
||||
!status.isFile() ||
|
||||
status.size > MAXIMUM_RECIPE_BYTES
|
||||
) {
|
||||
throw new Error("字体包配方不是受支持的普通文件");
|
||||
}
|
||||
const content = await readFile(absolutePath);
|
||||
const recipe = fontPackRecipeSchema.parse(
|
||||
JSON.parse(content.toString("utf8")) as unknown
|
||||
);
|
||||
return {
|
||||
recipe,
|
||||
recipeDirectory: await trustedRoot(dirname(absolutePath)),
|
||||
recipeSha256: sha256(content)
|
||||
};
|
||||
}
|
||||
|
||||
async function readVerifiedSource(
|
||||
root: string,
|
||||
asset: { source: string; bytes: number; sha256: string }
|
||||
) {
|
||||
const content = await readTrustedFile(root, asset.source, asset.bytes);
|
||||
if (content.byteLength !== asset.bytes) {
|
||||
throw new Error(`字体源大小不匹配:${asset.source}`);
|
||||
}
|
||||
if (sha256(content) !== asset.sha256) {
|
||||
throw new Error(`字体源 SHA-256 不匹配:${asset.source}`);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function assertWoff2(content: Uint8Array, path: string) {
|
||||
if (
|
||||
content.byteLength < 4 ||
|
||||
String.fromCharCode(...content.subarray(0, 4)) !== "wOF2"
|
||||
) {
|
||||
throw new Error(`Web 字体不是 WOFF2:${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertTrueType(content: Uint8Array, path: string) {
|
||||
if (
|
||||
content.byteLength < 4 ||
|
||||
content[0] !== 0 ||
|
||||
content[1] !== 1 ||
|
||||
content[2] !== 0 ||
|
||||
content[3] !== 0
|
||||
) {
|
||||
throw new Error(`DOCX 字体不是原生静态 TrueType:${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPreparedFont(
|
||||
font: PreparedDocxFont,
|
||||
recipe: FontPackRecipe,
|
||||
face: FontPackRecipe["faces"][number],
|
||||
kind: "web" | "docx"
|
||||
) {
|
||||
if (font.metadata.family !== recipe.internalFamily) {
|
||||
throw new Error(
|
||||
`${kind} 字体内部家族不匹配:${font.metadata.family} != ${recipe.internalFamily}`
|
||||
);
|
||||
}
|
||||
if (font.metadata.weightClass !== face.weight) {
|
||||
throw new Error(
|
||||
`${kind} 字体内部字重不匹配:${font.metadata.weightClass} != ${face.weight}`
|
||||
);
|
||||
}
|
||||
if (font.metadata.permission !== "installable" && font.metadata.permission !== "editable") {
|
||||
throw new Error(`${kind} 字体不允许文档嵌入`);
|
||||
}
|
||||
if (kind === "docx" && font.metadata.signature !== "\u0000\u0001\u0000\u0000") {
|
||||
throw new Error("DOCX 字体解码后不是 TrueType SFNT");
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareRecipeAssets(recipe: FontPackRecipe, sourceRoot: string) {
|
||||
const assets = await Promise.all(
|
||||
recipe.faces.map(async (face) => {
|
||||
const [web, docx] = await Promise.all([
|
||||
readVerifiedSource(sourceRoot, face.web),
|
||||
readVerifiedSource(sourceRoot, face.docx)
|
||||
]);
|
||||
assertWoff2(web, face.web.source);
|
||||
assertTrueType(docx, face.docx.source);
|
||||
return { face, web, docx };
|
||||
})
|
||||
);
|
||||
const createSources = (kind: "web" | "docx") =>
|
||||
assets.map(({ face, web, docx }) => ({
|
||||
family: recipe.internalFamily,
|
||||
aliases: [...face.targets],
|
||||
source: face[kind].source,
|
||||
weight: face.weight,
|
||||
style: face.style,
|
||||
license: recipe.license,
|
||||
content: kind === "web" ? web : docx
|
||||
}));
|
||||
const [webFonts, docxFonts] = await Promise.all([
|
||||
prepareDocxFonts(createSources("web")),
|
||||
prepareDocxFonts(createSources("docx"))
|
||||
]);
|
||||
for (const [index, item] of assets.entries()) {
|
||||
const webFont = webFonts[index];
|
||||
const docxFont = docxFonts[index];
|
||||
if (!webFont || !docxFont) {
|
||||
throw new Error("字体元数据验证结果不完整");
|
||||
}
|
||||
assertPreparedFont(webFont, recipe, item.face, "web");
|
||||
assertPreparedFont(docxFont, recipe, item.face, "docx");
|
||||
if (docxFont.fingerprint !== item.face.docx.sha256) {
|
||||
throw new Error(
|
||||
`DOCX 字体需要规范化后才能使用,不是冻结的原生静态资产:${item.face.docx.source}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
webFont.metadata.family !== docxFont.metadata.family ||
|
||||
webFont.metadata.weightClass !== docxFont.metadata.weightClass ||
|
||||
webFont.metadata.subfamily !== docxFont.metadata.subfamily
|
||||
) {
|
||||
throw new Error(`Web 与 DOCX 字体元数据不一致:${item.face.id}`);
|
||||
}
|
||||
}
|
||||
return assets.map((item, index) => ({
|
||||
...item,
|
||||
metadata: docxFonts[index]!.metadata
|
||||
}));
|
||||
}
|
||||
|
||||
function createManifest(
|
||||
recipe: FontPackRecipe,
|
||||
licenseContent: Uint8Array
|
||||
): FontPackManifest {
|
||||
return fontPackManifestSchema.parse({
|
||||
manifestVersion: recipe.manifestVersion,
|
||||
id: recipe.id,
|
||||
version: recipe.version,
|
||||
name: recipe.name,
|
||||
description: recipe.description,
|
||||
license: recipe.license,
|
||||
licenseFile: recipe.licensePath,
|
||||
licenseSha256: sha256(licenseContent),
|
||||
licenseBytes: licenseContent.byteLength,
|
||||
compatibility: recipe.compatibility,
|
||||
faces: recipe.faces.map((face) => ({
|
||||
id: face.id,
|
||||
targets: face.targets,
|
||||
weight: face.weight,
|
||||
style: face.style,
|
||||
web: {
|
||||
path: face.web.path,
|
||||
format: "woff2",
|
||||
bytes: face.web.bytes,
|
||||
sha256: face.web.sha256
|
||||
},
|
||||
docx: {
|
||||
path: face.docx.path,
|
||||
format: "truetype",
|
||||
bytes: face.docx.bytes,
|
||||
sha256: face.docx.sha256
|
||||
}
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
async function writePackFile(root: string, path: string, content: Uint8Array | string) {
|
||||
const target = resolve(root, ...path.split("/"));
|
||||
if (!isContainedPath(root, target)) {
|
||||
throw new Error(`输出路径越过字体包目录:${path}`);
|
||||
}
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await writeFile(target, content);
|
||||
}
|
||||
|
||||
async function directoryFingerprint(root: string) {
|
||||
const files: Array<{ path: string; sha256: string }> = [];
|
||||
async function visit(directory: string) {
|
||||
for (const entry of (await readdir(directory, { withFileTypes: true })).sort(
|
||||
(left, right) => left.name.localeCompare(right.name, "en")
|
||||
)) {
|
||||
const target = join(directory, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(`字体包输出包含符号链接:${target}`);
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
await visit(target);
|
||||
} else if (entry.isFile()) {
|
||||
files.push({
|
||||
path: relative(root, target).split(sep).join("/"),
|
||||
sha256: sha256(await readFile(target))
|
||||
});
|
||||
} else {
|
||||
throw new Error(`字体包输出包含不支持的文件类型:${target}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit(root);
|
||||
return sha256(JSON.stringify(files));
|
||||
}
|
||||
|
||||
async function installStagedPack(stagePack: string, outputRoot: string, recipe: FontPackRecipe) {
|
||||
const idDirectory = resolve(outputRoot, recipe.id);
|
||||
const finalDirectory = resolve(idDirectory, recipe.version);
|
||||
await mkdir(idDirectory, { recursive: true });
|
||||
try {
|
||||
const existing = await lstat(finalDirectory);
|
||||
if (existing.isSymbolicLink() || !existing.isDirectory()) {
|
||||
throw new Error(`同版本目标不是普通目录:${finalDirectory}`);
|
||||
}
|
||||
const [existingFingerprint, stagedFingerprint] = await Promise.all([
|
||||
directoryFingerprint(finalDirectory),
|
||||
directoryFingerprint(stagePack)
|
||||
]);
|
||||
if (existingFingerprint !== stagedFingerprint) {
|
||||
throw new Error(
|
||||
`拒绝覆盖内容不同的已发行字体包版本:${recipe.id}@${recipe.version}`
|
||||
);
|
||||
}
|
||||
return { directory: finalDirectory, unchanged: true };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
await rename(stagePack, finalDirectory);
|
||||
return { directory: finalDirectory, unchanged: false };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function validateInstalledPack(
|
||||
pack: InstalledFontPack,
|
||||
expectedVersion: string
|
||||
) {
|
||||
if (pack.version !== expectedVersion) {
|
||||
throw new Error(`字体包版本不匹配:${pack.version} != ${expectedVersion}`);
|
||||
}
|
||||
const contents = await Promise.all(
|
||||
pack.faces.map(async (face) => {
|
||||
const [web, docx] = await Promise.all([
|
||||
readInstalledFontPackAsset(face.web),
|
||||
readInstalledFontPackAsset(face.docx)
|
||||
]);
|
||||
assertWoff2(web, face.web.path);
|
||||
assertTrueType(docx, face.docx.path);
|
||||
return { face, web, docx };
|
||||
})
|
||||
);
|
||||
const createSources = (kind: "web" | "docx") =>
|
||||
contents.map(({ face, web, docx }) => ({
|
||||
family: face.targets[0]!,
|
||||
aliases: face.targets.slice(1),
|
||||
source: face[kind].absolutePath,
|
||||
weight: face.weight,
|
||||
style: face.style,
|
||||
license: pack.license,
|
||||
content: kind === "web" ? web : docx
|
||||
}));
|
||||
const [webFonts, docxFonts] = await Promise.all([
|
||||
prepareDocxFonts(createSources("web")),
|
||||
prepareDocxFonts(createSources("docx"))
|
||||
]);
|
||||
for (const [index, font] of docxFonts.entries()) {
|
||||
const face = pack.faces[index]!;
|
||||
const webFont = webFonts[index];
|
||||
if (
|
||||
!webFont ||
|
||||
font.metadata.signature !== "\u0000\u0001\u0000\u0000" ||
|
||||
font.metadata.weightClass !== face.weight ||
|
||||
font.fingerprint !== face.docx.sha256 ||
|
||||
webFont.metadata.family !== font.metadata.family ||
|
||||
webFont.metadata.subfamily !== font.metadata.subfamily ||
|
||||
webFont.metadata.weightClass !== font.metadata.weightClass
|
||||
) {
|
||||
throw new Error(`已安装字体包元数据不匹配:${face.id}`);
|
||||
}
|
||||
}
|
||||
return docxFonts;
|
||||
}
|
||||
|
||||
export async function verifyFontPack(
|
||||
options: VerifyFontPackOptions
|
||||
): Promise<FontPackBuildReport> {
|
||||
const result = await discoverFontPacks({
|
||||
roots: [options.root],
|
||||
appVersion: options.appVersion
|
||||
});
|
||||
const blocking = result.diagnostics.filter((item) => item.severity !== "info");
|
||||
if (blocking.length > 0) {
|
||||
throw new Error(
|
||||
`字体包注册验证失败:${blocking.map((item) => `${item.code} ${item.message}`).join(";")}`
|
||||
);
|
||||
}
|
||||
const pack = result.packs.find((item) => item.id === options.packId);
|
||||
if (!pack) {
|
||||
throw new Error(`未发现字体包:${options.packId}`);
|
||||
}
|
||||
const fonts = await validateInstalledPack(pack, options.packVersion);
|
||||
return {
|
||||
packId: pack.id,
|
||||
packVersion: pack.version,
|
||||
outputDirectory: pack.directory,
|
||||
unchanged: true,
|
||||
packFingerprint: pack.fingerprint,
|
||||
registryFingerprint: result.fingerprint,
|
||||
totalBytes: pack.faces.reduce(
|
||||
(total, face) => total + face.web.bytes + face.docx.bytes,
|
||||
pack.licenseBytes
|
||||
),
|
||||
faces: pack.faces.map((face, index) => ({
|
||||
id: face.id,
|
||||
targets: [...face.targets],
|
||||
weight: face.weight,
|
||||
style: face.style,
|
||||
family: fonts[index]!.metadata.family,
|
||||
subfamily: fonts[index]!.metadata.subfamily,
|
||||
...(fonts[index]!.metadata.postscriptName
|
||||
? { postscriptName: fonts[index]!.metadata.postscriptName }
|
||||
: {}),
|
||||
permission: fonts[index]!.metadata.permission,
|
||||
webSha256: face.web.sha256,
|
||||
docxSha256: face.docx.sha256
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildFontPack(
|
||||
options: BuildFontPackOptions
|
||||
): Promise<FontPackBuildReport> {
|
||||
const { recipe, recipeDirectory, recipeSha256 } = await readRecipe(
|
||||
options.recipePath
|
||||
);
|
||||
const sourceRoot = await trustedRoot(options.sourceRoot);
|
||||
const [licenseText, noticeText, assets] = await Promise.all([
|
||||
readTrustedFile(recipeDirectory, recipe.licenseSource, recipe.licenseBytes),
|
||||
readTrustedFile(recipeDirectory, recipe.noticeSource, MAXIMUM_NOTICE_BYTES),
|
||||
prepareRecipeAssets(recipe, sourceRoot)
|
||||
]);
|
||||
const normalizedLicense = new TextEncoder().encode(
|
||||
normalizeText(new TextDecoder().decode(licenseText))
|
||||
);
|
||||
if (
|
||||
normalizedLicense.byteLength !== recipe.licenseBytes ||
|
||||
sha256(normalizedLicense) !== recipe.licenseSha256
|
||||
) {
|
||||
throw new Error("字体许可证大小或 SHA-256 与配方不匹配");
|
||||
}
|
||||
const manifest = createManifest(recipe, normalizedLicense);
|
||||
const outputRoot = resolve(options.outputRoot);
|
||||
await mkdir(outputRoot, { recursive: true });
|
||||
const stageRoot = resolve(
|
||||
dirname(outputRoot),
|
||||
`.font-pack-stage-${recipe.id}-${randomUUID()}`
|
||||
);
|
||||
const stagePack = resolve(stageRoot, recipe.id, recipe.version);
|
||||
await mkdir(stagePack, { recursive: true });
|
||||
try {
|
||||
await Promise.all([
|
||||
writePackFile(stagePack, recipe.licensePath, normalizedLicense),
|
||||
writePackFile(
|
||||
stagePack,
|
||||
recipe.noticePath,
|
||||
normalizeText(new TextDecoder().decode(noticeText))
|
||||
),
|
||||
...assets.flatMap((item) => [
|
||||
writePackFile(stagePack, item.face.web.path, item.web),
|
||||
writePackFile(stagePack, item.face.docx.path, item.docx)
|
||||
])
|
||||
]);
|
||||
await writePackFile(
|
||||
stagePack,
|
||||
"font-pack.json",
|
||||
`${JSON.stringify(manifest, null, 2)}\n`
|
||||
);
|
||||
const stagedRegistry = await discoverFontPacks({
|
||||
roots: [stageRoot],
|
||||
appVersion: recipe.compatibility.minimumAppVersion
|
||||
});
|
||||
const stagedBlocking = stagedRegistry.diagnostics.filter(
|
||||
(item) => item.severity !== "info"
|
||||
);
|
||||
if (stagedBlocking.length > 0 || stagedRegistry.packs.length !== 1) {
|
||||
throw new Error(
|
||||
`暂存字体包注册验证失败:${stagedBlocking
|
||||
.map((item) => `${item.code} ${item.message}`)
|
||||
.join(";")}`
|
||||
);
|
||||
}
|
||||
const installed = await installStagedPack(stagePack, outputRoot, recipe);
|
||||
const report = await verifyFontPack({
|
||||
root: outputRoot,
|
||||
appVersion: recipe.compatibility.minimumAppVersion,
|
||||
packId: recipe.id,
|
||||
packVersion: recipe.version
|
||||
});
|
||||
const finalReport: FontPackBuildReport = {
|
||||
...report,
|
||||
recipeSha256,
|
||||
outputDirectory: installed.directory,
|
||||
unchanged: installed.unchanged
|
||||
};
|
||||
if (options.reportPath) {
|
||||
const reportPath = resolve(options.reportPath);
|
||||
await mkdir(dirname(reportPath), { recursive: true });
|
||||
await writeFile(reportPath, `${JSON.stringify(finalReport, null, 2)}\n`, "utf8");
|
||||
}
|
||||
return finalReport;
|
||||
} finally {
|
||||
await rm(stageRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { resolve } from "node:path";
|
||||
import { buildFontPack, verifyFontPack } from "./builder.js";
|
||||
|
||||
function argumentsMap(values: string[]) {
|
||||
const result = new Map<string, string>();
|
||||
for (let index = 0; index < values.length; index += 2) {
|
||||
const key = values[index];
|
||||
const value = values[index + 1];
|
||||
if (!key?.startsWith("--") || !value || value.startsWith("--")) {
|
||||
throw new Error(`无效命令参数:${key ?? ""}`);
|
||||
}
|
||||
result.set(key.slice(2), value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function option(
|
||||
values: Map<string, string>,
|
||||
name: string,
|
||||
fallback?: string
|
||||
) {
|
||||
const value = values.get(name) ?? fallback;
|
||||
if (!value) {
|
||||
throw new Error(`缺少参数 --${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const command = process.argv[2];
|
||||
const values = argumentsMap(process.argv.slice(3));
|
||||
if (command === "build") {
|
||||
return buildFontPack({
|
||||
recipePath: resolve(
|
||||
option(
|
||||
values,
|
||||
"recipe",
|
||||
"font-packs/recipes/mdtp-serif-sc/recipe.json"
|
||||
)
|
||||
),
|
||||
sourceRoot: resolve(
|
||||
option(
|
||||
values,
|
||||
"source-root",
|
||||
".local/font-pack-sources/mdtp-serif-sc/1.0.0"
|
||||
)
|
||||
),
|
||||
outputRoot: resolve(
|
||||
option(values, "output-root", "output/font-packs/root")
|
||||
),
|
||||
reportPath: resolve(
|
||||
option(values, "report", "output/font-packs/build-report.json")
|
||||
)
|
||||
});
|
||||
}
|
||||
if (command === "verify") {
|
||||
return verifyFontPack({
|
||||
root: resolve(option(values, "root", "output/font-packs/root")),
|
||||
appVersion: option(values, "app-version", "0.6.0"),
|
||||
packId: option(values, "pack-id", "mdtp-serif-sc"),
|
||||
packVersion: option(values, "pack-version", "1.0.0")
|
||||
});
|
||||
}
|
||||
throw new Error("命令必须是 build 或 verify");
|
||||
}
|
||||
|
||||
main()
|
||||
.then((result) => {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./builder.js";
|
||||
export * from "./recipe.js";
|
||||
@@ -0,0 +1,152 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
FONT_PACK_MANIFEST_VERSION,
|
||||
MAXIMUM_FONT_PACK_DOCX_FONT_BYTES,
|
||||
MAXIMUM_FONT_PACK_FACE_COUNT,
|
||||
MAXIMUM_FONT_PACK_LICENSE_BYTES,
|
||||
MAXIMUM_FONT_PACK_TOTAL_BYTES,
|
||||
MAXIMUM_FONT_PACK_WEB_FONT_BYTES,
|
||||
semanticVersionSchema
|
||||
} from "@md-to-pdf/font-pack-registry";
|
||||
|
||||
export const FONT_PACK_RECIPE_VERSION = 1;
|
||||
|
||||
const identifierSchema = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u);
|
||||
const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const fontNameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[\p{L}\p{N}\s._-]+$/u);
|
||||
const safeRelativePathSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(300)
|
||||
.refine(
|
||||
(value) =>
|
||||
!value.includes("\\") &&
|
||||
!value.includes("\0") &&
|
||||
!value.startsWith("/") &&
|
||||
!value.split("/").some((segment) => segment === ".." || segment === ""),
|
||||
"路径必须是安全的 POSIX 相对路径"
|
||||
);
|
||||
|
||||
const sourceAssetSchema = z
|
||||
.object({
|
||||
source: safeRelativePathSchema,
|
||||
path: safeRelativePathSchema,
|
||||
bytes: z.number().int().positive(),
|
||||
sha256: sha256Schema
|
||||
})
|
||||
.strict();
|
||||
|
||||
const faceSchema = z
|
||||
.object({
|
||||
id: identifierSchema,
|
||||
targets: z.array(fontNameSchema).min(1).max(32),
|
||||
weight: z.number().int().min(100).max(900).multipleOf(100),
|
||||
style: z.enum(["normal", "italic"]),
|
||||
web: sourceAssetSchema.extend({
|
||||
bytes: z.number().int().positive().max(MAXIMUM_FONT_PACK_WEB_FONT_BYTES)
|
||||
}),
|
||||
docx: sourceAssetSchema.extend({
|
||||
bytes: z.number().int().positive().max(MAXIMUM_FONT_PACK_DOCX_FONT_BYTES)
|
||||
})
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const fontPackRecipeSchema = z
|
||||
.object({
|
||||
recipeVersion: z.literal(FONT_PACK_RECIPE_VERSION),
|
||||
manifestVersion: z.literal(FONT_PACK_MANIFEST_VERSION),
|
||||
id: identifierSchema,
|
||||
version: semanticVersionSchema,
|
||||
name: z.string().trim().min(1).max(100),
|
||||
description: z.string().trim().max(500),
|
||||
internalFamily: fontNameSchema,
|
||||
license: z.string().trim().min(1).max(100),
|
||||
licenseSource: safeRelativePathSchema,
|
||||
licensePath: safeRelativePathSchema,
|
||||
licenseBytes: z.number().int().positive().max(MAXIMUM_FONT_PACK_LICENSE_BYTES),
|
||||
licenseSha256: sha256Schema,
|
||||
noticeSource: safeRelativePathSchema,
|
||||
noticePath: safeRelativePathSchema,
|
||||
compatibility: z
|
||||
.object({
|
||||
minimumAppVersion: semanticVersionSchema,
|
||||
maximumAppVersionExclusive: semanticVersionSchema.optional()
|
||||
})
|
||||
.strict(),
|
||||
faces: z.array(faceSchema).min(1).max(MAXIMUM_FONT_PACK_FACE_COUNT)
|
||||
})
|
||||
.strict()
|
||||
.superRefine((recipe, context) => {
|
||||
const paths = new Set([
|
||||
recipe.licensePath.toLowerCase(),
|
||||
recipe.noticePath.toLowerCase()
|
||||
]);
|
||||
const sourcePaths = new Set<string>();
|
||||
const ids = new Set<string>();
|
||||
for (const [index, face] of recipe.faces.entries()) {
|
||||
if (ids.has(face.id)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "字体面 ID 重复",
|
||||
path: ["faces", index, "id"]
|
||||
});
|
||||
}
|
||||
ids.add(face.id);
|
||||
for (const [kind, asset, extension] of [
|
||||
["web", face.web, ".woff2"],
|
||||
["docx", face.docx, ".ttf"]
|
||||
] as const) {
|
||||
if (!asset.source.toLowerCase().endsWith(extension)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `源资源必须使用 ${extension} 扩展名`,
|
||||
path: ["faces", index, kind, "source"]
|
||||
});
|
||||
}
|
||||
if (!asset.path.toLowerCase().endsWith(extension)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `目标资源必须使用 ${extension} 扩展名`,
|
||||
path: ["faces", index, kind, "path"]
|
||||
});
|
||||
}
|
||||
const outputPath = asset.path.toLowerCase();
|
||||
if (paths.has(outputPath)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "字体包输出路径重复",
|
||||
path: ["faces", index, kind, "path"]
|
||||
});
|
||||
}
|
||||
paths.add(outputPath);
|
||||
const sourcePath = asset.source.toLowerCase();
|
||||
if (sourcePaths.has(sourcePath)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "字体源路径重复",
|
||||
path: ["faces", index, kind, "source"]
|
||||
});
|
||||
}
|
||||
sourcePaths.add(sourcePath);
|
||||
}
|
||||
}
|
||||
const totalBytes = recipe.faces.reduce(
|
||||
(total, face) => total + face.web.bytes + face.docx.bytes,
|
||||
0
|
||||
);
|
||||
if (totalBytes > MAXIMUM_FONT_PACK_TOTAL_BYTES) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "字体包资源总大小超过限制",
|
||||
path: ["faces"]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type FontPackRecipe = z.infer<typeof fontPackRecipeSchema>;
|
||||
@@ -0,0 +1,157 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
copyFile,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildFontPack, verifyFontPack } from "../src/index.js";
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const katexFontRoot = join(repositoryRoot, "node_modules", "katex", "dist", "fonts");
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
function hash(content: Uint8Array | string) {
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
async function temporaryDirectory() {
|
||||
const directory = await mkdtemp(join(tmpdir(), "mdtp-font-pack-builder-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function createFixture(root: string) {
|
||||
const sourceRoot = join(root, "source");
|
||||
const recipeRoot = join(root, "recipe");
|
||||
const outputRoot = join(root, "output", "root");
|
||||
const reportPath = join(root, "output", "report.json");
|
||||
await Promise.all([mkdir(sourceRoot, { recursive: true }), mkdir(recipeRoot, { recursive: true })]);
|
||||
const webName = "KaTeX_Main-Regular.woff2";
|
||||
const docxName = "KaTeX_Main-Regular.ttf";
|
||||
await Promise.all([
|
||||
copyFile(join(katexFontRoot, webName), join(sourceRoot, webName)),
|
||||
copyFile(join(katexFontRoot, docxName), join(sourceRoot, docxName))
|
||||
]);
|
||||
const [web, docx] = await Promise.all([
|
||||
readFile(join(sourceRoot, webName)),
|
||||
readFile(join(sourceRoot, docxName))
|
||||
]);
|
||||
const license = "Test Font License\n";
|
||||
const notice = "# Test font\n";
|
||||
await Promise.all([
|
||||
writeFile(join(recipeRoot, "LICENSE.txt"), license, "utf8"),
|
||||
writeFile(join(recipeRoot, "SOURCE.md"), notice, "utf8")
|
||||
]);
|
||||
const recipe = {
|
||||
recipeVersion: 1,
|
||||
manifestVersion: 1,
|
||||
id: "test-serif",
|
||||
version: "1.0.0",
|
||||
name: "测试字体包",
|
||||
description: "构建器测试",
|
||||
internalFamily: "KaTeX_Main",
|
||||
license: "Test",
|
||||
licenseSource: "LICENSE.txt",
|
||||
licensePath: "LICENSE.txt",
|
||||
licenseBytes: Buffer.byteLength(license),
|
||||
licenseSha256: hash(license),
|
||||
noticeSource: "SOURCE.md",
|
||||
noticePath: "SOURCE.md",
|
||||
compatibility: {
|
||||
minimumAppVersion: "0.6.0",
|
||||
maximumAppVersionExclusive: "0.7.0"
|
||||
},
|
||||
faces: [
|
||||
{
|
||||
id: "main-regular",
|
||||
targets: ["KaTeX_Main"],
|
||||
weight: 400,
|
||||
style: "normal",
|
||||
web: {
|
||||
source: webName,
|
||||
path: `fonts/${webName}`,
|
||||
bytes: web.byteLength,
|
||||
sha256: hash(web)
|
||||
},
|
||||
docx: {
|
||||
source: docxName,
|
||||
path: `fonts/${docxName}`,
|
||||
bytes: docx.byteLength,
|
||||
sha256: hash(docx)
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
const recipePath = join(recipeRoot, "recipe.json");
|
||||
await writeFile(recipePath, `${JSON.stringify(recipe, null, 2)}\n`, "utf8");
|
||||
return { sourceRoot, recipeRoot, outputRoot, reportPath, recipePath, recipe };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
describe("字体包构建器", () => {
|
||||
it("从冻结资源构建可注册、可重复验证的字体包", async () => {
|
||||
const fixture = await createFixture(await temporaryDirectory());
|
||||
const first = await buildFontPack(fixture);
|
||||
expect(first.unchanged).toBe(false);
|
||||
expect(first.packId).toBe("test-serif");
|
||||
expect(first.faces[0]).toMatchObject({
|
||||
family: "KaTeX_Main",
|
||||
weight: 400,
|
||||
permission: "installable"
|
||||
});
|
||||
expect(JSON.parse(await readFile(fixture.reportPath, "utf8"))).toMatchObject({
|
||||
packId: "test-serif",
|
||||
packVersion: "1.0.0"
|
||||
});
|
||||
|
||||
const second = await buildFontPack(fixture);
|
||||
expect(second.unchanged).toBe(true);
|
||||
expect(second.packFingerprint).toBe(first.packFingerprint);
|
||||
|
||||
const verified = await verifyFontPack({
|
||||
root: fixture.outputRoot,
|
||||
appVersion: "0.6.0",
|
||||
packId: "test-serif",
|
||||
packVersion: "1.0.0"
|
||||
});
|
||||
expect(verified.faces).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("拒绝哈希不匹配的冻结字体源", async () => {
|
||||
const fixture = await createFixture(await temporaryDirectory());
|
||||
const webPath = join(fixture.sourceRoot, "KaTeX_Main-Regular.woff2");
|
||||
const content = await readFile(webPath);
|
||||
content[content.byteLength - 1] = (content[content.byteLength - 1] ?? 0) ^ 1;
|
||||
await writeFile(webPath, content);
|
||||
await expect(buildFontPack(fixture)).rejects.toThrow("SHA-256");
|
||||
});
|
||||
|
||||
it("拒绝覆盖内容不同的同版本发行包", async () => {
|
||||
const fixture = await createFixture(await temporaryDirectory());
|
||||
await buildFontPack(fixture);
|
||||
const changedRecipe = {
|
||||
...fixture.recipe,
|
||||
description: "同版本内容发生变化"
|
||||
};
|
||||
await writeFile(
|
||||
fixture.recipePath,
|
||||
`${JSON.stringify(changedRecipe, null, 2)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
await expect(buildFontPack(fixture)).rejects.toThrow("拒绝覆盖");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user