Files
MorphDoc/packages/docx-engine/src/header-footer-transform.ts
T
SkyJourney 58f87cc19f feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。

支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。

修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
2026-08-02 03:27:11 +08:00

655 lines
17 KiB
TypeScript

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,
millimetersToTwips,
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 footerTemplate(footer: FooterConfig) {
return 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}";
}
function appendInstruction(
paragraph: XmlElement,
value: string,
style: RunStyle
) {
const run = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendRunProperties(run, style);
const instruction = appendElement(
run,
WORD_NAMESPACE,
"w:instrText"
);
instruction.setAttributeNS(
XML_NAMESPACE,
"xml:space",
"preserve"
);
instruction.appendChild(
instruction.ownerDocument!.createTextNode(value)
);
}
function appendFieldCharacter(
paragraph: XmlElement,
type: "begin" | "separate" | "end",
style: RunStyle
) {
const run = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendRunProperties(run, style);
appendElement(run, WORD_NAMESPACE, "w:fldChar", {
"w:fldCharType": type,
...(type === "begin" ? { "w:dirty": "true" } : {})
});
}
function appendOffsetPageField(
paragraph: XmlElement,
offset: number,
style: RunStyle
) {
if (offset <= 0) {
appendField(paragraph, "PAGE", style);
return;
}
appendFieldCharacter(paragraph, "begin", style);
appendInstruction(paragraph, " = ", style);
appendFieldCharacter(paragraph, "begin", style);
appendInstruction(paragraph, " PAGE \\* MERGEFORMAT ", style);
appendFieldCharacter(paragraph, "separate", style);
appendText(paragraph, String(offset + 1), style);
appendFieldCharacter(paragraph, "end", style);
appendInstruction(paragraph, ` - ${offset} `, style);
appendFieldCharacter(paragraph, "separate", style);
appendText(paragraph, "1", style);
appendFieldCharacter(paragraph, "end", style);
}
function appendFooterContent(
paragraph: XmlElement,
footer: FooterConfig,
style: RunStyle,
pageNumberOffset = 0
) {
const tokens = footerTemplate(footer).split(
/(\$\{page\}|\$\{pages\})/gu
);
for (const token of tokens) {
if (token === "${page}") {
appendOffsetPageField(paragraph, pageNumberOffset, style);
} else if (token === "${pages}") {
appendField(paragraph, "NUMPAGES", style);
} else {
appendText(paragraph, token, style);
}
}
}
function appendParagraphProperties(
paragraph: XmlElement,
options: {
alignment?: "left" | "center" | "right";
position: "header" | "footer";
lineHeightTwips: number;
divider: boolean;
dividerColor: string;
centerTabTwips?: number;
rightTabTwips?: number;
}
) {
const properties = appendElement(
paragraph,
WORD_NAMESPACE,
"w:pPr"
);
appendElement(properties, WORD_NAMESPACE, "w:spacing", {
"w:before": "0",
"w:after": "0",
"w:line": String(options.lineHeightTwips),
"w:lineRule": "exact"
});
if (options.alignment) {
appendElement(properties, WORD_NAMESPACE, "w:jc", {
"w:val": options.alignment
});
}
if (
options.centerTabTwips !== undefined ||
options.rightTabTwips !== undefined
) {
const tabs = appendElement(properties, WORD_NAMESPACE, "w:tabs");
if (options.centerTabTwips !== undefined) {
appendElement(tabs, WORD_NAMESPACE, "w:tab", {
"w:val": "center",
"w:pos": String(options.centerTabTwips)
});
}
if (options.rightTabTwips !== undefined) {
appendElement(tabs, WORD_NAMESPACE, "w:tab", {
"w:val": "right",
"w:pos": String(options.rightTabTwips)
});
}
}
if (options.divider) {
const borders = appendElement(
properties,
WORD_NAMESPACE,
"w:pBdr"
);
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)
}
);
}
}
function appendTab(paragraph: XmlElement) {
const run = appendElement(paragraph, WORD_NAMESPACE, "w:r");
appendElement(run, WORD_NAMESPACE, "w:tab");
}
function appendHeaderParagraph(
root: XmlElement,
options: {
divider: boolean;
dividerColor: string;
contentWidthMm: number;
lineHeightTwips: number;
render: (
paragraph: XmlElement,
alignment: "left" | "center" | "right"
) => void;
}
) {
const paragraph = appendElement(root, WORD_NAMESPACE, "w:p");
const contentWidthTwips = millimetersToTwips(options.contentWidthMm);
appendParagraphProperties(paragraph, {
position: "header",
lineHeightTwips: options.lineHeightTwips,
divider: options.divider,
dividerColor: options.dividerColor,
centerTabTwips: Math.round(contentWidthTwips / 2),
rightTabTwips: contentWidthTwips
});
options.render(paragraph, "left");
appendTab(paragraph);
options.render(paragraph, "center");
appendTab(paragraph);
options.render(paragraph, "right");
}
function createHeaderPart(
header: HeaderConfig,
options: DynamicReferenceDocxOptions,
fallbackFont: string,
contentWidthMm: number,
empty = false
) {
const document = createWordPart("hdr");
const style: RunStyle = {
font: firstFontName(header.fontFamily, fallbackFont),
sizePt: (lengthToMillimeters(header.fontSize) * 72) / 25.4,
color: header.color
};
appendHeaderParagraph(document.documentElement!, {
divider: !empty && header.showDivider,
dividerColor: header.color,
contentWidthMm,
lineHeightTwips: Math.round(style.sizePt * 1.25 * 20),
render: (paragraph, alignment) => {
const slot = header[alignment];
if (!empty && slot.enabled) {
appendText(
paragraph,
resolveHeaderTemplate(slot.content, options),
style
);
}
}
});
return serializeXmlPart(document);
}
function createFooterPart(
footer: FooterConfig,
alignment: "left" | "center" | "right",
fallbackFont: string,
empty = false,
pageNumberOffset = 0
) {
const document = createWordPart("ftr");
const style: RunStyle = {
font: firstFontName(footer.fontFamily, fallbackFont),
sizePt: (lengthToMillimeters(footer.fontSize) * 72) / 25.4,
color: footer.color
};
const paragraph = appendElement(
document.documentElement!,
WORD_NAMESPACE,
"w:p"
);
appendParagraphProperties(paragraph, {
position: "footer",
lineHeightTwips: Math.round(style.sizePt * 1.25 * 20),
divider: !empty && footer.showDivider,
dividerColor: footer.color,
alignment
});
if (!empty) {
appendFooterContent(paragraph, footer, style, pageNumberOffset);
}
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,
contentWidthMm: number
): 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 coverPageOffset = options.semanticDocument?.regions.filter(
(region) => region.kind === "cover" && region.section
).length ?? 0;
const usesEvenAndOddPages =
footer.enabled &&
footer.alignment === "outer";
const usesDifferentFirstPage =
(header.enabled && !header.showOnFirstPage) ||
(footer.enabled && !footer.showOnFirstPage);
if (header.enabled) {
const headerContent = createHeaderPart(
header,
options,
fallbackFont,
contentWidthMm
);
for (const type of [
"default",
...(usesEvenAndOddPages ? ["even"] : [])
] as HeaderFooterReferenceType[]) {
const partName = `word/header${headerIndex++}.xml`;
entries.set(partName, headerContent);
parts.push({ kind: "header", type, partName });
}
if (usesDifferentFirstPage) {
const firstPartName = `word/header${headerIndex++}.xml`;
entries.set(
firstPartName,
createHeaderPart(
header,
options,
fallbackFont,
contentWidthMm,
!header.showOnFirstPage
)
);
parts.push({
kind: "header",
type: "first",
partName: firstPartName
});
}
}
if (footer.enabled) {
const defaultAlignment =
footer.alignment === "outer"
? coverPageOffset % 2 === 0
? "right"
: "left"
: footer.alignment;
const evenAlignment =
coverPageOffset % 2 === 0 ? "left" : "right";
const defaultPartName = `word/footer${footerIndex++}.xml`;
entries.set(
defaultPartName,
createFooterPart(
footer,
defaultAlignment,
fallbackFont,
false,
usesEvenAndOddPages ? coverPageOffset : 0
)
);
parts.push({
kind: "footer",
type: "default",
partName: defaultPartName
});
if (usesEvenAndOddPages) {
const evenPartName = `word/footer${footerIndex++}.xml`;
entries.set(
evenPartName,
createFooterPart(
footer,
evenAlignment,
fallbackFont,
false,
coverPageOffset
)
);
parts.push({
kind: "footer",
type: "even",
partName: evenPartName
});
}
if (usesDifferentFirstPage) {
const firstPartName = `word/footer${footerIndex++}.xml`;
entries.set(
firstPartName,
createFooterPart(
footer,
defaultAlignment,
fallbackFont,
!footer.showOnFirstPage,
usesEvenAndOddPages ? coverPageOffset : 0
)
);
parts.push({
kind: "footer",
type: "first",
partName: firstPartName
});
}
}
const references = updateRelationships(entries, parts);
updateContentTypes(entries, parts);
return {
entries,
references,
usesEvenAndOddPages,
usesDifferentFirstPage
};
}