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
+9 -3
View File
@@ -13,6 +13,7 @@ src/
application-service.ts 渲染、主题和资源用例入口
docx-export-service.ts DOCX 并发、超时、错误和完整导出编排
docx-media-service.ts DOCX 媒体尺寸计算、PNG 校验与清单组装
docx-theme-token-service.ts 主题指纹、快照缓存和 Word 令牌准备
image-resources.ts 本地、Base64 与受限远程图片处理
theme-registry.ts 内置及自定义主题扫描、校验与缓存
index.ts 公共导出入口
@@ -21,6 +22,7 @@ tests/
bundled-themes.test.ts
docx-export-service.test.ts
docx-media-service.test.ts
docx-theme-token-service.test.ts
image-resources.test.ts
```
@@ -56,10 +58,14 @@ Desktop 才会以 Markdown 所在目录为边界解析相对资源。
大小、总大小和媒体 ID。最终按文档顺序输出稳定的 `media-001.png`
清单;它不依赖 Playwright 或 Electron,平台代码只负责 Chromium 捕获。
`DocxExportService` 串联 capability 探测、请求准备、平台媒体捕获、
Pandoc 转换和最终 OOXML 校验。默认并发为 1、队列为 4、总超时为 90 秒;
`DocxThemeTokenService` 复用 Server Playwright 或 Desktop Electron
适配器采集主题计算样式,按主题 CSS 指纹缓存快照并生成 56 槽位令牌。
`explicit` 模式不启动浏览器,直接使用清单覆盖和预设降级。
`DocxExportService` 串联 capability 探测、请求准备、平台媒体捕获、主题
令牌准备、Pandoc 转换和最终 OOXML 校验。媒体与主题令牌并行准备。默认并发为 1、队列为 4、总超时为 90 秒;
可以通过 `DOCX_CONCURRENCY``DOCX_MAX_QUEUE``DOCX_TIMEOUT_MS`
配置。排队、探测、准备、媒体、模板、Pandoc、校验和总耗时使用共享协议
配置。排队、探测、准备、主题样式、媒体、模板、Pandoc、校验和总耗时使用共享协议
返回;关闭服务时会取消活动任务、拒绝排队任务并等待清理完成。
内置主题来自仓库 `themes/`,当前名称为 Typora Github、
+1
View File
@@ -23,6 +23,7 @@
"dependencies": {
"@md-to-pdf/core": "0.1.0",
"@md-to-pdf/docx-engine": "0.1.0",
"@md-to-pdf/docx-theme-engine": "0.1.0",
"@md-to-pdf/renderer": "0.1.0"
},
"devDependencies": {
@@ -20,6 +20,10 @@ import {
prepareDocxMedia,
type DocxMediaCaptureAdapter
} from "./docx-media-service.js";
import type {
DocxThemeTokenProvider,
PreparedDocxThemeTokens
} from "./docx-theme-token-service.js";
import type { ImageResolutionContext } from "./image-resources.js";
const DEFAULT_DOCX_CONCURRENCY = 1;
@@ -47,6 +51,7 @@ export interface DocxExportServiceOptions {
application: Pick<ApplicationService, "prepareDocxExport">;
runtime: DocxCapabilityProvider;
converter: DocxConversionPort;
themeTokens: DocxThemeTokenProvider;
limits?: Partial<DocxExportRuntimeLimits>;
}
@@ -359,24 +364,51 @@ export class DocxExportService {
const prepareMs = performance.now() - prepareStarted;
controller.signal.throwIfAborted();
const mediaStarted = performance.now();
const media = await prepareDocxMedia(
prepared,
options.mediaAdapter,
controller.signal
);
const mediaMs = performance.now() - mediaStarted;
let themeStyleMs = 0;
let mediaMs = 0;
const [themeTokens, media] = await Promise.all([
(async () => {
const started = performance.now();
try {
return await this.options.themeTokens.prepare(
prepared,
controller.signal
);
} finally {
themeStyleMs = performance.now() - started;
}
})(),
(async () => {
const started = performance.now();
try {
return await prepareDocxMedia(
prepared,
options.mediaAdapter,
controller.signal
);
} finally {
mediaMs = performance.now() - started;
}
})()
]);
controller.signal.throwIfAborted();
const conversion = await this.options.converter.convert(
this.createConversionInput(prepared, media),
this.createConversionInput(
prepared,
themeTokens,
media
),
controller.signal
);
return {
docx: conversion.docx,
fileName: createDocxFileName(prepared.request.fileName),
diagnostics: {
warnings: media.warnings,
warnings: [
...media.warnings,
...themeTokens.warnings
],
echartsErrors: media.echartsErrors,
mermaidErrors: media.mermaidErrors
},
@@ -384,6 +416,7 @@ export class DocxExportService {
queueMs,
probeMs,
prepareMs,
themeStyleMs,
mediaMs,
referenceMs: conversion.timings.referenceMs,
pandocMs: conversion.timings.pandocMs,
@@ -446,6 +479,7 @@ export class DocxExportService {
private createConversionInput(
prepared: PreparedDocxExport,
themeTokens: PreparedDocxThemeTokens,
media: Awaited<ReturnType<typeof prepareDocxMedia>>
): PandocDocxConversionInput {
return {
@@ -454,6 +488,7 @@ export class DocxExportService {
language: prepared.request.language,
exportConfig: prepared.request.exportConfig,
theme: prepared.theme.manifest,
themeTokens: themeTokens.tokens,
metadata: prepared.document.metadata,
media
};
@@ -0,0 +1,105 @@
import type {
DocxThemeTokenSet,
DocxThemeStyleCaptureAdapter
} from "@md-to-pdf/docx-theme-engine";
import {
DOCX_STYLE_SLOTS,
DocxThemeStyleSnapshotCache,
createDocxThemeStyleFingerprint,
normalizeDocxThemeMappingConfig,
resolveDocxThemeTokens,
type DocxThemeStyleSnapshot
} from "@md-to-pdf/docx-theme-engine";
import type { PreparedDocxExport } from "./application-service.js";
export interface PreparedDocxThemeTokens {
tokens: DocxThemeTokenSet;
warnings: string[];
}
export interface DocxThemeTokenProvider {
prepare(
prepared: PreparedDocxExport,
signal?: AbortSignal
): Promise<PreparedDocxThemeTokens>;
}
function createExplicitSnapshot(
themeId: string,
themeFingerprint: string
): DocxThemeStyleSnapshot {
return {
schemaVersion: 1,
themeId,
themeFingerprint,
viewport: {
widthPx: 794,
heightPx: 1123,
deviceScaleFactor: 1
},
rootFontSizePx: 16,
slots: DOCX_STYLE_SLOTS.map(({ name }) => ({
slot: name,
matched: false
}))
};
}
export class DocxThemeTokenService
implements DocxThemeTokenProvider
{
private readonly snapshots: DocxThemeStyleSnapshotCache;
constructor(
private readonly adapter: DocxThemeStyleCaptureAdapter
) {
this.snapshots = new DocxThemeStyleSnapshotCache(adapter);
}
async prepare(
prepared: PreparedDocxExport,
signal?: AbortSignal
): Promise<PreparedDocxThemeTokens> {
signal?.throwIfAborted();
const config = normalizeDocxThemeMappingConfig(
prepared.theme.manifest
);
const themeFingerprint =
await createDocxThemeStyleFingerprint(
prepared.theme.manifest.id,
prepared.theme.css
);
signal?.throwIfAborted();
const snapshot =
config.mode === "explicit"
? createExplicitSnapshot(
prepared.theme.manifest.id,
themeFingerprint
)
: await this.snapshots.capture(
{
themeId: prepared.theme.manifest.id,
themeFingerprint,
themeCss: prepared.theme.css,
baseUrl: this.adapter.themeStyleBaseUrl
},
signal
);
const tokens = resolveDocxThemeTokens({
snapshot,
config
});
return {
tokens,
warnings: tokens.diagnostics
.filter(
(diagnostic) => diagnostic.severity !== "info"
)
.map((diagnostic) => diagnostic.message)
};
}
invalidate(themeId?: string) {
this.snapshots.invalidate(themeId);
}
}
+5
View File
@@ -30,6 +30,11 @@ export {
type DocxMediaCaptureRequest,
type DocxMediaRenderDimensions
} from "./docx-media-service.js";
export {
DocxThemeTokenService,
type DocxThemeTokenProvider,
type PreparedDocxThemeTokens
} from "./docx-theme-token-service.js";
export {
DocxExportService,
DocxExportServiceError,
@@ -10,6 +10,7 @@ import type {
PandocDocxConversionResult,
PandocRuntimeResolution
} from "@md-to-pdf/docx-engine";
import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine";
import {
DocxExportService,
readDocxExportRuntimeLimits,
@@ -84,6 +85,16 @@ const availableCapability: DocxCapability = {
detectedVersion: "3.9.0.2"
};
const themeTokens: DocxThemeTokenSet = {
schemaVersion: 1,
themeId: "test-theme",
themeFingerprint: "c".repeat(64),
mode: "auto-with-overrides",
basePreset: "technical",
slots: [],
diagnostics: []
};
function resolution(
capability: DocxCapability = availableCapability
): PandocRuntimeResolution {
@@ -151,13 +162,22 @@ function createService(options: {
options.convert ??
vi.fn().mockResolvedValue(conversionResult())
},
themeTokens: {
prepare: vi.fn().mockResolvedValue({
tokens: themeTokens,
warnings: []
})
},
limits: options.limits
});
}
describe("DOCX 共享导出服务", () => {
it("串联准备、媒体和转换并返回完整耗时", async () => {
const service = createService({});
const convert = vi
.fn()
.mockResolvedValue(conversionResult());
const service = createService({ convert });
const result = await service.generate(
{ markdown: "# 测试" },
{ mediaAdapter: emptyAdapter }
@@ -170,7 +190,14 @@ describe("DOCX 共享导出服务", () => {
echartsErrors: [],
mermaidErrors: []
});
expect(convert).toHaveBeenCalledWith(
expect.objectContaining({
themeTokens
}),
expect.any(AbortSignal)
);
expect(result.timings).toMatchObject({
themeStyleMs: expect.any(Number),
referenceMs: 3,
pandocMs: 4,
validationMs: 5
@@ -0,0 +1,142 @@
import { describe, expect, it, vi } from "vitest";
import {
defaultExportConfig,
type ThemeManifest
} from "@md-to-pdf/core";
import {
DOCX_STYLE_SLOTS,
type DocxThemeStyleCaptureAdapter
} from "@md-to-pdf/docx-theme-engine";
import {
DocxThemeTokenService,
type PreparedDocxExport
} from "../src/index.js";
function manifest(
docxStyle: ThemeManifest["docxStyle"] = {
mode: "auto",
basePreset: "technical"
}
): ThemeManifest {
return {
manifestVersion: 1,
id: "test-theme",
name: "测试主题",
version: "1.0.0",
description: "测试",
author: "测试",
license: "内部许可",
entry: "theme.css",
domPreset: "generic",
defaultFontSize: "16px",
supportedFeatures: [],
category: "general",
compatibleProfiles: [],
docxStyle,
bundled: true
};
}
function prepared(
docxStyle?: ThemeManifest["docxStyle"]
): PreparedDocxExport {
return {
request: {
markdown: "# 测试",
fileName: "测试.md",
language: "zh-CN",
resources: [],
exportConfig: {
...defaultExportConfig,
themeId: "test-theme"
}
},
document: {
rendererVersion: 1,
articleHtml:
'<article id="write"><h1>测试</h1></article>',
bodyHtml: "<h1>测试</h1>",
metadata: {
title: "测试",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
semanticDocument: {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
},
regions: []
},
features: [],
warnings: []
},
theme: {
manifest: manifest(docxStyle),
source: "bundled",
css: "#write { font-family: Test; }"
}
};
}
function adapter(): DocxThemeStyleCaptureAdapter {
return {
themeStyleBaseUrl:
"http://localhost:5173/preview-frame.html",
captureThemeStyle: vi.fn(async (request) => ({
schemaVersion: 1,
themeId: request.themeId,
themeFingerprint: request.themeFingerprint,
viewport: {
widthPx: 794,
heightPx: 1123,
deviceScaleFactor: 1
},
rootFontSizePx: 16,
slots: DOCX_STYLE_SLOTS.map(({ name }) => ({
slot: name,
matched: false
}))
}))
};
}
describe("DOCX 主题令牌准备服务", () => {
it("按主题指纹缓存跨端样式快照", async () => {
const captureAdapter = adapter();
const service = new DocxThemeTokenService(captureAdapter);
const first = await service.prepare(prepared());
const second = await service.prepare(prepared());
expect(captureAdapter.captureThemeStyle).toHaveBeenCalledTimes(1);
expect(first.tokens).toEqual(second.tokens);
expect(first.tokens.themeId).toBe("test-theme");
expect(first.tokens.themeFingerprint).toMatch(/^[a-f0-9]{64}$/u);
expect(first.tokens.slots).toHaveLength(DOCX_STYLE_SLOTS.length);
});
it("显式模式跳过浏览器采集并只产生信息级降级诊断", async () => {
const captureAdapter = adapter();
const service = new DocxThemeTokenService(captureAdapter);
const result = await service.prepare(
prepared({
mode: "explicit",
basePreset: "technical"
})
);
expect(captureAdapter.captureThemeStyle).not.toHaveBeenCalled();
expect(result.tokens.mode).toBe("explicit");
expect(result.warnings).toEqual([]);
expect(
result.tokens.diagnostics.every(
(diagnostic) => diagnostic.severity === "info"
)
).toBe(true);
});
});