1.4.2 (build 48): OCR de screenshots en Share Extension
El extension acepta ahora imágenes además de texto: al compartir un screenshot del banco/broker, Vision reconoce el texto EN EL DISPOSITIVO (la imagen nunca sale del teléfono), extrae los importes candidatos y rellena el más prominente visualmente (en las UIs bancarias el balance es el número más grande). El resto de candidatos se ofrecen como chips de un toque. Percentajes y años se filtran. - ExtImageAmountScanner.swift: VNRecognizeTextRequest + ranking por altura de bounding box + dedup por valor - ShareViewController: carga UIImage/URL/Data del provider, estado de escaneo, sección de candidatos - Info.plist: NSExtensionActivationSupportsImageWithMaxCount=1 - Strings nuevas en los 7 idiomas (ext_scanning_image, ext_detected_amounts, ext_ocr_no_amounts) - PredictionEngine: troceado ternario que agotaba el type-checker en Debug - Bump a 1.4.2 build 48 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
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<String.Index>) -> Bool {
|
||||
let after = line[range.upperBound...].drop(while: { $0 == " " || $0 == "\u{00A0}" })
|
||||
if after.first == "%" { return true }
|
||||
let before = line[..<range.lowerBound].reversed().drop(while: { $0 == " " || $0 == "\u{00A0}" })
|
||||
return before.first == "%"
|
||||
}
|
||||
|
||||
/// Bare 4-digit integers in a plausible year range are dates, not amounts.
|
||||
private static func looksLikeYear(_ display: String) -> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user