feat: 实现动态 DOCX 模板与样式映射
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user