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
+1
View File
@@ -12,4 +12,5 @@ export * from "./reference-package.js";
export * from "./section-transform.js";
export * from "./style-presets.js";
export * from "./styles-transform.js";
export * from "./token-style-map.js";
export * from "./validator.js";
@@ -17,6 +17,7 @@ import {
type PreparedDocxMedia,
type ThemeManifest
} from "@md-to-pdf/core";
import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine";
import {
createDynamicReferenceCacheKey,
createDynamicReferenceDocx,
@@ -57,6 +58,7 @@ export interface PandocDocxConversionInput {
language: string;
exportConfig: ExportConfig;
theme: ThemeManifest;
themeTokens: DocxThemeTokenSet;
metadata: MarkdownDocumentMetadata;
media: PreparedDocxMedia;
}
@@ -133,6 +135,7 @@ function referenceOptions(
return {
exportConfig: input.exportConfig,
theme: input.theme,
themeTokens: input.themeTokens,
fileName: input.fileName,
metadata: {
title: input.metadata.title,
+26 -4
View File
@@ -23,6 +23,10 @@ import {
transformStylesXml,
transformThemeFontsXml
} from "./styles-transform.js";
import {
createDocxTokenSlotMap,
resolveTokenFonts
} from "./token-style-map.js";
import { resolveDocxThemeStyle } from "./style-presets.js";
import { validateDynamicReferenceDocx } from "./validator.js";
@@ -67,6 +71,7 @@ export function createDynamicReferenceCacheKey(
docxStyle: options.theme.docxStyle,
pageDefaults: options.theme.pageDefaults
},
themeTokens: options.themeTokens,
paper: options.exportConfig.paper,
pageDecorationsMode:
options.exportConfig.pageDecorationsMode,
@@ -85,29 +90,46 @@ export function createDynamicReferenceDocx(
): DynamicReferenceDocxResult {
const reference = readReferenceDocxPackage(baseline);
const style = resolveDocxThemeStyle(options.theme);
const tokenSlots = options.themeTokens
? createDocxTokenSlotMap(options.themeTokens)
: undefined;
const bodyFonts = resolveTokenFonts(
tokenSlots?.get("paragraph")?.style ??
tokenSlots?.get("document")?.style,
style.body.fonts
);
const page = resolveReferencePageOptions(options);
const withDecorations = createHeaderFooterParts(
reference.entries,
options,
page.header,
page.footer,
style.body.fonts.eastAsia,
bodyFonts.eastAsia,
page.dimensions.width - page.margins.left - page.margins.right
);
const entries = withDecorations.entries;
entries.set(
"word/styles.xml",
transformStylesXml(entries.get("word/styles.xml")!, style)
transformStylesXml(
entries.get("word/styles.xml")!,
style,
options.themeTokens
)
);
entries.set(
"word/fontTable.xml",
transformFontTableXml(entries.get("word/fontTable.xml")!, style)
transformFontTableXml(
entries.get("word/fontTable.xml")!,
style,
options.themeTokens
)
);
entries.set(
"word/theme/theme1.xml",
transformThemeFontsXml(
entries.get("word/theme/theme1.xml")!,
style
style,
options.themeTokens
)
);
entries.set(
@@ -7,12 +7,14 @@ import {
type MarkdownDocumentMetadata,
type ThemeManifest
} from "@md-to-pdf/core";
import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine";
export interface DynamicReferenceDocxOptions {
exportConfig: ExportConfig;
theme: ThemeManifest;
fileName: string;
metadata: Pick<MarkdownDocumentMetadata, "title" | "author">;
themeTokens?: DocxThemeTokenSet;
}
export function resolveReferencePageOptions(
+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);
+192
View File
@@ -0,0 +1,192 @@
import type { DocxFontFamily } from "@md-to-pdf/core";
import {
docxThemeTokenSetSchema,
type DocxResolvedStyleSlot,
type DocxSlotStyleToken,
type DocxStyleSlotName,
type DocxThemeTokenSet
} from "@md-to-pdf/docx-theme-engine";
export interface DocxWordStyleBinding {
styleId: string;
type: "paragraph" | "character";
basedOn?: string;
}
function paragraph(
styleId: string,
basedOn = "Normal"
): DocxWordStyleBinding {
return { styleId, type: "paragraph", basedOn };
}
function character(
styleId: string,
basedOn = "DefaultParagraphFont"
): DocxWordStyleBinding {
return { styleId, type: "character", basedOn };
}
export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly<
Partial<
Record<
DocxStyleSlotName,
readonly DocxWordStyleBinding[]
>
>
> = {
"document-title": [paragraph("Title")],
"document-author": [paragraph("Author")],
paragraph: [
paragraph("Normal", ""),
paragraph("BodyText"),
paragraph("FirstParagraph"),
paragraph("Definition"),
paragraph("Figure")
],
strong: [character("Strong")],
emphasis: [character("Emphasis")],
strikethrough: [character("Strikeout")],
"inline-code": [character("VerbatimChar", "BodyTextChar")],
"heading-1": [
paragraph("Heading1"),
character("Heading1Char")
],
"heading-2": [
paragraph("Heading2"),
character("Heading2Char")
],
"heading-3": [
paragraph("Heading3"),
character("Heading3Char")
],
"heading-4": [
paragraph("Heading4"),
character("Heading4Char")
],
"heading-5": [
paragraph("Heading5"),
character("Heading5Char")
],
"heading-6": [
paragraph("Heading6"),
character("Heading6Char")
],
"unordered-list": [paragraph("MdUnorderedList", "BodyText")],
"ordered-list": [paragraph("MdOrderedList", "BodyText")],
"list-item": [
paragraph("Compact", "BodyText"),
paragraph("ListParagraph", "BodyText")
],
"block-quote": [paragraph("BlockText", "BodyText")],
"code-block": [paragraph("SourceCode")],
hyperlink: [character("Hyperlink", "BodyTextChar")],
figure: [paragraph("Figure")],
caption: [
paragraph("Caption"),
paragraph("TableCaption", "Caption"),
paragraph("ImageCaption", "Caption")
],
footnotes: [paragraph("FootnoteText")],
"official-masthead": [paragraph("MdOfficialMasthead")],
"official-classification": [
paragraph("MdOfficialClassification")
],
"official-issuer": [paragraph("MdOfficialIssuer")],
"official-issue-row": [paragraph("MdOfficialIssueRow")],
"official-number": [paragraph("MdOfficialNumber")],
"official-signatory": [paragraph("MdOfficialSignatory")],
"official-title": [paragraph("MdOfficialTitle")],
"official-signature": [paragraph("MdOfficialSignature")],
"official-edition": [paragraph("MdOfficialEdition")],
"briefing-masthead": [paragraph("MdBriefingMasthead")],
"briefing-meta": [paragraph("MdBriefingMeta")],
"briefing-title": [paragraph("MdBriefingTitle")],
"briefing-contact": [paragraph("MdBriefingContact")],
"project-report-cover": [paragraph("MdProjectReportCover")],
"project-report-project-name": [
paragraph("MdProjectReportProjectName")
],
"project-report-title": [paragraph("MdProjectReportTitle")],
"project-report-version": [
paragraph("MdProjectReportVersion")
],
"project-report-owner": [paragraph("MdProjectReportOwner")],
"project-report-prepared-by": [
paragraph("MdProjectReportPreparedBy")
],
"project-report-date": [paragraph("MdProjectReportDate")],
"tender-cover": [paragraph("MdTenderCover")],
"tender-copy-mark": [paragraph("MdTenderCopyMark")],
"tender-project-name": [paragraph("MdTenderProjectName")],
"tender-project-number": [
paragraph("MdTenderProjectNumber")
],
"tender-title": [paragraph("MdTenderTitle")],
"tender-volume": [paragraph("MdTenderVolume")],
"tender-bidder": [paragraph("MdTenderBidder")],
"tender-representative": [
paragraph("MdTenderRepresentative")
],
"tender-date": [paragraph("MdTenderDate")]
};
const eastAsiaFontPattern =
/(?:yahei|simsun|simhei|fangsong|kaiti|dengxian|cjk|source\s+han|pingfang|heiti|songti|||仿||线)/iu;
const genericFontNames = new Set([
"serif",
"sans-serif",
"monospace",
"cursive",
"fantasy",
"system-ui"
]);
export function resolveTokenFonts(
token: DocxSlotStyleToken | undefined,
fallback: DocxFontFamily
): DocxFontFamily {
const candidates = (token?.fontCandidates ?? []).filter(
(candidate) =>
!genericFontNames.has(candidate.trim().toLowerCase())
);
if (!candidates.length) {
return fallback;
}
const eastAsia =
candidates.find((candidate) =>
eastAsiaFontPattern.test(candidate)
) ?? candidates[0]!;
const latin =
candidates.find(
(candidate) => !eastAsiaFontPattern.test(candidate)
) ?? candidates[0]!;
return {
latin,
eastAsia,
complexScript: latin
};
}
export function createDocxTokenSlotMap(
input: DocxThemeTokenSet
): ReadonlyMap<DocxStyleSlotName, DocxResolvedStyleSlot> {
const tokens = docxThemeTokenSetSchema.parse(input);
return new Map(
tokens.slots.map((entry) => [entry.slot, entry])
);
}
export function collectDocxTokenFonts(
input: DocxThemeTokenSet
): Set<string> {
const fonts = new Set<string>();
for (const { style } of input.slots) {
for (const candidate of style.fontCandidates) {
if (!genericFontNames.has(candidate.trim().toLowerCase())) {
fonts.add(candidate);
}
}
}
return fonts;
}