feat: 将主题令牌接入 DOCX 动态模板

This commit is contained in:
SkyJourney
2026-07-31 08:43:26 +08:00
parent 459a079f3b
commit 6e40374200
33 changed files with 1478 additions and 56 deletions
+482 -7
View File
@@ -1,5 +1,16 @@
import type { DocxFontFamily } from "@md-to-pdf/core";
import type {
DocxSlotStyleToken,
DocxStyleSlotName,
DocxThemeTokenSet
} from "@md-to-pdf/docx-theme-engine";
import type { ResolvedDocxThemeStyle } from "./style-presets.js";
import {
DOCX_SLOT_WORD_STYLE_BINDINGS,
collectDocxTokenFonts,
createDocxTokenSlotMap,
resolveTokenFonts
} from "./token-style-map.js";
import {
DRAWING_NAMESPACE,
WORD_NAMESPACE,
@@ -152,6 +163,242 @@ function ensureStyle(
return style;
}
function setWordAttribute(
element: XmlElement,
name: string,
value: string
) {
element.setAttributeNS(WORD_NAMESPACE, `w:${name}`, value);
}
function toggleProperty(
parent: XmlElement,
localName: string,
enabled: boolean | undefined
) {
if (enabled === undefined) {
return;
}
removeDirectChildren(parent, WORD_NAMESPACE, localName);
if (enabled) {
appendElement(parent, WORD_NAMESPACE, `w:${localName}`);
}
}
function fallbackFontsForSlot(
slot: DocxStyleSlotName,
fallback: ResolvedDocxThemeStyle
) {
if (slot.startsWith("heading-") || slot.endsWith("-title")) {
return fallback.headings.fonts;
}
if (slot === "inline-code" || slot === "code-block") {
return fallback.code.fonts;
}
if (
slot === "table" ||
slot === "table-header" ||
slot === "table-cell"
) {
return fallback.table.fonts;
}
if (slot === "caption") {
return fallback.caption.fonts;
}
return fallback.body.fonts;
}
function applyTokenRunStyle(
parent: XmlElement,
token: DocxSlotStyleToken,
fallbackFonts: DocxFontFamily
) {
if (token.fontCandidates.length) {
setFonts(parent, resolveTokenFonts(token, fallbackFonts));
}
if (token.fontSizePt !== undefined) {
removeDirectChildren(parent, WORD_NAMESPACE, "sz", "szCs");
const size = pointsToHalfPoints(token.fontSizePt);
appendElement(parent, WORD_NAMESPACE, "w:sz", {
"w:val": size
});
appendElement(parent, WORD_NAMESPACE, "w:szCs", {
"w:val": size
});
}
if (token.color !== undefined) {
removeDirectChildren(parent, WORD_NAMESPACE, "color");
appendElement(parent, WORD_NAMESPACE, "w:color", {
"w:val": colorValue(token.color)
});
}
if (token.backgroundColor !== undefined) {
removeDirectChildren(parent, WORD_NAMESPACE, "shd");
appendElement(parent, WORD_NAMESPACE, "w:shd", {
"w:val": "clear",
"w:color": "auto",
"w:fill": colorValue(token.backgroundColor)
});
}
if (token.underline !== undefined) {
removeDirectChildren(parent, WORD_NAMESPACE, "u");
if (token.underline) {
appendElement(parent, WORD_NAMESPACE, "w:u", {
"w:val": "single"
});
}
}
if (token.letterSpacingPt !== undefined) {
removeDirectChildren(parent, WORD_NAMESPACE, "spacing");
appendElement(parent, WORD_NAMESPACE, "w:spacing", {
"w:val": pointsToTwips(token.letterSpacingPt)
});
}
toggleProperty(parent, "b", token.bold);
toggleProperty(parent, "bCs", token.bold);
toggleProperty(parent, "i", token.italic);
toggleProperty(parent, "iCs", token.italic);
toggleProperty(parent, "strike", token.strikethrough);
toggleProperty(parent, "vanish", token.hidden);
}
function applyTokenBorders(
parent: XmlElement,
token: DocxSlotStyleToken
) {
if (!token.borders) {
return;
}
removeDirectChildren(parent, WORD_NAMESPACE, "pBdr");
const borders = appendElement(
parent,
WORD_NAMESPACE,
"w:pBdr"
);
for (const side of [
"top",
"right",
"bottom",
"left"
] as const) {
const border = token.borders[side];
if (!border) {
continue;
}
appendElement(borders, WORD_NAMESPACE, `w:${side}`, {
"w:val": border.style,
"w:sz": String(
Math.max(0, Math.min(160, Math.round(border.widthPt * 8)))
),
"w:space": String(
Math.max(
0,
Math.min(
31,
Math.round(token.paddingPt?.[side] ?? 0)
)
)
),
"w:color": colorValue(border.color)
});
}
}
function applyTokenParagraphStyle(
parent: XmlElement,
token: DocxSlotStyleToken
) {
if (
token.spacingBeforePt !== undefined ||
token.spacingAfterPt !== undefined ||
token.lineSpacing !== undefined
) {
const spacing = ensureDirectElement(
parent,
WORD_NAMESPACE,
"w:spacing"
);
if (token.spacingBeforePt !== undefined) {
setWordAttribute(
spacing,
"before",
pointsToTwips(token.spacingBeforePt)
);
}
if (token.spacingAfterPt !== undefined) {
setWordAttribute(
spacing,
"after",
pointsToTwips(token.spacingAfterPt)
);
}
if (token.lineSpacing !== undefined) {
setWordAttribute(
spacing,
"line",
String(Math.round(token.lineSpacing * 240))
);
setWordAttribute(spacing, "lineRule", "auto");
}
}
if (
token.firstLineIndentPt !== undefined ||
token.leftIndentPt !== undefined ||
token.rightIndentPt !== undefined
) {
const indent = ensureDirectElement(
parent,
WORD_NAMESPACE,
"w:ind"
);
if (token.firstLineIndentPt !== undefined) {
setWordAttribute(
indent,
"firstLine",
pointsToTwips(token.firstLineIndentPt)
);
indent.removeAttributeNS(WORD_NAMESPACE, "firstLineChars");
}
if (token.leftIndentPt !== undefined) {
setWordAttribute(
indent,
"left",
pointsToTwips(token.leftIndentPt)
);
indent.removeAttributeNS(WORD_NAMESPACE, "leftChars");
}
if (token.rightIndentPt !== undefined) {
setWordAttribute(
indent,
"right",
pointsToTwips(token.rightIndentPt)
);
}
}
if (token.alignment !== undefined) {
removeDirectChildren(parent, WORD_NAMESPACE, "jc");
appendElement(parent, WORD_NAMESPACE, "w:jc", {
"w:val": token.alignment
});
}
if (token.backgroundColor !== undefined) {
removeDirectChildren(parent, WORD_NAMESPACE, "shd");
appendElement(parent, WORD_NAMESPACE, "w:shd", {
"w:val": "clear",
"w:color": "auto",
"w:fill": colorValue(token.backgroundColor)
});
}
applyTokenBorders(parent, token);
toggleProperty(parent, "keepLines", token.keepLines);
toggleProperty(parent, "keepNext", token.keepWithNext);
toggleProperty(
parent,
"pageBreakBefore",
token.pageBreakBefore
);
}
function applyBodyStyle(
styles: XmlElement,
style: ResolvedDocxThemeStyle
@@ -589,9 +836,215 @@ function applyTableAndCaption(
);
}
function applyTableTokenStyles(
styles: XmlElement,
slots: ReadonlyMap<
DocxStyleSlotName,
{ style: DocxSlotStyleToken }
>,
fallback: ResolvedDocxThemeStyle
) {
const tableToken = slots.get("table")?.style;
const cellToken =
slots.get("table-cell")?.style ?? tableToken;
const headerToken =
slots.get("table-header")?.style ?? cellToken;
if (!tableToken && !cellToken && !headerToken) {
return;
}
const table = ensureStyle(
styles,
"Table",
"table",
"TableNormal"
);
const tblPr = ensureDirectElement(
table,
WORD_NAMESPACE,
"w:tblPr"
);
if (tableToken?.widthPercent !== undefined) {
removeDirectChildren(tblPr, WORD_NAMESPACE, "tblW");
appendElement(tblPr, WORD_NAMESPACE, "w:tblW", {
"w:w": String(
Math.round(tableToken.widthPercent * 50)
),
"w:type": "pct"
});
}
const padding = cellToken?.paddingPt ?? tableToken?.paddingPt;
if (padding) {
removeDirectChildren(tblPr, WORD_NAMESPACE, "tblCellMar");
const margins = appendElement(
tblPr,
WORD_NAMESPACE,
"w:tblCellMar"
);
for (const side of [
"top",
"right",
"bottom",
"left"
] as const) {
appendElement(margins, WORD_NAMESPACE, `w:${side}`, {
"w:w": pointsToTwips(padding[side]),
"w:type": "dxa"
});
}
}
if (tableToken?.borders) {
removeDirectChildren(tblPr, WORD_NAMESPACE, "tblBorders");
const borders = appendElement(
tblPr,
WORD_NAMESPACE,
"w:tblBorders"
);
for (const side of [
"top",
"right",
"bottom",
"left"
] as const) {
const border = tableToken.borders[side];
if (!border) {
continue;
}
appendElement(borders, WORD_NAMESPACE, `w:${side}`, {
"w:val": border.style,
"w:sz": String(Math.round(border.widthPt * 8)),
"w:space": "0",
"w:color": colorValue(border.color)
});
}
}
if (cellToken) {
applyTokenRunStyle(
ensureDirectElement(table, WORD_NAMESPACE, "w:rPr"),
cellToken,
fallback.table.fonts
);
}
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" }
);
}
if (headerToken) {
applyTokenRunStyle(
ensureDirectElement(firstRow, WORD_NAMESPACE, "w:rPr"),
headerToken,
fallback.table.fonts
);
if (headerToken.backgroundColor !== undefined) {
const tcPr = ensureDirectElement(
firstRow,
WORD_NAMESPACE,
"w:tcPr"
);
removeDirectChildren(tcPr, WORD_NAMESPACE, "shd");
appendElement(tcPr, WORD_NAMESPACE, "w:shd", {
"w:val": "clear",
"w:color": "auto",
"w:fill": colorValue(
headerToken.backgroundColor
)
});
}
}
}
function applyTokenStyles(
styles: XmlElement,
tokens: DocxThemeTokenSet,
fallback: ResolvedDocxThemeStyle
) {
const slots = createDocxTokenSlotMap(tokens);
const documentToken = slots.get("document")?.style;
if (documentToken) {
const defaults = firstDirectChild(
styles,
WORD_NAMESPACE,
"docDefaults"
);
if (defaults) {
applyTokenRunStyle(
ensureDirectElement(
ensureDirectElement(
defaults,
WORD_NAMESPACE,
"w:rPrDefault"
),
WORD_NAMESPACE,
"w:rPr"
),
documentToken,
fallback.body.fonts
);
applyTokenParagraphStyle(
ensureDirectElement(
ensureDirectElement(
defaults,
WORD_NAMESPACE,
"w:pPrDefault"
),
WORD_NAMESPACE,
"w:pPr"
),
documentToken
);
}
}
for (const [slot, entry] of slots) {
const bindings = DOCX_SLOT_WORD_STYLE_BINDINGS[slot];
if (!bindings) {
continue;
}
for (const binding of bindings) {
const target = ensureStyle(
styles,
binding.styleId,
binding.type,
binding.basedOn
);
if (binding.type === "paragraph") {
applyTokenParagraphStyle(
ensureDirectElement(
target,
WORD_NAMESPACE,
"w:pPr"
),
entry.style
);
}
applyTokenRunStyle(
ensureDirectElement(
target,
WORD_NAMESPACE,
"w:rPr"
),
entry.style,
fallbackFontsForSlot(slot, fallback)
);
}
}
applyTableTokenStyles(styles, slots, fallback);
}
export function transformStylesXml(
content: Uint8Array,
style: ResolvedDocxThemeStyle
style: ResolvedDocxThemeStyle,
tokens?: DocxThemeTokenSet
) {
const document = parseXmlPart(content, "word/styles.xml");
const styles = document.documentElement!;
@@ -605,12 +1058,16 @@ export function transformStylesXml(
applyHeadings(styles, style);
applyCodeAndQuote(styles, style);
applyTableAndCaption(styles, style);
if (tokens) {
applyTokenStyles(styles, tokens, style);
}
return serializeXmlPart(document);
}
export function transformFontTableXml(
content: Uint8Array,
style: ResolvedDocxThemeStyle
style: ResolvedDocxThemeStyle,
tokens?: DocxThemeTokenSet
) {
const document = parseXmlPart(content, "word/fontTable.xml");
const root = document.documentElement!;
@@ -628,6 +1085,11 @@ export function transformFontTableXml(
requiredFonts.add(fonts.complexScript);
}
}
if (tokens) {
for (const font of collectDocxTokenFonts(tokens)) {
requiredFonts.add(font);
}
}
const existing = new Set(
Array.from(
root.getElementsByTagNameNS(WORD_NAMESPACE, "font")
@@ -648,9 +1110,22 @@ export function transformFontTableXml(
export function transformThemeFontsXml(
content: Uint8Array,
style: ResolvedDocxThemeStyle
style: ResolvedDocxThemeStyle,
tokens?: DocxThemeTokenSet
) {
const document = parseXmlPart(content, "word/theme/theme1.xml");
const slots = tokens
? createDocxTokenSlotMap(tokens)
: undefined;
const headingFonts = resolveTokenFonts(
slots?.get("heading-1")?.style,
style.headings.fonts
);
const bodyFonts = resolveTokenFonts(
slots?.get("paragraph")?.style ??
slots?.get("document")?.style,
style.body.fonts
);
for (const schemeName of ["majorFont", "minorFont"]) {
const scheme = document.getElementsByTagNameNS(
DRAWING_NAMESPACE,
@@ -670,14 +1145,14 @@ export function transformThemeFontsXml(
latin?.setAttribute(
"typeface",
schemeName === "majorFont"
? style.headings.fonts.latin
: style.body.fonts.latin
? headingFonts.latin
: bodyFonts.latin
);
eastAsia?.setAttribute(
"typeface",
schemeName === "majorFont"
? style.headings.fonts.eastAsia
: style.body.fonts.eastAsia
? headingFonts.eastAsia
: bodyFonts.eastAsia
);
}
return serializeXmlPart(document);