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:
alexandrev-tibco
2026-07-08 15:59:54 +02:00
parent a953af2da0
commit 19b1408c55
12 changed files with 290 additions and 41 deletions
@@ -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
}
}
}
+2
View File
@@ -28,6 +28,8 @@
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
@@ -5,10 +5,11 @@ import UniformTypeIdentifiers
// MARK: - Quick Update share extension
//
// Appears in the share sheet when the user shares/selects text in ANY app (their
// bank, broker). Shows a compact form floating over the host app: pick a source
// (the next one pending this month is preselected), amount prefilled from the
// shared text, save and the user never leaves the bank app. The main app turns
// these captures into real snapshots on its next activation.
// bank, broker) or shares a screenshot where the balance is visible. Shows a
// compact form floating over the host app: pick a source (the next one pending
// this month is preselected), amount prefilled from the shared text or OCR'd
// on-device from the image save, and the user never leaves the bank app. The
// main app turns these captures into real snapshots on its next activation.
@objc(ShareViewController)
final class ShareViewController: UIViewController {
@@ -16,33 +17,53 @@ final class ShareViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
extractSharedText { [weak self] text in
extractSharedContent { [weak self] text, image in
DispatchQueue.main.async {
self?.presentForm(sharedText: text)
self?.presentForm(sharedText: text, sharedImage: image)
}
}
}
private func extractSharedText(completion: @escaping (String?) -> Void) {
private func extractSharedContent(completion: @escaping (String?, UIImage?) -> Void) {
let providers = (extensionContext?.inputItems as? [NSExtensionItem])?
.compactMap { $0.attachments }
.flatMap { $0 } ?? []
guard let provider = providers.first(where: {
if let provider = providers.first(where: {
$0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier)
}) else {
completion(nil)
}) {
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
completion(item as? String, nil)
}
return
}
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
completion(item as? String)
if let provider = providers.first(where: {
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
}) {
provider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { item, _ in
completion(nil, Self.image(from: item))
}
return
}
completion(nil, nil)
}
/// Share sheets deliver images as UIImage, file URL, or raw Data depending on the host app.
private static func image(from item: NSSecureCoding?) -> UIImage? {
switch item {
case let image as UIImage: return image
case let url as URL: return (try? Data(contentsOf: url)).flatMap(UIImage.init(data:))
case let data as Data: return UIImage(data: data)
default: return nil
}
}
private func presentForm(sharedText: String?) {
private func presentForm(sharedText: String?, sharedImage: UIImage?) {
let form = QuickUpdateShareView(
sharedText: sharedText,
sharedImage: sharedImage,
onDone: { [weak self] in
self?.extensionContext?.completeRequest(returningItems: nil)
},
@@ -68,6 +89,7 @@ final class ShareViewController: UIViewController {
struct QuickUpdateShareView: View {
let sharedText: String?
let sharedImage: UIImage?
let onDone: () -> Void
let onCancel: () -> Void
@@ -75,6 +97,9 @@ struct QuickUpdateShareView: View {
@State private var selectedSourceId: UUID?
@State private var amountText: String = ""
@State private var saved = false
@State private var scanning = false
@State private var candidates: [ExtAmountCandidate] = []
@State private var scanFoundNothing = false
@FocusState private var amountFocused: Bool
private var parsedAmount: Decimal? {
@@ -117,10 +142,59 @@ struct QuickUpdateShareView: View {
private var form: some View {
Form {
Section(String(localized: "ext_amount_section")) {
TextField(String(localized: "ext_amount_placeholder"), text: $amountText)
.keyboardType(.decimalPad)
.font(.title3.weight(.semibold))
.focused($amountFocused)
if scanning {
HStack(spacing: 10) {
ProgressView()
Text(String(localized: "ext_scanning_image"))
.font(.subheadline)
.foregroundColor(.secondary)
}
} else {
TextField(String(localized: "ext_amount_placeholder"), text: $amountText)
.keyboardType(.decimalPad)
.font(.title3.weight(.semibold))
.focused($amountFocused)
if scanFoundNothing {
Text(String(localized: "ext_ocr_no_amounts"))
.font(.caption)
.foregroundColor(.secondary)
}
}
}
if candidates.count > 1 {
Section(String(localized: "ext_detected_amounts")) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(candidates) { candidate in
Button {
amountText = "\(candidate.value)"
} label: {
Text(candidate.display)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(
Capsule().fill(
amountText == "\(candidate.value)"
? Color.blue.opacity(0.18)
: Color(.tertiarySystemFill)
)
)
.overlay(
Capsule().strokeBorder(
amountText == "\(candidate.value)" ? Color.blue : .clear,
lineWidth: 1
)
)
.foregroundColor(.primary)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 2)
}
}
}
Section(String(localized: "ext_source_section")) {
@@ -181,11 +255,30 @@ struct QuickUpdateShareView: View {
if let sharedText, let parsed = ExtAmountParser.parse(sharedText) {
amountText = "\(parsed)"
} else if let sharedImage {
scanImage(sharedImage)
} else {
amountFocused = true
}
}
private func scanImage(_ image: UIImage) {
scanning = true
ExtImageAmountScanner.scan(image) { found in
DispatchQueue.main.async {
scanning = false
candidates = found
if let best = found.first {
// Most visually prominent number in bank UIs, the balance.
amountText = "\(best.value)"
} else {
scanFoundNothing = true
amountFocused = true
}
}
}
}
private func save() {
guard let amount = parsedAmount, let sourceId = selectedSourceId else { return }
ExtSharedStore.appendPending(ExtPendingUpdate(
@@ -9,3 +9,6 @@
"ext_saved_body" = "Erscheint in Portfolio Journal beim nächsten Öffnen.";
"ext_no_sources_title" = "Noch keine Quellen";
"ext_no_sources_body" = "Öffne Portfolio Journal einmal, damit deine Quellen hier erscheinen.";
"ext_scanning_image" = "Bild wird gelesen…";
"ext_detected_amounts" = "Erkannte Beträge";
"ext_ocr_no_amounts" = "Keine Beträge erkannt — gib den Wert manuell ein.";
@@ -9,3 +9,6 @@
"ext_saved_body" = "It will appear in Portfolio Journal the next time you open it.";
"ext_no_sources_title" = "No sources yet";
"ext_no_sources_body" = "Open Portfolio Journal once so your sources appear here.";
"ext_scanning_image" = "Reading image…";
"ext_detected_amounts" = "Detected amounts";
"ext_ocr_no_amounts" = "No amounts recognized — type the value manually.";
@@ -9,3 +9,6 @@
"ext_saved_body" = "Aparecerá en Portfolio Journal la próxima vez que la abras.";
"ext_no_sources_title" = "Aún no hay fuentes";
"ext_no_sources_body" = "Abre Portfolio Journal una vez para que tus fuentes aparezcan aquí.";
"ext_scanning_image" = "Leyendo imagen…";
"ext_detected_amounts" = "Importes detectados";
"ext_ocr_no_amounts" = "No se han reconocido importes — escribe el valor a mano.";
@@ -9,3 +9,6 @@
"ext_saved_body" = "Apparaîtra dans Portfolio Journal à la prochaine ouverture.";
"ext_no_sources_title" = "Aucune source";
"ext_no_sources_body" = "Ouvrez Portfolio Journal une fois pour que vos sources apparaissent ici.";
"ext_scanning_image" = "Lecture de limage…";
"ext_detected_amounts" = "Montants détectés";
"ext_ocr_no_amounts" = "Aucun montant reconnu — saisissez la valeur manuellement.";
@@ -9,3 +9,6 @@
"ext_saved_body" = "Apparirà in Portfolio Journal alla prossima apertura.";
"ext_no_sources_title" = "Nessuna fonte";
"ext_no_sources_body" = "Apri Portfolio Journal una volta perché le tue fonti appaiano qui.";
"ext_scanning_image" = "Lettura dellimmagine…";
"ext_detected_amounts" = "Importi rilevati";
"ext_ocr_no_amounts" = "Nessun importo riconosciuto — inserisci il valore manualmente.";
@@ -9,3 +9,6 @@
"ext_saved_body" = "次回Portfolio Journalを開いたときに反映されます。";
"ext_no_sources_title" = "投資先がありません";
"ext_no_sources_body" = "一度Portfolio Journalを開くと、ここに投資先が表示されます。";
"ext_scanning_image" = "画像を読み取り中…";
"ext_detected_amounts" = "検出された金額";
"ext_ocr_no_amounts" = "金額を認識できませんでした。手動で入力してください。";
@@ -9,3 +9,6 @@
"ext_saved_body" = "Aparecerá no Portfolio Journal na próxima vez que você abrir.";
"ext_no_sources_title" = "Nenhuma fonte";
"ext_no_sources_body" = "Abra o Portfolio Journal uma vez para que suas fontes apareçam aqui.";
"ext_scanning_image" = "Lendo imagem…";
"ext_detected_amounts" = "Valores detectados";
"ext_ocr_no_amounts" = "Nenhum valor reconhecido — digite o valor manualmente.";