mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-08 22:39:36 +08:00
Merge branch 'feat/ocr' into feat/ocr-translate
This commit is contained in:
commit
64c55385a4
@ -197,6 +197,12 @@ export enum FeedUrl {
|
|||||||
GITHUB_LATEST = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download'
|
GITHUB_LATEST = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const tesseractLangs = ['chi_sim', 'chi_tra', 'eng']
|
||||||
|
export enum TesseractLangsDownloadUrl {
|
||||||
|
CN = 'https://gitcode.com/beyondkmp/tessdata/releases/download/4.1.0/',
|
||||||
|
GLOBAL = 'https://github.com/tesseract-ocr/tessdata/raw/main/'
|
||||||
|
}
|
||||||
|
|
||||||
export enum UpgradeChannel {
|
export enum UpgradeChannel {
|
||||||
LATEST = 'latest', // 最新稳定版本
|
LATEST = 'latest', // 最新稳定版本
|
||||||
RC = 'rc', // 公测版本
|
RC = 'rc', // 公测版本
|
||||||
|
|||||||
@ -30,7 +30,7 @@ import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceServic
|
|||||||
import NotificationService from './services/NotificationService'
|
import NotificationService from './services/NotificationService'
|
||||||
import * as NutstoreService from './services/NutstoreService'
|
import * as NutstoreService from './services/NutstoreService'
|
||||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||||
import { ipcOcr } from './services/ocr/OcrService'
|
import { ocrService } from './services/ocr/OcrService'
|
||||||
import { proxyManager } from './services/ProxyManager'
|
import { proxyManager } from './services/ProxyManager'
|
||||||
import { pythonService } from './services/PythonService'
|
import { pythonService } from './services/PythonService'
|
||||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||||
@ -713,5 +713,5 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run)
|
ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run)
|
||||||
|
|
||||||
// OCR
|
// OCR
|
||||||
ipcMain.handle(IpcChannel.OCR_ocr, ipcOcr)
|
ipcMain.handle(IpcChannel.OCR_ocr, (_, ...args: Parameters<typeof ocrService.ocr>) => ocrService.ocr(...args))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,91 +1,30 @@
|
|||||||
import { loggerService } from '@logger'
|
import { BuiltinOcrProviderIds, FileMetadata, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
|
||||||
import { MB } from '@shared/config/constant'
|
|
||||||
import {
|
|
||||||
ImageFileMetadata,
|
|
||||||
ImageOcrProvider,
|
|
||||||
isBuiltinOcrProvider,
|
|
||||||
isImageFile,
|
|
||||||
isImageOcrProvider,
|
|
||||||
OcrProvider,
|
|
||||||
OcrResult,
|
|
||||||
SupportedOcrFile
|
|
||||||
} from '@types'
|
|
||||||
import { statSync } from 'fs'
|
|
||||||
import { readFile } from 'fs/promises'
|
|
||||||
|
|
||||||
import { getTesseractWorker } from './tesseract/TesseractService'
|
import { tesseractService } from './tesseract/TesseractService'
|
||||||
|
|
||||||
const logger = loggerService.withContext('main:OcrService')
|
type OcrHandler = (file: FileMetadata) => Promise<OcrResult>
|
||||||
|
|
||||||
/**
|
export class OcrService {
|
||||||
* ocr by tesseract
|
private registry: Map<string, OcrHandler> = new Map()
|
||||||
* @param file image file or base64 string
|
|
||||||
* @returns ocr result
|
register(providerId: string, handler: OcrHandler): void {
|
||||||
* @throws {Error}
|
this.registry.set(providerId, handler)
|
||||||
*/
|
}
|
||||||
const tesseractOcr = async (file: ImageFileMetadata | string): Promise<Tesseract.RecognizeResult> => {
|
|
||||||
try {
|
unregister(providerId: string): void {
|
||||||
const worker = await getTesseractWorker()
|
this.registry.delete(providerId)
|
||||||
let ret: Tesseract.RecognizeResult
|
}
|
||||||
if (typeof file === 'string') {
|
|
||||||
ret = await worker.recognize(file)
|
public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> {
|
||||||
} else {
|
const handler = this.registry.get(provider.id)
|
||||||
const stat = statSync(file.path)
|
if (!handler) {
|
||||||
if (stat.size > 50 * MB) {
|
throw new Error(`Provider ${provider.id} is not registered`)
|
||||||
throw new Error('This image is too large (max 50MB)')
|
|
||||||
}
|
|
||||||
const buffer = await readFile(file.path)
|
|
||||||
ret = await worker.recognize(buffer)
|
|
||||||
}
|
}
|
||||||
return ret
|
return handler(file)
|
||||||
} catch (e) {
|
|
||||||
logger.error('Failed to ocr with tesseract.', e as Error)
|
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const ocrService = new OcrService()
|
||||||
* ocr image file
|
|
||||||
* @param file image file
|
|
||||||
* @param provider ocr provider that supports image ocr
|
|
||||||
* @returns ocr result
|
|
||||||
* @throws {Error}
|
|
||||||
*/
|
|
||||||
const imageOcr = async (file: ImageFileMetadata, provider: ImageOcrProvider): Promise<OcrResult> => {
|
|
||||||
if (isBuiltinOcrProvider(provider)) {
|
|
||||||
if (provider.id === 'tesseract') {
|
|
||||||
const result = await tesseractOcr(file)
|
|
||||||
return { text: result.data.text }
|
|
||||||
} else {
|
|
||||||
throw new Error(`Unsupported built-in ocr provider: ${provider.id}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`Provider ${provider.id} is not supported.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Register built-in providers
|
||||||
* ocr a file
|
ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(tesseractService))
|
||||||
* @param file any supported file
|
|
||||||
* @param provider ocr provider
|
|
||||||
* @returns ocr result
|
|
||||||
* @throws {Error}
|
|
||||||
*/
|
|
||||||
export const ocr = async (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> => {
|
|
||||||
if (isImageFile(file) && isImageOcrProvider(provider)) {
|
|
||||||
return imageOcr(file, provider)
|
|
||||||
} else {
|
|
||||||
throw new Error(`File type and provider capability is not matched, otherwise one of them is not supported.`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ocr a file
|
|
||||||
* @param _ ipc event
|
|
||||||
* @param file any supported file
|
|
||||||
* @param provider ocr provider
|
|
||||||
* @returns ocr result
|
|
||||||
* @throws {Error}
|
|
||||||
*/
|
|
||||||
export const ipcOcr = async (_: Electron.IpcMainInvokeEvent, ...args: Parameters<typeof ocr>) => {
|
|
||||||
return ocr(...args)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
|
import { getIpCountry } from '@main/utils/ipService'
|
||||||
|
import { MB, TesseractLangsDownloadUrl } from '@shared/config/constant'
|
||||||
|
import { FileMetadata, ImageFileMetadata, isImageFile, OcrResult } from '@types'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
import Tesseract, { createWorker } from 'tesseract.js'
|
import Tesseract, { createWorker } from 'tesseract.js'
|
||||||
|
|
||||||
const logger = loggerService.withContext('TesseractService')
|
const logger = loggerService.withContext('TesseractService')
|
||||||
|
|
||||||
let worker: Tesseract.Worker | null = null
|
|
||||||
|
|
||||||
// const languageCodeMap: Record<string, string> = {
|
// const languageCodeMap: Record<string, string> = {
|
||||||
// 'af-za': 'afr',
|
// 'af-za': 'afr',
|
||||||
// 'am-et': 'amh',
|
// 'am-et': 'amh',
|
||||||
@ -110,20 +114,65 @@ let worker: Tesseract.Worker | null = null
|
|||||||
// 'yi-us': 'yid'
|
// 'yi-us': 'yid'
|
||||||
// }
|
// }
|
||||||
|
|
||||||
export const getTesseractWorker = async (): Promise<Tesseract.Worker> => {
|
export class TesseractService {
|
||||||
if (!worker) {
|
private worker: Tesseract.Worker | null = null
|
||||||
// for now, only support limited languages
|
|
||||||
worker = await createWorker(['chi_sim', 'chi_tra', 'eng'], undefined, {
|
async getWorker(): Promise<Tesseract.Worker> {
|
||||||
// langPath: getCacheDir(),
|
if (!this.worker) {
|
||||||
logger: (m) => logger.debug('From worker', m)
|
// for now, only support limited languages
|
||||||
})
|
this.worker = await createWorker(['chi_sim', 'chi_tra', 'eng'], undefined, {
|
||||||
|
langPath: await this._getLangPath(),
|
||||||
|
cachePath: await this._getCacheDir(),
|
||||||
|
gzip: false,
|
||||||
|
logger: (m) => logger.debug('From worker', m)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return this.worker
|
||||||
|
}
|
||||||
|
|
||||||
|
async imageOcr(file: ImageFileMetadata): Promise<OcrResult> {
|
||||||
|
const worker = await this.getWorker()
|
||||||
|
const stat = await fs.promises.stat(file.path)
|
||||||
|
if (stat.size > 50 * MB) {
|
||||||
|
throw new Error('This image is too large (max 50MB)')
|
||||||
|
}
|
||||||
|
const buffer = await fs.promises.readFile(file.path)
|
||||||
|
const result = await worker.recognize(buffer)
|
||||||
|
return { text: result.data.text }
|
||||||
|
}
|
||||||
|
|
||||||
|
async ocr(file: FileMetadata): Promise<OcrResult> {
|
||||||
|
if (!isImageFile(file)) {
|
||||||
|
throw new Error('Only image files are supported currently')
|
||||||
|
}
|
||||||
|
return this.imageOcr(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _getLangPath(): Promise<string> {
|
||||||
|
const country = await getIpCountry()
|
||||||
|
return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : TesseractLangsDownloadUrl.GLOBAL
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _getCacheDir(): Promise<string> {
|
||||||
|
const cacheDir = path.join(app.getPath('userData'), 'tesseract')
|
||||||
|
// use access to check if the directory exists
|
||||||
|
if (
|
||||||
|
!(await fs.promises
|
||||||
|
.access(cacheDir, fs.constants.F_OK)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false))
|
||||||
|
) {
|
||||||
|
await fs.promises.mkdir(cacheDir, { recursive: true })
|
||||||
|
}
|
||||||
|
return cacheDir
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
if (this.worker) {
|
||||||
|
await this.worker.terminate()
|
||||||
|
this.worker = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return worker
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const disposeTesseractWorker = async () => {
|
export const tesseractService = new TesseractService()
|
||||||
if (worker) {
|
|
||||||
await worker.terminate()
|
|
||||||
worker = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2182,7 +2182,7 @@ const migrateConfig = {
|
|||||||
state.ocr.imageProvider = DEFAULT_OCR_PROVIDER.image
|
state.ocr.imageProvider = DEFAULT_OCR_PROVIDER.image
|
||||||
return state
|
return state
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('migrate 136 error', error as Error)
|
logger.error('migrate 137 error', error as Error)
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user