From d6d5f518d08ea548a5a693e079ef4f54a0c7c7b4 Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 10:18:45 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=20L2-4/L2-5]=20=ED=81=B4=EB=A6=BD?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20OCR=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20+=20Ctrl+Click=20=EB=8B=A4=EC=A4=91=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase L2-4: 클립보드 이미지 OCR 텍스트 추출 및 검색 - AxCopilot.csproj: TFM net8.0-windows → net8.0-windows10.0.17763.0 (Windows OCR API 활성화) - ClipboardEntry: OcrText 프로퍼티 추가 (set), Preview → OCR 텍스트 우선 표시 (72자 상한) - SavedClipEntry: OcrText 직렬화 필드 추가, BuildSnapshot/LoadHistory 연동 - ClipboardHistoryService.OnClipboardUpdate: 이미지 저장 후 백그라운드 OCR 트리거 (EnableOcrSearch 설정 체크, capturedEntry.OcrText 비동기 갱신) - ClipboardHistoryService.ImageCache.cs: ExtractOcrTextAsync() 추가 (WinRT BitmapDecoder → SoftwareBitmap → OcrEngine.RecognizeAsync, 5,000자 상한) WinRT 별칭(WinBitmapDecoder, WinSoftwareBitmap 등) 으로 WPF 네임스페이스 충돌 방지 - AppSettings.Models.cs: ClipboardHistorySettings.EnableOcrSearch (default=true) - ClipboardHistoryHandler.GetItemsAsync: OcrText 포함 검색, 'OCR ·' 표시 배지 Phase L2-5: Ctrl+Click 클립보드 항목 다중 선택 - LauncherWindow.Shell.cs: ResultList_PreviewMouseLeftButtonUp에 Ctrl+Click 분기 추가 (IsClipboardMode + Ctrl 조합 시 ToggleMergeItem 호출, 기존 단일 선택 흐름 유지) - LauncherWindow.ShortcutHelp.cs: Ctrl+Click / Shift+↑↓ / 병합 단축키 도움말 추가 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 --- docs/LAUNCHER_ROADMAP.md | 4 +- src/AxCopilot/AxCopilot.csproj | 2 +- .../Handlers/ClipboardHistoryHandler.cs | 10 +++- src/AxCopilot/Models/AppSettings.Models.cs | 4 ++ .../ClipboardHistoryService.ImageCache.cs | 58 +++++++++++++++++++ .../Services/ClipboardHistoryService.cs | 39 ++++++++++++- src/AxCopilot/Views/LauncherWindow.Shell.cs | 8 +++ .../Views/LauncherWindow.ShortcutHelp.cs | 3 + 8 files changed, 120 insertions(+), 8 deletions(-) diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 2ce4171..1c26126 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -73,8 +73,8 @@ | L2-1 | **클립보드 이미지 원본 해상도 보존** | 원본 PNG를 `clipboard_images/` 캐시 폴더에 저장, 썸네일(80px)은 표시용으로만 사용. 캐시 정리 정책 (30일/500MB). Enter 복원 시 원본 해상도로 클립보드 복사 | ✅ 완료 | | L2-2 | **Shift+Enter 실행 시 자동 클립보드 복사** | Shift+Enter로 외부 뷰어 열기 전에 해당 항목을 시스템 클립보드에 자동 복사. 텍스트/이미지(원본 해상도) 모두 지원 | ✅ 완료 | | ✅ L2-3 | **클립보드 이미지 미리보기 창** | `#` 이미지 항목에서 Shift+Enter → `ClipboardImagePreviewWindow`. 원본 해상도 표시, Ctrl+휠/+−/0/F 줌, PNG·JPEG·BMP 저장, Ctrl+C 복사 | 중간 | -| L2-4 | **클립보드 검색 강화** | 이미지 OCR 텍스트 추출 → 텍스트 기반 이미지 검색. Windows OCR API (로컬) 활용 | 중간 | -| L2-5 | **클립보드 항목 병합** | 여러 텍스트 항목을 선택하여 하나로 병합 (줄바꿈 구분). Ctrl+Click 다중 선택 | 낮음 | +| ✅ L2-4 | **클립보드 검색 강화** | 이미지 OCR 텍스트 추출 → 텍스트 기반 이미지 검색. Windows OCR API (로컬) 활용. `OcrText` 필드, `ExtractOcrTextAsync()`, TFM `net8.0-windows10.0.17763.0` | 중간 | +| ✅ L2-5 | **클립보드 항목 병합** | Ctrl+Click 마우스 다중 선택 추가 (Shift+↑/↓ 키보드 선택은 기존 구현). `ResultList_PreviewMouseLeftButtonUp` 분기 | 낮음 | ### Phase L2 추가 완료 (v1.5.3) diff --git a/src/AxCopilot/AxCopilot.csproj b/src/AxCopilot/AxCopilot.csproj index 939ea12..3ade69a 100644 --- a/src/AxCopilot/AxCopilot.csproj +++ b/src/AxCopilot/AxCopilot.csproj @@ -1,7 +1,7 @@ WinExe - net8.0-windows + net8.0-windows10.0.17763.0 enable enable true diff --git a/src/AxCopilot/Handlers/ClipboardHistoryHandler.cs b/src/AxCopilot/Handlers/ClipboardHistoryHandler.cs index da3b2ae..136fdf7 100644 --- a/src/AxCopilot/Handlers/ClipboardHistoryHandler.cs +++ b/src/AxCopilot/Handlers/ClipboardHistoryHandler.cs @@ -72,9 +72,11 @@ public class ClipboardHistoryHandler : IActionHandler else if (catFilter != null) filtered = filtered.Where(e => e.Category == catFilter); - // 텍스트 검색 + // 텍스트 검색 — 텍스트 내용, Preview, OCR 추출 텍스트 모두 포함 (Phase L2-4) if (!string.IsNullOrEmpty(q)) - filtered = filtered.Where(e => e.Preview.ToLowerInvariant().Contains(q)); + filtered = filtered.Where(e => + e.Preview.ToLowerInvariant().Contains(q) || + (e.OcrText != null && e.OcrText.ToLowerInvariant().Contains(q))); // 핀 항목을 상단에 배치 var sorted = filtered @@ -86,9 +88,11 @@ public class ClipboardHistoryHandler : IActionHandler { var pinMark = e.IsPinned ? "\uD83D\uDCCC " : ""; var catMark = e.Category != "일반" ? $"[{e.Category}] " : ""; + // 이미지 항목에 OCR 인덱스 완료 표시 + var ocrMark = !e.IsText && e.OcrText != null ? "OCR · " : ""; return new LauncherItem( $"{pinMark}{e.Preview}", - $"{catMark}{e.RelativeTime} · {e.CopiedAt:MM/dd HH:mm}", + $"{catMark}{ocrMark}{e.RelativeTime} · {e.CopiedAt:MM/dd HH:mm}", null, e, Symbol: e.IsPinned ? Symbols.Favorite : (e.IsText ? Symbols.History : Symbols.Picture)); diff --git a/src/AxCopilot/Models/AppSettings.Models.cs b/src/AxCopilot/Models/AppSettings.Models.cs index 75ac1ce..71e204f 100644 --- a/src/AxCopilot/Models/AppSettings.Models.cs +++ b/src/AxCopilot/Models/AppSettings.Models.cs @@ -189,6 +189,10 @@ public class ClipboardHistorySettings @"^\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}$", // 카드번호 @"^(?:\d{1,3}\.){3}\d{1,3}$" // IP 주소 }; + + /// 이미지 클립보드 항목에서 OCR 텍스트를 자동 추출하여 검색에 활용 (Windows OCR, 로컬 처리) + [JsonPropertyName("enableOcrSearch")] + public bool EnableOcrSearch { get; set; } = true; } // ─── 시스템 명령 ────────────────────────────────────────────────────────────── diff --git a/src/AxCopilot/Services/ClipboardHistoryService.ImageCache.cs b/src/AxCopilot/Services/ClipboardHistoryService.ImageCache.cs index 97f0ec9..4c071ab 100644 --- a/src/AxCopilot/Services/ClipboardHistoryService.ImageCache.cs +++ b/src/AxCopilot/Services/ClipboardHistoryService.ImageCache.cs @@ -9,6 +9,13 @@ using System.Windows; using System.Windows.Interop; using System.Windows.Media.Imaging; using AxCopilot.Models; +// WinRT OCR — 별칭으로 WPF Media.Imaging 네임스페이스 충돌 방지 +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.Services; @@ -122,4 +129,55 @@ public partial class ClipboardHistoryService } catch (Exception) { return null; } } + + // ─── Phase L2-4: OCR 텍스트 추출 ───────────────────────────────────────── + + /// + /// Windows OCR API를 사용하여 이미지 파일에서 텍스트를 추출합니다. + /// 로컬 처리 전용 (인터넷 연결 불필요). 사용자 언어 설정에 맞는 OCR 엔진 사용. + /// + internal static async Task ExtractOcrTextAsync(string imagePath) + { + try + { + if (!File.Exists(imagePath)) return null; + + // 사용자 프로파일 언어 기반 OCR 엔진 생성 (한국어/영어 등 시스템 설정 따름) + var engine = WinOcrEngine.TryCreateFromUserProfileLanguages(); + if (engine == null) return null; + + // Windows Storage API로 이미지 파일 로드 + var storageFile = await WinStorageFile.GetFileFromPathAsync(imagePath); + using var stream = await storageFile.OpenAsync(WinFileAccessMode.Read); + var decoder = await WinBitmapDecoder.CreateAsync(stream); + + // OCR은 Bgra8 포맷을 요구하므로 변환 + 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(); + + // OCR 결과 5,000자 상한 (전체 문서 스캔 대응) + 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; + } + } } diff --git a/src/AxCopilot/Services/ClipboardHistoryService.cs b/src/AxCopilot/Services/ClipboardHistoryService.cs index bc94c43..d323b12 100644 --- a/src/AxCopilot/Services/ClipboardHistoryService.cs +++ b/src/AxCopilot/Services/ClipboardHistoryService.cs @@ -105,6 +105,7 @@ public partial class ClipboardHistoryService : IDisposable OriginalImagePath = entry.OriginalImagePath, IsPinned = entry.IsPinned, Category = entry.Category, + OcrText = entry.OcrText, }; _history.Insert(0, updated); } @@ -218,6 +219,7 @@ public partial class ClipboardHistoryService : IDisposable OriginalImagePath = s.OriginalImagePath, IsPinned = s.IsPinned, Category = s.Category, + OcrText = s.OcrText, }); continue; } @@ -300,6 +302,7 @@ public partial class ClipboardHistoryService : IDisposable OriginalImagePath = e.OriginalImagePath, IsPinned = e.IsPinned, Category = e.Category, + OcrText = e.OcrText, }; }).ToList(); } @@ -316,6 +319,8 @@ public partial class ClipboardHistoryService : IDisposable public string? OriginalImagePath { get; set; } public bool IsPinned { get; set; } public string Category { get; set; } = "일반"; + /// 이미지에서 추출된 OCR 텍스트 (텍스트 항목은 null). + public string? OcrText { get; set; } } // ─── 내부 ────────────────────────────────────────────────────────────── @@ -380,6 +385,21 @@ public partial class ClipboardHistoryService : IDisposable Image = thumb, OriginalImagePath = originalPath, }; + + // Phase L2-4: OCR 텍스트 추출 (백그라운드, 로컬 처리) + if (_settings.Settings.ClipboardHistory.EnableOcrSearch && originalPath != null) + { + var capturedEntry = entry; + _ = Task.Run(async () => + { + var ocrText = await ExtractOcrTextAsync(originalPath).ConfigureAwait(false); + if (!string.IsNullOrEmpty(ocrText)) + { + capturedEntry.OcrText = ocrText; + _ = SaveHistoryAsync(); + } + }); + } } if (entry == null) return; @@ -439,15 +459,30 @@ public record ClipboardEntry(string Text, DateTime CopiedAt) /// 카테고리 (URL, 코드, 경로, 일반) public string Category { get; set; } = "일반"; + /// + /// Windows OCR API로 추출한 이미지 텍스트 (백그라운드 처리, null이면 미처리 또는 추출 실패). + /// 이미지 항목의 텍스트 기반 검색에 활용됩니다. + /// + public string? OcrText { get; set; } + /// 텍스트 항목 여부 public bool IsText => Image == null; - /// UI 표시용 첫 줄 미리보기 (최대 80자) + /// UI 표시용 첫 줄 미리보기 (최대 80자). 이미지의 경우 OCR 텍스트 우선 표시. public string Preview { get { - if (!IsText) return "[이미지]"; + if (!IsText) + { + // OCR 텍스트가 있으면 첫 줄 미리보기로 표시 + if (!string.IsNullOrWhiteSpace(OcrText)) + { + var ocrLine = OcrText.Replace("\r\n", " ").Replace("\n", " ").Replace("\r", " ").Trim(); + return ocrLine.Length > 72 ? ocrLine[..69] + "…" : ocrLine; + } + return "[이미지]"; + } var line = Text.Replace("\r\n", "↵ ").Replace("\n", "↵ ").Replace("\r", "↵ "); return line.Length > 80 ? line[..77] + "…" : line; } diff --git a/src/AxCopilot/Views/LauncherWindow.Shell.cs b/src/AxCopilot/Views/LauncherWindow.Shell.cs index 2f96728..08dbc80 100644 --- a/src/AxCopilot/Views/LauncherWindow.Shell.cs +++ b/src/AxCopilot/Views/LauncherWindow.Shell.cs @@ -125,6 +125,14 @@ public partial class LauncherWindow var clickedItem = lvi.Content as SDK.LauncherItem; if (clickedItem == null) return; + // Phase L2-5: Ctrl+Click → 클립보드 히스토리 모드에서 다중 선택 토글 + if ((Keyboard.Modifiers & ModifierKeys.Control) != 0 && _vm.IsClipboardMode) + { + _vm.ToggleMergeItem(clickedItem); + e.Handled = true; + return; + } + var now = DateTime.UtcNow; var timeSinceLastClick = (now - _lastClickTime).TotalMilliseconds; diff --git a/src/AxCopilot/Views/LauncherWindow.ShortcutHelp.cs b/src/AxCopilot/Views/LauncherWindow.ShortcutHelp.cs index de11d9d..cf27851 100644 --- a/src/AxCopilot/Views/LauncherWindow.ShortcutHelp.cs +++ b/src/AxCopilot/Views/LauncherWindow.ShortcutHelp.cs @@ -42,6 +42,9 @@ public partial class LauncherWindow "Ctrl+Enter 관리자 실행", "Alt+Enter 속성 보기", "Shift+Enter 대형 텍스트 / 이미지 미리보기 (#)", + "Ctrl+Click 클립보드 항목 다중 선택 (#)", + "Shift+↑/↓ 클립보드 항목 선택/해제 (#)", + "Shift+Enter 선택 항목 병합 (MergeCount > 0)", }; CustomMessageBox.Show(