From 2e34160f732dd6abbaf3248a0c23d7bd3fc5b7c3 Mon Sep 17 00:00:00 2001 From: Taitres <920643082@qq.com> Date: Wed, 30 Sep 2026 14:18:21 +0800 Subject: [PATCH] =?UTF-8?q?Update=20plugin=20=E6=88=AA=E5=9B=BE=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=20v1.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Initial commit - Release 0.8.0: pin overlay OCR/translate with host-default-first providers - chore: shorten plugin description to 截图 OCR 翻译 - docs: add CHANGELOG for 0.8.0 - chore: declare win32 platform for marketplace Windows build - Release 0.9.0: host-default-first providers, pin at capture origin, overlay result mode - Release 0.9.0 - docs: complete CHANGELOG for 0.9.0 - Release 1.0.0: popup translation mode, skip same-language translate and smart target lang --- plugins/snap-translate/CHANGELOG.md | 11 ++ ...7\346\241\243\345\234\260\345\235\200.txt" | 1 + plugins/snap-translate/package.json | 2 +- plugins/snap-translate/public/plugin.json | 2 +- .../snap-translate/public/preload/services.js | 37 ++++++ .../composables/skipSameLangTranslate.test.ts | 124 ++++++++++++++++++ .../snap-translate/src/composables/useLang.ts | 77 +++++++++++ .../src/composables/useOcrTranslate.ts | 66 +++++++++- plugins/snap-translate/src/env.d.ts | 1 + .../snap-translate/src/popupTranslate.test.ts | 43 ++++++ .../snap-translate/src/views/OcrResult.vue | 13 +- .../snap-translate/src/views/ResultView.vue | 7 +- plugins/snap-translate/src/views/SnapHub.vue | 61 +++++++-- .../src/views/TextTranslate.vue | 6 +- 14 files changed, 433 insertions(+), 18 deletions(-) create mode 100644 "plugins/snap-translate/Ztools\345\274\200\345\217\221\346\226\207\346\241\243\345\234\260\345\235\200.txt" create mode 100644 plugins/snap-translate/src/composables/skipSameLangTranslate.test.ts create mode 100644 plugins/snap-translate/src/popupTranslate.test.ts diff --git a/plugins/snap-translate/CHANGELOG.md b/plugins/snap-translate/CHANGELOG.md index 4a166dc6c..2d2453d71 100644 --- a/plugins/snap-translate/CHANGELOG.md +++ b/plugins/snap-translate/CHANGELOG.md @@ -1,3 +1,14 @@ +## 1.0.0 - 2026-09-30 + +### Added +- 贴图 OCR 结果弹窗内展示翻译:贴图 OCR 唤起的结果小窗中点击「翻译」按钮,直接在该弹窗内展示翻译结果(自适应调整弹窗高度,原位平滑刷新),不再覆盖桌面悬浮贴图 +- 相同语言自动跳过翻译:识别文本与目标语言一致时(如识别为中文且目标语言为中文),自动跳过翻译 API 调用,直接展示原文并轻提示,避免二次翻译导致乱码 +- 混合文本多行局部翻译:多行混合文本中逐行识别语言,仅对非目标语言行发起翻译请求,目标语言行原样保留并原位拼装回填 +- OCR 识别后智能推断目标语言:贴图 OCR 识别完成后,根据主体语言智能预设互补的目标翻译语言(如检测为中文默认目标设为英文,检测为英文默认目标设为中文) + +### Changed +- 弹窗翻译模式下失败时提供清晰 Toast 反馈,避免静默失败无感知 + ## 0.9.0 - 2026-09-17 ### Added diff --git "a/plugins/snap-translate/Ztools\345\274\200\345\217\221\346\226\207\346\241\243\345\234\260\345\235\200.txt" "b/plugins/snap-translate/Ztools\345\274\200\345\217\221\346\226\207\346\241\243\345\234\260\345\235\200.txt" new file mode 100644 index 000000000..7067822fa --- /dev/null +++ "b/plugins/snap-translate/Ztools\345\274\200\345\217\221\346\226\207\346\241\243\345\234\260\345\235\200.txt" @@ -0,0 +1 @@ +https://ztoolscenter.github.io/ZTools-doc/getting-started.html \ No newline at end of file diff --git a/plugins/snap-translate/package.json b/plugins/snap-translate/package.json index cb9bd68c4..d94f0a38c 100644 --- a/plugins/snap-translate/package.json +++ b/plugins/snap-translate/package.json @@ -1,6 +1,6 @@ { "name": "snap-translate", - "version": "0.9.0", + "version": "1.0.0", "description": "截图 OCR 翻译", "type": "module", "scripts": { diff --git a/plugins/snap-translate/public/plugin.json b/plugins/snap-translate/public/plugin.json index 433e2c643..c698eb379 100644 --- a/plugins/snap-translate/public/plugin.json +++ b/plugins/snap-translate/public/plugin.json @@ -4,7 +4,7 @@ "title": "截图翻译", "description": "截图 OCR 翻译", "author": "Taitres", - "version": "0.9.0", + "version": "1.0.0", "platform": [ "win32" ], diff --git a/plugins/snap-translate/public/preload/services.js b/plugins/snap-translate/public/preload/services.js index b590c988c..08f4e88ac 100644 --- a/plugins/snap-translate/public/preload/services.js +++ b/plugins/snap-translate/public/preload/services.js @@ -383,6 +383,7 @@ window.services = { }, injectBoardUpdate, + injectSideResult, getBoardBounds, /** @@ -633,6 +634,42 @@ window.services = { const inject = () => injectSideResult(payload); + if (sideWin && !sideWin.isDestroyed?.()) { + try { + let cur = null; + try { + cur = typeof sideWin.getBounds === "function" ? sideWin.getBounds() : null; + } catch (_) {} + let targetX = cur ? cur.x : Math.round(x); + let targetY = cur ? cur.y : Math.round(y); + if (targetX + boxW > work.x + work.width) { + targetX = Math.max(work.x, work.x + work.width - boxW - 16); + } + if (targetY + boxH > work.y + work.height) { + targetY = Math.max(work.y, work.y + work.height - boxH - 16); + } + if (typeof sideWin.setBounds === "function") { + sideWin.setBounds({ + x: Math.round(targetX), + y: Math.round(targetY), + width: boxW, + height: boxH, + }); + } else if (typeof sideWin.setSize === "function") { + sideWin.setSize(boxW, boxH); + if (typeof sideWin.setPosition === "function") { + sideWin.setPosition(Math.round(targetX), Math.round(targetY)); + } + } + inject(); + raiseBoard(); + setTimeout(inject, 200); + return true; + } catch (e) { + console.error("[snap-translate] update existing sideWin failed, fallback recreate", e); + } + } + try { if (sideWin && !sideWin.isDestroyed?.()) { try { diff --git a/plugins/snap-translate/src/composables/skipSameLangTranslate.test.ts b/plugins/snap-translate/src/composables/skipSameLangTranslate.test.ts new file mode 100644 index 000000000..2e3e10fc6 --- /dev/null +++ b/plugins/snap-translate/src/composables/skipSameLangTranslate.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { isTextInTargetLang } from './useLang' +import { translateLines, ocrTranslate } from './useOcrTranslate' + +function installHost(stubs: { + ocr?: any + translate?: any + providers?: any[] +}) { + const storage = new Map() + ;(globalThis as any).window = globalThis + ;(globalThis as any).window.ztools = { + ocr: stubs.ocr ?? vi.fn(), + translate: stubs.translate ?? vi.fn(), + providers: { + getProviders: vi.fn(async (type?: string) => { + const all = stubs.providers ?? [] + return type ? all.filter((p) => p.type === type) : all + }), + getDefaultProvider: vi.fn(async () => null), + invokeProvider: vi.fn() + }, + dbStorage: { + getItem: (k: string) => storage.get(k), + setItem: (k: string, v: unknown) => storage.set(k, v), + removeItem: (k: string) => storage.delete(k) + } + } +} + +describe('isTextInTargetLang', () => { + it('detects Chinese text correctly for zh-CN target', () => { + expect(isTextInTargetLang('识别出来是目标语言', 'zh-CN')).toBe(true) + expect(isTextInTargetLang('本来就是中文,点击翻译时', 'zh-CN')).toBe(true) + expect(isTextInTargetLang('点击确定(OK)', 'zh-CN')).toBe(true) + expect(isTextInTargetLang('打开 Chrome 浏览器', 'zh-CN')).toBe(true) + expect(isTextInTargetLang('Hello World', 'zh-CN')).toBe(false) + expect(isTextInTargetLang('Save and exit', 'zh-CN')).toBe(false) + }) + + it('detects Japanese containing Kana as NOT Chinese', () => { + expect(isTextInTargetLang('こんにちは', 'zh-CN')).toBe(false) + expect(isTextInTargetLang('日本語を勉強します', 'zh-CN')).toBe(false) + expect(isTextInTargetLang('こんにちは', 'ja')).toBe(true) + expect(isTextInTargetLang('日本語を勉強します', 'ja')).toBe(true) + }) + + it('detects Korean containing Hangul as NOT Chinese', () => { + expect(isTextInTargetLang('안녕하세요', 'zh-CN')).toBe(false) + expect(isTextInTargetLang('안녕하세요', 'ko')).toBe(true) + }) + + it('treats pure numbers and punctuation as already target lang without translating', () => { + expect(isTextInTargetLang('123456', 'zh-CN')).toBe(true) + expect(isTextInTargetLang('2026-09-30 11:20:00', 'zh-CN')).toBe(true) + expect(isTextInTargetLang('---===...', 'zh-CN')).toBe(true) + }) + + it('treats explicitly matching fromLang and toLang as already matching', () => { + expect(isTextInTargetLang('anything', 'zh-CN', 'zh-CN')).toBe(true) + expect(isTextInTargetLang('anything', 'en', 'en')).toBe(true) + }) +}) + +describe('skip translating text that is already in target language', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('does NOT call translate when all blocks are Chinese and target is zh-CN', async () => { + const translate = vi.fn() + installHost({ translate }) + + const result = await translateLines(['你好世界', '这是一段中文文本'], 'zh-CN') + + expect(translate).not.toHaveBeenCalled() + expect(result.translateOk).toBe(true) + expect(result.lines).toEqual([ + { text: '你好世界', translated: '你好世界' }, + { text: '这是一段中文文本', translated: '这是一段中文文本' } + ]) + expect(result.diagnostics.some((d) => d.includes('skipped translate'))).toBe(true) + }) + + it('only translates non-target blocks in mixed language lines', async () => { + const translate = vi.fn().mockResolvedValue({ + text: '世界你好', + detectedFrom: 'en' + }) + installHost({ translate }) + + const result = await translateLines( + ['已是中文第一行', 'Hello World', '已是中文第三行'], + 'zh-CN' + ) + + // Only 'Hello World' should be sent to translate, not the Chinese lines + expect(translate).toHaveBeenCalledTimes(1) + expect(translate).toHaveBeenCalledWith('Hello World', expect.objectContaining({ to: 'zh-CN' })) + + expect(result.translateOk).toBe(true) + expect(result.lines).toEqual([ + { text: '已是中文第一行', translated: '已是中文第一行' }, + { text: 'Hello World', translated: '世界你好' }, + { text: '已是中文第三行', translated: '已是中文第三行' } + ]) + }) + + it('ocrTranslate skips translate when OCR recognizes Chinese text and target is zh-CN', async () => { + const ocr = vi.fn().mockResolvedValue({ + text: '这是识别出的中文', + blocks: ['这是识别出的中文'] + }) + const translate = vi.fn() + installHost({ ocr, translate }) + + const result = await ocrTranslate('img-data', 'zh-CN') + + expect(ocr).toHaveBeenCalled() + expect(translate).not.toHaveBeenCalled() + expect(result.translateOk).toBe(true) + expect(result.lines[0].translated).toBe('这是识别出的中文') + }) +}) diff --git a/plugins/snap-translate/src/composables/useLang.ts b/plugins/snap-translate/src/composables/useLang.ts index 076e054fb..150187989 100644 --- a/plugins/snap-translate/src/composables/useLang.ts +++ b/plugins/snap-translate/src/composables/useLang.ts @@ -40,6 +40,83 @@ export function resolveTargetLang(text: string): string { return cjk / chars.length > 0.3 ? 'en' : 'zh-CN' } +/** + * 判断文本是否已经属于目标语言。 + * 若已是目标语言,跳过翻译直接保留原文,避免二次翻译导致乱码或变异。 + * 例如:文本本身是中文且目标语言是 zh-CN 时,返回 true。 + */ +export function isTextInTargetLang(text: string, toLang: string, fromLang?: string): boolean { + if (!text || !toLang || toLang === 'auto') return false + const trimmed = text.trim() + if (!trimmed) return true + + const to = toLang.toLowerCase() + const from = fromLang?.toLowerCase() + + // 若明确指定了源语言与目标语言且一致(如 zh-CN → zh-CN),直接判定匹配 + if (from && from !== 'auto' && (from === to || (from.startsWith('zh') && to.startsWith('zh')))) { + return true + } + + // 纯符号、数字、标点(如 "123", "---", "..."),无需翻译,视为保留 + const hasLetters = /[a-zA-Z\u00C0-\u024F\u0400-\u04FF\u0600-\u06FF\u0E00-\u0E7F\u3040-\u30FF\uAC00-\uD7AF\u4E00-\u9FFF\u3400-\u4DBF]/.test(trimmed) + if (!hasLetters) return true + + // 各语种脚本特征字符计数 + const cjkChars = (trimmed.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g) || []).length + const kanaChars = (trimmed.match(/[\u3040-\u30ff]/g) || []).length + const hangulChars = (trimmed.match(/[\uac00-\ud7af\u1100-\u11ff]/g) || []).length + const cyrillicChars = (trimmed.match(/[\u0400-\u04ff]/g) || []).length + const arabicChars = (trimmed.match(/[\u0600-\u06ff]/g) || []).length + const thaiChars = (trimmed.match(/[\u0e00-\u0e7f]/g) || []).length + const latinChars = (trimmed.match(/[a-zA-Z]/g) || []).length + + if (to.startsWith('zh')) { + // 目标语言是中文(zh / zh-CN / zh-TW): + // 若含有汉字,且无日文假名、无韩文字母: + // 即使夹杂少量英文单词/缩写(如“打开 Chrome”、“点击确定(OK)”),整体也是中文,不需要译为中文 + if (cjkChars > 0 && kanaChars === 0 && hangulChars === 0) { + if (cjkChars >= latinChars || cjkChars / (cjkChars + latinChars) >= 0.25) { + return true + } + } + return false + } + + if (to.startsWith('ja')) { + return kanaChars > 0 + } + + if (to.startsWith('ko')) { + return hangulChars > 0 + } + + if (to.startsWith('ru')) { + return cyrillicChars > 0 + } + + if (to.startsWith('ar')) { + return arabicChars > 0 + } + + if (to.startsWith('th')) { + return thaiChars > 0 + } + + if (to.startsWith('en')) { + // 目标是英文:有拉丁字母且不含中、日、韩、俄、阿、泰等字符,且为长文本或带空格(排除单字母测试数据) + const hasNonLatin = cjkChars > 0 || kanaChars > 0 || hangulChars > 0 || cyrillicChars > 0 || arabicChars > 0 || thaiChars > 0 + if (latinChars >= 4 && !hasNonLatin) { + if (/\s/.test(trimmed) || latinChars >= 8) { + return true + } + } + return false + } + + return false +} + const TARGET_LANG_KEY = 'snap-translate.targetLang' const FROM_LANG_KEY = 'snap-translate.fromLang' diff --git a/plugins/snap-translate/src/composables/useOcrTranslate.ts b/plugins/snap-translate/src/composables/useOcrTranslate.ts index 4cee2c836..832950adb 100644 --- a/plugins/snap-translate/src/composables/useOcrTranslate.ts +++ b/plugins/snap-translate/src/composables/useOcrTranslate.ts @@ -14,7 +14,7 @@ * 宿主「设置 → 提供商」里启用/设默认。 */ -import { resolveTargetLang, loadSavedTargetLang, loadSavedFromLang } from './useLang' +import { resolveTargetLang, loadSavedTargetLang, loadSavedFromLang, isTextInTargetLang } from './useLang' import { boxFromOcrLine, type PinOverlayLine } from '../pinOverlay' /** OCR 不可用(无默认 provider / provider 报错)。 */ @@ -297,6 +297,66 @@ async function translateMerged( return { lines, detectedFrom: out.detectedFrom, provider: out.provider } } +/** + * 翻译文本块列表: + * 若某一行识别出来已是目标语言,跳过翻译保留原文,避免翻译 API 将同一语言二次翻译导致乱码或变异。 + * 若全部行都已是目标语言,直接返回原文,不发起翻译请求。 + */ +async function translateBlocks( + blocks: string[], + to: string, + logs: string[], + from = 'auto' +): Promise<{ lines: string[]; detectedFrom?: string; provider?: string }> { + if (blocks.length === 0) { + return { lines: [], provider: undefined } + } + + // 检查哪些块已是目标语言,无需翻译 + const needsTranslate: boolean[] = blocks.map((b) => !isTextInTargetLang(b, to, from)) + const toTranslateIndices: number[] = [] + const toTranslateBlocks: string[] = [] + + blocks.forEach((b, i) => { + if (needsTranslate[i]) { + toTranslateIndices.push(i) + toTranslateBlocks.push(b) + } + }) + + // 如果全部行都已是目标语言,无需发起翻译 API 请求 + if (toTranslateBlocks.length === 0) { + pushLog(logs, `all ${blocks.length} blocks already in target lang (${to}), skipped translate`) + return { + lines: blocks.slice(), + detectedFrom: to, + provider: undefined + } + } + + // 如果部分行已是目标语言,只将需要翻译的行送入引擎 + if (toTranslateBlocks.length < blocks.length) { + pushLog( + logs, + `translating ${toTranslateBlocks.length}/${blocks.length} blocks (${blocks.length - toTranslateBlocks.length} already ${to})` + ) + } + + const translatedNeeded = await translateMerged(toTranslateBlocks, to, logs, from) + + // 组装结果:已是目标语言的行保留原文,需翻译的行填入翻译结果 + const resultLines: string[] = blocks.slice() + toTranslateIndices.forEach((origIdx, neededIdx) => { + resultLines[origIdx] = translatedNeeded.lines[neededIdx] ?? '' + }) + + return { + lines: resultLines, + detectedFrom: translatedNeeded.detectedFrom, + provider: translatedNeeded.provider + } +} + /** 纯 OCR(快速路径)。 */ export async function ocrOnly(image: string): Promise { const diagnostics: string[] = [] @@ -441,7 +501,7 @@ export async function ocrTranslate( let translateProvider: string | undefined try { - translated = await translateMerged(blocks, to, diagnostics, from) + translated = await translateBlocks(blocks, to, diagnostics, from) if (translated && translated.lines.every((l) => !l)) { translateError = '翻译结果为空' pushLog(diagnostics, translateError) @@ -495,7 +555,7 @@ export async function translateLines( let translated: { lines: string[]; detectedFrom?: string; provider?: string } | null = null let translateError: string | undefined try { - translated = await translateMerged(blocks, to, diagnostics, from) + translated = await translateBlocks(blocks, to, diagnostics, from) if (translated && translated.lines.every((l) => !l)) { translated = null translateError = '翻译结果为空' diff --git a/plugins/snap-translate/src/env.d.ts b/plugins/snap-translate/src/env.d.ts index c698e96fe..586561423 100644 --- a/plugins/snap-translate/src/env.d.ts +++ b/plugins/snap-translate/src/env.d.ts @@ -192,6 +192,7 @@ declare global { saveImageFile: (dataUrl: string, destPath: string) => string /** 贴在悬浮图旁的结果侧窗。 */ openSideResult: (payload: SnapResultPayload) => boolean + injectSideResult?: (payload: SnapResultPayload) => void closeSideResult?: () => void ocrImageDetail?: (image: string) => Promise<{ ok: boolean diff --git a/plugins/snap-translate/src/popupTranslate.test.ts b/plugins/snap-translate/src/popupTranslate.test.ts new file mode 100644 index 000000000..4fd10b1cd --- /dev/null +++ b/plugins/snap-translate/src/popupTranslate.test.ts @@ -0,0 +1,43 @@ +/** + * 契约测试:在贴图 OCR 之后的弹窗中点击翻译,必须是在小窗中展示翻译(弹小窗翻译),而不是覆盖贴图。 + */ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const dir = path.dirname(fileURLToPath(import.meta.url)) +const resultView = readFileSync(path.join(dir, 'views/ResultView.vue'), 'utf8') +const snapHub = readFileSync(path.join(dir, 'views/SnapHub.vue'), 'utf8') +const services = readFileSync( + path.join(dir, '../public/preload/services.js'), + 'utf8' +) + +describe('popup translate instead of board overlay', () => { + it('ResultView sends target: popup and fromResultWindow: true', () => { + expect(resultView).toContain("action: 'translate'") + expect(resultView).toContain("target: 'popup'") + expect(resultView).toContain('fromResultWindow: true') + }) + + it('SnapHub identifies popup translate requests and sets forcePopup', () => { + expect(snapHub).toMatch( + /const forcePopup =\s*data\?\.target === 'popup' \|\| data\?\.fromResultWindow === true \|\| !data\?\.image/ + ) + expect(snapHub).toMatch(/presentTranslateResult\([\s\S]*?\{ forcePopup \}/) + }) + + it('presentTranslateResult respects options.forcePopup over overlay setting', () => { + expect(snapHub).toMatch( + /const mode = options\?\.forcePopup\s*\?\s*'popup'\s*:\s*\(loadBoardSettings\(\)\.translateResultMode \|\| 'popup'\)/ + ) + }) + + it('services updates existing sideWin in place instead of closing and recreating', () => { + expect(services).toContain('sideWin && !sideWin.isDestroyed?.()') + expect(services).toContain('sideWin.setBounds') + expect(services).toContain('injectSideResult') + expect(services).toMatch(/window\.services\s*=\s*\{[\s\S]*?injectSideResult/) + }) +}) diff --git a/plugins/snap-translate/src/views/OcrResult.vue b/plugins/snap-translate/src/views/OcrResult.vue index 7b1c2255d..ab0ef8515 100644 --- a/plugins/snap-translate/src/views/OcrResult.vue +++ b/plugins/snap-translate/src/views/OcrResult.vue @@ -1,7 +1,7 @@