Files
MorphDoc/packages/docx-engine/src/ooxml.ts
T
SkyJourney 58f87cc19f feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。

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

修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
2026-08-02 03:27:11 +08:00

550 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
import type {
Document as XmlDocument,
Element as XmlElement,
Node as XmlNode
} from "@xmldom/xmldom";
export type { XmlDocument, XmlElement, XmlNode };
export const WORD_NAMESPACE =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
export const OFFICE_RELATIONSHIP_NAMESPACE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships";
export const PACKAGE_RELATIONSHIP_NAMESPACE =
"http://schemas.openxmlformats.org/package/2006/relationships";
export const CONTENT_TYPES_NAMESPACE =
"http://schemas.openxmlformats.org/package/2006/content-types";
export const DRAWING_NAMESPACE =
"http://schemas.openxmlformats.org/drawingml/2006/main";
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
) {
try {
return new DOMParser({
onError: (level, message) => {
if (level !== "warning") {
throw new Error(message);
}
}
}).parseFromString(decoder.decode(content), "application/xml");
} catch (error) {
throw new Error(
`${partName} 不是有效 OOXML${
error instanceof Error ? error.message : "XML 解析失败"
}`
);
}
}
export function serializeXmlPart(document: XmlDocument) {
normalizeWordprocessingElementOrder(document);
return encoder.encode(
`<?xml version="1.0" encoding="UTF-8"?>\n${new XMLSerializer().serializeToString(
document.documentElement!
)}`
);
}
export function directChildren(
parent: XmlElement,
namespace: string,
localName: string
) {
const children: XmlElement[] = [];
for (const node of Array.from(parent.childNodes)) {
const element = node as XmlElement;
if (
node.nodeType === 1 &&
element.namespaceURI === namespace &&
element.localName === localName
) {
children.push(element);
}
}
return children;
}
export function firstDirectChild(
parent: XmlElement,
namespace: string,
localName: string
) {
return directChildren(parent, namespace, localName)[0];
}
export function removeDirectChildren(
parent: XmlElement,
namespace: string,
...localNames: string[]
) {
for (const node of Array.from(parent.childNodes)) {
const element = node as XmlElement;
if (
node.nodeType === 1 &&
element.namespaceURI === namespace &&
localNames.includes(element.localName ?? "")
) {
parent.removeChild(node);
}
}
}
export function appendElement(
parent: XmlElement,
namespace: string,
qualifiedName: string,
attributes: Record<string, string> = {}
) {
const element = parent.ownerDocument!.createElementNS(
namespace,
qualifiedName
);
for (const [name, value] of Object.entries(attributes)) {
const prefix = name.includes(":") ? name.split(":")[0] : "";
const attributeNamespace =
prefix === "w"
? WORD_NAMESPACE
: prefix === "r"
? OFFICE_RELATIONSHIP_NAMESPACE
: null;
element.setAttributeNS(attributeNamespace, name, value);
}
if (namespace === WORD_NAMESPACE) {
insertWordElementInOrder(parent, element);
} else {
parent.appendChild(element);
}
return element;
}
export function ensureDirectElement(
parent: XmlElement,
namespace: string,
qualifiedName: string
) {
const localName = qualifiedName.split(":").at(-1)!;
return (
firstDirectChild(parent, namespace, localName) ??
appendElement(parent, namespace, qualifiedName)
);
}
export function colorValue(value: string) {
if (!/^#[0-9a-f]{6}$/iu.test(value)) {
throw new Error(`DOCX 颜色值无效:${value}`);
}
return value.replace(/^#/u, "").toUpperCase();
}
export function pointsToHalfPoints(value: number) {
return String(Math.round(value * 2));
}
export function wordCharacterSpacingTwips(
fontSizePt: number | undefined,
letterSpacingPt = 0
) {
const quantizationCompensation =
fontSizePt === undefined
? 0
: fontSizePt - Math.round(fontSizePt * 2) / 2;
return String(
Math.round(
(letterSpacingPt + quantizationCompensation) * 20
)
);
}
export function pointsToTwips(value: number) {
return String(Math.round(value * 20));
}
export function millimetersToTwips(value: number) {
return Math.round((value * 1440) / 25.4);
}