291 lines
8.0 KiB
TypeScript
291 lines
8.0 KiB
TypeScript
/// <reference lib="webworker" />
|
|
|
|
import * as ort from 'onnxruntime-web'
|
|
import wasmModuleUrl from '../../../node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.mjs?url'
|
|
import wasmBinaryUrl from '../../../node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.wasm?url'
|
|
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
|
import type { PDFPageProxy } from 'pdfjs-dist/types/src/display/api'
|
|
import type {
|
|
DocumentOcrAssets,
|
|
DocumentOcrRequest,
|
|
DocumentOcrResult
|
|
} from '../../shared/document-parsing-contracts'
|
|
import {
|
|
maximumDocumentExtractedCharacters,
|
|
maximumDocumentOcrSectionCharacters,
|
|
maximumDocumentParsingWarnings,
|
|
maximumPdfPageCount
|
|
} from '../../shared/document-parsing-contracts'
|
|
import { createWorkerPdfLoadingParameters } from './document-ocr-pdf'
|
|
|
|
type InitializeMessage = {
|
|
type: 'initialize'
|
|
assets: DocumentOcrAssets
|
|
}
|
|
|
|
type RecognizeMessage = {
|
|
type: 'recognize'
|
|
request: DocumentOcrRequest
|
|
}
|
|
|
|
type WorkerInput = InitializeMessage | RecognizeMessage
|
|
|
|
type WorkerOutput =
|
|
| { type: 'ready' }
|
|
| { type: 'progress'; requestId: string; pageNumber?: number }
|
|
| { type: 'result'; result: DocumentOcrResult }
|
|
| { type: 'error'; requestId?: string; error: string }
|
|
|
|
type OcrService = InstanceType<
|
|
typeof import('ppu-paddle-ocr/web').PaddleOcrService
|
|
>
|
|
|
|
const worker = self as DedicatedWorkerGlobalScope
|
|
let service: OcrService | undefined
|
|
|
|
function absoluteAssetUrl(value: string): string {
|
|
return new URL(value, worker.location.href).href
|
|
}
|
|
|
|
ort.env.wasm.numThreads = 1
|
|
ort.env.wasm.proxy = false
|
|
ort.env.wasm.wasmPaths = {
|
|
mjs: absoluteAssetUrl(wasmModuleUrl),
|
|
wasm: absoluteAssetUrl(wasmBinaryUrl)
|
|
}
|
|
|
|
function safeWorkerError(error: unknown): string {
|
|
return error instanceof Error
|
|
? error.message.slice(0, 1_000)
|
|
: '本地 OCR 识别失败'
|
|
}
|
|
|
|
async function initialize(assets: DocumentOcrAssets): Promise<void> {
|
|
await service?.destroy()
|
|
const { PaddleOcrService } = await import('ppu-paddle-ocr/web')
|
|
service = new PaddleOcrService({
|
|
model: {
|
|
detection: assets.detection,
|
|
recognition: assets.recognition,
|
|
charactersDictionary: assets.dictionary
|
|
},
|
|
session: {
|
|
executionProviders: ['wasm'],
|
|
graphOptimizationLevel: 'disabled'
|
|
},
|
|
processing: {
|
|
engine: 'canvas-native'
|
|
},
|
|
recognition: {
|
|
charactersDictionary: [],
|
|
minimumConfidence: 0.5,
|
|
strategy: 'per-line',
|
|
recBatchSize: 4
|
|
},
|
|
detection: {
|
|
maxSideLength: 1920
|
|
}
|
|
})
|
|
await service.initialize()
|
|
}
|
|
|
|
async function recognizeImage(
|
|
data: ArrayBuffer,
|
|
locator: string,
|
|
pageNumber?: number
|
|
): Promise<DocumentOcrResult['sections'][number] | undefined> {
|
|
if (!service?.isInitialized()) {
|
|
throw new Error('本地 OCR 模型尚未初始化')
|
|
}
|
|
const result = await service.recognize(data)
|
|
const content = result.text.replace(/\n{3,}/gu, '\n\n').trim()
|
|
if (content.length > maximumDocumentOcrSectionCharacters) {
|
|
throw new Error('单页 OCR 输出超过字符限制')
|
|
}
|
|
return content
|
|
? {
|
|
locator,
|
|
...(pageNumber === undefined ? {} : { pageNumber }),
|
|
content,
|
|
confidence: result.confidence
|
|
}
|
|
: undefined
|
|
}
|
|
|
|
async function renderPdfPage(
|
|
page: PDFPageProxy
|
|
): Promise<ArrayBuffer> {
|
|
const baseViewport = page.getViewport({ scale: 1 })
|
|
const scale = Math.min(
|
|
2,
|
|
2200 / Math.max(baseViewport.width, baseViewport.height, 1)
|
|
)
|
|
const viewport = page.getViewport({ scale })
|
|
const canvas = new OffscreenCanvas(
|
|
Math.max(1, Math.ceil(viewport.width)),
|
|
Math.max(1, Math.ceil(viewport.height))
|
|
)
|
|
const context = canvas.getContext('2d', {
|
|
alpha: false,
|
|
willReadFrequently: true
|
|
})
|
|
if (!context) {
|
|
canvas.width = 0
|
|
canvas.height = 0
|
|
throw new Error('无法创建 PDF 页面渲染画布')
|
|
}
|
|
try {
|
|
await page.render({
|
|
canvas: canvas as unknown as HTMLCanvasElement,
|
|
canvasContext: context as unknown as CanvasRenderingContext2D,
|
|
viewport
|
|
}).promise
|
|
const blob = await canvas.convertToBlob({
|
|
type: 'image/png'
|
|
})
|
|
return await blob.arrayBuffer()
|
|
} finally {
|
|
canvas.width = 0
|
|
canvas.height = 0
|
|
}
|
|
}
|
|
|
|
async function recognizePdf(
|
|
request: DocumentOcrRequest
|
|
): Promise<DocumentOcrResult> {
|
|
worker.postMessage({
|
|
type: 'progress',
|
|
requestId: request.requestId
|
|
} satisfies WorkerOutput)
|
|
const pdfjs = await import('pdfjs-dist')
|
|
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
|
|
const loadingTask = pdfjs.getDocument(
|
|
createWorkerPdfLoadingParameters(request.data)
|
|
)
|
|
const document = await loadingTask.promise
|
|
if (document.numPages > maximumPdfPageCount) {
|
|
await loadingTask.destroy()
|
|
throw new Error(
|
|
`PDF 有 ${document.numPages} 页,超过 ${maximumPdfPageCount} 页限制`
|
|
)
|
|
}
|
|
if (!request.pageNumbers && document.numPages > request.maximumPages) {
|
|
await loadingTask.destroy()
|
|
throw new Error(
|
|
`PDF 有 ${document.numPages} 页需要 OCR,超过 ${request.maximumPages} 页限制`
|
|
)
|
|
}
|
|
const selectedPages = new Set(
|
|
request.pageNumbers ??
|
|
Array.from(
|
|
{ length: document.numPages },
|
|
(_, index) => index + 1
|
|
)
|
|
)
|
|
const invalidPage = [...selectedPages].find(
|
|
(pageNumber) => pageNumber > document.numPages
|
|
)
|
|
if (invalidPage !== undefined) {
|
|
await loadingTask.destroy()
|
|
throw new Error(`PDF 不包含第 ${invalidPage} 页`)
|
|
}
|
|
if (selectedPages.size > request.maximumPages) {
|
|
await loadingTask.destroy()
|
|
throw new Error(
|
|
`PDF 有 ${selectedPages.size} 页需要 OCR,超过 ${request.maximumPages} 页限制`
|
|
)
|
|
}
|
|
const sections: DocumentOcrResult['sections'] = []
|
|
const warnings: string[] = []
|
|
let extractedCharacters = 0
|
|
try {
|
|
for (const pageNumber of [...selectedPages].sort(
|
|
(left, right) => left - right
|
|
)) {
|
|
worker.postMessage({
|
|
type: 'progress',
|
|
requestId: request.requestId,
|
|
pageNumber
|
|
} satisfies WorkerOutput)
|
|
const page = await document.getPage(pageNumber)
|
|
try {
|
|
const section = await recognizeImage(
|
|
await renderPdfPage(page),
|
|
`第 ${pageNumber} 页`,
|
|
pageNumber
|
|
)
|
|
if (section) {
|
|
extractedCharacters += section.content.length
|
|
if (
|
|
extractedCharacters >
|
|
maximumDocumentExtractedCharacters
|
|
) {
|
|
throw new Error('OCR 输出超过文档字符限制')
|
|
}
|
|
sections.push(section)
|
|
} else if (warnings.length < maximumDocumentParsingWarnings) {
|
|
warnings.push(`第 ${pageNumber} 页未识别到文字`)
|
|
}
|
|
} finally {
|
|
page.cleanup()
|
|
}
|
|
}
|
|
} finally {
|
|
await loadingTask.destroy()
|
|
}
|
|
return {
|
|
requestId: request.requestId,
|
|
sections,
|
|
pageCount: document.numPages,
|
|
warnings
|
|
}
|
|
}
|
|
|
|
async function recognize(request: DocumentOcrRequest): Promise<DocumentOcrResult> {
|
|
if (request.mimeType === 'application/pdf') {
|
|
return recognizePdf(request)
|
|
}
|
|
worker.postMessage({
|
|
type: 'progress',
|
|
requestId: request.requestId,
|
|
pageNumber: 1
|
|
} satisfies WorkerOutput)
|
|
const section = await recognizeImage(request.data, '图片')
|
|
return {
|
|
requestId: request.requestId,
|
|
sections: section ? [section] : [],
|
|
pageCount: 1,
|
|
warnings: section ? [] : ['图片中未识别到文字']
|
|
}
|
|
}
|
|
|
|
worker.addEventListener('message', (event: MessageEvent<WorkerInput>) => {
|
|
const input = event.data
|
|
if (input.type === 'initialize') {
|
|
void initialize(input.assets).then(
|
|
() => worker.postMessage({ type: 'ready' } satisfies WorkerOutput),
|
|
(error: unknown) =>
|
|
worker.postMessage({
|
|
type: 'error',
|
|
error: safeWorkerError(error)
|
|
} satisfies WorkerOutput)
|
|
)
|
|
return
|
|
}
|
|
void recognize(input.request).then(
|
|
(result) =>
|
|
worker.postMessage({
|
|
type: 'result',
|
|
result
|
|
} satisfies WorkerOutput),
|
|
(error: unknown) =>
|
|
worker.postMessage({
|
|
type: 'error',
|
|
requestId: input.request.requestId,
|
|
error: safeWorkerError(error)
|
|
} satisfies WorkerOutput)
|
|
)
|
|
})
|
|
|
|
export {}
|