From fa159d916f3a8cf5278fe1e0e38ecb6eeb79127b Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Thu, 30 Jul 2026 14:10:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=8A=A8=E6=80=81=20?= =?UTF-8?q?DOCX=20=E6=A8=A1=E6=9D=BF=E4=B8=8E=E6=A0=B7=E5=BC=8F=E6=98=A0?= =?UTF-8?q?=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PROGRESS.md | 15 +- package-lock.json | 24 +- package.json | 7 +- .../application/tests/bundled-themes.test.ts | 17 + packages/core/README.md | 1 + packages/core/src/docx-style.ts | 121 ++++ packages/core/src/index.ts | 1 + packages/core/src/theme.ts | 2 + packages/core/tests/docx.test.ts | 47 +- packages/docx-engine/README.md | 37 + .../docx-engine/fixtures/pandoc-reference.md | 26 + packages/docx-engine/package.json | 33 + .../scripts/verify-pandoc-reference.mjs | 299 ++++++++ .../src/header-footer-transform.ts | 519 +++++++++++++ packages/docx-engine/src/index.ts | 9 + packages/docx-engine/src/ooxml.ts | 149 ++++ packages/docx-engine/src/reference-builder.ts | 155 ++++ packages/docx-engine/src/reference-options.ts | 64 ++ packages/docx-engine/src/reference-package.ts | 196 +++++ packages/docx-engine/src/section-transform.ts | 149 ++++ packages/docx-engine/src/style-presets.ts | 258 +++++++ packages/docx-engine/src/styles-transform.ts | 684 ++++++++++++++++++ packages/docx-engine/src/validator.ts | 347 +++++++++ .../tests/reference-builder.test.ts | 275 +++++++ .../tests/reference-package.test.ts | 48 ++ .../docx-engine/tests/style-presets.test.ts | 67 ++ packages/docx-engine/tsconfig.json | 16 + themes/enterprise-red-simple/theme.json | 3 + themes/formal-feasibility/theme.json | 3 + themes/formal-regulation/theme.json | 3 + themes/formal-report/theme.json | 3 + themes/gov-red-letter/theme.json | 3 + themes/gov-red-standard/theme.json | 3 + themes/red-briefing/theme.json | 3 + themes/tender-blind/theme.json | 3 + themes/tender-business-blue/theme.json | 9 + themes/tender-classic/theme.json | 6 + themes/typora-github/theme.json | 3 + themes/typora-like/theme.json | 3 + themes/typora-pixyll/theme.json | 3 + themes/typora-whitey/theme.json | 3 + 41 files changed, 3610 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/docx-style.ts create mode 100644 packages/docx-engine/README.md create mode 100644 packages/docx-engine/fixtures/pandoc-reference.md create mode 100644 packages/docx-engine/package.json create mode 100644 packages/docx-engine/scripts/verify-pandoc-reference.mjs create mode 100644 packages/docx-engine/src/header-footer-transform.ts create mode 100644 packages/docx-engine/src/index.ts create mode 100644 packages/docx-engine/src/ooxml.ts create mode 100644 packages/docx-engine/src/reference-builder.ts create mode 100644 packages/docx-engine/src/reference-options.ts create mode 100644 packages/docx-engine/src/reference-package.ts create mode 100644 packages/docx-engine/src/section-transform.ts create mode 100644 packages/docx-engine/src/style-presets.ts create mode 100644 packages/docx-engine/src/styles-transform.ts create mode 100644 packages/docx-engine/src/validator.ts create mode 100644 packages/docx-engine/tests/reference-builder.test.ts create mode 100644 packages/docx-engine/tests/reference-package.test.ts create mode 100644 packages/docx-engine/tests/style-presets.test.ts create mode 100644 packages/docx-engine/tsconfig.json diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index eb86ae8..c8ecffa 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -98,6 +98,14 @@ Server 使用 Playwright Chromium、Desktop 使用 Electron Chromium, 万像素、8 MiB 单图和 30 MiB 总量。本阶段只提供共享服务和平台适配器, 尚未新增 DOCX HTTP 路由、应用 IPC、Pandoc 调用或导出按钮。 +`v0.6.0` 阶段 6 已完成纯 Node.js 动态 `reference.docx` 引擎:14 套 +内置主题均声明结构化 DOCX 样式预设;引擎基于 Pandoc 3.9.0.2 默认模板 +映射纸张、方向、页边距、正文、标题、代码、引用、表格、图注、字体、 +首页/奇偶页眉页脚和动态页码。生成后会校验全部 XML、包内关系、内容 +类型、最终节与关键样式,并使用稳定缓存键区分基线、Pandoc 版本、主题、 +配置和元数据。本机真实 Pandoc 验收覆盖公文 A4 和自定义横向两组模板, +输出仍由标准 Word 段落、文本 Run、表格和代码文本组成,可继续编辑。 + ## 2. 已完成 ### 2.1 项目骨架 @@ -976,6 +984,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果: - `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。 - `packages/application` 负责共享 Markdown 渲染和主题应用服务; `apps/server` 与 `apps/desktop` 分别提供 HTTP 和 IPC 适配。 +- `packages/docx-engine` 负责安全读取 Pandoc 默认模板、解析主题 DOCX + 样式、生成动态 OOXML 部件和执行结构校验;Pandoc 进程定位、临时目录 + 与转换超时仍属于后续转换服务。 - `apps/desktop/src/desktop-application-controller.ts` 负责 Electron 多窗口、同文件单例、文件变化检测和平台链接动作;`main.ts` 负责启动 组装、协议和生命周期。 @@ -1010,8 +1021,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果: - 阶段 3:已实现 DOCX 共享模型和跨端导出协议; - 阶段 4:已实现 CodeMirror 6 与可扩展基础 Markdown 工具栏; - 阶段 5:已实现 DOCX 资源预处理和跨端 Chromium PNG 捕获适配器; -- 阶段 6~8:依次实现动态 reference.docx、Pandoc 转换服务以及 - Web/Desktop 导出交互; +- 阶段 6:已实现动态 reference.docx、主题样式映射和真实 Pandoc 验证; +- 阶段 7~8:依次实现 Pandoc 转换服务以及 Web/Desktop 导出交互; - 阶段 9~10:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 diff --git a/package-lock.json b/package-lock.json index defd24f..8798221 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2183,6 +2183,10 @@ "resolved": "apps/desktop", "link": true }, + "node_modules/@md-to-pdf/docx-engine": { + "resolved": "packages/docx-engine", + "link": true + }, "node_modules/@md-to-pdf/markdown-echarts": { "resolved": "packages/markdown-echarts", "link": true @@ -3730,7 +3734,6 @@ "version": "0.9.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", - "dev": true, "license": "MIT", "engines": { "node": ">=14.6" @@ -6494,6 +6497,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -9911,6 +9920,19 @@ "vitest": "^4.1.10" } }, + "packages/docx-engine": { + "name": "@md-to-pdf/docx-engine", + "version": "0.1.0", + "dependencies": { + "@md-to-pdf/core": "0.1.0", + "@xmldom/xmldom": "0.9.10", + "fflate": "0.8.3" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "vitest": "^4.1.10" + } + }, "packages/markdown-echarts": { "name": "@md-to-pdf/markdown-echarts", "version": "0.1.0", diff --git a/package.json b/package.json index d168b15..21dea44 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ ], "scripts": { "build": "npm run build:web-runtime && 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/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", + "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/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/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n markdown-echarts,core,renderer,application,preview-engine,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"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", "dev:core": "npm run dev -w @md-to-pdf/core", @@ -23,8 +23,9 @@ "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/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", "theme:import-typora": "node scripts/import-typora-theme.mjs", - "test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && 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/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" + "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", + "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/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/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" }, "engines": { "node": ">=22" diff --git a/packages/application/tests/bundled-themes.test.ts b/packages/application/tests/bundled-themes.test.ts index eba9e1e..42a6682 100644 --- a/packages/application/tests/bundled-themes.test.ts +++ b/packages/application/tests/bundled-themes.test.ts @@ -1,6 +1,7 @@ import { readFile, readdir } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { docxThemeStyleSchema } from "@md-to-pdf/core"; const bundledThemeNames = { "typora-github": "Typora Github", @@ -83,6 +84,22 @@ describe("内置主题清单", () => { } }); + it("每个内置主题声明可校验的 DOCX 样式预设", async () => { + const themesRoot = fileURLToPath( + new URL("../../../themes/", import.meta.url) + ); + + for (const id of Object.keys(bundledThemeNames)) { + const manifest = JSON.parse( + await readFile(`${themesRoot}${id}/theme.json`, "utf8") + ) as { docxStyle?: unknown }; + + expect( + docxThemeStyleSchema.safeParse(manifest.docxStyle).success + ).toBe(true); + } + }); + it("为红头主题声明分类与兼容的结构化文档", async () => { const themesRoot = fileURLToPath( new URL("../../../themes/", import.meta.url) diff --git a/packages/core/README.md b/packages/core/README.md index 0bb8bcb..08ae43d 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -10,6 +10,7 @@ src/ document.ts Markdown 文档、分页载荷、结果与耗时模型 document-link.ts 跨端链接分类与 PDF 本地链接编码协议 docx.ts DOCX 请求、能力、错误、结果与文件名协议 + docx-style.ts 主题 DOCX 样式预设和安全覆盖协议 export-config.ts 纸张、边距、页眉页脚、页码与图表配置 theme.ts 主题清单、主题能力与 CSS 载荷模型 index.ts 公共导出入口 diff --git a/packages/core/src/docx-style.ts b/packages/core/src/docx-style.ts new file mode 100644 index 0000000..c9f1108 --- /dev/null +++ b/packages/core/src/docx-style.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; + +const colorSchema = z + .string() + .regex(/^#[0-9a-f]{6}$/iu, "DOCX 颜色必须使用六位十六进制格式"); +const fontNameSchema = z + .string() + .trim() + .min(1) + .max(100) + .regex( + /^[\p{L}\p{N}\s._-]+$/u, + "DOCX 字体名称包含不支持的字符" + ); +const pointSizeSchema = z.number().min(6).max(72); +const spacingSchema = z.number().min(0).max(144); + +export const docxStylePresetSchema = z.enum([ + "general", + "technical", + "official", + "formal", + "tender" +]); + +export type DocxStylePreset = z.infer< + typeof docxStylePresetSchema +>; + +export const docxFontFamilySchema = z.object({ + latin: fontNameSchema, + eastAsia: fontNameSchema, + complexScript: fontNameSchema.optional() +}); + +export type DocxFontFamily = z.infer< + typeof docxFontFamilySchema +>; + +const docxBodyStyleSchema = z.object({ + fonts: docxFontFamilySchema.optional(), + sizePt: pointSizeSchema.optional(), + color: colorSchema.optional(), + lineSpacing: z.number().min(1).max(3).optional(), + firstLineIndentChars: z.number().min(0).max(10).optional(), + spacingBeforePt: spacingSchema.optional(), + spacingAfterPt: spacingSchema.optional() +}); + +const docxHeadingStyleSchema = z.object({ + fonts: docxFontFamilySchema.optional(), + sizesPt: z + .tuple([ + pointSizeSchema, + pointSizeSchema, + pointSizeSchema, + pointSizeSchema, + pointSizeSchema, + pointSizeSchema + ]) + .optional(), + color: colorSchema.optional(), + bold: z.boolean().optional(), + spacingBeforePt: spacingSchema.optional(), + spacingAfterPt: spacingSchema.optional() +}); + +const docxCodeStyleSchema = z.object({ + fonts: docxFontFamilySchema.optional(), + sizePt: pointSizeSchema.optional(), + color: colorSchema.optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + lineSpacing: z.number().min(1).max(3).optional() +}); + +const docxBlockStyleSchema = z.object({ + color: colorSchema.optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + leftIndentChars: z.number().min(0).max(20).optional(), + italic: z.boolean().optional() +}); + +const docxTableStyleSchema = z.object({ + fonts: docxFontFamilySchema.optional(), + sizePt: pointSizeSchema.optional(), + color: colorSchema.optional(), + headerColor: colorSchema.optional(), + headerBackgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + cellMarginMm: z.number().min(0).max(20).optional() +}); + +const docxCaptionStyleSchema = z.object({ + fonts: docxFontFamilySchema.optional(), + sizePt: pointSizeSchema.optional(), + color: colorSchema.optional(), + italic: z.boolean().optional(), + alignment: z.enum(["left", "center", "right"]).optional() +}); + +export const docxThemeStyleSchema = z.object({ + preset: docxStylePresetSchema, + body: docxBodyStyleSchema.optional(), + headings: docxHeadingStyleSchema.optional(), + code: docxCodeStyleSchema.optional(), + blockQuote: docxBlockStyleSchema.optional(), + table: docxTableStyleSchema.optional(), + caption: docxCaptionStyleSchema.optional(), + hyperlink: z + .object({ + color: colorSchema.optional(), + underline: z.boolean().optional() + }) + .optional() +}); + +export type DocxThemeStyle = z.infer< + typeof docxThemeStyleSchema +>; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d1c19d7..0ba07d6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,6 @@ export * from "./document.js"; export * from "./docx.js"; +export * from "./docx-style.js"; export * from "./document-profile.js"; export * from "./document-link.js"; export * from "./export-config.js"; diff --git a/packages/core/src/theme.ts b/packages/core/src/theme.ts index 652f63f..21f1201 100644 --- a/packages/core/src/theme.ts +++ b/packages/core/src/theme.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { documentProfileNameSchema } from "./document-profile.js"; +import { docxThemeStyleSchema } from "./docx-style.js"; import { footerSchema, headerSchema, @@ -50,6 +51,7 @@ export const themeManifestSchema = z.object({ footer: footerSchema.optional() }) .optional(), + docxStyle: docxThemeStyleSchema.optional(), bundled: z.boolean() }); diff --git a/packages/core/tests/docx.test.ts b/packages/core/tests/docx.test.ts index 93658b5..d784bf5 100644 --- a/packages/core/tests/docx.test.ts +++ b/packages/core/tests/docx.test.ts @@ -10,7 +10,8 @@ import { docxCapabilitySchema, docxMediaCapturePlanSchema, docxExportErrorResponseSchema, - docxExportRequestSchema + docxExportRequestSchema, + docxThemeStyleSchema } from "../src/index.js"; describe("DOCX 共享协议", () => { @@ -165,3 +166,47 @@ describe("DOCX 共享协议", () => { ).toBe(false); }); }); + +describe("DOCX 主题样式协议", () => { + it("接受预设和受限样式覆盖", () => { + expect( + docxThemeStyleSchema.parse({ + preset: "formal", + body: { + fonts: { + latin: "Times New Roman", + eastAsia: "SimSun" + }, + sizePt: 12, + lineSpacing: 1.5 + }, + headings: { + sizesPt: [22, 18, 16, 14, 12, 10.5], + color: "#1f2937" + }, + table: { + borderColor: "#d1d5db", + cellMarginMm: 1.5 + } + }) + ).toMatchObject({ + preset: "formal", + body: { sizePt: 12, lineSpacing: 1.5 } + }); + }); + + it("拒绝危险字体名称和越界样式值", () => { + expect(() => + docxThemeStyleSchema.parse({ + preset: "general", + body: { + fonts: { + latin: "Arial; DROP", + eastAsia: "宋体" + }, + sizePt: 200 + } + }) + ).toThrow(); + }); +}); diff --git a/packages/docx-engine/README.md b/packages/docx-engine/README.md new file mode 100644 index 0000000..9c3f8c4 --- /dev/null +++ b/packages/docx-engine/README.md @@ -0,0 +1,37 @@ +# @md-to-pdf/docx-engine + +纯 Node.js 的 DOCX 模板和 Pandoc 编排核心。当前阶段负责读取固定 Pandoc +版本的默认 `reference.docx`、验证 ZIP 安全边界,将主题 DOCX 样式声明 +解析为完整样式预设,并生成纸张、页边距、字体、段落、代码、表格、 +页眉、页脚和页码均已映射的动态模板。 + +## 设计边界 + +- 不依赖 Fastify、Electron 或浏览器 UI; +- ZIP 使用纯 JavaScript `fflate`; +- OOXML 使用 `@xmldom/xmldom`,不拼接未经转义的用户 XML; +- 不解析任意主题 CSS,主题通过 `docxStyle` 提供受限结构化覆盖; +- 生成结果会复验全部 XML、包内关系、内容类型、最终节和关键样式; +- Pandoc 进程定位、超时和临时目录属于后续转换服务阶段。 + +## 安全限制 + +- 输入模板压缩包不超过 2 MiB; +- ZIP 条目不超过 256 个; +- 解压后总大小不超过 64 MiB; +- 拒绝加密、ZIP64、未知压缩方法、重复路径和路径穿越; +- 必须包含文档、样式、字体、编号、设置、关系和内容类型等关键部件。 + +## 验证 + +```powershell +npm run test -w @md-to-pdf/docx-engine +npm run typecheck -w @md-to-pdf/docx-engine +npm run build -w @md-to-pdf/docx-engine +npm run verify:docx-reference +``` + +`verify:docx-reference` 要求本机 `PATH` 中存在 Pandoc 3.9.0.2,也可以通过 +`MD_TO_PDF_PANDOC_PATH` 指定可执行文件。脚本从 Pandoc 读取原始默认模板, +分别验证公文 A4 与自定义横向模板,并在系统临时目录中完成转换和清理, +不会保留用户文档或验收产物。 diff --git a/packages/docx-engine/fixtures/pandoc-reference.md b/packages/docx-engine/fixtures/pandoc-reference.md new file mode 100644 index 0000000..6a785de --- /dev/null +++ b/packages/docx-engine/fixtures/pandoc-reference.md @@ -0,0 +1,26 @@ +--- +title: 阶段六动态模板验收 +author: Markdown PDF 导出器研发组 +--- + +# 一级标题 + +这是一段可编辑的中文正文,包含 **加粗**、*斜体*、`inline code` 和 +[内部链接](https://example.invalid/)。 + +## 二级标题 + +> 引用段落用于检查字体、颜色、缩进和段落间距。 + +| 验收项 | 预期结果 | +| --- | --- | +| 纸张 | 继承动态 reference.docx | +| 样式 | 保留标题、正文和表格样式 | + +```typescript +export function editableDocument(value: string) { + return `DOCX 内容仍可编辑:${value}`; +} +``` + +最后一段用于确认 Pandoc 输出仍由标准 Word 段落和文本 Run 组成。 diff --git a/packages/docx-engine/package.json b/packages/docx-engine/package.json new file mode 100644 index 0000000..449e59f --- /dev/null +++ b/packages/docx-engine/package.json @@ -0,0 +1,33 @@ +{ + "name": "@md-to-pdf/docx-engine", + "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": { + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", + "verify:pandoc": "node scripts/verify-pandoc-reference.mjs" + }, + "dependencies": { + "@md-to-pdf/core": "0.1.0", + "@xmldom/xmldom": "0.9.10", + "fflate": "0.8.3" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "vitest": "^4.1.10" + } +} diff --git a/packages/docx-engine/scripts/verify-pandoc-reference.mjs b/packages/docx-engine/scripts/verify-pandoc-reference.mjs new file mode 100644 index 0000000..915fb13 --- /dev/null +++ b/packages/docx-engine/scripts/verify-pandoc-reference.mjs @@ -0,0 +1,299 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + DOCX_PANDOC_VERSION, + defaultExportConfig, + themeManifestSchema +} from "@md-to-pdf/core"; +import { + createDynamicReferenceDocx, + validateDynamicReferenceDocx +} from "@md-to-pdf/docx-engine"; +import { unzipSync } from "fflate"; + +const directory = path.dirname(fileURLToPath(import.meta.url)); +const packageDirectory = path.resolve(directory, ".."); +const repositoryDirectory = path.resolve(packageDirectory, "../.."); +const fixturePath = path.join( + packageDirectory, + "fixtures", + "pandoc-reference.md" +); +const themePath = path.join( + repositoryDirectory, + "themes", + "gov-red-standard", + "theme.json" +); +const pandocExecutable = + process.env.MD_TO_PDF_PANDOC_PATH?.trim() || "pandoc"; +const maximumBuffer = 128 * 1024 * 1024; +const decoder = new TextDecoder(); + +function runPandoc(arguments_, options = {}) { + const result = spawnSync(pandocExecutable, arguments_, { + encoding: options.binary ? null : "utf8", + maxBuffer: maximumBuffer, + ...options + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + const stderr = Buffer.isBuffer(result.stderr) + ? result.stderr.toString("utf8") + : result.stderr; + throw new Error( + stderr?.trim() || `Pandoc 退出码 ${String(result.status)}` + ); + } + return result; +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function countMatches(value, pattern) { + return value.match(pattern)?.length ?? 0; +} + +function inspectResult(content, expected) { + const validation = validateDynamicReferenceDocx(content); + const entries = unzipSync(content); + const documentXml = decoder.decode(entries["word/document.xml"]); + const stylesXml = decoder.decode(entries["word/styles.xml"]); + const settingsXml = decoder.decode(entries["word/settings.xml"]); + const relationshipsXml = decoder.decode( + entries["word/_rels/document.xml.rels"] + ); + const bodyIsEditable = + documentXml.includes("可编辑的中文正文") && + documentXml.includes(" /^word\/footer\d+\.xml$/u.test(name)) + .some(([, value]) => + decoder.decode(value).includes(" PAGE \\* MERGEFORMAT ") + ), + headerRelationship: + expected.headerCount === 0 || + relationshipsXml.includes("relationships/header"), + footerRelationship: + expected.footerCount === 0 || + relationshipsXml.includes("relationships/footer"), + evenOddSetting: + expected.usesEvenAndOddPages === + settingsXml.includes(" + createVariant( + temporaryDirectory, + baseline, + theme, + markdown, + variant + ) + ); + console.log( + JSON.stringify( + { + pandocVersion: detectedVersion, + baselineBytes: baseline.byteLength, + variants: results + }, + null, + 2 + ) + ); +} finally { + fs.rmSync(temporaryDirectory, { + recursive: true, + force: true + }); +} diff --git a/packages/docx-engine/src/header-footer-transform.ts b/packages/docx-engine/src/header-footer-transform.ts new file mode 100644 index 0000000..9ddebab --- /dev/null +++ b/packages/docx-engine/src/header-footer-transform.ts @@ -0,0 +1,519 @@ +import { + lengthToMillimeters, + type FooterConfig, + type HeaderConfig +} from "@md-to-pdf/core"; +import { + CONTENT_TYPES_NAMESPACE, + OFFICE_RELATIONSHIP_NAMESPACE, + PACKAGE_RELATIONSHIP_NAMESPACE, + WORD_NAMESPACE, + appendElement, + colorValue, + parseXmlPart, + pointsToHalfPoints, + serializeXmlPart, + type XmlElement +} from "./ooxml.js"; +import { + resolveHeaderTemplate, + type DynamicReferenceDocxOptions +} from "./reference-options.js"; + +const HEADER_RELATIONSHIP_TYPE = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"; +const FOOTER_RELATIONSHIP_TYPE = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer"; +const HEADER_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"; +const FOOTER_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"; +const XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace"; + +type HeaderFooterReferenceType = "default" | "even" | "first"; + +export interface HeaderFooterReference { + kind: "header" | "footer"; + type: HeaderFooterReferenceType; + relationshipId: string; +} + +export interface HeaderFooterTransformResult { + entries: Map; + references: HeaderFooterReference[]; + usesEvenAndOddPages: boolean; + usesDifferentFirstPage: boolean; +} + +interface RunStyle { + font: string; + sizePt: number; + color: string; +} + +function firstFontName(fontFamily: string, fallback: string) { + const first = fontFamily.split(",")[0]?.trim() ?? ""; + return first.replace(/^["']|["']$/gu, "") || fallback; +} + +function createWordPart(rootName: "hdr" | "ftr") { + return parseXmlPart( + new TextEncoder().encode( + `` + ), + `word/${rootName}.xml` + ); +} + +function appendRunProperties(run: XmlElement, style: RunStyle) { + const rPr = appendElement(run, WORD_NAMESPACE, "w:rPr"); + appendElement(rPr, WORD_NAMESPACE, "w:rFonts", { + "w:ascii": style.font, + "w:hAnsi": style.font, + "w:eastAsia": style.font, + "w:cs": style.font + }); + appendElement(rPr, WORD_NAMESPACE, "w:color", { + "w:val": colorValue(style.color) + }); + const size = pointsToHalfPoints(style.sizePt); + appendElement(rPr, WORD_NAMESPACE, "w:sz", { + "w:val": size + }); + appendElement(rPr, WORD_NAMESPACE, "w:szCs", { + "w:val": size + }); +} + +function appendText( + paragraph: XmlElement, + value: string, + style: RunStyle +) { + if (!value) { + return; + } + const run = appendElement(paragraph, WORD_NAMESPACE, "w:r"); + appendRunProperties(run, style); + const text = appendElement(run, WORD_NAMESPACE, "w:t"); + text.setAttributeNS(XML_NAMESPACE, "xml:space", "preserve"); + text.appendChild(text.ownerDocument!.createTextNode(value)); +} + +function appendField( + paragraph: XmlElement, + instruction: "PAGE" | "NUMPAGES", + style: RunStyle +) { + const begin = appendElement(paragraph, WORD_NAMESPACE, "w:r"); + appendRunProperties(begin, style); + appendElement(begin, WORD_NAMESPACE, "w:fldChar", { + "w:fldCharType": "begin", + "w:dirty": "true" + }); + + const command = appendElement(paragraph, WORD_NAMESPACE, "w:r"); + appendRunProperties(command, style); + const instructionText = appendElement( + command, + WORD_NAMESPACE, + "w:instrText" + ); + instructionText.setAttributeNS( + XML_NAMESPACE, + "xml:space", + "preserve" + ); + instructionText.appendChild( + instructionText.ownerDocument!.createTextNode( + ` ${instruction} \\* MERGEFORMAT ` + ) + ); + + const separate = appendElement(paragraph, WORD_NAMESPACE, "w:r"); + appendRunProperties(separate, style); + appendElement(separate, WORD_NAMESPACE, "w:fldChar", { + "w:fldCharType": "separate" + }); + appendText(paragraph, "1", style); + + const end = appendElement(paragraph, WORD_NAMESPACE, "w:r"); + appendRunProperties(end, style); + appendElement(end, WORD_NAMESPACE, "w:fldChar", { + "w:fldCharType": "end" + }); +} + +function appendFooterContent( + paragraph: XmlElement, + footer: FooterConfig, + style: RunStyle +) { + const template = + footer.format === "page" + ? "${page}" + : footer.format === "page-total" + ? "${page} / ${pages}" + : footer.format === "chinese-page-total" + ? "第 ${page} 页 / 共 ${pages} 页" + : footer.format === "dash-page" + ? "- ${page} -" + : footer.format === "official-page" + ? "— ${page} —" + : footer.template || "${page} / ${pages}"; + const tokens = template.split(/(\$\{page\}|\$\{pages\})/gu); + for (const token of tokens) { + if (token === "${page}") { + appendField(paragraph, "PAGE", style); + } else if (token === "${pages}") { + appendField(paragraph, "NUMPAGES", style); + } else { + appendText(paragraph, token, style); + } + } +} + +function appendThreeColumnTable( + root: XmlElement, + options: { + position: "header" | "footer"; + divider: boolean; + dividerColor: string; + alignments: readonly ["left", "center", "right"]; + render: ( + paragraph: XmlElement, + alignment: "left" | "center" | "right" + ) => void; + } +) { + const table = appendElement(root, WORD_NAMESPACE, "w:tbl"); + const tableProperties = appendElement( + table, + WORD_NAMESPACE, + "w:tblPr" + ); + appendElement(tableProperties, WORD_NAMESPACE, "w:tblW", { + "w:w": "5000", + "w:type": "pct" + }); + appendElement(tableProperties, WORD_NAMESPACE, "w:tblLayout", { + "w:type": "fixed" + }); + if (options.divider) { + const borders = appendElement( + tableProperties, + WORD_NAMESPACE, + "w:tblBorders" + ); + appendElement( + borders, + WORD_NAMESPACE, + options.position === "header" ? "w:bottom" : "w:top", + { + "w:val": "single", + "w:sz": "4", + "w:space": "2", + "w:color": colorValue(options.dividerColor) + } + ); + } + const grid = appendElement(table, WORD_NAMESPACE, "w:tblGrid"); + for (let index = 0; index < 3; index += 1) { + appendElement(grid, WORD_NAMESPACE, "w:gridCol", { + "w:w": "2400" + }); + } + const row = appendElement(table, WORD_NAMESPACE, "w:tr"); + for (const alignment of options.alignments) { + const cell = appendElement(row, WORD_NAMESPACE, "w:tc"); + const cellProperties = appendElement( + cell, + WORD_NAMESPACE, + "w:tcPr" + ); + appendElement(cellProperties, WORD_NAMESPACE, "w:tcW", { + "w:w": "1667", + "w:type": "pct" + }); + const paragraph = appendElement(cell, WORD_NAMESPACE, "w:p"); + const paragraphProperties = appendElement( + paragraph, + WORD_NAMESPACE, + "w:pPr" + ); + appendElement(paragraphProperties, WORD_NAMESPACE, "w:jc", { + "w:val": alignment + }); + options.render(paragraph, alignment); + } +} + +function createHeaderPart( + header: HeaderConfig, + options: DynamicReferenceDocxOptions, + fallbackFont: string +) { + const document = createWordPart("hdr"); + const style: RunStyle = { + font: firstFontName(header.fontFamily, fallbackFont), + sizePt: (lengthToMillimeters(header.fontSize) * 72) / 25.4, + color: header.color + }; + appendThreeColumnTable(document.documentElement!, { + position: "header", + divider: header.showDivider, + dividerColor: header.color, + alignments: ["left", "center", "right"], + render: (paragraph, alignment) => { + const slot = header[alignment]; + if (slot.enabled) { + appendText( + paragraph, + resolveHeaderTemplate(slot.content, options), + style + ); + } + } + }); + return serializeXmlPart(document); +} + +function createFooterPart( + footer: FooterConfig, + alignment: "left" | "center" | "right", + fallbackFont: string, + empty = false +) { + const document = createWordPart("ftr"); + const style: RunStyle = { + font: firstFontName(footer.fontFamily, fallbackFont), + sizePt: (lengthToMillimeters(footer.fontSize) * 72) / 25.4, + color: footer.color + }; + appendThreeColumnTable(document.documentElement!, { + position: "footer", + divider: footer.showDivider, + dividerColor: footer.color, + alignments: ["left", "center", "right"], + render: (paragraph, cellAlignment) => { + if (!empty && cellAlignment === alignment) { + appendFooterContent(paragraph, footer, style); + } + } + }); + return serializeXmlPart(document); +} + +function removeExistingHeaderFooterParts( + entries: Map +) { + for (const name of [...entries.keys()]) { + if (/^word\/(?:header|footer)\d+\.xml$/u.test(name)) { + entries.delete(name); + } + } +} + +function nextRelationshipId(relationships: XmlElement) { + const maximum = Array.from( + relationships.getElementsByTagNameNS( + PACKAGE_RELATIONSHIP_NAMESPACE, + "Relationship" + ) + ).reduce((current, relationship) => { + const match = /^rId(\d+)$/u.exec( + relationship.getAttribute("Id") ?? "" + ); + return Math.max(current, Number(match?.[1] ?? 0)); + }, 0); + let next = maximum + 1; + return () => `rId${next++}`; +} + +function updateRelationships( + entries: Map, + parts: Array<{ + kind: "header" | "footer"; + type: HeaderFooterReferenceType; + partName: string; + }> +) { + const document = parseXmlPart( + entries.get("word/_rels/document.xml.rels")!, + "word/_rels/document.xml.rels" + ); + const root = document.documentElement!; + for (const relationship of Array.from( + root.getElementsByTagNameNS( + PACKAGE_RELATIONSHIP_NAMESPACE, + "Relationship" + ) + )) { + const type = relationship.getAttribute("Type"); + if ( + type === HEADER_RELATIONSHIP_TYPE || + type === FOOTER_RELATIONSHIP_TYPE + ) { + root.removeChild(relationship); + } + } + const allocateId = nextRelationshipId(root); + const references: HeaderFooterReference[] = []; + for (const part of parts) { + const relationshipId = allocateId(); + appendElement( + root, + PACKAGE_RELATIONSHIP_NAMESPACE, + "Relationship", + { + Id: relationshipId, + Type: + part.kind === "header" + ? HEADER_RELATIONSHIP_TYPE + : FOOTER_RELATIONSHIP_TYPE, + Target: part.partName.replace(/^word\//u, "") + } + ); + references.push({ + kind: part.kind, + type: part.type, + relationshipId + }); + } + entries.set( + "word/_rels/document.xml.rels", + serializeXmlPart(document) + ); + return references; +} + +function updateContentTypes( + entries: Map, + parts: Array<{ + kind: "header" | "footer"; + partName: string; + }> +) { + const document = parseXmlPart( + entries.get("[Content_Types].xml")!, + "[Content_Types].xml" + ); + const root = document.documentElement!; + for (const override of Array.from( + root.getElementsByTagNameNS(CONTENT_TYPES_NAMESPACE, "Override") + )) { + if ( + /^\/word\/(?:header|footer)\d+\.xml$/u.test( + override.getAttribute("PartName") ?? "" + ) + ) { + root.removeChild(override); + } + } + for (const part of parts) { + appendElement(root, CONTENT_TYPES_NAMESPACE, "Override", { + PartName: `/${part.partName}`, + ContentType: + part.kind === "header" + ? HEADER_CONTENT_TYPE + : FOOTER_CONTENT_TYPE + }); + } + entries.set("[Content_Types].xml", serializeXmlPart(document)); +} + +export function createHeaderFooterParts( + sourceEntries: ReadonlyMap, + options: DynamicReferenceDocxOptions, + header: HeaderConfig, + footer: FooterConfig, + fallbackFont: string +): HeaderFooterTransformResult { + const entries = new Map(sourceEntries); + removeExistingHeaderFooterParts(entries); + const parts: Array<{ + kind: "header" | "footer"; + type: HeaderFooterReferenceType; + partName: string; + }> = []; + let headerIndex = 1; + let footerIndex = 1; + const usesEvenAndOddPages = + footer.enabled && footer.alignment === "outer"; + const usesDifferentFirstPage = + footer.enabled && !footer.showOnFirstPage; + + if (header.enabled) { + const headerContent = createHeaderPart( + header, + options, + fallbackFont + ); + for (const type of [ + "default", + ...(usesEvenAndOddPages ? ["even"] : []), + ...(usesDifferentFirstPage ? ["first"] : []) + ] as HeaderFooterReferenceType[]) { + const partName = `word/header${headerIndex++}.xml`; + entries.set(partName, headerContent); + parts.push({ kind: "header", type, partName }); + } + } + + if (footer.enabled) { + const defaultAlignment = + footer.alignment === "outer" ? "right" : footer.alignment; + const defaultPartName = `word/footer${footerIndex++}.xml`; + entries.set( + defaultPartName, + createFooterPart( + footer, + defaultAlignment, + fallbackFont + ) + ); + parts.push({ + kind: "footer", + type: "default", + partName: defaultPartName + }); + if (usesEvenAndOddPages) { + const evenPartName = `word/footer${footerIndex++}.xml`; + entries.set( + evenPartName, + createFooterPart(footer, "left", fallbackFont) + ); + parts.push({ + kind: "footer", + type: "even", + partName: evenPartName + }); + } + if (usesDifferentFirstPage) { + const firstPartName = `word/footer${footerIndex++}.xml`; + entries.set( + firstPartName, + createFooterPart( + footer, + defaultAlignment, + fallbackFont, + true + ) + ); + parts.push({ + kind: "footer", + type: "first", + partName: firstPartName + }); + } + } + + const references = updateRelationships(entries, parts); + updateContentTypes(entries, parts); + return { + entries, + references, + usesEvenAndOddPages, + usesDifferentFirstPage + }; +} diff --git a/packages/docx-engine/src/index.ts b/packages/docx-engine/src/index.ts new file mode 100644 index 0000000..ea6023d --- /dev/null +++ b/packages/docx-engine/src/index.ts @@ -0,0 +1,9 @@ +export * from "./header-footer-transform.js"; +export * from "./ooxml.js"; +export * from "./reference-builder.js"; +export * from "./reference-options.js"; +export * from "./reference-package.js"; +export * from "./section-transform.js"; +export * from "./style-presets.js"; +export * from "./styles-transform.js"; +export * from "./validator.js"; diff --git a/packages/docx-engine/src/ooxml.ts b/packages/docx-engine/src/ooxml.ts new file mode 100644 index 0000000..34398df --- /dev/null +++ b/packages/docx-engine/src/ooxml.ts @@ -0,0 +1,149 @@ +import { DOMParser, XMLSerializer } from "@xmldom/xmldom"; +import type { + Document as XmlDocument, + Element as XmlElement +} from "@xmldom/xmldom"; + +export type { XmlDocument, XmlElement }; + +export const WORD_NAMESPACE = + "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; +export const OFFICE_RELATIONSHIP_NAMESPACE = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; +export const PACKAGE_RELATIONSHIP_NAMESPACE = + "http://schemas.openxmlformats.org/package/2006/relationships"; +export const CONTENT_TYPES_NAMESPACE = + "http://schemas.openxmlformats.org/package/2006/content-types"; +export const DRAWING_NAMESPACE = + "http://schemas.openxmlformats.org/drawingml/2006/main"; + +const decoder = new TextDecoder("utf-8", { fatal: true }); +const encoder = new TextEncoder(); + +export function parseXmlPart( + content: Uint8Array, + partName: string +) { + try { + return new DOMParser({ + onError: (level, message) => { + if (level !== "warning") { + throw new Error(message); + } + } + }).parseFromString(decoder.decode(content), "application/xml"); + } catch (error) { + throw new Error( + `${partName} 不是有效 OOXML:${ + error instanceof Error ? error.message : "XML 解析失败" + }` + ); + } +} + +export function serializeXmlPart(document: XmlDocument) { + return encoder.encode( + `\n${new XMLSerializer().serializeToString( + document.documentElement! + )}` + ); +} + +export function directChildren( + parent: XmlElement, + namespace: string, + localName: string +) { + const children: XmlElement[] = []; + for (const node of Array.from(parent.childNodes)) { + const element = node as XmlElement; + if ( + node.nodeType === 1 && + element.namespaceURI === namespace && + element.localName === localName + ) { + children.push(element); + } + } + return children; +} + +export function firstDirectChild( + parent: XmlElement, + namespace: string, + localName: string +) { + return directChildren(parent, namespace, localName)[0]; +} + +export function removeDirectChildren( + parent: XmlElement, + namespace: string, + ...localNames: string[] +) { + for (const node of Array.from(parent.childNodes)) { + const element = node as XmlElement; + if ( + node.nodeType === 1 && + element.namespaceURI === namespace && + localNames.includes(element.localName ?? "") + ) { + parent.removeChild(node); + } + } +} + +export function appendElement( + parent: XmlElement, + namespace: string, + qualifiedName: string, + attributes: Record = {} +) { + const element = parent.ownerDocument!.createElementNS( + namespace, + qualifiedName + ); + for (const [name, value] of Object.entries(attributes)) { + const prefix = name.includes(":") ? name.split(":")[0] : ""; + const attributeNamespace = + prefix === "w" + ? WORD_NAMESPACE + : prefix === "r" + ? OFFICE_RELATIONSHIP_NAMESPACE + : null; + element.setAttributeNS(attributeNamespace, name, value); + } + parent.appendChild(element); + return element; +} + +export function ensureDirectElement( + parent: XmlElement, + namespace: string, + qualifiedName: string +) { + const localName = qualifiedName.split(":").at(-1)!; + return ( + firstDirectChild(parent, namespace, localName) ?? + appendElement(parent, namespace, qualifiedName) + ); +} + +export function colorValue(value: string) { + if (!/^#[0-9a-f]{6}$/iu.test(value)) { + throw new Error(`DOCX 颜色值无效:${value}`); + } + return value.replace(/^#/u, "").toUpperCase(); +} + +export function pointsToHalfPoints(value: number) { + return String(Math.round(value * 2)); +} + +export function pointsToTwips(value: number) { + return String(Math.round(value * 20)); +} + +export function millimetersToTwips(value: number) { + return Math.round((value * 1440) / 25.4); +} diff --git a/packages/docx-engine/src/reference-builder.ts b/packages/docx-engine/src/reference-builder.ts new file mode 100644 index 0000000..b9361b7 --- /dev/null +++ b/packages/docx-engine/src/reference-builder.ts @@ -0,0 +1,155 @@ +import { createHash } from "node:crypto"; +import { + DOCX_PANDOC_VERSION, + lengthToMillimeters +} from "@md-to-pdf/core"; +import { + createHeaderFooterParts +} from "./header-footer-transform.js"; +import { + readReferenceDocxPackage, + writeReferenceDocxPackage +} from "./reference-package.js"; +import { + resolveReferencePageOptions, + type DynamicReferenceDocxOptions +} from "./reference-options.js"; +import { + transformDocumentSectionXml, + transformSettingsXml +} from "./section-transform.js"; +import { + transformFontTableXml, + transformStylesXml, + transformThemeFontsXml +} from "./styles-transform.js"; +import { resolveDocxThemeStyle } from "./style-presets.js"; +import { validateDynamicReferenceDocx } from "./validator.js"; + +export interface DynamicReferenceDocxResult { + content: Uint8Array; + baselineFingerprint: string; + templateFingerprint: string; + cacheKey: string; + stylePreset: string; + partCount: number; +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map( + ([key, item]) => + `${JSON.stringify(key)}:${stableJson(item)}` + ) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +export function createDynamicReferenceCacheKey( + baselineFingerprint: string, + options: DynamicReferenceDocxOptions +) { + return createHash("sha256") + .update( + stableJson({ + pandocVersion: DOCX_PANDOC_VERSION, + baselineFingerprint, + theme: { + id: options.theme.id, + version: options.theme.version, + category: options.theme.category, + docxStyle: options.theme.docxStyle, + pageDefaults: options.theme.pageDefaults + }, + paper: options.exportConfig.paper, + pageDecorationsMode: + options.exportConfig.pageDecorationsMode, + header: options.exportConfig.header, + footer: options.exportConfig.footer, + fileName: options.fileName, + metadata: options.metadata + }) + ) + .digest("hex"); +} + +export function createDynamicReferenceDocx( + baseline: Uint8Array, + options: DynamicReferenceDocxOptions +): DynamicReferenceDocxResult { + const reference = readReferenceDocxPackage(baseline); + const style = resolveDocxThemeStyle(options.theme); + const page = resolveReferencePageOptions(options); + const withDecorations = createHeaderFooterParts( + reference.entries, + options, + page.header, + page.footer, + style.body.fonts.eastAsia + ); + const entries = withDecorations.entries; + entries.set( + "word/styles.xml", + transformStylesXml(entries.get("word/styles.xml")!, style) + ); + entries.set( + "word/fontTable.xml", + transformFontTableXml(entries.get("word/fontTable.xml")!, style) + ); + entries.set( + "word/theme/theme1.xml", + transformThemeFontsXml( + entries.get("word/theme/theme1.xml")!, + style + ) + ); + entries.set( + "word/document.xml", + transformDocumentSectionXml( + entries.get("word/document.xml")!, + { + dimensions: page.dimensions, + margins: page.margins, + orientation: page.orientation, + headerHeightMm: page.header.enabled + ? lengthToMillimeters(page.header.height) + : 0, + footerHeightMm: page.footer.enabled + ? lengthToMillimeters(page.footer.height) + : 0, + pageNumberStart: page.footer.startFrom, + references: withDecorations.references, + usesDifferentFirstPage: + withDecorations.usesDifferentFirstPage + } + ) + ); + entries.set( + "word/settings.xml", + transformSettingsXml( + entries.get("word/settings.xml")!, + withDecorations.usesEvenAndOddPages + ) + ); + const content = writeReferenceDocxPackage(entries); + const validation = validateDynamicReferenceDocx(content); + return { + content, + baselineFingerprint: reference.fingerprint, + templateFingerprint: createHash("sha256") + .update(content) + .digest("hex"), + cacheKey: createDynamicReferenceCacheKey( + reference.fingerprint, + options + ), + stylePreset: style.preset, + partCount: validation.partCount + }; +} diff --git a/packages/docx-engine/src/reference-options.ts b/packages/docx-engine/src/reference-options.ts new file mode 100644 index 0000000..7454f93 --- /dev/null +++ b/packages/docx-engine/src/reference-options.ts @@ -0,0 +1,64 @@ +import { + defaultExportConfig, + getPaperDimensionsMm, + lengthToMillimeters, + resolvePageMargins, + type ExportConfig, + type MarkdownDocumentMetadata, + type ThemeManifest +} from "@md-to-pdf/core"; + +export interface DynamicReferenceDocxOptions { + exportConfig: ExportConfig; + theme: ThemeManifest; + fileName: string; + metadata: Pick; +} + +export function resolveReferencePageOptions( + options: DynamicReferenceDocxOptions +) { + const { exportConfig, theme } = options; + const themeDefaults = theme.pageDefaults; + const dimensions = getPaperDimensionsMm( + exportConfig.paper.format, + exportConfig.paper.orientation + ); + const margins = resolvePageMargins( + exportConfig.paper, + themeDefaults?.margins + ); + const useThemeDecorations = + exportConfig.pageDecorationsMode === "theme"; + return { + dimensions, + margins: { + top: lengthToMillimeters(margins.top), + right: lengthToMillimeters(margins.right), + bottom: lengthToMillimeters(margins.bottom), + left: lengthToMillimeters(margins.left) + }, + orientation: exportConfig.paper.orientation, + header: useThemeDecorations + ? themeDefaults?.header ?? defaultExportConfig.header + : exportConfig.header, + footer: useThemeDecorations + ? themeDefaults?.footer ?? defaultExportConfig.footer + : exportConfig.footer + }; +} + +export function resolveHeaderTemplate( + value: string, + options: DynamicReferenceDocxOptions +) { + const replacements: Record = { + title: options.metadata.title, + author: options.metadata.author, + filename: options.fileName + }; + return value.replace( + /\$\{(title|author|filename)\}/gu, + (_match, name: string) => replacements[name] ?? "" + ); +} diff --git a/packages/docx-engine/src/reference-package.ts b/packages/docx-engine/src/reference-package.ts new file mode 100644 index 0000000..27c1530 --- /dev/null +++ b/packages/docx-engine/src/reference-package.ts @@ -0,0 +1,196 @@ +import { createHash } from "node:crypto"; +import { unzipSync, zipSync, type Zippable } from "fflate"; + +export const MAXIMUM_REFERENCE_DOCX_BYTES = 2 * 1024 * 1024; +export const MAXIMUM_REFERENCE_ENTRY_COUNT = 256; +export const MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES = + 64 * 1024 * 1024; + +export const requiredReferenceDocxParts = [ + "[Content_Types].xml", + "_rels/.rels", + "word/document.xml", + "word/fontTable.xml", + "word/numbering.xml", + "word/settings.xml", + "word/styles.xml", + "word/_rels/document.xml.rels", + "word/theme/theme1.xml" +] as const; + +export interface ReferenceDocxPackage { + fingerprint: string; + entries: ReadonlyMap; +} + +interface ZipDirectoryEntry { + name: string; + uncompressedSize: number; +} + +function findEndOfCentralDirectory(content: Uint8Array) { + const view = new DataView( + content.buffer, + content.byteOffset, + content.byteLength + ); + const minimumOffset = Math.max(0, content.byteLength - 65_557); + for ( + let offset = content.byteLength - 22; + offset >= minimumOffset; + offset -= 1 + ) { + if (view.getUint32(offset, true) === 0x06054b50) { + return offset; + } + } + throw new Error("reference.docx 缺少 ZIP 中央目录"); +} + +function validateEntryName(name: string) { + if ( + !name || + name.includes("\\") || + name.startsWith("/") || + name.includes(":") || + name.split("/").some((segment) => segment === "..") + ) { + throw new Error(`reference.docx 包含不安全路径:${name}`); + } +} + +function inspectZipDirectory(content: Uint8Array) { + if ( + content.byteLength < 22 || + content.byteLength > MAXIMUM_REFERENCE_DOCX_BYTES + ) { + throw new Error("reference.docx 压缩包大小超过限制或内容不完整"); + } + + const view = new DataView( + content.buffer, + content.byteOffset, + content.byteLength + ); + const endOffset = findEndOfCentralDirectory(content); + const diskNumber = view.getUint16(endOffset + 4, true); + const directoryDisk = view.getUint16(endOffset + 6, true); + const entryCount = view.getUint16(endOffset + 10, true); + const directorySize = view.getUint32(endOffset + 12, true); + const directoryOffset = view.getUint32(endOffset + 16, true); + if ( + diskNumber !== 0 || + directoryDisk !== 0 || + entryCount > MAXIMUM_REFERENCE_ENTRY_COUNT || + entryCount === 0 || + directoryOffset + directorySize > endOffset + ) { + throw new Error("reference.docx ZIP 中央目录不受支持"); + } + + const decoder = new TextDecoder("utf-8", { fatal: true }); + const entries: ZipDirectoryEntry[] = []; + const names = new Set(); + let totalUncompressedBytes = 0; + let offset = directoryOffset; + for (let index = 0; index < entryCount; index += 1) { + if ( + offset + 46 > endOffset || + view.getUint32(offset, true) !== 0x02014b50 + ) { + throw new Error("reference.docx ZIP 中央目录损坏"); + } + const flags = view.getUint16(offset + 8, true); + const compressionMethod = view.getUint16(offset + 10, true); + const compressedSize = view.getUint32(offset + 20, true); + const uncompressedSize = view.getUint32(offset + 24, true); + const nameLength = view.getUint16(offset + 28, true); + const extraLength = view.getUint16(offset + 30, true); + const commentLength = view.getUint16(offset + 32, true); + const nextOffset = + offset + 46 + nameLength + extraLength + commentLength; + if ( + nextOffset > endOffset || + (flags & 0x1) !== 0 || + ![0, 8].includes(compressionMethod) || + compressedSize === 0xffffffff || + uncompressedSize === 0xffffffff + ) { + throw new Error("reference.docx 包含不支持的 ZIP 条目"); + } + const name = decoder.decode( + content.subarray(offset + 46, offset + 46 + nameLength) + ); + validateEntryName(name); + if (names.has(name)) { + throw new Error(`reference.docx 包含重复条目:${name}`); + } + names.add(name); + totalUncompressedBytes += uncompressedSize; + if ( + totalUncompressedBytes > + MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES + ) { + throw new Error("reference.docx 解压后总大小超过限制"); + } + entries.push({ name, uncompressedSize }); + offset = nextOffset; + } + if (offset !== directoryOffset + directorySize) { + throw new Error("reference.docx ZIP 中央目录长度不一致"); + } + return entries; +} + +export function readReferenceDocxPackage( + content: Uint8Array +): ReferenceDocxPackage { + const directory = inspectZipDirectory(content); + const unzipped = unzipSync(content); + const entries = new Map(); + for (const descriptor of directory) { + if (descriptor.name.endsWith("/")) { + continue; + } + const value = unzipped[descriptor.name]; + if ( + !value || + value.byteLength !== descriptor.uncompressedSize + ) { + throw new Error( + `reference.docx 条目解压尺寸不一致:${descriptor.name}` + ); + } + entries.set(descriptor.name, value); + } + for (const required of requiredReferenceDocxParts) { + if (!entries.has(required)) { + throw new Error(`reference.docx 缺少必要部件:${required}`); + } + } + return { + fingerprint: createHash("sha256") + .update(content) + .digest("hex"), + entries + }; +} + +export function writeReferenceDocxPackage( + entries: ReadonlyMap +) { + const zippable: Zippable = {}; + const mtime = new Date("1980-01-01T00:00:00.000Z"); + for (const [name, content] of [...entries].sort(([left], [right]) => + left.localeCompare(right) + )) { + validateEntryName(name); + zippable[name] = [content, { mtime }]; + } + const result = zipSync(zippable, { + level: 6, + mtime + }); + readReferenceDocxPackage(result); + return result; +} diff --git a/packages/docx-engine/src/section-transform.ts b/packages/docx-engine/src/section-transform.ts new file mode 100644 index 0000000..d121fe3 --- /dev/null +++ b/packages/docx-engine/src/section-transform.ts @@ -0,0 +1,149 @@ +import { + WORD_NAMESPACE, + appendElement, + firstDirectChild, + millimetersToTwips, + parseXmlPart, + removeDirectChildren, + serializeXmlPart, + type XmlElement +} from "./ooxml.js"; +import type { HeaderFooterReference } from "./header-footer-transform.js"; + +export interface ReferenceSectionOptions { + dimensions: { + width: number; + height: number; + }; + margins: { + top: number; + right: number; + bottom: number; + left: number; + }; + orientation: "portrait" | "landscape"; + headerHeightMm: number; + footerHeightMm: number; + pageNumberStart: number; + references: HeaderFooterReference[]; + usesDifferentFirstPage: boolean; +} + +function insertReference( + section: XmlElement, + reference: HeaderFooterReference +) { + const element = section.ownerDocument!.createElementNS( + WORD_NAMESPACE, + `w:${reference.kind}Reference` + ); + element.setAttributeNS( + WORD_NAMESPACE, + "w:type", + reference.type + ); + element.setAttributeNS( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "r:id", + reference.relationshipId + ); + section.insertBefore(element, section.firstChild); +} + +export function transformDocumentSectionXml( + content: Uint8Array, + options: ReferenceSectionOptions +) { + const document = parseXmlPart(content, "word/document.xml"); + const body = document.getElementsByTagNameNS( + WORD_NAMESPACE, + "body" + )[0]; + const section = body + ? firstDirectChild(body, WORD_NAMESPACE, "sectPr") + : undefined; + if (!section) { + throw new Error("word/document.xml 缺少 w:body/w:sectPr"); + } + removeDirectChildren( + section, + WORD_NAMESPACE, + "headerReference", + "footerReference", + "pgSz", + "pgMar", + "pgNumType", + "titlePg" + ); + for (const reference of [...options.references].reverse()) { + insertReference(section, reference); + } + + const pageSize = appendElement( + section, + WORD_NAMESPACE, + "w:pgSz", + { + "w:w": String(millimetersToTwips(options.dimensions.width)), + "w:h": String(millimetersToTwips(options.dimensions.height)) + } + ); + if (options.orientation === "landscape") { + pageSize.setAttributeNS( + WORD_NAMESPACE, + "w:orient", + "landscape" + ); + } + appendElement(section, WORD_NAMESPACE, "w:pgMar", { + "w:top": String(millimetersToTwips(options.margins.top)), + "w:right": String(millimetersToTwips(options.margins.right)), + "w:bottom": String(millimetersToTwips(options.margins.bottom)), + "w:left": String(millimetersToTwips(options.margins.left)), + "w:header": String( + millimetersToTwips( + Math.max( + 0, + options.margins.top - options.headerHeightMm + ) + ) + ), + "w:footer": String( + millimetersToTwips( + Math.max( + 0, + options.margins.bottom - options.footerHeightMm + ) + ) + ), + "w:gutter": "0" + }); + appendElement(section, WORD_NAMESPACE, "w:pgNumType", { + "w:start": String(options.pageNumberStart) + }); + if (options.usesDifferentFirstPage) { + appendElement(section, WORD_NAMESPACE, "w:titlePg"); + } + return serializeXmlPart(document); +} + +export function transformSettingsXml( + content: Uint8Array, + usesEvenAndOddPages: boolean +) { + const document = parseXmlPart(content, "word/settings.xml"); + const settings = document.documentElement!; + removeDirectChildren( + settings, + WORD_NAMESPACE, + "evenAndOddHeaders" + ); + if (usesEvenAndOddPages) { + appendElement( + settings, + WORD_NAMESPACE, + "w:evenAndOddHeaders" + ); + } + return serializeXmlPart(document); +} diff --git a/packages/docx-engine/src/style-presets.ts b/packages/docx-engine/src/style-presets.ts new file mode 100644 index 0000000..d9cc368 --- /dev/null +++ b/packages/docx-engine/src/style-presets.ts @@ -0,0 +1,258 @@ +import { + docxThemeStyleSchema, + type DocxFontFamily, + type DocxStylePreset, + type DocxThemeStyle, + type ThemeManifest +} from "@md-to-pdf/core"; + +export interface ResolvedDocxThemeStyle { + preset: DocxStylePreset; + body: { + fonts: DocxFontFamily; + sizePt: number; + color: string; + lineSpacing: number; + firstLineIndentChars: number; + spacingBeforePt: number; + spacingAfterPt: number; + }; + headings: { + fonts: DocxFontFamily; + sizesPt: [number, number, number, number, number, number]; + color: string; + bold: boolean; + spacingBeforePt: number; + spacingAfterPt: number; + }; + code: { + fonts: DocxFontFamily; + sizePt: number; + color: string; + backgroundColor: string; + borderColor: string; + lineSpacing: number; + }; + blockQuote: { + color: string; + backgroundColor: string; + borderColor: string; + leftIndentChars: number; + italic: boolean; + }; + table: { + fonts: DocxFontFamily; + sizePt: number; + color: string; + headerColor: string; + headerBackgroundColor: string; + borderColor: string; + cellMarginMm: number; + }; + caption: { + fonts: DocxFontFamily; + sizePt: number; + color: string; + italic: boolean; + alignment: "left" | "center" | "right"; + }; + hyperlink: { + color: string; + underline: boolean; + }; +} + +const songFonts: DocxFontFamily = { + latin: "Times New Roman", + eastAsia: "SimSun" +}; +const sansFonts: DocxFontFamily = { + latin: "Arial", + eastAsia: "SimHei" +}; +const technicalFonts: DocxFontFamily = { + latin: "Arial", + eastAsia: "Microsoft YaHei" +}; +const codeFonts: DocxFontFamily = { + latin: "Consolas", + eastAsia: "Microsoft YaHei" +}; + +const baseStyle: Omit = { + body: { + fonts: songFonts, + sizePt: 10.5, + color: "#1f2328", + lineSpacing: 1.5, + firstLineIndentChars: 0, + spacingBeforePt: 0, + spacingAfterPt: 6 + }, + headings: { + fonts: sansFonts, + sizesPt: [22, 18, 16, 14, 12, 10.5], + color: "#1f2328", + bold: true, + spacingBeforePt: 12, + spacingAfterPt: 6 + }, + code: { + fonts: codeFonts, + sizePt: 9, + color: "#24292f", + backgroundColor: "#f6f8fa", + borderColor: "#d0d7de", + lineSpacing: 1.25 + }, + blockQuote: { + color: "#57606a", + backgroundColor: "#ffffff", + borderColor: "#d0d7de", + leftIndentChars: 2, + italic: false + }, + table: { + fonts: songFonts, + sizePt: 10.5, + color: "#1f2328", + headerColor: "#1f2328", + headerBackgroundColor: "#f6f8fa", + borderColor: "#d0d7de", + cellMarginMm: 1.5 + }, + caption: { + fonts: songFonts, + sizePt: 9, + color: "#57606a", + italic: false, + alignment: "center" + }, + hyperlink: { + color: "#0969da", + underline: true + } +}; + +const presetOverrides: Record< + DocxStylePreset, + Partial> +> = { + general: {}, + technical: { + body: { + ...baseStyle.body, + fonts: technicalFonts + }, + table: { + ...baseStyle.table, + fonts: technicalFonts + } + }, + official: { + body: { + ...baseStyle.body, + sizePt: 16, + firstLineIndentChars: 2, + spacingAfterPt: 0 + }, + headings: { + ...baseStyle.headings, + color: "#000000", + sizesPt: [22, 18, 16, 16, 14, 12] + } + }, + formal: { + body: { + ...baseStyle.body, + sizePt: 13.5, + firstLineIndentChars: 2, + spacingAfterPt: 0 + }, + headings: { + ...baseStyle.headings, + color: "#1f2937" + } + }, + tender: { + body: { + ...baseStyle.body, + sizePt: 12, + lineSpacing: 1.5 + }, + headings: { + ...baseStyle.headings, + color: "#1f4e79" + }, + table: { + ...baseStyle.table, + headerBackgroundColor: "#dbeafe", + borderColor: "#94a3b8" + } + } +}; + +function mergeSection( + base: T, + override: + | { [Key in keyof T]?: T[Key] | undefined } + | undefined +): T { + if (!override) { + return { ...base }; + } + const result = { ...base }; + for (const key of Object.keys(override) as Array) { + const value = override[key]; + if (value !== undefined) { + result[key] = value; + } + } + return result; +} + +export function inferDocxStylePreset( + manifest: Pick +): DocxStylePreset { + if (manifest.category === "red-letter") { + return "official"; + } + if (manifest.category === "formal") { + return "formal"; + } + if (manifest.category === "tender") { + return "tender"; + } + return "general"; +} + +export function resolveDocxThemeStyle( + manifest: ThemeManifest +): ResolvedDocxThemeStyle { + const declared: DocxThemeStyle = docxThemeStyleSchema.parse( + manifest.docxStyle ?? { + preset: inferDocxStylePreset(manifest) + } + ); + const preset = presetOverrides[declared.preset]; + const base = { + ...baseStyle, + ...preset + }; + return { + preset: declared.preset, + body: mergeSection(base.body, declared.body), + headings: mergeSection(base.headings, declared.headings), + code: mergeSection(base.code, declared.code), + blockQuote: mergeSection( + base.blockQuote, + declared.blockQuote + ), + table: mergeSection(base.table, declared.table), + caption: mergeSection(base.caption, declared.caption), + hyperlink: mergeSection( + base.hyperlink, + declared.hyperlink + ) + }; +} diff --git a/packages/docx-engine/src/styles-transform.ts b/packages/docx-engine/src/styles-transform.ts new file mode 100644 index 0000000..bb3f4f2 --- /dev/null +++ b/packages/docx-engine/src/styles-transform.ts @@ -0,0 +1,684 @@ +import type { DocxFontFamily } from "@md-to-pdf/core"; +import type { ResolvedDocxThemeStyle } from "./style-presets.js"; +import { + DRAWING_NAMESPACE, + WORD_NAMESPACE, + appendElement, + colorValue, + directChildren, + ensureDirectElement, + firstDirectChild, + millimetersToTwips, + parseXmlPart, + pointsToHalfPoints, + pointsToTwips, + removeDirectChildren, + serializeXmlPart, + type XmlElement +} from "./ooxml.js"; + +function setFonts(parent: XmlElement, fonts: DocxFontFamily) { + removeDirectChildren(parent, WORD_NAMESPACE, "rFonts"); + appendElement(parent, WORD_NAMESPACE, "w:rFonts", { + "w:ascii": fonts.latin, + "w:hAnsi": fonts.latin, + "w:eastAsia": fonts.eastAsia, + "w:cs": fonts.complexScript ?? fonts.latin + }); +} + +function setRunStyle( + parent: XmlElement, + options: { + fonts?: DocxFontFamily; + sizePt?: number; + color?: string; + bold?: boolean; + italic?: boolean; + underline?: boolean; + backgroundColor?: string; + } +) { + removeDirectChildren( + parent, + WORD_NAMESPACE, + "rFonts", + "sz", + "szCs", + "color", + "b", + "bCs", + "i", + "iCs", + "u", + "shd" + ); + if (options.fonts) { + setFonts(parent, options.fonts); + } + if (options.bold) { + appendElement(parent, WORD_NAMESPACE, "w:b"); + appendElement(parent, WORD_NAMESPACE, "w:bCs"); + } + if (options.italic) { + appendElement(parent, WORD_NAMESPACE, "w:i"); + appendElement(parent, WORD_NAMESPACE, "w:iCs"); + } + if (options.color) { + appendElement(parent, WORD_NAMESPACE, "w:color", { + "w:val": colorValue(options.color) + }); + } + if (options.underline) { + appendElement(parent, WORD_NAMESPACE, "w:u", { + "w:val": "single" + }); + } + if (options.backgroundColor) { + appendElement(parent, WORD_NAMESPACE, "w:shd", { + "w:val": "clear", + "w:color": "auto", + "w:fill": colorValue(options.backgroundColor) + }); + } + if (options.sizePt !== undefined) { + const size = pointsToHalfPoints(options.sizePt); + appendElement(parent, WORD_NAMESPACE, "w:sz", { + "w:val": size + }); + appendElement(parent, WORD_NAMESPACE, "w:szCs", { + "w:val": size + }); + } +} + +function setParagraphSpacing( + parent: XmlElement, + options: { + beforePt: number; + afterPt: number; + lineSpacing: number; + firstLineIndentChars?: number; + } +) { + removeDirectChildren(parent, WORD_NAMESPACE, "spacing", "ind"); + appendElement(parent, WORD_NAMESPACE, "w:spacing", { + "w:before": pointsToTwips(options.beforePt), + "w:after": pointsToTwips(options.afterPt), + "w:line": String(Math.round(options.lineSpacing * 240)), + "w:lineRule": "auto" + }); + if (options.firstLineIndentChars !== undefined) { + appendElement(parent, WORD_NAMESPACE, "w:ind", { + "w:firstLineChars": String( + Math.round(options.firstLineIndentChars * 100) + ) + }); + } +} + +function findStyle(styles: XmlElement, styleId: string) { + return Array.from( + styles.getElementsByTagNameNS(WORD_NAMESPACE, "style") + ).find( + (element) => + element.getAttributeNS(WORD_NAMESPACE, "styleId") === styleId + ); +} + +function ensureStyle( + styles: XmlElement, + styleId: string, + type: "paragraph" | "character" | "table", + basedOn?: string +) { + const existing = findStyle(styles, styleId); + if (existing) { + return existing; + } + const style = appendElement(styles, WORD_NAMESPACE, "w:style", { + "w:type": type, + "w:customStyle": "1", + "w:styleId": styleId + }); + appendElement(style, WORD_NAMESPACE, "w:name", { + "w:val": styleId.replace(/([a-z])([A-Z])/gu, "$1 $2") + }); + if (basedOn) { + appendElement(style, WORD_NAMESPACE, "w:basedOn", { + "w:val": basedOn + }); + } + return style; +} + +function applyBodyStyle( + styles: XmlElement, + style: ResolvedDocxThemeStyle +) { + const defaults = firstDirectChild( + styles, + WORD_NAMESPACE, + "docDefaults" + ); + if (!defaults) { + throw new Error("word/styles.xml 缺少 w:docDefaults"); + } + const defaultRun = ensureDirectElement( + ensureDirectElement( + defaults, + WORD_NAMESPACE, + "w:rPrDefault" + ), + WORD_NAMESPACE, + "w:rPr" + ); + setRunStyle(defaultRun, { + fonts: style.body.fonts, + sizePt: style.body.sizePt, + color: style.body.color + }); + const defaultParagraph = ensureDirectElement( + ensureDirectElement( + defaults, + WORD_NAMESPACE, + "w:pPrDefault" + ), + WORD_NAMESPACE, + "w:pPr" + ); + setParagraphSpacing(defaultParagraph, { + beforePt: style.body.spacingBeforePt, + afterPt: style.body.spacingAfterPt, + lineSpacing: style.body.lineSpacing, + firstLineIndentChars: style.body.firstLineIndentChars + }); + + for (const styleId of [ + "Normal", + "BodyText", + "FirstParagraph", + "FootnoteText", + "Definition", + "Figure" + ]) { + const target = ensureStyle( + styles, + styleId, + "paragraph", + styleId === "Normal" ? undefined : "Normal" + ); + const pPr = ensureDirectElement( + target, + WORD_NAMESPACE, + "w:pPr" + ); + setParagraphSpacing(pPr, { + beforePt: style.body.spacingBeforePt, + afterPt: style.body.spacingAfterPt, + lineSpacing: style.body.lineSpacing, + firstLineIndentChars: style.body.firstLineIndentChars + }); + const rPr = ensureDirectElement( + target, + WORD_NAMESPACE, + "w:rPr" + ); + setRunStyle(rPr, { + fonts: style.body.fonts, + sizePt: style.body.sizePt, + color: style.body.color + }); + } + + const compact = ensureStyle( + styles, + "Compact", + "paragraph", + "BodyText" + ); + setParagraphSpacing( + ensureDirectElement(compact, WORD_NAMESPACE, "w:pPr"), + { + beforePt: 0, + afterPt: 0, + lineSpacing: style.body.lineSpacing, + firstLineIndentChars: 0 + } + ); +} + +function applyHeadings( + styles: XmlElement, + style: ResolvedDocxThemeStyle +) { + for (let level = 1; level <= 6; level += 1) { + const sizePt = style.headings.sizesPt[level - 1]!; + for (const styleId of [ + `Heading${level}`, + `Heading${level}Char` + ]) { + const target = ensureStyle( + styles, + styleId, + styleId.endsWith("Char") ? "character" : "paragraph", + styleId.endsWith("Char") + ? "DefaultParagraphFont" + : "Normal" + ); + if (!styleId.endsWith("Char")) { + const pPr = ensureDirectElement( + target, + WORD_NAMESPACE, + "w:pPr" + ); + setParagraphSpacing(pPr, { + beforePt: style.headings.spacingBeforePt, + afterPt: style.headings.spacingAfterPt, + lineSpacing: 1.25 + }); + if (!firstDirectChild(pPr, WORD_NAMESPACE, "keepNext")) { + appendElement(pPr, WORD_NAMESPACE, "w:keepNext"); + } + if (!firstDirectChild(pPr, WORD_NAMESPACE, "keepLines")) { + appendElement(pPr, WORD_NAMESPACE, "w:keepLines"); + } + } + setRunStyle( + ensureDirectElement(target, WORD_NAMESPACE, "w:rPr"), + { + fonts: style.headings.fonts, + sizePt, + color: style.headings.color, + bold: style.headings.bold + } + ); + } + } + + for (const [styleId, sizePt] of [ + ["Title", style.headings.sizesPt[0] + 4], + ["TitleChar", style.headings.sizesPt[0] + 4], + ["Subtitle", style.headings.sizesPt[1]], + ["SubtitleChar", style.headings.sizesPt[1]] + ] as const) { + const target = ensureStyle( + styles, + styleId, + styleId.endsWith("Char") ? "character" : "paragraph", + styleId.endsWith("Char") + ? "DefaultParagraphFont" + : "Normal" + ); + if (!styleId.endsWith("Char")) { + const pPr = ensureDirectElement( + target, + WORD_NAMESPACE, + "w:pPr" + ); + setParagraphSpacing(pPr, { + beforePt: 0, + afterPt: style.headings.spacingAfterPt, + lineSpacing: 1.25 + }); + removeDirectChildren(pPr, WORD_NAMESPACE, "jc"); + appendElement(pPr, WORD_NAMESPACE, "w:jc", { + "w:val": "center" + }); + } + setRunStyle( + ensureDirectElement(target, WORD_NAMESPACE, "w:rPr"), + { + fonts: style.headings.fonts, + sizePt, + color: style.headings.color, + bold: styleId.startsWith("Title") + } + ); + } +} + +function applyCodeAndQuote( + styles: XmlElement, + style: ResolvedDocxThemeStyle +) { + const inlineCode = ensureStyle( + styles, + "VerbatimChar", + "character", + "BodyTextChar" + ); + setRunStyle( + ensureDirectElement(inlineCode, WORD_NAMESPACE, "w:rPr"), + { + fonts: style.code.fonts, + sizePt: style.code.sizePt, + color: style.code.color, + backgroundColor: style.code.backgroundColor + } + ); + + const sourceCode = ensureStyle( + styles, + "SourceCode", + "paragraph", + "Normal" + ); + const codePPr = ensureDirectElement( + sourceCode, + WORD_NAMESPACE, + "w:pPr" + ); + setParagraphSpacing(codePPr, { + beforePt: 3, + afterPt: 3, + lineSpacing: style.code.lineSpacing, + firstLineIndentChars: 0 + }); + removeDirectChildren(codePPr, WORD_NAMESPACE, "shd", "pBdr"); + appendElement(codePPr, WORD_NAMESPACE, "w:shd", { + "w:val": "clear", + "w:color": "auto", + "w:fill": colorValue(style.code.backgroundColor) + }); + const codeBorders = appendElement( + codePPr, + WORD_NAMESPACE, + "w:pBdr" + ); + for (const side of ["top", "left", "bottom", "right"]) { + appendElement(codeBorders, WORD_NAMESPACE, `w:${side}`, { + "w:val": "single", + "w:sz": "4", + "w:space": "2", + "w:color": colorValue(style.code.borderColor) + }); + } + setRunStyle( + ensureDirectElement(sourceCode, WORD_NAMESPACE, "w:rPr"), + { + fonts: style.code.fonts, + sizePt: style.code.sizePt, + color: style.code.color, + backgroundColor: style.code.backgroundColor + } + ); + + const quote = ensureStyle( + styles, + "BlockText", + "paragraph", + "BodyText" + ); + const quotePPr = ensureDirectElement( + quote, + WORD_NAMESPACE, + "w:pPr" + ); + setParagraphSpacing(quotePPr, { + beforePt: 6, + afterPt: 6, + lineSpacing: style.body.lineSpacing, + firstLineIndentChars: 0 + }); + removeDirectChildren(quotePPr, WORD_NAMESPACE, "ind", "shd", "pBdr"); + appendElement(quotePPr, WORD_NAMESPACE, "w:ind", { + "w:leftChars": String(style.blockQuote.leftIndentChars * 100) + }); + appendElement(quotePPr, WORD_NAMESPACE, "w:shd", { + "w:val": "clear", + "w:color": "auto", + "w:fill": colorValue(style.blockQuote.backgroundColor) + }); + const quoteBorders = appendElement( + quotePPr, + WORD_NAMESPACE, + "w:pBdr" + ); + appendElement(quoteBorders, WORD_NAMESPACE, "w:left", { + "w:val": "single", + "w:sz": "18", + "w:space": "6", + "w:color": colorValue(style.blockQuote.borderColor) + }); + setRunStyle( + ensureDirectElement(quote, WORD_NAMESPACE, "w:rPr"), + { + fonts: style.body.fonts, + sizePt: style.body.sizePt, + color: style.blockQuote.color, + italic: style.blockQuote.italic + } + ); +} + +function applyTableAndCaption( + styles: XmlElement, + style: ResolvedDocxThemeStyle +) { + const table = ensureStyle(styles, "Table", "table", "TableNormal"); + const tblPr = ensureDirectElement( + table, + WORD_NAMESPACE, + "w:tblPr" + ); + removeDirectChildren(tblPr, WORD_NAMESPACE, "tblCellMar", "tblBorders"); + const margins = appendElement( + tblPr, + WORD_NAMESPACE, + "w:tblCellMar" + ); + const cellMargin = String( + millimetersToTwips(style.table.cellMarginMm) + ); + for (const side of ["top", "left", "bottom", "right"]) { + appendElement(margins, WORD_NAMESPACE, `w:${side}`, { + "w:w": cellMargin, + "w:type": "dxa" + }); + } + const borders = appendElement( + tblPr, + WORD_NAMESPACE, + "w:tblBorders" + ); + for (const side of [ + "top", + "left", + "bottom", + "right", + "insideH", + "insideV" + ]) { + appendElement(borders, WORD_NAMESPACE, `w:${side}`, { + "w:val": "single", + "w:sz": "4", + "w:space": "0", + "w:color": colorValue(style.table.borderColor) + }); + } + setRunStyle( + ensureDirectElement(table, WORD_NAMESPACE, "w:rPr"), + { + fonts: style.table.fonts, + sizePt: style.table.sizePt, + color: style.table.color + } + ); + let firstRow = directChildren( + table, + WORD_NAMESPACE, + "tblStylePr" + ).find( + (element) => + element.getAttributeNS(WORD_NAMESPACE, "type") === "firstRow" + ); + if (!firstRow) { + firstRow = appendElement( + table, + WORD_NAMESPACE, + "w:tblStylePr", + { "w:type": "firstRow" } + ); + } + setRunStyle( + ensureDirectElement(firstRow, WORD_NAMESPACE, "w:rPr"), + { + fonts: style.table.fonts, + sizePt: style.table.sizePt, + color: style.table.headerColor, + bold: true + } + ); + const cellProperties = ensureDirectElement( + firstRow, + WORD_NAMESPACE, + "w:tcPr" + ); + removeDirectChildren(cellProperties, WORD_NAMESPACE, "shd"); + appendElement(cellProperties, WORD_NAMESPACE, "w:shd", { + "w:val": "clear", + "w:color": "auto", + "w:fill": colorValue(style.table.headerBackgroundColor) + }); + + for (const styleId of ["Caption", "TableCaption", "ImageCaption"]) { + const caption = ensureStyle( + styles, + styleId, + "paragraph", + styleId === "Caption" ? "Normal" : "Caption" + ); + const pPr = ensureDirectElement( + caption, + WORD_NAMESPACE, + "w:pPr" + ); + setParagraphSpacing(pPr, { + beforePt: 3, + afterPt: 6, + lineSpacing: 1.25 + }); + removeDirectChildren(pPr, WORD_NAMESPACE, "jc"); + appendElement(pPr, WORD_NAMESPACE, "w:jc", { + "w:val": style.caption.alignment + }); + setRunStyle( + ensureDirectElement(caption, WORD_NAMESPACE, "w:rPr"), + { + fonts: style.caption.fonts, + sizePt: style.caption.sizePt, + color: style.caption.color, + italic: style.caption.italic + } + ); + } + + const hyperlink = ensureStyle( + styles, + "Hyperlink", + "character", + "BodyTextChar" + ); + setRunStyle( + ensureDirectElement(hyperlink, WORD_NAMESPACE, "w:rPr"), + { + color: style.hyperlink.color, + underline: style.hyperlink.underline + } + ); +} + +export function transformStylesXml( + content: Uint8Array, + style: ResolvedDocxThemeStyle +) { + const document = parseXmlPart(content, "word/styles.xml"); + const styles = document.documentElement!; + if ( + styles.namespaceURI !== WORD_NAMESPACE || + styles.localName !== "styles" + ) { + throw new Error("word/styles.xml 根元素无效"); + } + applyBodyStyle(styles, style); + applyHeadings(styles, style); + applyCodeAndQuote(styles, style); + applyTableAndCaption(styles, style); + return serializeXmlPart(document); +} + +export function transformFontTableXml( + content: Uint8Array, + style: ResolvedDocxThemeStyle +) { + const document = parseXmlPart(content, "word/fontTable.xml"); + const root = document.documentElement!; + const requiredFonts = new Set(); + for (const fonts of [ + style.body.fonts, + style.headings.fonts, + style.code.fonts, + style.table.fonts, + style.caption.fonts + ]) { + requiredFonts.add(fonts.latin); + requiredFonts.add(fonts.eastAsia); + if (fonts.complexScript) { + requiredFonts.add(fonts.complexScript); + } + } + const existing = new Set( + Array.from( + root.getElementsByTagNameNS(WORD_NAMESPACE, "font") + ).map((element) => element.getAttributeNS(WORD_NAMESPACE, "name")) + ); + for (const name of requiredFonts) { + if (!existing.has(name)) { + const font = appendElement(root, WORD_NAMESPACE, "w:font", { + "w:name": name + }); + appendElement(font, WORD_NAMESPACE, "w:family", { + "w:val": name === style.code.fonts.latin ? "modern" : "auto" + }); + } + } + return serializeXmlPart(document); +} + +export function transformThemeFontsXml( + content: Uint8Array, + style: ResolvedDocxThemeStyle +) { + const document = parseXmlPart(content, "word/theme/theme1.xml"); + for (const schemeName of ["majorFont", "minorFont"]) { + const scheme = document.getElementsByTagNameNS( + DRAWING_NAMESPACE, + schemeName + )[0]; + if (!scheme) { + continue; + } + const latin = scheme.getElementsByTagNameNS( + DRAWING_NAMESPACE, + "latin" + )[0]; + const eastAsia = scheme.getElementsByTagNameNS( + DRAWING_NAMESPACE, + "ea" + )[0]; + latin?.setAttribute( + "typeface", + schemeName === "majorFont" + ? style.headings.fonts.latin + : style.body.fonts.latin + ); + eastAsia?.setAttribute( + "typeface", + schemeName === "majorFont" + ? style.headings.fonts.eastAsia + : style.body.fonts.eastAsia + ); + } + return serializeXmlPart(document); +} diff --git a/packages/docx-engine/src/validator.ts b/packages/docx-engine/src/validator.ts new file mode 100644 index 0000000..04682e7 --- /dev/null +++ b/packages/docx-engine/src/validator.ts @@ -0,0 +1,347 @@ +import path from "node:path"; +import { + CONTENT_TYPES_NAMESPACE, + OFFICE_RELATIONSHIP_NAMESPACE, + PACKAGE_RELATIONSHIP_NAMESPACE, + WORD_NAMESPACE, + directChildren, + parseXmlPart, + type XmlElement +} from "./ooxml.js"; +import { readReferenceDocxPackage } from "./reference-package.js"; + +const HEADER_RELATIONSHIP_TYPE = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"; +const FOOTER_RELATIONSHIP_TYPE = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer"; +const HEADER_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"; +const FOOTER_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"; + +const requiredStyleIds = [ + "Normal", + "Heading1", + "SourceCode", + "Table", + "Caption" +] as const; + +interface Relationship { + id: string; + type: string; + targetPart: string | undefined; +} + +export interface DynamicReferenceValidation { + partCount: number; + xmlPartCount: number; + relationshipCount: number; + headerCount: number; + footerCount: number; +} + +function relationshipSourcePart(relationshipsPart: string) { + if (relationshipsPart === "_rels/.rels") { + return ""; + } + const match = /^(.*)\/_rels\/([^/]+)\.rels$/u.exec( + relationshipsPart + ); + if (!match) { + throw new Error(`OOXML 关系部件路径无效:${relationshipsPart}`); + } + return path.posix.join(match[1]!, match[2]!); +} + +function resolveRelationshipTarget( + relationshipsPart: string, + target: string +) { + if ( + !target || + target.includes("\\") || + target.includes("\0") || + /^[a-z][a-z0-9+.-]*:/iu.test(target) + ) { + throw new Error( + `${relationshipsPart} 包含不安全的内部关系目标:${target}` + ); + } + const sourcePart = relationshipSourcePart(relationshipsPart); + const targetPart = target.startsWith("/") + ? path.posix.normalize(target.slice(1)) + : path.posix.normalize( + path.posix.join(path.posix.dirname(sourcePart), target) + ); + if ( + !targetPart || + targetPart === "." || + targetPart === ".." || + targetPart.startsWith("../") || + targetPart.startsWith("/") + ) { + throw new Error( + `${relationshipsPart} 的内部关系目标越出 DOCX 包:${target}` + ); + } + return targetPart; +} + +function parseRelationships( + partName: string, + content: Uint8Array, + entries: ReadonlyMap +) { + const document = parseXmlPart(content, partName); + const root = document.documentElement; + if ( + !root || + root.namespaceURI !== PACKAGE_RELATIONSHIP_NAMESPACE || + root.localName !== "Relationships" + ) { + throw new Error(`${partName} 的关系根元素无效`); + } + + const relationships = new Map(); + for (const element of directChildren( + root, + PACKAGE_RELATIONSHIP_NAMESPACE, + "Relationship" + )) { + const id = element.getAttribute("Id"); + const type = element.getAttribute("Type"); + const target = element.getAttribute("Target"); + if (!id || !type || !target) { + throw new Error(`${partName} 包含不完整的关系声明`); + } + if (relationships.has(id)) { + throw new Error(`${partName} 包含重复关系 ID:${id}`); + } + const external = + (element.getAttribute("TargetMode") ?? "").toLowerCase() === + "external"; + const targetPart = external + ? undefined + : resolveRelationshipTarget(partName, target); + if (targetPart && !entries.has(targetPart)) { + throw new Error( + `${partName} 的关系 ${id} 指向缺失部件:${targetPart}` + ); + } + relationships.set(id, { id, type, targetPart }); + } + return relationships; +} + +function validateReferencedParts( + section: XmlElement, + localName: "headerReference" | "footerReference", + expectedType: string, + expectedRootName: "hdr" | "ftr", + relationships: ReadonlyMap, + entries: ReadonlyMap +) { + const references = directChildren( + section, + WORD_NAMESPACE, + localName + ); + const pageTypes = new Set(); + for (const reference of references) { + const pageType = + reference.getAttributeNS(WORD_NAMESPACE, "type") ?? ""; + const relationshipId = + reference.getAttributeNS( + OFFICE_RELATIONSHIP_NAMESPACE, + "id" + ) ?? ""; + if (!["default", "even", "first"].includes(pageType)) { + throw new Error(`${localName} 包含无效页面类型:${pageType}`); + } + if (pageTypes.has(pageType)) { + throw new Error(`${localName} 重复声明页面类型:${pageType}`); + } + pageTypes.add(pageType); + const relationship = relationships.get(relationshipId); + if ( + !relationship || + relationship.type !== expectedType || + !relationship.targetPart + ) { + throw new Error( + `${localName} 引用了无效关系:${relationshipId || "(空)"}` + ); + } + const part = entries.get(relationship.targetPart)!; + const document = parseXmlPart(part, relationship.targetPart); + const root = document.documentElement; + if ( + !root || + root.namespaceURI !== WORD_NAMESPACE || + root.localName !== expectedRootName + ) { + throw new Error( + `${relationship.targetPart} 的页眉页脚根元素无效` + ); + } + } + return references.length; +} + +function validateContentTypes( + entries: ReadonlyMap, + relationships: ReadonlyMap +) { + const partName = "[Content_Types].xml"; + const document = parseXmlPart(entries.get(partName)!, partName); + const root = document.documentElement; + if ( + !root || + root.namespaceURI !== CONTENT_TYPES_NAMESPACE || + root.localName !== "Types" + ) { + throw new Error("[Content_Types].xml 的根元素无效"); + } + const overrides = new Map(); + for (const element of directChildren( + root, + CONTENT_TYPES_NAMESPACE, + "Override" + )) { + const part = element.getAttribute("PartName"); + const contentType = element.getAttribute("ContentType"); + if (!part || !contentType || overrides.has(part)) { + throw new Error("[Content_Types].xml 包含无效或重复的 Override"); + } + overrides.set(part, contentType); + } + for (const relationship of relationships.values()) { + if ( + relationship.type !== HEADER_RELATIONSHIP_TYPE && + relationship.type !== FOOTER_RELATIONSHIP_TYPE + ) { + continue; + } + const expected = + relationship.type === HEADER_RELATIONSHIP_TYPE + ? HEADER_CONTENT_TYPE + : FOOTER_CONTENT_TYPE; + if ( + !relationship.targetPart || + overrides.get(`/${relationship.targetPart}`) !== expected + ) { + throw new Error( + `${relationship.targetPart ?? "(空)"} 缺少正确的 Content Type` + ); + } + } +} + +function validateStyles(entries: ReadonlyMap) { + const partName = "word/styles.xml"; + const document = parseXmlPart(entries.get(partName)!, partName); + const root = document.documentElement; + if ( + !root || + root.namespaceURI !== WORD_NAMESPACE || + root.localName !== "styles" + ) { + throw new Error("word/styles.xml 的根元素无效"); + } + const styleIds = new Set( + Array.from( + root.getElementsByTagNameNS(WORD_NAMESPACE, "style") + ).map((element) => + element.getAttributeNS(WORD_NAMESPACE, "styleId") + ) + ); + for (const styleId of requiredStyleIds) { + if (!styleIds.has(styleId)) { + throw new Error(`word/styles.xml 缺少关键样式:${styleId}`); + } + } +} + +export function validateDynamicReferenceDocx( + content: Uint8Array +): DynamicReferenceValidation { + const reference = readReferenceDocxPackage(content); + let xmlPartCount = 0; + let relationshipCount = 0; + const relationshipParts = new Map< + string, + ReadonlyMap + >(); + + for (const [partName, part] of reference.entries) { + if (partName.endsWith(".xml") || partName.endsWith(".rels")) { + parseXmlPart(part, partName); + xmlPartCount += 1; + } + if (partName.endsWith(".rels")) { + const relationships = parseRelationships( + partName, + part, + reference.entries + ); + relationshipParts.set(partName, relationships); + relationshipCount += relationships.size; + } + } + + const documentPart = "word/document.xml"; + const document = parseXmlPart( + reference.entries.get(documentPart)!, + documentPart + ); + const root = document.documentElement; + if ( + !root || + root.namespaceURI !== WORD_NAMESPACE || + root.localName !== "document" + ) { + throw new Error("word/document.xml 的根元素无效"); + } + const sections = Array.from( + root.getElementsByTagNameNS(WORD_NAMESPACE, "sectPr") + ); + const finalSection = sections.at(-1); + if ( + !finalSection || + directChildren(finalSection, WORD_NAMESPACE, "pgSz").length !== 1 || + directChildren(finalSection, WORD_NAMESPACE, "pgMar").length !== 1 + ) { + throw new Error("word/document.xml 缺少完整的最终节页面设置"); + } + + const documentRelationships = relationshipParts.get( + "word/_rels/document.xml.rels" + )!; + const headerCount = validateReferencedParts( + finalSection, + "headerReference", + HEADER_RELATIONSHIP_TYPE, + "hdr", + documentRelationships, + reference.entries + ); + const footerCount = validateReferencedParts( + finalSection, + "footerReference", + FOOTER_RELATIONSHIP_TYPE, + "ftr", + documentRelationships, + reference.entries + ); + validateContentTypes(reference.entries, documentRelationships); + validateStyles(reference.entries); + + return { + partCount: reference.entries.size, + xmlPartCount, + relationshipCount, + headerCount, + footerCount + }; +} diff --git a/packages/docx-engine/tests/reference-builder.test.ts b/packages/docx-engine/tests/reference-builder.test.ts new file mode 100644 index 0000000..329439a --- /dev/null +++ b/packages/docx-engine/tests/reference-builder.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from "vitest"; +import { unzipSync, zipSync } from "fflate"; +import { + defaultExportConfig, + type ExportConfig, + type ThemeManifest +} from "@md-to-pdf/core"; +import { + createDynamicReferenceDocx, + validateDynamicReferenceDocx, + writeReferenceDocxPackage +} from "../src/index.js"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const wordNamespace = + "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + +function xml(value: string) { + return encoder.encode(value); +} + +function createBaselineReference() { + const styles = [ + "Normal", + "BodyText", + "FirstParagraph", + "Compact", + "Title", + "TitleChar", + "Subtitle", + "SubtitleChar", + "Heading1", + "Heading1Char", + "Heading2", + "Heading2Char", + "Heading3", + "Heading3Char", + "Heading4", + "Heading4Char", + "Heading5", + "Heading5Char", + "Heading6", + "Heading6Char", + "BlockText", + "FootnoteText", + "DefaultParagraphFont", + "Table", + "Definition", + "Caption", + "TableCaption", + "ImageCaption", + "Figure", + "BodyTextChar", + "VerbatimChar", + "Hyperlink" + ] + .map((styleId) => { + const type = + styleId === "Table" + ? "table" + : styleId.endsWith("Char") || + styleId === "DefaultParagraphFont" || + styleId === "VerbatimChar" || + styleId === "Hyperlink" + ? "character" + : "paragraph"; + return ``; + }) + .join(""); + + return zipSync( + { + "[Content_Types].xml": xml( + '' + ), + "_rels/.rels": xml( + '' + ), + "word/document.xml": xml( + `` + ), + "word/fontTable.xml": xml( + `` + ), + "word/numbering.xml": xml( + `` + ), + "word/settings.xml": xml( + `` + ), + "word/styles.xml": xml( + `${styles}` + ), + "word/_rels/document.xml.rels": xml( + '' + ), + "word/theme/theme1.xml": xml( + '' + ) + }, + { mtime: new Date("2020-01-01T00:00:00.000Z") } + ); +} + +function theme( + overrides: Partial = {} +): ThemeManifest { + return { + manifestVersion: 1, + id: "test-theme", + name: "测试主题", + version: "1.0.0", + description: "测试", + author: "测试", + license: "内部许可", + entry: "theme.css", + domPreset: "generic", + defaultFontSize: "16px", + supportedFeatures: [], + category: "general", + compatibleProfiles: [], + docxStyle: { preset: "technical" }, + bundled: true, + ...overrides + }; +} + +function createOptions(exportConfig: ExportConfig) { + return { + exportConfig, + theme: theme(), + fileName: "报告 & 计划.md", + metadata: { + title: "年度 <报告>", + author: "测试人" + } + }; +} + +describe("动态 reference.docx", () => { + it("生成稳定的纸张、正文样式和页码模板", () => { + const baseline = createBaselineReference(); + const first = createDynamicReferenceDocx( + baseline, + createOptions(defaultExportConfig) + ); + const second = createDynamicReferenceDocx( + baseline, + createOptions(defaultExportConfig) + ); + const changedMetadata = createDynamicReferenceDocx(baseline, { + ...createOptions(defaultExportConfig), + metadata: { + title: "另一份报告", + author: "测试人" + } + }); + const entries = unzipSync(first.content); + const documentXml = decoder.decode(entries["word/document.xml"]); + const stylesXml = decoder.decode(entries["word/styles.xml"]); + const footerXml = decoder.decode(entries["word/footer1.xml"]); + + expect(first.templateFingerprint).toBe( + second.templateFingerprint + ); + expect(first.cacheKey).toBe(second.cacheKey); + expect(first.cacheKey).not.toBe(changedMetadata.cacheKey); + expect(documentXml).toContain('w:w="11906"'); + expect(documentXml).toContain('w:h="16838"'); + expect(documentXml).toContain('w:top="907"'); + expect(stylesXml).toContain('w:styleId="SourceCode"'); + expect(stylesXml).toContain('w:eastAsia="Microsoft YaHei"'); + expect(footerXml).toContain(" PAGE \\* MERGEFORMAT "); + expect(footerXml).toContain(" NUMPAGES \\* MERGEFORMAT "); + expect(validateDynamicReferenceDocx(first.content)).toMatchObject({ + partCount: first.partCount, + headerCount: 0, + footerCount: 1 + }); + }); + + it("生成横向、自定义页边距和奇偶首页页眉页脚", () => { + const exportConfig: ExportConfig = { + ...defaultExportConfig, + pageDecorationsMode: "custom", + paper: { + ...defaultExportConfig.paper, + orientation: "landscape", + marginMode: "custom", + margins: { + top: "20mm", + right: "25mm", + bottom: "22mm", + left: "30mm" + } + }, + header: { + ...defaultExportConfig.header, + enabled: true, + showDivider: true, + left: { + enabled: true, + content: "${title} / ${filename}" + } + }, + footer: { + ...defaultExportConfig.footer, + alignment: "outer", + format: "official-page", + startFrom: 5, + showOnFirstPage: false + } + }; + const result = createDynamicReferenceDocx( + createBaselineReference(), + createOptions(exportConfig) + ); + const entries = unzipSync(result.content); + const documentXml = decoder.decode(entries["word/document.xml"]); + const settingsXml = decoder.decode(entries["word/settings.xml"]); + const headerXml = decoder.decode(entries["word/header1.xml"]); + const relationships = decoder.decode( + entries["word/_rels/document.xml.rels"] + ); + + expect(documentXml).toContain('w:orient="landscape"'); + expect(documentXml).toContain('w:w="16838"'); + expect(documentXml).toContain('w:left="1701"'); + expect(documentXml).toContain('w:start="5"'); + expect(documentXml).toContain(" { + const result = createDynamicReferenceDocx( + createBaselineReference(), + createOptions(defaultExportConfig) + ); + const entries = new Map( + Object.entries(unzipSync(result.content)) + ); + const relationships = decoder + .decode(entries.get("word/_rels/document.xml.rels")!) + .replace( + "relationships/footer", + "relationships/header" + ); + entries.set( + "word/_rels/document.xml.rels", + encoder.encode(relationships) + ); + const damaged = writeReferenceDocxPackage(entries); + + expect(() => validateDynamicReferenceDocx(damaged)).toThrow( + /footerReference 引用了无效关系/u + ); + }); +}); diff --git a/packages/docx-engine/tests/reference-package.test.ts b/packages/docx-engine/tests/reference-package.test.ts new file mode 100644 index 0000000..1414eb0 --- /dev/null +++ b/packages/docx-engine/tests/reference-package.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { zipSync } from "fflate"; +import { + readReferenceDocxPackage, + requiredReferenceDocxParts +} from "../src/index.js"; + +function createReferencePackage( + overrides: Record = {} +) { + const entries = Object.fromEntries( + requiredReferenceDocxParts.map((name) => [ + name, + new TextEncoder().encode(``) + ]) + ); + return zipSync({ ...entries, ...overrides }); +} + +describe("reference.docx 包", () => { + it("预检中央目录并返回稳定指纹", () => { + const content = createReferencePackage(); + const first = readReferenceDocxPackage(content); + const second = readReferenceDocxPackage(content); + + expect(first.entries.size).toBe(requiredReferenceDocxParts.length); + expect(first.entries.has("word/styles.xml")).toBe(true); + expect(first.fingerprint).toMatch(/^[0-9a-f]{64}$/u); + expect(second.fingerprint).toBe(first.fingerprint); + }); + + it("拒绝路径穿越和缺少必要部件", () => { + expect(() => + readReferenceDocxPackage( + createReferencePackage({ + "../outside.xml": new Uint8Array([1]) + }) + ) + ).toThrow("不安全路径"); + + const incomplete = zipSync({ + "[Content_Types].xml": new Uint8Array([1]) + }); + expect(() => readReferenceDocxPackage(incomplete)).toThrow( + "缺少必要部件" + ); + }); +}); diff --git a/packages/docx-engine/tests/style-presets.test.ts b/packages/docx-engine/tests/style-presets.test.ts new file mode 100644 index 0000000..4354dac --- /dev/null +++ b/packages/docx-engine/tests/style-presets.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { ThemeManifest } from "@md-to-pdf/core"; +import { + inferDocxStylePreset, + resolveDocxThemeStyle +} from "../src/index.js"; + +function manifest( + overrides: Partial = {} +): ThemeManifest { + return { + manifestVersion: 1, + id: "test-theme", + name: "测试主题", + version: "1.0.0", + description: "测试", + author: "测试", + license: "内部许可", + entry: "theme.css", + domPreset: "generic", + defaultFontSize: "16px", + supportedFeatures: [], + category: "general", + compatibleProfiles: [], + bundled: true, + ...overrides + }; +} + +describe("DOCX 样式预设", () => { + it("按主题分类提供兼容回退", () => { + expect(inferDocxStylePreset(manifest())).toBe("general"); + expect( + inferDocxStylePreset(manifest({ category: "red-letter" })) + ).toBe("official"); + expect( + resolveDocxThemeStyle( + manifest({ category: "formal" }) + ).body.firstLineIndentChars + ).toBe(2); + }); + + it("将主题覆盖合并到完整预设", () => { + const resolved = resolveDocxThemeStyle( + manifest({ + docxStyle: { + preset: "tender", + body: { + sizePt: 13, + color: "#112233" + }, + headings: { + color: "#334455" + } + } + }) + ); + + expect(resolved.body).toMatchObject({ + sizePt: 13, + color: "#112233", + lineSpacing: 1.5 + }); + expect(resolved.headings.color).toBe("#334455"); + expect(resolved.code.fonts.latin).toBe("Consolas"); + }); +}); diff --git a/packages/docx-engine/tsconfig.json b/packages/docx-engine/tsconfig.json new file mode 100644 index 0000000..c6bd117 --- /dev/null +++ b/packages/docx-engine/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": [ + "src" + ] +} diff --git a/themes/enterprise-red-simple/theme.json b/themes/enterprise-red-simple/theme.json index b0dbc6a..f3e30d9 100644 --- a/themes/enterprise-red-simple/theme.json +++ b/themes/enterprise-red-simple/theme.json @@ -29,5 +29,8 @@ "left": "28mm" } }, + "docxStyle": { + "preset": "official" + }, "bundled": true } diff --git a/themes/formal-feasibility/theme.json b/themes/formal-feasibility/theme.json index cbfc794..2864772 100644 --- a/themes/formal-feasibility/theme.json +++ b/themes/formal-feasibility/theme.json @@ -21,5 +21,8 @@ "left": "30mm" } }, + "docxStyle": { + "preset": "formal" + }, "bundled": true } diff --git a/themes/formal-regulation/theme.json b/themes/formal-regulation/theme.json index 3b012d5..c2b35a1 100644 --- a/themes/formal-regulation/theme.json +++ b/themes/formal-regulation/theme.json @@ -21,5 +21,8 @@ "left": "28mm" } }, + "docxStyle": { + "preset": "formal" + }, "bundled": true } diff --git a/themes/formal-report/theme.json b/themes/formal-report/theme.json index 782de5b..456d612 100644 --- a/themes/formal-report/theme.json +++ b/themes/formal-report/theme.json @@ -21,5 +21,8 @@ "left": "28mm" } }, + "docxStyle": { + "preset": "formal" + }, "bundled": true } diff --git a/themes/gov-red-letter/theme.json b/themes/gov-red-letter/theme.json index b728908..d15d869 100644 --- a/themes/gov-red-letter/theme.json +++ b/themes/gov-red-letter/theme.json @@ -61,5 +61,8 @@ "showOnFirstPage": false } }, + "docxStyle": { + "preset": "official" + }, "bundled": true } diff --git a/themes/gov-red-standard/theme.json b/themes/gov-red-standard/theme.json index 8f90561..d71288f 100644 --- a/themes/gov-red-standard/theme.json +++ b/themes/gov-red-standard/theme.json @@ -61,5 +61,8 @@ "showOnFirstPage": true } }, + "docxStyle": { + "preset": "official" + }, "bundled": true } diff --git a/themes/red-briefing/theme.json b/themes/red-briefing/theme.json index 3910d13..58e2c46 100644 --- a/themes/red-briefing/theme.json +++ b/themes/red-briefing/theme.json @@ -29,5 +29,8 @@ "left": "25mm" } }, + "docxStyle": { + "preset": "official" + }, "bundled": true } diff --git a/themes/tender-blind/theme.json b/themes/tender-blind/theme.json index f3ca9c0..90c7db6 100644 --- a/themes/tender-blind/theme.json +++ b/themes/tender-blind/theme.json @@ -21,5 +21,8 @@ "left": "31.7mm" } }, + "docxStyle": { + "preset": "tender" + }, "bundled": true } diff --git a/themes/tender-business-blue/theme.json b/themes/tender-business-blue/theme.json index ad47ce7..1ba298b 100644 --- a/themes/tender-business-blue/theme.json +++ b/themes/tender-business-blue/theme.json @@ -21,5 +21,14 @@ "left": "30mm" } }, + "docxStyle": { + "preset": "tender", + "headings": { + "color": "#1f4e79" + }, + "hyperlink": { + "color": "#2563eb" + } + }, "bundled": true } diff --git a/themes/tender-classic/theme.json b/themes/tender-classic/theme.json index bda8b2c..91239d3 100644 --- a/themes/tender-classic/theme.json +++ b/themes/tender-classic/theme.json @@ -21,5 +21,11 @@ "left": "30mm" } }, + "docxStyle": { + "preset": "tender", + "headings": { + "color": "#000000" + } + }, "bundled": true } diff --git a/themes/typora-github/theme.json b/themes/typora-github/theme.json index 28467b3..c8ca7f6 100644 --- a/themes/typora-github/theme.json +++ b/themes/typora-github/theme.json @@ -18,5 +18,8 @@ "katex", "mermaid" ], + "docxStyle": { + "preset": "technical" + }, "bundled": true } diff --git a/themes/typora-like/theme.json b/themes/typora-like/theme.json index edce03b..adc2f0c 100644 --- a/themes/typora-like/theme.json +++ b/themes/typora-like/theme.json @@ -18,5 +18,8 @@ "katex", "mermaid" ], + "docxStyle": { + "preset": "technical" + }, "bundled": true } diff --git a/themes/typora-pixyll/theme.json b/themes/typora-pixyll/theme.json index 5a30888..5f55a38 100644 --- a/themes/typora-pixyll/theme.json +++ b/themes/typora-pixyll/theme.json @@ -18,5 +18,8 @@ "katex", "mermaid" ], + "docxStyle": { + "preset": "general" + }, "bundled": true } diff --git a/themes/typora-whitey/theme.json b/themes/typora-whitey/theme.json index b5ce878..b1555d1 100644 --- a/themes/typora-whitey/theme.json +++ b/themes/typora-whitey/theme.json @@ -18,5 +18,8 @@ "katex", "mermaid" ], + "docxStyle": { + "preset": "general" + }, "bundled": true }