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

This commit is contained in:
SkyJourney
2026-07-30 16:48:41 +08:00
parent f2dfc6ef72
commit aff2830c28
15 changed files with 1452 additions and 12 deletions
+9
View File
@@ -36,9 +36,18 @@ npm run typecheck -w @md-to-pdf/docx-engine
npm run build -w @md-to-pdf/docx-engine
npm run verify:docx-reference
npm run verify:docx-conversion
npm run verify:docx-acceptance
```
`verify:docx-reference` 要求本机 `PATH` 中存在 Pandoc 3.9.0.2,也可以通过
`DOCX_PANDOC_PATH` 指定可执行文件。脚本从 Pandoc 读取原始默认模板,
分别验证公文 A4 与自定义横向模板,并在系统临时目录中完成转换和清理,
不会保留用户文档或验收产物。
`verify:docx-acceptance` 使用综合 Markdown 夹具和固定 Pandoc 生成技术
文档 A4、公文 A4、技术文档 Letter 横向三套 DOCX,自动检查纸张、页边距、
原生段落、标题、编号、表格、链接、脚注、OMML、PNG、页眉页脚、页码、
关键样式和 `altChunk` 禁用门禁。DOCX 与 JSON 报告写入被 Git 忽略的
`output/docx-acceptance/`,供 Word/WPS 互操作验收使用。根级命令还会
依次执行动态模板、媒体转换、Server HTTP 和 Desktop 原生保存验收;
仅需重跑三配置矩阵时可使用 `npm run verify:docx-matrix`
@@ -0,0 +1,75 @@
---
title: v0.6.0 DOCX 自动验收
author: Markdown PDF 导出器研发组
subject: Word 与 WPS 可编辑性验收
keywords: [DOCX, Pandoc, OOXML]
lang: zh-CN
---
# 一级标题:可编辑文档
这是一段可编辑的中文正文,包含 **加粗文本**、*斜体文本*、
~~删除线文本~~`inlineCode()`
[外部链接](https://example.invalid/docx-acceptance)。
## 二级标题:段落结构
> 引用段落用于确认内容仍是 Word 原生段落,而不是页面截图。
- 无序列表第一项
- 嵌套无序列表
- 无序列表第二项
1. 有序列表第一项
2. 有序列表第二项
- [x] 已完成任务
- [ ] 未完成任务
### 三级标题:表格与代码
| 验收项 | Word 结构 | 预期结果 |
| :--- | :---: | ---: |
| 正文 | 段落与文本 Run | 可编辑 |
| 表格 | 原生表格与单元格 | 可增删内容 |
| 图表 | 高分辨率 PNG | 可调整图片 |
```typescript
export function editableDocument(value: string) {
return `DOCX 内容仍可编辑:${value}`;
}
```
脚注引用仍应保留原生结构。[^editable-footnote]
行内公式 $E = mc^2$ 与块级公式:
$$
\int_0^1 x^2\,dx = \frac{1}{3}
$$
![普通图片](ignored-original-image.png)
```mermaid
flowchart LR
A[Markdown] --> B[Pandoc]
B --> C[可编辑 DOCX]
```
```echarts
version: 1
caption: ECharts 柱状图
option:
xAxis:
type: category
data: [一月, 二月, 三月]
yAxis:
type: value
series:
- type: bar
data: [12, 20, 16]
```
最后一段用于确认正文没有通过 `altChunk` 嵌入 HTML,也没有整体图片化。
[^editable-footnote]: 这是可以继续编辑的 Word 原生脚注。
+2 -1
View File
@@ -21,7 +21,8 @@
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
"verify:pandoc": "node scripts/verify-pandoc-reference.mjs",
"verify:conversion": "node scripts/verify-pandoc-conversion.mjs"
"verify:conversion": "node scripts/verify-pandoc-conversion.mjs",
"verify:acceptance": "node scripts/verify-docx-acceptance.mjs"
},
"dependencies": {
"@md-to-pdf/core": "0.1.0",
@@ -0,0 +1,333 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
DOCX_PANDOC_VERSION,
defaultExportConfig,
themeManifestSchema
} from "@md-to-pdf/core";
import {
PandocDocxConverter,
PandocRuntime,
inspectDocxAcceptance
} from "@md-to-pdf/docx-engine";
const directory = path.dirname(fileURLToPath(import.meta.url));
const packageDirectory = path.resolve(directory, "..");
const repositoryDirectory = path.resolve(packageDirectory, "../..");
const outputDirectory = path.join(
repositoryDirectory,
"output",
"docx-acceptance"
);
const markdown = fs.readFileSync(
path.join(packageDirectory, "fixtures", "docx-acceptance.md"),
"utf8"
);
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64"
);
function loadTheme(id) {
return themeManifestSchema.parse(
JSON.parse(
fs.readFileSync(
path.join(repositoryDirectory, "themes", id, "theme.json"),
"utf8"
)
)
);
}
function mediaResource(kind, ordinal, dimensions, altText, caption) {
return {
id: `docx-media-${ordinal}`,
kind,
ordinal,
kindOrdinal: 1,
altText,
...(caption ? { caption } : {}),
displayWidthPx: dimensions.width,
displayHeightPx: dimensions.height,
captureX: 0,
captureY: 0,
captureWidthPx: dimensions.width,
captureHeightPx: dimensions.height,
rasterScale: 1,
fileName: `${kind}-${ordinal}.png`,
contentType: "image/png",
content: png,
pixelWidth: 1,
pixelHeight: 1
};
}
function createMedia() {
const resources = [
mediaResource(
"image",
1,
{ width: 384, height: 192 },
"普通图片"
),
mediaResource(
"mermaid",
2,
{ width: 480, height: 240 },
"Mermaid 流程图",
"Mermaid 流程图"
),
mediaResource(
"echarts",
3,
{ width: 576, height: 288 },
"ECharts 柱状图",
"ECharts 柱状图"
)
];
return {
resources,
echartsErrors: [],
mermaidErrors: [],
warnings: [],
totalBytes: png.byteLength * resources.length
};
}
const technicalTheme = loadTheme("typora-like");
const officialTheme = loadTheme("gov-red-standard");
const commonExpectation = {
requiredText: [
"可编辑的中文正文",
"原生表格与单元格",
"editableDocument",
"正文没有通过"
],
minimumParagraphs: 18,
minimumTextRuns: 24,
minimumTables: 1,
minimumNumberedParagraphs: 6,
minimumHyperlinks: 1,
minimumFootnoteReferences: 1,
minimumMathObjects: 2,
minimumDrawings: 3,
minimumPngImages: 1,
requiredImageAltText: [
"普通图片",
"Mermaid 流程图",
"ECharts 柱状图"
],
requiredStyleIds: [
"Normal",
"Heading1",
"Heading2",
"Heading3",
"SourceCode",
"Table",
"Caption"
],
requirePageField: true
};
const technicalA4Config = {
...defaultExportConfig,
name: "技术文档 A4 纵向验收",
themeId: technicalTheme.id,
pageDecorationsMode: "custom",
paper: {
...defaultExportConfig.paper,
format: "A4",
orientation: "portrait",
marginMode: "custom",
margins: {
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
}
}
};
const officialA4Config = {
...defaultExportConfig,
name: "公文 A4 纵向验收",
themeId: officialTheme.id,
pageDecorationsMode: "theme",
paper: {
...defaultExportConfig.paper,
format: "A4",
orientation: "portrait",
marginMode: "theme"
}
};
const landscapeLetterConfig = {
...defaultExportConfig,
name: "技术文档 Letter 横向验收",
themeId: technicalTheme.id,
pageDecorationsMode: "custom",
paper: {
...defaultExportConfig.paper,
format: "Letter",
orientation: "landscape",
marginMode: "custom",
margins: {
top: "20mm",
right: "18mm",
bottom: "22mm",
left: "24mm"
}
},
header: {
...defaultExportConfig.header,
enabled: true,
left: {
enabled: true,
content: "${title}"
},
right: {
enabled: true,
content: "${filename}"
}
},
footer: {
...defaultExportConfig.footer,
alignment: "right",
format: "page-total",
startFrom: 3
}
};
const variants = [
{
id: "technical-a4-portrait",
theme: technicalTheme,
exportConfig: technicalA4Config,
expectation: {
...commonExpectation,
id: "technical-a4-portrait",
page: {
widthTwips: 11906,
heightTwips: 16838,
orientation: "portrait",
marginsTwips: {
top: 907,
right: 907,
bottom: 907,
left: 907
}
}
}
},
{
id: "official-a4-portrait",
theme: officialTheme,
exportConfig: officialA4Config,
expectation: {
...commonExpectation,
id: "official-a4-portrait",
page: {
widthTwips: 11906,
heightTwips: 16838,
orientation: "portrait",
marginsTwips: {
top: 2098,
right: 1474,
bottom: 1984,
left: 1587
}
}
}
},
{
id: "technical-letter-landscape",
theme: technicalTheme,
exportConfig: landscapeLetterConfig,
expectation: {
...commonExpectation,
id: "technical-letter-landscape",
page: {
widthTwips: 15817,
heightTwips: 12246,
orientation: "landscape",
marginsTwips: {
top: 1134,
right: 1020,
bottom: 1247,
left: 1361
}
}
}
}
];
const runtime = new PandocRuntime(
process.env.DOCX_PANDOC_PATH?.trim()
? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() }
: {}
);
const capability = await runtime.probe();
if (capability.capability.status !== "available") {
throw new Error(capability.capability.message);
}
if (
capability.capability.detectedVersion !== DOCX_PANDOC_VERSION
) {
throw new Error(
`Pandoc 版本不匹配:期望 ${DOCX_PANDOC_VERSION},实际 ${capability.capability.detectedVersion}`
);
}
const converter = new PandocDocxConverter({ runtime });
fs.mkdirSync(outputDirectory, { recursive: true });
const results = [];
for (const variant of variants) {
const result = await converter.convert({
markdown,
fileName: `${variant.id}.md`,
language: "zh-CN",
exportConfig: variant.exportConfig,
theme: variant.theme,
metadata: {
title: "v0.6.0 DOCX 自动验收",
author: "Markdown PDF 导出器研发组",
subject: "Word 与 WPS 可编辑性验收",
keywords: ["DOCX", "Pandoc", "OOXML"],
language: "zh-CN"
},
media: createMedia()
});
const report = inspectDocxAcceptance(
result.docx,
variant.expectation
);
const outputPath = path.join(outputDirectory, `${variant.id}.docx`);
fs.writeFileSync(outputPath, result.docx);
results.push({
id: variant.id,
themeId: variant.theme.id,
outputFile: path.relative(repositoryDirectory, outputPath),
bytes: result.docx.byteLength,
templateFingerprint: result.templateFingerprint,
templateCacheKey: result.templateCacheKey,
conversionValidation: result.validation,
conversionTimings: result.timings,
acceptance: report
});
}
const acceptanceReport = {
generatedAt: new Date().toISOString(),
pandocVersion: capability.capability.detectedVersion,
fixture: path.relative(
repositoryDirectory,
path.join(packageDirectory, "fixtures", "docx-acceptance.md")
),
variants: results
};
const reportPath = path.join(outputDirectory, "acceptance-report.json");
fs.writeFileSync(
reportPath,
`${JSON.stringify(acceptanceReport, null, 2)}\n`,
"utf8"
);
console.log(JSON.stringify(acceptanceReport, null, 2));
@@ -0,0 +1,555 @@
import path from "node:path";
import { MAXIMUM_DOCX_OUTPUT_BYTES } from "@md-to-pdf/core";
import {
OFFICE_RELATIONSHIP_NAMESPACE,
PACKAGE_RELATIONSHIP_NAMESPACE,
WORD_NAMESPACE,
parseXmlPart,
type XmlElement
} from "./ooxml.js";
import {
MAXIMUM_GENERATED_DOCX_ENTRY_COUNT,
MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES,
readDocxPackage
} from "./reference-package.js";
import {
validateGeneratedDocx,
type DynamicReferenceValidation
} from "./validator.js";
const OFFICE_MATH_NAMESPACE =
"http://schemas.openxmlformats.org/officeDocument/2006/math";
const WORDPROCESSING_DRAWING_NAMESPACE =
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
const IMAGE_RELATIONSHIP_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
const HYPERLINK_RELATIONSHIP_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
const PNG_SIGNATURE = Uint8Array.of(
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a
);
export interface DocxAcceptancePageExpectation {
widthTwips: number;
heightTwips: number;
orientation: "portrait" | "landscape";
marginsTwips: {
top: number;
right: number;
bottom: number;
left: number;
};
}
export interface DocxAcceptanceExpectation {
id: string;
page: DocxAcceptancePageExpectation;
requiredText: readonly string[];
requiredFootnoteText?: readonly string[];
minimumParagraphs?: number;
minimumTextRuns?: number;
minimumTables?: number;
minimumNumberedParagraphs?: number;
minimumHyperlinks?: number;
minimumFootnoteReferences?: number;
minimumMathObjects?: number;
minimumDrawings?: number;
minimumPngImages?: number;
requiredImageAltText?: readonly string[];
requiredStyleIds?: readonly string[];
requirePageField?: boolean;
}
export interface DocxAcceptanceReport {
id: string;
package: DynamicReferenceValidation;
fingerprint: string;
page: {
widthTwips: number;
heightTwips: number;
orientation: "portrait" | "landscape";
marginsTwips: {
top: number;
right: number;
bottom: number;
left: number;
};
};
structure: {
paragraphCount: number;
textRunCount: number;
tableCount: number;
numberedParagraphCount: number;
hyperlinkCount: number;
footnoteReferenceCount: number;
mathObjectCount: number;
drawingCount: number;
pngImageCount: number;
pageFieldCount: number;
altChunkCount: number;
};
imageAltText: string[];
styleIds: string[];
checks: Record<string, true>;
}
interface Relationship {
id: string;
type: string;
target: string;
external: boolean;
}
function fail(id: string, check: string, detail: string): never {
throw new Error(`${id} 的 DOCX 自动验收失败:${check}${detail}`);
}
function numericAttribute(
element: XmlElement,
localName: string,
id: string,
check: string
) {
const value = element.getAttributeNS(WORD_NAMESPACE, localName);
if (!value || !/^\d+$/u.test(value)) {
fail(id, check, `缺少有效的 w:${localName}`);
}
return Number(value);
}
function assertEqual(
id: string,
check: string,
actual: unknown,
expected: unknown
) {
if (actual !== expected) {
fail(
id,
check,
`期望 ${JSON.stringify(expected)},实际 ${JSON.stringify(actual)}`
);
}
}
function assertMinimum(
id: string,
check: string,
actual: number,
expected = 0
) {
if (actual < expected) {
fail(id, check, `期望至少 ${expected},实际 ${actual}`);
}
}
function parseRelationshipPart(
content: Uint8Array,
partName: string
) {
const document = parseXmlPart(content, partName);
const relationships: Relationship[] = [];
for (const element of Array.from(
document.getElementsByTagNameNS(
PACKAGE_RELATIONSHIP_NAMESPACE,
"Relationship"
)
)) {
const id = element.getAttribute("Id");
const type = element.getAttribute("Type");
const target = element.getAttribute("Target");
if (!id || !type || !target) {
throw new Error(`${partName} 包含不完整的关系声明`);
}
relationships.push({
id,
type,
target,
external:
(element.getAttribute("TargetMode") ?? "").toLowerCase() ===
"external"
});
}
return relationships;
}
function resolveDocumentTarget(target: string) {
return path.posix.normalize(
path.posix.join("word", target.replace(/^\/+/u, ""))
);
}
function hasPngSignature(content: Uint8Array) {
return (
content.byteLength >= PNG_SIGNATURE.byteLength &&
PNG_SIGNATURE.every((value, index) => content[index] === value)
);
}
function collectPageFieldCount(
entries: ReadonlyMap<string, Uint8Array>
) {
let count = 0;
for (const [partName, content] of entries) {
if (!/^word\/(?:document|header\d+|footer\d+)\.xml$/u.test(partName)) {
continue;
}
const document = parseXmlPart(content, partName);
for (const element of Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "instrText")
)) {
if (/\bPAGE\b/u.test(element.textContent ?? "")) {
count += 1;
}
}
}
return count;
}
export function inspectDocxAcceptance(
content: Uint8Array,
expectation: DocxAcceptanceExpectation
): DocxAcceptanceReport {
const packageValidation = validateGeneratedDocx(content);
const packageContent = readDocxPackage(content, {
maximumBytes: MAXIMUM_DOCX_OUTPUT_BYTES,
maximumEntryCount: MAXIMUM_GENERATED_DOCX_ENTRY_COUNT,
maximumUncompressedBytes:
MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES
});
const entries = packageContent.entries;
const documentPart = entries.get("word/document.xml")!;
const document = parseXmlPart(documentPart, "word/document.xml");
const sections = Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "sectPr")
);
const section = sections.at(-1);
if (!section) {
fail(expectation.id, "page-section", "缺少最终节");
}
const pageSize = Array.from(
section.getElementsByTagNameNS(WORD_NAMESPACE, "pgSz")
)[0];
const pageMargins = Array.from(
section.getElementsByTagNameNS(WORD_NAMESPACE, "pgMar")
)[0];
if (!pageSize || !pageMargins) {
fail(expectation.id, "page-layout", "缺少纸张或页边距设置");
}
const actualPage = {
widthTwips: numericAttribute(
pageSize,
"w",
expectation.id,
"page-width"
),
heightTwips: numericAttribute(
pageSize,
"h",
expectation.id,
"page-height"
),
orientation:
pageSize.getAttributeNS(WORD_NAMESPACE, "orient") === "landscape"
? ("landscape" as const)
: ("portrait" as const),
marginsTwips: {
top: numericAttribute(
pageMargins,
"top",
expectation.id,
"margin-top"
),
right: numericAttribute(
pageMargins,
"right",
expectation.id,
"margin-right"
),
bottom: numericAttribute(
pageMargins,
"bottom",
expectation.id,
"margin-bottom"
),
left: numericAttribute(
pageMargins,
"left",
expectation.id,
"margin-left"
)
}
};
assertEqual(
expectation.id,
"page-width",
actualPage.widthTwips,
expectation.page.widthTwips
);
assertEqual(
expectation.id,
"page-height",
actualPage.heightTwips,
expectation.page.heightTwips
);
assertEqual(
expectation.id,
"page-orientation",
actualPage.orientation,
expectation.page.orientation
);
for (const side of ["top", "right", "bottom", "left"] as const) {
assertEqual(
expectation.id,
`margin-${side}`,
actualPage.marginsTwips[side],
expectation.page.marginsTwips[side]
);
}
const paragraphCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"p"
).length;
const textRunCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"t"
).length;
const tableCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"tbl"
).length;
const numberedParagraphCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"numPr"
).length;
const hyperlinkCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"hyperlink"
).length;
const footnoteReferenceCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"footnoteReference"
).length;
const mathObjectCount = document.getElementsByTagNameNS(
OFFICE_MATH_NAMESPACE,
"oMath"
).length;
const drawingCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"drawing"
).length;
const altChunkCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"altChunk"
).length;
assertMinimum(
expectation.id,
"paragraphs",
paragraphCount,
expectation.minimumParagraphs
);
assertMinimum(
expectation.id,
"text-runs",
textRunCount,
expectation.minimumTextRuns
);
assertMinimum(
expectation.id,
"tables",
tableCount,
expectation.minimumTables
);
assertMinimum(
expectation.id,
"numbering",
numberedParagraphCount,
expectation.minimumNumberedParagraphs
);
assertMinimum(
expectation.id,
"hyperlinks",
hyperlinkCount,
expectation.minimumHyperlinks
);
assertMinimum(
expectation.id,
"footnotes",
footnoteReferenceCount,
expectation.minimumFootnoteReferences
);
assertMinimum(
expectation.id,
"math",
mathObjectCount,
expectation.minimumMathObjects
);
assertMinimum(
expectation.id,
"drawings",
drawingCount,
expectation.minimumDrawings
);
assertEqual(expectation.id, "altChunk", altChunkCount, 0);
const bodyText = document.documentElement?.textContent ?? "";
for (const requiredText of expectation.requiredText) {
if (!bodyText.includes(requiredText)) {
fail(
expectation.id,
"editable-text",
`缺少文本 ${JSON.stringify(requiredText)}`
);
}
}
if ((expectation.requiredFootnoteText?.length ?? 0) > 0) {
const footnotesPart = entries.get("word/footnotes.xml");
if (!footnotesPart) {
fail(expectation.id, "footnote-text", "缺少 word/footnotes.xml");
}
const footnotes = parseXmlPart(
footnotesPart,
"word/footnotes.xml"
);
const footnoteText = footnotes.documentElement?.textContent ?? "";
for (const requiredText of expectation.requiredFootnoteText ?? []) {
if (!footnoteText.includes(requiredText)) {
fail(
expectation.id,
"footnote-text",
`缺少脚注文本 ${JSON.stringify(requiredText)}`
);
}
}
}
const relationshipPart = entries.get(
"word/_rels/document.xml.rels"
)!;
const relationships = parseRelationshipPart(
relationshipPart,
"word/_rels/document.xml.rels"
);
const hyperlinkRelationships = relationships.filter(
(relationship) =>
relationship.type === HYPERLINK_RELATIONSHIP_TYPE &&
relationship.external
).length;
assertMinimum(
expectation.id,
"hyperlink-relationships",
hyperlinkRelationships,
expectation.minimumHyperlinks
);
const imageRelationships = relationships.filter(
(relationship) =>
relationship.type === IMAGE_RELATIONSHIP_TYPE &&
!relationship.external
);
const pngParts = new Set<string>();
for (const relationship of imageRelationships) {
const partName = resolveDocumentTarget(relationship.target);
const image = entries.get(partName);
if (!image) {
fail(
expectation.id,
"image-relationship",
`${relationship.id} 指向缺失部件 ${partName}`
);
}
if (partName.toLowerCase().endsWith(".png") && hasPngSignature(image)) {
pngParts.add(partName);
}
}
assertMinimum(
expectation.id,
"png-images",
pngParts.size,
expectation.minimumPngImages
);
const imageAltText = Array.from(
document.getElementsByTagNameNS(
WORDPROCESSING_DRAWING_NAMESPACE,
"docPr"
)
)
.flatMap((element) => [
element.getAttribute("descr"),
element.getAttribute("title")
])
.filter((value): value is string => Boolean(value));
for (const requiredAltText of expectation.requiredImageAltText ?? []) {
if (!imageAltText.includes(requiredAltText)) {
fail(
expectation.id,
"image-alt-text",
`缺少替代文本 ${JSON.stringify(requiredAltText)}`
);
}
}
const styles = parseXmlPart(
entries.get("word/styles.xml")!,
"word/styles.xml"
);
const styleIds = Array.from(
styles.getElementsByTagNameNS(WORD_NAMESPACE, "style")
)
.map((element) =>
element.getAttributeNS(WORD_NAMESPACE, "styleId")
)
.filter((value): value is string => Boolean(value));
for (const requiredStyleId of expectation.requiredStyleIds ?? []) {
if (!styleIds.includes(requiredStyleId)) {
fail(
expectation.id,
"styles",
`缺少样式 ${requiredStyleId}`
);
}
}
const pageFieldCount = collectPageFieldCount(entries);
if (expectation.requirePageField) {
assertMinimum(expectation.id, "page-field", pageFieldCount, 1);
}
return {
id: expectation.id,
package: packageValidation,
fingerprint: packageContent.fingerprint,
page: actualPage,
structure: {
paragraphCount,
textRunCount,
tableCount,
numberedParagraphCount,
hyperlinkCount,
footnoteReferenceCount,
mathObjectCount,
drawingCount,
pngImageCount: pngParts.size,
pageFieldCount,
altChunkCount
},
imageAltText,
styleIds,
checks: {
package: true,
page: true,
editableStructure: true,
relationships: true,
pngMedia: true,
styles: true,
noAltChunk: true
}
};
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./acceptance-validator.js";
export * from "./header-footer-transform.js";
export * from "./ooxml.js";
export * from "./pandoc-process.js";
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { zipSync } from "fflate";
import { inspectDocxAcceptance } from "../src/index.js";
const encoder = new TextEncoder();
const word =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const relationships =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const packageRelationships =
"http://schemas.openxmlformats.org/package/2006/relationships";
const math =
"http://schemas.openxmlformats.org/officeDocument/2006/math";
const drawing =
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
const png = Uint8Array.of(
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
0,
0,
0,
0
);
function xml(value: string) {
return encoder.encode(value);
}
function createAcceptanceDocx(overrides: { altChunk?: boolean } = {}) {
return zipSync({
"[Content_Types].xml": xml(
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/></Types>'
),
"_rels/.rels": xml(
`<Relationships xmlns="${packageRelationships}"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`
),
"word/document.xml": xml(
`<w:document xmlns:w="${word}" xmlns:r="${relationships}" xmlns:m="${math}" xmlns:wp="${drawing}"><w:body><w:p><w:pPr><w:numPr><w:numId w:val="1"/></w:numPr></w:pPr><w:hyperlink r:id="rIdLink"><w:r><w:t>可编辑正文</w:t></w:r></w:hyperlink><w:r><w:footnoteReference w:id="1"/></w:r><m:oMath><m:r><m:t>x</m:t></m:r></m:oMath></w:p><w:tbl><w:tr><w:tc><w:p><w:r><w:t>原生表格</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p><w:r><w:drawing><wp:inline><wp:docPr id="1" name="图片 1" descr="普通图片"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/></wp:inline></w:drawing></w:r></w:p>${overrides.altChunk ? '<w:altChunk r:id="rIdChunk"/>' : ""}<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="907" w:right="907" w:bottom="907" w:left="907"/></w:sectPr></w:body></w:document>`
),
"word/styles.xml": xml(
`<w:styles xmlns:w="${word}">${["Normal", "Heading1", "SourceCode", "Table", "Caption"].map((id) => `<w:style w:type="paragraph" w:styleId="${id}"/>`).join("")}</w:styles>`
),
"word/settings.xml": xml(`<w:settings xmlns:w="${word}"/>`),
"word/fontTable.xml": xml(`<w:fonts xmlns:w="${word}"/>`),
"word/numbering.xml": xml(`<w:numbering xmlns:w="${word}"/>`),
"word/theme/theme1.xml": xml(
'<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/>'
),
"word/footer1.xml": xml(
`<w:ftr xmlns:w="${word}"><w:p><w:r><w:instrText> PAGE \\* MERGEFORMAT </w:instrText></w:r></w:p></w:ftr>`
),
"word/footnotes.xml": xml(
`<w:footnotes xmlns:w="${word}"><w:footnote w:id="1"><w:p><w:r><w:t>脚注</w:t></w:r></w:p></w:footnote></w:footnotes>`
),
"word/media/image1.png": png,
"word/_rels/document.xml.rels": xml(
`<Relationships xmlns="${packageRelationships}"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rIdFooter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/><Relationship Id="rIdImage" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/><Relationship Id="rIdLink" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.invalid/" TargetMode="External"/>${overrides.altChunk ? '<Relationship Id="rIdChunk" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" Target="chunk.html"/>' : ""}</Relationships>`
),
...(overrides.altChunk
? { "word/chunk.html": encoder.encode("<p>整体 HTML</p>") }
: {})
});
}
const expectation = {
id: "unit-fixture",
page: {
widthTwips: 11906,
heightTwips: 16838,
orientation: "portrait" as const,
marginsTwips: {
top: 907,
right: 907,
bottom: 907,
left: 907
}
},
requiredText: ["可编辑正文", "原生表格"],
requiredFootnoteText: ["脚注"],
minimumParagraphs: 3,
minimumTextRuns: 2,
minimumTables: 1,
minimumNumberedParagraphs: 1,
minimumHyperlinks: 1,
minimumFootnoteReferences: 1,
minimumMathObjects: 1,
minimumPngImages: 1,
requiredImageAltText: ["普通图片"],
requiredStyleIds: ["Normal", "Heading1", "SourceCode", "Table"],
requirePageField: true
};
describe("DOCX 自动验收器", () => {
it("验证纸张、可编辑结构、关系、PNG 和字段", () => {
const report = inspectDocxAcceptance(
createAcceptanceDocx(),
expectation
);
expect(report.page.orientation).toBe("portrait");
expect(report.structure).toMatchObject({
tableCount: 1,
numberedParagraphCount: 1,
hyperlinkCount: 1,
footnoteReferenceCount: 1,
mathObjectCount: 1,
pngImageCount: 1,
pageFieldCount: 1,
altChunkCount: 0
});
expect(report.checks).toEqual(
expect.objectContaining({
editableStructure: true,
pngMedia: true,
noAltChunk: true
})
);
});
it("拒绝通过 altChunk 嵌入的正文 HTML", () => {
expect(() =>
inspectDocxAcceptance(
createAcceptanceDocx({ altChunk: true }),
expectation
)
).toThrow("altChunk");
});
});