Переводчик экрана: наложение перевода поверх текста на экране
build / build (push) Failing after 10s
build / build (push) Failing after 10s
Программа накладывает русский перевод прямо на английские надписи — как переводчик по фото, только для монитора. Работает с играми, интерфейсами и документами: распознаёт кадр, собирает текст в связные блоки, переводит и рисует плашки под цвет фона. Распознавание — встроенный Windows.Media.Ocr, офлайн. Перевод — цепочка движков с автопереключением (google, lingva, mymemory), потому что до разных сервисов из разных сетей достучаться получается по-разному. Собирается без .NET SDK: Roslyn из Build Tools плюс сборки Framework 4.8, которые уже есть в системе. На выходе один exe без установщика. Главная сложность оказалась не в переводе, а в том, чтобы понять, где на экране связный текст, а где отдельные надписи. Классические алгоритмы сегментации решают это по межстрочному зазору, но в плотном интерфейсе он одинаков у строк абзаца и у соседних контролов. Поэтому решение принимает ансамбль признаков: фон, граница между строками, заполненность строки, кегль. Надписи на чужом языке отсеиваются тремя уровнями — форма слова, вторая модель распознавания и частоты буквенных пар. Чтобы правки эвристик не оценивались на глаз, в tools лежат эталонные кадры и RenderTest: прогон конвейера без окна с подсчётом попаданий. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
|||||||
|
# Сборка и публикация «Переводчика экрана».
|
||||||
|
#
|
||||||
|
# Идёт на self-hosted Windows-раннере (метка `windows`, host mode) — иначе никак:
|
||||||
|
# распознавание держится на Windows.Media.Ocr, а сборка — на Roslyn из VS Build Tools
|
||||||
|
# и на Windows.winmd из Windows SDK. В контейнере ничего этого нет.
|
||||||
|
#
|
||||||
|
# * push в master -> сборка + прогон проверок + перезапись релиза `latest`
|
||||||
|
# * push тега v* -> то же, но отдельный постоянный релиз с этой версией
|
||||||
|
#
|
||||||
|
# Требований к раннеру всего два, и оба уже выполнены (тот же хост собирает GusVoice):
|
||||||
|
# Visual Studio Build Tools (нужен Roslyn csc) и Windows SDK (Windows.winmd).
|
||||||
|
# Если Roslyn вдруг отсутствует, build.ps1 сам откатывается на компилятор из состава
|
||||||
|
# Windows — код специально написан без возможностей C# 6 и выше, чтобы это работало.
|
||||||
|
|
||||||
|
name: build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_HOST: gitea.gusik.tech
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: windows
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Сборка exe и диагностической утилиты
|
||||||
|
shell: pwsh
|
||||||
|
run: .\build.ps1 -Test
|
||||||
|
|
||||||
|
# Модель правдоподобия английского — единственная часть, которую можно проверить
|
||||||
|
# без экрана и без языковых пакетов. Проверяет, что таблица биграмм на месте и
|
||||||
|
# всё ещё отделяет настоящие надписи от каши распознавания.
|
||||||
|
- name: Проверка биграммной модели
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$out = & .\dist\RenderTest.exe --selftest 2>&1 | Out-String
|
||||||
|
Write-Host $out
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "selftest завершился с кодом $LASTEXITCODE" }
|
||||||
|
if ($out -notmatch 'худший «хороший»') { throw "selftest не выдал сводку — модель не загрузилась" }
|
||||||
|
|
||||||
|
# Гейт качества на эталонном кадре. Работает, только если на раннере установлен
|
||||||
|
# английский пакет распознавания; если его нет — не валим сборку, а предупреждаем:
|
||||||
|
# это среда, а не регрессия кода.
|
||||||
|
- name: Проверка распознавания на эталоне
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
.\tools\make-sample.ps1
|
||||||
|
$out = & .\dist\RenderTest.exe --in .\dist\sample.png --out .\dist\ci --no-translate 2>&1 | Out-String
|
||||||
|
Write-Host $out
|
||||||
|
if ($out -match 'OCR недоступен') {
|
||||||
|
Write-Host '::warning::на раннере нет модели распознавания — проверка кадра пропущена'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
if ($out -notmatch 'блоков (\d+)') { throw "RenderTest не отчитался о блоках" }
|
||||||
|
$blocks = [int]$Matches[1]
|
||||||
|
Write-Host "распознано блоков: $blocks"
|
||||||
|
# На этом кадре стабильно выходит 27-29 блоков. Резкое расхождение означает, что
|
||||||
|
# правки в Blocks.cs поехали — либо всё слиплось, либо рассыпалось.
|
||||||
|
if ($blocks -lt 18 -or $blocks -gt 40) { throw "неожиданное число блоков ($blocks) — сегментация сломана" }
|
||||||
|
|
||||||
|
- name: Сборка пакета
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$rel = 'release'
|
||||||
|
New-Item -ItemType Directory -Force -Path $rel | Out-Null
|
||||||
|
Copy-Item .\dist\ScreenTranslator.exe $rel -Force
|
||||||
|
Copy-Item '.\ЧИТАЙ МЕНЯ.txt' $rel -Force -ErrorAction SilentlyContinue
|
||||||
|
# config.json генерирует само приложение из вшитого умолчания
|
||||||
|
& .\dist\ScreenTranslator.exe --write-config
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
if (Test-Path .\dist\config.json) { Copy-Item .\dist\config.json $rel -Force }
|
||||||
|
else { Write-Host '::warning::config.json не создан — пакет уедет без него' }
|
||||||
|
Compress-Archive -Path "$rel\*" -DestinationPath 'ПереводчикЭкрана.zip' -Force
|
||||||
|
Get-ChildItem 'ПереводчикЭкрана.zip' | Format-List Name, Length
|
||||||
|
|
||||||
|
# Тег v* -> постоянный релиз. Любой push в master -> перезаписываемый `latest`,
|
||||||
|
# чтобы у тех, кому программу отдали, всегда была ссылка на свежую сборку.
|
||||||
|
- name: Публикация релиза
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
REF: ${{ github.ref }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$host_ = $env:GITEA_HOST
|
||||||
|
$base = "https://${host_}/api/v1/repos/$($env:REPO)"
|
||||||
|
$hdr = @{ Authorization = "token $($env:GITEA_TOKEN)" }
|
||||||
|
$isTag = $env:REF -like 'refs/tags/*'
|
||||||
|
$tag = if ($isTag) { $env:REF_NAME } else { 'latest' }
|
||||||
|
|
||||||
|
if (-not $isTag) {
|
||||||
|
# Скользящий релиз: сносим прошлый вместе с тегом, иначе он останется
|
||||||
|
# висеть на старом коммите и «свежая сборка» перестанет быть свежей.
|
||||||
|
try {
|
||||||
|
$old = Invoke-RestMethod -Uri "$base/releases/tags/$tag" -Headers $hdr
|
||||||
|
if ($old.id) {
|
||||||
|
Invoke-RestMethod -Method Delete -Uri "$base/releases/$($old.id)" -Headers $hdr | Out-Null
|
||||||
|
Write-Host "удалён прошлый релиз $tag"
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
try { Invoke-RestMethod -Method Delete -Uri "$base/tags/$tag" -Headers $hdr | Out-Null } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
$short = $env:SHA.Substring(0, 8)
|
||||||
|
$stamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm') + ' UTC'
|
||||||
|
$notes = if ($isTag) {
|
||||||
|
"Версия $tag`n`nСборка из коммита $short."
|
||||||
|
} else {
|
||||||
|
"Свежая сборка из master, коммит $short, собрана $stamp.`n`n" +
|
||||||
|
"Обновляется автоматически при каждом изменении. Для стабильной версии смотрите релизы с номером."
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = @{
|
||||||
|
tag_name = $tag
|
||||||
|
target_commitish = $env:SHA
|
||||||
|
name = if ($isTag) { $tag } else { "Свежая сборка ($short)" }
|
||||||
|
body = $notes
|
||||||
|
draft = $false
|
||||||
|
prerelease = (-not $isTag)
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
|
|
||||||
|
try {
|
||||||
|
$rel = Invoke-RestMethod -Method Post -Uri "$base/releases" -Headers $hdr -ContentType 'application/json' -Body $body
|
||||||
|
} catch {
|
||||||
|
# Релиз на этот тег уже есть (перезапуск задания) — берём его и обновляем описание
|
||||||
|
$rel = Invoke-RestMethod -Uri "$base/releases/tags/$tag" -Headers $hdr
|
||||||
|
$patch = @{ body = $notes } | ConvertTo-Json -Compress
|
||||||
|
Invoke-RestMethod -Method Patch -Uri "$base/releases/$($rel.id)" -Headers $hdr -ContentType 'application/json' -Body $patch | Out-Null
|
||||||
|
}
|
||||||
|
if (-not $rel.id) { throw "не удалось получить релиз для тега $tag" }
|
||||||
|
|
||||||
|
# Старые файлы с тем же именем API не заменяет, поэтому сначала чистим
|
||||||
|
try {
|
||||||
|
foreach ($a in (Invoke-RestMethod -Uri "$base/releases/$($rel.id)/assets" -Headers $hdr)) {
|
||||||
|
Invoke-RestMethod -Method Delete -Uri "$base/releases/$($rel.id)/assets/$($a.id)" -Headers $hdr | Out-Null
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
foreach ($f in @('ПереводчикЭкрана.zip', '.\dist\ScreenTranslator.exe')) {
|
||||||
|
$item = Get-Item $f
|
||||||
|
Write-Host "загружаю $($item.Name)…"
|
||||||
|
& curl.exe -sf -X POST "$base/releases/$($rel.id)/assets?name=$($item.Name)" `
|
||||||
|
-H "Authorization: token $($env:GITEA_TOKEN)" -F "attachment=@$($item.FullName)"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "не удалось загрузить $($item.Name)" }
|
||||||
|
}
|
||||||
|
Write-Host "релиз $tag опубликован: https://${host_}/$($env:REPO)/releases"
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# Сборка и готовый пакет — собираются из исходников, в репозитории не нужны.
|
||||||
|
# Важно: в dist/ по ходу отладки оседают скриншоты рабочего стола и cache.json
|
||||||
|
# с текстом, который был на экране, — этому в публичном репозитории точно не место.
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
*.zip
|
||||||
|
|
||||||
|
# Побочные файлы работающей программы
|
||||||
|
config.json
|
||||||
|
cache.json
|
||||||
|
|
||||||
|
*.exe
|
||||||
|
*.pdb
|
||||||
|
*.user
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
@@ -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.
|
||||||
@@ -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 обводит только тело букв, без выносных элементов, поэтому плашка расширяется
|
||||||
|
по вертикали — иначе сверху и снизу торчат хвосты оригинала.
|
||||||
|
* Строки склеиваются в абзац по межстрочному интервалу и одинаковому кеглю: рваные строки
|
||||||
|
переводятся заметно хуже целого абзаца.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="ScreenTranslator.app" />
|
||||||
|
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
|
||||||
|
<!-- Без этого блока Windows считает процесс "legacy" и не отдаёт PerMonitorV2 -->
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /><!-- Windows 10 / 11 -->
|
||||||
|
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" /><!-- Windows 8.1 -->
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
|
||||||
|
<!-- DPI: работаем строго в физических пикселях, иначе координаты OCR не совпадут с экраном -->
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
@@ -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 "Запущен. Значок в трее."
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Web.Script.Serialization;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace ScreenTranslator
|
||||||
|
{
|
||||||
|
/// <summary>Настройки из config.json рядом с exe. Разбор ручной и снисходительный:
|
||||||
|
/// незнакомые ключи игнорируются, кривое значение откатывается к дефолту, приложение
|
||||||
|
/// не должно падать из-за опечатки в конфиге.</summary>
|
||||||
|
internal class AppConfig
|
||||||
|
{
|
||||||
|
public string HotkeyTranslate = "Ctrl+Alt+Z";
|
||||||
|
public string HotkeyRegion = "Ctrl+Alt+X";
|
||||||
|
public string SourceLang = "en";
|
||||||
|
public string TargetLang = "ru";
|
||||||
|
public string OcrLanguage = "en-US";
|
||||||
|
/// <summary>Увеличение кадра перед OCR. Мелкий шрифт интерфейса на 1.0 читается плохо,
|
||||||
|
/// 2.0 заметно точнее, но дороже по времени.</summary>
|
||||||
|
public double OcrScale = 2.0;
|
||||||
|
|
||||||
|
/// <summary>frozen — оверлей показывает замороженный кадр экрана (стабильно, дефолт);
|
||||||
|
/// live — только плашки поверх живого экрана.</summary>
|
||||||
|
public string OverlayMode = "frozen";
|
||||||
|
|
||||||
|
/// <summary>monitor — монитор под курсором; all — весь виртуальный рабочий стол.</summary>
|
||||||
|
public string CaptureScope = "monitor";
|
||||||
|
|
||||||
|
/// <summary>Порядок движков перевода. Первый живой выигрывает, дохлые запоминаются.</summary>
|
||||||
|
public List<string> Chain = new List<string> { "google", "lingva", "mymemory" };
|
||||||
|
|
||||||
|
public int TimeoutMs = 7000;
|
||||||
|
public int MaxParallel = 6;
|
||||||
|
/// <summary>Пустая строка — прямое соединение. Формат: http://host:port (sing-box mixed подходит).</summary>
|
||||||
|
public string Proxy = "";
|
||||||
|
|
||||||
|
public string LibreUrl = "";
|
||||||
|
public string LibreApiKey = "";
|
||||||
|
public string RelayUrl = "";
|
||||||
|
public string DeeplUrl = "https://api-free.deepl.com/v2/translate";
|
||||||
|
public string DeeplApiKey = "";
|
||||||
|
public string YandexFolderId = "";
|
||||||
|
public string YandexApiKey = "";
|
||||||
|
public string LlmUrl = "https://api.anthropic.com/v1/messages";
|
||||||
|
public string LlmApiKey = "";
|
||||||
|
public string LlmModel = "claude-haiku-4-5-20251001";
|
||||||
|
|
||||||
|
public string FontFamily = "Segoe UI";
|
||||||
|
public int BoxPadding = 2;
|
||||||
|
public int MinFontPx = 8;
|
||||||
|
/// <summary>Насколько плашке разрешено вылезти за исходный бокс вправо/вниз, доля ширины.
|
||||||
|
/// Русский в среднем на 15–20 % длиннее английского, без запаса шрифт мельчает.</summary>
|
||||||
|
public double GrowRatio = 0.35;
|
||||||
|
public bool DimBackground = false;
|
||||||
|
public bool Cache = true;
|
||||||
|
public bool ShowTrayBalloonOnError = true;
|
||||||
|
/// <summary>Строки короче — мусор OCR, не переводим (номера строк, иконки, одиночные буквы).</summary>
|
||||||
|
public int MinChars = 2;
|
||||||
|
/// <summary>Отсеивать блоки, не похожие на связный текст (обычно это надписи на другом
|
||||||
|
/// языке, которые модель OCR прочитала как бессмыслицу).</summary>
|
||||||
|
public bool SkipGarbage = true;
|
||||||
|
/// <summary>Минимум слов в блоке, чтобы его переводить. 2 отключает одиночные слова —
|
||||||
|
/// на рабочем столе и панелях задач это сплошь названия программ, которые переводить
|
||||||
|
/// не нужно и вредно («Discord» → «Раздор»).</summary>
|
||||||
|
public int MinWords = 1;
|
||||||
|
/// <summary>Дополнительно распознавать кадр моделью другого языка и не переводить фрагменты,
|
||||||
|
/// которые она читает осмысленнее. Так надписи на языке системы остаются нетронутыми.
|
||||||
|
/// Стоит примерно вдвое дольше распознавания.</summary>
|
||||||
|
public bool DetectForeign = true;
|
||||||
|
/// <summary>Отмечать переведённые места тонкой линией — иначе плашку под цвет фона
|
||||||
|
/// не отличить от нетронутого текста.</summary>
|
||||||
|
public bool MarkTranslated = true;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
public static string Dir
|
||||||
|
{
|
||||||
|
get { return Path.GetDirectoryName(Application.ExecutablePath); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Path_ { get { return System.IO.Path.Combine(Dir, "config.json"); } }
|
||||||
|
|
||||||
|
public static AppConfig Load(out string error)
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
var cfg = new AppConfig();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(Path_))
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path_, DefaultJson, new UTF8Encoding(false));
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
string raw = StripComments(File.ReadAllText(Path_));
|
||||||
|
var ser = new JavaScriptSerializer();
|
||||||
|
var d = ser.Deserialize<Dictionary<string, object>>(raw);
|
||||||
|
if (d == null) return cfg;
|
||||||
|
|
||||||
|
cfg.HotkeyTranslate = Str(d, "hotkeyTranslate", cfg.HotkeyTranslate);
|
||||||
|
cfg.HotkeyRegion = Str(d, "hotkeyRegion", cfg.HotkeyRegion);
|
||||||
|
cfg.SourceLang = Str(d, "sourceLang", cfg.SourceLang);
|
||||||
|
cfg.TargetLang = Str(d, "targetLang", cfg.TargetLang);
|
||||||
|
cfg.OcrLanguage = Str(d, "ocrLanguage", cfg.OcrLanguage);
|
||||||
|
cfg.OcrScale = Math.Max(1.0, Math.Min(4.0, Dbl(d, "ocrScale", cfg.OcrScale)));
|
||||||
|
cfg.OverlayMode = Str(d, "overlayMode", cfg.OverlayMode).ToLowerInvariant();
|
||||||
|
cfg.CaptureScope = Str(d, "captureScope", cfg.CaptureScope).ToLowerInvariant();
|
||||||
|
cfg.TimeoutMs = Int(d, "timeoutMs", cfg.TimeoutMs);
|
||||||
|
cfg.MaxParallel = Math.Max(1, Math.Min(16, Int(d, "maxParallel", cfg.MaxParallel)));
|
||||||
|
cfg.Proxy = Str(d, "proxy", cfg.Proxy);
|
||||||
|
cfg.FontFamily = Str(d, "fontFamily", cfg.FontFamily);
|
||||||
|
cfg.BoxPadding = Int(d, "boxPadding", cfg.BoxPadding);
|
||||||
|
cfg.MinFontPx = Math.Max(5, Int(d, "minFontPx", cfg.MinFontPx));
|
||||||
|
cfg.GrowRatio = Dbl(d, "growRatio", cfg.GrowRatio);
|
||||||
|
cfg.DimBackground = Bool(d, "dimBackground", cfg.DimBackground);
|
||||||
|
cfg.Cache = Bool(d, "cache", cfg.Cache);
|
||||||
|
cfg.MinChars = Int(d, "minChars", cfg.MinChars);
|
||||||
|
cfg.SkipGarbage = Bool(d, "skipGarbage", cfg.SkipGarbage);
|
||||||
|
cfg.MinWords = Math.Max(1, Int(d, "minWords", cfg.MinWords));
|
||||||
|
cfg.DetectForeign = Bool(d, "detectForeign", cfg.DetectForeign);
|
||||||
|
cfg.MarkTranslated = Bool(d, "markTranslated", cfg.MarkTranslated);
|
||||||
|
cfg.ShowTrayBalloonOnError = Bool(d, "showTrayBalloonOnError", cfg.ShowTrayBalloonOnError);
|
||||||
|
|
||||||
|
var chain = d.ContainsKey("chain") ? d["chain"] as System.Collections.IEnumerable : null;
|
||||||
|
if (chain != null)
|
||||||
|
{
|
||||||
|
var list = new List<string>();
|
||||||
|
foreach (var o in chain)
|
||||||
|
{
|
||||||
|
if (o == null) continue;
|
||||||
|
var s = o.ToString().Trim().ToLowerInvariant();
|
||||||
|
if (s.Length > 0) list.Add(s);
|
||||||
|
}
|
||||||
|
if (list.Count > 0) cfg.Chain = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
var eng = Sub(d, "engines");
|
||||||
|
if (eng != null)
|
||||||
|
{
|
||||||
|
var libre = Sub(eng, "libretranslate");
|
||||||
|
if (libre != null)
|
||||||
|
{
|
||||||
|
cfg.LibreUrl = Str(libre, "url", cfg.LibreUrl);
|
||||||
|
cfg.LibreApiKey = Str(libre, "apiKey", cfg.LibreApiKey);
|
||||||
|
}
|
||||||
|
var relay = Sub(eng, "relay");
|
||||||
|
if (relay != null) cfg.RelayUrl = Str(relay, "url", cfg.RelayUrl);
|
||||||
|
|
||||||
|
var deepl = Sub(eng, "deepl");
|
||||||
|
if (deepl != null)
|
||||||
|
{
|
||||||
|
cfg.DeeplUrl = Str(deepl, "url", cfg.DeeplUrl);
|
||||||
|
cfg.DeeplApiKey = Str(deepl, "apiKey", cfg.DeeplApiKey);
|
||||||
|
}
|
||||||
|
var ya = Sub(eng, "yandex");
|
||||||
|
if (ya != null)
|
||||||
|
{
|
||||||
|
cfg.YandexFolderId = Str(ya, "folderId", cfg.YandexFolderId);
|
||||||
|
cfg.YandexApiKey = Str(ya, "apiKey", cfg.YandexApiKey);
|
||||||
|
}
|
||||||
|
var llm = Sub(eng, "llm");
|
||||||
|
if (llm != null)
|
||||||
|
{
|
||||||
|
cfg.LlmUrl = Str(llm, "url", cfg.LlmUrl);
|
||||||
|
cfg.LlmApiKey = Str(llm, "apiKey", cfg.LlmApiKey);
|
||||||
|
cfg.LlmModel = Str(llm, "model", cfg.LlmModel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
error = "config.json не разобран (" + ex.Message + "), взяты значения по умолчанию";
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Dictionary<string, object> Sub(Dictionary<string, object> d, string key)
|
||||||
|
{
|
||||||
|
object v;
|
||||||
|
if (d != null && d.TryGetValue(key, out v)) return v as Dictionary<string, object>;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Str(Dictionary<string, object> d, string k, string def)
|
||||||
|
{
|
||||||
|
object v;
|
||||||
|
return d.TryGetValue(k, out v) && v != null ? v.ToString() : def;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Int(Dictionary<string, object> d, string k, int def)
|
||||||
|
{
|
||||||
|
object v; int r;
|
||||||
|
if (d.TryGetValue(k, out v) && v != null &&
|
||||||
|
int.TryParse(v.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out r)) return r;
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double Dbl(Dictionary<string, object> d, string k, double def)
|
||||||
|
{
|
||||||
|
object v; double r;
|
||||||
|
if (d.TryGetValue(k, out v) && v != null &&
|
||||||
|
double.TryParse(v.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out r)) return r;
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool Bool(Dictionary<string, object> d, string k, bool def)
|
||||||
|
{
|
||||||
|
object v; bool r;
|
||||||
|
if (d.TryGetValue(k, out v) && v != null && bool.TryParse(v.ToString(), out r)) return r;
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>JSON комментариев не знает, но человек в конфиге их пишет — вырезаем,
|
||||||
|
/// аккуратно не трогая // внутри строковых литералов (например, в url).</summary>
|
||||||
|
internal static string StripComments(string s)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(s.Length);
|
||||||
|
bool inStr = false, esc = false;
|
||||||
|
for (int i = 0; i < s.Length; i++)
|
||||||
|
{
|
||||||
|
char c = s[i];
|
||||||
|
if (inStr)
|
||||||
|
{
|
||||||
|
sb.Append(c);
|
||||||
|
if (esc) esc = false;
|
||||||
|
else if (c == '\\') esc = true;
|
||||||
|
else if (c == '"') inStr = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == '"') { inStr = true; sb.Append(c); continue; }
|
||||||
|
if (c == '/' && i + 1 < s.Length && s[i + 1] == '/')
|
||||||
|
{
|
||||||
|
while (i < s.Length && s[i] != '\n') i++;
|
||||||
|
if (i < s.Length) sb.Append('\n');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == '/' && i + 1 < s.Length && s[i + 1] == '*')
|
||||||
|
{
|
||||||
|
i += 2;
|
||||||
|
while (i + 1 < s.Length && !(s[i] == '*' && s[i + 1] == '/')) i++;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sb.Append(c);
|
||||||
|
}
|
||||||
|
// висячие запятые перед } или ] JavaScriptSerializer не любит
|
||||||
|
return Regex.Replace(sb.ToString(), @",(\s*[}\]])", "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- разбор строки горячей клавиши ----
|
||||||
|
|
||||||
|
/// <summary>"Ctrl+Alt+Z" -> модификаторы + virtual-key. false, если строка непонятна.</summary>
|
||||||
|
public static bool ParseHotkey(string s, out uint mods, out uint vk, out string display)
|
||||||
|
{
|
||||||
|
mods = 0; vk = 0; display = s;
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return false;
|
||||||
|
var parts = s.Split(new[] { '+' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
string keyPart = null;
|
||||||
|
foreach (var raw in parts)
|
||||||
|
{
|
||||||
|
var p = raw.Trim().ToLowerInvariant();
|
||||||
|
switch (p)
|
||||||
|
{
|
||||||
|
case "ctrl": case "control": mods |= Native.MOD_CONTROL; break;
|
||||||
|
case "alt": mods |= Native.MOD_ALT; break;
|
||||||
|
case "shift": mods |= Native.MOD_SHIFT; break;
|
||||||
|
case "win": case "meta": mods |= Native.MOD_WIN; break;
|
||||||
|
default: keyPart = raw.Trim(); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (keyPart == null) return false;
|
||||||
|
|
||||||
|
Keys k;
|
||||||
|
if (!Enum.TryParse(keyPart, true, out k))
|
||||||
|
{
|
||||||
|
if (keyPart.Length == 1)
|
||||||
|
{
|
||||||
|
char c = char.ToUpperInvariant(keyPart[0]);
|
||||||
|
if (c >= 'A' && c <= 'Z') k = (Keys)c;
|
||||||
|
else if (c >= '0' && c <= '9') k = (Keys)c;
|
||||||
|
else return false;
|
||||||
|
}
|
||||||
|
else return false;
|
||||||
|
}
|
||||||
|
vk = (uint)k;
|
||||||
|
display = s;
|
||||||
|
return vk != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public const string DefaultJson = @"{
|
||||||
|
// ---- горячие клавиши ----
|
||||||
|
""hotkeyTranslate"": ""Ctrl+Alt+Z"", // перевести монитор под курсором
|
||||||
|
""hotkeyRegion"": ""Ctrl+Alt+X"", // перевести выделенную рамкой область
|
||||||
|
|
||||||
|
// ---- языки ----
|
||||||
|
""sourceLang"": ""en"", // язык оригинала (для переводчика)
|
||||||
|
""targetLang"": ""ru"", // на какой переводим
|
||||||
|
""ocrLanguage"": ""en-US"", // модель OCR Windows: en-US | ru | ...
|
||||||
|
""ocrScale"": 2.0, // увеличение кадра перед распознаванием (мелкий шрифт читается точнее)
|
||||||
|
|
||||||
|
// ---- поведение оверлея ----
|
||||||
|
""overlayMode"": ""frozen"", // frozen = замороженный кадр (стабильно) | live = плашки поверх живого экрана
|
||||||
|
""captureScope"": ""monitor"", // monitor = экран под курсором | all = все мониторы
|
||||||
|
""dimBackground"": false, // слегка притемнить непереведённое
|
||||||
|
""growRatio"": 0.35, // насколько плашке можно вылезти за оригинал (русский длиннее)
|
||||||
|
""fontFamily"": ""Segoe UI"",
|
||||||
|
""minFontPx"": 8,
|
||||||
|
""boxPadding"": 2,
|
||||||
|
""minChars"": 2,
|
||||||
|
""minWords"": 1, // 2 = не переводить одиночные слова (рабочий стол, названия программ)
|
||||||
|
""skipGarbage"": true, // не переводить кашу: надписи на чужом для модели OCR языке
|
||||||
|
""detectForeign"": true, // проверять кадр второй моделью OCR и не трогать чужой язык
|
||||||
|
""markTranslated"": true, // подчёркивать переведённые места тонкой линией
|
||||||
|
|
||||||
|
// ---- перевод ----
|
||||||
|
// Движки пробуются по порядку: первый ответивший выигрывает, упавший временно исключается.
|
||||||
|
// Доступны: google | lingva | mymemory | relay | libretranslate | deepl | yandex | llm
|
||||||
|
""chain"": [""google"", ""lingva"", ""mymemory""],
|
||||||
|
""timeoutMs"": 7000,
|
||||||
|
""maxParallel"": 6,
|
||||||
|
""cache"": true,
|
||||||
|
""proxy"": """", // например ""http://192.168.5.42:2080""
|
||||||
|
|
||||||
|
""engines"": {
|
||||||
|
""relay"": { ""url"": """" }, // свой прокси-эндпоинт, см. README
|
||||||
|
""libretranslate"": { ""url"": """", ""apiKey"": """" },
|
||||||
|
""deepl"": { ""url"": ""https://api-free.deepl.com/v2/translate"", ""apiKey"": """" },
|
||||||
|
""yandex"": { ""folderId"": """", ""apiKey"": """" },
|
||||||
|
""llm"": { ""url"": ""https://api.anthropic.com/v1/messages"",
|
||||||
|
""apiKey"": """", ""model"": ""claude-haiku-4-5-20251001"" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
";
|
||||||
|
}
|
||||||
|
}
|
||||||
+623
@@ -0,0 +1,623 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Imaging;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ScreenTranslator
|
||||||
|
{
|
||||||
|
/// <summary>Связный кусок текста на экране (абзац, пункт меню, реплика) — единица перевода
|
||||||
|
/// и одна плашка в оверлее.</summary>
|
||||||
|
internal class TextBlock
|
||||||
|
{
|
||||||
|
public Rectangle Rect;
|
||||||
|
public string Source = "";
|
||||||
|
public string Translated;
|
||||||
|
public bool Failed;
|
||||||
|
public double LineHeight = 14;
|
||||||
|
public int LineCount = 1;
|
||||||
|
public Color Bg = Color.White;
|
||||||
|
public Color Fg = Color.Black;
|
||||||
|
/// <summary>Куда реально нарисована плашка — нужно для попадания мышью.</summary>
|
||||||
|
public Rectangle DrawnRect;
|
||||||
|
/// <summary>Предел разрастания плашки: дальше начинается соседний текст.
|
||||||
|
/// Пустой — ограничений нет, кроме краёв кадра.</summary>
|
||||||
|
public Rectangle GrowLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class Blocks
|
||||||
|
{
|
||||||
|
/// <summary>Склеить OCR-строки в абзацы. Критерии соседства: строки идут подряд по вертикали,
|
||||||
|
/// заметно перекрываются по горизонтали и имеют сопоставимый кегль. Так пункты меню в разных
|
||||||
|
/// углах экрана не слипаются в один блок, а абзац текста не рассыпается на строки.</summary>
|
||||||
|
public static List<TextBlock> Group(List<OcrLineBox> lines, int minChars, bool skipGarbage = true,
|
||||||
|
FrameSampler sampler = null, int minWords = 1)
|
||||||
|
{
|
||||||
|
if (sampler != null)
|
||||||
|
foreach (var l in lines)
|
||||||
|
{
|
||||||
|
Color bg, fg;
|
||||||
|
sampler.Sample(l.Rect, out bg, out fg);
|
||||||
|
l.Bg = bg; l.BgKnown = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var src = MergeHorizontally(lines, sampler)
|
||||||
|
.Where(l => IsMeaningful(l.Text, minChars))
|
||||||
|
.OrderBy(l => l.Rect.Top).ThenBy(l => l.Rect.Left)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var open = new List<List<OcrLineBox>>();
|
||||||
|
var done = new List<List<OcrLineBox>>();
|
||||||
|
|
||||||
|
foreach (var line in src)
|
||||||
|
{
|
||||||
|
double h = LineH(line);
|
||||||
|
List<OcrLineBox> best = null;
|
||||||
|
double bestGap = double.MaxValue;
|
||||||
|
|
||||||
|
for (int i = open.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var blk = open[i];
|
||||||
|
var last = blk[blk.Count - 1];
|
||||||
|
double lh = LineH(last);
|
||||||
|
double gap = line.Rect.Top - last.Rect.Bottom;
|
||||||
|
|
||||||
|
// ушли ниже — блок больше не пополнится
|
||||||
|
if (gap > lh * 2.0 && gap > 40) { done.Add(blk); open.RemoveAt(i); continue; }
|
||||||
|
if (gap < -lh * 0.5) continue; // строки наложены — разные слои UI
|
||||||
|
if (Math.Max(lh, h) / Math.Max(1.0, Math.Min(lh, h)) > 1.5) continue; // разный кегль
|
||||||
|
|
||||||
|
// Порог по межстрочному интервалу. Строки абзаца стоят с интерлиньяжем около
|
||||||
|
// одной высоты глифов, поэтому 0.75 рвал абзацы на куски — а рваные строки и
|
||||||
|
// переводятся заметно хуже. Ровная левая кромка — верный признак абзаца,
|
||||||
|
// там допускаем разрядку побольше; пункты меню с их шагом в 1.5 высоты
|
||||||
|
// при этом остаются раздельными.
|
||||||
|
// Разный фон — значит это разные элементы интерфейса, а не строки одного
|
||||||
|
// абзаца. В плотных окнах (игры, панели настроек) чекбокс и выпадающий
|
||||||
|
// список стоят с тем же интервалом, что строки текста, и по одной геометрии
|
||||||
|
// их не развести: слипаясь, они дают и мусорный перевод, и плашку на пол-окна.
|
||||||
|
if (line.BgKnown && last.BgKnown && ColorDistance(line.Bg, last.Bg) > 42) continue;
|
||||||
|
|
||||||
|
// Рамка или край панели между строками — тоже признак разных элементов.
|
||||||
|
// Фон при этом может почти совпадать (в окне игры оба тёмные, разница
|
||||||
|
// всего 13 из 255), а вот граница поля даёт скачок яркости в разы.
|
||||||
|
if (sampler != null && line.Rect.Top - last.Rect.Bottom >= 2)
|
||||||
|
{
|
||||||
|
int x0 = Math.Max(line.Rect.Left, last.Rect.Left);
|
||||||
|
int x1 = Math.Min(line.Rect.Right, last.Rect.Right);
|
||||||
|
if (x1 - x0 >= 8 &&
|
||||||
|
sampler.BandContrast(x0, x1, last.Rect.Bottom, line.Rect.Top) > 35) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Признак «строка не дотянула до правого края» (ragged-right).
|
||||||
|
//
|
||||||
|
// В абзаце короткой имеет право быть только последняя строка — остальные
|
||||||
|
// заполнены до края, иначе слово перенеслось бы. Значит если предыдущая
|
||||||
|
// строка короткая, абзац на ней и кончился, а то, что идёт ниже, —
|
||||||
|
// самостоятельная надпись. Именно это разводит «Has Skill Up» и «Trade Goods»,
|
||||||
|
// которые стоят с абзацным интервалом и одинаковым отступом.
|
||||||
|
int widest = line.Rect.Width;
|
||||||
|
foreach (var bl in blk) if (bl.Rect.Width > widest) widest = bl.Rect.Width;
|
||||||
|
|
||||||
|
// Порог зависит от того, состоялся ли уже абзац. Для одиночной строки он строг:
|
||||||
|
// короткая надпись, под которой стоит другая, — это два разных элемента
|
||||||
|
// («Has Skill Up» и «Trade Goods»). А внутри абзаца из нескольких строк рваный
|
||||||
|
// правый край нормален, и строгий порог отрывал от него последнюю строку.
|
||||||
|
double fill = blk.Count >= 2 ? 0.6 : 0.65;
|
||||||
|
if (last.Rect.Width < widest * fill) continue;
|
||||||
|
|
||||||
|
bool aligned = Math.Abs(line.Rect.Left - last.Rect.Left) <= Math.Max(3.0, h * 0.25);
|
||||||
|
double thr = aligned ? 1.2 : 1.05;
|
||||||
|
if (gap > Math.Max(lh, h) * thr) continue;
|
||||||
|
|
||||||
|
// На разрядке около предельной одного выравнивания мало: так подпись-заголовок
|
||||||
|
// над пунктом меню слипалась со следующим пунктом. Требуем ещё и одинаковый кегль —
|
||||||
|
// внутри абзаца он совпадает, а заголовок от текста почти всегда отличается.
|
||||||
|
if (gap > Math.Max(lh, h) * 0.9 &&
|
||||||
|
Math.Max(lh, h) / Math.Max(1.0, Math.Min(lh, h)) > 1.15) continue;
|
||||||
|
|
||||||
|
if (HOverlap(line.Rect, last.Rect) < 0.35 &&
|
||||||
|
Math.Abs(line.Rect.Left - last.Rect.Left) > h * 0.8) continue;
|
||||||
|
|
||||||
|
if (gap < bestGap) { bestGap = gap; best = blk; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (best != null) best.Add(line);
|
||||||
|
else open.Add(new List<OcrLineBox> { line });
|
||||||
|
}
|
||||||
|
done.AddRange(open);
|
||||||
|
|
||||||
|
var result = new List<TextBlock>(done.Count);
|
||||||
|
foreach (var blk in done)
|
||||||
|
{
|
||||||
|
var rect = blk[0].Rect;
|
||||||
|
foreach (var l in blk) rect = Rectangle.Union(rect, l.Rect);
|
||||||
|
var heights = blk.Select(LineH).OrderBy(x => x).ToList();
|
||||||
|
|
||||||
|
result.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Rect = rect,
|
||||||
|
Source = Join(blk),
|
||||||
|
LineHeight = heights[heights.Count / 2],
|
||||||
|
LineCount = blk.Count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.Where(b => IsMeaningful(b.Source, minChars) &&
|
||||||
|
WordCount(b.Source) >= minWords &&
|
||||||
|
(!skipGarbage || LooksLikeLanguage(b.Source))).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static int WordCount(string s)
|
||||||
|
{
|
||||||
|
return s.Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Count(w => w.Any(char.IsLetter));
|
||||||
|
}
|
||||||
|
|
||||||
|
static int ColorDistance(Color a, Color b)
|
||||||
|
{
|
||||||
|
return (Math.Abs(a.R - b.R) + Math.Abs(a.G - b.G) + Math.Abs(a.B - b.B)) / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Собрать обратно строку, которую распознавание разрезало по широким пробелам.
|
||||||
|
///
|
||||||
|
/// В играх с моноширинным или пиксельным шрифтом промежутки между словами широкие, и OCR
|
||||||
|
/// отдаёт «SPACE/CLICK», «HERE», «TO CONTINUE» тремя отдельными строками. Переведённые
|
||||||
|
/// порознь, они превращаются в бессмыслицу, поэтому фрагменты на одной базовой линии
|
||||||
|
/// нужно склеить до того, как строки начнут собираться в абзацы.
|
||||||
|
///
|
||||||
|
/// Ограничения намеренно жёсткие: соседние элементы интерфейса тоже стоят в одну строку
|
||||||
|
/// («Search» — «Has Skill Up» — «Have Materials»), и склеить их было бы хуже, чем
|
||||||
|
/// оставить фразу разрезанной.</summary>
|
||||||
|
static List<OcrLineBox> MergeHorizontally(List<OcrLineBox> lines, FrameSampler sampler)
|
||||||
|
{
|
||||||
|
var byX = lines.OrderBy(l => l.Rect.Left).ToList();
|
||||||
|
var used = new bool[byX.Count];
|
||||||
|
var result = new List<OcrLineBox>();
|
||||||
|
|
||||||
|
for (int i = 0; i < byX.Count; i++)
|
||||||
|
{
|
||||||
|
if (used[i]) continue;
|
||||||
|
used[i] = true;
|
||||||
|
var cur = new OcrLineBox
|
||||||
|
{
|
||||||
|
Text = byX[i].Text,
|
||||||
|
Rect = byX[i].Rect,
|
||||||
|
AvgWordHeight = byX[i].AvgWordHeight,
|
||||||
|
Bg = byX[i].Bg,
|
||||||
|
BgKnown = byX[i].BgKnown
|
||||||
|
};
|
||||||
|
|
||||||
|
bool grew = true;
|
||||||
|
while (grew)
|
||||||
|
{
|
||||||
|
grew = false;
|
||||||
|
for (int j = 0; j < byX.Count; j++)
|
||||||
|
{
|
||||||
|
if (used[j] || !CanMergeH(cur, byX[j], sampler)) continue;
|
||||||
|
|
||||||
|
// Совсем узкий зазор — это разорванное слово, а не два соседних:
|
||||||
|
// «Li gh thouse» нужно собрать обратно в «Lighthouse», иначе не переведётся.
|
||||||
|
int gap = byX[j].Rect.Left - cur.Rect.Right;
|
||||||
|
string glue = gap < LineH(cur) * 0.32 ? "" : " ";
|
||||||
|
cur.Text = cur.Text.TrimEnd() + glue + byX[j].Text.TrimStart();
|
||||||
|
cur.Rect = Rectangle.Union(cur.Rect, byX[j].Rect);
|
||||||
|
cur.AvgWordHeight = Math.Max(cur.AvgWordHeight, byX[j].AvgWordHeight);
|
||||||
|
used[j] = true;
|
||||||
|
grew = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.Add(cur);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool CanMergeH(OcrLineBox a, OcrLineBox b, FrameSampler sampler)
|
||||||
|
{
|
||||||
|
int gap = b.Rect.Left - a.Rect.Right;
|
||||||
|
if (gap < -2) return false; // перекрываются — разные слои
|
||||||
|
|
||||||
|
double ha = LineH(a), hb = LineH(b);
|
||||||
|
if (Math.Max(ha, hb) / Math.Max(1.0, Math.Min(ha, hb)) > 1.4) return false;
|
||||||
|
|
||||||
|
// Пробел шире двух высот строки — это уже не пробел, а колонка или другой элемент.
|
||||||
|
if (gap > Math.Min(ha, hb) * 2.2) return false;
|
||||||
|
|
||||||
|
int overlap = Math.Min(a.Rect.Bottom, b.Rect.Bottom) - Math.Max(a.Rect.Top, b.Rect.Top);
|
||||||
|
if (overlap <= 0) return false;
|
||||||
|
if ((double)overlap / Math.Min(a.Rect.Height, b.Rect.Height) < 0.6) return false;
|
||||||
|
|
||||||
|
if (a.BgKnown && b.BgKnown && ColorDistance(a.Bg, b.Bg) > 42) return false;
|
||||||
|
|
||||||
|
// В промежутке стоит рамка, чекбокс или край колонки — значит фрагменты чужие
|
||||||
|
if (sampler != null && gap >= 3)
|
||||||
|
{
|
||||||
|
int top = Math.Min(a.Rect.Top, b.Rect.Top);
|
||||||
|
int bottom = Math.Max(a.Rect.Bottom, b.Rect.Bottom);
|
||||||
|
if (sampler.BandContrastVertical(a.Rect.Right + 1, b.Rect.Left - 1, top, bottom) > 45)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double LineH(OcrLineBox l)
|
||||||
|
{
|
||||||
|
return l.AvgWordHeight > 1 ? l.AvgWordHeight : Math.Max(1, l.Rect.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double HOverlap(Rectangle a, Rectangle b)
|
||||||
|
{
|
||||||
|
int o = Math.Min(a.Right, b.Right) - Math.Max(a.Left, b.Left);
|
||||||
|
if (o <= 0) return 0;
|
||||||
|
return (double)o / Math.Max(1, Math.Min(a.Width, b.Width));
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Join(List<OcrLineBox> blk)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < blk.Count; i++)
|
||||||
|
{
|
||||||
|
string t = blk[i].Text.Trim();
|
||||||
|
if (t.Length == 0) continue;
|
||||||
|
if (sb.Length > 0)
|
||||||
|
{
|
||||||
|
// перенос по дефису склеиваем обратно в слово
|
||||||
|
if (sb[sb.Length - 1] == '-' && t.Length > 0 && char.IsLower(t[0])) sb.Length -= 1;
|
||||||
|
else sb.Append(' ');
|
||||||
|
}
|
||||||
|
sb.Append(t);
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static readonly char[] Vowels = "aeiouyAEIOUYаеёиоуыэюяАЕЁИОУЫЭЮЯ".ToCharArray();
|
||||||
|
|
||||||
|
/// <summary>Насколько текст похож на осмысленный: доля слов без следов покорёженного
|
||||||
|
/// распознавания. 1.0 — чистый текст, около 0.5 и ниже — каша.</summary>
|
||||||
|
internal static double LanguageScore(string s)
|
||||||
|
{
|
||||||
|
var words = s.Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Where(w => w.Length >= 2).ToList();
|
||||||
|
if (words.Count == 0) return 0;
|
||||||
|
int good = 0;
|
||||||
|
foreach (var w in words)
|
||||||
|
if (!LooksMangled(w) && (w.Length < 3 || w.IndexOfAny(Vowels) >= 0)) good++;
|
||||||
|
return (double)good / words.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Выбросить блоки, написанные не на том языке, под который выбрана модель OCR.
|
||||||
|
///
|
||||||
|
/// Статистика по одному тексту тут бессильна: «npo cauyyn qnmy» из букв, которые модель
|
||||||
|
/// подобрала к кириллице, по форме неотличимо от английского. Зато если тот же участок
|
||||||
|
/// экрана прочитать второй моделью и он окажется заметно осмысленнее — вопрос закрыт,
|
||||||
|
/// это текст на её языке, и переводить его не нужно.</summary>
|
||||||
|
public static void DropForeign(List<TextBlock> blocks, List<OcrLineBox> otherLines,
|
||||||
|
bool sourceIsCyrillic, double margin = 0.15)
|
||||||
|
{
|
||||||
|
if (blocks == null || otherLines == null || otherLines.Count == 0) return;
|
||||||
|
|
||||||
|
blocks.RemoveAll(b =>
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var l in otherLines.OrderBy(l => l.Rect.Top))
|
||||||
|
{
|
||||||
|
var inter = Rectangle.Intersect(l.Rect, b.Rect);
|
||||||
|
if (inter.IsEmpty) continue;
|
||||||
|
long areaLine = (long)l.Rect.Width * l.Rect.Height;
|
||||||
|
if (areaLine <= 0) continue;
|
||||||
|
// Модели режут строки по-разному, поэтому требовать почти полного совпадения
|
||||||
|
// нельзя — иначе сравнение не состоится именно там, где оно нужнее всего.
|
||||||
|
if ((double)inter.Width * inter.Height / areaLine < 0.45) continue;
|
||||||
|
if (sb.Length > 0) sb.Append(' ');
|
||||||
|
sb.Append(l.Text.Trim());
|
||||||
|
}
|
||||||
|
if (sb.Length < 3) return false;
|
||||||
|
string other = sb.ToString();
|
||||||
|
|
||||||
|
// Решающий признак — письменность, а не осмысленность. Английская модель физически
|
||||||
|
// не может выдать кириллицу: «Профиль» она отдаёт как «npocbwlb», что по форме
|
||||||
|
// неотличимо от настоящего слова. Зато русская модель читает латиницу как латиницу,
|
||||||
|
// поэтому кириллица в её ответе означает, что надпись изначально русская.
|
||||||
|
int cyr = 0, lat = 0;
|
||||||
|
foreach (char c in other)
|
||||||
|
{
|
||||||
|
if (c >= 0x0400 && c <= 0x04FF) cyr++;
|
||||||
|
else if (c < 128 && char.IsLetter(c)) lat++;
|
||||||
|
}
|
||||||
|
if (cyr + lat >= 3)
|
||||||
|
{
|
||||||
|
bool otherIsCyrillic = cyr > lat;
|
||||||
|
if (otherIsCyrillic != sourceIsCyrillic)
|
||||||
|
{
|
||||||
|
// Вторая модель говорит «чужой алфавит», но она же принимает латинские
|
||||||
|
// C, a, e за кириллические и так топит короткие английские кнопки.
|
||||||
|
// Узнаваемое английское слово в исходнике — весомее её мнения.
|
||||||
|
if (!sourceIsCyrillic && EnglishWords.ContainsKnown(b.Source)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return LanguageScore(other) > LanguageScore(b.Source) + margin;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Пишется ли язык кириллицей — нужно, чтобы понимать, какой алфавит ожидать
|
||||||
|
/// от текста на языке оригинала.</summary>
|
||||||
|
/// <summary>Посчитать, докуда каждой плашке позволено разрастаться, чтобы не залезть
|
||||||
|
/// на соседний текст.
|
||||||
|
///
|
||||||
|
/// Перевод почти всегда длиннее оригинала, и плашке приходится расти. В разреженном
|
||||||
|
/// интерфейсе это незаметно, но в плотном (окна игры, таблицы) разросшаяся плашка
|
||||||
|
/// перекрывает соседние подписи — читаемым становится один фрагмент ценой двух соседних.</summary>
|
||||||
|
public static void ComputeGrowLimits(List<TextBlock> blocks, List<OcrLineBox> allLines, Size canvas)
|
||||||
|
{
|
||||||
|
// Препятствие — любой распознанный текст, а не только то, что мы переводим.
|
||||||
|
// Отфильтрованные строки (числа, надписи на другом языке) с экрана никуда не делись:
|
||||||
|
// заголовок, разросшийся на вторую строку, ложился ровно на них.
|
||||||
|
var obstacles = new List<Rectangle>();
|
||||||
|
foreach (var b in blocks) obstacles.Add(b.Rect);
|
||||||
|
if (allLines != null)
|
||||||
|
foreach (var l in allLines) obstacles.Add(l.Rect);
|
||||||
|
|
||||||
|
foreach (var b in blocks)
|
||||||
|
{
|
||||||
|
int right = canvas.Width, bottom = canvas.Height, top = 0;
|
||||||
|
foreach (var o in obstacles)
|
||||||
|
{
|
||||||
|
if (b.Rect.Contains(o) || o == b.Rect) continue; // часть самого блока
|
||||||
|
|
||||||
|
bool sameRows = o.Top < b.Rect.Bottom && o.Bottom > b.Rect.Top;
|
||||||
|
if (sameRows && o.Left >= b.Rect.Right)
|
||||||
|
right = Math.Min(right, o.Left - 2);
|
||||||
|
|
||||||
|
bool sameCols = o.Left < b.Rect.Right && o.Right > b.Rect.Left;
|
||||||
|
if (sameCols && o.Top >= b.Rect.Bottom)
|
||||||
|
bottom = Math.Min(bottom, o.Top - 1);
|
||||||
|
if (sameCols && o.Bottom <= b.Rect.Top)
|
||||||
|
top = Math.Max(top, o.Bottom + 1);
|
||||||
|
}
|
||||||
|
b.GrowLimit = Rectangle.FromLTRB(
|
||||||
|
b.Rect.Left, Math.Min(top, b.Rect.Top),
|
||||||
|
Math.Max(b.Rect.Right, right), Math.Max(b.Rect.Bottom, bottom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsCyrillicLanguage(string langCode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(langCode)) return false;
|
||||||
|
string two = langCode.Split('-')[0].ToLowerInvariant();
|
||||||
|
switch (two)
|
||||||
|
{
|
||||||
|
case "ru": case "uk": case "be": case "bg": case "sr":
|
||||||
|
case "mk": case "kk": case "ky": case "mn": case "tg":
|
||||||
|
return true;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Похоже ли это на настоящий текст, а не на кашу распознавания.
|
||||||
|
///
|
||||||
|
/// На экране почти всегда есть надписи не на том языке, для которого выбрана модель OCR:
|
||||||
|
/// английская модель, встретив кириллицу, выдаёт нечто вроде «gT0 ffileals Ha MauiHHe».
|
||||||
|
/// Такую кашу бессмысленно переводить — она только засоряет экран плашками и жжёт лимиты
|
||||||
|
/// переводчика. Отличаем по статистике: в настоящем тексте почти в каждом слове есть
|
||||||
|
/// гласная, мало цифр внутри слов и высокая доля букв.
|
||||||
|
///
|
||||||
|
/// Короткие подписи (OK, PVE, 45%) пропускаем без проверки — статистика на них не работает.</summary>
|
||||||
|
internal static bool LooksLikeLanguage(string s)
|
||||||
|
{
|
||||||
|
var words = s.Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Where(w => w.Length >= 2).ToList();
|
||||||
|
if (words.Count == 0) return false;
|
||||||
|
|
||||||
|
// Сигнатура каши хорошо видна даже в одном слове, поэтому короткие подписи
|
||||||
|
// тоже проверяем — именно на них («C03AaTb», «Pa3Mep», «npocM0TpeTb») фильтр
|
||||||
|
// по длине раньше и прокалывался, залепляя плашками весь русский интерфейс.
|
||||||
|
int suspicious = 0, fileish = 0;
|
||||||
|
foreach (var w in words)
|
||||||
|
{
|
||||||
|
if (LooksLikeFileName(w)) { fileish++; continue; }
|
||||||
|
if (LooksMangled(w)) suspicious++;
|
||||||
|
}
|
||||||
|
if (fileish == words.Count) return false; // имена файлов и пути переводить нечего
|
||||||
|
int scored = words.Count - fileish;
|
||||||
|
if (scored > 0 && (double)suspicious / scored >= 0.5) return false;
|
||||||
|
|
||||||
|
// «18 KS», «41 KS» — обрывки размеров и счётчиков: цифры есть, а осмысленного
|
||||||
|
// слова нет. Переводить нечего, а плашка поверх мешает.
|
||||||
|
bool hasDigit = s.IndexOfAny("0123456789".ToCharArray()) >= 0;
|
||||||
|
if (hasDigit && !words.Any(w => w.Count(char.IsLetter) >= 4)) return false;
|
||||||
|
|
||||||
|
// Частоты буквенных пар. Ловит именно ту кашу, что неотличима по форме от слова:
|
||||||
|
// «npocbwlb» (-3,5) и «Bxoaguvqe» (-4,5) против настоящих надписей, которые на этих
|
||||||
|
// же замерах не опускались ниже -2,9. Порог взят с зазором, чтобы не задеть их;
|
||||||
|
// кашу с цифрами и скачками регистра отсеивают правила выше.
|
||||||
|
double bigram = EnglishBigrams.TextScore(s);
|
||||||
|
if (!double.IsNaN(bigram) && bigram < -3.0) return false;
|
||||||
|
|
||||||
|
if (s.Length < 12) return true;
|
||||||
|
|
||||||
|
int letters = 0, nonSpace = 0;
|
||||||
|
foreach (char c in s)
|
||||||
|
{
|
||||||
|
if (char.IsWhiteSpace(c)) continue;
|
||||||
|
nonSpace++;
|
||||||
|
if (char.IsLetter(c)) letters++;
|
||||||
|
}
|
||||||
|
if (nonSpace == 0) return false;
|
||||||
|
if ((double)letters / nonSpace < 0.62) return false;
|
||||||
|
|
||||||
|
var longWords = words.Where(w => w.Length >= 3).ToList();
|
||||||
|
if (longWords.Count < 2) return true;
|
||||||
|
|
||||||
|
int withVowel = 0;
|
||||||
|
foreach (var w in longWords) if (w.IndexOfAny(Vowels) >= 0) withVowel++;
|
||||||
|
return (double)withVowel / longWords.Count >= 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Признаки слова, покорёженного распознаванием чужого алфавита: цифра, зажатая
|
||||||
|
/// между букв («Pa3Mep»), и заглавная сразу после строчной («COPTMPOBaTb»).
|
||||||
|
///
|
||||||
|
/// Оба признака встречаются и в нормальном тексте — CamelCase, «IPv6», «UTF-8», — поэтому
|
||||||
|
/// решение принимается не по слову, а по их доле в блоке.</summary>
|
||||||
|
const string OkPunctuation = ".,:;!?'\"()[]-–—/\\%&#@+*=_<>|~$^°«»…№";
|
||||||
|
|
||||||
|
static bool LooksMangled(string w)
|
||||||
|
{
|
||||||
|
for (int i = 1; i < w.Length - 1; i++)
|
||||||
|
if (char.IsDigit(w[i]) && char.IsLetter(w[i - 1]) && char.IsLetter(w[i + 1])) return true;
|
||||||
|
|
||||||
|
// Символы, которых в обычной надписи не бывает: так распознавание подменяет
|
||||||
|
// непонятые ему глифы («Pa60t-u•1i», «KOMnb}0Tep»).
|
||||||
|
foreach (char c in w)
|
||||||
|
if (!char.IsLetterOrDigit(c) && OkPunctuation.IndexOf(c) < 0) return true;
|
||||||
|
|
||||||
|
if (w.Length > 3 && w != w.ToUpperInvariant())
|
||||||
|
for (int i = 1; i < w.Length; i++)
|
||||||
|
if (char.IsUpper(w[i]) && char.IsLower(w[i - 1])) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Имя файла, путь или адрес — переводить их не нужно, «cache.json» не должен
|
||||||
|
/// превращаться в «кэш.json».</summary>
|
||||||
|
static bool LooksLikeFileName(string w)
|
||||||
|
{
|
||||||
|
if (w.IndexOf('/') >= 0 || w.IndexOf('\\') >= 0) return true;
|
||||||
|
int dot = w.LastIndexOf('.');
|
||||||
|
if (dot <= 0 || dot == w.Length - 1) return false;
|
||||||
|
string ext = w.Substring(dot + 1).Trim('"', '\'', ',', ')', ':', ';');
|
||||||
|
if (ext.Length < 1 || ext.Length > 5) return false;
|
||||||
|
foreach (char c in ext) if (!char.IsLetterOrDigit(c)) return false;
|
||||||
|
// «конец предложения.» — не имя файла
|
||||||
|
return !char.IsWhiteSpace(w[dot - 1]) && ext.ToLowerInvariant() != ext.ToUpperInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Отсев мусора OCR: рамки, иконки, одиночные символы, голые числа —
|
||||||
|
/// переводить нечего, а плашка поверх них только мешает.</summary>
|
||||||
|
static bool IsMeaningful(string s, int minChars)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return false;
|
||||||
|
int letters = 0;
|
||||||
|
foreach (char c in s) if (char.IsLetter(c)) letters++;
|
||||||
|
return letters >= Math.Max(1, minChars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Отвечает на вопрос «какого цвета фон и текст в этом прямоугольнике» — чтобы плашка
|
||||||
|
/// совпала с окном под ней. Работает по копии пикселей из CapturedFrame, поэтому безопасен
|
||||||
|
/// в фоновом потоке параллельно с отрисовкой оверлея.</summary>
|
||||||
|
internal sealed class FrameSampler
|
||||||
|
{
|
||||||
|
readonly byte[] _px;
|
||||||
|
readonly int _stride, _w, _h;
|
||||||
|
|
||||||
|
public FrameSampler(CapturedFrame frame)
|
||||||
|
{
|
||||||
|
_w = frame.Width; _h = frame.Height;
|
||||||
|
_stride = _w * 4;
|
||||||
|
_px = frame.Pixels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Разброс средней яркости строк пикселей в горизонтальной полосе.
|
||||||
|
///
|
||||||
|
/// Отвечает на вопрос «есть ли между двумя строками текста визуальная граница»: рамка поля
|
||||||
|
/// или край панели тянутся во всю ширину и дают резкий скачок, а промежуток внутри абзаца
|
||||||
|
/// однороден — выносные элементы букв среднюю по строке почти не двигают.</summary>
|
||||||
|
/// <summary>То же, но полоса вертикальная: есть ли разделитель СЛЕВА и СПРАВА между
|
||||||
|
/// двумя фрагментами на одной строке — рамка чекбокса, край колонки, граница поля.</summary>
|
||||||
|
public int BandContrastVertical(int x0, int x1, int y0, int y1)
|
||||||
|
{
|
||||||
|
x0 = Math.Max(0, x0); x1 = Math.Min(_w, x1);
|
||||||
|
y0 = Math.Max(0, y0); y1 = Math.Min(_h, y1);
|
||||||
|
if (x1 - x0 < 2 || y1 - y0 < 4) return 0;
|
||||||
|
|
||||||
|
int min = int.MaxValue, max = int.MinValue;
|
||||||
|
int step = Math.Max(1, (y1 - y0) / 40);
|
||||||
|
for (int x = x0; x < x1; x++)
|
||||||
|
{
|
||||||
|
int sum = 0, n = 0;
|
||||||
|
for (int y = y0; y < y1; y += step) { sum += Lum(At(x, y)); n++; }
|
||||||
|
if (n == 0) continue;
|
||||||
|
int avg = sum / n;
|
||||||
|
if (avg < min) min = avg;
|
||||||
|
if (avg > max) max = avg;
|
||||||
|
}
|
||||||
|
return max > min ? max - min : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int BandContrast(int x0, int x1, int y0, int y1)
|
||||||
|
{
|
||||||
|
x0 = Math.Max(0, x0); x1 = Math.Min(_w, x1);
|
||||||
|
y0 = Math.Max(0, y0); y1 = Math.Min(_h, y1);
|
||||||
|
if (x1 - x0 < 8 || y1 - y0 < 2) return 0;
|
||||||
|
|
||||||
|
int min = int.MaxValue, max = int.MinValue;
|
||||||
|
int step = Math.Max(1, (x1 - x0) / 160);
|
||||||
|
for (int y = y0; y < y1; y++)
|
||||||
|
{
|
||||||
|
int sum = 0, n = 0;
|
||||||
|
for (int x = x0; x < x1; x += step) { sum += Lum(At(x, y)); n++; }
|
||||||
|
if (n == 0) continue;
|
||||||
|
int avg = sum / n;
|
||||||
|
if (avg < min) min = avg;
|
||||||
|
if (avg > max) max = avg;
|
||||||
|
}
|
||||||
|
return max > min ? max - min : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color At(int x, int y)
|
||||||
|
{
|
||||||
|
int i = y * _stride + x * 4;
|
||||||
|
return Color.FromArgb(_px[i + 2], _px[i + 1], _px[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Lum(Color c) { return (c.R * 299 + c.G * 587 + c.B * 114) / 1000; }
|
||||||
|
|
||||||
|
/// <summary>Доминирующий цвет = фон; самый частый из «далёких по яркости» = цвет текста.
|
||||||
|
/// Если контраста нет (градиент, картинка), возвращаем чёрное на белом — читаемо всегда.</summary>
|
||||||
|
public void Sample(Rectangle r, out Color bg, out Color fg)
|
||||||
|
{
|
||||||
|
r.Inflate(2, 1);
|
||||||
|
r.Intersect(new Rectangle(0, 0, _w, _h));
|
||||||
|
if (r.Width < 2 || r.Height < 2) { bg = Color.White; fg = Color.Black; return; }
|
||||||
|
|
||||||
|
var hist = new Dictionary<int, int>(256);
|
||||||
|
int stepX = Math.Max(1, r.Width / 120), stepY = Math.Max(1, r.Height / 40);
|
||||||
|
for (int y = r.Top; y < r.Bottom; y += stepY)
|
||||||
|
for (int x = r.Left; x < r.Right; x += stepX)
|
||||||
|
{
|
||||||
|
var c = At(x, y);
|
||||||
|
int key = (c.R >> 3 << 10) | (c.G >> 3 << 5) | (c.B >> 3);
|
||||||
|
int n; hist.TryGetValue(key, out n); hist[key] = n + 1;
|
||||||
|
}
|
||||||
|
if (hist.Count == 0) { bg = Color.White; fg = Color.Black; return; }
|
||||||
|
|
||||||
|
int bgKey = 0, bgN = -1;
|
||||||
|
foreach (var kv in hist) if (kv.Value > bgN) { bgN = kv.Value; bgKey = kv.Key; }
|
||||||
|
bg = FromKey(bgKey);
|
||||||
|
int bgL = Lum(bg);
|
||||||
|
|
||||||
|
int fgKey = -1, fgN = -1;
|
||||||
|
foreach (var kv in hist)
|
||||||
|
{
|
||||||
|
var c = FromKey(kv.Key);
|
||||||
|
if (Math.Abs(Lum(c) - bgL) < 70) continue;
|
||||||
|
if (kv.Value > fgN) { fgN = kv.Value; fgKey = kv.Key; }
|
||||||
|
}
|
||||||
|
fg = fgKey >= 0 ? FromKey(fgKey) : (bgL > 128 ? Color.FromArgb(20, 20, 20) : Color.FromArgb(240, 240, 240));
|
||||||
|
|
||||||
|
// «почти совпало» — принудительно разводим, иначе перевод сольётся с плашкой
|
||||||
|
if (Math.Abs(Lum(fg) - bgL) < 60)
|
||||||
|
fg = bgL > 128 ? Color.FromArgb(20, 20, 20) : Color.FromArgb(240, 240, 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color FromKey(int k)
|
||||||
|
{
|
||||||
|
int r = (k >> 10) & 31, g = (k >> 5) & 31, b = k & 31;
|
||||||
|
return Color.FromArgb(r << 3 | r >> 2, g << 3 | g >> 2, b << 3 | b >> 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ScreenTranslator
|
||||||
|
{
|
||||||
|
/// <summary>Модель правдоподобия английского текста по частотам буквенных пар.
|
||||||
|
///
|
||||||
|
/// Решает задачу, с которой не справляются ни словарь, ни алфавит: английская модель OCR,
|
||||||
|
/// прочитав русскую надпись, выдаёт правдоподобное на вид латинское слово («Профиль» →
|
||||||
|
/// «npocbwlb»). Но пары букв выдают подделку сразу — сочетаний «wl», «cb», «bw» в английском
|
||||||
|
/// практически не бывает, тогда как «th», «he», «in» составляют его основу.
|
||||||
|
///
|
||||||
|
/// Таблица — 676 квантованных log10-вероятностей, по символу на пару, около 700 байт.
|
||||||
|
/// Сгенерирована tools/make-bigrams.ps1 из частот корпуса английских текстов
|
||||||
|
/// (practicalcryptography.com). Правится только генератором, не руками.</summary>
|
||||||
|
internal static class EnglishBigrams
|
||||||
|
{
|
||||||
|
const double Lo = -9, Step = 12;
|
||||||
|
|
||||||
|
const string Table =
|
||||||
|
"enqpakngpbkuqxan\\vuwkmi`oan`\\XrURYj\\PlZWmWGjd^mWXDkIr\\h]r[YrmRmk\\Zs]YkdojV[GcTqlijtjgjra_hihph\\koqlckPgVvnsvrpmmqdgsqwppdywuiopkm^" +
|
||||||
|
"mbeanl`eo\\[hd_rcRmfph[bNaPocdbpddnmYYgcgmcTmimiZeLbPubdaza^dtZZcddrbVigmhZdIdQngrppkn``Zgqozri\\ouu`nbbVfeNONdLKN]MLKOLhQ=URNhJM=GD" +
|
||||||
|
"h^^\\o_Z`lUWa_fe]L_hd_UaG_KsggnthbdsZcsgaqgUdnljcfMpSrjc_ta\\aoXW_i_pmN`ihiXbLfNsiqvtkuiqdhiikrhZgrwifjUj[llmmgtjik`iprwnoWvprtmp`f[" +
|
||||||
|
"o]^\\q^ZhkRUnbXokNpgiiS_H^JUPKJHJCKU@BJKEJHCHNMjFQ=F8tilnxijht_jkmmtiYlrrkhiQnWtkoitlfqsagkkjsoairwnbnSgUsikgviezv_`kievhYqrrn`nPm\\" +
|
||||||
|
"jhkikak]iV_njq`kOqqqTZ]X_YjPTVtPOPnKKSQQgT@VZTUORD[Cq^^^p]XoqVX`_in\\McfdYT`F_SbV`T`WRYbKLTXQZfGVYeXPWQVBmhhflgcgj]]fgdngVfml`^iM]U" +
|
||||||
|
"bSRQePOW`IOURO^PHQVSWLTCW[";
|
||||||
|
|
||||||
|
/// <summary>Средний log10 вероятности пар букв в слове. Около -2,5 у обычного английского
|
||||||
|
/// слова, ниже -4 у случайного набора букв. NaN, если букв слишком мало для оценки.</summary>
|
||||||
|
public static double WordScore(string word)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(word)) return double.NaN;
|
||||||
|
double sum = 0; int n = 0;
|
||||||
|
for (int i = 0; i + 1 < word.Length; i++)
|
||||||
|
{
|
||||||
|
int a = Index(word[i]), b = Index(word[i + 1]);
|
||||||
|
if (a < 0 || b < 0) continue;
|
||||||
|
sum += Lo + (Table[a * 26 + b] - 34) / Step;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n == 0 ? double.NaN : sum / n;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Index(char c)
|
||||||
|
{
|
||||||
|
if (c >= 'a' && c <= 'z') return c - 'a';
|
||||||
|
if (c >= 'A' && c <= 'Z') return c - 'A';
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Оценка текста целиком: усреднение по словам длиной от четырёх букв —
|
||||||
|
/// на более коротких пары букв ничего не доказывают. NaN, если оценивать нечего.</summary>
|
||||||
|
public static double TextScore(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return double.NaN;
|
||||||
|
double sum = 0; int n = 0;
|
||||||
|
foreach (var w in text.Split(new[] { ' ', '\t', '\n', '\r', '.', ',', ':', ';', '!', '?',
|
||||||
|
'(', ')', '[', ']', '"', '\'', '/', '\\', '-' },
|
||||||
|
StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
if (w.Length < 4) continue;
|
||||||
|
double s = WordScore(w);
|
||||||
|
if (double.IsNaN(s)) continue;
|
||||||
|
sum += s; n++;
|
||||||
|
}
|
||||||
|
return n == 0 ? double.NaN : sum / n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace ScreenTranslator
|
||||||
|
{
|
||||||
|
/// <summary>Короткий список ходовых английских слов.
|
||||||
|
///
|
||||||
|
/// Нужен для одного узкого, но принципиального решения. Русская модель OCR читает латинское
|
||||||
|
/// «Cancel» как кириллическое «Сапсеl», а английская читает русское «Профиль» как «npocbwlb» —
|
||||||
|
/// по алфавиту эти два случая неразличимы, и любой порог отправляет в отсев либо настоящие
|
||||||
|
/// английские кнопки, либо ничего. Словарь разрывает ничью: если основная модель выдала
|
||||||
|
/// осмысленное английское слово, значит надпись действительно английская.
|
||||||
|
///
|
||||||
|
/// Полнота тут не нужна и даже вредна — список работает как подтверждение, а не как проверка:
|
||||||
|
/// незнакомое слово («Leatherworking») ничего не решает само по себе, вопрос тогда
|
||||||
|
/// достаётся другим признакам.</summary>
|
||||||
|
internal static class EnglishWords
|
||||||
|
{
|
||||||
|
// Частотная лексика плюс то, из чего состоят интерфейсы: пункты меню, кнопки, статусы.
|
||||||
|
const string List =
|
||||||
|
"the be to of and in that have it for not on with he as you do at this but his by from " +
|
||||||
|
"they we say her she or an will my one all would there their what so up out if about who " +
|
||||||
|
"get which go me when make can like time no just him know take people into year your good " +
|
||||||
|
"some could them see other than then now look only come its over think also back after use " +
|
||||||
|
"two how our work first well way even new want because any these give day most us is are was " +
|
||||||
|
"were been has had did does more very much many such own same each few own here where why " +
|
||||||
|
"while before between under again both during through against off down out above below " +
|
||||||
|
|
||||||
|
"file edit view tools help window options settings preferences account profile user users " +
|
||||||
|
"name type size date time modified created folder path search find replace filter sort " +
|
||||||
|
"open close save load new delete remove add insert copy paste cut undo redo cancel ok yes " +
|
||||||
|
"no apply confirm submit send receive upload download import export print share link " +
|
||||||
|
"start stop pause resume restart reset refresh reload update upgrade install uninstall " +
|
||||||
|
"next previous back forward finish done continue skip retry ignore select choose enable " +
|
||||||
|
"disable show hide expand collapse minimize maximize restore exit quit login logout sign " +
|
||||||
|
"password username email address phone mobile home page main menu list item items details " +
|
||||||
|
"status error warning failed success complete completed pending loading please wait " +
|
||||||
|
"connection connected disconnected network server client host port proxy offline online " +
|
||||||
|
"device devices display screen sound audio video image picture photo music media player " +
|
||||||
|
"storage memory disk drive space free used total available required version build release " +
|
||||||
|
"language region keyboard mouse theme dark light color font text style format layout " +
|
||||||
|
"notification notifications message messages chat comment comments reply forward inbox " +
|
||||||
|
"history recent favorites bookmarks library collection playlist channel channels group " +
|
||||||
|
"groups friend friends member members public private security privacy permission access " +
|
||||||
|
"enabled disabled default custom advanced general basic system application program app " +
|
||||||
|
"window windows button field label value data info information description note notes " +
|
||||||
|
"warning caution danger critical unknown none auto manual always never once daily weekly " +
|
||||||
|
|
||||||
|
"level experience health mana damage defense attack skill skills quest quests inventory " +
|
||||||
|
"equip equipped weapon armor shield potion gold silver copper trade craft crafting cook " +
|
||||||
|
"cooking mining fishing gathering materials material recipe recipes learn learned train " +
|
||||||
|
"guild party raid dungeon boss enemy player character class race spell ability cooldown " +
|
||||||
|
"consumable equippable subclass slot slots reagent bag bank vendor merchant repair " +
|
||||||
|
|
||||||
|
"not equippable have has goods first aid all";
|
||||||
|
|
||||||
|
static readonly HashSet<string> Set = Build();
|
||||||
|
|
||||||
|
static HashSet<string> Build()
|
||||||
|
{
|
||||||
|
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var w in List.Split(' '))
|
||||||
|
if (w.Length > 0) set.Add(w);
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
static readonly char[] Trim = " \t\r\n.,:;!?'\"()[]{}<>|/\\-–—*&#@+=_%".ToCharArray();
|
||||||
|
|
||||||
|
/// <summary>Есть ли в тексте хотя бы одно узнаваемое английское слово.
|
||||||
|
/// Простые окончания (-s, -es, -ed, -ing) отсекаются, чтобы не раздувать список.</summary>
|
||||||
|
public static bool ContainsKnown(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return false;
|
||||||
|
foreach (var raw in text.Split(Trim, StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var w = raw.ToLowerInvariant();
|
||||||
|
if (w.Length < 2) continue;
|
||||||
|
if (Set.Contains(w)) return true;
|
||||||
|
if (w.Length > 3 && w.EndsWith("s") && Set.Contains(w.Substring(0, w.Length - 1))) return true;
|
||||||
|
if (w.Length > 4 && w.EndsWith("es") && Set.Contains(w.Substring(0, w.Length - 2))) return true;
|
||||||
|
if (w.Length > 4 && w.EndsWith("ed") && Set.Contains(w.Substring(0, w.Length - 2))) return true;
|
||||||
|
if (w.Length > 5 && w.EndsWith("ing") && Set.Contains(w.Substring(0, w.Length - 3))) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace ScreenTranslator
|
||||||
|
{
|
||||||
|
/// <summary>P/Invoke. Всё, что здесь возвращает координаты, отдаёт их в ФИЗИЧЕСКИХ пикселях
|
||||||
|
/// (процесс помечен PerMonitorV2 в app.manifest), поэтому они напрямую совпадают с пикселями
|
||||||
|
/// скриншота и с координатами боксов OCR.</summary>
|
||||||
|
internal static class Native
|
||||||
|
{
|
||||||
|
// ---- горячие клавиши ----
|
||||||
|
public const int WM_HOTKEY = 0x0312;
|
||||||
|
public const uint MOD_ALT = 0x0001, MOD_CONTROL = 0x0002, MOD_SHIFT = 0x0004, MOD_WIN = 0x0008;
|
||||||
|
public const uint MOD_NOREPEAT = 0x4000;
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
public static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
public static extern bool UnregisterHotKey(IntPtr hWnd, int id);
|
||||||
|
|
||||||
|
// ---- мониторы ----
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct POINT { public int X, Y; }
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct RECT
|
||||||
|
{
|
||||||
|
public int Left, Top, Right, Bottom;
|
||||||
|
public Rectangle ToRectangle() { return Rectangle.FromLTRB(Left, Top, Right, Bottom); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct MONITORINFO
|
||||||
|
{
|
||||||
|
public int cbSize;
|
||||||
|
public RECT rcMonitor;
|
||||||
|
public RECT rcWork;
|
||||||
|
public uint dwFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public const uint MONITOR_DEFAULTTONEAREST = 2;
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool GetCursorPos(out POINT lpPoint);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Auto)]
|
||||||
|
public static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
|
||||||
|
|
||||||
|
/// <summary>Границы монитора под курсором, физические пиксели.</summary>
|
||||||
|
public static Rectangle MonitorUnderCursor()
|
||||||
|
{
|
||||||
|
POINT p;
|
||||||
|
if (!GetCursorPos(out p)) return VirtualScreen();
|
||||||
|
IntPtr h = MonitorFromPoint(p, MONITOR_DEFAULTTONEAREST);
|
||||||
|
var mi = new MONITORINFO();
|
||||||
|
mi.cbSize = Marshal.SizeOf(typeof(MONITORINFO));
|
||||||
|
if (!GetMonitorInfo(h, ref mi)) return VirtualScreen();
|
||||||
|
return mi.rcMonitor.ToRectangle();
|
||||||
|
}
|
||||||
|
|
||||||
|
public const int SM_XVIRTUALSCREEN = 76, SM_YVIRTUALSCREEN = 77,
|
||||||
|
SM_CXVIRTUALSCREEN = 78, SM_CYVIRTUALSCREEN = 79;
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern int GetSystemMetrics(int nIndex);
|
||||||
|
|
||||||
|
public static Rectangle VirtualScreen()
|
||||||
|
{
|
||||||
|
return new Rectangle(
|
||||||
|
GetSystemMetrics(SM_XVIRTUALSCREEN), GetSystemMetrics(SM_YVIRTUALSCREEN),
|
||||||
|
GetSystemMetrics(SM_CXVIRTUALSCREEN), GetSystemMetrics(SM_CYVIRTUALSCREEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- окна ----
|
||||||
|
public const int GWL_EXSTYLE = -20;
|
||||||
|
public const int WS_EX_TOOLWINDOW = 0x00000080;
|
||||||
|
public const int WS_EX_NOACTIVATE = 0x08000000;
|
||||||
|
public const int WS_EX_TRANSPARENT = 0x00000020;
|
||||||
|
public const int WS_EX_LAYERED = 0x00080000;
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
public static extern int GetWindowLong(IntPtr hWnd, int nIndex);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern IntPtr GetForegroundWindow();
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||||
|
|
||||||
|
public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1);
|
||||||
|
public const uint SWP_NOACTIVATE = 0x0010, SWP_SHOWWINDOW = 0x0040;
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
|
||||||
|
int X, int Y, int cx, int cy, uint uFlags);
|
||||||
|
|
||||||
|
// ---- курсор для скриншота ----
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct CURSORINFO
|
||||||
|
{
|
||||||
|
public int cbSize;
|
||||||
|
public int flags;
|
||||||
|
public IntPtr hCursor;
|
||||||
|
public POINT ptScreenPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool GetCursorInfo(out CURSORINFO pci);
|
||||||
|
}
|
||||||
|
}
|
||||||
+222
@@ -0,0 +1,222 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Imaging;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.InteropServices.WindowsRuntime;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Windows.Globalization;
|
||||||
|
using Windows.Graphics.Imaging;
|
||||||
|
using Windows.Media.Ocr;
|
||||||
|
|
||||||
|
namespace ScreenTranslator
|
||||||
|
{
|
||||||
|
/// <summary>Одна распознанная строка: текст и прямоугольник в координатах захваченного кадра.</summary>
|
||||||
|
internal class OcrLineBox
|
||||||
|
{
|
||||||
|
public string Text;
|
||||||
|
public Rectangle Rect;
|
||||||
|
public double AvgWordHeight;
|
||||||
|
/// <summary>Фон под строкой. По нему отличают соседние строки одного абзаца от
|
||||||
|
/// самостоятельных элементов интерфейса, случайно оказавшихся рядом.</summary>
|
||||||
|
public Color Bg;
|
||||||
|
public bool BgKnown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Обёртка над встроенным в Windows OCR (Windows.Media.Ocr): офлайн, бесплатно,
|
||||||
|
/// отдаёт слова с координатами — именно это нужно, чтобы положить перевод на место оригинала.</summary>
|
||||||
|
internal class OcrService
|
||||||
|
{
|
||||||
|
readonly OcrEngine _engine;
|
||||||
|
public string LanguageTag { get; private set; }
|
||||||
|
|
||||||
|
OcrService(OcrEngine e) { _engine = e; LanguageTag = e.RecognizerLanguage.LanguageTag; }
|
||||||
|
|
||||||
|
public static List<string> AvailableLanguages()
|
||||||
|
{
|
||||||
|
var r = new List<string>();
|
||||||
|
foreach (var l in OcrEngine.AvailableRecognizerLanguages) r.Add(l.LanguageTag);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>null, если в системе нет ни запрошенной модели, ни языков профиля.</summary>
|
||||||
|
public static OcrService Create(string languageTag)
|
||||||
|
{
|
||||||
|
OcrEngine e = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(languageTag))
|
||||||
|
{
|
||||||
|
try { e = OcrEngine.TryCreateFromLanguage(new Language(languageTag)); }
|
||||||
|
catch { e = null; }
|
||||||
|
}
|
||||||
|
if (e == null) e = OcrEngine.TryCreateFromUserProfileLanguages();
|
||||||
|
return e == null ? null : new OcrService(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Модель другого языка — для проверки, не написан ли фрагмент не на том языке,
|
||||||
|
/// который мы собрались переводить. Берём язык интерфейса системы, иначе первый доступный.
|
||||||
|
/// null, если второй модели в системе нет.</summary>
|
||||||
|
public static OcrService CreateOther(string primaryTag)
|
||||||
|
{
|
||||||
|
var available = AvailableLanguages()
|
||||||
|
.Where(t => !Same(t, primaryTag)).ToList();
|
||||||
|
if (available.Count == 0) return null;
|
||||||
|
|
||||||
|
string ui = System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
|
||||||
|
string pick = available.FirstOrDefault(t => Same2(t, ui)) ?? available[0];
|
||||||
|
return Create(pick);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool Same(string a, string b)
|
||||||
|
{
|
||||||
|
return string.Equals(a, b, StringComparison.OrdinalIgnoreCase) || Same2(a, Two(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool Same2(string tag, string two)
|
||||||
|
{
|
||||||
|
return string.Equals(Two(tag), two, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Two(string tag)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(tag)) return "";
|
||||||
|
int i = tag.IndexOf('-');
|
||||||
|
return i > 0 ? tag.Substring(0, i) : tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Распознать кадр. scale > 1 увеличивает картинку перед распознаванием — мелкий
|
||||||
|
/// интерфейсный текст (9–12 px) так читается заметно лучше; координаты возвращаются уже
|
||||||
|
/// пересчитанными в масштаб оригинала.</summary>
|
||||||
|
public async Task<List<OcrLineBox>> RecognizeAsync(CapturedFrame frame, double scale)
|
||||||
|
{
|
||||||
|
long max = OcrEngine.MaxImageDimension;
|
||||||
|
if (scale > 1.01)
|
||||||
|
{
|
||||||
|
double fit = Math.Min((double)max / frame.Width, (double)max / frame.Height);
|
||||||
|
if (scale > fit) scale = Math.Max(1.0, fit);
|
||||||
|
}
|
||||||
|
else scale = 1.0;
|
||||||
|
|
||||||
|
// подготовка пикселей — в фоне: на 3440x1440 масштабирование стоит сотни миллисекунд
|
||||||
|
var prepared = await Task.Run(() => Prepare(frame, scale)).ConfigureAwait(true);
|
||||||
|
|
||||||
|
using (var sb = SoftwareBitmap.CreateCopyFromBuffer(
|
||||||
|
prepared.Data.AsBuffer(), BitmapPixelFormat.Bgra8,
|
||||||
|
prepared.W, prepared.H, BitmapAlphaMode.Ignore))
|
||||||
|
{
|
||||||
|
var res = await _engine.RecognizeAsync(sb);
|
||||||
|
return Convert(res, 1.0 / scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Prepared { public byte[] Data; public int W, H; }
|
||||||
|
|
||||||
|
/// <summary>Оттенки серого. Субпиксельное сглаживание ClearType красит края букв в
|
||||||
|
/// красный и синий; усреднение каналов убирает эту окраску, оставляя чистую форму глифа.</summary>
|
||||||
|
public bool Grayscale = false;
|
||||||
|
|
||||||
|
/// <summary>never | auto | always. Светлый текст на тёмном фоне распознаётся хуже
|
||||||
|
/// привычного тёмного на светлом; auto инвертирует кадр, если он преимущественно тёмный.</summary>
|
||||||
|
public string Invert = "never";
|
||||||
|
|
||||||
|
byte[] Preprocess(byte[] src, int w, int h)
|
||||||
|
{
|
||||||
|
bool gray = Grayscale;
|
||||||
|
bool invert = string.Equals(Invert, "always", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (!invert && string.Equals(Invert, "auto", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
long sum = 0; int n = 0;
|
||||||
|
for (int i = 0; i + 2 < src.Length; i += 64) // выборки достаточно для оценки фона
|
||||||
|
{
|
||||||
|
sum += (src[i + 2] * 299 + src[i + 1] * 587 + src[i] * 114) / 1000;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
invert = n > 0 && sum / n < 110;
|
||||||
|
}
|
||||||
|
if (!gray && !invert) return src;
|
||||||
|
|
||||||
|
var dst = new byte[src.Length]; // копия обязательна: исходные пиксели рисует оверлей
|
||||||
|
for (int i = 0; i + 3 < src.Length; i += 4)
|
||||||
|
{
|
||||||
|
byte b = src[i], g = src[i + 1], r = src[i + 2];
|
||||||
|
if (gray)
|
||||||
|
{
|
||||||
|
byte y = (byte)((r * 299 + g * 587 + b * 114) / 1000);
|
||||||
|
b = g = r = y;
|
||||||
|
}
|
||||||
|
if (invert) { b = (byte)(255 - b); g = (byte)(255 - g); r = (byte)(255 - r); }
|
||||||
|
dst[i] = b; dst[i + 1] = g; dst[i + 2] = r; dst[i + 3] = 255;
|
||||||
|
}
|
||||||
|
return dst;
|
||||||
|
}
|
||||||
|
|
||||||
|
Prepared Prepare(CapturedFrame frame, double scale)
|
||||||
|
{
|
||||||
|
if (scale <= 1.0)
|
||||||
|
return new Prepared
|
||||||
|
{
|
||||||
|
Data = Preprocess(frame.Pixels, frame.Width, frame.Height),
|
||||||
|
W = frame.Width,
|
||||||
|
H = frame.Height
|
||||||
|
};
|
||||||
|
|
||||||
|
int w = (int)(frame.Width * scale), h = (int)(frame.Height * scale);
|
||||||
|
var handle = GCHandle.Alloc(frame.Pixels, GCHandleType.Pinned);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var src = new Bitmap(frame.Width, frame.Height, frame.Width * 4,
|
||||||
|
PixelFormat.Format32bppArgb, handle.AddrOfPinnedObject()))
|
||||||
|
using (var dst = new Bitmap(w, h, PixelFormat.Format32bppArgb))
|
||||||
|
{
|
||||||
|
using (var g = Graphics.FromImage(dst))
|
||||||
|
{
|
||||||
|
g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
|
||||||
|
g.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality;
|
||||||
|
g.DrawImage(src, new Rectangle(0, 0, w, h));
|
||||||
|
}
|
||||||
|
var data = dst.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly,
|
||||||
|
PixelFormat.Format32bppArgb);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int dense = w * 4;
|
||||||
|
var buf = new byte[dense * h];
|
||||||
|
for (int y = 0; y < h; y++)
|
||||||
|
Marshal.Copy(IntPtr.Add(data.Scan0, y * data.Stride), buf, y * dense, dense);
|
||||||
|
return new Prepared { Data = Preprocess(buf, w, h), W = w, H = h };
|
||||||
|
}
|
||||||
|
finally { dst.UnlockBits(data); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { handle.Free(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<OcrLineBox> Convert(OcrResult res, double inv)
|
||||||
|
{
|
||||||
|
var list = new List<OcrLineBox>();
|
||||||
|
foreach (var line in res.Lines)
|
||||||
|
{
|
||||||
|
if (line.Words.Count == 0) continue;
|
||||||
|
double l = double.MaxValue, t = double.MaxValue, r = double.MinValue, b = double.MinValue, hsum = 0;
|
||||||
|
foreach (var w in line.Words)
|
||||||
|
{
|
||||||
|
var br = w.BoundingRect;
|
||||||
|
if (br.X < l) l = br.X;
|
||||||
|
if (br.Y < t) t = br.Y;
|
||||||
|
if (br.X + br.Width > r) r = br.X + br.Width;
|
||||||
|
if (br.Y + br.Height > b) b = br.Y + br.Height;
|
||||||
|
hsum += br.Height;
|
||||||
|
}
|
||||||
|
list.Add(new OcrLineBox
|
||||||
|
{
|
||||||
|
Text = line.Text,
|
||||||
|
Rect = Rectangle.FromLTRB(
|
||||||
|
(int)Math.Floor(l * inv), (int)Math.Floor(t * inv),
|
||||||
|
(int)Math.Ceiling(r * inv), (int)Math.Ceiling(b * inv)),
|
||||||
|
AvgWordHeight = hsum / line.Words.Count * inv
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace ScreenTranslator
|
||||||
|
{
|
||||||
|
/// <summary>Окно поверх всего, повторяющее захваченную область один-в-один.
|
||||||
|
///
|
||||||
|
/// Порядок появления намеренный: кадр показывается сразу после захвата, боксы — как только
|
||||||
|
/// отработал OCR, перевод — по готовности. Пользователь всё время видит прогресс,
|
||||||
|
/// а не пустой экран в ожидании сети.</summary>
|
||||||
|
internal class OverlayForm : Form
|
||||||
|
{
|
||||||
|
readonly AppConfig _cfg;
|
||||||
|
readonly Bitmap _frame; // кадр в координатах области (0,0 = левый верхний угол области)
|
||||||
|
readonly Rectangle _area; // область на рабочем столе, физические пиксели
|
||||||
|
readonly bool _frozen;
|
||||||
|
|
||||||
|
List<TextBlock> _blocks = new List<TextBlock>();
|
||||||
|
readonly Dictionary<TextBlock, Font> _fonts = new Dictionary<TextBlock, Font>();
|
||||||
|
readonly Dictionary<TextBlock, Rectangle> _layout = new Dictionary<TextBlock, Rectangle>();
|
||||||
|
|
||||||
|
string _status = "Распознавание…";
|
||||||
|
bool _busy = true;
|
||||||
|
int _hover = -1;
|
||||||
|
bool _peek; // удержан пробел — показать оригинал
|
||||||
|
|
||||||
|
public event EventHandler CancelRequested;
|
||||||
|
|
||||||
|
public OverlayForm(AppConfig cfg, Bitmap frame, Rectangle area)
|
||||||
|
{
|
||||||
|
_cfg = cfg; _frame = frame; _area = area;
|
||||||
|
_frozen = !string.Equals(cfg.OverlayMode, "live", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
FormBorderStyle = FormBorderStyle.None;
|
||||||
|
StartPosition = FormStartPosition.Manual;
|
||||||
|
ShowInTaskbar = false;
|
||||||
|
TopMost = true;
|
||||||
|
KeyPreview = true;
|
||||||
|
AutoScaleMode = AutoScaleMode.None; // работаем строго в физических пикселях
|
||||||
|
Bounds = area;
|
||||||
|
Cursor = Cursors.Default; // крест читается как «сейчас надо что-то выделить» — это не так
|
||||||
|
DoubleBuffered = true;
|
||||||
|
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||||
|
ControlStyles.OptimizedDoubleBuffer, true);
|
||||||
|
|
||||||
|
if (!_frozen)
|
||||||
|
{
|
||||||
|
BackColor = Color.Magenta;
|
||||||
|
TransparencyKey = Color.Magenta; // сквозь пустоту видно живой экран и проходят клики
|
||||||
|
}
|
||||||
|
else BackColor = Color.Black;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override CreateParams CreateParams
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var cp = base.CreateParams;
|
||||||
|
cp.ExStyle |= Native.WS_EX_TOOLWINDOW; // не мелькать в Alt+Tab
|
||||||
|
return cp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnShown(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnShown(e);
|
||||||
|
// WinForms при PerMonitorV2 может пересчитать размеры под DPI монитора — возвращаем как было
|
||||||
|
Native.SetWindowPos(Handle, Native.HWND_TOPMOST, _area.X, _area.Y, _area.Width, _area.Height,
|
||||||
|
Native.SWP_SHOWWINDOW);
|
||||||
|
Activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- данные ----------------
|
||||||
|
|
||||||
|
public void SetBlocks(List<TextBlock> blocks, string status)
|
||||||
|
{
|
||||||
|
_blocks = blocks ?? new List<TextBlock>();
|
||||||
|
_fonts.Clear(); _layout.Clear();
|
||||||
|
_status = status;
|
||||||
|
Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetStatus(string status, bool busy)
|
||||||
|
{
|
||||||
|
_status = status; _busy = busy;
|
||||||
|
Invalidate(StatusRect());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Refresh(List<TextBlock> blocks, string status, bool busy)
|
||||||
|
{
|
||||||
|
_blocks = blocks ?? _blocks;
|
||||||
|
_fonts.Clear(); _layout.Clear();
|
||||||
|
_status = status; _busy = busy;
|
||||||
|
Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- отрисовка ----------------
|
||||||
|
|
||||||
|
protected override void OnPaint(PaintEventArgs e)
|
||||||
|
{
|
||||||
|
var g = e.Graphics;
|
||||||
|
g.CompositingMode = CompositingMode.SourceOver;
|
||||||
|
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||||
|
g.InterpolationMode = InterpolationMode.NearestNeighbor;
|
||||||
|
g.PixelOffsetMode = PixelOffsetMode.Half;
|
||||||
|
|
||||||
|
if (_frozen)
|
||||||
|
{
|
||||||
|
g.DrawImage(_frame, new Rectangle(0, 0, _area.Width, _area.Height),
|
||||||
|
0, 0, _frame.Width, _frame.Height, GraphicsUnit.Pixel);
|
||||||
|
if (_cfg.DimBackground)
|
||||||
|
using (var b = new SolidBrush(Color.FromArgb(40, 0, 0, 0)))
|
||||||
|
g.FillRectangle(b, ClientRectangle);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < _blocks.Count; i++)
|
||||||
|
{
|
||||||
|
var blk = _blocks[i];
|
||||||
|
var r = ToClient(blk.Rect);
|
||||||
|
if (!e.ClipRectangle.IntersectsWith(Rectangle.Inflate(r, 40, 40))) continue;
|
||||||
|
|
||||||
|
// Оригинал показываем только по удержанию пробела. Раньше плашка пряталась ещё и
|
||||||
|
// под курсором — при обычном движении мышью перевод беспорядочно исчезал и появлялся.
|
||||||
|
bool peeking = _peek;
|
||||||
|
|
||||||
|
if (blk.Translated == null)
|
||||||
|
{
|
||||||
|
// перевод ещё в пути — подсвечиваем, что этот кусок взят в работу
|
||||||
|
if (!blk.Failed)
|
||||||
|
{
|
||||||
|
using (var b = new SolidBrush(Color.FromArgb(_frozen ? 46 : 70, 90, 160, 255)))
|
||||||
|
g.FillRectangle(b, Rectangle.Inflate(r, 2, 1));
|
||||||
|
using (var p = new Pen(Color.FromArgb(120, 90, 160, 255)))
|
||||||
|
g.DrawRectangle(p, Rectangle.Inflate(r, 2, 1));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peeking && _frozen) continue; // удержан пробел — показываем оригинал под плашкой
|
||||||
|
|
||||||
|
PlateRenderer.Draw(g, blk, _cfg, ClientSize, _fonts, _layout);
|
||||||
|
|
||||||
|
// Плашка красится под цвет фона и потому незаметна: без пометки непонятно,
|
||||||
|
// где перевод, а где нетронутый текст. Тонкая линия снизу — минимум, который
|
||||||
|
// отвечает на этот вопрос, не мешая читать.
|
||||||
|
if (_cfg.MarkTranslated && !blk.DrawnRect.IsEmpty)
|
||||||
|
{
|
||||||
|
var r2 = blk.DrawnRect;
|
||||||
|
using (var p = new Pen(Color.FromArgb(i == _hover ? 190 : 90, 90, 160, 255)))
|
||||||
|
g.DrawLine(p, r2.Left + 1, r2.Bottom - 1, r2.Right - 2, r2.Bottom - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DrawStatus(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle StatusRect() { return new Rectangle(0, 0, Math.Min(760, ClientSize.Width), 46); }
|
||||||
|
|
||||||
|
void DrawStatus(Graphics g)
|
||||||
|
{
|
||||||
|
string hint = _busy
|
||||||
|
? _status
|
||||||
|
: _status + " · Esc — закрыть · пробел — оригинал · клик — скопировать";
|
||||||
|
|
||||||
|
using (var f = new Font(_cfg.FontFamily, 13f, FontStyle.Regular, GraphicsUnit.Pixel))
|
||||||
|
{
|
||||||
|
var sz = TextRenderer.MeasureText(g, hint, f);
|
||||||
|
var box = new Rectangle(12, 12, sz.Width + 24, sz.Height + 12);
|
||||||
|
using (var b = new SolidBrush(Color.FromArgb(215, 18, 18, 20)))
|
||||||
|
using (var path = PlateRenderer.Rounded(box, 5))
|
||||||
|
g.FillPath(b, path);
|
||||||
|
if (_busy)
|
||||||
|
using (var p = new Pen(Color.FromArgb(200, 90, 160, 255), 2))
|
||||||
|
g.DrawLine(p, box.X + 2, box.Bottom - 1, box.X + 2 + box.Width / 3, box.Bottom - 1);
|
||||||
|
TextRenderer.DrawText(g, hint, f, new Point(box.X + 12, box.Y + 6),
|
||||||
|
Color.FromArgb(235, 235, 240), TextFormatFlags.NoPadding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle ToClient(Rectangle deskRect)
|
||||||
|
{
|
||||||
|
return new Rectangle(deskRect.X, deskRect.Y, deskRect.Width, deskRect.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- ввод ----------------
|
||||||
|
|
||||||
|
protected override void OnMouseMove(MouseEventArgs e)
|
||||||
|
{
|
||||||
|
int h = HitTest(e.Location);
|
||||||
|
if (h != _hover) { _hover = h; Invalidate(); }
|
||||||
|
base.OnMouseMove(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
int HitTest(Point p)
|
||||||
|
{
|
||||||
|
for (int i = _blocks.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var b = _blocks[i];
|
||||||
|
if (b.Translated == null) continue;
|
||||||
|
var r = b.DrawnRect.IsEmpty ? ToClient(b.Rect) : b.DrawnRect;
|
||||||
|
if (r.Contains(p)) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnMouseDown(MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Button == MouseButtons.Right) { Close(); return; }
|
||||||
|
int i = HitTest(e.Location);
|
||||||
|
if (i >= 0 && _blocks[i].Translated != null)
|
||||||
|
{
|
||||||
|
TrySetClipboard(_blocks[i].Translated);
|
||||||
|
SetStatus("Скопировано: " + Trim(_blocks[i].Translated, 60), false);
|
||||||
|
}
|
||||||
|
base.OnMouseDown(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnKeyDown(KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.KeyCode == Keys.Escape) { Close(); return; }
|
||||||
|
if (e.KeyCode == Keys.Space && !_peek) { _peek = true; Invalidate(); }
|
||||||
|
if (e.Control && e.KeyCode == Keys.C)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var b in _blocks.OrderBy(b => b.Rect.Top).ThenBy(b => b.Rect.Left))
|
||||||
|
if (!string.IsNullOrEmpty(b.Translated)) sb.AppendLine(b.Translated);
|
||||||
|
TrySetClipboard(sb.ToString());
|
||||||
|
SetStatus("Весь перевод скопирован в буфер", false);
|
||||||
|
}
|
||||||
|
base.OnKeyDown(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnKeyUp(KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.KeyCode == Keys.Space && _peek) { _peek = false; Invalidate(); }
|
||||||
|
base.OnKeyUp(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автозакрытия по потере фокуса здесь намеренно нет. Оно выглядело удобным, но любое
|
||||||
|
// окно, забравшее фокус, — «Ножницы», всплывающее уведомление, переключение раскладки —
|
||||||
|
// рушило оверлей на полуслове: снять его скриншот было невозможно, а со стороны это
|
||||||
|
// читалось как случайные исчезновения. Закрываем только явно: Esc, правый клик,
|
||||||
|
// повторное нажатие горячей клавиши.
|
||||||
|
|
||||||
|
protected override void OnFormClosed(FormClosedEventArgs e)
|
||||||
|
{
|
||||||
|
var h = CancelRequested;
|
||||||
|
if (h != null) h(this, EventArgs.Empty);
|
||||||
|
foreach (var f in _fonts.Values) f.Dispose();
|
||||||
|
_fonts.Clear();
|
||||||
|
if (_frame != null) _frame.Dispose();
|
||||||
|
base.OnFormClosed(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Trim(string s, int n) { return s.Length <= n ? s : s.Substring(0, n - 1) + "…"; }
|
||||||
|
|
||||||
|
static void TrySetClipboard(string s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(s)) return;
|
||||||
|
for (int i = 0; i < 3; i++) // буфер бывает занят другим процессом
|
||||||
|
{
|
||||||
|
try { Clipboard.SetText(s); return; }
|
||||||
|
catch { System.Threading.Thread.Sleep(40); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace ScreenTranslator
|
||||||
|
{
|
||||||
|
/// <summary>Рисование плашки с переводом поверх места оригинала.
|
||||||
|
/// Вынесено из формы, чтобы ту же отрисовку можно было прогнать в оффскрин-картинку
|
||||||
|
/// (проверка результата без запуска оверлея).</summary>
|
||||||
|
internal static class PlateRenderer
|
||||||
|
{
|
||||||
|
const TextFormatFlags Flags =
|
||||||
|
TextFormatFlags.WordBreak | TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix;
|
||||||
|
|
||||||
|
public static void Draw(Graphics g, TextBlock blk, AppConfig cfg, Size canvas,
|
||||||
|
Dictionary<TextBlock, Font> fontCache,
|
||||||
|
Dictionary<TextBlock, Rectangle> layoutCache)
|
||||||
|
{
|
||||||
|
Font font;
|
||||||
|
Rectangle plate;
|
||||||
|
if (fontCache == null || !fontCache.TryGetValue(blk, out font))
|
||||||
|
{
|
||||||
|
plate = Fit(g, blk, cfg, canvas, out font);
|
||||||
|
if (fontCache != null) fontCache[blk] = font;
|
||||||
|
if (layoutCache != null) layoutCache[blk] = plate;
|
||||||
|
}
|
||||||
|
else plate = layoutCache[blk];
|
||||||
|
|
||||||
|
blk.DrawnRect = plate;
|
||||||
|
|
||||||
|
using (var b = new SolidBrush(blk.Bg))
|
||||||
|
using (var path = Rounded(plate, 3))
|
||||||
|
g.FillPath(b, path);
|
||||||
|
|
||||||
|
TextRenderer.DrawText(g, blk.Translated, font,
|
||||||
|
Rectangle.Inflate(plate, -cfg.BoxPadding, -cfg.BoxPadding), blk.Fg,
|
||||||
|
Flags | TextFormatFlags.VerticalCenter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Подобрать кегль так, чтобы перевод влез на место оригинала. Русский текст в среднем
|
||||||
|
/// длиннее английского, поэтому плашке разрешено немного подрасти (growRatio) прежде чем
|
||||||
|
/// начнём мельчить шрифт — читаемость важнее точного совпадения габаритов.</summary>
|
||||||
|
public static Rectangle Fit(Graphics g, TextBlock blk, AppConfig cfg, Size canvas, out Font font)
|
||||||
|
{
|
||||||
|
int pad = cfg.BoxPadding;
|
||||||
|
|
||||||
|
// OCR обводит только тело букв — без выносных элементов и интерлиньяжа. Если заливать
|
||||||
|
// ровно по этому прямоугольнику, сверху и снизу остаются хвосты оригинала и текст двоится,
|
||||||
|
// поэтому по вертикали расширяем на треть высоты строки.
|
||||||
|
int vpad = Math.Max(pad, (int)Math.Round(blk.LineHeight * 0.32));
|
||||||
|
if (!blk.GrowLimit.IsEmpty) // не залезать вверх на строку, которая идёт выше
|
||||||
|
vpad = Math.Max(pad, Math.Min(vpad, blk.Rect.Top - blk.GrowLimit.Top));
|
||||||
|
var baseRect = Rectangle.Inflate(blk.Rect, pad + 1, vpad);
|
||||||
|
|
||||||
|
// Абсолютная добавка к запасу важна для коротких надписей: «Activity» → «Деятельность»
|
||||||
|
// не влезает ни в какие проценты от 55 пикселей.
|
||||||
|
int growW = (int)(baseRect.Width * cfg.GrowRatio) + 24;
|
||||||
|
int growH = (int)(baseRect.Height * cfg.GrowRatio * 2) + 6;
|
||||||
|
// Дальше — либо край кадра, либо соседний текст, если он ближе
|
||||||
|
int edgeRight = blk.GrowLimit.IsEmpty ? canvas.Width : Math.Min(canvas.Width, blk.GrowLimit.Right);
|
||||||
|
int edgeBottom = blk.GrowLimit.IsEmpty ? canvas.Height : Math.Min(canvas.Height, blk.GrowLimit.Bottom);
|
||||||
|
int hardW = Math.Max(24, edgeRight - baseRect.X);
|
||||||
|
int hardH = Math.Max(12, edgeBottom - baseRect.Y);
|
||||||
|
// Сосед может стоять вплотную, но накрыть собственный оригинал плашка обязана —
|
||||||
|
// иначе из-под неё будет торчать английский текст.
|
||||||
|
hardW = Math.Max(hardW, baseRect.Width);
|
||||||
|
hardH = Math.Max(hardH, baseRect.Height);
|
||||||
|
int softW = Math.Max(baseRect.Width, Math.Min(baseRect.Width + growW, hardW));
|
||||||
|
int softH = Math.Max(baseRect.Height, Math.Min(baseRect.Height + growH, hardH));
|
||||||
|
|
||||||
|
float hi = (float)Math.Max(cfg.MinFontPx, blk.LineHeight * 1.15);
|
||||||
|
Font best = null;
|
||||||
|
Size bestSize = Size.Empty;
|
||||||
|
|
||||||
|
// Считаем два варианта: уложиться строго в габариты оригинала и уложиться с запасом.
|
||||||
|
// Плашка, не вылезающая за оригинал, аккуратнее, но только пока не приходится ради
|
||||||
|
// этого сильно мельчить — нечитаемый перевод хуже слегка выпирающей плашки.
|
||||||
|
Size sizeTight, sizeSoft;
|
||||||
|
var fitTight = Search(g, blk.Translated, cfg, hi, cfg.MinFontPx, baseRect.Size, pad, out sizeTight);
|
||||||
|
var fitSoft = Search(g, blk.Translated, cfg, hi, cfg.MinFontPx, new Size(softW, softH), pad, out sizeSoft);
|
||||||
|
|
||||||
|
if (fitTight != null && (fitSoft == null || fitTight.Size >= fitSoft.Size * 0.85f))
|
||||||
|
{
|
||||||
|
best = fitTight; bestSize = sizeTight;
|
||||||
|
if (fitSoft != null) fitSoft.Dispose();
|
||||||
|
}
|
||||||
|
else if (fitSoft != null)
|
||||||
|
{
|
||||||
|
best = fitSoft; bestSize = sizeSoft;
|
||||||
|
if (fitTight != null) fitTight.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (best == null)
|
||||||
|
{
|
||||||
|
// даже мельчайшим кеглем не уложились — пусть плашка вырастет,
|
||||||
|
// это честнее, чем обрезать перевод на полуслове
|
||||||
|
best = new Font(cfg.FontFamily, cfg.MinFontPx, FontStyle.Regular, GraphicsUnit.Pixel);
|
||||||
|
bestSize = TextRenderer.MeasureText(g, blk.Translated, best,
|
||||||
|
new Size(Math.Max(16, softW - pad * 2), int.MaxValue), Flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
font = best;
|
||||||
|
int w = Math.Min(hardW, Math.Max(baseRect.Width, bestSize.Width + pad * 2 + 2));
|
||||||
|
int h = Math.Min(hardH, Math.Max(baseRect.Height, bestSize.Height + pad * 2 + 1));
|
||||||
|
return new Rectangle(baseRect.X, baseRect.Y, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Наибольший кегль в диапазоне, при котором текст целиком помещается в box.
|
||||||
|
/// null — не помещается даже минимальным.</summary>
|
||||||
|
static Font Search(Graphics g, string text, AppConfig cfg, float hi, float lo, Size box, int pad, out Size need)
|
||||||
|
{
|
||||||
|
need = Size.Empty;
|
||||||
|
for (float size = hi; size >= lo; size -= Math.Max(0.5f, size * 0.08f))
|
||||||
|
{
|
||||||
|
var f = new Font(cfg.FontFamily, size, FontStyle.Regular, GraphicsUnit.Pixel);
|
||||||
|
if (FitsIn(g, text, f, box, pad, out need)) return f;
|
||||||
|
f.Dispose();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Проверяем обе стороны: перенос по словам не спасает от длинного слова —
|
||||||
|
/// оно просто вылезет за плашку и ляжет поверх оригинала.</summary>
|
||||||
|
static bool FitsIn(Graphics g, string text, Font f, Size box, int pad, out Size need)
|
||||||
|
{
|
||||||
|
int availW = Math.Max(8, box.Width - pad * 2);
|
||||||
|
int availH = Math.Max(6, box.Height - pad * 2);
|
||||||
|
need = TextRenderer.MeasureText(g, text, f, new Size(availW, int.MaxValue), Flags);
|
||||||
|
return need.Width <= availW && need.Height <= availH;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GraphicsPath Rounded(Rectangle r, int radius)
|
||||||
|
{
|
||||||
|
var p = new GraphicsPath();
|
||||||
|
if (radius <= 0 || r.Width < radius * 2 || r.Height < radius * 2) { p.AddRectangle(r); return p; }
|
||||||
|
int d = radius * 2;
|
||||||
|
p.AddArc(r.X, r.Y, d, d, 180, 90);
|
||||||
|
p.AddArc(r.Right - d, r.Y, d, d, 270, 90);
|
||||||
|
p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90);
|
||||||
|
p.AddArc(r.X, r.Bottom - d, d, d, 90, 90);
|
||||||
|
p.CloseFigure();
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+398
@@ -0,0 +1,398 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace ScreenTranslator
|
||||||
|
{
|
||||||
|
static class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
static void Main(string[] args)
|
||||||
|
{
|
||||||
|
// Сборщику нужен эталонный config.json рядом с exe, а держать его в репозитории
|
||||||
|
// нельзя — файл правится пользователем и обрастает личными настройками.
|
||||||
|
// Ключ создаёт его из вшитого умолчания и сразу выходит, без окна и трея.
|
||||||
|
foreach (var a in args)
|
||||||
|
if (string.Equals(a, "--write-config", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
string err;
|
||||||
|
AppConfig.Load(out err); // создаёт файл, если его нет
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool created;
|
||||||
|
using (var mutex = new Mutex(true, "Local\\ScreenTranslator.SingleInstance", out created))
|
||||||
|
{
|
||||||
|
if (!created)
|
||||||
|
{
|
||||||
|
MessageBox.Show("Переводчик экрана уже запущен — значок в трее.",
|
||||||
|
"Переводчик экрана", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Application.EnableVisualStyles();
|
||||||
|
Application.SetCompatibleTextRenderingDefault(false);
|
||||||
|
Application.Run(new TrayApp());
|
||||||
|
GC.KeepAlive(mutex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Приложение живёт в трее и просыпается по горячей клавише.</summary>
|
||||||
|
internal class TrayApp : ApplicationContext
|
||||||
|
{
|
||||||
|
const int HK_SCREEN = 1, HK_REGION = 2;
|
||||||
|
|
||||||
|
AppConfig _cfg;
|
||||||
|
OcrService _ocr;
|
||||||
|
OcrService _ocrOther; // модель другого языка — чтобы не переводить надписи на нём
|
||||||
|
TranslationCache _cache;
|
||||||
|
TranslationChain _chain;
|
||||||
|
|
||||||
|
readonly NotifyIcon _tray;
|
||||||
|
readonly HotkeyWindow _hotkeys;
|
||||||
|
OverlayForm _overlay;
|
||||||
|
CancellationTokenSource _cts;
|
||||||
|
bool _busy;
|
||||||
|
|
||||||
|
public TrayApp()
|
||||||
|
{
|
||||||
|
_tray = new NotifyIcon
|
||||||
|
{
|
||||||
|
Icon = MakeIcon(),
|
||||||
|
Text = "Переводчик экрана",
|
||||||
|
Visible = true
|
||||||
|
};
|
||||||
|
_tray.DoubleClick += delegate { Start(false); };
|
||||||
|
|
||||||
|
_hotkeys = new HotkeyWindow();
|
||||||
|
_hotkeys.Pressed += OnHotkey;
|
||||||
|
|
||||||
|
LoadConfig(true);
|
||||||
|
BuildMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- конфигурация ----------------
|
||||||
|
|
||||||
|
void LoadConfig(bool firstRun)
|
||||||
|
{
|
||||||
|
string err;
|
||||||
|
_cfg = AppConfig.Load(out err);
|
||||||
|
|
||||||
|
if (_cache != null) _cache.Save();
|
||||||
|
_cache = _cfg.Cache ? new TranslationCache(_cfg) : null;
|
||||||
|
_chain = new TranslationChain(_cfg, _cache);
|
||||||
|
|
||||||
|
_ocr = OcrService.Create(_cfg.OcrLanguage);
|
||||||
|
_ocrOther = _cfg.DetectForeign && _ocr != null ? OcrService.CreateOther(_ocr.LanguageTag) : null;
|
||||||
|
if (_ocr == null)
|
||||||
|
{
|
||||||
|
Balloon("OCR недоступен",
|
||||||
|
"В Windows не установлена ни одна модель распознавания. Параметры → Время и язык → " +
|
||||||
|
"Язык и регион → у нужного языка «Компоненты языка» → Распознавание текста.",
|
||||||
|
ToolTipIcon.Error);
|
||||||
|
}
|
||||||
|
else if (!string.Equals(_ocr.LanguageTag, _cfg.OcrLanguage, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Balloon("Модель OCR заменена",
|
||||||
|
"Модели «" + _cfg.OcrLanguage + "» нет, используется «" + _ocr.LanguageTag +
|
||||||
|
"». Доступны: " + string.Join(", ", OcrService.AvailableLanguages()),
|
||||||
|
ToolTipIcon.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterHotkeys();
|
||||||
|
|
||||||
|
if (err != null) Balloon("Настройки", err, ToolTipIcon.Warning);
|
||||||
|
else if (firstRun)
|
||||||
|
{
|
||||||
|
_tray.Text = "Переводчик экрана — " + _cfg.HotkeyTranslate;
|
||||||
|
Balloon("Переводчик экрана запущен",
|
||||||
|
_cfg.HotkeyTranslate + " — перевести экран, " + _cfg.HotkeyRegion + " — область.",
|
||||||
|
ToolTipIcon.Info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RegisterHotkeys()
|
||||||
|
{
|
||||||
|
_hotkeys.UnregisterAll();
|
||||||
|
string bad = null;
|
||||||
|
if (!_hotkeys.Register(HK_SCREEN, _cfg.HotkeyTranslate)) bad = _cfg.HotkeyTranslate;
|
||||||
|
if (!_hotkeys.Register(HK_REGION, _cfg.HotkeyRegion))
|
||||||
|
bad = bad == null ? _cfg.HotkeyRegion : bad + " и " + _cfg.HotkeyRegion;
|
||||||
|
if (bad != null)
|
||||||
|
Balloon("Горячая клавиша занята",
|
||||||
|
"Не удалось назначить " + bad + " — комбинацию уже держит другая программа. " +
|
||||||
|
"Поменяйте её в config.json.", ToolTipIcon.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildMenu()
|
||||||
|
{
|
||||||
|
var m = new ContextMenuStrip();
|
||||||
|
m.Items.Add("Перевести экран\t" + _cfg.HotkeyTranslate, null, delegate { Start(false); });
|
||||||
|
m.Items.Add("Перевести область\t" + _cfg.HotkeyRegion, null, delegate { Start(true); });
|
||||||
|
m.Items.Add(new ToolStripSeparator());
|
||||||
|
m.Items.Add("Проверить переводчики…", null, delegate { ProbeEngines(); });
|
||||||
|
m.Items.Add("Открыть config.json", null, delegate
|
||||||
|
{
|
||||||
|
try { Process.Start(new ProcessStartInfo(AppConfig.Path_) { UseShellExecute = true }); }
|
||||||
|
catch (Exception ex) { Balloon("Не открылось", ex.Message, ToolTipIcon.Error); }
|
||||||
|
});
|
||||||
|
m.Items.Add("Перечитать настройки", null, delegate
|
||||||
|
{
|
||||||
|
LoadConfig(false);
|
||||||
|
BuildMenu();
|
||||||
|
Balloon("Настройки перечитаны",
|
||||||
|
"Движки: " + string.Join(" → ", _chain.EngineNames) + ". OCR: " +
|
||||||
|
(_ocr == null ? "нет" : _ocr.LanguageTag), ToolTipIcon.Info);
|
||||||
|
});
|
||||||
|
m.Items.Add(new ToolStripSeparator());
|
||||||
|
m.Items.Add("Выход", null, delegate { ExitThread(); });
|
||||||
|
_tray.ContextMenuStrip = m;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnHotkey(int id)
|
||||||
|
{
|
||||||
|
if (id == HK_SCREEN) Start(false);
|
||||||
|
else if (id == HK_REGION) Start(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- основной сценарий ----------------
|
||||||
|
|
||||||
|
void Start(bool region)
|
||||||
|
{
|
||||||
|
// повторное нажатие при открытом оверлее — закрыть, это же и «отмена»
|
||||||
|
if (_overlay != null && !_overlay.IsDisposed)
|
||||||
|
{
|
||||||
|
_overlay.Close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_busy) return;
|
||||||
|
if (_ocr == null)
|
||||||
|
{
|
||||||
|
Balloon("OCR недоступен", "Нет модели распознавания текста, перевод невозможен.", ToolTipIcon.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
RunAsync(region);
|
||||||
|
}
|
||||||
|
|
||||||
|
async void RunAsync(bool region)
|
||||||
|
{
|
||||||
|
_busy = true;
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
CapturedFrame frame = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var area = region || string.Equals(_cfg.CaptureScope, "all", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? Native.VirtualScreen()
|
||||||
|
: Native.MonitorUnderCursor();
|
||||||
|
|
||||||
|
frame = ScreenCapture.Capture(area);
|
||||||
|
|
||||||
|
if (region)
|
||||||
|
{
|
||||||
|
var sel = RegionSelector.Select(frame);
|
||||||
|
if (sel.IsEmpty) { frame.Image.Dispose(); return; }
|
||||||
|
var crop = ScreenCapture.Crop(frame, sel);
|
||||||
|
frame.Image.Dispose();
|
||||||
|
frame = crop;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var ct = _cts.Token;
|
||||||
|
|
||||||
|
_overlay = new OverlayForm(_cfg, frame.Image, frame.Area);
|
||||||
|
_overlay.CancelRequested += delegate { try { _cts.Cancel(); } catch { } };
|
||||||
|
_overlay.Show();
|
||||||
|
_overlay.Refresh(); // кадр должен появиться до того, как начнётся распознавание
|
||||||
|
|
||||||
|
var lines = await _ocr.RecognizeAsync(frame, _cfg.OcrScale);
|
||||||
|
if (ct.IsCancellationRequested || _overlay.IsDisposed) return;
|
||||||
|
|
||||||
|
var sampler = new FrameSampler(frame);
|
||||||
|
var blocks = Blocks.Group(lines, _cfg.MinChars, _cfg.SkipGarbage, sampler, _cfg.MinWords);
|
||||||
|
|
||||||
|
if (_cfg.DetectForeign && _ocrOther != null && blocks.Count > 0)
|
||||||
|
{
|
||||||
|
_overlay.SetBlocks(blocks, "Проверка языка…");
|
||||||
|
var otherLines = await _ocrOther.RecognizeAsync(frame, _cfg.OcrScale);
|
||||||
|
if (ct.IsCancellationRequested || _overlay.IsDisposed) return;
|
||||||
|
int before = blocks.Count;
|
||||||
|
Blocks.DropForeign(blocks, otherLines, Blocks.IsCyrillicLanguage(_cfg.SourceLang));
|
||||||
|
if (blocks.Count < before)
|
||||||
|
Debug.WriteLine("отброшено как чужой язык: " + (before - blocks.Count));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blocks.Count == 0)
|
||||||
|
{
|
||||||
|
_overlay.SetBlocks(blocks, "Текст не найден");
|
||||||
|
_overlay.SetStatus("Текст не найден — Esc, чтобы закрыть", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Blocks.ComputeGrowLimits(blocks, lines, new Size(frame.Width, frame.Height));
|
||||||
|
|
||||||
|
foreach (var b in blocks)
|
||||||
|
{
|
||||||
|
Color bg, fg;
|
||||||
|
sampler.Sample(b.Rect, out bg, out fg);
|
||||||
|
b.Bg = bg; b.Fg = fg;
|
||||||
|
}
|
||||||
|
|
||||||
|
_overlay.SetBlocks(blocks, "Перевод: " + blocks.Count + " фрагм. …");
|
||||||
|
|
||||||
|
var sources = blocks.Select(b => b.Source).ToArray();
|
||||||
|
var translated = await _chain.TranslateAsync(sources, ct);
|
||||||
|
if (ct.IsCancellationRequested || _overlay.IsDisposed) return;
|
||||||
|
|
||||||
|
int ok = 0;
|
||||||
|
for (int i = 0; i < blocks.Count; i++)
|
||||||
|
{
|
||||||
|
var t = translated[i];
|
||||||
|
if (string.IsNullOrWhiteSpace(t)) { blocks[i].Failed = true; continue; }
|
||||||
|
blocks[i].Translated = t;
|
||||||
|
ok++;
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.Stop();
|
||||||
|
string status = ok > 0
|
||||||
|
? string.Format("{0} · {1} из {2} · {3:0.0} с",
|
||||||
|
_chain.LastUsed ?? "?", ok, blocks.Count, sw.ElapsedMilliseconds / 1000.0)
|
||||||
|
: "Не удалось перевести: " + (_chain.LastError ?? "нет доступного движка");
|
||||||
|
|
||||||
|
_overlay.Refresh(blocks, status, false);
|
||||||
|
|
||||||
|
if (ok == 0)
|
||||||
|
Balloon("Перевод не удался", _chain.LastError ?? "все движки недоступны", ToolTipIcon.Error);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (_overlay != null && !_overlay.IsDisposed) _overlay.Close();
|
||||||
|
Balloon("Ошибка", ex.Message, ToolTipIcon.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (frame != null) frame.Dispose();
|
||||||
|
if (_cache != null) _cache.Save();
|
||||||
|
_busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async void ProbeEngines()
|
||||||
|
{
|
||||||
|
_tray.Text = "Переводчик экрана — проверяю движки…";
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(40)))
|
||||||
|
{
|
||||||
|
var list = await _chain.ProbeAsync(cts.Token);
|
||||||
|
foreach (var s in list)
|
||||||
|
sb.AppendLine(string.Format("{0,-16} {1}",
|
||||||
|
s.Name, s.Ok ? "ок, " + s.Ms + " мс" : "не отвечает — " + s.Error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) { sb.AppendLine("Проверка прервана: " + ex.Message); }
|
||||||
|
|
||||||
|
_tray.Text = "Переводчик экрана — " + _cfg.HotkeyTranslate;
|
||||||
|
MessageBox.Show(sb.ToString() +
|
||||||
|
"\nПорядок использования задаётся полем \"chain\" в config.json.",
|
||||||
|
"Доступность переводчиков", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Balloon(string title, string text, ToolTipIcon icon)
|
||||||
|
{
|
||||||
|
if (!_cfg.ShowTrayBalloonOnError && icon != ToolTipIcon.Info) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_tray.BalloonTipTitle = title;
|
||||||
|
_tray.BalloonTipText = text;
|
||||||
|
_tray.BalloonTipIcon = icon;
|
||||||
|
_tray.ShowBalloonTip(6000);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Иконка рисуется в коде, чтобы приложение оставалось одним файлом без ресурсов.</summary>
|
||||||
|
static Icon MakeIcon()
|
||||||
|
{
|
||||||
|
var bmp = new Bitmap(32, 32);
|
||||||
|
using (var g = Graphics.FromImage(bmp))
|
||||||
|
{
|
||||||
|
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||||
|
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
|
||||||
|
using (var b = new LinearGradientBrush(new Rectangle(0, 0, 32, 32),
|
||||||
|
Color.FromArgb(38, 84, 160), Color.FromArgb(22, 42, 88), 45f))
|
||||||
|
g.FillEllipse(b, 0, 0, 31, 31);
|
||||||
|
using (var f = new Font("Segoe UI", 15f, FontStyle.Bold, GraphicsUnit.Pixel))
|
||||||
|
{
|
||||||
|
g.DrawString("A", f, Brushes.White, new PointF(2.5f, 4f));
|
||||||
|
g.DrawString("Я", f, new SolidBrush(Color.FromArgb(120, 200, 255)), new PointF(13.5f, 12f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IntPtr h = bmp.GetHicon();
|
||||||
|
try { return (Icon)Icon.FromHandle(h).Clone(); }
|
||||||
|
finally { DestroyIcon(h); bmp.Dispose(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true)]
|
||||||
|
static extern bool DestroyIcon(IntPtr handle);
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
try { if (_cts != null) _cts.Cancel(); } catch { }
|
||||||
|
if (_cache != null) _cache.Save();
|
||||||
|
if (_hotkeys != null) _hotkeys.Dispose();
|
||||||
|
if (_tray != null) { _tray.Visible = false; _tray.Dispose(); }
|
||||||
|
}
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Невидимое окно, принимающее WM_HOTKEY: глобальные клавиши нужны и когда фокус
|
||||||
|
/// в игре или чужом окне, поэтому обычные события формы тут не годятся.</summary>
|
||||||
|
internal class HotkeyWindow : NativeWindow, IDisposable
|
||||||
|
{
|
||||||
|
readonly System.Collections.Generic.List<int> _ids = new System.Collections.Generic.List<int>();
|
||||||
|
public event Action<int> Pressed;
|
||||||
|
|
||||||
|
public HotkeyWindow() { CreateHandle(new CreateParams()); }
|
||||||
|
|
||||||
|
public bool Register(int id, string combo)
|
||||||
|
{
|
||||||
|
uint mods, vk; string display;
|
||||||
|
if (!AppConfig.ParseHotkey(combo, out mods, out vk, out display)) return false;
|
||||||
|
if (!Native.RegisterHotKey(Handle, id, mods | Native.MOD_NOREPEAT, vk)) return false;
|
||||||
|
_ids.Add(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UnregisterAll()
|
||||||
|
{
|
||||||
|
foreach (var id in _ids) Native.UnregisterHotKey(Handle, id);
|
||||||
|
_ids.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void WndProc(ref Message m)
|
||||||
|
{
|
||||||
|
if (m.Msg == Native.WM_HOTKEY)
|
||||||
|
{
|
||||||
|
var h = Pressed;
|
||||||
|
if (h != null) h(m.WParam.ToInt32());
|
||||||
|
}
|
||||||
|
base.WndProc(ref m);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
UnregisterAll();
|
||||||
|
DestroyHandle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace ScreenTranslator
|
||||||
|
{
|
||||||
|
/// <summary>Выбор области рамкой поверх замороженного кадра. Работаем именно по кадру, а не по
|
||||||
|
/// живому экрану: заново снимать нельзя — сверху уже висит наше собственное окно.</summary>
|
||||||
|
internal class RegionSelector : Form
|
||||||
|
{
|
||||||
|
readonly CapturedFrame _frame;
|
||||||
|
Point _start;
|
||||||
|
bool _dragging;
|
||||||
|
Rectangle _sel = Rectangle.Empty;
|
||||||
|
|
||||||
|
public Rectangle Selection { get { return _sel; } }
|
||||||
|
|
||||||
|
public static Rectangle Select(CapturedFrame frame)
|
||||||
|
{
|
||||||
|
using (var f = new RegionSelector(frame))
|
||||||
|
return f.ShowDialog() == DialogResult.OK ? f.Selection : Rectangle.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
RegionSelector(CapturedFrame frame)
|
||||||
|
{
|
||||||
|
_frame = frame;
|
||||||
|
FormBorderStyle = FormBorderStyle.None;
|
||||||
|
StartPosition = FormStartPosition.Manual;
|
||||||
|
ShowInTaskbar = false;
|
||||||
|
TopMost = true;
|
||||||
|
KeyPreview = true;
|
||||||
|
AutoScaleMode = AutoScaleMode.None;
|
||||||
|
Bounds = frame.Area;
|
||||||
|
Cursor = Cursors.Cross;
|
||||||
|
DoubleBuffered = true;
|
||||||
|
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||||
|
ControlStyles.OptimizedDoubleBuffer, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override CreateParams CreateParams
|
||||||
|
{
|
||||||
|
get { var cp = base.CreateParams; cp.ExStyle |= Native.WS_EX_TOOLWINDOW; return cp; }
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnShown(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnShown(e);
|
||||||
|
Native.SetWindowPos(Handle, Native.HWND_TOPMOST, _frame.Area.X, _frame.Area.Y,
|
||||||
|
_frame.Area.Width, _frame.Area.Height, Native.SWP_SHOWWINDOW);
|
||||||
|
Activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPaint(PaintEventArgs e)
|
||||||
|
{
|
||||||
|
var g = e.Graphics;
|
||||||
|
g.DrawImage(_frame.Image, new Rectangle(0, 0, ClientSize.Width, ClientSize.Height),
|
||||||
|
0, 0, _frame.Width, _frame.Height, GraphicsUnit.Pixel);
|
||||||
|
|
||||||
|
using (var shade = new SolidBrush(Color.FromArgb(120, 0, 0, 0)))
|
||||||
|
{
|
||||||
|
if (_sel.Width > 1 && _sel.Height > 1)
|
||||||
|
{
|
||||||
|
// затемняем всё, кроме выбранного куска
|
||||||
|
var r = ClientRectangle;
|
||||||
|
g.FillRectangle(shade, new Rectangle(r.X, r.Y, r.Width, _sel.Top - r.Y));
|
||||||
|
g.FillRectangle(shade, new Rectangle(r.X, _sel.Bottom, r.Width, r.Bottom - _sel.Bottom));
|
||||||
|
g.FillRectangle(shade, new Rectangle(r.X, _sel.Top, _sel.Left - r.X, _sel.Height));
|
||||||
|
g.FillRectangle(shade, new Rectangle(_sel.Right, _sel.Top, r.Right - _sel.Right, _sel.Height));
|
||||||
|
|
||||||
|
using (var p = new Pen(Color.FromArgb(230, 90, 160, 255), 1.5f))
|
||||||
|
g.DrawRectangle(p, _sel);
|
||||||
|
|
||||||
|
string label = _sel.Width + " × " + _sel.Height;
|
||||||
|
using (var f = new Font("Segoe UI", 12f, FontStyle.Regular, GraphicsUnit.Pixel))
|
||||||
|
{
|
||||||
|
var sz = TextRenderer.MeasureText(g, label, f);
|
||||||
|
var box = new Rectangle(_sel.X, Math.Max(0, _sel.Y - sz.Height - 8),
|
||||||
|
sz.Width + 12, sz.Height + 6);
|
||||||
|
using (var b = new SolidBrush(Color.FromArgb(220, 18, 18, 20))) g.FillRectangle(b, box);
|
||||||
|
TextRenderer.DrawText(g, label, f, new Point(box.X + 6, box.Y + 3), Color.White);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else g.FillRectangle(shade, ClientRectangle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_dragging && _sel.IsEmpty)
|
||||||
|
{
|
||||||
|
const string hint = "Выделите область для перевода · Esc — отмена";
|
||||||
|
using (var f = new Font("Segoe UI", 15f, FontStyle.Regular, GraphicsUnit.Pixel))
|
||||||
|
{
|
||||||
|
var sz = TextRenderer.MeasureText(g, hint, f);
|
||||||
|
var box = new Rectangle((ClientSize.Width - sz.Width) / 2 - 14, 40, sz.Width + 28, sz.Height + 14);
|
||||||
|
using (var b = new SolidBrush(Color.FromArgb(220, 18, 18, 20)))
|
||||||
|
using (var path = Round(box, 6))
|
||||||
|
g.FillPath(b, path);
|
||||||
|
TextRenderer.DrawText(g, hint, f, new Point(box.X + 14, box.Y + 7), Color.White);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static GraphicsPath Round(Rectangle r, int radius)
|
||||||
|
{
|
||||||
|
var p = new GraphicsPath();
|
||||||
|
int d = radius * 2;
|
||||||
|
p.AddArc(r.X, r.Y, d, d, 180, 90);
|
||||||
|
p.AddArc(r.Right - d, r.Y, d, d, 270, 90);
|
||||||
|
p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90);
|
||||||
|
p.AddArc(r.X, r.Bottom - d, d, d, 90, 90);
|
||||||
|
p.CloseFigure();
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnMouseDown(MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Button != MouseButtons.Left) { DialogResult = DialogResult.Cancel; Close(); return; }
|
||||||
|
_dragging = true; _start = e.Location; _sel = Rectangle.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnMouseMove(MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_dragging) return;
|
||||||
|
_sel = Rectangle.FromLTRB(
|
||||||
|
Math.Min(_start.X, e.X), Math.Min(_start.Y, e.Y),
|
||||||
|
Math.Max(_start.X, e.X), Math.Max(_start.Y, e.Y));
|
||||||
|
Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnMouseUp(MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_dragging) return;
|
||||||
|
_dragging = false;
|
||||||
|
if (_sel.Width < 8 || _sel.Height < 8) { DialogResult = DialogResult.Cancel; Close(); return; }
|
||||||
|
DialogResult = DialogResult.OK;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnKeyDown(KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.KeyCode == Keys.Escape) { DialogResult = DialogResult.Cancel; Close(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Imaging;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace ScreenTranslator
|
||||||
|
{
|
||||||
|
/// <summary>Снимок области экрана: и готовый Bitmap для отрисовки в оверлее, и отдельная копия
|
||||||
|
/// пикселей. Копия нужна, чтобы OCR и замер цветов работали в фоновом потоке, не трогая тот же
|
||||||
|
/// Bitmap, который в это время рисует UI-поток (иначе GDI+ падает на «region already locked»).</summary>
|
||||||
|
internal sealed class CapturedFrame : IDisposable
|
||||||
|
{
|
||||||
|
public Bitmap Image;
|
||||||
|
public byte[] Pixels; // BGRA, плотно, stride == Width * 4
|
||||||
|
public int Width, Height;
|
||||||
|
public Rectangle Area; // положение на рабочем столе, физические пиксели
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
// Image отдан оверлею — освобождает он; здесь только буфер
|
||||||
|
Pixels = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class ScreenCapture
|
||||||
|
{
|
||||||
|
public static CapturedFrame Capture(Rectangle area)
|
||||||
|
{
|
||||||
|
var bmp = new Bitmap(area.Width, area.Height, PixelFormat.Format32bppArgb);
|
||||||
|
using (var g = Graphics.FromImage(bmp))
|
||||||
|
{
|
||||||
|
g.CopyFromScreen(area.X, area.Y, 0, 0, area.Size, CopyPixelOperation.SourceCopy);
|
||||||
|
}
|
||||||
|
|
||||||
|
var f = new CapturedFrame { Image = bmp, Width = area.Width, Height = area.Height, Area = area };
|
||||||
|
f.Pixels = Extract(bmp);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Вырезать подобласть из уже снятого кадра (режим выделения рамкой) — переснимать
|
||||||
|
/// экран нельзя: под рамкой выделения уже наш собственный оверлей.</summary>
|
||||||
|
public static CapturedFrame Crop(CapturedFrame src, Rectangle clientRect)
|
||||||
|
{
|
||||||
|
clientRect.Intersect(new Rectangle(0, 0, src.Width, src.Height));
|
||||||
|
var bmp = new Bitmap(clientRect.Width, clientRect.Height, PixelFormat.Format32bppArgb);
|
||||||
|
using (var g = Graphics.FromImage(bmp))
|
||||||
|
g.DrawImage(src.Image, new Rectangle(0, 0, clientRect.Width, clientRect.Height),
|
||||||
|
clientRect, GraphicsUnit.Pixel);
|
||||||
|
|
||||||
|
var area = new Rectangle(src.Area.X + clientRect.X, src.Area.Y + clientRect.Y,
|
||||||
|
clientRect.Width, clientRect.Height);
|
||||||
|
return new CapturedFrame
|
||||||
|
{
|
||||||
|
Image = bmp,
|
||||||
|
Width = clientRect.Width,
|
||||||
|
Height = clientRect.Height,
|
||||||
|
Area = area,
|
||||||
|
Pixels = Extract(bmp)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] Extract(Bitmap bmp)
|
||||||
|
{
|
||||||
|
var rect = new Rectangle(0, 0, bmp.Width, bmp.Height);
|
||||||
|
BitmapData d = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
d = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||||
|
int dense = bmp.Width * 4;
|
||||||
|
var buf = new byte[dense * bmp.Height];
|
||||||
|
int stride = Math.Abs(d.Stride);
|
||||||
|
bool bottomUp = d.Stride < 0;
|
||||||
|
|
||||||
|
for (int y = 0; y < bmp.Height; y++)
|
||||||
|
{
|
||||||
|
IntPtr src = bottomUp
|
||||||
|
? IntPtr.Add(d.Scan0, -y * stride)
|
||||||
|
: IntPtr.Add(d.Scan0, y * stride);
|
||||||
|
Marshal.Copy(src, buf, y * dense, dense);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
finally { if (d != null) bmp.UnlockBits(d); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Web;
|
||||||
|
using System.Web.Script.Serialization;
|
||||||
|
|
||||||
|
namespace ScreenTranslator
|
||||||
|
{
|
||||||
|
internal interface ITranslator
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
/// <summary>false, если для движка не хватает настроек (нет ключа/URL) — такой пропускаем молча.</summary>
|
||||||
|
bool Configured { get; }
|
||||||
|
Task<string[]> TranslateAsync(string[] texts, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
internal abstract class HttpTranslatorBase : ITranslator
|
||||||
|
{
|
||||||
|
protected readonly AppConfig Cfg;
|
||||||
|
protected HttpTranslatorBase(AppConfig cfg) { Cfg = cfg; }
|
||||||
|
|
||||||
|
public abstract string Name { get; }
|
||||||
|
public virtual bool Configured { get { return true; } }
|
||||||
|
public abstract Task<string[]> TranslateAsync(string[] texts, CancellationToken ct);
|
||||||
|
|
||||||
|
protected static readonly JavaScriptSerializer Json = new JavaScriptSerializer { MaxJsonLength = 32 * 1024 * 1024 };
|
||||||
|
|
||||||
|
static HttpTranslatorBase()
|
||||||
|
{
|
||||||
|
ServicePointManager.DefaultConnectionLimit = 24;
|
||||||
|
ServicePointManager.Expect100Continue = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ServicePointManager.SecurityProtocol =
|
||||||
|
SecurityProtocolType.Tls12 | (SecurityProtocolType)12288 /* Tls13 */;
|
||||||
|
}
|
||||||
|
catch { ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12; }
|
||||||
|
}
|
||||||
|
|
||||||
|
protected HttpWebRequest Request(string url, string method)
|
||||||
|
{
|
||||||
|
var r = (HttpWebRequest)WebRequest.Create(url);
|
||||||
|
r.Method = method;
|
||||||
|
r.Timeout = Cfg.TimeoutMs;
|
||||||
|
r.ReadWriteTimeout = Cfg.TimeoutMs;
|
||||||
|
r.UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ScreenTranslator/1.0";
|
||||||
|
r.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||||
|
r.Proxy = string.IsNullOrWhiteSpace(Cfg.Proxy) ? null : new WebProxy(Cfg.Proxy, true);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task<string> SendAsync(HttpWebRequest req, string body, string contentType, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using (ct.Register(delegate { try { req.Abort(); } catch { } }))
|
||||||
|
{
|
||||||
|
if (body != null)
|
||||||
|
{
|
||||||
|
req.ContentType = contentType;
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(body);
|
||||||
|
req.ContentLength = bytes.Length;
|
||||||
|
using (var s = await req.GetRequestStreamAsync().ConfigureAwait(false))
|
||||||
|
await s.WriteAsync(bytes, 0, bytes.Length, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
using (var resp = (HttpWebResponse)await req.GetResponseAsync().ConfigureAwait(false))
|
||||||
|
using (var rs = resp.GetResponseStream())
|
||||||
|
using (var sr = new StreamReader(rs, Encoding.UTF8))
|
||||||
|
return await sr.ReadToEndAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Прогнать тексты по одному с ограничением параллелизма — для API без пакетного режима.</summary>
|
||||||
|
protected async Task<string[]> EachAsync(string[] texts, Func<string, CancellationToken, Task<string>> one, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = new string[texts.Length];
|
||||||
|
using (var gate = new SemaphoreSlim(Cfg.MaxParallel))
|
||||||
|
{
|
||||||
|
var tasks = new Task[texts.Length];
|
||||||
|
for (int i = 0; i < texts.Length; i++)
|
||||||
|
{
|
||||||
|
int idx = i;
|
||||||
|
tasks[i] = Task.Run(async delegate
|
||||||
|
{
|
||||||
|
await gate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try { result[idx] = await one(texts[idx], ct).ConfigureAwait(false); }
|
||||||
|
finally { gate.Release(); }
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/// <summary>Бесплатный публичный эндпоинт Google Translate (client=gtx), без ключа.
|
||||||
|
/// Пакетно: тексты склеиваются через \n, ответ приходит сегментами по предложениям, но перевод
|
||||||
|
/// строки Google сохраняет — по нему и восстанавливаем исходные границы блоков.</summary>
|
||||||
|
internal class GoogleTranslator : HttpTranslatorBase
|
||||||
|
{
|
||||||
|
public GoogleTranslator(AppConfig cfg) : base(cfg) { }
|
||||||
|
public override string Name { get { return "google"; } }
|
||||||
|
|
||||||
|
const int ChunkChars = 3500;
|
||||||
|
|
||||||
|
public override async Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = new string[texts.Length];
|
||||||
|
int i = 0;
|
||||||
|
while (i < texts.Length)
|
||||||
|
{
|
||||||
|
int start = i, len = 0;
|
||||||
|
while (i < texts.Length && (len == 0 || len + texts[i].Length + 1 <= ChunkChars))
|
||||||
|
{
|
||||||
|
len += texts[i].Length + 1;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
var chunk = new string[i - start];
|
||||||
|
Array.Copy(texts, start, chunk, 0, chunk.Length);
|
||||||
|
var got = await ChunkAsync(chunk, ct).ConfigureAwait(false);
|
||||||
|
Array.Copy(got, 0, result, start, got.Length);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<string[]> ChunkAsync(string[] chunk, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string joined = string.Join("\n", chunk.Select(t => t.Replace('\n', ' ').Replace('\r', ' ')));
|
||||||
|
string url = "https://translate.googleapis.com/translate_a/single?client=gtx" +
|
||||||
|
"&sl=" + Uri.EscapeDataString(Cfg.SourceLang) +
|
||||||
|
"&tl=" + Uri.EscapeDataString(Cfg.TargetLang) + "&dt=t";
|
||||||
|
var req = Request(url, "POST");
|
||||||
|
string raw = await SendAsync(req, "q=" + HttpUtility.UrlEncode(joined, Encoding.UTF8),
|
||||||
|
"application/x-www-form-urlencoded; charset=utf-8", ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var lines = Reassemble(raw, chunk.Length);
|
||||||
|
if (lines != null) return lines;
|
||||||
|
|
||||||
|
// границы поехали (Google схлопнул строку) — добираем поштучно, зато без рассинхрона
|
||||||
|
return await EachAsync(chunk, async (t, c) =>
|
||||||
|
{
|
||||||
|
var r2 = Request(url, "POST");
|
||||||
|
var raw2 = await SendAsync(r2, "q=" + HttpUtility.UrlEncode(t.Replace('\n', ' '), Encoding.UTF8),
|
||||||
|
"application/x-www-form-urlencoded; charset=utf-8", c).ConfigureAwait(false);
|
||||||
|
var one = Reassemble(raw2, 1);
|
||||||
|
return one != null ? one[0] : t;
|
||||||
|
}, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Склеить сегменты ответа обратно в строки: сегмент, оканчивающийся на \n, закрывает строку.
|
||||||
|
/// null — если получилось не столько строк, сколько отправляли.</summary>
|
||||||
|
internal static string[] Reassemble(string raw, int expected)
|
||||||
|
{
|
||||||
|
object parsed;
|
||||||
|
try { parsed = Json.DeserializeObject(raw); } catch { return null; }
|
||||||
|
var top = parsed as object[];
|
||||||
|
if (top == null || top.Length == 0) return null;
|
||||||
|
var segs = top[0] as object[];
|
||||||
|
if (segs == null) return null;
|
||||||
|
|
||||||
|
var lines = new List<string>();
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var so in segs)
|
||||||
|
{
|
||||||
|
var seg = so as object[];
|
||||||
|
if (seg == null || seg.Length == 0 || seg[0] == null) continue;
|
||||||
|
string piece = seg[0].ToString();
|
||||||
|
bool ends = piece.EndsWith("\n");
|
||||||
|
sb.Append(ends ? piece.Substring(0, piece.Length - 1) : piece);
|
||||||
|
if (ends) { lines.Add(sb.ToString().Trim()); sb.Length = 0; }
|
||||||
|
}
|
||||||
|
if (sb.Length > 0) lines.Add(sb.ToString().Trim());
|
||||||
|
|
||||||
|
return lines.Count == expected ? lines.ToArray() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lingva — открытый фронтенд к Google. Полезен ровно тогда, когда до самого Google
|
||||||
|
/// с этой сети не достучаться. Пакетного режима нет, поэтому по одному тексту.</summary>
|
||||||
|
internal class LingvaTranslator : HttpTranslatorBase
|
||||||
|
{
|
||||||
|
public LingvaTranslator(AppConfig cfg) : base(cfg) { }
|
||||||
|
public override string Name { get { return "lingva"; } }
|
||||||
|
|
||||||
|
static readonly string[] Hosts = { "https://lingva.ml", "https://lingva.lunar.icu", "https://translate.plausibility.cloud" };
|
||||||
|
|
||||||
|
public override Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return EachAsync(texts, async (t, c) =>
|
||||||
|
{
|
||||||
|
Exception last = null;
|
||||||
|
foreach (var host in Hosts)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string url = host + "/api/v1/" + Cfg.SourceLang + "/" + Cfg.TargetLang + "/" +
|
||||||
|
Uri.EscapeDataString(t);
|
||||||
|
var raw = await SendAsync(Request(url, "GET"), null, null, c).ConfigureAwait(false);
|
||||||
|
var d = Json.Deserialize<Dictionary<string, object>>(raw);
|
||||||
|
object v;
|
||||||
|
if (d != null && d.TryGetValue("translation", out v) && v != null) return v.ToString();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { last = ex; }
|
||||||
|
}
|
||||||
|
if (last != null) throw last;
|
||||||
|
return t;
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>MyMemory: бесплатно и без ключа, но лимит ~5000 слов в сутки на IP и предел 500 символов
|
||||||
|
/// на запрос — годится как последний рубеж, не как основной движок.</summary>
|
||||||
|
internal class MyMemoryTranslator : HttpTranslatorBase
|
||||||
|
{
|
||||||
|
public MyMemoryTranslator(AppConfig cfg) : base(cfg) { }
|
||||||
|
public override string Name { get { return "mymemory"; } }
|
||||||
|
|
||||||
|
public override Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return EachAsync(texts, async (t, c) =>
|
||||||
|
{
|
||||||
|
string q = t.Length > 480 ? t.Substring(0, 480) : t;
|
||||||
|
string url = "https://api.mymemory.translated.net/get?q=" + Uri.EscapeDataString(q) +
|
||||||
|
"&langpair=" + Cfg.SourceLang + "|" + Cfg.TargetLang;
|
||||||
|
var raw = await SendAsync(Request(url, "GET"), null, null, c).ConfigureAwait(false);
|
||||||
|
var d = Json.Deserialize<Dictionary<string, object>>(raw);
|
||||||
|
object rd;
|
||||||
|
if (d != null && d.TryGetValue("responseData", out rd))
|
||||||
|
{
|
||||||
|
var inner = rd as Dictionary<string, object>;
|
||||||
|
object tt;
|
||||||
|
if (inner != null && inner.TryGetValue("translatedText", out tt) && tt != null)
|
||||||
|
return HttpUtility.HtmlDecode(tt.ToString());
|
||||||
|
}
|
||||||
|
throw new Exception("mymemory: неожиданный ответ");
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Свой релей (см. README): POST {texts, from, to} -> {texts:[...]}.
|
||||||
|
/// Нужен, когда до публичных API из сети клиента не дотянуться, а свой сервер доступен.</summary>
|
||||||
|
internal class RelayTranslator : HttpTranslatorBase
|
||||||
|
{
|
||||||
|
public RelayTranslator(AppConfig cfg) : base(cfg) { }
|
||||||
|
public override string Name { get { return "relay"; } }
|
||||||
|
public override bool Configured { get { return !string.IsNullOrWhiteSpace(Cfg.RelayUrl); } }
|
||||||
|
|
||||||
|
public override async Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string body = Json.Serialize(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "texts", texts }, { "from", Cfg.SourceLang }, { "to", Cfg.TargetLang }
|
||||||
|
});
|
||||||
|
var raw = await SendAsync(Request(Cfg.RelayUrl, "POST"), body, "application/json; charset=utf-8", ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var d = Json.Deserialize<Dictionary<string, object>>(raw);
|
||||||
|
object arr;
|
||||||
|
if (d != null && d.TryGetValue("texts", out arr))
|
||||||
|
{
|
||||||
|
var a = arr as object[];
|
||||||
|
if (a != null && a.Length == texts.Length)
|
||||||
|
return a.Select(o => o == null ? "" : o.ToString()).ToArray();
|
||||||
|
}
|
||||||
|
throw new Exception("relay: ответ не совпал по длине");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class LibreTranslateTranslator : HttpTranslatorBase
|
||||||
|
{
|
||||||
|
public LibreTranslateTranslator(AppConfig cfg) : base(cfg) { }
|
||||||
|
public override string Name { get { return "libretranslate"; } }
|
||||||
|
public override bool Configured { get { return !string.IsNullOrWhiteSpace(Cfg.LibreUrl); } }
|
||||||
|
|
||||||
|
public override async Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var payload = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "q", texts }, { "source", Cfg.SourceLang }, { "target", Cfg.TargetLang }, { "format", "text" }
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrWhiteSpace(Cfg.LibreApiKey)) payload["api_key"] = Cfg.LibreApiKey;
|
||||||
|
|
||||||
|
var raw = await SendAsync(Request(Cfg.LibreUrl, "POST"), Json.Serialize(payload),
|
||||||
|
"application/json; charset=utf-8", ct).ConfigureAwait(false);
|
||||||
|
var d = Json.Deserialize<Dictionary<string, object>>(raw);
|
||||||
|
object tr;
|
||||||
|
if (d != null && d.TryGetValue("translatedText", out tr))
|
||||||
|
{
|
||||||
|
var a = tr as object[];
|
||||||
|
if (a != null && a.Length == texts.Length) return a.Select(o => o == null ? "" : o.ToString()).ToArray();
|
||||||
|
if (texts.Length == 1 && tr != null) return new[] { tr.ToString() };
|
||||||
|
}
|
||||||
|
throw new Exception("libretranslate: неожиданный ответ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class DeeplTranslator : HttpTranslatorBase
|
||||||
|
{
|
||||||
|
public DeeplTranslator(AppConfig cfg) : base(cfg) { }
|
||||||
|
public override string Name { get { return "deepl"; } }
|
||||||
|
public override bool Configured { get { return !string.IsNullOrWhiteSpace(Cfg.DeeplApiKey); } }
|
||||||
|
|
||||||
|
public override async Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("source_lang=").Append(Cfg.SourceLang.ToUpperInvariant());
|
||||||
|
sb.Append("&target_lang=").Append(Cfg.TargetLang.ToUpperInvariant());
|
||||||
|
foreach (var t in texts) sb.Append("&text=").Append(HttpUtility.UrlEncode(t, Encoding.UTF8));
|
||||||
|
|
||||||
|
var req = Request(Cfg.DeeplUrl, "POST");
|
||||||
|
req.Headers.Add("Authorization", "DeepL-Auth-Key " + Cfg.DeeplApiKey);
|
||||||
|
var raw = await SendAsync(req, sb.ToString(), "application/x-www-form-urlencoded; charset=utf-8", ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var d = Json.Deserialize<Dictionary<string, object>>(raw);
|
||||||
|
object tr;
|
||||||
|
if (d != null && d.TryGetValue("translations", out tr))
|
||||||
|
{
|
||||||
|
var a = tr as object[];
|
||||||
|
if (a != null && a.Length == texts.Length)
|
||||||
|
return a.Select(o =>
|
||||||
|
{
|
||||||
|
var e = o as Dictionary<string, object>;
|
||||||
|
object v;
|
||||||
|
return e != null && e.TryGetValue("text", out v) && v != null ? v.ToString() : "";
|
||||||
|
}).ToArray();
|
||||||
|
}
|
||||||
|
throw new Exception("deepl: неожиданный ответ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Yandex Cloud Translate — самый устойчивый канал внутри РФ, но требует folderId + API-ключ.</summary>
|
||||||
|
internal class YandexTranslator : HttpTranslatorBase
|
||||||
|
{
|
||||||
|
public YandexTranslator(AppConfig cfg) : base(cfg) { }
|
||||||
|
public override string Name { get { return "yandex"; } }
|
||||||
|
public override bool Configured
|
||||||
|
{
|
||||||
|
get { return !string.IsNullOrWhiteSpace(Cfg.YandexApiKey) && !string.IsNullOrWhiteSpace(Cfg.YandexFolderId); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var payload = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "folderId", Cfg.YandexFolderId },
|
||||||
|
{ "texts", texts },
|
||||||
|
{ "sourceLanguageCode", Cfg.SourceLang },
|
||||||
|
{ "targetLanguageCode", Cfg.TargetLang },
|
||||||
|
{ "format", "PLAIN_TEXT" }
|
||||||
|
};
|
||||||
|
var req = Request("https://translate.api.cloud.yandex.net/translate/v2/translate", "POST");
|
||||||
|
req.Headers.Add("Authorization", "Api-Key " + Cfg.YandexApiKey);
|
||||||
|
var raw = await SendAsync(req, Json.Serialize(payload), "application/json; charset=utf-8", ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var d = Json.Deserialize<Dictionary<string, object>>(raw);
|
||||||
|
object tr;
|
||||||
|
if (d != null && d.TryGetValue("translations", out tr))
|
||||||
|
{
|
||||||
|
var a = tr as object[];
|
||||||
|
if (a != null && a.Length == texts.Length)
|
||||||
|
return a.Select(o =>
|
||||||
|
{
|
||||||
|
var e = o as Dictionary<string, object>;
|
||||||
|
object v;
|
||||||
|
return e != null && e.TryGetValue("text", out v) && v != null ? v.ToString() : "";
|
||||||
|
}).ToArray();
|
||||||
|
}
|
||||||
|
throw new Exception("yandex: неожиданный ответ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Перевод целым экраном через LLM (Anthropic Messages API): лучше держит контекст и
|
||||||
|
/// термины интерфейса, но платный и медленнее. Строки нумеруются, ответ ждём строго построчно.</summary>
|
||||||
|
internal class LlmTranslator : HttpTranslatorBase
|
||||||
|
{
|
||||||
|
public LlmTranslator(AppConfig cfg) : base(cfg) { }
|
||||||
|
public override string Name { get { return "llm"; } }
|
||||||
|
public override bool Configured { get { return !string.IsNullOrWhiteSpace(Cfg.LlmApiKey); } }
|
||||||
|
|
||||||
|
public override async Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < texts.Length; i++)
|
||||||
|
sb.Append(i + 1).Append('\t').Append(texts[i].Replace('\n', ' ')).Append('\n');
|
||||||
|
|
||||||
|
string prompt =
|
||||||
|
"Переведи фрагменты интерфейса и текста с " + Cfg.SourceLang + " на " + Cfg.TargetLang + ".\n" +
|
||||||
|
"Это надписи с экрана компьютера: сохраняй краткость, не добавляй пояснений.\n" +
|
||||||
|
"Ответь строго " + texts.Length + " строками в формате <номер>\\t<перевод>, без иного текста.\n\n" +
|
||||||
|
sb;
|
||||||
|
|
||||||
|
var payload = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "model", Cfg.LlmModel },
|
||||||
|
{ "max_tokens", Math.Min(8192, 256 + texts.Length * 64) },
|
||||||
|
{ "messages", new object[] { new Dictionary<string, object> { { "role", "user" }, { "content", prompt } } } }
|
||||||
|
};
|
||||||
|
|
||||||
|
var req = Request(Cfg.LlmUrl, "POST");
|
||||||
|
req.Headers.Add("x-api-key", Cfg.LlmApiKey);
|
||||||
|
req.Headers.Add("anthropic-version", "2023-06-01");
|
||||||
|
var raw = await SendAsync(req, Json.Serialize(payload), "application/json; charset=utf-8", ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var d = Json.Deserialize<Dictionary<string, object>>(raw);
|
||||||
|
object content;
|
||||||
|
if (d == null || !d.TryGetValue("content", out content)) throw new Exception("llm: нет content");
|
||||||
|
var blocks = content as object[];
|
||||||
|
var text = new StringBuilder();
|
||||||
|
if (blocks != null)
|
||||||
|
foreach (var b in blocks)
|
||||||
|
{
|
||||||
|
var e = b as Dictionary<string, object>;
|
||||||
|
object v;
|
||||||
|
if (e != null && e.TryGetValue("text", out v) && v != null) text.Append(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new string[texts.Length];
|
||||||
|
foreach (var line in text.ToString().Split('\n'))
|
||||||
|
{
|
||||||
|
int tab = line.IndexOf('\t');
|
||||||
|
if (tab <= 0) continue;
|
||||||
|
int n;
|
||||||
|
if (!int.TryParse(line.Substring(0, tab).Trim(), out n)) continue;
|
||||||
|
if (n >= 1 && n <= result.Length) result[n - 1] = line.Substring(tab + 1).Trim();
|
||||||
|
}
|
||||||
|
for (int i = 0; i < result.Length; i++) if (result[i] == null) result[i] = texts[i];
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
internal class EngineStatus
|
||||||
|
{
|
||||||
|
public string Name;
|
||||||
|
public bool Ok;
|
||||||
|
public long Ms;
|
||||||
|
public string Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Цепочка движков: работаем первым живым, упавший запоминаем и какое-то время не трогаем.
|
||||||
|
/// Смысл в том, чтобы у пользователя в другой сети (где, скажем, режется Google) всё
|
||||||
|
/// заработало само, без разбирательств с настройками.</summary>
|
||||||
|
internal class TranslationChain
|
||||||
|
{
|
||||||
|
readonly List<ITranslator> _engines = new List<ITranslator>();
|
||||||
|
readonly Dictionary<string, DateTime> _cooldown = new Dictionary<string, DateTime>();
|
||||||
|
readonly TranslationCache _cache;
|
||||||
|
readonly AppConfig _cfg;
|
||||||
|
|
||||||
|
public string LastUsed { get; private set; }
|
||||||
|
public string LastError { get; private set; }
|
||||||
|
|
||||||
|
public TranslationChain(AppConfig cfg, TranslationCache cache)
|
||||||
|
{
|
||||||
|
_cfg = cfg; _cache = cache;
|
||||||
|
foreach (var name in cfg.Chain)
|
||||||
|
{
|
||||||
|
var e = Make(name, cfg);
|
||||||
|
if (e == null) continue;
|
||||||
|
if (!e.Configured) continue;
|
||||||
|
_engines.Add(e);
|
||||||
|
}
|
||||||
|
if (_engines.Count == 0) _engines.Add(new GoogleTranslator(cfg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<string> EngineNames { get { return _engines.Select(e => e.Name); } }
|
||||||
|
|
||||||
|
static ITranslator Make(string name, AppConfig cfg)
|
||||||
|
{
|
||||||
|
switch (name)
|
||||||
|
{
|
||||||
|
case "google": return new GoogleTranslator(cfg);
|
||||||
|
case "lingva": return new LingvaTranslator(cfg);
|
||||||
|
case "mymemory": return new MyMemoryTranslator(cfg);
|
||||||
|
case "relay": return new RelayTranslator(cfg);
|
||||||
|
case "libretranslate": case "libre": return new LibreTranslateTranslator(cfg);
|
||||||
|
case "deepl": return new DeeplTranslator(cfg);
|
||||||
|
case "yandex": return new YandexTranslator(cfg);
|
||||||
|
case "llm": case "claude": return new LlmTranslator(cfg);
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Возвращает переводы в том же порядке; на элемент, который не удалось перевести, — null.</summary>
|
||||||
|
public async Task<string[]> TranslateAsync(string[] texts, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = new string[texts.Length];
|
||||||
|
var todoIdx = new List<int>();
|
||||||
|
var todoTxt = new List<string>();
|
||||||
|
|
||||||
|
for (int i = 0; i < texts.Length; i++)
|
||||||
|
{
|
||||||
|
string hit;
|
||||||
|
if (_cache != null && _cache.TryGet(texts[i], out hit)) result[i] = hit;
|
||||||
|
else { todoIdx.Add(i); todoTxt.Add(texts[i]); }
|
||||||
|
}
|
||||||
|
if (todoTxt.Count == 0) { LastUsed = "cache"; return result; }
|
||||||
|
|
||||||
|
var errors = new List<string>();
|
||||||
|
foreach (var eng in _engines)
|
||||||
|
{
|
||||||
|
DateTime until;
|
||||||
|
if (_cooldown.TryGetValue(eng.Name, out until) && DateTime.UtcNow < until) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var got = await eng.TranslateAsync(todoTxt.ToArray(), ct).ConfigureAwait(false);
|
||||||
|
if (got == null || got.Length != todoTxt.Count) throw new Exception("длина ответа не совпала");
|
||||||
|
for (int i = 0; i < got.Length; i++)
|
||||||
|
{
|
||||||
|
result[todoIdx[i]] = got[i];
|
||||||
|
if (_cache != null && !string.IsNullOrEmpty(got[i])) _cache.Put(todoTxt[i], got[i]);
|
||||||
|
}
|
||||||
|
LastUsed = eng.Name;
|
||||||
|
LastError = errors.Count > 0 ? string.Join("; ", errors) : null;
|
||||||
|
_cooldown.Remove(eng.Name);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errors.Add(eng.Name + ": " + Short(ex));
|
||||||
|
_cooldown[eng.Name] = DateTime.UtcNow.AddSeconds(60);
|
||||||
|
}
|
||||||
|
if (ct.IsCancellationRequested) break;
|
||||||
|
}
|
||||||
|
LastError = string.Join("; ", errors);
|
||||||
|
LastUsed = null;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Проверка «кто вообще жив» для меню в трее.</summary>
|
||||||
|
public async Task<List<EngineStatus>> ProbeAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var list = new List<EngineStatus>();
|
||||||
|
foreach (var eng in _engines)
|
||||||
|
{
|
||||||
|
var st = new EngineStatus { Name = eng.Name };
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = await eng.TranslateAsync(new[] { "Open settings" }, ct).ConfigureAwait(false);
|
||||||
|
st.Ok = r != null && r.Length == 1 && !string.IsNullOrWhiteSpace(r[0]);
|
||||||
|
if (!st.Ok) st.Error = "пустой ответ";
|
||||||
|
}
|
||||||
|
catch (Exception ex) { st.Error = Short(ex); }
|
||||||
|
st.Ms = sw.ElapsedMilliseconds;
|
||||||
|
list.Add(st);
|
||||||
|
if (st.Ok) _cooldown.Remove(eng.Name);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Short(Exception ex)
|
||||||
|
{
|
||||||
|
var we = ex as WebException;
|
||||||
|
if (we != null)
|
||||||
|
{
|
||||||
|
var r = we.Response as HttpWebResponse;
|
||||||
|
if (r != null) return (int)r.StatusCode + " " + r.StatusDescription;
|
||||||
|
return we.Status.ToString();
|
||||||
|
}
|
||||||
|
return ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Кэш переводов. Экран часто пересканируют почти без изменений — повтор должен быть мгновенным.</summary>
|
||||||
|
internal class TranslationCache
|
||||||
|
{
|
||||||
|
readonly Dictionary<string, string> _map = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
readonly string _path;
|
||||||
|
readonly string _prefix;
|
||||||
|
bool _dirty;
|
||||||
|
|
||||||
|
public TranslationCache(AppConfig cfg)
|
||||||
|
{
|
||||||
|
_path = Path.Combine(AppConfig.Dir, "cache.json");
|
||||||
|
_prefix = cfg.SourceLang + ">" + cfg.TargetLang + "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_path))
|
||||||
|
{
|
||||||
|
var d = new JavaScriptSerializer { MaxJsonLength = 64 * 1024 * 1024 }
|
||||||
|
.Deserialize<Dictionary<string, string>>(File.ReadAllText(_path));
|
||||||
|
if (d != null) foreach (var kv in d) _map[kv.Key] = kv.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* битый кэш — просто начинаем с чистого */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGet(string src, out string dst) { return _map.TryGetValue(_prefix + src, out dst); }
|
||||||
|
|
||||||
|
public void Put(string src, string dst)
|
||||||
|
{
|
||||||
|
_map[_prefix + src] = dst;
|
||||||
|
_dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save()
|
||||||
|
{
|
||||||
|
if (!_dirty) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// не даём файлу расти бесконечно
|
||||||
|
var src = _map;
|
||||||
|
if (src.Count > 20000)
|
||||||
|
src = src.Skip(src.Count - 20000).ToDictionary(k => k.Key, v => v.Value);
|
||||||
|
File.WriteAllText(_path,
|
||||||
|
new JavaScriptSerializer { MaxJsonLength = 64 * 1024 * 1024 }.Serialize(src),
|
||||||
|
new UTF8Encoding(false));
|
||||||
|
_dirty = false;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Imaging;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace ScreenTranslator
|
||||||
|
{
|
||||||
|
/// <summary>Прогон всего конвейера без окна: захват -> OCR -> блоки -> перевод -> отрисовка в PNG.
|
||||||
|
/// Нужен, чтобы проверять качество распознавания и вёрстки плашек, не гоняя оверлей поверх
|
||||||
|
/// рабочего стола и не кликая по нему.
|
||||||
|
///
|
||||||
|
/// RenderTest.exe [--in кадр.png] [--out каталог] [--scale 2.0] [--no-translate]
|
||||||
|
/// </summary>
|
||||||
|
static class RenderTest
|
||||||
|
{
|
||||||
|
[System.Runtime.InteropServices.DllImport("kernel32.dll")]
|
||||||
|
static extern int GetConsoleProcessList(int[] buffer, int count);
|
||||||
|
|
||||||
|
/// <summary>Запущено двойным кликом (своя консоль), а не из терминала — тогда после
|
||||||
|
/// работы надо дождаться пользователя, иначе окно захлопнется вместе с выводом.</summary>
|
||||||
|
static bool OwnsConsole()
|
||||||
|
{
|
||||||
|
try { return GetConsoleProcessList(new int[4], 4) <= 1; }
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[STAThread]
|
||||||
|
static int Main(string[] args)
|
||||||
|
{
|
||||||
|
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||||
|
bool pause = OwnsConsole();
|
||||||
|
try { return Run(args); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("\nОшибка: " + ex.Message);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (pause)
|
||||||
|
{
|
||||||
|
Console.WriteLine("\nНажмите Enter, чтобы закрыть окно…");
|
||||||
|
Console.ReadLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Проверка биграммной модели на реальных примерах из прошлых прогонов:
|
||||||
|
/// слева то, что должно проходить, справа — то, что модель обязана отбраковывать.
|
||||||
|
/// Порог берётся из этих чисел, а не из общих соображений.</summary>
|
||||||
|
static int SelfTest()
|
||||||
|
{
|
||||||
|
string[] good = {
|
||||||
|
"Profile", "Cancel", "Settings", "Search", "Mining", "Tailoring", "Engineering",
|
||||||
|
"Leatherworking", "Consumable", "Trade Goods", "Not equippable", "All Subclasses",
|
||||||
|
"Have Materials", "Network configuration", "Wireless adapter", "Save changes",
|
||||||
|
"Connection established", "Restore defaults", "First Aid", "Storage almost full"
|
||||||
|
};
|
||||||
|
string[] bad = {
|
||||||
|
"npocbwlb", "C03AaTb", "Pa3Mep", "npocM0TpeTb", "COPTMPOBaTb", "Bxoaguvqe",
|
||||||
|
"KOMnb}0Tep", "Meccetqxep", "KonnpoeaTb", "nepenaeH0BaTb", "Bblpe3aTb",
|
||||||
|
"3TOT KOMnb0Tep", "6b1CTPb1i AOCTyn", "npoeKTb1 Crpeneu", "Pa60ti cron"
|
||||||
|
};
|
||||||
|
|
||||||
|
Console.WriteLine("--- должно проходить ---");
|
||||||
|
double gMin = double.MaxValue;
|
||||||
|
foreach (var s in good)
|
||||||
|
{
|
||||||
|
double v = EnglishBigrams.TextScore(s);
|
||||||
|
Console.WriteLine(" {0,8:F3} {1}", v, s);
|
||||||
|
if (!double.IsNaN(v) && v < gMin) gMin = v;
|
||||||
|
}
|
||||||
|
Console.WriteLine("--- должно отбраковываться ---");
|
||||||
|
double bMax = double.MinValue;
|
||||||
|
foreach (var s in bad)
|
||||||
|
{
|
||||||
|
double v = EnglishBigrams.TextScore(s);
|
||||||
|
Console.WriteLine(" {0,8:F3} {1}", v, s);
|
||||||
|
if (!double.IsNaN(v) && v > bMax) bMax = v;
|
||||||
|
}
|
||||||
|
Console.WriteLine("\nхудший «хороший»: {0:F3} лучший «плохой»: {1:F3} зазор: {2:F3}",
|
||||||
|
gMin, bMax, gMin - bMax);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Run(string[] args)
|
||||||
|
{
|
||||||
|
if (args.Contains("--selftest")) return SelfTest();
|
||||||
|
|
||||||
|
string inPath = Arg(args, "--in", null);
|
||||||
|
string outDir = Arg(args, "--out", Path.Combine(Path.GetTempPath(), "screen-translator-test"));
|
||||||
|
double scale = double.Parse(Arg(args, "--scale", "2.0"), System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
bool translate = !args.Contains("--no-translate");
|
||||||
|
Directory.CreateDirectory(outDir);
|
||||||
|
|
||||||
|
string err;
|
||||||
|
var cfg = AppConfig.Load(out err);
|
||||||
|
cfg.OcrScale = scale;
|
||||||
|
if (err != null) Console.WriteLine("config: " + err);
|
||||||
|
|
||||||
|
var ocr = OcrService.Create(cfg.OcrLanguage);
|
||||||
|
if (ocr == null) { Console.WriteLine("OCR недоступен"); return 2; }
|
||||||
|
ocr.Grayscale = args.Contains("--gray");
|
||||||
|
ocr.Invert = Arg(args, "--invert", "never");
|
||||||
|
Console.WriteLine("OCR: {0} масштаб: {1} серый: {2} инверсия: {3}",
|
||||||
|
ocr.LanguageTag, scale, ocr.Grayscale ? "да" : "нет", ocr.Invert);
|
||||||
|
|
||||||
|
CapturedFrame frame = inPath != null ? FromFile(inPath) : ScreenCapture.Capture(Native.MonitorUnderCursor());
|
||||||
|
Console.WriteLine("кадр: {0}x{1}", frame.Width, frame.Height);
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var lines = ocr.RecognizeAsync(frame, cfg.OcrScale).GetAwaiter().GetResult();
|
||||||
|
long tOcr = sw.ElapsedMilliseconds;
|
||||||
|
|
||||||
|
var sampler = new FrameSampler(frame);
|
||||||
|
int minWords = int.Parse(Arg(args, "--min-words", cfg.MinWords.ToString()));
|
||||||
|
var blocks = Blocks.Group(lines, cfg.MinChars, cfg.SkipGarbage, sampler, minWords);
|
||||||
|
Console.WriteLine("OCR {0} мс: строк {1} -> блоков {2}", tOcr, lines.Count, blocks.Count);
|
||||||
|
|
||||||
|
if (cfg.DetectForeign)
|
||||||
|
{
|
||||||
|
var other = OcrService.CreateOther(ocr.LanguageTag);
|
||||||
|
if (other == null) Console.WriteLine("второй модели OCR нет — проверка языка пропущена");
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sw.Restart();
|
||||||
|
var otherLines = other.RecognizeAsync(frame, cfg.OcrScale).GetAwaiter().GetResult();
|
||||||
|
var before = new List<TextBlock>(blocks);
|
||||||
|
Blocks.DropForeign(blocks, otherLines, Blocks.IsCyrillicLanguage(cfg.SourceLang));
|
||||||
|
Console.WriteLine("проверка моделью «{0}» {1} мс: отброшено {2}, осталось {3}",
|
||||||
|
other.LanguageTag, sw.ElapsedMilliseconds, before.Count - blocks.Count, blocks.Count);
|
||||||
|
foreach (var dropped in before.Where(b => !blocks.Contains(b)))
|
||||||
|
Console.WriteLine(" отброшено: " + Cut(dropped.Source, 70));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Blocks.ComputeGrowLimits(blocks, lines, new Size(frame.Width, frame.Height));
|
||||||
|
|
||||||
|
foreach (var b in blocks)
|
||||||
|
{
|
||||||
|
Color bg, fg;
|
||||||
|
sampler.Sample(b.Rect, out bg, out fg);
|
||||||
|
b.Bg = bg; b.Fg = fg;
|
||||||
|
}
|
||||||
|
|
||||||
|
long tTr = 0;
|
||||||
|
if (translate && blocks.Count > 0)
|
||||||
|
{
|
||||||
|
var cache = cfg.Cache ? new TranslationCache(cfg) : null;
|
||||||
|
var chain = new TranslationChain(cfg, cache);
|
||||||
|
sw.Restart();
|
||||||
|
var res = chain.TranslateAsync(blocks.Select(b => b.Source).ToArray(), CancellationToken.None)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
tTr = sw.ElapsedMilliseconds;
|
||||||
|
for (int i = 0; i < blocks.Count; i++)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(res[i])) { blocks[i].Failed = true; continue; }
|
||||||
|
blocks[i].Translated = res[i];
|
||||||
|
}
|
||||||
|
if (cache != null) cache.Save();
|
||||||
|
Console.WriteLine("перевод {0} мс движком «{1}»{2}", tTr, chain.LastUsed ?? "—",
|
||||||
|
chain.LastError != null ? " (ошибки: " + chain.LastError + ")" : "");
|
||||||
|
}
|
||||||
|
else foreach (var b in blocks) b.Translated = b.Source;
|
||||||
|
|
||||||
|
// 1) как видит OCR
|
||||||
|
using (var dbg = Clone(frame))
|
||||||
|
using (var g = Graphics.FromImage(dbg))
|
||||||
|
{
|
||||||
|
foreach (var b in blocks)
|
||||||
|
using (var p = new Pen(Color.FromArgb(200, 255, 60, 60)))
|
||||||
|
g.DrawRectangle(p, b.Rect);
|
||||||
|
Save(dbg, Path.Combine(outDir, "1-blocks.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) итог: перевод поверх оригинала
|
||||||
|
using (var res = Clone(frame))
|
||||||
|
using (var g = Graphics.FromImage(res))
|
||||||
|
{
|
||||||
|
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
|
||||||
|
var fonts = new Dictionary<TextBlock, Font>();
|
||||||
|
var layout = new Dictionary<TextBlock, Rectangle>();
|
||||||
|
foreach (var b in blocks)
|
||||||
|
{
|
||||||
|
if (b.Translated == null) continue;
|
||||||
|
PlateRenderer.Draw(g, b, cfg, new Size(frame.Width, frame.Height), fonts, layout);
|
||||||
|
}
|
||||||
|
foreach (var f in fonts.Values) f.Dispose();
|
||||||
|
Save(res, Path.Combine(outDir, "2-translated.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
foreach (var b in blocks.OrderBy(b => b.Rect.Top).Take(15))
|
||||||
|
Console.WriteLine(" [{0,4},{1,4} {2,3}x{3,-3} h={4:0.0}] {5}\n -> {6}",
|
||||||
|
b.Rect.X, b.Rect.Y, b.Rect.Width, b.Rect.Height, b.LineHeight,
|
||||||
|
Cut(b.Source, 70), Cut(b.Translated ?? "—", 70));
|
||||||
|
|
||||||
|
// Замер качества распознавания: сколько заранее известных строк реально нашлось.
|
||||||
|
// Без такой метрики любые правки препроцессинга оцениваются на глаз, а на глаз
|
||||||
|
// одинаково убедительно выглядят и улучшение, и ухудшение.
|
||||||
|
string expect = Arg(args, "--expect", null);
|
||||||
|
if (expect != null)
|
||||||
|
{
|
||||||
|
var want = File.ReadAllLines(expect)
|
||||||
|
.Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
|
||||||
|
var all = string.Join(" | ", blocks.Select(b => b.Source));
|
||||||
|
int hit = 0;
|
||||||
|
var missed = new List<string>();
|
||||||
|
foreach (var w in want)
|
||||||
|
{
|
||||||
|
if (all.IndexOf(w, StringComparison.OrdinalIgnoreCase) >= 0) hit++;
|
||||||
|
else missed.Add(w);
|
||||||
|
}
|
||||||
|
Console.WriteLine("\nРАСПОЗНАНО: {0} из {1} ({2:P0})", hit, want.Count, (double)hit / want.Count);
|
||||||
|
if (missed.Count > 0) Console.WriteLine("не найдено: " + string.Join(", ", missed));
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("\nкартинки: " + outDir);
|
||||||
|
frame.Image.Dispose();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Cut(string s, int n) { return s.Length <= n ? s : s.Substring(0, n - 1) + "…"; }
|
||||||
|
|
||||||
|
static string Arg(string[] a, string name, string def)
|
||||||
|
{
|
||||||
|
int i = Array.IndexOf(a, name);
|
||||||
|
return i >= 0 && i + 1 < a.Length ? a[i + 1] : def;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Bitmap Clone(CapturedFrame f)
|
||||||
|
{
|
||||||
|
var b = new Bitmap(f.Width, f.Height, PixelFormat.Format32bppArgb);
|
||||||
|
using (var g = Graphics.FromImage(b)) g.DrawImageUnscaled(f.Image, 0, 0);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
static CapturedFrame FromFile(string path)
|
||||||
|
{
|
||||||
|
using (var src = new Bitmap(path))
|
||||||
|
{
|
||||||
|
var bmp = new Bitmap(src.Width, src.Height, PixelFormat.Format32bppArgb);
|
||||||
|
using (var g = Graphics.FromImage(bmp)) g.DrawImageUnscaled(src, 0, 0);
|
||||||
|
var tmp = new CapturedFrame
|
||||||
|
{
|
||||||
|
Image = bmp,
|
||||||
|
Width = bmp.Width,
|
||||||
|
Height = bmp.Height,
|
||||||
|
Area = new Rectangle(0, 0, bmp.Width, bmp.Height)
|
||||||
|
};
|
||||||
|
// тот же путь извлечения пикселей, что и у экранного захвата
|
||||||
|
tmp.Pixels = ScreenCapture.Crop(tmp, new Rectangle(0, 0, bmp.Width, bmp.Height)).Pixels;
|
||||||
|
return tmp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Save(Bitmap b, string path)
|
||||||
|
{
|
||||||
|
b.Save(path, ImageFormat.Png);
|
||||||
|
Console.WriteLine(" сохранено: " + path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<#
|
||||||
|
Превращает частотную таблицу английских биграмм в компактный C#-файл.
|
||||||
|
|
||||||
|
Каждая из 676 биграмм кодируется одним печатным ASCII-символом (квантованный log10
|
||||||
|
вероятности), поэтому вся модель — одна строка примерно в 700 байт вместо мегабайтных
|
||||||
|
словарей и без единой внешней зависимости.
|
||||||
|
|
||||||
|
Источник частот: practicalcryptography.com (корпус английских текстов).
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$In = "$PSScriptRoot\english_bigrams.txt",
|
||||||
|
[string]$Out = "$PSScriptRoot\..\src\EnglishBigrams.cs"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$counts = @{}
|
||||||
|
[double]$total = 0
|
||||||
|
foreach ($line in Get-Content $In) {
|
||||||
|
if ($line -notmatch '^\s*([A-Za-z]{2})\s+(\d+)\s*$') { continue }
|
||||||
|
$bg = $Matches[1].ToUpperInvariant()
|
||||||
|
$n = [double]$Matches[2]
|
||||||
|
$counts[$bg] = $n
|
||||||
|
$total += $n
|
||||||
|
}
|
||||||
|
Write-Host "биграмм: $($counts.Count), суммарная частота: $total"
|
||||||
|
|
||||||
|
# Квантование: log10(p) укладываем в печатный ASCII с шагом 1/12 порядка величины.
|
||||||
|
$LO = -9.0; $STEP = 12.0
|
||||||
|
$sb = New-Object Text.StringBuilder
|
||||||
|
$minLog = 0.0
|
||||||
|
foreach ($i in 0..25) {
|
||||||
|
foreach ($j in 0..25) {
|
||||||
|
$bg = [char](65 + $i) + [string][char](65 + $j)
|
||||||
|
$n = if ($counts.ContainsKey($bg)) { $counts[$bg] } else { 0 }
|
||||||
|
# ненаблюдаемой биграмме даём вероятность заметно ниже самой редкой наблюдаемой
|
||||||
|
$p = if ($n -gt 0) { $n / $total } else { 1e-9 }
|
||||||
|
$lg = [Math]::Log10($p)
|
||||||
|
if ($lg -lt $minLog) { $minLog = $lg }
|
||||||
|
$q = [int][Math]::Round(($lg - $LO) * $STEP)
|
||||||
|
if ($q -lt 0) { $q = 0 }
|
||||||
|
if ($q -gt 88) { $q = 88 }
|
||||||
|
[void]$sb.Append([char](34 + $q)) # с 34, чтобы не попасть на кавычку и обратный слэш
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host "минимальный log10(p): $minLog"
|
||||||
|
|
||||||
|
$data = $sb.ToString()
|
||||||
|
$chunks = @()
|
||||||
|
for ($i = 0; $i -lt $data.Length; $i += 130) {
|
||||||
|
$len = [Math]::Min(130, $data.Length - $i)
|
||||||
|
$chunks += ' "' + $data.Substring($i, $len).Replace('\', '\\') + '"'
|
||||||
|
}
|
||||||
|
$joined = $chunks -join " +`r`n"
|
||||||
|
|
||||||
|
$code = @"
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ScreenTranslator
|
||||||
|
{
|
||||||
|
/// <summary>Модель правдоподобия английского текста по частотам буквенных пар.
|
||||||
|
///
|
||||||
|
/// Решает задачу, с которой не справляются ни словарь, ни алфавит: английская модель OCR,
|
||||||
|
/// прочитав русскую надпись, выдаёт правдоподобное на вид латинское слово («Профиль» →
|
||||||
|
/// «npocbwlb»). Но пары букв выдают подделку сразу — сочетаний «wl», «cb», «bw» в английском
|
||||||
|
/// практически не бывает, тогда как «th», «he», «in» составляют его основу.
|
||||||
|
///
|
||||||
|
/// Таблица — 676 квантованных log10-вероятностей, по символу на пару, около 700 байт.
|
||||||
|
/// Сгенерирована tools/make-bigrams.ps1 из частот корпуса английских текстов
|
||||||
|
/// (practicalcryptography.com). Правится только генератором, не руками.</summary>
|
||||||
|
internal static class EnglishBigrams
|
||||||
|
{
|
||||||
|
const double Lo = $LO, Step = $STEP;
|
||||||
|
|
||||||
|
const string Table =
|
||||||
|
$joined;
|
||||||
|
|
||||||
|
/// <summary>Средний log10 вероятности пар букв в слове. Около -2,5 у обычного английского
|
||||||
|
/// слова, ниже -4 у случайного набора букв. NaN, если букв слишком мало для оценки.</summary>
|
||||||
|
public static double WordScore(string word)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(word)) return double.NaN;
|
||||||
|
double sum = 0; int n = 0;
|
||||||
|
for (int i = 0; i + 1 < word.Length; i++)
|
||||||
|
{
|
||||||
|
int a = Index(word[i]), b = Index(word[i + 1]);
|
||||||
|
if (a < 0 || b < 0) continue;
|
||||||
|
sum += Lo + (Table[a * 26 + b] - 34) / Step;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n == 0 ? double.NaN : sum / n;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Index(char c)
|
||||||
|
{
|
||||||
|
if (c >= 'a' && c <= 'z') return c - 'a';
|
||||||
|
if (c >= 'A' && c <= 'Z') return c - 'A';
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Оценка текста целиком: усреднение по словам длиной от четырёх букв —
|
||||||
|
/// на более коротких пары букв ничего не доказывают. NaN, если оценивать нечего.</summary>
|
||||||
|
public static double TextScore(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return double.NaN;
|
||||||
|
double sum = 0; int n = 0;
|
||||||
|
foreach (var w in text.Split(new[] { ' ', '\t', '\n', '\r', '.', ',', ':', ';', '!', '?',
|
||||||
|
'(', ')', '[', ']', '"', '\'', '/', '\\', '-' },
|
||||||
|
StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
if (w.Length < 4) continue;
|
||||||
|
double s = WordScore(w);
|
||||||
|
if (double.IsNaN(s)) continue;
|
||||||
|
sum += s; n++;
|
||||||
|
}
|
||||||
|
return n == 0 ? double.NaN : sum / n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
|
||||||
|
$code | Set-Content $Out -Encoding UTF8
|
||||||
|
Write-Host "записано: $Out ($($data.Length) символов таблицы)"
|
||||||
@@ -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"
|
||||||
@@ -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 абзаца целыми блоками, заголовки и пункты списка отдельно."
|
||||||
@@ -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"
|
||||||
@@ -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 рядом с программой, открывается блокнотом.
|
||||||
|
После правки: значок в трее → «Перечитать настройки», перезапуск не нужен.
|
||||||
Reference in New Issue
Block a user