From c64c7296e2b74bdc39590fbbcbc225619536d79c Mon Sep 17 00:00:00 2001 From: TZGyn Date: Sun, 9 Aug 2026 19:08:12 +0800 Subject: [PATCH] Show screenshot window before OCR; fix signing cert setup --- README.md | 32 ++-- Sources/Kapture/CaptureController.swift | 21 +-- Sources/Kapture/OverlayView.swift | 2 +- Sources/Kapture/ResultWindow.swift | 193 ++++++++++++++++++++---- build-app.sh | 2 +- 5 files changed, 194 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index b6c1461..0dc3dab 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,11 @@ # Kapture -A native macOS menu bar app that captures a screen region and extracts the text -from it using on-device Vision OCR. Press **⌘⇧S**, drag to select an area, and -the recognized text is copied to your clipboard and shown in a floating window. +A native macOS menu bar app that captures a screen region, shows the screenshot +in a window, and extracts text from it on demand with on-device Vision OCR. + +Press **⌘⇧S**, drag to select an area, and a window opens with your screenshot. +Click **Get Text** to recognize the text — it is copied to your clipboard and +shown alongside the image. No network calls, no API keys — everything runs locally. @@ -10,9 +13,12 @@ No network calls, no API keys — everything runs locally. - Global hotkey **⌘⇧S** works from any app - Region selection overlay (all screens, Escape cancels) -- OCR for English, Chinese (simplified/traditional), Japanese, Korean -- Text is auto-copied to the clipboard and shown in a floating window -- Extract text from an existing image file via the menu bar icon +- The screenshot appears in a window (Screenshot / Text tabs) instead of being + instantly OCR'd +- **Get Text** button runs on-device OCR (English, Chinese simplified/traditional, + Japanese, Korean) and auto-copies the result to the clipboard +- **Save PNG…** keeps the screenshot without any OCR +- Open an existing image file (or drag) via the menu bar icon - Menu bar icon with permission status ## Build & run @@ -31,10 +37,12 @@ swift run Kapture # runs from terminal (will appear in Dock) ## First launch 1. Click the menu bar icon → **Grant Screen Recording Permission…** - (required so the app can capture the screen; text extraction from files - works without it). -2. Press **⌘⇧S**, drag to select a region, release. -3. The extracted text lands on your clipboard and in the result window. + (required so the app can capture the screen; opening image files works + without it). +2. Press **⌘⇧S**, drag to select a region, release — the screenshot opens in a + window. +3. Click **Get Text** — the extracted text is copied to your clipboard and + shown in the Text tab. Note: macOS may require re-granting Screen Recording permission after rebuilding the app, since the binary identity changes. @@ -53,7 +61,7 @@ swift run Kapture --test-ocr # renders text, OCRs it, prints PASS/FAIL | Selection | Borderless `NSPanel` overlay per screen, dim + clear-cut rectangle | | Capture | `CGWindowListCreateImage` (retina-aware) | | OCR | Vision `VNRecognizeTextRequest` (accurate, multi-language) | -| UI | SwiftUI `MenuBarExtra` + floating result window | +| UI | SwiftUI `MenuBarExtra` + floating result window (Screenshot/Text tabs) | ## Layout @@ -66,5 +74,5 @@ Sources/Kapture/ OverlayView.swift selection overlay panel + drawing CaptureController.swift selection flow, screen capture, coordinate mapping OCRService.swift Vision text recognition - ResultWindow.swift floating result window + image-file extraction + ResultWindow.swift floating result window (screenshot view, Get Text, Save PNG) ``` diff --git a/Sources/Kapture/CaptureController.swift b/Sources/Kapture/CaptureController.swift index 1f53842..3980b71 100644 --- a/Sources/Kapture/CaptureController.swift +++ b/Sources/Kapture/CaptureController.swift @@ -62,21 +62,16 @@ final class CaptureController: NSObject { guard let image = ScreenCapture.capture(rect) else { isSelecting = false restorePreviousApp() - ResultWindow.shared.show("Capture failed. Check Screen Recording permission.") + Task { @MainActor in + ResultWindow.shared.show(image: ResultWindow.failureImage("Capture failed. Check Screen Recording permission.")) + } return } - OCRService.shared.recognize(image) { [weak self] text in - DispatchQueue.main.async { - self?.isSelecting = false - self?.restorePreviousApp() - if text.isEmpty { - ResultWindow.shared.show("No text found in the selected area.") - } else { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(text, forType: .string) - ResultWindow.shared.show(text) - } + DispatchQueue.main.async { [weak self] in + self?.isSelecting = false + self?.restorePreviousApp() + Task { @MainActor in + ResultWindow.shared.show(image: image) } } } diff --git a/Sources/Kapture/OverlayView.swift b/Sources/Kapture/OverlayView.swift index 6b89d25..a72d9b6 100644 --- a/Sources/Kapture/OverlayView.swift +++ b/Sources/Kapture/OverlayView.swift @@ -97,7 +97,7 @@ final class OverlayView: NSView { let border = NSBezierPath(rect: sel) border.lineWidth = 1.5 - NSColor.systemOrange.setStroke() + NSColor.controlAccentColor.setStroke() border.stroke() let label = "\(Int(sel.width)) × \(Int(sel.height))" diff --git a/Sources/Kapture/ResultWindow.swift b/Sources/Kapture/ResultWindow.swift index fc9f84a..c19ccda 100644 --- a/Sources/Kapture/ResultWindow.swift +++ b/Sources/Kapture/ResultWindow.swift @@ -7,28 +7,25 @@ final class ResultWindow { private var window: NSWindow? - func show(_ text: String) { + @MainActor + func show(image: CGImage) { window?.close() - let hosting = NSHostingController(rootView: ResultView(text: text) { [weak self] in + let model = CaptureModel(image: image) + let hosting = NSHostingController(rootView: ResultView(model: model) { [weak self] in self?.window?.close() }) let w = NSWindow(contentViewController: hosting) - w.title = "Kapture — Extracted Text" + w.title = "Kapture — Screenshot" w.level = .floating w.styleMask = [.titled, .closable, .resizable] - w.setContentSize(NSSize(width: 520, height: 380)) + w.setContentSize(NSSize(width: 694, height: 520)) w.isReleasedWhenClosed = false - - let mouse = NSEvent.mouseLocation - if let screen = NSScreen.screens.first(where: { $0.frame.contains(mouse) }) ?? NSScreen.main { - let x = min(max(mouse.x - screen.frame.minX - 260, screen.frame.minX + 8), screen.frame.maxX - 528) - let y = min(max(mouse.y - screen.frame.minY + 60, screen.frame.minY + 8), screen.frame.maxY - 388) - w.setFrameOrigin(NSPoint(x: x, y: y)) - } + w.center() w.makeKeyAndOrderFront(nil) window = w } + @MainActor func showOpenPanel() { let panel = NSOpenPanel() panel.allowedContentTypes = [.image] @@ -36,40 +33,178 @@ final class ResultWindow { guard panel.runModal() == .OK, let url = panel.url else { return } guard let image = NSImage(contentsOf: url), let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - show("Could not load the selected image.") + self.show(image: Self.failureImage("Could not load the selected image.")) return } - OCRService.shared.recognize(cgImage) { text in - DispatchQueue.main.async { - self.show(text.isEmpty ? "No text found in the image." : text) + show(image: cgImage) + } + + static func failureImage(_ message: String) -> CGImage { + let size = NSSize(width: 800, height: 200) + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0 + ) else { return NSImage(size: size).cgImage(forProposedRect: nil, context: nil, hints: nil)! } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + NSColor.white.setFill() + NSRect(origin: .zero, size: size).fill() + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 30), + .foregroundColor: NSColor.systemRed, + ] + message.draw(at: NSPoint(x: 40, y: 80), withAttributes: attrs) + NSGraphicsContext.restoreGraphicsState() + return rep.cgImage! + } +} + +@MainActor +final class CaptureModel: ObservableObject { + let image: CGImage + + @Published var mode: CaptureMode = .screenshot + @Published var extractedText: String? + @Published var isOCRRunning = false + @Published var statusMessage: String? + + enum CaptureMode { + case screenshot + case text + } + + init(image: CGImage) { + self.image = image + } + + var displayText: String { + if isOCRRunning { + return "Recognizing text…" + } + return extractedText ?? "Text appears here — click “Get Text”." + } + + func runOCR() { + guard !isOCRRunning else { return } + isOCRRunning = true + statusMessage = nil + OCRService.shared.recognize(image) { [weak self] text in + Task { @MainActor in + guard let self else { return } + self.isOCRRunning = false + self.mode = .text + if text.isEmpty { + self.extractedText = "No text found in the screenshot." + } else { + self.extractedText = text + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + self.statusMessage = "Copied to clipboard." + } } } } + + func copyText() { + guard let text = extractedText else { return } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + statusMessage = "Copied to clipboard." + } + + func savePNG() { + let panel = NSSavePanel() + panel.allowedContentTypes = [.png] + panel.nameFieldStringValue = "Kapture-\(Self.timestamp()).png" + guard panel.runModal() == .OK, let url = panel.url else { return } + guard let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) else { + statusMessage = "Could not create PNG file." + return + } + CGImageDestinationAddImage(dest, image, nil) + if CGImageDestinationFinalize(dest) { + statusMessage = "Saved PNG." + } else { + statusMessage = "Could not save PNG." + } + } + + private static func timestamp() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmmss" + return formatter.string(from: Date()) + } } struct ResultView: View { - let text: String + @ObservedObject var model: CaptureModel let onClose: () -> Void var body: some View { - VStack(spacing: 12) { - TextEditor(text: .constant(text)) - .font(.system(.body)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.secondary.opacity(0.4)) - ) + VStack(spacing: 10) { HStack { - Button("Copy to Clipboard") { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(text, forType: .string) + Picker("", selection: $model.mode) { + Text("Screenshot").tag(CaptureModel.CaptureMode.screenshot) + Text("Text").tag(CaptureModel.CaptureMode.text) + if model.isOCRRunning { + Text("…").tag(CaptureModel.CaptureMode.text) + } } + .pickerStyle(.segmented) + .fixedSize() Spacer() + Button("Save PNG…") { model.savePNG() } + Button("Get Text") { model.runOCR() } + .disabled(model.isOCRRunning || model.extractedText != nil || model.mode == .text) Button("Close") { onClose() } } + .padding(.horizontal, 12) + .padding(.top, 10) + + if model.mode == .screenshot { + Image(nsImage: NSImage(cgImage: model.image, size: .zero)) + .resizable() + .aspectRatio(contentMode: .fit) + .border(Color.secondary.opacity(0.4)) + .padding(.horizontal, 12) + } else { + ZStack(alignment: .bottomTrailing) { + ScrollView { + Text(model.displayText) + .font(.system(.body)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary.opacity(0.4)) + ) + + HStack(spacing: 8) { + if let msg = model.statusMessage { + Text(msg) + .font(.caption) + .foregroundColor(.secondary) + } + Button("Copy Text") { model.copyText() } + } + .padding(8) + } + } + + if model.statusMessage != nil && model.mode == .screenshot { + Text(model.statusMessage!) + .font(.caption) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + } } - .padding() - .frame(minWidth: 480, minHeight: 340) + .padding(.bottom, 10) + .frame(minWidth: 480, minHeight: 380) } } diff --git a/build-app.sh b/build-app.sh index e602ea4..00665ec 100755 --- a/build-app.sh +++ b/build-app.sh @@ -8,7 +8,7 @@ BIN=".build/release/Kapture" APP="Kapture.app" CERT="kapture-dev" -if ! security find-identity -v -p codesigning 2>/dev/null | grep -q "\"$CERT\""; then +if ! security find-identity -p codesigning 2>/dev/null | grep -q "kapture-dev"; then echo "Creating self-signed code signing identity '$CERT' (one-time)..." TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT