feat: 实现动态 DOCX 模板与样式映射
This commit is contained in:
+13
-2
@@ -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 捕获适配器;
|
||||||
- 阶段 6~8:依次实现动态 reference.docx、Pandoc 转换服务以及
|
- 阶段 6:已实现动态 reference.docx、主题样式映射和真实 Pandoc 验证;
|
||||||
Web/Desktop 导出交互;
|
- 阶段 7~8:依次实现 Pandoc 转换服务以及 Web/Desktop 导出交互;
|
||||||
- 阶段 9~10:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。
|
- 阶段 9~10:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。
|
||||||
|
|
||||||
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
||||||
|
|||||||
Generated
+23
-1
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 公共导出入口
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|||||||
@@ -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()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 组成。
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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("年度 <报告>");
|
||||||
|
expect(headerXml).toContain("报告 & 计划.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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -29,5 +29,8 @@
|
|||||||
"left": "28mm"
|
"left": "28mm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"docxStyle": {
|
||||||
|
"preset": "official"
|
||||||
|
},
|
||||||
"bundled": true
|
"bundled": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,8 @@
|
|||||||
"left": "30mm"
|
"left": "30mm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"docxStyle": {
|
||||||
|
"preset": "formal"
|
||||||
|
},
|
||||||
"bundled": true
|
"bundled": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,8 @@
|
|||||||
"left": "28mm"
|
"left": "28mm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"docxStyle": {
|
||||||
|
"preset": "formal"
|
||||||
|
},
|
||||||
"bundled": true
|
"bundled": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,8 @@
|
|||||||
"left": "28mm"
|
"left": "28mm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"docxStyle": {
|
||||||
|
"preset": "formal"
|
||||||
|
},
|
||||||
"bundled": true
|
"bundled": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,5 +61,8 @@
|
|||||||
"showOnFirstPage": false
|
"showOnFirstPage": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"docxStyle": {
|
||||||
|
"preset": "official"
|
||||||
|
},
|
||||||
"bundled": true
|
"bundled": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,5 +61,8 @@
|
|||||||
"showOnFirstPage": true
|
"showOnFirstPage": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"docxStyle": {
|
||||||
|
"preset": "official"
|
||||||
|
},
|
||||||
"bundled": true
|
"bundled": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,5 +29,8 @@
|
|||||||
"left": "25mm"
|
"left": "25mm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"docxStyle": {
|
||||||
|
"preset": "official"
|
||||||
|
},
|
||||||
"bundled": true
|
"bundled": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,8 @@
|
|||||||
"left": "31.7mm"
|
"left": "31.7mm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"docxStyle": {
|
||||||
|
"preset": "tender"
|
||||||
|
},
|
||||||
"bundled": true
|
"bundled": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,14 @@
|
|||||||
"left": "30mm"
|
"left": "30mm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"docxStyle": {
|
||||||
|
"preset": "tender",
|
||||||
|
"headings": {
|
||||||
|
"color": "#1f4e79"
|
||||||
|
},
|
||||||
|
"hyperlink": {
|
||||||
|
"color": "#2563eb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"bundled": true
|
"bundled": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,11 @@
|
|||||||
"left": "30mm"
|
"left": "30mm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"docxStyle": {
|
||||||
|
"preset": "tender",
|
||||||
|
"headings": {
|
||||||
|
"color": "#000000"
|
||||||
|
}
|
||||||
|
},
|
||||||
"bundled": true
|
"bundled": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,8 @@
|
|||||||
"katex",
|
"katex",
|
||||||
"mermaid"
|
"mermaid"
|
||||||
],
|
],
|
||||||
|
"docxStyle": {
|
||||||
|
"preset": "technical"
|
||||||
|
},
|
||||||
"bundled": true
|
"bundled": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,8 @@
|
|||||||
"katex",
|
"katex",
|
||||||
"mermaid"
|
"mermaid"
|
||||||
],
|
],
|
||||||
|
"docxStyle": {
|
||||||
|
"preset": "technical"
|
||||||
|
},
|
||||||
"bundled": true
|
"bundled": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,8 @@
|
|||||||
"katex",
|
"katex",
|
||||||
"mermaid"
|
"mermaid"
|
||||||
],
|
],
|
||||||
|
"docxStyle": {
|
||||||
|
"preset": "general"
|
||||||
|
},
|
||||||
"bundled": true
|
"bundled": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,8 @@
|
|||||||
"katex",
|
"katex",
|
||||||
"mermaid"
|
"mermaid"
|
||||||
],
|
],
|
||||||
|
"docxStyle": {
|
||||||
|
"preset": "general"
|
||||||
|
},
|
||||||
"bundled": true
|
"bundled": true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user