feat: 实现 DOCX 主题样式采集
This commit is contained in:
@@ -17,11 +17,13 @@
|
|||||||
"package": "npm run build:embedded-web && npm run build && electron-builder --dir --config electron-builder.config.cjs --x64",
|
"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",
|
"make": "npm run build:embedded-web && npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64",
|
||||||
"test": "vitest run",
|
"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": {
|
"devDependencies": {
|
||||||
"@md-to-pdf/application": "0.4.1",
|
"@md-to-pdf/application": "0.4.1",
|
||||||
"@md-to-pdf/docx-engine": "0.1.0",
|
"@md-to-pdf/docx-engine": "0.1.0",
|
||||||
|
"@md-to-pdf/docx-theme-engine": "0.1.0",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"electron": "43.2.0",
|
"electron": "43.2.0",
|
||||||
"electron-builder": "26.15.3",
|
"electron-builder": "26.15.3",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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("<!doctype html><html><body></body></html>");
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -3,7 +3,12 @@ import type {
|
|||||||
DocxMediaCaptureAdapter,
|
DocxMediaCaptureAdapter,
|
||||||
DocxMediaCaptureRequest
|
DocxMediaCaptureRequest
|
||||||
} from "@md-to-pdf/application";
|
} 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 { captureDocxMediaWithElectronWebContents } from "./electron-docx-media-capture.js";
|
||||||
|
import { captureDocxThemeStyleWithElectronWebContents } from "./electron-docx-theme-style.js";
|
||||||
import { isAllowedPdfRuntimeUrl } from "./pdf-contract.js";
|
import { isAllowedPdfRuntimeUrl } from "./pdf-contract.js";
|
||||||
|
|
||||||
export const DESKTOP_DOCX_PARTITION =
|
export const DESKTOP_DOCX_PARTITION =
|
||||||
@@ -14,7 +19,7 @@ export interface ElectronDocxMediaEngineOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ElectronDocxMediaEngine
|
export class ElectronDocxMediaEngine
|
||||||
implements DocxMediaCaptureAdapter
|
implements DocxMediaCaptureAdapter, DocxThemeStyleCaptureAdapter
|
||||||
{
|
{
|
||||||
private readonly renderUrl: string;
|
private readonly renderUrl: string;
|
||||||
private windowPromise: Promise<BrowserWindow> | undefined;
|
private windowPromise: Promise<BrowserWindow> | undefined;
|
||||||
@@ -42,6 +47,23 @@ export class ElectronDocxMediaEngine
|
|||||||
return operation;
|
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() {
|
async close() {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
await this.queue;
|
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() {
|
private getWindow() {
|
||||||
if (!this.windowPromise) {
|
if (!this.windowPromise) {
|
||||||
this.windowPromise = this.createWindow().catch((error) => {
|
this.windowPromise = this.createWindow().catch((error) => {
|
||||||
|
|||||||
@@ -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<DocxThemeStyleSnapshot> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,12 +9,14 @@
|
|||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
|
"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": {
|
"dependencies": {
|
||||||
"@md-to-pdf/application": "0.4.1",
|
"@md-to-pdf/application": "0.4.1",
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
"@md-to-pdf/docx-engine": "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",
|
"@md-to-pdf/renderer": "0.1.0",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
"playwright": "1.62.0"
|
"playwright": "1.62.0"
|
||||||
|
|||||||
@@ -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("<!doctype html><html><body></body></html>");
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -3,11 +3,17 @@ import type {
|
|||||||
DocxMediaCaptureAdapter,
|
DocxMediaCaptureAdapter,
|
||||||
DocxMediaCaptureRequest
|
DocxMediaCaptureRequest
|
||||||
} from "@md-to-pdf/application";
|
} 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 { captureDocxMediaWithPlaywrightPage } from "./playwright-docx-media-capture.js";
|
||||||
|
import { captureDocxThemeStyleWithPlaywrightPage } from "./playwright-docx-theme-style.js";
|
||||||
import { isAllowedPdfRequestUrl } from "./pdf-engine.js";
|
import { isAllowedPdfRequestUrl } from "./pdf-engine.js";
|
||||||
|
|
||||||
export interface ServerDocxMediaCaptureAdapter
|
export interface ServerDocxMediaCaptureAdapter
|
||||||
extends DocxMediaCaptureAdapter {
|
extends DocxMediaCaptureAdapter,
|
||||||
|
DocxThemeStyleCaptureAdapter {
|
||||||
warmup?(): Promise<void>;
|
warmup?(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -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() {
|
async close() {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
const browserPromise = this.browserPromise;
|
const browserPromise = this.browserPromise;
|
||||||
|
|||||||
@@ -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<DocxThemeStyleSnapshot> {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
await page.emulateMedia({ media: "print" });
|
||||||
|
const capture = await page.evaluate(
|
||||||
|
createDocxThemeStyleCaptureScript(request)
|
||||||
|
);
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
return parseDocxThemeStyleRuntimeCapture(request, capture);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+12
-3
@@ -160,8 +160,16 @@ DOCX 映射。
|
|||||||
扁平样式覆盖保持兼容。标准探针 DOM 已覆盖普通文档、公文、简报、项目
|
扁平样式覆盖保持兼容。标准探针 DOM 已覆盖普通文档、公文、简报、项目
|
||||||
报告和标书,Profile 专属槽位避免同名类在不同结构上下文中互相污染;
|
报告和标书,Profile 专属槽位避免同名类在不同结构上下文中互相污染;
|
||||||
平台无关采集器可以按稳定顺序读取全部 `getComputedStyle()` 并保留未
|
平台无关采集器可以按稳定顺序读取全部 `getComputedStyle()` 并保留未
|
||||||
命中状态。当前尚未实现 Playwright/Electron 页面适配器,也未将快照
|
命中状态。Server Playwright 与 Desktop Electron 适配器已经复用现有
|
||||||
归一化为 Word 令牌或接入 Pandoc 转换链路。
|
受限浏览器和隐藏窗口,通过临时同源 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. 已完成
|
## 2. 已完成
|
||||||
|
|
||||||
@@ -1089,7 +1097,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
映射门禁未通过;
|
映射门禁未通过;
|
||||||
- 阶段 11:已建立独立 `packages/docx-theme-engine`、标准语义槽位及
|
- 阶段 11:已建立独立 `packages/docx-theme-engine`、标准语义槽位及
|
||||||
快照、令牌、来源、置信度、诊断协议、标准探针 DOM 和平台无关采集器;
|
快照、令牌、来源、置信度、诊断协议、标准探针 DOM 和平台无关采集器;
|
||||||
下一步实现 Playwright/Electron 计算样式页面适配;
|
Playwright/Electron 真实采集、主题指纹缓存和双引擎一致性矩阵已通过;
|
||||||
|
下一步实现 CSS 计算值到 Word 样式令牌的归一化;
|
||||||
- 阶段 12:建立统一语义文档模型,完成 Front Matter、封面、分节、
|
- 阶段 12:建立统一语义文档模型,完成 Front Matter、封面、分节、
|
||||||
表格宽度和分页控制;
|
表格宽度和分页控制;
|
||||||
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
|
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
|
||||||
|
|||||||
Generated
+2
@@ -26,6 +26,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@md-to-pdf/application": "0.4.1",
|
"@md-to-pdf/application": "0.4.1",
|
||||||
"@md-to-pdf/docx-engine": "0.1.0",
|
"@md-to-pdf/docx-engine": "0.1.0",
|
||||||
|
"@md-to-pdf/docx-theme-engine": "0.1.0",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"electron": "43.2.0",
|
"electron": "43.2.0",
|
||||||
"electron-builder": "26.15.3",
|
"electron-builder": "26.15.3",
|
||||||
@@ -526,6 +527,7 @@
|
|||||||
"@md-to-pdf/application": "0.4.1",
|
"@md-to-pdf/application": "0.4.1",
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
"@md-to-pdf/docx-engine": "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",
|
"@md-to-pdf/renderer": "0.1.0",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
"playwright": "1.62.0"
|
"playwright": "1.62.0"
|
||||||
|
|||||||
@@ -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-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-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-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-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",
|
"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",
|
"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",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ DOCX 主题映射引擎负责在浏览器主题 CSS 与 Word 样式之间建立
|
|||||||
- 覆盖普通文档、公文、简报、项目报告和标书的标准探针 DOM;
|
- 覆盖普通文档、公文、简报、项目报告和标书的标准探针 DOM;
|
||||||
- Chromium/Electron 计算样式快照协议;
|
- Chromium/Electron 计算样式快照协议;
|
||||||
- 不绑定具体浏览器实现的计算样式采集器;
|
- 不绑定具体浏览器实现的计算样式采集器;
|
||||||
|
- 可安全注入主题 CSS、等待字体并自动清理 iframe 的浏览器运行脚本;
|
||||||
|
- 基于主题 CSS SHA-256 指纹的并发合并和 LRU 快照缓存;
|
||||||
- Word 目标样式令牌;
|
- Word 目标样式令牌;
|
||||||
- 自动映射、显式覆盖和预设降级配置;
|
- 自动映射、显式覆盖和预设降级配置;
|
||||||
- 样式来源、映射置信度和诊断协议;
|
- 样式来源、映射置信度和诊断协议;
|
||||||
@@ -22,7 +24,7 @@ DOCX 主题映射引擎负责在浏览器主题 CSS 与 Word 样式之间建立
|
|||||||
- 直接修改 OOXML;
|
- 直接修改 OOXML;
|
||||||
- 按主题 ID 维护专属转换分支。
|
- 按主题 ID 维护专属转换分支。
|
||||||
|
|
||||||
后续 Chromium 与 Electron 适配器只负责挂载探针、应用主题 CSS,并将
|
Server Playwright 与 Desktop Electron 适配器负责在现有受限浏览器生命周期
|
||||||
平台的 `getComputedStyle()` 接入本包采集器。本包负责将快照归一化为
|
中执行本包脚本;主题 CSS 和字体资源继续使用各平台已有的同源资源协议。
|
||||||
DOCX 样式令牌,`@md-to-pdf/docx-engine` 再消费令牌生成
|
本包下一步负责将快照归一化为 DOCX 样式令牌,
|
||||||
`reference.docx` 和最终 OOXML。
|
`@md-to-pdf/docx-engine` 再消费令牌生成 `reference.docx` 和最终 OOXML。
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export async function createDocxThemeStyleFingerprint(
|
||||||
|
themeId: string,
|
||||||
|
themeCss: string
|
||||||
|
): Promise<string> {
|
||||||
|
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("");
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
export * from "./collector.js";
|
export * from "./collector.js";
|
||||||
export * from "./configuration.js";
|
export * from "./configuration.js";
|
||||||
|
export * from "./fingerprint.js";
|
||||||
export * from "./probe.js";
|
export * from "./probe.js";
|
||||||
|
export * from "./runtime.js";
|
||||||
|
export * from "./snapshot-cache.js";
|
||||||
export * from "./slots.js";
|
export * from "./slots.js";
|
||||||
export * from "./snapshot.js";
|
export * from "./snapshot.js";
|
||||||
export * from "./tokens.js";
|
export * from "./tokens.js";
|
||||||
|
|||||||
@@ -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<DocxThemeStyleSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
"<!doctype html><html><head></head><body></body></html>"
|
||||||
|
);
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<T> {
|
||||||
|
if (!signal) {
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
signal.throwIfAborted();
|
||||||
|
return new Promise<T>((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<string, DocxThemeStyleSnapshot>();
|
||||||
|
private readonly pending =
|
||||||
|
new Map<string, Promise<DocxThemeStyleSnapshot>>();
|
||||||
|
|
||||||
|
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<DocxThemeStyleSnapshot> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: "</style><script>x</script>"; }'
|
||||||
|
});
|
||||||
|
expect(script).toContain("theme.textContent");
|
||||||
|
expect(script).toContain("frame.remove()");
|
||||||
|
expect(script).not.toContain("</style><script>x</script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user