feat: 完成 DOCX 通用结构映射与严格收口
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user