From 7c6b182402161a125a42ff308cf87442e911ad48 Mon Sep 17 00:00:00 2001 From: gusadmin Date: Mon, 27 Jul 2026 15:54:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D1=87=D0=B8=D0=BA=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0:=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=D0=B0=20=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=85=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Программа накладывает русский перевод прямо на английские надписи — как переводчик по фото, только для монитора. Работает с играми, интерфейсами и документами: распознаёт кадр, собирает текст в связные блоки, переводит и рисует плашки под цвет фона. Распознавание — встроенный Windows.Media.Ocr, офлайн. Перевод — цепочка движков с автопереключением (google, lingva, mymemory), потому что до разных сервисов из разных сетей достучаться получается по-разному. Собирается без .NET SDK: Roslyn из Build Tools плюс сборки Framework 4.8, которые уже есть в системе. На выходе один exe без установщика. Главная сложность оказалась не в переводе, а в том, чтобы понять, где на экране связный текст, а где отдельные надписи. Классические алгоритмы сегментации решают это по межстрочному зазору, но в плотном интерфейсе он одинаков у строк абзаца и у соседних контролов. Поэтому решение принимает ансамбль признаков: фон, граница между строками, заполненность строки, кегль. Надписи на чужом языке отсеиваются тремя уровнями — форма слова, вторая модель распознавания и частоты буквенных пар. Чтобы правки эвристик не оценивались на глаз, в tools лежат эталонные кадры и RenderTest: прогон конвейера без окна с подсчётом попаданий. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/build.yaml | 159 +++++++++ .gitignore | 16 + LICENSE | 21 ++ README.md | 241 +++++++++++++ app.manifest | 29 ++ build.ps1 | 126 +++++++ src/AppConfig.cs | 339 ++++++++++++++++++ src/Blocks.cs | 623 +++++++++++++++++++++++++++++++++ src/EnglishBigrams.cs | 68 ++++ src/EnglishWords.cs | 87 +++++ src/Native.cs | 118 +++++++ src/Ocr.cs | 222 ++++++++++++ src/OverlayForm.cs | 272 +++++++++++++++ src/PlateRenderer.cs | 147 ++++++++ src/Program.cs | 398 +++++++++++++++++++++ src/RegionSelector.cs | 143 ++++++++ src/ScreenCapture.cs | 86 +++++ src/Translators.cs | 625 +++++++++++++++++++++++++++++++++ tools/RenderTest.cs | 268 ++++++++++++++ tools/english_bigrams.txt | 677 ++++++++++++++++++++++++++++++++++++ tools/expect-wow.txt | 14 + tools/make-bigrams.ps1 | 123 +++++++ tools/make-sample-ru.ps1 | 85 +++++ tools/make-sample-text.ps1 | 86 +++++ tools/make-sample.ps1 | 101 ++++++ ЧИТАЙ МЕНЯ.txt | 87 +++++ 26 files changed, 5161 insertions(+) create mode 100644 .gitea/workflows/build.yaml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app.manifest create mode 100644 build.ps1 create mode 100644 src/AppConfig.cs create mode 100644 src/Blocks.cs create mode 100644 src/EnglishBigrams.cs create mode 100644 src/EnglishWords.cs create mode 100644 src/Native.cs create mode 100644 src/Ocr.cs create mode 100644 src/OverlayForm.cs create mode 100644 src/PlateRenderer.cs create mode 100644 src/Program.cs create mode 100644 src/RegionSelector.cs create mode 100644 src/ScreenCapture.cs create mode 100644 src/Translators.cs create mode 100644 tools/RenderTest.cs create mode 100644 tools/english_bigrams.txt create mode 100644 tools/expect-wow.txt create mode 100644 tools/make-bigrams.ps1 create mode 100644 tools/make-sample-ru.ps1 create mode 100644 tools/make-sample-text.ps1 create mode 100644 tools/make-sample.ps1 create mode 100644 ЧИТАЙ МЕНЯ.txt diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..b68bf8c --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6de6e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Сборка и готовый пакет — собираются из исходников, в репозитории не нужны. +# Важно: в dist/ по ходу отладки оседают скриншоты рабочего стола и cache.json +# с текстом, который был на экране, — этому в публичном репозитории точно не место. +dist/ +release/ +*.zip + +# Побочные файлы работающей программы +config.json +cache.json + +*.exe +*.pdb +*.user +Thumbs.db +desktop.ini diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1983d7b --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5085a54 --- /dev/null +++ b/README.md @@ -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 обводит только тело букв, без выносных элементов, поэтому плашка расширяется + по вертикали — иначе сверху и снизу торчат хвосты оригинала. +* Строки склеиваются в абзац по межстрочному интервалу и одинаковому кеглю: рваные строки + переводятся заметно хуже целого абзаца. diff --git a/app.manifest b/app.manifest new file mode 100644 index 0000000..50f1977 --- /dev/null +++ b/app.manifest @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + true/pm + PerMonitorV2 + true + + + diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..dc61204 --- /dev/null +++ b/build.ps1 @@ -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 "Запущен. Значок в трее." +} diff --git a/src/AppConfig.cs b/src/AppConfig.cs new file mode 100644 index 0000000..3088ffa --- /dev/null +++ b/src/AppConfig.cs @@ -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 +{ + /// Настройки из config.json рядом с exe. Разбор ручной и снисходительный: + /// незнакомые ключи игнорируются, кривое значение откатывается к дефолту, приложение + /// не должно падать из-за опечатки в конфиге. + 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"; + /// Увеличение кадра перед OCR. Мелкий шрифт интерфейса на 1.0 читается плохо, + /// 2.0 заметно точнее, но дороже по времени. + public double OcrScale = 2.0; + + /// frozen — оверлей показывает замороженный кадр экрана (стабильно, дефолт); + /// live — только плашки поверх живого экрана. + public string OverlayMode = "frozen"; + + /// monitor — монитор под курсором; all — весь виртуальный рабочий стол. + public string CaptureScope = "monitor"; + + /// Порядок движков перевода. Первый живой выигрывает, дохлые запоминаются. + public List Chain = new List { "google", "lingva", "mymemory" }; + + public int TimeoutMs = 7000; + public int MaxParallel = 6; + /// Пустая строка — прямое соединение. Формат: http://host:port (sing-box mixed подходит). + 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; + /// Насколько плашке разрешено вылезти за исходный бокс вправо/вниз, доля ширины. + /// Русский в среднем на 15–20 % длиннее английского, без запаса шрифт мельчает. + public double GrowRatio = 0.35; + public bool DimBackground = false; + public bool Cache = true; + public bool ShowTrayBalloonOnError = true; + /// Строки короче — мусор OCR, не переводим (номера строк, иконки, одиночные буквы). + public int MinChars = 2; + /// Отсеивать блоки, не похожие на связный текст (обычно это надписи на другом + /// языке, которые модель OCR прочитала как бессмыслицу). + public bool SkipGarbage = true; + /// Минимум слов в блоке, чтобы его переводить. 2 отключает одиночные слова — + /// на рабочем столе и панелях задач это сплошь названия программ, которые переводить + /// не нужно и вредно («Discord» → «Раздор»). + public int MinWords = 1; + /// Дополнительно распознавать кадр моделью другого языка и не переводить фрагменты, + /// которые она читает осмысленнее. Так надписи на языке системы остаются нетронутыми. + /// Стоит примерно вдвое дольше распознавания. + public bool DetectForeign = true; + /// Отмечать переведённые места тонкой линией — иначе плашку под цвет фона + /// не отличить от нетронутого текста. + 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>(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(); + 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 Sub(Dictionary d, string key) + { + object v; + if (d != null && d.TryGetValue(key, out v)) return v as Dictionary; + return null; + } + + static string Str(Dictionary d, string k, string def) + { + object v; + return d.TryGetValue(k, out v) && v != null ? v.ToString() : def; + } + + static int Int(Dictionary 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 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 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; + } + + /// JSON комментариев не знает, но человек в конфиге их пишет — вырезаем, + /// аккуратно не трогая // внутри строковых литералов (например, в url). + 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"); + } + + // ---- разбор строки горячей клавиши ---- + + /// "Ctrl+Alt+Z" -> модификаторы + virtual-key. false, если строка непонятна. + 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"" } + } +} +"; + } +} diff --git a/src/Blocks.cs b/src/Blocks.cs new file mode 100644 index 0000000..8fa5d01 --- /dev/null +++ b/src/Blocks.cs @@ -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 +{ + /// Связный кусок текста на экране (абзац, пункт меню, реплика) — единица перевода + /// и одна плашка в оверлее. + 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; + /// Куда реально нарисована плашка — нужно для попадания мышью. + public Rectangle DrawnRect; + /// Предел разрастания плашки: дальше начинается соседний текст. + /// Пустой — ограничений нет, кроме краёв кадра. + public Rectangle GrowLimit; + } + + internal static class Blocks + { + /// Склеить OCR-строки в абзацы. Критерии соседства: строки идут подряд по вертикали, + /// заметно перекрываются по горизонтали и имеют сопоставимый кегль. Так пункты меню в разных + /// углах экрана не слипаются в один блок, а абзац текста не рассыпается на строки. + public static List Group(List 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>(); + var done = new List>(); + + foreach (var line in src) + { + double h = LineH(line); + List 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 { line }); + } + done.AddRange(open); + + var result = new List(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; + } + + /// Собрать обратно строку, которую распознавание разрезало по широким пробелам. + /// + /// В играх с моноширинным или пиксельным шрифтом промежутки между словами широкие, и OCR + /// отдаёт «SPACE/CLICK», «HERE», «TO CONTINUE» тремя отдельными строками. Переведённые + /// порознь, они превращаются в бессмыслицу, поэтому фрагменты на одной базовой линии + /// нужно склеить до того, как строки начнут собираться в абзацы. + /// + /// Ограничения намеренно жёсткие: соседние элементы интерфейса тоже стоят в одну строку + /// («Search» — «Has Skill Up» — «Have Materials»), и склеить их было бы хуже, чем + /// оставить фразу разрезанной. + static List MergeHorizontally(List lines, FrameSampler sampler) + { + var byX = lines.OrderBy(l => l.Rect.Left).ToList(); + var used = new bool[byX.Count]; + var result = new List(); + + 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 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(); + + /// Насколько текст похож на осмысленный: доля слов без следов покорёженного + /// распознавания. 1.0 — чистый текст, около 0.5 и ниже — каша. + 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; + } + + /// Выбросить блоки, написанные не на том языке, под который выбрана модель OCR. + /// + /// Статистика по одному тексту тут бессильна: «npo cauyyn qnmy» из букв, которые модель + /// подобрала к кириллице, по форме неотличимо от английского. Зато если тот же участок + /// экрана прочитать второй моделью и он окажется заметно осмысленнее — вопрос закрыт, + /// это текст на её языке, и переводить его не нужно. + public static void DropForeign(List blocks, List 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; + }); + } + + /// Пишется ли язык кириллицей — нужно, чтобы понимать, какой алфавит ожидать + /// от текста на языке оригинала. + /// Посчитать, докуда каждой плашке позволено разрастаться, чтобы не залезть + /// на соседний текст. + /// + /// Перевод почти всегда длиннее оригинала, и плашке приходится расти. В разреженном + /// интерфейсе это незаметно, но в плотном (окна игры, таблицы) разросшаяся плашка + /// перекрывает соседние подписи — читаемым становится один фрагмент ценой двух соседних. + public static void ComputeGrowLimits(List blocks, List allLines, Size canvas) + { + // Препятствие — любой распознанный текст, а не только то, что мы переводим. + // Отфильтрованные строки (числа, надписи на другом языке) с экрана никуда не делись: + // заголовок, разросшийся на вторую строку, ложился ровно на них. + var obstacles = new List(); + 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; + } + } + + /// Похоже ли это на настоящий текст, а не на кашу распознавания. + /// + /// На экране почти всегда есть надписи не на том языке, для которого выбрана модель OCR: + /// английская модель, встретив кириллицу, выдаёт нечто вроде «gT0 ffileals Ha MauiHHe». + /// Такую кашу бессмысленно переводить — она только засоряет экран плашками и жжёт лимиты + /// переводчика. Отличаем по статистике: в настоящем тексте почти в каждом слове есть + /// гласная, мало цифр внутри слов и высокая доля букв. + /// + /// Короткие подписи (OK, PVE, 45%) пропускаем без проверки — статистика на них не работает. + 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; + } + + /// Признаки слова, покорёженного распознаванием чужого алфавита: цифра, зажатая + /// между букв («Pa3Mep»), и заглавная сразу после строчной («COPTMPOBaTb»). + /// + /// Оба признака встречаются и в нормальном тексте — CamelCase, «IPv6», «UTF-8», — поэтому + /// решение принимается не по слову, а по их доле в блоке. + 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; + } + + /// Имя файла, путь или адрес — переводить их не нужно, «cache.json» не должен + /// превращаться в «кэш.json». + 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(); + } + + /// Отсев мусора OCR: рамки, иконки, одиночные символы, голые числа — + /// переводить нечего, а плашка поверх них только мешает. + 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); + } + } + + /// Отвечает на вопрос «какого цвета фон и текст в этом прямоугольнике» — чтобы плашка + /// совпала с окном под ней. Работает по копии пикселей из CapturedFrame, поэтому безопасен + /// в фоновом потоке параллельно с отрисовкой оверлея. + 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; + } + + /// Разброс средней яркости строк пикселей в горизонтальной полосе. + /// + /// Отвечает на вопрос «есть ли между двумя строками текста визуальная граница»: рамка поля + /// или край панели тянутся во всю ширину и дают резкий скачок, а промежуток внутри абзаца + /// однороден — выносные элементы букв среднюю по строке почти не двигают. + /// То же, но полоса вертикальная: есть ли разделитель СЛЕВА и СПРАВА между + /// двумя фрагментами на одной строке — рамка чекбокса, край колонки, граница поля. + 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; } + + /// Доминирующий цвет = фон; самый частый из «далёких по яркости» = цвет текста. + /// Если контраста нет (градиент, картинка), возвращаем чёрное на белом — читаемо всегда. + 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(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); + } + } +} diff --git a/src/EnglishBigrams.cs b/src/EnglishBigrams.cs new file mode 100644 index 0000000..4b126bc --- /dev/null +++ b/src/EnglishBigrams.cs @@ -0,0 +1,68 @@ +using System; + +namespace ScreenTranslator +{ + /// Модель правдоподобия английского текста по частотам буквенных пар. + /// + /// Решает задачу, с которой не справляются ни словарь, ни алфавит: английская модель OCR, + /// прочитав русскую надпись, выдаёт правдоподобное на вид латинское слово («Профиль» → + /// «npocbwlb»). Но пары букв выдают подделку сразу — сочетаний «wl», «cb», «bw» в английском + /// практически не бывает, тогда как «th», «he», «in» составляют его основу. + /// + /// Таблица — 676 квантованных log10-вероятностей, по символу на пару, около 700 байт. + /// Сгенерирована tools/make-bigrams.ps1 из частот корпуса английских текстов + /// (practicalcryptography.com). Правится только генератором, не руками. + 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["; + + /// Средний log10 вероятности пар букв в слове. Около -2,5 у обычного английского + /// слова, ниже -4 у случайного набора букв. NaN, если букв слишком мало для оценки. + 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; + } + + /// Оценка текста целиком: усреднение по словам длиной от четырёх букв — + /// на более коротких пары букв ничего не доказывают. NaN, если оценивать нечего. + 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; + } + } +} diff --git a/src/EnglishWords.cs b/src/EnglishWords.cs new file mode 100644 index 0000000..fdfebe3 --- /dev/null +++ b/src/EnglishWords.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; + +namespace ScreenTranslator +{ + /// Короткий список ходовых английских слов. + /// + /// Нужен для одного узкого, но принципиального решения. Русская модель OCR читает латинское + /// «Cancel» как кириллическое «Сапсеl», а английская читает русское «Профиль» как «npocbwlb» — + /// по алфавиту эти два случая неразличимы, и любой порог отправляет в отсев либо настоящие + /// английские кнопки, либо ничего. Словарь разрывает ничью: если основная модель выдала + /// осмысленное английское слово, значит надпись действительно английская. + /// + /// Полнота тут не нужна и даже вредна — список работает как подтверждение, а не как проверка: + /// незнакомое слово («Leatherworking») ничего не решает само по себе, вопрос тогда + /// достаётся другим признакам. + 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 Set = Build(); + + static HashSet Build() + { + var set = new HashSet(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(); + + /// Есть ли в тексте хотя бы одно узнаваемое английское слово. + /// Простые окончания (-s, -es, -ed, -ing) отсекаются, чтобы не раздувать список. + 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; + } + } +} diff --git a/src/Native.cs b/src/Native.cs new file mode 100644 index 0000000..481d4b7 --- /dev/null +++ b/src/Native.cs @@ -0,0 +1,118 @@ +using System; +using System.Drawing; +using System.Runtime.InteropServices; + +namespace ScreenTranslator +{ + /// P/Invoke. Всё, что здесь возвращает координаты, отдаёт их в ФИЗИЧЕСКИХ пикселях + /// (процесс помечен PerMonitorV2 в app.manifest), поэтому они напрямую совпадают с пикселями + /// скриншота и с координатами боксов OCR. + 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); + + /// Границы монитора под курсором, физические пиксели. + 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); + } +} diff --git a/src/Ocr.cs b/src/Ocr.cs new file mode 100644 index 0000000..0a4c6e6 --- /dev/null +++ b/src/Ocr.cs @@ -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 +{ + /// Одна распознанная строка: текст и прямоугольник в координатах захваченного кадра. + internal class OcrLineBox + { + public string Text; + public Rectangle Rect; + public double AvgWordHeight; + /// Фон под строкой. По нему отличают соседние строки одного абзаца от + /// самостоятельных элементов интерфейса, случайно оказавшихся рядом. + public Color Bg; + public bool BgKnown; + } + + /// Обёртка над встроенным в Windows OCR (Windows.Media.Ocr): офлайн, бесплатно, + /// отдаёт слова с координатами — именно это нужно, чтобы положить перевод на место оригинала. + internal class OcrService + { + readonly OcrEngine _engine; + public string LanguageTag { get; private set; } + + OcrService(OcrEngine e) { _engine = e; LanguageTag = e.RecognizerLanguage.LanguageTag; } + + public static List AvailableLanguages() + { + var r = new List(); + foreach (var l in OcrEngine.AvailableRecognizerLanguages) r.Add(l.LanguageTag); + return r; + } + + /// null, если в системе нет ни запрошенной модели, ни языков профиля. + 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); + } + + /// Модель другого языка — для проверки, не написан ли фрагмент не на том языке, + /// который мы собрались переводить. Берём язык интерфейса системы, иначе первый доступный. + /// null, если второй модели в системе нет. + 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; + } + + /// Распознать кадр. scale > 1 увеличивает картинку перед распознаванием — мелкий + /// интерфейсный текст (9–12 px) так читается заметно лучше; координаты возвращаются уже + /// пересчитанными в масштаб оригинала. + public async Task> 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; } + + /// Оттенки серого. Субпиксельное сглаживание ClearType красит края букв в + /// красный и синий; усреднение каналов убирает эту окраску, оставляя чистую форму глифа. + public bool Grayscale = false; + + /// never | auto | always. Светлый текст на тёмном фоне распознаётся хуже + /// привычного тёмного на светлом; auto инвертирует кадр, если он преимущественно тёмный. + 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 Convert(OcrResult res, double inv) + { + var list = new List(); + 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; + } + } +} diff --git a/src/OverlayForm.cs b/src/OverlayForm.cs new file mode 100644 index 0000000..14a1627 --- /dev/null +++ b/src/OverlayForm.cs @@ -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 +{ + /// Окно поверх всего, повторяющее захваченную область один-в-один. + /// + /// Порядок появления намеренный: кадр показывается сразу после захвата, боксы — как только + /// отработал OCR, перевод — по готовности. Пользователь всё время видит прогресс, + /// а не пустой экран в ожидании сети. + internal class OverlayForm : Form + { + readonly AppConfig _cfg; + readonly Bitmap _frame; // кадр в координатах области (0,0 = левый верхний угол области) + readonly Rectangle _area; // область на рабочем столе, физические пиксели + readonly bool _frozen; + + List _blocks = new List(); + readonly Dictionary _fonts = new Dictionary(); + readonly Dictionary _layout = new Dictionary(); + + 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 blocks, string status) + { + _blocks = blocks ?? new List(); + _fonts.Clear(); _layout.Clear(); + _status = status; + Invalidate(); + } + + public void SetStatus(string status, bool busy) + { + _status = status; _busy = busy; + Invalidate(StatusRect()); + } + + public void Refresh(List 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); } + } + } + } +} diff --git a/src/PlateRenderer.cs b/src/PlateRenderer.cs new file mode 100644 index 0000000..01eadd3 --- /dev/null +++ b/src/PlateRenderer.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Windows.Forms; + +namespace ScreenTranslator +{ + /// Рисование плашки с переводом поверх места оригинала. + /// Вынесено из формы, чтобы ту же отрисовку можно было прогнать в оффскрин-картинку + /// (проверка результата без запуска оверлея). + 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 fontCache, + Dictionary 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); + } + + /// Подобрать кегль так, чтобы перевод влез на место оригинала. Русский текст в среднем + /// длиннее английского, поэтому плашке разрешено немного подрасти (growRatio) прежде чем + /// начнём мельчить шрифт — читаемость важнее точного совпадения габаритов. + 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); + } + + /// Наибольший кегль в диапазоне, при котором текст целиком помещается в box. + /// null — не помещается даже минимальным. + 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; + } + + /// Проверяем обе стороны: перенос по словам не спасает от длинного слова — + /// оно просто вылезет за плашку и ляжет поверх оригинала. + 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; + } + } +} diff --git a/src/Program.cs b/src/Program.cs new file mode 100644 index 0000000..6bd47cd --- /dev/null +++ b/src/Program.cs @@ -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); + } + } + } + + /// Приложение живёт в трее и просыпается по горячей клавише. + 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 { } + } + + /// Иконка рисуется в коде, чтобы приложение оставалось одним файлом без ресурсов. + 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); + } + } + + /// Невидимое окно, принимающее WM_HOTKEY: глобальные клавиши нужны и когда фокус + /// в игре или чужом окне, поэтому обычные события формы тут не годятся. + internal class HotkeyWindow : NativeWindow, IDisposable + { + readonly System.Collections.Generic.List _ids = new System.Collections.Generic.List(); + public event Action 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(); + } + } +} diff --git a/src/RegionSelector.cs b/src/RegionSelector.cs new file mode 100644 index 0000000..147aa45 --- /dev/null +++ b/src/RegionSelector.cs @@ -0,0 +1,143 @@ +using System; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Windows.Forms; + +namespace ScreenTranslator +{ + /// Выбор области рамкой поверх замороженного кадра. Работаем именно по кадру, а не по + /// живому экрану: заново снимать нельзя — сверху уже висит наше собственное окно. + 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(); } + } + } +} diff --git a/src/ScreenCapture.cs b/src/ScreenCapture.cs new file mode 100644 index 0000000..e3ea6b2 --- /dev/null +++ b/src/ScreenCapture.cs @@ -0,0 +1,86 @@ +using System; +using System.Drawing; +using System.Drawing.Imaging; +using System.Runtime.InteropServices; + +namespace ScreenTranslator +{ + /// Снимок области экрана: и готовый Bitmap для отрисовки в оверлее, и отдельная копия + /// пикселей. Копия нужна, чтобы OCR и замер цветов работали в фоновом потоке, не трогая тот же + /// Bitmap, который в это время рисует UI-поток (иначе GDI+ падает на «region already locked»). + 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; + } + + /// Вырезать подобласть из уже снятого кадра (режим выделения рамкой) — переснимать + /// экран нельзя: под рамкой выделения уже наш собственный оверлей. + 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); } + } + } +} diff --git a/src/Translators.cs b/src/Translators.cs new file mode 100644 index 0000000..5db48ff --- /dev/null +++ b/src/Translators.cs @@ -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; } + /// false, если для движка не хватает настроек (нет ключа/URL) — такой пропускаем молча. + bool Configured { get; } + Task 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 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 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); + } + } + + /// Прогнать тексты по одному с ограничением параллелизма — для API без пакетного режима. + protected async Task EachAsync(string[] texts, Func> 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; + } + } + + // ===================================================================== + + /// Бесплатный публичный эндпоинт Google Translate (client=gtx), без ключа. + /// Пакетно: тексты склеиваются через \n, ответ приходит сегментами по предложениям, но перевод + /// строки Google сохраняет — по нему и восстанавливаем исходные границы блоков. + 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 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 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); + } + + /// Склеить сегменты ответа обратно в строки: сегмент, оканчивающийся на \n, закрывает строку. + /// null — если получилось не столько строк, сколько отправляли. + 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(); + 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; + } + } + + /// Lingva — открытый фронтенд к Google. Полезен ровно тогда, когда до самого Google + /// с этой сети не достучаться. Пакетного режима нет, поэтому по одному тексту. + 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 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>(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); + } + } + + /// MyMemory: бесплатно и без ключа, но лимит ~5000 слов в сутки на IP и предел 500 символов + /// на запрос — годится как последний рубеж, не как основной движок. + internal class MyMemoryTranslator : HttpTranslatorBase + { + public MyMemoryTranslator(AppConfig cfg) : base(cfg) { } + public override string Name { get { return "mymemory"; } } + + public override Task 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>(raw); + object rd; + if (d != null && d.TryGetValue("responseData", out rd)) + { + var inner = rd as Dictionary; + object tt; + if (inner != null && inner.TryGetValue("translatedText", out tt) && tt != null) + return HttpUtility.HtmlDecode(tt.ToString()); + } + throw new Exception("mymemory: неожиданный ответ"); + }, ct); + } + } + + /// Свой релей (см. README): POST {texts, from, to} -> {texts:[...]}. + /// Нужен, когда до публичных API из сети клиента не дотянуться, а свой сервер доступен. + 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 TranslateAsync(string[] texts, CancellationToken ct) + { + string body = Json.Serialize(new Dictionary + { + { "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>(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 TranslateAsync(string[] texts, CancellationToken ct) + { + var payload = new Dictionary + { + { "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>(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 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>(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; + object v; + return e != null && e.TryGetValue("text", out v) && v != null ? v.ToString() : ""; + }).ToArray(); + } + throw new Exception("deepl: неожиданный ответ"); + } + } + + /// Yandex Cloud Translate — самый устойчивый канал внутри РФ, но требует folderId + API-ключ. + 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 TranslateAsync(string[] texts, CancellationToken ct) + { + var payload = new Dictionary + { + { "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>(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; + object v; + return e != null && e.TryGetValue("text", out v) && v != null ? v.ToString() : ""; + }).ToArray(); + } + throw new Exception("yandex: неожиданный ответ"); + } + } + + /// Перевод целым экраном через LLM (Anthropic Messages API): лучше держит контекст и + /// термины интерфейса, но платный и медленнее. Строки нумеруются, ответ ждём строго построчно. + 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 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 + { + { "model", Cfg.LlmModel }, + { "max_tokens", Math.Min(8192, 256 + texts.Length * 64) }, + { "messages", new object[] { new Dictionary { { "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>(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; + 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; + } + + /// Цепочка движков: работаем первым живым, упавший запоминаем и какое-то время не трогаем. + /// Смысл в том, чтобы у пользователя в другой сети (где, скажем, режется Google) всё + /// заработало само, без разбирательств с настройками. + internal class TranslationChain + { + readonly List _engines = new List(); + readonly Dictionary _cooldown = new Dictionary(); + 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 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; + } + } + + /// Возвращает переводы в том же порядке; на элемент, который не удалось перевести, — null. + public async Task TranslateAsync(string[] texts, CancellationToken ct) + { + var result = new string[texts.Length]; + var todoIdx = new List(); + var todoTxt = new List(); + + 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(); + 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; + } + + /// Проверка «кто вообще жив» для меню в трее. + public async Task> ProbeAsync(CancellationToken ct) + { + var list = new List(); + 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; + } + } + + /// Кэш переводов. Экран часто пересканируют почти без изменений — повтор должен быть мгновенным. + internal class TranslationCache + { + readonly Dictionary _map = new Dictionary(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>(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 { } + } + } +} diff --git a/tools/RenderTest.cs b/tools/RenderTest.cs new file mode 100644 index 0000000..456a77c --- /dev/null +++ b/tools/RenderTest.cs @@ -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 +{ + /// Прогон всего конвейера без окна: захват -> OCR -> блоки -> перевод -> отрисовка в PNG. + /// Нужен, чтобы проверять качество распознавания и вёрстки плашек, не гоняя оверлей поверх + /// рабочего стола и не кликая по нему. + /// + /// RenderTest.exe [--in кадр.png] [--out каталог] [--scale 2.0] [--no-translate] + /// + static class RenderTest + { + [System.Runtime.InteropServices.DllImport("kernel32.dll")] + static extern int GetConsoleProcessList(int[] buffer, int count); + + /// Запущено двойным кликом (своя консоль), а не из терминала — тогда после + /// работы надо дождаться пользователя, иначе окно захлопнется вместе с выводом. + 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(); + } + } + } + + /// Проверка биграммной модели на реальных примерах из прошлых прогонов: + /// слева то, что должно проходить, справа — то, что модель обязана отбраковывать. + /// Порог берётся из этих чисел, а не из общих соображений. + 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(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(); + var layout = new Dictionary(); + 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(); + 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); + } + } +} diff --git a/tools/english_bigrams.txt b/tools/english_bigrams.txt new file mode 100644 index 0000000..53e044e --- /dev/null +++ b/tools/english_bigrams.txt @@ -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 + diff --git a/tools/expect-wow.txt b/tools/expect-wow.txt new file mode 100644 index 0000000..2d0eed2 --- /dev/null +++ b/tools/expect-wow.txt @@ -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 diff --git a/tools/make-bigrams.ps1 b/tools/make-bigrams.ps1 new file mode 100644 index 0000000..cf06834 --- /dev/null +++ b/tools/make-bigrams.ps1 @@ -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 +{ + /// Модель правдоподобия английского текста по частотам буквенных пар. + /// + /// Решает задачу, с которой не справляются ни словарь, ни алфавит: английская модель OCR, + /// прочитав русскую надпись, выдаёт правдоподобное на вид латинское слово («Профиль» → + /// «npocbwlb»). Но пары букв выдают подделку сразу — сочетаний «wl», «cb», «bw» в английском + /// практически не бывает, тогда как «th», «he», «in» составляют его основу. + /// + /// Таблица — 676 квантованных log10-вероятностей, по символу на пару, около 700 байт. + /// Сгенерирована tools/make-bigrams.ps1 из частот корпуса английских текстов + /// (practicalcryptography.com). Правится только генератором, не руками. + internal static class EnglishBigrams + { + const double Lo = $LO, Step = $STEP; + + const string Table = +$joined; + + /// Средний log10 вероятности пар букв в слове. Около -2,5 у обычного английского + /// слова, ниже -4 у случайного набора букв. NaN, если букв слишком мало для оценки. + 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; + } + + /// Оценка текста целиком: усреднение по словам длиной от четырёх букв — + /// на более коротких пары букв ничего не доказывают. NaN, если оценивать нечего. + 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) символов таблицы)" diff --git a/tools/make-sample-ru.ps1 b/tools/make-sample-ru.ps1 new file mode 100644 index 0000000..11c4993 --- /dev/null +++ b/tools/make-sample-ru.ps1 @@ -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" diff --git a/tools/make-sample-text.ps1 b/tools/make-sample-text.ps1 new file mode 100644 index 0000000..41dab94 --- /dev/null +++ b/tools/make-sample-text.ps1 @@ -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 абзаца целыми блоками, заголовки и пункты списка отдельно." diff --git a/tools/make-sample.ps1 b/tools/make-sample.ps1 new file mode 100644 index 0000000..5630cfe --- /dev/null +++ b/tools/make-sample.ps1 @@ -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" diff --git a/ЧИТАЙ МЕНЯ.txt b/ЧИТАЙ МЕНЯ.txt new file mode 100644 index 0000000..861b8a2 --- /dev/null +++ b/ЧИТАЙ МЕНЯ.txt @@ -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 рядом с программой, открывается блокнотом. +После правки: значок в трее → «Перечитать настройки», перезапуск не нужен.