[Phase L2-4/L2-5] 클립보드 OCR 이미지 검색 + Ctrl+Click 다중 선택

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 10:18:45 +09:00
parent e78da8eb81
commit d6d5f518d0
8 changed files with 120 additions and 8 deletions

View File

@@ -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)

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net8.0-windows10.0.17763.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>

View File

@@ -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));

View File

@@ -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 주소
};
/// <summary>이미지 클립보드 항목에서 OCR 텍스트를 자동 추출하여 검색에 활용 (Windows OCR, 로컬 처리)</summary>
[JsonPropertyName("enableOcrSearch")]
public bool EnableOcrSearch { get; set; } = true;
}
// ─── 시스템 명령 ──────────────────────────────────────────────────────────────

View File

@@ -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 텍스트 추출 ─────────────────────────────────────────
/// <summary>
/// Windows OCR API를 사용하여 이미지 파일에서 텍스트를 추출합니다.
/// 로컬 처리 전용 (인터넷 연결 불필요). 사용자 언어 설정에 맞는 OCR 엔진 사용.
/// </summary>
internal static async Task<string?> 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;
}
}
}

View File

@@ -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; } = "일반";
/// <summary>이미지에서 추출된 OCR 텍스트 (텍스트 항목은 null).</summary>
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)
/// <summary>카테고리 (URL, 코드, 경로, 일반)</summary>
public string Category { get; set; } = "일반";
/// <summary>
/// Windows OCR API로 추출한 이미지 텍스트 (백그라운드 처리, null이면 미처리 또는 추출 실패).
/// 이미지 항목의 텍스트 기반 검색에 활용됩니다.
/// </summary>
public string? OcrText { get; set; }
/// <summary>텍스트 항목 여부</summary>
public bool IsText => Image == null;
/// <summary>UI 표시용 첫 줄 미리보기 (최대 80자)</summary>
/// <summary>UI 표시용 첫 줄 미리보기 (최대 80자). 이미지의 경우 OCR 텍스트 우선 표시.</summary>
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;
}

View File

@@ -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;

View File

@@ -42,6 +42,9 @@ public partial class LauncherWindow
"Ctrl+Enter 관리자 실행",
"Alt+Enter 속성 보기",
"Shift+Enter 대형 텍스트 / 이미지 미리보기 (#)",
"Ctrl+Click 클립보드 항목 다중 선택 (#)",
"Shift+↑/↓ 클립보드 항목 선택/해제 (#)",
"Shift+Enter 선택 항목 병합 (MergeCount > 0)",
};
CustomMessageBox.Show(