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:
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user