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
+11 -3
View File
@@ -1,8 +1,8 @@
# @md-to-pdf/docx-engine
纯 Node.js 的 DOCX 模板和 Pandoc 编排核心。当前阶段负责读取固定 Pandoc
版本的默认 `reference.docx`、验证 ZIP 安全边界,将主题 DOCX 样式声明
解析为完整样式预设,并生成纸张、页边距、字体、段落、代码、表格、
版本的默认 `reference.docx`、验证 ZIP 安全边界,将归一化主题令牌和
兼容样式预设映射为 Word 样式,并生成纸张、页边距、字体、段落、代码、表格、
页眉、页脚和页码均已映射的动态模板。Pandoc 转换器通过静态 Lua Filter
将普通图片、Mermaid 和 ECharts 节点替换为受控 PNG,同时保留 Markdown
正文、标题、列表、表格、代码和公式的可编辑文档结构。
@@ -12,7 +12,9 @@
- 不依赖 Fastify、Electron 或浏览器 UI
- ZIP 使用纯 JavaScript `fflate`
- OOXML 使用 `@xmldom/xmldom`,不拼接未经转义的用户 XML
- 不解析任意主题 CSS主题通过 `docxStyle` 提供受限结构化覆盖;
- 不解析任意主题 CSS只消费 `@md-to-pdf/docx-theme-engine` 的受限
语义令牌;`docxStyle` 预设在槽位缺失时提供兼容降级;
- 槽位只映射到稳定的标准或 `Md*` Word 样式,不包含主题 ID 分支;
- 生成结果会复验全部 XML、包内关系、内容类型、最终节和关键样式;
- 每次转换使用独立临时目录、隔离 Pandoc data 目录并在 `finally` 清理;
- 媒体映射检查顺序、PNG、像素、单图/总大小和物理显示尺寸;
@@ -37,6 +39,7 @@ npm run build -w @md-to-pdf/docx-engine
npm run verify:docx-reference
npm run verify:docx-conversion
npm run verify:docx-acceptance
npm run verify:docx-theme-styles
```
`verify:docx-reference` 要求本机 `PATH` 中存在 Pandoc 3.9.0.2,也可以通过
@@ -51,3 +54,8 @@ npm run verify:docx-acceptance
`output/docx-acceptance/`,供 Word/WPS 互操作验收使用。根级命令还会
依次执行动态模板、媒体转换、Server HTTP 和 Desktop 原生保存验收;
仅需重跑三配置矩阵时可使用 `npm run verify:docx-matrix`
`verify:docx-theme-styles` 使用 Playwright 与 Electron 分别采集 14 套
内置主题的 56 个槽位,检查跨引擎令牌一致性,并使用固定 Pandoc 默认
模板生成 14 份动态 `reference.docx`。模板矩阵检查标准 Markdown 样式、
结构化 `Md*` 样式、正文字体、字号、字体表和缓存指纹。
+1
View File
@@ -27,6 +27,7 @@
},
"dependencies": {
"@md-to-pdf/core": "0.1.0",
"@md-to-pdf/docx-theme-engine": "0.1.0",
"@xmldom/xmldom": "0.9.10",
"fflate": "0.8.3"
},
+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;
}
@@ -13,6 +13,7 @@ import {
type PreparedDocxMedia,
type ThemeManifest
} from "@md-to-pdf/core";
import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine";
import {
PandocDocxConversionError,
PandocDocxConverter,
@@ -49,6 +50,16 @@ const emptyMedia: PreparedDocxMedia = {
totalBytes: 0
};
const themeTokens: DocxThemeTokenSet = {
schemaVersion: 1,
themeId: "test-theme",
themeFingerprint: "c".repeat(64),
mode: "auto-with-overrides",
basePreset: "technical",
slots: [],
diagnostics: []
};
function input() {
return {
markdown: "# 测试",
@@ -56,6 +67,7 @@ function input() {
language: "zh-CN",
exportConfig: defaultExportConfig,
theme: theme(),
themeTokens,
metadata: {
title: "测试",
author: "测试人",
@@ -6,6 +6,10 @@ import {
type ExportConfig,
type ThemeManifest
} from "@md-to-pdf/core";
import type {
DocxResolvedStyleSlot,
DocxThemeTokenSet
} from "@md-to-pdf/docx-theme-engine";
import {
createDynamicReferenceDocx,
validateGeneratedDocx,
@@ -129,6 +133,21 @@ function theme(
};
}
function themeTokens(
fingerprint = "c".repeat(64),
slots: DocxResolvedStyleSlot[] = []
): DocxThemeTokenSet {
return {
schemaVersion: 1,
themeId: "test-theme",
themeFingerprint: fingerprint,
mode: "auto-with-overrides",
basePreset: "technical",
slots,
diagnostics: []
};
}
function createOptions(exportConfig: ExportConfig) {
return {
exportConfig,
@@ -137,7 +156,8 @@ function createOptions(exportConfig: ExportConfig) {
metadata: {
title: "年度 <报告>",
author: "测试人"
}
},
themeTokens: themeTokens()
};
}
@@ -159,6 +179,10 @@ describe("动态 reference.docx", () => {
author: "测试人"
}
});
const changedTokens = createDynamicReferenceDocx(baseline, {
...createOptions(defaultExportConfig),
themeTokens: themeTokens("d".repeat(64))
});
const entries = unzipSync(first.content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const stylesXml = decoder.decode(entries["word/styles.xml"]);
@@ -169,6 +193,7 @@ describe("动态 reference.docx", () => {
);
expect(first.cacheKey).toBe(second.cacheKey);
expect(first.cacheKey).not.toBe(changedMetadata.cacheKey);
expect(first.cacheKey).not.toBe(changedTokens.cacheKey);
expect(documentXml).toContain('w:w="11906"');
expect(documentXml).toContain('w:h="16838"');
expect(documentXml).toContain('w:top="907"');
@@ -186,6 +211,106 @@ describe("动态 reference.docx", () => {
});
});
it("将通用主题令牌写入标准样式、结构样式和字体部件", () => {
const result = createDynamicReferenceDocx(
createBaselineReference(),
{
...createOptions(defaultExportConfig),
themeTokens: themeTokens("e".repeat(64), [
{
slot: "paragraph",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [
"Source Han Serif SC",
"Times New Roman"
],
fontSizePt: 14,
color: "#112233",
lineSpacing: 1.8,
firstLineIndentPt: 28
}
},
{
slot: "heading-1",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["SimHei", "Arial"],
fontSizePt: 22,
bold: true,
color: "#aa0000",
alignment: "center",
keepWithNext: true
}
},
{
slot: "table",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
widthPercent: 100,
paddingPt: {
top: 3,
right: 4,
bottom: 3,
left: 4
},
borders: {
top: {
widthPt: 1,
color: "#445566",
style: "single"
}
}
}
},
{
slot: "official-title",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["SimSun"],
fontSizePt: 22,
color: "#ff0000",
alignment: "center",
keepWithNext: true
}
}
])
}
);
const entries = unzipSync(result.content);
const stylesXml = decoder.decode(entries["word/styles.xml"]);
const fontTableXml = decoder.decode(
entries["word/fontTable.xml"]
);
const themeXml = decoder.decode(
entries["word/theme/theme1.xml"]
);
expect(stylesXml).toContain('w:styleId="MdOfficialTitle"');
expect(stylesXml).toContain('w:eastAsia="Source Han Serif SC"');
expect(stylesXml).toContain('w:ascii="Times New Roman"');
expect(stylesXml).toContain('w:val="112233"');
expect(stylesXml).toContain('w:styleId="Heading1"');
expect(stylesXml).toContain('w:val="AA0000"');
expect(stylesXml).toContain('w:w="5000" w:type="pct"');
expect(stylesXml).toContain('w:color="445566"');
expect(fontTableXml).toContain(
'w:name="Source Han Serif SC"'
);
expect(fontTableXml).toContain('w:name="SimHei"');
expect(themeXml).toContain(
'<a:latin typeface="Arial"'
);
expect(themeXml).toContain(
'<a:ea typeface="SimHei"'
);
});
it("生成横向、自定义页边距和奇偶首页页眉页脚", () => {
const exportConfig: ExportConfig = {
...defaultExportConfig,
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine";
import {
DOCX_SLOT_WORD_STYLE_BINDINGS,
collectDocxTokenFonts,
createDocxTokenSlotMap,
resolveTokenFonts
} from "../src/index.js";
const tokens: DocxThemeTokenSet = {
schemaVersion: 1,
themeId: "test-theme",
themeFingerprint: "a".repeat(64),
mode: "auto",
basePreset: "general",
slots: [
{
slot: "paragraph",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [
"Source Han Serif SC",
"Times New Roman",
"serif"
]
}
}
],
diagnostics: []
};
describe("DOCX 令牌样式映射", () => {
it("以稳定样式 ID 覆盖标准 Markdown 和结构槽位", () => {
expect(
DOCX_SLOT_WORD_STYLE_BINDINGS["heading-1"]
).toEqual([
{
styleId: "Heading1",
type: "paragraph",
basedOn: "Normal"
},
{
styleId: "Heading1Char",
type: "character",
basedOn: "DefaultParagraphFont"
}
]);
expect(
DOCX_SLOT_WORD_STYLE_BINDINGS["official-title"]?.[0]
?.styleId
).toBe("MdOfficialTitle");
expect(
DOCX_SLOT_WORD_STYLE_BINDINGS["tender-representative"]?.[0]
?.styleId
).toBe("MdTenderRepresentative");
});
it("按文字系统选择字体并过滤 CSS 通用族名", () => {
const slot = createDocxTokenSlotMap(tokens).get("paragraph");
expect(
resolveTokenFonts(slot?.style, {
latin: "Arial",
eastAsia: "SimSun"
})
).toEqual({
latin: "Times New Roman",
eastAsia: "Source Han Serif SC",
complexScript: "Times New Roman"
});
expect([...collectDocxTokenFonts(tokens)]).toEqual([
"Source Han Serif SC",
"Times New Roman"
]);
});
});