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,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);
}