[Phase L5-2] OCR 화면 텍스트 추출 기능 구현

OcrHandler.cs 신규 생성 (Handlers/, 220줄):
- prefix="ocr" — "화면 영역 텍스트 추출" / "클립보드 이미지 텍스트 추출" 2가지 옵션
- ExecuteRegionOcrAsync(): 180ms 대기 → 전체 화면 캡처 → RegionSelectWindow 오버레이 → 영역 크롭
- ExecuteClipboardOcrAsync(): 클립보드 BitmapSource → System.Drawing.Bitmap 변환 → OCR
- RunOcrOnBitmapAsync(): Bitmap → 임시 PNG → WinStorageFile → WinBitmapDecoder → WinOcrEngine.RecognizeAsync()
  (기존 ClipboardHistoryService.ImageCache.cs 패턴 재사용, 5,000자 상한)
- HandleOcrResult(): 클립보드 복사 + 런처 입력창 자동 채움 + 완료 알림

App.xaml.cs:
- OcrHandler 등록 (Phase L5-2 주석)

LauncherWindow.Keyboard.cs:
- F4 키: 런처 Hide → OcrHandler.__ocr_region__ 즉시 실행 (백그라운드 Task)

HelpDetailWindow.Shortcuts.cs:
- F4 단축키 항목 추가 (F3 바로 다음, 아이콘 \uE8D2, 색상 #0F766E)

HelpDetailWindow.xaml.cs:
- "런처 탐색" 카테고리에 F4/OCR 항목 추가

Services/L10n.cs:
- Phase L5 신기능 팁 5종 추가 (OCR F4, ocr 예약어, 전용 핫키 설정, hotkey 예약어, OCR 입력창 채움)

docs/LAUNCHER_ROADMAP.md:
- L5-2  완료 표시

빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 12:19:13 +09:00
parent c12e863e3a
commit ce02343624
7 changed files with 309 additions and 1 deletions

View File

@@ -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;
/// <summary>
/// L5-2: 화면 텍스트 OCR 추출 핸들러.
/// 예: ocr → 옵션 목록 (영역 선택 / 클립보드 이미지)
/// ocr region → 드래그 영역 선택 후 텍스트 추출
/// ocr clip → 클립보드 이미지에서 텍스트 추출
/// 결과 텍스트는 클립보드에 복사되고 런처 입력창에 채워집니다.
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>
{
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<IEnumerable<LauncherItem>>(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<string?> 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<Views.LauncherWindow>().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;
}
}