feat: 实现动态 DOCX 模板与样式映射
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
import {
|
||||
lengthToMillimeters,
|
||||
type FooterConfig,
|
||||
type HeaderConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
CONTENT_TYPES_NAMESPACE,
|
||||
OFFICE_RELATIONSHIP_NAMESPACE,
|
||||
PACKAGE_RELATIONSHIP_NAMESPACE,
|
||||
WORD_NAMESPACE,
|
||||
appendElement,
|
||||
colorValue,
|
||||
parseXmlPart,
|
||||
pointsToHalfPoints,
|
||||
serializeXmlPart,
|
||||
type XmlElement
|
||||
} from "./ooxml.js";
|
||||
import {
|
||||
resolveHeaderTemplate,
|
||||
type DynamicReferenceDocxOptions
|
||||
} from "./reference-options.js";
|
||||
|
||||
const HEADER_RELATIONSHIP_TYPE =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
|
||||
const FOOTER_RELATIONSHIP_TYPE =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
|
||||
const HEADER_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml";
|
||||
const FOOTER_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml";
|
||||
const XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace";
|
||||
|
||||
type HeaderFooterReferenceType = "default" | "even" | "first";
|
||||
|
||||
export interface HeaderFooterReference {
|
||||
kind: "header" | "footer";
|
||||
type: HeaderFooterReferenceType;
|
||||
relationshipId: string;
|
||||
}
|
||||
|
||||
export interface HeaderFooterTransformResult {
|
||||
entries: Map<string, Uint8Array>;
|
||||
references: HeaderFooterReference[];
|
||||
usesEvenAndOddPages: boolean;
|
||||
usesDifferentFirstPage: boolean;
|
||||
}
|
||||
|
||||
interface RunStyle {
|
||||
font: string;
|
||||
sizePt: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function firstFontName(fontFamily: string, fallback: string) {
|
||||
const first = fontFamily.split(",")[0]?.trim() ?? "";
|
||||
return first.replace(/^["']|["']$/gu, "") || fallback;
|
||||
}
|
||||
|
||||
function createWordPart(rootName: "hdr" | "ftr") {
|
||||
return parseXmlPart(
|
||||
new TextEncoder().encode(
|
||||
`<w:${rootName} xmlns:w="${WORD_NAMESPACE}" xmlns:r="${OFFICE_RELATIONSHIP_NAMESPACE}"/>`
|
||||
),
|
||||
`word/${rootName}.xml`
|
||||
);
|
||||
}
|
||||
|
||||
function appendRunProperties(run: XmlElement, style: RunStyle) {
|
||||
const rPr = appendElement(run, WORD_NAMESPACE, "w:rPr");
|
||||
appendElement(rPr, WORD_NAMESPACE, "w:rFonts", {
|
||||
"w:ascii": style.font,
|
||||
"w:hAnsi": style.font,
|
||||
"w:eastAsia": style.font,
|
||||
"w:cs": style.font
|
||||
});
|
||||
appendElement(rPr, WORD_NAMESPACE, "w:color", {
|
||||
"w:val": colorValue(style.color)
|
||||
});
|
||||
const size = pointsToHalfPoints(style.sizePt);
|
||||
appendElement(rPr, WORD_NAMESPACE, "w:sz", {
|
||||
"w:val": size
|
||||
});
|
||||
appendElement(rPr, WORD_NAMESPACE, "w:szCs", {
|
||||
"w:val": size
|
||||
});
|
||||
}
|
||||
|
||||
function appendText(
|
||||
paragraph: XmlElement,
|
||||
value: string,
|
||||
style: RunStyle
|
||||
) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const run = appendElement(paragraph, WORD_NAMESPACE, "w:r");
|
||||
appendRunProperties(run, style);
|
||||
const text = appendElement(run, WORD_NAMESPACE, "w:t");
|
||||
text.setAttributeNS(XML_NAMESPACE, "xml:space", "preserve");
|
||||
text.appendChild(text.ownerDocument!.createTextNode(value));
|
||||
}
|
||||
|
||||
function appendField(
|
||||
paragraph: XmlElement,
|
||||
instruction: "PAGE" | "NUMPAGES",
|
||||
style: RunStyle
|
||||
) {
|
||||
const begin = appendElement(paragraph, WORD_NAMESPACE, "w:r");
|
||||
appendRunProperties(begin, style);
|
||||
appendElement(begin, WORD_NAMESPACE, "w:fldChar", {
|
||||
"w:fldCharType": "begin",
|
||||
"w:dirty": "true"
|
||||
});
|
||||
|
||||
const command = appendElement(paragraph, WORD_NAMESPACE, "w:r");
|
||||
appendRunProperties(command, style);
|
||||
const instructionText = appendElement(
|
||||
command,
|
||||
WORD_NAMESPACE,
|
||||
"w:instrText"
|
||||
);
|
||||
instructionText.setAttributeNS(
|
||||
XML_NAMESPACE,
|
||||
"xml:space",
|
||||
"preserve"
|
||||
);
|
||||
instructionText.appendChild(
|
||||
instructionText.ownerDocument!.createTextNode(
|
||||
` ${instruction} \\* MERGEFORMAT `
|
||||
)
|
||||
);
|
||||
|
||||
const separate = appendElement(paragraph, WORD_NAMESPACE, "w:r");
|
||||
appendRunProperties(separate, style);
|
||||
appendElement(separate, WORD_NAMESPACE, "w:fldChar", {
|
||||
"w:fldCharType": "separate"
|
||||
});
|
||||
appendText(paragraph, "1", style);
|
||||
|
||||
const end = appendElement(paragraph, WORD_NAMESPACE, "w:r");
|
||||
appendRunProperties(end, style);
|
||||
appendElement(end, WORD_NAMESPACE, "w:fldChar", {
|
||||
"w:fldCharType": "end"
|
||||
});
|
||||
}
|
||||
|
||||
function appendFooterContent(
|
||||
paragraph: XmlElement,
|
||||
footer: FooterConfig,
|
||||
style: RunStyle
|
||||
) {
|
||||
const template =
|
||||
footer.format === "page"
|
||||
? "${page}"
|
||||
: footer.format === "page-total"
|
||||
? "${page} / ${pages}"
|
||||
: footer.format === "chinese-page-total"
|
||||
? "第 ${page} 页 / 共 ${pages} 页"
|
||||
: footer.format === "dash-page"
|
||||
? "- ${page} -"
|
||||
: footer.format === "official-page"
|
||||
? "— ${page} —"
|
||||
: footer.template || "${page} / ${pages}";
|
||||
const tokens = template.split(/(\$\{page\}|\$\{pages\})/gu);
|
||||
for (const token of tokens) {
|
||||
if (token === "${page}") {
|
||||
appendField(paragraph, "PAGE", style);
|
||||
} else if (token === "${pages}") {
|
||||
appendField(paragraph, "NUMPAGES", style);
|
||||
} else {
|
||||
appendText(paragraph, token, style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendThreeColumnTable(
|
||||
root: XmlElement,
|
||||
options: {
|
||||
position: "header" | "footer";
|
||||
divider: boolean;
|
||||
dividerColor: string;
|
||||
alignments: readonly ["left", "center", "right"];
|
||||
render: (
|
||||
paragraph: XmlElement,
|
||||
alignment: "left" | "center" | "right"
|
||||
) => void;
|
||||
}
|
||||
) {
|
||||
const table = appendElement(root, WORD_NAMESPACE, "w:tbl");
|
||||
const tableProperties = appendElement(
|
||||
table,
|
||||
WORD_NAMESPACE,
|
||||
"w:tblPr"
|
||||
);
|
||||
appendElement(tableProperties, WORD_NAMESPACE, "w:tblW", {
|
||||
"w:w": "5000",
|
||||
"w:type": "pct"
|
||||
});
|
||||
appendElement(tableProperties, WORD_NAMESPACE, "w:tblLayout", {
|
||||
"w:type": "fixed"
|
||||
});
|
||||
if (options.divider) {
|
||||
const borders = appendElement(
|
||||
tableProperties,
|
||||
WORD_NAMESPACE,
|
||||
"w:tblBorders"
|
||||
);
|
||||
appendElement(
|
||||
borders,
|
||||
WORD_NAMESPACE,
|
||||
options.position === "header" ? "w:bottom" : "w:top",
|
||||
{
|
||||
"w:val": "single",
|
||||
"w:sz": "4",
|
||||
"w:space": "2",
|
||||
"w:color": colorValue(options.dividerColor)
|
||||
}
|
||||
);
|
||||
}
|
||||
const grid = appendElement(table, WORD_NAMESPACE, "w:tblGrid");
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
appendElement(grid, WORD_NAMESPACE, "w:gridCol", {
|
||||
"w:w": "2400"
|
||||
});
|
||||
}
|
||||
const row = appendElement(table, WORD_NAMESPACE, "w:tr");
|
||||
for (const alignment of options.alignments) {
|
||||
const cell = appendElement(row, WORD_NAMESPACE, "w:tc");
|
||||
const cellProperties = appendElement(
|
||||
cell,
|
||||
WORD_NAMESPACE,
|
||||
"w:tcPr"
|
||||
);
|
||||
appendElement(cellProperties, WORD_NAMESPACE, "w:tcW", {
|
||||
"w:w": "1667",
|
||||
"w:type": "pct"
|
||||
});
|
||||
const paragraph = appendElement(cell, WORD_NAMESPACE, "w:p");
|
||||
const paragraphProperties = appendElement(
|
||||
paragraph,
|
||||
WORD_NAMESPACE,
|
||||
"w:pPr"
|
||||
);
|
||||
appendElement(paragraphProperties, WORD_NAMESPACE, "w:jc", {
|
||||
"w:val": alignment
|
||||
});
|
||||
options.render(paragraph, alignment);
|
||||
}
|
||||
}
|
||||
|
||||
function createHeaderPart(
|
||||
header: HeaderConfig,
|
||||
options: DynamicReferenceDocxOptions,
|
||||
fallbackFont: string
|
||||
) {
|
||||
const document = createWordPart("hdr");
|
||||
const style: RunStyle = {
|
||||
font: firstFontName(header.fontFamily, fallbackFont),
|
||||
sizePt: (lengthToMillimeters(header.fontSize) * 72) / 25.4,
|
||||
color: header.color
|
||||
};
|
||||
appendThreeColumnTable(document.documentElement!, {
|
||||
position: "header",
|
||||
divider: header.showDivider,
|
||||
dividerColor: header.color,
|
||||
alignments: ["left", "center", "right"],
|
||||
render: (paragraph, alignment) => {
|
||||
const slot = header[alignment];
|
||||
if (slot.enabled) {
|
||||
appendText(
|
||||
paragraph,
|
||||
resolveHeaderTemplate(slot.content, options),
|
||||
style
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
return serializeXmlPart(document);
|
||||
}
|
||||
|
||||
function createFooterPart(
|
||||
footer: FooterConfig,
|
||||
alignment: "left" | "center" | "right",
|
||||
fallbackFont: string,
|
||||
empty = false
|
||||
) {
|
||||
const document = createWordPart("ftr");
|
||||
const style: RunStyle = {
|
||||
font: firstFontName(footer.fontFamily, fallbackFont),
|
||||
sizePt: (lengthToMillimeters(footer.fontSize) * 72) / 25.4,
|
||||
color: footer.color
|
||||
};
|
||||
appendThreeColumnTable(document.documentElement!, {
|
||||
position: "footer",
|
||||
divider: footer.showDivider,
|
||||
dividerColor: footer.color,
|
||||
alignments: ["left", "center", "right"],
|
||||
render: (paragraph, cellAlignment) => {
|
||||
if (!empty && cellAlignment === alignment) {
|
||||
appendFooterContent(paragraph, footer, style);
|
||||
}
|
||||
}
|
||||
});
|
||||
return serializeXmlPart(document);
|
||||
}
|
||||
|
||||
function removeExistingHeaderFooterParts(
|
||||
entries: Map<string, Uint8Array>
|
||||
) {
|
||||
for (const name of [...entries.keys()]) {
|
||||
if (/^word\/(?:header|footer)\d+\.xml$/u.test(name)) {
|
||||
entries.delete(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function nextRelationshipId(relationships: XmlElement) {
|
||||
const maximum = Array.from(
|
||||
relationships.getElementsByTagNameNS(
|
||||
PACKAGE_RELATIONSHIP_NAMESPACE,
|
||||
"Relationship"
|
||||
)
|
||||
).reduce((current, relationship) => {
|
||||
const match = /^rId(\d+)$/u.exec(
|
||||
relationship.getAttribute("Id") ?? ""
|
||||
);
|
||||
return Math.max(current, Number(match?.[1] ?? 0));
|
||||
}, 0);
|
||||
let next = maximum + 1;
|
||||
return () => `rId${next++}`;
|
||||
}
|
||||
|
||||
function updateRelationships(
|
||||
entries: Map<string, Uint8Array>,
|
||||
parts: Array<{
|
||||
kind: "header" | "footer";
|
||||
type: HeaderFooterReferenceType;
|
||||
partName: string;
|
||||
}>
|
||||
) {
|
||||
const document = parseXmlPart(
|
||||
entries.get("word/_rels/document.xml.rels")!,
|
||||
"word/_rels/document.xml.rels"
|
||||
);
|
||||
const root = document.documentElement!;
|
||||
for (const relationship of Array.from(
|
||||
root.getElementsByTagNameNS(
|
||||
PACKAGE_RELATIONSHIP_NAMESPACE,
|
||||
"Relationship"
|
||||
)
|
||||
)) {
|
||||
const type = relationship.getAttribute("Type");
|
||||
if (
|
||||
type === HEADER_RELATIONSHIP_TYPE ||
|
||||
type === FOOTER_RELATIONSHIP_TYPE
|
||||
) {
|
||||
root.removeChild(relationship);
|
||||
}
|
||||
}
|
||||
const allocateId = nextRelationshipId(root);
|
||||
const references: HeaderFooterReference[] = [];
|
||||
for (const part of parts) {
|
||||
const relationshipId = allocateId();
|
||||
appendElement(
|
||||
root,
|
||||
PACKAGE_RELATIONSHIP_NAMESPACE,
|
||||
"Relationship",
|
||||
{
|
||||
Id: relationshipId,
|
||||
Type:
|
||||
part.kind === "header"
|
||||
? HEADER_RELATIONSHIP_TYPE
|
||||
: FOOTER_RELATIONSHIP_TYPE,
|
||||
Target: part.partName.replace(/^word\//u, "")
|
||||
}
|
||||
);
|
||||
references.push({
|
||||
kind: part.kind,
|
||||
type: part.type,
|
||||
relationshipId
|
||||
});
|
||||
}
|
||||
entries.set(
|
||||
"word/_rels/document.xml.rels",
|
||||
serializeXmlPart(document)
|
||||
);
|
||||
return references;
|
||||
}
|
||||
|
||||
function updateContentTypes(
|
||||
entries: Map<string, Uint8Array>,
|
||||
parts: Array<{
|
||||
kind: "header" | "footer";
|
||||
partName: string;
|
||||
}>
|
||||
) {
|
||||
const document = parseXmlPart(
|
||||
entries.get("[Content_Types].xml")!,
|
||||
"[Content_Types].xml"
|
||||
);
|
||||
const root = document.documentElement!;
|
||||
for (const override of Array.from(
|
||||
root.getElementsByTagNameNS(CONTENT_TYPES_NAMESPACE, "Override")
|
||||
)) {
|
||||
if (
|
||||
/^\/word\/(?:header|footer)\d+\.xml$/u.test(
|
||||
override.getAttribute("PartName") ?? ""
|
||||
)
|
||||
) {
|
||||
root.removeChild(override);
|
||||
}
|
||||
}
|
||||
for (const part of parts) {
|
||||
appendElement(root, CONTENT_TYPES_NAMESPACE, "Override", {
|
||||
PartName: `/${part.partName}`,
|
||||
ContentType:
|
||||
part.kind === "header"
|
||||
? HEADER_CONTENT_TYPE
|
||||
: FOOTER_CONTENT_TYPE
|
||||
});
|
||||
}
|
||||
entries.set("[Content_Types].xml", serializeXmlPart(document));
|
||||
}
|
||||
|
||||
export function createHeaderFooterParts(
|
||||
sourceEntries: ReadonlyMap<string, Uint8Array>,
|
||||
options: DynamicReferenceDocxOptions,
|
||||
header: HeaderConfig,
|
||||
footer: FooterConfig,
|
||||
fallbackFont: string
|
||||
): HeaderFooterTransformResult {
|
||||
const entries = new Map(sourceEntries);
|
||||
removeExistingHeaderFooterParts(entries);
|
||||
const parts: Array<{
|
||||
kind: "header" | "footer";
|
||||
type: HeaderFooterReferenceType;
|
||||
partName: string;
|
||||
}> = [];
|
||||
let headerIndex = 1;
|
||||
let footerIndex = 1;
|
||||
const usesEvenAndOddPages =
|
||||
footer.enabled && footer.alignment === "outer";
|
||||
const usesDifferentFirstPage =
|
||||
footer.enabled && !footer.showOnFirstPage;
|
||||
|
||||
if (header.enabled) {
|
||||
const headerContent = createHeaderPart(
|
||||
header,
|
||||
options,
|
||||
fallbackFont
|
||||
);
|
||||
for (const type of [
|
||||
"default",
|
||||
...(usesEvenAndOddPages ? ["even"] : []),
|
||||
...(usesDifferentFirstPage ? ["first"] : [])
|
||||
] as HeaderFooterReferenceType[]) {
|
||||
const partName = `word/header${headerIndex++}.xml`;
|
||||
entries.set(partName, headerContent);
|
||||
parts.push({ kind: "header", type, partName });
|
||||
}
|
||||
}
|
||||
|
||||
if (footer.enabled) {
|
||||
const defaultAlignment =
|
||||
footer.alignment === "outer" ? "right" : footer.alignment;
|
||||
const defaultPartName = `word/footer${footerIndex++}.xml`;
|
||||
entries.set(
|
||||
defaultPartName,
|
||||
createFooterPart(
|
||||
footer,
|
||||
defaultAlignment,
|
||||
fallbackFont
|
||||
)
|
||||
);
|
||||
parts.push({
|
||||
kind: "footer",
|
||||
type: "default",
|
||||
partName: defaultPartName
|
||||
});
|
||||
if (usesEvenAndOddPages) {
|
||||
const evenPartName = `word/footer${footerIndex++}.xml`;
|
||||
entries.set(
|
||||
evenPartName,
|
||||
createFooterPart(footer, "left", fallbackFont)
|
||||
);
|
||||
parts.push({
|
||||
kind: "footer",
|
||||
type: "even",
|
||||
partName: evenPartName
|
||||
});
|
||||
}
|
||||
if (usesDifferentFirstPage) {
|
||||
const firstPartName = `word/footer${footerIndex++}.xml`;
|
||||
entries.set(
|
||||
firstPartName,
|
||||
createFooterPart(
|
||||
footer,
|
||||
defaultAlignment,
|
||||
fallbackFont,
|
||||
true
|
||||
)
|
||||
);
|
||||
parts.push({
|
||||
kind: "footer",
|
||||
type: "first",
|
||||
partName: firstPartName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const references = updateRelationships(entries, parts);
|
||||
updateContentTypes(entries, parts);
|
||||
return {
|
||||
entries,
|
||||
references,
|
||||
usesEvenAndOddPages,
|
||||
usesDifferentFirstPage
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from "./header-footer-transform.js";
|
||||
export * from "./ooxml.js";
|
||||
export * from "./reference-builder.js";
|
||||
export * from "./reference-options.js";
|
||||
export * from "./reference-package.js";
|
||||
export * from "./section-transform.js";
|
||||
export * from "./style-presets.js";
|
||||
export * from "./styles-transform.js";
|
||||
export * from "./validator.js";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
DOCX_PANDOC_VERSION,
|
||||
lengthToMillimeters
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
createHeaderFooterParts
|
||||
} from "./header-footer-transform.js";
|
||||
import {
|
||||
readReferenceDocxPackage,
|
||||
writeReferenceDocxPackage
|
||||
} from "./reference-package.js";
|
||||
import {
|
||||
resolveReferencePageOptions,
|
||||
type DynamicReferenceDocxOptions
|
||||
} from "./reference-options.js";
|
||||
import {
|
||||
transformDocumentSectionXml,
|
||||
transformSettingsXml
|
||||
} from "./section-transform.js";
|
||||
import {
|
||||
transformFontTableXml,
|
||||
transformStylesXml,
|
||||
transformThemeFontsXml
|
||||
} from "./styles-transform.js";
|
||||
import { resolveDocxThemeStyle } from "./style-presets.js";
|
||||
import { validateDynamicReferenceDocx } from "./validator.js";
|
||||
|
||||
export interface DynamicReferenceDocxResult {
|
||||
content: Uint8Array;
|
||||
baselineFingerprint: string;
|
||||
templateFingerprint: string;
|
||||
cacheKey: string;
|
||||
stylePreset: string;
|
||||
partCount: number;
|
||||
}
|
||||
|
||||
function stableJson(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableJson).join(",")}]`;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(
|
||||
([key, item]) =>
|
||||
`${JSON.stringify(key)}:${stableJson(item)}`
|
||||
)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? "null";
|
||||
}
|
||||
|
||||
export function createDynamicReferenceCacheKey(
|
||||
baselineFingerprint: string,
|
||||
options: DynamicReferenceDocxOptions
|
||||
) {
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
stableJson({
|
||||
pandocVersion: DOCX_PANDOC_VERSION,
|
||||
baselineFingerprint,
|
||||
theme: {
|
||||
id: options.theme.id,
|
||||
version: options.theme.version,
|
||||
category: options.theme.category,
|
||||
docxStyle: options.theme.docxStyle,
|
||||
pageDefaults: options.theme.pageDefaults
|
||||
},
|
||||
paper: options.exportConfig.paper,
|
||||
pageDecorationsMode:
|
||||
options.exportConfig.pageDecorationsMode,
|
||||
header: options.exportConfig.header,
|
||||
footer: options.exportConfig.footer,
|
||||
fileName: options.fileName,
|
||||
metadata: options.metadata
|
||||
})
|
||||
)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function createDynamicReferenceDocx(
|
||||
baseline: Uint8Array,
|
||||
options: DynamicReferenceDocxOptions
|
||||
): DynamicReferenceDocxResult {
|
||||
const reference = readReferenceDocxPackage(baseline);
|
||||
const style = resolveDocxThemeStyle(options.theme);
|
||||
const page = resolveReferencePageOptions(options);
|
||||
const withDecorations = createHeaderFooterParts(
|
||||
reference.entries,
|
||||
options,
|
||||
page.header,
|
||||
page.footer,
|
||||
style.body.fonts.eastAsia
|
||||
);
|
||||
const entries = withDecorations.entries;
|
||||
entries.set(
|
||||
"word/styles.xml",
|
||||
transformStylesXml(entries.get("word/styles.xml")!, style)
|
||||
);
|
||||
entries.set(
|
||||
"word/fontTable.xml",
|
||||
transformFontTableXml(entries.get("word/fontTable.xml")!, style)
|
||||
);
|
||||
entries.set(
|
||||
"word/theme/theme1.xml",
|
||||
transformThemeFontsXml(
|
||||
entries.get("word/theme/theme1.xml")!,
|
||||
style
|
||||
)
|
||||
);
|
||||
entries.set(
|
||||
"word/document.xml",
|
||||
transformDocumentSectionXml(
|
||||
entries.get("word/document.xml")!,
|
||||
{
|
||||
dimensions: page.dimensions,
|
||||
margins: page.margins,
|
||||
orientation: page.orientation,
|
||||
headerHeightMm: page.header.enabled
|
||||
? lengthToMillimeters(page.header.height)
|
||||
: 0,
|
||||
footerHeightMm: page.footer.enabled
|
||||
? lengthToMillimeters(page.footer.height)
|
||||
: 0,
|
||||
pageNumberStart: page.footer.startFrom,
|
||||
references: withDecorations.references,
|
||||
usesDifferentFirstPage:
|
||||
withDecorations.usesDifferentFirstPage
|
||||
}
|
||||
)
|
||||
);
|
||||
entries.set(
|
||||
"word/settings.xml",
|
||||
transformSettingsXml(
|
||||
entries.get("word/settings.xml")!,
|
||||
withDecorations.usesEvenAndOddPages
|
||||
)
|
||||
);
|
||||
const content = writeReferenceDocxPackage(entries);
|
||||
const validation = validateDynamicReferenceDocx(content);
|
||||
return {
|
||||
content,
|
||||
baselineFingerprint: reference.fingerprint,
|
||||
templateFingerprint: createHash("sha256")
|
||||
.update(content)
|
||||
.digest("hex"),
|
||||
cacheKey: createDynamicReferenceCacheKey(
|
||||
reference.fingerprint,
|
||||
options
|
||||
),
|
||||
stylePreset: style.preset,
|
||||
partCount: validation.partCount
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
defaultExportConfig,
|
||||
getPaperDimensionsMm,
|
||||
lengthToMillimeters,
|
||||
resolvePageMargins,
|
||||
type ExportConfig,
|
||||
type MarkdownDocumentMetadata,
|
||||
type ThemeManifest
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export interface DynamicReferenceDocxOptions {
|
||||
exportConfig: ExportConfig;
|
||||
theme: ThemeManifest;
|
||||
fileName: string;
|
||||
metadata: Pick<MarkdownDocumentMetadata, "title" | "author">;
|
||||
}
|
||||
|
||||
export function resolveReferencePageOptions(
|
||||
options: DynamicReferenceDocxOptions
|
||||
) {
|
||||
const { exportConfig, theme } = options;
|
||||
const themeDefaults = theme.pageDefaults;
|
||||
const dimensions = getPaperDimensionsMm(
|
||||
exportConfig.paper.format,
|
||||
exportConfig.paper.orientation
|
||||
);
|
||||
const margins = resolvePageMargins(
|
||||
exportConfig.paper,
|
||||
themeDefaults?.margins
|
||||
);
|
||||
const useThemeDecorations =
|
||||
exportConfig.pageDecorationsMode === "theme";
|
||||
return {
|
||||
dimensions,
|
||||
margins: {
|
||||
top: lengthToMillimeters(margins.top),
|
||||
right: lengthToMillimeters(margins.right),
|
||||
bottom: lengthToMillimeters(margins.bottom),
|
||||
left: lengthToMillimeters(margins.left)
|
||||
},
|
||||
orientation: exportConfig.paper.orientation,
|
||||
header: useThemeDecorations
|
||||
? themeDefaults?.header ?? defaultExportConfig.header
|
||||
: exportConfig.header,
|
||||
footer: useThemeDecorations
|
||||
? themeDefaults?.footer ?? defaultExportConfig.footer
|
||||
: exportConfig.footer
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveHeaderTemplate(
|
||||
value: string,
|
||||
options: DynamicReferenceDocxOptions
|
||||
) {
|
||||
const replacements: Record<string, string> = {
|
||||
title: options.metadata.title,
|
||||
author: options.metadata.author,
|
||||
filename: options.fileName
|
||||
};
|
||||
return value.replace(
|
||||
/\$\{(title|author|filename)\}/gu,
|
||||
(_match, name: string) => replacements[name] ?? ""
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { unzipSync, zipSync, type Zippable } from "fflate";
|
||||
|
||||
export const MAXIMUM_REFERENCE_DOCX_BYTES = 2 * 1024 * 1024;
|
||||
export const MAXIMUM_REFERENCE_ENTRY_COUNT = 256;
|
||||
export const MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES =
|
||||
64 * 1024 * 1024;
|
||||
|
||||
export const requiredReferenceDocxParts = [
|
||||
"[Content_Types].xml",
|
||||
"_rels/.rels",
|
||||
"word/document.xml",
|
||||
"word/fontTable.xml",
|
||||
"word/numbering.xml",
|
||||
"word/settings.xml",
|
||||
"word/styles.xml",
|
||||
"word/_rels/document.xml.rels",
|
||||
"word/theme/theme1.xml"
|
||||
] as const;
|
||||
|
||||
export interface ReferenceDocxPackage {
|
||||
fingerprint: string;
|
||||
entries: ReadonlyMap<string, Uint8Array>;
|
||||
}
|
||||
|
||||
interface ZipDirectoryEntry {
|
||||
name: string;
|
||||
uncompressedSize: number;
|
||||
}
|
||||
|
||||
function findEndOfCentralDirectory(content: Uint8Array) {
|
||||
const view = new DataView(
|
||||
content.buffer,
|
||||
content.byteOffset,
|
||||
content.byteLength
|
||||
);
|
||||
const minimumOffset = Math.max(0, content.byteLength - 65_557);
|
||||
for (
|
||||
let offset = content.byteLength - 22;
|
||||
offset >= minimumOffset;
|
||||
offset -= 1
|
||||
) {
|
||||
if (view.getUint32(offset, true) === 0x06054b50) {
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
throw new Error("reference.docx 缺少 ZIP 中央目录");
|
||||
}
|
||||
|
||||
function validateEntryName(name: string) {
|
||||
if (
|
||||
!name ||
|
||||
name.includes("\\") ||
|
||||
name.startsWith("/") ||
|
||||
name.includes(":") ||
|
||||
name.split("/").some((segment) => segment === "..")
|
||||
) {
|
||||
throw new Error(`reference.docx 包含不安全路径:${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function inspectZipDirectory(content: Uint8Array) {
|
||||
if (
|
||||
content.byteLength < 22 ||
|
||||
content.byteLength > MAXIMUM_REFERENCE_DOCX_BYTES
|
||||
) {
|
||||
throw new Error("reference.docx 压缩包大小超过限制或内容不完整");
|
||||
}
|
||||
|
||||
const view = new DataView(
|
||||
content.buffer,
|
||||
content.byteOffset,
|
||||
content.byteLength
|
||||
);
|
||||
const endOffset = findEndOfCentralDirectory(content);
|
||||
const diskNumber = view.getUint16(endOffset + 4, true);
|
||||
const directoryDisk = view.getUint16(endOffset + 6, true);
|
||||
const entryCount = view.getUint16(endOffset + 10, true);
|
||||
const directorySize = view.getUint32(endOffset + 12, true);
|
||||
const directoryOffset = view.getUint32(endOffset + 16, true);
|
||||
if (
|
||||
diskNumber !== 0 ||
|
||||
directoryDisk !== 0 ||
|
||||
entryCount > MAXIMUM_REFERENCE_ENTRY_COUNT ||
|
||||
entryCount === 0 ||
|
||||
directoryOffset + directorySize > endOffset
|
||||
) {
|
||||
throw new Error("reference.docx ZIP 中央目录不受支持");
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
const entries: ZipDirectoryEntry[] = [];
|
||||
const names = new Set<string>();
|
||||
let totalUncompressedBytes = 0;
|
||||
let offset = directoryOffset;
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (
|
||||
offset + 46 > endOffset ||
|
||||
view.getUint32(offset, true) !== 0x02014b50
|
||||
) {
|
||||
throw new Error("reference.docx ZIP 中央目录损坏");
|
||||
}
|
||||
const flags = view.getUint16(offset + 8, true);
|
||||
const compressionMethod = view.getUint16(offset + 10, true);
|
||||
const compressedSize = view.getUint32(offset + 20, true);
|
||||
const uncompressedSize = view.getUint32(offset + 24, true);
|
||||
const nameLength = view.getUint16(offset + 28, true);
|
||||
const extraLength = view.getUint16(offset + 30, true);
|
||||
const commentLength = view.getUint16(offset + 32, true);
|
||||
const nextOffset =
|
||||
offset + 46 + nameLength + extraLength + commentLength;
|
||||
if (
|
||||
nextOffset > endOffset ||
|
||||
(flags & 0x1) !== 0 ||
|
||||
![0, 8].includes(compressionMethod) ||
|
||||
compressedSize === 0xffffffff ||
|
||||
uncompressedSize === 0xffffffff
|
||||
) {
|
||||
throw new Error("reference.docx 包含不支持的 ZIP 条目");
|
||||
}
|
||||
const name = decoder.decode(
|
||||
content.subarray(offset + 46, offset + 46 + nameLength)
|
||||
);
|
||||
validateEntryName(name);
|
||||
if (names.has(name)) {
|
||||
throw new Error(`reference.docx 包含重复条目:${name}`);
|
||||
}
|
||||
names.add(name);
|
||||
totalUncompressedBytes += uncompressedSize;
|
||||
if (
|
||||
totalUncompressedBytes >
|
||||
MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES
|
||||
) {
|
||||
throw new Error("reference.docx 解压后总大小超过限制");
|
||||
}
|
||||
entries.push({ name, uncompressedSize });
|
||||
offset = nextOffset;
|
||||
}
|
||||
if (offset !== directoryOffset + directorySize) {
|
||||
throw new Error("reference.docx ZIP 中央目录长度不一致");
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function readReferenceDocxPackage(
|
||||
content: Uint8Array
|
||||
): ReferenceDocxPackage {
|
||||
const directory = inspectZipDirectory(content);
|
||||
const unzipped = unzipSync(content);
|
||||
const entries = new Map<string, Uint8Array>();
|
||||
for (const descriptor of directory) {
|
||||
if (descriptor.name.endsWith("/")) {
|
||||
continue;
|
||||
}
|
||||
const value = unzipped[descriptor.name];
|
||||
if (
|
||||
!value ||
|
||||
value.byteLength !== descriptor.uncompressedSize
|
||||
) {
|
||||
throw new Error(
|
||||
`reference.docx 条目解压尺寸不一致:${descriptor.name}`
|
||||
);
|
||||
}
|
||||
entries.set(descriptor.name, value);
|
||||
}
|
||||
for (const required of requiredReferenceDocxParts) {
|
||||
if (!entries.has(required)) {
|
||||
throw new Error(`reference.docx 缺少必要部件:${required}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
fingerprint: createHash("sha256")
|
||||
.update(content)
|
||||
.digest("hex"),
|
||||
entries
|
||||
};
|
||||
}
|
||||
|
||||
export function writeReferenceDocxPackage(
|
||||
entries: ReadonlyMap<string, Uint8Array>
|
||||
) {
|
||||
const zippable: Zippable = {};
|
||||
const mtime = new Date("1980-01-01T00:00:00.000Z");
|
||||
for (const [name, content] of [...entries].sort(([left], [right]) =>
|
||||
left.localeCompare(right)
|
||||
)) {
|
||||
validateEntryName(name);
|
||||
zippable[name] = [content, { mtime }];
|
||||
}
|
||||
const result = zipSync(zippable, {
|
||||
level: 6,
|
||||
mtime
|
||||
});
|
||||
readReferenceDocxPackage(result);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
WORD_NAMESPACE,
|
||||
appendElement,
|
||||
firstDirectChild,
|
||||
millimetersToTwips,
|
||||
parseXmlPart,
|
||||
removeDirectChildren,
|
||||
serializeXmlPart,
|
||||
type XmlElement
|
||||
} from "./ooxml.js";
|
||||
import type { HeaderFooterReference } from "./header-footer-transform.js";
|
||||
|
||||
export interface ReferenceSectionOptions {
|
||||
dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
margins: {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
};
|
||||
orientation: "portrait" | "landscape";
|
||||
headerHeightMm: number;
|
||||
footerHeightMm: number;
|
||||
pageNumberStart: number;
|
||||
references: HeaderFooterReference[];
|
||||
usesDifferentFirstPage: boolean;
|
||||
}
|
||||
|
||||
function insertReference(
|
||||
section: XmlElement,
|
||||
reference: HeaderFooterReference
|
||||
) {
|
||||
const element = section.ownerDocument!.createElementNS(
|
||||
WORD_NAMESPACE,
|
||||
`w:${reference.kind}Reference`
|
||||
);
|
||||
element.setAttributeNS(
|
||||
WORD_NAMESPACE,
|
||||
"w:type",
|
||||
reference.type
|
||||
);
|
||||
element.setAttributeNS(
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
"r:id",
|
||||
reference.relationshipId
|
||||
);
|
||||
section.insertBefore(element, section.firstChild);
|
||||
}
|
||||
|
||||
export function transformDocumentSectionXml(
|
||||
content: Uint8Array,
|
||||
options: ReferenceSectionOptions
|
||||
) {
|
||||
const document = parseXmlPart(content, "word/document.xml");
|
||||
const body = document.getElementsByTagNameNS(
|
||||
WORD_NAMESPACE,
|
||||
"body"
|
||||
)[0];
|
||||
const section = body
|
||||
? firstDirectChild(body, WORD_NAMESPACE, "sectPr")
|
||||
: undefined;
|
||||
if (!section) {
|
||||
throw new Error("word/document.xml 缺少 w:body/w:sectPr");
|
||||
}
|
||||
removeDirectChildren(
|
||||
section,
|
||||
WORD_NAMESPACE,
|
||||
"headerReference",
|
||||
"footerReference",
|
||||
"pgSz",
|
||||
"pgMar",
|
||||
"pgNumType",
|
||||
"titlePg"
|
||||
);
|
||||
for (const reference of [...options.references].reverse()) {
|
||||
insertReference(section, reference);
|
||||
}
|
||||
|
||||
const pageSize = appendElement(
|
||||
section,
|
||||
WORD_NAMESPACE,
|
||||
"w:pgSz",
|
||||
{
|
||||
"w:w": String(millimetersToTwips(options.dimensions.width)),
|
||||
"w:h": String(millimetersToTwips(options.dimensions.height))
|
||||
}
|
||||
);
|
||||
if (options.orientation === "landscape") {
|
||||
pageSize.setAttributeNS(
|
||||
WORD_NAMESPACE,
|
||||
"w:orient",
|
||||
"landscape"
|
||||
);
|
||||
}
|
||||
appendElement(section, WORD_NAMESPACE, "w:pgMar", {
|
||||
"w:top": String(millimetersToTwips(options.margins.top)),
|
||||
"w:right": String(millimetersToTwips(options.margins.right)),
|
||||
"w:bottom": String(millimetersToTwips(options.margins.bottom)),
|
||||
"w:left": String(millimetersToTwips(options.margins.left)),
|
||||
"w:header": String(
|
||||
millimetersToTwips(
|
||||
Math.max(
|
||||
0,
|
||||
options.margins.top - options.headerHeightMm
|
||||
)
|
||||
)
|
||||
),
|
||||
"w:footer": String(
|
||||
millimetersToTwips(
|
||||
Math.max(
|
||||
0,
|
||||
options.margins.bottom - options.footerHeightMm
|
||||
)
|
||||
)
|
||||
),
|
||||
"w:gutter": "0"
|
||||
});
|
||||
appendElement(section, WORD_NAMESPACE, "w:pgNumType", {
|
||||
"w:start": String(options.pageNumberStart)
|
||||
});
|
||||
if (options.usesDifferentFirstPage) {
|
||||
appendElement(section, WORD_NAMESPACE, "w:titlePg");
|
||||
}
|
||||
return serializeXmlPart(document);
|
||||
}
|
||||
|
||||
export function transformSettingsXml(
|
||||
content: Uint8Array,
|
||||
usesEvenAndOddPages: boolean
|
||||
) {
|
||||
const document = parseXmlPart(content, "word/settings.xml");
|
||||
const settings = document.documentElement!;
|
||||
removeDirectChildren(
|
||||
settings,
|
||||
WORD_NAMESPACE,
|
||||
"evenAndOddHeaders"
|
||||
);
|
||||
if (usesEvenAndOddPages) {
|
||||
appendElement(
|
||||
settings,
|
||||
WORD_NAMESPACE,
|
||||
"w:evenAndOddHeaders"
|
||||
);
|
||||
}
|
||||
return serializeXmlPart(document);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
docxThemeStyleSchema,
|
||||
type DocxFontFamily,
|
||||
type DocxStylePreset,
|
||||
type DocxThemeStyle,
|
||||
type ThemeManifest
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export interface ResolvedDocxThemeStyle {
|
||||
preset: DocxStylePreset;
|
||||
body: {
|
||||
fonts: DocxFontFamily;
|
||||
sizePt: number;
|
||||
color: string;
|
||||
lineSpacing: number;
|
||||
firstLineIndentChars: number;
|
||||
spacingBeforePt: number;
|
||||
spacingAfterPt: number;
|
||||
};
|
||||
headings: {
|
||||
fonts: DocxFontFamily;
|
||||
sizesPt: [number, number, number, number, number, number];
|
||||
color: string;
|
||||
bold: boolean;
|
||||
spacingBeforePt: number;
|
||||
spacingAfterPt: number;
|
||||
};
|
||||
code: {
|
||||
fonts: DocxFontFamily;
|
||||
sizePt: number;
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
lineSpacing: number;
|
||||
};
|
||||
blockQuote: {
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
leftIndentChars: number;
|
||||
italic: boolean;
|
||||
};
|
||||
table: {
|
||||
fonts: DocxFontFamily;
|
||||
sizePt: number;
|
||||
color: string;
|
||||
headerColor: string;
|
||||
headerBackgroundColor: string;
|
||||
borderColor: string;
|
||||
cellMarginMm: number;
|
||||
};
|
||||
caption: {
|
||||
fonts: DocxFontFamily;
|
||||
sizePt: number;
|
||||
color: string;
|
||||
italic: boolean;
|
||||
alignment: "left" | "center" | "right";
|
||||
};
|
||||
hyperlink: {
|
||||
color: string;
|
||||
underline: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const songFonts: DocxFontFamily = {
|
||||
latin: "Times New Roman",
|
||||
eastAsia: "SimSun"
|
||||
};
|
||||
const sansFonts: DocxFontFamily = {
|
||||
latin: "Arial",
|
||||
eastAsia: "SimHei"
|
||||
};
|
||||
const technicalFonts: DocxFontFamily = {
|
||||
latin: "Arial",
|
||||
eastAsia: "Microsoft YaHei"
|
||||
};
|
||||
const codeFonts: DocxFontFamily = {
|
||||
latin: "Consolas",
|
||||
eastAsia: "Microsoft YaHei"
|
||||
};
|
||||
|
||||
const baseStyle: Omit<ResolvedDocxThemeStyle, "preset"> = {
|
||||
body: {
|
||||
fonts: songFonts,
|
||||
sizePt: 10.5,
|
||||
color: "#1f2328",
|
||||
lineSpacing: 1.5,
|
||||
firstLineIndentChars: 0,
|
||||
spacingBeforePt: 0,
|
||||
spacingAfterPt: 6
|
||||
},
|
||||
headings: {
|
||||
fonts: sansFonts,
|
||||
sizesPt: [22, 18, 16, 14, 12, 10.5],
|
||||
color: "#1f2328",
|
||||
bold: true,
|
||||
spacingBeforePt: 12,
|
||||
spacingAfterPt: 6
|
||||
},
|
||||
code: {
|
||||
fonts: codeFonts,
|
||||
sizePt: 9,
|
||||
color: "#24292f",
|
||||
backgroundColor: "#f6f8fa",
|
||||
borderColor: "#d0d7de",
|
||||
lineSpacing: 1.25
|
||||
},
|
||||
blockQuote: {
|
||||
color: "#57606a",
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#d0d7de",
|
||||
leftIndentChars: 2,
|
||||
italic: false
|
||||
},
|
||||
table: {
|
||||
fonts: songFonts,
|
||||
sizePt: 10.5,
|
||||
color: "#1f2328",
|
||||
headerColor: "#1f2328",
|
||||
headerBackgroundColor: "#f6f8fa",
|
||||
borderColor: "#d0d7de",
|
||||
cellMarginMm: 1.5
|
||||
},
|
||||
caption: {
|
||||
fonts: songFonts,
|
||||
sizePt: 9,
|
||||
color: "#57606a",
|
||||
italic: false,
|
||||
alignment: "center"
|
||||
},
|
||||
hyperlink: {
|
||||
color: "#0969da",
|
||||
underline: true
|
||||
}
|
||||
};
|
||||
|
||||
const presetOverrides: Record<
|
||||
DocxStylePreset,
|
||||
Partial<Omit<ResolvedDocxThemeStyle, "preset">>
|
||||
> = {
|
||||
general: {},
|
||||
technical: {
|
||||
body: {
|
||||
...baseStyle.body,
|
||||
fonts: technicalFonts
|
||||
},
|
||||
table: {
|
||||
...baseStyle.table,
|
||||
fonts: technicalFonts
|
||||
}
|
||||
},
|
||||
official: {
|
||||
body: {
|
||||
...baseStyle.body,
|
||||
sizePt: 16,
|
||||
firstLineIndentChars: 2,
|
||||
spacingAfterPt: 0
|
||||
},
|
||||
headings: {
|
||||
...baseStyle.headings,
|
||||
color: "#000000",
|
||||
sizesPt: [22, 18, 16, 16, 14, 12]
|
||||
}
|
||||
},
|
||||
formal: {
|
||||
body: {
|
||||
...baseStyle.body,
|
||||
sizePt: 13.5,
|
||||
firstLineIndentChars: 2,
|
||||
spacingAfterPt: 0
|
||||
},
|
||||
headings: {
|
||||
...baseStyle.headings,
|
||||
color: "#1f2937"
|
||||
}
|
||||
},
|
||||
tender: {
|
||||
body: {
|
||||
...baseStyle.body,
|
||||
sizePt: 12,
|
||||
lineSpacing: 1.5
|
||||
},
|
||||
headings: {
|
||||
...baseStyle.headings,
|
||||
color: "#1f4e79"
|
||||
},
|
||||
table: {
|
||||
...baseStyle.table,
|
||||
headerBackgroundColor: "#dbeafe",
|
||||
borderColor: "#94a3b8"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function mergeSection<T extends object>(
|
||||
base: T,
|
||||
override:
|
||||
| { [Key in keyof T]?: T[Key] | undefined }
|
||||
| undefined
|
||||
): T {
|
||||
if (!override) {
|
||||
return { ...base };
|
||||
}
|
||||
const result = { ...base };
|
||||
for (const key of Object.keys(override) as Array<keyof T>) {
|
||||
const value = override[key];
|
||||
if (value !== undefined) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function inferDocxStylePreset(
|
||||
manifest: Pick<ThemeManifest, "category">
|
||||
): DocxStylePreset {
|
||||
if (manifest.category === "red-letter") {
|
||||
return "official";
|
||||
}
|
||||
if (manifest.category === "formal") {
|
||||
return "formal";
|
||||
}
|
||||
if (manifest.category === "tender") {
|
||||
return "tender";
|
||||
}
|
||||
return "general";
|
||||
}
|
||||
|
||||
export function resolveDocxThemeStyle(
|
||||
manifest: ThemeManifest
|
||||
): ResolvedDocxThemeStyle {
|
||||
const declared: DocxThemeStyle = docxThemeStyleSchema.parse(
|
||||
manifest.docxStyle ?? {
|
||||
preset: inferDocxStylePreset(manifest)
|
||||
}
|
||||
);
|
||||
const preset = presetOverrides[declared.preset];
|
||||
const base = {
|
||||
...baseStyle,
|
||||
...preset
|
||||
};
|
||||
return {
|
||||
preset: declared.preset,
|
||||
body: mergeSection(base.body, declared.body),
|
||||
headings: mergeSection(base.headings, declared.headings),
|
||||
code: mergeSection(base.code, declared.code),
|
||||
blockQuote: mergeSection(
|
||||
base.blockQuote,
|
||||
declared.blockQuote
|
||||
),
|
||||
table: mergeSection(base.table, declared.table),
|
||||
caption: mergeSection(base.caption, declared.caption),
|
||||
hyperlink: mergeSection(
|
||||
base.hyperlink,
|
||||
declared.hyperlink
|
||||
)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
import type { DocxFontFamily } from "@md-to-pdf/core";
|
||||
import type { ResolvedDocxThemeStyle } from "./style-presets.js";
|
||||
import {
|
||||
DRAWING_NAMESPACE,
|
||||
WORD_NAMESPACE,
|
||||
appendElement,
|
||||
colorValue,
|
||||
directChildren,
|
||||
ensureDirectElement,
|
||||
firstDirectChild,
|
||||
millimetersToTwips,
|
||||
parseXmlPart,
|
||||
pointsToHalfPoints,
|
||||
pointsToTwips,
|
||||
removeDirectChildren,
|
||||
serializeXmlPart,
|
||||
type XmlElement
|
||||
} from "./ooxml.js";
|
||||
|
||||
function setFonts(parent: XmlElement, fonts: DocxFontFamily) {
|
||||
removeDirectChildren(parent, WORD_NAMESPACE, "rFonts");
|
||||
appendElement(parent, WORD_NAMESPACE, "w:rFonts", {
|
||||
"w:ascii": fonts.latin,
|
||||
"w:hAnsi": fonts.latin,
|
||||
"w:eastAsia": fonts.eastAsia,
|
||||
"w:cs": fonts.complexScript ?? fonts.latin
|
||||
});
|
||||
}
|
||||
|
||||
function setRunStyle(
|
||||
parent: XmlElement,
|
||||
options: {
|
||||
fonts?: DocxFontFamily;
|
||||
sizePt?: number;
|
||||
color?: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
) {
|
||||
removeDirectChildren(
|
||||
parent,
|
||||
WORD_NAMESPACE,
|
||||
"rFonts",
|
||||
"sz",
|
||||
"szCs",
|
||||
"color",
|
||||
"b",
|
||||
"bCs",
|
||||
"i",
|
||||
"iCs",
|
||||
"u",
|
||||
"shd"
|
||||
);
|
||||
if (options.fonts) {
|
||||
setFonts(parent, options.fonts);
|
||||
}
|
||||
if (options.bold) {
|
||||
appendElement(parent, WORD_NAMESPACE, "w:b");
|
||||
appendElement(parent, WORD_NAMESPACE, "w:bCs");
|
||||
}
|
||||
if (options.italic) {
|
||||
appendElement(parent, WORD_NAMESPACE, "w:i");
|
||||
appendElement(parent, WORD_NAMESPACE, "w:iCs");
|
||||
}
|
||||
if (options.color) {
|
||||
appendElement(parent, WORD_NAMESPACE, "w:color", {
|
||||
"w:val": colorValue(options.color)
|
||||
});
|
||||
}
|
||||
if (options.underline) {
|
||||
appendElement(parent, WORD_NAMESPACE, "w:u", {
|
||||
"w:val": "single"
|
||||
});
|
||||
}
|
||||
if (options.backgroundColor) {
|
||||
appendElement(parent, WORD_NAMESPACE, "w:shd", {
|
||||
"w:val": "clear",
|
||||
"w:color": "auto",
|
||||
"w:fill": colorValue(options.backgroundColor)
|
||||
});
|
||||
}
|
||||
if (options.sizePt !== undefined) {
|
||||
const size = pointsToHalfPoints(options.sizePt);
|
||||
appendElement(parent, WORD_NAMESPACE, "w:sz", {
|
||||
"w:val": size
|
||||
});
|
||||
appendElement(parent, WORD_NAMESPACE, "w:szCs", {
|
||||
"w:val": size
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setParagraphSpacing(
|
||||
parent: XmlElement,
|
||||
options: {
|
||||
beforePt: number;
|
||||
afterPt: number;
|
||||
lineSpacing: number;
|
||||
firstLineIndentChars?: number;
|
||||
}
|
||||
) {
|
||||
removeDirectChildren(parent, WORD_NAMESPACE, "spacing", "ind");
|
||||
appendElement(parent, WORD_NAMESPACE, "w:spacing", {
|
||||
"w:before": pointsToTwips(options.beforePt),
|
||||
"w:after": pointsToTwips(options.afterPt),
|
||||
"w:line": String(Math.round(options.lineSpacing * 240)),
|
||||
"w:lineRule": "auto"
|
||||
});
|
||||
if (options.firstLineIndentChars !== undefined) {
|
||||
appendElement(parent, WORD_NAMESPACE, "w:ind", {
|
||||
"w:firstLineChars": String(
|
||||
Math.round(options.firstLineIndentChars * 100)
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function findStyle(styles: XmlElement, styleId: string) {
|
||||
return Array.from(
|
||||
styles.getElementsByTagNameNS(WORD_NAMESPACE, "style")
|
||||
).find(
|
||||
(element) =>
|
||||
element.getAttributeNS(WORD_NAMESPACE, "styleId") === styleId
|
||||
);
|
||||
}
|
||||
|
||||
function ensureStyle(
|
||||
styles: XmlElement,
|
||||
styleId: string,
|
||||
type: "paragraph" | "character" | "table",
|
||||
basedOn?: string
|
||||
) {
|
||||
const existing = findStyle(styles, styleId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const style = appendElement(styles, WORD_NAMESPACE, "w:style", {
|
||||
"w:type": type,
|
||||
"w:customStyle": "1",
|
||||
"w:styleId": styleId
|
||||
});
|
||||
appendElement(style, WORD_NAMESPACE, "w:name", {
|
||||
"w:val": styleId.replace(/([a-z])([A-Z])/gu, "$1 $2")
|
||||
});
|
||||
if (basedOn) {
|
||||
appendElement(style, WORD_NAMESPACE, "w:basedOn", {
|
||||
"w:val": basedOn
|
||||
});
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
function applyBodyStyle(
|
||||
styles: XmlElement,
|
||||
style: ResolvedDocxThemeStyle
|
||||
) {
|
||||
const defaults = firstDirectChild(
|
||||
styles,
|
||||
WORD_NAMESPACE,
|
||||
"docDefaults"
|
||||
);
|
||||
if (!defaults) {
|
||||
throw new Error("word/styles.xml 缺少 w:docDefaults");
|
||||
}
|
||||
const defaultRun = ensureDirectElement(
|
||||
ensureDirectElement(
|
||||
defaults,
|
||||
WORD_NAMESPACE,
|
||||
"w:rPrDefault"
|
||||
),
|
||||
WORD_NAMESPACE,
|
||||
"w:rPr"
|
||||
);
|
||||
setRunStyle(defaultRun, {
|
||||
fonts: style.body.fonts,
|
||||
sizePt: style.body.sizePt,
|
||||
color: style.body.color
|
||||
});
|
||||
const defaultParagraph = ensureDirectElement(
|
||||
ensureDirectElement(
|
||||
defaults,
|
||||
WORD_NAMESPACE,
|
||||
"w:pPrDefault"
|
||||
),
|
||||
WORD_NAMESPACE,
|
||||
"w:pPr"
|
||||
);
|
||||
setParagraphSpacing(defaultParagraph, {
|
||||
beforePt: style.body.spacingBeforePt,
|
||||
afterPt: style.body.spacingAfterPt,
|
||||
lineSpacing: style.body.lineSpacing,
|
||||
firstLineIndentChars: style.body.firstLineIndentChars
|
||||
});
|
||||
|
||||
for (const styleId of [
|
||||
"Normal",
|
||||
"BodyText",
|
||||
"FirstParagraph",
|
||||
"FootnoteText",
|
||||
"Definition",
|
||||
"Figure"
|
||||
]) {
|
||||
const target = ensureStyle(
|
||||
styles,
|
||||
styleId,
|
||||
"paragraph",
|
||||
styleId === "Normal" ? undefined : "Normal"
|
||||
);
|
||||
const pPr = ensureDirectElement(
|
||||
target,
|
||||
WORD_NAMESPACE,
|
||||
"w:pPr"
|
||||
);
|
||||
setParagraphSpacing(pPr, {
|
||||
beforePt: style.body.spacingBeforePt,
|
||||
afterPt: style.body.spacingAfterPt,
|
||||
lineSpacing: style.body.lineSpacing,
|
||||
firstLineIndentChars: style.body.firstLineIndentChars
|
||||
});
|
||||
const rPr = ensureDirectElement(
|
||||
target,
|
||||
WORD_NAMESPACE,
|
||||
"w:rPr"
|
||||
);
|
||||
setRunStyle(rPr, {
|
||||
fonts: style.body.fonts,
|
||||
sizePt: style.body.sizePt,
|
||||
color: style.body.color
|
||||
});
|
||||
}
|
||||
|
||||
const compact = ensureStyle(
|
||||
styles,
|
||||
"Compact",
|
||||
"paragraph",
|
||||
"BodyText"
|
||||
);
|
||||
setParagraphSpacing(
|
||||
ensureDirectElement(compact, WORD_NAMESPACE, "w:pPr"),
|
||||
{
|
||||
beforePt: 0,
|
||||
afterPt: 0,
|
||||
lineSpacing: style.body.lineSpacing,
|
||||
firstLineIndentChars: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function applyHeadings(
|
||||
styles: XmlElement,
|
||||
style: ResolvedDocxThemeStyle
|
||||
) {
|
||||
for (let level = 1; level <= 6; level += 1) {
|
||||
const sizePt = style.headings.sizesPt[level - 1]!;
|
||||
for (const styleId of [
|
||||
`Heading${level}`,
|
||||
`Heading${level}Char`
|
||||
]) {
|
||||
const target = ensureStyle(
|
||||
styles,
|
||||
styleId,
|
||||
styleId.endsWith("Char") ? "character" : "paragraph",
|
||||
styleId.endsWith("Char")
|
||||
? "DefaultParagraphFont"
|
||||
: "Normal"
|
||||
);
|
||||
if (!styleId.endsWith("Char")) {
|
||||
const pPr = ensureDirectElement(
|
||||
target,
|
||||
WORD_NAMESPACE,
|
||||
"w:pPr"
|
||||
);
|
||||
setParagraphSpacing(pPr, {
|
||||
beforePt: style.headings.spacingBeforePt,
|
||||
afterPt: style.headings.spacingAfterPt,
|
||||
lineSpacing: 1.25
|
||||
});
|
||||
if (!firstDirectChild(pPr, WORD_NAMESPACE, "keepNext")) {
|
||||
appendElement(pPr, WORD_NAMESPACE, "w:keepNext");
|
||||
}
|
||||
if (!firstDirectChild(pPr, WORD_NAMESPACE, "keepLines")) {
|
||||
appendElement(pPr, WORD_NAMESPACE, "w:keepLines");
|
||||
}
|
||||
}
|
||||
setRunStyle(
|
||||
ensureDirectElement(target, WORD_NAMESPACE, "w:rPr"),
|
||||
{
|
||||
fonts: style.headings.fonts,
|
||||
sizePt,
|
||||
color: style.headings.color,
|
||||
bold: style.headings.bold
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [styleId, sizePt] of [
|
||||
["Title", style.headings.sizesPt[0] + 4],
|
||||
["TitleChar", style.headings.sizesPt[0] + 4],
|
||||
["Subtitle", style.headings.sizesPt[1]],
|
||||
["SubtitleChar", style.headings.sizesPt[1]]
|
||||
] as const) {
|
||||
const target = ensureStyle(
|
||||
styles,
|
||||
styleId,
|
||||
styleId.endsWith("Char") ? "character" : "paragraph",
|
||||
styleId.endsWith("Char")
|
||||
? "DefaultParagraphFont"
|
||||
: "Normal"
|
||||
);
|
||||
if (!styleId.endsWith("Char")) {
|
||||
const pPr = ensureDirectElement(
|
||||
target,
|
||||
WORD_NAMESPACE,
|
||||
"w:pPr"
|
||||
);
|
||||
setParagraphSpacing(pPr, {
|
||||
beforePt: 0,
|
||||
afterPt: style.headings.spacingAfterPt,
|
||||
lineSpacing: 1.25
|
||||
});
|
||||
removeDirectChildren(pPr, WORD_NAMESPACE, "jc");
|
||||
appendElement(pPr, WORD_NAMESPACE, "w:jc", {
|
||||
"w:val": "center"
|
||||
});
|
||||
}
|
||||
setRunStyle(
|
||||
ensureDirectElement(target, WORD_NAMESPACE, "w:rPr"),
|
||||
{
|
||||
fonts: style.headings.fonts,
|
||||
sizePt,
|
||||
color: style.headings.color,
|
||||
bold: styleId.startsWith("Title")
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function applyCodeAndQuote(
|
||||
styles: XmlElement,
|
||||
style: ResolvedDocxThemeStyle
|
||||
) {
|
||||
const inlineCode = ensureStyle(
|
||||
styles,
|
||||
"VerbatimChar",
|
||||
"character",
|
||||
"BodyTextChar"
|
||||
);
|
||||
setRunStyle(
|
||||
ensureDirectElement(inlineCode, WORD_NAMESPACE, "w:rPr"),
|
||||
{
|
||||
fonts: style.code.fonts,
|
||||
sizePt: style.code.sizePt,
|
||||
color: style.code.color,
|
||||
backgroundColor: style.code.backgroundColor
|
||||
}
|
||||
);
|
||||
|
||||
const sourceCode = ensureStyle(
|
||||
styles,
|
||||
"SourceCode",
|
||||
"paragraph",
|
||||
"Normal"
|
||||
);
|
||||
const codePPr = ensureDirectElement(
|
||||
sourceCode,
|
||||
WORD_NAMESPACE,
|
||||
"w:pPr"
|
||||
);
|
||||
setParagraphSpacing(codePPr, {
|
||||
beforePt: 3,
|
||||
afterPt: 3,
|
||||
lineSpacing: style.code.lineSpacing,
|
||||
firstLineIndentChars: 0
|
||||
});
|
||||
removeDirectChildren(codePPr, WORD_NAMESPACE, "shd", "pBdr");
|
||||
appendElement(codePPr, WORD_NAMESPACE, "w:shd", {
|
||||
"w:val": "clear",
|
||||
"w:color": "auto",
|
||||
"w:fill": colorValue(style.code.backgroundColor)
|
||||
});
|
||||
const codeBorders = appendElement(
|
||||
codePPr,
|
||||
WORD_NAMESPACE,
|
||||
"w:pBdr"
|
||||
);
|
||||
for (const side of ["top", "left", "bottom", "right"]) {
|
||||
appendElement(codeBorders, WORD_NAMESPACE, `w:${side}`, {
|
||||
"w:val": "single",
|
||||
"w:sz": "4",
|
||||
"w:space": "2",
|
||||
"w:color": colorValue(style.code.borderColor)
|
||||
});
|
||||
}
|
||||
setRunStyle(
|
||||
ensureDirectElement(sourceCode, WORD_NAMESPACE, "w:rPr"),
|
||||
{
|
||||
fonts: style.code.fonts,
|
||||
sizePt: style.code.sizePt,
|
||||
color: style.code.color,
|
||||
backgroundColor: style.code.backgroundColor
|
||||
}
|
||||
);
|
||||
|
||||
const quote = ensureStyle(
|
||||
styles,
|
||||
"BlockText",
|
||||
"paragraph",
|
||||
"BodyText"
|
||||
);
|
||||
const quotePPr = ensureDirectElement(
|
||||
quote,
|
||||
WORD_NAMESPACE,
|
||||
"w:pPr"
|
||||
);
|
||||
setParagraphSpacing(quotePPr, {
|
||||
beforePt: 6,
|
||||
afterPt: 6,
|
||||
lineSpacing: style.body.lineSpacing,
|
||||
firstLineIndentChars: 0
|
||||
});
|
||||
removeDirectChildren(quotePPr, WORD_NAMESPACE, "ind", "shd", "pBdr");
|
||||
appendElement(quotePPr, WORD_NAMESPACE, "w:ind", {
|
||||
"w:leftChars": String(style.blockQuote.leftIndentChars * 100)
|
||||
});
|
||||
appendElement(quotePPr, WORD_NAMESPACE, "w:shd", {
|
||||
"w:val": "clear",
|
||||
"w:color": "auto",
|
||||
"w:fill": colorValue(style.blockQuote.backgroundColor)
|
||||
});
|
||||
const quoteBorders = appendElement(
|
||||
quotePPr,
|
||||
WORD_NAMESPACE,
|
||||
"w:pBdr"
|
||||
);
|
||||
appendElement(quoteBorders, WORD_NAMESPACE, "w:left", {
|
||||
"w:val": "single",
|
||||
"w:sz": "18",
|
||||
"w:space": "6",
|
||||
"w:color": colorValue(style.blockQuote.borderColor)
|
||||
});
|
||||
setRunStyle(
|
||||
ensureDirectElement(quote, WORD_NAMESPACE, "w:rPr"),
|
||||
{
|
||||
fonts: style.body.fonts,
|
||||
sizePt: style.body.sizePt,
|
||||
color: style.blockQuote.color,
|
||||
italic: style.blockQuote.italic
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function applyTableAndCaption(
|
||||
styles: XmlElement,
|
||||
style: ResolvedDocxThemeStyle
|
||||
) {
|
||||
const table = ensureStyle(styles, "Table", "table", "TableNormal");
|
||||
const tblPr = ensureDirectElement(
|
||||
table,
|
||||
WORD_NAMESPACE,
|
||||
"w:tblPr"
|
||||
);
|
||||
removeDirectChildren(tblPr, WORD_NAMESPACE, "tblCellMar", "tblBorders");
|
||||
const margins = appendElement(
|
||||
tblPr,
|
||||
WORD_NAMESPACE,
|
||||
"w:tblCellMar"
|
||||
);
|
||||
const cellMargin = String(
|
||||
millimetersToTwips(style.table.cellMarginMm)
|
||||
);
|
||||
for (const side of ["top", "left", "bottom", "right"]) {
|
||||
appendElement(margins, WORD_NAMESPACE, `w:${side}`, {
|
||||
"w:w": cellMargin,
|
||||
"w:type": "dxa"
|
||||
});
|
||||
}
|
||||
const borders = appendElement(
|
||||
tblPr,
|
||||
WORD_NAMESPACE,
|
||||
"w:tblBorders"
|
||||
);
|
||||
for (const side of [
|
||||
"top",
|
||||
"left",
|
||||
"bottom",
|
||||
"right",
|
||||
"insideH",
|
||||
"insideV"
|
||||
]) {
|
||||
appendElement(borders, WORD_NAMESPACE, `w:${side}`, {
|
||||
"w:val": "single",
|
||||
"w:sz": "4",
|
||||
"w:space": "0",
|
||||
"w:color": colorValue(style.table.borderColor)
|
||||
});
|
||||
}
|
||||
setRunStyle(
|
||||
ensureDirectElement(table, WORD_NAMESPACE, "w:rPr"),
|
||||
{
|
||||
fonts: style.table.fonts,
|
||||
sizePt: style.table.sizePt,
|
||||
color: style.table.color
|
||||
}
|
||||
);
|
||||
let firstRow = directChildren(
|
||||
table,
|
||||
WORD_NAMESPACE,
|
||||
"tblStylePr"
|
||||
).find(
|
||||
(element) =>
|
||||
element.getAttributeNS(WORD_NAMESPACE, "type") === "firstRow"
|
||||
);
|
||||
if (!firstRow) {
|
||||
firstRow = appendElement(
|
||||
table,
|
||||
WORD_NAMESPACE,
|
||||
"w:tblStylePr",
|
||||
{ "w:type": "firstRow" }
|
||||
);
|
||||
}
|
||||
setRunStyle(
|
||||
ensureDirectElement(firstRow, WORD_NAMESPACE, "w:rPr"),
|
||||
{
|
||||
fonts: style.table.fonts,
|
||||
sizePt: style.table.sizePt,
|
||||
color: style.table.headerColor,
|
||||
bold: true
|
||||
}
|
||||
);
|
||||
const cellProperties = ensureDirectElement(
|
||||
firstRow,
|
||||
WORD_NAMESPACE,
|
||||
"w:tcPr"
|
||||
);
|
||||
removeDirectChildren(cellProperties, WORD_NAMESPACE, "shd");
|
||||
appendElement(cellProperties, WORD_NAMESPACE, "w:shd", {
|
||||
"w:val": "clear",
|
||||
"w:color": "auto",
|
||||
"w:fill": colorValue(style.table.headerBackgroundColor)
|
||||
});
|
||||
|
||||
for (const styleId of ["Caption", "TableCaption", "ImageCaption"]) {
|
||||
const caption = ensureStyle(
|
||||
styles,
|
||||
styleId,
|
||||
"paragraph",
|
||||
styleId === "Caption" ? "Normal" : "Caption"
|
||||
);
|
||||
const pPr = ensureDirectElement(
|
||||
caption,
|
||||
WORD_NAMESPACE,
|
||||
"w:pPr"
|
||||
);
|
||||
setParagraphSpacing(pPr, {
|
||||
beforePt: 3,
|
||||
afterPt: 6,
|
||||
lineSpacing: 1.25
|
||||
});
|
||||
removeDirectChildren(pPr, WORD_NAMESPACE, "jc");
|
||||
appendElement(pPr, WORD_NAMESPACE, "w:jc", {
|
||||
"w:val": style.caption.alignment
|
||||
});
|
||||
setRunStyle(
|
||||
ensureDirectElement(caption, WORD_NAMESPACE, "w:rPr"),
|
||||
{
|
||||
fonts: style.caption.fonts,
|
||||
sizePt: style.caption.sizePt,
|
||||
color: style.caption.color,
|
||||
italic: style.caption.italic
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const hyperlink = ensureStyle(
|
||||
styles,
|
||||
"Hyperlink",
|
||||
"character",
|
||||
"BodyTextChar"
|
||||
);
|
||||
setRunStyle(
|
||||
ensureDirectElement(hyperlink, WORD_NAMESPACE, "w:rPr"),
|
||||
{
|
||||
color: style.hyperlink.color,
|
||||
underline: style.hyperlink.underline
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function transformStylesXml(
|
||||
content: Uint8Array,
|
||||
style: ResolvedDocxThemeStyle
|
||||
) {
|
||||
const document = parseXmlPart(content, "word/styles.xml");
|
||||
const styles = document.documentElement!;
|
||||
if (
|
||||
styles.namespaceURI !== WORD_NAMESPACE ||
|
||||
styles.localName !== "styles"
|
||||
) {
|
||||
throw new Error("word/styles.xml 根元素无效");
|
||||
}
|
||||
applyBodyStyle(styles, style);
|
||||
applyHeadings(styles, style);
|
||||
applyCodeAndQuote(styles, style);
|
||||
applyTableAndCaption(styles, style);
|
||||
return serializeXmlPart(document);
|
||||
}
|
||||
|
||||
export function transformFontTableXml(
|
||||
content: Uint8Array,
|
||||
style: ResolvedDocxThemeStyle
|
||||
) {
|
||||
const document = parseXmlPart(content, "word/fontTable.xml");
|
||||
const root = document.documentElement!;
|
||||
const requiredFonts = new Set<string>();
|
||||
for (const fonts of [
|
||||
style.body.fonts,
|
||||
style.headings.fonts,
|
||||
style.code.fonts,
|
||||
style.table.fonts,
|
||||
style.caption.fonts
|
||||
]) {
|
||||
requiredFonts.add(fonts.latin);
|
||||
requiredFonts.add(fonts.eastAsia);
|
||||
if (fonts.complexScript) {
|
||||
requiredFonts.add(fonts.complexScript);
|
||||
}
|
||||
}
|
||||
const existing = new Set(
|
||||
Array.from(
|
||||
root.getElementsByTagNameNS(WORD_NAMESPACE, "font")
|
||||
).map((element) => element.getAttributeNS(WORD_NAMESPACE, "name"))
|
||||
);
|
||||
for (const name of requiredFonts) {
|
||||
if (!existing.has(name)) {
|
||||
const font = appendElement(root, WORD_NAMESPACE, "w:font", {
|
||||
"w:name": name
|
||||
});
|
||||
appendElement(font, WORD_NAMESPACE, "w:family", {
|
||||
"w:val": name === style.code.fonts.latin ? "modern" : "auto"
|
||||
});
|
||||
}
|
||||
}
|
||||
return serializeXmlPart(document);
|
||||
}
|
||||
|
||||
export function transformThemeFontsXml(
|
||||
content: Uint8Array,
|
||||
style: ResolvedDocxThemeStyle
|
||||
) {
|
||||
const document = parseXmlPart(content, "word/theme/theme1.xml");
|
||||
for (const schemeName of ["majorFont", "minorFont"]) {
|
||||
const scheme = document.getElementsByTagNameNS(
|
||||
DRAWING_NAMESPACE,
|
||||
schemeName
|
||||
)[0];
|
||||
if (!scheme) {
|
||||
continue;
|
||||
}
|
||||
const latin = scheme.getElementsByTagNameNS(
|
||||
DRAWING_NAMESPACE,
|
||||
"latin"
|
||||
)[0];
|
||||
const eastAsia = scheme.getElementsByTagNameNS(
|
||||
DRAWING_NAMESPACE,
|
||||
"ea"
|
||||
)[0];
|
||||
latin?.setAttribute(
|
||||
"typeface",
|
||||
schemeName === "majorFont"
|
||||
? style.headings.fonts.latin
|
||||
: style.body.fonts.latin
|
||||
);
|
||||
eastAsia?.setAttribute(
|
||||
"typeface",
|
||||
schemeName === "majorFont"
|
||||
? style.headings.fonts.eastAsia
|
||||
: style.body.fonts.eastAsia
|
||||
);
|
||||
}
|
||||
return serializeXmlPart(document);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
CONTENT_TYPES_NAMESPACE,
|
||||
OFFICE_RELATIONSHIP_NAMESPACE,
|
||||
PACKAGE_RELATIONSHIP_NAMESPACE,
|
||||
WORD_NAMESPACE,
|
||||
directChildren,
|
||||
parseXmlPart,
|
||||
type XmlElement
|
||||
} from "./ooxml.js";
|
||||
import { readReferenceDocxPackage } from "./reference-package.js";
|
||||
|
||||
const HEADER_RELATIONSHIP_TYPE =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
|
||||
const FOOTER_RELATIONSHIP_TYPE =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
|
||||
const HEADER_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml";
|
||||
const FOOTER_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml";
|
||||
|
||||
const requiredStyleIds = [
|
||||
"Normal",
|
||||
"Heading1",
|
||||
"SourceCode",
|
||||
"Table",
|
||||
"Caption"
|
||||
] as const;
|
||||
|
||||
interface Relationship {
|
||||
id: string;
|
||||
type: string;
|
||||
targetPart: string | undefined;
|
||||
}
|
||||
|
||||
export interface DynamicReferenceValidation {
|
||||
partCount: number;
|
||||
xmlPartCount: number;
|
||||
relationshipCount: number;
|
||||
headerCount: number;
|
||||
footerCount: number;
|
||||
}
|
||||
|
||||
function relationshipSourcePart(relationshipsPart: string) {
|
||||
if (relationshipsPart === "_rels/.rels") {
|
||||
return "";
|
||||
}
|
||||
const match = /^(.*)\/_rels\/([^/]+)\.rels$/u.exec(
|
||||
relationshipsPart
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(`OOXML 关系部件路径无效:${relationshipsPart}`);
|
||||
}
|
||||
return path.posix.join(match[1]!, match[2]!);
|
||||
}
|
||||
|
||||
function resolveRelationshipTarget(
|
||||
relationshipsPart: string,
|
||||
target: string
|
||||
) {
|
||||
if (
|
||||
!target ||
|
||||
target.includes("\\") ||
|
||||
target.includes("\0") ||
|
||||
/^[a-z][a-z0-9+.-]*:/iu.test(target)
|
||||
) {
|
||||
throw new Error(
|
||||
`${relationshipsPart} 包含不安全的内部关系目标:${target}`
|
||||
);
|
||||
}
|
||||
const sourcePart = relationshipSourcePart(relationshipsPart);
|
||||
const targetPart = target.startsWith("/")
|
||||
? path.posix.normalize(target.slice(1))
|
||||
: path.posix.normalize(
|
||||
path.posix.join(path.posix.dirname(sourcePart), target)
|
||||
);
|
||||
if (
|
||||
!targetPart ||
|
||||
targetPart === "." ||
|
||||
targetPart === ".." ||
|
||||
targetPart.startsWith("../") ||
|
||||
targetPart.startsWith("/")
|
||||
) {
|
||||
throw new Error(
|
||||
`${relationshipsPart} 的内部关系目标越出 DOCX 包:${target}`
|
||||
);
|
||||
}
|
||||
return targetPart;
|
||||
}
|
||||
|
||||
function parseRelationships(
|
||||
partName: string,
|
||||
content: Uint8Array,
|
||||
entries: ReadonlyMap<string, Uint8Array>
|
||||
) {
|
||||
const document = parseXmlPart(content, partName);
|
||||
const root = document.documentElement;
|
||||
if (
|
||||
!root ||
|
||||
root.namespaceURI !== PACKAGE_RELATIONSHIP_NAMESPACE ||
|
||||
root.localName !== "Relationships"
|
||||
) {
|
||||
throw new Error(`${partName} 的关系根元素无效`);
|
||||
}
|
||||
|
||||
const relationships = new Map<string, Relationship>();
|
||||
for (const element of directChildren(
|
||||
root,
|
||||
PACKAGE_RELATIONSHIP_NAMESPACE,
|
||||
"Relationship"
|
||||
)) {
|
||||
const id = element.getAttribute("Id");
|
||||
const type = element.getAttribute("Type");
|
||||
const target = element.getAttribute("Target");
|
||||
if (!id || !type || !target) {
|
||||
throw new Error(`${partName} 包含不完整的关系声明`);
|
||||
}
|
||||
if (relationships.has(id)) {
|
||||
throw new Error(`${partName} 包含重复关系 ID:${id}`);
|
||||
}
|
||||
const external =
|
||||
(element.getAttribute("TargetMode") ?? "").toLowerCase() ===
|
||||
"external";
|
||||
const targetPart = external
|
||||
? undefined
|
||||
: resolveRelationshipTarget(partName, target);
|
||||
if (targetPart && !entries.has(targetPart)) {
|
||||
throw new Error(
|
||||
`${partName} 的关系 ${id} 指向缺失部件:${targetPart}`
|
||||
);
|
||||
}
|
||||
relationships.set(id, { id, type, targetPart });
|
||||
}
|
||||
return relationships;
|
||||
}
|
||||
|
||||
function validateReferencedParts(
|
||||
section: XmlElement,
|
||||
localName: "headerReference" | "footerReference",
|
||||
expectedType: string,
|
||||
expectedRootName: "hdr" | "ftr",
|
||||
relationships: ReadonlyMap<string, Relationship>,
|
||||
entries: ReadonlyMap<string, Uint8Array>
|
||||
) {
|
||||
const references = directChildren(
|
||||
section,
|
||||
WORD_NAMESPACE,
|
||||
localName
|
||||
);
|
||||
const pageTypes = new Set<string>();
|
||||
for (const reference of references) {
|
||||
const pageType =
|
||||
reference.getAttributeNS(WORD_NAMESPACE, "type") ?? "";
|
||||
const relationshipId =
|
||||
reference.getAttributeNS(
|
||||
OFFICE_RELATIONSHIP_NAMESPACE,
|
||||
"id"
|
||||
) ?? "";
|
||||
if (!["default", "even", "first"].includes(pageType)) {
|
||||
throw new Error(`${localName} 包含无效页面类型:${pageType}`);
|
||||
}
|
||||
if (pageTypes.has(pageType)) {
|
||||
throw new Error(`${localName} 重复声明页面类型:${pageType}`);
|
||||
}
|
||||
pageTypes.add(pageType);
|
||||
const relationship = relationships.get(relationshipId);
|
||||
if (
|
||||
!relationship ||
|
||||
relationship.type !== expectedType ||
|
||||
!relationship.targetPart
|
||||
) {
|
||||
throw new Error(
|
||||
`${localName} 引用了无效关系:${relationshipId || "(空)"}`
|
||||
);
|
||||
}
|
||||
const part = entries.get(relationship.targetPart)!;
|
||||
const document = parseXmlPart(part, relationship.targetPart);
|
||||
const root = document.documentElement;
|
||||
if (
|
||||
!root ||
|
||||
root.namespaceURI !== WORD_NAMESPACE ||
|
||||
root.localName !== expectedRootName
|
||||
) {
|
||||
throw new Error(
|
||||
`${relationship.targetPart} 的页眉页脚根元素无效`
|
||||
);
|
||||
}
|
||||
}
|
||||
return references.length;
|
||||
}
|
||||
|
||||
function validateContentTypes(
|
||||
entries: ReadonlyMap<string, Uint8Array>,
|
||||
relationships: ReadonlyMap<string, Relationship>
|
||||
) {
|
||||
const partName = "[Content_Types].xml";
|
||||
const document = parseXmlPart(entries.get(partName)!, partName);
|
||||
const root = document.documentElement;
|
||||
if (
|
||||
!root ||
|
||||
root.namespaceURI !== CONTENT_TYPES_NAMESPACE ||
|
||||
root.localName !== "Types"
|
||||
) {
|
||||
throw new Error("[Content_Types].xml 的根元素无效");
|
||||
}
|
||||
const overrides = new Map<string, string>();
|
||||
for (const element of directChildren(
|
||||
root,
|
||||
CONTENT_TYPES_NAMESPACE,
|
||||
"Override"
|
||||
)) {
|
||||
const part = element.getAttribute("PartName");
|
||||
const contentType = element.getAttribute("ContentType");
|
||||
if (!part || !contentType || overrides.has(part)) {
|
||||
throw new Error("[Content_Types].xml 包含无效或重复的 Override");
|
||||
}
|
||||
overrides.set(part, contentType);
|
||||
}
|
||||
for (const relationship of relationships.values()) {
|
||||
if (
|
||||
relationship.type !== HEADER_RELATIONSHIP_TYPE &&
|
||||
relationship.type !== FOOTER_RELATIONSHIP_TYPE
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const expected =
|
||||
relationship.type === HEADER_RELATIONSHIP_TYPE
|
||||
? HEADER_CONTENT_TYPE
|
||||
: FOOTER_CONTENT_TYPE;
|
||||
if (
|
||||
!relationship.targetPart ||
|
||||
overrides.get(`/${relationship.targetPart}`) !== expected
|
||||
) {
|
||||
throw new Error(
|
||||
`${relationship.targetPart ?? "(空)"} 缺少正确的 Content Type`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateStyles(entries: ReadonlyMap<string, Uint8Array>) {
|
||||
const partName = "word/styles.xml";
|
||||
const document = parseXmlPart(entries.get(partName)!, partName);
|
||||
const root = document.documentElement;
|
||||
if (
|
||||
!root ||
|
||||
root.namespaceURI !== WORD_NAMESPACE ||
|
||||
root.localName !== "styles"
|
||||
) {
|
||||
throw new Error("word/styles.xml 的根元素无效");
|
||||
}
|
||||
const styleIds = new Set(
|
||||
Array.from(
|
||||
root.getElementsByTagNameNS(WORD_NAMESPACE, "style")
|
||||
).map((element) =>
|
||||
element.getAttributeNS(WORD_NAMESPACE, "styleId")
|
||||
)
|
||||
);
|
||||
for (const styleId of requiredStyleIds) {
|
||||
if (!styleIds.has(styleId)) {
|
||||
throw new Error(`word/styles.xml 缺少关键样式:${styleId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validateDynamicReferenceDocx(
|
||||
content: Uint8Array
|
||||
): DynamicReferenceValidation {
|
||||
const reference = readReferenceDocxPackage(content);
|
||||
let xmlPartCount = 0;
|
||||
let relationshipCount = 0;
|
||||
const relationshipParts = new Map<
|
||||
string,
|
||||
ReadonlyMap<string, Relationship>
|
||||
>();
|
||||
|
||||
for (const [partName, part] of reference.entries) {
|
||||
if (partName.endsWith(".xml") || partName.endsWith(".rels")) {
|
||||
parseXmlPart(part, partName);
|
||||
xmlPartCount += 1;
|
||||
}
|
||||
if (partName.endsWith(".rels")) {
|
||||
const relationships = parseRelationships(
|
||||
partName,
|
||||
part,
|
||||
reference.entries
|
||||
);
|
||||
relationshipParts.set(partName, relationships);
|
||||
relationshipCount += relationships.size;
|
||||
}
|
||||
}
|
||||
|
||||
const documentPart = "word/document.xml";
|
||||
const document = parseXmlPart(
|
||||
reference.entries.get(documentPart)!,
|
||||
documentPart
|
||||
);
|
||||
const root = document.documentElement;
|
||||
if (
|
||||
!root ||
|
||||
root.namespaceURI !== WORD_NAMESPACE ||
|
||||
root.localName !== "document"
|
||||
) {
|
||||
throw new Error("word/document.xml 的根元素无效");
|
||||
}
|
||||
const sections = Array.from(
|
||||
root.getElementsByTagNameNS(WORD_NAMESPACE, "sectPr")
|
||||
);
|
||||
const finalSection = sections.at(-1);
|
||||
if (
|
||||
!finalSection ||
|
||||
directChildren(finalSection, WORD_NAMESPACE, "pgSz").length !== 1 ||
|
||||
directChildren(finalSection, WORD_NAMESPACE, "pgMar").length !== 1
|
||||
) {
|
||||
throw new Error("word/document.xml 缺少完整的最终节页面设置");
|
||||
}
|
||||
|
||||
const documentRelationships = relationshipParts.get(
|
||||
"word/_rels/document.xml.rels"
|
||||
)!;
|
||||
const headerCount = validateReferencedParts(
|
||||
finalSection,
|
||||
"headerReference",
|
||||
HEADER_RELATIONSHIP_TYPE,
|
||||
"hdr",
|
||||
documentRelationships,
|
||||
reference.entries
|
||||
);
|
||||
const footerCount = validateReferencedParts(
|
||||
finalSection,
|
||||
"footerReference",
|
||||
FOOTER_RELATIONSHIP_TYPE,
|
||||
"ftr",
|
||||
documentRelationships,
|
||||
reference.entries
|
||||
);
|
||||
validateContentTypes(reference.entries, documentRelationships);
|
||||
validateStyles(reference.entries);
|
||||
|
||||
return {
|
||||
partCount: reference.entries.size,
|
||||
xmlPartCount,
|
||||
relationshipCount,
|
||||
headerCount,
|
||||
footerCount
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user