test: 建立 DOCX 自动化验收门禁

This commit is contained in:
SkyJourney
2026-07-30 16:48:41 +08:00
parent f2dfc6ef72
commit aff2830c28
15 changed files with 1452 additions and 12 deletions
@@ -52,6 +52,7 @@ import {
toDesktopDocxExportFailure,
type DesktopDocxExportOutcome
} from "./docx-contract.js";
import { saveDesktopDocx } from "./desktop-docx-save.js";
import { ElectronDocxMediaEngine } from "./electron-docx-media-engine.js";
import {
parseMarkdownRenderRequest,
@@ -987,16 +988,14 @@ export class DesktopApplicationController {
timings: generated.timings
};
}
const targetPath = selection.filePath
.toLowerCase()
.endsWith(".docx")
? selection.filePath
: `${selection.filePath}.docx`;
await writeFile(targetPath, generated.docx);
const saved = await saveDesktopDocx(
selection.filePath,
generated.docx
);
return {
ok: true,
saved: true,
fileName: path.basename(targetPath),
fileName: saved.fileName,
diagnostics: generated.diagnostics,
timings: generated.timings
};
+23
View File
@@ -0,0 +1,23 @@
import path from "node:path";
import { writeFile } from "node:fs/promises";
export function resolveDesktopDocxTargetPath(selectedPath: string) {
return selectedPath.toLowerCase().endsWith(".docx")
? selectedPath
: `${selectedPath}.docx`;
}
export async function saveDesktopDocx(
selectedPath: string,
content: Uint8Array
) {
if (!selectedPath || content.byteLength === 0) {
throw new Error("DOCX 保存参数无效");
}
const targetPath = resolveDesktopDocxTargetPath(selectedPath);
await writeFile(targetPath, content);
return {
targetPath,
fileName: path.basename(targetPath)
};
}
@@ -0,0 +1,57 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
resolveDesktopDocxTargetPath,
saveDesktopDocx
} from "../src/desktop-docx-save.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
);
});
async function createTemporaryDirectory() {
const directory = await mkdtemp(
path.join(os.tmpdir(), "md-to-pdf-desktop-docx-save-")
);
temporaryDirectories.push(directory);
return directory;
}
describe("Desktop DOCX 原生保存", () => {
it("在用户未填写扩展名时补充 .docx 并原样落盘", async () => {
const directory = await createTemporaryDirectory();
const content = Uint8Array.of(80, 75, 3, 4, 1, 2, 3);
const selectedPath = path.join(directory, "桌面验收");
const saved = await saveDesktopDocx(selectedPath, content);
expect(saved).toEqual({
targetPath: `${selectedPath}.docx`,
fileName: "桌面验收.docx"
});
expect(await readFile(saved.targetPath)).toEqual(Buffer.from(content));
});
it("大小写不敏感地保留已有 DOCX 扩展名", () => {
expect(resolveDesktopDocxTargetPath("报告.DOCX")).toBe(
"报告.DOCX"
);
});
it("拒绝空路径或空内容", async () => {
await expect(
saveDesktopDocx("", Uint8Array.of(80, 75))
).rejects.toThrow("DOCX 保存参数无效");
await expect(
saveDesktopDocx("报告.docx", new Uint8Array())
).rejects.toThrow("DOCX 保存参数无效");
});
});
+2 -1
View File
@@ -8,7 +8,8 @@
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
"verify:docx-http": "node scripts/verify-docx-http.mjs"
},
"dependencies": {
"@md-to-pdf/application": "0.4.1",
+237
View File
@@ -0,0 +1,237 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
DOCX_MIME_TYPE,
defaultExportConfig
} from "@md-to-pdf/core";
import { inspectDocxAcceptance } from "@md-to-pdf/docx-engine";
import { buildApp } from "../dist/app.js";
const directory = path.dirname(fileURLToPath(import.meta.url));
const repositoryDirectory = path.resolve(directory, "../../..");
const outputDirectory = path.join(
repositoryDirectory,
"output",
"docx-acceptance"
);
const temporaryPrefix = "md-to-pdf-docx-";
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function temporaryDirectories() {
return new Set(
fs
.readdirSync(os.tmpdir(), { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() && entry.name.startsWith(temporaryPrefix)
)
.map((entry) => entry.name)
);
}
const markdown = `---
title: Server HTTP DOCX 验收
author: Markdown PDF 导出器研发组
---
# HTTP 导出保持可编辑
这是一段由真实 Fastify 路由和 Pandoc 生成的可编辑中文正文,包含
[验收链接](https://example.invalid/server-docx)。
1. 原生编号第一项
2. 原生编号第二项
| HTTP 门禁 | 预期 |
| --- | --- |
| MIME | DOCX |
| 内容 | 可编辑 |
脚注必须保留。[^server-footnote]
公式 $a^2+b^2=c^2$ 必须使用 OMML。
[^server-footnote]: Server HTTP 验收脚注。
`;
const exportConfig = {
...defaultExportConfig,
themeId: "typora-like",
pageDecorationsMode: "custom",
paper: {
...defaultExportConfig.paper,
marginMode: "custom",
margins: {
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
}
}
};
const mediaAdapter = {
async capture() {
return {
plan: {
targets: [],
echartsErrors: [],
mermaidErrors: []
},
captures: []
};
},
async close() {}
};
const pdfGenerator = {
async generate() {
throw new Error("DOCX HTTP 验收不得调用 PDF");
},
async close() {}
};
const beforeTemporaryDirectories = temporaryDirectories();
const app = buildApp({
logger: false,
pdfGenerator,
docxMediaAdapter: mediaAdapter
});
let response;
try {
const capabilityResponse = await app.inject({
method: "GET",
url: "/api/docx/capability"
});
assert(capabilityResponse.statusCode === 200, "DOCX capability 请求失败");
assert(
capabilityResponse.json().status === "available",
`DOCX capability 不可用:${capabilityResponse.body}`
);
response = await app.inject({
method: "POST",
url: "/api/docx",
payload: {
markdown,
fileName: "目录/Server HTTP 验收.md",
language: "zh-CN",
resources: [],
exportConfig
}
});
} finally {
await app.close();
}
assert(response.statusCode === 200, `DOCX HTTP 返回 ${response.statusCode}`);
assert(
response.headers["content-type"]?.includes(DOCX_MIME_TYPE),
`DOCX MIME 无效:${response.headers["content-type"]}`
);
assert(
response.headers["content-disposition"]?.includes(
"Server%20HTTP%20%E9%AA%8C%E6%94%B6.docx"
),
`DOCX 文件名无效:${response.headers["content-disposition"]}`
);
assert(
response.headers["cache-control"] === "no-store",
"DOCX HTTP 响应必须禁用缓存"
);
for (const timing of [
"runtime-probe",
"prepare",
"media",
"reference",
"pandoc",
"validation",
"total"
]) {
assert(
response.headers["server-timing"]?.includes(`${timing};dur=`),
`Server-Timing 缺少 ${timing}`
);
}
assert(
response.headers["x-docx-warning-count"] === "0",
"无媒体 HTTP 验收不应产生 DOCX 警告"
);
const report = inspectDocxAcceptance(response.rawPayload, {
id: "server-http",
page: {
widthTwips: 11906,
heightTwips: 16838,
orientation: "portrait",
marginsTwips: {
top: 907,
right: 907,
bottom: 907,
left: 907
}
},
requiredText: [
"真实 Fastify 路由",
"原生编号第一项",
"HTTP 门禁"
],
requiredFootnoteText: ["Server HTTP 验收脚注"],
minimumParagraphs: 8,
minimumTextRuns: 12,
minimumTables: 1,
minimumNumberedParagraphs: 2,
minimumHyperlinks: 1,
minimumFootnoteReferences: 1,
minimumMathObjects: 1,
requiredStyleIds: [
"Normal",
"Heading1",
"SourceCode",
"Table",
"Caption"
],
requirePageField: true
});
const afterTemporaryDirectories = temporaryDirectories();
const leakedTemporaryDirectories = [...afterTemporaryDirectories].filter(
(name) => !beforeTemporaryDirectories.has(name)
);
assert(
leakedTemporaryDirectories.length === 0,
`DOCX HTTP 遗留临时目录:${leakedTemporaryDirectories.join(", ")}`
);
fs.mkdirSync(outputDirectory, { recursive: true });
const outputPath = path.join(outputDirectory, "server-http.docx");
const reportPath = path.join(
outputDirectory,
"server-http-report.json"
);
fs.writeFileSync(outputPath, response.rawPayload);
const result = {
outputFile: path.relative(repositoryDirectory, outputPath),
bytes: response.rawPayload.byteLength,
headers: {
contentType: response.headers["content-type"],
contentDisposition: response.headers["content-disposition"],
cacheControl: response.headers["cache-control"],
serverTiming: response.headers["server-timing"],
warningCount: response.headers["x-docx-warning-count"],
echartsErrorCount: response.headers["x-echarts-error-count"],
mermaidErrorCount: response.headers["x-mermaid-error-count"]
},
leakedTemporaryDirectories,
acceptance: report
};
fs.writeFileSync(
reportPath,
`${JSON.stringify(result, null, 2)}\n`,
"utf8"
);
console.log(JSON.stringify(result, null, 2));
+13 -1
View File
@@ -124,6 +124,16 @@ Toast 与非确定进度条,不污染预览状态或 PDF 精确预览缓存。
退出会等待 PDF、DOCX、Pandoc 与隐藏媒体窗口完成清理,Lua Filter 作为
明确构建资源进入桌面主进程目录。
`v0.6.0` 阶段 9 已建立 DOCX 自动化验收门禁:综合样例覆盖可编辑正文、
标题、字符样式、列表、表格、代码、链接、脚注、OMML、普通图片、
Mermaid 和 EChartsOOXML 验收器检查纸张、方向、四边页边距、原生结构、
PNG 关系、替代文本、页眉页脚、页码、关键样式及 `altChunk` 禁用。
真实 Pandoc 3.9.0.2 矩阵生成技术文档 A4、公文 A4 和技术文档 Letter
横向三套固定产物;Server HTTP 验收覆盖 MIME、UTF-8 文件名、诊断头、
分阶段耗时和临时目录清理,Desktop 原生保存覆盖扩展名与字节完整性。
统一命令为 `npm run verify:docx-acceptance`,产物及 JSON 报告写入被 Git
忽略的 `output/docx-acceptance/`,供阶段 10 的 Word/WPS 双向互存使用。
## 2. 已完成
### 2.1 项目骨架
@@ -1044,7 +1054,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
- 阶段 6:已实现动态 reference.docx、主题样式映射和真实 Pandoc 验证;
- 阶段 7:已实现 Pandoc 运行时、Lua 媒体映射和共享转换服务;
- 阶段 8:已实现 Web/Desktop DOCX 导出交互;
- 阶段 910:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。
- 阶段 9:已完成 DOCX 自动化结构、真实 Pandoc、Server HTTP 与
Desktop 保存验收;
- 阶段 10:完成 Word/WPS 双向互存、视觉、体积和正式发布验收。
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
后续阶段的功能实现。
+2 -2
View File
@@ -404,8 +404,8 @@ DOCX 沿用当前无历史数据库原则。Markdown、图片、中间 PNG、动
6. 动态 `reference.docx`
7. Pandoc DOCX 转换服务;
8. Web 与 Desktop 导出交互;
9. 自动化测试与 Word/WPS 验收;
10. 体积、文档和正式发布。
9. DOCX 自动化结构、真实 Pandoc、Server HTTP 与 Desktop 保存验收;
10. Word/WPS 双向互存、视觉、体积、文档和正式发布。
每个阶段遵循:
+3
View File
@@ -26,6 +26,9 @@
"theme:import-typora": "node scripts/import-typora-theme.mjs",
"verify:docx-reference": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:pandoc -w @md-to-pdf/docx-engine",
"verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine",
"verify:docx-matrix": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine",
"verify:docx-acceptance": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop && npm run verify:pandoc -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine && npm run verify:docx-http -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop -- desktop-docx-save.test.ts",
"verify:docx-http": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run verify:docx-http -w @md-to-pdf/server",
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/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"
},
+9
View File
@@ -36,9 +36,18 @@ npm run typecheck -w @md-to-pdf/docx-engine
npm run build -w @md-to-pdf/docx-engine
npm run verify:docx-reference
npm run verify:docx-conversion
npm run verify:docx-acceptance
```
`verify:docx-reference` 要求本机 `PATH` 中存在 Pandoc 3.9.0.2,也可以通过
`DOCX_PANDOC_PATH` 指定可执行文件。脚本从 Pandoc 读取原始默认模板,
分别验证公文 A4 与自定义横向模板,并在系统临时目录中完成转换和清理,
不会保留用户文档或验收产物。
`verify:docx-acceptance` 使用综合 Markdown 夹具和固定 Pandoc 生成技术
文档 A4、公文 A4、技术文档 Letter 横向三套 DOCX,自动检查纸张、页边距、
原生段落、标题、编号、表格、链接、脚注、OMML、PNG、页眉页脚、页码、
关键样式和 `altChunk` 禁用门禁。DOCX 与 JSON 报告写入被 Git 忽略的
`output/docx-acceptance/`,供 Word/WPS 互操作验收使用。根级命令还会
依次执行动态模板、媒体转换、Server HTTP 和 Desktop 原生保存验收;
仅需重跑三配置矩阵时可使用 `npm run verify:docx-matrix`
@@ -0,0 +1,75 @@
---
title: v0.6.0 DOCX 自动验收
author: Markdown PDF 导出器研发组
subject: Word 与 WPS 可编辑性验收
keywords: [DOCX, Pandoc, OOXML]
lang: zh-CN
---
# 一级标题:可编辑文档
这是一段可编辑的中文正文,包含 **加粗文本**、*斜体文本*、
~~删除线文本~~`inlineCode()`
[外部链接](https://example.invalid/docx-acceptance)。
## 二级标题:段落结构
> 引用段落用于确认内容仍是 Word 原生段落,而不是页面截图。
- 无序列表第一项
- 嵌套无序列表
- 无序列表第二项
1. 有序列表第一项
2. 有序列表第二项
- [x] 已完成任务
- [ ] 未完成任务
### 三级标题:表格与代码
| 验收项 | Word 结构 | 预期结果 |
| :--- | :---: | ---: |
| 正文 | 段落与文本 Run | 可编辑 |
| 表格 | 原生表格与单元格 | 可增删内容 |
| 图表 | 高分辨率 PNG | 可调整图片 |
```typescript
export function editableDocument(value: string) {
return `DOCX 内容仍可编辑:${value}`;
}
```
脚注引用仍应保留原生结构。[^editable-footnote]
行内公式 $E = mc^2$ 与块级公式:
$$
\int_0^1 x^2\,dx = \frac{1}{3}
$$
![普通图片](ignored-original-image.png)
```mermaid
flowchart LR
A[Markdown] --> B[Pandoc]
B --> C[可编辑 DOCX]
```
```echarts
version: 1
caption: ECharts 柱状图
option:
xAxis:
type: category
data: [一月, 二月, 三月]
yAxis:
type: value
series:
- type: bar
data: [12, 20, 16]
```
最后一段用于确认正文没有通过 `altChunk` 嵌入 HTML,也没有整体图片化。
[^editable-footnote]: 这是可以继续编辑的 Word 原生脚注。
+2 -1
View File
@@ -21,7 +21,8 @@
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
"verify:pandoc": "node scripts/verify-pandoc-reference.mjs",
"verify:conversion": "node scripts/verify-pandoc-conversion.mjs"
"verify:conversion": "node scripts/verify-pandoc-conversion.mjs",
"verify:acceptance": "node scripts/verify-docx-acceptance.mjs"
},
"dependencies": {
"@md-to-pdf/core": "0.1.0",
@@ -0,0 +1,333 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
DOCX_PANDOC_VERSION,
defaultExportConfig,
themeManifestSchema
} from "@md-to-pdf/core";
import {
PandocDocxConverter,
PandocRuntime,
inspectDocxAcceptance
} from "@md-to-pdf/docx-engine";
const directory = path.dirname(fileURLToPath(import.meta.url));
const packageDirectory = path.resolve(directory, "..");
const repositoryDirectory = path.resolve(packageDirectory, "../..");
const outputDirectory = path.join(
repositoryDirectory,
"output",
"docx-acceptance"
);
const markdown = fs.readFileSync(
path.join(packageDirectory, "fixtures", "docx-acceptance.md"),
"utf8"
);
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64"
);
function loadTheme(id) {
return themeManifestSchema.parse(
JSON.parse(
fs.readFileSync(
path.join(repositoryDirectory, "themes", id, "theme.json"),
"utf8"
)
)
);
}
function mediaResource(kind, ordinal, dimensions, altText, caption) {
return {
id: `docx-media-${ordinal}`,
kind,
ordinal,
kindOrdinal: 1,
altText,
...(caption ? { caption } : {}),
displayWidthPx: dimensions.width,
displayHeightPx: dimensions.height,
captureX: 0,
captureY: 0,
captureWidthPx: dimensions.width,
captureHeightPx: dimensions.height,
rasterScale: 1,
fileName: `${kind}-${ordinal}.png`,
contentType: "image/png",
content: png,
pixelWidth: 1,
pixelHeight: 1
};
}
function createMedia() {
const resources = [
mediaResource(
"image",
1,
{ width: 384, height: 192 },
"普通图片"
),
mediaResource(
"mermaid",
2,
{ width: 480, height: 240 },
"Mermaid 流程图",
"Mermaid 流程图"
),
mediaResource(
"echarts",
3,
{ width: 576, height: 288 },
"ECharts 柱状图",
"ECharts 柱状图"
)
];
return {
resources,
echartsErrors: [],
mermaidErrors: [],
warnings: [],
totalBytes: png.byteLength * resources.length
};
}
const technicalTheme = loadTheme("typora-like");
const officialTheme = loadTheme("gov-red-standard");
const commonExpectation = {
requiredText: [
"可编辑的中文正文",
"原生表格与单元格",
"editableDocument",
"正文没有通过"
],
minimumParagraphs: 18,
minimumTextRuns: 24,
minimumTables: 1,
minimumNumberedParagraphs: 6,
minimumHyperlinks: 1,
minimumFootnoteReferences: 1,
minimumMathObjects: 2,
minimumDrawings: 3,
minimumPngImages: 1,
requiredImageAltText: [
"普通图片",
"Mermaid 流程图",
"ECharts 柱状图"
],
requiredStyleIds: [
"Normal",
"Heading1",
"Heading2",
"Heading3",
"SourceCode",
"Table",
"Caption"
],
requirePageField: true
};
const technicalA4Config = {
...defaultExportConfig,
name: "技术文档 A4 纵向验收",
themeId: technicalTheme.id,
pageDecorationsMode: "custom",
paper: {
...defaultExportConfig.paper,
format: "A4",
orientation: "portrait",
marginMode: "custom",
margins: {
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
}
}
};
const officialA4Config = {
...defaultExportConfig,
name: "公文 A4 纵向验收",
themeId: officialTheme.id,
pageDecorationsMode: "theme",
paper: {
...defaultExportConfig.paper,
format: "A4",
orientation: "portrait",
marginMode: "theme"
}
};
const landscapeLetterConfig = {
...defaultExportConfig,
name: "技术文档 Letter 横向验收",
themeId: technicalTheme.id,
pageDecorationsMode: "custom",
paper: {
...defaultExportConfig.paper,
format: "Letter",
orientation: "landscape",
marginMode: "custom",
margins: {
top: "20mm",
right: "18mm",
bottom: "22mm",
left: "24mm"
}
},
header: {
...defaultExportConfig.header,
enabled: true,
left: {
enabled: true,
content: "${title}"
},
right: {
enabled: true,
content: "${filename}"
}
},
footer: {
...defaultExportConfig.footer,
alignment: "right",
format: "page-total",
startFrom: 3
}
};
const variants = [
{
id: "technical-a4-portrait",
theme: technicalTheme,
exportConfig: technicalA4Config,
expectation: {
...commonExpectation,
id: "technical-a4-portrait",
page: {
widthTwips: 11906,
heightTwips: 16838,
orientation: "portrait",
marginsTwips: {
top: 907,
right: 907,
bottom: 907,
left: 907
}
}
}
},
{
id: "official-a4-portrait",
theme: officialTheme,
exportConfig: officialA4Config,
expectation: {
...commonExpectation,
id: "official-a4-portrait",
page: {
widthTwips: 11906,
heightTwips: 16838,
orientation: "portrait",
marginsTwips: {
top: 2098,
right: 1474,
bottom: 1984,
left: 1587
}
}
}
},
{
id: "technical-letter-landscape",
theme: technicalTheme,
exportConfig: landscapeLetterConfig,
expectation: {
...commonExpectation,
id: "technical-letter-landscape",
page: {
widthTwips: 15817,
heightTwips: 12246,
orientation: "landscape",
marginsTwips: {
top: 1134,
right: 1020,
bottom: 1247,
left: 1361
}
}
}
}
];
const runtime = new PandocRuntime(
process.env.DOCX_PANDOC_PATH?.trim()
? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() }
: {}
);
const capability = await runtime.probe();
if (capability.capability.status !== "available") {
throw new Error(capability.capability.message);
}
if (
capability.capability.detectedVersion !== DOCX_PANDOC_VERSION
) {
throw new Error(
`Pandoc 版本不匹配:期望 ${DOCX_PANDOC_VERSION},实际 ${capability.capability.detectedVersion}`
);
}
const converter = new PandocDocxConverter({ runtime });
fs.mkdirSync(outputDirectory, { recursive: true });
const results = [];
for (const variant of variants) {
const result = await converter.convert({
markdown,
fileName: `${variant.id}.md`,
language: "zh-CN",
exportConfig: variant.exportConfig,
theme: variant.theme,
metadata: {
title: "v0.6.0 DOCX 自动验收",
author: "Markdown PDF 导出器研发组",
subject: "Word 与 WPS 可编辑性验收",
keywords: ["DOCX", "Pandoc", "OOXML"],
language: "zh-CN"
},
media: createMedia()
});
const report = inspectDocxAcceptance(
result.docx,
variant.expectation
);
const outputPath = path.join(outputDirectory, `${variant.id}.docx`);
fs.writeFileSync(outputPath, result.docx);
results.push({
id: variant.id,
themeId: variant.theme.id,
outputFile: path.relative(repositoryDirectory, outputPath),
bytes: result.docx.byteLength,
templateFingerprint: result.templateFingerprint,
templateCacheKey: result.templateCacheKey,
conversionValidation: result.validation,
conversionTimings: result.timings,
acceptance: report
});
}
const acceptanceReport = {
generatedAt: new Date().toISOString(),
pandocVersion: capability.capability.detectedVersion,
fixture: path.relative(
repositoryDirectory,
path.join(packageDirectory, "fixtures", "docx-acceptance.md")
),
variants: results
};
const reportPath = path.join(outputDirectory, "acceptance-report.json");
fs.writeFileSync(
reportPath,
`${JSON.stringify(acceptanceReport, null, 2)}\n`,
"utf8"
);
console.log(JSON.stringify(acceptanceReport, null, 2));
@@ -0,0 +1,555 @@
import path from "node:path";
import { MAXIMUM_DOCX_OUTPUT_BYTES } from "@md-to-pdf/core";
import {
OFFICE_RELATIONSHIP_NAMESPACE,
PACKAGE_RELATIONSHIP_NAMESPACE,
WORD_NAMESPACE,
parseXmlPart,
type XmlElement
} from "./ooxml.js";
import {
MAXIMUM_GENERATED_DOCX_ENTRY_COUNT,
MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES,
readDocxPackage
} from "./reference-package.js";
import {
validateGeneratedDocx,
type DynamicReferenceValidation
} from "./validator.js";
const OFFICE_MATH_NAMESPACE =
"http://schemas.openxmlformats.org/officeDocument/2006/math";
const WORDPROCESSING_DRAWING_NAMESPACE =
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
const IMAGE_RELATIONSHIP_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
const HYPERLINK_RELATIONSHIP_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
const PNG_SIGNATURE = Uint8Array.of(
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a
);
export interface DocxAcceptancePageExpectation {
widthTwips: number;
heightTwips: number;
orientation: "portrait" | "landscape";
marginsTwips: {
top: number;
right: number;
bottom: number;
left: number;
};
}
export interface DocxAcceptanceExpectation {
id: string;
page: DocxAcceptancePageExpectation;
requiredText: readonly string[];
requiredFootnoteText?: readonly string[];
minimumParagraphs?: number;
minimumTextRuns?: number;
minimumTables?: number;
minimumNumberedParagraphs?: number;
minimumHyperlinks?: number;
minimumFootnoteReferences?: number;
minimumMathObjects?: number;
minimumDrawings?: number;
minimumPngImages?: number;
requiredImageAltText?: readonly string[];
requiredStyleIds?: readonly string[];
requirePageField?: boolean;
}
export interface DocxAcceptanceReport {
id: string;
package: DynamicReferenceValidation;
fingerprint: string;
page: {
widthTwips: number;
heightTwips: number;
orientation: "portrait" | "landscape";
marginsTwips: {
top: number;
right: number;
bottom: number;
left: number;
};
};
structure: {
paragraphCount: number;
textRunCount: number;
tableCount: number;
numberedParagraphCount: number;
hyperlinkCount: number;
footnoteReferenceCount: number;
mathObjectCount: number;
drawingCount: number;
pngImageCount: number;
pageFieldCount: number;
altChunkCount: number;
};
imageAltText: string[];
styleIds: string[];
checks: Record<string, true>;
}
interface Relationship {
id: string;
type: string;
target: string;
external: boolean;
}
function fail(id: string, check: string, detail: string): never {
throw new Error(`${id} 的 DOCX 自动验收失败:${check}${detail}`);
}
function numericAttribute(
element: XmlElement,
localName: string,
id: string,
check: string
) {
const value = element.getAttributeNS(WORD_NAMESPACE, localName);
if (!value || !/^\d+$/u.test(value)) {
fail(id, check, `缺少有效的 w:${localName}`);
}
return Number(value);
}
function assertEqual(
id: string,
check: string,
actual: unknown,
expected: unknown
) {
if (actual !== expected) {
fail(
id,
check,
`期望 ${JSON.stringify(expected)},实际 ${JSON.stringify(actual)}`
);
}
}
function assertMinimum(
id: string,
check: string,
actual: number,
expected = 0
) {
if (actual < expected) {
fail(id, check, `期望至少 ${expected},实际 ${actual}`);
}
}
function parseRelationshipPart(
content: Uint8Array,
partName: string
) {
const document = parseXmlPart(content, partName);
const relationships: Relationship[] = [];
for (const element of Array.from(
document.getElementsByTagNameNS(
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} 包含不完整的关系声明`);
}
relationships.push({
id,
type,
target,
external:
(element.getAttribute("TargetMode") ?? "").toLowerCase() ===
"external"
});
}
return relationships;
}
function resolveDocumentTarget(target: string) {
return path.posix.normalize(
path.posix.join("word", target.replace(/^\/+/u, ""))
);
}
function hasPngSignature(content: Uint8Array) {
return (
content.byteLength >= PNG_SIGNATURE.byteLength &&
PNG_SIGNATURE.every((value, index) => content[index] === value)
);
}
function collectPageFieldCount(
entries: ReadonlyMap<string, Uint8Array>
) {
let count = 0;
for (const [partName, content] of entries) {
if (!/^word\/(?:document|header\d+|footer\d+)\.xml$/u.test(partName)) {
continue;
}
const document = parseXmlPart(content, partName);
for (const element of Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "instrText")
)) {
if (/\bPAGE\b/u.test(element.textContent ?? "")) {
count += 1;
}
}
}
return count;
}
export function inspectDocxAcceptance(
content: Uint8Array,
expectation: DocxAcceptanceExpectation
): DocxAcceptanceReport {
const packageValidation = validateGeneratedDocx(content);
const packageContent = readDocxPackage(content, {
maximumBytes: MAXIMUM_DOCX_OUTPUT_BYTES,
maximumEntryCount: MAXIMUM_GENERATED_DOCX_ENTRY_COUNT,
maximumUncompressedBytes:
MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES
});
const entries = packageContent.entries;
const documentPart = entries.get("word/document.xml")!;
const document = parseXmlPart(documentPart, "word/document.xml");
const sections = Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "sectPr")
);
const section = sections.at(-1);
if (!section) {
fail(expectation.id, "page-section", "缺少最终节");
}
const pageSize = Array.from(
section.getElementsByTagNameNS(WORD_NAMESPACE, "pgSz")
)[0];
const pageMargins = Array.from(
section.getElementsByTagNameNS(WORD_NAMESPACE, "pgMar")
)[0];
if (!pageSize || !pageMargins) {
fail(expectation.id, "page-layout", "缺少纸张或页边距设置");
}
const actualPage = {
widthTwips: numericAttribute(
pageSize,
"w",
expectation.id,
"page-width"
),
heightTwips: numericAttribute(
pageSize,
"h",
expectation.id,
"page-height"
),
orientation:
pageSize.getAttributeNS(WORD_NAMESPACE, "orient") === "landscape"
? ("landscape" as const)
: ("portrait" as const),
marginsTwips: {
top: numericAttribute(
pageMargins,
"top",
expectation.id,
"margin-top"
),
right: numericAttribute(
pageMargins,
"right",
expectation.id,
"margin-right"
),
bottom: numericAttribute(
pageMargins,
"bottom",
expectation.id,
"margin-bottom"
),
left: numericAttribute(
pageMargins,
"left",
expectation.id,
"margin-left"
)
}
};
assertEqual(
expectation.id,
"page-width",
actualPage.widthTwips,
expectation.page.widthTwips
);
assertEqual(
expectation.id,
"page-height",
actualPage.heightTwips,
expectation.page.heightTwips
);
assertEqual(
expectation.id,
"page-orientation",
actualPage.orientation,
expectation.page.orientation
);
for (const side of ["top", "right", "bottom", "left"] as const) {
assertEqual(
expectation.id,
`margin-${side}`,
actualPage.marginsTwips[side],
expectation.page.marginsTwips[side]
);
}
const paragraphCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"p"
).length;
const textRunCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"t"
).length;
const tableCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"tbl"
).length;
const numberedParagraphCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"numPr"
).length;
const hyperlinkCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"hyperlink"
).length;
const footnoteReferenceCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"footnoteReference"
).length;
const mathObjectCount = document.getElementsByTagNameNS(
OFFICE_MATH_NAMESPACE,
"oMath"
).length;
const drawingCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"drawing"
).length;
const altChunkCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"altChunk"
).length;
assertMinimum(
expectation.id,
"paragraphs",
paragraphCount,
expectation.minimumParagraphs
);
assertMinimum(
expectation.id,
"text-runs",
textRunCount,
expectation.minimumTextRuns
);
assertMinimum(
expectation.id,
"tables",
tableCount,
expectation.minimumTables
);
assertMinimum(
expectation.id,
"numbering",
numberedParagraphCount,
expectation.minimumNumberedParagraphs
);
assertMinimum(
expectation.id,
"hyperlinks",
hyperlinkCount,
expectation.minimumHyperlinks
);
assertMinimum(
expectation.id,
"footnotes",
footnoteReferenceCount,
expectation.minimumFootnoteReferences
);
assertMinimum(
expectation.id,
"math",
mathObjectCount,
expectation.minimumMathObjects
);
assertMinimum(
expectation.id,
"drawings",
drawingCount,
expectation.minimumDrawings
);
assertEqual(expectation.id, "altChunk", altChunkCount, 0);
const bodyText = document.documentElement?.textContent ?? "";
for (const requiredText of expectation.requiredText) {
if (!bodyText.includes(requiredText)) {
fail(
expectation.id,
"editable-text",
`缺少文本 ${JSON.stringify(requiredText)}`
);
}
}
if ((expectation.requiredFootnoteText?.length ?? 0) > 0) {
const footnotesPart = entries.get("word/footnotes.xml");
if (!footnotesPart) {
fail(expectation.id, "footnote-text", "缺少 word/footnotes.xml");
}
const footnotes = parseXmlPart(
footnotesPart,
"word/footnotes.xml"
);
const footnoteText = footnotes.documentElement?.textContent ?? "";
for (const requiredText of expectation.requiredFootnoteText ?? []) {
if (!footnoteText.includes(requiredText)) {
fail(
expectation.id,
"footnote-text",
`缺少脚注文本 ${JSON.stringify(requiredText)}`
);
}
}
}
const relationshipPart = entries.get(
"word/_rels/document.xml.rels"
)!;
const relationships = parseRelationshipPart(
relationshipPart,
"word/_rels/document.xml.rels"
);
const hyperlinkRelationships = relationships.filter(
(relationship) =>
relationship.type === HYPERLINK_RELATIONSHIP_TYPE &&
relationship.external
).length;
assertMinimum(
expectation.id,
"hyperlink-relationships",
hyperlinkRelationships,
expectation.minimumHyperlinks
);
const imageRelationships = relationships.filter(
(relationship) =>
relationship.type === IMAGE_RELATIONSHIP_TYPE &&
!relationship.external
);
const pngParts = new Set<string>();
for (const relationship of imageRelationships) {
const partName = resolveDocumentTarget(relationship.target);
const image = entries.get(partName);
if (!image) {
fail(
expectation.id,
"image-relationship",
`${relationship.id} 指向缺失部件 ${partName}`
);
}
if (partName.toLowerCase().endsWith(".png") && hasPngSignature(image)) {
pngParts.add(partName);
}
}
assertMinimum(
expectation.id,
"png-images",
pngParts.size,
expectation.minimumPngImages
);
const imageAltText = Array.from(
document.getElementsByTagNameNS(
WORDPROCESSING_DRAWING_NAMESPACE,
"docPr"
)
)
.flatMap((element) => [
element.getAttribute("descr"),
element.getAttribute("title")
])
.filter((value): value is string => Boolean(value));
for (const requiredAltText of expectation.requiredImageAltText ?? []) {
if (!imageAltText.includes(requiredAltText)) {
fail(
expectation.id,
"image-alt-text",
`缺少替代文本 ${JSON.stringify(requiredAltText)}`
);
}
}
const styles = parseXmlPart(
entries.get("word/styles.xml")!,
"word/styles.xml"
);
const styleIds = Array.from(
styles.getElementsByTagNameNS(WORD_NAMESPACE, "style")
)
.map((element) =>
element.getAttributeNS(WORD_NAMESPACE, "styleId")
)
.filter((value): value is string => Boolean(value));
for (const requiredStyleId of expectation.requiredStyleIds ?? []) {
if (!styleIds.includes(requiredStyleId)) {
fail(
expectation.id,
"styles",
`缺少样式 ${requiredStyleId}`
);
}
}
const pageFieldCount = collectPageFieldCount(entries);
if (expectation.requirePageField) {
assertMinimum(expectation.id, "page-field", pageFieldCount, 1);
}
return {
id: expectation.id,
package: packageValidation,
fingerprint: packageContent.fingerprint,
page: actualPage,
structure: {
paragraphCount,
textRunCount,
tableCount,
numberedParagraphCount,
hyperlinkCount,
footnoteReferenceCount,
mathObjectCount,
drawingCount,
pngImageCount: pngParts.size,
pageFieldCount,
altChunkCount
},
imageAltText,
styleIds,
checks: {
package: true,
page: true,
editableStructure: true,
relationships: true,
pngMedia: true,
styles: true,
noAltChunk: true
}
};
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./acceptance-validator.js";
export * from "./header-footer-transform.js";
export * from "./ooxml.js";
export * from "./pandoc-process.js";
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { zipSync } from "fflate";
import { inspectDocxAcceptance } from "../src/index.js";
const encoder = new TextEncoder();
const word =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const relationships =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const packageRelationships =
"http://schemas.openxmlformats.org/package/2006/relationships";
const math =
"http://schemas.openxmlformats.org/officeDocument/2006/math";
const drawing =
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
const png = Uint8Array.of(
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
0,
0,
0,
0
);
function xml(value: string) {
return encoder.encode(value);
}
function createAcceptanceDocx(overrides: { altChunk?: boolean } = {}) {
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"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/></Types>'
),
"_rels/.rels": xml(
`<Relationships xmlns="${packageRelationships}"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`
),
"word/document.xml": xml(
`<w:document xmlns:w="${word}" xmlns:r="${relationships}" xmlns:m="${math}" xmlns:wp="${drawing}"><w:body><w:p><w:pPr><w:numPr><w:numId w:val="1"/></w:numPr></w:pPr><w:hyperlink r:id="rIdLink"><w:r><w:t>可编辑正文</w:t></w:r></w:hyperlink><w:r><w:footnoteReference w:id="1"/></w:r><m:oMath><m:r><m:t>x</m:t></m:r></m:oMath></w:p><w:tbl><w:tr><w:tc><w:p><w:r><w:t>原生表格</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p><w:r><w:drawing><wp:inline><wp:docPr id="1" name="图片 1" descr="普通图片"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/></wp:inline></w:drawing></w:r></w:p>${overrides.altChunk ? '<w:altChunk r:id="rIdChunk"/>' : ""}<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="907" w:right="907" w:bottom="907" w:left="907"/></w:sectPr></w:body></w:document>`
),
"word/styles.xml": xml(
`<w:styles xmlns:w="${word}">${["Normal", "Heading1", "SourceCode", "Table", "Caption"].map((id) => `<w:style w:type="paragraph" w:styleId="${id}"/>`).join("")}</w:styles>`
),
"word/settings.xml": xml(`<w:settings xmlns:w="${word}"/>`),
"word/fontTable.xml": xml(`<w:fonts xmlns:w="${word}"/>`),
"word/numbering.xml": xml(`<w:numbering xmlns:w="${word}"/>`),
"word/theme/theme1.xml": xml(
'<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/>'
),
"word/footer1.xml": xml(
`<w:ftr xmlns:w="${word}"><w:p><w:r><w:instrText> PAGE \\* MERGEFORMAT </w:instrText></w:r></w:p></w:ftr>`
),
"word/footnotes.xml": xml(
`<w:footnotes xmlns:w="${word}"><w:footnote w:id="1"><w:p><w:r><w:t>脚注</w:t></w:r></w:p></w:footnote></w:footnotes>`
),
"word/media/image1.png": png,
"word/_rels/document.xml.rels": xml(
`<Relationships xmlns="${packageRelationships}"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rIdFooter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/><Relationship Id="rIdImage" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/><Relationship Id="rIdLink" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.invalid/" TargetMode="External"/>${overrides.altChunk ? '<Relationship Id="rIdChunk" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" Target="chunk.html"/>' : ""}</Relationships>`
),
...(overrides.altChunk
? { "word/chunk.html": encoder.encode("<p>整体 HTML</p>") }
: {})
});
}
const expectation = {
id: "unit-fixture",
page: {
widthTwips: 11906,
heightTwips: 16838,
orientation: "portrait" as const,
marginsTwips: {
top: 907,
right: 907,
bottom: 907,
left: 907
}
},
requiredText: ["可编辑正文", "原生表格"],
requiredFootnoteText: ["脚注"],
minimumParagraphs: 3,
minimumTextRuns: 2,
minimumTables: 1,
minimumNumberedParagraphs: 1,
minimumHyperlinks: 1,
minimumFootnoteReferences: 1,
minimumMathObjects: 1,
minimumPngImages: 1,
requiredImageAltText: ["普通图片"],
requiredStyleIds: ["Normal", "Heading1", "SourceCode", "Table"],
requirePageField: true
};
describe("DOCX 自动验收器", () => {
it("验证纸张、可编辑结构、关系、PNG 和字段", () => {
const report = inspectDocxAcceptance(
createAcceptanceDocx(),
expectation
);
expect(report.page.orientation).toBe("portrait");
expect(report.structure).toMatchObject({
tableCount: 1,
numberedParagraphCount: 1,
hyperlinkCount: 1,
footnoteReferenceCount: 1,
mathObjectCount: 1,
pngImageCount: 1,
pageFieldCount: 1,
altChunkCount: 0
});
expect(report.checks).toEqual(
expect.objectContaining({
editableStructure: true,
pngMedia: true,
noAltChunk: true
})
);
});
it("拒绝通过 altChunk 嵌入的正文 HTML", () => {
expect(() =>
inspectDocxAcceptance(
createAcceptanceDocx({ altChunk: true }),
expectation
)
).toThrow("altChunk");
});
});