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);
});
});
+8
View File
@@ -101,6 +101,14 @@ WOFF2 可用,生产 DOCX 含 5 个字体部件。当前镜像大小为 711,082
字节;Desktop 主安装器为 140,279,447 字节,独立字体安装器为
24,488,770 字节,字体 ZIP 为 29,186,293 字节。
`v0.6.0` 发布前 Desktop DOCX 媒体捕获超时修复已完成。共享图片等待
逻辑消除了 `decode()` 与加载事件之间的竞态,并增加有界超时;隐藏窗口
中的媒体渲染使用定时器驱动帧调度,Electron 捕获阶段临时激活合成表面,
同时为布局与单媒体截图提供可诊断的独立超时。Windows 目录版使用默认
10 页示例完成真实导出,12 个媒体全部生成 PNG,最终 DOCX 为 8,937,824
字节、包含 12 个媒体部件和 5 个字体部件;单元测试、类型检查、生产构建
`git diff --check` 均通过。
`v0.6.0` 阶段 3 已完成 DOCX 共享协议:`packages/core` 统一定义请求
Schema、资源、Pandoc capability、错误码、结果、诊断、耗时、MIME 和
安全文件名;`packages/application` 新增 `prepareDocxExport()`,复用
@@ -74,24 +74,55 @@ export interface PreviewEngineStyles {
echartsCss: string;
}
function waitForImages(container: ParentNode) {
const images = Array.from(container.querySelectorAll("img"));
return Promise.all(
images.map(async (image) => {
if (image.complete) {
export const DEFAULT_IMAGE_READY_TIMEOUT_MS = 10_000;
function waitForImage(
image: HTMLImageElement,
timeoutMs: number
) {
if (image.complete) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
image.removeEventListener("load", finish);
image.removeEventListener("error", finish);
resolve();
};
const timeout = window.setTimeout(finish, timeoutMs);
try {
await image.decode();
} catch {
await new Promise<void>((resolve) => {
image.addEventListener("load", () => resolve(), { once: true });
image.addEventListener("error", () => resolve(), { once: true });
});
// 先监听事件,再检查 complete 和调用 decode,避免 Electron 中图片
// 已经失败后才注册 error 监听器而永久等待。
image.addEventListener("load", finish, { once: true });
image.addEventListener("error", finish, { once: true });
if (image.complete) {
finish();
return;
}
void image.decode().then(finish, () => {
// decode 失败时仍允许尚未到达的 load/error 事件完成等待;若事件
// 已经发生,complete 会立即结束。最终由有界超时兜底。
if (image.complete) {
finish();
}
})
);
});
});
}
export function waitForImages(
container: ParentNode,
timeoutMs = DEFAULT_IMAGE_READY_TIMEOUT_MS
) {
const images = Array.from(container.querySelectorAll("img"));
return Promise.all(images.map((image) => waitForImage(image, timeoutMs)));
}
function stylesheet(
@@ -0,0 +1,60 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import { waitForImages } from "../src/index.js";
function createPendingImage() {
const image = document.createElement("img");
Object.defineProperty(image, "complete", {
configurable: true,
get: () => false
});
return image;
}
describe("文档图片就绪等待", () => {
it("图片正常加载后立即完成", async () => {
const image = createPendingImage();
image.decode = vi.fn(() => new Promise(() => undefined));
document.body.replaceChildren(image);
const waiting = waitForImages(document, 1_000);
image.dispatchEvent(new Event("load"));
await expect(waiting).resolves.toEqual([undefined]);
});
it("decode 拒绝且图片已经失败时不会错过 error 状态", async () => {
let complete = false;
const image = document.createElement("img");
Object.defineProperty(image, "complete", {
configurable: true,
get: () => complete
});
image.decode = vi.fn(async () => {
complete = true;
throw new Error("decode failed");
});
document.body.replaceChildren(image);
await expect(waitForImages(document, 1_000)).resolves.toEqual([
undefined
]);
});
it("图片永不响应时由局部超时结束等待", async () => {
vi.useFakeTimers();
try {
const image = createPendingImage();
image.decode = vi.fn(() => new Promise(() => undefined));
document.body.replaceChildren(image);
const waiting = waitForImages(document, 25);
await vi.advanceTimersByTimeAsync(25);
await expect(waiting).resolves.toEqual([undefined]);
} finally {
vi.useRealTimers();
}
});
});