feat: 将主题令牌接入 DOCX 动态模板
This commit is contained in:
@@ -3,6 +3,7 @@ import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createApplicationService } from "@md-to-pdf/application";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
DOCX_STYLE_SLOT_NAMES,
|
||||
createDocxThemeStyleCaptureScript,
|
||||
@@ -120,8 +121,17 @@ try {
|
||||
const tokenSets = [];
|
||||
for (const theme of bundledThemes) {
|
||||
console.error(`[Electron 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 request = {
|
||||
@@ -150,7 +160,7 @@ try {
|
||||
tokenSets.push(
|
||||
resolveDocxThemeTokens({
|
||||
snapshot,
|
||||
config: normalizeDocxThemeMappingConfig(theme)
|
||||
config: normalizeDocxThemeMappingConfig(manifest)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import path from "node:path";
|
||||
import {
|
||||
DocxExportService,
|
||||
DocxThemeTokenService,
|
||||
MAXIMUM_MARKDOWN_LENGTH,
|
||||
readDocxExportRuntimeLimits,
|
||||
type ApplicationService
|
||||
@@ -159,6 +160,9 @@ export class DesktopApplicationController {
|
||||
const pandocRuntime = new PandocRuntime({
|
||||
desktopResourcesPath: options.desktopResourcesPath
|
||||
});
|
||||
this.#docxMediaEngine = new ElectronDocxMediaEngine({
|
||||
renderUrl: options.docxRenderUrl
|
||||
});
|
||||
this.#docxExportService = new DocxExportService({
|
||||
application: this.#applicationService,
|
||||
runtime: pandocRuntime,
|
||||
@@ -169,11 +173,11 @@ export class DesktopApplicationController {
|
||||
import.meta.url
|
||||
)
|
||||
}),
|
||||
themeTokens: new DocxThemeTokenService(
|
||||
this.#docxMediaEngine
|
||||
),
|
||||
limits: readDocxExportRuntimeLimits()
|
||||
});
|
||||
this.#docxMediaEngine = new ElectronDocxMediaEngine({
|
||||
renderUrl: options.docxRenderUrl
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface ElectronDocxMediaEngineOptions {
|
||||
export class ElectronDocxMediaEngine
|
||||
implements DocxMediaCaptureAdapter, DocxThemeStyleCaptureAdapter
|
||||
{
|
||||
readonly themeStyleBaseUrl: string;
|
||||
private readonly renderUrl: string;
|
||||
private windowPromise: Promise<BrowserWindow> | undefined;
|
||||
private queue = Promise.resolve();
|
||||
@@ -28,6 +29,7 @@ export class ElectronDocxMediaEngine
|
||||
|
||||
constructor(options: ElectronDocxMediaEngineOptions) {
|
||||
this.renderUrl = options.renderUrl;
|
||||
this.themeStyleBaseUrl = this.renderUrl;
|
||||
}
|
||||
|
||||
capture(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 ??
|
||||
(() =>
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -133,6 +133,7 @@ describe("DOCX 导出客户端", () => {
|
||||
queueMs: 0,
|
||||
probeMs: 0,
|
||||
prepareMs: 0,
|
||||
themeStyleMs: 0,
|
||||
mediaMs: 0,
|
||||
referenceMs: 0,
|
||||
pandocMs: 0,
|
||||
|
||||
Reference in New Issue
Block a user