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
@@ -0,0 +1,519 @@
import {
lengthToMillimeters,
type FooterConfig,
type HeaderConfig
} from "@md-to-pdf/core";
import {
CONTENT_TYPES_NAMESPACE,
OFFICE_RELATIONSHIP_NAMESPACE,
PACKAGE_RELATIONSHIP_NAMESPACE,
WORD_NAMESPACE,
appendElement,
colorValue,
parseXmlPart,
pointsToHalfPoints,
serializeXmlPart,
type XmlElement
} from "./ooxml.js";
import {
resolveHeaderTemplate,
type DynamicReferenceDocxOptions
} from "./reference-options.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 XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace";
type HeaderFooterReferenceType = "default" | "even" | "first";
export interface HeaderFooterReference {
kind: "header" | "footer";
type: HeaderFooterReferenceType;
relationshipId: string;
}
export interface HeaderFooterTransformResult {
entries: Map<string, Uint8Array>;
references: HeaderFooterReference[];
usesEvenAndOddPages: boolean;
usesDifferentFirstPage: boolean;
}
interface RunStyle {
font: string;
sizePt: number;
color: string;
}
function firstFontName(fontFamily: string, fallback: string) {
const first = fontFamily.split(",")[0]?.trim() ?? "";
return first.replace(/^["']|["']$/gu, "") || fallback;
}
function createWordPart(rootName: "hdr" | "ftr") {
return parseXmlPart(
new TextEncoder().encode(
`<w:${rootName} xmlns:w="${WORD_NAMESPACE}" xmlns:r="${OFFICE_RELATIONSHIP_NAMESPACE}"/>`
),
`word/${rootName}.xml`
);
}
function appendRunProperties(run: XmlElement, style: RunStyle) {
const rPr = appendElement(run, WORD_NAMESPACE, "w:rPr");
appendElement(rPr, WORD_NAMESPACE, "w:rFonts", {
"w:ascii": style.font,
"w:hAnsi": style.font,
"w:eastAsia": style.font,
"w:cs": style.font
});
appendElement(rPr, WORD_NAMESPACE, "w:color", {
"w:val": colorValue(style.color)
});
const size = pointsToHalfPoints(style.sizePt);
appendElement(rPr, WORD_NAMESPACE, "w:sz", {
"w:val": size
});
appendElement(rPr, WORD_NAMESPACE, "w:szCs", {
"w:val": size
});
}
function appendText(
paragraph: XmlElement,
value: string,
style: RunStyle
) {
if (!value) {
return;
}
const run = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendRunProperties(run, style);
const text = appendElement(run, WORD_NAMESPACE, "w:t");
text.setAttributeNS(XML_NAMESPACE, "xml:space", "preserve");
text.appendChild(text.ownerDocument!.createTextNode(value));
}
function appendField(
paragraph: XmlElement,
instruction: "PAGE" | "NUMPAGES",
style: RunStyle
) {
const begin = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendRunProperties(begin, style);
appendElement(begin, WORD_NAMESPACE, "w:fldChar", {
"w:fldCharType": "begin",
"w:dirty": "true"
});
const command = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendRunProperties(command, style);
const instructionText = appendElement(
command,
WORD_NAMESPACE,
"w:instrText"
);
instructionText.setAttributeNS(
XML_NAMESPACE,
"xml:space",
"preserve"
);
instructionText.appendChild(
instructionText.ownerDocument!.createTextNode(
` ${instruction} \\* MERGEFORMAT `
)
);
const separate = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendRunProperties(separate, style);
appendElement(separate, WORD_NAMESPACE, "w:fldChar", {
"w:fldCharType": "separate"
});
appendText(paragraph, "1", style);
const end = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendRunProperties(end, style);
appendElement(end, WORD_NAMESPACE, "w:fldChar", {
"w:fldCharType": "end"
});
}
function appendFooterContent(
paragraph: XmlElement,
footer: FooterConfig,
style: RunStyle
) {
const template =
footer.format === "page"
? "${page}"
: footer.format === "page-total"
? "${page} / ${pages}"
: footer.format === "chinese-page-total"
? "第 ${page} 页 / 共 ${pages} 页"
: footer.format === "dash-page"
? "- ${page} -"
: footer.format === "official-page"
? "— ${page} —"
: footer.template || "${page} / ${pages}";
const tokens = template.split(/(\$\{page\}|\$\{pages\})/gu);
for (const token of tokens) {
if (token === "${page}") {
appendField(paragraph, "PAGE", style);
} else if (token === "${pages}") {
appendField(paragraph, "NUMPAGES", style);
} else {
appendText(paragraph, token, style);
}
}
}
function appendThreeColumnTable(
root: XmlElement,
options: {
position: "header" | "footer";
divider: boolean;
dividerColor: string;
alignments: readonly ["left", "center", "right"];
render: (
paragraph: XmlElement,
alignment: "left" | "center" | "right"
) => void;
}
) {
const table = appendElement(root, WORD_NAMESPACE, "w:tbl");
const tableProperties = appendElement(
table,
WORD_NAMESPACE,
"w:tblPr"
);
appendElement(tableProperties, WORD_NAMESPACE, "w:tblW", {
"w:w": "5000",
"w:type": "pct"
});
appendElement(tableProperties, WORD_NAMESPACE, "w:tblLayout", {
"w:type": "fixed"
});
if (options.divider) {
const borders = appendElement(
tableProperties,
WORD_NAMESPACE,
"w:tblBorders"
);
appendElement(
borders,
WORD_NAMESPACE,
options.position === "header" ? "w:bottom" : "w:top",
{
"w:val": "single",
"w:sz": "4",
"w:space": "2",
"w:color": colorValue(options.dividerColor)
}
);
}
const grid = appendElement(table, WORD_NAMESPACE, "w:tblGrid");
for (let index = 0; index < 3; index += 1) {
appendElement(grid, WORD_NAMESPACE, "w:gridCol", {
"w:w": "2400"
});
}
const row = appendElement(table, WORD_NAMESPACE, "w:tr");
for (const alignment of options.alignments) {
const cell = appendElement(row, WORD_NAMESPACE, "w:tc");
const cellProperties = appendElement(
cell,
WORD_NAMESPACE,
"w:tcPr"
);
appendElement(cellProperties, WORD_NAMESPACE, "w:tcW", {
"w:w": "1667",
"w:type": "pct"
});
const paragraph = appendElement(cell, WORD_NAMESPACE, "w:p");
const paragraphProperties = appendElement(
paragraph,
WORD_NAMESPACE,
"w:pPr"
);
appendElement(paragraphProperties, WORD_NAMESPACE, "w:jc", {
"w:val": alignment
});
options.render(paragraph, alignment);
}
}
function createHeaderPart(
header: HeaderConfig,
options: DynamicReferenceDocxOptions,
fallbackFont: string
) {
const document = createWordPart("hdr");
const style: RunStyle = {
font: firstFontName(header.fontFamily, fallbackFont),
sizePt: (lengthToMillimeters(header.fontSize) * 72) / 25.4,
color: header.color
};
appendThreeColumnTable(document.documentElement!, {
position: "header",
divider: header.showDivider,
dividerColor: header.color,
alignments: ["left", "center", "right"],
render: (paragraph, alignment) => {
const slot = header[alignment];
if (slot.enabled) {
appendText(
paragraph,
resolveHeaderTemplate(slot.content, options),
style
);
}
}
});
return serializeXmlPart(document);
}
function createFooterPart(
footer: FooterConfig,
alignment: "left" | "center" | "right",
fallbackFont: string,
empty = false
) {
const document = createWordPart("ftr");
const style: RunStyle = {
font: firstFontName(footer.fontFamily, fallbackFont),
sizePt: (lengthToMillimeters(footer.fontSize) * 72) / 25.4,
color: footer.color
};
appendThreeColumnTable(document.documentElement!, {
position: "footer",
divider: footer.showDivider,
dividerColor: footer.color,
alignments: ["left", "center", "right"],
render: (paragraph, cellAlignment) => {
if (!empty && cellAlignment === alignment) {
appendFooterContent(paragraph, footer, style);
}
}
});
return serializeXmlPart(document);
}
function removeExistingHeaderFooterParts(
entries: Map<string, Uint8Array>
) {
for (const name of [...entries.keys()]) {
if (/^word\/(?:header|footer)\d+\.xml$/u.test(name)) {
entries.delete(name);
}
}
}
function nextRelationshipId(relationships: XmlElement) {
const maximum = Array.from(
relationships.getElementsByTagNameNS(
PACKAGE_RELATIONSHIP_NAMESPACE,
"Relationship"
)
).reduce((current, relationship) => {
const match = /^rId(\d+)$/u.exec(
relationship.getAttribute("Id") ?? ""
);
return Math.max(current, Number(match?.[1] ?? 0));
}, 0);
let next = maximum + 1;
return () => `rId${next++}`;
}
function updateRelationships(
entries: Map<string, Uint8Array>,
parts: Array<{
kind: "header" | "footer";
type: HeaderFooterReferenceType;
partName: string;
}>
) {
const document = parseXmlPart(
entries.get("word/_rels/document.xml.rels")!,
"word/_rels/document.xml.rels"
);
const root = document.documentElement!;
for (const relationship of Array.from(
root.getElementsByTagNameNS(
PACKAGE_RELATIONSHIP_NAMESPACE,
"Relationship"
)
)) {
const type = relationship.getAttribute("Type");
if (
type === HEADER_RELATIONSHIP_TYPE ||
type === FOOTER_RELATIONSHIP_TYPE
) {
root.removeChild(relationship);
}
}
const allocateId = nextRelationshipId(root);
const references: HeaderFooterReference[] = [];
for (const part of parts) {
const relationshipId = allocateId();
appendElement(
root,
PACKAGE_RELATIONSHIP_NAMESPACE,
"Relationship",
{
Id: relationshipId,
Type:
part.kind === "header"
? HEADER_RELATIONSHIP_TYPE
: FOOTER_RELATIONSHIP_TYPE,
Target: part.partName.replace(/^word\//u, "")
}
);
references.push({
kind: part.kind,
type: part.type,
relationshipId
});
}
entries.set(
"word/_rels/document.xml.rels",
serializeXmlPart(document)
);
return references;
}
function updateContentTypes(
entries: Map<string, Uint8Array>,
parts: Array<{
kind: "header" | "footer";
partName: string;
}>
) {
const document = parseXmlPart(
entries.get("[Content_Types].xml")!,
"[Content_Types].xml"
);
const root = document.documentElement!;
for (const override of Array.from(
root.getElementsByTagNameNS(CONTENT_TYPES_NAMESPACE, "Override")
)) {
if (
/^\/word\/(?:header|footer)\d+\.xml$/u.test(
override.getAttribute("PartName") ?? ""
)
) {
root.removeChild(override);
}
}
for (const part of parts) {
appendElement(root, CONTENT_TYPES_NAMESPACE, "Override", {
PartName: `/${part.partName}`,
ContentType:
part.kind === "header"
? HEADER_CONTENT_TYPE
: FOOTER_CONTENT_TYPE
});
}
entries.set("[Content_Types].xml", serializeXmlPart(document));
}
export function createHeaderFooterParts(
sourceEntries: ReadonlyMap<string, Uint8Array>,
options: DynamicReferenceDocxOptions,
header: HeaderConfig,
footer: FooterConfig,
fallbackFont: string
): HeaderFooterTransformResult {
const entries = new Map(sourceEntries);
removeExistingHeaderFooterParts(entries);
const parts: Array<{
kind: "header" | "footer";
type: HeaderFooterReferenceType;
partName: string;
}> = [];
let headerIndex = 1;
let footerIndex = 1;
const usesEvenAndOddPages =
footer.enabled && footer.alignment === "outer";
const usesDifferentFirstPage =
footer.enabled && !footer.showOnFirstPage;
if (header.enabled) {
const headerContent = createHeaderPart(
header,
options,
fallbackFont
);
for (const type of [
"default",
...(usesEvenAndOddPages ? ["even"] : []),
...(usesDifferentFirstPage ? ["first"] : [])
] as HeaderFooterReferenceType[]) {
const partName = `word/header${headerIndex++}.xml`;
entries.set(partName, headerContent);
parts.push({ kind: "header", type, partName });
}
}
if (footer.enabled) {
const defaultAlignment =
footer.alignment === "outer" ? "right" : footer.alignment;
const defaultPartName = `word/footer${footerIndex++}.xml`;
entries.set(
defaultPartName,
createFooterPart(
footer,
defaultAlignment,
fallbackFont
)
);
parts.push({
kind: "footer",
type: "default",
partName: defaultPartName
});
if (usesEvenAndOddPages) {
const evenPartName = `word/footer${footerIndex++}.xml`;
entries.set(
evenPartName,
createFooterPart(footer, "left", fallbackFont)
);
parts.push({
kind: "footer",
type: "even",
partName: evenPartName
});
}
if (usesDifferentFirstPage) {
const firstPartName = `word/footer${footerIndex++}.xml`;
entries.set(
firstPartName,
createFooterPart(
footer,
defaultAlignment,
fallbackFont,
true
)
);
parts.push({
kind: "footer",
type: "first",
partName: firstPartName
});
}
}
const references = updateRelationships(entries, parts);
updateContentTypes(entries, parts);
return {
entries,
references,
usesEvenAndOddPages,
usesDifferentFirstPage
};
}