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
+388 -3
View File
@@ -1,10 +1,11 @@
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
import type {
Document as XmlDocument,
Element as XmlElement
Element as XmlElement,
Node as XmlNode
} from "@xmldom/xmldom";
export type { XmlDocument, XmlElement };
export type { XmlDocument, XmlElement, XmlNode };
export const WORD_NAMESPACE =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
@@ -20,6 +21,385 @@ export const DRAWING_NAMESPACE =
const decoder = new TextDecoder("utf-8", { fatal: true });
const encoder = new TextEncoder();
const wordprocessingChildOrders = new Map<
string,
readonly string[]
>([
[
"style",
[
"name",
"aliases",
"basedOn",
"next",
"link",
"autoRedefine",
"hidden",
"uiPriority",
"semiHidden",
"unhideWhenUsed",
"qFormat",
"locked",
"personal",
"personalCompose",
"personalReply",
"rsid",
"pPr",
"rPr",
"tblPr",
"trPr",
"tcPr"
]
],
[
"pPr",
[
"pStyle",
"keepNext",
"keepLines",
"pageBreakBefore",
"framePr",
"widowControl",
"numPr",
"suppressLineNumbers",
"pBdr",
"shd",
"tabs",
"suppressAutoHyphens",
"kinsoku",
"wordWrap",
"overflowPunct",
"topLinePunct",
"autoSpaceDE",
"autoSpaceDN",
"bidi",
"adjustRightInd",
"snapToGrid",
"spacing",
"ind",
"contextualSpacing",
"mirrorIndents",
"suppressOverlap",
"jc",
"textDirection",
"textAlignment",
"textboxTightWrap",
"outlineLvl",
"divId",
"cnfStyle",
"rPr",
"sectPr",
"pPrChange"
]
],
[
"rPr",
[
"rStyle",
"rFonts",
"b",
"bCs",
"i",
"iCs",
"caps",
"smallCaps",
"strike",
"dstrike",
"outline",
"shadow",
"emboss",
"imprint",
"noProof",
"snapToGrid",
"vanish",
"webHidden",
"color",
"spacing",
"w",
"kern",
"position",
"sz",
"szCs",
"highlight",
"u",
"effect",
"bdr",
"shd",
"fitText",
"vertAlign",
"rtl",
"cs",
"em",
"lang",
"eastAsianLayout",
"specVanish",
"oMath",
"rPrChange"
]
],
[
"numPr",
["ilvl", "numId", "numberingChange", "ins"]
],
[
"pBdr",
["top", "left", "bottom", "right", "between", "bar"]
],
[
"tblPr",
[
"tblStyle",
"tblpPr",
"tblOverlap",
"bidiVisual",
"tblStyleRowBandSize",
"tblStyleColBandSize",
"tblW",
"jc",
"tblCellSpacing",
"tblInd",
"tblBorders",
"shd",
"tblLayout",
"tblCellMar",
"tblLook",
"tblCaption",
"tblDescription",
"tblPrChange"
]
],
[
"trPr",
[
"cnfStyle",
"divId",
"gridBefore",
"gridAfter",
"wBefore",
"wAfter",
"cantSplit",
"trHeight",
"tblHeader",
"tblCellSpacing",
"jc",
"hidden",
"ins",
"del",
"trPrChange",
"conflictIns",
"conflictDel"
]
],
[
"tcPr",
[
"cnfStyle",
"tcW",
"gridSpan",
"hMerge",
"vMerge",
"tcBorders",
"shd",
"noWrap",
"tcMar",
"textDirection",
"tcFitText",
"vAlign",
"hideMark",
"headers",
"cellIns",
"cellDel",
"cellMerge",
"tcPrChange"
]
],
[
"tblStylePr",
["pPr", "rPr", "tblPr", "trPr", "tcPr"]
],
[
"sectPr",
[
"headerReference",
"footerReference",
"footnotePr",
"endnotePr",
"type",
"pgSz",
"pgMar",
"paperSrc",
"pgBorders",
"lnNumType",
"pgNumType",
"cols",
"formProt",
"vAlign",
"noEndnote",
"titlePg",
"textDirection",
"bidi",
"rtlGutter",
"docGrid",
"printerSettings",
"sectPrChange"
]
],
[
"tblBorders",
["top", "left", "bottom", "right", "insideH", "insideV"]
],
[
"tcBorders",
[
"top",
"left",
"bottom",
"right",
"insideH",
"insideV",
"tl2br",
"tr2bl"
]
],
[
"tblCellMar",
["top", "left", "start", "bottom", "right", "end"]
],
[
"tcMar",
["top", "left", "start", "bottom", "right", "end"]
],
[
"font",
[
"altName",
"panose1",
"charset",
"family",
"notTrueType",
"pitch",
"sig",
"embedRegular",
"embedBold",
"embedItalic",
"embedBoldItalic"
]
]
]);
function directWordChildren(parent: XmlElement) {
return Array.from(parent.childNodes).filter(
(node): node is XmlElement =>
node.nodeType === 1 &&
(node as XmlElement).namespaceURI === WORD_NAMESPACE
);
}
function wordChildOrder(parent: XmlElement) {
if (parent.namespaceURI !== WORD_NAMESPACE) {
return undefined;
}
return wordprocessingChildOrders.get(parent.localName ?? "");
}
function orderedWordChildren(parent: XmlElement) {
const order = wordChildOrder(parent);
if (!order) {
return undefined;
}
const ranks = new Map(order.map((name, index) => [name, index]));
return {
order,
ranks,
children: directWordChildren(parent).filter((child) =>
ranks.has(child.localName ?? "")
)
};
}
function insertWordElementInOrder(
parent: XmlElement,
element: XmlElement
) {
const ordered = orderedWordChildren(parent);
const rank = ordered?.ranks.get(element.localName ?? "");
if (!ordered || rank === undefined) {
parent.appendChild(element);
return;
}
const insertionPoint = ordered.children.find((child) => {
const childRank = ordered.ranks.get(child.localName ?? "");
return childRank !== undefined && childRank > rank;
});
parent.insertBefore(element, insertionPoint ?? null);
}
export function normalizeWordprocessingElementOrder(
document: XmlDocument
) {
const elements = Array.from(document.getElementsByTagName("*"));
for (const parent of elements) {
const ordered = orderedWordChildren(parent);
if (!ordered || ordered.children.length < 2) {
continue;
}
const sorted = ordered.children
.map((child, index) => ({ child, index }))
.sort((left, right) => {
const leftRank = ordered.ranks.get(
left.child.localName ?? ""
)!;
const rightRank = ordered.ranks.get(
right.child.localName ?? ""
)!;
return leftRank - rightRank || left.index - right.index;
})
.map(({ child }) => child);
if (
sorted.every(
(child, index) => child === ordered.children[index]
)
) {
continue;
}
const anchor: XmlNode | null =
ordered.children.at(-1)?.nextSibling ?? null;
for (const child of ordered.children) {
parent.removeChild(child);
}
for (const child of sorted) {
parent.insertBefore(child, anchor);
}
}
}
export function validateWordprocessingElementOrder(
document: XmlDocument,
partName: string
) {
const elements = Array.from(document.getElementsByTagName("*"));
for (const parent of elements) {
const ordered = orderedWordChildren(parent);
if (!ordered) {
continue;
}
let previousRank = -1;
let previousName = "";
for (const child of ordered.children) {
const name = child.localName ?? "";
const rank = ordered.ranks.get(name)!;
if (rank < previousRank) {
throw new Error(
`${partName} 的 w:${parent.localName} 子节点顺序无效:` +
`w:${name} 不得位于 w:${previousName} 之后`
);
}
previousRank = rank;
previousName = name;
}
}
}
export function parseXmlPart(
content: Uint8Array,
partName: string
@@ -42,6 +422,7 @@ export function parseXmlPart(
}
export function serializeXmlPart(document: XmlDocument) {
normalizeWordprocessingElementOrder(document);
return encoder.encode(
`<?xml version="1.0" encoding="UTF-8"?>\n${new XMLSerializer().serializeToString(
document.documentElement!
@@ -113,7 +494,11 @@ export function appendElement(
: null;
element.setAttributeNS(attributeNamespace, name, value);
}
parent.appendChild(element);
if (namespace === WORD_NAMESPACE) {
insertWordElementInOrder(parent, element);
} else {
parent.appendChild(element);
}
return element;
}