From 10bc62cee476e70e1246933aa329f157a874aa31 Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Fri, 31 Jul 2026 16:18:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20DOCX=20=E5=8F=AF?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=AD=97=E4=BD=93=E5=B5=8C=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PROGRESS.md | 24 +- docs/V0.6.0_DOCX_FONT_EMBEDDING.md | 164 ++++++ package-lock.json | 47 +- .../application/src/application-service.ts | 12 +- .../application/src/docx-export-service.ts | 1 + packages/application/src/theme-registry.ts | 96 +++- .../tests/application-service.test.ts | 40 ++ .../tests/docx-export-service.test.ts | 6 +- .../tests/docx-media-service.test.ts | 3 +- .../tests/docx-theme-token-service.test.ts | 3 +- packages/core/src/theme.ts | 68 +++ packages/core/tests/theme.test.ts | 74 +++ packages/docx-engine/package.json | 3 +- .../scripts/verify-theme-docx-matrix.mjs | 29 + packages/docx-engine/src/font-embedding.ts | 520 ++++++++++++++++++ .../docx-engine/src/font-package-transform.ts | 417 ++++++++++++++ packages/docx-engine/src/fontverter.d.ts | 15 + packages/docx-engine/src/index.ts | 2 + packages/docx-engine/src/pandoc-converter.ts | 13 +- packages/docx-engine/src/token-style-map.ts | 2 +- packages/docx-engine/src/validator.ts | 160 +++++- .../docx-engine/tests/font-embedding.test.ts | 153 ++++++ .../tests/font-package-transform.test.ts | 135 +++++ .../tests/pandoc-converter.test.ts | 1 + themes/_shared/official-fonts/fonts.css | 39 ++ themes/formal-report/theme.css | 6 +- themes/formal-report/theme.json | 16 + themes/gov-red-standard/theme.css | 62 +-- themes/gov-red-standard/theme.json | 40 +- themes/typora-github/github.css | 2 +- .../github/OPEN-SANS-LICENSE.txt | 78 +++ themes/typora-github/theme.json | 40 ++ 32 files changed, 2195 insertions(+), 76 deletions(-) create mode 100644 docs/V0.6.0_DOCX_FONT_EMBEDDING.md create mode 100644 packages/docx-engine/src/font-embedding.ts create mode 100644 packages/docx-engine/src/font-package-transform.ts create mode 100644 packages/docx-engine/src/fontverter.d.ts create mode 100644 packages/docx-engine/tests/font-embedding.test.ts create mode 100644 packages/docx-engine/tests/font-package-transform.test.ts create mode 100644 themes/_shared/official-fonts/fonts.css create mode 100644 themes/typora-github/github/OPEN-SANS-LICENSE.txt diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 7e7011e..12afbbd 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -232,6 +232,25 @@ Server Playwright 与 Desktop Electron 适配器,按主题 CSS SHA-256 空白正文。下一步按 R2 字体嵌入、R3 PDF 与 DOCX 视觉差异引擎、R4 全主题 深度回归的顺序推进。 +阶段 12D-R2 已完成纯 Node.js DOCX 字体嵌入和代表主题验收。主题清单可 +声明本地或内置共享的 OTF、TTF、WOFF、WOFF2 字形;引擎使用 +`fontverter` 将 Web 字体转为 SFNT,解析字体内部名称、字重和 `fsType`, +仅接受可安装或可编辑嵌入权限,再以稳定字体键生成 ODTTF 部件、字体表 +关系和内容类型。最终校验会解混淆字体并重新检查 SFNT、权限、字体族、 +关系、孤立部件和内容类型。WOFF2 WASM 解码已改为进程内串行调度,避免 +并发非重入导致批量字体损坏。内置主题统一前置共享字体 CSS,网页、PDF +和 DOCX 使用同一字体资产,映射过程不按主题 ID 分支。 + +测试机未安装 Fandol 或 Open Sans。政企正式工作报告、政企红头标准文件 +和 Typora Github 分别成功嵌入 2、5、5 个字形面,Word 与 WPS 均能打开、 +显示和编辑;Word → WPS → Word 双向编辑 3/3 通过。完整 14 主题矩阵、 +全项目测试、类型检查和构建通过。视觉检查确认正式报告和 Github 在 +Word/WPS 均为 2 页;红头主题在 Word 为 2 页、WPS 为 3 页,版记被移到 +近空白末页,已冻结为 R3 视觉差异引擎的首个回归样例。WPS 默认另存还会 +移除嵌入字体,启用字体嵌入后可保留但会扩大互存文件;项目不会因 WPS +写回器产生的 OOXML 顺序问题放宽自身严格门禁。完整记录见 +[DOCX 字体嵌入与代表主题验收](V0.6.0_DOCX_FONT_EMBEDDING.md)。 + ## 2. 已完成 ### 2.1 项目骨架 @@ -1168,8 +1187,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果: - 阶段 12C:已完成通用 Pandoc 结构映射、封面真实分节、标题去重、 表格内容区全宽、分页控制、OOXML 严格性和 14 主题真实 Word/WPS 无修复打开门禁; -- 阶段 12D:依次完成可分发字体嵌入、PDF 与 DOCX 自动视觉差异、14 套 - 主题真实 Word/WPS 编辑互存和深度视觉回归; +- 阶段 12D-R2:已完成可分发字体嵌入和三套代表主题真实 Word/WPS + 编辑互存;下一步 R3 建立 PDF 与 DOCX 自动视觉差异引擎,再由 R4 完成 + 14 套主题深度视觉回归; - 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 diff --git a/docs/V0.6.0_DOCX_FONT_EMBEDDING.md b/docs/V0.6.0_DOCX_FONT_EMBEDDING.md new file mode 100644 index 0000000..4013f61 --- /dev/null +++ b/docs/V0.6.0_DOCX_FONT_EMBEDDING.md @@ -0,0 +1,164 @@ +# v0.6.0 DOCX 字体嵌入与代表主题验收 + +最后更新:2026-07-31 + +## 1. 目标与边界 + +阶段 12D-R2 解决 DOCX 在目标机器没有安装主题字体时发生字体回退的问题。 +实现必须同时满足: + +- 纯 Node.js 运行,不增加 Python 环境; +- 接受主题现有的 OTF、TTF、WOFF 和 WOFF2 资源; +- 只嵌入明确声明且许可证位允许编辑的字体; +- 使用 Word 原生嵌入字体部件,正文、标题、表格和代码仍可编辑; +- Word 与 WPS 均可打开、编辑和互存; +- 转换、关系、内容类型和字体部件继续通过严格 OOXML 校验; +- 外部主题可以通过清单接入,不依赖主题 ID 分支。 + +字体采用完整字形嵌入,不做子集化。这样用户在 Word 或 WPS 中输入原文 +没有出现过的新字符时,仍可继续使用同一字体。代价是生成文件明显增大。 + +## 2. 通用实现 + +主题清单新增可选的 `docxFonts.faces`: + +```json +{ + "family": "FandolFang", + "aliases": ["FangSong", "STFangsong"], + "source": "theme-shared:official-fonts/FandolFang-Regular.woff2", + "weight": 400, + "style": "normal", + "license": "GPL-3.0-or-later WITH Font-exception-2.0" +} +``` + +处理链路如下: + +```mermaid +flowchart LR + A["主题字体清单"] --> B["受限资源读取"] + B --> C["WOFF/WOFF2 转 SFNT"] + C --> D["解析名称、字重与 fsType"] + D --> E["许可证和体积门禁"] + E --> F["ODTTF 混淆"] + F --> G["写入 fontTable 与关系"] + G --> H["重写 Word 字体引用"] + H --> I["严格 OOXML 校验"] +``` + +核心约束: + +- 单主题最多 16 个字形面; +- 单个源字体最多 8 MiB,源字体总量最多 32 MiB; +- 单个解码后 SFNT 最多 16 MiB,总量最多 48 MiB; +- 接受 `fsType=0x0000` 的可安装嵌入和 `fsType=0x0008` 的可编辑嵌入; +- 拒绝受限、仅预览打印、位图嵌入及无效权限组合; +- 字体键和 ODTTF 部件名由内容指纹稳定生成; +- WOFF2 WASM 解码采用进程内串行调度,避免底层非重入导致批量损坏; +- 同一字体内容按 SHA-256 合并并使用有界缓存; +- 内置主题可引用 `_shared` 字体,本地主题不能越权引用内置共享资源; +- 最终校验器会解混淆字体,重新检查 SFNT、内部字体族、权限位、关系和 + 孤立部件。 + +`themes/_shared/official-fonts/fonts.css` 会统一前置到内置主题 CSS,使网页、 +PDF 和 DOCX 使用同一字体资产。字体选择和别名替换按字体族工作,没有 +主题 ID 判断。 + +## 3. 许可证边界 + +Fandol 字体: + +- 使用仓库已有的 Fandol WOFF2; +- 字体内 `fsType=0x0008`,允许可编辑嵌入; +- 许可证为 GPL-3.0-or-later with Font Exception; +- 完整许可证已位于 + `themes/_shared/official-fonts/LICENSE.txt`。 + +Open Sans: + +- `typora-github` 当前包含的是 `v17` 历史字体文件; +- 该批二进制的字体元数据和历史发行信息对应 Apache License 2.0; +- 历史版权声明和完整许可证随字体放在 + `themes/typora-github/github/OPEN-SANS-LICENSE.txt`; +- 当前上游 Open Sans 已迁移到 SIL Open Font License 1.1。未来替换字体 + 二进制时必须重新核对许可证、版权声明和嵌入权限,不能沿用本阶段清单。 + +字体清单中的 `license` 是审计字段,不替代随包许可证文件。 + +## 4. 代表主题结果 + +测试机未安装 FandolSong、FandolFang、FandolHei、FandolKai 或 +Open Sans,能够真实检验嵌入字体而不是本机字体回退。 + +| 主题 | 嵌入字形面 | 原始 DOCX 大小 | Word 页数 | WPS 页数 | +| --- | ---: | ---: | ---: | ---: | +| 政企正式·工作报告 | 2 | 9,932,187 字节 | 2 | 2 | +| 政企红头·标准文件 | 5 | 23,920,588 字节 | 2 | 3 | +| Typora Github | 5 | 4,680,418 字节 | 2 | 2 | + +三份文件均满足: + +- 内置校验器确认嵌入字体部件、关系、内容类型和字体键完整; +- Word 使用 `OpenNoRepairDialog` 正常打开并导出 PDF; +- WPS 正常打开并导出 PDF; +- 未发现方框、缺字或明显系统字体回退; +- Word 追加编辑标记后,WPS 可以读取并继续编辑; +- WPS 另存后,Word 仍可禁止修复打开; +- 最终文件同时保留 Word 和 WPS 两段编辑文本。 + +14 套主题完整矩阵也已通过。未声明 `docxFonts` 的其余 11 套主题继续产生 +零嵌入字体,不会被共享 CSS 隐式扩大 DOCX。 + +## 5. 已知客户端差异 + +### 5.1 WPS 红头主题分页 + +同一份红头 DOCX 在 Word 中为 2 页,在 WPS 中为 3 页。WPS 将版记整体 +移动到近空白第 3 页。字体显示正确,内容没有丢失,但尚未达到跨客户端 +视觉一致门禁。 + +该问题进入阶段 12D-R3,由 PDF 与 DOCX 自动视觉差异引擎量化后处理。 +当前不通过主题 ID 特判或缩小正文来掩盖差异,避免在没有 PDF 基线的情况 +下破坏 Word 与 Chromium 的一致性。 + +### 5.2 WPS 互存策略 + +WPS 默认另存会移除嵌入字体,三份文件会缩小到约 20 KiB。设置文档属性 +“嵌入 TrueType 字体”并禁用子集后可以保留字体,但 WPS 会重新生成字体 +部件,互存文件增大到约 14~45 MiB。 + +WPS 还会重排 `styles.xml` 的 `uiPriority`,并可能删除未使用的 +`numbering.xml`。Microsoft Open XML SDK 会报告 WPS 写回文件自身产生的 +顺序问题,但 Word 仍能禁止修复打开。项目不会因此放宽对自身生成文件的 +严格 OOXML 门禁。 + +## 6. 验证结果 + +- `npm run verify:docx-themes`:14/14 主题通过; +- `npm test`:全项目通过; +- `npm run typecheck`:全项目通过; +- `npm run build`:全项目通过; +- `git diff --check`:通过; +- Word 原始文档:3/3 禁止修复打开并导出 PDF; +- WPS 原始文档:3/3 打开并导出 PDF; +- Word → WPS → Word 双向编辑:3/3 通过; +- Word/WPS 原始渲染:共 13 页逐页检查完成。 + +本地验收产物位于被 Git 忽略的: + +```text +output/docx-theme-matrix/ +``` + +## 7. 下一步 + +阶段 12D-R3 建立 PDF 与 DOCX 自动视觉差异引擎,优先量化: + +- 页面数和分页边界; +- 文本块位置和尺寸; +- 字体、字号、颜色和字重; +- 封面、版头、版记、表格及代码块边界; +- Word、WPS 与 Chromium PDF 的差异。 + +红头主题的 WPS 版记溢出作为首个固定回归样例。 diff --git a/package-lock.json b/package-lock.json index 27c017a..cae4a4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6583,6 +6583,16 @@ } } }, + "node_modules/fontverter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fontverter/-/fontverter-2.0.0.tgz", + "integrity": "sha512-DFVX5hvXuhi1Jven1tbpebYTCT9XYnvx6/Z+HFUPb7ZRMCW+pj2clU9VMhoTPgWKPhAs7JJDSk3CW1jNUvKCZQ==", + "license": "BSD-3-Clause", + "dependencies": { + "wawoff2": "^2.0.0", + "woff2sfnt-sfnt2woff": "^1.0.0" + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -7984,6 +7994,12 @@ "event-emitter": "^0.3.5" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse-srcset": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", @@ -9698,6 +9714,25 @@ "node": ">=20.0.0" } }, + "node_modules/wawoff2": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wawoff2/-/wawoff2-2.0.1.tgz", + "integrity": "sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "woff2_compress.js": "bin/woff2_compress.js", + "woff2_decompress.js": "bin/woff2_decompress.js" + } + }, + "node_modules/wawoff2/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/webcrypto-core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", @@ -9755,6 +9790,15 @@ "node": ">=8" } }, + "node_modules/woff2sfnt-sfnt2woff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/woff2sfnt-sfnt2woff/-/woff2sfnt-sfnt2woff-1.0.0.tgz", + "integrity": "sha512-edK4COc1c1EpRfMqCZO1xJOvdUtM5dbVb9iz97rScvnTevqEB3GllnLWCmMVp1MfQBdF1DFg/11I0rSyAdS4qQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.7" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -9941,7 +9985,8 @@ "@md-to-pdf/core": "0.1.0", "@md-to-pdf/docx-theme-engine": "0.1.0", "@xmldom/xmldom": "0.9.10", - "fflate": "0.8.3" + "fflate": "0.8.3", + "fontverter": "2.0.0" }, "devDependencies": { "@types/node": "^24.10.1", diff --git a/packages/application/src/application-service.ts b/packages/application/src/application-service.ts index 0da7b9b..75c6363 100644 --- a/packages/application/src/application-service.ts +++ b/packages/application/src/application-service.ts @@ -8,6 +8,7 @@ import { type RenderedMarkdownDocument, type ThemeManifest } from "@md-to-pdf/core"; +import type { DocxFontSource } from "@md-to-pdf/docx-engine"; import { createThemeRegistry, type ThemeRegistryOptions @@ -48,6 +49,7 @@ export interface PreparedDocxExport { manifest: ThemeManifest; source: "bundled" | "local"; css: string; + fonts: DocxFontSource[]; }; } @@ -186,11 +188,12 @@ export function createApplicationService( ); } - const [document, themeCss] = await Promise.all([ + const [document, themeCss, themeFonts] = await Promise.all([ render(parsed.data, context), - themes.getCss(theme.manifest.id) + themes.getCss(theme.manifest.id), + themes.getDocxFonts(theme.manifest.id) ]); - if (themeCss === undefined) { + if (themeCss === undefined || themeFonts === undefined) { throw new ApplicationRequestError( 404, "THEME_NOT_FOUND", @@ -204,7 +207,8 @@ export function createApplicationService( theme: { manifest: theme.manifest, source: theme.source, - css: themeCss + css: themeCss, + fonts: themeFonts } }; } diff --git a/packages/application/src/docx-export-service.ts b/packages/application/src/docx-export-service.ts index 1bd4b80..3afee90 100644 --- a/packages/application/src/docx-export-service.ts +++ b/packages/application/src/docx-export-service.ts @@ -489,6 +489,7 @@ export class DocxExportService { exportConfig: prepared.request.exportConfig, theme: prepared.theme.manifest, themeTokens: themeTokens.tokens, + fonts: prepared.theme.fonts, metadata: prepared.document.metadata, semanticDocument: prepared.document.semanticDocument, media diff --git a/packages/application/src/theme-registry.ts b/packages/application/src/theme-registry.ts index adb88ec..b406240 100644 --- a/packages/application/src/theme-registry.ts +++ b/packages/application/src/theme-registry.ts @@ -13,7 +13,9 @@ import { sep } from "node:path"; import { + MAXIMUM_DOCX_FONT_TOTAL_SOURCE_BYTES, themeManifestSchema, + type DocxFontFace, type ThemeManifest } from "@md-to-pdf/core"; @@ -23,6 +25,10 @@ export interface ThemeRecord { source: "bundled" | "local"; } +export interface ThemeFontAsset extends DocxFontFace { + content: Uint8Array; +} + export interface ThemeRegistryOptions { bundledRoot: string; localRoot: string; @@ -48,6 +54,7 @@ const cssImportPattern = const maximumCssImportDepth = 8; const sharedThemeAssetId = "_shared"; const sharedThemeAssetScheme = "theme-shared:"; +const sharedFontCssPath = "official-fonts/fonts.css"; function isMissingFileError(error: unknown) { return ( @@ -314,6 +321,40 @@ export function createThemeRegistry(options: ThemeRegistryOptions) { promise: Promise; } | undefined; + let sharedFontCssPromise: Promise | undefined; + + function loadSharedFontCss() { + if (!sharedFontCssPromise) { + sharedFontCssPromise = (async () => { + const sharedRoot = resolve( + options.bundledRoot, + sharedThemeAssetId + ); + try { + const cssPath = await resolveThemeFile( + sharedRoot, + sharedFontCssPath + ); + return prepareCssSegment( + await readFile(cssPath, "utf8"), + sharedThemeAssetId, + posix.dirname(sharedFontCssPath), + createAssetUrl, + false + ); + } catch (error) { + if (isMissingFileError(error)) { + return ""; + } + throw error; + } + })(); + void sharedFontCssPromise.catch(() => { + sharedFontCssPromise = undefined; + }); + } + return sharedFontCssPromise; + } async function scanThemes() { const [bundledThemes, localThemes] = await Promise.all([ @@ -366,6 +407,7 @@ export function createThemeRegistry(options: ThemeRegistryOptions) { function invalidate() { cachedThemes = undefined; + sharedFontCssPromise = undefined; } async function get(themeId: string) { @@ -385,13 +427,15 @@ export function createThemeRegistry(options: ThemeRegistryOptions) { ? [theme.manifest.print] : []) ]; - return ( - await Promise.all( + const css = await Promise.all( cssFiles.map((path) => loadThemeCssFile(theme, path, createAssetUrl) ) - ) - ).join("\n"); + ); + if (theme.source === "bundled") { + css.unshift(await loadSharedFontCss()); + } + return css.filter(Boolean).join("\n"); } async function getAsset(themeId: string, assetPath: string) { @@ -427,10 +471,54 @@ export function createThemeRegistry(options: ThemeRegistryOptions) { }; } + async function getDocxFonts( + themeId: string + ): Promise { + const theme = await get(themeId); + if (!theme) { + return undefined; + } + const faces = theme.manifest.docxFonts?.faces ?? []; + const fonts = await Promise.all( + faces.map(async (face) => { + const shared = face.source.startsWith( + sharedThemeAssetScheme + ); + if (shared && theme.source !== "bundled") { + throw new Error("本地主题不能嵌入内置共享字体"); + } + const asset = await getAsset( + shared ? sharedThemeAssetId : theme.manifest.id, + shared + ? face.source.slice(sharedThemeAssetScheme.length) + : face.source + ); + if (!asset || !asset.contentType.startsWith("font/")) { + throw new Error( + `DOCX 字体资源无效:${face.source}` + ); + } + return { + ...face, + content: asset.content + }; + }) + ); + const totalBytes = fonts.reduce( + (total, font) => total + font.content.byteLength, + 0 + ); + if (totalBytes > MAXIMUM_DOCX_FONT_TOTAL_SOURCE_BYTES) { + throw new Error("DOCX 字体资源总大小超过限制"); + } + return fonts; + } + return { get, getAsset, getCss, + getDocxFonts, invalidate, list }; diff --git a/packages/application/tests/application-service.test.ts b/packages/application/tests/application-service.test.ts index 067c4c3..c5b55f2 100644 --- a/packages/application/tests/application-service.test.ts +++ b/packages/application/tests/application-service.test.ts @@ -63,6 +63,21 @@ async function createThemeFixture() { domPreset: "typora", defaultFontSize: "16px", supportedFeatures: ["code", "table"], + docxFonts: { + faces: [ + { + family: "Test", + source: "fonts/test.woff2", + license: "OFL-1.1" + }, + { + family: "SharedTest", + source: + "theme-shared:official-fonts/shared.woff2", + license: "GPL-3.0-with-font-exception" + } + ] + }, pageDefaults: { margins: { top: "37mm", @@ -98,6 +113,16 @@ async function createThemeFixture() { join(sharedFontRoot, "shared.woff2"), Buffer.from([4, 5, 6, 7]) ); + await writeFile( + join(sharedFontRoot, "fonts.css"), + [ + "@font-face {", + " font-family: SharedTest;", + " src: url('./shared.woff2') format('woff2');", + "}" + ].join("\n"), + "utf8" + ); return { bundledRoot, localRoot }; } @@ -198,6 +223,9 @@ describe("共享应用服务", () => { ).resolves.toContain( "mdpdf://theme/_shared/official-fonts/shared.woff2" ); + await expect( + service.getThemeCss("test-theme") + ).resolves.toContain("font-family: SharedTest"); }); it("安全读取主题二进制资源", async () => { @@ -308,6 +336,18 @@ describe("共享应用服务", () => { expect(prepared.theme.css).toContain( "#write { font-family: Test; }" ); + expect(prepared.theme.fonts).toEqual([ + expect.objectContaining({ + family: "Test", + source: "fonts/test.woff2", + content: Buffer.from([0, 1, 2, 3]) + }), + expect.objectContaining({ + family: "SharedTest", + source: "theme-shared:official-fonts/shared.woff2", + content: Buffer.from([4, 5, 6, 7]) + }) + ]); }); it("以稳定错误码拒绝非法 DOCX 配置和缺失主题", async () => { diff --git a/packages/application/tests/docx-export-service.test.ts b/packages/application/tests/docx-export-service.test.ts index a54f5a6..d92129a 100644 --- a/packages/application/tests/docx-export-service.test.ts +++ b/packages/application/tests/docx-export-service.test.ts @@ -74,7 +74,8 @@ const prepared: PreparedDocxExport = { theme: { manifest: theme(), source: "bundled", - css: "" + css: "", + fonts: [] } }; @@ -126,7 +127,8 @@ function conversionResult(): PandocDocxConversionResult { xmlPartCount: 9, relationshipCount: 3, headerCount: 0, - footerCount: 1 + footerCount: 1, + embeddedFontCount: 0 }, timings: { referenceMs: 3, diff --git a/packages/application/tests/docx-media-service.test.ts b/packages/application/tests/docx-media-service.test.ts index 0e9acdc..7397d49 100644 --- a/packages/application/tests/docx-media-service.test.ts +++ b/packages/application/tests/docx-media-service.test.ts @@ -84,7 +84,8 @@ const prepared: PreparedDocxExport = { theme: { manifest, source: "bundled", - css: "#write { color: black; }" + css: "#write { color: black; }", + fonts: [] } }; diff --git a/packages/application/tests/docx-theme-token-service.test.ts b/packages/application/tests/docx-theme-token-service.test.ts index cd251eb..ee7db33 100644 --- a/packages/application/tests/docx-theme-token-service.test.ts +++ b/packages/application/tests/docx-theme-token-service.test.ts @@ -77,7 +77,8 @@ function prepared( theme: { manifest: manifest(docxStyle), source: "bundled", - css: "#write { font-family: Test; }" + css: "#write { font-family: Test; }", + fonts: [] } }; } diff --git a/packages/core/src/theme.ts b/packages/core/src/theme.ts index 21f1201..db12bdb 100644 --- a/packages/core/src/theme.ts +++ b/packages/core/src/theme.ts @@ -8,6 +8,73 @@ import { } from "./export-config.js"; export const THEME_MANIFEST_VERSION = 1; +export const MAXIMUM_DOCX_FONT_FACE_COUNT = 16; +export const MAXIMUM_DOCX_FONT_SOURCE_BYTES = 8 * 1024 * 1024; +export const MAXIMUM_DOCX_FONT_TOTAL_SOURCE_BYTES = + 32 * 1024 * 1024; + +const docxFontNameSchema = z + .string() + .trim() + .min(1) + .max(100) + .regex( + /^[\p{L}\p{N}\s._-]+$/u, + "DOCX 字体名称包含不支持的字符" + ); + +const docxFontSourceSchema = z + .string() + .trim() + .min(1) + .max(500) + .refine( + (value) => + !value.includes("\0") && + !value.startsWith("/") && + !value.startsWith("\\") && + !/^[a-z]:[\\/]/iu.test(value) && + !/^(?:https?:)?\/\//iu.test(value) && + !value + .replace(/^theme-shared:/u, "") + .split(/[\\/]/u) + .includes(".."), + "DOCX 字体资源路径不安全" + ) + .refine( + (value) => /\.(?:otf|ttf|woff2?)$/iu.test(value), + "DOCX 字体资源格式必须为 OTF、TTF、WOFF 或 WOFF2" + ); + +export const docxFontFaceSchema = z.object({ + family: docxFontNameSchema, + aliases: z + .array(docxFontNameSchema) + .max(16) + .default([]), + source: docxFontSourceSchema, + weight: z + .number() + .int() + .min(100) + .max(900) + .multipleOf(100) + .default(400), + style: z.enum(["normal", "italic"]).default("normal"), + license: z.string().trim().min(1).max(200) +}); + +export const docxFontManifestSchema = z.object({ + faces: z + .array(docxFontFaceSchema) + .min(1) + .max(MAXIMUM_DOCX_FONT_FACE_COUNT) +}); + +export type DocxFontFace = z.infer; +export type DocxFontManifest = z.infer< + typeof docxFontManifestSchema +>; export const themeFeatureSchema = z.enum([ "code", @@ -51,6 +118,7 @@ export const themeManifestSchema = z.object({ footer: footerSchema.optional() }) .optional(), + docxFonts: docxFontManifestSchema.optional(), docxStyle: docxThemeStyleSchema.optional(), bundled: z.boolean() }); diff --git a/packages/core/tests/theme.test.ts b/packages/core/tests/theme.test.ts index 53d1dda..7234f86 100644 --- a/packages/core/tests/theme.test.ts +++ b/packages/core/tests/theme.test.ts @@ -57,4 +57,78 @@ describe("主题清单", () => { showOnFirstPage: false }); }); + + it("允许声明可嵌入的本地 DOCX 字体字形面", () => { + const parsed = themeManifestSchema.parse({ + manifestVersion: 1, + id: "font-test", + name: "字体测试主题", + version: "0.6.0", + description: "测试可移植 DOCX 字体声明。", + author: "md-to-pdf contributors", + license: "项目自有", + entry: "theme.css", + domPreset: "generic", + defaultFontSize: "16px", + supportedFeatures: [], + docxFonts: { + faces: [ + { + family: "Mdpdf Fandol Fang", + aliases: ["FangSong", "STFangsong"], + source: + "theme-shared:official-fonts/FandolFang-Regular.woff2", + weight: 400, + style: "normal", + license: "GPL-3.0-with-font-exception" + } + ] + }, + bundled: true + }); + + expect(parsed.docxFonts?.faces[0]).toMatchObject({ + family: "Mdpdf Fandol Fang", + weight: 400, + style: "normal" + }); + }); + + it("拒绝远程或越界的 DOCX 字体资源", () => { + const base = { + manifestVersion: 1, + id: "font-test", + name: "字体测试主题", + version: "0.6.0", + description: "测试字体路径门禁。", + author: "md-to-pdf contributors", + license: "项目自有", + entry: "theme.css", + domPreset: "generic", + defaultFontSize: "16px", + supportedFeatures: [], + bundled: true + }; + + for (const source of [ + "https://example.com/font.woff2", + "../font.woff2", + "C:\\fonts\\font.ttf" + ]) { + expect(() => + themeManifestSchema.parse({ + ...base, + docxFonts: { + faces: [ + { + family: "Unsafe Font", + source, + license: "unknown" + } + ] + } + }) + ).toThrow(); + } + }); }); diff --git a/packages/docx-engine/package.json b/packages/docx-engine/package.json index 5faa860..e0d6e82 100644 --- a/packages/docx-engine/package.json +++ b/packages/docx-engine/package.json @@ -29,7 +29,8 @@ "@md-to-pdf/core": "0.1.0", "@md-to-pdf/docx-theme-engine": "0.1.0", "@xmldom/xmldom": "0.9.10", - "fflate": "0.8.3" + "fflate": "0.8.3", + "fontverter": "2.0.0" }, "devDependencies": { "@types/node": "^24.10.1", diff --git a/packages/docx-engine/scripts/verify-theme-docx-matrix.mjs b/packages/docx-engine/scripts/verify-theme-docx-matrix.mjs index 003fdf8..2555c5e 100644 --- a/packages/docx-engine/scripts/verify-theme-docx-matrix.mjs +++ b/packages/docx-engine/scripts/verify-theme-docx-matrix.mjs @@ -31,6 +31,7 @@ const themeTokenReportPath = path.join( "snapshots.json" ); const decoder = new TextDecoder(); +const sharedFontSourcePrefix = "theme-shared:"; const placeholderPng = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64" @@ -104,6 +105,33 @@ function readThemeManifests() { ); } +function readThemeFonts(theme) { + return (theme.docxFonts?.faces ?? []).map((face) => { + const shared = face.source.startsWith(sharedFontSourcePrefix); + const relativePath = shared + ? path.join( + "_shared", + face.source.slice(sharedFontSourcePrefix.length) + ) + : path.join(theme.id, face.source); + const sourcePath = path.resolve(themesDirectory, relativePath); + const relativeSourcePath = path.relative( + themesDirectory, + sourcePath + ); + assert( + relativeSourcePath !== ".." && + !relativeSourcePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativeSourcePath), + `字体资源路径越界:${face.source}` + ); + return { + ...face, + content: fs.readFileSync(sourcePath) + }; + }); +} + function structuralValues(document) { if (!document) { return []; @@ -393,6 +421,7 @@ for (const theme of themes) { themeTokens, metadata: rendered.metadata, semanticDocument: rendered.semanticDocument, + fonts: readThemeFonts(theme), media: createMedia(markdown) }); } catch (error) { diff --git a/packages/docx-engine/src/font-embedding.ts b/packages/docx-engine/src/font-embedding.ts new file mode 100644 index 0000000..17ee1e0 --- /dev/null +++ b/packages/docx-engine/src/font-embedding.ts @@ -0,0 +1,520 @@ +import { createHash } from "node:crypto"; +import { + MAXIMUM_DOCX_FONT_FACE_COUNT, + MAXIMUM_DOCX_FONT_SOURCE_BYTES, + MAXIMUM_DOCX_FONT_TOTAL_SOURCE_BYTES +} from "@md-to-pdf/core"; +import fontverter from "fontverter"; + +export const MAXIMUM_DOCX_FONT_SFNT_BYTES = 16 * 1024 * 1024; +export const MAXIMUM_DOCX_FONT_TOTAL_SFNT_BYTES = + 48 * 1024 * 1024; +const MAXIMUM_FONT_CONVERSION_CACHE_ENTRIES = 8; +const fontConversionCache = new Map< + string, + Promise +>(); +let fontConversionQueue = Promise.resolve(); + +const supportedSfntSignatures = new Set([ + "\u0000\u0001\u0000\u0000", + "OTTO", + "true" +]); + +export type DocxFontStyle = "normal" | "italic"; +export type DocxFontEmbeddingPermission = + | "installable" + | "editable"; + +export interface DocxFontSource { + family: string; + aliases: string[]; + source: string; + weight: number; + style: DocxFontStyle; + license: string; + content: Uint8Array; +} + +export interface SfntFontMetadata { + family: string; + subfamily: string; + postscriptName?: string; + weightClass: number; + fsType: number; + permission: DocxFontEmbeddingPermission; + noSubsetting: boolean; + signature: "\u0000\u0001\u0000\u0000" | "OTTO" | "true"; +} + +export interface PreparedDocxFont { + source: DocxFontSource; + metadata: SfntFontMetadata; + sfnt: Uint8Array; + fingerprint: string; +} + +export interface ObfuscatedDocxFont { + content: Uint8Array; + fontKey: string; + partName: string; +} + +interface SfntTable { + offset: number; + length: number; +} + +function readTag(content: Uint8Array, offset: number) { + return String.fromCharCode( + content[offset] ?? 0, + content[offset + 1] ?? 0, + content[offset + 2] ?? 0, + content[offset + 3] ?? 0 + ); +} + +function assertRange( + content: Uint8Array, + offset: number, + length: number, + label: string +) { + if ( + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(length) || + offset < 0 || + length < 0 || + offset + length > content.byteLength + ) { + throw new Error(`字体 ${label} 数据越界`); + } +} + +function readUint16( + view: DataView, + content: Uint8Array, + offset: number, + label: string +) { + assertRange(content, offset, 2, label); + return view.getUint16(offset, false); +} + +function readUint32( + view: DataView, + content: Uint8Array, + offset: number, + label: string +) { + assertRange(content, offset, 4, label); + return view.getUint32(offset, false); +} + +function readSfntTables(content: Uint8Array) { + if ( + content.byteLength < 12 || + content.byteLength > MAXIMUM_DOCX_FONT_SFNT_BYTES + ) { + throw new Error("SFNT 字体大小无效"); + } + const signature = readTag(content, 0); + if (!supportedSfntSignatures.has(signature)) { + throw new Error("字体不是受支持的 TrueType 或 OpenType SFNT"); + } + + const view = new DataView( + content.buffer, + content.byteOffset, + content.byteLength + ); + const tableCount = readUint16( + view, + content, + 4, + "表目录数量" + ); + if (tableCount < 1 || tableCount > 256) { + throw new Error("SFNT 字体表数量无效"); + } + assertRange(content, 12, tableCount * 16, "表目录"); + + const tables = new Map(); + for (let index = 0; index < tableCount; index += 1) { + const recordOffset = 12 + index * 16; + const tag = readTag(content, recordOffset); + const offset = readUint32( + view, + content, + recordOffset + 8, + `${tag} 表偏移` + ); + const length = readUint32( + view, + content, + recordOffset + 12, + `${tag} 表长度` + ); + assertRange(content, offset, length, `${tag} 表`); + if (tables.has(tag)) { + throw new Error(`SFNT 字体包含重复表:${tag}`); + } + tables.set(tag, { offset, length }); + } + + return { + signature: signature as SfntFontMetadata["signature"], + tables, + view + }; +} + +function decodeUtf16Be(content: Uint8Array) { + if (content.byteLength % 2 !== 0) { + throw new Error("字体名称 UTF-16BE 长度无效"); + } + let value = ""; + for (let offset = 0; offset < content.byteLength; offset += 2) { + value += String.fromCharCode( + ((content[offset] ?? 0) << 8) | + (content[offset + 1] ?? 0) + ); + } + return value.replaceAll("\0", "").trim(); +} + +function decodeName( + content: Uint8Array, + platformId: number +) { + if (platformId === 0 || platformId === 3) { + return decodeUtf16Be(content); + } + return new TextDecoder("windows-1252") + .decode(content) + .replaceAll("\0", "") + .trim(); +} + +function readFontName( + content: Uint8Array, + table: SfntTable, + nameIds: readonly number[] +) { + if (table.length < 6) { + throw new Error("字体 name 表不完整"); + } + const view = new DataView( + content.buffer, + content.byteOffset, + content.byteLength + ); + const count = readUint16( + view, + content, + table.offset + 2, + "name 记录数量" + ); + const stringsOffset = readUint16( + view, + content, + table.offset + 4, + "name 字符串偏移" + ); + assertRange(content, table.offset + 6, count * 12, "name 记录"); + + const candidates: Array<{ + value: string; + priority: number; + }> = []; + for (let index = 0; index < count; index += 1) { + const offset = table.offset + 6 + index * 12; + const platformId = readUint16( + view, + content, + offset, + "name platformID" + ); + const languageId = readUint16( + view, + content, + offset + 4, + "name languageID" + ); + const nameId = readUint16( + view, + content, + offset + 6, + "name nameID" + ); + const requestedIndex = nameIds.indexOf(nameId); + if (requestedIndex < 0) { + continue; + } + const length = readUint16( + view, + content, + offset + 8, + "name 字符串长度" + ); + const relativeOffset = readUint16( + view, + content, + offset + 10, + "name 字符串位置" + ); + const valueOffset = + table.offset + stringsOffset + relativeOffset; + assertRange(content, valueOffset, length, "name 字符串"); + const value = decodeName( + content.subarray(valueOffset, valueOffset + length), + platformId + ); + if (!value) { + continue; + } + const platformPriority = + platformId === 3 ? 30 : platformId === 0 ? 20 : 10; + const languagePriority = + languageId === 0x0409 ? 2 : languageId === 0 ? 1 : 0; + candidates.push({ + value, + priority: + (nameIds.length - requestedIndex) * 100 + + platformPriority + + languagePriority + }); + } + + return candidates.sort( + (left, right) => right.priority - left.priority + )[0]?.value; +} + +export function resolveFontEmbeddingPermission(fsType: number) { + if ( + !Number.isInteger(fsType) || + fsType < 0 || + fsType > 0xffff + ) { + throw new Error("字体 fsType 无效"); + } + if ((fsType & 0xfef0) !== 0) { + throw new Error( + `字体 fsType 包含不支持的保留或位图嵌入标志:0x${fsType + .toString(16) + .padStart(4, "0")}` + ); + } + + const usage = fsType & 0x000f; + if (usage === 0) { + return "installable" satisfies DocxFontEmbeddingPermission; + } + if (usage === 0x0008) { + return "editable" satisfies DocxFontEmbeddingPermission; + } + if (usage === 0x0002) { + throw new Error("字体许可证禁止嵌入"); + } + if (usage === 0x0004) { + throw new Error("字体仅允许预览和打印嵌入,不能用于可编辑 DOCX"); + } + throw new Error( + `字体 fsType 使用权限组合无效:0x${usage + .toString(16) + .padStart(4, "0")}` + ); +} + +export function readSfntFontMetadata( + content: Uint8Array +): SfntFontMetadata { + const { signature, tables, view } = readSfntTables(content); + const os2 = tables.get("OS/2"); + const name = tables.get("name"); + if (!os2 || os2.length < 10) { + throw new Error("SFNT 字体缺少完整 OS/2 表"); + } + if (!name) { + throw new Error("SFNT 字体缺少 name 表"); + } + + const family = readFontName(content, name, [16, 1]); + const subfamily = readFontName(content, name, [17, 2]); + if (!family || !subfamily) { + throw new Error("SFNT 字体缺少家族或字形面名称"); + } + const fsType = readUint16( + view, + content, + os2.offset + 8, + "OS/2 fsType" + ); + const permission = resolveFontEmbeddingPermission(fsType); + const postscriptName = readFontName(content, name, [6]); + + return { + family, + subfamily, + ...(postscriptName ? { postscriptName } : {}), + weightClass: readUint16( + view, + content, + os2.offset + 4, + "OS/2 usWeightClass" + ), + fsType, + permission, + noSubsetting: (fsType & 0x0100) !== 0, + signature + }; +} + +export async function prepareDocxFont( + source: DocxFontSource +): Promise { + if ( + source.content.byteLength < 32 || + source.content.byteLength > MAXIMUM_DOCX_FONT_SOURCE_BYTES + ) { + throw new Error(`字体资源大小无效:${source.source}`); + } + const sourceFingerprint = createHash("sha256") + .update(source.content) + .digest("hex"); + let conversion = fontConversionCache.get(sourceFingerprint); + if (!conversion) { + if ( + fontConversionCache.size >= + MAXIMUM_FONT_CONVERSION_CACHE_ENTRIES + ) { + const oldest = fontConversionCache.keys().next().value; + if (oldest !== undefined) { + fontConversionCache.delete(oldest); + } + } + const sourceContent = Buffer.from(source.content); + conversion = fontConversionQueue + .then(() => fontverter.convert(sourceContent, "sfnt")) + .then((converted) => { + if ( + converted.byteLength < 32 || + converted.byteLength > MAXIMUM_DOCX_FONT_SFNT_BYTES + ) { + throw new Error( + `字体解码后大小超过限制:${source.source}` + ); + } + return new Uint8Array(converted); + }); + fontConversionQueue = conversion.then( + () => undefined, + () => undefined + ); + fontConversionCache.set(sourceFingerprint, conversion); + void conversion.catch(() => { + if ( + fontConversionCache.get(sourceFingerprint) === conversion + ) { + fontConversionCache.delete(sourceFingerprint); + } + }); + } + const sfnt = (await conversion).slice(); + const metadata = readSfntFontMetadata(sfnt); + const fingerprint = createHash("sha256") + .update(sfnt) + .digest("hex"); + return { + source, + metadata, + sfnt, + fingerprint + }; +} + +export async function prepareDocxFonts( + sources: readonly DocxFontSource[] +) { + if (sources.length > MAXIMUM_DOCX_FONT_FACE_COUNT) { + throw new Error("DOCX 字体字形面数量超过限制"); + } + const totalSourceBytes = sources.reduce( + (total, source) => total + source.content.byteLength, + 0 + ); + if (totalSourceBytes > MAXIMUM_DOCX_FONT_TOTAL_SOURCE_BYTES) { + throw new Error("DOCX 字体资源总大小超过限制"); + } + const fonts = await Promise.all(sources.map(prepareDocxFont)); + const totalSfntBytes = fonts.reduce( + (total, font) => total + font.sfnt.byteLength, + 0 + ); + if (totalSfntBytes > MAXIMUM_DOCX_FONT_TOTAL_SFNT_BYTES) { + throw new Error("DOCX 字体解码后总大小超过限制"); + } + return fonts; +} + +function formatGuid(bytes: Uint8Array) { + const hex = Buffer.from(bytes).toString("hex").toUpperCase(); + return `{${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice( + 12, + 16 + )}-${hex.slice(16, 20)}-${hex.slice(20)}}`; +} + +function fontKeyBytes(fontKey: string) { + const hex = fontKey.replace(/[{}-]/gu, ""); + if (!/^[0-9a-f]{32}$/iu.test(hex)) { + throw new Error("DOCX 字体混淆键无效"); + } + return Uint8Array.from( + Array.from({ length: 16 }, (_, index) => + Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16) + ) + ).reverse(); +} + +export function xorDocxFont( + content: Uint8Array, + fontKey: string +) { + if (content.byteLength < 32) { + throw new Error("待混淆字体内容不足 32 字节"); + } + const result = content.slice(); + const key = fontKeyBytes(fontKey); + for (let index = 0; index < 32; index += 1) { + result[index] = + (result[index] ?? 0) ^ (key[index % key.length] ?? 0); + } + return result; +} + +export function obfuscateDocxFont( + font: PreparedDocxFont +): ObfuscatedDocxFont { + const keyBytes = createHash("sha256") + .update(font.sfnt) + .update("\0", "utf8") + .update(font.source.family, "utf8") + .update("\0", "utf8") + .update(String(font.source.weight), "utf8") + .update("\0", "utf8") + .update(font.source.style, "utf8") + .digest() + .subarray(0, 16); + keyBytes[6] = ((keyBytes[6] ?? 0) & 0x0f) | 0x40; + keyBytes[8] = ((keyBytes[8] ?? 0) & 0x3f) | 0x80; + const fontKey = formatGuid(keyBytes); + const partStem = fontKey.slice(1, -1).toLowerCase(); + return { + content: xorDocxFont(font.sfnt, fontKey), + fontKey, + partName: `word/fonts/${partStem}.odttf` + }; +} diff --git a/packages/docx-engine/src/font-package-transform.ts b/packages/docx-engine/src/font-package-transform.ts new file mode 100644 index 0000000..15500e0 --- /dev/null +++ b/packages/docx-engine/src/font-package-transform.ts @@ -0,0 +1,417 @@ +import { + CONTENT_TYPES_NAMESPACE, + DRAWING_NAMESPACE, + OFFICE_RELATIONSHIP_NAMESPACE, + PACKAGE_RELATIONSHIP_NAMESPACE, + WORD_NAMESPACE, + appendElement, + directChildren, + parseXmlPart, + removeDirectChildren, + serializeXmlPart, + type XmlElement +} from "./ooxml.js"; +import { + obfuscateDocxFont, + type PreparedDocxFont +} from "./font-embedding.js"; +import { + readGeneratedDocxPackage, + writeGeneratedDocxPackage +} from "./reference-package.js"; + +const FONT_RELATIONSHIP_TYPE = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font"; +const OBFUSCATED_FONT_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.obfuscatedFont"; +const XMLNS_NAMESPACE = "http://www.w3.org/2000/xmlns/"; + +export interface DocxFontEmbeddingReport { + embeddedFaceCount: number; + embeddedFamilyCount: number; + replacedFontReferenceCount: number; + totalSfntBytes: number; +} + +export interface DocxFontEmbeddingResult { + content: Uint8Array; + report: DocxFontEmbeddingReport; +} + +type EmbeddedFontRole = + | "embedRegular" + | "embedBold" + | "embedItalic" + | "embedBoldItalic"; + +interface EmbeddedFace { + font: PreparedDocxFont; + role: EmbeddedFontRole; + relationshipId: string; + fontKey: string; + partName: string; + content: Uint8Array; +} + +function normalizeFontName(value: string) { + return value.trim().toLocaleLowerCase("en-US"); +} + +function fontRole(font: PreparedDocxFont): EmbeddedFontRole { + const bold = font.source.weight >= 600; + const italic = font.source.style === "italic"; + if (bold && italic) { + return "embedBoldItalic"; + } + if (bold) { + return "embedBold"; + } + if (italic) { + return "embedItalic"; + } + return "embedRegular"; +} + +function createFontLookup(fonts: readonly PreparedDocxFont[]) { + const lookup = new Map(); + for (const font of fonts) { + for (const name of [ + font.metadata.family, + font.source.family, + ...font.source.aliases + ]) { + const normalized = normalizeFontName(name); + if (!lookup.has(normalized)) { + lookup.set(normalized, font); + } + } + } + return lookup; +} + +function replaceFontName( + value: string, + lookup: ReadonlyMap +) { + return lookup.get(normalizeFontName(value))?.metadata.family; +} + +function rewriteWordFontReferences( + entries: Map, + lookup: ReadonlyMap +) { + let count = 0; + for (const [partName, content] of entries) { + if ( + !/^word\/.+\.xml$/u.test(partName) || + partName === "word/fontTable.xml" + ) { + continue; + } + const document = parseXmlPart(content, partName); + let changed = false; + for (const fonts of Array.from( + document.getElementsByTagNameNS(WORD_NAMESPACE, "rFonts") + )) { + for (const attribute of [ + "ascii", + "hAnsi", + "eastAsia", + "cs" + ]) { + const value = fonts.getAttributeNS( + WORD_NAMESPACE, + attribute + ); + if (!value) { + continue; + } + const replacement = replaceFontName(value, lookup); + if (!replacement || replacement === value) { + continue; + } + fonts.setAttributeNS( + WORD_NAMESPACE, + `w:${attribute}`, + replacement + ); + count += 1; + changed = true; + } + } + for (const element of Array.from( + document.getElementsByTagNameNS("*", "*") + )) { + if ( + element.namespaceURI !== DRAWING_NAMESPACE || + !element.hasAttribute("typeface") + ) { + continue; + } + const value = element.getAttribute("typeface"); + if (!value) { + continue; + } + const replacement = replaceFontName(value, lookup); + if (!replacement || replacement === value) { + continue; + } + element.setAttribute("typeface", replacement); + count += 1; + changed = true; + } + if (changed) { + entries.set(partName, serializeXmlPart(document)); + } + } + return count; +} + +function nextRelationshipId(root: XmlElement) { + const used = new Set( + Array.from( + root.getElementsByTagNameNS( + PACKAGE_RELATIONSHIP_NAMESPACE, + "Relationship" + ) + ).map((relationship) => relationship.getAttribute("Id") ?? "") + ); + let next = 1; + return () => { + while (used.has(`rId${next}`)) { + next += 1; + } + const value = `rId${next}`; + used.add(value); + next += 1; + return value; + }; +} + +function prepareEmbeddedFaces( + fonts: readonly PreparedDocxFont[], + relationships: XmlElement +) { + const allocateId = nextRelationshipId(relationships); + const roles = new Map(); + const faces: EmbeddedFace[] = []; + for (const font of [...fonts].sort((left, right) => { + const family = left.metadata.family.localeCompare( + right.metadata.family + ); + return ( + family || + left.source.weight - right.source.weight || + left.source.style.localeCompare(right.source.style) + ); + })) { + const role = fontRole(font); + const roleKey = `${normalizeFontName( + font.metadata.family + )}\0${role}`; + const existingFingerprint = roles.get(roleKey); + if (existingFingerprint) { + if (existingFingerprint !== font.fingerprint) { + throw new Error( + `字体 ${font.metadata.family} 的 ${role} 字形面重复` + ); + } + continue; + } + roles.set(roleKey, font.fingerprint); + const obfuscated = obfuscateDocxFont(font); + faces.push({ + font, + role, + relationshipId: allocateId(), + ...obfuscated + }); + } + return faces; +} + +function createRelationshipsDocument() { + return parseXmlPart( + new TextEncoder().encode( + `` + ), + "word/_rels/fontTable.xml.rels" + ); +} + +function updateFontRelationships( + entries: Map, + fonts: readonly PreparedDocxFont[] +) { + const partName = "word/_rels/fontTable.xml.rels"; + const document = entries.has(partName) + ? parseXmlPart(entries.get(partName)!, partName) + : createRelationshipsDocument(); + const root = document.documentElement!; + if ( + root.namespaceURI !== PACKAGE_RELATIONSHIP_NAMESPACE || + root.localName !== "Relationships" + ) { + throw new Error("fontTable.xml.rels 根元素无效"); + } + for (const relationship of Array.from( + root.getElementsByTagNameNS( + PACKAGE_RELATIONSHIP_NAMESPACE, + "Relationship" + ) + )) { + if (relationship.getAttribute("Type") === FONT_RELATIONSHIP_TYPE) { + root.removeChild(relationship); + } + } + const faces = prepareEmbeddedFaces(fonts, root); + for (const face of faces) { + appendElement( + root, + PACKAGE_RELATIONSHIP_NAMESPACE, + "Relationship", + { + Id: face.relationshipId, + Type: FONT_RELATIONSHIP_TYPE, + Target: face.partName.replace(/^word\//u, "") + } + ); + } + entries.set(partName, serializeXmlPart(document)); + return faces; +} + +function updateFontTable( + entries: Map, + faces: readonly EmbeddedFace[] +) { + const partName = "word/fontTable.xml"; + const document = parseXmlPart(entries.get(partName)!, partName); + const root = document.documentElement!; + if ( + root.namespaceURI !== WORD_NAMESPACE || + root.localName !== "fonts" + ) { + throw new Error("word/fontTable.xml 根元素无效"); + } + root.setAttributeNS( + XMLNS_NAMESPACE, + "xmlns:r", + OFFICE_RELATIONSHIP_NAMESPACE + ); + for (const font of directChildren(root, WORD_NAMESPACE, "font")) { + removeDirectChildren( + font, + WORD_NAMESPACE, + "embedRegular", + "embedBold", + "embedItalic", + "embedBoldItalic" + ); + } + + const byName = new Map( + directChildren(root, WORD_NAMESPACE, "font").map((font) => [ + normalizeFontName( + font.getAttributeNS(WORD_NAMESPACE, "name") ?? "" + ), + font + ]) + ); + for (const face of faces) { + const family = face.font.metadata.family; + let font = byName.get(normalizeFontName(family)); + if (!font) { + font = appendElement(root, WORD_NAMESPACE, "w:font", { + "w:name": family + }); + appendElement(font, WORD_NAMESPACE, "w:family", { + "w:val": "auto" + }); + byName.set(normalizeFontName(family), font); + } + appendElement(font, WORD_NAMESPACE, `w:${face.role}`, { + "r:id": face.relationshipId, + "w:fontKey": face.fontKey + }); + } + entries.set(partName, serializeXmlPart(document)); +} + +function updateContentTypes(entries: Map) { + const partName = "[Content_Types].xml"; + const document = parseXmlPart(entries.get(partName)!, partName); + const root = document.documentElement!; + if ( + root.namespaceURI !== CONTENT_TYPES_NAMESPACE || + root.localName !== "Types" + ) { + throw new Error("[Content_Types].xml 根元素无效"); + } + for (const entry of Array.from( + root.getElementsByTagNameNS(CONTENT_TYPES_NAMESPACE, "Default") + )) { + if ( + (entry.getAttribute("Extension") ?? "").toLowerCase() === + "odttf" + ) { + root.removeChild(entry); + } + } + appendElement(root, CONTENT_TYPES_NAMESPACE, "Default", { + Extension: "odttf", + ContentType: OBFUSCATED_FONT_CONTENT_TYPE + }); + entries.set(partName, serializeXmlPart(document)); +} + +function removeExistingFontParts(entries: Map) { + for (const partName of [...entries.keys()]) { + if (/^word\/fonts\//u.test(partName)) { + entries.delete(partName); + } + } +} + +export function embedFontsInGeneratedDocx( + content: Uint8Array, + fonts: readonly PreparedDocxFont[] +): DocxFontEmbeddingResult { + if (fonts.length === 0) { + return { + content, + report: { + embeddedFaceCount: 0, + embeddedFamilyCount: 0, + replacedFontReferenceCount: 0, + totalSfntBytes: 0 + } + }; + } + const source = readGeneratedDocxPackage(content); + const entries = new Map(source.entries); + removeExistingFontParts(entries); + const lookup = createFontLookup(fonts); + const replacedFontReferenceCount = + rewriteWordFontReferences(entries, lookup); + const faces = updateFontRelationships(entries, fonts); + updateFontTable(entries, faces); + updateContentTypes(entries); + for (const face of faces) { + entries.set(face.partName, face.content); + } + return { + content: writeGeneratedDocxPackage(entries), + report: { + embeddedFaceCount: faces.length, + embeddedFamilyCount: new Set( + faces.map((face) => face.font.metadata.family) + ).size, + replacedFontReferenceCount, + totalSfntBytes: faces.reduce( + (total, face) => total + face.font.sfnt.byteLength, + 0 + ) + } + }; +} diff --git a/packages/docx-engine/src/fontverter.d.ts b/packages/docx-engine/src/fontverter.d.ts new file mode 100644 index 0000000..3f6a169 --- /dev/null +++ b/packages/docx-engine/src/fontverter.d.ts @@ -0,0 +1,15 @@ +declare module "fontverter" { + export type FontFormat = "sfnt" | "woff" | "woff2"; + + export interface Fontverter { + convert( + content: Buffer, + targetFormat: FontFormat, + sourceFormat?: FontFormat + ): Promise; + detectFormat(content: Buffer): FontFormat; + } + + const fontverter: Fontverter; + export default fontverter; +} diff --git a/packages/docx-engine/src/index.ts b/packages/docx-engine/src/index.ts index 987308b..b795a9b 100644 --- a/packages/docx-engine/src/index.ts +++ b/packages/docx-engine/src/index.ts @@ -1,5 +1,7 @@ export * from "./acceptance-validator.js"; export * from "./document-structure-transform.js"; +export * from "./font-embedding.js"; +export * from "./font-package-transform.js"; export * from "./header-footer-transform.js"; export * from "./ooxml.js"; export * from "./pandoc-process.js"; diff --git a/packages/docx-engine/src/pandoc-converter.ts b/packages/docx-engine/src/pandoc-converter.ts index fee7c79..2e1300e 100644 --- a/packages/docx-engine/src/pandoc-converter.ts +++ b/packages/docx-engine/src/pandoc-converter.ts @@ -28,6 +28,11 @@ import { type DynamicReferenceDocxResult } from "./reference-builder.js"; import { finalizeGeneratedDocxStructure } from "./document-structure-transform.js"; +import { + prepareDocxFonts, + type DocxFontSource +} from "./font-embedding.js"; +import { embedFontsInGeneratedDocx } from "./font-package-transform.js"; import type { DynamicReferenceDocxOptions } from "./reference-options.js"; import { preparePandocMedia } from "./pandoc-media.js"; import { createPandocStructurePlan } from "./pandoc-structure.js"; @@ -67,6 +72,7 @@ export interface PandocDocxConversionInput { exportConfig: ExportConfig; theme: ThemeManifest; themeTokens: DocxThemeTokenSet; + fonts: DocxFontSource[]; metadata: MarkdownDocumentMetadata; semanticDocument: SemanticDocumentModel; media: PreparedDocxMedia; @@ -225,8 +231,10 @@ export class PandocDocxConverter { ): Promise { let preparedMedia; let structurePlan; + let preparedFonts; try { preparedMedia = preparePandocMedia(input.media); + preparedFonts = await prepareDocxFonts(input.fonts); structurePlan = createPandocStructurePlan( input.semanticDocument, input.themeTokens, @@ -367,7 +375,10 @@ export class PandocDocxConverter { structurePlan, input.themeTokens ); - docx = finalized.content; + docx = embedFontsInGeneratedDocx( + finalized.content, + preparedFonts + ).content; if (docx.byteLength > this.maximumOutputBytes) { throw new Error("DOCX 结构收口后的输出大小无效"); } diff --git a/packages/docx-engine/src/token-style-map.ts b/packages/docx-engine/src/token-style-map.ts index d95fc31..227c881 100644 --- a/packages/docx-engine/src/token-style-map.ts +++ b/packages/docx-engine/src/token-style-map.ts @@ -132,7 +132,7 @@ export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly< }; const eastAsiaFontPattern = - /(?:yahei|simsun|simhei|fangsong|kaiti|dengxian|cjk|source\s+han|pingfang|heiti|songti|宋|黑|仿宋|楷|等线)/iu; + /(?:fandol|yahei|simsun|simhei|fangsong|kaiti|dengxian|cjk|source\s+han|pingfang|heiti|songti|宋|黑|仿宋|楷|等线)/iu; const genericFontNames = new Set([ "serif", "sans-serif", diff --git a/packages/docx-engine/src/validator.ts b/packages/docx-engine/src/validator.ts index 2256a87..e0732c4 100644 --- a/packages/docx-engine/src/validator.ts +++ b/packages/docx-engine/src/validator.ts @@ -10,6 +10,10 @@ import { validateWordprocessingElementOrder, type XmlElement } from "./ooxml.js"; +import { + readSfntFontMetadata, + xorDocxFont +} from "./font-embedding.js"; import { MAXIMUM_GENERATED_DOCX_ENTRY_COUNT, MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES, @@ -25,6 +29,10 @@ const HEADER_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"; const FOOTER_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"; +const FONT_RELATIONSHIP_TYPE = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font"; +const OBFUSCATED_FONT_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.obfuscatedFont"; const requiredStyleIds = [ "Normal", @@ -46,6 +54,7 @@ export interface DynamicReferenceValidation { relationshipCount: number; headerCount: number; footerCount: number; + embeddedFontCount: number; } function relationshipSourcePart(relationshipsPart: string) { @@ -318,6 +327,150 @@ function validateStyles(entries: ReadonlyMap) { } } +function validateEmbeddedFonts( + entries: ReadonlyMap, + relationshipParts: ReadonlyMap< + string, + ReadonlyMap + > +) { + const partName = "word/fontTable.xml"; + const document = parseXmlPart(entries.get(partName)!, partName); + const root = document.documentElement; + if ( + !root || + root.namespaceURI !== WORD_NAMESPACE || + root.localName !== "fonts" + ) { + throw new Error("word/fontTable.xml 的根元素无效"); + } + const relationships = + relationshipParts.get("word/_rels/fontTable.xml.rels") ?? + new Map(); + const referencedParts = new Set(); + const usedRelationships = new Set(); + const faceKeys = new Set(); + let embeddedFontCount = 0; + + for (const font of directChildren(root, WORD_NAMESPACE, "font")) { + const family = + font.getAttributeNS(WORD_NAMESPACE, "name") ?? ""; + for (const role of [ + "embedRegular", + "embedBold", + "embedItalic", + "embedBoldItalic" + ]) { + for (const embedded of directChildren( + font, + WORD_NAMESPACE, + role + )) { + const relationshipId = + embedded.getAttributeNS( + OFFICE_RELATIONSHIP_NAMESPACE, + "id" + ) ?? ""; + const fontKey = + embedded.getAttributeNS(WORD_NAMESPACE, "fontKey") ?? + ""; + if ( + !relationshipId || + !/^\{[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\}$/iu.test( + fontKey + ) + ) { + throw new Error( + `word/fontTable.xml 的 ${family} ${role} 嵌入声明无效` + ); + } + const faceKey = `${family.toLocaleLowerCase( + "en-US" + )}\0${role}`; + if (faceKeys.has(faceKey)) { + throw new Error( + `word/fontTable.xml 重复声明字体字形面:${family} ${role}` + ); + } + faceKeys.add(faceKey); + const relationship = relationships.get(relationshipId); + if ( + !relationship || + relationship.type !== FONT_RELATIONSHIP_TYPE || + !relationship.targetPart || + !/^word\/fonts\/[^/]+\.odttf$/u.test( + relationship.targetPart + ) + ) { + throw new Error( + `word/fontTable.xml 引用了无效字体关系:${relationshipId}` + ); + } + const obfuscated = entries.get(relationship.targetPart); + if (!obfuscated) { + throw new Error( + `DOCX 缺少嵌入字体部件:${relationship.targetPart}` + ); + } + const metadata = readSfntFontMetadata( + xorDocxFont(obfuscated, fontKey) + ); + if (metadata.family !== family) { + throw new Error( + `嵌入字体家族名不匹配:${family} != ${metadata.family}` + ); + } + usedRelationships.add(relationshipId); + referencedParts.add(relationship.targetPart); + embeddedFontCount += 1; + } + } + } + + for (const relationship of relationships.values()) { + if ( + relationship.type === FONT_RELATIONSHIP_TYPE && + !usedRelationships.has(relationship.id) + ) { + throw new Error( + `fontTable.xml.rels 包含未使用的字体关系:${relationship.id}` + ); + } + } + for (const entry of entries.keys()) { + if ( + /^word\/fonts\//u.test(entry) && + !referencedParts.has(entry) + ) { + throw new Error(`DOCX 包含未引用字体部件:${entry}`); + } + } + + if (embeddedFontCount > 0) { + const contentTypes = parseXmlPart( + entries.get("[Content_Types].xml")!, + "[Content_Types].xml" + ); + const validContentType = directChildren( + contentTypes.documentElement!, + CONTENT_TYPES_NAMESPACE, + "Default" + ).some( + (entry) => + (entry.getAttribute("Extension") ?? "").toLowerCase() === + "odttf" && + entry.getAttribute("ContentType") === + OBFUSCATED_FONT_CONTENT_TYPE + ); + if (!validContentType) { + throw new Error( + "嵌入字体缺少正确的 ODTTF Content Type" + ); + } + } + return embeddedFontCount; +} + function validateDocx( content: Uint8Array, generatedOutput: boolean @@ -400,13 +553,18 @@ function validateDocx( ); validateContentTypes(reference.entries, documentRelationships); validateStyles(reference.entries); + const embeddedFontCount = validateEmbeddedFonts( + reference.entries, + relationshipParts + ); return { partCount: reference.entries.size, xmlPartCount, relationshipCount, headerCount, - footerCount + footerCount, + embeddedFontCount }; } diff --git a/packages/docx-engine/tests/font-embedding.test.ts b/packages/docx-engine/tests/font-embedding.test.ts new file mode 100644 index 0000000..53d87d8 --- /dev/null +++ b/packages/docx-engine/tests/font-embedding.test.ts @@ -0,0 +1,153 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { + obfuscateDocxFont, + prepareDocxFont, + prepareDocxFonts, + readSfntFontMetadata, + resolveFontEmbeddingPermission, + xorDocxFont +} from "../src/font-embedding.js"; + +const fandolFangUrl = new URL( + "../../../themes/_shared/official-fonts/FandolFang-Regular.woff2", + import.meta.url +); +const fandolFaces = [ + ["FandolSong", "FandolSong-Regular.woff2", 400], + ["FandolSong", "FandolSong-Bold.woff2", 700], + ["FandolFang", "FandolFang-Regular.woff2", 400], + ["FandolHei", "FandolHei-Regular.woff2", 400], + ["FandolKai", "FandolKai-Regular.woff2", 400] +] as const; + +describe("DOCX 字体嵌入", () => { + it("在纯 Node.js 中将 WOFF2 转为可编辑嵌入的 OpenType SFNT", async () => { + const content = await readFile(fandolFangUrl); + const prepared = await prepareDocxFont({ + family: "Mdpdf Fandol Fang", + aliases: ["FangSong", "STFangsong"], + source: + "theme-shared:official-fonts/FandolFang-Regular.woff2", + weight: 400, + style: "normal", + license: "GPL-3.0-with-font-exception", + content + }); + + expect(prepared.metadata).toMatchObject({ + family: "FandolFang", + subfamily: "Regular", + postscriptName: "FandolFang-Regular", + fsType: 0x0008, + permission: "editable", + noSubsetting: false, + signature: "OTTO" + }); + expect(prepared.sfnt.byteLength).toBeGreaterThan( + content.byteLength + ); + expect( + readSfntFontMetadata(prepared.sfnt) + ).toEqual(prepared.metadata); + }); + + it("使用稳定字体键混淆前 32 字节并可按同一算法还原", async () => { + const prepared = await prepareDocxFont({ + family: "Mdpdf Fandol Fang", + aliases: [], + source: + "theme-shared:official-fonts/FandolFang-Regular.woff2", + weight: 400, + style: "normal", + license: "GPL-3.0-with-font-exception", + content: await readFile(fandolFangUrl) + }); + + const first = obfuscateDocxFont(prepared); + const second = obfuscateDocxFont(prepared); + expect(first.fontKey).toBe(second.fontKey); + expect(first.partName).toBe(second.partName); + expect( + Buffer.compare( + Buffer.from(first.content), + Buffer.from(second.content) + ) + ).toBe(0); + expect(first.fontKey).toMatch( + /^\{[0-9A-F]{8}(?:-[0-9A-F]{4}){3}-[0-9A-F]{12}\}$/u + ); + expect(first.partName).toMatch(/^word\/fonts\/.+\.odttf$/u); + expect(first.content.subarray(0, 32)).not.toEqual( + prepared.sfnt.subarray(0, 32) + ); + expect( + Buffer.compare( + Buffer.from( + xorDocxFont(first.content, first.fontKey) + ), + Buffer.from(prepared.sfnt) + ) + ).toBe(0); + }); + + it("串行调度底层 WASM 并安全准备多个并发 WOFF2 字形", async () => { + const prepared = await prepareDocxFonts( + await Promise.all( + fandolFaces.map( + async ([family, fileName, weight]) => ({ + family, + aliases: [], + source: `theme-shared:official-fonts/${fileName}`, + weight, + style: "normal" as const, + license: "GPL-3.0-with-font-exception", + content: await readFile( + new URL( + `../../../themes/_shared/official-fonts/${fileName}`, + import.meta.url + ) + ) + }) + ) + ) + ); + + expect(prepared).toHaveLength(5); + expect( + prepared.map((font) => font.metadata.family) + ).toEqual([ + "FandolSong", + "FandolSong", + "FandolFang", + "FandolHei", + "FandolKai" + ]); + expect( + prepared.every((font) => font.metadata.signature === "OTTO") + ).toBe(true); + }); + + it("只接受可安装或可编辑嵌入权限", () => { + expect(resolveFontEmbeddingPermission(0)).toBe("installable"); + expect(resolveFontEmbeddingPermission(0x0008)).toBe("editable"); + expect(() => resolveFontEmbeddingPermission(0x0002)).toThrow( + "禁止嵌入" + ); + expect(() => resolveFontEmbeddingPermission(0x0004)).toThrow( + "仅允许预览和打印" + ); + expect(() => resolveFontEmbeddingPermission(0x0200)).toThrow( + "位图" + ); + expect(() => resolveFontEmbeddingPermission(0x000c)).toThrow( + "组合无效" + ); + }); + + it("拒绝损坏或非 SFNT 字体", () => { + expect(() => + readSfntFontMetadata(new Uint8Array(64)) + ).toThrow("TrueType 或 OpenType"); + }); +}); diff --git a/packages/docx-engine/tests/font-package-transform.test.ts b/packages/docx-engine/tests/font-package-transform.test.ts new file mode 100644 index 0000000..cc6629e --- /dev/null +++ b/packages/docx-engine/tests/font-package-transform.test.ts @@ -0,0 +1,135 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { prepareDocxFont, xorDocxFont } from "../src/font-embedding.js"; +import { embedFontsInGeneratedDocx } from "../src/font-package-transform.js"; +import { + readGeneratedDocxPackage, + writeGeneratedDocxPackage +} from "../src/reference-package.js"; +import { validateGeneratedDocx } from "../src/validator.js"; +import { createTestBaselineReference } from "./reference-test-fixture.js"; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); +const wordNamespace = + "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; +const relationshipNamespace = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; +const fandolFangUrl = new URL( + "../../../themes/_shared/official-fonts/FandolFang-Regular.woff2", + import.meta.url +); + +describe("DOCX 字体包写入", () => { + it("写入 ODTTF、字体关系和内容类型并替换逻辑字体名", async () => { + const source = readGeneratedDocxPackage( + createTestBaselineReference() + ); + const entries = new Map(source.entries); + const originalStyles = decoder.decode( + entries.get("word/styles.xml") + ); + entries.set( + "word/styles.xml", + encoder.encode( + originalStyles.replace( + "", + '' + ).replace( + "", + '' + ) + ) + ); + entries.set( + "word/document.xml", + encoder.encode( + decoder + .decode(entries.get("word/document.xml")) + .replace( + "", + '' + ) + ) + ); + const input = writeGeneratedDocxPackage(entries); + const font = await prepareDocxFont({ + family: "Mdpdf Fandol Fang", + aliases: ["FangSong", "STFangsong"], + source: + "theme-shared:official-fonts/FandolFang-Regular.woff2", + weight: 400, + style: "normal", + license: "GPL-3.0-with-font-exception", + content: await readFile(fandolFangUrl) + }); + + const result = embedFontsInGeneratedDocx(input, [font]); + const output = readGeneratedDocxPackage(result.content); + const fontParts = [...output.entries.keys()].filter((name) => + /^word\/fonts\/.+\.odttf$/u.test(name) + ); + expect(result.report).toMatchObject({ + embeddedFaceCount: 1, + embeddedFamilyCount: 1, + replacedFontReferenceCount: 3, + totalSfntBytes: font.sfnt.byteLength + }); + expect(fontParts).toHaveLength(1); + + const contentTypes = decoder.decode( + output.entries.get("[Content_Types].xml") + ); + expect(contentTypes).toContain( + 'Extension="odttf" ContentType="application/vnd.openxmlformats-officedocument.obfuscatedFont"' + ); + const relationships = decoder.decode( + output.entries.get("word/_rels/fontTable.xml.rels") + ); + expect(relationships).toContain( + 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/font"' + ); + expect(relationships).toContain( + `Target="${fontParts[0]!.replace(/^word\//u, "")}"` + ); + + const fontTable = decoder.decode( + output.entries.get("word/fontTable.xml") + ); + expect(fontTable).toContain('w:name="FandolFang"'); + expect(fontTable).toContain(" { + const input = createTestBaselineReference(); + const result = embedFontsInGeneratedDocx(input, []); + expect(result.content).toBe(input); + expect(result.report.embeddedFaceCount).toBe(0); + }); +}); diff --git a/packages/docx-engine/tests/pandoc-converter.test.ts b/packages/docx-engine/tests/pandoc-converter.test.ts index 14facca..7bc6327 100644 --- a/packages/docx-engine/tests/pandoc-converter.test.ts +++ b/packages/docx-engine/tests/pandoc-converter.test.ts @@ -68,6 +68,7 @@ function input() { exportConfig: defaultExportConfig, theme: theme(), themeTokens, + fonts: [], metadata: { title: "测试", author: "测试人", diff --git a/themes/_shared/official-fonts/fonts.css b/themes/_shared/official-fonts/fonts.css new file mode 100644 index 0000000..ad3c250 --- /dev/null +++ b/themes/_shared/official-fonts/fonts.css @@ -0,0 +1,39 @@ +@font-face { + font-family: "FandolSong"; + font-style: normal; + font-weight: 400; + font-display: block; + src: url("./FandolSong-Regular.woff2") format("woff2"); +} + +@font-face { + font-family: "FandolSong"; + font-style: normal; + font-weight: 700; + font-display: block; + src: url("./FandolSong-Bold.woff2") format("woff2"); +} + +@font-face { + font-family: "FandolFang"; + font-style: normal; + font-weight: 400; + font-display: block; + src: url("./FandolFang-Regular.woff2") format("woff2"); +} + +@font-face { + font-family: "FandolHei"; + font-style: normal; + font-weight: 400; + font-display: block; + src: url("./FandolHei-Regular.woff2") format("woff2"); +} + +@font-face { + font-family: "FandolKai"; + font-style: normal; + font-weight: 400; + font-display: block; + src: url("./FandolKai-Regular.woff2") format("woff2"); +} diff --git a/themes/formal-report/theme.css b/themes/formal-report/theme.css index 8c69b73..af2acda 100644 --- a/themes/formal-report/theme.css +++ b/themes/formal-report/theme.css @@ -10,7 +10,7 @@ #write { color: var(--report-ink); font-family: - "Noto Serif CJK SC", "Source Han Serif SC", + "FandolFang", "Noto Serif CJK SC", "Source Han Serif SC", "Microsoft YaHei", "SimSun", serif; font-size: 18px; line-height: 1.8; @@ -32,7 +32,7 @@ #write h3, #write h4 { font-family: - "Noto Sans CJK SC", "Source Han Sans SC", + "FandolHei", "Noto Sans CJK SC", "Source Han Sans SC", "Microsoft YaHei", sans-serif; font-weight: 700; } @@ -108,7 +108,7 @@ color: #fff; background: var(--report-navy); font-family: - "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; + "FandolHei", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; text-align: center; } diff --git a/themes/formal-report/theme.json b/themes/formal-report/theme.json index 456d612..61c11b5 100644 --- a/themes/formal-report/theme.json +++ b/themes/formal-report/theme.json @@ -21,6 +21,22 @@ "left": "28mm" } }, + "docxFonts": { + "faces": [ + { + "family": "FandolFang", + "aliases": ["FangSong", "STFangsong", "Noto Serif CJK SC", "Source Han Serif SC", "Microsoft YaHei", "SimSun"], + "source": "theme-shared:official-fonts/FandolFang-Regular.woff2", + "license": "GPL-3.0-or-later WITH Font-exception-2.0" + }, + { + "family": "FandolHei", + "aliases": ["SimHei", "Noto Sans CJK SC", "Source Han Sans SC", "Microsoft YaHei"], + "source": "theme-shared:official-fonts/FandolHei-Regular.woff2", + "license": "GPL-3.0-or-later WITH Font-exception-2.0" + } + ] + }, "docxStyle": { "preset": "formal" }, diff --git a/themes/gov-red-standard/theme.css b/themes/gov-red-standard/theme.css index 93e9102..44fea3d 100644 --- a/themes/gov-red-standard/theme.css +++ b/themes/gov-red-standard/theme.css @@ -1,43 +1,3 @@ -@font-face { - font-family: "Mdpdf Fandol Song"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("theme-shared:official-fonts/FandolSong-Regular.woff2") format("woff2"); -} - -@font-face { - font-family: "Mdpdf Fandol Song"; - font-style: normal; - font-weight: 700; - font-display: block; - src: url("theme-shared:official-fonts/FandolSong-Bold.woff2") format("woff2"); -} - -@font-face { - font-family: "Mdpdf Fandol Fang"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("theme-shared:official-fonts/FandolFang-Regular.woff2") format("woff2"); -} - -@font-face { - font-family: "Mdpdf Fandol Hei"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("theme-shared:official-fonts/FandolHei-Regular.woff2") format("woff2"); -} - -@font-face { - font-family: "Mdpdf Fandol Kai"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("theme-shared:official-fonts/FandolKai-Regular.woff2") format("woff2"); -} - :root { --official-red: #d40000; --official-ink: #111; @@ -48,7 +8,7 @@ #write { color: var(--official-ink); font-family: - "Mdpdf Fandol Fang", "FangSong", "STFangsong", "Noto Serif CJK SC", + "FandolFang", "FangSong", "STFangsong", "Noto Serif CJK SC", "Source Han Serif SC", "SimSun", serif; font-size: 21.333px; line-height: 1.75; @@ -72,7 +32,7 @@ justify-content: space-between; color: var(--official-ink); font-family: - "Mdpdf Fandol Hei", "SimHei", "Noto Sans CJK SC", + "FandolHei", "SimHei", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; font-size: 0.75em; font-weight: 400; @@ -82,7 +42,7 @@ margin: 9mm 0 8mm; color: var(--official-red); font-family: - "Mdpdf Fandol Song", "STZhongsong", "Noto Serif CJK SC", + "FandolSong", "STZhongsong", "Noto Serif CJK SC", "Source Han Serif SC", serif; font-size: 2em; font-weight: 700; @@ -104,13 +64,13 @@ #write .doc-signatory > span { font-family: - "Mdpdf Fandol Fang", "FangSong", "STFangsong", + "FandolFang", "FangSong", "STFangsong", "Noto Serif CJK SC", serif; } #write .doc-signatory { font-family: - "Mdpdf Fandol Kai", "KaiTi", "STKaiti", + "FandolKai", "KaiTi", "STKaiti", "Noto Serif CJK SC", serif; } @@ -118,7 +78,7 @@ margin: 0 0 12mm; color: var(--official-ink); font-family: - "Mdpdf Fandol Song", "STZhongsong", "Noto Serif CJK SC", + "FandolSong", "STZhongsong", "Noto Serif CJK SC", "Source Han Serif SC", serif; font-size: 1.375em; font-weight: 700; @@ -136,7 +96,7 @@ margin: 1em 0 0.35em; color: var(--official-ink); font-family: - "Mdpdf Fandol Hei", "SimHei", "Noto Sans CJK SC", + "FandolHei", "SimHei", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; font-size: 1em; font-weight: 400; @@ -150,7 +110,7 @@ #write h5, #write h6 { font-family: - "Mdpdf Fandol Kai", "KaiTi", "STKaiti", + "FandolKai", "KaiTi", "STKaiti", "Noto Serif CJK SC", serif; font-weight: 400; } @@ -200,7 +160,7 @@ margin: 0.8em 0; border-collapse: collapse; font-family: - "Mdpdf Fandol Song", "SimSun", "Noto Serif CJK SC", + "FandolSong", "SimSun", "Noto Serif CJK SC", "Source Han Serif SC", serif; font-size: 0.75em; line-height: 1.5; @@ -215,7 +175,7 @@ #write th { background: #f5f5f5; font-family: - "Mdpdf Fandol Hei", "SimHei", "Noto Sans CJK SC", + "FandolHei", "SimHei", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; font-weight: 400; text-align: center; @@ -262,7 +222,7 @@ border-bottom: 0.35mm solid var(--official-rule); break-inside: avoid; font-family: - "Mdpdf Fandol Fang", "FangSong", "STFangsong", + "FandolFang", "FangSong", "STFangsong", "Noto Serif CJK SC", serif; font-size: 0.875em; line-height: 1.7; diff --git a/themes/gov-red-standard/theme.json b/themes/gov-red-standard/theme.json index d71288f..86a3459 100644 --- a/themes/gov-red-standard/theme.json +++ b/themes/gov-red-standard/theme.json @@ -33,7 +33,7 @@ "height": "8mm", "showDivider": false, "fontSize": "4.94mm", - "fontFamily": "\"Mdpdf Fandol Song\", SimSun, serif", + "fontFamily": "\"FandolSong\", SimSun, serif", "color": "#000000", "left": { "enabled": false, @@ -53,7 +53,7 @@ "height": "7mm", "showDivider": false, "fontSize": "4.94mm", - "fontFamily": "\"Mdpdf Fandol Song\", SimSun, serif", + "fontFamily": "\"FandolSong\", SimSun, serif", "color": "#000000", "alignment": "outer", "format": "official-page", @@ -61,6 +61,42 @@ "showOnFirstPage": true } }, + "docxFonts": { + "faces": [ + { + "family": "FandolSong", + "aliases": ["Mdpdf Fandol Song", "STZhongsong", "SimSun"], + "source": "theme-shared:official-fonts/FandolSong-Regular.woff2", + "weight": 400, + "license": "GPL-3.0-or-later WITH Font-exception-2.0" + }, + { + "family": "FandolSong", + "aliases": ["Mdpdf Fandol Song", "STZhongsong", "SimSun"], + "source": "theme-shared:official-fonts/FandolSong-Bold.woff2", + "weight": 700, + "license": "GPL-3.0-or-later WITH Font-exception-2.0" + }, + { + "family": "FandolFang", + "aliases": ["Mdpdf Fandol Fang", "FangSong", "STFangsong"], + "source": "theme-shared:official-fonts/FandolFang-Regular.woff2", + "license": "GPL-3.0-or-later WITH Font-exception-2.0" + }, + { + "family": "FandolHei", + "aliases": ["Mdpdf Fandol Hei", "SimHei", "Noto Sans CJK SC", "Source Han Sans SC", "Microsoft YaHei"], + "source": "theme-shared:official-fonts/FandolHei-Regular.woff2", + "license": "GPL-3.0-or-later WITH Font-exception-2.0" + }, + { + "family": "FandolKai", + "aliases": ["Mdpdf Fandol Kai", "KaiTi", "STKaiti"], + "source": "theme-shared:official-fonts/FandolKai-Regular.woff2", + "license": "GPL-3.0-or-later WITH Font-exception-2.0" + } + ] + }, "docxStyle": { "preset": "official" }, diff --git a/themes/typora-github/github.css b/themes/typora-github/github.css index a4b3fec..0b7230d 100644 --- a/themes/typora-github/github.css +++ b/themes/typora-github/github.css @@ -44,7 +44,7 @@ html { } body { - font-family: "Open Sans","Clear Sans", "Helvetica Neue", Helvetica, Arial, 'Segoe UI Emoji', 'SF Pro', sans-serif; + font-family: "Open Sans", "FandolHei", "Clear Sans", "Helvetica Neue", Helvetica, Arial, 'Segoe UI Emoji', 'SF Pro', sans-serif; color: rgb(51, 51, 51); line-height: 1.6; } diff --git a/themes/typora-github/github/OPEN-SANS-LICENSE.txt b/themes/typora-github/github/OPEN-SANS-LICENSE.txt new file mode 100644 index 0000000..b137a07 --- /dev/null +++ b/themes/typora-github/github/OPEN-SANS-LICENSE.txt @@ -0,0 +1,78 @@ +Open Sans is a trademark of Google and may be registered in certain +jurisdictions. + +Digitized data copyright 2010-2011, Google Corporation. + +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and + +You must cause any modified files to carry prominent notices stating that You changed the files; and + +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/themes/typora-github/theme.json b/themes/typora-github/theme.json index c8ca7f6..719288f 100644 --- a/themes/typora-github/theme.json +++ b/themes/typora-github/theme.json @@ -18,6 +18,46 @@ "katex", "mermaid" ], + "docxFonts": { + "faces": [ + { + "family": "Open Sans", + "aliases": ["Clear Sans", "Helvetica Neue", "Helvetica", "Arial"], + "source": "github/open-sans-v17-latin-ext_latin-regular.woff2", + "weight": 400, + "license": "Apache-2.0" + }, + { + "family": "Open Sans", + "aliases": ["Clear Sans", "Helvetica Neue", "Helvetica", "Arial"], + "source": "github/open-sans-v17-latin-ext_latin-italic.woff2", + "weight": 400, + "style": "italic", + "license": "Apache-2.0" + }, + { + "family": "Open Sans", + "aliases": ["Clear Sans", "Helvetica Neue", "Helvetica", "Arial"], + "source": "github/open-sans-v17-latin-ext_latin-700.woff2", + "weight": 700, + "license": "Apache-2.0" + }, + { + "family": "Open Sans", + "aliases": ["Clear Sans", "Helvetica Neue", "Helvetica", "Arial"], + "source": "github/open-sans-v17-latin-ext_latin-700italic.woff2", + "weight": 700, + "style": "italic", + "license": "Apache-2.0" + }, + { + "family": "FandolHei", + "aliases": ["SimHei", "Noto Sans CJK SC", "Source Han Sans SC", "Microsoft YaHei"], + "source": "theme-shared:official-fonts/FandolHei-Regular.woff2", + "license": "GPL-3.0-or-later WITH Font-exception-2.0" + } + ] + }, "docxStyle": { "preset": "technical" },