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
@@ -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
}
};
}