Переводчик экрана: наложение перевода поверх текста на экране
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