test: 建立 DOCX 自动化验收门禁
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 保存参数无效");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -124,6 +124,16 @@ Toast 与非确定进度条,不污染预览状态或 PDF 精确预览缓存。
|
||||
退出会等待 PDF、DOCX、Pandoc 与隐藏媒体窗口完成清理,Lua Filter 作为
|
||||
明确构建资源进入桌面主进程目录。
|
||||
|
||||
`v0.6.0` 阶段 9 已建立 DOCX 自动化验收门禁:综合样例覆盖可编辑正文、
|
||||
标题、字符样式、列表、表格、代码、链接、脚注、OMML、普通图片、
|
||||
Mermaid 和 ECharts;OOXML 验收器检查纸张、方向、四边页边距、原生结构、
|
||||
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 导出交互;
|
||||
- 阶段 9~10:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。
|
||||
- 阶段 9:已完成 DOCX 自动化结构、真实 Pandoc、Server HTTP 与
|
||||
Desktop 保存验收;
|
||||
- 阶段 10:完成 Word/WPS 双向互存、视觉、体积和正式发布验收。
|
||||
|
||||
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
||||
后续阶段的功能实现。
|
||||
|
||||
@@ -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 双向互存、视觉、体积、文档和正式发布。
|
||||
|
||||
每个阶段遵循:
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
$$
|
||||
|
||||

|
||||
|
||||
```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 原生脚注。
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user