feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。 支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。 修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user