feat: 实现动态 DOCX 模板与样式映射

This commit is contained in:
SkyJourney
2026-07-30 14:10:43 +08:00
parent fe5d67fb6d
commit fa159d916f
41 changed files with 3610 additions and 7 deletions
+149
View File
@@ -0,0 +1,149 @@
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
import type {
Document as XmlDocument,
Element as XmlElement
} from "@xmldom/xmldom";
export type { XmlDocument, XmlElement };
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();
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) {
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);
}
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 pointsToTwips(value: number) {
return String(Math.round(value * 20));
}
export function millimetersToTwips(value: number) {
return Math.round((value * 1440) / 25.4);
}