diff --git a/apps/desktop/src/docx-contract.ts b/apps/desktop/src/docx-contract.ts index 7f8aad0..cff84b4 100644 --- a/apps/desktop/src/docx-contract.ts +++ b/apps/desktop/src/docx-contract.ts @@ -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 }; } diff --git a/apps/desktop/src/electron-docx-media-capture.ts b/apps/desktop/src/electron-docx-media-capture.ts index bb2ffb1..69df713 100644 --- a/apps/desktop/src/electron-docx-media-capture.ts +++ b/apps/desktop/src/electron-docx-media-capture.ts @@ -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( + operation: Promise, + timeoutMs: number, + message: string, + signal?: AbortSignal +) { + return new Promise((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, diff --git a/apps/desktop/src/electron-docx-media-engine.ts b/apps/desktop/src/electron-docx-media-engine.ts index 4b02b33..d523ea9 100644 --- a/apps/desktop/src/electron-docx-media-engine.ts +++ b/apps/desktop/src/electron-docx-media-engine.ts @@ -18,6 +18,25 @@ export interface ElectronDocxMediaEngineOptions { renderUrl: string; } +export async function withActiveDocxCaptureSurface( + window: Pick< + BrowserWindow, + "showInactive" | "hide" | "isDestroyed" + >, + operation: () => Promise, + 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); diff --git a/apps/desktop/tests/docx-contract.test.ts b/apps/desktop/tests/docx-contract.test.ts index 5575c57..0164ee9 100644 --- a/apps/desktop/tests/docx-contract.test.ts +++ b/apps/desktop/tests/docx-contract.test.ts @@ -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( diff --git a/apps/desktop/tests/electron-docx-media-capture.test.ts b/apps/desktop/tests/electron-docx-media-capture.test.ts index c66ef8e..fa35032 100644 --- a/apps/desktop/tests/electron-docx-media-capture.test.ts +++ b/apps/desktop/tests/electron-docx-media-capture.test.ts @@ -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(); + }); }); diff --git a/apps/desktop/tests/electron-docx-media-engine.test.ts b/apps/desktop/tests/electron-docx-media-engine.test.ts new file mode 100644 index 0000000..b79bca0 --- /dev/null +++ b/apps/desktop/tests/electron-docx-media-engine.test.ts @@ -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(); + }); +}); diff --git a/apps/web/src/hidden-window-animation-frame.ts b/apps/web/src/hidden-window-animation-frame.ts new file mode 100644 index 0000000..e70c61f --- /dev/null +++ b/apps/web/src/hidden-window-animation-frame.ts @@ -0,0 +1,16 @@ +export async function withHiddenWindowAnimationFrames( + windowRef: Window, + operation: () => Promise +) { + const requestAnimationFrame = windowRef.requestAnimationFrame; + windowRef.requestAnimationFrame = (callback) => + windowRef.setTimeout( + () => callback(windowRef.performance.now()), + 0 + ); + try { + return await operation(); + } finally { + windowRef.requestAnimationFrame = requestAnimationFrame; + } +} diff --git a/apps/web/src/paged-preview-frame.ts b/apps/web/src/paged-preview-frame.ts index b73c331..e85a72e 100644 --- a/apps/web/src/paged-preview-frame.ts +++ b/apps/web/src/paged-preview-frame.ts @@ -15,6 +15,7 @@ import { beginPreviewFrameRender, completePreviewFrameRender } from "./preview-frame-visibility"; +import { withHiddenWindowAnimationFrames } from "./hidden-window-animation-frame"; function getPreviewRoot() { const element = document.querySelector("#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(); diff --git a/apps/web/tests/hidden-window-animation-frame.test.ts b/apps/web/tests/hidden-window-animation-frame.test.ts new file mode 100644 index 0000000..9980fd5 --- /dev/null +++ b/apps/web/tests/hidden-window-animation-frame.test.ts @@ -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((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); + }); +}); diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 79c85ed..e056160 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -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()`,复用 diff --git a/packages/preview-engine/src/paged-document-runtime.ts b/packages/preview-engine/src/paged-document-runtime.ts index 6f24c91..5aeb077 100644 --- a/packages/preview-engine/src/paged-document-runtime.ts +++ b/packages/preview-engine/src/paged-document-runtime.ts @@ -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((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((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( diff --git a/packages/preview-engine/tests/image-ready.test.ts b/packages/preview-engine/tests/image-ready.test.ts new file mode 100644 index 0000000..bfd419a --- /dev/null +++ b/packages/preview-engine/tests/image-ready.test.ts @@ -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(); + } + }); +});