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
@@ -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
};
}
+9
View File
@@ -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";
+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);
}
@@ -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);
}
+258
View File
@@ -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);
}
+347
View File
@@ -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
};
}