test: 建立 DOCX 自动化验收门禁
This commit is contained in:
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user