import Foundation import UIKit import Vision // MARK: - Screenshot amount scanner (on-device OCR) // // When the user shares an image (a bank/broker screenshot), Vision runs text // recognition entirely on-device — the image never leaves the phone. Every // number that parses as a positive amount becomes a candidate, ranked by // visual prominence: in banking UIs the account balance is almost always the // biggest number on screen, so the tallest match is prefilled and the rest are // offered as one-tap alternatives. struct ExtAmountCandidate: Identifiable, Equatable { let id: UUID let display: String let value: Decimal let prominence: CGFloat init(display: String, value: Decimal, prominence: CGFloat) { self.id = UUID() self.display = display self.value = value self.prominence = prominence } static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id } } enum ExtImageAmountScanner { static let maxCandidates = 6 static func scan(_ image: UIImage, completion: @escaping ([ExtAmountCandidate]) -> Void) { guard let cgImage = image.cgImage else { completion([]) return } let request = VNRecognizeTextRequest { request, _ in let observations = (request.results as? [VNRecognizedTextObservation]) ?? [] completion(candidates(from: observations)) } request.recognitionLevel = .accurate // Amounts are digits and separators; language correction only "fixes" them into words. request.usesLanguageCorrection = false DispatchQueue.global(qos: .userInitiated).async { let handler = VNImageRequestHandler(cgImage: cgImage, orientation: cgOrientation(from: image.imageOrientation)) do { try handler.perform([request]) } catch { completion([]) } } } // MARK: Candidate extraction private static let numberPattern: NSRegularExpression = { // Grouped amounts (12.345,67 / 1,234.56 / 12 345,67), plain decimals (1234,5) or integers. // Non-raw string so \u{00A0} becomes a literal NBSP inside the character class. 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]) -> [ExtAmountCandidate] { var found: [ExtAmountCandidate] = [] 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 matchRange = Range(match.range, in: line) else { continue } let display = String(line[matchRange]) guard !isPercentage(display, in: line, at: matchRange), !looksLikeYear(display), let value = ExtAmountParser.parse(display) else { continue } found.append(ExtAmountCandidate( display: display.replacingOccurrences(of: "\u{00A0}", with: " "), value: value, prominence: observation.boundingBox.height )) } } // Dedup by value, keeping the most prominent occurrence. var byValue: [Decimal: ExtAmountCandidate] = [:] for candidate in found { if let existing = byValue[candidate.value], existing.prominence >= candidate.prominence { continue } byValue[candidate.value] = candidate } return byValue.values .sorted { $0.prominence > $1.prominence } .prefix(maxCandidates) .map { $0 } } /// "1,25 %" or "%1.25" — performance figures, not balances. 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 value = Int(display) else { return false } return (1990...2099).contains(value) } private static func cgOrientation(from orientation: UIImage.Orientation) -> CGImagePropertyOrientation { switch orientation { 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 } } }