fix: recognize PPTX images and release idle OCR memory

Image-only PPTX attachments failed with no indexable text even when a local
OCR model was configured. PPTX parsing now recognizes embedded PNG, JPEG
and WebP images through the existing local OCR path, preserves native text
and slide locators, and honors the selected parsing workflow.

OCR workers previously retained model sessions and WASM memory indefinitely
after successful parsing. Release the worker after 60 seconds with no active
or queued requests, reuse it for continuous work, and reload on demand.
Fast text/index modes still skip OCR. This does not render Office layouts,
recognize inherited master images, or change Agent execution permissions.

Validated four real PPTX attachments (40 pages), idle memory release and a
nine-page reimport after model reload on Windows x64 with PP-OCRv6 Tiny.
49 local OCR requests and zero text-model calls. All 4293 tests passed
with 67 skipped; typecheck, lint and the isolated Electron build passed.

Release note: 修复已配置本地 OCR 时图片型 PPTX 仍无法导入的问题,识别结果保留幻灯片定位。OCR 空闲 60 秒后自动释放模型运行内存,连续解析复用模型,下次使用时自动重新加载。
This commit is contained in:
mesalogo
2026-09-15 16:58:28 +08:00
parent 02e59427a1
commit 8a63ac9ee8
11 changed files with 631 additions and 28 deletions
@@ -0,0 +1,156 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DocumentOcrRequest } from '../../shared/document-parsing-contracts'
class TestWorker extends EventTarget {
static instances: TestWorker[] = []
terminate = vi.fn()
postMessage = vi.fn((message: { type: string }) => {
if (message.type === 'initialize') {
this.dispatchEvent(new MessageEvent('message', { data: { type: 'ready' } }))
}
})
constructor() {
super()
TestWorker.instances.push(this)
}
finish(requestId: string): void {
this.dispatchEvent(new MessageEvent('message', {
data: { type: 'result', result: { requestId, sections: [], pageCount: 1, warnings: [] } }
}))
}
}
function request(): DocumentOcrRequest {
return {
requestId: crypto.randomUUID(),
modelId: 'pp-ocrv6-tiny',
fileName: 'scan.png',
mimeType: 'image/png',
data: new ArrayBuffer(1),
maximumPages: 1,
pageTimeoutSeconds: 300
}
}
let dispose: (() => void) | undefined
beforeEach(() => {
vi.resetModules()
vi.useFakeTimers()
TestWorker.instances = []
vi.stubGlobal('Worker', TestWorker)
})
afterEach(async () => {
dispose?.()
dispose = undefined
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
vi.unstubAllGlobals()
})
async function setup() {
let receive!: (request: DocumentOcrRequest) => void
let cancel!: (requestId: string) => void
const getOcrAssets = vi.fn(async (modelId: string) => ({
modelId, detection: new ArrayBuffer(1), recognition: new ArrayBuffer(1), dictionary: new ArrayBuffer(1)
}))
const respondOcr = vi.fn(async () => undefined)
const removeRequest = vi.fn()
const removeCancel = vi.fn()
vi.stubGlobal('goodbuddy', {
documentParsing: {
getOcrAssets, respondOcr,
onOcrRequest: (listener: typeof receive) => { receive = listener; return removeRequest },
onOcrCancel: (listener: typeof cancel) => { cancel = listener; return removeCancel }
}
})
const { installDocumentOcrBridge } = await import('./document-ocr-bridge')
dispose = installDocumentOcrBridge()
const start = async () => {
const input = request()
receive(input)
await vi.advanceTimersByTimeAsync(0)
return input
}
const finish = async (input: DocumentOcrRequest) => {
TestWorker.instances.at(-1)!.finish(input.requestId)
await vi.advanceTimersByTimeAsync(0)
}
return { start, finish, receive, cancel, getOcrAssets, respondOcr, removeRequest, removeCancel }
}
describe('document OCR worker idle release', () => {
it('releases after 60 idle seconds and reloads the model for the next request', async () => {
const api = await setup()
const input = await api.start()
const worker = TestWorker.instances[0]!
await api.finish(input)
await vi.advanceTimersByTimeAsync(59_999)
expect(worker.terminate).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(worker.terminate).toHaveBeenCalledOnce()
const next = await api.start()
expect(TestWorker.instances).toHaveLength(2)
expect(api.getOcrAssets).toHaveBeenCalledTimes(2)
await api.finish(next)
})
it('cancels the idle timer for new work and reuses the loaded worker', async () => {
const api = await setup()
await api.finish(await api.start())
await vi.advanceTimersByTimeAsync(59_000)
const next = await api.start()
const worker = TestWorker.instances[0]!
await vi.advanceTimersByTimeAsync(61_000)
expect(worker.terminate).not.toHaveBeenCalled()
expect(api.getOcrAssets).toHaveBeenCalledOnce()
await api.finish(next)
await vi.advanceTimersByTimeAsync(60_000)
expect(worker.terminate).toHaveBeenCalledOnce()
})
it('does not release while a second request is queued or running', async () => {
const api = await setup()
const first = await api.start()
const second = await api.start()
const worker = TestWorker.instances[0]!
await vi.advanceTimersByTimeAsync(61_000)
expect(worker.terminate).not.toHaveBeenCalled()
await api.finish(first)
await vi.advanceTimersByTimeAsync(61_000)
expect(worker.terminate).not.toHaveBeenCalled()
await api.finish(second)
await vi.advanceTimersByTimeAsync(60_000)
expect(worker.terminate).toHaveBeenCalledOnce()
expect(api.respondOcr).toHaveBeenCalledTimes(2)
})
it('clears the idle timer on disposal and removes subscriptions', async () => {
const api = await setup()
await api.finish(await api.start())
const worker = TestWorker.instances[0]!
dispose?.()
dispose = undefined
expect(api.removeRequest).toHaveBeenCalledOnce()
expect(api.removeCancel).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(60_000)
expect(worker.terminate).toHaveBeenCalledOnce()
})
it('still terminates immediately on cancellation', async () => {
const api = await setup()
const input = await api.start()
const worker = TestWorker.instances[0]!
api.cancel(input.requestId)
await vi.advanceTimersByTimeAsync(0)
expect(worker.terminate).toHaveBeenCalledOnce()
expect(api.respondOcr).toHaveBeenCalledWith({
requestId: input.requestId, error: '本地 OCR 解析已取消'
})
await vi.advanceTimersByTimeAsync(60_000)
expect(worker.terminate).toHaveBeenCalledOnce()
})
})
+27
View File
@@ -26,6 +26,26 @@ const pending = new Map<string, PendingWorkerRequest>()
const cancelledRequestIds = new Set<string>()
let activeRequestId: string | undefined
let requestQueue: Promise<void> = Promise.resolve()
let queuedRequestCount = 0
let idleTimer: number | undefined
const workerIdleTimeoutMs = 60_000
function clearIdleTimer(): void {
window.clearTimeout(idleTimer)
idleTimer = undefined
}
function scheduleIdleRelease(): void {
clearIdleTimer()
if (!worker || queuedRequestCount > 0 || activeRequestId || pending.size > 0) {
return
}
idleTimer = window.setTimeout(() => {
if (queuedRequestCount === 0 && !activeRequestId && pending.size === 0) {
terminateWorker(new Error('本地 OCR 空闲资源已释放'))
}
}, workerIdleTimeoutMs)
}
function safeError(error: unknown): string {
return error instanceof Error
@@ -34,6 +54,7 @@ function safeError(error: unknown): string {
}
function terminateWorker(error: Error): void {
clearIdleTimer()
worker?.terminate()
rejectWorkerReady?.(error)
worker = undefined
@@ -199,9 +220,15 @@ export function installDocumentOcrBridge(): () => void {
return () => undefined
}
const removeRequestListener = api.onOcrRequest((request) => {
clearIdleTimer()
queuedRequestCount += 1
requestQueue = requestQueue
.then(() => handleRequest(request))
.catch(() => undefined)
.finally(() => {
queuedRequestCount -= 1
scheduleIdleRelease()
})
})
const removeCancelListener = api.onOcrCancel((requestId) => {
cancelledRequestIds.add(requestId)