Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions Sources/Stag/Capture/TextRecognition.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Foundation

/// What an OCR pass produced, once the per-observation strings have been pulled
/// out of Vision.
enum OCROutcome: Equatable {
case noText
case copied(text: String, lineCount: Int)
}

/// What a barcode/QR scan produced, once payload strings have been pulled out of
/// Vision. `text` is always the clipboard text (all payloads joined); `url` is
/// set only when the first payload parses as an openable URL.
enum BarcodeOutcome: Equatable {
case none
case found(text: String, count: Int, url: URL?, firstPayload: String)
}

/// Pure post-processing for Vision text/barcode results, lifted out of the OCR
/// closures in CaptureManager and EditorView so the classification rules are
/// testable without Vision.
enum TextRecognition {

/// Joins recognized OCR lines and classifies the outcome. `lines` are the
/// per-observation strings already pulled from Vision.
static func ocrOutcome(from lines: [String]) -> OCROutcome {
let joined = lines.joined(separator: "\n")
guard !joined.isEmpty else { return .noText }
return .copied(text: joined, lineCount: joined.components(separatedBy: "\n").count)
}

/// Classifies decoded barcode payloads. When the first payload parses as a
/// URL with a scheme, the URL is surfaced so the caller can offer to open it;
/// the clipboard text is always every payload joined by newlines.
static func barcodeOutcome(from payloads: [String]) -> BarcodeOutcome {
guard let first = payloads.first else { return .none }
let combined = payloads.joined(separator: "\n")
let url: URL? = {
guard let candidate = URL(string: first), candidate.scheme != nil else { return nil }
return candidate
}()
return .found(text: combined, count: payloads.count, url: url, firstPayload: first)
}
}
13 changes: 6 additions & 7 deletions Sources/Stag/CaptureManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -458,18 +458,17 @@ final class CaptureManager {
let request = VNRecognizeTextRequest { request, _ in
defer { continuation.resume() }
let observations = request.results as? [VNRecognizedTextObservation] ?? []
let text = observations
.compactMap { $0.topCandidates(1).first?.string }
.joined(separator: "\n")
let lines = observations.compactMap { $0.topCandidates(1).first?.string }
let outcome = TextRecognition.ocrOutcome(from: lines)
DispatchQueue.main.async {
if text.isEmpty {
switch outcome {
case .noText:
ToastWindow.show("No text found",
icon: "text.slash",
iconColor: .secondary)
} else {
case .copied(let text, let lineCount):
Clipboard.copy(text: text)
let lines = text.components(separatedBy: "\n").count
ToastWindow.show("Copied \(lines) line\(lines == 1 ? "" : "s")",
ToastWindow.show("Copied \(lineCount) line\(lineCount == 1 ? "" : "s")",
icon: "doc.on.clipboard.fill",
iconColor: .green)
}
Expand Down
44 changes: 22 additions & 22 deletions Sources/Stag/Views/Editor/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2117,16 +2117,16 @@ struct EditorView: View {
self.ocrAlertMessage = "OCR failed: \(error.localizedDescription)"
return
}
let texts = (request.results as? [VNRecognizedTextObservation])?.compactMap { obs in
let lines = (request.results as? [VNRecognizedTextObservation])?.compactMap { obs in
obs.topCandidates(1).first?.string
} ?? []
let result = texts.joined(separator: "\n")
guard !result.isEmpty else {
switch TextRecognition.ocrOutcome(from: lines) {
case .noText:
self.ocrAlertMessage = "No text found in image."
return
case .copied(let text, let lineCount):
Clipboard.copy(text: text)
self.ocrAlertMessage = "Copied \(lineCount) line(s) to clipboard."
}
Clipboard.copy(text: result)
self.ocrAlertMessage = "Copied \(texts.count) line(s) to clipboard."
}
}
request.recognitionLevel = .accurate
Expand All @@ -2147,24 +2147,24 @@ struct EditorView: View {
}
let payloads = (req.results as? [VNBarcodeObservation])?
.compactMap { $0.payloadStringValue } ?? []
guard !payloads.isEmpty else {
switch TextRecognition.barcodeOutcome(from: payloads) {
case .none:
self.ocrAlertMessage = "No QR code or barcode found."
return
}
let combined = payloads.joined(separator: "\n")
Clipboard.copy(text: combined)
// If it looks like a URL, offer to open it
if let first = payloads.first, let url = URL(string: first), url.scheme != nil {
let alert = NSAlert()
alert.messageText = "QR Code Found"
alert.informativeText = first
alert.addButton(withTitle: "Open URL")
alert.addButton(withTitle: "Copied — Done")
if alert.runModal() == .alertFirstButtonReturn {
NSWorkspace.shared.open(url)
case .found(let text, let count, let url, let firstPayload):
Clipboard.copy(text: text)
// If it looks like a URL, offer to open it
if let url = url {
let alert = NSAlert()
alert.messageText = "QR Code Found"
alert.informativeText = firstPayload
alert.addButton(withTitle: "Open URL")
alert.addButton(withTitle: "Copied — Done")
if alert.runModal() == .alertFirstButtonReturn {
NSWorkspace.shared.open(url)
}
} else {
self.ocrAlertMessage = "Copied \(count) code(s) to clipboard."
}
} else {
self.ocrAlertMessage = "Copied \(payloads.count) code(s) to clipboard."
}
}
}
Expand Down
74 changes: 74 additions & 0 deletions Tests/StagTests/TextRecognitionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import XCTest
@testable import Stag

/// Post-processing rules extracted from the OCR / QR closures in CaptureManager
/// and EditorView.
final class TextRecognitionTests: XCTestCase {

// MARK: OCR

func testOCRNoLinesIsNoText() {
XCTAssertEqual(TextRecognition.ocrOutcome(from: []), .noText)
}

func testOCREmptyStringsAreNoText() {
XCTAssertEqual(TextRecognition.ocrOutcome(from: [""]), .noText)
}

func testOCRSingleLine() {
XCTAssertEqual(TextRecognition.ocrOutcome(from: ["hello"]), .copied(text: "hello", lineCount: 1))
}

func testOCRMultipleLinesJoinAndCount() {
XCTAssertEqual(
TextRecognition.ocrOutcome(from: ["one", "two", "three"]),
.copied(text: "one\ntwo\nthree", lineCount: 3)
)
}

// MARK: Barcode / QR

func testBarcodeNoneWhenEmpty() {
XCTAssertEqual(TextRecognition.barcodeOutcome(from: []), .none)
}

func testBarcodeURLPayloadSurfacesURL() {
let outcome = TextRecognition.barcodeOutcome(from: ["https://example.com"])
XCTAssertEqual(
outcome,
.found(text: "https://example.com", count: 1,
url: URL(string: "https://example.com"), firstPayload: "https://example.com")
)
}

func testBarcodeNonURLPayloadHasNilURL() {
let outcome = TextRecognition.barcodeOutcome(from: ["just some text"])
XCTAssertEqual(
outcome,
.found(text: "just some text", count: 1, url: nil, firstPayload: "just some text")
)
}

func testBarcodeMailtoSchemeIsOpenable() {
let outcome = TextRecognition.barcodeOutcome(from: ["mailto:a@b.com"])
guard case .found(_, _, let url, _) = outcome else { return XCTFail("expected .found") }
XCTAssertEqual(url?.scheme, "mailto")
}

func testBarcodeMultiplePayloadsJoinTextAndKeepFirstForURL() {
let outcome = TextRecognition.barcodeOutcome(from: ["https://x.com", "extra"])
XCTAssertEqual(
outcome,
.found(text: "https://x.com\nextra", count: 2,
url: URL(string: "https://x.com"), firstPayload: "https://x.com")
)
}

func testBarcodeNonURLFirstWithMultipleHasNilURL() {
let outcome = TextRecognition.barcodeOutcome(from: ["plain", "https://x.com"])
guard case .found(_, let count, let url, let first) = outcome else { return XCTFail("expected .found") }
XCTAssertNil(url)
XCTAssertEqual(count, 2)
XCTAssertEqual(first, "plain")
}
}
Loading