From ae9fde3ac640425ad79b7e353a5a119e5352288b Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Fri, 31 Jul 2026 21:52:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BB=BA=E7=AB=8B=E5=8F=AF=E5=A4=8D?= =?UTF-8?q?=E7=8E=B0=E5=AD=97=E4=BD=93=E5=8C=85=E6=9E=84=E5=BB=BA=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PROGRESS.md | 26 +- font-packs/README.md | 47 ++ .../recipes/mdtp-serif-sc/OFL-NotoSerifSC.txt | 94 ++++ font-packs/recipes/mdtp-serif-sc/SOURCE.md | 19 + font-packs/recipes/mdtp-serif-sc/recipe.json | 57 ++ package-lock.json | 17 + package.json | 8 +- packages/font-pack-builder/package.json | 31 ++ packages/font-pack-builder/src/builder.ts | 525 ++++++++++++++++++ packages/font-pack-builder/src/cli.ts | 76 +++ packages/font-pack-builder/src/index.ts | 2 + packages/font-pack-builder/src/recipe.ts | 152 +++++ .../font-pack-builder/tests/builder.test.ts | 157 ++++++ packages/font-pack-builder/tsconfig.json | 19 + 14 files changed, 1225 insertions(+), 5 deletions(-) create mode 100644 font-packs/README.md create mode 100644 font-packs/recipes/mdtp-serif-sc/OFL-NotoSerifSC.txt create mode 100644 font-packs/recipes/mdtp-serif-sc/SOURCE.md create mode 100644 font-packs/recipes/mdtp-serif-sc/recipe.json create mode 100644 packages/font-pack-builder/package.json create mode 100644 packages/font-pack-builder/src/builder.ts create mode 100644 packages/font-pack-builder/src/cli.ts create mode 100644 packages/font-pack-builder/src/index.ts create mode 100644 packages/font-pack-builder/src/recipe.ts create mode 100644 packages/font-pack-builder/tests/builder.test.ts create mode 100644 packages/font-pack-builder/tsconfig.json diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index cc1b40e..46703c4 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -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 双向互存、外部主题兼容、体积和正式发布验收。 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 diff --git a/font-packs/README.md b/font-packs/README.md new file mode 100644 index 0000000..8d212db --- /dev/null +++ b/font-packs/README.md @@ -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///font-pack.json +``` + +同一 ID 和版本的内容完全相同时,重复构建保持幂等;只要任一文件发生 +变化,构建器就会拒绝覆盖。发行内容变化必须提升字体包 SemVer。 + +当前 `mdtp-serif-sc@1.0.0` 只替换 `FandolSong` 与 +`Mdpdf Fandol Song` 的 400/700 字重,不匹配 `SimSun` 等通用别名,避免 +错误替换仿宋、黑体、楷体或普通外部主题字体。 diff --git a/font-packs/recipes/mdtp-serif-sc/OFL-NotoSerifSC.txt b/font-packs/recipes/mdtp-serif-sc/OFL-NotoSerifSC.txt new file mode 100644 index 0000000..94d1bf7 --- /dev/null +++ b/font-packs/recipes/mdtp-serif-sc/OFL-NotoSerifSC.txt @@ -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. diff --git a/font-packs/recipes/mdtp-serif-sc/SOURCE.md b/font-packs/recipes/mdtp-serif-sc/SOURCE.md new file mode 100644 index 0000000..48b645b --- /dev/null +++ b/font-packs/recipes/mdtp-serif-sc/SOURCE.md @@ -0,0 +1,19 @@ +# MdTP Serif SC 字体来源说明 + +本字体包的 Regular 400 与 Bold 700 字形面派生自 Noto Serif SC 可变字体。 + +- 上游项目: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、格式、字体内部家族、字重与嵌入权限。 diff --git a/font-packs/recipes/mdtp-serif-sc/recipe.json b/font-packs/recipes/mdtp-serif-sc/recipe.json new file mode 100644 index 0000000..59a80b4 --- /dev/null +++ b/font-packs/recipes/mdtp-serif-sc/recipe.json @@ -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" + } + } + ] +} diff --git a/package-lock.json b/package-lock.json index cdeafb0..b075d06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3ca1862..472ce72 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/packages/font-pack-builder/package.json b/packages/font-pack-builder/package.json new file mode 100644 index 0000000..49febdb --- /dev/null +++ b/packages/font-pack-builder/package.json @@ -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" + } +} diff --git a/packages/font-pack-builder/src/builder.ts b/packages/font-pack-builder/src/builder.ts new file mode 100644 index 0000000..4a78619 --- /dev/null +++ b/packages/font-pack-builder/src/builder.ts @@ -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 { + 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 { + 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 }); + } +} diff --git a/packages/font-pack-builder/src/cli.ts b/packages/font-pack-builder/src/cli.ts new file mode 100644 index 0000000..c759051 --- /dev/null +++ b/packages/font-pack-builder/src/cli.ts @@ -0,0 +1,76 @@ +import { resolve } from "node:path"; +import { buildFontPack, verifyFontPack } from "./builder.js"; + +function argumentsMap(values: string[]) { + const result = new Map(); + 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, + 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; + }); diff --git a/packages/font-pack-builder/src/index.ts b/packages/font-pack-builder/src/index.ts new file mode 100644 index 0000000..7e69f6a --- /dev/null +++ b/packages/font-pack-builder/src/index.ts @@ -0,0 +1,2 @@ +export * from "./builder.js"; +export * from "./recipe.js"; diff --git a/packages/font-pack-builder/src/recipe.ts b/packages/font-pack-builder/src/recipe.ts new file mode 100644 index 0000000..3d74a37 --- /dev/null +++ b/packages/font-pack-builder/src/recipe.ts @@ -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(); + const ids = new Set(); + 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; diff --git a/packages/font-pack-builder/tests/builder.test.ts b/packages/font-pack-builder/tests/builder.test.ts new file mode 100644 index 0000000..43bac6d --- /dev/null +++ b/packages/font-pack-builder/tests/builder.test.ts @@ -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("拒绝覆盖"); + }); +}); diff --git a/packages/font-pack-builder/tsconfig.json b/packages/font-pack-builder/tsconfig.json new file mode 100644 index 0000000..9960e15 --- /dev/null +++ b/packages/font-pack-builder/tsconfig.json @@ -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" + ] +}