import path from "node:path"; import { CONTENT_TYPES_NAMESPACE, OFFICE_RELATIONSHIP_NAMESPACE, PACKAGE_RELATIONSHIP_NAMESPACE, WORD_NAMESPACE, directChildren, parseXmlPart, type XmlElement } from "./ooxml.js"; import { readReferenceDocxPackage } from "./reference-package.js"; const HEADER_RELATIONSHIP_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"; const FOOTER_RELATIONSHIP_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer"; const HEADER_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"; const FOOTER_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"; const requiredStyleIds = [ "Normal", "Heading1", "SourceCode", "Table", "Caption" ] as const; interface Relationship { id: string; type: string; targetPart: string | undefined; } export interface DynamicReferenceValidation { partCount: number; xmlPartCount: number; relationshipCount: number; headerCount: number; footerCount: number; } function relationshipSourcePart(relationshipsPart: string) { if (relationshipsPart === "_rels/.rels") { return ""; } const match = /^(.*)\/_rels\/([^/]+)\.rels$/u.exec( relationshipsPart ); if (!match) { throw new Error(`OOXML 关系部件路径无效:${relationshipsPart}`); } return path.posix.join(match[1]!, match[2]!); } function resolveRelationshipTarget( relationshipsPart: string, target: string ) { if ( !target || target.includes("\\") || target.includes("\0") || /^[a-z][a-z0-9+.-]*:/iu.test(target) ) { throw new Error( `${relationshipsPart} 包含不安全的内部关系目标:${target}` ); } const sourcePart = relationshipSourcePart(relationshipsPart); const targetPart = target.startsWith("/") ? path.posix.normalize(target.slice(1)) : path.posix.normalize( path.posix.join(path.posix.dirname(sourcePart), target) ); if ( !targetPart || targetPart === "." || targetPart === ".." || targetPart.startsWith("../") || targetPart.startsWith("/") ) { throw new Error( `${relationshipsPart} 的内部关系目标越出 DOCX 包:${target}` ); } return targetPart; } function parseRelationships( partName: string, content: Uint8Array, entries: ReadonlyMap ) { const document = parseXmlPart(content, partName); const root = document.documentElement; if ( !root || root.namespaceURI !== PACKAGE_RELATIONSHIP_NAMESPACE || root.localName !== "Relationships" ) { throw new Error(`${partName} 的关系根元素无效`); } const relationships = new Map(); for (const element of directChildren( root, 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} 包含不完整的关系声明`); } if (relationships.has(id)) { throw new Error(`${partName} 包含重复关系 ID:${id}`); } const external = (element.getAttribute("TargetMode") ?? "").toLowerCase() === "external"; const targetPart = external ? undefined : resolveRelationshipTarget(partName, target); if (targetPart && !entries.has(targetPart)) { throw new Error( `${partName} 的关系 ${id} 指向缺失部件:${targetPart}` ); } relationships.set(id, { id, type, targetPart }); } return relationships; } function validateReferencedParts( section: XmlElement, localName: "headerReference" | "footerReference", expectedType: string, expectedRootName: "hdr" | "ftr", relationships: ReadonlyMap, entries: ReadonlyMap ) { const references = directChildren( section, WORD_NAMESPACE, localName ); const pageTypes = new Set(); for (const reference of references) { const pageType = reference.getAttributeNS(WORD_NAMESPACE, "type") ?? ""; const relationshipId = reference.getAttributeNS( OFFICE_RELATIONSHIP_NAMESPACE, "id" ) ?? ""; if (!["default", "even", "first"].includes(pageType)) { throw new Error(`${localName} 包含无效页面类型:${pageType}`); } if (pageTypes.has(pageType)) { throw new Error(`${localName} 重复声明页面类型:${pageType}`); } pageTypes.add(pageType); const relationship = relationships.get(relationshipId); if ( !relationship || relationship.type !== expectedType || !relationship.targetPart ) { throw new Error( `${localName} 引用了无效关系:${relationshipId || "(空)"}` ); } const part = entries.get(relationship.targetPart)!; const document = parseXmlPart(part, relationship.targetPart); const root = document.documentElement; if ( !root || root.namespaceURI !== WORD_NAMESPACE || root.localName !== expectedRootName ) { throw new Error( `${relationship.targetPart} 的页眉页脚根元素无效` ); } } return references.length; } function validateContentTypes( entries: ReadonlyMap, relationships: ReadonlyMap ) { const partName = "[Content_Types].xml"; const document = parseXmlPart(entries.get(partName)!, partName); const root = document.documentElement; if ( !root || root.namespaceURI !== CONTENT_TYPES_NAMESPACE || root.localName !== "Types" ) { throw new Error("[Content_Types].xml 的根元素无效"); } const overrides = new Map(); for (const element of directChildren( root, CONTENT_TYPES_NAMESPACE, "Override" )) { const part = element.getAttribute("PartName"); const contentType = element.getAttribute("ContentType"); if (!part || !contentType || overrides.has(part)) { throw new Error("[Content_Types].xml 包含无效或重复的 Override"); } overrides.set(part, contentType); } for (const relationship of relationships.values()) { if ( relationship.type !== HEADER_RELATIONSHIP_TYPE && relationship.type !== FOOTER_RELATIONSHIP_TYPE ) { continue; } const expected = relationship.type === HEADER_RELATIONSHIP_TYPE ? HEADER_CONTENT_TYPE : FOOTER_CONTENT_TYPE; if ( !relationship.targetPart || overrides.get(`/${relationship.targetPart}`) !== expected ) { throw new Error( `${relationship.targetPart ?? "(空)"} 缺少正确的 Content Type` ); } } } function validateStyles(entries: ReadonlyMap) { const partName = "word/styles.xml"; const document = parseXmlPart(entries.get(partName)!, partName); const root = document.documentElement; if ( !root || root.namespaceURI !== WORD_NAMESPACE || root.localName !== "styles" ) { throw new Error("word/styles.xml 的根元素无效"); } const styleIds = new Set( Array.from( root.getElementsByTagNameNS(WORD_NAMESPACE, "style") ).map((element) => element.getAttributeNS(WORD_NAMESPACE, "styleId") ) ); for (const styleId of requiredStyleIds) { if (!styleIds.has(styleId)) { throw new Error(`word/styles.xml 缺少关键样式:${styleId}`); } } } export function validateDynamicReferenceDocx( content: Uint8Array ): DynamicReferenceValidation { const reference = readReferenceDocxPackage(content); let xmlPartCount = 0; let relationshipCount = 0; const relationshipParts = new Map< string, ReadonlyMap >(); for (const [partName, part] of reference.entries) { if (partName.endsWith(".xml") || partName.endsWith(".rels")) { parseXmlPart(part, partName); xmlPartCount += 1; } if (partName.endsWith(".rels")) { const relationships = parseRelationships( partName, part, reference.entries ); relationshipParts.set(partName, relationships); relationshipCount += relationships.size; } } const documentPart = "word/document.xml"; const document = parseXmlPart( reference.entries.get(documentPart)!, documentPart ); const root = document.documentElement; if ( !root || root.namespaceURI !== WORD_NAMESPACE || root.localName !== "document" ) { throw new Error("word/document.xml 的根元素无效"); } const sections = Array.from( root.getElementsByTagNameNS(WORD_NAMESPACE, "sectPr") ); const finalSection = sections.at(-1); if ( !finalSection || directChildren(finalSection, WORD_NAMESPACE, "pgSz").length !== 1 || directChildren(finalSection, WORD_NAMESPACE, "pgMar").length !== 1 ) { throw new Error("word/document.xml 缺少完整的最终节页面设置"); } const documentRelationships = relationshipParts.get( "word/_rels/document.xml.rels" )!; const headerCount = validateReferencedParts( finalSection, "headerReference", HEADER_RELATIONSHIP_TYPE, "hdr", documentRelationships, reference.entries ); const footerCount = validateReferencedParts( finalSection, "footerReference", FOOTER_RELATIONSHIP_TYPE, "ftr", documentRelationships, reference.entries ); validateContentTypes(reference.entries, documentRelationships); validateStyles(reference.entries); return { partCount: reference.entries.size, xmlPartCount, relationshipCount, headerCount, footerCount }; }