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",
|
||||
"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",
|
||||
|
||||
@@ -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,
|
||||
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<BrowserWindow> | 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) => {
|
||||
|
||||
@@ -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",
|
||||
"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"
|
||||
|
||||
@@ -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,
|
||||
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<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() {
|
||||
this.closed = true;
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user