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
@@ -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,