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; } 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 ) { 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(); 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 } }; }