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

348 lines
9.5 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 {
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<string, Uint8Array>
) {
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<string, Relationship>();
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<string, Relationship>,
entries: ReadonlyMap<string, Uint8Array>
) {
const references = directChildren(
section,
WORD_NAMESPACE,
localName
);
const pageTypes = new Set<string>();
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<string, Uint8Array>,
relationships: ReadonlyMap<string, Relationship>
) {
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<string, string>();
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<string, Uint8Array>) {
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<string, Relationship>
>();
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
};
}