From f95b9cef777d71cd80299de5e2e05266451d0ac5 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Thu, 28 Aug 2025 15:28:27 +0800 Subject: [PATCH] feat: System (MacOS & Windows) OCR (#9572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: 添加 macOS 系统 OCR 作为可选依赖 * refactor: 移动TesseractService * feat(ocr): 添加MacOS Vision OCR支持并优化类型定义 添加对MacOS Vision OCR的支持,同时重构OCR相关类型定义以提升可维护性。新增PDF文件元数据类型为后续功能做准备。 * refactor(types): 重命名 isImageFile 为 isImageFileMetadata 以更准确描述功能 * refactor(ocr): 更新导入 * feat(ocr): 实现MacOS Vision OCR服务并重构OCR基础结构 添加MacOcrService以支持MacOS Vision OCR功能 创建OcrBaseService作为OCR服务的基类 清理MacOS OCR配置中的冗余字段 * fix(store): 更新持久化存储版本至138并添加MAC OCR提供者 添加内置OCR提供者支持并清空翻译输入框 * chore: 更新 @cherrystudio/mac-system-ocr 依赖至 0.2.4 版本 * feat(ocr): 添加 macOS 原生 OCR 服务支持 添加 macOS 原生 OCR 服务作为内置 OCR 提供商 在设置页面显示不可配置提示 添加相关 logo 和翻译文本 * build: 将 @cherrystudio/mac-system-ocr 从可选依赖移至常规依赖 * fix(ocr): 临时使用any类型替代平台特定依赖的类型定义 为了避免在Linux上运行类型检查CI时抛出错误,暂时将MacOCR属性的类型从平台特定依赖的类型定义改为any类型 * refactor(build): 将mac-system-ocr移至optionalDependencies并更新vite配置 将@cherrystudio/mac-system-ocr从dependencies移至optionalDependencies 更新electron.vite.config.ts中的external配置以包含该依赖 * feat(OCR设置): 根据平台过滤OCR提供商选项 添加平台检测逻辑,在非Mac平台隐藏Mac内置OCR提供商选项 * feat(OCR): 添加非MacOS系统的错误提示 在OCR图片设置中添加对非MacOS系统的错误提示,当用户尝试在非Mac系统上使用OCR功能时显示错误标签 * feat(i18n): 添加 OCR 相关多语言翻译 为 OCR 功能添加错误提示和配置项的多语言翻译,包括非 MacOS 系统提示和无配置项提示 * fix(MacOcrService): 忽略macOS专属模块的类型检查错误 添加@ts-ignore注释以避免在非macOS平台上的类型检查错误,该模块仅在macOS上可用 * build: 添加 @napi-rs/system-ocr 依赖以支持OCR功能 * chore: 移除未使用的mac-system-ocr依赖 * refactor(ocr): 将 MacOS OCR 重构为跨平台的系统 OCR 重构 OCR 服务,将原本仅支持 MacOS 的 OCR 功能扩展为支持 Windows 和 MacOS 的系统 OCR 更新相关类型定义、配置和界面适配 * feat(hooks): 添加设置图片OCR提供商的功能 * refactor(ocr): 重构OCR提供者相关逻辑,优化代码结构 - 将OCR提供者相关工具函数和hook合并到useOcrProvider中 - 替换mac提供者为system提供者 - 优化OCR设置界面的错误处理和UI展示 - 删除不再使用的ocr.ts工具文件 * refactor(OCR设置): 移除多余的SettingGroup包装并优化provider设置逻辑 移除OcrSettings中多余的SettingGroup包装,将主题样式直接应用于OcrProviderSettings组件 优化OcrProviderSettings逻辑,对于system provider直接返回null * fix(i18n): 移除OCR服务中不可配置项的翻译并更新系统OCR支持提示 * fix(ocr): 根据系统平台设置默认OCR提供商 在Windows和Mac平台上使用系统OCR作为默认提供商,其他平台继续使用Tesseract * build: 从外部依赖中移除 @cherrystudio/mac-system-ocr * fix(i18n): 更新多语言OCR相关翻译 * fix(store): 在迁移配置中移除翻译输入的清空操作 * refactor(hooks): 将 getOcrProviderLogo 重命名为 OcrProviderLogo 并改为组件形式 将 useOcrProviders 中的 getOcrProviderLogo 函数重构为 OcrProviderLogo 组件 更新 OcrProviderSettings 中对应的调用方式 * support jpg * refactor(ocr): 重构OCR服务基础结构并支持多语言配置 重构OCR基础服务类,提取公共接口为抽象类 为系统OCR和Tesseract服务添加多语言配置支持 * refactor(ocr): 重构OCR类型定义以提高可维护性 将OcrProviderConfig拆分为基础配置和具体实现配置类型 优化类型结构以更清晰地区分不同OCR提供者的配置 * feat(组件): 新增错误标签组件 ErrorTag * refactor(ocr): 替换自定义标签组件为ErrorTag组件以简化代码 * fix(ocr): 在macOS下忽略语言参数 * feat(组件): 添加警告标签组件用于显示警告信息 * feat(ocr): 添加系统OCR支持并优化语言配置 - 新增系统OCR设置组件,支持Windows和MacOS平台 - 为系统OCR添加语言选择功能,Windows需配置语言包 - 创建SuccessTag组件用于显示配置状态 - 统一OCR语言设置相关翻译键名 - 修复系统OCR在非Windows/Mac平台下的显示问题 * feat(i18n): 添加 OCR 设置页面的多语言支持 为 OCR 设置页面添加了新的多语言翻译,包括支持的语言列表和系统 OCR 的相关提示信息 * feat(ocr): 支持自定义 Tesseract OCR 语言选择 添加 Tesseract OCR 语言映射配置和动态语言选择功能 在设置界面实现多语言选择器,支持用户自定义 OCR 语言 更新相关类型定义和工具提示信息 * docs(i18n): 为Tesseract OCR添加自定义语言支持提示文本 * fix(i18n): 移除OCR服务中临时语言支持提示 * fix(ocr): 修复OCR服务未传递provider配置的问题 * fix(ocr): 修复OCR服务未传递provider配置的问题 * fix(TesseractService): 修复worker没有显式dispose的问题 * feat(拖拽): 在useDrag钩子中暴露setIsDragging方法 允许外部组件直接控制拖拽状态,用于在TranslatePage中处理文件拖放时重置拖拽状态 * feat(i18n): 更新输入框占位文本以支持OCR功能 * fix(ocr): 添加错误处理并记录日志以改进Tesseract服务 在TesseractService中添加错误处理回调函数,捕获并抛出worker创建过程中的错误 同时增加调试日志以跟踪语言数组和worker创建过程 * refactor(ocr): 重构OCR状态管理,使用ID引用图像提供者并添加选择器 将imageProvider字段改为imageProviderId以简化状态管理 添加getImageProvider选择器方便获取当前图像提供者 * update cn data * refactor(ocr): 重构OCR提供者管理逻辑,使用自定义hook统一处理 - 将OCR提供者状态管理从Redux迁移到自定义hook useOcrProviders - 修复默认OCR提供者初始化问题 - 优化OCR图片识别逻辑,使用useCallback提升性能 * fix(ocr): 修复Tesseract worker初始化错误处理逻辑 重构worker初始化流程,使用Promise处理错误而非全局变量 修正非CN地区语言包下载URL为空的问题 * fix(ocr): 修复url * feat(OCR设置): 在Tesseract语言选择器中添加自定义标签渲染 添加CustomTag组件以禁用默认的关闭操作 * refactor(translate): 优化拖拽上传文件的hooks调用顺序 将useDrag hooks的声明移到使用位置附近,提高代码可读性 * perf(ocr): 移除不必要的await提升图像预处理性能 * feat(translate): 添加文本文件类型检查并优化文件处理逻辑 在翻译页面中增加对文本文件类型的检查,避免处理非文本文件。同时优化文件处理流程,包括错误处理和加载状态管理。 * feat(i18n): 添加文件类型检查错误的多语言翻译 * docs(i18n): 更新输入框占位符文本以更清晰描述支持的功能 --------- Co-authored-by: beyondkmp --- package.json | 1 + src/main/services/ocr/OcrService.ts | 6 +- .../services/ocr/builtin/OcrBaseService.ts | 5 + .../services/ocr/builtin/SystemOcrService.ts | 39 +++++ .../services/ocr/builtin/TesseractService.ts | 115 ++++++++++++++ .../ocr/tesseract/TesseractService.ts | 82 ---------- src/main/utils/ocr.ts | 7 +- src/renderer/src/components/InfoPopover.tsx | 20 +++ src/renderer/src/components/Tags/ErrorTag.tsx | 16 ++ .../src/components/Tags/SuccessTag.tsx | 16 ++ src/renderer/src/components/Tags/WarnTag.tsx | 18 +++ src/renderer/src/config/ocr.ts | 134 +++++++++++++++- src/renderer/src/hooks/useDrag.ts | 2 +- src/renderer/src/hooks/useOcr.ts | 20 ++- src/renderer/src/hooks/useOcrProvider.ts | 84 ---------- src/renderer/src/hooks/useOcrProvider.tsx | 148 ++++++++++++++++++ src/renderer/src/i18n/label.ts | 13 +- src/renderer/src/i18n/locales/en-us.json | 25 ++- src/renderer/src/i18n/locales/ja-jp.json | 25 ++- src/renderer/src/i18n/locales/ru-ru.json | 25 ++- src/renderer/src/i18n/locales/zh-cn.json | 25 ++- src/renderer/src/i18n/locales/zh-tw.json | 25 ++- src/renderer/src/i18n/translate/el-gr.json | 26 ++- src/renderer/src/i18n/translate/es-es.json | 26 ++- src/renderer/src/i18n/translate/fr-fr.json | 26 ++- src/renderer/src/i18n/translate/pt-pt.json | 26 ++- .../DocProcessSettings/OcrImageSettings.tsx | 38 +++-- .../OcrProviderSettings.tsx | 40 +++-- .../DocProcessSettings/OcrSettings.tsx | 8 +- .../DocProcessSettings/OcrSystemSettings.tsx | 78 +++++++++ .../OcrTesseractSettings.tsx | 66 ++++++-- .../src/pages/translate/TranslatePage.tsx | 67 +++++--- src/renderer/src/services/ocr/OcrService.ts | 2 +- src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 21 ++- src/renderer/src/store/ocr.ts | 19 ++- src/renderer/src/types/file.ts | 6 +- src/renderer/src/types/index.ts | 1 + src/renderer/src/types/ocr.ts | 58 +++++-- src/renderer/src/utils/file.ts | 7 +- src/renderer/src/utils/ocr.ts | 12 -- yarn.lock | 50 ++++++ 42 files changed, 1099 insertions(+), 331 deletions(-) create mode 100644 src/main/services/ocr/builtin/OcrBaseService.ts create mode 100644 src/main/services/ocr/builtin/SystemOcrService.ts create mode 100644 src/main/services/ocr/builtin/TesseractService.ts delete mode 100644 src/main/services/ocr/tesseract/TesseractService.ts create mode 100644 src/renderer/src/components/InfoPopover.tsx create mode 100644 src/renderer/src/components/Tags/ErrorTag.tsx create mode 100644 src/renderer/src/components/Tags/SuccessTag.tsx create mode 100644 src/renderer/src/components/Tags/WarnTag.tsx delete mode 100644 src/renderer/src/hooks/useOcrProvider.ts create mode 100644 src/renderer/src/hooks/useOcrProvider.tsx create mode 100644 src/renderer/src/pages/settings/DocProcessSettings/OcrSystemSettings.tsx delete mode 100644 src/renderer/src/utils/ocr.ts diff --git a/package.json b/package.json index 445b572adf..1c38f1e50f 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "dependencies": { "@libsql/client": "0.14.0", "@libsql/win32-x64-msvc": "^0.4.7", + "@napi-rs/system-ocr": "^1.0.2", "@strongtz/win32-arm64-msvc": "^0.4.7", "graceful-fs": "^4.2.11", "jsdom": "26.1.0", diff --git a/src/main/services/ocr/OcrService.ts b/src/main/services/ocr/OcrService.ts index 6ac8c311e3..0d7383a24a 100644 --- a/src/main/services/ocr/OcrService.ts +++ b/src/main/services/ocr/OcrService.ts @@ -1,7 +1,8 @@ import { loggerService } from '@logger' import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types' -import { tesseractService } from './tesseract/TesseractService' +import { systemOcrService } from './builtin/SystemOcrService' +import { tesseractService } from './builtin/TesseractService' const logger = loggerService.withContext('OcrService') @@ -24,7 +25,7 @@ export class OcrService { if (!handler) { throw new Error(`Provider ${provider.id} is not registered`) } - return handler(file) + return handler(file, provider.config) } } @@ -32,3 +33,4 @@ export const ocrService = new OcrService() // Register built-in providers ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(tesseractService)) +ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService)) diff --git a/src/main/services/ocr/builtin/OcrBaseService.ts b/src/main/services/ocr/builtin/OcrBaseService.ts new file mode 100644 index 0000000000..9c36e79c3a --- /dev/null +++ b/src/main/services/ocr/builtin/OcrBaseService.ts @@ -0,0 +1,5 @@ +import { OcrHandler } from '@types' + +export abstract class OcrBaseService { + abstract ocr: OcrHandler +} diff --git a/src/main/services/ocr/builtin/SystemOcrService.ts b/src/main/services/ocr/builtin/SystemOcrService.ts new file mode 100644 index 0000000000..cda52bfec6 --- /dev/null +++ b/src/main/services/ocr/builtin/SystemOcrService.ts @@ -0,0 +1,39 @@ +import { isMac, isWin } from '@main/constant' +import { loadOcrImage } from '@main/utils/ocr' +import { OcrAccuracy, recognize } from '@napi-rs/system-ocr' +import { + ImageFileMetadata, + isImageFileMetadata as isImageFileMetadata, + OcrResult, + OcrSystemConfig, + SupportedOcrFile +} from '@types' + +import { OcrBaseService } from './OcrBaseService' + +// const logger = loggerService.withContext('SystemOcrService') +export class SystemOcrService extends OcrBaseService { + constructor() { + super() + if (!isWin && !isMac) { + throw new Error('System OCR is only supported on Windows and macOS') + } + } + + private async ocrImage(file: ImageFileMetadata, options?: OcrSystemConfig): Promise { + const buffer = await loadOcrImage(file) + const langs = isWin ? options?.langs : undefined + const result = await recognize(buffer, OcrAccuracy.Accurate, langs) + return { text: result.text } + } + + public ocr = async (file: SupportedOcrFile, options?: OcrSystemConfig): Promise => { + if (isImageFileMetadata(file)) { + return this.ocrImage(file, options) + } else { + throw new Error('Unsupported file type, currently only image files are supported') + } + } +} + +export const systemOcrService = new SystemOcrService() diff --git a/src/main/services/ocr/builtin/TesseractService.ts b/src/main/services/ocr/builtin/TesseractService.ts new file mode 100644 index 0000000000..9fd7bbcf01 --- /dev/null +++ b/src/main/services/ocr/builtin/TesseractService.ts @@ -0,0 +1,115 @@ +import { loggerService } from '@logger' +import { getIpCountry } from '@main/utils/ipService' +import { loadOcrImage } from '@main/utils/ocr' +import { MB } from '@shared/config/constant' +import { ImageFileMetadata, isImageFileMetadata, OcrResult, OcrTesseractConfig, SupportedOcrFile } from '@types' +import { app } from 'electron' +import fs from 'fs' +import { isEqual } from 'lodash' +import path from 'path' +import Tesseract, { createWorker, LanguageCode } from 'tesseract.js' + +import { OcrBaseService } from './OcrBaseService' + +const logger = loggerService.withContext('TesseractService') + +// config +const MB_SIZE_THRESHOLD = 50 +const defaultLangs = ['chi_sim', 'chi_tra', 'eng'] satisfies LanguageCode[] +enum TesseractLangsDownloadUrl { + CN = 'https://gitcode.com/beyondkmp/tessdata-best/releases/download/1.0.0/' +} + +export class TesseractService extends OcrBaseService { + private worker: Tesseract.Worker | null = null + private previousLangs: OcrTesseractConfig['langs'] + + constructor() { + super() + this.previousLangs = {} + } + + async getWorker(options?: OcrTesseractConfig): Promise { + let langsArray: LanguageCode[] + if (options?.langs) { + // TODO: use type safe objectKeys + langsArray = Object.keys(options.langs) as LanguageCode[] + if (langsArray.length === 0) { + logger.warn('Empty langs option. Fallback to defaultLangs.') + langsArray = defaultLangs + } + } else { + langsArray = defaultLangs + } + logger.debug('langsArray', langsArray) + if (!this.worker || !isEqual(this.previousLangs, langsArray)) { + if (this.worker) { + await this.dispose() + } + logger.debug('use langsArray to create worker', langsArray) + const langPath = await this._getLangPath() + const cachePath = await this._getCacheDir() + const promise = new Promise((resolve, reject) => { + createWorker(langsArray, undefined, { + langPath, + cachePath, + logger: (m) => logger.debug('From worker', m), + errorHandler: (e) => { + logger.error('Worker Error', e) + reject(e) + } + }) + .then(resolve) + .catch(reject) + }) + this.worker = await promise + } + return this.worker + } + + private async imageOcr(file: ImageFileMetadata, options?: OcrTesseractConfig): Promise { + const worker = await this.getWorker(options) + const stat = await fs.promises.stat(file.path) + if (stat.size > MB_SIZE_THRESHOLD * MB) { + throw new Error(`This image is too large (max ${MB_SIZE_THRESHOLD}MB)`) + } + const buffer = await loadOcrImage(file) + const result = await worker.recognize(buffer) + return { text: result.data.text } + } + + public ocr = async (file: SupportedOcrFile, options?: OcrTesseractConfig): Promise => { + if (!isImageFileMetadata(file)) { + throw new Error('Only image files are supported currently') + } + return this.imageOcr(file, options) + } + + private async _getLangPath(): Promise { + const country = await getIpCountry() + return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : '' + } + + private async _getCacheDir(): Promise { + 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 { + if (this.worker) { + await this.worker.terminate() + this.worker = null + } + } +} + +export const tesseractService = new TesseractService() diff --git a/src/main/services/ocr/tesseract/TesseractService.ts b/src/main/services/ocr/tesseract/TesseractService.ts deleted file mode 100644 index d2ba6d2ed8..0000000000 --- a/src/main/services/ocr/tesseract/TesseractService.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { loggerService } from '@logger' -import { getIpCountry } from '@main/utils/ipService' -import { loadOcrImage } from '@main/utils/ocr' -import { MB } from '@shared/config/constant' -import { ImageFileMetadata, isImageFile, OcrResult, SupportedOcrFile } from '@types' -import { app } from 'electron' -import fs from 'fs' -import path from 'path' -import Tesseract, { createWorker, LanguageCode } from 'tesseract.js' - -const logger = loggerService.withContext('TesseractService') - -// config -const MB_SIZE_THRESHOLD = 50 -const tesseractLangs = ['chi_sim', 'chi_tra', 'eng'] satisfies LanguageCode[] -enum TesseractLangsDownloadUrl { - CN = 'https://gitcode.com/beyondkmp/tessdata/releases/download/4.1.0/', - GLOBAL = 'https://github.com/tesseract-ocr/tessdata/raw/main/' -} - -export class TesseractService { - private worker: Tesseract.Worker | null = null - - async getWorker(): Promise { - if (!this.worker) { - // for now, only support limited languages - this.worker = await createWorker(tesseractLangs, 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 { - const worker = await this.getWorker() - const stat = await fs.promises.stat(file.path) - if (stat.size > MB_SIZE_THRESHOLD * MB) { - throw new Error(`This image is too large (max ${MB_SIZE_THRESHOLD}MB)`) - } - const buffer = await loadOcrImage(file) - const result = await worker.recognize(buffer) - return { text: result.data.text } - } - - async ocr(file: SupportedOcrFile): Promise { - if (!isImageFile(file)) { - throw new Error('Only image files are supported currently') - } - return this.imageOcr(file) - } - - private async _getLangPath(): Promise { - const country = await getIpCountry() - return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : TesseractLangsDownloadUrl.GLOBAL - } - - private async _getCacheDir(): Promise { - 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 { - if (this.worker) { - await this.worker.terminate() - this.worker = null - } - } -} - -export const tesseractService = new TesseractService() diff --git a/src/main/utils/ocr.ts b/src/main/utils/ocr.ts index ca63e82f07..446fbe63d6 100644 --- a/src/main/utils/ocr.ts +++ b/src/main/utils/ocr.ts @@ -2,11 +2,12 @@ import { ImageFileMetadata } from '@types' import { readFile } from 'fs/promises' import sharp from 'sharp' -const preprocessImage = async (buffer: Buffer) => { - return await sharp(buffer) +const preprocessImage = async (buffer: Buffer): Promise => { + return sharp(buffer) .grayscale() // 转为灰度 .normalize() .sharpen() + .png({ quality: 100 }) .toBuffer() } @@ -23,5 +24,5 @@ const preprocessImage = async (buffer: Buffer) => { */ export const loadOcrImage = async (file: ImageFileMetadata): Promise => { const buffer = await readFile(file.path) - return await preprocessImage(buffer) + return preprocessImage(buffer) } diff --git a/src/renderer/src/components/InfoPopover.tsx b/src/renderer/src/components/InfoPopover.tsx new file mode 100644 index 0000000000..888aefc702 --- /dev/null +++ b/src/renderer/src/components/InfoPopover.tsx @@ -0,0 +1,20 @@ +import { Popover, PopoverProps } from 'antd' +import { Info } from 'lucide-react' + +type InheritedPopoverProps = Omit + +interface InfoPopoverProps extends InheritedPopoverProps { + iconColor?: string + iconSize?: string | number + iconStyle?: React.CSSProperties +} + +const InfoPopover = ({ iconColor = 'var(--color-text-3)', iconSize = 14, iconStyle, ...rest }: InfoPopoverProps) => { + return ( + + + + ) +} + +export default InfoPopover diff --git a/src/renderer/src/components/Tags/ErrorTag.tsx b/src/renderer/src/components/Tags/ErrorTag.tsx new file mode 100644 index 0000000000..54a7a4ec98 --- /dev/null +++ b/src/renderer/src/components/Tags/ErrorTag.tsx @@ -0,0 +1,16 @@ +import { CircleXIcon } from 'lucide-react' + +import CustomTag from './CustomTag' + +type Props = { + iconSize?: number + message: string +} + +export const ErrorTag = ({ iconSize: size = 14, message }: Props) => { + return ( + } color="var(--color-status-error)"> + {message} + + ) +} diff --git a/src/renderer/src/components/Tags/SuccessTag.tsx b/src/renderer/src/components/Tags/SuccessTag.tsx new file mode 100644 index 0000000000..b3ca056133 --- /dev/null +++ b/src/renderer/src/components/Tags/SuccessTag.tsx @@ -0,0 +1,16 @@ +import { CheckIcon } from 'lucide-react' + +import CustomTag from './CustomTag' + +type Props = { + iconSize?: number + message: string +} + +export const SuccessTag = ({ iconSize: size = 14, message }: Props) => { + return ( + } color="var(--color-status-success)"> + {message} + + ) +} diff --git a/src/renderer/src/components/Tags/WarnTag.tsx b/src/renderer/src/components/Tags/WarnTag.tsx new file mode 100644 index 0000000000..75a307fde0 --- /dev/null +++ b/src/renderer/src/components/Tags/WarnTag.tsx @@ -0,0 +1,18 @@ +import { AlertTriangleIcon } from 'lucide-react' + +import CustomTag from './CustomTag' + +type Props = { + iconSize?: number + message: string +} + +export const WarnTag = ({ iconSize: size = 14, message }: Props) => { + return ( + } + color="var(--color-status-warning)"> + {message} + + ) +} diff --git a/src/renderer/src/config/ocr.ts b/src/renderer/src/config/ocr.ts index b899cbb5f0..3c9693dea9 100644 --- a/src/renderer/src/config/ocr.ts +++ b/src/renderer/src/config/ocr.ts @@ -1,12 +1,16 @@ import { BuiltinOcrProvider, BuiltinOcrProviderId, - ImageOcrProvider, OcrProviderCapability, - OcrTesseractProvider + OcrSystemProvider, + OcrTesseractProvider, + TesseractLangCode, + TranslateLanguageCode } from '@renderer/types' -const tesseract: BuiltinOcrProvider & ImageOcrProvider & OcrTesseractProvider = { +import { isMac, isWin } from './constant' + +const tesseract: OcrTesseractProvider = { id: 'tesseract', name: 'Tesseract', capabilities: { @@ -19,14 +23,132 @@ const tesseract: BuiltinOcrProvider & ImageOcrProvider & OcrTesseractProvider = eng: true } } -} as const satisfies OcrTesseractProvider +} as const + +const systemOcr: OcrSystemProvider = { + id: 'system', + name: 'System', + config: { + langs: isWin ? ['en-us'] : undefined + }, + capabilities: { + image: true + // pdf: true + } +} as const satisfies OcrSystemProvider export const BUILTIN_OCR_PROVIDERS_MAP = { - tesseract + tesseract, + system: systemOcr } as const satisfies Record export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP) export const DEFAULT_OCR_PROVIDER = { - image: tesseract + image: isWin || isMac ? systemOcr : tesseract } as const satisfies Record + +export const TESSERACT_LANG_MAP: Record = { + 'af-za': 'afr', + 'am-et': 'amh', + 'ar-sa': 'ara', + 'as-in': 'asm', + 'az-az': 'aze', + 'az-cyrl-az': 'aze_cyrl', + 'be-by': 'bel', + 'bn-bd': 'ben', + 'bo-cn': 'bod', + 'bs-ba': 'bos', + 'bg-bg': 'bul', + 'ca-es': 'cat', + 'ceb-ph': 'ceb', + 'cs-cz': 'ces', + 'zh-cn': 'chi_sim', + 'zh-tw': 'chi_tra', + 'chr-us': 'chr', + 'cy-gb': 'cym', + 'da-dk': 'dan', + 'de-de': 'deu', + 'dz-bt': 'dzo', + 'el-gr': 'ell', + 'en-us': 'eng', + 'enm-gb': 'enm', + 'eo-world': 'epo', + 'et-ee': 'est', + 'eu-es': 'eus', + 'fa-ir': 'fas', + 'fi-fi': 'fin', + 'fr-fr': 'fra', + 'frk-de': 'frk', + 'frm-fr': 'frm', + 'ga-ie': 'gle', + 'gl-es': 'glg', + 'grc-gr': 'grc', + 'gu-in': 'guj', + 'ht-ht': 'hat', + 'he-il': 'heb', + 'hi-in': 'hin', + 'hr-hr': 'hrv', + 'hu-hu': 'hun', + 'iu-ca': 'iku', + 'id-id': 'ind', + 'is-is': 'isl', + 'it-it': 'ita', + 'ita-it': 'ita_old', + 'jv-id': 'jav', + 'ja-jp': 'jpn', + 'kn-in': 'kan', + 'ka-ge': 'kat', + 'kat-ge': 'kat_old', + 'kk-kz': 'kaz', + 'km-kh': 'khm', + 'ky-kg': 'kir', + 'ko-kr': 'kor', + 'ku-tr': 'kur', + 'la-la': 'lao', + 'la-va': 'lat', + 'lv-lv': 'lav', + 'lt-lt': 'lit', + 'ml-in': 'mal', + 'mr-in': 'mar', + 'mk-mk': 'mkd', + 'mt-mt': 'mlt', + 'ms-my': 'msa', + 'my-mm': 'mya', + 'ne-np': 'nep', + 'nl-nl': 'nld', + 'no-no': 'nor', + 'or-in': 'ori', + 'pa-in': 'pan', + 'pl-pl': 'pol', + 'pt-pt': 'por', + 'ps-af': 'pus', + 'ro-ro': 'ron', + 'ru-ru': 'rus', + 'sa-in': 'san', + 'si-lk': 'sin', + 'sk-sk': 'slk', + 'sl-si': 'slv', + 'es-es': 'spa', + 'spa-es': 'spa_old', + 'sq-al': 'sqi', + 'sr-rs': 'srp', + 'sr-latn-rs': 'srp_latn', + 'sw-tz': 'swa', + 'sv-se': 'swe', + 'syr-sy': 'syr', + 'ta-in': 'tam', + 'te-in': 'tel', + 'tg-tj': 'tgk', + 'tl-ph': 'tgl', + 'th-th': 'tha', + 'ti-er': 'tir', + 'tr-tr': 'tur', + 'ug-cn': 'uig', + 'uk-ua': 'ukr', + 'ur-pk': 'urd', + 'uz-uz': 'uzb', + 'uz-cyrl-uz': 'uzb_cyrl', + 'vi-vn': 'vie', + 'yi-us': 'yid' +} diff --git a/src/renderer/src/hooks/useDrag.ts b/src/renderer/src/hooks/useDrag.ts index 803d86048e..52682b7878 100644 --- a/src/renderer/src/hooks/useDrag.ts +++ b/src/renderer/src/hooks/useDrag.ts @@ -39,5 +39,5 @@ export const useDrag = (onDrop?: (e: React.DragEvent) [onDrop] ) - return { isDragging, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } + return { isDragging, setIsDragging, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } } diff --git a/src/renderer/src/hooks/useOcr.ts b/src/renderer/src/hooks/useOcr.ts index a1cbac0f8f..93350fda1d 100644 --- a/src/renderer/src/hooks/useOcr.ts +++ b/src/renderer/src/hooks/useOcr.ts @@ -1,16 +1,18 @@ import { loggerService } from '@logger' import * as OcrService from '@renderer/services/ocr/OcrService' -import { useAppSelector } from '@renderer/store' -import { ImageFileMetadata, isImageFile, SupportedOcrFile } from '@renderer/types' +import { ImageFileMetadata, isImageFileMetadata, SupportedOcrFile } from '@renderer/types' import { uuid } from '@renderer/utils' import { formatErrorMessage } from '@renderer/utils/error' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { useOcrProviders } from './useOcrProvider' + const logger = loggerService.withContext('useOcr') export const useOcr = () => { const { t } = useTranslation() - const imageProvider = useAppSelector((state) => state.ocr.imageProvider) + const { imageProvider } = useOcrProviders() /** * 对图片文件进行OCR识别 @@ -18,9 +20,13 @@ export const useOcr = () => { * @returns OCR识别结果的Promise * @throws OCR失败时抛出错误 */ - const ocrImage = async (image: ImageFileMetadata) => { - return OcrService.ocr(image, imageProvider) - } + const ocrImage = useCallback( + async (image: ImageFileMetadata) => { + logger.debug('ocrImage', { config: imageProvider.config }) + return OcrService.ocr(image, imageProvider) + }, + [imageProvider] + ) /** * 对支持的文件进行OCR识别. @@ -33,7 +39,7 @@ export const useOcr = () => { window.message.loading({ content: t('ocr.processing'), key, duration: 0 }) // await to keep show loading message try { - if (isImageFile(file)) { + if (isImageFileMetadata(file)) { return await ocrImage(file) } else { // @ts-expect-error all types should be covered diff --git a/src/renderer/src/hooks/useOcrProvider.ts b/src/renderer/src/hooks/useOcrProvider.ts deleted file mode 100644 index ce2eb5b8fc..0000000000 --- a/src/renderer/src/hooks/useOcrProvider.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { loggerService } from '@logger' -import { BUILTIN_OCR_PROVIDERS_MAP } from '@renderer/config/ocr' -import { useAppSelector } from '@renderer/store' -import { addOcrProvider, removeOcrProvider, updateOcrProviderConfig } from '@renderer/store/ocr' -import { isBuiltinOcrProviderId, OcrProvider, OcrProviderConfig } from '@renderer/types' -import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' - -const logger = loggerService.withContext('useOcrProvider') - -export const useOcrProviders = () => { - const providers = useAppSelector((state) => state.ocr.providers) - const dispatch = useDispatch() - const { t } = useTranslation() - - /** - * 添加一个新的OCR服务提供者 - * @param provider - OCR提供者对象,包含id和其他配置信息 - * @throws {Error} 当尝试添加一个已存在ID的提供者时抛出错误 - */ - const addProvider = (provider: OcrProvider) => { - if (providers.some((p) => p.id === provider.id)) { - const msg = `Provider with id ${provider.id} already exists` - logger.error(msg) - window.message.error(t('ocr.error.provider.existing')) - throw new Error(msg) - } - dispatch(addOcrProvider(provider)) - } - - /** - * 移除一个OCR服务提供者 - * @param id - 要移除的OCR提供者ID - * @throws {Error} 当尝试移除一个内置提供商时抛出错误 - */ - const removeProvider = (id: string) => { - if (isBuiltinOcrProviderId(id)) { - const msg = `Cannot remove builtin provider ${id}` - logger.error(msg) - window.message.error(t('ocr.error.provider.cannot_remove_builtin')) - throw new Error(msg) - } - - dispatch(removeOcrProvider(id)) - } - - return { providers, addProvider, removeProvider } -} - -export const useOcrProvider = (id: string) => { - const { t } = useTranslation() - const dispatch = useDispatch() - const { providers, addProvider } = useOcrProviders() - let provider = providers.find((p) => p.id === id) - - // safely fallback - if (!provider) { - logger.error(`Ocr Provider ${id} not found`) - window.message.error(t('ocr.error.provider.not_found')) - if (isBuiltinOcrProviderId(id)) { - try { - addProvider(BUILTIN_OCR_PROVIDERS_MAP[id]) - } catch (e) { - logger.warn(`Add ${BUILTIN_OCR_PROVIDERS_MAP[id].name} failed. Just use temp provider from config.`) - window.message.warning(t('ocr.warning.provider.fallback', { name: BUILTIN_OCR_PROVIDERS_MAP[id].name })) - } finally { - provider = BUILTIN_OCR_PROVIDERS_MAP[id] - } - } else { - logger.warn(`Fallback to tesseract`) - window.message.warning(t('ocr.warning.provider.fallback', { name: 'Tesseract' })) - provider = BUILTIN_OCR_PROVIDERS_MAP.tesseract - } - } - - const updateConfig = (update: Partial) => { - dispatch(updateOcrProviderConfig({ id: provider.id, update })) - } - - return { - provider, - updateConfig - } -} diff --git a/src/renderer/src/hooks/useOcrProvider.tsx b/src/renderer/src/hooks/useOcrProvider.tsx new file mode 100644 index 0000000000..b0e23c20e3 --- /dev/null +++ b/src/renderer/src/hooks/useOcrProvider.tsx @@ -0,0 +1,148 @@ +import { loggerService } from '@logger' +import TesseractLogo from '@renderer/assets/images/providers/Tesseract.js.png' +import { BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr' +import { getBuiltinOcrProviderLabel } from '@renderer/i18n/label' +import { useAppSelector } from '@renderer/store' +import { addOcrProvider, removeOcrProvider, setImageOcrProviderId, updateOcrProviderConfig } from '@renderer/store/ocr' +import { + ImageOcrProvider, + isBuiltinOcrProvider, + isBuiltinOcrProviderId, + isImageOcrProvider, + OcrProvider, + OcrProviderConfig +} from '@renderer/types' +import { Avatar } from 'antd' +import { FileQuestionMarkIcon, MonitorIcon } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' + +const logger = loggerService.withContext('useOcrProvider') + +export const useOcrProviders = () => { + const providers = useAppSelector((state) => state.ocr.providers) + const imageProviders = providers.filter(isImageOcrProvider) + const imageProviderId = useAppSelector((state) => state.ocr.imageProviderId) + const [imageProvider, setImageProvider] = useState(DEFAULT_OCR_PROVIDER.image) + const dispatch = useDispatch() + const { t } = useTranslation() + + /** + * 添加一个新的OCR服务提供者 + * @param provider - OCR提供者对象,包含id和其他配置信息 + * @throws {Error} 当尝试添加一个已存在ID的提供者时抛出错误 + */ + const addProvider = useCallback( + (provider: OcrProvider) => { + if (providers.some((p) => p.id === provider.id)) { + const msg = `Provider with id ${provider.id} already exists` + logger.error(msg) + window.message.error(t('ocr.error.provider.existing')) + throw new Error(msg) + } + dispatch(addOcrProvider(provider)) + }, + [dispatch, providers, t] + ) + + /** + * 移除一个OCR服务提供者 + * @param id - 要移除的OCR提供者ID + * @throws {Error} 当尝试移除一个内置提供商时抛出错误 + */ + const removeProvider = (id: string) => { + if (isBuiltinOcrProviderId(id)) { + const msg = `Cannot remove builtin provider ${id}` + logger.error(msg) + window.message.error(t('ocr.error.provider.cannot_remove_builtin')) + throw new Error(msg) + } + + dispatch(removeOcrProvider(id)) + } + + const setImageProviderId = useCallback( + (id: string) => { + dispatch(setImageOcrProviderId(id)) + }, + [dispatch] + ) + + const getOcrProviderName = (p: OcrProvider) => { + return isBuiltinOcrProvider(p) ? getBuiltinOcrProviderLabel(p.id) : p.name + } + + const OcrProviderLogo = ({ provider: p, size = 14 }: { provider: OcrProvider; size?: number }) => { + if (isBuiltinOcrProvider(p)) { + switch (p.id) { + case 'tesseract': + return + case 'system': + return + } + } + return + } + + useEffect(() => { + const actualImageProvider = imageProviders.find((p) => p.id === imageProviderId) + if (!actualImageProvider) { + if (isBuiltinOcrProviderId(imageProviderId)) { + logger.warn(`Builtin ocr provider ${imageProviderId} not exist. Will add it to providers.`) + addProvider(BUILTIN_OCR_PROVIDERS_MAP[imageProviderId]) + } + setImageProviderId(DEFAULT_OCR_PROVIDER.image.id) + setImageProvider(DEFAULT_OCR_PROVIDER.image) + } else { + setImageProviderId(actualImageProvider.id) + setImageProvider(actualImageProvider) + } + }, [addProvider, imageProviderId, imageProviders, setImageProviderId]) + + return { + providers, + imageProvider, + addProvider, + removeProvider, + setImageProviderId, + getOcrProviderName, + OcrProviderLogo + } +} + +export const useOcrProvider = (id: string) => { + const { t } = useTranslation() + const dispatch = useDispatch() + const { providers, addProvider } = useOcrProviders() + let provider = providers.find((p) => p.id === id) + + // safely fallback + if (!provider) { + logger.error(`Ocr Provider ${id} not found`) + window.message.error(t('ocr.error.provider.not_found')) + if (isBuiltinOcrProviderId(id)) { + try { + addProvider(BUILTIN_OCR_PROVIDERS_MAP[id]) + } catch (e) { + logger.warn(`Add ${BUILTIN_OCR_PROVIDERS_MAP[id].name} failed. Just use temp provider from config.`) + window.message.warning(t('ocr.warning.provider.fallback', { name: BUILTIN_OCR_PROVIDERS_MAP[id].name })) + } finally { + provider = BUILTIN_OCR_PROVIDERS_MAP[id] + } + } else { + logger.warn(`Fallback to tesseract`) + window.message.warning(t('ocr.warning.provider.fallback', { name: 'Tesseract' })) + provider = BUILTIN_OCR_PROVIDERS_MAP.tesseract + } + } + + const updateConfig = (update: Partial) => { + dispatch(updateOcrProviderConfig({ id: provider.id, update })) + } + + return { + provider, + updateConfig + } +} diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index 5df6b42cef..2e85dbaf62 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -5,8 +5,7 @@ */ import { loggerService } from '@logger' -import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@renderer/types' -import { ThinkingOption } from '@renderer/types' +import { BuiltinMCPServerName, BuiltinMCPServerNames, BuiltinOcrProviderId, ThinkingOption } from '@renderer/types' import i18n from './index' @@ -322,3 +321,13 @@ const builtInMcpDescriptionKeyMap: Record = { export const getBuiltInMcpServerDescriptionLabel = (key: string): string => { return getLabel(key, builtInMcpDescriptionKeyMap, t('settings.mcp.builtinServersDescriptions.no')) } + +const builtinOcrProviderKeyMap = { + system: 'ocr.builtin.system', + tesseract: '' +} as const satisfies Record + +export const getBuiltinOcrProviderLabel = (key: BuiltinOcrProviderId) => { + if (key === 'tesseract') return 'Tesseract' + else return getLabel(key, builtinOcrProviderKeyMap) +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index a74a7364c9..5a29fb31a2 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1587,6 +1587,9 @@ "tip": "If the response is successful, then only messages exceeding 30 seconds will trigger a reminder" }, "ocr": { + "builtin": { + "system": "System OCR" + }, "error": { "provider": { "cannot_remove_builtin": "Cannot delete built-in provider", @@ -3531,17 +3534,30 @@ "title": "Settings", "tool": { "ocr": { + "common": { + "langs": "Supported languages" + }, + "error": { + "not_system": "System OCR only supports Windows and MacOS" + }, "image": { "error": { "provider_not_found": "The provider does not exist" }, - "tesseract": { - "langs": "Supported languages", - "temp_tooltip": "Currently only Chinese and English are supported" + "system": { + "no_need_configure": "MacOS requires no configuration" }, "title": "Image" }, "image_provider": "OCR service provider", + "system": { + "win": { + "langs_tooltip": "Dependent on Windows to provide services, you need to download language packs in the system to support the relevant languages." + } + }, + "tesseract": { + "langs_tooltip": "Read the documentation to learn which custom languages are supported" + }, "title": "OCR service" }, "preprocess": { @@ -3787,6 +3803,7 @@ "files": { "drag_text": "Drop here", "error": { + "check_type": "An error occurred while checking the file type", "multiple": "Multiple file uploads are not allowed", "too_large": "File too large", "unknown": "Failed to read file content" @@ -3811,7 +3828,7 @@ "aborted": "Translation aborted" }, "input": { - "placeholder": "Enter text to translate" + "placeholder": "Text, files, or images (OCR supported) can be pasted or dragged in" }, "language": { "not_pair": "Source language is different from the set language", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 7920f1a5ee..fa70851685 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1587,6 +1587,9 @@ "tip": "応答が成功した場合、30秒を超えるメッセージのみに通知を行います" }, "ocr": { + "builtin": { + "system": "システム OCR" + }, "error": { "provider": { "cannot_remove_builtin": "組み込みプロバイダーは削除できません", @@ -3531,17 +3534,30 @@ "title": "設定", "tool": { "ocr": { + "common": { + "langs": "サポートされている言語" + }, + "error": { + "not_system": "システムOCRはWindowsとMacOSのみをサポートしています" + }, "image": { "error": { "provider_not_found": "該提供者は存在しません" }, - "tesseract": { - "langs": "サポートされている言語", - "temp_tooltip": "現在のところ、中国語と英語のみをサポートしています" + "system": { + "no_need_configure": "MacOS は設定不要" }, "title": "画像" }, "image_provider": "OCRサービスプロバイダー", + "system": { + "win": { + "langs_tooltip": "Windows が提供するサービスに依存しており、関連する言語をサポートするには、システムで言語パックをダウンロードする必要があります。" + } + }, + "tesseract": { + "langs_tooltip": "ドキュメントを読んで、どのカスタム言語がサポートされているかを確認してください。" + }, "title": "OCRサービス" }, "preprocess": { @@ -3787,6 +3803,7 @@ "files": { "drag_text": "ここにドラッグ&ドロップしてください", "error": { + "check_type": "ファイルタイプの確認中にエラーが発生しました", "multiple": "複数のファイルのアップロードは許可されていません", "too_large": "ファイルが大きすぎます", "unknown": "ファイルの内容を読み取るのに失敗しました" @@ -3811,7 +3828,7 @@ "aborted": "翻訳中止" }, "input": { - "placeholder": "翻訳するテキストを入力" + "placeholder": "テキスト、ファイル、画像(OCR対応)を貼り付けたりドラッグアンドドロップしたりできます" }, "language": { "not_pair": "ソース言語が設定された言語と異なります", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index a00dc5bf47..6bb1df6e00 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1587,6 +1587,9 @@ "tip": "Если ответ успешен, уведомление выдается только по сообщениям, превышающим 30 секунд" }, "ocr": { + "builtin": { + "system": "Системное распознавание текста" + }, "error": { "provider": { "cannot_remove_builtin": "Не удается удалить встроенного поставщика", @@ -3531,17 +3534,30 @@ "title": "Настройки", "tool": { "ocr": { + "common": { + "langs": "Поддерживаемые языки" + }, + "error": { + "not_system": "Системный OCR поддерживается только в Windows и MacOS" + }, "image": { "error": { "provider_not_found": "Поставщик не существует" }, - "tesseract": { - "langs": "Поддерживаемые языки", - "temp_tooltip": "На данный момент поддерживаются только китайский и английский языки" + "system": { + "no_need_configure": "MacOS не требует настройки" }, "title": "Изображение" }, "image_provider": "Поставщик услуг OCR", + "system": { + "win": { + "langs_tooltip": "Для предоставления служб Windows необходимо загрузить языковой пакет в системе для поддержки соответствующего языка." + } + }, + "tesseract": { + "langs_tooltip": "Ознакомьтесь с документацией, чтобы узнать, какие пользовательские языки поддерживаются" + }, "title": "OCR-сервис" }, "preprocess": { @@ -3787,6 +3803,7 @@ "files": { "drag_text": "Перетащите сюда", "error": { + "check_type": "Ошибка при проверке типа файла", "multiple": "Не разрешается загружать несколько файлов", "too_large": "Файл слишком большой", "unknown": "Ошибка при чтении содержимого файла" @@ -3811,7 +3828,7 @@ "aborted": "Перевод прерван" }, "input": { - "placeholder": "Введите текст для перевода" + "placeholder": "Можно вставить или перетащить текст, файлы, изображения (поддержка OCR)" }, "language": { "not_pair": "Исходный язык отличается от настроенного", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 6d3f2af47e..53ee849746 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1587,6 +1587,9 @@ "tip": "如果响应成功,则只针对超过30秒的消息进行提醒" }, "ocr": { + "builtin": { + "system": "系统 OCR" + }, "error": { "provider": { "cannot_remove_builtin": "不能删除内置提供商", @@ -3531,17 +3534,30 @@ "title": "设置", "tool": { "ocr": { + "common": { + "langs": "支持的语言" + }, + "error": { + "not_system": "系统 OCR 仅支持 Windows 与 MacOS" + }, "image": { "error": { "provider_not_found": "该提供商不存在" }, - "tesseract": { - "langs": "支持的语言", - "temp_tooltip": "目前暂时只支持中文和英文" + "system": { + "no_need_configure": "MacOS 无需配置" }, "title": "图片" }, "image_provider": "OCR 服务提供商", + "system": { + "win": { + "langs_tooltip": "依赖 Windows 提供服务,您需要在系统中下载语言包来支持相关语言。" + } + }, + "tesseract": { + "langs_tooltip": "阅读文档以了解哪些自定义语言是受支持的" + }, "title": "OCR 服务" }, "preprocess": { @@ -3787,6 +3803,7 @@ "files": { "drag_text": "拖放到此处", "error": { + "check_type": "检查文件类型时发生错误", "multiple": "不允许上传多个文件", "too_large": "文件过大", "unknown": "读取文件内容失败" @@ -3811,7 +3828,7 @@ "aborted": "翻译中止" }, "input": { - "placeholder": "输入文本进行翻译" + "placeholder": "可粘贴或拖入文本、文件、图片(支持OCR)" }, "language": { "not_pair": "源语言与设置的语言不同", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index e0d12b4c1a..5414005224 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1587,6 +1587,9 @@ "tip": "如果回應成功,則只針對超過30秒的訊息發出提醒" }, "ocr": { + "builtin": { + "system": "系统 OCR" + }, "error": { "provider": { "cannot_remove_builtin": "不能刪除內建提供者", @@ -3531,17 +3534,30 @@ "title": "設定", "tool": { "ocr": { + "common": { + "langs": "支援的語言" + }, + "error": { + "not_system": "系統 OCR 僅支援 Windows 與 MacOS" + }, "image": { "error": { "provider_not_found": "該提供商不存在" }, - "tesseract": { - "langs": "支援的語言", - "temp_tooltip": "目前暫時只支援中文和英文" + "system": { + "no_need_configure": "MacOS 無需配置" }, "title": "圖片" }, "image_provider": "OCR 服務提供商", + "system": { + "win": { + "langs_tooltip": "依賴 Windows 提供服務,您需要在系統中下載語言包來支援相關語言。" + } + }, + "tesseract": { + "langs_tooltip": "閱讀文件以了解哪些自訂語言受支援" + }, "title": "OCR 服務" }, "preprocess": { @@ -3787,6 +3803,7 @@ "files": { "drag_text": "拖放到此处", "error": { + "check_type": "檢查檔案類型時發生錯誤", "multiple": "不允许上传多个文件", "too_large": "文件過大", "unknown": "读取文件内容失败" @@ -3811,7 +3828,7 @@ "aborted": "翻譯中止" }, "input": { - "placeholder": "輸入文字進行翻譯" + "placeholder": "可粘貼或拖入文字、檔案、圖片(支援OCR)" }, "language": { "not_pair": "源語言與設定的語言不同", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 4c4ed4ed18..673b449c6d 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -1587,6 +1587,9 @@ "tip": "Εάν η απάντηση είναι επιτυχής, η ειδοποίηση εμφανίζεται μόνο για μηνύματα που υπερβαίνουν τα 30 δευτερόλεπτα" }, "ocr": { + "builtin": { + "system": "σύστημα OCR" + }, "error": { "provider": { "cannot_remove_builtin": "Δεν είναι δυνατή η διαγραφή του ενσωματωμένου παρόχου", @@ -2727,6 +2730,7 @@ "title": "Αυτόματη ενημέρωση" }, "avatar": { + "builtin": "Ενσωματωμένο προφίλ", "reset": "Επαναφορά εικονιδίου" }, "backup": { @@ -3530,17 +3534,30 @@ "title": "Ρυθμίσεις", "tool": { "ocr": { + "common": { + "langs": "Υποστηριζόμενες γλώσσες" + }, + "error": { + "not_system": "Το σύστημα OCR υποστηρίζει μόνο Windows και MacOS" + }, "image": { "error": { "provider_not_found": "Ο πάροχος δεν υπάρχει" }, - "tesseract": { - "langs": "Υποστηριζόμενες γλώσσες", - "temp_tooltip": "Προς το παρόν υποστηρίζονται μόνο η κινεζική και η αγγλική γλώσσα" + "system": { + "no_need_configure": "MacOS δεν απαιτεί ρύθμιση" }, "title": "Εικόνα" }, "image_provider": "Πάροχοι υπηρεσιών OCR", + "system": { + "win": { + "langs_tooltip": "Εξαρτάται από τα Windows για την παροχή υπηρεσιών, πρέπει να κατεβάσετε το πακέτο γλώσσας στο σύστημα για να υποστηρίξετε τις σχετικές γλώσσες." + } + }, + "tesseract": { + "langs_tooltip": "Διαβάστε την τεκμηρίωση για να μάθετε ποιες προσαρμοσμένες γλώσσες υποστηρίζονται" + }, "title": "Υπηρεσία OCR" }, "preprocess": { @@ -3786,6 +3803,7 @@ "files": { "drag_text": "Σύρετε και αφήστε εδώ", "error": { + "check_type": "Παρουσιάστηκε σφάλμα κατά τον έλεγχο του τύπου αρχείου", "multiple": "Δεν επιτρέπεται η μεταφόρτωση πολλαπλών αρχείων", "too_large": "Το αρχείο είναι πολύ μεγάλο", "unknown": "Αποτυχία ανάγνωσης του περιεχομένου του αρχείου" @@ -3810,7 +3828,7 @@ "aborted": "Η μετάφραση διακόπηκε" }, "input": { - "placeholder": "Εισαγάγετε κείμενο για μετάφραση" + "placeholder": "Μπορείτε να επικολλήσετε ή να σύρετε κείμενο, αρχεία, εικόνες (με υποστήριξη OCR)" }, "language": { "not_pair": "Η γλώσσα πηγής διαφέρει από την οριζόμενη γλώσσα", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index c738217322..3be80c67b0 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -1587,6 +1587,9 @@ "tip": "Si la respuesta es exitosa, solo se enviará un recordatorio para mensajes que excedan los 30 segundos" }, "ocr": { + "builtin": { + "system": "OCR del sistema" + }, "error": { "provider": { "cannot_remove_builtin": "No se puede eliminar el proveedor integrado", @@ -2727,6 +2730,7 @@ "title": "Actualización automática" }, "avatar": { + "builtin": "Avatares integrados", "reset": "Restablecer avatar" }, "backup": { @@ -3530,17 +3534,30 @@ "title": "Configuración", "tool": { "ocr": { + "common": { + "langs": "Idiomas compatibles" + }, + "error": { + "not_system": "El OCR del sistema solo admite Windows y MacOS" + }, "image": { "error": { "provider_not_found": "El proveedor no existe" }, - "tesseract": { - "langs": "Idiomas compatibles", - "temp_tooltip": "Actualmente solo se admiten chino e inglés." + "system": { + "no_need_configure": "MacOS no requiere configuración" }, "title": "Imagen" }, "image_provider": "Proveedor de servicios OCR", + "system": { + "win": { + "langs_tooltip": "Dependiendo de Windows para proporcionar servicios, necesita descargar el paquete de idioma en el sistema para admitir los idiomas correspondientes." + } + }, + "tesseract": { + "langs_tooltip": "Lea la documentación para conocer qué idiomas personalizados son compatibles" + }, "title": "Servicio OCR" }, "preprocess": { @@ -3786,6 +3803,7 @@ "files": { "drag_text": "Arrastrar y soltar aquí", "error": { + "check_type": "Se produjo un error al verificar el tipo de archivo", "multiple": "No se permite cargar varios archivos", "too_large": "El archivo es demasiado grande", "unknown": "Error al leer el contenido del archivo" @@ -3810,7 +3828,7 @@ "aborted": "Traducción cancelada" }, "input": { - "placeholder": "Ingrese el texto para traducir" + "placeholder": "Se puede pegar o arrastrar texto, archivos e imágenes (compatible con OCR)" }, "language": { "not_pair": "El idioma de origen es diferente al idioma configurado", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 4273f8c5a5..2cf7b9938f 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -1587,6 +1587,9 @@ "tip": "Si la réponse est réussie, un rappel est envoyé uniquement pour les messages dépassant 30 secondes" }, "ocr": { + "builtin": { + "system": "OCR système" + }, "error": { "provider": { "cannot_remove_builtin": "Impossible de supprimer le fournisseur intégré", @@ -2727,6 +2730,7 @@ "title": "Mise à jour automatique" }, "avatar": { + "builtin": "Avatar intégré", "reset": "Réinitialiser l'avatar" }, "backup": { @@ -3530,17 +3534,30 @@ "title": "Paramètres", "tool": { "ocr": { + "common": { + "langs": "Langues prises en charge" + }, + "error": { + "not_system": "L'OCR système prend uniquement en charge Windows et MacOS" + }, "image": { "error": { "provider_not_found": "Ce fournisseur n'existe pas" }, - "tesseract": { - "langs": "Langues prises en charge", - "temp_tooltip": "Pour le moment, seuls le chinois et l'anglais sont pris en charge." + "system": { + "no_need_configure": "MacOS ne nécessite aucune configuration" }, "title": "Image" }, "image_provider": "Fournisseur de service OCR", + "system": { + "win": { + "langs_tooltip": "Dépendre de Windows pour fournir des services, vous devez télécharger des packs linguistiques dans le système afin de prendre en charge les langues concernées." + } + }, + "tesseract": { + "langs_tooltip": "Lisez la documentation pour connaître les langues personnalisées prises en charge" + }, "title": "Service OCR" }, "preprocess": { @@ -3786,6 +3803,7 @@ "files": { "drag_text": "Glisser-déposer ici", "error": { + "check_type": "Une erreur s'est produite lors de la vérification du type de fichier", "multiple": "Impossible de téléverser plusieurs fichiers", "too_large": "Fichier trop volumineux", "unknown": "Échec de la lecture du contenu du fichier" @@ -3810,7 +3828,7 @@ "aborted": "Traduction annulée" }, "input": { - "placeholder": "entrez le texte à traduire" + "placeholder": "Peut coller ou glisser du texte, des fichiers, des images (avec reconnaissance optique de caractères)" }, "language": { "not_pair": "La langue source est différente de la langue définie", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 03b0c47fef..4c0989ef7c 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -1587,6 +1587,9 @@ "tip": "Se a resposta for bem-sucedida, lembrete apenas para mensagens que excedam 30 segundos" }, "ocr": { + "builtin": { + "system": "OCR do sistema" + }, "error": { "provider": { "cannot_remove_builtin": "Não é possível excluir o provedor integrado", @@ -2727,6 +2730,7 @@ "title": "Atualização automática" }, "avatar": { + "builtin": "Avatares embutidos", "reset": "Redefinir avatar" }, "backup": { @@ -3530,17 +3534,30 @@ "title": "Configurações", "tool": { "ocr": { + "common": { + "langs": "Idiomas suportados" + }, + "error": { + "not_system": "O OCR do sistema suporta apenas Windows e MacOS" + }, "image": { "error": { "provider_not_found": "O provedor não existe" }, - "tesseract": { - "langs": "Idiomas suportados", - "temp_tooltip": "No momento, apenas chinês e inglês são suportados." + "system": { + "no_need_configure": "MacOS não requer configuração" }, "title": "Imagem" }, "image_provider": "Provedor de serviços OCR", + "system": { + "win": { + "langs_tooltip": "Dependendo do Windows para fornecer serviços, você precisa baixar pacotes de idiomas no sistema para dar suporte aos idiomas relevantes." + } + }, + "tesseract": { + "langs_tooltip": "Leia a documentação para saber quais idiomas personalizados são suportados" + }, "title": "Serviço OCR" }, "preprocess": { @@ -3786,6 +3803,7 @@ "files": { "drag_text": "Arraste e solte aqui", "error": { + "check_type": "Ocorreu um erro ao verificar o tipo de arquivo", "multiple": "Não é permitido fazer upload de vários arquivos", "too_large": "Arquivo muito grande", "unknown": "Falha ao ler o conteúdo do arquivo" @@ -3810,7 +3828,7 @@ "aborted": "Tradução interrompida" }, "input": { - "placeholder": "Digite o texto para traduzir" + "placeholder": "Pode colar ou arrastar e soltar texto, arquivos e imagens (suporte a OCR)" }, "language": { "not_pair": "O idioma de origem é diferente do idioma definido", diff --git a/src/renderer/src/pages/settings/DocProcessSettings/OcrImageSettings.tsx b/src/renderer/src/pages/settings/DocProcessSettings/OcrImageSettings.tsx index 3efdf94fa0..8568c47011 100644 --- a/src/renderer/src/pages/settings/DocProcessSettings/OcrImageSettings.tsx +++ b/src/renderer/src/pages/settings/DocProcessSettings/OcrImageSettings.tsx @@ -1,11 +1,11 @@ import { loggerService } from '@logger' -import { useAppSelector } from '@renderer/store' -import { setImageOcrProvider } from '@renderer/store/ocr' -import { isImageOcrProvider, OcrProvider } from '@renderer/types' +import { ErrorTag } from '@renderer/components/Tags/ErrorTag' +import { isMac, isWin } from '@renderer/config/constant' +import { useOcrProviders } from '@renderer/hooks/useOcrProvider' +import { BuiltinOcrProviderIds, ImageOcrProvider, isImageOcrProvider, OcrProvider } from '@renderer/types' import { Select } from 'antd' -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' import { SettingRow, SettingRowTitle } from '..' @@ -17,17 +17,16 @@ type Props = { const OcrImageSettings = ({ setProvider }: Props) => { const { t } = useTranslation() - const providers = useAppSelector((state) => state.ocr.providers) - const imageProvider = useAppSelector((state) => state.ocr.imageProvider) + const { providers, imageProvider, getOcrProviderName, setImageProviderId } = useOcrProviders() + const imageProviders = providers.filter((p) => isImageOcrProvider(p)) - const dispatch = useDispatch() // 挂载时更新外部状态 useEffect(() => { setProvider(imageProvider) }, [imageProvider, setProvider]) - const updateImageProvider = (id: string) => { + const setImageProvider = (id: string) => { const provider = imageProviders.find((p) => p.id === id) if (!provider) { logger.error(`Failed to find image provider by id: ${id}`) @@ -36,22 +35,29 @@ const OcrImageSettings = ({ setProvider }: Props) => { } setProvider(provider) - dispatch(setImageOcrProvider(provider)) + setImageProviderId(id) } + const platformSupport = isMac || isWin + const options = useMemo(() => { + const platformFilter = platformSupport ? () => true : (p: ImageOcrProvider) => p.id !== BuiltinOcrProviderIds.system + return imageProviders.filter(platformFilter).map((p) => ({ + value: p.id, + label: getOcrProviderName(p) + })) + }, [getOcrProviderName, imageProviders, platformSupport]) + return ( <> {t('settings.tool.ocr.image_provider')} -
+
+ {!platformSupport && } + )} +
+ + + ) +} diff --git a/src/renderer/src/pages/settings/DocProcessSettings/OcrTesseractSettings.tsx b/src/renderer/src/pages/settings/DocProcessSettings/OcrTesseractSettings.tsx index 8c85cee8bc..b0ad67232d 100644 --- a/src/renderer/src/pages/settings/DocProcessSettings/OcrTesseractSettings.tsx +++ b/src/renderer/src/pages/settings/DocProcessSettings/OcrTesseractSettings.tsx @@ -1,8 +1,12 @@ // import { loggerService } from '@logger' import InfoTooltip from '@renderer/components/InfoTooltip' +import CustomTag from '@renderer/components/Tags/CustomTag' +import { TESSERACT_LANG_MAP } from '@renderer/config/ocr' import { useOcrProvider } from '@renderer/hooks/useOcrProvider' -import { BuiltinOcrProviderIds, isOcrTesseractProvider } from '@renderer/types' +import useTranslate from '@renderer/hooks/useTranslate' +import { BuiltinOcrProviderIds, isOcrTesseractProvider, TesseractLangCode } from '@renderer/types' import { Flex, Select } from 'antd' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { SettingRow, SettingRowTitle } from '..' @@ -11,38 +15,70 @@ import { SettingRow, SettingRowTitle } from '..' export const OcrTesseractSettings = () => { const { t } = useTranslation() - const { provider } = useOcrProvider(BuiltinOcrProviderIds.tesseract) + const { provider, updateConfig } = useOcrProvider(BuiltinOcrProviderIds.tesseract) if (!isOcrTesseractProvider(provider)) { throw new Error('Not tesseract provider.') } - // const [langs, setLangs] = useState(provider.config?.langs ?? {}) + const [langs, setLangs] = useState>>(provider.config?.langs ?? {}) + const { translateLanguages } = useTranslate() - // currently static - const options = [ - { value: 'chi_sim', label: t('languages.chinese') }, - { value: 'chi_tra', label: t('languages.chinese-traditional') }, - { value: 'eng', label: t('languages.english') } - ] + const options = useMemo( + () => + translateLanguages + .map((lang) => ({ + value: TESSERACT_LANG_MAP[lang.langCode], + label: lang.emoji + ' ' + lang.label() + })) + .filter((option) => option.value), + [translateLanguages] + ) + + // TODO: type safe objectKeys + const value = useMemo( + () => + Object.entries(langs) + .filter(([, enabled]) => enabled) + .map(([lang]) => lang) as TesseractLangCode[], + [langs] + ) + + const onChange = useCallback((values: TesseractLangCode[]) => { + setLangs(() => { + const newLangs = {} + values.forEach((v) => { + newLangs[v] = true + }) + return newLangs + }) + }, []) + + const onBlur = useCallback(() => { + updateConfig({ langs }) + }, [langs, updateConfig]) return ( <> - {t('settings.tool.ocr.image.tesseract.langs')} - + {t('settings.tool.ocr.common.langs')} +