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:
@@ -3,6 +3,7 @@ import {
|
||||
defaultDocumentParsingSettings
|
||||
} from './document-parsing-settings-store'
|
||||
import { DocumentParsingService } from './document-parsing-service'
|
||||
import { createImagePptx } from '../../tests/support/pptx-fixture'
|
||||
|
||||
function createPdfFixture(...pageTexts: string[]): Buffer {
|
||||
const texts = pageTexts.length > 0 ? pageTexts : ['']
|
||||
@@ -109,6 +110,73 @@ function createService(overrides?: {
|
||||
}
|
||||
|
||||
describe('DocumentParsingService', () => {
|
||||
it.each(['chat-attachment', 'knowledge-index', 'artifact-import', 'diagnostic'] as const)(
|
||||
'recognizes embedded PPTX images for %s',
|
||||
async (purpose) => {
|
||||
const { recognize, service } = createService()
|
||||
const parsed = await service.parse('slides.pptx', createImagePptx('', 2), purpose)
|
||||
expect(parsed.sourceFormat).toBe('.pptx')
|
||||
expect(parsed.pageCount).toBe(2)
|
||||
expect(parsed.sections.map(({ locator, method, pageNumber }) => ({
|
||||
locator, method, pageNumber
|
||||
}))).toEqual([
|
||||
{ locator: '幻灯片 1 · 图片 1', method: 'ocr', pageNumber: 1 },
|
||||
{ locator: '幻灯片 2 · 图片 1', method: 'ocr', pageNumber: 2 }
|
||||
])
|
||||
expect(recognize).toHaveBeenCalledTimes(2)
|
||||
expect(recognize).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mimeType: 'image/png', maximumPages: 1, modelId: 'pp-ocrv6-tiny'
|
||||
}))
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves PPTX native text while recognizing image content on the same slide', async () => {
|
||||
const { service } = createService()
|
||||
const parsed = await service.parse('slides.pptx', createImagePptx('Native slide title'), 'chat-attachment')
|
||||
expect(parsed.content).toContain('Native slide title')
|
||||
expect(parsed.content).toContain('扫描件识别正文')
|
||||
const diagnostic = await service.diagnose('slides.pptx', createImagePptx('Native slide title'))
|
||||
expect(diagnostic).toMatchObject({ method: 'mixed', pageCount: 1, ocrPageCount: 1 })
|
||||
})
|
||||
|
||||
it('reports disabled OCR for image-only PPTX in fast-text mode', async () => {
|
||||
const { service, recognize } = createService({ settings: { chatWorkflow: 'fast-text' } })
|
||||
await expect(service.parse('slides.pptx', createImagePptx(), 'chat-attachment')).rejects.toThrow('当前工作流未启用 OCR')
|
||||
expect(recognize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('enforces PPTX OCR page limits before recognizing images', async () => {
|
||||
const { service, recognize } = createService({ settings: { maximumPages: 1 } })
|
||||
await expect(service.parse('slides.pptx', createImagePptx('', 2), 'knowledge-index')).rejects.toThrow('超过 1 页限制')
|
||||
expect(recognize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the model error for image-only PPTX, not empty document text', async () => {
|
||||
const { service } = createService({ modelStatus: {
|
||||
available: false, verified: false, detail: '模型尚未安装'
|
||||
} })
|
||||
await expect(service.parse('slides.pptx', createImagePptx(), 'chat-attachment')).rejects.toThrow('模型尚未安装')
|
||||
})
|
||||
|
||||
it('only falls back to PPTX native text for automatic non-index workflows', async () => {
|
||||
const { service } = createService({ recognize: async () => { throw new Error('OCR runtime failed') } })
|
||||
const bytes = createImagePptx('Useful native slide body')
|
||||
expect((await service.parse('slides.pptx', bytes, 'chat-attachment')).warnings).toEqual([
|
||||
expect.stringContaining('OCR runtime failed')
|
||||
])
|
||||
await expect(service.parse('slides.pptx', bytes, 'knowledge-index')).rejects.toThrow('OCR runtime failed')
|
||||
})
|
||||
|
||||
it('stops PPTX OCR between images on cancellation instead of returning native text', async () => {
|
||||
const controller = new AbortController()
|
||||
const { service, recognize } = createService({ recognize: async () => {
|
||||
controller.abort(new Error('Cancelled PPTX'))
|
||||
throw new Error('OCR stopped')
|
||||
} })
|
||||
await expect(service.parse('slides.pptx', createImagePptx('Useful native text', 2), 'chat-attachment', controller.signal)).rejects.toThrow('Cancelled PPTX')
|
||||
expect(recognize).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps useful PDF text local without invoking OCR', async () => {
|
||||
const { recognize, service } = createService()
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { DocumentOcrBroker } from './document-ocr-broker'
|
||||
import type { DocumentOcrModelManager } from './document-ocr-model-manager'
|
||||
import type { DocumentParsingSettingsStore } from './document-parsing-settings-store'
|
||||
import { extractPptxPages } from './knowledge/pptx-parser'
|
||||
import {
|
||||
assertDocumentBuffer,
|
||||
DocumentTextUnavailableError,
|
||||
@@ -78,11 +79,12 @@ function effectiveOcrMode(
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
function buildPdfDocument(
|
||||
function buildPagedDocument(
|
||||
name: string,
|
||||
sections: ParsedSection[],
|
||||
pageCount: number,
|
||||
warnings: string[] = []
|
||||
warnings: string[] = [],
|
||||
sourceFormat: '.pdf' | '.pptx' = '.pdf'
|
||||
): ParsedDocument {
|
||||
const truncationWarning =
|
||||
'文档提取文本超过 5,000,000 字符,已截断'
|
||||
@@ -130,7 +132,7 @@ function buildPdfDocument(
|
||||
: boundedWarnings.slice(0, maximumDocumentParsingWarnings)
|
||||
return {
|
||||
title: name.replace(/\.[^.]+$/u, ''),
|
||||
sourceFormat: '.pdf',
|
||||
sourceFormat,
|
||||
content,
|
||||
sections: limitedSections,
|
||||
pageCount,
|
||||
@@ -204,6 +206,9 @@ export class DocumentParsingService {
|
||||
) => {
|
||||
ensureNotAborted(signal)
|
||||
assertDocumentBuffer(buffer)
|
||||
if (extname(name).toLowerCase() === '.pptx') {
|
||||
return this.parsePptx(name, buffer, purpose, signal)
|
||||
}
|
||||
if (extname(name).toLowerCase() !== '.pdf') {
|
||||
return parseDocument(name, buffer, signal)
|
||||
}
|
||||
@@ -234,7 +239,7 @@ export class DocumentParsingService {
|
||||
? ['文档提取文本超过 5,000,000 字符,已截断']
|
||||
: [])
|
||||
]
|
||||
return buildPdfDocument(
|
||||
return buildPagedDocument(
|
||||
name,
|
||||
native,
|
||||
extracted.pageCount,
|
||||
@@ -246,7 +251,7 @@ export class DocumentParsingService {
|
||||
)
|
||||
}
|
||||
if (ocrPageNumbers.length === 0) {
|
||||
return buildPdfDocument(
|
||||
return buildPagedDocument(
|
||||
name,
|
||||
nativePdfSections(pages),
|
||||
extracted.pageCount,
|
||||
@@ -270,7 +275,7 @@ export class DocumentParsingService {
|
||||
purpose !== 'knowledge-index' &&
|
||||
native.some((section) => hasUsefulText(section.content))
|
||||
) {
|
||||
return buildPdfDocument(
|
||||
return buildPagedDocument(
|
||||
name,
|
||||
native,
|
||||
extracted.pageCount,
|
||||
@@ -308,7 +313,7 @@ export class DocumentParsingService {
|
||||
) {
|
||||
const detail =
|
||||
error instanceof Error ? error.message : '本地 OCR 识别失败'
|
||||
return buildPdfDocument(
|
||||
return buildPagedDocument(
|
||||
name,
|
||||
native,
|
||||
extracted.pageCount,
|
||||
@@ -367,7 +372,7 @@ export class DocumentParsingService {
|
||||
}]
|
||||
: []
|
||||
})
|
||||
return buildPdfDocument(
|
||||
return buildPagedDocument(
|
||||
name,
|
||||
merged,
|
||||
extracted.pageCount,
|
||||
@@ -380,6 +385,115 @@ export class DocumentParsingService {
|
||||
)
|
||||
}
|
||||
|
||||
private async parsePptx(
|
||||
name: string,
|
||||
buffer: Buffer,
|
||||
purpose: DocumentParsingPurpose,
|
||||
signal?: AbortSignal
|
||||
): Promise<ParsedDocument> {
|
||||
const settings = await this.settingsStore.get()
|
||||
const mode = effectiveOcrMode(settings, purpose)
|
||||
if (mode === 'disabled') {
|
||||
try {
|
||||
return await parseDocument(name, buffer, signal)
|
||||
} catch (error) {
|
||||
if (error instanceof DocumentTextUnavailableError) {
|
||||
throw new DocumentTextUnavailableError(
|
||||
'PPTX 没有可用文本,当前工作流未启用 OCR'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const pages = extractPptxPages(buffer)
|
||||
ensureNotAborted(signal)
|
||||
const native: ParsedSection[] = pages
|
||||
.filter((page) => page.content)
|
||||
.map((page) => ({
|
||||
locator: `幻灯片 ${page.pageNumber}`,
|
||||
content: page.content,
|
||||
method: 'native',
|
||||
pageNumber: page.pageNumber,
|
||||
blockKind: 'slide'
|
||||
}))
|
||||
const imagePages = pages.filter((page) => page.images.length > 0)
|
||||
if (imagePages.length > settings.maximumPages) {
|
||||
throw new Error(
|
||||
`PPTX 有 ${imagePages.length} 页需要 OCR,超过 ${settings.maximumPages} 页限制`
|
||||
)
|
||||
}
|
||||
const sections = [...native]
|
||||
const warnings: string[] = []
|
||||
let extractedCharacters = native.reduce(
|
||||
(total, section) => total + section.content.length, 0
|
||||
)
|
||||
if (imagePages.length > 0) {
|
||||
try {
|
||||
const status = await this.modelManager.getStatus(settings.localOcrModelId)
|
||||
if (!status.available || !status.verified) {
|
||||
throw new Error(status.detail)
|
||||
}
|
||||
ocrPages: for (const page of imagePages) {
|
||||
for (const [index, image] of page.images.entries()) {
|
||||
ensureNotAborted(signal)
|
||||
const locator = `幻灯片 ${page.pageNumber} · 图片 ${index + 1}`
|
||||
if (!image.mimeType) {
|
||||
throw new Error(`${locator} 的格式暂不支持 OCR:${extname(image.name)}`)
|
||||
}
|
||||
const request = {
|
||||
modelId: settings.localOcrModelId,
|
||||
fileName: image.name,
|
||||
mimeType: image.mimeType,
|
||||
data: Uint8Array.from(image.data).buffer,
|
||||
maximumPages: 1,
|
||||
pageTimeoutSeconds: settings.pageTimeoutSeconds
|
||||
}
|
||||
const result = await (signal
|
||||
? this.ocrBroker.recognize(request, signal)
|
||||
: this.ocrBroker.recognize(request))
|
||||
ensureNotAborted(signal)
|
||||
sections.push(...result.sections.map((section): ParsedSection => ({
|
||||
locator,
|
||||
content: section.content,
|
||||
confidence: section.confidence,
|
||||
method: 'ocr',
|
||||
pageNumber: page.pageNumber,
|
||||
blockKind: 'slide'
|
||||
})))
|
||||
for (const warning of result.warnings) {
|
||||
if (warnings.length < maximumDocumentParsingWarnings) {
|
||||
warnings.push(`${locator}:${warning}`.slice(0, 500))
|
||||
}
|
||||
}
|
||||
extractedCharacters += result.sections.reduce(
|
||||
(total, section) => total + section.content.length, 0
|
||||
)
|
||||
if (extractedCharacters >= maximumDocumentExtractedCharacters) {
|
||||
warnings.push('文档提取文本超过 5,000,000 字符,已截断')
|
||||
break ocrPages
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ensureNotAborted(signal)
|
||||
if (
|
||||
mode !== 'auto' ||
|
||||
purpose === 'knowledge-index' ||
|
||||
!native.some((section) => hasUsefulText(section.content))
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
return buildPagedDocument(name, native, pages.length, [
|
||||
`本地 OCR 不可用,已保留 PPTX 文本内容:${
|
||||
error instanceof Error ? error.message : '本地 OCR 识别失败'
|
||||
}`.slice(0, 500)
|
||||
], '.pptx')
|
||||
}
|
||||
}
|
||||
sections.sort((left, right) => (left.pageNumber ?? 0) - (right.pageNumber ?? 0))
|
||||
return buildPagedDocument(name, sections, pages.length, warnings, '.pptx')
|
||||
}
|
||||
|
||||
async diagnose(
|
||||
name: string,
|
||||
buffer: Buffer,
|
||||
@@ -387,9 +501,9 @@ export class DocumentParsingService {
|
||||
): Promise<DocumentParsingDiagnostic> {
|
||||
const startedAt = Date.now()
|
||||
const parsed = await this.parse(name, buffer, purpose)
|
||||
const ocrPageCount = parsed.sections.filter(
|
||||
const ocrPageCount = new Set(parsed.sections.filter(
|
||||
(section) => section.method === 'ocr'
|
||||
).length
|
||||
).map((section) => section.pageNumber ?? section.locator)).size
|
||||
const nativePageCount = parsed.sections.filter(
|
||||
(section) => section.method !== 'ocr'
|
||||
).length
|
||||
@@ -398,7 +512,7 @@ export class DocumentParsingService {
|
||||
sourceFormat:
|
||||
parsed.sourceFormat.replace(/^\./u, '').toUpperCase() || 'UNKNOWN',
|
||||
pageCount:
|
||||
parsed.sourceFormat === '.pdf'
|
||||
parsed.sourceFormat === '.pdf' || parsed.sourceFormat === '.pptx'
|
||||
? (parsed.pageCount ?? parsed.sections.length)
|
||||
: 0,
|
||||
ocrPageCount,
|
||||
|
||||
@@ -102,7 +102,7 @@ function decodeXmlEntities(value: string): string {
|
||||
)
|
||||
}
|
||||
|
||||
function extractXmlText(xml: string): string {
|
||||
export function extractXmlText(xml: string): string {
|
||||
return decodeXmlEntities(
|
||||
xml
|
||||
.replace(/<w:tab\b[^>]*\/>/g, '\t')
|
||||
@@ -239,19 +239,10 @@ function extractWorksheetText(
|
||||
.trim()
|
||||
}
|
||||
|
||||
function parseOfficeArchive(
|
||||
export function readOfficeArchive(
|
||||
buffer: Buffer,
|
||||
extension: string
|
||||
): ParsedSection[] {
|
||||
const patterns =
|
||||
extension === '.docx'
|
||||
? [/^word\/document\.xml$/]
|
||||
: extension === '.xlsx'
|
||||
? [
|
||||
/^xl\/sharedStrings\.xml$/,
|
||||
/^xl\/worksheets\/sheet\d+\.xml$/
|
||||
]
|
||||
: [/^ppt\/slides\/slide\d+\.xml$/]
|
||||
patterns: RegExp[]
|
||||
): Record<string, Uint8Array> {
|
||||
let archive: Record<string, Uint8Array>
|
||||
let entryCount = 0
|
||||
let selectedBytes = 0
|
||||
@@ -281,6 +272,24 @@ function parseOfficeArchive(
|
||||
} catch {
|
||||
throw new Error('Office 文档已损坏或不是有效的 Open XML 文件')
|
||||
}
|
||||
return archive
|
||||
}
|
||||
|
||||
function parseOfficeArchive(
|
||||
buffer: Buffer,
|
||||
extension: string
|
||||
): ParsedSection[] {
|
||||
const archive = readOfficeArchive(
|
||||
buffer,
|
||||
extension === '.docx'
|
||||
? [/^word\/document\.xml$/]
|
||||
: extension === '.xlsx'
|
||||
? [
|
||||
/^xl\/sharedStrings\.xml$/,
|
||||
/^xl\/worksheets\/sheet\d+\.xml$/
|
||||
]
|
||||
: [/^ppt\/slides\/slide\d+\.xml$/]
|
||||
)
|
||||
|
||||
if (extension === '.docx') {
|
||||
const documentXml = archive['word/document.xml']
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractPptxPages } from './pptx-parser'
|
||||
import { createImagePptx } from '../../../tests/support/pptx-fixture'
|
||||
|
||||
describe('PPTX image extraction', () => {
|
||||
it('extracts referenced images with slide locators', () => {
|
||||
const pages = extractPptxPages(createImagePptx('Native text', 2))
|
||||
expect(pages.map((page) => page.pageNumber)).toEqual([1, 2])
|
||||
expect(pages[0]).toEqual({
|
||||
pageNumber: 1,
|
||||
content: 'Native text',
|
||||
images: [{
|
||||
name: 'image1.png',
|
||||
data: new Uint8Array([1, 2, 3]),
|
||||
mimeType: 'image/png'
|
||||
}]
|
||||
})
|
||||
})
|
||||
|
||||
it('follows presentation order and ignores external and unreferenced media', () => {
|
||||
const pages = extractPptxPages(Buffer.from(zipSync({
|
||||
'ppt/presentation.xml': strToU8('<p:presentation><p:sldIdLst><p:sldId r:id="second"/><p:sldId r:id="first"/></p:sldIdLst></p:presentation>'),
|
||||
'ppt/_rels/presentation.xml.rels': strToU8('<Relationships><Relationship Id="first" Target="slides/slide1.xml"/><Relationship Id="second" Target="/ppt/slides/slide2.xml"/></Relationships>'),
|
||||
'ppt/slides/slide1.xml': strToU8('<p:sld><a:t>First</a:t><a:blip r:embed="external"/></p:sld>'),
|
||||
'ppt/slides/_rels/slide1.xml.rels': strToU8('<Relationships><Relationship Id="external" Target="https://example.invalid/image.png" TargetMode="External"/></Relationships>'),
|
||||
'ppt/slides/slide2.xml': strToU8('<p:sld><a:t>Second</a:t></p:sld>'),
|
||||
'ppt/media/unused.png': new Uint8Array([1])
|
||||
})))
|
||||
expect(pages.map(({ pageNumber, content, images }) => ({
|
||||
pageNumber, content, images
|
||||
}))).toEqual([
|
||||
{ pageNumber: 1, content: 'Second', images: [] },
|
||||
{ pageNumber: 2, content: 'First', images: [] }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { posix } from 'node:path'
|
||||
import { extractXmlText, readOfficeArchive } from './document-parser'
|
||||
|
||||
export type PptxPage = {
|
||||
pageNumber: number
|
||||
content: string
|
||||
images: Array<{
|
||||
name: string
|
||||
data: Uint8Array
|
||||
mimeType?: 'image/png' | 'image/jpeg' | 'image/webp'
|
||||
}>
|
||||
}
|
||||
|
||||
function attribute(tag: string, name: string): string | undefined {
|
||||
return new RegExp(`\\b${name}=["']([^"']*)["']`, 'u').exec(tag)?.[1]
|
||||
}
|
||||
|
||||
function relationships(xml: string, owner: string): Map<string, string> {
|
||||
const result = new Map<string, string>()
|
||||
for (const tag of xml.match(/<(?:\w+:)?Relationship\b[^>]*>/gu) ?? []) {
|
||||
const id = attribute(tag, 'Id')
|
||||
const target = attribute(tag, 'Target')
|
||||
if (!id || !target || attribute(tag, 'TargetMode') === 'External') {
|
||||
continue
|
||||
}
|
||||
result.set(
|
||||
id,
|
||||
posix.normalize(
|
||||
target.startsWith('/')
|
||||
? target.slice(1)
|
||||
: posix.join(posix.dirname(owner), target)
|
||||
)
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function extractPptxPages(buffer: Buffer): PptxPage[] {
|
||||
const archive = readOfficeArchive(buffer, [
|
||||
/^ppt\/presentation\.xml$/,
|
||||
/^ppt\/_rels\/presentation\.xml\.rels$/,
|
||||
/^ppt\/slides\/[^/]+\.xml$/,
|
||||
/^ppt\/slides\/_rels\/[^/]+\.xml\.rels$/,
|
||||
/^ppt\/media\/[^/]+$/
|
||||
])
|
||||
const text = (name: string): string =>
|
||||
archive[name] ? Buffer.from(archive[name]).toString('utf8') : ''
|
||||
const slideRelationships = relationships(
|
||||
text('ppt/_rels/presentation.xml.rels'),
|
||||
'ppt/presentation.xml'
|
||||
)
|
||||
const slideIds =
|
||||
text('ppt/presentation.xml').match(/<(?:\w+:)?sldId\b[^>]*>/gu) ?? []
|
||||
const paths = slideIds.length > 0
|
||||
? slideIds.map((tag) => {
|
||||
const path = slideRelationships.get(attribute(tag, 'r:id') ?? '')
|
||||
if (!path || !archive[path]) {
|
||||
throw new Error('PPTX 幻灯片关系无效')
|
||||
}
|
||||
return path
|
||||
})
|
||||
: Object.keys(archive)
|
||||
.filter((path) => /^ppt\/slides\/slide\d+\.xml$/u.test(path))
|
||||
.sort((left, right) => left.localeCompare(right, undefined, { numeric: true }))
|
||||
|
||||
return paths.map((path, index) => {
|
||||
const xml = text(path)
|
||||
const imageRelationships = relationships(
|
||||
text(posix.join(posix.dirname(path), '_rels', `${posix.basename(path)}.rels`)),
|
||||
path
|
||||
)
|
||||
const imagePaths = new Set(
|
||||
(xml.match(/<(?:\w+:)?blip\b[^>]*>/gu) ?? [])
|
||||
.map((tag) => imageRelationships.get(attribute(tag, 'r:embed') ?? ''))
|
||||
.filter((value): value is string => value !== undefined)
|
||||
)
|
||||
const images: PptxPage['images'] = []
|
||||
for (const imagePath of imagePaths) {
|
||||
const extension = posix.extname(imagePath).toLowerCase()
|
||||
const mimeType = extension === '.png'
|
||||
? 'image/png'
|
||||
: extension === '.jpg' || extension === '.jpeg'
|
||||
? 'image/jpeg'
|
||||
: extension === '.webp'
|
||||
? 'image/webp'
|
||||
: undefined
|
||||
const data = archive[imagePath]
|
||||
if (!data) {
|
||||
throw new Error(`幻灯片 ${index + 1} 的内嵌图片缺失`)
|
||||
}
|
||||
images.push({ name: posix.basename(imagePath), data, mimeType })
|
||||
}
|
||||
return {
|
||||
pageNumber: index + 1,
|
||||
content: extractXmlText(xml),
|
||||
images
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user