Files
MorphDoc/packages/docx-engine/src/acceptance-validator.ts
T

556 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
};
}