import AppKit import Vision final class OCRService { static let shared = OCRService() private let queue = DispatchQueue(label: "dev.tzgyn.kapture.ocr", qos: .userInitiated) func recognize(_ cgImage: CGImage, completion: @escaping (String) -> Void) { queue.async { let text = Self.perform(cgImage) // Tiny text (small UI labels) often reads as nothing at native // size; retry upscaled and keep whichever pass read more. guard text.count < 3 else { completion(text) return } var best = text for factor in [CGFloat(2), CGFloat(3)] { guard let upscaled = Self.upscaled(cgImage, factor: factor) else { continue } let candidate = Self.perform(upscaled) if candidate.count > best.count { best = candidate } if best.count >= 3 { break } } completion(best) } } private static func perform(_ cgImage: CGImage) -> String { let request = VNRecognizeTextRequest() request.recognitionLevel = .accurate request.usesLanguageCorrection = true request.recognitionLanguages = ["en-US", "zh-Hans", "zh-Hant", "ja-JP", "ko-KR"] // Without this, Vision defaults to the first listed language and // renders CJK as Latin garbage (verified: 你好世界 -> "177WR"). if #available(macOS 13.0, *) { request.automaticallyDetectsLanguage = true } let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) do { try handler.perform([request]) } catch { return "" } let observations = request.results ?? [] let lines = observations .sorted { a, b in let ay = a.boundingBox.midY let by = b.boundingBox.midY if abs(ay - by) > 0.02 { return ay > by } return a.boundingBox.minX < b.boundingBox.minX } .compactMap { $0.topCandidates(1).first?.string } return lines.joined(separator: "\n") } private static func upscaled(_ cgImage: CGImage, factor: CGFloat) -> CGImage? { let width = Int(CGFloat(cgImage.width) * factor) let height = Int(CGFloat(cgImage.height) * factor) guard width > 0, height > 0, width <= 20000, height <= 20000 else { return nil } guard let ctx = CGContext( data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { return nil } ctx.interpolationQuality = .high ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height))) return ctx.makeImage() } }