fix: 修复桌面 DOCX 媒体捕获超时
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 个媒体(echarts)PNG 捕获超过 20000ms"
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
).toMatchObject({
|
||||
message:
|
||||
"DOCX 导出失败:桌面 DOCX 第 2/7 个媒体(echarts)PNG 捕获超过 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 个媒体(image)PNG 捕获超过 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user