diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 6d9f87b..bda5c62 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -130,7 +130,7 @@ | # | 기능 | 설명 | 우선순위 | |---|------|------|----------| | L5-1 | **항목별 전용 핫키** ✅ | 앱·URL·폴더에 `Ctrl+Alt+숫자` 등 글로벌 단축키 직접 할당. `hotkey` 프리픽스로 관리. `HotkeyAssignment` 모델 + `InputListener` 확장 + 설정창 "전용 핫키" 탭 | 높음 | -| L5-2 | **OCR 화면 텍스트 추출** | `ocr` 명령 또는 캡처에서 F4 → 화면 영역 드래그 → Windows OCR(로컬) 추출 → 클립보드 복사 / 런처 입력 | 높음 | +| L5-2 | **OCR 화면 텍스트 추출** ✅ | `ocr` 프리픽스 + F4 글로벌 단축키. RegionSelectWindow 재사용, Windows.Media.Ocr 로컬 엔진. 결과 → 클립보드 복사 + 런처 입력창 자동 채움 | 높음 | | L5-3 | **QuickLook 인라인 편집** | F3 미리보기에서 텍스트·마크다운 파일 직접 편집 + Ctrl+S 저장. 변경 감지(수정 표시 `●`), Esc 취소 | 중간 | | L5-4 | **앱 세션 스냅** | 여러 앱을 지정 레이아웃으로 한번에 열기. `snap 세션이름` → 등록된 앱 목록을 각 레이아웃에 배치 | 중간 | | L5-5 | **배치 파일 이름 변경** | 다중 선택 후 `rename {패턴}` → 넘버링·날짜·정규식 치환 미리보기 → 일괄 적용 | 중간 | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 5bb1e58..7e4e9a3 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -180,6 +180,8 @@ public partial class App : System.Windows.Application // ─── Phase L5 핸들러 ────────────────────────────────────────────────── // Phase L5-1: 전용 핫키 목록 관리 (prefix=hotkey) commandResolver.RegisterHandler(new HotkeyHandler(settings)); + // Phase L5-2: OCR 화면 텍스트 추출 (prefix=ocr) + commandResolver.RegisterHandler(new OcrHandler()); // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); diff --git a/src/AxCopilot/Handlers/OcrHandler.cs b/src/AxCopilot/Handlers/OcrHandler.cs new file mode 100644 index 0000000..e1ee6f6 --- /dev/null +++ b/src/AxCopilot/Handlers/OcrHandler.cs @@ -0,0 +1,264 @@ +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +using WinBitmapDecoder = Windows.Graphics.Imaging.BitmapDecoder; +using WinBitmapPixelFmt = Windows.Graphics.Imaging.BitmapPixelFormat; +using WinSoftwareBitmap = Windows.Graphics.Imaging.SoftwareBitmap; +using WinOcrEngine = Windows.Media.Ocr.OcrEngine; +using WinStorageFile = Windows.Storage.StorageFile; +using WinFileAccessMode = Windows.Storage.FileAccessMode; + +namespace AxCopilot.Handlers; + +/// +/// L5-2: 화면 텍스트 OCR 추출 핸들러. +/// 예: ocr → 옵션 목록 (영역 선택 / 클립보드 이미지) +/// ocr region → 드래그 영역 선택 후 텍스트 추출 +/// ocr clip → 클립보드 이미지에서 텍스트 추출 +/// 결과 텍스트는 클립보드에 복사되고 런처 입력창에 채워집니다. +/// +public class OcrHandler : IActionHandler +{ + public string? Prefix => "ocr"; + + public PluginMetadata Metadata => new( + "OcrExtractor", + "화면 텍스트 추출 (OCR)", + "1.0", + "AX"); + + private const string DataRegion = "__ocr_region__"; + private const string DataClipboard = "__ocr_clipboard__"; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + + var items = new List + { + new LauncherItem( + "화면 영역 텍스트 추출", + "드래그로 영역을 선택하면 텍스트를 자동으로 인식합니다 · F4 단축키 지원", + null, DataRegion, + Symbol: "\uE8D2"), + + new LauncherItem( + "클립보드 이미지 텍스트 추출", + "클립보드에 복사된 이미지에서 텍스트를 인식합니다", + null, DataClipboard, + Symbol: "\uE77F") + }; + + // 쿼리 필터링 + if (!string.IsNullOrEmpty(q)) + { + items = items.Where(i => + i.Title.Contains(q, StringComparison.OrdinalIgnoreCase) || + i.Subtitle.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + // OCR 미지원 안내 + if (WinOcrEngine.TryCreateFromUserProfileLanguages() == null) + { + items.Clear(); + items.Add(new LauncherItem( + "OCR 기능을 사용할 수 없습니다", + "Windows 설정 → 언어에서 OCR 지원 언어 팩을 설치하세요", + null, null, + Symbol: Symbols.Info)); + } + + return Task.FromResult>(items); + } + + public async Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data as string) + { + case DataRegion: + await ExecuteRegionOcrAsync(ct); + break; + case DataClipboard: + await ExecuteClipboardOcrAsync(ct); + break; + } + } + + // ─── 영역 선택 OCR ─────────────────────────────────────────────────────── + + private static async Task ExecuteRegionOcrAsync(CancellationToken ct) + { + // 런처가 완전히 사라질 때까지 대기 + await Task.Delay(180, ct); + + System.Drawing.Rectangle? selected = null; + Bitmap? fullBmp = null; + + // UI 스레드에서 오버레이 창 표시 + await Application.Current.Dispatcher.InvokeAsync(() => + { + var bounds = GetAllScreenBounds(); + fullBmp = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppArgb); + using var g = Graphics.FromImage(fullBmp); + g.CopyFromScreen(bounds.X, bounds.Y, 0, 0, bounds.Size, CopyPixelOperation.SourceCopy); + + var overlay = new Views.RegionSelectWindow(fullBmp, bounds); + overlay.ShowDialog(); + selected = overlay.SelectedRect; + }); + + if (selected == null || selected.Value.Width < 8 || selected.Value.Height < 8) + { + fullBmp?.Dispose(); + NotificationService.Notify("AX Copilot", "영역 선택이 취소되었습니다."); + return; + } + + // 선택 영역 크롭 + var r = selected.Value; + using var crop = new Bitmap(r.Width, r.Height, PixelFormat.Format32bppArgb); + using (var cg = Graphics.FromImage(crop)) + cg.DrawImage(fullBmp!, new System.Drawing.Rectangle(0, 0, r.Width, r.Height), r, System.Drawing.GraphicsUnit.Pixel); + fullBmp?.Dispose(); + + // OCR 실행 + var text = await RunOcrOnBitmapAsync(crop); + + // 결과 처리 + HandleOcrResult(text, $"{r.Width}×{r.Height} 영역"); + } + + // ─── 클립보드 이미지 OCR ───────────────────────────────────────────────── + + private static async Task ExecuteClipboardOcrAsync(CancellationToken ct) + { + Bitmap? clipBmp = null; + + await Application.Current.Dispatcher.InvokeAsync(() => + { + if (Clipboard.ContainsImage()) + { + var src = Clipboard.GetImage(); + if (src != null) + { + // BitmapSource → System.Drawing.Bitmap 변환 + var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder(); + encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(src)); + using var ms = new MemoryStream(); + encoder.Save(ms); + ms.Position = 0; + clipBmp = new Bitmap(ms); + } + } + }); + + if (clipBmp == null) + { + NotificationService.Notify("AX Copilot — OCR", "클립보드에 이미지가 없습니다."); + return; + } + + using (clipBmp) + { + var text = await RunOcrOnBitmapAsync(clipBmp); + HandleOcrResult(text, "클립보드 이미지"); + } + } + + // ─── 공통: Bitmap → OCR ───────────────────────────────────────────────── + + private static async Task RunOcrOnBitmapAsync(Bitmap bmp) + { + var engine = WinOcrEngine.TryCreateFromUserProfileLanguages(); + if (engine == null) return null; + + // Bitmap을 임시 PNG로 저장 + var tmpPath = Path.Combine(Path.GetTempPath(), $"axocr_{Guid.NewGuid():N}.png"); + try + { + bmp.Save(tmpPath, ImageFormat.Png); + + var storageFile = await WinStorageFile.GetFileFromPathAsync(tmpPath); + using var stream = await storageFile.OpenAsync(WinFileAccessMode.Read); + var decoder = await WinBitmapDecoder.CreateAsync(stream); + + WinSoftwareBitmap? origBitmap = null; + WinSoftwareBitmap? ocrBitmap = null; + try + { + origBitmap = await decoder.GetSoftwareBitmapAsync(); + ocrBitmap = origBitmap.BitmapPixelFormat == WinBitmapPixelFmt.Bgra8 + ? origBitmap + : WinSoftwareBitmap.Convert(origBitmap, WinBitmapPixelFmt.Bgra8); + + var result = await engine.RecognizeAsync(ocrBitmap); + var text = result.Text?.Trim(); + if (text?.Length > 5_000) text = text[..5_000]; + return string.IsNullOrWhiteSpace(text) ? null : text; + } + finally + { + if (!ReferenceEquals(origBitmap, ocrBitmap)) origBitmap?.Dispose(); + ocrBitmap?.Dispose(); + } + } + catch (Exception ex) + { + LogService.Warn($"OCR 실행 오류: {ex.Message}"); + return null; + } + finally + { + try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { /* ignore */ } + } + } + + // ─── 공통: 결과 처리 ──────────────────────────────────────────────────── + + private static void HandleOcrResult(string? text, string source) + { + if (string.IsNullOrWhiteSpace(text)) + { + NotificationService.Notify("OCR 완료", $"{source}에서 텍스트를 인식하지 못했습니다."); + return; + } + + // 클립보드에 복사 + Application.Current?.Dispatcher.Invoke(() => + { + Clipboard.SetText(text); + }); + + // 런처를 다시 열고 결과 텍스트를 입력창에 채움 + Application.Current?.Dispatcher.BeginInvoke(() => + { + var launcher = Application.Current?.Windows + .OfType().FirstOrDefault(); + if (launcher != null) + { + launcher.SetInputText(text.Length > 200 ? text[..200] : text); + launcher.Show(); + } + }, System.Windows.Threading.DispatcherPriority.Background); + + // 완료 알림 + var preview = text.Length > 60 ? text[..57].Replace('\n', ' ') + "…" : text.Replace('\n', ' '); + NotificationService.Notify("OCR 완료", $"클립보드 복사됨: {preview}"); + LogService.Info($"OCR 성공 ({source}, {text.Length}자)"); + } + + // ─── 헬퍼 ─────────────────────────────────────────────────────────────── + + private static System.Drawing.Rectangle GetAllScreenBounds() + { + var bounds = System.Drawing.Rectangle.Empty; + foreach (System.Windows.Forms.Screen screen in System.Windows.Forms.Screen.AllScreens) + bounds = System.Drawing.Rectangle.Union(bounds, screen.Bounds); + return bounds; + } +} diff --git a/src/AxCopilot/Services/L10n.cs b/src/AxCopilot/Services/L10n.cs index 7f52b18..8720bf1 100644 --- a/src/AxCopilot/Services/L10n.cs +++ b/src/AxCopilot/Services/L10n.cs @@ -194,6 +194,13 @@ public static class L10n "= 20km 처럼 입력하면 킬로미터를 자동으로 변환 제안해 드립니다!", "= 100°F 를 입력하면 섭씨·켈빈으로 자동 변환됩니다.", "= today+30d 로 30일 후 날짜를 바로 계산할 수 있습니다.", + + // ── Phase L5 신기능 안내 (OCR·전용 핫키) ── + "F4 키로 화면 드래그 영역의 텍스트를 즉시 추출할 수 있습니다!", + "'ocr' 을 입력하면 화면 또는 클립보드 이미지에서 텍스트를 인식합니다.", + "자주 쓰는 파일·앱에 Ctrl+Alt+숫자 전용 핫키를 설정해보세요. 설정 → 전용 핫키", + "'hotkey' 를 입력하면 등록된 전용 핫키 목록을 확인하고 바로 실행할 수 있습니다.", + "OCR로 이미지 속 텍스트를 추출하면 입력창에 자동으로 채워집니다!", ]; private static readonly string[] _enPlaceholders = diff --git a/src/AxCopilot/Views/HelpDetailWindow.Shortcuts.cs b/src/AxCopilot/Views/HelpDetailWindow.Shortcuts.cs index 5af8e49..c36f4ef 100644 --- a/src/AxCopilot/Views/HelpDetailWindow.Shortcuts.cs +++ b/src/AxCopilot/Views/HelpDetailWindow.Shortcuts.cs @@ -68,6 +68,10 @@ public partial class HelpDetailWindow "파일 빠른 미리보기 창 열기/닫기 (QuickLook)", "선택된 파일의 미리보기 창을 토글합니다. 이미지·텍스트·코드(줄번호+색상)·PDF(텍스트 추출)·Word·Excel 미리보기를 지원합니다. 다시 F3을 누르면 창이 닫힙니다.", "\uE8A4", "#6B2C91")); + items.Add(MakeShortcut("런처 기능", "F4", + "화면 영역 텍스트 추출 (OCR)", + "런처를 닫고 화면 드래그 영역 선택 모드를 즉시 실행합니다. 선택한 영역의 텍스트를 자동으로 인식해 클립보드에 복사하고 런처 입력창에 채웁니다.", + "\uE8D2", "#0F766E")); items.Add(MakeShortcut("런처 기능", "F1", "도움말 창 열기", "이 화면을 직접 엽니다. 'help' 를 입력하는 것과 동일합니다.", diff --git a/src/AxCopilot/Views/HelpDetailWindow.xaml.cs b/src/AxCopilot/Views/HelpDetailWindow.xaml.cs index 851c74b..5215200 100644 --- a/src/AxCopilot/Views/HelpDetailWindow.xaml.cs +++ b/src/AxCopilot/Views/HelpDetailWindow.xaml.cs @@ -150,6 +150,15 @@ public partial class HelpDetailWindow : Window ColorBrush = ParseColor("#6B2C91") }, new() + { + Category = "런처 탐색", Command = "F4", + Title = "화면 영역 텍스트 추출 (OCR)", + Description = "F4를 누르면 런처가 닫히고 화면 드래그 선택 모드가 즉시 실행됩니다. 원하는 영역을 드래그해 선택하면 내부 텍스트를 자동으로 인식해 클립보드에 복사하고 런처 입력창에 채웁니다. 'ocr' 예약어로도 실행할 수 있으며 클립보드 이미지도 지원합니다.", + Example = "F4 또는 ocr → Enter", + Symbol = "\uE8D2", + ColorBrush = ParseColor("#0F766E") + }, + new() { Category = "런처 탐색", Command = "↑ / ↓ (입력 없을 때)", Title = "검색 히스토리 탐색", diff --git a/src/AxCopilot/Views/LauncherWindow.Keyboard.cs b/src/AxCopilot/Views/LauncherWindow.Keyboard.cs index e3c23cf..796d2c9 100644 --- a/src/AxCopilot/Views/LauncherWindow.Keyboard.cs +++ b/src/AxCopilot/Views/LauncherWindow.Keyboard.cs @@ -500,6 +500,28 @@ public partial class LauncherWindow return; } + // ─── F4 → 화면 영역 OCR 즉시 실행 ────────────────────────────────── + if (e.Key == Key.F4) + { + Hide(); + _ = Task.Run(async () => + { + try + { + var handler = new Handlers.OcrHandler(); + var item = new SDK.LauncherItem( + "화면 영역 텍스트 추출", "", null, "__ocr_region__"); + await handler.ExecuteAsync(item, CancellationToken.None); + } + catch (Exception ex) + { + Services.LogService.Error($"F4 OCR 실행 오류: {ex.Message}"); + } + }); + e.Handled = true; + return; + } + // ─── Ctrl+1~9 → n번째 결과 즉시 실행 ─────────────────────────────── if (mod == ModifierKeys.Control) {