From 24399f1d702fec93c2c61f7d18951c1029b11d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B5=E6=91=87=E5=B0=8F=E5=AD=90?= <84499436+Wxw-Gu@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:23:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Windows=20?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=9B=BE=E7=89=87=E6=96=87=E5=AD=97=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron-builder.win.yml | 2 + electron.vite.config.ts | 2 +- package.json | 2 + pnpm-lock.yaml | 42 ++ scripts/after-pack.cjs | 58 +- src/main/image-decrypt-service.ts | 6 +- src/main/index.ts | 30 + src/main/services/image-insight-service.ts | 31 + src/main/services/system-ocr-service.ts | 558 ++++++++++++++++++ src/preload/index.d.ts | 4 + src/preload/index.ts | 6 + .../ai-model/LocalImageTextRecognition.tsx | 255 ++++++++ .../features/settings/pages/AIModelPage.tsx | 2 + src/renderer/src/styles/reports-core.scss | 161 +++++ src/shared/system-ocr.ts | 266 +++++++++ tests/component/ai-model-settings.test.tsx | 14 +- .../local-image-text-recognition.test.tsx | 107 ++++ tests/fixtures/ocr/system-ocr-en.png | Bin 0 -> 10436 bytes tests/fixtures/ocr/system-ocr-mixed.jpg | Bin 0 -> 20895 bytes tests/fixtures/ocr/system-ocr-mixed.png | Bin 0 -> 12790 bytes tests/fixtures/ocr/system-ocr-zh.png | Bin 0 -> 13835 bytes tests/integration/system-ocr-windows.test.ts | 96 +++ tests/unit/system-ocr-service.test.ts | 349 +++++++++++ 23 files changed, 1984 insertions(+), 7 deletions(-) create mode 100644 src/main/services/system-ocr-service.ts create mode 100644 src/renderer/src/features/settings/ai-model/LocalImageTextRecognition.tsx create mode 100644 src/shared/system-ocr.ts create mode 100644 tests/component/local-image-text-recognition.test.tsx create mode 100644 tests/fixtures/ocr/system-ocr-en.png create mode 100644 tests/fixtures/ocr/system-ocr-mixed.jpg create mode 100644 tests/fixtures/ocr/system-ocr-mixed.png create mode 100644 tests/fixtures/ocr/system-ocr-zh.png create mode 100644 tests/integration/system-ocr-windows.test.ts create mode 100644 tests/unit/system-ocr-service.test.ts diff --git a/electron-builder.win.yml b/electron-builder.win.yml index bade454..9db6e81 100644 --- a/electron-builder.win.yml +++ b/electron-builder.win.yml @@ -19,6 +19,8 @@ asarUnpack: - node_modules/silk-wasm/** - node_modules/sherpa-onnx-node/** - node_modules/sherpa-onnx-*/** + - node_modules/@napi-rs/system-ocr/** + - node_modules/@napi-rs/system-ocr-*/** extraResources: # Keep the updater provider in every packaged Windows app. electron-builder also # regenerates this file during publish, using the same release configuration. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 2b7139e..90fe99c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ output: { entryFileNames: '[name].js' }, - external: ['koffi', 'sherpa-onnx-node'] + external: ['koffi', 'sherpa-onnx-node', '@napi-rs/system-ocr'] } } }, diff --git a/package.json b/package.json index fd63b3c..243f371 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,8 @@ "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", "@koromix/koffi-win32-x64": "3.1.0", + "@napi-rs/system-ocr": "1.2.0", + "@napi-rs/system-ocr-win32-x64-msvc": "1.2.0", "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-checkbox": "^1.3.11", "@radix-ui/react-dialog": "^1.1.23", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90372c5..1533c85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,8 @@ specifiers: '@electron-toolkit/tsconfig': ^2.0.0 '@electron-toolkit/utils': ^4.0.0 '@koromix/koffi-win32-x64': 3.1.0 + '@napi-rs/system-ocr': 1.2.0 + '@napi-rs/system-ocr-win32-x64-msvc': 1.2.0 '@playwright/test': ^1.62.1 '@radix-ui/react-alert-dialog': ^1.1.23 '@radix-ui/react-checkbox': ^1.3.11 @@ -86,6 +88,8 @@ dependencies: '@electron-toolkit/preload': 3.0.2_electron@43.1.0 '@electron-toolkit/utils': 4.0.0_electron@43.1.0 '@koromix/koffi-win32-x64': 3.1.0 + '@napi-rs/system-ocr': 1.2.0 + '@napi-rs/system-ocr-win32-x64-msvc': 1.2.0 '@radix-ui/react-alert-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu '@radix-ui/react-checkbox': 1.3.11_eijghdl4n2x4hz6j4cg7ctgbuu '@radix-ui/react-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu @@ -1685,6 +1689,44 @@ packages: - supports-color dev: true + /@napi-rs/system-ocr/1.2.0: + resolution: {integrity: sha512-r0f2xNH6U+sth44qF+lUP+2WuHSGUBAry5KSCNuaLDGRbgslFqeROr/qJJ/fb6AjBp3Ov+CJP5MdrOWoaoM3cw==} + engines: {node: '>= 10'} + optionalDependencies: + '@napi-rs/system-ocr-darwin-arm64': 1.2.0 + '@napi-rs/system-ocr-darwin-x64': 1.2.0 + '@napi-rs/system-ocr-win32-arm64-msvc': 1.2.0 + '@napi-rs/system-ocr-win32-x64-msvc': 1.2.0 + dev: false + + /@napi-rs/system-ocr-darwin-arm64/1.2.0: + resolution: {integrity: sha512-cK8dcDBEl3P4A04xmFJSHEJQxfDytaAIFyDCLqavTp92FVU5plESttWzZsqtTkS81/kzKiBfHyPQffSIndfWbQ==} + cpu: [arm64] + os: [darwin] + engines: {node: '>= 10'} + dev: false + + /@napi-rs/system-ocr-darwin-x64/1.2.0: + resolution: {integrity: sha512-u3TBvBGrhmT5Os6AfaxbUEg6VHe8lvrFJNPgThJgshJHyRXUx/wCfTyOroJ22KdVCP5AE4GpwS5tFHMb6p6iaQ==} + cpu: [x64] + os: [darwin] + engines: {node: '>= 10'} + dev: false + + /@napi-rs/system-ocr-win32-arm64-msvc/1.2.0: + resolution: {integrity: sha512-7ej8uMvmXomw3NXo5gZ5p2Nl6UKsHI+VRU3ELv0mhcxR0sJ6wFifYTu5bJrM1TGcz1/RsaX+TjWMmsDq8vriKQ==} + cpu: [arm64] + os: [win32] + engines: {node: '>= 10'} + dev: false + + /@napi-rs/system-ocr-win32-x64-msvc/1.2.0: + resolution: {integrity: sha512-oOoCj3FPWDVctTxx98vMBiMI6m51U+w7SMmMefvmtpcpLelzZ/zYTqdwtWZFAjShaHO+RdaKkcpeVcQuBQiVbA==} + cpu: [x64] + os: [win32] + engines: {node: '>= 10'} + dev: false + /@nodelib/fs.scandir/2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index d64d1d3..4f6502b 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -78,6 +78,44 @@ function validateSherpaRuntime(runtimeResources, platform, arch) { } } +/** + * System OCR 用 native package(@napi-rs/system-ocr)。它是 external + asarUnpack, + * 打包后必须以 unpacked 形式存在,否则运行时会 MODULE_NOT_FOUND / native binding missing。 + * 本轮只有 Windows 是 supported target,所以只在 Windows 上做硬校验。 + */ +function systemOcrTarget(platform, arch) { + return platform === 'win32' ? `${platform}-${arch}-msvc` : `${platform}-${arch}` +} + +function validateSystemOcrRuntime(runtimeResources, platform, arch) { + if (platform !== 'win32') return + const target = systemOcrTarget(platform, arch) + const basePath = path.join( + runtimeResources, + 'app.asar.unpacked', + 'node_modules', + '@napi-rs', + 'system-ocr' + ) + const nativePath = path.join( + runtimeResources, + 'app.asar.unpacked', + 'node_modules', + '@napi-rs', + `system-ocr-${target}` + ) + const requiredFiles = [ + path.join(basePath, 'package.json'), + path.join(basePath, 'index.js'), + path.join(nativePath, 'package.json'), + path.join(nativePath, `system-ocr.${target}.node`) + ] + const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath)) + if (missingFiles.length > 0) { + throw new Error(`Missing unpacked System OCR runtime: ${missingFiles.join(', ')}`) + } +} + function normalizeBuilderArch(arch) { if (typeof arch === 'string') return arch return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch) @@ -152,16 +190,24 @@ function validateReaderSkillRuntime(runtimeResources) { * The loaders pick their package from process.platform/arch, so the siblings * are dead weight — drop them. */ +// 每个条目返回 platform package 的**完整后缀**(不含 package 前缀与连字符)。 const NATIVE_RUNTIME_PACKAGES = [ { modules: [], prefix: 'sherpa-onnx', - platformName: (platform) => (platform === 'win32' ? 'win' : platform) + platformName: (platform, arch) => `${platform === 'win32' ? 'win' : platform}-${arch}` }, { modules: ['@koromix'], prefix: 'koffi', - platformName: (platform) => platform + platformName: (platform, arch) => `${platform}-${arch}` + }, + { + // @napi-rs 的 platform package 目录名带 -msvc 后缀(win32-x64-msvc)。 + modules: ['@napi-rs'], + prefix: 'system-ocr', + platformName: (platform, arch) => systemOcrTarget(platform, arch), + foreignPattern: /^system-ocr-[a-z0-9]+-(arm64|x64|ia32|loong64|riscv64)(-msvc)?$/ } ] @@ -173,8 +219,10 @@ function pruneForeignArchNativeRuntimes(runtimeResources, platform, arch) { for (const runtime of NATIVE_RUNTIME_PACKAGES) { const modulesRoot = path.join(unpackedRoot, ...runtime.modules) if (!existsSync(modulesRoot)) continue - const expected = `${runtime.prefix}-${runtime.platformName(platform)}-${arch}` - const foreign = new RegExp(`^${runtime.prefix}-[a-z0-9]+-(arm64|x64|ia32|loong64|riscv64)$`) + const expected = `${runtime.prefix}-${runtime.platformName(platform, arch)}` + const foreign = + runtime.foreignPattern || + new RegExp(`^${runtime.prefix}-[a-z0-9]+-(arm64|x64|ia32|loong64|riscv64)$`) for (const entry of readdirSync(modulesRoot, { withFileTypes: true })) { if (!entry.isDirectory() || entry.name === expected || !foreign.test(entry.name)) continue rmSync(path.join(modulesRoot, entry.name), { recursive: true, force: true }) @@ -223,6 +271,7 @@ exports.default = async function afterPack(context) { 'Bundled ffmpeg' ) validateSherpaRuntime(runtimeResources, context.electronPlatformName, arch) + validateSystemOcrRuntime(runtimeResources, context.electronPlatformName, arch) pruneIntelMacKeyTool(runtimeResources, context.electronPlatformName, arch) pruneForeignArchConnectors(runtimeResources, context.electronPlatformName, arch) pruneForeignArchNativeRuntimes(runtimeResources, context.electronPlatformName, arch) @@ -258,6 +307,7 @@ exports.validateReaderSkillRuntime = validateReaderSkillRuntime exports.validateFfmpegRuntime = validateFfmpegRuntime exports.validateSilkWasmRuntime = validateSilkWasmRuntime exports.validateSherpaRuntime = validateSherpaRuntime +exports.validateSystemOcrRuntime = validateSystemOcrRuntime exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool exports.pruneForeignArchConnectors = pruneForeignArchConnectors exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes diff --git a/src/main/image-decrypt-service.ts b/src/main/image-decrypt-service.ts index fcb4994..e738281 100644 --- a/src/main/image-decrypt-service.ts +++ b/src/main/image-decrypt-service.ts @@ -114,7 +114,11 @@ function getFfmpegCandidates(selectedPath = loadSettings().ffmpegPath): FfmpegCa ) } -function resolveFfmpegExecutable(): string { +/** + * 解析可用的 ffmpeg 可执行文件。除图片解密自身使用外,也供 System OCR 的 + * 图片归一化(GIF/BMP/WebP/TIFF → PNG)复用,避免重复一套路径探测逻辑。 + */ +export function resolveFfmpegExecutable(): string { for (const candidate of getFfmpegCandidates()) { const pathLike = candidate.executable.includes('/') || candidate.executable.includes('\\') if (pathLike) { diff --git a/src/main/index.ts b/src/main/index.ts index a84f495..a56f313 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -29,6 +29,7 @@ import { ImageDecryptService, inspectImageDecoderExecutable, inspectImageDecoderStatus, + resolveFfmpegExecutable, type DecodedImage } from './image-decrypt-service' import { @@ -64,6 +65,7 @@ import { apiTokenStore } from './api-token-store' import { ImageKeyConfigService } from './services/image-key-config-service' import { AIProviderService } from './services/ai-provider-service' import { imageInsightService } from './services/image-insight-service' +import { systemOcrService } from './services/system-ocr-service' import type { ImageAnalysisRequest, ImageAnalysisResponse, @@ -71,6 +73,7 @@ import type { ImageCandidateQuery, ImageInsight } from '../shared/image-insight' +import type { SystemOcrCapability, SystemOcrRequest, SystemOcrResult } from '../shared/system-ocr' import { KeyServiceMac } from './key-service-mac' import { KeyService as KeyServiceWin } from './key-service-win' import * as chat from './services/chat-service' @@ -1814,6 +1817,13 @@ app.whenReady().then(async () => { } }) + // System OCR 是独立的本地 Runtime(不是 AI Provider):只注入项目统一的 ffmpeg + // 解析逻辑(GIF/BMP/WebP/TIFF → PNG 归一化)和系统 locale(OCR 语言包探测)。 + systemOcrService.bind({ + resolveFfmpegExecutable, + locale: () => app.getLocale() + }) + /** 日报入口:取会话 Top N 热点图片 + 已缓存的 Insight */ ipcMain.handle( 'image:listCandidates', @@ -1885,6 +1895,26 @@ app.whenReady().then(async () => { } ) + // ============================================================ + // 本地图片文字识别(System OCR / Windows System OCR Runtime) + // ============================================================ + // 这是本地 Runtime,不是 AI Vision Provider: + // - 不联网、不上传原图; + // - 不读写 AI Provider / Vision 模型配置; + // - 结果不落库(派生内容,本轮只做内存级闭环)。 + ipcMain.handle('system-ocr:getCapability', async (): Promise => { + return imageInsightService.getSystemOcrCapability() + }) + + ipcMain.handle( + 'system-ocr:recognize', + async (_, request: SystemOcrRequest): Promise => { + // 日志只记录结构性信息,不记录 base64、不记录识别正文。 + console.log('[IPC] system-ocr:recognize hash=%s', request?.imageHash || 'auto') + return imageInsightService.extractLocalText(request) + } + ) + ipcMain.handle('db:getSticker', async (_, cdnUrl?: string, md5?: string) => { if (!stickerService) { stickerService = new StickerService(chat.getChatDb()?.getWcdb4Client()) diff --git a/src/main/services/image-insight-service.ts b/src/main/services/image-insight-service.ts index 2c4cbde..fe5fa2c 100644 --- a/src/main/services/image-insight-service.ts +++ b/src/main/services/image-insight-service.ts @@ -27,6 +27,8 @@ import { isFreshImageInsight, isHotImageCandidate } from '../../shared/image-insight' +import type { SystemOcrCapability, SystemOcrRequest, SystemOcrResult } from '../../shared/system-ocr' +import { systemOcrService } from './system-ocr-service' /** * 单张图片的最小信息(由 renderer 从已加载的 messages 中提取并传入 main)。 @@ -312,6 +314,35 @@ class ImageInsightService { listBySession(sessionId: string, limit?: number): ImageInsight[] { return imageInsightsStore.listBySession(sessionId, limit) } + + // ============================================================ + // 本地图片文字识别(System OCR) + // ============================================================ + // + // 与 Vision 路径的关系: + // ImageInsightService 是统一编排入口,下面挂两条互不干扰的运行时—— + // - Vision Model Runtime(AIProviderService,走 AI Provider,可能联网) + // - Windows System OCR Runtime(SystemOcrService,纯本地,不联网) + // + // 边界与约束: + // 1. 本地 OCR 结果属于 **派生内容**,原始消息始终是权威来源; + // 本轮不落库、不写 Knowledge、不做历史图片 backfill。 + // 2. 本地 OCR 结果 **不会** 写入 image-insights.json——那是 Vision 结果的缓存, + // 两者的缓存键空间也不同(见 buildSystemOcrCacheKey)。 + // 3. 这里不读取也绝不修改 AI Vision Provider / 模型配置。 + + /** 本机是否支持本地图片文字识别(Windows System OCR)。 */ + getSystemOcrCapability(): Promise { + return systemOcrService.getCapability() + } + + /** + * 只做「把图片里的文字读出来」。不发网络请求,不动 AI Provider 配置。 + * 失败不抛,返回带 errorCode 的结果。 + */ + extractLocalText(request: SystemOcrRequest): Promise { + return systemOcrService.recognize(request) + } } export const imageInsightService = new ImageInsightService() diff --git a/src/main/services/system-ocr-service.ts b/src/main/services/system-ocr-service.ts new file mode 100644 index 0000000..3974034 --- /dev/null +++ b/src/main/services/system-ocr-service.ts @@ -0,0 +1,558 @@ +// src/main/services/system-ocr-service.ts +// +// System OCR Runtime(本地图片文字识别)。 +// +// 职责边界(只做这些事): +// 1. capability detection +// 2. image normalization / preparation +// 3. OCR execution +// 4. result normalization +// 5. runtime metadata +// 6. error mapping +// +// 明确不做: +// - 不伪装成 AI Provider / Vision Model;不读写 AIVisionRuntimeConfig; +// - 不发任何网络请求;不上传原图; +// - 不遍历历史图片、不做 backfill、不写 Knowledge; +// - 不把 OCR 文本写进 image-insights.json(那是 Vision 结果的缓存)。 +// +// Windows 后端:Windows.Media.Ocr.OcrEngine(经 @napi-rs/system-ocr)。 +// 已实测的引擎行为(@napi-rs/system-ocr 1.2.0 / Electron 43 / Windows x64): +// - Buffer 输入只接受 PNG;JPEG / WEBP / BMP 会被判为不可识别, +// 所以本服务在边界上统一归一化成 PNG 字节再调用(不落盘)。 +// - preferredLangs 只使用第一个语言;语言包缺失时引擎创建失败, +// 抛出的错误是 `Windows error 操作成功完成。 (0x00000000)`(HRESULT 为 S_OK)。 +// - 空白图不会报错,返回空文本 → 映射成 OCR_EMPTY_RESULT。 +// - CJK 字符之间会被引擎插入空格,结果里做归一化。 + +import crypto from 'node:crypto' +import { spawn } from 'node:child_process' +import { + SYSTEM_OCR_CACHE_TTL_MS, + SYSTEM_OCR_ENGINE, + SYSTEM_OCR_PROBE_PNG_BASE64, + buildSystemOcrCacheKey, + detectSystemOcrImageFormat, + mapSystemOcrNativeError, + normalizeSystemOcrText, + parseImageDataUrl, + resolveSystemOcrLanguageTag +} from '../../shared/system-ocr' +import type { + SystemOcrCapability, + SystemOcrErrorCode, + SystemOcrImageFormat, + SystemOcrLine, + SystemOcrRequest, + SystemOcrResult +} from '../../shared/system-ocr' + +const NATIVE_PACKAGE = '@napi-rs/system-ocr' +const MAX_CACHE_ENTRIES = 32 +const FFMPEG_TIMEOUT_MS = 10_000 + +interface NativeLine { + text: string + confidence: number + boundingBox: { x: number; y: number; width: number; height: number } +} + +interface NativeResult { + text: string + confidence: number + lines: NativeLine[] +} + +interface NativeRuntime { + version: string | null + recognize: (image: Uint8Array, accuracy?: number, languages?: string[]) => Promise +} + +export interface SystemOcrServiceDeps { + /** 加载 native 运行时;不可用时返回 null(不允许抛) */ + loadRuntime?: () => NativeRuntime | null + /** 把输入图片转成 PNG 字节;失败返回 null */ + toPngBytes?: (input: { + buffer: Buffer + format: SystemOcrImageFormat + }) => Promise + /** + * ffmpeg 可执行文件解析器。只用于 GIF/BMP/WebP/TIFF → PNG 的兜底归一化。 + * main/index.ts 会注入项目统一的解析逻辑(与图片解密共用一套候选路径)。 + */ + resolveFfmpegExecutable?: () => string + platform?: NodeJS.Platform + arch?: string + /** 系统 locale(如 zh-CN),用于推导 OCR 语言标签 */ + locale?: () => string +} + +/** 未被显式注入时的兜底:环境变量 → 打包内 ffmpeg-static → PATH。 */ +const defaultResolveFfmpegExecutable = (): string => { + const fromEnvironment = String(process.env['FFMPEG_BIN'] || '').trim() + if (fromEnvironment) return fromEnvironment + try { + const bundled = require('ffmpeg-static') as string | null + if (bundled) return bundled + } catch { + // 忽略:退回到 PATH 上的 ffmpeg + } + return process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' +} + +const toLines = (lines: NativeLine[] | undefined): SystemOcrLine[] => + Array.isArray(lines) + ? lines.map((line) => ({ + text: normalizeSystemOcrText(line.text), + confidence: typeof line.confidence === 'number' ? line.confidence : 1, + boundingBox: { + x: Number(line.boundingBox?.x ?? 0), + y: Number(line.boundingBox?.y ?? 0), + width: Number(line.boundingBox?.width ?? 0), + height: Number(line.boundingBox?.height ?? 0) + } + })) + : [] + +const failure = ( + errorCode: SystemOcrErrorCode, + error: string, + startedAt: number +): SystemOcrResult => ({ + success: false, + text: '', + lines: [], + language: null, + engine: SYSTEM_OCR_ENGINE, + durationMs: Date.now() - startedAt, + errorCode, + error +}) + +/** 把任意容器(gif/bmp/webp/tiff)用 ffmpeg 走内存管道转成 PNG。不落盘。 */ +const convertWithFfmpeg = (buffer: Buffer, executable: string): Promise => + new Promise((resolve) => { + let settled = false + const finish = (value: Buffer | null): void => { + if (settled) return + settled = true + resolve(value) + } + let child: ReturnType + try { + child = spawn( + executable, + [ + '-hide_banner', + '-loglevel', + 'error', + '-i', + 'pipe:0', + '-frames:v', + '1', + '-f', + 'image2pipe', + '-vcodec', + 'png', + 'pipe:1' + ], + { windowsHide: true } + ) + } catch { + finish(null) + return + } + const chunks: Buffer[] = [] + const timeout = setTimeout(() => { + try { + child.kill() + } catch { + // best-effort + } + finish(null) + }, FFMPEG_TIMEOUT_MS) + child.stdout?.on('data', (chunk: Buffer) => chunks.push(chunk)) + child.on('error', () => { + clearTimeout(timeout) + finish(null) + }) + child.on('close', (code) => { + clearTimeout(timeout) + finish(code === 0 && chunks.length > 0 ? Buffer.concat(chunks) : null) + }) + child.stdin?.on('error', () => undefined) + child.stdin?.end(buffer) + }) + +class SystemOcrService { + private runtime: NativeRuntime | null = null + private runtimeLoaded = false + private capability: SystemOcrCapability | null = null + private capabilityPromise: Promise | null = null + private readonly cache = new Map() + private deps: SystemOcrServiceDeps = {} + + constructor(deps: SystemOcrServiceDeps = {}) { + this.deps = deps + } + + /** 由 main/index.ts 在 app ready 后调用(可选,用于注入 app.getLocale 等)。 */ + bind(deps: SystemOcrServiceDeps): void { + this.deps = { ...this.deps, ...deps } + this.runtime = null + this.runtimeLoaded = false + this.capability = null + this.capabilityPromise = null + } + + /** 仅测试用:清空探测与缓存状态。 */ + reset(): void { + this.runtime = null + this.runtimeLoaded = false + this.capability = null + this.capabilityPromise = null + this.cache.clear() + } + + private get platform(): NodeJS.Platform { + return this.deps.platform ?? process.platform + } + + private get arch(): string { + return this.deps.arch ?? process.arch + } + + private get locale(): string { + if (this.deps.locale) { + try { + return this.deps.locale() + } catch { + return '' + } + } + try { + const { app } = require('electron') as typeof import('electron') + return app?.getLocale?.() ?? '' + } catch { + return '' + } + } + + private loadRuntime(): NativeRuntime | null { + if (this.runtimeLoaded) return this.runtime + this.runtimeLoaded = true + if (this.deps.loadRuntime) { + this.runtime = this.deps.loadRuntime() + return this.runtime + } + if (this.platform !== 'win32') { + this.runtime = null + return this.runtime + } + try { + // 原生模块必须在打包时 external + asarUnpack,否则这里会 MODULE_NOT_FOUND。 + const nativeModule = require(NATIVE_PACKAGE) as { + recognize: NativeRuntime['recognize'] + } + let version: string | null = null + try { + version = (require(`${NATIVE_PACKAGE}/package.json`) as { version?: string }).version ?? null + } catch { + version = null + } + this.runtime = + nativeModule && typeof nativeModule.recognize === 'function' + ? { version, recognize: nativeModule.recognize.bind(nativeModule) } + : null + } catch (error) { + console.warn( + '[SystemOcrService] native runtime unavailable engine=%s platform=%s reason=%s', + SYSTEM_OCR_ENGINE, + this.platform, + error instanceof Error ? error.message.split('\n')[0] : String(error) + ) + this.runtime = null + } + return this.runtime + } + + private async toPngBytes( + buffer: Buffer, + format: SystemOcrImageFormat + ): Promise { + if (this.deps.toPngBytes) return this.deps.toPngBytes({ buffer, format }) + if (format === 'png') return buffer + if (format === 'jpeg') { + // 项目内已有的进程内解码能力,优先于 ffmpeg(更快、无子进程)。 + try { + const { nativeImage } = require('electron') as typeof import('electron') + const image = nativeImage.createFromBuffer(buffer) + if (!image.isEmpty()) { + const png = image.toPNG() + if (png && png.length > 0) return png + } + } catch { + // 继续走 ffmpeg 兜底 + } + } + try { + const resolveFfmpeg = this.deps.resolveFfmpegExecutable ?? defaultResolveFfmpegExecutable + const png = await convertWithFfmpeg(buffer, resolveFfmpeg()) + return png + } catch { + return null + } + } + + /** capability 探测:平台 → native 运行时 → 至少一个可用 OCR 语言。 */ + async getCapability(force = false): Promise { + if (!force && this.capability) return this.capability + if (!force && this.capabilityPromise) return this.capabilityPromise + this.capabilityPromise = this.detectCapability() + try { + this.capability = await this.capabilityPromise + } finally { + this.capabilityPromise = null + } + return this.capability + } + + private async detectCapability(): Promise { + const base: Pick< + SystemOcrCapability, + 'engine' | 'platform' | 'arch' | 'runtimeVersion' | 'language' + > = { + engine: SYSTEM_OCR_ENGINE, + platform: this.platform, + arch: this.arch, + runtimeVersion: null, + language: null + } + if (this.platform !== 'win32') { + return { + ...base, + available: false, + reason: 'UNSUPPORTED_PLATFORM', + message: '本地图片文字识别目前仅支持 Windows。' + } + } + const runtime = this.loadRuntime() + if (!runtime) { + return { + ...base, + available: false, + reason: 'NATIVE_MODULE_MISSING', + message: '本地文字识别组件不可用,请重新安装 TraceMemo。' + } + } + const probed = await this.probeLanguage(runtime) + if (probed.reason) { + return { + ...base, + runtimeVersion: runtime.version, + available: false, + reason: probed.reason, + message: probed.message + } + } + return { + ...base, + runtimeVersion: runtime.version, + available: true, + language: probed.language, + message: probed.language + ? `本地图片文字识别可用(Windows 系统 OCR,${probed.language})。` + : '本地图片文字识别可用(Windows 系统 OCR,跟随系统语言)。' + } + } + + /** + * 用一个 64x32 纯白 PNG 探测语言可用性:引擎能创建即说明语言包可用。 + * 首选「系统 locale 推导出的标签」,失败再退回「系统用户语言配置」。 + */ + private async probeLanguage( + runtime: NativeRuntime + ): Promise< + | { language: string | null; reason?: undefined; message?: undefined } + | { language: null; reason: 'LANGUAGE_UNAVAILABLE' | 'NATIVE_MODULE_MISSING'; message: string } + > { + const probeBuffer = Buffer.from(SYSTEM_OCR_PROBE_PNG_BASE64, 'base64') + const preferred = resolveSystemOcrLanguageTag(this.locale) + const candidates: Array = preferred ? [preferred, null] : [null] + let lastCode: SystemOcrErrorCode = 'OCR_FAILED' + for (const candidate of candidates) { + try { + await runtime.recognize( + probeBuffer, + undefined, + candidate ? [candidate] : undefined + ) + return { language: candidate } + } catch (error) { + lastCode = mapSystemOcrNativeError( + error instanceof Error ? error.message : String(error) + ) + } + } + if (lastCode === 'OCR_LANGUAGE_UNAVAILABLE') { + return { + language: null, + reason: 'LANGUAGE_UNAVAILABLE', + message: + '当前 Windows 未安装可用的 OCR 语言支持,请在系统「语言和区域」里安装简体中文或英文的 OCR 语言包后重试。' + } + } + return { + language: null, + reason: 'NATIVE_MODULE_MISSING', + message: '本地文字识别引擎初始化失败,请重启 TraceMemo 或重新安装。' + } + } + + private readCache(key: string, startedAt: number): SystemOcrResult | null { + const hit = this.cache.get(key) + if (!hit) return null + if (hit.expireAt <= Date.now()) { + this.cache.delete(key) + return null + } + return { ...hit.value, durationMs: Date.now() - startedAt, fromCache: true } + } + + private writeCache(key: string, value: SystemOcrResult): void { + if (this.cache.size >= MAX_CACHE_ENTRIES) { + const oldest = this.cache.keys().next() + if (!oldest.done) this.cache.delete(oldest.value) + } + this.cache.set(key, { value, expireAt: Date.now() + SYSTEM_OCR_CACHE_TTL_MS }) + } + + /** + * 识别一张图片里的文字。 + * 任意失败都不抛,统一返回 success=false + 产品级 errorCode。 + */ + async recognize(request: SystemOcrRequest): Promise { + const startedAt = Date.now() + const parsed = parseImageDataUrl(request.imageDataUrl) + if (!parsed) { + return failure('UNSUPPORTED_IMAGE', '仅支持 PNG、JPG、JPEG、WebP、GIF、BMP 图片。', startedAt) + } + let sourceBuffer: Buffer + try { + sourceBuffer = Buffer.from(parsed.base64, 'base64') + } catch { + return failure('IMAGE_DECODE_FAILED', '图片数据无法解码。', startedAt) + } + if (sourceBuffer.length === 0) { + return failure('IMAGE_DECODE_FAILED', '图片数据为空。', startedAt) + } + const format = detectSystemOcrImageFormat(sourceBuffer) + if (!format) { + return failure('UNSUPPORTED_IMAGE', '无法识别的图片格式。', startedAt) + } + + const imageHash = + request.imageHash?.trim() || + crypto.createHash('sha256').update(sourceBuffer).digest('hex').slice(0, 32) + const requestedLanguage = request.language?.trim() || null + + const capability = await this.getCapability() + if (!capability.available) { + const errorCode: SystemOcrErrorCode = + capability.reason === 'UNSUPPORTED_PLATFORM' + ? 'UNSUPPORTED_PLATFORM' + : capability.reason === 'LANGUAGE_UNAVAILABLE' + ? 'OCR_LANGUAGE_UNAVAILABLE' + : 'SYSTEM_OCR_UNAVAILABLE' + return failure(errorCode, capability.message, startedAt) + } + + const languageForCache = requestedLanguage ?? capability.language + const cacheKey = buildSystemOcrCacheKey({ + imageHash, + language: languageForCache, + runtimeVersion: capability.runtimeVersion, + platform: capability.platform + }) + if (requestedLanguage === null) { + const cached = this.readCache(cacheKey, startedAt) + if (cached) return cached + } + + const runtime = this.loadRuntime() + if (!runtime) { + return failure('SYSTEM_OCR_UNAVAILABLE', '本地文字识别组件不可用。', startedAt) + } + + const png = await this.toPngBytes(sourceBuffer, format) + if (!png || png.length === 0 || !detectSystemOcrImageFormat(png)) { + return failure('IMAGE_DECODE_FAILED', '图片解码失败,无法读取这张图片。', startedAt) + } + + const candidates: Array = requestedLanguage + ? [requestedLanguage] + : capability.language + ? [capability.language, null] + : [null] + let lastErrorCode: SystemOcrErrorCode = 'OCR_FAILED' + let lastErrorMessage = '' + let usedLanguage: string | null = null + for (const candidate of candidates) { + try { + const result = await runtime.recognize( + png, + undefined, + candidate ? [candidate] : undefined + ) + const text = normalizeSystemOcrText(result?.text ?? '') + const lines = toLines(result?.lines) + usedLanguage = candidate + if (!text) { + return failure('OCR_EMPTY_RESULT', '没有在这张图片里识别到文字。', startedAt) + } + const succeeded: SystemOcrResult = { + success: true, + text, + lines, + language: usedLanguage, + engine: SYSTEM_OCR_ENGINE, + durationMs: Date.now() - startedAt + } + // 生产日志只记录 error code / engine / platform / duration,绝不记录识别正文。 + console.log( + '[SystemOcrService] ok engine=%s platform=%s language=%s chars=%d durationMs=%d', + SYSTEM_OCR_ENGINE, + capability.platform, + usedLanguage ?? 'system-default', + text.length, + succeeded.durationMs + ) + this.writeCache(cacheKey, succeeded) + return succeeded + } catch (error) { + lastErrorMessage = error instanceof Error ? error.message : String(error) + lastErrorCode = mapSystemOcrNativeError(lastErrorMessage) + // 语言不可用才值得换下一个候选;其它错误直接结束,避免无意义重试。 + if (lastErrorCode !== 'OCR_LANGUAGE_UNAVAILABLE') break + } + } + + console.warn( + '[SystemOcrService] failed engine=%s platform=%s errorCode=%s durationMs=%d', + SYSTEM_OCR_ENGINE, + capability.platform, + lastErrorCode, + Date.now() - startedAt + ) + return failure( + lastErrorCode, + lastErrorCode === 'OCR_LANGUAGE_UNAVAILABLE' + ? '当前 Windows 未安装可用的 OCR 语言支持。' + : '本地文字识别失败,请稍后重试。', + startedAt + ) + } +} + +export { SystemOcrService } +export const systemOcrService = new SystemOcrService() diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 6df2003..7e8bd3a 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -57,6 +57,7 @@ import type { ImageCandidateQuery, ImageInsight } from '../shared/image-insight' +import type { SystemOcrCapability, SystemOcrRequest, SystemOcrResult } from '../shared/system-ocr' import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub' import type { PersonalWechatGeneratedTtsVoiceRequest, @@ -660,6 +661,9 @@ declare global { sessionId: string, limit?: number ) => Promise<{ success: boolean; insights: ImageInsight[] }> + // 本地图片文字识别(System OCR,本地 Runtime,非 AI Provider) + getSystemOcrCapability: () => Promise + recognizeLocalImageText: (request: SystemOcrRequest) => Promise getPersonalWechatSenderStatus: () => Promise getPersonalWechatSendCapability: () => Promise getPersonalWechatKeepOneBotProcess: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index ad99fd8..1f17ae3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -30,6 +30,7 @@ import type { ImageCandidateQuery, ImageInsight } from '../shared/image-insight' +import type { SystemOcrCapability, SystemOcrRequest, SystemOcrResult } from '../shared/system-ocr' import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub' import type { PersonalWechatGeneratedTtsVoiceRequest, @@ -462,6 +463,11 @@ const api = { limit?: number ): Promise<{ success: boolean; insights: ImageInsight[] }> => ipcRenderer.invoke('image:listInsights', sessionId, limit), + // 本地图片文字识别(System OCR,本地 Runtime,非 AI Provider) + getSystemOcrCapability: (): Promise => + ipcRenderer.invoke('system-ocr:getCapability'), + recognizeLocalImageText: (request: SystemOcrRequest): Promise => + ipcRenderer.invoke('system-ocr:recognize', request), getPersonalWechatSenderStatus: (): Promise => ipcRenderer.invoke('wechat-personal:getStatus'), getPersonalWechatSendCapability: (): Promise => diff --git a/src/renderer/src/features/settings/ai-model/LocalImageTextRecognition.tsx b/src/renderer/src/features/settings/ai-model/LocalImageTextRecognition.tsx new file mode 100644 index 0000000..c832b7e --- /dev/null +++ b/src/renderer/src/features/settings/ai-model/LocalImageTextRecognition.tsx @@ -0,0 +1,255 @@ +import { useCallback, useEffect, useState } from 'react' +import type { SystemOcrCapability, SystemOcrResult } from '../../../../../shared/system-ocr' +import { Button } from '../../../components/ui' + +const MAX_FILE_BYTES = 10 * 1024 * 1024 + +type LocalOcrStatus = 'idle' | 'reading' | 'ready' | 'running' | 'done' | 'error' + +interface LocalOcrState { + status: LocalOcrStatus + image?: { + dataUrl: string + fileName: string + size: number + } + result?: SystemOcrResult + error?: string +} + +/** + * 本地图片文字识别(Windows 系统 OCR)。 + * + * 这是**本地 Runtime**,不是 AI 图片理解: + * - 只把图片里的文字读出来;不描述画面、人物、场景,也不做视觉推理; + * - 原始图片不会因为这一步发给任何 AI Provider; + * - 结果只是派生内容,不会写进本地知识库。 + */ +export function LocalImageTextRecognition(): React.ReactElement { + const [capability, setCapability] = useState(null) + const [state, setState] = useState({ status: 'idle' }) + + useEffect(() => { + let alive = true + void window.api + .getSystemOcrCapability() + .then((value) => { + if (alive) setCapability(value) + }) + .catch(() => { + if (alive) setCapability(null) + }) + return () => { + alive = false + } + }, []) + + const selectImage = useCallback(async (file: File): Promise => { + const extension = file.name.split('.').pop()?.toLowerCase() + const inferredType = + extension === 'png' + ? 'image/png' + : extension === 'jpg' || extension === 'jpeg' + ? 'image/jpeg' + : extension === 'webp' + ? 'image/webp' + : extension === 'bmp' + ? 'image/bmp' + : extension === 'gif' + ? 'image/gif' + : '' + const mimeType = file.type === 'image/jpg' ? 'image/jpeg' : file.type || inferredType + const supportedTypes = new Set([ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/bmp', + 'image/gif' + ]) + if (!supportedTypes.has(mimeType)) { + setState({ status: 'error', error: '请选择 PNG、JPG、JPEG、WebP、BMP 或 GIF 图片' }) + return + } + if (!file.size || file.size > MAX_FILE_BYTES) { + setState({ status: 'error', error: '图片大小必须在 10 MB 以内' }) + return + } + setState({ status: 'reading' }) + try { + const rawDataUrl = await readFileAsDataUrl(file) + const dataUrl = rawDataUrl.replace(/^data:[^;]*;/, `data:${mimeType};`) + setState({ + status: 'ready', + image: { dataUrl, fileName: file.name, size: file.size } + }) + } catch { + setState({ status: 'error', error: '图片无法读取,请重新选择' }) + } + }, []) + + const run = useCallback(async (): Promise => { + const image = state.image + if (!image) return + if (!capability?.available) { + setState((current) => ({ + ...current, + status: 'error', + error: capability?.message || '本机当前不支持本地图片文字识别' + })) + return + } + setState((current) => ({ ...current, status: 'running', result: undefined, error: undefined })) + try { + const result = await window.api.recognizeLocalImageText({ imageDataUrl: image.dataUrl }) + setState((current) => + result.success + ? { ...current, status: 'done', result, error: undefined } + : { ...current, status: 'error', result: undefined, error: localOcrErrorMessage(result) } + ) + } catch { + setState((current) => ({ + ...current, + status: 'error', + result: undefined, + error: '本地文字识别调用失败,请重试' + })) + } + }, [capability, state.image]) + + const clear = useCallback(() => setState({ status: 'idle' }), []) + + const running = state.status === 'running' + const result = state.result + + return ( +
+
+
+

