feat: 将主题令牌接入 DOCX 动态模板
This commit is contained in:
@@ -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*` 样式、正文字体、字号、字体表和缓存指纹。
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user