test: 建立 DOCX 自动化验收门禁
This commit is contained in:
@@ -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