feat: 实现动态 DOCX 模板与样式映射

This commit is contained in:
SkyJourney
2026-07-30 14:10:43 +08:00
parent fe5d67fb6d
commit fa159d916f
41 changed files with 3610 additions and 7 deletions
+13 -2
View File
@@ -98,6 +98,14 @@ Server 使用 Playwright Chromium、Desktop 使用 Electron Chromium
万像素、8 MiB 单图和 30 MiB 总量。本阶段只提供共享服务和平台适配器, 万像素、8 MiB 单图和 30 MiB 总量。本阶段只提供共享服务和平台适配器,
尚未新增 DOCX HTTP 路由、应用 IPC、Pandoc 调用或导出按钮。 尚未新增 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. 已完成
### 2.1 项目骨架 ### 2.1 项目骨架
@@ -976,6 +984,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
- `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。 - `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。
- `packages/application` 负责共享 Markdown 渲染和主题应用服务; - `packages/application` 负责共享 Markdown 渲染和主题应用服务;
`apps/server``apps/desktop` 分别提供 HTTP 和 IPC 适配。 `apps/server``apps/desktop` 分别提供 HTTP 和 IPC 适配。
- `packages/docx-engine` 负责安全读取 Pandoc 默认模板、解析主题 DOCX
样式、生成动态 OOXML 部件和执行结构校验;Pandoc 进程定位、临时目录
与转换超时仍属于后续转换服务。
- `apps/desktop/src/desktop-application-controller.ts` 负责 Electron - `apps/desktop/src/desktop-application-controller.ts` 负责 Electron
多窗口、同文件单例、文件变化检测和平台链接动作;`main.ts` 负责启动 多窗口、同文件单例、文件变化检测和平台链接动作;`main.ts` 负责启动
组装、协议和生命周期。 组装、协议和生命周期。
@@ -1010,8 +1021,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
- 阶段 3:已实现 DOCX 共享模型和跨端导出协议; - 阶段 3:已实现 DOCX 共享模型和跨端导出协议;
- 阶段 4:已实现 CodeMirror 6 与可扩展基础 Markdown 工具栏; - 阶段 4:已实现 CodeMirror 6 与可扩展基础 Markdown 工具栏;
- 阶段 5:已实现 DOCX 资源预处理和跨端 Chromium PNG 捕获适配器; - 阶段 5:已实现 DOCX 资源预处理和跨端 Chromium PNG 捕获适配器;
- 阶段 68:依次实现动态 reference.docx、Pandoc 转换服务以及 - 阶段 6:已实现动态 reference.docx、主题样式映射和真实 Pandoc 验证;
Web/Desktop 导出交互; - 阶段 7~8:依次实现 Pandoc 转换服务以及 Web/Desktop 导出交互;
- 阶段 910:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。 - 阶段 910:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
+23 -1
View File
@@ -2183,6 +2183,10 @@
"resolved": "apps/desktop", "resolved": "apps/desktop",
"link": true "link": true
}, },
"node_modules/@md-to-pdf/docx-engine": {
"resolved": "packages/docx-engine",
"link": true
},
"node_modules/@md-to-pdf/markdown-echarts": { "node_modules/@md-to-pdf/markdown-echarts": {
"resolved": "packages/markdown-echarts", "resolved": "packages/markdown-echarts",
"link": true "link": true
@@ -3730,7 +3734,6 @@
"version": "0.9.10", "version": "0.9.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
"integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=14.6" "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": { "node_modules/filelist": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -9911,6 +9920,19 @@
"vitest": "^4.1.10" "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": { "packages/markdown-echarts": {
"name": "@md-to-pdf/markdown-echarts", "name": "@md-to-pdf/markdown-echarts",
"version": "0.1.0", "version": "0.1.0",
+4 -3
View File
@@ -10,7 +10,7 @@
], ],
"scripts": { "scripts": {
"build": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop", "build": "npm run build:web-runtime && npm run build -w @md-to-pdf/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": "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:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts",
"dev:core": "npm run dev -w @md-to-pdf/core", "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: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", "desktop:package": "npm run package -w @md-to-pdf/desktop",
"theme:import-typora": "node scripts/import-typora-theme.mjs", "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", "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",
"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" "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": { "engines": {
"node": ">=22" "node": ">=22"
@@ -1,6 +1,7 @@
import { readFile, readdir } from "node:fs/promises"; import { readFile, readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { docxThemeStyleSchema } from "@md-to-pdf/core";
const bundledThemeNames = { const bundledThemeNames = {
"typora-github": "Typora Github", "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 () => { it("为红头主题声明分类与兼容的结构化文档", async () => {
const themesRoot = fileURLToPath( const themesRoot = fileURLToPath(
new URL("../../../themes/", import.meta.url) new URL("../../../themes/", import.meta.url)
+1
View File
@@ -10,6 +10,7 @@ src/
document.ts Markdown 文档、分页载荷、结果与耗时模型 document.ts Markdown 文档、分页载荷、结果与耗时模型
document-link.ts 跨端链接分类与 PDF 本地链接编码协议 document-link.ts 跨端链接分类与 PDF 本地链接编码协议
docx.ts DOCX 请求、能力、错误、结果与文件名协议 docx.ts DOCX 请求、能力、错误、结果与文件名协议
docx-style.ts 主题 DOCX 样式预设和安全覆盖协议
export-config.ts 纸张、边距、页眉页脚、页码与图表配置 export-config.ts 纸张、边距、页眉页脚、页码与图表配置
theme.ts 主题清单、主题能力与 CSS 载荷模型 theme.ts 主题清单、主题能力与 CSS 载荷模型
index.ts 公共导出入口 index.ts 公共导出入口
+121
View File
@@ -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
>;
+1
View File
@@ -1,5 +1,6 @@
export * from "./document.js"; export * from "./document.js";
export * from "./docx.js"; export * from "./docx.js";
export * from "./docx-style.js";
export * from "./document-profile.js"; export * from "./document-profile.js";
export * from "./document-link.js"; export * from "./document-link.js";
export * from "./export-config.js"; export * from "./export-config.js";
+2
View File
@@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { documentProfileNameSchema } from "./document-profile.js"; import { documentProfileNameSchema } from "./document-profile.js";
import { docxThemeStyleSchema } from "./docx-style.js";
import { import {
footerSchema, footerSchema,
headerSchema, headerSchema,
@@ -50,6 +51,7 @@ export const themeManifestSchema = z.object({
footer: footerSchema.optional() footer: footerSchema.optional()
}) })
.optional(), .optional(),
docxStyle: docxThemeStyleSchema.optional(),
bundled: z.boolean() bundled: z.boolean()
}); });
+46 -1
View File
@@ -10,7 +10,8 @@ import {
docxCapabilitySchema, docxCapabilitySchema,
docxMediaCapturePlanSchema, docxMediaCapturePlanSchema,
docxExportErrorResponseSchema, docxExportErrorResponseSchema,
docxExportRequestSchema docxExportRequestSchema,
docxThemeStyleSchema
} from "../src/index.js"; } from "../src/index.js";
describe("DOCX 共享协议", () => { describe("DOCX 共享协议", () => {
@@ -165,3 +166,47 @@ describe("DOCX 共享协议", () => {
).toBe(false); ).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();
});
});
+37
View File
@@ -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 与自定义横向模板,并在系统临时目录中完成转换和清理,
不会保留用户文档或验收产物。
@@ -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 组成。
+33
View File
@@ -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"
}
}
@@ -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("<w:tbl") &&
documentXml.includes("editableDocument");
const checks = {
pageWidth: documentXml.includes(`w:w="${expected.pageWidth}"`),
pageHeight: documentXml.includes(`w:h="${expected.pageHeight}"`),
orientation:
expected.orientation === "portrait"
? !documentXml.includes('w:orient="landscape"')
: documentXml.includes('w:orient="landscape"'),
leftMargin: documentXml.includes(`w:left="${expected.leftMargin}"`),
sourceCodeStyle: stylesXml.includes('w:styleId="SourceCode"'),
chineseBodyFont: stylesXml.includes('w:eastAsia="SimSun"'),
editableBody: bodyIsEditable,
headerReferences:
countMatches(documentXml, /w:headerReference/gu) ===
expected.headerCount,
footerReferences:
countMatches(documentXml, /w:footerReference/gu) ===
expected.footerCount,
pageField: Object.entries(entries)
.filter(([name]) => /^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("<w:evenAndOddHeaders")
};
for (const [name, passed] of Object.entries(checks)) {
assert(passed, `${expected.id} 的语义检查失败:${name}`);
}
assert(
validation.headerCount === expected.headerCount,
`${expected.id} 的页眉部件数量不正确`
);
assert(
validation.footerCount === expected.footerCount,
`${expected.id} 的页脚部件数量不正确`
);
return { validation, checks };
}
function createVariant(
temporaryDirectory,
baseline,
theme,
markdown,
variant
) {
const reference = createDynamicReferenceDocx(baseline, {
exportConfig: variant.exportConfig,
theme,
fileName: `${variant.id}.md`,
metadata: {
title: "阶段六动态模板验收",
author: "Markdown PDF 导出器研发组"
}
});
const referencePath = path.join(
temporaryDirectory,
`${variant.id}-reference.docx`
);
const markdownPath = path.join(
temporaryDirectory,
`${variant.id}.md`
);
const resultPath = path.join(
temporaryDirectory,
`${variant.id}-result.docx`
);
fs.writeFileSync(referencePath, reference.content);
fs.writeFileSync(markdownPath, markdown);
runPandoc([
markdownPath,
"--from",
"markdown",
"--to",
"docx",
"--reference-doc",
referencePath,
"--output",
resultPath
]);
const result = fs.readFileSync(resultPath);
return {
id: variant.id,
referenceBytes: reference.content.byteLength,
resultBytes: result.byteLength,
templateFingerprint: reference.templateFingerprint,
cacheKey: reference.cacheKey,
...inspectResult(result, variant.expected)
};
}
const versionResult = runPandoc(["--version"]);
const detectedVersion =
versionResult.stdout
.split(/\r?\n/u)[0]
?.replace(/^pandoc\s+/u, "")
.trim() ?? "";
assert(
detectedVersion === DOCX_PANDOC_VERSION,
`Pandoc 版本不匹配:期望 ${DOCX_PANDOC_VERSION},实际 ${detectedVersion || "未知"}`
);
const baselineResult = runPandoc(
["--print-default-data-file", "reference.docx"],
{ binary: true }
);
const baseline = new Uint8Array(baselineResult.stdout);
const theme = themeManifestSchema.parse(
JSON.parse(fs.readFileSync(themePath, "utf8"))
);
const markdown = fs.readFileSync(fixturePath, "utf8");
const temporaryDirectory = fs.mkdtempSync(
path.join(os.tmpdir(), "md-to-pdf-docx-reference-")
);
const customConfig = {
...defaultExportConfig,
name: "DOCX 横向验收",
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}"
},
center: {
enabled: true,
content: "${author}"
},
right: {
enabled: true,
content: "${filename}"
}
},
footer: {
...defaultExportConfig.footer,
alignment: "outer",
format: "chinese-page-total",
startFrom: 5,
showOnFirstPage: false
}
};
const variants = [
{
id: "official-a4",
exportConfig: defaultExportConfig,
expected: {
id: "official-a4",
pageWidth: 11906,
pageHeight: 16838,
orientation: "portrait",
leftMargin: 1587,
headerCount: 0,
footerCount: 2,
usesEvenAndOddPages: true
}
},
{
id: "custom-landscape",
exportConfig: customConfig,
expected: {
id: "custom-landscape",
pageWidth: 16838,
pageHeight: 11906,
orientation: "landscape",
leftMargin: 1701,
headerCount: 3,
footerCount: 3,
usesEvenAndOddPages: true
}
}
];
try {
const results = variants.map((variant) =>
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
});
}
@@ -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<string, Uint8Array>;
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(
`<w:${rootName} xmlns:w="${WORD_NAMESPACE}" xmlns:r="${OFFICE_RELATIONSHIP_NAMESPACE}"/>`
),
`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<string, Uint8Array>
) {
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<string, Uint8Array>,
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<string, Uint8Array>,
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<string, Uint8Array>,
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
};
}
+9
View File
@@ -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";
+149
View File
@@ -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(
`<?xml version="1.0" encoding="UTF-8"?>\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<string, string> = {}
) {
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);
}
@@ -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
};
}
@@ -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<MarkdownDocumentMetadata, "title" | "author">;
}
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<string, string> = {
title: options.metadata.title,
author: options.metadata.author,
filename: options.fileName
};
return value.replace(
/\$\{(title|author|filename)\}/gu,
(_match, name: string) => replacements[name] ?? ""
);
}
@@ -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<string, Uint8Array>;
}
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<string>();
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<string, Uint8Array>();
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<string, Uint8Array>
) {
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;
}
@@ -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);
}
+258
View File
@@ -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<ResolvedDocxThemeStyle, "preset"> = {
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<Omit<ResolvedDocxThemeStyle, "preset">>
> = {
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<T extends object>(
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<keyof T>) {
const value = override[key];
if (value !== undefined) {
result[key] = value;
}
}
return result;
}
export function inferDocxStylePreset(
manifest: Pick<ThemeManifest, "category">
): 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
)
};
}
@@ -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<string>();
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);
}
+347
View File
@@ -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<string, Uint8Array>
) {
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<string, Relationship>();
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<string, Relationship>,
entries: ReadonlyMap<string, Uint8Array>
) {
const references = directChildren(
section,
WORD_NAMESPACE,
localName
);
const pageTypes = new Set<string>();
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<string, Uint8Array>,
relationships: ReadonlyMap<string, Relationship>
) {
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<string, string>();
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<string, Uint8Array>) {
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<string, Relationship>
>();
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
};
}
@@ -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 `<w:style w:type="${type}" w:styleId="${styleId}"><w:name w:val="${styleId}"/></w:style>`;
})
.join("");
return zipSync(
{
"[Content_Types].xml": xml(
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>'
),
"_rels/.rels": xml(
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>'
),
"word/document.xml": xml(
`<w:document xmlns:w="${wordNamespace}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p/><w:sectPr><w:footnotePr/></w:sectPr></w:body></w:document>`
),
"word/fontTable.xml": xml(
`<w:fonts xmlns:w="${wordNamespace}"><w:font w:name="Arial"/></w:fonts>`
),
"word/numbering.xml": xml(
`<w:numbering xmlns:w="${wordNamespace}"/>`
),
"word/settings.xml": xml(
`<w:settings xmlns:w="${wordNamespace}"/>`
),
"word/styles.xml": xml(
`<w:styles xmlns:w="${wordNamespace}"><w:docDefaults><w:rPrDefault><w:rPr/></w:rPrDefault><w:pPrDefault><w:pPr/></w:pPrDefault></w:docDefaults>${styles}</w:styles>`
),
"word/_rels/document.xml.rels": xml(
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>'
),
"word/theme/theme1.xml": xml(
'<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:themeElements><a:fontScheme><a:majorFont><a:latin typeface="Arial"/><a:ea typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Arial"/><a:ea typeface=""/></a:minorFont></a:fontScheme></a:themeElements></a:theme>'
)
},
{ mtime: new Date("2020-01-01T00:00:00.000Z") }
);
}
function theme(
overrides: Partial<ThemeManifest> = {}
): 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("<w:titlePg");
expect(documentXml.match(/w:headerReference/gu)).toHaveLength(3);
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(3);
expect(settingsXml).toContain("<w:evenAndOddHeaders");
expect(headerXml).toContain("年度 &lt;报告&gt;");
expect(headerXml).toContain("报告 &amp; 计划.md");
expect(relationships.match(/relationships\/header/gu)).toHaveLength(
3
);
expect(relationships.match(/relationships\/footer/gu)).toHaveLength(
3
);
expect(entries["word/footer3.xml"]).toBeDefined();
expect(validateDynamicReferenceDocx(result.content)).toMatchObject({
headerCount: 3,
footerCount: 3
});
});
it("拒绝正文中断裂或类型错误的页眉页脚关系", () => {
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
);
});
});
@@ -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<string, Uint8Array> = {}
) {
const entries = Object.fromEntries(
requiredReferenceDocxParts.map((name) => [
name,
new TextEncoder().encode(`<part name="${name}"/>`)
])
);
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(
"缺少必要部件"
);
});
});
@@ -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> = {}
): 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");
});
});
+16
View File
@@ -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"
]
}
+3
View File
@@ -29,5 +29,8 @@
"left": "28mm" "left": "28mm"
} }
}, },
"docxStyle": {
"preset": "official"
},
"bundled": true "bundled": true
} }
+3
View File
@@ -21,5 +21,8 @@
"left": "30mm" "left": "30mm"
} }
}, },
"docxStyle": {
"preset": "formal"
},
"bundled": true "bundled": true
} }
+3
View File
@@ -21,5 +21,8 @@
"left": "28mm" "left": "28mm"
} }
}, },
"docxStyle": {
"preset": "formal"
},
"bundled": true "bundled": true
} }
+3
View File
@@ -21,5 +21,8 @@
"left": "28mm" "left": "28mm"
} }
}, },
"docxStyle": {
"preset": "formal"
},
"bundled": true "bundled": true
} }
+3
View File
@@ -61,5 +61,8 @@
"showOnFirstPage": false "showOnFirstPage": false
} }
}, },
"docxStyle": {
"preset": "official"
},
"bundled": true "bundled": true
} }
+3
View File
@@ -61,5 +61,8 @@
"showOnFirstPage": true "showOnFirstPage": true
} }
}, },
"docxStyle": {
"preset": "official"
},
"bundled": true "bundled": true
} }
+3
View File
@@ -29,5 +29,8 @@
"left": "25mm" "left": "25mm"
} }
}, },
"docxStyle": {
"preset": "official"
},
"bundled": true "bundled": true
} }
+3
View File
@@ -21,5 +21,8 @@
"left": "31.7mm" "left": "31.7mm"
} }
}, },
"docxStyle": {
"preset": "tender"
},
"bundled": true "bundled": true
} }
+9
View File
@@ -21,5 +21,14 @@
"left": "30mm" "left": "30mm"
} }
}, },
"docxStyle": {
"preset": "tender",
"headings": {
"color": "#1f4e79"
},
"hyperlink": {
"color": "#2563eb"
}
},
"bundled": true "bundled": true
} }
+6
View File
@@ -21,5 +21,11 @@
"left": "30mm" "left": "30mm"
} }
}, },
"docxStyle": {
"preset": "tender",
"headings": {
"color": "#000000"
}
},
"bundled": true "bundled": true
} }
+3
View File
@@ -18,5 +18,8 @@
"katex", "katex",
"mermaid" "mermaid"
], ],
"docxStyle": {
"preset": "technical"
},
"bundled": true "bundled": true
} }
+3
View File
@@ -18,5 +18,8 @@
"katex", "katex",
"mermaid" "mermaid"
], ],
"docxStyle": {
"preset": "technical"
},
"bundled": true "bundled": true
} }
+3
View File
@@ -18,5 +18,8 @@
"katex", "katex",
"mermaid" "mermaid"
], ],
"docxStyle": {
"preset": "general"
},
"bundled": true "bundled": true
} }
+3
View File
@@ -18,5 +18,8 @@
"katex", "katex",
"mermaid" "mermaid"
], ],
"docxStyle": {
"preset": "general"
},
"bundled": true "bundled": true
} }