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
+125 -11
View File
@@ -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,