import Foundation import UIKit import Vision // MARK: - On-device amount OCR (app target) // // Recognizes the monetary amounts in a shared/picked image entirely on-device // (Vision), ranked by visual prominence — the balance is almost always the // biggest number on a bank screen. Mirrors the Share Extension's scanner so the // two entry points behave identically. struct ScannedAmount: Identifiable, Equatable { let id = UUID() let display: String let value: Decimal let prominence: CGFloat static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id } } enum ImageAmountScanner { static let maxCandidates = 6 static func scan(_ image: UIImage, completion: @escaping ([ScannedAmount]) -> Void) { guard let cgImage = image.cgImage else { completion([]); return } let request = VNRecognizeTextRequest { request, _ in let obs = (request.results as? [VNRecognizedTextObservation]) ?? [] completion(candidates(from: obs)) } request.recognitionLevel = .accurate request.usesLanguageCorrection = false DispatchQueue.global(qos: .userInitiated).async { let handler = VNImageRequestHandler(cgImage: cgImage, orientation: cgOrientation(from: image.imageOrientation)) try? handler.perform([request]) } } private static let numberPattern: NSRegularExpression = { let pattern = "\\d{1,3}(?:[.,\u{00A0} ]\\d{3})+(?:[.,]\\d{1,2})?|\\d+[.,]\\d{1,2}|\\d+" return try! NSRegularExpression(pattern: pattern) }() private static func candidates(from observations: [VNRecognizedTextObservation]) -> [ScannedAmount] { var found: [ScannedAmount] = [] for observation in observations { guard let recognized = observation.topCandidates(1).first else { continue } let line = recognized.string let range = NSRange(line.startIndex..., in: line) for match in numberPattern.matches(in: line, range: range) { guard let r = Range(match.range, in: line) else { continue } let display = String(line[r]) guard !isPercentage(display, in: line, at: r), !looksLikeYear(display), let value = CurrencyFormatter.parseUserInput(display), value > 0 else { continue } found.append(ScannedAmount( display: display.replacingOccurrences(of: "\u{00A0}", with: " "), value: value, prominence: observation.boundingBox.height)) } } var byValue: [Decimal: ScannedAmount] = [:] for c in found { if let e = byValue[c.value], e.prominence >= c.prominence { continue } byValue[c.value] = c } return byValue.values.sorted { $0.prominence > $1.prominence }.prefix(maxCandidates).map { $0 } } private static func isPercentage(_ display: String, in line: String, at range: Range) -> Bool { let after = line[range.upperBound...].drop(while: { $0 == " " || $0 == "\u{00A0}" }) if after.first == "%" { return true } let before = line[.. Bool { guard display.count == 4, display.allSatisfy(\.isNumber), let v = Int(display) else { return false } return (1990...2099).contains(v) } private static func cgOrientation(from o: UIImage.Orientation) -> CGImagePropertyOrientation { switch o { case .up: return .up case .down: return .down case .left: return .left case .right: return .right case .upMirrored: return .upMirrored case .downMirrored: return .downMirrored case .leftMirrored: return .leftMirrored case .rightMirrored: return .rightMirrored @unknown default: return .up } } }