feat: 完成 DOCX R4 元素级视觉门禁

建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。

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

修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
SkyJourney
2026-08-02 03:27:11 +08:00
parent f9f5fccfc9
commit 58f87cc19f
100 changed files with 10409 additions and 386 deletions
@@ -145,27 +145,89 @@ function appendField(
});
}
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
style: RunStyle,
pageNumberOffset = 0
) {
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);
const tokens = footerTemplate(footer).split(
/(\$\{page\}|\$\{pages\})/gu
);
for (const token of tokens) {
if (token === "${page}") {
appendField(paragraph, "PAGE", style);
appendOffsetPageField(paragraph, pageNumberOffset, style);
} else if (token === "${pages}") {
appendField(paragraph, "NUMPAGES", style);
} else {
@@ -179,6 +241,7 @@ function appendParagraphProperties(
options: {
alignment?: "left" | "center" | "right";
position: "header" | "footer";
lineHeightTwips: number;
divider: boolean;
dividerColor: string;
centerTabTwips?: number;
@@ -192,7 +255,9 @@ function appendParagraphProperties(
);
appendElement(properties, WORD_NAMESPACE, "w:spacing", {
"w:before": "0",
"w:after": "0"
"w:after": "0",
"w:line": String(options.lineHeightTwips),
"w:lineRule": "exact"
});
if (options.alignment) {
appendElement(properties, WORD_NAMESPACE, "w:jc", {
@@ -200,18 +265,22 @@ function appendParagraphProperties(
});
}
if (
options.centerTabTwips !== undefined &&
options.centerTabTwips !== undefined ||
options.rightTabTwips !== undefined
) {
const tabs = appendElement(properties, WORD_NAMESPACE, "w:tabs");
appendElement(tabs, WORD_NAMESPACE, "w:tab", {
"w:val": "center",
"w:pos": String(options.centerTabTwips)
});
appendElement(tabs, WORD_NAMESPACE, "w:tab", {
"w:val": "right",
"w:pos": String(options.rightTabTwips)
});
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(
@@ -244,6 +313,7 @@ function appendHeaderParagraph(
divider: boolean;
dividerColor: string;
contentWidthMm: number;
lineHeightTwips: number;
render: (
paragraph: XmlElement,
alignment: "left" | "center" | "right"
@@ -254,6 +324,7 @@ function appendHeaderParagraph(
const contentWidthTwips = millimetersToTwips(options.contentWidthMm);
appendParagraphProperties(paragraph, {
position: "header",
lineHeightTwips: options.lineHeightTwips,
divider: options.divider,
dividerColor: options.dividerColor,
centerTabTwips: Math.round(contentWidthTwips / 2),
@@ -270,7 +341,8 @@ function createHeaderPart(
header: HeaderConfig,
options: DynamicReferenceDocxOptions,
fallbackFont: string,
contentWidthMm: number
contentWidthMm: number,
empty = false
) {
const document = createWordPart("hdr");
const style: RunStyle = {
@@ -279,12 +351,13 @@ function createHeaderPart(
color: header.color
};
appendHeaderParagraph(document.documentElement!, {
divider: header.showDivider,
divider: !empty && header.showDivider,
dividerColor: header.color,
contentWidthMm,
lineHeightTwips: Math.round(style.sizePt * 1.25 * 20),
render: (paragraph, alignment) => {
const slot = header[alignment];
if (slot.enabled) {
if (!empty && slot.enabled) {
appendText(
paragraph,
resolveHeaderTemplate(slot.content, options),
@@ -300,7 +373,8 @@ function createFooterPart(
footer: FooterConfig,
alignment: "left" | "center" | "right",
fallbackFont: string,
empty = false
empty = false,
pageNumberOffset = 0
) {
const document = createWordPart("ftr");
const style: RunStyle = {
@@ -315,12 +389,13 @@ function createFooterPart(
);
appendParagraphProperties(paragraph, {
position: "footer",
divider: footer.showDivider,
lineHeightTwips: Math.round(style.sizePt * 1.25 * 20),
divider: !empty && footer.showDivider,
dividerColor: footer.color,
alignment
});
if (!empty) {
appendFooterContent(paragraph, footer, style);
appendFooterContent(paragraph, footer, style, pageNumberOffset);
}
return serializeXmlPart(document);
}
@@ -460,10 +535,15 @@ export function createHeaderFooterParts(
}> = [];
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";
footer.enabled &&
footer.alignment === "outer";
const usesDifferentFirstPage =
footer.enabled && !footer.showOnFirstPage;
(header.enabled && !header.showOnFirstPage) ||
(footer.enabled && !footer.showOnFirstPage);
if (header.enabled) {
const headerContent = createHeaderPart(
@@ -474,25 +554,50 @@ export function createHeaderFooterParts(
);
for (const type of [
"default",
...(usesEvenAndOddPages ? ["even"] : []),
...(usesDifferentFirstPage ? ["first"] : [])
...(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" ? "right" : footer.alignment;
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
fallbackFont,
false,
usesEvenAndOddPages ? coverPageOffset : 0
)
);
parts.push({
@@ -504,7 +609,13 @@ export function createHeaderFooterParts(
const evenPartName = `word/footer${footerIndex++}.xml`;
entries.set(
evenPartName,
createFooterPart(footer, "left", fallbackFont)
createFooterPart(
footer,
evenAlignment,
fallbackFont,
false,
coverPageOffset
)
);
parts.push({
kind: "footer",
@@ -520,7 +631,8 @@ export function createHeaderFooterParts(
footer,
defaultAlignment,
fallbackFont,
true
!footer.showOnFirstPage,
usesEvenAndOddPages ? coverPageOffset : 0
)
);
parts.push({