using System; using System.Diagnostics; using System.Drawing; using System.Drawing.Drawing2D; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; namespace ScreenTranslator { static class Program { [STAThread] static void Main(string[] args) { // Сборщику нужен эталонный config.json рядом с exe, а держать его в репозитории // нельзя — файл правится пользователем и обрастает личными настройками. // Ключ создаёт его из вшитого умолчания и сразу выходит, без окна и трея. foreach (var a in args) if (string.Equals(a, "--write-config", StringComparison.OrdinalIgnoreCase)) { string err; AppConfig.Load(out err); // создаёт файл, если его нет return; } bool created; using (var mutex = new Mutex(true, "Local\\ScreenTranslator.SingleInstance", out created)) { if (!created) { MessageBox.Show("Переводчик экрана уже запущен — значок в трее.", "Переводчик экрана", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new TrayApp()); GC.KeepAlive(mutex); } } } /// Приложение живёт в трее и просыпается по горячей клавише. internal class TrayApp : ApplicationContext { const int HK_SCREEN = 1, HK_REGION = 2; AppConfig _cfg; OcrService _ocr; OcrService _ocrOther; // модель другого языка — чтобы не переводить надписи на нём TranslationCache _cache; TranslationChain _chain; readonly NotifyIcon _tray; readonly HotkeyWindow _hotkeys; OverlayForm _overlay; CancellationTokenSource _cts; bool _busy; public TrayApp() { _tray = new NotifyIcon { Icon = MakeIcon(), Text = "Переводчик экрана", Visible = true }; _tray.DoubleClick += delegate { Start(false); }; _hotkeys = new HotkeyWindow(); _hotkeys.Pressed += OnHotkey; LoadConfig(true); BuildMenu(); } // ---------------- конфигурация ---------------- void LoadConfig(bool firstRun) { string err; _cfg = AppConfig.Load(out err); if (_cache != null) _cache.Save(); _cache = _cfg.Cache ? new TranslationCache(_cfg) : null; _chain = new TranslationChain(_cfg, _cache); _ocr = OcrService.Create(_cfg.OcrLanguage); _ocrOther = _cfg.DetectForeign && _ocr != null ? OcrService.CreateOther(_ocr.LanguageTag) : null; if (_ocr == null) { Balloon("OCR недоступен", "В Windows не установлена ни одна модель распознавания. Параметры → Время и язык → " + "Язык и регион → у нужного языка «Компоненты языка» → Распознавание текста.", ToolTipIcon.Error); } else if (!string.Equals(_ocr.LanguageTag, _cfg.OcrLanguage, StringComparison.OrdinalIgnoreCase)) { Balloon("Модель OCR заменена", "Модели «" + _cfg.OcrLanguage + "» нет, используется «" + _ocr.LanguageTag + "». Доступны: " + string.Join(", ", OcrService.AvailableLanguages()), ToolTipIcon.Warning); } RegisterHotkeys(); if (err != null) Balloon("Настройки", err, ToolTipIcon.Warning); else if (firstRun) { _tray.Text = "Переводчик экрана — " + _cfg.HotkeyTranslate; Balloon("Переводчик экрана запущен", _cfg.HotkeyTranslate + " — перевести экран, " + _cfg.HotkeyRegion + " — область.", ToolTipIcon.Info); } } void RegisterHotkeys() { _hotkeys.UnregisterAll(); string bad = null; if (!_hotkeys.Register(HK_SCREEN, _cfg.HotkeyTranslate)) bad = _cfg.HotkeyTranslate; if (!_hotkeys.Register(HK_REGION, _cfg.HotkeyRegion)) bad = bad == null ? _cfg.HotkeyRegion : bad + " и " + _cfg.HotkeyRegion; if (bad != null) Balloon("Горячая клавиша занята", "Не удалось назначить " + bad + " — комбинацию уже держит другая программа. " + "Поменяйте её в config.json.", ToolTipIcon.Warning); } void BuildMenu() { var m = new ContextMenuStrip(); m.Items.Add("Перевести экран\t" + _cfg.HotkeyTranslate, null, delegate { Start(false); }); m.Items.Add("Перевести область\t" + _cfg.HotkeyRegion, null, delegate { Start(true); }); m.Items.Add(new ToolStripSeparator()); m.Items.Add("Проверить переводчики…", null, delegate { ProbeEngines(); }); m.Items.Add("Открыть config.json", null, delegate { try { Process.Start(new ProcessStartInfo(AppConfig.Path_) { UseShellExecute = true }); } catch (Exception ex) { Balloon("Не открылось", ex.Message, ToolTipIcon.Error); } }); m.Items.Add("Перечитать настройки", null, delegate { LoadConfig(false); BuildMenu(); Balloon("Настройки перечитаны", "Движки: " + string.Join(" → ", _chain.EngineNames) + ". OCR: " + (_ocr == null ? "нет" : _ocr.LanguageTag), ToolTipIcon.Info); }); m.Items.Add(new ToolStripSeparator()); m.Items.Add("Выход", null, delegate { ExitThread(); }); _tray.ContextMenuStrip = m; } void OnHotkey(int id) { if (id == HK_SCREEN) Start(false); else if (id == HK_REGION) Start(true); } // ---------------- основной сценарий ---------------- void Start(bool region) { // повторное нажатие при открытом оверлее — закрыть, это же и «отмена» if (_overlay != null && !_overlay.IsDisposed) { _overlay.Close(); return; } if (_busy) return; if (_ocr == null) { Balloon("OCR недоступен", "Нет модели распознавания текста, перевод невозможен.", ToolTipIcon.Error); return; } RunAsync(region); } async void RunAsync(bool region) { _busy = true; var sw = Stopwatch.StartNew(); CapturedFrame frame = null; try { var area = region || string.Equals(_cfg.CaptureScope, "all", StringComparison.OrdinalIgnoreCase) ? Native.VirtualScreen() : Native.MonitorUnderCursor(); frame = ScreenCapture.Capture(area); if (region) { var sel = RegionSelector.Select(frame); if (sel.IsEmpty) { frame.Image.Dispose(); return; } var crop = ScreenCapture.Crop(frame, sel); frame.Image.Dispose(); frame = crop; } _cts = new CancellationTokenSource(); var ct = _cts.Token; _overlay = new OverlayForm(_cfg, frame.Image, frame.Area); _overlay.CancelRequested += delegate { try { _cts.Cancel(); } catch { } }; _overlay.Show(); _overlay.Refresh(); // кадр должен появиться до того, как начнётся распознавание var lines = await _ocr.RecognizeAsync(frame, _cfg.OcrScale); if (ct.IsCancellationRequested || _overlay.IsDisposed) return; var sampler = new FrameSampler(frame); var blocks = Blocks.Group(lines, _cfg.MinChars, _cfg.SkipGarbage, sampler, _cfg.MinWords); if (_cfg.DetectForeign && _ocrOther != null && blocks.Count > 0) { _overlay.SetBlocks(blocks, "Проверка языка…"); var otherLines = await _ocrOther.RecognizeAsync(frame, _cfg.OcrScale); if (ct.IsCancellationRequested || _overlay.IsDisposed) return; int before = blocks.Count; Blocks.DropForeign(blocks, otherLines, Blocks.IsCyrillicLanguage(_cfg.SourceLang)); if (blocks.Count < before) Debug.WriteLine("отброшено как чужой язык: " + (before - blocks.Count)); } if (blocks.Count == 0) { _overlay.SetBlocks(blocks, "Текст не найден"); _overlay.SetStatus("Текст не найден — Esc, чтобы закрыть", false); return; } Blocks.ComputeGrowLimits(blocks, lines, new Size(frame.Width, frame.Height)); foreach (var b in blocks) { Color bg, fg; sampler.Sample(b.Rect, out bg, out fg); b.Bg = bg; b.Fg = fg; } _overlay.SetBlocks(blocks, "Перевод: " + blocks.Count + " фрагм. …"); var sources = blocks.Select(b => b.Source).ToArray(); var translated = await _chain.TranslateAsync(sources, ct); if (ct.IsCancellationRequested || _overlay.IsDisposed) return; int ok = 0; for (int i = 0; i < blocks.Count; i++) { var t = translated[i]; if (string.IsNullOrWhiteSpace(t)) { blocks[i].Failed = true; continue; } blocks[i].Translated = t; ok++; } sw.Stop(); string status = ok > 0 ? string.Format("{0} · {1} из {2} · {3:0.0} с", _chain.LastUsed ?? "?", ok, blocks.Count, sw.ElapsedMilliseconds / 1000.0) : "Не удалось перевести: " + (_chain.LastError ?? "нет доступного движка"); _overlay.Refresh(blocks, status, false); if (ok == 0) Balloon("Перевод не удался", _chain.LastError ?? "все движки недоступны", ToolTipIcon.Error); } catch (Exception ex) { if (_overlay != null && !_overlay.IsDisposed) _overlay.Close(); Balloon("Ошибка", ex.Message, ToolTipIcon.Error); } finally { if (frame != null) frame.Dispose(); if (_cache != null) _cache.Save(); _busy = false; } } async void ProbeEngines() { _tray.Text = "Переводчик экрана — проверяю движки…"; var sb = new StringBuilder(); try { using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(40))) { var list = await _chain.ProbeAsync(cts.Token); foreach (var s in list) sb.AppendLine(string.Format("{0,-16} {1}", s.Name, s.Ok ? "ок, " + s.Ms + " мс" : "не отвечает — " + s.Error)); } } catch (Exception ex) { sb.AppendLine("Проверка прервана: " + ex.Message); } _tray.Text = "Переводчик экрана — " + _cfg.HotkeyTranslate; MessageBox.Show(sb.ToString() + "\nПорядок использования задаётся полем \"chain\" в config.json.", "Доступность переводчиков", MessageBoxButtons.OK, MessageBoxIcon.Information); } void Balloon(string title, string text, ToolTipIcon icon) { if (!_cfg.ShowTrayBalloonOnError && icon != ToolTipIcon.Info) return; try { _tray.BalloonTipTitle = title; _tray.BalloonTipText = text; _tray.BalloonTipIcon = icon; _tray.ShowBalloonTip(6000); } catch { } } /// Иконка рисуется в коде, чтобы приложение оставалось одним файлом без ресурсов. static Icon MakeIcon() { var bmp = new Bitmap(32, 32); using (var g = Graphics.FromImage(bmp)) { g.SmoothingMode = SmoothingMode.AntiAlias; g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; using (var b = new LinearGradientBrush(new Rectangle(0, 0, 32, 32), Color.FromArgb(38, 84, 160), Color.FromArgb(22, 42, 88), 45f)) g.FillEllipse(b, 0, 0, 31, 31); using (var f = new Font("Segoe UI", 15f, FontStyle.Bold, GraphicsUnit.Pixel)) { g.DrawString("A", f, Brushes.White, new PointF(2.5f, 4f)); g.DrawString("Я", f, new SolidBrush(Color.FromArgb(120, 200, 255)), new PointF(13.5f, 12f)); } } IntPtr h = bmp.GetHicon(); try { return (Icon)Icon.FromHandle(h).Clone(); } finally { DestroyIcon(h); bmp.Dispose(); } } [System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true)] static extern bool DestroyIcon(IntPtr handle); protected override void Dispose(bool disposing) { if (disposing) { try { if (_cts != null) _cts.Cancel(); } catch { } if (_cache != null) _cache.Save(); if (_hotkeys != null) _hotkeys.Dispose(); if (_tray != null) { _tray.Visible = false; _tray.Dispose(); } } base.Dispose(disposing); } } /// Невидимое окно, принимающее WM_HOTKEY: глобальные клавиши нужны и когда фокус /// в игре или чужом окне, поэтому обычные события формы тут не годятся. internal class HotkeyWindow : NativeWindow, IDisposable { readonly System.Collections.Generic.List _ids = new System.Collections.Generic.List(); public event Action Pressed; public HotkeyWindow() { CreateHandle(new CreateParams()); } public bool Register(int id, string combo) { uint mods, vk; string display; if (!AppConfig.ParseHotkey(combo, out mods, out vk, out display)) return false; if (!Native.RegisterHotKey(Handle, id, mods | Native.MOD_NOREPEAT, vk)) return false; _ids.Add(id); return true; } public void UnregisterAll() { foreach (var id in _ids) Native.UnregisterHotKey(Handle, id); _ids.Clear(); } protected override void WndProc(ref Message m) { if (m.Msg == Native.WM_HOTKEY) { var h = Pressed; if (h != null) h(m.WParam.ToInt32()); } base.WndProc(ref m); } public void Dispose() { UnregisterAll(); DestroyHandle(); } } }