fix: 修复桌面 DOCX 媒体捕获超时

This commit is contained in:
SkyJourney
2026-08-02 13:50:25 +08:00
parent 14f470454c
commit 5c82b3df7f
12 changed files with 417 additions and 47 deletions
+14 -1
View File
@@ -31,6 +31,18 @@ export type DesktopDocxExportOutcome =
export type DesktopDocxCapability = DocxCapability;
function readSafeDesktopMediaDetail(error: DocxExportServiceError) {
const cause = error.cause;
if (!(cause instanceof Error)) {
return undefined;
}
return /^ DOCX (?: \d+ms| \d+\/\d+ (?:image|mermaid|echarts)PNG \d+ms)$/u.test(
cause.message
)
? cause.message
: undefined;
}
export function toDesktopDocxExportFailure(
error: unknown
): DesktopDocxExportFailure {
@@ -54,10 +66,11 @@ export function toDesktopDocxExportFailure(
};
}
if (error instanceof DocxExportServiceError) {
const detail = readSafeDesktopMediaDetail(error);
return {
ok: false,
error: error.code,
message: error.message,
message: detail ? `${error.message}${detail}` : error.message,
retryable: error.retryable
};
}
+70 -17
View File
@@ -9,6 +9,51 @@ interface CaptureScreenshotResult {
data: string;
}
export interface ElectronDocxMediaCaptureTimeouts {
renderMs: number;
screenshotMs: number;
}
const DEFAULT_CAPTURE_TIMEOUTS: ElectronDocxMediaCaptureTimeouts = {
renderMs: 30_000,
screenshotMs: 20_000
};
function withTimeout<T>(
operation: Promise<T>,
timeoutMs: number,
message: string,
signal?: AbortSignal
) {
return new Promise<T>((resolve, reject) => {
let settled = false;
const finish = (callback: () => void) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
signal?.removeEventListener("abort", abort);
callback();
};
const abort = () =>
finish(() => reject(signal?.reason ?? new Error("操作已取消")));
const timeout = setTimeout(
() => finish(() => reject(new Error(message))),
timeoutMs
);
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) {
abort();
return;
}
operation.then(
(value) => finish(() => resolve(value)),
(error) => finish(() => reject(error))
);
});
}
function createRenderScript(request: DocxMediaCaptureRequest) {
const serialized = JSON.stringify(request);
return `(async () => {
@@ -24,12 +69,15 @@ function createRenderScript(request: DocxMediaCaptureRequest) {
export async function captureDocxMediaWithElectronWebContents(
webContents: WebContents,
request: DocxMediaCaptureRequest,
signal?: AbortSignal
signal?: AbortSignal,
timeouts: ElectronDocxMediaCaptureTimeouts = DEFAULT_CAPTURE_TIMEOUTS
) {
signal?.throwIfAborted();
const plan = (await webContents.executeJavaScript(
createRenderScript(request),
true
const plan = (await withTimeout(
webContents.executeJavaScript(createRenderScript(request), true),
timeouts.renderMs,
`桌面 DOCX 媒体布局超过 ${timeouts.renderMs}ms`,
signal
)) as DocxMediaCapturePlan;
signal?.throwIfAborted();
const ownsDebugger = !webContents.debugger.isAttached();
@@ -41,20 +89,25 @@ export async function captureDocxMediaWithElectronWebContents(
const captures = [];
for (const target of plan.targets) {
signal?.throwIfAborted();
const result = (await webContents.debugger.sendCommand(
"Page.captureScreenshot",
{
format: "png",
fromSurface: true,
captureBeyondViewport: true,
clip: {
x: target.captureX,
y: target.captureY,
width: target.captureWidthPx,
height: target.captureHeightPx,
scale: target.rasterScale
const result = (await withTimeout(
webContents.debugger.sendCommand(
"Page.captureScreenshot",
{
format: "png",
fromSurface: true,
captureBeyondViewport: true,
clip: {
x: target.captureX,
y: target.captureY,
width: target.captureWidthPx,
height: target.captureHeightPx,
scale: target.rasterScale
}
}
}
),
timeouts.screenshotMs,
`桌面 DOCX 第 ${target.ordinal}/${plan.targets.length} 个媒体(${target.kind}PNG 捕获超过 ${timeouts.screenshotMs}ms`,
signal
)) as CaptureScreenshotResult;
captures.push({
id: target.id,
+27 -4
View File
@@ -18,6 +18,25 @@ export interface ElectronDocxMediaEngineOptions {
renderUrl: string;
}
export async function withActiveDocxCaptureSurface<T>(
window: Pick<
BrowserWindow,
"showInactive" | "hide" | "isDestroyed"
>,
operation: () => Promise<T>,
settleMs = 50
) {
window.showInactive();
await new Promise((resolve) => setTimeout(resolve, settleMs));
try {
return await operation();
} finally {
if (!window.isDestroyed()) {
window.hide();
}
}
}
export class ElectronDocxMediaEngine
implements DocxMediaCaptureAdapter, DocxThemeStyleCaptureAdapter
{
@@ -98,10 +117,14 @@ export class ElectronDocxMediaEngine
};
signal?.addEventListener("abort", abort, { once: true });
try {
return await captureDocxMediaWithElectronWebContents(
window.webContents,
request,
signal
return await withActiveDocxCaptureSurface(
window,
() =>
captureDocxMediaWithElectronWebContents(
window.webContents,
request,
signal
)
);
} finally {
signal?.removeEventListener("abort", abort);
+34
View File
@@ -41,6 +41,40 @@ describe("Desktop DOCX IPC 契约", () => {
});
});
it("仅透传格式受控的桌面媒体超时详情", () => {
expect(
toDesktopDocxExportFailure(
new DocxExportServiceError(
500,
"DOCX_GENERATION_FAILED",
"DOCX 导出失败",
false,
{ cause: new Error("桌面 DOCX 媒体布局超过 30000ms") }
)
)
).toMatchObject({
message: "DOCX 导出失败:桌面 DOCX 媒体布局超过 30000ms"
});
expect(
toDesktopDocxExportFailure(
new DocxExportServiceError(
500,
"DOCX_GENERATION_FAILED",
"DOCX 导出失败",
false,
{
cause: new Error(
"桌面 DOCX 第 2/7 个媒体(echartsPNG 捕获超过 20000ms"
)
}
)
)
).toMatchObject({
message:
"DOCX 导出失败:桌面 DOCX 第 2/7 个媒体(echartsPNG 捕获超过 20000ms"
});
});
it("保留 Front Matter 解析错误", () => {
expect(
toDesktopDocxExportFailure(
@@ -104,4 +104,52 @@ describe("Electron DOCX 媒体捕获", () => {
);
expect(detach).toHaveBeenCalledOnce();
});
it("媒体布局永久不返回时报告局部超时", async () => {
const webContents = {
executeJavaScript: vi.fn(() => new Promise(() => undefined)),
debugger: {
isAttached: vi.fn(() => false)
}
} as unknown as WebContents;
await expect(
captureDocxMediaWithElectronWebContents(
webContents,
request,
undefined,
{ renderMs: 10, screenshotMs: 10 }
)
).rejects.toThrow("桌面 DOCX 媒体布局超过 10ms");
});
it("单个 PNG 永久不返回时报告媒体编号和类型", async () => {
let attached = false;
const detach = vi.fn(() => {
attached = false;
});
const webContents = {
executeJavaScript: vi.fn(async () => plan),
debugger: {
isAttached: vi.fn(() => attached),
attach: vi.fn(() => {
attached = true;
}),
detach,
sendCommand: vi.fn(() => new Promise(() => undefined))
}
} as unknown as WebContents;
await expect(
captureDocxMediaWithElectronWebContents(
webContents,
request,
undefined,
{ renderMs: 10, screenshotMs: 10 }
)
).rejects.toThrow(
"桌面 DOCX 第 1/1 个媒体(imagePNG 捕获超过 10ms"
);
expect(detach).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { withActiveDocxCaptureSurface } from "../src/electron-docx-media-engine.js";
function createWindow() {
let destroyed = false;
return {
showInactive: vi.fn(),
hide: vi.fn(),
isDestroyed: vi.fn(() => destroyed),
destroy: () => {
destroyed = true;
}
};
}
describe("Electron DOCX 捕获合成表面", () => {
it("截图期间无焦点显示窗口并在完成后隐藏", async () => {
const window = createWindow();
const operation = vi.fn(async () => "captured");
await expect(
withActiveDocxCaptureSurface(window, operation, 0)
).resolves.toBe("captured");
expect(window.showInactive).toHaveBeenCalledOnce();
expect(operation).toHaveBeenCalledOnce();
expect(window.hide).toHaveBeenCalledOnce();
});
it("窗口在失败期间销毁时不再隐藏", async () => {
const window = createWindow();
await expect(
withActiveDocxCaptureSurface(
window,
async () => {
window.destroy();
throw new Error("capture failed");
},
0
)
).rejects.toThrow("capture failed");
expect(window.showInactive).toHaveBeenCalledOnce();
expect(window.hide).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,16 @@
export async function withHiddenWindowAnimationFrames<T>(
windowRef: Window,
operation: () => Promise<T>
) {
const requestAnimationFrame = windowRef.requestAnimationFrame;
windowRef.requestAnimationFrame = (callback) =>
windowRef.setTimeout(
() => callback(windowRef.performance.now()),
0
);
try {
return await operation();
} finally {
windowRef.requestAnimationFrame = requestAnimationFrame;
}
}
+12 -11
View File
@@ -15,6 +15,7 @@ import {
beginPreviewFrameRender,
completePreviewFrameRender
} from "./preview-frame-visibility";
import { withHiddenWindowAnimationFrames } from "./hidden-window-animation-frame";
function getPreviewRoot() {
const element = document.querySelector<HTMLElement>("#preview-root");
@@ -139,22 +140,24 @@ window.__mdToPdfRender = async (
return result;
};
window.__mdToPdfRenderDocxMedia = (payload, dimensions) =>
renderDocxMediaCapturePlan(
runtime,
previewRoot,
payload,
dimensions
withHiddenWindowAnimationFrames(window, () =>
renderDocxMediaCapturePlan(
runtime,
previewRoot,
payload,
dimensions
)
);
document.documentElement.dataset.runtimeReady = "true";
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
if (desktopPdfBridge) {
desktopPdfBridge.onRender(async (requestId, payload) => {
const requestAnimationFrame = window.requestAnimationFrame;
window.requestAnimationFrame = (callback) =>
window.setTimeout(() => callback(performance.now()), 0);
try {
const result = await window.__mdToPdfRender?.(payload, "pdf");
const result = await withHiddenWindowAnimationFrames(
window,
async () => window.__mdToPdfRender?.(payload, "pdf")
);
if (!result) {
throw new Error("PDF 分页运行时不可用");
}
@@ -164,8 +167,6 @@ if (desktopPdfBridge) {
requestId,
reason instanceof Error ? reason.message : "PDF 分页失败"
);
} finally {
window.requestAnimationFrame = requestAnimationFrame;
}
});
desktopPdfBridge.ready();
@@ -0,0 +1,36 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import { withHiddenWindowAnimationFrames } from "../src/hidden-window-animation-frame";
describe("隐藏窗口动画帧调度", () => {
it("使用定时器驱动动画帧并在成功后恢复", async () => {
const original = window.requestAnimationFrame;
const callback = vi.fn();
await withHiddenWindowAnimationFrames(window, async () => {
expect(window.requestAnimationFrame).not.toBe(original);
await new Promise<void>((resolve) => {
window.requestAnimationFrame((timestamp) => {
callback(timestamp);
resolve();
});
});
});
expect(callback).toHaveBeenCalledOnce();
expect(window.requestAnimationFrame).toBe(original);
});
it("操作失败后仍恢复原动画帧函数", async () => {
const original = window.requestAnimationFrame;
await expect(
withHiddenWindowAnimationFrames(window, async () => {
throw new Error("render failed");
})
).rejects.toThrow("render failed");
expect(window.requestAnimationFrame).toBe(original);
});
});