feat: 完成 DOCX 通用结构映射与严格收口

This commit is contained in:
SkyJourney
2026-07-31 15:19:34 +08:00
parent 6e40374200
commit f3847f3e4b
31 changed files with 3175 additions and 64 deletions
@@ -0,0 +1,864 @@
import type {
DocxSlotStyleToken,
DocxThemeTokenSet
} from "@md-to-pdf/docx-theme-engine";
import {
WORD_NAMESPACE,
appendElement,
colorValue,
directChildren,
firstDirectChild,
parseXmlPart,
removeDirectChildren,
serializeXmlPart,
type XmlElement
} from "./ooxml.js";
import type {
PandocStructureBlock,
PandocStructureContainer,
PandocStructurePlan,
PandocStructureSectionBreak
} from "./pandoc-structure.js";
import {
readGeneratedDocxPackage,
writeGeneratedDocxPackage
} from "./reference-package.js";
import {
createDocxTokenSlotMap,
DOCX_SLOT_WORD_STYLE_BINDINGS
} from "./token-style-map.js";
type DocxBorderToken = NonNullable<
NonNullable<DocxSlotStyleToken["borders"]>["top"]
>;
export interface GeneratedDocxStructureReport {
containerCount: number;
sectionCount: number;
tableCount: number;
pageBreakAfterCount: number;
sectionPageFieldCount: number;
}
export interface FinalizedGeneratedDocx {
content: Uint8Array;
report: GeneratedDocxStructureReport;
}
interface ContainerDescriptor {
container: PandocStructureContainer;
followedBySection: boolean;
}
function setWordAttribute(
element: XmlElement,
localName: string,
value: string
) {
element.setAttributeNS(
WORD_NAMESPACE,
`w:${localName}`,
value
);
}
function wordAttribute(
element: XmlElement,
localName: string
) {
return element.getAttributeNS(WORD_NAMESPACE, localName);
}
function ensureFirstElement(
parent: XmlElement,
localName: string,
qualifiedName: string
) {
const existing = firstDirectChild(
parent,
WORD_NAMESPACE,
localName
);
if (existing) {
return existing;
}
const element = parent.ownerDocument!.createElementNS(
WORD_NAMESPACE,
qualifiedName
);
parent.insertBefore(element, parent.firstChild);
return element;
}
function paragraphProperties(paragraph: XmlElement) {
return ensureFirstElement(paragraph, "pPr", "w:pPr");
}
function paragraphText(paragraph: XmlElement) {
return Array.from(
paragraph.getElementsByTagNameNS(WORD_NAMESPACE, "t")
)
.map((element) => element.textContent ?? "")
.join("");
}
function bodyElements(body: XmlElement) {
return Array.from(body.childNodes).filter(
(node): node is XmlElement => node.nodeType === 1
);
}
function markerParagraph(
body: XmlElement,
marker: string
): XmlElement {
const matches = bodyElements(body).filter(
(element) =>
element.namespaceURI === WORD_NAMESPACE &&
element.localName === "p" &&
paragraphText(element) === marker
);
if (matches.length !== 1) {
throw new Error(
`DOCX 结构标记数量无效:${marker}${matches.length}`
);
}
return matches[0]!;
}
function flattenContainers(
blocks: readonly PandocStructureBlock[]
): PandocStructureContainer[] {
return blocks.flatMap((block) =>
block.kind === "container"
? [block, ...flattenContainers(block.blocks)]
: []
);
}
function containerDescriptors(
plan: PandocStructurePlan
): ContainerDescriptor[] {
const descriptors: ContainerDescriptor[] = [];
for (const blocks of [plan.prefix, plan.suffix]) {
for (const [index, block] of blocks.entries()) {
if (block.kind !== "container") {
continue;
}
descriptors.push({
container: block,
followedBySection:
blocks[index + 1]?.kind === "section-break"
});
for (const nested of flattenContainers(block.blocks)) {
descriptors.push({
container: nested,
followedBySection: false
});
}
}
}
return descriptors;
}
function sectionBreaks(
blocks: readonly PandocStructureBlock[]
): PandocStructureSectionBreak[] {
return blocks.flatMap((block) => {
if (block.kind === "section-break") {
return [block];
}
return block.kind === "container"
? sectionBreaks(block.blocks)
: [];
});
}
function toggleParagraphProperty(
paragraph: XmlElement,
localName: "keepLines" | "keepNext" | "pageBreakBefore",
enabled: boolean
) {
const properties = paragraphProperties(paragraph);
removeDirectChildren(properties, WORD_NAMESPACE, localName);
if (enabled) {
appendElement(
properties,
WORD_NAMESPACE,
`w:${localName}`
);
}
}
function appendPageBreak(paragraph: XmlElement): boolean {
const existing = Array.from(
paragraph.getElementsByTagNameNS(WORD_NAMESPACE, "br")
).some(
(element) => wordAttribute(element, "type") === "page"
);
if (existing) {
return false;
}
const run = appendElement(
paragraph,
WORD_NAMESPACE,
"w:r"
);
appendElement(run, WORD_NAMESPACE, "w:br", {
"w:type": "page"
});
return true;
}
function borderAttributes(border: DocxBorderToken) {
return {
"w:val": border.style,
"w:sz": String(
Math.max(2, Math.min(96, Math.round(border.widthPt * 8)))
),
"w:space": "0",
"w:color": colorValue(border.color)
};
}
function applyContainerToken(
paragraphs: readonly XmlElement[],
token: DocxSlotStyleToken | undefined,
followedBySection: boolean
) {
if (!token || paragraphs.length === 0) {
return 0;
}
for (const [index, paragraph] of paragraphs.entries()) {
const properties = paragraphProperties(paragraph);
if (token.backgroundColor) {
removeDirectChildren(properties, WORD_NAMESPACE, "shd");
appendElement(properties, WORD_NAMESPACE, "w:shd", {
"w:val": "clear",
"w:color": "auto",
"w:fill": colorValue(token.backgroundColor)
});
}
if (token.borders) {
const borders =
firstDirectChild(properties, WORD_NAMESPACE, "pBdr") ??
appendElement(properties, WORD_NAMESPACE, "w:pBdr");
for (const side of ["left", "right"] as const) {
removeDirectChildren(borders, WORD_NAMESPACE, side);
const border = token.borders[side];
if (border) {
appendElement(
borders,
WORD_NAMESPACE,
`w:${side}`,
borderAttributes(border)
);
}
}
if (index === 0) {
removeDirectChildren(borders, WORD_NAMESPACE, "top");
if (token.borders.top) {
appendElement(
borders,
WORD_NAMESPACE,
"w:top",
borderAttributes(token.borders.top)
);
}
}
if (index === paragraphs.length - 1) {
removeDirectChildren(borders, WORD_NAMESPACE, "bottom");
if (token.borders.bottom) {
appendElement(
borders,
WORD_NAMESPACE,
"w:bottom",
borderAttributes(token.borders.bottom)
);
}
}
}
if (token.keepLines) {
toggleParagraphProperty(paragraph, "keepLines", true);
if (index < paragraphs.length - 1) {
toggleParagraphProperty(paragraph, "keepNext", true);
}
}
if (token.keepWithNext) {
toggleParagraphProperty(paragraph, "keepNext", true);
}
if (index === 0 && token.pageBreakBefore) {
toggleParagraphProperty(
paragraph,
"pageBreakBefore",
true
);
}
}
return token.pageBreakAfter && !followedBySection
? Number(appendPageBreak(paragraphs.at(-1)!))
: 0;
}
function applyContainerRanges(
body: XmlElement,
plan: PandocStructurePlan,
tokens: DocxThemeTokenSet
) {
const slots = createDocxTokenSlotMap(tokens);
let pageBreakAfterCount = 0;
let containerCount = 0;
for (const descriptor of containerDescriptors(plan)) {
const { container } = descriptor;
const start = markerParagraph(body, container.startMarker);
const end = markerParagraph(body, container.endMarker);
const elements = bodyElements(body);
const startIndex = elements.indexOf(start);
const endIndex = elements.indexOf(end);
if (startIndex < 0 || endIndex <= startIndex) {
throw new Error(
`DOCX 容器标记顺序无效:${container.startMarker}`
);
}
const paragraphs = elements
.slice(startIndex + 1, endIndex)
.filter(
(element) =>
element.namespaceURI === WORD_NAMESPACE &&
element.localName === "p"
);
const token = container.slot
? slots.get(container.slot)?.style
: undefined;
pageBreakAfterCount += applyContainerToken(
paragraphs,
token,
descriptor.followedBySection
);
body.removeChild(start);
body.removeChild(end);
containerCount += 1;
}
return { containerCount, pageBreakAfterCount };
}
function finalSection(body: XmlElement) {
const section = firstDirectChild(
body,
WORD_NAMESPACE,
"sectPr"
);
if (!section) {
throw new Error("DOCX 正文缺少最终节");
}
return section;
}
function setSectionPageNumber(
section: XmlElement,
start: number
) {
removeDirectChildren(section, WORD_NAMESPACE, "pgNumType");
const pageNumber = section.ownerDocument!.createElementNS(
WORD_NAMESPACE,
"w:pgNumType"
);
setWordAttribute(pageNumber, "start", String(start));
const insertionPoint =
firstDirectChild(section, WORD_NAMESPACE, "cols") ??
firstDirectChild(section, WORD_NAMESPACE, "formProt") ??
firstDirectChild(section, WORD_NAMESPACE, "vAlign") ??
firstDirectChild(section, WORD_NAMESPACE, "titlePg");
section.insertBefore(pageNumber, insertionPoint ?? null);
}
function applySections(
body: XmlElement,
plan: PandocStructurePlan
) {
const breaks = [
...sectionBreaks(plan.prefix),
...sectionBreaks(plan.suffix)
];
if (breaks.length === 0) {
return 0;
}
const followingSection = finalSection(body);
for (const sectionBreak of breaks) {
const marker = markerParagraph(body, sectionBreak.marker);
const elements = bodyElements(body);
const markerIndex = elements.indexOf(marker);
const previous = [...elements.slice(0, markerIndex)]
.reverse()
.find(
(element) =>
element.namespaceURI === WORD_NAMESPACE &&
element.localName === "p"
);
if (!previous) {
throw new Error(
`DOCX 分节标记之前缺少段落:${sectionBreak.marker}`
);
}
const coverSection = followingSection.cloneNode(
true
) as XmlElement;
removeDirectChildren(
coverSection,
WORD_NAMESPACE,
"headerReference",
"footerReference",
"pgNumType",
"titlePg",
"type",
"vAlign"
);
const type = coverSection.ownerDocument!.createElementNS(
WORD_NAMESPACE,
"w:type"
);
setWordAttribute(type, "val", "nextPage");
coverSection.insertBefore(
type,
firstDirectChild(coverSection, WORD_NAMESPACE, "pgSz") ??
coverSection.firstChild
);
if (sectionBreak.verticalAlignment) {
appendElement(coverSection, WORD_NAMESPACE, "w:vAlign", {
"w:val": sectionBreak.verticalAlignment
});
}
const properties = paragraphProperties(previous);
removeDirectChildren(properties, WORD_NAMESPACE, "sectPr");
properties.appendChild(coverSection);
setSectionPageNumber(
followingSection,
sectionBreak.followingPageNumberStart
);
body.removeChild(marker);
}
return breaks.length;
}
function replaceSectionPageFields(
entries: Map<string, Uint8Array>,
enabled: boolean
) {
if (!enabled) {
return 0;
}
let count = 0;
for (const [partName, content] of entries) {
if (!/^word\/footer\d+\.xml$/u.test(partName)) {
continue;
}
const document = parseXmlPart(content, partName);
for (const instruction of Array.from(
document.getElementsByTagNameNS(
WORD_NAMESPACE,
"instrText"
)
)) {
const value = instruction.textContent ?? "";
const replaced = value.replace(
/\bNUMPAGES\b/gu,
"SECTIONPAGES"
);
if (replaced === value) {
continue;
}
while (instruction.firstChild) {
instruction.removeChild(instruction.firstChild);
}
instruction.appendChild(
instruction.ownerDocument!.createTextNode(replaced)
);
count += 1;
}
entries.set(partName, serializeXmlPart(document));
}
return count;
}
function normalizeGeneratedStyles(
entries: Map<string, Uint8Array>
) {
const partName = "word/styles.xml";
const document = parseXmlPart(entries.get(partName)!, partName);
const styles = document.documentElement;
if (
!styles ||
styles.namespaceURI !== WORD_NAMESPACE ||
styles.localName !== "styles"
) {
throw new Error("word/styles.xml 的根元素无效");
}
const seenStyleIds = new Set<string>();
for (const style of directChildren(
styles,
WORD_NAMESPACE,
"style"
)) {
const styleId = wordAttribute(style, "styleId");
if (!styleId || !seenStyleIds.has(styleId)) {
if (styleId) {
seenStyleIds.add(styleId);
}
continue;
}
styles.removeChild(style);
}
for (const alignment of Array.from(
styles.getElementsByTagNameNS(WORD_NAMESPACE, "jc")
)) {
if (wordAttribute(alignment, "val") === "justify") {
setWordAttribute(alignment, "val", "both");
}
}
for (const style of directChildren(
styles,
WORD_NAMESPACE,
"style"
)) {
if (wordAttribute(style, "type") !== "table") {
continue;
}
const properties = firstDirectChild(
style,
WORD_NAMESPACE,
"tblPr"
);
if (properties) {
removeDirectChildren(properties, WORD_NAMESPACE, "tblW");
}
}
entries.set(partName, serializeXmlPart(document));
}
function sectionContentWidth(section: XmlElement) {
const pageSize = firstDirectChild(
section,
WORD_NAMESPACE,
"pgSz"
);
const margins = firstDirectChild(
section,
WORD_NAMESPACE,
"pgMar"
);
const width = Number(pageSize && wordAttribute(pageSize, "w"));
const left = Number(margins && wordAttribute(margins, "left"));
const right = Number(margins && wordAttribute(margins, "right"));
const contentWidth = width - left - right;
if (
!Number.isFinite(contentWidth) ||
contentWidth <= 0
) {
throw new Error("DOCX 内容区宽度无效");
}
return Math.round(contentWidth);
}
function gridSpan(cell: XmlElement) {
const properties = firstDirectChild(
cell,
WORD_NAMESPACE,
"tcPr"
);
const span = properties
? firstDirectChild(properties, WORD_NAMESPACE, "gridSpan")
: undefined;
const value = Number(span && wordAttribute(span, "val"));
return Number.isInteger(value) && value > 0 ? value : 1;
}
function tableColumnCount(table: XmlElement) {
return Math.max(
1,
...directChildren(table, WORD_NAMESPACE, "tr").map((row) =>
directChildren(row, WORD_NAMESPACE, "tc").reduce(
(total, cell) => total + gridSpan(cell),
0
)
)
);
}
function scaledColumnWidths(
table: XmlElement,
count: number,
targetWidth: number
) {
const grid = firstDirectChild(
table,
WORD_NAMESPACE,
"tblGrid"
);
const declared = grid
? directChildren(grid, WORD_NAMESPACE, "gridCol").map(
(column) => Number(wordAttribute(column, "w"))
)
: [];
const weights =
declared.length === count &&
declared.every((value) => Number.isFinite(value) && value > 0)
? declared
: Array.from({ length: count }, () => 1);
const total = weights.reduce((sum, value) => sum + value, 0);
const widths = weights.map((value) =>
Math.max(1, Math.round((targetWidth * value) / total))
);
const lastIndex = widths.length - 1;
widths[lastIndex] =
widths[lastIndex]! +
targetWidth - widths.reduce((sum, value) => sum + value, 0);
return widths;
}
function ensureTableGrid(table: XmlElement) {
const existing = firstDirectChild(
table,
WORD_NAMESPACE,
"tblGrid"
);
if (existing) {
return existing;
}
const grid = table.ownerDocument!.createElementNS(
WORD_NAMESPACE,
"w:tblGrid"
);
const firstRow = firstDirectChild(
table,
WORD_NAMESPACE,
"tr"
);
table.insertBefore(grid, firstRow ?? null);
return grid;
}
function applyTables(
document: ReturnType<typeof parseXmlPart>,
section: XmlElement,
tokens: DocxThemeTokenSet
) {
const tableToken =
createDocxTokenSlotMap(tokens).get("table")?.style;
const widthPercent = tableToken?.widthPercent ?? 100;
const targetWidth = Math.max(
1,
Math.round(
(sectionContentWidth(section) * widthPercent) / 100
)
);
const tables = Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "tbl")
);
for (const table of tables) {
const properties = ensureFirstElement(
table,
"tblPr",
"w:tblPr"
);
removeDirectChildren(
properties,
WORD_NAMESPACE,
"tblW",
"tblLayout"
);
appendElement(properties, WORD_NAMESPACE, "w:tblW", {
"w:w": String(Math.round(widthPercent * 50)),
"w:type": "pct"
});
appendElement(properties, WORD_NAMESPACE, "w:tblLayout", {
"w:type": "fixed"
});
const columnCount = tableColumnCount(table);
const widths = scaledColumnWidths(
table,
columnCount,
targetWidth
);
const grid = ensureTableGrid(table);
for (const child of Array.from(grid.childNodes)) {
grid.removeChild(child);
}
for (const width of widths) {
appendElement(grid, WORD_NAMESPACE, "w:gridCol", {
"w:w": String(width)
});
}
for (const row of directChildren(
table,
WORD_NAMESPACE,
"tr"
)) {
if (tableToken?.keepLines) {
const rowProperties = ensureFirstElement(
row,
"trPr",
"w:trPr"
);
removeDirectChildren(
rowProperties,
WORD_NAMESPACE,
"cantSplit"
);
appendElement(
rowProperties,
WORD_NAMESPACE,
"w:cantSplit"
);
}
let columnIndex = 0;
for (const cell of directChildren(
row,
WORD_NAMESPACE,
"tc"
)) {
const span = gridSpan(cell);
const cellWidth = widths
.slice(columnIndex, columnIndex + span)
.reduce((sum, value) => sum + value, 0);
columnIndex += span;
const cellProperties = ensureFirstElement(
cell,
"tcPr",
"w:tcPr"
);
removeDirectChildren(
cellProperties,
WORD_NAMESPACE,
"tcW"
);
appendElement(
cellProperties,
WORD_NAMESPACE,
"w:tcW",
{
"w:w": String(cellWidth),
"w:type": "dxa"
}
);
}
}
}
return tables.length;
}
function pageBreakAfterStyleIds(tokens: DocxThemeTokenSet) {
const ids = new Set<string>();
for (const entry of tokens.slots) {
if (!entry.style.pageBreakAfter) {
continue;
}
for (const binding of
DOCX_SLOT_WORD_STYLE_BINDINGS[entry.slot] ?? []) {
if (binding.type === "paragraph") {
ids.add(binding.styleId);
}
}
}
return ids;
}
function applyParagraphPageBreaks(
document: ReturnType<typeof parseXmlPart>,
tokens: DocxThemeTokenSet
) {
const styleIds = pageBreakAfterStyleIds(tokens);
let count = 0;
for (const paragraph of Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "p")
)) {
const properties = firstDirectChild(
paragraph,
WORD_NAMESPACE,
"pPr"
);
const style = properties
? firstDirectChild(properties, WORD_NAMESPACE, "pStyle")
: undefined;
const styleId = style && wordAttribute(style, "val");
if (styleId && styleIds.has(styleId)) {
count += Number(appendPageBreak(paragraph));
}
}
return count;
}
function ensureNoMarkers(
body: XmlElement,
plan: PandocStructurePlan
) {
const markers = new Set<string>();
for (const descriptor of containerDescriptors(plan)) {
markers.add(descriptor.container.startMarker);
markers.add(descriptor.container.endMarker);
}
for (const section of [
...sectionBreaks(plan.prefix),
...sectionBreaks(plan.suffix)
]) {
markers.add(section.marker);
}
const residual = bodyElements(body).find(
(element) =>
element.localName === "p" &&
markers.has(paragraphText(element))
);
if (residual) {
throw new Error(
`DOCX 结构标记未被消费:${paragraphText(residual)}`
);
}
}
export function finalizeGeneratedDocxStructure(
content: Uint8Array,
plan: PandocStructurePlan,
tokens: DocxThemeTokenSet
): FinalizedGeneratedDocx {
const source = readGeneratedDocxPackage(content);
const entries = new Map(source.entries);
const document = parseXmlPart(
entries.get("word/document.xml")!,
"word/document.xml"
);
const body = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"body"
)[0];
if (!body) {
throw new Error("DOCX 正文结构无效");
}
const containers = applyContainerRanges(body, plan, tokens);
const sectionCount = applySections(body, plan);
const final = finalSection(body);
const tableCount = applyTables(document, final, tokens);
const pageBreakAfterCount =
containers.pageBreakAfterCount +
applyParagraphPageBreaks(document, tokens);
ensureNoMarkers(body, plan);
entries.set(
"word/document.xml",
serializeXmlPart(document)
);
const sectionPageFieldCount = replaceSectionPageFields(
entries,
sectionCount > 0
);
normalizeGeneratedStyles(entries);
return {
content: writeGeneratedDocxPackage(entries),
report: {
containerCount: containers.containerCount,
sectionCount,
tableCount,
pageBreakAfterCount,
sectionPageFieldCount
}
};
}