feat: 将主题令牌接入 DOCX 动态模板
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user