feat: add document OCR and offline model archives

This commit is contained in:
lofyer
2026-08-11 16:49:51 +08:00
parent 19a4469561
commit e0e7bc573c
46 changed files with 7831 additions and 85 deletions
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const getDocument = vi.hoisted(() => vi.fn())
vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({
getDocument
}))
import { extractPdfTextPages } from './document-parser'
describe('PDF extraction in Electron main', () => {
beforeEach(() => {
getDocument.mockReset()
})
it('disables PDF.js DOM factories for headless text extraction', async () => {
const cleanup = vi.fn()
const destroy = vi.fn(async () => undefined)
getDocument.mockReturnValue({
promise: Promise.resolve({
numPages: 1,
getPage: vi.fn(async () => ({
getTextContent: vi.fn(async () => ({
items: [{ str: 'PDF body text' }]
})),
cleanup
}))
}),
destroy
})
await expect(
extractPdfTextPages(Buffer.from('synthetic PDF'))
).resolves.toEqual([
{
pageNumber: 1,
content: 'PDF body text'
}
])
expect(getDocument).toHaveBeenCalledWith({
data: expect.any(Uint8Array),
disableFontFace: true,
isOffscreenCanvasSupported: false,
useSystemFonts: false,
useWorkerFetch: false
})
expect(cleanup).toHaveBeenCalledOnce()
expect(destroy).toHaveBeenCalledOnce()
})
})
+44 -11
View File
@@ -5,12 +5,17 @@ import { extname } from 'node:path'
export type ParsedSection = {
locator: string
content: string
method?: 'native' | 'ocr' | 'converted' | 'vision'
confidence?: number
}
export type ParsedDocument = {
title: string
sourceFormat: string
content: string
sections: ParsedSection[]
warnings: string[]
pageCount?: number
}
export type DocumentChunk = {
@@ -160,12 +165,43 @@ function parseOfficeArchive(
}
async function parsePdf(buffer: Buffer): Promise<ParsedSection[]> {
const pages = await extractPdfTextPages(buffer)
return pages
.filter((page) => page.content.length > 0)
.map((page) => ({
locator: `第 ${page.pageNumber} 页`,
content: page.content
}))
}
export type PdfTextPage = {
pageNumber: number
content: string
}
export class DocumentTextUnavailableError extends Error {
constructor(message = '文档中没有可索引的文本内容') {
super(message)
this.name = 'DocumentTextUnavailableError'
}
}
export async function extractPdfTextPages(
buffer: Buffer
): Promise<PdfTextPage[]> {
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs')
const loadingTask = pdfjs.getDocument({
data: new Uint8Array(buffer)
data: new Uint8Array(buffer),
// Electron's main process identifies itself as process.type ===
// "browser", so PDF.js otherwise selects DOM font factories even
// though no document exists there.
disableFontFace: true,
isOffscreenCanvasSupported: false,
useSystemFonts: false,
useWorkerFetch: false
})
const document = await loadingTask.promise
const sections: ParsedSection[] = []
const pages: PdfTextPage[] = []
try {
for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) {
const page = await document.getPage(pageNumber)
@@ -175,18 +211,13 @@ async function parsePdf(buffer: Buffer): Promise<ParsedSection[]> {
.join(' ')
.replace(/\s+/g, ' ')
.trim()
if (content) {
sections.push({
locator: `第 ${pageNumber} 页`,
content
})
}
pages.push({ pageNumber, content })
page.cleanup()
}
} finally {
await loadingTask.destroy()
}
return sections
return pages
}
export async function parseDocument(
@@ -227,12 +258,14 @@ export async function parseDocument(
.join('\n\n')
.slice(0, maximumExtractedCharacters)
if (!content) {
throw new Error('文档中没有可索引的文本内容')
throw new DocumentTextUnavailableError()
}
return {
title: name.replace(/\.[^.]+$/, ''),
sourceFormat: extension || 'unknown',
content,
sections
sections,
warnings: []
}
}
+22 -3
View File
@@ -18,7 +18,12 @@ import {
relative,
resolve
} from 'node:path'
import { chunkDocument, parseDocument, supportedDocumentExtensions } from './document-parser'
import {
chunkDocument,
parseDocument,
supportedDocumentExtensions,
type ParsedDocument
} from './document-parser'
import { classifyEmbeddingError } from './embedding-errors'
import {
extractKnowledgeGraph,
@@ -99,6 +104,12 @@ export type KnowledgeServiceOptions = {
urlImporter?: UrlImporter
embeddingProvider?: EmbeddingProvider
embeddingBatchSize?: number
parseDocument?: (
name: string,
buffer: Buffer,
purpose: 'knowledge-index',
signal?: AbortSignal
) => Promise<ParsedDocument>
}
const supportedExtensions = new Set<string>(supportedDocumentExtensions)
@@ -118,6 +129,9 @@ export class KnowledgeService {
private readonly managedRoot: string
private readonly extractStructured?: ExtractStructured
private readonly urlImporter: UrlImporter
private readonly documentParser: NonNullable<
KnowledgeServiceOptions['parseDocument']
>
private embeddingProvider?: EmbeddingProvider
private readonly embeddingBatchSize: number
private readonly watchers = new Map<string, FSWatcher>()
@@ -131,6 +145,9 @@ export class KnowledgeService {
this.managedRoot = resolve(options.managedRoot)
this.extractStructured = options.extractStructured
this.urlImporter = options.urlImporter ?? new UrlImporter()
this.documentParser =
options.parseDocument ??
((name, buffer) => parseDocument(name, buffer))
this.embeddingProvider = options.embeddingProvider
const embeddingBatchSize = options.embeddingBatchSize ?? 16
if (
@@ -839,9 +856,11 @@ export class KnowledgeService {
})
continue
}
const parsed = await parseDocument(
const parsed = await this.documentParser(
basename(file.absolutePath),
buffer
buffer,
'knowledge-index',
this.lifecycleController.signal
)
this.updateKnowledgeTask(parsingTask.id, {
progress: 75,