本地图片文字识别

+

使用 Windows 系统 OCR 在本机读取图片中的文字,原始图片无需发送给 AI Provider。

+
+ + {capability ? (capability.available ? '本机可用' : '本机不可用') : '检测中…'} + +
+ + {capability && !capability.available ? ( +

{capability.message}

+ ) : null} + {capability?.available ? ( +

+ 引擎:Windows 系统 OCR + {capability.runtimeVersion ? ` · 组件 ${capability.runtimeVersion}` : ''} + {capability.language ? ` · 语言 ${capability.language}` : ' · 语言跟随系统'} +

+ ) : null} + + + +

+ 使用本地 OCR 时,原始图片无需发送给 AI Provider,也不会写入本地缓存或知识库。如果后续继续使用云端 + AI 分析,提取出的文字可能按当前 Provider 配置发送。 +

+ + {state.error ?

{state.error}

: null} + + {result?.success ? ( +
+

识别结果

+
+
+
引擎
+
Windows 系统 OCR
+
+
+
语言
+
{result.language || '跟随系统'}
+
+
+
耗时
+
{Math.round(result.durationMs)} ms
+
+
+
{result.text}
+

+ 本地文字识别只读取图片中的文字内容,不会描述画面、人物或场景。 +

+
+ ) : null} + +
+ {state.image ? ( + + ) : null} + +
+
+ ) +} + +function localOcrErrorMessage(result: SystemOcrResult): string { + switch (result.errorCode) { + case 'UNSUPPORTED_PLATFORM': + return '本地图片文字识别目前仅支持 Windows。' + case 'SYSTEM_OCR_UNAVAILABLE': + return '本地文字识别组件不可用,请重新安装 TraceMemo。' + case 'OCR_LANGUAGE_UNAVAILABLE': + return '当前 Windows 未安装可用的 OCR 语言支持,请在系统「语言和区域」中安装中文或英文语言包。' + case 'UNSUPPORTED_IMAGE': + return '这张图片的格式暂不支持本地文字识别。' + case 'IMAGE_DECODE_FAILED': + return '图片解码失败,无法读取这张图片。' + case 'OCR_EMPTY_RESULT': + return '没有在这张图片里识别到文字。' + default: + return result.error || '本地文字识别失败,请重试。' + } +} + +function formatFileSize(bytes: number): string { + return bytes < 1024 * 1024 + ? `${Math.max(1, Math.round(bytes / 1024))} KB` + : `${(bytes / 1024 / 1024).toFixed(1)} MB` +} + +function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.addEventListener('load', () => + typeof reader.result === 'string' ? resolve(reader.result) : reject(new Error('invalid image')) + ) + reader.addEventListener('error', () => reject(reader.error || new Error('read failed'))) + reader.readAsDataURL(file) + }) +} diff --git a/src/renderer/src/features/settings/pages/AIModelPage.tsx b/src/renderer/src/features/settings/pages/AIModelPage.tsx index 5089d52..c846566 100644 --- a/src/renderer/src/features/settings/pages/AIModelPage.tsx +++ b/src/renderer/src/features/settings/pages/AIModelPage.tsx @@ -4,6 +4,7 @@ import { Button } from '../../../components/ui' import { AIProviderCard } from '../ai-model/AIProviderCard' import { AIProviderEditor } from '../ai-model/AIProviderEditor' import { AIImageUnderstandingTest } from '../ai-model/AIImageUnderstandingTest' +import { LocalImageTextRecognition } from '../ai-model/LocalImageTextRecognition' import { useAIModelSettingsController } from '../ai-model/useAIModelSettingsController' export function AIModelPage({ @@ -68,6 +69,7 @@ export function AIModelPage({ onTest={() => void controller.runVisionTest()} onClear={controller.clearVisionImage} /> + {controller.state.error ? (

{controller.state.error}

) : null} diff --git a/src/renderer/src/styles/reports-core.scss b/src/renderer/src/styles/reports-core.scss index 72febcb..62648a2 100644 --- a/src/renderer/src/styles/reports-core.scss +++ b/src/renderer/src/styles/reports-core.scss @@ -1605,6 +1605,167 @@ justify-content: flex-end; } +/* 本地图片文字识别(System OCR,本地 Runtime,与 AI 图片理解刻意区分) */ +.local-ocr-test { + display: grid; + gap: 16px; +} + +.local-ocr-test > header, +.local-ocr-test > footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.local-ocr-test h2, +.local-ocr-test h3, +.local-ocr-test p { + margin: 0; +} + +.local-ocr-test h2 { + color: var(--wxex-text-primary); + font-size: 15px; +} + +.local-ocr-test header p, +.local-ocr-runtime, +.local-ocr-upload small, +.local-ocr-hint { + margin-top: 4px; + color: var(--wxex-text-secondary); + font-size: 12px; +} + +.local-ocr-capability { + border-radius: 999px; + padding: 5px 9px; + background: var(--wxex-bg-sidebar); + color: var(--wxex-text-secondary); + white-space: nowrap; + font-size: 12px; +} + +.local-ocr-capability.supported { + background: var(--wxex-brand-soft); + color: var(--wxex-brand); +} + +.local-ocr-notice { + border-left: 3px solid var(--wxex-danger); + padding: 9px 11px; + background: color-mix(in srgb, var(--wxex-danger) 12%, transparent); + color: var(--wxex-danger); + font-size: 12px; + line-height: 1.6; +} + +.local-ocr-upload { + display: flex; + min-height: 112px; + align-items: center; + justify-content: center; + gap: 14px; + border: 1px dashed var(--wxex-border); + border-radius: var(--wxex-radius-md); + padding: 14px; + background: var(--wxex-bg-sidebar); + color: var(--wxex-text-primary); + text-align: center; + cursor: pointer; +} + +.local-ocr-upload:hover { + border-color: var(--wxex-brand); + background: var(--wxex-brand-soft); +} + +.local-ocr-upload input { + display: none; +} + +.local-ocr-upload.has-image { + justify-content: flex-start; + text-align: left; +} + +.local-ocr-upload img { + width: 112px; + height: 82px; + flex: 0 0 auto; + border-radius: 8px; + object-fit: cover; +} + +.local-ocr-upload strong, +.local-ocr-upload small { + display: block; +} + +.local-ocr-privacy { + color: var(--wxex-text-secondary); + font-size: 12px; + line-height: 1.6; +} + +.local-ocr-error { + border-left: 3px solid var(--wxex-danger); + padding: 9px 11px; + background: color-mix(in srgb, var(--wxex-danger) 12%, transparent); + color: var(--wxex-danger); + font-size: 12px; +} + +.local-ocr-result { + display: grid; + gap: 12px; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-md); + padding: 14px; + background: var(--wxex-bg-sidebar); +} + +.local-ocr-result dl { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin: 0; +} + +.local-ocr-result dt { + color: var(--wxex-text-muted); + font-size: 11px; +} + +.local-ocr-result dd { + margin: 4px 0 0; + color: var(--wxex-text-primary); + font-size: 12px; + font-weight: 600; +} + +.local-ocr-text { + max-height: 240px; + overflow: auto; + margin: 0; + border: 1px solid var(--wxex-border); + border-radius: 8px; + padding: 12px; + background: var(--wxex-bg-app); + color: var(--wxex-text-primary); + font-family: inherit; + font-size: 13px; + line-height: 1.7; + white-space: pre-wrap; + word-break: break-word; +} + +.local-ocr-test > footer { + justify-content: flex-end; +} + .report-history-sidebar { display: flex; min-width: 0; diff --git a/src/shared/system-ocr.ts b/src/shared/system-ocr.ts new file mode 100644 index 0000000..b3e28ed --- /dev/null +++ b/src/shared/system-ocr.ts @@ -0,0 +1,266 @@ +// src/shared/system-ocr.ts +// +// 本地系统 OCR(System OCR)共享契约。 +// +// 架构边界(不要混淆): +// - System OCR 是**本地 Runtime**,不是 AI Provider,也不是 Vision Model。 +// 它不占用 AIVisionRuntimeConfig.source,也不产生任何网络请求。 +// - 能力边界:只把图片里的文字读出来。它不等于「理解人物 / 理解场景 / +// 描述照片 / 理解表情包语义 / 视觉推理」——那些仍然属于 Vision Model。 +// - Windows 后端为 Windows.Media.Ocr.OcrEngine(经 @napi-rs/system-ocr 调用)。 +// macOS 本轮只保留架构位置,未实现;Linux 不支持。 +// +// 数据边界(本轮不做): +// - 不做历史图片全量 OCR、不做 Knowledge 回填、不把 OCR 文字伪装成原始聊天文字。 +// 原始消息始终是权威来源,OCR 文字只是派生内容(本轮仅存在于内存)。 + +/** System OCR 引擎标识。这是本地 Runtime,不是 provider id。 */ +export const SYSTEM_OCR_ENGINE = 'windows-system-ocr' + +/** 本地 OCR 结果在内存中的缓存时长。 */ +export const SYSTEM_OCR_CACHE_TTL_MS = 10 * 60 * 1000 + +/** + * 产品级错误码。用户可见文案由 error 字段承载,任何 native 堆栈 / HRESULT + * 都不会直接透出到 Renderer。 + */ +export type SystemOcrErrorCode = + /** 运行时不可用(native binding 缺失 / 加载失败) */ + | 'SYSTEM_OCR_UNAVAILABLE' + /** 当前平台不支持(Linux,或非 Windows 平台) */ + | 'UNSUPPORTED_PLATFORM' + /** 图片格式不在支持范围内 */ + | 'UNSUPPORTED_IMAGE' + /** 图片解码失败(格式可识别但内容损坏或无法转成 PNG) */ + | 'IMAGE_DECODE_FAILED' + /** 当前 Windows 未安装对应的 OCR 语言支持 */ + | 'OCR_LANGUAGE_UNAVAILABLE' + /** 引擎执行失败 */ + | 'OCR_FAILED' + /** 识别成功执行,但图里没有文字 */ + | 'OCR_EMPTY_RESULT' + +export type SystemOcrUnavailableReason = + | 'UNSUPPORTED_PLATFORM' + | 'NATIVE_MODULE_MISSING' + | 'LANGUAGE_UNAVAILABLE' + +/** 本机 System OCR 能力。UI 只用它决定是否展示「本地文字识别」入口。 */ +export interface SystemOcrCapability { + /** 本机当前是否真的可以识别图片文字 */ + available: boolean + engine: typeof SYSTEM_OCR_ENGINE + platform: NodeJS.Platform + arch: string + /** @napi-rs/system-ocr 运行时版本;无法读取时为 null */ + runtimeVersion: string | null + /** 实际可用的 OCR 语言标签(对应 Windows 语言包);null 表示走系统用户语言 */ + language: string | null + reason?: SystemOcrUnavailableReason + /** 面向用户的中文说明,可直接展示 */ + message: string +} + +export interface SystemOcrBoundingBox { + /** 归一化到 0..1,原点在左上角 */ + x: number + y: number + width: number + height: number +} + +export interface SystemOcrLine { + text: string + /** Windows 恒为 1.0 */ + confidence: number + boundingBox: SystemOcrBoundingBox +} + +/** 本地 OCR 结果。不包含任何 Windows handle / native 内部对象。 */ +export interface SystemOcrResult { + success: boolean + /** 归一化后的文本(去掉 CJK 字符之间的引擎伪空格) */ + text: string + lines: SystemOcrLine[] + /** 实际使用的 OCR 语言标签;null 表示由系统用户语言决定 */ + language: string | null + engine: typeof SYSTEM_OCR_ENGINE + durationMs: number + /** 命中内存缓存时为 true */ + fromCache?: boolean + errorCode?: SystemOcrErrorCode + error?: string +} + +export interface SystemOcrRequest { + /** data URL(data:image/png;base64,...)。base64 只在 main 内部流转,不回传 Renderer。 */ + imageDataUrl: string + /** 调用方已经算好的图片内容哈希;未传时由 main 内部计算 */ + imageHash?: string + /** 指定 OCR 语言标签;默认按系统语言解析 */ + language?: string +} + +/** + * 缓存 key 组合。刻意与 ImageInsight 的 `imageHash` 保持不同的键空间, + * 保证远端 Vision 的旧结果永远不会被当成"本地 OCR 结果"复用, + * 也保证 System OCR 运行时升级后不会永远命中旧结果。 + */ +export const buildSystemOcrCacheKey = (input: { + imageHash: string + language: string | null + runtimeVersion: string | null + platform?: string +}): string => + [ + input.imageHash, + SYSTEM_OCR_ENGINE, + input.platform ?? 'unknown', + input.language ?? 'auto', + input.runtimeVersion ?? 'unknown' + ].join('|') + +const CJK_CHAR = + /[\u3000-\u303f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef\uac00-\ud7af]/ + +/** + * Windows OCR 会在每个 CJK 字符之间插入空格("本 地 图 片")。 + * 这里只删除 **两侧都是 CJK** 的空格,保留 "TraceMemo 本地图片文字识别" 里的真实分隔。 + */ +export const normalizeSystemOcrText = (value: string): string => { + const source = String(value ?? '') + if (!source) return '' + let result = '' + for (let index = 0; index < source.length; index += 1) { + const char = source[index] + if (char === ' ' || char === '\u3000') { + const previous = result[result.length - 1] + let next = '' + for (let lookahead = index + 1; lookahead < source.length; lookahead += 1) { + if (source[lookahead] !== ' ' && source[lookahead] !== '\u3000') { + next = source[lookahead] + break + } + } + if (previous && next && CJK_CHAR.test(previous) && CJK_CHAR.test(next)) continue + } + result += char + } + return result.trim() +} + +/** 把系统 locale(如 zh-CN / en-US)映射成 Windows OCR 语言标签。 */ +const LANGUAGE_TAG_BY_LOCALE: Record = { + zh: 'zh-Hans-CN', + 'zh-cn': 'zh-Hans-CN', + 'zh-hans': 'zh-Hans-CN', + 'zh-hans-cn': 'zh-Hans-CN', + 'zh-sg': 'zh-Hans-CN', + 'zh-tw': 'zh-Hant-TW', + 'zh-hant': 'zh-Hant-TW', + 'zh-hant-tw': 'zh-Hant-TW', + 'zh-hk': 'zh-Hant-HK', + 'zh-hant-hk': 'zh-Hant-HK', + 'zh-mo': 'zh-Hant-MO', + 'zh-hant-mo': 'zh-Hant-MO', + en: 'en-US', + 'en-us': 'en-US', + 'en-gb': 'en-GB', + 'en-au': 'en-AU', + 'en-ca': 'en-CA', + ja: 'ja-JP', + 'ja-jp': 'ja-JP', + ko: 'ko-KR', + 'ko-kr': 'ko-KR', + fr: 'fr-FR', + 'fr-fr': 'fr-FR', + de: 'de-DE', + 'de-de': 'de-DE', + es: 'es-ES', + 'es-es': 'es-ES', + it: 'it-IT', + 'it-it': 'it-IT', + pt: 'pt-BR', + 'pt-br': 'pt-BR', + ru: 'ru-RU', + 'ru-ru': 'ru-RU' +} + +export const resolveSystemOcrLanguageTag = ( + locale: string | null | undefined +): string | null => { + const normalized = String(locale ?? '') + .trim() + .toLowerCase() + .replace(/_/g, '-') + if (!normalized) return null + if (LANGUAGE_TAG_BY_LOCALE[normalized]) return LANGUAGE_TAG_BY_LOCALE[normalized] + const primary = normalized.split('-')[0] + return LANGUAGE_TAG_BY_LOCALE[primary] ?? null +} + +/** + * 把 native 错误映射成产品级错误码。 + * + * 已确认的 Windows 行为(1.2.0): + * - 语言包缺失 / 引擎无法创建:`Windows error 操作成功完成。 (0x00000000)` + * —— TryCreateFromLanguage 返回 null 引擎但 HRESULT 是 S_OK,非常容易误判。 + * - 送给解码器的字节不是可识别的图片:`Windows error Could not recognize file (0x80070005)` + */ +export const mapSystemOcrNativeError = (message: string): SystemOcrErrorCode => { + const detail = String(message ?? '') + if (!detail) return 'OCR_FAILED' + if (/Cannot find native binding|Failed to load native binding|MODULE_NOT_FOUND/i.test(detail)) { + return 'SYSTEM_OCR_UNAVAILABLE' + } + if (/\(0x00000000\)/.test(detail)) return 'OCR_LANGUAGE_UNAVAILABLE' + if (/Could not recognize file/i.test(detail)) return 'IMAGE_DECODE_FAILED' + if (/Could not open file/i.test(detail)) return 'IMAGE_DECODE_FAILED' + return 'OCR_FAILED' +} + +/** 解析 data URL;只接受图片 MIME。 */ +export const parseImageDataUrl = ( + dataUrl: string +): { mimeType: string; base64: string } | null => { + const matched = /^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i.exec(String(dataUrl ?? '').trim()) + if (!matched) return null + return { mimeType: matched[1].toLowerCase(), base64: matched[2] } +} + +export type SystemOcrImageFormat = 'png' | 'jpeg' | 'gif' | 'bmp' | 'webp' | 'tiff' + +/** 按魔数识别格式。返回 null 表示不在支持范围内。 */ +export const detectSystemOcrImageFormat = (buffer: Uint8Array): SystemOcrImageFormat | null => { + if (!buffer || buffer.length < 4) return null + const byte = (index: number): number => buffer[index] + if (byte(0) === 0x89 && byte(1) === 0x50 && byte(2) === 0x4e && byte(3) === 0x47) return 'png' + if (byte(0) === 0xff && byte(1) === 0xd8 && byte(2) === 0xff) return 'jpeg' + if (byte(0) === 0x47 && byte(1) === 0x49 && byte(2) === 0x46) return 'gif' + if (byte(0) === 0x42 && byte(1) === 0x4d) return 'bmp' + if ( + byte(0) === 0x52 && + byte(1) === 0x49 && + byte(2) === 0x46 && + byte(3) === 0x46 && + buffer.length > 11 && + byte(8) === 0x57 && + byte(9) === 0x45 && + byte(10) === 0x42 && + byte(11) === 0x50 + ) { + return 'webp' + } + if ((byte(0) === 0x49 && byte(1) === 0x49) || (byte(0) === 0x4d && byte(1) === 0x4d)) { + return 'tiff' + } + return null +} + +/** + * 64x32 纯白 PNG。仅用于 language capability 探测: + * 引擎能创建 → 该语言包可用;引擎创建失败 → 语言不可用。 + * 探测耗时量级为个位数毫秒。 + */ +export const SYSTEM_OCR_PROBE_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAIAAAAt/+nTAAAANUlEQVR42u3PAQkAAAgDMLV/59tCELYG6yT12dRzAgICAgICAgICAgICAgICAgICAgICAvcWMisDPdIJjMIAAAAASUVORK5CYII=' diff --git a/tests/component/ai-model-settings.test.tsx b/tests/component/ai-model-settings.test.tsx index 523c0d1..b00f417 100644 --- a/tests/component/ai-model-settings.test.tsx +++ b/tests/component/ai-model-settings.test.tsx @@ -6,6 +6,7 @@ import { AIProviderCard } from '../../src/renderer/src/features/settings/ai-mode import { AIModelPage } from '../../src/renderer/src/features/settings/pages/AIModelPage' import type { AIProviderSummary, AIRuntimeModelConfig } from '../../src/shared/ai-provider' import type { AIVisionTestState } from '../../src/renderer/src/features/settings/ai-model/types' +import type { SystemOcrCapability } from '../../src/shared/system-ocr' const runtime: AIRuntimeModelConfig = { providerName: 'Not configured', @@ -15,6 +16,16 @@ const runtime: AIRuntimeModelConfig = { status: 'untested' } +const systemOcrCapability: SystemOcrCapability = { + available: true, + engine: 'windows-system-ocr', + platform: 'win32', + arch: 'x64', + runtimeVersion: '1.2.0', + language: 'zh-Hans-CN', + message: '本地图片文字识别可用(Windows 系统 OCR,zh-Hans-CN)。' +} + const provider: AIProviderSummary = { id: 'fixture-provider', name: '本地假服务', @@ -56,7 +67,8 @@ describe('AI model settings', () => { }) window.api = { listAIProviders: vi.fn().mockResolvedValue({ success: true, providers: [] }), - getAIRuntimeConfig: vi.fn().mockResolvedValue(runtime) + getAIRuntimeConfig: vi.fn().mockResolvedValue(runtime), + getSystemOcrCapability: vi.fn().mockResolvedValue(systemOcrCapability) } as typeof window.api }) diff --git a/tests/component/local-image-text-recognition.test.tsx b/tests/component/local-image-text-recognition.test.tsx new file mode 100644 index 0000000..96687b8 --- /dev/null +++ b/tests/component/local-image-text-recognition.test.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { LocalImageTextRecognition } from '../../src/renderer/src/features/settings/ai-model/LocalImageTextRecognition' +import type { SystemOcrCapability, SystemOcrResult } from '../../src/shared/system-ocr' + +const availableCapability: SystemOcrCapability = { + available: true, + engine: 'windows-system-ocr', + platform: 'win32', + arch: 'x64', + runtimeVersion: '1.2.0', + language: 'zh-Hans-CN', + message: '本地图片文字识别可用(Windows 系统 OCR,zh-Hans-CN)。' +} + +const unavailableCapability: SystemOcrCapability = { + available: false, + engine: 'windows-system-ocr', + platform: 'win32', + arch: 'x64', + runtimeVersion: '1.2.0', + language: null, + reason: 'LANGUAGE_UNAVAILABLE', + message: '当前 Windows 未安装可用的 OCR 语言支持。' +} + +const successResult: SystemOcrResult = { + success: true, + text: 'TraceMemo 本地文字识别', + lines: [], + language: 'zh-Hans-CN', + engine: 'windows-system-ocr', + durationMs: 42 +} + +const emptyResult: SystemOcrResult = { + success: false, + text: '', + lines: [], + language: 'zh-Hans-CN', + engine: 'windows-system-ocr', + durationMs: 12, + errorCode: 'OCR_EMPTY_RESULT', + error: '没有在这张图片里识别到文字。' +} + +const selectImage = (): void => { + const input = document.querySelector('input[type="file"]') + if (!input) throw new Error('file input missing') + const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], 'fixture.png', { + type: 'image/png' + }) + fireEvent.change(input, { target: { files: [file] } }) +} + +describe('LocalImageTextRecognition', () => { + beforeEach(() => { + window.api = { + getSystemOcrCapability: vi.fn().mockResolvedValue(availableCapability), + recognizeLocalImageText: vi.fn().mockResolvedValue(successResult) + } as typeof window.api + }) + + it('shows local availability without requiring any AI provider', async () => { + render() + expect(await screen.findByText('本机可用')).toBeInTheDocument() + expect(screen.getByText(/组件 1\.2\.0/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: '本地文字识别' })).toBeDisabled() + }) + + it('recognizes text locally and renders the extracted text', async () => { + render() + await screen.findByText('本机可用') + + selectImage() + const button = await screen.findByRole('button', { name: '本地文字识别' }) + await waitFor(() => expect(button).toBeEnabled()) + fireEvent.click(button) + + expect(await screen.findByText('TraceMemo 本地文字识别')).toBeInTheDocument() + expect(window.api.recognizeLocalImageText).toHaveBeenCalledWith({ + imageDataUrl: expect.stringContaining('data:image/png;base64,') + }) + }) + + it('surfaces an empty OCR result as a product message, not a native error', async () => { + vi.mocked(window.api.recognizeLocalImageText).mockResolvedValue(emptyResult) + render() + await screen.findByText('本机可用') + + selectImage() + const button = await screen.findByRole('button', { name: '本地文字识别' }) + await waitFor(() => expect(button).toBeEnabled()) + fireEvent.click(button) + + expect(await screen.findByText('没有在这张图片里识别到文字。')).toBeInTheDocument() + }) + + it('keeps the entry disabled and explains why when the language pack is missing', async () => { + vi.mocked(window.api.getSystemOcrCapability).mockResolvedValue(unavailableCapability) + render() + + expect(await screen.findByText('本机不可用')).toBeInTheDocument() + expect(screen.getByText('当前 Windows 未安装可用的 OCR 语言支持。')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '本地文字识别' })).toBeDisabled() + }) +}) diff --git a/tests/fixtures/ocr/system-ocr-en.png b/tests/fixtures/ocr/system-ocr-en.png new file mode 100644 index 0000000000000000000000000000000000000000..ba96237ecb9bf59b5fbc6d3c507017c459e4c7a1 GIT binary patch literal 10436 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 nG_N|LvRHX-JneThkB%a}Wa5JD)$BqZ6&TF5lk zr0lXYma^xb%D6K#x995fJ&x!3pWlQ>s)4IaD#!_ zcIK4%DGUz}2EzmXVKzoECor3M{=WWRH~oF_@&3K?^YQWW3GfRD{O2twv{^tFTb#$fZ%^_`G0lU_<-5I8I!^*%*!K>*|eR9 zcRSBU69xzN$^W12fzAHci)RxrAHRSg*oFwWp>iA8J})oWG(XrexH}a5KZb8Rzu4YG zCIUOGuL{chiED)=Wp7qE{)R4bt{Ya=cDfNRB)n5{mz1>9K4q2t2Xu7x^ba2~IC0YS zl-X(XGv_Z{ykv9P*3Q|*)y>_*)9dCf|A4@t;M);*Bcr14#l$A3JWNeXfAl!xc}{NL zi~N@bugc0RDyypBzN=|yY-(<4{n+-Yr?;>F>%id9@Z{9=%(w5ebMp(#pTB;uveuA4 z>wo3q!SMcz9eXHy_}^KR$k-+M?v%*0yos^1QIt+37F$CI+( z(1jGV&%qK-H@by)D(XxsG5?D8AIbio2^RkUNwWVb*#DAi7_)_!2OJ*nb_^cFYUEKR2ysPVY|J$_PW%g#9~b7%?-@< zLWr(Ln+rp`aEOHm#CcnqM`NTz@O8~4upYk`(qF<6;y$gHCR=v6O4Y3^h*Bq=aGxrLccKa)(amyu zVHca*Un?QL7VmfW%QpQudB+`dOSbSw;t&ZHN@w#^nowOD63^a`wxiOp@w8z(i*myp0rT7mTR$zFJVDg34F*yyG2zzx9r=N{QsOi*Nbic8gxI|*LDGcYP8=cfi*Nnslp%x^^|p_O<)OB}xRSRpmh znn}7>TrnsWttg}+E1s0~xQG@(t%Q~;*(cl8aOG>u`x#hSBcb|RFzJ4gzqa%SCOER9 zH0*~5FRk>!28L0Hhwbp-Y&LU!81|*Z_8%Pa1Cmo)GLk(SKMXs>x7{6#JFl6^Ds1<8 z$G#F0u+s6hV{{8Qm>34ZWEv-o=0;dwXC|T%&$|Q{@Eoy5R78@k4AC8pa8sQU=;w7} zoK6f!vcP`WI|CPgP2H!|S~)&TCeq&f$=!qR5?uJ+`gFZLAkZ^$Y2YWcf#JKr&0bwW z6lOU)4e8_Ckr+rfI?Pxjh-CVvf2Njf%6(F5Yv(2HFzNi0y3cdDv7NJ@71nauW;N^> ze-NG{PC|v%*~SoE6Yo|_9gb!_aPXQvm3X^8x^GgG{{7OHX5X~oXuycC6b_27S_JKA$ zJ((D_UwN!vYc~%iY5{y)41lS&Mb|&#l@X@bFV$L zU?Dw*MDnGM2P^Ki3Q6X_P6X=`1}CN`Vsc>{ux#&Sdc5wV)C>oO28vN{rA)4{R#P)c z`zn2#zesI(r6Q$f&cGZ|97y)W!&cBBj>vp~_-9i_ zz(zI6$9jWRv+c-ddUETu++~a+pVlw2T0D3M=AHxbOG2h>nrV@E0kRRdwS2_!vbEJGQmzV2)xZZn6Ay_zM8 zq#s4s&weJn7Ux+Mw>HIyTN58|VBpJNM9{6r`Zh3LvU1a?5O&&9`z+#Sg!QValS~S7 zc(e$4*Zq2e?(3>Ueu_T(LrAB87^~fbdm(Pa*uZe)@h~}%E1@p9f#KosY+ycCwsHKC z2w+20$`?qGY|i{OO>A8eoui1uX-Rr{B@5L%>o;BH+g@^3!bij~CGvum?NI6kv15DW zvVqzE!!5#lkzyy{8=A}hu=Z5qvg$O55{Pmm5zc}djxd&?7Mp>LOQ|eKa&TmXo6+NG zx6A0I!<1#Lo`P11coMNXm2(bl##Ik+#Lv@-)%AtU5ta?Ia`hR<4H+o9GJks_#{a0Y znSJP)*-L#_YaYAUj|7xg+LFZfjTcD2I4HbVp5{oK+z!IsG;OZH1CL-CSk&|xc3fB$ zhS2Re#MlsxO#=enK`|Sc89S~J@DqEE3Ao-PW4Q6;t4sw{q>cHcVOWm#BIv8R8ppcW zD?=bNe{haOauwg?xx}aU=0{yfq`--~idZcZlVdnZh`vnw=?qK(zf@?xfqBuM!M(xF zfM!PPaT}PylsssD_eCTUC>277HZX5ymk+UJIcjk0(j?o4ql<(Ucut6oFIx3-wokvu zU&R^zQ?BW^Wi+v2!JEnJf6 z={@NnIdv)BzrfN@Hd!)K{ds1dyH&!O$dAKAyQMK-;e3t+mbH`Fx}0ACFu}-3li9vZ z+*NV)P+uL`;^vMQS*K&id~sa$<;X&(eV6{Y9_bJuNHCQ-a{3z>m6*y6Oj+gg4b1$} zRWhBevVr+DKe2F2R3*IY zkvV2^j;}{S>$U48i8gR|BMrV-d;%46W@F8#uu=LCG@DlLNhW(xFu#(nTz;MC|25b8 ztshB7qw9vz{IgN1Kw%C*>IgE#n3w~|n{W9NqMv{m+3O|mUm-iJ2Yhj+-9L1tl7F=f z6sm^4`nj+e_=I&;16 zqtb%dqXw>mBf;wfUBowu968|aeBZb^Mi<=~nu(|wQpLwz z9)y4O&qOL%w;SBC4E@C*BKul7J9;PcBXeMzFxu6LyTg>nyN_%8;2OC{>$>l|o$|0a z(^`APDI|rs1*pIQ&lsQ$HnM6Fi{c`^OWfxfwl98%eK*-6$1W(bG2?wncK*bOnV&P{dcCairpQZ|Wc_m& zyB~({65G5dwvyOU&5;fP=X4n za%hjH9-3)y?$9I1^-E_)ac#e9B2VCPl3TsK(X>@FeV8uSe;d*i}K|N4)y$Xw_c5nQLNU+az-Xs@lfTq3`o8(yn9n8-SU zn6Y^%pU366DHJ}7SJihpnl}r~b2KW0G&JNbz8wF~o$}mNvTCl8DV|j8R5JA?Z4XYC zG6BIiJC^|P^UZP#$(C#!*#|LWhFoCkM=HDtJ;iRQ5lBCJKYZbn$VX1~+rI1vQ-*g% zYjhh0coq1NB_e#A$`NZYm%uU1TIcA8HBgD}gGW1b!=W>rzuw*#q}@ORi!&B=k>Kg# ztrHizbys3GFrvSgo&TwI=yn+Hy!yOZO~bHIK`Uv_lCm-moPd|cNRVWey5l)J1}3L+ ziT}XVM4iSL z4vrshoAE_vjeBBE#Ljet>@ThM{&N>t$P*eP8plIpJc5H%S?{>_@fCG=#)^1QQiTJd zVLKCLSw?Vd(Oy}>uk`2N@g9I883xNY)0YX#k`$-aWDvBtoYtvUOU6(;dw zx)sx6KF|(-&tGcfR(kbL$(y!uk7bbDw8PG+oqJO4Ns^rSW1|lvl8dUELxC*!UL(;BjBw*w zZf2bbX);?d+2|fn?Q^#@NUd;d07SFk0>R0Nt_*t!4b8FB64kPENbWwbObazuuu4C6Rr3aGFR{EKy$ z8#yH>Sjkk+r5xz`9j~$!ef(=tN$Vq@bkz1k>&FMCYZoeFpY7`-&a)?R3DM2CNV#3e zqbj1CoIJdm_Coz9A(eGc%;D*;77j(6XJ!JhOk}^=5+Kxkt%x2?3J|4 z&fwan>GNNIhzof5{5x9vkw4H->(rCPma6zjS$D%1i^mkoZWuP^wMoX8NUH5`A!RYVW<9ZMu{3( z5p~n(0hN)=x}aztesM*i_d6q62rfI zNxyHTncHf3&_MTdnf-#aq2cbj;DiN8I=?oAy3!eHCOwFO^Km zGr#{QFg+QkA-Uvy-$EW&AM?O_YGN})XHvJUkWZ%79|ab4%e%%m*|jOX#;oZjspR6( zlVX>T<+6iw7KtQEBLw?aFX^+jI2KI8GLG5$!N9DU7(-EG2ILjj7ffa9`rcZW@b#U^ z+*4lhaM#)%e}O4ePmBGohaz5wIxuHhySbo*<2eyE00{vG`=x#>Q!XOVt4qdWOkf*p z;gr6~ftShSCjBlkseN}$1nwLmBFgz3@e$T>u(2%8z9&HaZf>?^C8=M5?T+mADqdg*0%mMW{#yMV@aiNHYkxRO@a>pVGndX+Tv@SsOZtw3 ze0Pss=heP?@e$=X*$$jM!4?1Oe z%@_sHz1MNv4E(>u!A_Pj66gunvTkyp)G19CANV?UHL3WPN7lkGUj84xE~2`hqia8X zZXFQk`MJW`UW;xOMF3FohcQdu!x0TsJ`RCFl0t4a&f3-YRvNw~U2`kn(afu(Z@uK^ zpX@=mi8*{N{JP&ducJK!JDAS0KnTo*4Gb$2xyZ%`IXB83>93HwOX+;`d#7|MYC{nL?6uQ+wHvcv zMNiniJCqz?;pdrm*W#F8*XUse2fc$k1WwWy&lp>zA`1D9mVzuMH=5?rDNXdqki56- zpAg>7&0nCm%Za{Ws=s~X*`z@`MzP+@>qd|fcHH8Ez* zdNiohGHbSRT)Q^*V4OeXMmMEj{BS8lFx(Bpk5%R8Iab<2Kvc2II{;VILAz+L@d>Nc zksQ{)-9|j{5qpc2mhE;P>;4bpHZVIZOgwfP#=R@0O~WJDvHR1831j_h(Htp~VlqTO zVB}OXa~D}I_+a-Mlc zrw;zfafTD4zxDds9IDUs5sgWk5bbxpddT>NTdN(Kqr%I~pUme-W8rd^1Gl(-2iiv~ z$1eL!i9AAaWV7x{?)gTFQ4iyBX|WHpm{y9`T6IG$pm^Jld0b@8#Z1%TOC) zCy9gGKFz&NkfwAJMZ~5~_U`TBn7nG+HtCuzYOK zRi;rhUqt5;+)^K7)^RP~|A1NHv3$fM>*=FNvGRZ<(00YqUi> z#&yKv>}vz7A7+QNSdA!N+j&W<#RQb^=Jh!;c|hcIOgu+Ifwc>Gaon`%s2IlrfnJG< zv>&-Mp!Idm8<=Nv6+GXlFtKz{sLmd^Ul2DD&&WY}yJKi@Ivpz*L~Kp| zKt0r3qEqPp`rQqK%_WYf&1X_VcFk3;bHV)*TpFlKsDt0SVP%l3ZS$62X)(rue! z9+oxnrK|Y(l|4qmN#GKSw?+iZ^oizKLZ|!o2g8*v{_pG zUS>Nw)ad(lZP&T(D4Q*lHaFaZU@Wo^z(OD@GR9Uyn;f?wN^B4i=qh`e)W$ge_iwv6 zj#NIQiv?A$d&ezxdMn3LhfR)%)waG9$Gmw3iH59j@U7_9ryH1DHBD538c+LUe8(85 zHR1x;6{sVE#YnWUaV2g#-jlYovxIXTA^Q0E>hx5pFj(>;D1MAIvTg$rUj;=>5n8E(ty54VZEH}( zWLCMS|I48C$shT-^95?@Rr=dUiqa1qa6ibocezS@m0s!(7MP&TjMVc%gUmuuy1l>y zVmWM4itNKJTLNTR0q4zwU$$Yg9T9)FK3STgO3IFt${y@i25u?rg%ydQCPX7qdDTvI>~)Twk$!;TQWr}OLFw`%0_JAvFT(V zlGyjYQ>9W#$9*rvPbWQZfoJl61ah_s9_!)!HCr!k*6(Gx?!8e295tw$PUj)@q zJ9F8@ce-s5{mags8PS|=E7KJ=4^;2{=89by)voMy?0tNCjZYZEp>i{UkKvI0)f`!z zCnIt2{5hn+kAaN>_D@zI>9>~3c+Pc@EpNN08G7!E+2UmD7OS)W+L`9>t^S6-LSmYM zKmmJzh72jY$pYLI&UuiV=y9LdT`fTp>2~7lE!mTaQucOdGTv9#x!LxNTX;+z{G?#x za>_d$Uwq z4MJa(GBTabX^}4Bi)Y@(dJMW~>0H~4x3a`gbRc>r4z}LF#77BA@6IF)n99%NW z(Ft!q2ODP>T#+i-aXK?=|F!lvquulaC!IUa6t^8a{#=F4Mn$xN82WH4M=YHs{}*-I zi?v2BGEG-Md5rUPm0Y>EQt6pAtgXU#C`vKGJeST_6vA@}^sYh0M}TnU9~x|(81wbj zY&#U5uF<}5s`Ld0Vov`V0M%zYQ_grmkx5-zh2Lo?TI$JaHc1yH@kFKtCbvj9FI;r~ z_oz|h;xRc@aFl@ghaW-5h_D|iMwc+M!dl}NR4!}MZ491%t#Paj^kEf#ap-31NV}G6 z(Ounlxaz-J+FBGRBXNU9KznJhOJ~FBdMU>R=uPV~X)2EynpbFX^#%EORb@uIb?T{; z`@BzQhd8w6K2hSmU+T9GA{t*g;yFnL;0#+*0sY64l|04u0`Nk&B#qD9QOdI4?3U%6 zAY!hQe421$X5hV*;I9n~5E*or=*~1RY!tOJwUx8|7b?7cO4f0GUb9puk$(btHP`-f z&fq=u@%Oo3pJyswzl}AGyLl+#7xlX zB#VTctcVZR1=VZy-m}QR_GUI|6W9OPstaCpu3T{mSEzj6whDR(ha*staxfn7j5?^z zQtHbcfIM(AxdB_(rJuyq?HBzjruFKv`pYlxPi{+mtS@x=rdS&|6PN^Xw*SB)N)tDS&dDHY&(MNIMWqi?ASdw7|Gtbf4{&y$V0 zl>pFQ5v#FF>Ts!PW%rLd$>%}iq=-3JRk`$+o>>~NqgvEwI4@yRrTdZLlyd(~v}+J! zToXC)BO6q@IwXnZ6U*C!b)#EB2b`&dMC2~Ka;ocd3j4f$K}jZ=o&M>q>^XnMNU;`d z0}Vbl@yE`Y1tN6~P*y<|)e)E|$Vj=MMCd^n+`-D}0lTS(V9R^CWnU{S?vn0PVZJ4)E4M@%u%E`ZygstU=_#g6WlyAkQ15~?26e+Tz7deU;A6lk`6{`O0hzwQ zO|6)aI8hjs(n`qlvMfdJj-T>re%On7WgSrU+c8vife=T8PvAi#gKhx1u0%tyH3?D7 z|IjHB)X56#{@zW}d9Pse{%!wq!^H3-Uw4bsbw!WR?plo&9jnhD!jh^1C#~ARv?X!? zn3WYPqfa!vSKC>hpnZJ<0gTz{Y$}LUepZ4%LxCm$j|&fj=oWZJWtK8GgXTrJuMW3P z+r;$zT$~bkC!70^&$*+Q9Dj)E6cP#h+j^^wvM@2tz(;-F*6}Ehf z_rMEroS5nHbs>J!@3l|A?W?pVdFCtCbziB=x|n;qqv#)t_AmPc|M4#+aUVk8Zmq;j zthU;0V0!uAhvLDe<3V~Z?*lCrd(hSip~rBifd&MOg*m$sZ=k&rL~05sL)b|F*}}L2 zLd+dUqAzs+HVpC3JonwgKQLqJa+l7J&o^w_6xT|>BqB;$nz0b$$HZ`;P26{B*XYR^f5 zh5Ro^XU^@Db<{JI;TOUTJcZ~lYw*pQ!@a0*2>UEVKhH5`6jT%oHd`&E#eRAB%<_cI z*S&qiJyM(HJi}F8FDayoHJ#PJe}{rv1G>cl#V7h4ZI?e)3ZkDTi^5kma(%SvG!gX| zQ3HyR@9W>c3x0}aJ&)>wMhwt+$YtVAmJBzI=B619T84>tuVsff(v7$E{tikL{}2$) zXZB#d{H?oFu-Z(PC8Rq62jjvjX#(IGmw#)@KFai|Glg@^Yu~)%{q}25?fbAU+=}MI z`(`<%#gHY9A5{qonGU3{2$S96)Q0xFpxVX&gN`Q$4(?^tRGUxlNua65V&*+SKLxdd z7?m|wh_5?2y9-`&9FXgA|Gws!!nB0qb7KKcQ?BJTHOjb<&+qJ`NY;A=jRqF-IdYcF zUfaKI7{1DGinxbO=XNBHsZHozy>RJ6TFY0FxbNo|(Io!t)=G*aL?>tBz46EqEmHWn zfOCjWmMR<=k$s3v|KSq*#ld{BW}suTwnw~!-4=Baf0TK-^%%$BAR&;cP;D84cH(BQ_D6seI#KDGrOWD z#WGy}_7bt|%z~j5NCPA&xi+&{j&ux@aN;jS^I0Q-lkc^bU2EY>?@Q-0%EGmGFTc7% zl&2C#9X~+QT)AhPVpX1DzZIe)ho*>IgEY~tjaAIV`(zW>+<9q2Gcl?>0gCZ=X{>Q z{>Tkf`y6^_9}TceL^HiXU6U0Cwa{UU~bAIaIIfnf=jI$F%n7?_81| ztTx$ejyZaP_W{oosGARPBy#99_%ab-?pBDAMT5^e@Y)~|dGm5A-n0EWUk><;@BKQi zTpcc>l6f*}ms8sGt)lEJpH4TNlHI`E0Koi!`o_2rXz(Xc{!Q{hz^5$z`PSE>D?Bxf zEY&NXOryPZZ_|FxZ{NVgE2IU)*vGd^Kix7gRmzd!XOqdg+!(y~vKcBwWjmsuv&3Mt z#7YOZ>Ek^VdE*F)s&{fF$*Be!Ieu-)M|O)xJ`DSQ?YN>?VgB!sWBlUQ$3Tj#8ACQ? zbYh7?wax3RX{mXh0m%+tc3C;M9@r0?NT&t6Bz?SKYGv-W2P33Ai>q>9&I3Kn4^$pq z((nmZ7W20OHd^PI*rDpr@A@<@?~u(a+VdkLa5&)E9Iv*7sT*;&d^&v@xFo+9Q1A;> ze;mw|l<9|Q)Cs|=|Mcx(UlQ^imo?aY!p$(#ca?|zPb*JP$DR)o{LVLJERXFr<4EvM zmw;$0C=1cOIkIeH%Ez5+f%QT?e8!S6`d0nX-rniV*}l~wi74m00yk=3+a2Ef=R{i> z?FII}baVO#>`+Lv0Z5OZ?W{tDVA0i<$I`!J)Rr=HaChRN1Ai95v<%R_Blj_$kpv0v z>#CPl1py~xxkmGTy}Vbzt8}}l9+@S0kD9vA#dq5Z#ZGToS`tqT<-X<|4VhXQ!c9>l zbq3o{0|3z{{D7oN?dM3B@;MP+f0h4f)aCw9^%E_n zV1$On3qA(KrFU*%b{9e3&`3mUbv-|#?sFXs+@#BY$*#0G*BDwA=`^#VUZFbv>9QD2 zyQBNh1q=-)nKcmaL-0i$q7fKJV*qH`W~8Y^jjB_fl0DySP?Wo~D)C&sd3Nvbk53ab zv(3pLLX@8Wp!<)p|I*MFq_+SCPlZ9?O`H&`uy3iZBSxp&IplrziHdSCNgA4)9= z6MVDjJ;XBMCeqv|2G27z;@SG3rOgx3i0a-=w4m14N*U)*ISgjv7z16JM@yS&D0tzLtpioO*_B#UUUIM1^hHS`b|h|1T6r zt8{Nl+)9`#y+_(#{vTe3w5V6@n=|$cPFTGNqm!89sWncI|Yvef|QMeA~IUZaH->fK&LE_NOK)3u1 z?o&LYv|hT8eB@bB0K`YOgqJhMz}9ZZ{~RS%sl6nr%?eXJ?ie1>vzF6y-SXBYko|&| zdNgEm49pYFqFbMIQRK)1$(H&JtO?1A(>iRGJVW`eN@V#ii#&V7RD$zHV~IMN3iAQX3 zaKxfMAeI2^?i#Y4muVichgm*s*Xs1@_Kq7v9S08`lf`qk;ZUm~_R$T@5FQ|-nFWzJ zkcfVO$`UyT2bO%`HI_N}_!{s9WEoQ$c6>}nz}jU_T`)SV%|Lr7cMi**|ck53W|mpy*Nabw$wTue+E04f(i#e3nDeH-_Q1~JZji% z2jnd+ndFQ7RlFqsc=w&#Quk(l9m83HgJR&T^BV|}IG8ZMfw>w&5Vsu<`ZbkS{NjsW zwta_mDE@SLUBYVVKC7G3b*!%DUDtviKPVwrHPK+#QqJ?j7Anh8m&Dbc>pw9$z>&TP z|HKZ7OeNki)~0;bM^%tXrc>|P>b`o-MQOf_SP!d_g@C}HrrzGVJ?7e9SSCA0%?;k2 z(|e_X%&;}l%^;xh0S}KphJMNzSZdqAh)ke6aV%P*2FIKW%x5!=MM#|;8nO?iwy`g^ zEYqCFXhEHi<8ypZ3r=Avr8~%8kp_O*&k`CT?Vo>>SP86O%>0FOqnr2%EsVvke^@UFp-d8>_4b62t zP}UmksfSI~in^LCWow(%-~E^{l@4anI#&=C{iR>boxhqUU;-#O$))Goj=tG8?v;^N zJo(;Ke)LSUVvW-kp{j_rmJb9&$-@|2-GsQY0Y?_ukB?qLRXOH8Ix>k8Ncg}BxVa=J zs6emq<>;q3Cwxe_@zi#M85KhtwLz=Plp}!b;>!YKDSIeih{7~)(50Lg8y)5t4wT*^ zi5qRcO!q9ue@&B0Hu`fmuBn);LkROO z4WAcp*ng<%f~~~E(|2>64}3o8&E!R~=^K~_`hU%x=y+%Y({;;^8v_h01Z{veLyRZ1 zvixYsr8L|i>kahWL)7n3pa0^+w1pqvgY7)?Zp6N<&Fql4z`twvD*iA;zlmo=+zZgHm^OK(y~ub4IZms>R~V10Md7DeBcFy?c-C4lwXymEHXF}K$Njud8|p0gz?e|&+C_tV*}%eHampwp_(zJJ*O5dWvs$4ritX7_G@FA;XUFW6@vOaQUEakZGWZu*s{@7svGGP+LXe0bFro~ZN=4NQ7y6W!`bZZYc zrS3fY43I{76)S_toK9OihgeUw6Lwy)AIbK_oxPl2S+@r}nP8Z7y|v8#&~iiwx*61A z1XeaMjZaa-pxS97M;A=3tC5g1WJMjqQYT^q!VM0P$TP8!;b(Rz=zp!+2P_lcKV?R& zTNKP^_*T9c^%(A;@{eVXVnzFlKOaB+JwEJ`*(E-$2dDSICe0WM9-?Pc;d9u#6FVU% z5dxyT7E8YBew8UWnUzgh*GLS$UZ7F>e00=2K>5xgflE=Ivk51!JTwuJ!zB&|xSDD# zVR#i&K1d*e>_8yeQ;G~Tu(^;53N@g}I&6%KDjy}v3>ryI9rhK?dVJkJU{Q6^$-O$B zFczQlpkRIvMuh>0C1U{Ot{P@7_J7wo2i74ze+QEzKD~XDI3*ZU`TJ*i7vPw6mMxTX zjDXJD7;X!q%Cl(b19?P^%ivp;n|o-T!`wl*e9RQZI8zKWdr%# z2G4$N+$1<%{l7O%da#IMDMu`hQOyz4W(B@SB;drBn%Nj%-7oHJ`=%Q7-5cqjzdoHV z7e5sL`lOgHkJfTux)+1Ss0I?A!XxC;M$#rSAT&`)fL8c{0Cx6W;7e{fx(#&W6082e zxsHyVRrbLl0kcQ0&F+%*;4J~>hlM`Lf_h;hdtVjtOJN+y57Ra<-vFhNgO+yWLi4`} zr8%^J!QpzNyV6;PKq<;lgFl$5(C6%Nl#Vt>C(zGq$&tD`bnW9W*PrFo+V6Tty(Lj$ z2et&TmL}*bSc=j@h0ADv>CWBpo(?Qxj(Q#9#DvmHllRM?c-9RN#KqR z&0zV&mY+y8n}}{FEG3q&lvh^$G%BsUveG(KO&T{w^_VHFt@*CpyceUMRDXEBS$4!x=g4!amZ$}b2*xkxzoD|u`hP? z96w-xORZ~uIQ@axH)hY_AL=>xvs#t{+T>=z)(Ak2H=)T+Tt##Y%|jEpMno>t;G`gV zA{KIi8s4KH*~aX4dn*15+VyTCvD|0qbtvb@wBYZ=mU>}0x+zg`fr_o)6Xexu+g17A z*jv{5bl-BCcgTpRRqlf}nN6}%mkZ7vGyx-X1Vj(;uMkih1rrqEp+U+F(}DX-41@|l zX$%;bW#XbrG&YT0xjZaScYk>@_eX|un3Ij)nwyR29jod&{+-qc6bhQ`Ph`K?r(U!x5#U2zfH_2&*$O{MmA8zS>RU16BWLNu9GVxg{W}mi-r1K z6`2kfjjT(9vMxXLd&KAy8`imYeJgr&-AXV5{gMOVBpjd(y(6A&9wd0YE@kpZ*z^<4 z9aqM$*w?g@o_TolU2+5jzg@33#M}SSs^~3l>OJ(i>MRb~2WA`xSQo(p7dc`njJ2iy z$@b`XecuwMn;q4@%DVQgjyyakF`IPpqBZZq1V!O{Qup{Xhw2laZK5gT8GyTk`Cgq!yw0#4EVW6D|sO79{!(VmQiN z(D8GHqVVu>{9S2%&dy(`h#7+*i*!ywO`1~sGAD6c=BonM6AC74YZvX?Tlz|ySNnKM z^Ad)yeH&u@!W?i3{7K3BZ@(A1h)Xnpl(fkbzGXsM<;~9@}u-(cK@jn&# zPB41}%KWj$JV+SCpvK|hRN#F_^5-mfXHW>N1I`x`Ob$)aSSg z(O0K;2}-a_H!xdB?7bV9?!v!=1`RU6EvI#OI4DFvj%PfgRbn|}-cwrvDdf)u)}rY} z+1rEeS{yd5e_wz&Z6+_43Jr}l%Qg;=+ei7=_6%zMX!GN-+BGTW^&iTl3^qp~1DeZk z%Y87(eO4FD)?bvvfH@pmc}dAPnTq%TmtASomPsRDf=+BIlJ%2G*_C`Qp>eS=RN+iz zPl`!puln?xgi-re*WCx?FNZI0zst*WoLRlARpM77D6O(~FqN0V@_egI{E`p*5VC44 zu@I=IcO5yyK34O@qOAApc+kXBN7^x5S=|Aq&Y8UHjO9P4F!+NIV-=f-<0GGE>vIkw zN|T9k<5GQf&bef> z9s>NtX=3muOPooJxrRFre~+0?l=QZ0Gx{p+{)FTM1xm!_>cs|s*Y)#f;QBbxhiV_3L)6aHj;;l??`Tu(5}a&Z%GtoE=92-O zlo-&^N9v37AXXcZQ0pB)O|IUi8LMz>n0qr&D>nh|UD@-Q}j z+G6Zzj`Nn=nMLdW1W2-&9T%QEm}pfz*KQ*!R_**}U{DW)(%YaL{q5PQ8ZNcoI5*Pl zQ4->Q(pB&+-@>%Q1n>MY++W^w;O~s#;2J9&*o{5q%lKhrm90Y7W!6_?=?=m@vd+yY zfA6YlZnZ^d`{uMMNxxn00m55(0uIj%0CS@^g2!9{6(jg0Rgf$IzJIoVlE^!EDQi#P zY84HRY>j-5wr8|-s~_4GJKEHR)mk|cGp!iWRs!&su?itdFONGTyiBCZ*lJv|8>76) zeeL~gak>=DGoMGdg7_U?$5DYH`pO-<`WU#Ct~Fv(U`Ck=?EOuuELu;<4wc6qkWACL zoOAEcmNqc~x0}asy~+(oq2_uu#CkGO9ATSRzaH?{#M(M|U(yS!EA)Q1$1Fo<)WXL` zO~Cvx@4gyogImw<;8kgiMbMrt1+2jrl>0_3xX+9QgWMd>w}Ln^Q&aD7phJF5x+=yX z`+c~Q^<$;`&Ej))$pu<3HTMX9TS4>$Q4ydBcREd=q7+faF285Ww>PXA%cVX;u1+5f zn7sPtU0I-pPW$80$neW!Puc?aO6y%NE0z|*lM8=VqryOy0(q1ce6@SLKRyAE;n@4Z z-mMuGoi_`sQu36>V_sc3r1T=Ko$Mxcg*(4D=|XZxcWrVL@2XzQdi(jZxO2TE`M1?? z<6Xjo6t^ZTY>B$Gdz;ywgi0pFk!VFlAhr@XD|O@T-q@m`6*H!Cv+Xq(f-qUPw=~WB zo$t`;So`Mt-+TJ)w{);A4wSV>y)FHSK#VRDqPh~E#8H4-Sxm&wXR^h~=q@w*p4y51 zMkziLajhw%B)r`key|+fQi`}wYVI5kngt^V`EJajZT~1ep=WI_OK$l(AJ%5Ol1Dl^ z?Q3#*I+7j3j|r0GNKdjc9Cz+JFr%{^hzd`$Erzd*dpJrn1+rfad9B|yv}tlwtJTtO z+tnhaUN`&r(Z6ah_@x>nx$VFxUQ!yRH96veED)I3iVKq0Z5QPB_S_wp?knLLaxJv4 z>2z1!CL}a_>LoJM@{{N5`}9dtLnnw&d=xoQyAq;oLlpjjC!%hNC{}b=9F^<-x2huY zw!HiuLB-_T$>xg5mZUDhpO3-)YT#)F$RfJgn(c!0vd(d<$a{-CQ2qh`$X-rpUYkdc z146W^-gkJFFXo2p{TzvhW+{q}7h-7bC!qyNMtbW+IKCqPjvBiEZ;5xO5q?^8>%<;! zA7W!hk6g z?QyCkQ#T%TX1ZzokT;0u?ya<;>DH=oWtdX#7qT_AWXM$hM8Mbd6x)vTpD(&uAFX?Z zlewbes#YY#*#>KJq+;0KWNW5weIA<#(M{?!(wI*zKxxd&Znf>7nOC|iL&s+ap8C`0 ziEpW(#%}|Uph9D8dGKAe!-(0GsQbk(BN_hTVJ`#Do1G~=Eqn#^#YwF6wvCy&! zVc&|x4h)p7`Y4Mm5~!e68G{IjK+OoUM1+iEfIu;qfJj;LuqeigELOG|P-F=@Y%y$+ zHCDht0t65Vy9v;6A<69=`>B7yJoBL+&hy+)_qq3+_x#TL`@Qe^%?xdXd3o&TYRCFM zxwdW;rlyG&Mj@&nKpedpEsz9t8bZq!nfNhp%J&EtD~hd_IRLe1goCI8-lkq6&ye5@ zVfuu`2bE znSjS!PA#0gG9x@EMpGTo8Hn%rcE85bY~hFaZ}p=~!V01^s#XJ-_v0xUE!}I4N6hZ%^XiSaeJfnt|%#2yV^n{SP?UX$#S?=28q~H%qp0 z`#PIyV4UChMq@*LX1u{!nv-uBd9$dn>k`^k@2@6}++UAZukZq&R(d{L4ld~!r;WFA zVv~qR+WmS4jILf(yyI7)`D!-(Ay6*V>;MKWOPj!ZxOJCUsHGkXDSRhChQ7!0-= zkzDa6>mwWc0pC31?kL7mfh{}rAv1U5dT0M!S>}x9 z%Z|bs^ltc-@M~{%L-)M(zqnqFB4=qNOUsqjzAsE;0uf>5R^w->SemC>^-}8v~or6 zBJi#|kms7lzNvh`u3mod6Ft7D{k&rshJ3>KWke!pyS55>Q`V$Gs}5P(!?M9;=-^)TazCUnmj^+{I6_EeAFAAlBGOX|Z2vhTrMu5i$fSoh_|A;aG*BT}PGs#QRH>4bg z?WQNJ{{mONxo=snFtqp|4!>R!+aiC?499`grct(|qFCxII{BnxGIVe!cB7J-(axT< z*c2cNc$z7w+@)hcMq?m8_tQKcP7(nBWzC8B1*q#je=#Wz69=`NJDMA%WLhMh_P`8- z(w6aY>2)$Bv6I)iTR|+4K3sDxm{Z61B{y3=?tOMFHH4glOM3duzF*D6_H8XHb=H6} zp+eBYZC{yZ>cRL|rAAC~HLcw{ydrup;1NB!6!?%n<1$P_DAhrQo-x8kyHhobWdj`uCzvwMnuZO?e?tlUs=x17x z(ebawHr$7G9dGy)M^g2ihAlLo#}41Y;D2xE@u{hYH!7Sgonv=%vOA57S!KE@N>9jl zPrW=iMQ}mr>K~dbNOrmd0~7r#HUmJ#z#=MLJxve`OHnSwnaiwx0hJ`Wv3i~@pA%|<%pdUr*0Q>eSK*IZ6;K? a_EBOl$=`RMx9*?g&pG(N#DLOolm7*0PFlVI literal 0 HcmV?d00001 diff --git a/tests/fixtures/ocr/system-ocr-mixed.png b/tests/fixtures/ocr/system-ocr-mixed.png new file mode 100644 index 0000000000000000000000000000000000000000..ee4903381f23e8bdd6531eab193130d1779139b1 GIT binary patch literal 12790 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%0DBO)T2 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

D}GkR6WtqqeO754dup9WXAr6 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 L8KGjq_rCuJ4)D=3 literal 0 HcmV?d00001 diff --git a/tests/integration/system-ocr-windows.test.ts b/tests/integration/system-ocr-windows.test.ts new file mode 100644 index 0000000..43bcb54 --- /dev/null +++ b/tests/integration/system-ocr-windows.test.ts @@ -0,0 +1,96 @@ +// Windows System OCR native fidelity。 +// +// 这是 capability-gated 的原生冒烟测试: +// - 只有在「当前平台支持 + native 运行时可用 + 有可用 OCR 语言包」时才真正跑; +// - CI 环境无法保证 Windows OCR 语言包,所以中文识别不作为所有 CI 的硬门槛 +// (mock 单元测试才是 mandatory,见 tests/unit/system-ocr-service.test.ts); +// - 在 Windows 真机上必须实际通过。 +// +// fixture 全部是 synthetic 图片(tests/fixtures/ocr/*),不含任何真实聊天数据。 + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { SYSTEM_OCR_ENGINE } from '../../src/shared/system-ocr' + +vi.mock('../../src/main/image-decrypt-service', () => ({ + resolveFfmpegExecutable: (): string => 'ffmpeg' +})) + +import { systemOcrService } from '../../src/main/services/system-ocr-service' + +const fixtureDirectory = join(__dirname, '..', 'fixtures', 'ocr') + +const toDataUrl = (fileName: string, mimeType: string): string => + `data:${mimeType};base64,${readFileSync(join(fixtureDirectory, fileName)).toString('base64')}` + +/** 只比较"主要 token",避免系统字体 / 识别微差造成脆弱测试。 */ +const expectContainsTokens = (text: string, tokens: string[]): void => { + const normalized = text.replace(/[\s\u3000]+/g, '').toLowerCase() + for (const token of tokens) { + expect(normalized).toContain(token.replace(/[\s\u3000]+/g, '').toLowerCase()) + } +} + +const capability = await systemOcrService.getCapability() +const nativeGate = capability.available ? it : it.skip + +describe('Windows System OCR native fidelity', () => { + it('reports a usable capability on this machine', () => { + expect(capability.engine).toBe(SYSTEM_OCR_ENGINE) + if (!capability.available) { + console.warn(`[integration] System OCR native smoke skipped: ${capability.message}`) + } + }) + + nativeGate('recognizes simplified Chinese text', async () => { + const result = await systemOcrService.recognize({ + imageDataUrl: toDataUrl('system-ocr-zh.png', 'image/png') + }) + expect(result.success).toBe(true) + expectContainsTokens(result.text, ['TraceMemo', '本地', '文字', '识别']) + // 语言要么是探测到的语言包,要么是"跟随系统用户语言"(null)。 + if (capability.language) { + expect(result.language).toBe(capability.language) + } else { + expect(result.language).toBeNull() + } + expect(result.durationMs).toBeGreaterThan(0) + }) + + nativeGate('recognizes English text', async () => { + const result = await systemOcrService.recognize({ + imageDataUrl: toDataUrl('system-ocr-en.png', 'image/png') + }) + expect(result.success).toBe(true) + expectContainsTokens(result.text, ['TraceMemo', 'System', 'OCR']) + }) + + nativeGate('recognizes mixed Chinese/English text', async () => { + const result = await systemOcrService.recognize({ + imageDataUrl: toDataUrl('system-ocr-mixed.png', 'image/png') + }) + expect(result.success).toBe(true) + expectContainsTokens(result.text, ['TraceMemo', '本地', 'OCR', '2026']) + }) + + /** + * 引擎的 Buffer 输入只接受 PNG,所以 JPEG 必须走本服务的归一化路径。 + * 这条用例就是那个约束的回归保护。 + */ + nativeGate('normalizes a JPEG source before OCR', async () => { + const result = await systemOcrService.recognize({ + imageDataUrl: toDataUrl('system-ocr-mixed.jpg', 'image/jpeg') + }) + expect(result.success).toBe(true) + expectContainsTokens(result.text, ['TraceMemo', 'OCR', '2026']) + }) + + nativeGate('caches an identical repeat request', async () => { + const request = { imageDataUrl: toDataUrl('system-ocr-mixed.png', 'image/png') } + const first = await systemOcrService.recognize(request) + const second = await systemOcrService.recognize(request) + expect(first.success).toBe(true) + expect(second.fromCache).toBe(true) + }) +}) diff --git a/tests/unit/system-ocr-service.test.ts b/tests/unit/system-ocr-service.test.ts new file mode 100644 index 0000000..52f9564 --- /dev/null +++ b/tests/unit/system-ocr-service.test.ts @@ -0,0 +1,349 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + SYSTEM_OCR_ENGINE, + SYSTEM_OCR_PROBE_PNG_BASE64, + buildSystemOcrCacheKey, + detectSystemOcrImageFormat, + mapSystemOcrNativeError, + normalizeSystemOcrText, + parseImageDataUrl, + resolveSystemOcrLanguageTag +} from '../../src/shared/system-ocr' + +vi.mock('../../src/main/image-decrypt-service', () => ({ + resolveFfmpegExecutable: (): string => 'ffmpeg' +})) + +const { getByHash, upsert } = vi.hoisted(() => ({ + getByHash: vi.fn(), + upsert: vi.fn() +})) + +vi.mock('../../src/main/db/image-insights-store', () => ({ + imageInsightsStore: { + getByHash, + upsert, + listBySession: vi.fn(() => []) + } +})) + +import { SystemOcrService } from '../../src/main/services/system-ocr-service' +import { imageInsightService } from '../../src/main/services/image-insight-service' + +const PROBE_BYTES = Buffer.from(SYSTEM_OCR_PROBE_PNG_BASE64, 'base64') +const FIXTURE_PNG = readFileSync(join(__dirname, '..', 'fixtures', 'ocr', 'system-ocr-zh.png')) +const PNG_DATA_URL = `data:image/png;base64,${FIXTURE_PNG.toString('base64')}` +const JPEG_DATA_URL = `data:image/jpeg;base64,${Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]).toString('base64')}` +const GARBAGE_DATA_URL = `data:image/png;base64,${Buffer.from('definitely-not-an-image').toString('base64')}` + +const LANGUAGE_UNAVAILABLE_MESSAGE = 'Windows error 操作成功完成。 (0x00000000)' +const DECODE_FAILED_MESSAGE = 'Windows error Could not recognize file (0x80070005)' + +/** + * mock 运行时按「探测图字节」区分 capability probe 和业务调用, + * 这样 probeError 才能稳定复现「语言包缺失」的场景。 + */ +const createRuntime = ( + options: { + text?: string + lines?: Array<{ text: string; confidence?: number }> + probeError?: string + error?: string + version?: string | null + } = {} +): { version: string | null; recognize: ReturnType } => { + const recognize = vi.fn( + async (image: Uint8Array, _accuracy?: number, _languages?: string[]): Promise => { + if (Buffer.from(image).equals(PROBE_BYTES)) { + if (options.probeError) throw new Error(options.probeError) + return { text: '', confidence: 1, lines: [] } + } + if (options.error) throw new Error(options.error) + const text = options.text ?? '' + return { + text, + confidence: 1, + lines: (options.lines ?? [{ text, confidence: 1 }]).map((line) => ({ + text: line.text, + confidence: line.confidence ?? 1, + boundingBox: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 } + })) + } + } + ) + return { version: options.version === undefined ? '1.2.0' : options.version, recognize } +} + +const createService = ( + runtime: { version: string | null; recognize: ReturnType } | null, + overrides: Partial[0]> = {} +): SystemOcrService => + new SystemOcrService({ + platform: 'win32', + arch: 'x64', + locale: () => 'zh-CN', + loadRuntime: () => (runtime ? { ...runtime } : null), + toPngBytes: async ({ buffer }) => buffer, + ...overrides + }) + +describe('system-ocr shared helpers', () => { + it('removes only the engine-inserted spaces between CJK glyphs', () => { + expect(normalizeSystemOcrText('TraceMemo 本 地 图 片 文 字 识 别')).toBe( + 'TraceMemo 本地图片文字识别' + ) + expect(normalizeSystemOcrText('TraceMemo System OCR')).toBe('TraceMemo System OCR') + expect(normalizeSystemOcrText(' 本 地 ')).toBe('本地') + }) + + it('maps system locale onto Windows OCR language tags', () => { + expect(resolveSystemOcrLanguageTag('zh-CN')).toBe('zh-Hans-CN') + expect(resolveSystemOcrLanguageTag('zh-Hans-CN')).toBe('zh-Hans-CN') + expect(resolveSystemOcrLanguageTag('zh_TW')).toBe('zh-Hant-TW') + expect(resolveSystemOcrLanguageTag('en-US')).toBe('en-US') + expect(resolveSystemOcrLanguageTag('en')).toBe('en-US') + expect(resolveSystemOcrLanguageTag('')).toBeNull() + expect(resolveSystemOcrLanguageTag('xx-YY')).toBeNull() + }) + + it('maps native Windows errors onto product error codes', () => { + expect(mapSystemOcrNativeError(LANGUAGE_UNAVAILABLE_MESSAGE)).toBe('OCR_LANGUAGE_UNAVAILABLE') + expect(mapSystemOcrNativeError(DECODE_FAILED_MESSAGE)).toBe('IMAGE_DECODE_FAILED') + expect(mapSystemOcrNativeError('Cannot find native binding.')).toBe('SYSTEM_OCR_UNAVAILABLE') + expect(mapSystemOcrNativeError('Failed to load native binding')).toBe('SYSTEM_OCR_UNAVAILABLE') + expect(mapSystemOcrNativeError('Windows error something broke (0x80070057)')).toBe('OCR_FAILED') + expect(mapSystemOcrNativeError('')).toBe('OCR_FAILED') + }) + + it('parses image data urls and rejects other payloads', () => { + expect(parseImageDataUrl(PNG_DATA_URL)).toMatchObject({ mimeType: 'image/png' }) + expect(parseImageDataUrl('data:text/plain;base64,aGk=')).toBeNull() + expect(parseImageDataUrl('not-a-data-url')).toBeNull() + }) + + it('detects supported container formats by magic bytes', () => { + expect(detectSystemOcrImageFormat(Buffer.from([0x89, 0x50, 0x4e, 0x47]))).toBe('png') + expect(detectSystemOcrImageFormat(Buffer.from([0xff, 0xd8, 0xff, 0xe0]))).toBe('jpeg') + expect(detectSystemOcrImageFormat(Buffer.from('GIF89a'))).toBe('gif') + expect(detectSystemOcrImageFormat(Buffer.from('BM1234'))).toBe('bmp') + expect( + detectSystemOcrImageFormat(Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBP')])) + ).toBe('webp') + expect(detectSystemOcrImageFormat(Buffer.from([0x49, 0x49, 0x2a, 0x00]))).toBe('tiff') + expect(detectSystemOcrImageFormat(Buffer.from('nope'))).toBeNull() + }) + + it('keeps the local OCR cache keyspace separate from the vision imageHash', () => { + const base = { imageHash: 'a'.repeat(32), language: 'zh-Hans-CN', runtimeVersion: '1.2.0' } + const key = buildSystemOcrCacheKey({ ...base, platform: 'win32' }) + expect(key).not.toBe(base.imageHash) + expect(key).toContain(SYSTEM_OCR_ENGINE) + expect(key).toContain('zh-Hans-CN') + expect(key).toContain('1.2.0') + // 语言或运行时版本变化必须换 key,避免复用过期 / 跨引擎结果。 + expect(buildSystemOcrCacheKey({ ...base, language: 'en-US', platform: 'win32' })).not.toBe(key) + expect( + buildSystemOcrCacheKey({ ...base, runtimeVersion: '1.3.0', platform: 'win32' }) + ).not.toBe(key) + }) +}) + +describe('SystemOcrService capability detection', () => { + it('reports available with the probed language on Windows', async () => { + const service = createService(createRuntime()) + const capability = await service.getCapability() + expect(capability).toMatchObject({ + available: true, + engine: SYSTEM_OCR_ENGINE, + platform: 'win32', + arch: 'x64', + runtimeVersion: '1.2.0', + language: 'zh-Hans-CN' + }) + }) + + it('is unavailable on unsupported platforms without loading a runtime', async () => { + const loadRuntime = vi.fn(() => null) + const service = createService(null, { platform: 'linux', loadRuntime }) + const capability = await service.getCapability() + expect(capability.available).toBe(false) + expect(capability.reason).toBe('UNSUPPORTED_PLATFORM') + expect(loadRuntime).not.toHaveBeenCalled() + }) + + it('is unavailable when the native runtime cannot be loaded', async () => { + const service = createService(null) + const capability = await service.getCapability() + expect(capability.available).toBe(false) + expect(capability.reason).toBe('NATIVE_MODULE_MISSING') + }) + + it('reports a missing Windows OCR language pack as LANGUAGE_UNAVAILABLE', async () => { + const runtime = createRuntime({ probeError: LANGUAGE_UNAVAILABLE_MESSAGE }) + const service = createService(runtime) + const capability = await service.getCapability() + expect(capability.available).toBe(false) + expect(capability.reason).toBe('LANGUAGE_UNAVAILABLE') + expect(capability.message).toContain('OCR 语言') + }) +}) + +describe('SystemOcrService recognition', () => { + beforeEach(() => { + getByHash.mockReset() + getByHash.mockReturnValue(null) + upsert.mockReset() + }) + + it('normalizes a successful result and reports runtime metadata', async () => { + const runtime = createRuntime({ + text: 'TraceMemo 本 地 OCR 2026', + lines: [{ text: 'TraceMemo 本 地 OCR 2026' }] + }) + const service = createService(runtime) + + const result = await service.recognize({ imageDataUrl: PNG_DATA_URL }) + + expect(result).toMatchObject({ + success: true, + text: 'TraceMemo 本地 OCR 2026', + language: 'zh-Hans-CN', + engine: SYSTEM_OCR_ENGINE + }) + expect(result.lines[0].boundingBox).toEqual({ x: 0.1, y: 0.2, width: 0.3, height: 0.4 }) + expect(result.durationMs).toBeGreaterThanOrEqual(0) + }) + + it('returns OCR_EMPTY_RESULT when the engine finds no text', async () => { + const service = createService(createRuntime({ text: '' })) + const result = await service.recognize({ imageDataUrl: PNG_DATA_URL }) + expect(result.success).toBe(false) + expect(result.errorCode).toBe('OCR_EMPTY_RESULT') + expect(result.text).toBe('') + }) + + it('returns UNSUPPORTED_PLATFORM on non-Windows platforms', async () => { + const service = createService(null, { platform: 'darwin', arch: 'arm64' }) + const result = await service.recognize({ imageDataUrl: PNG_DATA_URL }) + expect(result.success).toBe(false) + expect(result.errorCode).toBe('UNSUPPORTED_PLATFORM') + expect(result.engine).toBe(SYSTEM_OCR_ENGINE) + }) + + it('returns OCR_LANGUAGE_UNAVAILABLE when no OCR language pack is installed', async () => { + const service = createService(createRuntime({ probeError: LANGUAGE_UNAVAILABLE_MESSAGE })) + const result = await service.recognize({ imageDataUrl: PNG_DATA_URL }) + expect(result.success).toBe(false) + expect(result.errorCode).toBe('OCR_LANGUAGE_UNAVAILABLE') + }) + + it('rejects payloads that are not a supported image container', async () => { + const runtime = createRuntime() + const service = createService(runtime) + + const badUrl = await service.recognize({ imageDataUrl: 'nope' }) + expect(badUrl.errorCode).toBe('UNSUPPORTED_IMAGE') + + const garbage = await service.recognize({ imageDataUrl: GARBAGE_DATA_URL }) + expect(garbage.errorCode).toBe('UNSUPPORTED_IMAGE') + expect(runtime.recognize).not.toHaveBeenCalled() + }) + + it('maps an image preparation failure onto IMAGE_DECODE_FAILED', async () => { + const runtime = createRuntime() + const service = createService(runtime, { toPngBytes: async () => null }) + const result = await service.recognize({ imageDataUrl: JPEG_DATA_URL }) + expect(result.success).toBe(false) + expect(result.errorCode).toBe('IMAGE_DECODE_FAILED') + }) + + it('maps a native OCR failure onto a product error code', async () => { + const runtime = createRuntime({ error: DECODE_FAILED_MESSAGE }) + const service = createService(runtime) + const result = await service.recognize({ imageDataUrl: PNG_DATA_URL }) + expect(result.success).toBe(false) + expect(result.errorCode).toBe('IMAGE_DECODE_FAILED') + expect(result.error).not.toContain('0x80070005') + }) + + it('caches by image identity + engine + language + runtime version only', async () => { + const runtime = createRuntime({ text: 'TraceMemo 本 地' }) + const service = createService(runtime) + + const first = await service.recognize({ imageDataUrl: PNG_DATA_URL }) + const callsAfterFirst = runtime.recognize.mock.calls.length + const second = await service.recognize({ imageDataUrl: PNG_DATA_URL }) + + expect(first.success).toBe(true) + expect(second.fromCache).toBe(true) + expect(runtime.recognize.mock.calls.length).toBe(callsAfterFirst) + + // 运行时版本升级 → 缓存 key 变化 → 必须重新识别,不能永远吃旧结果。 + const upgradedRuntime = createRuntime({ text: 'TraceMemo 本 地' }) + service.bind({ + loadRuntime: () => ({ version: '1.3.0', recognize: upgradedRuntime.recognize }) + }) + const afterUpgrade = await service.recognize({ imageDataUrl: PNG_DATA_URL }) + + expect(afterUpgrade.success).toBe(true) + expect(afterUpgrade.fromCache).toBeUndefined() + expect(upgradedRuntime.recognize).toHaveBeenCalled() + }) + + it('never reuses a remote Vision insight as a local OCR result', async () => { + getByHash.mockReturnValue({ + imageHash: 'a'.repeat(32), + description: '远端 Vision 旧结果', + ocrText: '远端 OCR 文本', + updatedAt: Date.now() + }) + const runtime = createRuntime({ text: '本地 文 字' }) + const service = createService(runtime) + + const result = await service.recognize({ imageDataUrl: PNG_DATA_URL }) + + expect(result.text).toBe('本地文字') + expect(getByHash).not.toHaveBeenCalled() + }) +}) + +describe('ImageInsightService local OCR orchestration', () => { + beforeEach(() => { + getByHash.mockReset() + getByHash.mockReturnValue(null) + upsert.mockReset() + }) + + it('exposes capability and never falls back to the remote vision provider', async () => { + const analyzeImage = vi.fn(async () => ({ success: true, data: '{}' })) + imageInsightService.bind({ + providerService: { + list: () => ({ providers: [], defaultProviderId: 'vision-provider' }), + getVisionRuntimeConfig: () => ({ + providerId: 'vision-provider', + providerName: 'OpenAI', + model: 'gpt-vision', + modelName: 'gpt-vision', + configured: true + }), + analyzeImage + }, + decryptService: { + findImageFile: () => null, + decryptImageToBase64: () => null + } + }) + + const capability = await imageInsightService.getSystemOcrCapability() + expect(capability.engine).toBe(SYSTEM_OCR_ENGINE) + + const result = await imageInsightService.extractLocalText({ imageDataUrl: PNG_DATA_URL }) + expect(result.engine).toBe(SYSTEM_OCR_ENGINE) + // 关键约束:本地 OCR 路径绝不调用远端 Vision Provider。 + expect(analyzeImage).not.toHaveBeenCalled() + // 也不写 Vision 的 insight 缓存。 + expect(upsert).not.toHaveBeenCalled() + }) +})