test: 建立 DOCX 自动化验收门禁
This commit is contained in:
@@ -52,6 +52,7 @@ import {
|
|||||||
toDesktopDocxExportFailure,
|
toDesktopDocxExportFailure,
|
||||||
type DesktopDocxExportOutcome
|
type DesktopDocxExportOutcome
|
||||||
} from "./docx-contract.js";
|
} from "./docx-contract.js";
|
||||||
|
import { saveDesktopDocx } from "./desktop-docx-save.js";
|
||||||
import { ElectronDocxMediaEngine } from "./electron-docx-media-engine.js";
|
import { ElectronDocxMediaEngine } from "./electron-docx-media-engine.js";
|
||||||
import {
|
import {
|
||||||
parseMarkdownRenderRequest,
|
parseMarkdownRenderRequest,
|
||||||
@@ -987,16 +988,14 @@ export class DesktopApplicationController {
|
|||||||
timings: generated.timings
|
timings: generated.timings
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const targetPath = selection.filePath
|
const saved = await saveDesktopDocx(
|
||||||
.toLowerCase()
|
selection.filePath,
|
||||||
.endsWith(".docx")
|
generated.docx
|
||||||
? selection.filePath
|
);
|
||||||
: `${selection.filePath}.docx`;
|
|
||||||
await writeFile(targetPath, generated.docx);
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
saved: true,
|
saved: true,
|
||||||
fileName: path.basename(targetPath),
|
fileName: saved.fileName,
|
||||||
diagnostics: generated.diagnostics,
|
diagnostics: generated.diagnostics,
|
||||||
timings: generated.timings
|
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",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "vitest run",
|
"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": {
|
"dependencies": {
|
||||||
"@md-to-pdf/application": "0.4.1",
|
"@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 作为
|
退出会等待 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. 已完成
|
||||||
|
|
||||||
### 2.1 项目骨架
|
### 2.1 项目骨架
|
||||||
@@ -1044,7 +1054,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
- 阶段 6:已实现动态 reference.docx、主题样式映射和真实 Pandoc 验证;
|
- 阶段 6:已实现动态 reference.docx、主题样式映射和真实 Pandoc 验证;
|
||||||
- 阶段 7:已实现 Pandoc 运行时、Lua 媒体映射和共享转换服务;
|
- 阶段 7:已实现 Pandoc 运行时、Lua 媒体映射和共享转换服务;
|
||||||
- 阶段 8:已实现 Web/Desktop DOCX 导出交互;
|
- 阶段 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`;
|
6. 动态 `reference.docx`;
|
||||||
7. Pandoc DOCX 转换服务;
|
7. Pandoc DOCX 转换服务;
|
||||||
8. Web 与 Desktop 导出交互;
|
8. Web 与 Desktop 导出交互;
|
||||||
9. 自动化测试与 Word/WPS 验收;
|
9. DOCX 自动化结构、真实 Pandoc、Server HTTP 与 Desktop 保存验收;
|
||||||
10. 体积、文档和正式发布。
|
10. Word/WPS 双向互存、视觉、体积、文档和正式发布。
|
||||||
|
|
||||||
每个阶段遵循:
|
每个阶段遵循:
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
"theme:import-typora": "node scripts/import-typora-theme.mjs",
|
"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-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-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",
|
"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"
|
"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 build -w @md-to-pdf/docx-engine
|
||||||
npm run verify:docx-reference
|
npm run verify:docx-reference
|
||||||
npm run verify:docx-conversion
|
npm run verify:docx-conversion
|
||||||
|
npm run verify:docx-acceptance
|
||||||
```
|
```
|
||||||
|
|
||||||
`verify:docx-reference` 要求本机 `PATH` 中存在 Pandoc 3.9.0.2,也可以通过
|
`verify:docx-reference` 要求本机 `PATH` 中存在 Pandoc 3.9.0.2,也可以通过
|
||||||
`DOCX_PANDOC_PATH` 指定可执行文件。脚本从 Pandoc 读取原始默认模板,
|
`DOCX_PANDOC_PATH` 指定可执行文件。脚本从 Pandoc 读取原始默认模板,
|
||||||
分别验证公文 A4 与自定义横向模板,并在系统临时目录中完成转换和清理,
|
分别验证公文 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",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
|
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
|
||||||
"verify:pandoc": "node scripts/verify-pandoc-reference.mjs",
|
"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": {
|
"dependencies": {
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@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 "./header-footer-transform.js";
|
||||||
export * from "./ooxml.js";
|
export * from "./ooxml.js";
|
||||||
export * from "./pandoc-process.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