feat: 实现动态 DOCX 模板与样式映射

This commit is contained in:
SkyJourney
2026-07-30 14:10:43 +08:00
parent fe5d67fb6d
commit fa159d916f
41 changed files with 3610 additions and 7 deletions
+347
View File
@@ -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
};
}