Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions plugins/snap-translate/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions plugins/snap-translate/Ztools开发文档地址.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
https://ztoolscenter.github.io/ZTools-doc/getting-started.html
2 changes: 1 addition & 1 deletion plugins/snap-translate/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "snap-translate",
"version": "0.9.0",
"version": "1.0.0",
"description": "截图 OCR 翻译",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion plugins/snap-translate/public/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"title": "截图翻译",
"description": "截图 OCR 翻译",
"author": "Taitres",
"version": "0.9.0",
"version": "1.0.0",
"platform": [
"win32"
],
Expand Down
37 changes: 37 additions & 0 deletions plugins/snap-translate/public/preload/services.js
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ window.services = {
},

injectBoardUpdate,
injectSideResult,
getBoardBounds,

/**
Expand Down Expand Up @@ -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 {
Expand Down
124 changes: 124 additions & 0 deletions plugins/snap-translate/src/composables/skipSameLangTranslate.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>()
;(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('这是识别出的中文')
})
})
77 changes: 77 additions & 0 deletions plugins/snap-translate/src/composables/useLang.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
66 changes: 63 additions & 3 deletions plugins/snap-translate/src/composables/useOcrTranslate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 报错)。 */
Expand Down Expand Up @@ -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<OcrOnlyResult> {
const diagnostics: string[] = []
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = '翻译结果为空'
Expand Down
1 change: 1 addition & 0 deletions plugins/snap-translate/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading