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
@@ -3,12 +3,22 @@ import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createApplicationService } from "@md-to-pdf/application";
import {
DOCX_PANDOC_VERSION,
defaultExportConfig
} from "@md-to-pdf/core";
import {
DOCX_STYLE_SLOT_NAMES,
createDocxThemeStyleFingerprint,
normalizeDocxThemeMappingConfig,
resolveDocxThemeTokens
} from "@md-to-pdf/docx-theme-engine";
import {
PandocRuntime,
createDynamicReferenceDocx,
readReferenceDocxPackage,
resolveTokenFonts
} from "@md-to-pdf/docx-engine";
import { chromium } from "playwright";
import { captureDocxThemeStyleWithPlaywrightPage } from "../dist/playwright-docx-theme-style.js";
@@ -19,6 +29,10 @@ const outputDirectory = path.join(
"output",
"docx-theme-styles"
);
const referenceDirectory = path.join(
outputDirectory,
"references"
);
function assert(condition, message) {
if (!condition) {
@@ -26,6 +40,81 @@ function assert(condition, message) {
}
}
function styleBlock(stylesXml, styleId) {
const escaped = styleId.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
return stylesXml.match(
new RegExp(
`<w:style[^>]*w:styleId="${escaped}"[^>]*>[\\s\\S]*?<\\/w:style>`,
"u"
)
)?.[0];
}
function inspectReference(reference, tokens) {
const entries = readReferenceDocxPackage(reference.content).entries;
const stylesXml = new TextDecoder().decode(
entries.get("word/styles.xml")
);
const fontTableXml = new TextDecoder().decode(
entries.get("word/fontTable.xml")
);
const normal = styleBlock(stylesXml, "Normal");
const paragraph = tokens.slots.find(
(slot) => slot.slot === "paragraph"
)?.style;
assert(normal, `主题 ${tokens.themeId} 缺少 Normal 样式`);
const fonts = resolveTokenFonts(paragraph, {
latin: "Arial",
eastAsia: "SimSun"
});
if (paragraph?.fontCandidates.length) {
assert(
normal.includes(`w:ascii="${fonts.latin}"`),
`主题 ${tokens.themeId} 的 Latin 正文字体未写入模板`
);
assert(
normal.includes(`w:eastAsia="${fonts.eastAsia}"`),
`主题 ${tokens.themeId} 的东亚正文字体未写入模板`
);
assert(
fontTableXml.includes(`w:name="${fonts.eastAsia}"`),
`主题 ${tokens.themeId} 的东亚字体未写入字体表`
);
}
if (paragraph?.fontSizePt !== undefined) {
assert(
normal.includes(
`w:sz w:val="${Math.round(paragraph.fontSizePt * 2)}"`
),
`主题 ${tokens.themeId} 的正文字号未写入模板`
);
}
for (const styleId of [
"Heading1",
"SourceCode",
"Table",
"MdOfficialTitle",
"MdBriefingTitle",
"MdProjectReportTitle",
"MdTenderTitle"
]) {
assert(
stylesXml.includes(`w:styleId="${styleId}"`),
`主题 ${tokens.themeId} 缺少样式 ${styleId}`
);
}
return {
bytes: reference.content.byteLength,
templateFingerprint: reference.templateFingerprint,
cacheKey: reference.cacheKey,
stylePreset: reference.stylePreset,
partCount: reference.partCount,
paragraphFonts: fonts,
customStyleCount:
stylesXml.match(/w:styleId="Md[A-Za-z]+"/gu)?.length ?? 0
};
}
const application = createApplicationService({
bundledRoot: path.join(repositoryDirectory, "themes"),
localRoot: path.join(repositoryDirectory, ".local", "themes")
@@ -88,6 +177,20 @@ if (!address || typeof address === "string") {
}
const baseUrl = `http://127.0.0.1:${address.port}/`;
const browser = await chromium.launch({ headless: true });
const runtime = new PandocRuntime(
process.env.DOCX_PANDOC_PATH?.trim()
? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() }
: {}
);
const capability = await runtime.probe();
if (capability.capability.status !== "available") {
throw new Error(capability.capability.message);
}
assert(
capability.capability.detectedVersion === DOCX_PANDOC_VERSION,
`Pandoc 版本不匹配:期望 ${DOCX_PANDOC_VERSION},实际 ${capability.capability.detectedVersion}`
);
const baseline = await runtime.getDefaultReferenceDocx();
try {
const context = await browser.newContext({
@@ -104,10 +207,20 @@ try {
);
const snapshots = [];
const tokenSets = [];
const references = [];
for (const theme of bundledThemes) {
console.error(`[DOCX theme styles] capturing ${theme.id}`);
const themeCss = await application.getThemeCss(theme.id);
assert(themeCss !== undefined, `主题 ${theme.id} 缺少 CSS`);
const prepared = await application.prepareDocxExport({
markdown: `# ${theme.name}`,
fileName: `${theme.id}.md`,
language: "zh-CN",
exportConfig: {
...defaultExportConfig,
themeId: theme.id
}
});
const themeCss = prepared.theme.css;
const manifest = prepared.theme.manifest;
const themeFingerprint =
await createDocxThemeStyleFingerprint(theme.id, themeCss);
const snapshot =
@@ -129,12 +242,27 @@ try {
.join("、")}`
);
snapshots.push(snapshot);
tokenSets.push(
resolveDocxThemeTokens({
snapshot,
config: normalizeDocxThemeMappingConfig(theme)
})
const tokens = resolveDocxThemeTokens({
snapshot,
config: normalizeDocxThemeMappingConfig(manifest)
});
tokenSets.push(tokens);
const reference = createDynamicReferenceDocx(baseline, {
exportConfig: prepared.request.exportConfig,
theme: manifest,
fileName: prepared.request.fileName,
metadata: prepared.document.metadata,
themeTokens: tokens
});
fs.mkdirSync(referenceDirectory, { recursive: true });
fs.writeFileSync(
path.join(referenceDirectory, `${theme.id}.docx`),
reference.content
);
references.push({
themeId: theme.id,
...inspectReference(reference, tokens)
});
}
assert(
snapshots.length === 14,
@@ -172,7 +300,9 @@ try {
themeCount: snapshots.length,
slotCount: DOCX_STYLE_SLOT_NAMES.length,
snapshots,
tokenSets
tokenSets,
pandocVersion: capability.capability.detectedVersion,
references
},
null,
2
@@ -190,7 +320,9 @@ try {
diagnostics: tokenSets.reduce(
(count, tokens) => count + tokens.diagnostics.length,
0
)
),
pandocVersion: capability.capability.detectedVersion,
references: references.length
})
);
await context.close();
+7 -2
View File
@@ -14,6 +14,7 @@ import {
ApplicationRequestError,
DocxExportService,
DocxExportServiceError,
DocxThemeTokenService,
createApplicationService,
readDocxExportRuntimeLimits,
type ApplicationService
@@ -133,6 +134,7 @@ function createDocxServerTiming(
`queue;dur=${milliseconds(timings.queueMs)}`,
`runtime-probe;dur=${milliseconds(timings.probeMs)}`,
`prepare;dur=${milliseconds(timings.prepareMs)}`,
`theme-style;dur=${milliseconds(timings.themeStyleMs)}`,
`media;dur=${milliseconds(timings.mediaMs)}`,
`reference;dur=${milliseconds(timings.referenceMs)}`,
`pandoc;dur=${milliseconds(timings.pandocMs)}`,
@@ -166,6 +168,8 @@ export function buildApp(options: BuildAppOptions = {}) {
const pandocRuntime = options.docxExportService
? undefined
: new PandocRuntime();
const docxMediaAdapter =
options.docxMediaAdapter ?? createDocxMediaEngine();
const docxExportService =
options.docxExportService ??
new DocxExportService({
@@ -174,10 +178,11 @@ export function buildApp(options: BuildAppOptions = {}) {
converter: new PandocDocxConverter({
runtime: pandocRuntime!
}),
themeTokens: new DocxThemeTokenService(
docxMediaAdapter
),
limits: readDocxExportRuntimeLimits()
});
const docxMediaAdapter =
options.docxMediaAdapter ?? createDocxMediaEngine();
if (options.prewarmPdfBrowser) {
app.addHook("onReady", async () => {
+2
View File
@@ -38,6 +38,7 @@ async function closeContext(context: BrowserContext) {
export class PlaywrightDocxMediaEngine
implements ServerDocxMediaCaptureAdapter
{
readonly themeStyleBaseUrl: string;
private readonly renderOrigin: string;
private readonly renderUrl: string;
private readonly launchBrowser: () => Promise<Browser>;
@@ -54,6 +55,7 @@ export class PlaywrightDocxMediaEngine
"/preview-frame.html?target=continuous",
this.renderOrigin
).href;
this.themeStyleBaseUrl = this.renderUrl;
this.launchBrowser =
options.launchBrowser ??
(() =>
+12 -6
View File
@@ -42,11 +42,12 @@ function generatedDocx(): DocxExportResult {
queueMs: 1,
probeMs: 2,
prepareMs: 3,
mediaMs: 4,
referenceMs: 5,
pandocMs: 6,
validationMs: 7,
totalMs: 28
themeStyleMs: 4,
mediaMs: 5,
referenceMs: 6,
pandocMs: 7,
validationMs: 8,
totalMs: 36
}
};
}
@@ -72,6 +73,8 @@ function createDocxService(
function createMediaAdapter() {
return {
themeStyleBaseUrl:
"http://localhost:5173/preview-frame.html",
capture: vi.fn<
DocxMediaCaptureAdapter["capture"]
>(async () => ({
@@ -82,6 +85,9 @@ function createMediaAdapter() {
},
captures: []
})),
captureThemeStyle: vi.fn(async () => {
throw new Error("此测试使用注入的 DOCX 导出服务");
}),
close: vi.fn(async () => undefined)
} satisfies ServerDocxMediaCaptureAdapter;
}
@@ -173,7 +179,7 @@ describe("DOCX HTTP API", () => {
"runtime-probe;dur=2.0"
);
expect(response.headers["server-timing"]).toContain(
"pandoc;dur=6.0"
"pandoc;dur=7.0"
);
expect(response.rawPayload).toEqual(
Buffer.from([80, 75, 3, 4])