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