Переводчик экрана: наложение перевода поверх текста на экране
build / build (push) Failing after 10s

Программа накладывает русский перевод прямо на английские надписи —
как переводчик по фото, только для монитора. Работает с играми,
интерфейсами и документами: распознаёт кадр, собирает текст в связные
блоки, переводит и рисует плашки под цвет фона.

Распознавание — встроенный Windows.Media.Ocr, офлайн. Перевод — цепочка
движков с автопереключением (google, lingva, mymemory), потому что до
разных сервисов из разных сетей достучаться получается по-разному.

Собирается без .NET SDK: Roslyn из Build Tools плюс сборки Framework 4.8,
которые уже есть в системе. На выходе один exe без установщика.

Главная сложность оказалась не в переводе, а в том, чтобы понять, где на
экране связный текст, а где отдельные надписи. Классические алгоритмы
сегментации решают это по межстрочному зазору, но в плотном интерфейсе он
одинаков у строк абзаца и у соседних контролов. Поэтому решение принимает
ансамбль признаков: фон, граница между строками, заполненность строки,
кегль. Надписи на чужом языке отсеиваются тремя уровнями — форма слова,
вторая модель распознавания и частоты буквенных пар.

Чтобы правки эвристик не оценивались на глаз, в tools лежат эталонные
кадры и RenderTest: прогон конвейера без окна с подсчётом попаданий.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 15:54:02 +03:00
commit 7c6b182402
26 changed files with 5161 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
# Сборка и публикация «Переводчика экрана».
#
# Идёт на self-hosted Windows-раннере (метка `windows`, host mode) — иначе никак:
# распознавание держится на Windows.Media.Ocr, а сборка — на Roslyn из VS Build Tools
# и на Windows.winmd из Windows SDK. В контейнере ничего этого нет.
#
# * push в master -> сборка + прогон проверок + перезапись релиза `latest`
# * push тега v* -> то же, но отдельный постоянный релиз с этой версией
#
# Требований к раннеру всего два, и оба уже выполнены (тот же хост собирает GusVoice):
# Visual Studio Build Tools (нужен Roslyn csc) и Windows SDK (Windows.winmd).
# Если Roslyn вдруг отсутствует, build.ps1 сам откатывается на компилятор из состава
# Windows — код специально написан без возможностей C# 6 и выше, чтобы это работало.
name: build
on:
push:
branches: [master]
tags: ['v*']
workflow_dispatch:
permissions:
contents: write
env:
GITEA_HOST: gitea.gusik.tech
jobs:
build:
runs-on: windows
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Сборка exe и диагностической утилиты
shell: pwsh
run: .\build.ps1 -Test
# Модель правдоподобия английского — единственная часть, которую можно проверить
# без экрана и без языковых пакетов. Проверяет, что таблица биграмм на месте и
# всё ещё отделяет настоящие надписи от каши распознавания.
- name: Проверка биграммной модели
shell: pwsh
run: |
$out = & .\dist\RenderTest.exe --selftest 2>&1 | Out-String
Write-Host $out
if ($LASTEXITCODE -ne 0) { throw "selftest завершился с кодом $LASTEXITCODE" }
if ($out -notmatch 'худший «хороший»') { throw "selftest не выдал сводку — модель не загрузилась" }
# Гейт качества на эталонном кадре. Работает, только если на раннере установлен
# английский пакет распознавания; если его нет — не валим сборку, а предупреждаем:
# это среда, а не регрессия кода.
- name: Проверка распознавания на эталоне
shell: pwsh
run: |
.\tools\make-sample.ps1
$out = & .\dist\RenderTest.exe --in .\dist\sample.png --out .\dist\ci --no-translate 2>&1 | Out-String
Write-Host $out
if ($out -match 'OCR недоступен') {
Write-Host '::warning::на раннере нет модели распознавания — проверка кадра пропущена'
exit 0
}
if ($out -notmatch 'блоков (\d+)') { throw "RenderTest не отчитался о блоках" }
$blocks = [int]$Matches[1]
Write-Host "распознано блоков: $blocks"
# На этом кадре стабильно выходит 27-29 блоков. Резкое расхождение означает, что
# правки в Blocks.cs поехали — либо всё слиплось, либо рассыпалось.
if ($blocks -lt 18 -or $blocks -gt 40) { throw "неожиданное число блоков ($blocks) — сегментация сломана" }
- name: Сборка пакета
shell: pwsh
run: |
$rel = 'release'
New-Item -ItemType Directory -Force -Path $rel | Out-Null
Copy-Item .\dist\ScreenTranslator.exe $rel -Force
Copy-Item '.\ЧИТАЙ МЕНЯ.txt' $rel -Force -ErrorAction SilentlyContinue
# config.json генерирует само приложение из вшитого умолчания
& .\dist\ScreenTranslator.exe --write-config
Start-Sleep -Seconds 2
if (Test-Path .\dist\config.json) { Copy-Item .\dist\config.json $rel -Force }
else { Write-Host '::warning::config.json не создан — пакет уедет без него' }
Compress-Archive -Path "$rel\*" -DestinationPath 'ПереводчикЭкрана.zip' -Force
Get-ChildItem 'ПереводчикЭкрана.zip' | Format-List Name, Length
# Тег v* -> постоянный релиз. Любой push в master -> перезаписываемый `latest`,
# чтобы у тех, кому программу отдали, всегда была ссылка на свежую сборку.
- name: Публикация релиза
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REPO: ${{ github.repository }}
REF_NAME: ${{ github.ref_name }}
REF: ${{ github.ref }}
SHA: ${{ github.sha }}
run: |
$ErrorActionPreference = 'Stop'
$host_ = $env:GITEA_HOST
$base = "https://${host_}/api/v1/repos/$($env:REPO)"
$hdr = @{ Authorization = "token $($env:GITEA_TOKEN)" }
$isTag = $env:REF -like 'refs/tags/*'
$tag = if ($isTag) { $env:REF_NAME } else { 'latest' }
if (-not $isTag) {
# Скользящий релиз: сносим прошлый вместе с тегом, иначе он останется
# висеть на старом коммите и «свежая сборка» перестанет быть свежей.
try {
$old = Invoke-RestMethod -Uri "$base/releases/tags/$tag" -Headers $hdr
if ($old.id) {
Invoke-RestMethod -Method Delete -Uri "$base/releases/$($old.id)" -Headers $hdr | Out-Null
Write-Host "удалён прошлый релиз $tag"
}
} catch { }
try { Invoke-RestMethod -Method Delete -Uri "$base/tags/$tag" -Headers $hdr | Out-Null } catch { }
}
$short = $env:SHA.Substring(0, 8)
$stamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm') + ' UTC'
$notes = if ($isTag) {
"Версия $tag`n`nСборка из коммита $short."
} else {
"Свежая сборка из master, коммит $short, собрана $stamp.`n`n" +
"Обновляется автоматически при каждом изменении. Для стабильной версии смотрите релизы с номером."
}
$body = @{
tag_name = $tag
target_commitish = $env:SHA
name = if ($isTag) { $tag } else { "Свежая сборка ($short)" }
body = $notes
draft = $false
prerelease = (-not $isTag)
} | ConvertTo-Json -Compress
try {
$rel = Invoke-RestMethod -Method Post -Uri "$base/releases" -Headers $hdr -ContentType 'application/json' -Body $body
} catch {
# Релиз на этот тег уже есть (перезапуск задания) — берём его и обновляем описание
$rel = Invoke-RestMethod -Uri "$base/releases/tags/$tag" -Headers $hdr
$patch = @{ body = $notes } | ConvertTo-Json -Compress
Invoke-RestMethod -Method Patch -Uri "$base/releases/$($rel.id)" -Headers $hdr -ContentType 'application/json' -Body $patch | Out-Null
}
if (-not $rel.id) { throw "не удалось получить релиз для тега $tag" }
# Старые файлы с тем же именем API не заменяет, поэтому сначала чистим
try {
foreach ($a in (Invoke-RestMethod -Uri "$base/releases/$($rel.id)/assets" -Headers $hdr)) {
Invoke-RestMethod -Method Delete -Uri "$base/releases/$($rel.id)/assets/$($a.id)" -Headers $hdr | Out-Null
}
} catch { }
foreach ($f in @('ПереводчикЭкрана.zip', '.\dist\ScreenTranslator.exe')) {
$item = Get-Item $f
Write-Host "загружаю $($item.Name)…"
& curl.exe -sf -X POST "$base/releases/$($rel.id)/assets?name=$($item.Name)" `
-H "Authorization: token $($env:GITEA_TOKEN)" -F "attachment=@$($item.FullName)"
if ($LASTEXITCODE -ne 0) { throw "не удалось загрузить $($item.Name)" }
}
Write-Host "релиз $tag опубликован: https://${host_}/$($env:REPO)/releases"
+16
View File
@@ -0,0 +1,16 @@
# Сборка и готовый пакет — собираются из исходников, в репозитории не нужны.
# Важно: в dist/ по ходу отладки оседают скриншоты рабочего стола и cache.json
# с текстом, который был на экране, — этому в публичном репозитории точно не место.
dist/
release/
*.zip
# Побочные файлы работающей программы
config.json
cache.json
*.exe
*.pdb
*.user
Thumbs.db
desktop.ini
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 gusadmin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+241
View File
@@ -0,0 +1,241 @@
# Переводчик экрана
Наводит перевод поверх английского текста прямо на экране — как Яндекс.Переводчик по фото,
только для монитора. Нажал горячую клавишу → английские надписи закрылись плашками
под цвет фона, на них русский текст. Остальная картинка остаётся на месте.
Работает с любой программой: игры, PDF, видео, интерфейсы, картинки — всё, что видно на экране.
* Распознавание — встроенный в Windows OCR: офлайн, бесплатно, ничего ставить не надо.
* Перевод — несколько движков с автоматическим переключением, если какой-то не отвечает.
* Один файл `ScreenTranslator.exe` (81 КБ), без установщика и без .NET SDK.
## Как пользоваться
| Клавиши | Действие |
|---|---|
| `Ctrl+Alt+Z` | перевести монитор, на котором сейчас курсор |
| `Ctrl+Alt+X` | выделить область рамкой и перевести её |
| `Esc` или повторное нажатие | закрыть перевод |
| Пробел (удерживать) | подсмотреть оригинал под плашками |
| Наведение мышью | показать оригинал одного фрагмента |
| Клик по плашке | скопировать её перевод |
| `Ctrl+C` | скопировать весь перевод |
Значок в трее — те же команды плюс «Проверить переводчики» и правка настроек.
Пока идёт распознавание и перевод, экран уже заморожен, а фрагменты подсвечены — видно,
что программа работает. Полный цикл на мониторе 3440×1440 — около трёх секунд
(0,6 с распознавание + ~2 с перевод), повтор того же текста — мгновенно из кэша.
## Установка
Скопировать папку `dist` куда угодно и запустить `ScreenTranslator.exe`. Всё.
Если Windows покажет «SmartScreen защитил ваш компьютер» — «Подробнее» → «Выполнить в любом случае»
(файл без цифровой подписи).
Автозапуск: `Win+R``shell:startup` → положить туда ярлык на exe.
### Требование одно: модель распознавания
Нужен установленный в Windows языковой компонент «Распознавание текста» для английского.
Проверить: значок в трее → «Проверить переводчики» покажет ошибку, если моделей нет.
Ставится так: **Параметры → Время и язык → Язык и регион** → у английского языка `···`
**Компоненты языка** → отметить **Распознавание текста**. На большинстве систем уже стоит.
## Настройка
Рядом с exe лежит `config.json` (создаётся при первом запуске). После правки —
«Перечитать настройки» в трее, перезапуск не нужен.
Самое полезное:
```jsonc
"hotkeyTranslate": "Ctrl+Alt+Z", // своя комбинация, если эта занята
"ocrLanguage": "en-US", // язык оригинала на экране
"targetLang": "ru", // на какой переводить
"overlayMode": "frozen", // frozen — стоп-кадр; live — плашки поверх живого экрана
"captureScope": "monitor", // monitor — экран под курсором; all — все мониторы сразу
"ocrScale": 2.0, // 1.0 быстрее, 2.0 точнее на мелком шрифте
"detectForeign": true, // не трогать надписи на языке системы (см. ниже)
"markTranslated": true, // подчёркивать переведённые места
"chain": ["google", "lingva", "mymemory"]
```
### Смешанный экран
Английская модель OCR, встретив русскую надпись, читает её как бессмыслицу вроде `C03AaTb`
или `Pa3Mep` — и без защиты программа переводила бы эту кашу, залепляя плашками весь
русский интерфейс.
Защита двухступенчатая:
1. `skipGarbage` — отсев по форме слов: цифра посреди слова, заглавная после строчной,
символы-артефакты, имена файлов.
2. `detectForeign` — кадр дополнительно распознаётся моделью второго языка. Если она видит
на этом месте кириллицу, надпись изначально русская и её не трогают. Это ещё примерно
столько же времени на распознавание; выключается, если весь экран и так на одном языке.
3. Список ходовых английских слов ([EnglishWords.cs](src/EnglishWords.cs)) — арбитр в спорных
случаях. Русская модель читает латинское «Cancel» как «Сапсеl», то есть по алфавиту оно
неотличимо от русского «Профиль» → `npocbwlb`. Если основная модель выдала узнаваемое
английское слово, верим ей.
На эталонах это даёт 22 распознанных блока из 22 на английском кадре (ни одной потери)
и 2 оставшихся из 20 на русском.
Совсем идеально не будет: очень мелкие надписи (вкладки браузера, 8 px) обе модели читают
плохо, и часть их может проскочить. **Если на экране мешанина языков — надёжнее `Ctrl+Alt+X`
и рамка вокруг нужного куска.**
### Чего программа не умеет и не сможет
Рабочий стол, панель задач, списки установленных программ — не её сценарий. Подписи там это
названия приложений, а отличить название от обычного слова невозможно: «Steam», «Discord»,
«Wand» одновременно и то, и другое. Перевод получится буквальным и бессмысленным.
Помогает `"minWords": 2` — тогда переводятся только фразы от двух слов, а одиночные подписи
остаются как есть. Но за это приходится платить: на интерфейсе игры теряется половина надписей
(«Mining», «Search», «Consumable» — тоже одиночные слова), поэтому по умолчанию стоит `1`.
### Про переводчики и Россию
`chain` — это порядок попыток. Первый ответивший движок используется, упавший временно
исключается и пробуется следующий. Ничего перенастраивать при переезде в другую сеть не нужно —
если Google недоступен, программа сама уйдёт на запасной.
Что стоит знать:
* **google** — бесплатный публичный эндпоинт, без ключа, лучшее соотношение качества и скорости.
`translate.googleapis.com` в России не заблокирован (замедляли YouTube, а не API), но
у отдельных провайдеров ТСПУ рвёт к нему TLS. Отсюда и нужна цепочка.
* **lingva** — открытый шлюз к тому же Google на других адресах. Ровно тот случай, когда
напрямую не пускает, а через шлюз проходит. Медленнее (2–4 с).
* **mymemory** — последний рубеж, без ключа, лимит около 5000 слов в сутки на IP.
* **yandex** — самый устойчивый канал внутри РФ, но нужен платный Yandex Cloud
(`folderId` + API-ключ).
* **deepl** — лучшее качество en→ru, есть бесплатный тариф с ключом.
* **llm** — перевод целым экраном через Claude API: лучше держит контекст интерфейса,
но платный по токенам и медленнее.
* **libretranslate** / **relay** — свой сервер, ничего не уходит наружу.
Проверить, что доступно из конкретной сети: трей → **Проверить переводчики** — покажет
время ответа или ошибку по каждому.
Через прокси (например, свой sing-box в режиме mixed):
```jsonc
"proxy": "http://192.168.5.42:2080"
```
### Свой релей
Если публичные сервисы недоступны, а свой сервер есть — поднимите эндпоинт, принимающий
`POST {"texts": [...], "from": "en", "to": "ru"}` и возвращающий `{"texts": [...]}`
той же длины, затем:
```jsonc
"chain": ["relay", "google"],
"engines": { "relay": { "url": "https://ваш-домен/translate" } }
```
Внутри релей может ходить хоть в Google, хоть в локальную модель — клиенту всё равно.
## Приватность
Распознавание идёт локально, наружу уходит **только распознанный текст** — картинка экрана
никогда не покидает компьютер. Но текст с экрана всё же уходит в выбранный сервис перевода,
поэтому для чувствительного содержимого используйте `libretranslate` или `relay` на своём сервере.
Кэш переводов лежит рядом в `cache.json` — его можно удалять в любой момент.
## Сборка из исходников
.NET SDK не нужен — используется Roslyn из Visual Studio Build Tools и сборки .NET Framework 4.8,
которые уже есть в Windows.
```powershell
.\build.ps1 # собрать в .\dist
.\build.ps1 -Run # собрать и запустить
.\build.ps1 -Test # плюс RenderTest.exe
```
`RenderTest.exe` — не сама программа, а диагностика: прогоняет весь конвейер без окна и
сохраняет две картинки (как OCR разметил блоки и как лёг перевод). Запускать лучше из терминала;
при двойном клике он всё равно отработает и подождёт Enter, а картинки положит
в `%TEMP%\screen-translator-test`.
```powershell
.\dist\RenderTest.exe --in кадр.png --out .\dist\test
.\dist\RenderTest.exe --scale 1.0 --no-translate
```
Два эталонных кадра для прогонов рисуются скриптами:
* `tools\make-sample.ps1` — англоязычный интерфейс. Проверка «всё ли переводится»:
правильный результат — 22 блока.
* `tools\make-sample-ru.ps1` — русский интерфейс. Негативная проверка: английская модель OCR
читает кириллицу как кашу, и её нельзя ни переводить, ни закрывать плашками.
Правильный результат — около 5 блоков (мелкие обрывки вроде «Tn»), было 20 до фильтра.
Оба стоит прогонять после правок в `Blocks.cs`: ужесточение фильтра легко начинает резать
короткие английские кнопки, и английский кадр это сразу показывает.
Флаг `--expect файл.txt` считает, сколько заранее известных строк реально распозналось —
без такой метрики улучшение и ухудшение на глаз выглядят одинаково убедительно.
`--selftest` проверяет биграммную модель на примерах настоящих надписей и каши.
```powershell
.\dist\RenderTest.exe --in wow.jpg --expect tools\expect-wow.txt --no-translate
.\dist\RenderTest.exe --selftest
```
Текущие показатели (после каждой правки должны держаться):
| Кадр | Метрика | Значение |
|---|---|---|
| Скриншот игры | распознано известных надписей | 14 из 14 |
| Английский эталон | ложно отброшено | 0 |
| Русский эталон | осталось непойманной каши | 2 из 20 |
## Что внутри
| Файл | Зачем |
|---|---|
| `src/Program.cs` | трей, горячие клавиши, порядок работы |
| `src/ScreenCapture.cs` | снимок экрана и отдельная копия пикселей для фоновой обработки |
| `src/Ocr.cs` | Windows.Media.Ocr, увеличение кадра ради мелкого шрифта |
| `src/Blocks.cs` | склейка строк в абзацы, отсев каши, подбор цветов плашки |
| `src/Translators.cs` | движки перевода, цепочка фоллбеков, кэш |
| `src/PlateRenderer.cs` | подбор кегля и отрисовка плашки |
| `src/OverlayForm.cs` | окно поверх экрана |
| `src/RegionSelector.cs` | выделение области рамкой |
| `src/EnglishBigrams.cs` | частоты буквенных пар: отличает слово от каши распознавания |
| `src/EnglishWords.cs` | список ходовых английских слов — арбитр в спорных случаях |
Откуда взялись решения (короткий разбор чужого опыта):
* Классические алгоритмы сегментации — RLSA, Docstrum, XY-cut, Voronoi — принимают решение
«склеивать или нет» по одному сигналу, межстрочному зазору. В плотном интерфейсе он
одинаков у строк абзаца и у соседних контролов, поэтому ни один из них эту задачу не решает.
Работает только ансамбль независимых признаков — как в Tesseract (`paragraphs.h`, геометрия
плюс пунктуация) и UIED, где графику детектируют отдельно от текста.
* Отсюда четыре признака разрыва: разный фон, граница между строками, «строка не дотянула
до правого края» (в абзаце короткой бывает только последняя) и несовпадение кегля.
* Определение чужого языка — это не задача language identification: готовые детекторы
(cld3, fastText) всегда выбирают какой-то язык из списка и уверенно зовут кашу английским.
Нужна одноклассовая модель «похоже на настоящий английский», отсюда биграммы.
* Проверено и отвергнуто: перевод в оттенки серого и инверсия тёмного фона перед OCR.
Обещали заметный прирост, на замере не дали ничего (14 из 20 во всех вариантах),
поэтому по умолчанию выключены, хотя в конфиге остались.
Тонкие места, если будете править:
* Приложение помечено `PerMonitorV2` — все координаты физические, иначе разметка OCR
разъезжается с экраном при масштабе интерфейса больше 100 %.
* OCR обводит только тело букв, без выносных элементов, поэтому плашка расширяется
по вертикали — иначе сверху и снизу торчат хвосты оригинала.
* Строки склеиваются в абзац по межстрочному интервалу и одинаковому кеглю: рваные строки
переводятся заметно хуже целого абзаца.
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="ScreenTranslator.app" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<!-- Без этого блока Windows считает процесс "legacy" и не отдаёт PerMonitorV2 -->
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /><!-- Windows 10 / 11 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" /><!-- Windows 8.1 -->
</application>
</compatibility>
<!-- DPI: работаем строго в физических пикселях, иначе координаты OCR не совпадут с экраном -->
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>
+126
View File
@@ -0,0 +1,126 @@
<#
Сборка «Переводчика экрана».
.NET SDK не нужен: используется Roslyn из VS Build Tools и сборки .NET Framework 4.8,
которые уже есть в Windows. На выходе — один самодостаточный exe.
Использование: .\build.ps1 собрать в .\dist
.\build.ps1 -Run собрать и запустить
#>
[CmdletBinding()]
param(
[switch]$Run,
[switch]$Test, # дополнительно собрать RenderTest.exe — прогон конвейера без окна, результат в PNG
[string]$OutDir = "$PSScriptRoot\dist"
)
$ErrorActionPreference = 'Stop'
function Find-Csc {
$candidates = @(
"C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\Roslyn\csc.exe",
"C:\Program Files\Microsoft Visual Studio\2022\*\MSBuild\Current\Bin\Roslyn\csc.exe",
"C:\Program Files (x86)\Microsoft Visual Studio\2022\*\MSBuild\Current\Bin\Roslyn\csc.exe",
"C:\Program Files (x86)\MSBuild\14.0\Bin\csc.exe"
)
foreach ($c in $candidates) {
$hit = Get-Item $c -ErrorAction SilentlyContinue | Select-Object -First 1
if ($hit) { return $hit.FullName }
}
# запасной вариант — компилятор из самой Windows (старый C#, но соберёт)
$fw = "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe"
if (Test-Path $fw) { return $fw }
throw "Не найден компилятор C#. Поставьте Visual Studio Build Tools 2022."
}
function Find-WinMd {
$root = "C:\Program Files (x86)\Windows Kits\10\UnionMetadata"
if (-not (Test-Path $root)) { throw "Не найден Windows SDK (UnionMetadata) — без него нет доступа к Windows OCR." }
# рядом лежит папка Facade с заглушкой — берём только версионные каталоги (10.0.x)
$ver = Get-ChildItem $root -Directory |
Where-Object { $_.Name -match '^\d+\.' -and (Test-Path (Join-Path $_.FullName 'Windows.winmd')) } |
Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1
if (-not $ver) { throw "В Windows SDK нет версионного Windows.winmd." }
return (Join-Path $ver.FullName 'Windows.winmd')
}
$csc = Find-Csc
$winmd = Find-WinMd
$fw = "C:\Windows\Microsoft.NET\Framework64\v4.0.30319"
$gac = "C:\Windows\Microsoft.NET\assembly\GAC_MSIL"
function Gac($name) {
$hit = Get-ChildItem (Join-Path $gac $name) -Recurse -Filter "$name.dll" -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $hit) { throw "Нет сборки $name в GAC." }
return $hit.FullName
}
$refs = @(
"$fw\mscorlib.dll"
"$fw\System.dll"
"$fw\System.Core.dll"
"$fw\System.Drawing.dll"
"$fw\System.Windows.Forms.dll"
"$fw\System.Runtime.dll"
"$fw\System.Web.dll"
(Gac 'System.Web.Extensions')
(Gac 'System.Runtime.WindowsRuntime')
$winmd
)
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$exe = Join-Path $OutDir 'ScreenTranslator.exe'
# запущенная копия держит файл, и линковка падает с невнятной ошибкой
$running = Get-Process ScreenTranslator -ErrorAction SilentlyContinue
if ($running) {
Write-Host "Останавливаю запущенный ScreenTranslator (PID $($running.Id))…"
$running | Stop-Process -Force
Start-Sleep -Milliseconds 400
}
$sources = Get-ChildItem "$PSScriptRoot\src" -Filter *.cs | ForEach-Object { $_.FullName }
Write-Host "csc : $csc"
Write-Host "winmd : $winmd"
Write-Host "исходники: $($sources.Count) файлов"
Write-Host "выход : $exe"
Write-Host ''
$cscArgs = @(
'/nologo'
'/target:winexe'
'/platform:x64'
'/optimize+'
'/langversion:latest'
"/out:$exe"
"/win32manifest:$PSScriptRoot\app.manifest"
) + ($refs | ForEach-Object { "/r:$_" }) + $sources
& $csc @cscArgs
if ($LASTEXITCODE -ne 0) { throw "Сборка не удалась (код $LASTEXITCODE)." }
Write-Host ("`nГотово: {0} ({1:N0} КБ)" -f $exe, ((Get-Item $exe).Length / 1KB))
if ($Test) {
$testExe = Join-Path $OutDir 'RenderTest.exe'
# тот же код, но с консольной точкой входа вместо трея
$testSources = ($sources | Where-Object { $_ -notlike '*\Program.cs' }) + "$PSScriptRoot\tools\RenderTest.cs"
$testArgs = @(
'/nologo', '/target:exe', '/platform:x64', '/optimize+', '/langversion:latest'
"/out:$testExe"
"/win32manifest:$PSScriptRoot\app.manifest" # без него захват придёт в логических пикселях
) + ($refs | ForEach-Object { "/r:$_" }) + $testSources
& $csc @testArgs
if ($LASTEXITCODE -ne 0) { throw "Сборка теста не удалась (код $LASTEXITCODE)." }
Write-Host ("Готово: {0} ({1:N0} КБ)" -f $testExe, ((Get-Item $testExe).Length / 1KB))
}
if ($Run) {
Get-Process ScreenTranslator -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Milliseconds 300
Start-Process $exe
Write-Host "Запущен. Значок в трее."
}
+339
View File
@@ -0,0 +1,339 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Web.Script.Serialization;
using System.Windows.Forms;
namespace ScreenTranslator
{
/// <summary>Настройки из config.json рядом с exe. Разбор ручной и снисходительный:
/// незнакомые ключи игнорируются, кривое значение откатывается к дефолту, приложение
/// не должно падать из-за опечатки в конфиге.</summary>
internal class AppConfig
{
public string HotkeyTranslate = "Ctrl+Alt+Z";
public string HotkeyRegion = "Ctrl+Alt+X";
public string SourceLang = "en";
public string TargetLang = "ru";
public string OcrLanguage = "en-US";
/// <summary>Увеличение кадра перед OCR. Мелкий шрифт интерфейса на 1.0 читается плохо,
/// 2.0 заметно точнее, но дороже по времени.</summary>
public double OcrScale = 2.0;
/// <summary>frozen — оверлей показывает замороженный кадр экрана (стабильно, дефолт);
/// live — только плашки поверх живого экрана.</summary>
public string OverlayMode = "frozen";
/// <summary>monitor — монитор под курсором; all — весь виртуальный рабочий стол.</summary>
public string CaptureScope = "monitor";
/// <summary>Порядок движков перевода. Первый живой выигрывает, дохлые запоминаются.</summary>
public List<string> Chain = new List<string> { "google", "lingva", "mymemory" };
public int TimeoutMs = 7000;
public int MaxParallel = 6;
/// <summary>Пустая строка — прямое соединение. Формат: http://host:port (sing-box mixed подходит).</summary>
public string Proxy = "";
public string LibreUrl = "";
public string LibreApiKey = "";
public string RelayUrl = "";
public string DeeplUrl = "https://api-free.deepl.com/v2/translate";
public string DeeplApiKey = "";
public string YandexFolderId = "";
public string YandexApiKey = "";
public string LlmUrl = "https://api.anthropic.com/v1/messages";
public string LlmApiKey = "";
public string LlmModel = "claude-haiku-4-5-20251001";
public string FontFamily = "Segoe UI";
public int BoxPadding = 2;
public int MinFontPx = 8;
/// <summary>Насколько плашке разрешено вылезти за исходный бокс вправо/вниз, доля ширины.
/// Русский в среднем на 15–20 % длиннее английского, без запаса шрифт мельчает.</summary>
public double GrowRatio = 0.35;
public bool DimBackground = false;
public bool Cache = true;
public bool ShowTrayBalloonOnError = true;
/// <summary>Строки короче — мусор OCR, не переводим (номера строк, иконки, одиночные буквы).</summary>
public int MinChars = 2;
/// <summary>Отсеивать блоки, не похожие на связный текст (обычно это надписи на другом
/// языке, которые модель OCR прочитала как бессмыслицу).</summary>
public bool SkipGarbage = true;
/// <summary>Минимум слов в блоке, чтобы его переводить. 2 отключает одиночные слова —
/// на рабочем столе и панелях задач это сплошь названия программ, которые переводить
/// не нужно и вредно («Discord» → «Раздор»).</summary>
public int MinWords = 1;
/// <summary>Дополнительно распознавать кадр моделью другого языка и не переводить фрагменты,
/// которые она читает осмысленнее. Так надписи на языке системы остаются нетронутыми.
/// Стоит примерно вдвое дольше распознавания.</summary>
public bool DetectForeign = true;
/// <summary>Отмечать переведённые места тонкой линией — иначе плашку под цвет фона
/// не отличить от нетронутого текста.</summary>
public bool MarkTranslated = true;
// ------------------------------------------------------------------
public static string Dir
{
get { return Path.GetDirectoryName(Application.ExecutablePath); }
}
public static string Path_ { get { return System.IO.Path.Combine(Dir, "config.json"); } }
public static AppConfig Load(out string error)
{
error = null;
var cfg = new AppConfig();
try
{
if (!File.Exists(Path_))
{
File.WriteAllText(Path_, DefaultJson, new UTF8Encoding(false));
return cfg;
}
string raw = StripComments(File.ReadAllText(Path_));
var ser = new JavaScriptSerializer();
var d = ser.Deserialize<Dictionary<string, object>>(raw);
if (d == null) return cfg;
cfg.HotkeyTranslate = Str(d, "hotkeyTranslate", cfg.HotkeyTranslate);
cfg.HotkeyRegion = Str(d, "hotkeyRegion", cfg.HotkeyRegion);
cfg.SourceLang = Str(d, "sourceLang", cfg.SourceLang);
cfg.TargetLang = Str(d, "targetLang", cfg.TargetLang);
cfg.OcrLanguage = Str(d, "ocrLanguage", cfg.OcrLanguage);
cfg.OcrScale = Math.Max(1.0, Math.Min(4.0, Dbl(d, "ocrScale", cfg.OcrScale)));
cfg.OverlayMode = Str(d, "overlayMode", cfg.OverlayMode).ToLowerInvariant();
cfg.CaptureScope = Str(d, "captureScope", cfg.CaptureScope).ToLowerInvariant();
cfg.TimeoutMs = Int(d, "timeoutMs", cfg.TimeoutMs);
cfg.MaxParallel = Math.Max(1, Math.Min(16, Int(d, "maxParallel", cfg.MaxParallel)));
cfg.Proxy = Str(d, "proxy", cfg.Proxy);
cfg.FontFamily = Str(d, "fontFamily", cfg.FontFamily);
cfg.BoxPadding = Int(d, "boxPadding", cfg.BoxPadding);
cfg.MinFontPx = Math.Max(5, Int(d, "minFontPx", cfg.MinFontPx));
cfg.GrowRatio = Dbl(d, "growRatio", cfg.GrowRatio);
cfg.DimBackground = Bool(d, "dimBackground", cfg.DimBackground);
cfg.Cache = Bool(d, "cache", cfg.Cache);
cfg.MinChars = Int(d, "minChars", cfg.MinChars);
cfg.SkipGarbage = Bool(d, "skipGarbage", cfg.SkipGarbage);
cfg.MinWords = Math.Max(1, Int(d, "minWords", cfg.MinWords));
cfg.DetectForeign = Bool(d, "detectForeign", cfg.DetectForeign);
cfg.MarkTranslated = Bool(d, "markTranslated", cfg.MarkTranslated);
cfg.ShowTrayBalloonOnError = Bool(d, "showTrayBalloonOnError", cfg.ShowTrayBalloonOnError);
var chain = d.ContainsKey("chain") ? d["chain"] as System.Collections.IEnumerable : null;
if (chain != null)
{
var list = new List<string>();
foreach (var o in chain)
{
if (o == null) continue;
var s = o.ToString().Trim().ToLowerInvariant();
if (s.Length > 0) list.Add(s);
}
if (list.Count > 0) cfg.Chain = list;
}
var eng = Sub(d, "engines");
if (eng != null)
{
var libre = Sub(eng, "libretranslate");
if (libre != null)
{
cfg.LibreUrl = Str(libre, "url", cfg.LibreUrl);
cfg.LibreApiKey = Str(libre, "apiKey", cfg.LibreApiKey);
}
var relay = Sub(eng, "relay");
if (relay != null) cfg.RelayUrl = Str(relay, "url", cfg.RelayUrl);
var deepl = Sub(eng, "deepl");
if (deepl != null)
{
cfg.DeeplUrl = Str(deepl, "url", cfg.DeeplUrl);
cfg.DeeplApiKey = Str(deepl, "apiKey", cfg.DeeplApiKey);
}
var ya = Sub(eng, "yandex");
if (ya != null)
{
cfg.YandexFolderId = Str(ya, "folderId", cfg.YandexFolderId);
cfg.YandexApiKey = Str(ya, "apiKey", cfg.YandexApiKey);
}
var llm = Sub(eng, "llm");
if (llm != null)
{
cfg.LlmUrl = Str(llm, "url", cfg.LlmUrl);
cfg.LlmApiKey = Str(llm, "apiKey", cfg.LlmApiKey);
cfg.LlmModel = Str(llm, "model", cfg.LlmModel);
}
}
}
catch (Exception ex)
{
error = "config.json не разобран (" + ex.Message + "), взяты значения по умолчанию";
}
return cfg;
}
static Dictionary<string, object> Sub(Dictionary<string, object> d, string key)
{
object v;
if (d != null && d.TryGetValue(key, out v)) return v as Dictionary<string, object>;
return null;
}
static string Str(Dictionary<string, object> d, string k, string def)
{
object v;
return d.TryGetValue(k, out v) && v != null ? v.ToString() : def;
}
static int Int(Dictionary<string, object> d, string k, int def)
{
object v; int r;
if (d.TryGetValue(k, out v) && v != null &&
int.TryParse(v.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out r)) return r;
return def;
}
static double Dbl(Dictionary<string, object> d, string k, double def)
{
object v; double r;
if (d.TryGetValue(k, out v) && v != null &&
double.TryParse(v.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out r)) return r;
return def;
}
static bool Bool(Dictionary<string, object> d, string k, bool def)
{
object v; bool r;
if (d.TryGetValue(k, out v) && v != null && bool.TryParse(v.ToString(), out r)) return r;
return def;
}
/// <summary>JSON комментариев не знает, но человек в конфиге их пишет — вырезаем,
/// аккуратно не трогая // внутри строковых литералов (например, в url).</summary>
internal static string StripComments(string s)
{
var sb = new StringBuilder(s.Length);
bool inStr = false, esc = false;
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
if (inStr)
{
sb.Append(c);
if (esc) esc = false;
else if (c == '\\') esc = true;
else if (c == '"') inStr = false;
continue;
}
if (c == '"') { inStr = true; sb.Append(c); continue; }
if (c == '/' && i + 1 < s.Length && s[i + 1] == '/')
{
while (i < s.Length && s[i] != '\n') i++;
if (i < s.Length) sb.Append('\n');
continue;
}
if (c == '/' && i + 1 < s.Length && s[i + 1] == '*')
{
i += 2;
while (i + 1 < s.Length && !(s[i] == '*' && s[i + 1] == '/')) i++;
i++;
continue;
}
sb.Append(c);
}
// висячие запятые перед } или ] JavaScriptSerializer не любит
return Regex.Replace(sb.ToString(), @",(\s*[}\]])", "$1");
}
// ---- разбор строки горячей клавиши ----
/// <summary>"Ctrl+Alt+Z" -> модификаторы + virtual-key. false, если строка непонятна.</summary>
public static bool ParseHotkey(string s, out uint mods, out uint vk, out string display)
{
mods = 0; vk = 0; display = s;
if (string.IsNullOrWhiteSpace(s)) return false;
var parts = s.Split(new[] { '+' }, StringSplitOptions.RemoveEmptyEntries);
string keyPart = null;
foreach (var raw in parts)
{
var p = raw.Trim().ToLowerInvariant();
switch (p)
{
case "ctrl": case "control": mods |= Native.MOD_CONTROL; break;
case "alt": mods |= Native.MOD_ALT; break;
case "shift": mods |= Native.MOD_SHIFT; break;
case "win": case "meta": mods |= Native.MOD_WIN; break;
default: keyPart = raw.Trim(); break;
}
}
if (keyPart == null) return false;
Keys k;
if (!Enum.TryParse(keyPart, true, out k))
{
if (keyPart.Length == 1)
{
char c = char.ToUpperInvariant(keyPart[0]);
if (c >= 'A' && c <= 'Z') k = (Keys)c;
else if (c >= '0' && c <= '9') k = (Keys)c;
else return false;
}
else return false;
}
vk = (uint)k;
display = s;
return vk != 0;
}
public const string DefaultJson = @"{
// ---- горячие клавиши ----
""hotkeyTranslate"": ""Ctrl+Alt+Z"", // перевести монитор под курсором
""hotkeyRegion"": ""Ctrl+Alt+X"", // перевести выделенную рамкой область
// ---- языки ----
""sourceLang"": ""en"", // язык оригинала (для переводчика)
""targetLang"": ""ru"", // на какой переводим
""ocrLanguage"": ""en-US"", // модель OCR Windows: en-US | ru | ...
""ocrScale"": 2.0, // увеличение кадра перед распознаванием (мелкий шрифт читается точнее)
// ---- поведение оверлея ----
""overlayMode"": ""frozen"", // frozen = замороженный кадр (стабильно) | live = плашки поверх живого экрана
""captureScope"": ""monitor"", // monitor = экран под курсором | all = все мониторы
""dimBackground"": false, // слегка притемнить непереведённое
""growRatio"": 0.35, // насколько плашке можно вылезти за оригинал (русский длиннее)
""fontFamily"": ""Segoe UI"",
""minFontPx"": 8,
""boxPadding"": 2,
""minChars"": 2,
""minWords"": 1, // 2 = не переводить одиночные слова (рабочий стол, названия программ)
""skipGarbage"": true, // не переводить кашу: надписи на чужом для модели OCR языке
""detectForeign"": true, // проверять кадр второй моделью OCR и не трогать чужой язык
""markTranslated"": true, // подчёркивать переведённые места тонкой линией
// ---- перевод ----
// Движки пробуются по порядку: первый ответивший выигрывает, упавший временно исключается.
// Доступны: google | lingva | mymemory | relay | libretranslate | deepl | yandex | llm
""chain"": [""google"", ""lingva"", ""mymemory""],
""timeoutMs"": 7000,
""maxParallel"": 6,
""cache"": true,
""proxy"": """", // например ""http://192.168.5.42:2080""
""engines"": {
""relay"": { ""url"": """" }, // свой прокси-эндпоинт, см. README
""libretranslate"": { ""url"": """", ""apiKey"": """" },
""deepl"": { ""url"": ""https://api-free.deepl.com/v2/translate"", ""apiKey"": """" },
""yandex"": { ""folderId"": """", ""apiKey"": """" },
""llm"": { ""url"": ""https://api.anthropic.com/v1/messages"",
""apiKey"": """", ""model"": ""claude-haiku-4-5-20251001"" }
}
}
";
}
}
+623
View File
@@ -0,0 +1,623 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
namespace ScreenTranslator
{
/// <summary>Связный кусок текста на экране (абзац, пункт меню, реплика) — единица перевода
/// и одна плашка в оверлее.</summary>
internal class TextBlock
{
public Rectangle Rect;
public string Source = "";
public string Translated;
public bool Failed;
public double LineHeight = 14;
public int LineCount = 1;
public Color Bg = Color.White;
public Color Fg = Color.Black;
/// <summary>Куда реально нарисована плашка — нужно для попадания мышью.</summary>
public Rectangle DrawnRect;
/// <summary>Предел разрастания плашки: дальше начинается соседний текст.
/// Пустой — ограничений нет, кроме краёв кадра.</summary>
public Rectangle GrowLimit;
}
internal static class Blocks
{
/// <summary>Склеить OCR-строки в абзацы. Критерии соседства: строки идут подряд по вертикали,
/// заметно перекрываются по горизонтали и имеют сопоставимый кегль. Так пункты меню в разных
/// углах экрана не слипаются в один блок, а абзац текста не рассыпается на строки.</summary>
public static List<TextBlock> Group(List<OcrLineBox> lines, int minChars, bool skipGarbage = true,
FrameSampler sampler = null, int minWords = 1)
{
if (sampler != null)
foreach (var l in lines)
{
Color bg, fg;
sampler.Sample(l.Rect, out bg, out fg);
l.Bg = bg; l.BgKnown = true;
}
var src = MergeHorizontally(lines, sampler)
.Where(l => IsMeaningful(l.Text, minChars))
.OrderBy(l => l.Rect.Top).ThenBy(l => l.Rect.Left)
.ToList();
var open = new List<List<OcrLineBox>>();
var done = new List<List<OcrLineBox>>();
foreach (var line in src)
{
double h = LineH(line);
List<OcrLineBox> best = null;
double bestGap = double.MaxValue;
for (int i = open.Count - 1; i >= 0; i--)
{
var blk = open[i];
var last = blk[blk.Count - 1];
double lh = LineH(last);
double gap = line.Rect.Top - last.Rect.Bottom;
// ушли ниже — блок больше не пополнится
if (gap > lh * 2.0 && gap > 40) { done.Add(blk); open.RemoveAt(i); continue; }
if (gap < -lh * 0.5) continue; // строки наложены — разные слои UI
if (Math.Max(lh, h) / Math.Max(1.0, Math.Min(lh, h)) > 1.5) continue; // разный кегль
// Порог по межстрочному интервалу. Строки абзаца стоят с интерлиньяжем около
// одной высоты глифов, поэтому 0.75 рвал абзацы на куски — а рваные строки и
// переводятся заметно хуже. Ровная левая кромка — верный признак абзаца,
// там допускаем разрядку побольше; пункты меню с их шагом в 1.5 высоты
// при этом остаются раздельными.
// Разный фон — значит это разные элементы интерфейса, а не строки одного
// абзаца. В плотных окнах (игры, панели настроек) чекбокс и выпадающий
// список стоят с тем же интервалом, что строки текста, и по одной геометрии
// их не развести: слипаясь, они дают и мусорный перевод, и плашку на пол-окна.
if (line.BgKnown && last.BgKnown && ColorDistance(line.Bg, last.Bg) > 42) continue;
// Рамка или край панели между строками — тоже признак разных элементов.
// Фон при этом может почти совпадать (в окне игры оба тёмные, разница
// всего 13 из 255), а вот граница поля даёт скачок яркости в разы.
if (sampler != null && line.Rect.Top - last.Rect.Bottom >= 2)
{
int x0 = Math.Max(line.Rect.Left, last.Rect.Left);
int x1 = Math.Min(line.Rect.Right, last.Rect.Right);
if (x1 - x0 >= 8 &&
sampler.BandContrast(x0, x1, last.Rect.Bottom, line.Rect.Top) > 35) continue;
}
// Признак «строка не дотянула до правого края» (ragged-right).
//
// В абзаце короткой имеет право быть только последняя строка — остальные
// заполнены до края, иначе слово перенеслось бы. Значит если предыдущая
// строка короткая, абзац на ней и кончился, а то, что идёт ниже, —
// самостоятельная надпись. Именно это разводит «Has Skill Up» и «Trade Goods»,
// которые стоят с абзацным интервалом и одинаковым отступом.
int widest = line.Rect.Width;
foreach (var bl in blk) if (bl.Rect.Width > widest) widest = bl.Rect.Width;
// Порог зависит от того, состоялся ли уже абзац. Для одиночной строки он строг:
// короткая надпись, под которой стоит другая, — это два разных элемента
// («Has Skill Up» и «Trade Goods»). А внутри абзаца из нескольких строк рваный
// правый край нормален, и строгий порог отрывал от него последнюю строку.
double fill = blk.Count >= 2 ? 0.6 : 0.65;
if (last.Rect.Width < widest * fill) continue;
bool aligned = Math.Abs(line.Rect.Left - last.Rect.Left) <= Math.Max(3.0, h * 0.25);
double thr = aligned ? 1.2 : 1.05;
if (gap > Math.Max(lh, h) * thr) continue;
// На разрядке около предельной одного выравнивания мало: так подпись-заголовок
// над пунктом меню слипалась со следующим пунктом. Требуем ещё и одинаковый кегль —
// внутри абзаца он совпадает, а заголовок от текста почти всегда отличается.
if (gap > Math.Max(lh, h) * 0.9 &&
Math.Max(lh, h) / Math.Max(1.0, Math.Min(lh, h)) > 1.15) continue;
if (HOverlap(line.Rect, last.Rect) < 0.35 &&
Math.Abs(line.Rect.Left - last.Rect.Left) > h * 0.8) continue;
if (gap < bestGap) { bestGap = gap; best = blk; }
}
if (best != null) best.Add(line);
else open.Add(new List<OcrLineBox> { line });
}
done.AddRange(open);
var result = new List<TextBlock>(done.Count);
foreach (var blk in done)
{
var rect = blk[0].Rect;
foreach (var l in blk) rect = Rectangle.Union(rect, l.Rect);
var heights = blk.Select(LineH).OrderBy(x => x).ToList();
result.Add(new TextBlock
{
Rect = rect,
Source = Join(blk),
LineHeight = heights[heights.Count / 2],
LineCount = blk.Count
});
}
return result.Where(b => IsMeaningful(b.Source, minChars) &&
WordCount(b.Source) >= minWords &&
(!skipGarbage || LooksLikeLanguage(b.Source))).ToList();
}
static int WordCount(string s)
{
return s.Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
.Count(w => w.Any(char.IsLetter));
}
static int ColorDistance(Color a, Color b)
{
return (Math.Abs(a.R - b.R) + Math.Abs(a.G - b.G) + Math.Abs(a.B - b.B)) / 3;
}
/// <summary>Собрать обратно строку, которую распознавание разрезало по широким пробелам.
///
/// В играх с моноширинным или пиксельным шрифтом промежутки между словами широкие, и OCR
/// отдаёт «SPACE/CLICK», «HERE», «TO CONTINUE» тремя отдельными строками. Переведённые
/// порознь, они превращаются в бессмыслицу, поэтому фрагменты на одной базовой линии
/// нужно склеить до того, как строки начнут собираться в абзацы.
///
/// Ограничения намеренно жёсткие: соседние элементы интерфейса тоже стоят в одну строку
/// («Search» — «Has Skill Up» — «Have Materials»), и склеить их было бы хуже, чем
/// оставить фразу разрезанной.</summary>
static List<OcrLineBox> MergeHorizontally(List<OcrLineBox> lines, FrameSampler sampler)
{
var byX = lines.OrderBy(l => l.Rect.Left).ToList();
var used = new bool[byX.Count];
var result = new List<OcrLineBox>();
for (int i = 0; i < byX.Count; i++)
{
if (used[i]) continue;
used[i] = true;
var cur = new OcrLineBox
{
Text = byX[i].Text,
Rect = byX[i].Rect,
AvgWordHeight = byX[i].AvgWordHeight,
Bg = byX[i].Bg,
BgKnown = byX[i].BgKnown
};
bool grew = true;
while (grew)
{
grew = false;
for (int j = 0; j < byX.Count; j++)
{
if (used[j] || !CanMergeH(cur, byX[j], sampler)) continue;
// Совсем узкий зазор — это разорванное слово, а не два соседних:
// «Li gh thouse» нужно собрать обратно в «Lighthouse», иначе не переведётся.
int gap = byX[j].Rect.Left - cur.Rect.Right;
string glue = gap < LineH(cur) * 0.32 ? "" : " ";
cur.Text = cur.Text.TrimEnd() + glue + byX[j].Text.TrimStart();
cur.Rect = Rectangle.Union(cur.Rect, byX[j].Rect);
cur.AvgWordHeight = Math.Max(cur.AvgWordHeight, byX[j].AvgWordHeight);
used[j] = true;
grew = true;
break;
}
}
result.Add(cur);
}
return result;
}
static bool CanMergeH(OcrLineBox a, OcrLineBox b, FrameSampler sampler)
{
int gap = b.Rect.Left - a.Rect.Right;
if (gap < -2) return false; // перекрываются — разные слои
double ha = LineH(a), hb = LineH(b);
if (Math.Max(ha, hb) / Math.Max(1.0, Math.Min(ha, hb)) > 1.4) return false;
// Пробел шире двух высот строки — это уже не пробел, а колонка или другой элемент.
if (gap > Math.Min(ha, hb) * 2.2) return false;
int overlap = Math.Min(a.Rect.Bottom, b.Rect.Bottom) - Math.Max(a.Rect.Top, b.Rect.Top);
if (overlap <= 0) return false;
if ((double)overlap / Math.Min(a.Rect.Height, b.Rect.Height) < 0.6) return false;
if (a.BgKnown && b.BgKnown && ColorDistance(a.Bg, b.Bg) > 42) return false;
// В промежутке стоит рамка, чекбокс или край колонки — значит фрагменты чужие
if (sampler != null && gap >= 3)
{
int top = Math.Min(a.Rect.Top, b.Rect.Top);
int bottom = Math.Max(a.Rect.Bottom, b.Rect.Bottom);
if (sampler.BandContrastVertical(a.Rect.Right + 1, b.Rect.Left - 1, top, bottom) > 45)
return false;
}
return true;
}
static double LineH(OcrLineBox l)
{
return l.AvgWordHeight > 1 ? l.AvgWordHeight : Math.Max(1, l.Rect.Height);
}
static double HOverlap(Rectangle a, Rectangle b)
{
int o = Math.Min(a.Right, b.Right) - Math.Max(a.Left, b.Left);
if (o <= 0) return 0;
return (double)o / Math.Max(1, Math.Min(a.Width, b.Width));
}
static string Join(List<OcrLineBox> blk)
{
var sb = new StringBuilder();
for (int i = 0; i < blk.Count; i++)
{
string t = blk[i].Text.Trim();
if (t.Length == 0) continue;
if (sb.Length > 0)
{
// перенос по дефису склеиваем обратно в слово
if (sb[sb.Length - 1] == '-' && t.Length > 0 && char.IsLower(t[0])) sb.Length -= 1;
else sb.Append(' ');
}
sb.Append(t);
}
return sb.ToString();
}
static readonly char[] Vowels = "aeiouyAEIOUYаеёиоуыэюяАЕЁИОУЫЭЮЯ".ToCharArray();
/// <summary>Насколько текст похож на осмысленный: доля слов без следов покорёженного
/// распознавания. 1.0 — чистый текст, около 0.5 и ниже — каша.</summary>
internal static double LanguageScore(string s)
{
var words = s.Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
.Where(w => w.Length >= 2).ToList();
if (words.Count == 0) return 0;
int good = 0;
foreach (var w in words)
if (!LooksMangled(w) && (w.Length < 3 || w.IndexOfAny(Vowels) >= 0)) good++;
return (double)good / words.Count;
}
/// <summary>Выбросить блоки, написанные не на том языке, под который выбрана модель OCR.
///
/// Статистика по одному тексту тут бессильна: «npo cauyyn qnmy» из букв, которые модель
/// подобрала к кириллице, по форме неотличимо от английского. Зато если тот же участок
/// экрана прочитать второй моделью и он окажется заметно осмысленнее — вопрос закрыт,
/// это текст на её языке, и переводить его не нужно.</summary>
public static void DropForeign(List<TextBlock> blocks, List<OcrLineBox> otherLines,
bool sourceIsCyrillic, double margin = 0.15)
{
if (blocks == null || otherLines == null || otherLines.Count == 0) return;
blocks.RemoveAll(b =>
{
var sb = new StringBuilder();
foreach (var l in otherLines.OrderBy(l => l.Rect.Top))
{
var inter = Rectangle.Intersect(l.Rect, b.Rect);
if (inter.IsEmpty) continue;
long areaLine = (long)l.Rect.Width * l.Rect.Height;
if (areaLine <= 0) continue;
// Модели режут строки по-разному, поэтому требовать почти полного совпадения
// нельзя — иначе сравнение не состоится именно там, где оно нужнее всего.
if ((double)inter.Width * inter.Height / areaLine < 0.45) continue;
if (sb.Length > 0) sb.Append(' ');
sb.Append(l.Text.Trim());
}
if (sb.Length < 3) return false;
string other = sb.ToString();
// Решающий признак — письменность, а не осмысленность. Английская модель физически
// не может выдать кириллицу: «Профиль» она отдаёт как «npocbwlb», что по форме
// неотличимо от настоящего слова. Зато русская модель читает латиницу как латиницу,
// поэтому кириллица в её ответе означает, что надпись изначально русская.
int cyr = 0, lat = 0;
foreach (char c in other)
{
if (c >= 0x0400 && c <= 0x04FF) cyr++;
else if (c < 128 && char.IsLetter(c)) lat++;
}
if (cyr + lat >= 3)
{
bool otherIsCyrillic = cyr > lat;
if (otherIsCyrillic != sourceIsCyrillic)
{
// Вторая модель говорит «чужой алфавит», но она же принимает латинские
// C, a, e за кириллические и так топит короткие английские кнопки.
// Узнаваемое английское слово в исходнике — весомее её мнения.
if (!sourceIsCyrillic && EnglishWords.ContainsKnown(b.Source)) return false;
return true;
}
}
return LanguageScore(other) > LanguageScore(b.Source) + margin;
});
}
/// <summary>Пишется ли язык кириллицей — нужно, чтобы понимать, какой алфавит ожидать
/// от текста на языке оригинала.</summary>
/// <summary>Посчитать, докуда каждой плашке позволено разрастаться, чтобы не залезть
/// на соседний текст.
///
/// Перевод почти всегда длиннее оригинала, и плашке приходится расти. В разреженном
/// интерфейсе это незаметно, но в плотном (окна игры, таблицы) разросшаяся плашка
/// перекрывает соседние подписи — читаемым становится один фрагмент ценой двух соседних.</summary>
public static void ComputeGrowLimits(List<TextBlock> blocks, List<OcrLineBox> allLines, Size canvas)
{
// Препятствие — любой распознанный текст, а не только то, что мы переводим.
// Отфильтрованные строки (числа, надписи на другом языке) с экрана никуда не делись:
// заголовок, разросшийся на вторую строку, ложился ровно на них.
var obstacles = new List<Rectangle>();
foreach (var b in blocks) obstacles.Add(b.Rect);
if (allLines != null)
foreach (var l in allLines) obstacles.Add(l.Rect);
foreach (var b in blocks)
{
int right = canvas.Width, bottom = canvas.Height, top = 0;
foreach (var o in obstacles)
{
if (b.Rect.Contains(o) || o == b.Rect) continue; // часть самого блока
bool sameRows = o.Top < b.Rect.Bottom && o.Bottom > b.Rect.Top;
if (sameRows && o.Left >= b.Rect.Right)
right = Math.Min(right, o.Left - 2);
bool sameCols = o.Left < b.Rect.Right && o.Right > b.Rect.Left;
if (sameCols && o.Top >= b.Rect.Bottom)
bottom = Math.Min(bottom, o.Top - 1);
if (sameCols && o.Bottom <= b.Rect.Top)
top = Math.Max(top, o.Bottom + 1);
}
b.GrowLimit = Rectangle.FromLTRB(
b.Rect.Left, Math.Min(top, b.Rect.Top),
Math.Max(b.Rect.Right, right), Math.Max(b.Rect.Bottom, bottom));
}
}
public static bool IsCyrillicLanguage(string langCode)
{
if (string.IsNullOrEmpty(langCode)) return false;
string two = langCode.Split('-')[0].ToLowerInvariant();
switch (two)
{
case "ru": case "uk": case "be": case "bg": case "sr":
case "mk": case "kk": case "ky": case "mn": case "tg":
return true;
default: return false;
}
}
/// <summary>Похоже ли это на настоящий текст, а не на кашу распознавания.
///
/// На экране почти всегда есть надписи не на том языке, для которого выбрана модель OCR:
/// английская модель, встретив кириллицу, выдаёт нечто вроде «gT0 ffileals Ha MauiHHe».
/// Такую кашу бессмысленно переводить — она только засоряет экран плашками и жжёт лимиты
/// переводчика. Отличаем по статистике: в настоящем тексте почти в каждом слове есть
/// гласная, мало цифр внутри слов и высокая доля букв.
///
/// Короткие подписи (OK, PVE, 45%) пропускаем без проверки — статистика на них не работает.</summary>
internal static bool LooksLikeLanguage(string s)
{
var words = s.Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
.Where(w => w.Length >= 2).ToList();
if (words.Count == 0) return false;
// Сигнатура каши хорошо видна даже в одном слове, поэтому короткие подписи
// тоже проверяем — именно на них («C03AaTb», «Pa3Mep», «npocM0TpeTb») фильтр
// по длине раньше и прокалывался, залепляя плашками весь русский интерфейс.
int suspicious = 0, fileish = 0;
foreach (var w in words)
{
if (LooksLikeFileName(w)) { fileish++; continue; }
if (LooksMangled(w)) suspicious++;
}
if (fileish == words.Count) return false; // имена файлов и пути переводить нечего
int scored = words.Count - fileish;
if (scored > 0 && (double)suspicious / scored >= 0.5) return false;
// «18 KS», «41 KS» — обрывки размеров и счётчиков: цифры есть, а осмысленного
// слова нет. Переводить нечего, а плашка поверх мешает.
bool hasDigit = s.IndexOfAny("0123456789".ToCharArray()) >= 0;
if (hasDigit && !words.Any(w => w.Count(char.IsLetter) >= 4)) return false;
// Частоты буквенных пар. Ловит именно ту кашу, что неотличима по форме от слова:
// «npocbwlb» (-3,5) и «Bxoaguvqe» (-4,5) против настоящих надписей, которые на этих
// же замерах не опускались ниже -2,9. Порог взят с зазором, чтобы не задеть их;
// кашу с цифрами и скачками регистра отсеивают правила выше.
double bigram = EnglishBigrams.TextScore(s);
if (!double.IsNaN(bigram) && bigram < -3.0) return false;
if (s.Length < 12) return true;
int letters = 0, nonSpace = 0;
foreach (char c in s)
{
if (char.IsWhiteSpace(c)) continue;
nonSpace++;
if (char.IsLetter(c)) letters++;
}
if (nonSpace == 0) return false;
if ((double)letters / nonSpace < 0.62) return false;
var longWords = words.Where(w => w.Length >= 3).ToList();
if (longWords.Count < 2) return true;
int withVowel = 0;
foreach (var w in longWords) if (w.IndexOfAny(Vowels) >= 0) withVowel++;
return (double)withVowel / longWords.Count >= 0.72;
}
/// <summary>Признаки слова, покорёженного распознаванием чужого алфавита: цифра, зажатая
/// между букв («Pa3Mep»), и заглавная сразу после строчной («COPTMPOBaTb»).
///
/// Оба признака встречаются и в нормальном тексте — CamelCase, «IPv6», «UTF-8», — поэтому
/// решение принимается не по слову, а по их доле в блоке.</summary>
const string OkPunctuation = ".,:;!?'\"()[]-–—/\\%&#@+*=_<>|~$^°«»…№";
static bool LooksMangled(string w)
{
for (int i = 1; i < w.Length - 1; i++)
if (char.IsDigit(w[i]) && char.IsLetter(w[i - 1]) && char.IsLetter(w[i + 1])) return true;
// Символы, которых в обычной надписи не бывает: так распознавание подменяет
// непонятые ему глифы («Pa60t-u•1i», «KOMnb}0Tep»).
foreach (char c in w)
if (!char.IsLetterOrDigit(c) && OkPunctuation.IndexOf(c) < 0) return true;
if (w.Length > 3 && w != w.ToUpperInvariant())
for (int i = 1; i < w.Length; i++)
if (char.IsUpper(w[i]) && char.IsLower(w[i - 1])) return true;
return false;
}
/// <summary>Имя файла, путь или адрес — переводить их не нужно, «cache.json» не должен
/// превращаться в «кэш.json».</summary>
static bool LooksLikeFileName(string w)
{
if (w.IndexOf('/') >= 0 || w.IndexOf('\\') >= 0) return true;
int dot = w.LastIndexOf('.');
if (dot <= 0 || dot == w.Length - 1) return false;
string ext = w.Substring(dot + 1).Trim('"', '\'', ',', ')', ':', ';');
if (ext.Length < 1 || ext.Length > 5) return false;
foreach (char c in ext) if (!char.IsLetterOrDigit(c)) return false;
// «конец предложения.» — не имя файла
return !char.IsWhiteSpace(w[dot - 1]) && ext.ToLowerInvariant() != ext.ToUpperInvariant();
}
/// <summary>Отсев мусора OCR: рамки, иконки, одиночные символы, голые числа —
/// переводить нечего, а плашка поверх них только мешает.</summary>
static bool IsMeaningful(string s, int minChars)
{
if (string.IsNullOrWhiteSpace(s)) return false;
int letters = 0;
foreach (char c in s) if (char.IsLetter(c)) letters++;
return letters >= Math.Max(1, minChars);
}
}
/// <summary>Отвечает на вопрос «какого цвета фон и текст в этом прямоугольнике» — чтобы плашка
/// совпала с окном под ней. Работает по копии пикселей из CapturedFrame, поэтому безопасен
/// в фоновом потоке параллельно с отрисовкой оверлея.</summary>
internal sealed class FrameSampler
{
readonly byte[] _px;
readonly int _stride, _w, _h;
public FrameSampler(CapturedFrame frame)
{
_w = frame.Width; _h = frame.Height;
_stride = _w * 4;
_px = frame.Pixels;
}
/// <summary>Разброс средней яркости строк пикселей в горизонтальной полосе.
///
/// Отвечает на вопрос «есть ли между двумя строками текста визуальная граница»: рамка поля
/// или край панели тянутся во всю ширину и дают резкий скачок, а промежуток внутри абзаца
/// однороден — выносные элементы букв среднюю по строке почти не двигают.</summary>
/// <summary>То же, но полоса вертикальная: есть ли разделитель СЛЕВА и СПРАВА между
/// двумя фрагментами на одной строке — рамка чекбокса, край колонки, граница поля.</summary>
public int BandContrastVertical(int x0, int x1, int y0, int y1)
{
x0 = Math.Max(0, x0); x1 = Math.Min(_w, x1);
y0 = Math.Max(0, y0); y1 = Math.Min(_h, y1);
if (x1 - x0 < 2 || y1 - y0 < 4) return 0;
int min = int.MaxValue, max = int.MinValue;
int step = Math.Max(1, (y1 - y0) / 40);
for (int x = x0; x < x1; x++)
{
int sum = 0, n = 0;
for (int y = y0; y < y1; y += step) { sum += Lum(At(x, y)); n++; }
if (n == 0) continue;
int avg = sum / n;
if (avg < min) min = avg;
if (avg > max) max = avg;
}
return max > min ? max - min : 0;
}
public int BandContrast(int x0, int x1, int y0, int y1)
{
x0 = Math.Max(0, x0); x1 = Math.Min(_w, x1);
y0 = Math.Max(0, y0); y1 = Math.Min(_h, y1);
if (x1 - x0 < 8 || y1 - y0 < 2) return 0;
int min = int.MaxValue, max = int.MinValue;
int step = Math.Max(1, (x1 - x0) / 160);
for (int y = y0; y < y1; y++)
{
int sum = 0, n = 0;
for (int x = x0; x < x1; x += step) { sum += Lum(At(x, y)); n++; }
if (n == 0) continue;
int avg = sum / n;
if (avg < min) min = avg;
if (avg > max) max = avg;
}
return max > min ? max - min : 0;
}
Color At(int x, int y)
{
int i = y * _stride + x * 4;
return Color.FromArgb(_px[i + 2], _px[i + 1], _px[i]);
}
static int Lum(Color c) { return (c.R * 299 + c.G * 587 + c.B * 114) / 1000; }
/// <summary>Доминирующий цвет = фон; самый частый из «далёких по яркости» = цвет текста.
/// Если контраста нет (градиент, картинка), возвращаем чёрное на белом — читаемо всегда.</summary>
public void Sample(Rectangle r, out Color bg, out Color fg)
{
r.Inflate(2, 1);
r.Intersect(new Rectangle(0, 0, _w, _h));
if (r.Width < 2 || r.Height < 2) { bg = Color.White; fg = Color.Black; return; }
var hist = new Dictionary<int, int>(256);
int stepX = Math.Max(1, r.Width / 120), stepY = Math.Max(1, r.Height / 40);
for (int y = r.Top; y < r.Bottom; y += stepY)
for (int x = r.Left; x < r.Right; x += stepX)
{
var c = At(x, y);
int key = (c.R >> 3 << 10) | (c.G >> 3 << 5) | (c.B >> 3);
int n; hist.TryGetValue(key, out n); hist[key] = n + 1;
}
if (hist.Count == 0) { bg = Color.White; fg = Color.Black; return; }
int bgKey = 0, bgN = -1;
foreach (var kv in hist) if (kv.Value > bgN) { bgN = kv.Value; bgKey = kv.Key; }
bg = FromKey(bgKey);
int bgL = Lum(bg);
int fgKey = -1, fgN = -1;
foreach (var kv in hist)
{
var c = FromKey(kv.Key);
if (Math.Abs(Lum(c) - bgL) < 70) continue;
if (kv.Value > fgN) { fgN = kv.Value; fgKey = kv.Key; }
}
fg = fgKey >= 0 ? FromKey(fgKey) : (bgL > 128 ? Color.FromArgb(20, 20, 20) : Color.FromArgb(240, 240, 240));
// «почти совпало» — принудительно разводим, иначе перевод сольётся с плашкой
if (Math.Abs(Lum(fg) - bgL) < 60)
fg = bgL > 128 ? Color.FromArgb(20, 20, 20) : Color.FromArgb(240, 240, 240);
}
static Color FromKey(int k)
{
int r = (k >> 10) & 31, g = (k >> 5) & 31, b = k & 31;
return Color.FromArgb(r << 3 | r >> 2, g << 3 | g >> 2, b << 3 | b >> 2);
}
}
}
+68
View File
@@ -0,0 +1,68 @@
using System;
namespace ScreenTranslator
{
/// <summary>Модель правдоподобия английского текста по частотам буквенных пар.
///
/// Решает задачу, с которой не справляются ни словарь, ни алфавит: английская модель OCR,
/// прочитав русскую надпись, выдаёт правдоподобное на вид латинское слово («Профиль» →
/// «npocbwlb»). Но пары букв выдают подделку сразу — сочетаний «wl», «cb», «bw» в английском
/// практически не бывает, тогда как «th», «he», «in» составляют его основу.
///
/// Таблица — 676 квантованных log10-вероятностей, по символу на пару, около 700 байт.
/// Сгенерирована tools/make-bigrams.ps1 из частот корпуса английских текстов
/// (practicalcryptography.com). Правится только генератором, не руками.</summary>
internal static class EnglishBigrams
{
const double Lo = -9, Step = 12;
const string Table =
"enqpakngpbkuqxan\\vuwkmi`oan`\\XrURYj\\PlZWmWGjd^mWXDkIr\\h]r[YrmRmk\\Zs]YkdojV[GcTqlijtjgjra_hihph\\koqlckPgVvnsvrpmmqdgsqwppdywuiopkm^" +
"mbeanl`eo\\[hd_rcRmfph[bNaPocdbpddnmYYgcgmcTmimiZeLbPubdaza^dtZZcddrbVigmhZdIdQngrppkn``Zgqozri\\ouu`nbbVfeNONdLKN]MLKOLhQ=URNhJM=GD" +
"h^^\\o_Z`lUWa_fe]L_hd_UaG_KsggnthbdsZcsgaqgUdnljcfMpSrjc_ta\\aoXW_i_pmN`ihiXbLfNsiqvtkuiqdhiikrhZgrwifjUj[llmmgtjik`iprwnoWvprtmp`f[" +
"o]^\\q^ZhkRUnbXokNpgiiS_H^JUPKJHJCKU@BJKEJHCHNMjFQ=F8tilnxijht_jkmmtiYlrrkhiQnWtkoitlfqsagkkjsoairwnbnSgUsikgviezv_`kievhYqrrn`nPm\\" +
"jhkikak]iV_njq`kOqqqTZ]X_YjPTVtPOPnKKSQQgT@VZTUORD[Cq^^^p]XoqVX`_in\\McfdYT`F_SbV`T`WRYbKLTXQZfGVYeXPWQVBmhhflgcgj]]fgdngVfml`^iM]U" +
"bSRQePOW`IOURO^PHQVSWLTCW[";
/// <summary>Средний log10 вероятности пар букв в слове. Около -2,5 у обычного английского
/// слова, ниже -4 у случайного набора букв. NaN, если букв слишком мало для оценки.</summary>
public static double WordScore(string word)
{
if (string.IsNullOrEmpty(word)) return double.NaN;
double sum = 0; int n = 0;
for (int i = 0; i + 1 < word.Length; i++)
{
int a = Index(word[i]), b = Index(word[i + 1]);
if (a < 0 || b < 0) continue;
sum += Lo + (Table[a * 26 + b] - 34) / Step;
n++;
}
return n == 0 ? double.NaN : sum / n;
}
static int Index(char c)
{
if (c >= 'a' && c <= 'z') return c - 'a';
if (c >= 'A' && c <= 'Z') return c - 'A';
return -1;
}
/// <summary>Оценка текста целиком: усреднение по словам длиной от четырёх букв —
/// на более коротких пары букв ничего не доказывают. NaN, если оценивать нечего.</summary>
public static double TextScore(string text)
{
if (string.IsNullOrEmpty(text)) return double.NaN;
double sum = 0; int n = 0;
foreach (var w in text.Split(new[] { ' ', '\t', '\n', '\r', '.', ',', ':', ';', '!', '?',
'(', ')', '[', ']', '"', '\'', '/', '\\', '-' },
StringSplitOptions.RemoveEmptyEntries))
{
if (w.Length < 4) continue;
double s = WordScore(w);
if (double.IsNaN(s)) continue;
sum += s; n++;
}
return n == 0 ? double.NaN : sum / n;
}
}
}
+87
View File
@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
namespace ScreenTranslator
{
/// <summary>Короткий список ходовых английских слов.
///
/// Нужен для одного узкого, но принципиального решения. Русская модель OCR читает латинское
/// «Cancel» как кириллическое «Сапсеl», а английская читает русское «Профиль» как «npocbwlb» —
/// по алфавиту эти два случая неразличимы, и любой порог отправляет в отсев либо настоящие
/// английские кнопки, либо ничего. Словарь разрывает ничью: если основная модель выдала
/// осмысленное английское слово, значит надпись действительно английская.
///
/// Полнота тут не нужна и даже вредна — список работает как подтверждение, а не как проверка:
/// незнакомое слово («Leatherworking») ничего не решает само по себе, вопрос тогда
/// достаётся другим признакам.</summary>
internal static class EnglishWords
{
// Частотная лексика плюс то, из чего состоят интерфейсы: пункты меню, кнопки, статусы.
const string List =
"the be to of and in that have it for not on with he as you do at this but his by from " +
"they we say her she or an will my one all would there their what so up out if about who " +
"get which go me when make can like time no just him know take people into year your good " +
"some could them see other than then now look only come its over think also back after use " +
"two how our work first well way even new want because any these give day most us is are was " +
"were been has had did does more very much many such own same each few own here where why " +
"while before between under again both during through against off down out above below " +
"file edit view tools help window options settings preferences account profile user users " +
"name type size date time modified created folder path search find replace filter sort " +
"open close save load new delete remove add insert copy paste cut undo redo cancel ok yes " +
"no apply confirm submit send receive upload download import export print share link " +
"start stop pause resume restart reset refresh reload update upgrade install uninstall " +
"next previous back forward finish done continue skip retry ignore select choose enable " +
"disable show hide expand collapse minimize maximize restore exit quit login logout sign " +
"password username email address phone mobile home page main menu list item items details " +
"status error warning failed success complete completed pending loading please wait " +
"connection connected disconnected network server client host port proxy offline online " +
"device devices display screen sound audio video image picture photo music media player " +
"storage memory disk drive space free used total available required version build release " +
"language region keyboard mouse theme dark light color font text style format layout " +
"notification notifications message messages chat comment comments reply forward inbox " +
"history recent favorites bookmarks library collection playlist channel channels group " +
"groups friend friends member members public private security privacy permission access " +
"enabled disabled default custom advanced general basic system application program app " +
"window windows button field label value data info information description note notes " +
"warning caution danger critical unknown none auto manual always never once daily weekly " +
"level experience health mana damage defense attack skill skills quest quests inventory " +
"equip equipped weapon armor shield potion gold silver copper trade craft crafting cook " +
"cooking mining fishing gathering materials material recipe recipes learn learned train " +
"guild party raid dungeon boss enemy player character class race spell ability cooldown " +
"consumable equippable subclass slot slots reagent bag bank vendor merchant repair " +
"not equippable have has goods first aid all";
static readonly HashSet<string> Set = Build();
static HashSet<string> Build()
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var w in List.Split(' '))
if (w.Length > 0) set.Add(w);
return set;
}
static readonly char[] Trim = " \t\r\n.,:;!?'\"()[]{}<>|/\\-–—*&#@+=_%".ToCharArray();
/// <summary>Есть ли в тексте хотя бы одно узнаваемое английское слово.
/// Простые окончания (-s, -es, -ed, -ing) отсекаются, чтобы не раздувать список.</summary>
public static bool ContainsKnown(string text)
{
if (string.IsNullOrEmpty(text)) return false;
foreach (var raw in text.Split(Trim, StringSplitOptions.RemoveEmptyEntries))
{
var w = raw.ToLowerInvariant();
if (w.Length < 2) continue;
if (Set.Contains(w)) return true;
if (w.Length > 3 && w.EndsWith("s") && Set.Contains(w.Substring(0, w.Length - 1))) return true;
if (w.Length > 4 && w.EndsWith("es") && Set.Contains(w.Substring(0, w.Length - 2))) return true;
if (w.Length > 4 && w.EndsWith("ed") && Set.Contains(w.Substring(0, w.Length - 2))) return true;
if (w.Length > 5 && w.EndsWith("ing") && Set.Contains(w.Substring(0, w.Length - 3))) return true;
}
return false;
}
}
}
+118
View File
@@ -0,0 +1,118 @@
using System;
using System.Drawing;
using System.Runtime.InteropServices;
namespace ScreenTranslator
{
/// <summary>P/Invoke. Всё, что здесь возвращает координаты, отдаёт их в ФИЗИЧЕСКИХ пикселях
/// (процесс помечен PerMonitorV2 в app.manifest), поэтому они напрямую совпадают с пикселями
/// скриншота и с координатами боксов OCR.</summary>
internal static class Native
{
// ---- горячие клавиши ----
public const int WM_HOTKEY = 0x0312;
public const uint MOD_ALT = 0x0001, MOD_CONTROL = 0x0002, MOD_SHIFT = 0x0004, MOD_WIN = 0x0008;
public const uint MOD_NOREPEAT = 0x4000;
[DllImport("user32.dll", SetLastError = true)]
public static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
[DllImport("user32.dll", SetLastError = true)]
public static extern bool UnregisterHotKey(IntPtr hWnd, int id);
// ---- мониторы ----
[StructLayout(LayoutKind.Sequential)]
public struct POINT { public int X, Y; }
[StructLayout(LayoutKind.Sequential)]
public struct RECT
{
public int Left, Top, Right, Bottom;
public Rectangle ToRectangle() { return Rectangle.FromLTRB(Left, Top, Right, Bottom); }
}
[StructLayout(LayoutKind.Sequential)]
public struct MONITORINFO
{
public int cbSize;
public RECT rcMonitor;
public RECT rcWork;
public uint dwFlags;
}
public const uint MONITOR_DEFAULTTONEAREST = 2;
[DllImport("user32.dll")]
public static extern bool GetCursorPos(out POINT lpPoint);
[DllImport("user32.dll")]
public static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags);
[DllImport("user32.dll", CharSet = CharSet.Auto)]
public static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
/// <summary>Границы монитора под курсором, физические пиксели.</summary>
public static Rectangle MonitorUnderCursor()
{
POINT p;
if (!GetCursorPos(out p)) return VirtualScreen();
IntPtr h = MonitorFromPoint(p, MONITOR_DEFAULTTONEAREST);
var mi = new MONITORINFO();
mi.cbSize = Marshal.SizeOf(typeof(MONITORINFO));
if (!GetMonitorInfo(h, ref mi)) return VirtualScreen();
return mi.rcMonitor.ToRectangle();
}
public const int SM_XVIRTUALSCREEN = 76, SM_YVIRTUALSCREEN = 77,
SM_CXVIRTUALSCREEN = 78, SM_CYVIRTUALSCREEN = 79;
[DllImport("user32.dll")]
public static extern int GetSystemMetrics(int nIndex);
public static Rectangle VirtualScreen()
{
return new Rectangle(
GetSystemMetrics(SM_XVIRTUALSCREEN), GetSystemMetrics(SM_YVIRTUALSCREEN),
GetSystemMetrics(SM_CXVIRTUALSCREEN), GetSystemMetrics(SM_CYVIRTUALSCREEN));
}
// ---- окна ----
public const int GWL_EXSTYLE = -20;
public const int WS_EX_TOOLWINDOW = 0x00000080;
public const int WS_EX_NOACTIVATE = 0x08000000;
public const int WS_EX_TRANSPARENT = 0x00000020;
public const int WS_EX_LAYERED = 0x00080000;
[DllImport("user32.dll", SetLastError = true)]
public static extern int GetWindowLong(IntPtr hWnd, int nIndex);
[DllImport("user32.dll", SetLastError = true)]
public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
public static extern bool SetForegroundWindow(IntPtr hWnd);
public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1);
public const uint SWP_NOACTIVATE = 0x0010, SWP_SHOWWINDOW = 0x0040;
[DllImport("user32.dll", SetLastError = true)]
public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
int X, int Y, int cx, int cy, uint uFlags);
// ---- курсор для скриншота ----
[StructLayout(LayoutKind.Sequential)]
public struct CURSORINFO
{
public int cbSize;
public int flags;
public IntPtr hCursor;
public POINT ptScreenPos;
}
[DllImport("user32.dll")]
public static extern bool GetCursorInfo(out CURSORINFO pci);
}
}
+222
View File
@@ -0,0 +1,222 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.Linq;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Threading.Tasks;
using Windows.Globalization;
using Windows.Graphics.Imaging;
using Windows.Media.Ocr;
namespace ScreenTranslator
{
/// <summary>Одна распознанная строка: текст и прямоугольник в координатах захваченного кадра.</summary>
internal class OcrLineBox
{
public string Text;
public Rectangle Rect;
public double AvgWordHeight;
/// <summary>Фон под строкой. По нему отличают соседние строки одного абзаца от
/// самостоятельных элементов интерфейса, случайно оказавшихся рядом.</summary>
public Color Bg;
public bool BgKnown;
}
/// <summary>Обёртка над встроенным в Windows OCR (Windows.Media.Ocr): офлайн, бесплатно,
/// отдаёт слова с координатами — именно это нужно, чтобы положить перевод на место оригинала.</summary>
internal class OcrService
{
readonly OcrEngine _engine;
public string LanguageTag { get; private set; }
OcrService(OcrEngine e) { _engine = e; LanguageTag = e.RecognizerLanguage.LanguageTag; }
public static List<string> AvailableLanguages()
{
var r = new List<string>();
foreach (var l in OcrEngine.AvailableRecognizerLanguages) r.Add(l.LanguageTag);
return r;
}
/// <summary>null, если в системе нет ни запрошенной модели, ни языков профиля.</summary>
public static OcrService Create(string languageTag)
{
OcrEngine e = null;
if (!string.IsNullOrWhiteSpace(languageTag))
{
try { e = OcrEngine.TryCreateFromLanguage(new Language(languageTag)); }
catch { e = null; }
}
if (e == null) e = OcrEngine.TryCreateFromUserProfileLanguages();
return e == null ? null : new OcrService(e);
}
/// <summary>Модель другого языка — для проверки, не написан ли фрагмент не на том языке,
/// который мы собрались переводить. Берём язык интерфейса системы, иначе первый доступный.
/// null, если второй модели в системе нет.</summary>
public static OcrService CreateOther(string primaryTag)
{
var available = AvailableLanguages()
.Where(t => !Same(t, primaryTag)).ToList();
if (available.Count == 0) return null;
string ui = System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
string pick = available.FirstOrDefault(t => Same2(t, ui)) ?? available[0];
return Create(pick);
}
static bool Same(string a, string b)
{
return string.Equals(a, b, StringComparison.OrdinalIgnoreCase) || Same2(a, Two(b));
}
static bool Same2(string tag, string two)
{
return string.Equals(Two(tag), two, StringComparison.OrdinalIgnoreCase);
}
static string Two(string tag)
{
if (string.IsNullOrEmpty(tag)) return "";
int i = tag.IndexOf('-');
return i > 0 ? tag.Substring(0, i) : tag;
}
/// <summary>Распознать кадр. scale &gt; 1 увеличивает картинку перед распознаванием — мелкий
/// интерфейсный текст (9–12 px) так читается заметно лучше; координаты возвращаются уже
/// пересчитанными в масштаб оригинала.</summary>
public async Task<List<OcrLineBox>> RecognizeAsync(CapturedFrame frame, double scale)
{
long max = OcrEngine.MaxImageDimension;
if (scale > 1.01)
{
double fit = Math.Min((double)max / frame.Width, (double)max / frame.Height);
if (scale > fit) scale = Math.Max(1.0, fit);
}
else scale = 1.0;
// подготовка пикселей — в фоне: на 3440x1440 масштабирование стоит сотни миллисекунд
var prepared = await Task.Run(() => Prepare(frame, scale)).ConfigureAwait(true);
using (var sb = SoftwareBitmap.CreateCopyFromBuffer(
prepared.Data.AsBuffer(), BitmapPixelFormat.Bgra8,
prepared.W, prepared.H, BitmapAlphaMode.Ignore))
{
var res = await _engine.RecognizeAsync(sb);
return Convert(res, 1.0 / scale);
}
}
struct Prepared { public byte[] Data; public int W, H; }
/// <summary>Оттенки серого. Субпиксельное сглаживание ClearType красит края букв в
/// красный и синий; усреднение каналов убирает эту окраску, оставляя чистую форму глифа.</summary>
public bool Grayscale = false;
/// <summary>never | auto | always. Светлый текст на тёмном фоне распознаётся хуже
/// привычного тёмного на светлом; auto инвертирует кадр, если он преимущественно тёмный.</summary>
public string Invert = "never";
byte[] Preprocess(byte[] src, int w, int h)
{
bool gray = Grayscale;
bool invert = string.Equals(Invert, "always", StringComparison.OrdinalIgnoreCase);
if (!invert && string.Equals(Invert, "auto", StringComparison.OrdinalIgnoreCase))
{
long sum = 0; int n = 0;
for (int i = 0; i + 2 < src.Length; i += 64) // выборки достаточно для оценки фона
{
sum += (src[i + 2] * 299 + src[i + 1] * 587 + src[i] * 114) / 1000;
n++;
}
invert = n > 0 && sum / n < 110;
}
if (!gray && !invert) return src;
var dst = new byte[src.Length]; // копия обязательна: исходные пиксели рисует оверлей
for (int i = 0; i + 3 < src.Length; i += 4)
{
byte b = src[i], g = src[i + 1], r = src[i + 2];
if (gray)
{
byte y = (byte)((r * 299 + g * 587 + b * 114) / 1000);
b = g = r = y;
}
if (invert) { b = (byte)(255 - b); g = (byte)(255 - g); r = (byte)(255 - r); }
dst[i] = b; dst[i + 1] = g; dst[i + 2] = r; dst[i + 3] = 255;
}
return dst;
}
Prepared Prepare(CapturedFrame frame, double scale)
{
if (scale <= 1.0)
return new Prepared
{
Data = Preprocess(frame.Pixels, frame.Width, frame.Height),
W = frame.Width,
H = frame.Height
};
int w = (int)(frame.Width * scale), h = (int)(frame.Height * scale);
var handle = GCHandle.Alloc(frame.Pixels, GCHandleType.Pinned);
try
{
using (var src = new Bitmap(frame.Width, frame.Height, frame.Width * 4,
PixelFormat.Format32bppArgb, handle.AddrOfPinnedObject()))
using (var dst = new Bitmap(w, h, PixelFormat.Format32bppArgb))
{
using (var g = Graphics.FromImage(dst))
{
g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
g.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality;
g.DrawImage(src, new Rectangle(0, 0, w, h));
}
var data = dst.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly,
PixelFormat.Format32bppArgb);
try
{
int dense = w * 4;
var buf = new byte[dense * h];
for (int y = 0; y < h; y++)
Marshal.Copy(IntPtr.Add(data.Scan0, y * data.Stride), buf, y * dense, dense);
return new Prepared { Data = Preprocess(buf, w, h), W = w, H = h };
}
finally { dst.UnlockBits(data); }
}
}
finally { handle.Free(); }
}
static List<OcrLineBox> Convert(OcrResult res, double inv)
{
var list = new List<OcrLineBox>();
foreach (var line in res.Lines)
{
if (line.Words.Count == 0) continue;
double l = double.MaxValue, t = double.MaxValue, r = double.MinValue, b = double.MinValue, hsum = 0;
foreach (var w in line.Words)
{
var br = w.BoundingRect;
if (br.X < l) l = br.X;
if (br.Y < t) t = br.Y;
if (br.X + br.Width > r) r = br.X + br.Width;
if (br.Y + br.Height > b) b = br.Y + br.Height;
hsum += br.Height;
}
list.Add(new OcrLineBox
{
Text = line.Text,
Rect = Rectangle.FromLTRB(
(int)Math.Floor(l * inv), (int)Math.Floor(t * inv),
(int)Math.Ceiling(r * inv), (int)Math.Ceiling(b * inv)),
AvgWordHeight = hsum / line.Words.Count * inv
});
}
return list;
}
}
}
+272
View File
@@ -0,0 +1,272 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Text;
using System.Windows.Forms;
namespace ScreenTranslator
{
/// <summary>Окно поверх всего, повторяющее захваченную область один-в-один.
///
/// Порядок появления намеренный: кадр показывается сразу после захвата, боксы — как только
/// отработал OCR, перевод — по готовности. Пользователь всё время видит прогресс,
/// а не пустой экран в ожидании сети.</summary>
internal class OverlayForm : Form
{
readonly AppConfig _cfg;
readonly Bitmap _frame; // кадр в координатах области (0,0 = левый верхний угол области)
readonly Rectangle _area; // область на рабочем столе, физические пиксели
readonly bool _frozen;
List<TextBlock> _blocks = new List<TextBlock>();
readonly Dictionary<TextBlock, Font> _fonts = new Dictionary<TextBlock, Font>();
readonly Dictionary<TextBlock, Rectangle> _layout = new Dictionary<TextBlock, Rectangle>();
string _status = "Распознавание…";
bool _busy = true;
int _hover = -1;
bool _peek; // удержан пробел — показать оригинал
public event EventHandler CancelRequested;
public OverlayForm(AppConfig cfg, Bitmap frame, Rectangle area)
{
_cfg = cfg; _frame = frame; _area = area;
_frozen = !string.Equals(cfg.OverlayMode, "live", StringComparison.OrdinalIgnoreCase);
FormBorderStyle = FormBorderStyle.None;
StartPosition = FormStartPosition.Manual;
ShowInTaskbar = false;
TopMost = true;
KeyPreview = true;
AutoScaleMode = AutoScaleMode.None; // работаем строго в физических пикселях
Bounds = area;
Cursor = Cursors.Default; // крест читается как «сейчас надо что-то выделить» — это не так
DoubleBuffered = true;
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer, true);
if (!_frozen)
{
BackColor = Color.Magenta;
TransparencyKey = Color.Magenta; // сквозь пустоту видно живой экран и проходят клики
}
else BackColor = Color.Black;
}
protected override CreateParams CreateParams
{
get
{
var cp = base.CreateParams;
cp.ExStyle |= Native.WS_EX_TOOLWINDOW; // не мелькать в Alt+Tab
return cp;
}
}
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
// WinForms при PerMonitorV2 может пересчитать размеры под DPI монитора — возвращаем как было
Native.SetWindowPos(Handle, Native.HWND_TOPMOST, _area.X, _area.Y, _area.Width, _area.Height,
Native.SWP_SHOWWINDOW);
Activate();
}
// ---------------- данные ----------------
public void SetBlocks(List<TextBlock> blocks, string status)
{
_blocks = blocks ?? new List<TextBlock>();
_fonts.Clear(); _layout.Clear();
_status = status;
Invalidate();
}
public void SetStatus(string status, bool busy)
{
_status = status; _busy = busy;
Invalidate(StatusRect());
}
public void Refresh(List<TextBlock> blocks, string status, bool busy)
{
_blocks = blocks ?? _blocks;
_fonts.Clear(); _layout.Clear();
_status = status; _busy = busy;
Invalidate();
}
// ---------------- отрисовка ----------------
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
g.CompositingMode = CompositingMode.SourceOver;
g.SmoothingMode = SmoothingMode.AntiAlias;
g.InterpolationMode = InterpolationMode.NearestNeighbor;
g.PixelOffsetMode = PixelOffsetMode.Half;
if (_frozen)
{
g.DrawImage(_frame, new Rectangle(0, 0, _area.Width, _area.Height),
0, 0, _frame.Width, _frame.Height, GraphicsUnit.Pixel);
if (_cfg.DimBackground)
using (var b = new SolidBrush(Color.FromArgb(40, 0, 0, 0)))
g.FillRectangle(b, ClientRectangle);
}
for (int i = 0; i < _blocks.Count; i++)
{
var blk = _blocks[i];
var r = ToClient(blk.Rect);
if (!e.ClipRectangle.IntersectsWith(Rectangle.Inflate(r, 40, 40))) continue;
// Оригинал показываем только по удержанию пробела. Раньше плашка пряталась ещё и
// под курсором — при обычном движении мышью перевод беспорядочно исчезал и появлялся.
bool peeking = _peek;
if (blk.Translated == null)
{
// перевод ещё в пути — подсвечиваем, что этот кусок взят в работу
if (!blk.Failed)
{
using (var b = new SolidBrush(Color.FromArgb(_frozen ? 46 : 70, 90, 160, 255)))
g.FillRectangle(b, Rectangle.Inflate(r, 2, 1));
using (var p = new Pen(Color.FromArgb(120, 90, 160, 255)))
g.DrawRectangle(p, Rectangle.Inflate(r, 2, 1));
}
continue;
}
if (peeking && _frozen) continue; // удержан пробел — показываем оригинал под плашкой
PlateRenderer.Draw(g, blk, _cfg, ClientSize, _fonts, _layout);
// Плашка красится под цвет фона и потому незаметна: без пометки непонятно,
// где перевод, а где нетронутый текст. Тонкая линия снизу — минимум, который
// отвечает на этот вопрос, не мешая читать.
if (_cfg.MarkTranslated && !blk.DrawnRect.IsEmpty)
{
var r2 = blk.DrawnRect;
using (var p = new Pen(Color.FromArgb(i == _hover ? 190 : 90, 90, 160, 255)))
g.DrawLine(p, r2.Left + 1, r2.Bottom - 1, r2.Right - 2, r2.Bottom - 1);
}
}
DrawStatus(g);
}
Rectangle StatusRect() { return new Rectangle(0, 0, Math.Min(760, ClientSize.Width), 46); }
void DrawStatus(Graphics g)
{
string hint = _busy
? _status
: _status + " · Esc — закрыть · пробел — оригинал · клик — скопировать";
using (var f = new Font(_cfg.FontFamily, 13f, FontStyle.Regular, GraphicsUnit.Pixel))
{
var sz = TextRenderer.MeasureText(g, hint, f);
var box = new Rectangle(12, 12, sz.Width + 24, sz.Height + 12);
using (var b = new SolidBrush(Color.FromArgb(215, 18, 18, 20)))
using (var path = PlateRenderer.Rounded(box, 5))
g.FillPath(b, path);
if (_busy)
using (var p = new Pen(Color.FromArgb(200, 90, 160, 255), 2))
g.DrawLine(p, box.X + 2, box.Bottom - 1, box.X + 2 + box.Width / 3, box.Bottom - 1);
TextRenderer.DrawText(g, hint, f, new Point(box.X + 12, box.Y + 6),
Color.FromArgb(235, 235, 240), TextFormatFlags.NoPadding);
}
}
Rectangle ToClient(Rectangle deskRect)
{
return new Rectangle(deskRect.X, deskRect.Y, deskRect.Width, deskRect.Height);
}
// ---------------- ввод ----------------
protected override void OnMouseMove(MouseEventArgs e)
{
int h = HitTest(e.Location);
if (h != _hover) { _hover = h; Invalidate(); }
base.OnMouseMove(e);
}
int HitTest(Point p)
{
for (int i = _blocks.Count - 1; i >= 0; i--)
{
var b = _blocks[i];
if (b.Translated == null) continue;
var r = b.DrawnRect.IsEmpty ? ToClient(b.Rect) : b.DrawnRect;
if (r.Contains(p)) return i;
}
return -1;
}
protected override void OnMouseDown(MouseEventArgs e)
{
if (e.Button == MouseButtons.Right) { Close(); return; }
int i = HitTest(e.Location);
if (i >= 0 && _blocks[i].Translated != null)
{
TrySetClipboard(_blocks[i].Translated);
SetStatus("Скопировано: " + Trim(_blocks[i].Translated, 60), false);
}
base.OnMouseDown(e);
}
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.KeyCode == Keys.Escape) { Close(); return; }
if (e.KeyCode == Keys.Space && !_peek) { _peek = true; Invalidate(); }
if (e.Control && e.KeyCode == Keys.C)
{
var sb = new StringBuilder();
foreach (var b in _blocks.OrderBy(b => b.Rect.Top).ThenBy(b => b.Rect.Left))
if (!string.IsNullOrEmpty(b.Translated)) sb.AppendLine(b.Translated);
TrySetClipboard(sb.ToString());
SetStatus("Весь перевод скопирован в буфер", false);
}
base.OnKeyDown(e);
}
protected override void OnKeyUp(KeyEventArgs e)
{
if (e.KeyCode == Keys.Space && _peek) { _peek = false; Invalidate(); }
base.OnKeyUp(e);
}
// Автозакрытия по потере фокуса здесь намеренно нет. Оно выглядело удобным, но любое
// окно, забравшее фокус, — «Ножницы», всплывающее уведомление, переключение раскладки —
// рушило оверлей на полуслове: снять его скриншот было невозможно, а со стороны это
// читалось как случайные исчезновения. Закрываем только явно: Esc, правый клик,
// повторное нажатие горячей клавиши.
protected override void OnFormClosed(FormClosedEventArgs e)
{
var h = CancelRequested;
if (h != null) h(this, EventArgs.Empty);
foreach (var f in _fonts.Values) f.Dispose();
_fonts.Clear();
if (_frame != null) _frame.Dispose();
base.OnFormClosed(e);
}
static string Trim(string s, int n) { return s.Length <= n ? s : s.Substring(0, n - 1) + "…"; }
static void TrySetClipboard(string s)
{
if (string.IsNullOrEmpty(s)) return;
for (int i = 0; i < 3; i++) // буфер бывает занят другим процессом
{
try { Clipboard.SetText(s); return; }
catch { System.Threading.Thread.Sleep(40); }
}
}
}
}
+147
View File
@@ -0,0 +1,147 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms;
namespace ScreenTranslator
{
/// <summary>Рисование плашки с переводом поверх места оригинала.
/// Вынесено из формы, чтобы ту же отрисовку можно было прогнать в оффскрин-картинку
/// (проверка результата без запуска оверлея).</summary>
internal static class PlateRenderer
{
const TextFormatFlags Flags =
TextFormatFlags.WordBreak | TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix;
public static void Draw(Graphics g, TextBlock blk, AppConfig cfg, Size canvas,
Dictionary<TextBlock, Font> fontCache,
Dictionary<TextBlock, Rectangle> layoutCache)
{
Font font;
Rectangle plate;
if (fontCache == null || !fontCache.TryGetValue(blk, out font))
{
plate = Fit(g, blk, cfg, canvas, out font);
if (fontCache != null) fontCache[blk] = font;
if (layoutCache != null) layoutCache[blk] = plate;
}
else plate = layoutCache[blk];
blk.DrawnRect = plate;
using (var b = new SolidBrush(blk.Bg))
using (var path = Rounded(plate, 3))
g.FillPath(b, path);
TextRenderer.DrawText(g, blk.Translated, font,
Rectangle.Inflate(plate, -cfg.BoxPadding, -cfg.BoxPadding), blk.Fg,
Flags | TextFormatFlags.VerticalCenter);
}
/// <summary>Подобрать кегль так, чтобы перевод влез на место оригинала. Русский текст в среднем
/// длиннее английского, поэтому плашке разрешено немного подрасти (growRatio) прежде чем
/// начнём мельчить шрифт — читаемость важнее точного совпадения габаритов.</summary>
public static Rectangle Fit(Graphics g, TextBlock blk, AppConfig cfg, Size canvas, out Font font)
{
int pad = cfg.BoxPadding;
// OCR обводит только тело букв — без выносных элементов и интерлиньяжа. Если заливать
// ровно по этому прямоугольнику, сверху и снизу остаются хвосты оригинала и текст двоится,
// поэтому по вертикали расширяем на треть высоты строки.
int vpad = Math.Max(pad, (int)Math.Round(blk.LineHeight * 0.32));
if (!blk.GrowLimit.IsEmpty) // не залезать вверх на строку, которая идёт выше
vpad = Math.Max(pad, Math.Min(vpad, blk.Rect.Top - blk.GrowLimit.Top));
var baseRect = Rectangle.Inflate(blk.Rect, pad + 1, vpad);
// Абсолютная добавка к запасу важна для коротких надписей: «Activity» → «Деятельность»
// не влезает ни в какие проценты от 55 пикселей.
int growW = (int)(baseRect.Width * cfg.GrowRatio) + 24;
int growH = (int)(baseRect.Height * cfg.GrowRatio * 2) + 6;
// Дальше — либо край кадра, либо соседний текст, если он ближе
int edgeRight = blk.GrowLimit.IsEmpty ? canvas.Width : Math.Min(canvas.Width, blk.GrowLimit.Right);
int edgeBottom = blk.GrowLimit.IsEmpty ? canvas.Height : Math.Min(canvas.Height, blk.GrowLimit.Bottom);
int hardW = Math.Max(24, edgeRight - baseRect.X);
int hardH = Math.Max(12, edgeBottom - baseRect.Y);
// Сосед может стоять вплотную, но накрыть собственный оригинал плашка обязана —
// иначе из-под неё будет торчать английский текст.
hardW = Math.Max(hardW, baseRect.Width);
hardH = Math.Max(hardH, baseRect.Height);
int softW = Math.Max(baseRect.Width, Math.Min(baseRect.Width + growW, hardW));
int softH = Math.Max(baseRect.Height, Math.Min(baseRect.Height + growH, hardH));
float hi = (float)Math.Max(cfg.MinFontPx, blk.LineHeight * 1.15);
Font best = null;
Size bestSize = Size.Empty;
// Считаем два варианта: уложиться строго в габариты оригинала и уложиться с запасом.
// Плашка, не вылезающая за оригинал, аккуратнее, но только пока не приходится ради
// этого сильно мельчить — нечитаемый перевод хуже слегка выпирающей плашки.
Size sizeTight, sizeSoft;
var fitTight = Search(g, blk.Translated, cfg, hi, cfg.MinFontPx, baseRect.Size, pad, out sizeTight);
var fitSoft = Search(g, blk.Translated, cfg, hi, cfg.MinFontPx, new Size(softW, softH), pad, out sizeSoft);
if (fitTight != null && (fitSoft == null || fitTight.Size >= fitSoft.Size * 0.85f))
{
best = fitTight; bestSize = sizeTight;
if (fitSoft != null) fitSoft.Dispose();
}
else if (fitSoft != null)
{
best = fitSoft; bestSize = sizeSoft;
if (fitTight != null) fitTight.Dispose();
}
if (best == null)
{
// даже мельчайшим кеглем не уложились — пусть плашка вырастет,
// это честнее, чем обрезать перевод на полуслове
best = new Font(cfg.FontFamily, cfg.MinFontPx, FontStyle.Regular, GraphicsUnit.Pixel);
bestSize = TextRenderer.MeasureText(g, blk.Translated, best,
new Size(Math.Max(16, softW - pad * 2), int.MaxValue), Flags);
}
font = best;
int w = Math.Min(hardW, Math.Max(baseRect.Width, bestSize.Width + pad * 2 + 2));
int h = Math.Min(hardH, Math.Max(baseRect.Height, bestSize.Height + pad * 2 + 1));
return new Rectangle(baseRect.X, baseRect.Y, w, h);
}
/// <summary>Наибольший кегль в диапазоне, при котором текст целиком помещается в box.
/// null — не помещается даже минимальным.</summary>
static Font Search(Graphics g, string text, AppConfig cfg, float hi, float lo, Size box, int pad, out Size need)
{
need = Size.Empty;
for (float size = hi; size >= lo; size -= Math.Max(0.5f, size * 0.08f))
{
var f = new Font(cfg.FontFamily, size, FontStyle.Regular, GraphicsUnit.Pixel);
if (FitsIn(g, text, f, box, pad, out need)) return f;
f.Dispose();
}
return null;
}
/// <summary>Проверяем обе стороны: перенос по словам не спасает от длинного слова —
/// оно просто вылезет за плашку и ляжет поверх оригинала.</summary>
static bool FitsIn(Graphics g, string text, Font f, Size box, int pad, out Size need)
{
int availW = Math.Max(8, box.Width - pad * 2);
int availH = Math.Max(6, box.Height - pad * 2);
need = TextRenderer.MeasureText(g, text, f, new Size(availW, int.MaxValue), Flags);
return need.Width <= availW && need.Height <= availH;
}
public static GraphicsPath Rounded(Rectangle r, int radius)
{
var p = new GraphicsPath();
if (radius <= 0 || r.Width < radius * 2 || r.Height < radius * 2) { p.AddRectangle(r); return p; }
int d = radius * 2;
p.AddArc(r.X, r.Y, d, d, 180, 90);
p.AddArc(r.Right - d, r.Y, d, d, 270, 90);
p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90);
p.AddArc(r.X, r.Bottom - d, d, d, 90, 90);
p.CloseFigure();
return p;
}
}
}
+398
View File
@@ -0,0 +1,398 @@
using System;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace ScreenTranslator
{
static class Program
{
[STAThread]
static void Main(string[] args)
{
// Сборщику нужен эталонный config.json рядом с exe, а держать его в репозитории
// нельзя — файл правится пользователем и обрастает личными настройками.
// Ключ создаёт его из вшитого умолчания и сразу выходит, без окна и трея.
foreach (var a in args)
if (string.Equals(a, "--write-config", StringComparison.OrdinalIgnoreCase))
{
string err;
AppConfig.Load(out err); // создаёт файл, если его нет
return;
}
bool created;
using (var mutex = new Mutex(true, "Local\\ScreenTranslator.SingleInstance", out created))
{
if (!created)
{
MessageBox.Show("Переводчик экрана уже запущен — значок в трее.",
"Переводчик экрана", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new TrayApp());
GC.KeepAlive(mutex);
}
}
}
/// <summary>Приложение живёт в трее и просыпается по горячей клавише.</summary>
internal class TrayApp : ApplicationContext
{
const int HK_SCREEN = 1, HK_REGION = 2;
AppConfig _cfg;
OcrService _ocr;
OcrService _ocrOther; // модель другого языка — чтобы не переводить надписи на нём
TranslationCache _cache;
TranslationChain _chain;
readonly NotifyIcon _tray;
readonly HotkeyWindow _hotkeys;
OverlayForm _overlay;
CancellationTokenSource _cts;
bool _busy;
public TrayApp()
{
_tray = new NotifyIcon
{
Icon = MakeIcon(),
Text = "Переводчик экрана",
Visible = true
};
_tray.DoubleClick += delegate { Start(false); };
_hotkeys = new HotkeyWindow();
_hotkeys.Pressed += OnHotkey;
LoadConfig(true);
BuildMenu();
}
// ---------------- конфигурация ----------------
void LoadConfig(bool firstRun)
{
string err;
_cfg = AppConfig.Load(out err);
if (_cache != null) _cache.Save();
_cache = _cfg.Cache ? new TranslationCache(_cfg) : null;
_chain = new TranslationChain(_cfg, _cache);
_ocr = OcrService.Create(_cfg.OcrLanguage);
_ocrOther = _cfg.DetectForeign && _ocr != null ? OcrService.CreateOther(_ocr.LanguageTag) : null;
if (_ocr == null)
{
Balloon("OCR недоступен",
"В Windows не установлена ни одна модель распознавания. Параметры → Время и язык → " +
"Язык и регион → у нужного языка «Компоненты языка» → Распознавание текста.",
ToolTipIcon.Error);
}
else if (!string.Equals(_ocr.LanguageTag, _cfg.OcrLanguage, StringComparison.OrdinalIgnoreCase))
{
Balloon("Модель OCR заменена",
"Модели «" + _cfg.OcrLanguage + "» нет, используется «" + _ocr.LanguageTag +
"». Доступны: " + string.Join(", ", OcrService.AvailableLanguages()),
ToolTipIcon.Warning);
}
RegisterHotkeys();
if (err != null) Balloon("Настройки", err, ToolTipIcon.Warning);
else if (firstRun)
{
_tray.Text = "Переводчик экрана — " + _cfg.HotkeyTranslate;
Balloon("Переводчик экрана запущен",
_cfg.HotkeyTranslate + " — перевести экран, " + _cfg.HotkeyRegion + " — область.",
ToolTipIcon.Info);
}
}
void RegisterHotkeys()
{
_hotkeys.UnregisterAll();
string bad = null;
if (!_hotkeys.Register(HK_SCREEN, _cfg.HotkeyTranslate)) bad = _cfg.HotkeyTranslate;
if (!_hotkeys.Register(HK_REGION, _cfg.HotkeyRegion))
bad = bad == null ? _cfg.HotkeyRegion : bad + " и " + _cfg.HotkeyRegion;
if (bad != null)
Balloon("Горячая клавиша занята",
"Не удалось назначить " + bad + " — комбинацию уже держит другая программа. " +
"Поменяйте её в config.json.", ToolTipIcon.Warning);
}
void BuildMenu()
{
var m = new ContextMenuStrip();
m.Items.Add("Перевести экран\t" + _cfg.HotkeyTranslate, null, delegate { Start(false); });
m.Items.Add("Перевести область\t" + _cfg.HotkeyRegion, null, delegate { Start(true); });
m.Items.Add(new ToolStripSeparator());
m.Items.Add("Проверить переводчики…", null, delegate { ProbeEngines(); });
m.Items.Add("Открыть config.json", null, delegate
{
try { Process.Start(new ProcessStartInfo(AppConfig.Path_) { UseShellExecute = true }); }
catch (Exception ex) { Balloon("Не открылось", ex.Message, ToolTipIcon.Error); }
});
m.Items.Add("Перечитать настройки", null, delegate
{
LoadConfig(false);
BuildMenu();
Balloon("Настройки перечитаны",
"Движки: " + string.Join(" → ", _chain.EngineNames) + ". OCR: " +
(_ocr == null ? "нет" : _ocr.LanguageTag), ToolTipIcon.Info);
});
m.Items.Add(new ToolStripSeparator());
m.Items.Add("Выход", null, delegate { ExitThread(); });
_tray.ContextMenuStrip = m;
}
void OnHotkey(int id)
{
if (id == HK_SCREEN) Start(false);
else if (id == HK_REGION) Start(true);
}
// ---------------- основной сценарий ----------------
void Start(bool region)
{
// повторное нажатие при открытом оверлее — закрыть, это же и «отмена»
if (_overlay != null && !_overlay.IsDisposed)
{
_overlay.Close();
return;
}
if (_busy) return;
if (_ocr == null)
{
Balloon("OCR недоступен", "Нет модели распознавания текста, перевод невозможен.", ToolTipIcon.Error);
return;
}
RunAsync(region);
}
async void RunAsync(bool region)
{
_busy = true;
var sw = Stopwatch.StartNew();
CapturedFrame frame = null;
try
{
var area = region || string.Equals(_cfg.CaptureScope, "all", StringComparison.OrdinalIgnoreCase)
? Native.VirtualScreen()
: Native.MonitorUnderCursor();
frame = ScreenCapture.Capture(area);
if (region)
{
var sel = RegionSelector.Select(frame);
if (sel.IsEmpty) { frame.Image.Dispose(); return; }
var crop = ScreenCapture.Crop(frame, sel);
frame.Image.Dispose();
frame = crop;
}
_cts = new CancellationTokenSource();
var ct = _cts.Token;
_overlay = new OverlayForm(_cfg, frame.Image, frame.Area);
_overlay.CancelRequested += delegate { try { _cts.Cancel(); } catch { } };
_overlay.Show();
_overlay.Refresh(); // кадр должен появиться до того, как начнётся распознавание
var lines = await _ocr.RecognizeAsync(frame, _cfg.OcrScale);
if (ct.IsCancellationRequested || _overlay.IsDisposed) return;
var sampler = new FrameSampler(frame);
var blocks = Blocks.Group(lines, _cfg.MinChars, _cfg.SkipGarbage, sampler, _cfg.MinWords);
if (_cfg.DetectForeign && _ocrOther != null && blocks.Count > 0)
{
_overlay.SetBlocks(blocks, "Проверка языка…");
var otherLines = await _ocrOther.RecognizeAsync(frame, _cfg.OcrScale);
if (ct.IsCancellationRequested || _overlay.IsDisposed) return;
int before = blocks.Count;
Blocks.DropForeign(blocks, otherLines, Blocks.IsCyrillicLanguage(_cfg.SourceLang));
if (blocks.Count < before)
Debug.WriteLine("отброшено как чужой язык: " + (before - blocks.Count));
}
if (blocks.Count == 0)
{
_overlay.SetBlocks(blocks, "Текст не найден");
_overlay.SetStatus("Текст не найден — Esc, чтобы закрыть", false);
return;
}
Blocks.ComputeGrowLimits(blocks, lines, new Size(frame.Width, frame.Height));
foreach (var b in blocks)
{
Color bg, fg;
sampler.Sample(b.Rect, out bg, out fg);
b.Bg = bg; b.Fg = fg;
}
_overlay.SetBlocks(blocks, "Перевод: " + blocks.Count + " фрагм. …");
var sources = blocks.Select(b => b.Source).ToArray();
var translated = await _chain.TranslateAsync(sources, ct);
if (ct.IsCancellationRequested || _overlay.IsDisposed) return;
int ok = 0;
for (int i = 0; i < blocks.Count; i++)
{
var t = translated[i];
if (string.IsNullOrWhiteSpace(t)) { blocks[i].Failed = true; continue; }
blocks[i].Translated = t;
ok++;
}
sw.Stop();
string status = ok > 0
? string.Format("{0} · {1} из {2} · {3:0.0} с",
_chain.LastUsed ?? "?", ok, blocks.Count, sw.ElapsedMilliseconds / 1000.0)
: "Не удалось перевести: " + (_chain.LastError ?? "нет доступного движка");
_overlay.Refresh(blocks, status, false);
if (ok == 0)
Balloon("Перевод не удался", _chain.LastError ?? "все движки недоступны", ToolTipIcon.Error);
}
catch (Exception ex)
{
if (_overlay != null && !_overlay.IsDisposed) _overlay.Close();
Balloon("Ошибка", ex.Message, ToolTipIcon.Error);
}
finally
{
if (frame != null) frame.Dispose();
if (_cache != null) _cache.Save();
_busy = false;
}
}
async void ProbeEngines()
{
_tray.Text = "Переводчик экрана — проверяю движки…";
var sb = new StringBuilder();
try
{
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(40)))
{
var list = await _chain.ProbeAsync(cts.Token);
foreach (var s in list)
sb.AppendLine(string.Format("{0,-16} {1}",
s.Name, s.Ok ? "ок, " + s.Ms + " мс" : "не отвечает — " + s.Error));
}
}
catch (Exception ex) { sb.AppendLine("Проверка прервана: " + ex.Message); }
_tray.Text = "Переводчик экрана — " + _cfg.HotkeyTranslate;
MessageBox.Show(sb.ToString() +
"\nПорядок использования задаётся полем \"chain\" в config.json.",
"Доступность переводчиков", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
void Balloon(string title, string text, ToolTipIcon icon)
{
if (!_cfg.ShowTrayBalloonOnError && icon != ToolTipIcon.Info) return;
try
{
_tray.BalloonTipTitle = title;
_tray.BalloonTipText = text;
_tray.BalloonTipIcon = icon;
_tray.ShowBalloonTip(6000);
}
catch { }
}
/// <summary>Иконка рисуется в коде, чтобы приложение оставалось одним файлом без ресурсов.</summary>
static Icon MakeIcon()
{
var bmp = new Bitmap(32, 32);
using (var g = Graphics.FromImage(bmp))
{
g.SmoothingMode = SmoothingMode.AntiAlias;
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
using (var b = new LinearGradientBrush(new Rectangle(0, 0, 32, 32),
Color.FromArgb(38, 84, 160), Color.FromArgb(22, 42, 88), 45f))
g.FillEllipse(b, 0, 0, 31, 31);
using (var f = new Font("Segoe UI", 15f, FontStyle.Bold, GraphicsUnit.Pixel))
{
g.DrawString("A", f, Brushes.White, new PointF(2.5f, 4f));
g.DrawString("Я", f, new SolidBrush(Color.FromArgb(120, 200, 255)), new PointF(13.5f, 12f));
}
}
IntPtr h = bmp.GetHicon();
try { return (Icon)Icon.FromHandle(h).Clone(); }
finally { DestroyIcon(h); bmp.Dispose(); }
}
[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true)]
static extern bool DestroyIcon(IntPtr handle);
protected override void Dispose(bool disposing)
{
if (disposing)
{
try { if (_cts != null) _cts.Cancel(); } catch { }
if (_cache != null) _cache.Save();
if (_hotkeys != null) _hotkeys.Dispose();
if (_tray != null) { _tray.Visible = false; _tray.Dispose(); }
}
base.Dispose(disposing);
}
}
/// <summary>Невидимое окно, принимающее WM_HOTKEY: глобальные клавиши нужны и когда фокус
/// в игре или чужом окне, поэтому обычные события формы тут не годятся.</summary>
internal class HotkeyWindow : NativeWindow, IDisposable
{
readonly System.Collections.Generic.List<int> _ids = new System.Collections.Generic.List<int>();
public event Action<int> Pressed;
public HotkeyWindow() { CreateHandle(new CreateParams()); }
public bool Register(int id, string combo)
{
uint mods, vk; string display;
if (!AppConfig.ParseHotkey(combo, out mods, out vk, out display)) return false;
if (!Native.RegisterHotKey(Handle, id, mods | Native.MOD_NOREPEAT, vk)) return false;
_ids.Add(id);
return true;
}
public void UnregisterAll()
{
foreach (var id in _ids) Native.UnregisterHotKey(Handle, id);
_ids.Clear();
}
protected override void WndProc(ref Message m)
{
if (m.Msg == Native.WM_HOTKEY)
{
var h = Pressed;
if (h != null) h(m.WParam.ToInt32());
}
base.WndProc(ref m);
}
public void Dispose()
{
UnregisterAll();
DestroyHandle();
}
}
}
+143
View File
@@ -0,0 +1,143 @@
using System;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms;
namespace ScreenTranslator
{
/// <summary>Выбор области рамкой поверх замороженного кадра. Работаем именно по кадру, а не по
/// живому экрану: заново снимать нельзя — сверху уже висит наше собственное окно.</summary>
internal class RegionSelector : Form
{
readonly CapturedFrame _frame;
Point _start;
bool _dragging;
Rectangle _sel = Rectangle.Empty;
public Rectangle Selection { get { return _sel; } }
public static Rectangle Select(CapturedFrame frame)
{
using (var f = new RegionSelector(frame))
return f.ShowDialog() == DialogResult.OK ? f.Selection : Rectangle.Empty;
}
RegionSelector(CapturedFrame frame)
{
_frame = frame;
FormBorderStyle = FormBorderStyle.None;
StartPosition = FormStartPosition.Manual;
ShowInTaskbar = false;
TopMost = true;
KeyPreview = true;
AutoScaleMode = AutoScaleMode.None;
Bounds = frame.Area;
Cursor = Cursors.Cross;
DoubleBuffered = true;
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer, true);
}
protected override CreateParams CreateParams
{
get { var cp = base.CreateParams; cp.ExStyle |= Native.WS_EX_TOOLWINDOW; return cp; }
}
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
Native.SetWindowPos(Handle, Native.HWND_TOPMOST, _frame.Area.X, _frame.Area.Y,
_frame.Area.Width, _frame.Area.Height, Native.SWP_SHOWWINDOW);
Activate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
g.DrawImage(_frame.Image, new Rectangle(0, 0, ClientSize.Width, ClientSize.Height),
0, 0, _frame.Width, _frame.Height, GraphicsUnit.Pixel);
using (var shade = new SolidBrush(Color.FromArgb(120, 0, 0, 0)))
{
if (_sel.Width > 1 && _sel.Height > 1)
{
// затемняем всё, кроме выбранного куска
var r = ClientRectangle;
g.FillRectangle(shade, new Rectangle(r.X, r.Y, r.Width, _sel.Top - r.Y));
g.FillRectangle(shade, new Rectangle(r.X, _sel.Bottom, r.Width, r.Bottom - _sel.Bottom));
g.FillRectangle(shade, new Rectangle(r.X, _sel.Top, _sel.Left - r.X, _sel.Height));
g.FillRectangle(shade, new Rectangle(_sel.Right, _sel.Top, r.Right - _sel.Right, _sel.Height));
using (var p = new Pen(Color.FromArgb(230, 90, 160, 255), 1.5f))
g.DrawRectangle(p, _sel);
string label = _sel.Width + " × " + _sel.Height;
using (var f = new Font("Segoe UI", 12f, FontStyle.Regular, GraphicsUnit.Pixel))
{
var sz = TextRenderer.MeasureText(g, label, f);
var box = new Rectangle(_sel.X, Math.Max(0, _sel.Y - sz.Height - 8),
sz.Width + 12, sz.Height + 6);
using (var b = new SolidBrush(Color.FromArgb(220, 18, 18, 20))) g.FillRectangle(b, box);
TextRenderer.DrawText(g, label, f, new Point(box.X + 6, box.Y + 3), Color.White);
}
}
else g.FillRectangle(shade, ClientRectangle);
}
if (!_dragging && _sel.IsEmpty)
{
const string hint = "Выделите область для перевода · Esc — отмена";
using (var f = new Font("Segoe UI", 15f, FontStyle.Regular, GraphicsUnit.Pixel))
{
var sz = TextRenderer.MeasureText(g, hint, f);
var box = new Rectangle((ClientSize.Width - sz.Width) / 2 - 14, 40, sz.Width + 28, sz.Height + 14);
using (var b = new SolidBrush(Color.FromArgb(220, 18, 18, 20)))
using (var path = Round(box, 6))
g.FillPath(b, path);
TextRenderer.DrawText(g, hint, f, new Point(box.X + 14, box.Y + 7), Color.White);
}
}
}
static GraphicsPath Round(Rectangle r, int radius)
{
var p = new GraphicsPath();
int d = radius * 2;
p.AddArc(r.X, r.Y, d, d, 180, 90);
p.AddArc(r.Right - d, r.Y, d, d, 270, 90);
p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90);
p.AddArc(r.X, r.Bottom - d, d, d, 90, 90);
p.CloseFigure();
return p;
}
protected override void OnMouseDown(MouseEventArgs e)
{
if (e.Button != MouseButtons.Left) { DialogResult = DialogResult.Cancel; Close(); return; }
_dragging = true; _start = e.Location; _sel = Rectangle.Empty;
}
protected override void OnMouseMove(MouseEventArgs e)
{
if (!_dragging) return;
_sel = Rectangle.FromLTRB(
Math.Min(_start.X, e.X), Math.Min(_start.Y, e.Y),
Math.Max(_start.X, e.X), Math.Max(_start.Y, e.Y));
Invalidate();
}
protected override void OnMouseUp(MouseEventArgs e)
{
if (!_dragging) return;
_dragging = false;
if (_sel.Width < 8 || _sel.Height < 8) { DialogResult = DialogResult.Cancel; Close(); return; }
DialogResult = DialogResult.OK;
Close();
}
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.KeyCode == Keys.Escape) { DialogResult = DialogResult.Cancel; Close(); }
}
}
}
+86
View File
@@ -0,0 +1,86 @@
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
namespace ScreenTranslator
{
/// <summary>Снимок области экрана: и готовый Bitmap для отрисовки в оверлее, и отдельная копия
/// пикселей. Копия нужна, чтобы OCR и замер цветов работали в фоновом потоке, не трогая тот же
/// Bitmap, который в это время рисует UI-поток (иначе GDI+ падает на «region already locked»).</summary>
internal sealed class CapturedFrame : IDisposable
{
public Bitmap Image;
public byte[] Pixels; // BGRA, плотно, stride == Width * 4
public int Width, Height;
public Rectangle Area; // положение на рабочем столе, физические пиксели
public void Dispose()
{
// Image отдан оверлею — освобождает он; здесь только буфер
Pixels = null;
}
}
internal static class ScreenCapture
{
public static CapturedFrame Capture(Rectangle area)
{
var bmp = new Bitmap(area.Width, area.Height, PixelFormat.Format32bppArgb);
using (var g = Graphics.FromImage(bmp))
{
g.CopyFromScreen(area.X, area.Y, 0, 0, area.Size, CopyPixelOperation.SourceCopy);
}
var f = new CapturedFrame { Image = bmp, Width = area.Width, Height = area.Height, Area = area };
f.Pixels = Extract(bmp);
return f;
}
/// <summary>Вырезать подобласть из уже снятого кадра (режим выделения рамкой) — переснимать
/// экран нельзя: под рамкой выделения уже наш собственный оверлей.</summary>
public static CapturedFrame Crop(CapturedFrame src, Rectangle clientRect)
{
clientRect.Intersect(new Rectangle(0, 0, src.Width, src.Height));
var bmp = new Bitmap(clientRect.Width, clientRect.Height, PixelFormat.Format32bppArgb);
using (var g = Graphics.FromImage(bmp))
g.DrawImage(src.Image, new Rectangle(0, 0, clientRect.Width, clientRect.Height),
clientRect, GraphicsUnit.Pixel);
var area = new Rectangle(src.Area.X + clientRect.X, src.Area.Y + clientRect.Y,
clientRect.Width, clientRect.Height);
return new CapturedFrame
{
Image = bmp,
Width = clientRect.Width,
Height = clientRect.Height,
Area = area,
Pixels = Extract(bmp)
};
}
static byte[] Extract(Bitmap bmp)
{
var rect = new Rectangle(0, 0, bmp.Width, bmp.Height);
BitmapData d = null;
try
{
d = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
int dense = bmp.Width * 4;
var buf = new byte[dense * bmp.Height];
int stride = Math.Abs(d.Stride);
bool bottomUp = d.Stride < 0;
for (int y = 0; y < bmp.Height; y++)
{
IntPtr src = bottomUp
? IntPtr.Add(d.Scan0, -y * stride)
: IntPtr.Add(d.Scan0, y * stride);
Marshal.Copy(src, buf, y * dense, dense);
}
return buf;
}
finally { if (d != null) bmp.UnlockBits(d); }
}
}
}
+625
View File
@@ -0,0 +1,625 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using System.Web.Script.Serialization;
namespace ScreenTranslator
{
internal interface ITranslator
{
string Name { get; }
/// <summary>false, если для движка не хватает настроек (нет ключа/URL) — такой пропускаем молча.</summary>
bool Configured { get; }
Task<string[]> TranslateAsync(string[] texts, CancellationToken ct);
}
// =====================================================================
internal abstract class HttpTranslatorBase : ITranslator
{
protected readonly AppConfig Cfg;
protected HttpTranslatorBase(AppConfig cfg) { Cfg = cfg; }
public abstract string Name { get; }
public virtual bool Configured { get { return true; } }
public abstract Task<string[]> TranslateAsync(string[] texts, CancellationToken ct);
protected static readonly JavaScriptSerializer Json = new JavaScriptSerializer { MaxJsonLength = 32 * 1024 * 1024 };
static HttpTranslatorBase()
{
ServicePointManager.DefaultConnectionLimit = 24;
ServicePointManager.Expect100Continue = false;
try
{
ServicePointManager.SecurityProtocol =
SecurityProtocolType.Tls12 | (SecurityProtocolType)12288 /* Tls13 */;
}
catch { ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12; }
}
protected HttpWebRequest Request(string url, string method)
{
var r = (HttpWebRequest)WebRequest.Create(url);
r.Method = method;
r.Timeout = Cfg.TimeoutMs;
r.ReadWriteTimeout = Cfg.TimeoutMs;
r.UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ScreenTranslator/1.0";
r.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
r.Proxy = string.IsNullOrWhiteSpace(Cfg.Proxy) ? null : new WebProxy(Cfg.Proxy, true);
return r;
}
protected async Task<string> SendAsync(HttpWebRequest req, string body, string contentType, CancellationToken ct)
{
using (ct.Register(delegate { try { req.Abort(); } catch { } }))
{
if (body != null)
{
req.ContentType = contentType;
var bytes = Encoding.UTF8.GetBytes(body);
req.ContentLength = bytes.Length;
using (var s = await req.GetRequestStreamAsync().ConfigureAwait(false))
await s.WriteAsync(bytes, 0, bytes.Length, ct).ConfigureAwait(false);
}
using (var resp = (HttpWebResponse)await req.GetResponseAsync().ConfigureAwait(false))
using (var rs = resp.GetResponseStream())
using (var sr = new StreamReader(rs, Encoding.UTF8))
return await sr.ReadToEndAsync().ConfigureAwait(false);
}
}
/// <summary>Прогнать тексты по одному с ограничением параллелизма — для API без пакетного режима.</summary>
protected async Task<string[]> EachAsync(string[] texts, Func<string, CancellationToken, Task<string>> one, CancellationToken ct)
{
var result = new string[texts.Length];
using (var gate = new SemaphoreSlim(Cfg.MaxParallel))
{
var tasks = new Task[texts.Length];
for (int i = 0; i < texts.Length; i++)
{
int idx = i;
tasks[i] = Task.Run(async delegate
{
await gate.WaitAsync(ct).ConfigureAwait(false);
try { result[idx] = await one(texts[idx], ct).ConfigureAwait(false); }
finally { gate.Release(); }
}, ct);
}
await Task.WhenAll(tasks).ConfigureAwait(false);
}
return result;
}
}
// =====================================================================
/// <summary>Бесплатный публичный эндпоинт Google Translate (client=gtx), без ключа.
/// Пакетно: тексты склеиваются через \n, ответ приходит сегментами по предложениям, но перевод
/// строки Google сохраняет — по нему и восстанавливаем исходные границы блоков.</summary>
internal class GoogleTranslator : HttpTranslatorBase
{
public GoogleTranslator(AppConfig cfg) : base(cfg) { }
public override string Name { get { return "google"; } }
const int ChunkChars = 3500;
public override async Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
{
var result = new string[texts.Length];
int i = 0;
while (i < texts.Length)
{
int start = i, len = 0;
while (i < texts.Length && (len == 0 || len + texts[i].Length + 1 <= ChunkChars))
{
len += texts[i].Length + 1;
i++;
}
var chunk = new string[i - start];
Array.Copy(texts, start, chunk, 0, chunk.Length);
var got = await ChunkAsync(chunk, ct).ConfigureAwait(false);
Array.Copy(got, 0, result, start, got.Length);
}
return result;
}
async Task<string[]> ChunkAsync(string[] chunk, CancellationToken ct)
{
string joined = string.Join("\n", chunk.Select(t => t.Replace('\n', ' ').Replace('\r', ' ')));
string url = "https://translate.googleapis.com/translate_a/single?client=gtx" +
"&sl=" + Uri.EscapeDataString(Cfg.SourceLang) +
"&tl=" + Uri.EscapeDataString(Cfg.TargetLang) + "&dt=t";
var req = Request(url, "POST");
string raw = await SendAsync(req, "q=" + HttpUtility.UrlEncode(joined, Encoding.UTF8),
"application/x-www-form-urlencoded; charset=utf-8", ct).ConfigureAwait(false);
var lines = Reassemble(raw, chunk.Length);
if (lines != null) return lines;
// границы поехали (Google схлопнул строку) — добираем поштучно, зато без рассинхрона
return await EachAsync(chunk, async (t, c) =>
{
var r2 = Request(url, "POST");
var raw2 = await SendAsync(r2, "q=" + HttpUtility.UrlEncode(t.Replace('\n', ' '), Encoding.UTF8),
"application/x-www-form-urlencoded; charset=utf-8", c).ConfigureAwait(false);
var one = Reassemble(raw2, 1);
return one != null ? one[0] : t;
}, ct).ConfigureAwait(false);
}
/// <summary>Склеить сегменты ответа обратно в строки: сегмент, оканчивающийся на \n, закрывает строку.
/// null — если получилось не столько строк, сколько отправляли.</summary>
internal static string[] Reassemble(string raw, int expected)
{
object parsed;
try { parsed = Json.DeserializeObject(raw); } catch { return null; }
var top = parsed as object[];
if (top == null || top.Length == 0) return null;
var segs = top[0] as object[];
if (segs == null) return null;
var lines = new List<string>();
var sb = new StringBuilder();
foreach (var so in segs)
{
var seg = so as object[];
if (seg == null || seg.Length == 0 || seg[0] == null) continue;
string piece = seg[0].ToString();
bool ends = piece.EndsWith("\n");
sb.Append(ends ? piece.Substring(0, piece.Length - 1) : piece);
if (ends) { lines.Add(sb.ToString().Trim()); sb.Length = 0; }
}
if (sb.Length > 0) lines.Add(sb.ToString().Trim());
return lines.Count == expected ? lines.ToArray() : null;
}
}
/// <summary>Lingva — открытый фронтенд к Google. Полезен ровно тогда, когда до самого Google
/// с этой сети не достучаться. Пакетного режима нет, поэтому по одному тексту.</summary>
internal class LingvaTranslator : HttpTranslatorBase
{
public LingvaTranslator(AppConfig cfg) : base(cfg) { }
public override string Name { get { return "lingva"; } }
static readonly string[] Hosts = { "https://lingva.ml", "https://lingva.lunar.icu", "https://translate.plausibility.cloud" };
public override Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
{
return EachAsync(texts, async (t, c) =>
{
Exception last = null;
foreach (var host in Hosts)
{
try
{
string url = host + "/api/v1/" + Cfg.SourceLang + "/" + Cfg.TargetLang + "/" +
Uri.EscapeDataString(t);
var raw = await SendAsync(Request(url, "GET"), null, null, c).ConfigureAwait(false);
var d = Json.Deserialize<Dictionary<string, object>>(raw);
object v;
if (d != null && d.TryGetValue("translation", out v) && v != null) return v.ToString();
}
catch (Exception ex) { last = ex; }
}
if (last != null) throw last;
return t;
}, ct);
}
}
/// <summary>MyMemory: бесплатно и без ключа, но лимит ~5000 слов в сутки на IP и предел 500 символов
/// на запрос — годится как последний рубеж, не как основной движок.</summary>
internal class MyMemoryTranslator : HttpTranslatorBase
{
public MyMemoryTranslator(AppConfig cfg) : base(cfg) { }
public override string Name { get { return "mymemory"; } }
public override Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
{
return EachAsync(texts, async (t, c) =>
{
string q = t.Length > 480 ? t.Substring(0, 480) : t;
string url = "https://api.mymemory.translated.net/get?q=" + Uri.EscapeDataString(q) +
"&langpair=" + Cfg.SourceLang + "|" + Cfg.TargetLang;
var raw = await SendAsync(Request(url, "GET"), null, null, c).ConfigureAwait(false);
var d = Json.Deserialize<Dictionary<string, object>>(raw);
object rd;
if (d != null && d.TryGetValue("responseData", out rd))
{
var inner = rd as Dictionary<string, object>;
object tt;
if (inner != null && inner.TryGetValue("translatedText", out tt) && tt != null)
return HttpUtility.HtmlDecode(tt.ToString());
}
throw new Exception("mymemory: неожиданный ответ");
}, ct);
}
}
/// <summary>Свой релей (см. README): POST {texts, from, to} -> {texts:[...]}.
/// Нужен, когда до публичных API из сети клиента не дотянуться, а свой сервер доступен.</summary>
internal class RelayTranslator : HttpTranslatorBase
{
public RelayTranslator(AppConfig cfg) : base(cfg) { }
public override string Name { get { return "relay"; } }
public override bool Configured { get { return !string.IsNullOrWhiteSpace(Cfg.RelayUrl); } }
public override async Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
{
string body = Json.Serialize(new Dictionary<string, object>
{
{ "texts", texts }, { "from", Cfg.SourceLang }, { "to", Cfg.TargetLang }
});
var raw = await SendAsync(Request(Cfg.RelayUrl, "POST"), body, "application/json; charset=utf-8", ct)
.ConfigureAwait(false);
var d = Json.Deserialize<Dictionary<string, object>>(raw);
object arr;
if (d != null && d.TryGetValue("texts", out arr))
{
var a = arr as object[];
if (a != null && a.Length == texts.Length)
return a.Select(o => o == null ? "" : o.ToString()).ToArray();
}
throw new Exception("relay: ответ не совпал по длине");
}
}
internal class LibreTranslateTranslator : HttpTranslatorBase
{
public LibreTranslateTranslator(AppConfig cfg) : base(cfg) { }
public override string Name { get { return "libretranslate"; } }
public override bool Configured { get { return !string.IsNullOrWhiteSpace(Cfg.LibreUrl); } }
public override async Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
{
var payload = new Dictionary<string, object>
{
{ "q", texts }, { "source", Cfg.SourceLang }, { "target", Cfg.TargetLang }, { "format", "text" }
};
if (!string.IsNullOrWhiteSpace(Cfg.LibreApiKey)) payload["api_key"] = Cfg.LibreApiKey;
var raw = await SendAsync(Request(Cfg.LibreUrl, "POST"), Json.Serialize(payload),
"application/json; charset=utf-8", ct).ConfigureAwait(false);
var d = Json.Deserialize<Dictionary<string, object>>(raw);
object tr;
if (d != null && d.TryGetValue("translatedText", out tr))
{
var a = tr as object[];
if (a != null && a.Length == texts.Length) return a.Select(o => o == null ? "" : o.ToString()).ToArray();
if (texts.Length == 1 && tr != null) return new[] { tr.ToString() };
}
throw new Exception("libretranslate: неожиданный ответ");
}
}
internal class DeeplTranslator : HttpTranslatorBase
{
public DeeplTranslator(AppConfig cfg) : base(cfg) { }
public override string Name { get { return "deepl"; } }
public override bool Configured { get { return !string.IsNullOrWhiteSpace(Cfg.DeeplApiKey); } }
public override async Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
{
var sb = new StringBuilder();
sb.Append("source_lang=").Append(Cfg.SourceLang.ToUpperInvariant());
sb.Append("&target_lang=").Append(Cfg.TargetLang.ToUpperInvariant());
foreach (var t in texts) sb.Append("&text=").Append(HttpUtility.UrlEncode(t, Encoding.UTF8));
var req = Request(Cfg.DeeplUrl, "POST");
req.Headers.Add("Authorization", "DeepL-Auth-Key " + Cfg.DeeplApiKey);
var raw = await SendAsync(req, sb.ToString(), "application/x-www-form-urlencoded; charset=utf-8", ct)
.ConfigureAwait(false);
var d = Json.Deserialize<Dictionary<string, object>>(raw);
object tr;
if (d != null && d.TryGetValue("translations", out tr))
{
var a = tr as object[];
if (a != null && a.Length == texts.Length)
return a.Select(o =>
{
var e = o as Dictionary<string, object>;
object v;
return e != null && e.TryGetValue("text", out v) && v != null ? v.ToString() : "";
}).ToArray();
}
throw new Exception("deepl: неожиданный ответ");
}
}
/// <summary>Yandex Cloud Translate — самый устойчивый канал внутри РФ, но требует folderId + API-ключ.</summary>
internal class YandexTranslator : HttpTranslatorBase
{
public YandexTranslator(AppConfig cfg) : base(cfg) { }
public override string Name { get { return "yandex"; } }
public override bool Configured
{
get { return !string.IsNullOrWhiteSpace(Cfg.YandexApiKey) && !string.IsNullOrWhiteSpace(Cfg.YandexFolderId); }
}
public override async Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
{
var payload = new Dictionary<string, object>
{
{ "folderId", Cfg.YandexFolderId },
{ "texts", texts },
{ "sourceLanguageCode", Cfg.SourceLang },
{ "targetLanguageCode", Cfg.TargetLang },
{ "format", "PLAIN_TEXT" }
};
var req = Request("https://translate.api.cloud.yandex.net/translate/v2/translate", "POST");
req.Headers.Add("Authorization", "Api-Key " + Cfg.YandexApiKey);
var raw = await SendAsync(req, Json.Serialize(payload), "application/json; charset=utf-8", ct)
.ConfigureAwait(false);
var d = Json.Deserialize<Dictionary<string, object>>(raw);
object tr;
if (d != null && d.TryGetValue("translations", out tr))
{
var a = tr as object[];
if (a != null && a.Length == texts.Length)
return a.Select(o =>
{
var e = o as Dictionary<string, object>;
object v;
return e != null && e.TryGetValue("text", out v) && v != null ? v.ToString() : "";
}).ToArray();
}
throw new Exception("yandex: неожиданный ответ");
}
}
/// <summary>Перевод целым экраном через LLM (Anthropic Messages API): лучше держит контекст и
/// термины интерфейса, но платный и медленнее. Строки нумеруются, ответ ждём строго построчно.</summary>
internal class LlmTranslator : HttpTranslatorBase
{
public LlmTranslator(AppConfig cfg) : base(cfg) { }
public override string Name { get { return "llm"; } }
public override bool Configured { get { return !string.IsNullOrWhiteSpace(Cfg.LlmApiKey); } }
public override async Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
{
var sb = new StringBuilder();
for (int i = 0; i < texts.Length; i++)
sb.Append(i + 1).Append('\t').Append(texts[i].Replace('\n', ' ')).Append('\n');
string prompt =
"Переведи фрагменты интерфейса и текста с " + Cfg.SourceLang + " на " + Cfg.TargetLang + ".\n" +
"Это надписи с экрана компьютера: сохраняй краткость, не добавляй пояснений.\n" +
"Ответь строго " + texts.Length + " строками в формате <номер>\\t<перевод>, без иного текста.\n\n" +
sb;
var payload = new Dictionary<string, object>
{
{ "model", Cfg.LlmModel },
{ "max_tokens", Math.Min(8192, 256 + texts.Length * 64) },
{ "messages", new object[] { new Dictionary<string, object> { { "role", "user" }, { "content", prompt } } } }
};
var req = Request(Cfg.LlmUrl, "POST");
req.Headers.Add("x-api-key", Cfg.LlmApiKey);
req.Headers.Add("anthropic-version", "2023-06-01");
var raw = await SendAsync(req, Json.Serialize(payload), "application/json; charset=utf-8", ct)
.ConfigureAwait(false);
var d = Json.Deserialize<Dictionary<string, object>>(raw);
object content;
if (d == null || !d.TryGetValue("content", out content)) throw new Exception("llm: нет content");
var blocks = content as object[];
var text = new StringBuilder();
if (blocks != null)
foreach (var b in blocks)
{
var e = b as Dictionary<string, object>;
object v;
if (e != null && e.TryGetValue("text", out v) && v != null) text.Append(v);
}
var result = new string[texts.Length];
foreach (var line in text.ToString().Split('\n'))
{
int tab = line.IndexOf('\t');
if (tab <= 0) continue;
int n;
if (!int.TryParse(line.Substring(0, tab).Trim(), out n)) continue;
if (n >= 1 && n <= result.Length) result[n - 1] = line.Substring(tab + 1).Trim();
}
for (int i = 0; i < result.Length; i++) if (result[i] == null) result[i] = texts[i];
return result;
}
}
// =====================================================================
internal class EngineStatus
{
public string Name;
public bool Ok;
public long Ms;
public string Error;
}
/// <summary>Цепочка движков: работаем первым живым, упавший запоминаем и какое-то время не трогаем.
/// Смысл в том, чтобы у пользователя в другой сети (где, скажем, режется Google) всё
/// заработало само, без разбирательств с настройками.</summary>
internal class TranslationChain
{
readonly List<ITranslator> _engines = new List<ITranslator>();
readonly Dictionary<string, DateTime> _cooldown = new Dictionary<string, DateTime>();
readonly TranslationCache _cache;
readonly AppConfig _cfg;
public string LastUsed { get; private set; }
public string LastError { get; private set; }
public TranslationChain(AppConfig cfg, TranslationCache cache)
{
_cfg = cfg; _cache = cache;
foreach (var name in cfg.Chain)
{
var e = Make(name, cfg);
if (e == null) continue;
if (!e.Configured) continue;
_engines.Add(e);
}
if (_engines.Count == 0) _engines.Add(new GoogleTranslator(cfg));
}
public IEnumerable<string> EngineNames { get { return _engines.Select(e => e.Name); } }
static ITranslator Make(string name, AppConfig cfg)
{
switch (name)
{
case "google": return new GoogleTranslator(cfg);
case "lingva": return new LingvaTranslator(cfg);
case "mymemory": return new MyMemoryTranslator(cfg);
case "relay": return new RelayTranslator(cfg);
case "libretranslate": case "libre": return new LibreTranslateTranslator(cfg);
case "deepl": return new DeeplTranslator(cfg);
case "yandex": return new YandexTranslator(cfg);
case "llm": case "claude": return new LlmTranslator(cfg);
default: return null;
}
}
/// <summary>Возвращает переводы в том же порядке; на элемент, который не удалось перевести, — null.</summary>
public async Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
{
var result = new string[texts.Length];
var todoIdx = new List<int>();
var todoTxt = new List<string>();
for (int i = 0; i < texts.Length; i++)
{
string hit;
if (_cache != null && _cache.TryGet(texts[i], out hit)) result[i] = hit;
else { todoIdx.Add(i); todoTxt.Add(texts[i]); }
}
if (todoTxt.Count == 0) { LastUsed = "cache"; return result; }
var errors = new List<string>();
foreach (var eng in _engines)
{
DateTime until;
if (_cooldown.TryGetValue(eng.Name, out until) && DateTime.UtcNow < until) continue;
try
{
var got = await eng.TranslateAsync(todoTxt.ToArray(), ct).ConfigureAwait(false);
if (got == null || got.Length != todoTxt.Count) throw new Exception("длина ответа не совпала");
for (int i = 0; i < got.Length; i++)
{
result[todoIdx[i]] = got[i];
if (_cache != null && !string.IsNullOrEmpty(got[i])) _cache.Put(todoTxt[i], got[i]);
}
LastUsed = eng.Name;
LastError = errors.Count > 0 ? string.Join("; ", errors) : null;
_cooldown.Remove(eng.Name);
return result;
}
catch (Exception ex)
{
errors.Add(eng.Name + ": " + Short(ex));
_cooldown[eng.Name] = DateTime.UtcNow.AddSeconds(60);
}
if (ct.IsCancellationRequested) break;
}
LastError = string.Join("; ", errors);
LastUsed = null;
return result;
}
/// <summary>Проверка «кто вообще жив» для меню в трее.</summary>
public async Task<List<EngineStatus>> ProbeAsync(CancellationToken ct)
{
var list = new List<EngineStatus>();
foreach (var eng in _engines)
{
var st = new EngineStatus { Name = eng.Name };
var sw = Stopwatch.StartNew();
try
{
var r = await eng.TranslateAsync(new[] { "Open settings" }, ct).ConfigureAwait(false);
st.Ok = r != null && r.Length == 1 && !string.IsNullOrWhiteSpace(r[0]);
if (!st.Ok) st.Error = "пустой ответ";
}
catch (Exception ex) { st.Error = Short(ex); }
st.Ms = sw.ElapsedMilliseconds;
list.Add(st);
if (st.Ok) _cooldown.Remove(eng.Name);
}
return list;
}
static string Short(Exception ex)
{
var we = ex as WebException;
if (we != null)
{
var r = we.Response as HttpWebResponse;
if (r != null) return (int)r.StatusCode + " " + r.StatusDescription;
return we.Status.ToString();
}
return ex.Message;
}
}
/// <summary>Кэш переводов. Экран часто пересканируют почти без изменений — повтор должен быть мгновенным.</summary>
internal class TranslationCache
{
readonly Dictionary<string, string> _map = new Dictionary<string, string>(StringComparer.Ordinal);
readonly string _path;
readonly string _prefix;
bool _dirty;
public TranslationCache(AppConfig cfg)
{
_path = Path.Combine(AppConfig.Dir, "cache.json");
_prefix = cfg.SourceLang + ">" + cfg.TargetLang + "";
try
{
if (File.Exists(_path))
{
var d = new JavaScriptSerializer { MaxJsonLength = 64 * 1024 * 1024 }
.Deserialize<Dictionary<string, string>>(File.ReadAllText(_path));
if (d != null) foreach (var kv in d) _map[kv.Key] = kv.Value;
}
}
catch { /* битый кэш — просто начинаем с чистого */ }
}
public bool TryGet(string src, out string dst) { return _map.TryGetValue(_prefix + src, out dst); }
public void Put(string src, string dst)
{
_map[_prefix + src] = dst;
_dirty = true;
}
public void Save()
{
if (!_dirty) return;
try
{
// не даём файлу расти бесконечно
var src = _map;
if (src.Count > 20000)
src = src.Skip(src.Count - 20000).ToDictionary(k => k.Key, v => v.Value);
File.WriteAllText(_path,
new JavaScriptSerializer { MaxJsonLength = 64 * 1024 * 1024 }.Serialize(src),
new UTF8Encoding(false));
_dirty = false;
}
catch { }
}
}
}
+268
View File
@@ -0,0 +1,268 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using System.Threading;
using System.Windows.Forms;
namespace ScreenTranslator
{
/// <summary>Прогон всего конвейера без окна: захват -> OCR -> блоки -> перевод -> отрисовка в PNG.
/// Нужен, чтобы проверять качество распознавания и вёрстки плашек, не гоняя оверлей поверх
/// рабочего стола и не кликая по нему.
///
/// RenderTest.exe [--in кадр.png] [--out каталог] [--scale 2.0] [--no-translate]
/// </summary>
static class RenderTest
{
[System.Runtime.InteropServices.DllImport("kernel32.dll")]
static extern int GetConsoleProcessList(int[] buffer, int count);
/// <summary>Запущено двойным кликом (своя консоль), а не из терминала — тогда после
/// работы надо дождаться пользователя, иначе окно захлопнется вместе с выводом.</summary>
static bool OwnsConsole()
{
try { return GetConsoleProcessList(new int[4], 4) <= 1; }
catch { return false; }
}
[STAThread]
static int Main(string[] args)
{
Console.OutputEncoding = System.Text.Encoding.UTF8;
bool pause = OwnsConsole();
try { return Run(args); }
catch (Exception ex)
{
Console.WriteLine("\nОшибка: " + ex.Message);
return 1;
}
finally
{
if (pause)
{
Console.WriteLine("\nНажмите Enter, чтобы закрыть окно…");
Console.ReadLine();
}
}
}
/// <summary>Проверка биграммной модели на реальных примерах из прошлых прогонов:
/// слева то, что должно проходить, справа — то, что модель обязана отбраковывать.
/// Порог берётся из этих чисел, а не из общих соображений.</summary>
static int SelfTest()
{
string[] good = {
"Profile", "Cancel", "Settings", "Search", "Mining", "Tailoring", "Engineering",
"Leatherworking", "Consumable", "Trade Goods", "Not equippable", "All Subclasses",
"Have Materials", "Network configuration", "Wireless adapter", "Save changes",
"Connection established", "Restore defaults", "First Aid", "Storage almost full"
};
string[] bad = {
"npocbwlb", "C03AaTb", "Pa3Mep", "npocM0TpeTb", "COPTMPOBaTb", "Bxoaguvqe",
"KOMnb}0Tep", "Meccetqxep", "KonnpoeaTb", "nepenaeH0BaTb", "Bblpe3aTb",
"3TOT KOMnb0Tep", "6b1CTPb1i AOCTyn", "npoeKTb1 Crpeneu", "Pa60ti cron"
};
Console.WriteLine("--- должно проходить ---");
double gMin = double.MaxValue;
foreach (var s in good)
{
double v = EnglishBigrams.TextScore(s);
Console.WriteLine(" {0,8:F3} {1}", v, s);
if (!double.IsNaN(v) && v < gMin) gMin = v;
}
Console.WriteLine("--- должно отбраковываться ---");
double bMax = double.MinValue;
foreach (var s in bad)
{
double v = EnglishBigrams.TextScore(s);
Console.WriteLine(" {0,8:F3} {1}", v, s);
if (!double.IsNaN(v) && v > bMax) bMax = v;
}
Console.WriteLine("\nхудший «хороший»: {0:F3} лучший «плохой»: {1:F3} зазор: {2:F3}",
gMin, bMax, gMin - bMax);
return 0;
}
static int Run(string[] args)
{
if (args.Contains("--selftest")) return SelfTest();
string inPath = Arg(args, "--in", null);
string outDir = Arg(args, "--out", Path.Combine(Path.GetTempPath(), "screen-translator-test"));
double scale = double.Parse(Arg(args, "--scale", "2.0"), System.Globalization.CultureInfo.InvariantCulture);
bool translate = !args.Contains("--no-translate");
Directory.CreateDirectory(outDir);
string err;
var cfg = AppConfig.Load(out err);
cfg.OcrScale = scale;
if (err != null) Console.WriteLine("config: " + err);
var ocr = OcrService.Create(cfg.OcrLanguage);
if (ocr == null) { Console.WriteLine("OCR недоступен"); return 2; }
ocr.Grayscale = args.Contains("--gray");
ocr.Invert = Arg(args, "--invert", "never");
Console.WriteLine("OCR: {0} масштаб: {1} серый: {2} инверсия: {3}",
ocr.LanguageTag, scale, ocr.Grayscale ? "да" : "нет", ocr.Invert);
CapturedFrame frame = inPath != null ? FromFile(inPath) : ScreenCapture.Capture(Native.MonitorUnderCursor());
Console.WriteLine("кадр: {0}x{1}", frame.Width, frame.Height);
var sw = Stopwatch.StartNew();
var lines = ocr.RecognizeAsync(frame, cfg.OcrScale).GetAwaiter().GetResult();
long tOcr = sw.ElapsedMilliseconds;
var sampler = new FrameSampler(frame);
int minWords = int.Parse(Arg(args, "--min-words", cfg.MinWords.ToString()));
var blocks = Blocks.Group(lines, cfg.MinChars, cfg.SkipGarbage, sampler, minWords);
Console.WriteLine("OCR {0} мс: строк {1} -> блоков {2}", tOcr, lines.Count, blocks.Count);
if (cfg.DetectForeign)
{
var other = OcrService.CreateOther(ocr.LanguageTag);
if (other == null) Console.WriteLine("второй модели OCR нет — проверка языка пропущена");
else
{
sw.Restart();
var otherLines = other.RecognizeAsync(frame, cfg.OcrScale).GetAwaiter().GetResult();
var before = new List<TextBlock>(blocks);
Blocks.DropForeign(blocks, otherLines, Blocks.IsCyrillicLanguage(cfg.SourceLang));
Console.WriteLine("проверка моделью «{0}» {1} мс: отброшено {2}, осталось {3}",
other.LanguageTag, sw.ElapsedMilliseconds, before.Count - blocks.Count, blocks.Count);
foreach (var dropped in before.Where(b => !blocks.Contains(b)))
Console.WriteLine(" отброшено: " + Cut(dropped.Source, 70));
}
}
Blocks.ComputeGrowLimits(blocks, lines, new Size(frame.Width, frame.Height));
foreach (var b in blocks)
{
Color bg, fg;
sampler.Sample(b.Rect, out bg, out fg);
b.Bg = bg; b.Fg = fg;
}
long tTr = 0;
if (translate && blocks.Count > 0)
{
var cache = cfg.Cache ? new TranslationCache(cfg) : null;
var chain = new TranslationChain(cfg, cache);
sw.Restart();
var res = chain.TranslateAsync(blocks.Select(b => b.Source).ToArray(), CancellationToken.None)
.GetAwaiter().GetResult();
tTr = sw.ElapsedMilliseconds;
for (int i = 0; i < blocks.Count; i++)
{
if (string.IsNullOrWhiteSpace(res[i])) { blocks[i].Failed = true; continue; }
blocks[i].Translated = res[i];
}
if (cache != null) cache.Save();
Console.WriteLine("перевод {0} мс движком «{1}»{2}", tTr, chain.LastUsed ?? "—",
chain.LastError != null ? " (ошибки: " + chain.LastError + ")" : "");
}
else foreach (var b in blocks) b.Translated = b.Source;
// 1) как видит OCR
using (var dbg = Clone(frame))
using (var g = Graphics.FromImage(dbg))
{
foreach (var b in blocks)
using (var p = new Pen(Color.FromArgb(200, 255, 60, 60)))
g.DrawRectangle(p, b.Rect);
Save(dbg, Path.Combine(outDir, "1-blocks.png"));
}
// 2) итог: перевод поверх оригинала
using (var res = Clone(frame))
using (var g = Graphics.FromImage(res))
{
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
var fonts = new Dictionary<TextBlock, Font>();
var layout = new Dictionary<TextBlock, Rectangle>();
foreach (var b in blocks)
{
if (b.Translated == null) continue;
PlateRenderer.Draw(g, b, cfg, new Size(frame.Width, frame.Height), fonts, layout);
}
foreach (var f in fonts.Values) f.Dispose();
Save(res, Path.Combine(outDir, "2-translated.png"));
}
Console.WriteLine();
foreach (var b in blocks.OrderBy(b => b.Rect.Top).Take(15))
Console.WriteLine(" [{0,4},{1,4} {2,3}x{3,-3} h={4:0.0}] {5}\n -> {6}",
b.Rect.X, b.Rect.Y, b.Rect.Width, b.Rect.Height, b.LineHeight,
Cut(b.Source, 70), Cut(b.Translated ?? "—", 70));
// Замер качества распознавания: сколько заранее известных строк реально нашлось.
// Без такой метрики любые правки препроцессинга оцениваются на глаз, а на глаз
// одинаково убедительно выглядят и улучшение, и ухудшение.
string expect = Arg(args, "--expect", null);
if (expect != null)
{
var want = File.ReadAllLines(expect)
.Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
var all = string.Join(" | ", blocks.Select(b => b.Source));
int hit = 0;
var missed = new List<string>();
foreach (var w in want)
{
if (all.IndexOf(w, StringComparison.OrdinalIgnoreCase) >= 0) hit++;
else missed.Add(w);
}
Console.WriteLine("\nРАСПОЗНАНО: {0} из {1} ({2:P0})", hit, want.Count, (double)hit / want.Count);
if (missed.Count > 0) Console.WriteLine("не найдено: " + string.Join(", ", missed));
}
Console.WriteLine("\nкартинки: " + outDir);
frame.Image.Dispose();
return 0;
}
static string Cut(string s, int n) { return s.Length <= n ? s : s.Substring(0, n - 1) + "…"; }
static string Arg(string[] a, string name, string def)
{
int i = Array.IndexOf(a, name);
return i >= 0 && i + 1 < a.Length ? a[i + 1] : def;
}
static Bitmap Clone(CapturedFrame f)
{
var b = new Bitmap(f.Width, f.Height, PixelFormat.Format32bppArgb);
using (var g = Graphics.FromImage(b)) g.DrawImageUnscaled(f.Image, 0, 0);
return b;
}
static CapturedFrame FromFile(string path)
{
using (var src = new Bitmap(path))
{
var bmp = new Bitmap(src.Width, src.Height, PixelFormat.Format32bppArgb);
using (var g = Graphics.FromImage(bmp)) g.DrawImageUnscaled(src, 0, 0);
var tmp = new CapturedFrame
{
Image = bmp,
Width = bmp.Width,
Height = bmp.Height,
Area = new Rectangle(0, 0, bmp.Width, bmp.Height)
};
// тот же путь извлечения пикселей, что и у экранного захвата
tmp.Pixels = ScreenCapture.Crop(tmp, new Rectangle(0, 0, bmp.Width, bmp.Height)).Pixels;
return tmp;
}
}
static void Save(Bitmap b, string path)
{
b.Save(path, ImageFormat.Png);
Console.WriteLine(" сохранено: " + path);
}
}
}
+677
View File
@@ -0,0 +1,677 @@
TH 116997844
HE 100689263
IN 87674002
ER 77134382
AN 69775179
RE 60923600
ES 57070453
ON 56915252
ST 54018399
NT 50701084
EN 48991276
AT 48274564
ED 46647960
ND 46194306
TO 46115188
OR 45725191
EA 43329810
TI 42888666
AR 42353262
TE 42295813
NG 38567365
AL 38211584
IT 37938534
AS 37773878
IS 37349981
HA 35971841
ET 32872552
SE 31532272
OU 31112284
OF 30540904
LE 30383262
SA 30080131
VE 29320973
RO 29230770
RA 28645577
RI 27634643
HI 27495342
NE 27331675
ME 27237733
DE 27029835
CO 26737101
TA 26147593
EC 25775798
SI 25758841
LL 24636875
SO 23903631
NA 23547524
LI 23291169
LA 23178317
EL 23092248
MA 21828378
DI 21673998
IC 21468412
RT 21456059
NS 21306421
RS 21237259
IO 21210160
OM 21066156
CH 20132750
OT 20088048
CA 19930754
CE 19803619
HO 19729026
BE 19468489
TT 19367472
FO 18923772
TS 18922522
SS 18915696
NO 18894111
EE 18497942
EM 18145294
AC 17904683
IL 17877600
DA 17584055
NI 17452104
UR 17341717
WA 16838794
SH 16773127
EI 16026915
AM 15975981
TR 15821226
DT 15759673
US 15699353
LO 15596310
PE 15573318
UN 15237699
NC 15214623
WI 15213018
UT 15137169
AD 14877234
EW 14776406
OW 14610429
GE 14425023
EP 14024377
AI 13974919
LY 13742031
OL 13726491
FT 13696078
OS 13596265
EO 13524186
EF 13252227
PR 13191182
WE 13185116
DO 13120322
MO 12950768
ID 12896787
IE 12505546
MI 12168944
PA 12068709
FI 11993833
PO 11917535
CT 11888752
WH 11852909
IR 11681353
AY 11523416
GA 11239788
SC 10800636
KE 10650670
EV 10574011
SP 10570626
IM 10544422
OP 10459455
DS 10429887
LD 10245579
UL 10173468
OO 10168856
SU 10031005
IA 10002012
GH 9880399
PL 9812226
EB 9738798
IG 9530574
VI 9380037
IV 9129232
WO 9106647
YO 9088497
RD 9025637
TW 8910254
BA 8867461
AG 8809266
RY 8788539
AB 8775582
LS 8675452
SW 8673234
AP 8553911
FE 8529289
TU 8477495
CI 8446084
FA 8357929
HT 8351551
FR 8339376
AV 8288885
EG 8286463
GO 8188708
BO 8172395
BU 8113271
TY 8008918
MP 7835172
OC 7646952
OD 7610214
EH 7559141
YS 7539621
EY 7528342
RM 7377989
OV 7350014
GT 7347990
YA 7239548
CK 7205091
GI 7103140
RN 7064635
GR 6989963
RC 6974063
BL 6941044
LT 6817273
YT 6714151
OA 6554221
YE 6499305
OB 6212512
DB 6106719
FF 6085519
SF 6073995
RR 5896212
DU 5861311
KI 5814357
UC 5742385
IF 5740414
AF 5702567
DR 5701879
CL 5683204
EX 5649363
SM 5580755
PI 5559210
SB 5553684
CR 5514347
TL 5403137
OI 5336616
RU 5330557
UP 5306948
BY 5232074
TC 5196817
NN 5180899
AK 5137311
SL 4965012
NF 4950333
UE 4927837
DW 4906814
AU 4884168
PP 4873393
UG 4832325
RL 4803246
RG 4645938
BR 4621080
CU 4604045
UA 4589997
DH 4585765
RK 4491400
YI 4461214
LU 4402940
UM 4389720
BI 4356462
NY 4343290
NW 4215967
QU 4169424
OG 4163126
SN 4157990
MB 4121764
VA 4111375
DF 4033878
DD 4001275
MS 3922855
GS 3920675
AW 3918960
NH 3915410
PU 3858148
HR 3843001
SD 3842250
TB 3815459
PT 3812475
NM 3796928
DC 3782481
GU 3768430
TM 3759861
MU 3755834
NU 3732602
MM 3730508
NL 3692985
EU 3674130
WN 3649615
NB 3602692
RP 3588188
DM 3544905
SR 3513808
UD 3499535
UI 3481482
RF 3436232
OK 3397570
YW 3379064
TF 3368452
IP 3348621
RW 3348005
RB 3346212
OH 3254659
KS 3227333
DP 3145043
FU 3138900
YC 3128053
TP 3070427
MT 3055946
DL 3050945
NK 3043200
CC 3026492
UB 2990868
RH 2968706
NP 2968126
JU 2924815
FL 2890839
DN 2840522
KA 2833038
PH 2825344
HU 2771830
JO 2721345
LF 2702522
YB 2696786
RV 2692445
OE 2616308
IB 2598444
IK 2585124
YP 2581863
GL 2576787
LP 2543957
YM 2516273
LB 2463693
HS 2462026
DG 2442139
GN 2426429
EK 2411639
NR 2393580
PS 2377036
TD 2346516
LC 2328063
SK 2321888
YF 2305244
YH 2291273
VO 2253292
AH 2225270
DY 2218040
LM 2216514
SY 2214270
NV 2194534
YD 2122337
FS 2047416
SG 2043770
YR 2021939
YL 2013939
WS 1988727
MY 1949129
OY 1932892
KN 1903836
IZ 1865802
XP 1840696
LW 1836811
TN 1782119
KO 1758001
AA 1721143
JA 1712763
ZE 1709871
FC 1570791
GW 1567991
TG 1530045
XT 1509969
FH 1507604
LR 1505092
JE 1487348
YN 1485655
GG 1468286
GF 1465290
EQ 1461436
HY 1446451
KT 1443985
HC 1441057
BS 1409672
HW 1403223
HN 1383958
CS 1381608
HM 1353001
NJ 1342735
HH 1329998
WT 1301293
GC 1299541
LH 1274048
EJ 1256993
FM 1251312
DV 1238565
LV 1238287
WR 1226755
GP 1215204
FP 1199845
GB 1184377
GM 1178511
HL 1169468
LK 1164186
CY 1145316
MC 1101727
YG 1049082
XI 1024736
HB 1014004
FW 1005903
GY 979804
HP 978649
MW 937621
PM 931225
ZA 929119
LG 926472
IW 922059
XA 904148
FB 888155
SV 882083
GD 879792
IX 879360
AJ 870262
KL 846309
HF 834284
HD 828755
AE 815963
SQ 800346
DJ 799366
FY 789961
AZ 768359
LN 752316
AO 749566
FD 748027
KW 719633
MF 715087
MH 710864
SJ 704442
UF 701892
TV 698150
XC 697995
YU 695512
BB 689158
WW 674610
OJ 661082
AX 660826
MR 660619
WL 657782
XE 653947
KH 650095
OX 650078
UO 649906
ZI 644035
FG 637758
IH 610683
TK 610333
II 607124
IU 576683
TJ 559473
MN 558397
WY 553647
KY 553296
KF 537342
FN 534362
UY 531960
PW 530411
DK 525744
RJ 518157
UK 514873
KR 507020
KU 506618
WM 505687
KM 485617
MD 481126
ML 478528
EZ 465466
KB 457860
WC 448394
WD 432646
HG 429607
BT 428276
ZO 424016
KC 420017
PF 418168
YV 411487
PC 400308
PY 396147
WB 394820
YK 391953
CP 382923
YJ 378679
KP 375653
PB 369336
CD 358435
JI 357577
UW 352732
UH 339341
WF 336213
YY 332973
WP 321746
BC 320380
AQ 315068
CB 298053
IQ 291635
CM 285942
MG 285133
DQ 283314
BJ 282608
TZ 280007
KD 277982
PD 273162
FJ 269865
CF 267630
NZ 266461
CW 257253
FV 244685
VY 233082
FK 228905
OZ 228556
ZZ 221275
IJ 219128
LJ 218362
NQ 217422
UV 212051
XO 211173
PG 211133
HK 210385
KG 209266
VS 204093
HV 197539
BM 191807
HJ 189906
CN 188046
GV 186777
CG 181590
WU 180884
GJ 176947
XH 166599
GK 163830
TQ 159111
CQ 157546
RQ 156933
BH 154489
XS 154347
UZ 153736
WK 148964
XU 147533
UX 144814
BD 141752
BW 140189
WG 139890
MV 136314
MJ 134263
PN 131645
XM 127492
OQ 122677
BV 120081
XW 119322
KK 118811
BP 115161
ZU 113538
RZ 113432
XF 113031
MK 111041
ZH 107639
BN 106125
ZY 105871
HQ 101241
WJ 99435
IY 98361
DZ 98038
VR 96416
ZS 94993
XY 94329
CV 94224
XB 94041
XR 90046
UJ 88168
YQ 87953
VD 85611
PK 83017
VU 82830
JR 80471
ZL 80039
SZ 79840
YZ 78281
LQ 77148
KJ 76816
BF 75352
NX 74844
QA 73527
QI 73387
KV 73184
ZW 68865
WV 63930
UU 63043
VT 62912
VP 62577
XD 60101
GQ 59750
XL 59585
VC 59024
CZ 57914
LZ 57314
ZT 56955
WZ 52836
SX 50975
ZB 50652
VL 49032
PV 48105
FQ 47504
PJ 47043
ZM 46034
VW 45608
CJ 41526
ZC 41037
BG 40516
JS 39326
XG 39289
RX 38654
HZ 37066
XX 35052
VM 35024
XN 34734
QW 34669
JP 34520
VN 33082
ZD 32906
ZR 32685
FZ 31186
XV 31117
ZP 30389
VH 30203
VB 29192
ZF 28658
GZ 28514
TX 28156
VF 28090
DX 27413
QB 27307
BK 26993
ZG 26369
VG 25585
JC 24770
ZK 24262
ZN 24241
UQ 23386
JM 22338
VV 22329
JD 21903
MQ 21358
JH 20960
QS 20847
JT 20408
JB 19380
FX 19313
PQ 18607
MZ 18271
YX 16945
QT 16914
WQ 16245
JJ 16085
JW 16083
LX 15467
GX 14778
JN 14452
ZV 14339
MX 14250
JK 13967
KQ 13905
XK 13651
JF 12640
QM 12315
QH 12273
JL 12149
JG 12023
VK 11469
VJ 11432
KZ 11192
QC 10667
XJ 10629
PZ 9697
QL 9603
QO 9394
JV 8925
QF 8778
QD 8678
BZ 8132
HX 7526
ZJ 7167
PX 6814
QP 6062
QE 6020
QR 5975
ZQ 5773
JY 5723
BQ 5513
XQ 5416
CX 5300
KX 5083
WX 4678
QY 4557
QV 4212
QN 3808
VX 3192
BX 3021
JZ 2859
VZ 2633
QG 2567
QQ 2499
ZX 2463
XZ 2082
QK 2023
VQ 1488
QJ 1342
QX 765
JX 747
JQ 722
QZ 280
+14
View File
@@ -0,0 +1,14 @@
Mining
Tailoring
Cooking
Engineering
First Aid
Leatherworking
Search
Has Skill Up
Have Materials
Trade Goods
Not equippable
All Subclasses
All Slots
Consumable
+123
View File
@@ -0,0 +1,123 @@
<#
Превращает частотную таблицу английских биграмм в компактный C#-файл.
Каждая из 676 биграмм кодируется одним печатным ASCII-символом (квантованный log10
вероятности), поэтому вся модель — одна строка примерно в 700 байт вместо мегабайтных
словарей и без единой внешней зависимости.
Источник частот: practicalcryptography.com (корпус английских текстов).
#>
param(
[string]$In = "$PSScriptRoot\english_bigrams.txt",
[string]$Out = "$PSScriptRoot\..\src\EnglishBigrams.cs"
)
$ErrorActionPreference = 'Stop'
$counts = @{}
[double]$total = 0
foreach ($line in Get-Content $In) {
if ($line -notmatch '^\s*([A-Za-z]{2})\s+(\d+)\s*$') { continue }
$bg = $Matches[1].ToUpperInvariant()
$n = [double]$Matches[2]
$counts[$bg] = $n
$total += $n
}
Write-Host "биграмм: $($counts.Count), суммарная частота: $total"
# Квантование: log10(p) укладываем в печатный ASCII с шагом 1/12 порядка величины.
$LO = -9.0; $STEP = 12.0
$sb = New-Object Text.StringBuilder
$minLog = 0.0
foreach ($i in 0..25) {
foreach ($j in 0..25) {
$bg = [char](65 + $i) + [string][char](65 + $j)
$n = if ($counts.ContainsKey($bg)) { $counts[$bg] } else { 0 }
# ненаблюдаемой биграмме даём вероятность заметно ниже самой редкой наблюдаемой
$p = if ($n -gt 0) { $n / $total } else { 1e-9 }
$lg = [Math]::Log10($p)
if ($lg -lt $minLog) { $minLog = $lg }
$q = [int][Math]::Round(($lg - $LO) * $STEP)
if ($q -lt 0) { $q = 0 }
if ($q -gt 88) { $q = 88 }
[void]$sb.Append([char](34 + $q)) # с 34, чтобы не попасть на кавычку и обратный слэш
}
}
Write-Host "минимальный log10(p): $minLog"
$data = $sb.ToString()
$chunks = @()
for ($i = 0; $i -lt $data.Length; $i += 130) {
$len = [Math]::Min(130, $data.Length - $i)
$chunks += ' "' + $data.Substring($i, $len).Replace('\', '\\') + '"'
}
$joined = $chunks -join " +`r`n"
$code = @"
using System;
namespace ScreenTranslator
{
/// <summary>Модель правдоподобия английского текста по частотам буквенных пар.
///
/// Решает задачу, с которой не справляются ни словарь, ни алфавит: английская модель OCR,
/// прочитав русскую надпись, выдаёт правдоподобное на вид латинское слово («Профиль»
/// «npocbwlb»). Но пары букв выдают подделку сразу сочетаний «wl», «cb», «bw» в английском
/// практически не бывает, тогда как «th», «he», «in» составляют его основу.
///
/// Таблица 676 квантованных log10-вероятностей, по символу на пару, около 700 байт.
/// Сгенерирована tools/make-bigrams.ps1 из частот корпуса английских текстов
/// (practicalcryptography.com). Правится только генератором, не руками.</summary>
internal static class EnglishBigrams
{
const double Lo = $LO, Step = $STEP;
const string Table =
$joined;
/// <summary>Средний log10 вероятности пар букв в слове. Около -2,5 у обычного английского
/// слова, ниже -4 у случайного набора букв. NaN, если букв слишком мало для оценки.</summary>
public static double WordScore(string word)
{
if (string.IsNullOrEmpty(word)) return double.NaN;
double sum = 0; int n = 0;
for (int i = 0; i + 1 < word.Length; i++)
{
int a = Index(word[i]), b = Index(word[i + 1]);
if (a < 0 || b < 0) continue;
sum += Lo + (Table[a * 26 + b] - 34) / Step;
n++;
}
return n == 0 ? double.NaN : sum / n;
}
static int Index(char c)
{
if (c >= 'a' && c <= 'z') return c - 'a';
if (c >= 'A' && c <= 'Z') return c - 'A';
return -1;
}
/// <summary>Оценка текста целиком: усреднение по словам длиной от четырёх букв
/// на более коротких пары букв ничего не доказывают. NaN, если оценивать нечего.</summary>
public static double TextScore(string text)
{
if (string.IsNullOrEmpty(text)) return double.NaN;
double sum = 0; int n = 0;
foreach (var w in text.Split(new[] { ' ', '\t', '\n', '\r', '.', ',', ':', ';', '!', '?',
'(', ')', '[', ']', '"', '\'', '/', '\\', '-' },
StringSplitOptions.RemoveEmptyEntries))
{
if (w.Length < 4) continue;
double s = WordScore(w);
if (double.IsNaN(s)) continue;
sum += s; n++;
}
return n == 0 ? double.NaN : sum / n;
}
}
}
"@
$code | Set-Content $Out -Encoding UTF8
Write-Host "записано: $Out ($($data.Length) символов таблицы)"
+85
View File
@@ -0,0 +1,85 @@
<#
Эталонный кадр с РУССКИМ интерфейсом.
Нужен как негативный тест: английская модель OCR читает кириллицу как бессмыслицу,
и эту бессмыслицу нельзя ни переводить, ни закрывать плашками. На таком кадре
правильный результат — ноль переведённых блоков.
#>
param([string]$Out = "$PSScriptRoot\..\dist\sample-ru.png")
Add-Type -AssemblyName System.Drawing
$W, $H = 1600, 900
$bmp = New-Object Drawing.Bitmap($W, $H, [Drawing.Imaging.PixelFormat]::Format32bppArgb)
$g = [Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = 'AntiAlias'
$g.TextRenderingHint = 'ClearTypeGridFit'
function Brush($r, $gg, $b) { New-Object Drawing.SolidBrush ([Drawing.Color]::FromArgb($r, $gg, $b)) }
function Fnt($size, $style = 'Regular') { New-Object Drawing.Font('Segoe UI', $size, [Drawing.FontStyle]::$style, [Drawing.GraphicsUnit]::Pixel) }
function Text($s, $font, $brush, $x, $y) { $g.DrawString($s, $font, $brush, [single]$x, [single]$y) }
$g.FillRectangle((Brush 32 34 42), 0, 0, $W, $H)
$g.FillRectangle((Brush 24 26 32), 0, 0, 260, $H)
$white = Brush 236 238 243
$grey = Brush 150 155 168
$body = Brush 205 210 220
# боковая панель — короткие подписи, на них и сыпался фильтр
Text 'Быстрый доступ' (Fnt 12) $grey 20 18
Text 'Рабочий стол' (Fnt 14) $white 20 46
Text 'Загрузки' (Fnt 14) $white 20 76
Text 'Документы' (Fnt 14) $white 20 106
Text 'Изображения' (Fnt 14) $white 20 136
Text 'Этот компьютер' (Fnt 14) $white 20 176
Text 'Проекты' (Fnt 14) $white 20 206
Text 'Стрелец' (Fnt 14) $white 20 236
# панель инструментов
Text 'Создать' (Fnt 13) $white 290 20
Text 'Вырезать' (Fnt 13) $white 370 20
Text 'Копировать' (Fnt 13) $white 460 20
Text 'Переименовать' (Fnt 13) $white 570 20
Text 'Сортировать' (Fnt 13) $white 700 20
Text 'Просмотреть' (Fnt 13) $white 810 20
# заголовки таблицы
Text 'Имя' (Fnt 13) $grey 290 70
Text 'Дата изменения' (Fnt 13) $grey 600 70
Text 'Тип' (Fnt 13) $grey 820 70
Text 'Размер' (Fnt 13) $grey 980 70
# строки таблицы: имена файлов латиницей рядом с русскими типами
$rows = @(
@('config.json', '27.07.2026 13:05', 'Файл "JSON"', '3 КБ'),
@('cache.json', '27.07.2026 13:17', 'Файл "JSON"', '18 КБ'),
@('sample.png', '27.07.2026 13:05', 'Файл "PNG"', '41 КБ'),
@('ScreenTranslator.exe', '27.07.2026 13:17', 'Приложение', '81 КБ')
)
$y = 104
foreach ($r in $rows) {
Text $r[0] (Fnt 14) $body 290 $y
Text $r[1] (Fnt 13) $body 600 $y
Text $r[2] (Fnt 13) $body 820 $y
Text $r[3] (Fnt 13) $body 980 $y
$y += 30
}
# связный русский абзац
$para = @(
'Выберите файлы, которые нужно переместить в архив. После подтверждения',
'операция будет выполнена в фоновом режиме, а исходные файлы останутся',
'на месте до завершения проверки контрольных сумм.'
)
$y = 300
foreach ($line in $para) { Text $line (Fnt 15) $body 290 $y; $y += 26 }
Text 'Элементов: 6' (Fnt 12) $grey 290 420
Text 'Выбран 1 элемент: 73,5 КБ' (Fnt 12) $grey 420 420
$g.Dispose()
New-Item -ItemType Directory -Force -Path (Split-Path $Out -Parent) | Out-Null
$bmp.Save($Out, [Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
Write-Host "Русский эталонный кадр: $Out"
+86
View File
@@ -0,0 +1,86 @@
<#
Эталонный кадр со связной прозой: абзацы разной длины, заголовки, список, подпись.
Проверяет главное требование к переводу — что абзац остаётся ЦЕЛЫМ блоком и переводится
связно, а не рассыпается на отдельные строки. Признаки разрыва блоков (граница, фон,
fill-ratio) настраивались на плотных интерфейсах, и этот кадр страхует от перекоса
в другую сторону.
#>
param([string]$Out = "$PSScriptRoot\..\dist\sample-text.png")
Add-Type -AssemblyName System.Drawing
$W, $H = 1400, 900
$bmp = New-Object Drawing.Bitmap($W, $H, [Drawing.Imaging.PixelFormat]::Format32bppArgb)
$g = [Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = 'AntiAlias'
$g.TextRenderingHint = 'ClearTypeGridFit'
function Brush($r, $gg, $b) { New-Object Drawing.SolidBrush ([Drawing.Color]::FromArgb($r, $gg, $b)) }
function Fnt($size, $style = 'Regular') { New-Object Drawing.Font('Georgia', $size, [Drawing.FontStyle]::$style, [Drawing.GraphicsUnit]::Pixel) }
$g.FillRectangle((Brush 252 251 248), 0, 0, $W, $H)
$dark = Brush 26 26 28
$body = Brush 52 54 58
$grey = Brush 130 134 140
$y = 60
$g.DrawString('The Lighthouse at the End of the Road', (Fnt 34 'Bold'), $dark, 90, $y)
$y += 56
$g.DrawString('A short account of the winter crossing, 1897', (Fnt 17 'Italic'), $grey, 90, $y)
$y += 54
# абзац 1 — длинный, шесть строк
$p1 = @(
'The keeper had lived alone on the rock for eleven years, and in that time he had',
'learned to read the weather the way other men read a newspaper. He knew that a',
'certain thinness in the light before noon meant the wind would turn by evening,',
'and that gulls settling early on the western ledge were worth more than any',
'barometer. When the supply boat failed to arrive in the first week of December,',
'he did not worry. When it failed again in the second week, he began to count.'
)
foreach ($line in $p1) { $g.DrawString($line, (Fnt 19), $body, 90, $y); $y += 30 }
$y += 26
# абзац 2 — средний, три строки
$p2 = @(
'There were four barrels of oil remaining, and rather less flour than he would have',
'liked. He wrote the numbers in the margin of the log, where the inspector would not',
'think to look for them, and then he climbed the stairs to light the lamp.'
)
foreach ($line in $p2) { $g.DrawString($line, (Fnt 19), $body, 90, $y); $y += 30 }
$y += 26
# подзаголовок
$g.DrawString('What the log recorded', (Fnt 22 'Bold'), $dark, 90, $y)
$y += 40
# список — короткие пункты, НЕ должны склеиться в один абзац
$items = @(
'Fog signal sounded for nine hours without interruption.',
'The eastern glazing cracked but did not give way.',
'No vessel sighted between the fourth and the nineteenth.'
)
foreach ($line in $items) {
$g.FillEllipse((Brush 150 150 155), 92, ($y + 9), 5, 5)
$g.DrawString($line, (Fnt 18), $body, 112, $y)
$y += 32
}
$y += 24
# абзац 3 — короткий, две строки
$p3 = @(
'By the time the boat came, on the second morning of the new year, the lamp had',
'not failed once. This, he felt, was the only fact that deserved recording.'
)
foreach ($line in $p3) { $g.DrawString($line, (Fnt 19), $body, 90, $y); $y += 30 }
$y += 30
$g.DrawString('Transcribed from the original manuscript', (Fnt 15 'Italic'), $grey, 90, $y)
$g.Dispose()
New-Item -ItemType Directory -Force -Path (Split-Path $Out -Parent) | Out-Null
$bmp.Save($Out, [Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
Write-Host "Эталон со связным текстом: $Out"
Write-Host "Ожидание: 3 абзаца целыми блоками, заголовки и пункты списка отдельно."
+101
View File
@@ -0,0 +1,101 @@
<#
Рисует эталонный кадр с англоязычным интерфейсом: мелкое меню, заголовки, абзац,
кнопки, светлая и тёмная зоны. Нужен, чтобы прогонять конвейер на предсказуемом входе.
#>
param([string]$Out = "$PSScriptRoot\..\dist\sample.png")
Add-Type -AssemblyName System.Drawing
$W, $H = 1600, 900
$bmp = New-Object Drawing.Bitmap($W, $H, [Drawing.Imaging.PixelFormat]::Format32bppArgb)
$g = [Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = 'AntiAlias'
$g.TextRenderingHint = 'ClearTypeGridFit'
function Brush($r, $gg, $b) { New-Object Drawing.SolidBrush ([Drawing.Color]::FromArgb($r, $gg, $b)) }
function Fnt($size, $style = 'Regular') { New-Object Drawing.Font('Segoe UI', $size, [Drawing.FontStyle]::$style, [Drawing.GraphicsUnit]::Pixel) }
function Text($s, $font, $brush, $x, $y) { $g.DrawString($s, $font, $brush, [single]$x, [single]$y) }
# фон приложения
$g.FillRectangle((Brush 250 250 252), 0, 0, $W, $H)
# сайдбар
$g.FillRectangle((Brush 32 34 42), 0, 0, 240, $H)
# верхняя панель
$g.FillRectangle((Brush 255 255 255), 240, 0, $W - 240, 56)
$g.DrawLine((New-Object Drawing.Pen(([Drawing.Color]::FromArgb(226, 228, 233)))), 240, 56, $W, 56)
$white = Brush 236 238 243
$grey = Brush 150 155 168
$dark = Brush 28 30 36
$body = Brush 70 74 84
$blue = Brush 46 108 220
# --- сайдбар: мелкий текст на тёмном ---
Text 'Workspace' (Fnt 12) $grey 20 18
Text 'Dashboard' (Fnt 14) $white 20 46
Text 'Recent files' (Fnt 14) $white 20 76
Text 'Shared with me' (Fnt 14) $white 20 106
Text 'Notifications' (Fnt 14) $white 20 136
Text 'Trash' (Fnt 14) $white 20 166
Text 'Storage almost full' (Fnt 12) $grey 20 210
Text 'Upgrade your plan to continue' (Fnt 12) $grey 20 230
# --- верхняя панель ---
Text 'Project settings' (Fnt 15 'Bold') $dark 264 18
Text 'Save changes' (Fnt 13) $blue 1360 20
Text 'Cancel' (Fnt 13) $body 1480 20
# --- заголовок и абзац ---
Text 'Network configuration' (Fnt 30 'Bold') $dark 264 100
Text 'Choose how this device connects to the internet.' (Fnt 16) $body 264 148
$para = @(
'When automatic configuration is enabled, the application selects the fastest available',
'connection and switches between them without interrupting downloads. Manual mode gives',
'you full control over the proxy address, port and authentication method, but requires',
'the settings to be updated whenever the server changes.'
)
$y = 200
foreach ($line in $para) { Text $line (Fnt 15) $body 264 $y; $y += 26 }
# --- карточка со списком ---
$g.FillRectangle((Brush 255 255 255), 264, 330, 700, 250)
$g.DrawRectangle((New-Object Drawing.Pen(([Drawing.Color]::FromArgb(224, 226, 232)))), 264, 330, 700, 250)
Text 'Connection status' (Fnt 17 'Bold') $dark 288 350
Text 'Interface' (Fnt 13) $grey 288 392
Text 'Wireless adapter' (Fnt 14) $dark 288 412
Text 'IP address' (Fnt 13) $grey 288 448
Text 'Assigned automatically' (Fnt 14) $dark 288 468
Text 'Last error' (Fnt 13) $grey 288 504
Text 'The remote server did not respond in time' (Fnt 14) $dark 288 524
# --- кнопки ---
$g.FillRectangle((Brush 46 108 220), 264, 620, 170, 40)
Text 'Apply and restart' (Fnt 14) (Brush 255 255 255) 282 631
$g.FillRectangle((Brush 238 240 244), 450, 620, 130, 40)
Text 'Restore defaults' (Fnt 14) $body 464 631
# --- тёмная плашка с предупреждением ---
$g.FillRectangle((Brush 58 30 30), 264, 700, 700, 90)
Text 'Warning' (Fnt 15 'Bold') (Brush 255 180 170) 288 716
Text 'Changing these values may disconnect active sessions.' (Fnt 14) (Brush 240 200 195) 288 742
Text 'Make sure no downloads are running before you continue.' (Fnt 14) (Brush 240 200 195) 288 764
# --- правая колонка, мелкий текст ---
Text 'Activity' (Fnt 15 'Bold') $dark 1020 100
$log = @(
'Connection established',
'Certificate verified',
'Profile synchronised',
'Background sync paused',
'Waiting for network'
)
$y = 140
foreach ($line in $log) { Text $line (Fnt 12) $body 1020 $y; $y += 22 }
$g.Dispose()
$dir = Split-Path $Out -Parent
New-Item -ItemType Directory -Force -Path $dir | Out-Null
$bmp.Save($Out, [Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
Write-Host "Эталонный кадр: $Out"
+87
View File
@@ -0,0 +1,87 @@
ПЕРЕВОДЧИК ЭКРАНА
=================
Накладывает русский перевод прямо поверх английского текста на экране —
как Яндекс.Переводчик по фото, только для монитора. Работает с чем угодно:
игры, сайты, программы, PDF.
КАК ЗАПУСТИТЬ
-------------
1. Распакуй папку куда угодно (например, на рабочий стол).
2. Запусти ScreenTranslator.exe
3. Windows скажет «SmartScreen защитил ваш компьютер» — это потому, что
у файла нет платной подписи. Жми «Подробнее» → «Выполнить в любом случае».
4. Появится значок в трее (синий кружок «AЯ», рядом с часами). Всё, работает.
Окна никакого нет и не должно быть — программа ждёт горячую клавишу.
КАК ПОЛЬЗОВАТЬСЯ
----------------
Ctrl + Alt + Z перевести тот монитор, где сейчас курсор
Ctrl + Alt + X обвести рамкой кусок экрана и перевести только его
Esc закрыть перевод (или ещё раз Ctrl+Alt+Z)
пробел пока держишь — видно оригинал под переводом
клик по тексту скопировать перевод в буфер
Ctrl + C скопировать весь перевод
Как это выглядит: экран замирает, английские надписи закрываются плашками
под цвет фона, на них русский текст. Переведённые места подчёркнуты тонкой
синей линией. Перевод занимает пару секунд, всё это время видно, что
программа работает.
В игре лучше оконный или безрамочный режим — в полноэкранном Windows
не даёт рисовать поверх.
ЧТО ХОЧЕТСЯ ПРОВЕРИТЬ
---------------------
Главное — связный текст: описания квестов, книги, диалоги, подсказки.
Он должен переводиться целыми фразами, а не по отдельным словам.
Ещё интересно:
- меню и панели (окна профессий, настройки, инвентарь);
- не наезжают ли плашки на соседние надписи;
- успевает ли по скорости.
Если что-то пойдёт не так — нажми PrintScreen прямо поверх перевода
(оверлей от этого не закроется) и скинь скриншот целиком, не обрезая.
По полному кадру видно, где именно проблема.
ЧТО УЖЕ ИЗВЕСТНО
----------------
- Названия переводятся буквально: Steam → «Пар», Discord → «Раздор».
Отличить название от обычного слова программа не может, это нормально.
- Рабочий стол и панель задач переводить бесполезно — там одни названия.
- Совсем мелкие надписи (8 пикселей, вкладки браузера) распознаются плохо.
- Русский текст на экране программа старается не трогать, но иногда
что-то проскакивает.
ЕСЛИ НЕ РАБОТАЕТ
----------------
Ничего не происходит по Ctrl+Alt+Z
Комбинацию мог занять кто-то другой (часто это Яндекс.Диск или Punto).
Открой config.json блокнотом, поменяй hotkeyTranslate на другую,
например "Ctrl+Alt+Q", потом правый клик по значку в трее →
«Перечитать настройки».
Пишет, что нет распознавания текста
Нужен языковой компонент Windows. Параметры → Время и язык →
Язык и регион → у английского языка «...» → Компоненты языка →
поставить галочку «Распознавание текста». Обычно он уже стоит.
Не переводит, ругается на сеть
Правый клик по значку в трее → «Проверить переводчики». Покажет,
какой из трёх сервисов отвечает, а какой нет. Скинь этот результат.
Настройки — файл config.json рядом с программой, открывается блокнотом.
После правки: значок в трее → «Перечитать настройки», перезапуск не нужен.