diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index ac877dd..b2c7968 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -17,11 +17,13 @@
"package": "npm run build:embedded-web && npm run build && electron-builder --dir --config electron-builder.config.cjs --x64",
"make": "npm run build:embedded-web && npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64",
"test": "vitest run",
- "typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
+ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
+ "verify:docx-theme-styles": "electron scripts/verify-docx-theme-styles.cjs"
},
"devDependencies": {
"@md-to-pdf/application": "0.4.1",
"@md-to-pdf/docx-engine": "0.1.0",
+ "@md-to-pdf/docx-theme-engine": "0.1.0",
"@types/node": "^24.10.1",
"electron": "43.2.0",
"electron-builder": "26.15.3",
diff --git a/apps/desktop/scripts/verify-docx-theme-styles.cjs b/apps/desktop/scripts/verify-docx-theme-styles.cjs
new file mode 100644
index 0000000..04f2e0e
--- /dev/null
+++ b/apps/desktop/scripts/verify-docx-theme-styles.cjs
@@ -0,0 +1,8 @@
+void import("./verify-docx-theme-styles.mjs").catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+ const { app } = require("electron");
+ if (app.isReady()) {
+ app.quit();
+ }
+});
diff --git a/apps/desktop/scripts/verify-docx-theme-styles.mjs b/apps/desktop/scripts/verify-docx-theme-styles.mjs
new file mode 100644
index 0000000..654f959
--- /dev/null
+++ b/apps/desktop/scripts/verify-docx-theme-styles.mjs
@@ -0,0 +1,191 @@
+import fs from "node:fs";
+import http from "node:http";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { createApplicationService } from "@md-to-pdf/application";
+import {
+ DOCX_STYLE_SLOT_NAMES,
+ createDocxThemeStyleCaptureScript,
+ createDocxThemeStyleFingerprint,
+ parseDocxThemeStyleRuntimeCapture
+} from "@md-to-pdf/docx-theme-engine";
+import { app, BrowserWindow } from "electron";
+
+const directory = path.dirname(fileURLToPath(import.meta.url));
+const repositoryDirectory = path.resolve(directory, "../../..");
+const outputDirectory = path.join(
+ repositoryDirectory,
+ "output",
+ "docx-theme-styles"
+);
+
+function assert(condition, message) {
+ if (!condition) {
+ throw new Error(message);
+ }
+}
+
+const application = createApplicationService({
+ bundledRoot: path.join(repositoryDirectory, "themes"),
+ localRoot: path.join(repositoryDirectory, ".local", "themes")
+});
+
+const server = http.createServer(async (request, response) => {
+ try {
+ const url = new URL(request.url ?? "/", "http://localhost");
+ if (url.pathname === "/") {
+ response.writeHead(200, {
+ "content-type": "text/html; charset=utf-8"
+ });
+ response.end("
");
+ return;
+ }
+ const match = url.pathname.match(
+ /^\/api\/themes\/([^/]+)\/assets\/(.+)$/u
+ );
+ if (!match) {
+ response.writeHead(404);
+ response.end("Not found");
+ return;
+ }
+ const themeId = decodeURIComponent(match[1]);
+ const assetPath = match[2]
+ .split("/")
+ .map((segment) => decodeURIComponent(segment))
+ .join("/");
+ const asset = await application.getThemeAsset(
+ themeId,
+ assetPath
+ );
+ if (!asset) {
+ response.writeHead(404);
+ response.end("Not found");
+ return;
+ }
+ response.writeHead(200, {
+ "access-control-allow-origin": "*",
+ "content-type": asset.contentType,
+ "x-content-type-options": "nosniff"
+ });
+ response.end(asset.content);
+ } catch (error) {
+ response.writeHead(500);
+ response.end(
+ error instanceof Error ? error.message : "Unknown error"
+ );
+ }
+});
+
+await app.whenReady();
+await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", resolve);
+});
+
+const address = server.address();
+if (!address || typeof address === "string") {
+ throw new Error("无法获取 Electron DOCX 主题样式验收地址");
+}
+const baseUrl = `http://127.0.0.1:${address.port}/`;
+const window = new BrowserWindow({
+ show: false,
+ focusable: false,
+ width: 800,
+ height: 600,
+ paintWhenInitiallyHidden: true,
+ webPreferences: {
+ backgroundThrottling: false,
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true
+ }
+});
+
+try {
+ await window.loadURL(baseUrl);
+ window.webContents.debugger.attach("1.3");
+ await window.webContents.debugger.sendCommand(
+ "Emulation.setEmulatedMedia",
+ { media: "print" }
+ );
+ const { themes } = await application.listThemes();
+ const bundledThemes = themes
+ .filter((theme) => theme.source === "bundled")
+ .sort((first, second) =>
+ first.id.localeCompare(second.id, "en")
+ );
+ const snapshots = [];
+ 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 themeFingerprint =
+ await createDocxThemeStyleFingerprint(theme.id, themeCss);
+ const request = {
+ themeId: theme.id,
+ themeFingerprint,
+ themeCss,
+ baseUrl
+ };
+ const capture = await window.webContents.executeJavaScript(
+ createDocxThemeStyleCaptureScript(request),
+ true
+ );
+ const snapshot = parseDocxThemeStyleRuntimeCapture(
+ request,
+ capture
+ );
+ assert(
+ snapshot.slots.length === DOCX_STYLE_SLOT_NAMES.length,
+ `主题 ${theme.id} 的槽位数量不正确`
+ );
+ assert(
+ snapshot.slots.every((slot) => slot.matched),
+ `主题 ${theme.id} 存在未命中槽位`
+ );
+ snapshots.push(snapshot);
+ }
+ assert(
+ snapshots.length === 14,
+ `内置主题数量应为 14,实际为 ${snapshots.length}`
+ );
+ fs.mkdirSync(outputDirectory, { recursive: true });
+ fs.writeFileSync(
+ path.join(outputDirectory, "electron-snapshots.json"),
+ `${JSON.stringify(
+ {
+ generatedAt: new Date().toISOString(),
+ electronVersion: process.versions.electron,
+ chromiumVersion: process.versions.chrome,
+ themeCount: snapshots.length,
+ slotCount: DOCX_STYLE_SLOT_NAMES.length,
+ snapshots
+ },
+ null,
+ 2
+ )}\n`,
+ "utf8"
+ );
+ console.log(
+ JSON.stringify({
+ electronVersion: process.versions.electron,
+ chromiumVersion: process.versions.chrome,
+ themes: snapshots.length,
+ slotsPerTheme: DOCX_STYLE_SLOT_NAMES.length,
+ totalSlots:
+ snapshots.length * DOCX_STYLE_SLOT_NAMES.length
+ })
+ );
+} finally {
+ if (
+ !window.isDestroyed() &&
+ window.webContents.debugger.isAttached()
+ ) {
+ window.webContents.debugger.detach();
+ }
+ if (!window.isDestroyed()) {
+ window.destroy();
+ }
+ await new Promise((resolve) => server.close(resolve));
+ app.quit();
+}
diff --git a/apps/desktop/src/electron-docx-media-engine.ts b/apps/desktop/src/electron-docx-media-engine.ts
index 22ca0dc..e9a01e0 100644
--- a/apps/desktop/src/electron-docx-media-engine.ts
+++ b/apps/desktop/src/electron-docx-media-engine.ts
@@ -3,7 +3,12 @@ import type {
DocxMediaCaptureAdapter,
DocxMediaCaptureRequest
} from "@md-to-pdf/application";
+import type {
+ DocxThemeStyleCaptureAdapter,
+ DocxThemeStyleCaptureRequest
+} from "@md-to-pdf/docx-theme-engine";
import { captureDocxMediaWithElectronWebContents } from "./electron-docx-media-capture.js";
+import { captureDocxThemeStyleWithElectronWebContents } from "./electron-docx-theme-style.js";
import { isAllowedPdfRuntimeUrl } from "./pdf-contract.js";
export const DESKTOP_DOCX_PARTITION =
@@ -14,7 +19,7 @@ export interface ElectronDocxMediaEngineOptions {
}
export class ElectronDocxMediaEngine
- implements DocxMediaCaptureAdapter
+ implements DocxMediaCaptureAdapter, DocxThemeStyleCaptureAdapter
{
private readonly renderUrl: string;
private windowPromise: Promise | undefined;
@@ -42,6 +47,23 @@ export class ElectronDocxMediaEngine
return operation;
}
+ captureThemeStyle(
+ request: DocxThemeStyleCaptureRequest,
+ signal?: AbortSignal
+ ) {
+ if (this.closed) {
+ return Promise.reject(new Error("桌面 DOCX 媒体引擎已关闭"));
+ }
+ const operation = this.queue.then(() =>
+ this.captureThemeStyleNow(request, signal)
+ );
+ this.queue = operation.then(
+ () => undefined,
+ () => undefined
+ );
+ return operation;
+ }
+
async close() {
this.closed = true;
await this.queue;
@@ -84,6 +106,36 @@ export class ElectronDocxMediaEngine
}
}
+ private async captureThemeStyleNow(
+ request: DocxThemeStyleCaptureRequest,
+ signal?: AbortSignal
+ ) {
+ signal?.throwIfAborted();
+ if (
+ new URL(request.baseUrl).origin !==
+ new URL(this.renderUrl).origin
+ ) {
+ throw new Error("桌面 DOCX 主题资源地址必须与渲染地址同源");
+ }
+ const window = await this.getWindow();
+ signal?.throwIfAborted();
+ const abort = () => {
+ if (!window.isDestroyed()) {
+ window.destroy();
+ }
+ };
+ signal?.addEventListener("abort", abort, { once: true });
+ try {
+ return await captureDocxThemeStyleWithElectronWebContents(
+ window.webContents,
+ request,
+ signal
+ );
+ } finally {
+ signal?.removeEventListener("abort", abort);
+ }
+ }
+
private getWindow() {
if (!this.windowPromise) {
this.windowPromise = this.createWindow().catch((error) => {
diff --git a/apps/desktop/src/electron-docx-theme-style.ts b/apps/desktop/src/electron-docx-theme-style.ts
new file mode 100644
index 0000000..078a2ba
--- /dev/null
+++ b/apps/desktop/src/electron-docx-theme-style.ts
@@ -0,0 +1,45 @@
+import {
+ createDocxThemeStyleCaptureScript,
+ parseDocxThemeStyleRuntimeCapture,
+ type DocxThemeStyleCaptureRequest,
+ type DocxThemeStyleSnapshot
+} from "@md-to-pdf/docx-theme-engine";
+import type { WebContents } from "electron";
+
+export async function captureDocxThemeStyleWithElectronWebContents(
+ webContents: WebContents,
+ request: DocxThemeStyleCaptureRequest,
+ signal?: AbortSignal
+): Promise {
+ signal?.throwIfAborted();
+ const ownsDebugger = !webContents.debugger.isAttached();
+ if (ownsDebugger) {
+ webContents.debugger.attach("1.3");
+ }
+ try {
+ await webContents.debugger.sendCommand(
+ "Emulation.setEmulatedMedia",
+ { media: "print" }
+ );
+ const capture = await webContents.executeJavaScript(
+ createDocxThemeStyleCaptureScript(request),
+ true
+ );
+ signal?.throwIfAborted();
+ return parseDocxThemeStyleRuntimeCapture(request, capture);
+ } finally {
+ if (
+ !webContents.isDestroyed() &&
+ webContents.debugger.isAttached()
+ ) {
+ await webContents.debugger
+ .sendCommand("Emulation.setEmulatedMedia", {
+ media: "screen"
+ })
+ .catch(() => undefined);
+ if (ownsDebugger && webContents.debugger.isAttached()) {
+ webContents.debugger.detach();
+ }
+ }
+ }
+}
diff --git a/apps/desktop/tests/electron-docx-theme-style.test.ts b/apps/desktop/tests/electron-docx-theme-style.test.ts
new file mode 100644
index 0000000..1068b04
--- /dev/null
+++ b/apps/desktop/tests/electron-docx-theme-style.test.ts
@@ -0,0 +1,97 @@
+import {
+ DOCX_STYLE_SLOT_NAMES,
+ type DocxComputedStyle,
+ type DocxThemeStyleCaptureRequest
+} from "@md-to-pdf/docx-theme-engine";
+import { describe, expect, it, vi } from "vitest";
+import { captureDocxThemeStyleWithElectronWebContents } from "../src/electron-docx-theme-style.js";
+
+const computed: DocxComputedStyle = {
+ fontFamily: "Arial",
+ fontSize: "16px",
+ fontWeight: "400",
+ fontStyle: "normal",
+ color: "rgb(0, 0, 0)",
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ lineHeight: "normal",
+ letterSpacing: "normal",
+ textAlign: "start",
+ textIndent: "0px",
+ textDecorationLine: "none",
+ marginTop: "0px",
+ marginRight: "0px",
+ marginBottom: "0px",
+ marginLeft: "0px",
+ paddingTop: "0px",
+ paddingRight: "0px",
+ paddingBottom: "0px",
+ paddingLeft: "0px",
+ borderTop: "0px none rgb(0, 0, 0)",
+ borderRight: "0px none rgb(0, 0, 0)",
+ borderBottom: "0px none rgb(0, 0, 0)",
+ borderLeft: "0px none rgb(0, 0, 0)",
+ width: "640px",
+ maxWidth: "none",
+ breakBefore: "auto",
+ breakAfter: "auto",
+ breakInside: "auto",
+ display: "block"
+};
+
+const request: DocxThemeStyleCaptureRequest = {
+ themeId: "external-clean",
+ themeFingerprint: "b".repeat(64),
+ themeCss: "#write { color: #000; }",
+ baseUrl: "mdpdf://app/"
+};
+
+describe("Electron DOCX 主题样式采集", () => {
+ it("通过 CDP 切换打印媒体并在结束后恢复", async () => {
+ let attached = false;
+ const sendCommand = vi.fn(async () => undefined);
+ const webContents = {
+ debugger: {
+ isAttached: () => attached,
+ attach: vi.fn(() => {
+ attached = true;
+ }),
+ detach: vi.fn(() => {
+ attached = false;
+ }),
+ sendCommand
+ },
+ executeJavaScript: vi.fn(async () => ({
+ rootFontSizePx: 16,
+ viewport: {
+ widthPx: 794,
+ heightPx: 1123,
+ deviceScaleFactor: 1
+ },
+ slots: DOCX_STYLE_SLOT_NAMES.map((slot) => ({
+ slot,
+ matched: true,
+ computed
+ }))
+ })),
+ isDestroyed: () => false
+ };
+ const snapshot =
+ await captureDocxThemeStyleWithElectronWebContents(
+ webContents as never,
+ request
+ );
+ expect(sendCommand).toHaveBeenNthCalledWith(
+ 1,
+ "Emulation.setEmulatedMedia",
+ { media: "print" }
+ );
+ expect(sendCommand).toHaveBeenLastCalledWith(
+ "Emulation.setEmulatedMedia",
+ { media: "screen" }
+ );
+ expect(webContents.debugger.detach).toHaveBeenCalledTimes(1);
+ expect(snapshot.slots).toHaveLength(
+ DOCX_STYLE_SLOT_NAMES.length
+ );
+ });
+});
diff --git a/apps/server/package.json b/apps/server/package.json
index a924034..9c4417c 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -9,12 +9,14 @@
"start": "node dist/index.js",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
- "verify:docx-http": "node scripts/verify-docx-http.mjs"
+ "verify:docx-http": "node scripts/verify-docx-http.mjs",
+ "verify:docx-theme-styles": "node scripts/verify-docx-theme-styles.mjs"
},
"dependencies": {
"@md-to-pdf/application": "0.4.1",
"@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",
"fastify": "^5.6.2",
"playwright": "1.62.0"
diff --git a/apps/server/scripts/verify-docx-theme-styles.mjs b/apps/server/scripts/verify-docx-theme-styles.mjs
new file mode 100644
index 0000000..cabfc6a
--- /dev/null
+++ b/apps/server/scripts/verify-docx-theme-styles.mjs
@@ -0,0 +1,174 @@
+import fs from "node:fs";
+import http from "node:http";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { createApplicationService } from "@md-to-pdf/application";
+import {
+ DOCX_STYLE_SLOT_NAMES,
+ createDocxThemeStyleFingerprint
+} from "@md-to-pdf/docx-theme-engine";
+import { chromium } from "playwright";
+import { captureDocxThemeStyleWithPlaywrightPage } from "../dist/playwright-docx-theme-style.js";
+
+const directory = path.dirname(fileURLToPath(import.meta.url));
+const repositoryDirectory = path.resolve(directory, "../../..");
+const outputDirectory = path.join(
+ repositoryDirectory,
+ "output",
+ "docx-theme-styles"
+);
+
+function assert(condition, message) {
+ if (!condition) {
+ throw new Error(message);
+ }
+}
+
+const application = createApplicationService({
+ bundledRoot: path.join(repositoryDirectory, "themes"),
+ localRoot: path.join(repositoryDirectory, ".local", "themes")
+});
+
+const server = http.createServer(async (request, response) => {
+ try {
+ const url = new URL(request.url ?? "/", "http://localhost");
+ if (url.pathname === "/") {
+ response.writeHead(200, {
+ "content-type": "text/html; charset=utf-8"
+ });
+ response.end("");
+ return;
+ }
+ const match = url.pathname.match(
+ /^\/api\/themes\/([^/]+)\/assets\/(.+)$/u
+ );
+ if (!match) {
+ response.writeHead(404);
+ response.end("Not found");
+ return;
+ }
+ const themeId = decodeURIComponent(match[1]);
+ const assetPath = match[2]
+ .split("/")
+ .map((segment) => decodeURIComponent(segment))
+ .join("/");
+ const asset = await application.getThemeAsset(
+ themeId,
+ assetPath
+ );
+ if (!asset) {
+ response.writeHead(404);
+ response.end("Not found");
+ return;
+ }
+ response.writeHead(200, {
+ "access-control-allow-origin": "*",
+ "content-type": asset.contentType,
+ "x-content-type-options": "nosniff"
+ });
+ response.end(asset.content);
+ } catch (error) {
+ response.writeHead(500);
+ response.end(
+ error instanceof Error ? error.message : "Unknown error"
+ );
+ }
+});
+
+await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", resolve);
+});
+
+const address = server.address();
+if (!address || typeof address === "string") {
+ throw new Error("无法获取 DOCX 主题样式验收地址");
+}
+const baseUrl = `http://127.0.0.1:${address.port}/`;
+const browser = await chromium.launch({ headless: true });
+
+try {
+ const context = await browser.newContext({
+ locale: "zh-CN",
+ serviceWorkers: "block"
+ });
+ const page = await context.newPage();
+ await page.goto(baseUrl, { waitUntil: "domcontentloaded" });
+ const { themes } = await application.listThemes();
+ const bundledThemes = themes
+ .filter((theme) => theme.source === "bundled")
+ .sort((first, second) =>
+ first.id.localeCompare(second.id, "en")
+ );
+ const snapshots = [];
+ 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 themeFingerprint =
+ await createDocxThemeStyleFingerprint(theme.id, themeCss);
+ const snapshot =
+ await captureDocxThemeStyleWithPlaywrightPage(page, {
+ themeId: theme.id,
+ themeFingerprint,
+ themeCss,
+ baseUrl
+ });
+ assert(
+ snapshot.slots.length === DOCX_STYLE_SLOT_NAMES.length,
+ `主题 ${theme.id} 的槽位数量不正确`
+ );
+ const missing = snapshot.slots.filter((slot) => !slot.matched);
+ assert(
+ missing.length === 0,
+ `主题 ${theme.id} 缺少槽位:${missing
+ .map((slot) => slot.slot)
+ .join("、")}`
+ );
+ snapshots.push(snapshot);
+ }
+ assert(
+ snapshots.length === 14,
+ `内置主题数量应为 14,实际为 ${snapshots.length}`
+ );
+ const paragraphStyles = new Set(
+ snapshots.map(
+ (snapshot) =>
+ snapshot.slots.find((slot) => slot.slot === "paragraph")
+ ?.computed?.fontFamily
+ )
+ );
+ assert(
+ paragraphStyles.size > 1,
+ "主题 CSS 未产生可区分的正文字体计算结果"
+ );
+ fs.mkdirSync(outputDirectory, { recursive: true });
+ fs.writeFileSync(
+ path.join(outputDirectory, "snapshots.json"),
+ `${JSON.stringify(
+ {
+ generatedAt: new Date().toISOString(),
+ chromiumVersion: browser.version(),
+ themeCount: snapshots.length,
+ slotCount: DOCX_STYLE_SLOT_NAMES.length,
+ snapshots
+ },
+ null,
+ 2
+ )}\n`,
+ "utf8"
+ );
+ console.log(
+ JSON.stringify({
+ chromiumVersion: browser.version(),
+ themes: snapshots.length,
+ slotsPerTheme: DOCX_STYLE_SLOT_NAMES.length,
+ totalSlots:
+ snapshots.length * DOCX_STYLE_SLOT_NAMES.length
+ })
+ );
+ await context.close();
+} finally {
+ await browser.close();
+ await new Promise((resolve) => server.close(resolve));
+}
diff --git a/apps/server/src/docx-media-engine.ts b/apps/server/src/docx-media-engine.ts
index 971102c..43bd04b 100644
--- a/apps/server/src/docx-media-engine.ts
+++ b/apps/server/src/docx-media-engine.ts
@@ -3,11 +3,17 @@ import type {
DocxMediaCaptureAdapter,
DocxMediaCaptureRequest
} from "@md-to-pdf/application";
+import type {
+ DocxThemeStyleCaptureAdapter,
+ DocxThemeStyleCaptureRequest
+} from "@md-to-pdf/docx-theme-engine";
import { captureDocxMediaWithPlaywrightPage } from "./playwright-docx-media-capture.js";
+import { captureDocxThemeStyleWithPlaywrightPage } from "./playwright-docx-theme-style.js";
import { isAllowedPdfRequestUrl } from "./pdf-engine.js";
export interface ServerDocxMediaCaptureAdapter
- extends DocxMediaCaptureAdapter {
+ extends DocxMediaCaptureAdapter,
+ DocxThemeStyleCaptureAdapter {
warmup?(): Promise;
close(): Promise;
}
@@ -116,6 +122,57 @@ export class PlaywrightDocxMediaEngine
}
}
+ async captureThemeStyle(
+ request: DocxThemeStyleCaptureRequest,
+ signal?: AbortSignal
+ ) {
+ if (this.closed) {
+ throw new Error("DOCX 媒体服务已关闭");
+ }
+ signal?.throwIfAborted();
+ if (new URL(request.baseUrl).origin !== this.renderOrigin) {
+ throw new Error("DOCX 主题样式资源地址必须与渲染地址同源");
+ }
+
+ const browser = await this.getBrowser();
+ signal?.throwIfAborted();
+ const context = await browser.newContext({
+ locale: "zh-CN",
+ serviceWorkers: "block"
+ });
+ const abort = () => {
+ void closeContext(context);
+ };
+ signal?.addEventListener("abort", abort, { once: true });
+
+ try {
+ await context.route("**/*", async (route) => {
+ if (
+ isAllowedPdfRequestUrl(
+ route.request().url(),
+ this.renderOrigin
+ )
+ ) {
+ await route.continue();
+ return;
+ }
+ await route.abort("blockedbyclient");
+ });
+ const page = await context.newPage();
+ await page.goto(this.renderUrl, {
+ waitUntil: "domcontentloaded"
+ });
+ return await captureDocxThemeStyleWithPlaywrightPage(
+ page,
+ request,
+ signal
+ );
+ } finally {
+ signal?.removeEventListener("abort", abort);
+ await closeContext(context);
+ }
+ }
+
async close() {
this.closed = true;
const browserPromise = this.browserPromise;
diff --git a/apps/server/src/playwright-docx-theme-style.ts b/apps/server/src/playwright-docx-theme-style.ts
new file mode 100644
index 0000000..56c5c6f
--- /dev/null
+++ b/apps/server/src/playwright-docx-theme-style.ts
@@ -0,0 +1,21 @@
+import {
+ createDocxThemeStyleCaptureScript,
+ parseDocxThemeStyleRuntimeCapture,
+ type DocxThemeStyleCaptureRequest,
+ type DocxThemeStyleSnapshot
+} from "@md-to-pdf/docx-theme-engine";
+import type { Page } from "playwright";
+
+export async function captureDocxThemeStyleWithPlaywrightPage(
+ page: Page,
+ request: DocxThemeStyleCaptureRequest,
+ signal?: AbortSignal
+): Promise {
+ signal?.throwIfAborted();
+ await page.emulateMedia({ media: "print" });
+ const capture = await page.evaluate(
+ createDocxThemeStyleCaptureScript(request)
+ );
+ signal?.throwIfAborted();
+ return parseDocxThemeStyleRuntimeCapture(request, capture);
+}
diff --git a/apps/server/tests/playwright-docx-theme-style.test.ts b/apps/server/tests/playwright-docx-theme-style.test.ts
new file mode 100644
index 0000000..760bff8
--- /dev/null
+++ b/apps/server/tests/playwright-docx-theme-style.test.ts
@@ -0,0 +1,80 @@
+import {
+ DOCX_STYLE_SLOT_NAMES,
+ type DocxComputedStyle,
+ type DocxThemeStyleCaptureRequest
+} from "@md-to-pdf/docx-theme-engine";
+import { describe, expect, it, vi } from "vitest";
+import { captureDocxThemeStyleWithPlaywrightPage } from "../src/playwright-docx-theme-style.js";
+
+const computed: DocxComputedStyle = {
+ fontFamily: "Arial",
+ fontSize: "16px",
+ fontWeight: "400",
+ fontStyle: "normal",
+ color: "rgb(0, 0, 0)",
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ lineHeight: "normal",
+ letterSpacing: "normal",
+ textAlign: "start",
+ textIndent: "0px",
+ textDecorationLine: "none",
+ marginTop: "0px",
+ marginRight: "0px",
+ marginBottom: "0px",
+ marginLeft: "0px",
+ paddingTop: "0px",
+ paddingRight: "0px",
+ paddingBottom: "0px",
+ paddingLeft: "0px",
+ borderTop: "0px none rgb(0, 0, 0)",
+ borderRight: "0px none rgb(0, 0, 0)",
+ borderBottom: "0px none rgb(0, 0, 0)",
+ borderLeft: "0px none rgb(0, 0, 0)",
+ width: "640px",
+ maxWidth: "none",
+ breakBefore: "auto",
+ breakAfter: "auto",
+ breakInside: "auto",
+ display: "block"
+};
+
+const request: DocxThemeStyleCaptureRequest = {
+ themeId: "external-clean",
+ themeFingerprint: "a".repeat(64),
+ themeCss: "#write { color: #000; }",
+ baseUrl: "http://localhost:3001/"
+};
+
+describe("Playwright DOCX 主题样式采集", () => {
+ it("使用打印媒体并校验浏览器返回的快照", async () => {
+ const page = {
+ emulateMedia: vi.fn(async () => undefined),
+ evaluate: vi.fn(async () => ({
+ rootFontSizePx: 16,
+ viewport: {
+ widthPx: 794,
+ heightPx: 1123,
+ deviceScaleFactor: 1
+ },
+ slots: DOCX_STYLE_SLOT_NAMES.map((slot) => ({
+ slot,
+ matched: true,
+ computed
+ }))
+ }))
+ };
+ const snapshot =
+ await captureDocxThemeStyleWithPlaywrightPage(
+ page as never,
+ request
+ );
+ expect(page.emulateMedia).toHaveBeenCalledWith({
+ media: "print"
+ });
+ expect(page.evaluate).toHaveBeenCalledTimes(1);
+ expect(snapshot.themeId).toBe("external-clean");
+ expect(snapshot.slots).toHaveLength(
+ DOCX_STYLE_SLOT_NAMES.length
+ );
+ });
+});
diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md
index da34418..ca0be01 100644
--- a/docs/PROGRESS.md
+++ b/docs/PROGRESS.md
@@ -160,8 +160,16 @@ DOCX 映射。
扁平样式覆盖保持兼容。标准探针 DOM 已覆盖普通文档、公文、简报、项目
报告和标书,Profile 专属槽位避免同名类在不同结构上下文中互相污染;
平台无关采集器可以按稳定顺序读取全部 `getComputedStyle()` 并保留未
-命中状态。当前尚未实现 Playwright/Electron 页面适配器,也未将快照
-归一化为 Word 令牌或接入 Pandoc 转换链路。
+命中状态。Server Playwright 与 Desktop Electron 适配器已经复用现有
+受限浏览器和隐藏窗口,通过临时同源 iframe 安全注入主题 CSS、等待字体、
+采集样式并立即清理;映射引擎同时提供按主题 CSS SHA-256 指纹进行并发
+合并和有界 LRU 缓存的快照服务,待下一阶段接入导出编排。真实矩阵对
+14 套主题分别在 Playwright Chromium 151 和
+Electron Chromium 150 中采集 56 个槽位,共检查两组各 784 个槽位;
+两端逐槽位 `font-family`、`font-size` 和 `color` 差异为 0。统一复现
+命令为 `npm run verify:docx-theme-styles`,结果写入被 Git 忽略的
+`output/docx-theme-styles/`。当前尚未将快照归一化为 Word 令牌或接入
+Pandoc 转换链路。
## 2. 已完成
@@ -1089,7 +1097,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
映射门禁未通过;
- 阶段 11:已建立独立 `packages/docx-theme-engine`、标准语义槽位及
快照、令牌、来源、置信度、诊断协议、标准探针 DOM 和平台无关采集器;
- 下一步实现 Playwright/Electron 计算样式页面适配;
+ Playwright/Electron 真实采集、主题指纹缓存和双引擎一致性矩阵已通过;
+ 下一步实现 CSS 计算值到 Word 样式令牌的归一化;
- 阶段 12:建立统一语义文档模型,完成 Front Matter、封面、分节、
表格宽度和分页控制;
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
diff --git a/package-lock.json b/package-lock.json
index 4c6e088..03fb801 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,7 @@
"devDependencies": {
"@md-to-pdf/application": "0.4.1",
"@md-to-pdf/docx-engine": "0.1.0",
+ "@md-to-pdf/docx-theme-engine": "0.1.0",
"@types/node": "^24.10.1",
"electron": "43.2.0",
"electron-builder": "26.15.3",
@@ -526,6 +527,7 @@
"@md-to-pdf/application": "0.4.1",
"@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",
"fastify": "^5.6.2",
"playwright": "1.62.0"
diff --git a/package.json b/package.json
index 146dbe3..93f701b 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
"verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine",
"verify:docx-matrix": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine",
"verify:docx-themes": "npm run build:web-runtime && npm run verify:themes -w @md-to-pdf/docx-engine",
+ "verify:docx-theme-styles": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/desktop",
"verify:docx-acceptance": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop && npm run verify:pandoc -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine && npm run verify:docx-http -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop -- desktop-docx-save.test.ts",
"verify:docx-http": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run verify:docx-http -w @md-to-pdf/server",
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop",
diff --git a/packages/docx-theme-engine/README.md b/packages/docx-theme-engine/README.md
index 90d32dc..ef6c781 100644
--- a/packages/docx-theme-engine/README.md
+++ b/packages/docx-theme-engine/README.md
@@ -9,6 +9,8 @@ DOCX 主题映射引擎负责在浏览器主题 CSS 与 Word 样式之间建立
- 覆盖普通文档、公文、简报、项目报告和标书的标准探针 DOM;
- Chromium/Electron 计算样式快照协议;
- 不绑定具体浏览器实现的计算样式采集器;
+- 可安全注入主题 CSS、等待字体并自动清理 iframe 的浏览器运行脚本;
+- 基于主题 CSS SHA-256 指纹的并发合并和 LRU 快照缓存;
- Word 目标样式令牌;
- 自动映射、显式覆盖和预设降级配置;
- 样式来源、映射置信度和诊断协议;
@@ -22,7 +24,7 @@ DOCX 主题映射引擎负责在浏览器主题 CSS 与 Word 样式之间建立
- 直接修改 OOXML;
- 按主题 ID 维护专属转换分支。
-后续 Chromium 与 Electron 适配器只负责挂载探针、应用主题 CSS,并将
-平台的 `getComputedStyle()` 接入本包采集器。本包负责将快照归一化为
-DOCX 样式令牌,`@md-to-pdf/docx-engine` 再消费令牌生成
-`reference.docx` 和最终 OOXML。
+Server Playwright 与 Desktop Electron 适配器负责在现有受限浏览器生命周期
+中执行本包脚本;主题 CSS 和字体资源继续使用各平台已有的同源资源协议。
+本包下一步负责将快照归一化为 DOCX 样式令牌,
+`@md-to-pdf/docx-engine` 再消费令牌生成 `reference.docx` 和最终 OOXML。
diff --git a/packages/docx-theme-engine/src/fingerprint.ts b/packages/docx-theme-engine/src/fingerprint.ts
new file mode 100644
index 0000000..986e4c2
--- /dev/null
+++ b/packages/docx-theme-engine/src/fingerprint.ts
@@ -0,0 +1,15 @@
+export async function createDocxThemeStyleFingerprint(
+ themeId: string,
+ themeCss: string
+): Promise {
+ const content = new TextEncoder().encode(
+ `docx-theme-style-v1\0${themeId}\0${themeCss}`
+ );
+ const digest = await globalThis.crypto.subtle.digest(
+ "SHA-256",
+ content
+ );
+ return [...new Uint8Array(digest)]
+ .map((value) => value.toString(16).padStart(2, "0"))
+ .join("");
+}
diff --git a/packages/docx-theme-engine/src/index.ts b/packages/docx-theme-engine/src/index.ts
index 5de94ba..a87e214 100644
--- a/packages/docx-theme-engine/src/index.ts
+++ b/packages/docx-theme-engine/src/index.ts
@@ -1,6 +1,9 @@
export * from "./collector.js";
export * from "./configuration.js";
+export * from "./fingerprint.js";
export * from "./probe.js";
+export * from "./runtime.js";
+export * from "./snapshot-cache.js";
export * from "./slots.js";
export * from "./snapshot.js";
export * from "./tokens.js";
diff --git a/packages/docx-theme-engine/src/runtime.ts b/packages/docx-theme-engine/src/runtime.ts
new file mode 100644
index 0000000..65304bc
--- /dev/null
+++ b/packages/docx-theme-engine/src/runtime.ts
@@ -0,0 +1,225 @@
+import { z } from "zod";
+import { createDocxStyleProbeMarkup } from "./probe.js";
+import {
+ docxThemeStyleSnapshotSchema,
+ type DocxComputedStyle,
+ type DocxThemeStyleSnapshot
+} from "./snapshot.js";
+import { DOCX_STYLE_SLOTS } from "./slots.js";
+
+const DOCX_STYLE_PROBE_WIDTH_PX = 794;
+const DOCX_STYLE_PROBE_HEIGHT_PX = 1123;
+const DOCX_STYLE_PROBE_CONTENT_WIDTH_PX = 640;
+
+export const docxThemeStyleCaptureRequestSchema = z.object({
+ themeId: z
+ .string()
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u),
+ themeFingerprint: z.string().regex(/^[a-f0-9]{64}$/u),
+ themeCss: z.string().max(4 * 1024 * 1024),
+ baseUrl: z.string().url().max(2048)
+});
+
+export type DocxThemeStyleCaptureRequest = z.infer<
+ typeof docxThemeStyleCaptureRequestSchema
+>;
+
+export interface DocxThemeStyleCaptureAdapter {
+ captureThemeStyle(
+ request: DocxThemeStyleCaptureRequest,
+ signal?: AbortSignal
+ ): Promise;
+}
+
+const runtimeCaptureSchema = z.object({
+ rootFontSizePx: z.number().positive().max(200),
+ viewport: z.object({
+ widthPx: z.number().int().positive().max(10000),
+ heightPx: z.number().int().positive().max(10000),
+ deviceScaleFactor: z.number().positive().max(10)
+ }),
+ slots: z.array(
+ z.object({
+ slot: z.string(),
+ matched: z.boolean(),
+ computed: z.record(z.string(), z.string()).optional()
+ })
+ )
+});
+
+const computedProperties = [
+ "fontFamily",
+ "fontSize",
+ "fontWeight",
+ "fontStyle",
+ "color",
+ "backgroundColor",
+ "lineHeight",
+ "letterSpacing",
+ "textAlign",
+ "textIndent",
+ "textDecorationLine",
+ "marginTop",
+ "marginRight",
+ "marginBottom",
+ "marginLeft",
+ "paddingTop",
+ "paddingRight",
+ "paddingBottom",
+ "paddingLeft",
+ "borderTop",
+ "borderRight",
+ "borderBottom",
+ "borderLeft",
+ "width",
+ "maxWidth",
+ "breakBefore",
+ "breakAfter",
+ "breakInside",
+ "display"
+] as const satisfies readonly (keyof DocxComputedStyle)[];
+
+const baselineCss = `
+html,
+body {
+ margin: 0;
+ padding: 0;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+#write {
+ width: ${DOCX_STYLE_PROBE_CONTENT_WIDTH_PX}px;
+ margin: 0;
+ padding: 0;
+}
+`;
+
+export function createDocxThemeStyleCaptureScript(
+ input: DocxThemeStyleCaptureRequest
+): string {
+ const request = docxThemeStyleCaptureRequestSchema.parse(input);
+ const payload = JSON.stringify({
+ request,
+ markup: createDocxStyleProbeMarkup(),
+ slots: DOCX_STYLE_SLOTS,
+ properties: computedProperties,
+ baselineCss,
+ viewport: {
+ width: DOCX_STYLE_PROBE_WIDTH_PX,
+ height: DOCX_STYLE_PROBE_HEIGHT_PX
+ }
+ })
+ .replaceAll("<", "\\u003c")
+ .replaceAll(">", "\\u003e")
+ .replaceAll("\u2028", "\\u2028")
+ .replaceAll("\u2029", "\\u2029");
+ return `(async () => {
+ const payload = ${payload};
+ const frame = document.createElement("iframe");
+ frame.setAttribute("aria-hidden", "true");
+ frame.style.cssText =
+ "position:fixed;left:-32000px;top:-32000px;" +
+ "width:" + payload.viewport.width + "px;" +
+ "height:" + payload.viewport.height + "px;" +
+ "border:0;visibility:hidden;pointer-events:none;";
+ document.body.appendChild(frame);
+ try {
+ const probeDocument = frame.contentDocument;
+ const probeWindow = frame.contentWindow;
+ if (!probeDocument || !probeWindow) {
+ throw new Error("DOCX 主题样式探针 iframe 不可用");
+ }
+ probeDocument.open();
+ probeDocument.write(
+ ""
+ );
+ probeDocument.close();
+ const base = probeDocument.createElement("base");
+ base.href = payload.request.baseUrl;
+ probeDocument.head.appendChild(base);
+ const baseline = probeDocument.createElement("style");
+ baseline.dataset.docxProbeStyle = "baseline";
+ baseline.textContent = payload.baselineCss;
+ probeDocument.head.appendChild(baseline);
+ const theme = probeDocument.createElement("style");
+ theme.dataset.docxProbeStyle = "theme";
+ theme.textContent = payload.request.themeCss;
+ probeDocument.head.appendChild(theme);
+ probeDocument.body.innerHTML = payload.markup;
+ const fontReady = probeDocument.fonts
+ ? probeDocument.fonts.ready
+ : Promise.resolve();
+ await Promise.race([
+ fontReady,
+ new Promise((_, reject) => {
+ setTimeout(
+ () => reject(new Error("DOCX 主题字体等待超时")),
+ 10000
+ );
+ })
+ ]);
+ await new Promise((resolve) =>
+ probeWindow.requestAnimationFrame(() =>
+ probeWindow.requestAnimationFrame(resolve)
+ )
+ );
+ const slots = payload.slots.map((definition) => {
+ const element = probeDocument.querySelector(
+ definition.selector
+ );
+ if (!element) {
+ return {
+ slot: definition.name,
+ matched: false
+ };
+ }
+ const style = probeWindow.getComputedStyle(element);
+ const computed = {};
+ for (const property of payload.properties) {
+ computed[property] = style[property];
+ }
+ return {
+ slot: definition.name,
+ matched: true,
+ computed
+ };
+ });
+ const rootFontSize = Number.parseFloat(
+ probeWindow.getComputedStyle(
+ probeDocument.documentElement
+ ).fontSize
+ );
+ return {
+ rootFontSizePx: rootFontSize,
+ viewport: {
+ widthPx: payload.viewport.width,
+ heightPx: payload.viewport.height,
+ deviceScaleFactor: probeWindow.devicePixelRatio || 1
+ },
+ slots
+ };
+ } finally {
+ frame.remove();
+ }
+ })()`;
+}
+
+export function parseDocxThemeStyleRuntimeCapture(
+ requestInput: DocxThemeStyleCaptureRequest,
+ captureInput: unknown
+): DocxThemeStyleSnapshot {
+ const request =
+ docxThemeStyleCaptureRequestSchema.parse(requestInput);
+ const capture = runtimeCaptureSchema.parse(captureInput);
+ return docxThemeStyleSnapshotSchema.parse({
+ schemaVersion: 1,
+ themeId: request.themeId,
+ themeFingerprint: request.themeFingerprint,
+ viewport: capture.viewport,
+ rootFontSizePx: capture.rootFontSizePx,
+ slots: capture.slots
+ });
+}
diff --git a/packages/docx-theme-engine/src/snapshot-cache.ts b/packages/docx-theme-engine/src/snapshot-cache.ts
new file mode 100644
index 0000000..bce1926
--- /dev/null
+++ b/packages/docx-theme-engine/src/snapshot-cache.ts
@@ -0,0 +1,116 @@
+import type { DocxThemeStyleSnapshot } from "./snapshot.js";
+import {
+ docxThemeStyleCaptureRequestSchema,
+ type DocxThemeStyleCaptureAdapter,
+ type DocxThemeStyleCaptureRequest
+} from "./runtime.js";
+
+export interface DocxThemeStyleSnapshotCacheOptions {
+ maximumEntries?: number;
+}
+
+function waitWithSignal(
+ promise: Promise,
+ signal?: AbortSignal
+): Promise {
+ if (!signal) {
+ return promise;
+ }
+ signal.throwIfAborted();
+ return new Promise((resolve, reject) => {
+ const abort = () => reject(signal.reason);
+ signal.addEventListener("abort", abort, { once: true });
+ void promise.then(
+ (value) => {
+ signal.removeEventListener("abort", abort);
+ resolve(value);
+ },
+ (error: unknown) => {
+ signal.removeEventListener("abort", abort);
+ reject(error);
+ }
+ );
+ });
+}
+
+export class DocxThemeStyleSnapshotCache {
+ private readonly maximumEntries: number;
+ private readonly entries =
+ new Map();
+ private readonly pending =
+ new Map>();
+
+ constructor(
+ private readonly adapter: DocxThemeStyleCaptureAdapter,
+ options: DocxThemeStyleSnapshotCacheOptions = {}
+ ) {
+ this.maximumEntries = options.maximumEntries ?? 32;
+ if (
+ !Number.isInteger(this.maximumEntries) ||
+ this.maximumEntries < 1 ||
+ this.maximumEntries > 1000
+ ) {
+ throw new Error("DOCX 主题样式缓存容量必须是 1 到 1000 的整数");
+ }
+ }
+
+ capture(
+ input: DocxThemeStyleCaptureRequest,
+ signal?: AbortSignal
+ ): Promise {
+ const request =
+ docxThemeStyleCaptureRequestSchema.parse(input);
+ const key = this.createKey(request);
+ const cached = this.entries.get(key);
+ if (cached) {
+ this.entries.delete(key);
+ this.entries.set(key, cached);
+ return waitWithSignal(Promise.resolve(cached), signal);
+ }
+ let operation = this.pending.get(key);
+ if (!operation) {
+ operation = this.adapter
+ .captureThemeStyle(request)
+ .then((snapshot) => {
+ this.entries.set(key, snapshot);
+ this.trim();
+ return snapshot;
+ })
+ .finally(() => {
+ this.pending.delete(key);
+ });
+ this.pending.set(key, operation);
+ }
+ return waitWithSignal(operation, signal);
+ }
+
+ invalidate(themeId?: string) {
+ if (!themeId) {
+ this.entries.clear();
+ return;
+ }
+ for (const [key, snapshot] of this.entries) {
+ if (snapshot.themeId === themeId) {
+ this.entries.delete(key);
+ }
+ }
+ }
+
+ get size() {
+ return this.entries.size;
+ }
+
+ private createKey(request: DocxThemeStyleCaptureRequest) {
+ return `${request.themeId}:${request.themeFingerprint}`;
+ }
+
+ private trim() {
+ while (this.entries.size > this.maximumEntries) {
+ const oldest = this.entries.keys().next().value;
+ if (oldest === undefined) {
+ return;
+ }
+ this.entries.delete(oldest);
+ }
+ }
+}
diff --git a/packages/docx-theme-engine/tests/runtime.test.ts b/packages/docx-theme-engine/tests/runtime.test.ts
new file mode 100644
index 0000000..de01e6b
--- /dev/null
+++ b/packages/docx-theme-engine/tests/runtime.test.ts
@@ -0,0 +1,152 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ DOCX_STYLE_SLOT_NAMES,
+ DocxThemeStyleSnapshotCache,
+ createDocxThemeStyleCaptureScript,
+ createDocxThemeStyleFingerprint,
+ parseDocxThemeStyleRuntimeCapture,
+ type DocxComputedStyle,
+ type DocxThemeStyleCaptureRequest
+} from "../src/index.js";
+
+const request: DocxThemeStyleCaptureRequest = {
+ themeId: "external-clean",
+ themeFingerprint: "d".repeat(64),
+ themeCss: "#write { color: #111; }",
+ baseUrl: "https://example.invalid/"
+};
+
+const computed: DocxComputedStyle = {
+ fontFamily: "Arial",
+ fontSize: "16px",
+ fontWeight: "400",
+ fontStyle: "normal",
+ color: "rgb(17, 17, 17)",
+ backgroundColor: "rgba(0, 0, 0, 0)",
+ lineHeight: "normal",
+ letterSpacing: "normal",
+ textAlign: "start",
+ textIndent: "0px",
+ textDecorationLine: "none",
+ marginTop: "0px",
+ marginRight: "0px",
+ marginBottom: "0px",
+ marginLeft: "0px",
+ paddingTop: "0px",
+ paddingRight: "0px",
+ paddingBottom: "0px",
+ paddingLeft: "0px",
+ borderTop: "0px none rgb(17, 17, 17)",
+ borderRight: "0px none rgb(17, 17, 17)",
+ borderBottom: "0px none rgb(17, 17, 17)",
+ borderLeft: "0px none rgb(17, 17, 17)",
+ width: "640px",
+ maxWidth: "none",
+ breakBefore: "auto",
+ breakAfter: "auto",
+ breakInside: "auto",
+ display: "block"
+};
+
+function createRuntimeCapture() {
+ return {
+ rootFontSizePx: 16,
+ viewport: {
+ widthPx: 794,
+ heightPx: 1123,
+ deviceScaleFactor: 1
+ },
+ slots: DOCX_STYLE_SLOT_NAMES.map((slot) => ({
+ slot,
+ matched: true,
+ computed
+ }))
+ };
+}
+
+describe("DOCX 主题样式浏览器运行时", () => {
+ it("脚本只通过序列化载荷注入主题 CSS", () => {
+ const script = createDocxThemeStyleCaptureScript({
+ ...request,
+ themeCss: '#write::before { content: ""; }'
+ });
+ expect(script).toContain("theme.textContent");
+ expect(script).toContain("frame.remove()");
+ expect(script).not.toContain("");
+ });
+
+ it("将浏览器采集结果绑定到主题身份", () => {
+ const snapshot = parseDocxThemeStyleRuntimeCapture(
+ request,
+ createRuntimeCapture()
+ );
+ expect(snapshot.themeId).toBe("external-clean");
+ expect(snapshot.slots).toHaveLength(
+ DOCX_STYLE_SLOT_NAMES.length
+ );
+ });
+
+ it("同一主题内容生成稳定 SHA-256 指纹", async () => {
+ const first = await createDocxThemeStyleFingerprint(
+ request.themeId,
+ request.themeCss
+ );
+ const second = await createDocxThemeStyleFingerprint(
+ request.themeId,
+ request.themeCss
+ );
+ expect(first).toBe(second);
+ expect(first).toMatch(/^[a-f0-9]{64}$/u);
+ expect(
+ await createDocxThemeStyleFingerprint(
+ request.themeId,
+ `${request.themeCss}\n`
+ )
+ ).not.toBe(first);
+ });
+});
+
+describe("DOCX 主题样式快照缓存", () => {
+ it("合并并发采集并按指纹复用结果", async () => {
+ const adapter = {
+ captureThemeStyle: vi.fn(async (input) =>
+ parseDocxThemeStyleRuntimeCapture(
+ input,
+ createRuntimeCapture()
+ )
+ )
+ };
+ const cache = new DocxThemeStyleSnapshotCache(adapter);
+ const [first, second] = await Promise.all([
+ cache.capture(request),
+ cache.capture(request)
+ ]);
+ expect(first).toBe(second);
+ expect(adapter.captureThemeStyle).toHaveBeenCalledTimes(1);
+ expect((await cache.capture(request))).toBe(first);
+ expect(adapter.captureThemeStyle).toHaveBeenCalledTimes(1);
+ });
+
+ it("按最近使用顺序限制缓存容量", async () => {
+ const adapter = {
+ captureThemeStyle: vi.fn(async (input) =>
+ parseDocxThemeStyleRuntimeCapture(
+ input,
+ createRuntimeCapture()
+ )
+ )
+ };
+ const cache = new DocxThemeStyleSnapshotCache(adapter, {
+ maximumEntries: 1
+ });
+ await cache.capture(request);
+ await cache.capture({
+ ...request,
+ themeId: "external-dark",
+ themeFingerprint: "e".repeat(64)
+ });
+ expect(cache.size).toBe(1);
+ await cache.capture(request);
+ expect(adapter.captureThemeStyle).toHaveBeenCalledTimes(3);
+ });
+});