From d2acc9c5ce1bd447db26ed9e56780d5341a70f3f Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Thu, 30 Jul 2026 22:09:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20DOCX=20=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E6=A0=B7=E5=BC=8F=E9=87=87=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/package.json | 4 +- .../scripts/verify-docx-theme-styles.cjs | 8 + .../scripts/verify-docx-theme-styles.mjs | 191 +++++++++++++++ .../desktop/src/electron-docx-media-engine.ts | 54 ++++- apps/desktop/src/electron-docx-theme-style.ts | 45 ++++ .../tests/electron-docx-theme-style.test.ts | 97 ++++++++ apps/server/package.json | 4 +- .../scripts/verify-docx-theme-styles.mjs | 174 ++++++++++++++ apps/server/src/docx-media-engine.ts | 59 ++++- .../server/src/playwright-docx-theme-style.ts | 21 ++ .../tests/playwright-docx-theme-style.test.ts | 80 +++++++ docs/PROGRESS.md | 15 +- package-lock.json | 2 + package.json | 1 + packages/docx-theme-engine/README.md | 10 +- packages/docx-theme-engine/src/fingerprint.ts | 15 ++ packages/docx-theme-engine/src/index.ts | 3 + packages/docx-theme-engine/src/runtime.ts | 225 ++++++++++++++++++ .../docx-theme-engine/src/snapshot-cache.ts | 116 +++++++++ .../docx-theme-engine/tests/runtime.test.ts | 152 ++++++++++++ 20 files changed, 1265 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/scripts/verify-docx-theme-styles.cjs create mode 100644 apps/desktop/scripts/verify-docx-theme-styles.mjs create mode 100644 apps/desktop/src/electron-docx-theme-style.ts create mode 100644 apps/desktop/tests/electron-docx-theme-style.test.ts create mode 100644 apps/server/scripts/verify-docx-theme-styles.mjs create mode 100644 apps/server/src/playwright-docx-theme-style.ts create mode 100644 apps/server/tests/playwright-docx-theme-style.test.ts create mode 100644 packages/docx-theme-engine/src/fingerprint.ts create mode 100644 packages/docx-theme-engine/src/runtime.ts create mode 100644 packages/docx-theme-engine/src/snapshot-cache.ts create mode 100644 packages/docx-theme-engine/tests/runtime.test.ts 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); + }); +});