From 9bd6ca846c7677a476c1e54ef778ea737c12957d Mon Sep 17 00:00:00 2001 From: Victor Augusto Date: Fri, 19 Dec 2025 11:11:24 -0300 Subject: [PATCH 01/17] feat: add barcode support and rotation fix for iOS (cherry picked from commit 725e705b0bc5cd63812b983bc64399315fb7f55b) --- ios/DocScanner/DocScanner.swift | 167 +++++++++++++++------------ ios/DocScanner/UIImage+Barcode.swift | 63 ++++++++++ ios/DocumentScanner.swift | 37 +++++- 3 files changed, 185 insertions(+), 82 deletions(-) create mode 100644 ios/DocScanner/UIImage+Barcode.swift diff --git a/ios/DocScanner/DocScanner.swift b/ios/DocScanner/DocScanner.swift index 251c633..47decbf 100644 --- a/ios/DocScanner/DocScanner.swift +++ b/ios/DocScanner/DocScanner.swift @@ -1,46 +1,39 @@ import UIKit import VisionKit +import Vision /** - This class uses VisonKit to start a document scan. It either returns the cropped images in base64 or as file paths - depending on the configuration. + This class uses VisonKit to start a document scan. It returns an array of objects containing URI, Barcode and Success status. */ @available(iOS 13.0, *) public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { - /** @property viewController the document scanner gets called from this view controller */ + /** @property viewController the document scanner gets called from this view controller */ private var viewController: UIViewController? - /** @property successHandler a callback triggered when the user completes the document scan successfully */ - private var successHandler: ([String]) -> Void + /** @property successHandler a callback triggered when the user completes the document scan successfully */ + // MUDANÇA: Retorna array de objetos (Dicionários) em vez de array de Strings + private var successHandler: ([[String: Any]]) -> Void - /** @property errorHandler a callback triggered when there's an error */ + /** @property errorHandler a callback triggered when there's an error */ private var errorHandler: (String) -> Void - /** @property cancelHandler a callback triggered when the user cancels the document scan */ + /** @property cancelHandler a callback triggered when the user cancels the document scan */ private var cancelHandler: () -> Void - /** @property responseType determines the format response (base64 or file paths) */ + /** @property responseType determines the format response (base64 or file paths) */ private var responseType: String - /** @property croppedImageQuality the 0 - 100 quality of the cropped image */ + /** @property croppedImageQuality the 0 - 100 quality of the cropped image */ private var croppedImageQuality: Int /** constructor for DocScanner - - @param viewController the ViewController that starts the document scan - @param successHandler a callback triggered when the user completes the document scan successfully - @param errorHandler a callback triggered when there's an error - @param cancelHandler a callback triggered when the user cancels the document scan - @param responseType determines the format response (base64 or file paths) - @param croppedImageQuality the 0 - 100 quality of the cropped image - - @return Returns a DocScanner */ public init( _ viewController: UIViewController? = nil, - successHandler: @escaping ([String]) -> Void = {_ in }, + // MUDANÇA NA ASSINATURA: [[String: Any]] + successHandler: @escaping ([[String: Any]]) -> Void = {_ in }, errorHandler: @escaping (String) -> Void = {_ in }, cancelHandler: @escaping () -> Void = {}, responseType: String = ResponseType.imageFilePath, @@ -54,11 +47,6 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { self.croppedImageQuality = croppedImageQuality } - /** - constructor for DocScanner - - @return Returns a DocScanner - */ public convenience override init() { self.init(nil) } @@ -67,14 +55,12 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { opens the camera, and starts the document scan */ public func startScan() { - // make sure device has the ability to scan documents if (!VNDocumentCameraViewController.isSupported) { self.errorHandler("Document scanning is not supported on this device") return } DispatchQueue.main.async { - // launch the document scanner let documentCameraViewController = VNDocumentCameraViewController() documentCameraViewController.delegate = self self.viewController?.present(documentCameraViewController, animated: true) @@ -82,18 +68,12 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { } /** - opens the camera, and starts the document scan - - @param viewController the ViewController that starts the document scan - @param successHandler a callback triggered when the user completes the document scan successfully - @param errorHandler a callback triggered when there's an error - @param cancelHandler a callback triggered when the user cancels the document scan - @param responseType determines the format response (base64 or file paths) - @param croppedImageQuality the 0 - 100 quality of the cropped image + opens the camera, and starts the document scan (Overload) */ public func startScan( _ viewController: UIViewController? = nil, - successHandler: @escaping ([String]) -> Void = {_ in }, + // MUDANÇA NA ASSINATURA: [[String: Any]] + successHandler: @escaping ([[String: Any]]) -> Void = {_ in }, errorHandler: @escaping (String) -> Void = {_ in }, cancelHandler: @escaping () -> Void = {}, responseType: String? = ResponseType.imageFilePath, @@ -110,101 +90,136 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { } /** - This gets called on document scan success. Either return an array with cropped images in base64 format, or save the cropped - images and return an array with image file paths - - @param controller the ViewController that starts the document scan - @param scan contains details like number of pages scanned and UIImages for all scanned pages + This gets called on document scan success. */ public func documentCameraViewController( _ controller: VNDocumentCameraViewController, didFinishWith scan: VNDocumentCameraScan ) { - var results: [String] = [] + // Array de objetos para retorno + var processedResults: [[String: Any]] = [] // loop through all scanned pages for pageNumber in 0...scan.pageCount - 1 { - // convert scan UIImage to jpeg data - guard let scannedDocumentImage: Data = scan - .imageOfPage(at: pageNumber) + let scannedImage: UIImage = scan.imageOfPage(at: pageNumber) + + // 1. Tenta na imagem original + var barcodeValue = scannedImage.findITFBarcodeInTopRightAreaSync() + + // 2. Se não achou, tenta girar 90 graus (Simulando foto Landscape) + if barcodeValue == nil { + if let rotatedImage = scannedImage.rotate(radians: .pi/2) { // 90 graus + barcodeValue = rotatedImage.findITFBarcodeInTopRightAreaSync() + // Opcional: Se achou aqui, talvez você queira salvar a rotatedImage no lugar da scannedImage? + // scannedImage = rotatedImage (se for var) + } + } + + // 3. Se ainda não achou, tenta girar -90 graus (Lado oposto) + if barcodeValue == nil { + if let rotatedImage = scannedImage.rotate(radians: -.pi/2) { // -90 graus + barcodeValue = rotatedImage.findITFBarcodeInTopRightAreaSync() + } + } + + // 4. Se ainda não achou, tenta 180 graus (De ponta cabeça - acontece muito com guia na mesa) + if barcodeValue == nil { + if let rotatedImage = scannedImage.rotate(radians: .pi) { // 180 graus + barcodeValue = rotatedImage.findITFBarcodeInTopRightAreaSync() + } + } + + let barcodeSuccess = barcodeValue != nil + + // 2. Converter imagem + guard let scannedDocumentImage: Data = scannedImage .jpegData(compressionQuality: CGFloat(self.croppedImageQuality) / CGFloat(100)) else { goBackToPreviousView(controller) self.errorHandler("Unable to get scanned document in jpeg format") return } + var documentIdentifier: String = "" + switch responseType { case ResponseType.base64: - // convert scan jpeg data to base64 - let base64EncodedImage: String = scannedDocumentImage.base64EncodedString() - results.append(base64EncodedImage) + documentIdentifier = scannedDocumentImage.base64EncodedString() case ResponseType.imageFilePath: do { - // save scan jpeg let croppedImageFilePath = FileUtil().createImageFile(pageNumber) try scannedDocumentImage.write(to: croppedImageFilePath) - - // store image file path - results.append(croppedImageFilePath.absoluteString) + documentIdentifier = croppedImageFilePath.absoluteString } catch { goBackToPreviousView(controller) self.errorHandler("Unable to save scanned image: \(error.localizedDescription)") return } default: - self.errorHandler( - "responseType must be \(ResponseType.base64) or \(ResponseType.imageFilePath)" - ) + goBackToPreviousView(controller) + self.errorHandler("responseType must be base64 or imageFilePath") + return } + // 3. Adicionar ao array de objetos (Estrutura do Backup) + processedResults.append([ + "uri": documentIdentifier, + "barcode": barcodeValue as Any, + "success": barcodeSuccess + ]) + } // exit document scanner goBackToPreviousView(controller) - // return scanned document results - self.successHandler(results) + // return scanned document results (Passa o array de objetos) + self.successHandler(processedResults) } - /** - This gets called if the user cancels the document scan - - @param controller the ViewController that starts the document scan - */ public func documentCameraViewControllerDidCancel( _ controller: VNDocumentCameraViewController ) { - // exit document scanner goBackToPreviousView(controller) self.cancelHandler() } - /** - This gets called if there's an error during the document scan - - @param controller the ViewController that starts the document scan - @param error the error - */ public func documentCameraViewController( _ controller: VNDocumentCameraViewController, didFailWithError error: Error ) { - // exit document scanner goBackToPreviousView(controller) - - // return the error message self.errorHandler(error.localizedDescription) } - /** - returns the user back to the ViewController that starts the document scan - - @param controller the ViewController that starts the document scan - */ private func goBackToPreviousView(_ controller: VNDocumentCameraViewController) { DispatchQueue.main.async { controller.dismiss(animated: true) } } } + +extension UIImage { + func rotate(radians: Float) -> UIImage? { + var newSize = CGRect(origin: CGPoint.zero, size: self.size).applying(CGAffineTransform(rotationAngle: CGFloat(radians))).integral.size + // Garante que o tamanho seja inteiro + newSize.width = floor(newSize.width); + newSize.height = floor(newSize.height); + + UIGraphicsBeginImageContextWithOptions(newSize, false, self.scale) + let context = UIGraphicsGetCurrentContext()! + + // Move a origem para o centro da imagem para girar + context.translateBy(x: newSize.width/2, y: newSize.height/2) + // Rotaciona + context.rotate(by: CGFloat(radians)) + + // Desenha a imagem antiga + self.draw(in: CGRect(x: -self.size.width/2, y: -self.size.height/2, width: self.size.width, height: self.size.height)) + + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return newImage + } +} diff --git a/ios/DocScanner/UIImage+Barcode.swift b/ios/DocScanner/UIImage+Barcode.swift new file mode 100644 index 0000000..a593024 --- /dev/null +++ b/ios/DocScanner/UIImage+Barcode.swift @@ -0,0 +1,63 @@ +import UIKit +import Vision + +// MARK: - Constantes do Barcode (as mesmas do Kotlin) +private let BARCODE_LARGURA_CORTE_PERCENTUAL: CGFloat = 20.0 +private let BARCODE_ALTURA_CORTE_PERCENTUAL: CGFloat = 16.0 +private let BARCODE_MARGEM_CANTO_PERCENTUAL: CGFloat = 2.0 / 100.0 // 2% em decimal + +extension UIImage { + + /** + * Função Síncrona que decodifica um código de barras ITF na área de interesse (canto superior direito). + * @return O valor do código de barras (String) ou nil. + */ + func findITFBarcodeInTopRightAreaSync() -> String? { + + let width = self.size.width + let height = self.size.height + + // 1. Cálculo do Retângulo de Interesse (ROI) - Permanece o mesmo + let larguraCorte = (width * BARCODE_LARGURA_CORTE_PERCENTUAL) / 100.0 + let alturaCorte = (height * BARCODE_ALTURA_CORTE_PERCENTUAL) / 100.0 + let posicaoX = width * BARCODE_MARGEM_CANTO_PERCENTUAL + let posicaoY = height * BARCODE_MARGEM_CANTO_PERCENTUAL + + let rectX = width - larguraCorte - posicaoX + let rectY = posicaoY + let rectWidth = larguraCorte + let rectHeight = alturaCorte + + let targetRect = CGRect(x: rectX, y: rectY, width: rectWidth, height: rectHeight) + + // 2. Crop Físico + guard let cgImage = self.cgImage?.cropping(to: targetRect) else { + return nil + } + + let request = VNDetectBarcodesRequest() + + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) + + do { + try handler.perform([request]) + + // 4. Processar resultados: Pegar a primeira observação decodificada + // Garantimos que seja um array de observações de código de barras + guard let observations = request.results as? [VNBarcodeObservation] else { + return nil + } + + // Pegamos o primeiro resultado válido + // Opcional: Você pode querer verificar se o tipo é VNBarcodeSymbologyITF14 + // Mas, dado o crop, o primeiro resultado válido deve ser o seu código de barras. + let firstDecodedBarcode = observations.first?.payloadStringValue + + return firstDecodedBarcode + + } catch { + print("Vision barcode detection error: \(error.localizedDescription)") + return nil + } + } +} diff --git a/ios/DocumentScanner.swift b/ios/DocumentScanner.swift index 43af2f8..4a3b045 100644 --- a/ios/DocumentScanner.swift +++ b/ios/DocumentScanner.swift @@ -19,6 +19,7 @@ public class DocumentScannerImpl: NSObject { return } + // 1. Configuração das opções (Logica do Fork) let opts = options as? [String: Any] ?? [:] let responseType = opts["responseType"] as? String let quality = opts["croppedImageQuality"] as? Int @@ -26,13 +27,27 @@ public class DocumentScannerImpl: NSObject { DispatchQueue.main.async { self.docScanner = DocScanner() + + // 2. Inicia o Scan self.docScanner?.startScan( RCTPresentedViewController(), - successHandler: { images in + + // 3. Handler de Sucesso MODIFICADO + // Agora recebe [[String: Any]] (Array de Objetos do seu Backup) + successHandler: { (scannedData: [[String: Any]]) in + let fm = FileManager.default - let sanitized: [String] = images.compactMap { raw -> String? in - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + // 4. Sanitização (Lógica do Fork adaptada para Objetos) + // Filtra os resultados para garantir que o arquivo realmente foi criado + let sanitized: [[String: Any]] = scannedData.compactMap { item -> [String: Any]? in + + // Tenta pegar a URI do objeto + guard let uri = item["uri"] as? String else { return nil } + let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } + + // Se for arquivo (não base64), verifica se existe no disco (Segurança do Fork) if !isBase64Response { let path: String if let url = URL(string: trimmed), url.isFileURL { @@ -41,21 +56,30 @@ public class DocumentScannerImpl: NSObject { path = trimmed } if !fm.fileExists(atPath: path) { - return nil + return nil // Arquivo não encontrado, descarta } } - return trimmed + + // Se passou na validação, retorna o objeto COMPLETO (com barcode) + // Se precisar atualizar a URI "trimmada", recriamos o objeto + var validItem = item + validItem["uri"] = trimmed + return validItem } + + // 5. Retorna para o JS resolve([ "status": "success", - "scannedImages": sanitized + "scannedImages": sanitized // Array de Objetos {uri, barcode, success} ]) self.docScanner = nil }, + errorHandler: { msg in reject("document_scan_error", msg, nil) self.docScanner = nil }, + cancelHandler: { resolve([ "status": "cancel", @@ -63,6 +87,7 @@ public class DocumentScannerImpl: NSObject { ]) self.docScanner = nil }, + responseType: responseType, croppedImageQuality: quality ) From 2d8f2be8de88a13cdf8244a9537c90a7b850d990 Mon Sep 17 00:00:00 2001 From: Victor Augusto <55756904+VictorAugustoDn@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:18:37 -0300 Subject: [PATCH 02/17] Update barcode constants for width and height (cherry picked from commit f1f9e26554c4eca6ca4beb13be9e39648617ec33) --- ios/DocScanner/UIImage+Barcode.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ios/DocScanner/UIImage+Barcode.swift b/ios/DocScanner/UIImage+Barcode.swift index a593024..f681196 100644 --- a/ios/DocScanner/UIImage+Barcode.swift +++ b/ios/DocScanner/UIImage+Barcode.swift @@ -2,9 +2,9 @@ import UIKit import Vision // MARK: - Constantes do Barcode (as mesmas do Kotlin) -private let BARCODE_LARGURA_CORTE_PERCENTUAL: CGFloat = 20.0 -private let BARCODE_ALTURA_CORTE_PERCENTUAL: CGFloat = 16.0 -private let BARCODE_MARGEM_CANTO_PERCENTUAL: CGFloat = 2.0 / 100.0 // 2% em decimal +private let BARCODE_LARGURA_CORTE_PERCENTUAL: CGFloat = 25.0 +private let BARCODE_ALTURA_CORTE_PERCENTUAL: CGFloat = 20.0 +private let BARCODE_MARGEM_CANTO_PERCENTUAL: CGFloat = 3.0 / 100.0 // 2% em decimal extension UIImage { From 95edaa5b907b426cc4a66976633aa7f4fafbc39e Mon Sep 17 00:00:00 2001 From: Preeternal Date: Sat, 21 Mar 2026 21:32:22 +0200 Subject: [PATCH 03/17] feat(barcode): add native extractBarcodesFromImages API with optional format filters --- DocumentScanner.podspec | 8 +- README.md | 111 +++++- android/build.gradle | 18 + .../scanner/barcode/BarcodeExtractorImpl.kt | 230 +++++++++++ .../barcode/BarcodeImageSourceLoader.kt | 164 ++++++++ .../scanner/DocumentScannerModule.kt | 304 +++++++++++++-- .../scanner/barcode/BarcodeExtractor.kt | 20 + .../scanner/barcode/BarcodeFormats.kt | 59 +++ .../scanner/barcode/BarcodeExtractorImpl.kt | 17 + ios/DocScanner/Barcode/BarcodeExtractor.swift | 238 ++++++++++++ .../Barcode/BarcodeFeatureFlags.swift | 7 + .../Barcode/BarcodeImageSource.swift | 50 +++ ios/DocScanner/DocScanner.swift | 362 ++++++++---------- ios/DocScanner/UIImage+Barcode.swift | 63 --- ios/DocumentScanner.mm | 57 +++ ios/DocumentScanner.swift | 176 +++++++-- src/NativeDocumentScanner.ts | 80 ++++ src/index.tsx | 24 +- 18 files changed, 1648 insertions(+), 340 deletions(-) create mode 100644 android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt create mode 100644 android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeImageSourceLoader.kt create mode 100644 android/src/main/java/com/preeternal/scanner/barcode/BarcodeExtractor.kt create mode 100644 android/src/main/java/com/preeternal/scanner/barcode/BarcodeFormats.kt create mode 100644 android/src/no-barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt create mode 100644 ios/DocScanner/Barcode/BarcodeExtractor.swift create mode 100644 ios/DocScanner/Barcode/BarcodeFeatureFlags.swift create mode 100644 ios/DocScanner/Barcode/BarcodeImageSource.swift delete mode 100644 ios/DocScanner/UIImage+Barcode.swift diff --git a/DocumentScanner.podspec b/DocumentScanner.podspec index 7cab021..43df343 100644 --- a/DocumentScanner.podspec +++ b/DocumentScanner.podspec @@ -1,6 +1,11 @@ require "json" package = JSON.parse(File.read(File.join(__dir__, "package.json"))) +barcode_enabled = ENV["DOCUMENT_SCANNER_ENABLE_BARCODE"] == "1" +swift_conditions = ['$(inherited)'] +if barcode_enabled + swift_conditions << 'DOCUMENT_SCANNER_ENABLE_BARCODE' +end Pod::Spec.new do |s| s.name = "DocumentScanner" @@ -14,7 +19,8 @@ Pod::Spec.new do |s| s.swift_version = '5.9' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', - 'SWIFT_INSTALL_OBJC_HEADER' => 'YES' + 'SWIFT_INSTALL_OBJC_HEADER' => 'YES', + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS' => swift_conditions.join(' ') } s.source = { :git => "https://github.com/Preeternal/react-native-document-scanner-plugin.git", :tag => "#{s.version}" } s.source_files = "ios/**/*.{h,m,mm,swift}" diff --git a/README.md b/README.md index b8e070a..f65def3 100644 --- a/README.md +++ b/README.md @@ -162,15 +162,34 @@ export default () => { ## Differences from the original - New Architecture (TurboModule) support — **now also available upstream**; this fork shipped it earlier and keeps parity. +- Optional post-processing barcode extraction with compile-time feature gating. - Additional hardening for Expo/EAS and CI examples. - Minor documentation updates and ongoing maintenance. ## Documentation * [`scanDocument(...)`](#scandocument) +* [Barcode extraction (optional feature)](#barcode-extraction-optional-feature) +* [`extractBarcodesFromImages(...)`](#extractbarcodesfromimages) * [Interfaces](#interfaces) * [Enums](#enums) +### scanDocument(...) + +```typescript +scanDocument(options?: ScanDocumentOptions | undefined) => Promise +``` + +Opens the camera, and starts the document scan + +| Param | Type | +| ------------- | ------------------------------------------------------------------- | +| **`options`** | ScanDocumentOptions | + +**Returns:** Promise<ScanDocumentResponse> + +-------------------- + ### Response sanitization (since v0.2.2) The module now sanitizes results on both platforms, so you no longer need to post‑filter `scannedImages` in JS: @@ -188,19 +207,74 @@ if (status === 'success' && scannedImages.length) { } ``` -### scanDocument(...) +### Barcode extraction (optional feature) + +Barcode extraction is a post-processing stage that runs after document capture when you call: + +```ts +const result = await DocumentScanner.scanDocument({ + responseType: 'imageFilePath', + extractBarcodes: true, + barcodeFormats: ['ean13', 'itf'], +}) +``` + +You can also run barcode extraction directly on existing images: + +```ts +const barcodes = await DocumentScanner.extractBarcodesFromImages( + scannedImages, + { barcodeFormats: ['ean13', 'itf'] } +) +``` + +`scanDocument` remains backward compatible: +- `status` and `scannedImages: string[]` are unchanged. +- Barcode data is returned in optional fields: + - `barcodes?: Barcode[]` + - `barcodeExtractionStatus?: 'success' | 'not_enabled' | 'failed'` + +Barcode extraction is disabled by default at build time. + +Enable on Android (includes ML Kit barcode dependency only when enabled): + +```bash +./gradlew :react-native-document-scanner-plugin:assemble -PenableBarcode=true +``` + +or add this to `android/gradle.properties`: + +```properties +DocumentScanner_enableBarcode=true +``` + +Enable on iOS before `pod install`: + +```bash +cd ios +DOCUMENT_SCANNER_ENABLE_BARCODE=1 pod install +``` + +If `extractBarcodes: true` is requested but the feature is not enabled in the native build, scanning still succeeds and `barcodeExtractionStatus` is returned as `'not_enabled'`. +For `extractBarcodesFromImages(...)` in the same situation, the call rejects with `barcode_not_enabled`. + +### extractBarcodesFromImages(...) ```typescript -scanDocument(options?: ScanDocumentOptions | undefined) => Promise +extractBarcodesFromImages( + images: string[], + options?: ExtractBarcodesFromImagesOptions | undefined +) => Promise ``` -Opens the camera, and starts the document scan +Extracts barcodes from existing images without opening scanner UI. -| Param | Type | -| ------------- | ------------------------------------------------------------------- | -| **`options`** | ScanDocumentOptions | +| Param | Type | +| ------------- | ------------------------------------------------------------------------------------------------------ | +| **`images`** | string[] | +| **`options`** | ExtractBarcodesFromImagesOptions | -**Returns:** Promise<ScanDocumentResponse> +**Returns:** Promise<Barcode[]> -------------------- @@ -214,6 +288,8 @@ Opens the camera, and starts the document scan | ------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | **`scannedImages`** | string[] | Array of valid file URIs or base64 strings (already sanitized by the module). | | **`status`** | ScanDocumentResponseStatus | The status lets you know if the document scan completes successfully, or if the user cancels before completing the document scan. | +| **`barcodes`** | Barcode[] | Optional list of extracted barcodes when `extractBarcodes` is requested and the native feature is enabled. | +| **`barcodeExtractionStatus`** | 'success' \\| 'not_enabled' \\| 'failed' | Optional status for barcode post-processing. | #### ScanDocumentOptions @@ -223,6 +299,24 @@ Opens the camera, and starts the document scan | **`croppedImageQuality`** | number | The quality of the cropped image from 0 - 100. 100 is the best quality. | : 100 | | **`maxNumDocuments`** | number | Android only: The maximum number of photos an user can take (not counting photo retakes) | : undefined | | **`responseType`** | ResponseType | The response comes back in this format on success. It can be the document scan image file paths or base64 images. | : ResponseType.ImageFilePath | +| **`extractBarcodes`** | boolean | Optional barcode extraction after document capture. Requires native barcode feature to be enabled at build time. | : false | +| **`barcodeFormats`** | Barcode['format'][] | Optional allow-list of normalized barcode formats used during extraction. When omitted, all supported formats are scanned. | : undefined | + + +#### ExtractBarcodesFromImagesOptions + +| Prop | Type | Description | Default | +| ------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------- | +| **`barcodeFormats`** | Barcode['format'][] | Optional allow-list of normalized barcode formats used during extraction. When omitted, all supported formats are scanned. | : undefined | + + +#### Barcode + +| Prop | Type | Description | +| ------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| **`value`** | string | Decoded barcode payload. | +| **`format`** | 'aztec' \\| 'codabar' \\| 'code39' \\| 'code93' \\| 'code128' \\| 'dataMatrix' \\| 'ean8' \\| 'ean13' \\| 'itf' \\| 'pdf417' \\| 'qr' \\| 'upca' \\| 'upce' \\| 'unknown' | Normalized barcode format. | +| **`sourceImageIndex`** | number | Index of the source image in `scannedImages` that produced this barcode. | ### Enums @@ -327,6 +421,9 @@ Both packages expose the same public API. To switch: This project builds on the excellent work by [WebsiteBeaver/react-native-document-scanner-plugin](https://github.com/WebsiteBeaver/react-native-document-scanner-plugin). The original repository is MIT‑licensed; original copyright notices are preserved in this fork’s LICENSE. +Barcode extraction logic in this repository was adapted from: +- [VictorAugustoDn/react-native-concluir-guias](https://github.com/VictorAugustoDn/react-native-concluir-guias) + ## License MIT diff --git a/android/build.gradle b/android/build.gradle index 52eb079..a6d5d88 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -25,6 +25,13 @@ def getExtOrIntegerDefault(name) { return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["DocumentScanner_" + name]).toInteger() } +def barcodeFeatureEnabled = + (project.findProperty("enableBarcode") + ?: project.findProperty("DocumentScanner_enableBarcode") + ?: "false") + .toString() + .toBoolean() + android { namespace "com.preeternal.scanner" @@ -33,6 +40,7 @@ android { defaultConfig { minSdkVersion getExtOrIntegerDefault("minSdkVersion") targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + buildConfigField "boolean", "BARCODE_FEATURE_ENABLED", barcodeFeatureEnabled ? "true" : "false" } buildFeatures { @@ -60,6 +68,11 @@ android { "generated/java", "generated/jni" ] + if (barcodeFeatureEnabled) { + java.srcDirs += ["src/barcode/java"] + } else { + java.srcDirs += ["src/no-barcode/java"] + } } } } @@ -75,4 +88,9 @@ dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "com.google.android.gms:play-services-mlkit-document-scanner:16.0.0-beta1" + + if (barcodeFeatureEnabled) { + implementation "com.google.mlkit:barcode-scanning:17.3.0" + implementation "androidx.exifinterface:exifinterface:1.4.2" + } } diff --git a/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt b/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt new file mode 100644 index 0000000..f1615fc --- /dev/null +++ b/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt @@ -0,0 +1,230 @@ +package com.preeternal.scanner.barcode + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Matrix +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import kotlin.math.roundToInt + +class BarcodeExtractorImpl : BarcodeExtractor { + companion object { + private const val CROP_WIDTH_PERCENT = 25.0f + private const val CROP_HEIGHT_PERCENT = 20.0f + private const val CORNER_MARGIN_PERCENT = 3.0f + + private val formatToMlKit = mapOf( + BarcodeFormats.AZTEC to Barcode.FORMAT_AZTEC, + BarcodeFormats.CODABAR to Barcode.FORMAT_CODABAR, + BarcodeFormats.CODE_39 to Barcode.FORMAT_CODE_39, + BarcodeFormats.CODE_93 to Barcode.FORMAT_CODE_93, + BarcodeFormats.CODE_128 to Barcode.FORMAT_CODE_128, + BarcodeFormats.DATA_MATRIX to Barcode.FORMAT_DATA_MATRIX, + BarcodeFormats.EAN_8 to Barcode.FORMAT_EAN_8, + BarcodeFormats.EAN_13 to Barcode.FORMAT_EAN_13, + BarcodeFormats.ITF to Barcode.FORMAT_ITF, + BarcodeFormats.PDF_417 to Barcode.FORMAT_PDF417, + BarcodeFormats.QR to Barcode.FORMAT_QR_CODE, + BarcodeFormats.UPC_A to Barcode.FORMAT_UPC_A, + BarcodeFormats.UPC_E to Barcode.FORMAT_UPC_E + ) + } + + private val scannerCache = mutableMapOf() + + override fun isFeatureEnabled(): Boolean = true + + override fun extractFromSource( + context: Context, + imageSource: String, + sourceImageIndex: Int, + allowedFormats: Set, + callback: (List) -> Unit + ) { + val normalizedAllowList = BarcodeFormats.normalizeRequestedFormats(allowedFormats.toList()) + val sourceBitmap = BarcodeImageSourceLoader.loadBitmap(context, imageSource) ?: run { + callback(emptyList()) + return + } + + val scanner = scannerForAllowedFormats(normalizedAllowList) + processAttempt( + scanner = scanner, + sourceBitmap = sourceBitmap, + sourceImageIndex = sourceImageIndex, + allowedFormats = normalizedAllowList, + attemptIndex = 0, + callback = callback + ) + } + + private fun processAttempt( + scanner: BarcodeScanner, + sourceBitmap: Bitmap, + sourceImageIndex: Int, + allowedFormats: Set, + attemptIndex: Int, + callback: (List) -> Unit + ) { + if (attemptIndex >= 4) { + callback(emptyList()) + return + } + + val angle = when (attemptIndex) { + 0 -> 0f + 1 -> 90f + 2 -> -90f + else -> 180f + } + + val candidate = if (angle == 0f) { + sourceBitmap + } else { + rotateBitmap(sourceBitmap, angle) + } + + if (candidate == null) { + processAttempt(scanner, sourceBitmap, sourceImageIndex, allowedFormats, attemptIndex + 1, callback) + return + } + + val roiBitmap = cropTopRightRoi(candidate) + if (roiBitmap == null) { + processAttempt(scanner, sourceBitmap, sourceImageIndex, allowedFormats, attemptIndex + 1, callback) + return + } + + val input = InputImage.fromBitmap(roiBitmap, 0) + scanner.process(input) + .addOnSuccessListener { barcodes -> + val mapped = barcodesToResults(barcodes, sourceImageIndex, allowedFormats) + if (mapped.isNotEmpty()) { + callback(mapped) + } else { + processAttempt(scanner, sourceBitmap, sourceImageIndex, allowedFormats, attemptIndex + 1, callback) + } + } + .addOnFailureListener { + processAttempt(scanner, sourceBitmap, sourceImageIndex, allowedFormats, attemptIndex + 1, callback) + } + } + + private fun barcodesToResults( + barcodes: List, + sourceImageIndex: Int, + allowedFormats: Set + ): List { + val dedup = LinkedHashMap() + + for (barcode in barcodes) { + val value = barcode.rawValue?.trim() + if (value.isNullOrEmpty()) { + continue + } + + val format = normalizeFormat(barcode.format) + if (allowedFormats.isNotEmpty() && !allowedFormats.contains(format)) { + continue + } + + val key = "$format|$value" + if (!dedup.containsKey(key)) { + dedup[key] = BarcodeResult( + value = value, + format = format, + sourceImageIndex = sourceImageIndex + ) + } + } + + return dedup.values.toList() + } + + private fun normalizeFormat(format: Int): String { + return when (format) { + Barcode.FORMAT_AZTEC -> BarcodeFormats.AZTEC + Barcode.FORMAT_CODABAR -> BarcodeFormats.CODABAR + Barcode.FORMAT_CODE_39 -> BarcodeFormats.CODE_39 + Barcode.FORMAT_CODE_93 -> BarcodeFormats.CODE_93 + Barcode.FORMAT_CODE_128 -> BarcodeFormats.CODE_128 + Barcode.FORMAT_DATA_MATRIX -> BarcodeFormats.DATA_MATRIX + Barcode.FORMAT_EAN_8 -> BarcodeFormats.EAN_8 + Barcode.FORMAT_EAN_13 -> BarcodeFormats.EAN_13 + Barcode.FORMAT_ITF -> BarcodeFormats.ITF + Barcode.FORMAT_PDF417 -> BarcodeFormats.PDF_417 + Barcode.FORMAT_QR_CODE -> BarcodeFormats.QR + Barcode.FORMAT_UPC_A -> BarcodeFormats.UPC_A + Barcode.FORMAT_UPC_E -> BarcodeFormats.UPC_E + else -> BarcodeFormats.UNKNOWN + } + } + + private fun cropTopRightRoi(bitmap: Bitmap): Bitmap? { + // ROI tuned for shipping-label top-right barcode placement. + val width = bitmap.width + val height = bitmap.height + if (width <= 1 || height <= 1) { + return null + } + + val cropWidth = (width * (CROP_WIDTH_PERCENT / 100f)).roundToInt().coerceAtLeast(1) + val cropHeight = (height * (CROP_HEIGHT_PERCENT / 100f)).roundToInt().coerceAtLeast(1) + val marginX = (width * (CORNER_MARGIN_PERCENT / 100f)).roundToInt() + val marginY = (height * (CORNER_MARGIN_PERCENT / 100f)).roundToInt() + + val left = (width - cropWidth - marginX).coerceAtLeast(0) + val top = marginY.coerceAtLeast(0) + val right = (left + cropWidth).coerceAtMost(width) + val bottom = (top + cropHeight).coerceAtMost(height) + val finalWidth = right - left + val finalHeight = bottom - top + + if (finalWidth <= 1 || finalHeight <= 1) { + return null + } + + return try { + Bitmap.createBitmap(bitmap, left, top, finalWidth, finalHeight) + } catch (_: IllegalArgumentException) { + null + } + } + + private fun rotateBitmap(source: Bitmap, angle: Float): Bitmap? { + val matrix = Matrix().apply { + postRotate(angle) + } + + return try { + Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true) + } catch (_: Exception) { + null + } + } + + private fun scannerForAllowedFormats(allowedFormats: Set): BarcodeScanner { + val key = allowedFormats.sorted().joinToString(",") + return scannerCache.getOrPut(key) { + createScanner(allowedFormats) + } + } + + private fun createScanner(allowedFormats: Set): BarcodeScanner { + val optionsBuilder = BarcodeScannerOptions.Builder() + val requestedFormats = allowedFormats + .mapNotNull { formatToMlKit[it] } + .distinct() + + if (requestedFormats.isNotEmpty()) { + val first = requestedFormats.first() + val rest = requestedFormats.drop(1).toIntArray() + optionsBuilder.setBarcodeFormats(first, *rest) + } + + return BarcodeScanning.getClient(optionsBuilder.build()) + } +} diff --git a/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeImageSourceLoader.kt b/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeImageSourceLoader.kt new file mode 100644 index 0000000..4f94f51 --- /dev/null +++ b/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeImageSourceLoader.kt @@ -0,0 +1,164 @@ +package com.preeternal.scanner.barcode + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +import android.util.Base64 +import androidx.exifinterface.media.ExifInterface +import java.io.File +import java.io.FileInputStream +import java.io.InputStream + +internal object BarcodeImageSourceLoader { + private const val MAX_DECODE_DIMENSION = 2048 + private val base64Regex = Regex("^[A-Za-z0-9+/=\\s]+$") + + fun loadBitmap(context: Context, imageSource: String): Bitmap? { + val normalized = imageSource.trim() + if (normalized.isEmpty()) { + return null + } + + if (normalized.startsWith("data:", ignoreCase = true) && normalized.contains("base64,")) { + val payload = normalized.substringAfter("base64,", "") + return decodeFromBase64(payload) + } + + if (normalized.startsWith("content://", ignoreCase = true)) { + return loadFromUri(context, Uri.parse(normalized)) + } + + if (normalized.startsWith("file://", ignoreCase = true)) { + return loadFromFilePath(Uri.parse(normalized).path) + } + + if (File(normalized).exists()) { + return loadFromFilePath(normalized) + } + + if (looksLikeBase64(normalized)) { + return decodeFromBase64(normalized) + } + + val parsed = Uri.parse(normalized) + return when (parsed.scheme?.lowercase()) { + "content" -> loadFromUri(context, parsed) + "file" -> loadFromFilePath(parsed.path) + else -> null + } + } + + private fun decodeFromBase64(payload: String): Bitmap? { + return try { + val decoded = Base64.decode(payload, Base64.DEFAULT) + decodeSampledBitmap(decoded) + } catch (_: IllegalArgumentException) { + null + } + } + + private fun loadFromUri(context: Context, uri: Uri): Bitmap? { + val bitmap = context.contentResolver.openInputStream(uri)?.use { decodeSampledBitmap(it) } + ?: return null + val orientation = context.contentResolver.openInputStream(uri)?.use { + readOrientation(it) + } ?: ExifInterface.ORIENTATION_NORMAL + + return rotateBitmapIfRequired(bitmap, orientation) + } + + private fun loadFromFilePath(path: String?): Bitmap? { + if (path.isNullOrBlank()) { + return null + } + + val file = File(path) + if (!file.exists() || !file.isFile) { + return null + } + + val bitmap = FileInputStream(file).use { decodeSampledBitmap(it) } ?: return null + val orientation = FileInputStream(file).use { + readOrientation(it) + } + + return rotateBitmapIfRequired(bitmap, orientation) + } + + private fun decodeSampledBitmap(stream: InputStream): Bitmap? { + val bytes = stream.readBytes() + return decodeSampledBitmap(bytes) + } + + private fun decodeSampledBitmap(bytes: ByteArray): Bitmap? { + if (bytes.isEmpty()) { + return null + } + + val bounds = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) { + return null + } + + val options = BitmapFactory.Options().apply { + inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight) + inPreferredConfig = Bitmap.Config.RGB_565 + } + + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) + } + + private fun calculateInSampleSize(width: Int, height: Int): Int { + var inSampleSize = 1 + var maxDimension = maxOf(width, height) + + while (maxDimension / inSampleSize > MAX_DECODE_DIMENSION) { + inSampleSize *= 2 + } + + return inSampleSize.coerceAtLeast(1) + } + + private fun readOrientation(stream: InputStream): Int { + return try { + ExifInterface(stream).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + } catch (_: Exception) { + ExifInterface.ORIENTATION_NORMAL + } + } + + private fun rotateBitmapIfRequired(bitmap: Bitmap, orientation: Int): Bitmap { + return when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> rotateBitmap(bitmap, 90f) ?: bitmap + ExifInterface.ORIENTATION_ROTATE_180 -> rotateBitmap(bitmap, 180f) ?: bitmap + ExifInterface.ORIENTATION_ROTATE_270 -> rotateBitmap(bitmap, 270f) ?: bitmap + else -> bitmap + } + } + + private fun rotateBitmap(source: Bitmap, angle: Float): Bitmap? { + val matrix = Matrix().apply { + postRotate(angle) + } + + return try { + Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true) + } catch (_: Exception) { + null + } + } + + private fun looksLikeBase64(value: String): Boolean { + val compact = value.replace("\\s".toRegex(), "") + return compact.length >= 32 && compact.length % 4 == 0 && base64Regex.matches(compact) + } +} diff --git a/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt b/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt index 70d45ae..f0e3676 100644 --- a/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt +++ b/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt @@ -13,6 +13,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.WindowCompat import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableNativeArray import com.facebook.react.bridge.WritableNativeMap @@ -21,6 +22,10 @@ import com.google.mlkit.vision.documentscanner.GmsDocumentScanner import com.google.mlkit.vision.documentscanner.GmsDocumentScannerOptions import com.google.mlkit.vision.documentscanner.GmsDocumentScanning import com.google.mlkit.vision.documentscanner.GmsDocumentScanningResult +import com.preeternal.scanner.barcode.BarcodeExtractor +import com.preeternal.scanner.barcode.BarcodeExtractorImpl +import com.preeternal.scanner.barcode.BarcodeFormats +import com.preeternal.scanner.barcode.BarcodeResult import java.io.ByteArrayOutputStream import java.io.FileNotFoundException import java.lang.ref.WeakReference @@ -36,10 +41,14 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : override fun getName(): String = NAME + private val barcodeExtractor: BarcodeExtractor = BarcodeExtractorImpl() + private var launcher: ActivityResultLauncher? = null private var pendingPromise: Promise? = null private var pendingOptions: ReadableMap? = null private var pendingQuality: Int = 100 + private var pendingExtractBarcodes: Boolean = false + private var pendingBarcodeFormats: Set = emptySet() private var scanner: GmsDocumentScanner? = null private var hostActivityRef: WeakReference? = null @@ -63,18 +72,49 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : pendingPromise = promise pendingOptions = options - pendingQuality = - if (options.hasKey("croppedImageQuality")) options.getInt("croppedImageQuality") else 100 + // Keep original image-quality behavior for base64 responses. + pendingQuality = if (options.hasKey("croppedImageQuality")) options.getInt("croppedImageQuality") else 100 + // Barcode extraction is opt-in and post-processing only. + pendingExtractBarcodes = options.hasKey("extractBarcodes") && options.getBoolean("extractBarcodes") + pendingBarcodeFormats = parseAllowedFormats(getArrayOrNull(options, "barcodeFormats")) hostActivityRef = WeakReference(componentActivity) ensureSystemBarsVisible(componentActivity) initLauncher(componentActivity) initScanner(options) - startScan(activity) } + override fun extractBarcodesFromImages(options: ReadableMap, promise: Promise) { + if (!barcodeExtractor.isFeatureEnabled()) { + promise.reject( + "barcode_not_enabled", + "Barcode extraction feature is disabled. Enable -PenableBarcode=true to build with barcode support." + ) + return + } + + val images = getArrayOrNull(options, "images") + if (images == null || images.size() == 0) { + promise.resolve(WritableNativeArray()) + return + } + + val allowedFormats = parseAllowedFormats(getArrayOrNull(options, "barcodeFormats")) + val barcodes = WritableNativeArray() + + processInputImages( + context = reactApplicationContext, + images = images, + imageIndex = 0, + allowedFormats = allowedFormats, + barcodes = barcodes + ) { + promise.resolve(barcodes) + } + } + private fun initScanner(options: ReadableMap) { val builder = GmsDocumentScannerOptions.Builder() .setResultFormats(GmsDocumentScannerOptions.RESULT_FORMAT_JPEG) @@ -96,41 +136,248 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : val options = pendingOptions val response = WritableNativeMap() val images = WritableNativeArray() + val barcodes = WritableNativeArray() + val shouldExtractBarcodes = pendingExtractBarcodes + val requestedBarcodeFormats = pendingBarcodeFormats + // Feature can be requested at runtime but still disabled at build time. + val canExtractBarcodes = shouldExtractBarcodes && barcodeExtractor.isFeatureEnabled() if (result.resultCode == Activity.RESULT_OK) { val docResult = GmsDocumentScanningResult.fromActivityResultIntent(result.data) - + val pages = docResult?.pages.orEmpty() val responseType = options?.getString("responseType")?.lowercase() - docResult?.pages?.forEach { page -> - val uri = page.imageUri - if (responseType == "base64") { - if (uri == null) return@forEach - val encoded = try { - uriToBase64(activity, uri, pendingQuality) - } catch (e: FileNotFoundException) { - promise.reject("document_scan_error", e.message) - clearPending() - return@register - } - - if (encoded.isNotBlank()) { - images.pushString(encoded) + // Process pages sequentially to keep deterministic sourceImageIndex mapping. + processPages( + activity = activity, + pages = pages, + pageIndex = 0, + responseType = responseType, + images = images, + shouldExtractBarcodes = canExtractBarcodes, + allowedFormats = requestedBarcodeFormats, + barcodes = barcodes, + onError = { errorMessage -> + promise.reject("document_scan_error", errorMessage, null) + clearPending() + }, + onComplete = { + response.putString("status", "success") + response.putArray("scannedImages", images) + if (shouldExtractBarcodes) { + if (canExtractBarcodes) { + response.putArray("barcodes", barcodes) + response.putString("barcodeExtractionStatus", "success") + } else { + response.putString("barcodeExtractionStatus", "not_enabled") + } } - } else if (uri != null && canReadUri(activity, uri)) { - images.pushString(uri.toString()) + promise.resolve(response) + clearPending() } - } - - response.putString("status", "success") + ) } else { response.putString("status", "cancel") + response.putArray("scannedImages", images) + if (shouldExtractBarcodes && !canExtractBarcodes) { + response.putString("barcodeExtractionStatus", "not_enabled") + } + promise.resolve(response) + clearPending() } + } + } - response.putArray("scannedImages", images) - promise.resolve(response) + private fun processPages( + activity: Activity, + pages: List, + pageIndex: Int, + responseType: String?, + images: WritableNativeArray, + shouldExtractBarcodes: Boolean, + allowedFormats: Set, + barcodes: WritableNativeArray, + onError: (String) -> Unit, + onComplete: () -> Unit + ) { + if (pageIndex >= pages.size) { + onComplete() + return + } - clearPending() + val uri = pages[pageIndex].imageUri + + val outputImage = try { + mapOutputImage(activity, uri, responseType) + } catch (e: FileNotFoundException) { + onError(e.message ?: "Unable to read scanned image") + return + } + + if (outputImage == null || outputImage.isBlank()) { + processPages( + activity = activity, + pages = pages, + pageIndex = pageIndex + 1, + responseType = responseType, + images = images, + shouldExtractBarcodes = shouldExtractBarcodes, + allowedFormats = allowedFormats, + barcodes = barcodes, + onError = onError, + onComplete = onComplete + ) + return + } + + val sourceImageIndex = images.size() + images.pushString(outputImage) + + // Preserve original behavior when barcode extraction is not requested. + if (!shouldExtractBarcodes) { + processPages( + activity = activity, + pages = pages, + pageIndex = pageIndex + 1, + responseType = responseType, + images = images, + shouldExtractBarcodes = false, + allowedFormats = allowedFormats, + barcodes = barcodes, + onError = onError, + onComplete = onComplete + ) + return + } + + barcodeExtractor.extractFromSource( + context = activity, + imageSource = uri.toString(), + sourceImageIndex = sourceImageIndex, + allowedFormats = allowedFormats + ) { detected -> + // Emit normalized barcode payloads with image index linkage. + for (barcode in detected) { + barcodes.pushMap(toWritableBarcode(barcode)) + } + + processPages( + activity = activity, + pages = pages, + pageIndex = pageIndex + 1, + responseType = responseType, + images = images, + shouldExtractBarcodes = true, + allowedFormats = allowedFormats, + barcodes = barcodes, + onError = onError, + onComplete = onComplete + ) + } + } + + private fun mapOutputImage( + activity: Activity, + uri: Uri, + responseType: String? + ): String? { + // Keep existing API contract: scannedImages is base64[] or uri[] depending on responseType. + return if (responseType == "base64") { + val encoded = uriToBase64(activity, uri, pendingQuality) + if (encoded.isBlank()) null else encoded + } else { + if (canReadUri(activity, uri)) uri.toString() else null + } + } + + private fun toWritableBarcode(barcode: BarcodeResult): WritableNativeMap { + val map = WritableNativeMap() + map.putString("value", barcode.value) + map.putString("format", barcode.format) + map.putInt("sourceImageIndex", barcode.sourceImageIndex) + return map + } + + private fun processInputImages( + context: ReactApplicationContext, + images: ReadableArray, + imageIndex: Int, + allowedFormats: Set, + barcodes: WritableNativeArray, + onComplete: () -> Unit + ) { + if (imageIndex >= images.size()) { + onComplete() + return + } + + val imageSource = readStringAt(images, imageIndex) + if (imageSource.isNullOrBlank()) { + processInputImages( + context = context, + images = images, + imageIndex = imageIndex + 1, + allowedFormats = allowedFormats, + barcodes = barcodes, + onComplete = onComplete + ) + return + } + + barcodeExtractor.extractFromSource( + context = context, + imageSource = imageSource, + sourceImageIndex = imageIndex, + allowedFormats = allowedFormats + ) { detected -> + for (barcode in detected) { + barcodes.pushMap(toWritableBarcode(barcode)) + } + + processInputImages( + context = context, + images = images, + imageIndex = imageIndex + 1, + allowedFormats = allowedFormats, + barcodes = barcodes, + onComplete = onComplete + ) + } + } + + private fun parseAllowedFormats(rawFormats: ReadableArray?): Set { + if (rawFormats == null || rawFormats.size() == 0) { + return emptySet() + } + + val values = mutableListOf() + for (index in 0 until rawFormats.size()) { + val value = readStringAt(rawFormats, index) + if (!value.isNullOrBlank()) { + values.add(value) + } + } + + return BarcodeFormats.normalizeRequestedFormats(values) + } + + private fun readStringAt(array: ReadableArray, index: Int): String? { + return try { + if (array.isNull(index)) null else array.getString(index) + } catch (_: Exception) { + null + } + } + + private fun getArrayOrNull(map: ReadableMap, key: String): ReadableArray? { + return try { + if (!map.hasKey(key) || map.isNull(key)) { + null + } else { + map.getArray(key) + } + } catch (_: Exception) { + null } } @@ -160,7 +407,8 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : private fun uriToBase64(activity: Activity, uri: Uri, quality: Int): String { val bmp = activity.contentResolver.openInputStream(uri).use { input -> BitmapFactory.decodeStream(input) - } + } ?: throw FileNotFoundException("Unable to decode scanned image") + val baos = ByteArrayOutputStream() bmp.compress(Bitmap.CompressFormat.JPEG, quality, baos) return Base64.encodeToString(baos.toByteArray(), Base64.DEFAULT) @@ -170,6 +418,8 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : pendingPromise = null pendingOptions = null pendingQuality = 100 + pendingExtractBarcodes = false + pendingBarcodeFormats = emptySet() restoreSystemBars() } diff --git a/android/src/main/java/com/preeternal/scanner/barcode/BarcodeExtractor.kt b/android/src/main/java/com/preeternal/scanner/barcode/BarcodeExtractor.kt new file mode 100644 index 0000000..f0f78e6 --- /dev/null +++ b/android/src/main/java/com/preeternal/scanner/barcode/BarcodeExtractor.kt @@ -0,0 +1,20 @@ +package com.preeternal.scanner.barcode + +import android.content.Context + +data class BarcodeResult( + val value: String, + val format: String, + val sourceImageIndex: Int +) + +interface BarcodeExtractor { + fun isFeatureEnabled(): Boolean + fun extractFromSource( + context: Context, + imageSource: String, + sourceImageIndex: Int, + allowedFormats: Set, + callback: (List) -> Unit + ) +} diff --git a/android/src/main/java/com/preeternal/scanner/barcode/BarcodeFormats.kt b/android/src/main/java/com/preeternal/scanner/barcode/BarcodeFormats.kt new file mode 100644 index 0000000..5f578f6 --- /dev/null +++ b/android/src/main/java/com/preeternal/scanner/barcode/BarcodeFormats.kt @@ -0,0 +1,59 @@ +package com.preeternal.scanner.barcode + +object BarcodeFormats { + const val AZTEC = "aztec" + const val CODABAR = "codabar" + const val CODE_39 = "code39" + const val CODE_93 = "code93" + const val CODE_128 = "code128" + const val DATA_MATRIX = "dataMatrix" + const val EAN_8 = "ean8" + const val EAN_13 = "ean13" + const val ITF = "itf" + const val PDF_417 = "pdf417" + const val QR = "qr" + const val UPC_A = "upca" + const val UPC_E = "upce" + const val UNKNOWN = "unknown" + + private val aliases = mapOf( + "aztec" to AZTEC, + "codabar" to CODABAR, + "code39" to CODE_39, + "code93" to CODE_93, + "code128" to CODE_128, + "datamatrix" to DATA_MATRIX, + "ean8" to EAN_8, + "ean13" to EAN_13, + "itf" to ITF, + "i2of5" to ITF, + "interleaved2of5" to ITF, + "pdf417" to PDF_417, + "micropdf417" to PDF_417, + "qr" to QR, + "microqr" to QR, + "upca" to UPC_A, + "upce" to UPC_E + ) + + fun normalizeRequestedFormat(value: String): String? { + val compact = value + .trim() + .lowercase() + .replace("_", "") + .replace("-", "") + .replace(" ", "") + + return aliases[compact] + } + + fun normalizeRequestedFormats(values: List): Set { + if (values.isEmpty()) { + return emptySet() + } + + return values + .mapNotNull(::normalizeRequestedFormat) + .toSet() + } +} diff --git a/android/src/no-barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt b/android/src/no-barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt new file mode 100644 index 0000000..b103160 --- /dev/null +++ b/android/src/no-barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt @@ -0,0 +1,17 @@ +package com.preeternal.scanner.barcode + +import android.content.Context + +class BarcodeExtractorImpl : BarcodeExtractor { + override fun isFeatureEnabled(): Boolean = false + + override fun extractFromSource( + context: Context, + imageSource: String, + sourceImageIndex: Int, + allowedFormats: Set, + callback: (List) -> Unit + ) { + callback(emptyList()) + } +} diff --git a/ios/DocScanner/Barcode/BarcodeExtractor.swift b/ios/DocScanner/Barcode/BarcodeExtractor.swift new file mode 100644 index 0000000..d769d2b --- /dev/null +++ b/ios/DocScanner/Barcode/BarcodeExtractor.swift @@ -0,0 +1,238 @@ +#if DOCUMENT_SCANNER_ENABLE_BARCODE +import CoreGraphics +import Foundation +import UIKit +import Vision + +private enum BarcodeExtractionConstants { + static let cropWidthPercent: CGFloat = 25.0 + static let cropHeightPercent: CGFloat = 20.0 + static let cornerMarginPercent: CGFloat = 0.03 +} + +private let supportedNormalizedFormats: Set = [ + "aztec", + "codabar", + "code39", + "code93", + "code128", + "dataMatrix", + "ean8", + "ean13", + "itf", + "pdf417", + "qr", + "upca", + "upce" +] + +struct ExtractedBarcode { + let value: String + let format: String +} + +enum BarcodeExtractor { + static func extractFromImage( + _ image: UIImage, + allowedFormats: [String] = [] + ) -> [ExtractedBarcode] { + let normalizedAllowedFormats = normalizeAllowedFormats(allowedFormats) + let candidates = rotationCandidates(for: image) + + for candidate in candidates { + let detected = detectInTopRightROI(candidate, allowedFormats: normalizedAllowedFormats) + if !detected.isEmpty { + return detected + } + } + + return [] + } + + private static func rotationCandidates(for image: UIImage) -> [UIImage] { + var candidates: [UIImage] = [image] + + if let rotated90 = image.rotated(by: .pi / 2) { + candidates.append(rotated90) + } + if let rotatedMinus90 = image.rotated(by: -.pi / 2) { + candidates.append(rotatedMinus90) + } + if let rotated180 = image.rotated(by: .pi) { + candidates.append(rotated180) + } + + return candidates + } + + private static func detectInTopRightROI( + _ image: UIImage, + allowedFormats: Set + ) -> [ExtractedBarcode] { + guard let cgImage = image.cgImage else { return [] } + + let roi = roiRect(forWidth: CGFloat(cgImage.width), height: CGFloat(cgImage.height)).integral + guard roi.width > 1, roi.height > 1 else { return [] } + + guard let croppedCgImage = cgImage.cropping(to: roi) else { return [] } + + let request = VNDetectBarcodesRequest() + if !allowedFormats.isEmpty { + let requestedSymbologies = request.supportedSymbologies.filter { + allowedFormats.contains(normalizeFormat($0)) + } + if !requestedSymbologies.isEmpty { + request.symbologies = requestedSymbologies + } + } + + let handler = VNImageRequestHandler(cgImage: croppedCgImage, options: [:]) + + do { + try handler.perform([request]) + } catch { + return [] + } + + guard let observations = request.results as? [VNBarcodeObservation] else { + return [] + } + + var deduplicated = Set() + var results: [ExtractedBarcode] = [] + + for observation in observations { + guard let payload = observation.payloadStringValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !payload.isEmpty else { + continue + } + + let normalizedFormat = normalizeFormat(observation.symbology) + if !allowedFormats.isEmpty && !allowedFormats.contains(normalizedFormat) { + continue + } + + let dedupKey = "\(normalizedFormat)|\(payload)" + if deduplicated.insert(dedupKey).inserted { + results.append(ExtractedBarcode(value: payload, format: normalizedFormat)) + } + } + + return results + } + + private static func roiRect(forWidth width: CGFloat, height: CGFloat) -> CGRect { + let cropWidth = width * BarcodeExtractionConstants.cropWidthPercent / 100.0 + let cropHeight = height * BarcodeExtractionConstants.cropHeightPercent / 100.0 + let marginX = width * BarcodeExtractionConstants.cornerMarginPercent + let marginY = height * BarcodeExtractionConstants.cornerMarginPercent + + let originX = max(0, width - cropWidth - marginX) + let originY = max(0, marginY) + let finalWidth = min(cropWidth, width - originX) + let finalHeight = min(cropHeight, height - originY) + + return CGRect(x: originX, y: originY, width: finalWidth, height: finalHeight) + } + + private static func normalizeAllowedFormats(_ rawFormats: [String]) -> Set { + guard !rawFormats.isEmpty else { + return [] + } + + let normalized = rawFormats.compactMap { normalizeRequestedFormat($0) } + return Set(normalized).intersection(supportedNormalizedFormats) + } + + private static func normalizeRequestedFormat(_ format: String) -> String? { + let compact = format + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "_", with: "") + .replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: " ", with: "") + + switch compact { + case "aztec": + return "aztec" + case "codabar": + return "codabar" + case "code39": + return "code39" + case "code93": + return "code93" + case "code128": + return "code128" + case "datamatrix": + return "dataMatrix" + case "ean8": + return "ean8" + case "ean13": + return "ean13" + case "itf", "i2of5", "interleaved2of5": + return "itf" + case "pdf417", "micropdf417": + return "pdf417" + case "qr", "microqr": + return "qr" + case "upca": + return "upca" + case "upce": + return "upce" + default: + return nil + } + } + + private static func normalizeFormat(_ symbology: VNBarcodeSymbology) -> String { + let raw = symbology.rawValue.lowercased() + if raw.contains("aztec") { return "aztec" } + if raw.contains("codabar") { return "codabar" } + if raw.contains("code39") { return "code39" } + if raw.contains("code93") { return "code93" } + if raw.contains("code128") { return "code128" } + if raw.contains("datamatrix") { return "dataMatrix" } + if raw.contains("ean8") { return "ean8" } + if raw.contains("ean13") { return "ean13" } + if raw.contains("itf") || raw.contains("i2of5") || raw.contains("interleaved2of5") { return "itf" } + if raw.contains("pdf417") || raw.contains("micropdf417") { return "pdf417" } + if raw.contains("qr") || raw.contains("microqr") { return "qr" } + if raw.contains("upca") { return "upca" } + if raw.contains("upce") { return "upce" } + return "unknown" + } +} + +private extension UIImage { + func rotated(by radians: CGFloat) -> UIImage? { + var newSize = CGRect(origin: .zero, size: size) + .applying(CGAffineTransform(rotationAngle: radians)) + .integral + .size + + newSize.width = floor(newSize.width) + newSize.height = floor(newSize.height) + + UIGraphicsBeginImageContextWithOptions(newSize, false, scale) + guard let context = UIGraphicsGetCurrentContext() else { + UIGraphicsEndImageContext() + return nil + } + + context.translateBy(x: newSize.width / 2, y: newSize.height / 2) + context.rotate(by: radians) + + draw(in: CGRect( + x: -size.width / 2, + y: -size.height / 2, + width: size.width, + height: size.height + )) + + let rotated = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return rotated + } +} +#endif diff --git a/ios/DocScanner/Barcode/BarcodeFeatureFlags.swift b/ios/DocScanner/Barcode/BarcodeFeatureFlags.swift new file mode 100644 index 0000000..d78be34 --- /dev/null +++ b/ios/DocScanner/Barcode/BarcodeFeatureFlags.swift @@ -0,0 +1,7 @@ +enum BarcodeFeatureFlags { + #if DOCUMENT_SCANNER_ENABLE_BARCODE + static let isEnabled = true + #else + static let isEnabled = false + #endif +} diff --git a/ios/DocScanner/Barcode/BarcodeImageSource.swift b/ios/DocScanner/Barcode/BarcodeImageSource.swift new file mode 100644 index 0000000..085ddf7 --- /dev/null +++ b/ios/DocScanner/Barcode/BarcodeImageSource.swift @@ -0,0 +1,50 @@ +import Foundation +import UIKit + +enum BarcodeImageSource { + static func loadImage(from imageSource: String) -> UIImage? { + let normalized = imageSource.trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.isEmpty { + return nil + } + + if let localFromUri = loadFromFileUri(normalized) { + return localFromUri + } + + if let localFromPath = loadFromPath(normalized) { + return localFromPath + } + + if let data = decodeBase64Data(normalized) { + return UIImage(data: data) + } + + return nil + } + + private static func loadFromFileUri(_ value: String) -> UIImage? { + guard let url = URL(string: value), url.isFileURL else { + return nil + } + return UIImage(contentsOfFile: url.path) + } + + private static func loadFromPath(_ value: String) -> UIImage? { + guard FileManager.default.fileExists(atPath: value) else { + return nil + } + return UIImage(contentsOfFile: value) + } + + private static func decodeBase64Data(_ value: String) -> Data? { + let payload: String + if let marker = value.range(of: "base64,") { + payload = String(value[marker.upperBound...]) + } else { + payload = value + } + + return Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) + } +} diff --git a/ios/DocScanner/DocScanner.swift b/ios/DocScanner/DocScanner.swift index 47decbf..e771fb7 100644 --- a/ios/DocScanner/DocScanner.swift +++ b/ios/DocScanner/DocScanner.swift @@ -1,225 +1,177 @@ import UIKit import VisionKit -import Vision /** - This class uses VisonKit to start a document scan. It returns an array of objects containing URI, Barcode and Success status. + This class uses VisionKit to start a document scan. It returns scanned images in base64 or file-path format + and can optionally attach barcode extraction results for each scanned page. */ @available(iOS 13.0, *) public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { - - /** @property viewController the document scanner gets called from this view controller */ - private var viewController: UIViewController? - - /** @property successHandler a callback triggered when the user completes the document scan successfully */ - // MUDANÇA: Retorna array de objetos (Dicionários) em vez de array de Strings - private var successHandler: ([[String: Any]]) -> Void - - /** @property errorHandler a callback triggered when there's an error */ - private var errorHandler: (String) -> Void - - /** @property cancelHandler a callback triggered when the user cancels the document scan */ - private var cancelHandler: () -> Void - - /** @property responseType determines the format response (base64 or file paths) */ - private var responseType: String - - /** @property croppedImageQuality the 0 - 100 quality of the cropped image */ - private var croppedImageQuality: Int - - /** - constructor for DocScanner - */ - public init( - _ viewController: UIViewController? = nil, - // MUDANÇA NA ASSINATURA: [[String: Any]] - successHandler: @escaping ([[String: Any]]) -> Void = {_ in }, - errorHandler: @escaping (String) -> Void = {_ in }, - cancelHandler: @escaping () -> Void = {}, - responseType: String = ResponseType.imageFilePath, - croppedImageQuality: Int = 100 - ) { - self.viewController = viewController - self.successHandler = successHandler - self.errorHandler = errorHandler - self.cancelHandler = cancelHandler - self.responseType = responseType - self.croppedImageQuality = croppedImageQuality - } - - public convenience override init() { - self.init(nil) - } - - /** - opens the camera, and starts the document scan - */ - public func startScan() { - if (!VNDocumentCameraViewController.isSupported) { - self.errorHandler("Document scanning is not supported on this device") - return - } - - DispatchQueue.main.async { - let documentCameraViewController = VNDocumentCameraViewController() - documentCameraViewController.delegate = self - self.viewController?.present(documentCameraViewController, animated: true) - } - } - - /** - opens the camera, and starts the document scan (Overload) - */ - public func startScan( - _ viewController: UIViewController? = nil, - // MUDANÇA NA ASSINATURA: [[String: Any]] - successHandler: @escaping ([[String: Any]]) -> Void = {_ in }, - errorHandler: @escaping (String) -> Void = {_ in }, - cancelHandler: @escaping () -> Void = {}, - responseType: String? = ResponseType.imageFilePath, - croppedImageQuality: Int? = 100 - ) { - self.viewController = viewController - self.successHandler = successHandler - self.errorHandler = errorHandler - self.cancelHandler = cancelHandler - self.responseType = responseType ?? ResponseType.imageFilePath - self.croppedImageQuality = croppedImageQuality ?? 100 - - self.startScan() + + private var viewController: UIViewController? + private var successHandler: ([[String: Any]]) -> Void + private var errorHandler: (String) -> Void + private var cancelHandler: () -> Void + private var responseType: String + private var croppedImageQuality: Int + private var extractBarcodes: Bool + private var barcodeFormats: [String] + + public init( + _ viewController: UIViewController? = nil, + successHandler: @escaping ([[String: Any]]) -> Void = { _ in }, + errorHandler: @escaping (String) -> Void = { _ in }, + cancelHandler: @escaping () -> Void = {}, + responseType: String = ResponseType.imageFilePath, + croppedImageQuality: Int = 100, + extractBarcodes: Bool = false, + barcodeFormats: [String] = [] + ) { + self.viewController = viewController + self.successHandler = successHandler + self.errorHandler = errorHandler + self.cancelHandler = cancelHandler + self.responseType = responseType + self.croppedImageQuality = croppedImageQuality + self.extractBarcodes = extractBarcodes + self.barcodeFormats = barcodeFormats + } + + public convenience override init() { + self.init(nil) + } + + public func startScan() { + // Make sure the device supports document scanning. + if !VNDocumentCameraViewController.isSupported { + self.errorHandler("Document scanning is not supported on this device") + return } - - /** - This gets called on document scan success. - */ - public func documentCameraViewController( - _ controller: VNDocumentCameraViewController, - didFinishWith scan: VNDocumentCameraScan - ) { - // Array de objetos para retorno - var processedResults: [[String: Any]] = [] - - // loop through all scanned pages - for pageNumber in 0...scan.pageCount - 1 { - - let scannedImage: UIImage = scan.imageOfPage(at: pageNumber) - - // 1. Tenta na imagem original - var barcodeValue = scannedImage.findITFBarcodeInTopRightAreaSync() - - // 2. Se não achou, tenta girar 90 graus (Simulando foto Landscape) - if barcodeValue == nil { - if let rotatedImage = scannedImage.rotate(radians: .pi/2) { // 90 graus - barcodeValue = rotatedImage.findITFBarcodeInTopRightAreaSync() - // Opcional: Se achou aqui, talvez você queira salvar a rotatedImage no lugar da scannedImage? - // scannedImage = rotatedImage (se for var) - } - } - - // 3. Se ainda não achou, tenta girar -90 graus (Lado oposto) - if barcodeValue == nil { - if let rotatedImage = scannedImage.rotate(radians: -.pi/2) { // -90 graus - barcodeValue = rotatedImage.findITFBarcodeInTopRightAreaSync() - } - } - - // 4. Se ainda não achou, tenta 180 graus (De ponta cabeça - acontece muito com guia na mesa) - if barcodeValue == nil { - if let rotatedImage = scannedImage.rotate(radians: .pi) { // 180 graus - barcodeValue = rotatedImage.findITFBarcodeInTopRightAreaSync() - } - } - - let barcodeSuccess = barcodeValue != nil - - // 2. Converter imagem - guard let scannedDocumentImage: Data = scannedImage - .jpegData(compressionQuality: CGFloat(self.croppedImageQuality) / CGFloat(100)) else { - goBackToPreviousView(controller) - self.errorHandler("Unable to get scanned document in jpeg format") - return - } - - var documentIdentifier: String = "" - - switch responseType { - case ResponseType.base64: - documentIdentifier = scannedDocumentImage.base64EncodedString() - case ResponseType.imageFilePath: - do { - let croppedImageFilePath = FileUtil().createImageFile(pageNumber) - try scannedDocumentImage.write(to: croppedImageFilePath) - documentIdentifier = croppedImageFilePath.absoluteString - } catch { - goBackToPreviousView(controller) - self.errorHandler("Unable to save scanned image: \(error.localizedDescription)") - return - } - default: - goBackToPreviousView(controller) - self.errorHandler("responseType must be base64 or imageFilePath") - return - } - - // 3. Adicionar ao array de objetos (Estrutura do Backup) - processedResults.append([ - "uri": documentIdentifier, - "barcode": barcodeValue as Any, - "success": barcodeSuccess - ]) - - } - - // exit document scanner - goBackToPreviousView(controller) - - // return scanned document results (Passa o array de objetos) - self.successHandler(processedResults) + + DispatchQueue.main.async { + // Launch the native document scanner UI. + let documentCameraViewController = VNDocumentCameraViewController() + documentCameraViewController.delegate = self + self.viewController?.present(documentCameraViewController, animated: true) } - - public func documentCameraViewControllerDidCancel( - _ controller: VNDocumentCameraViewController - ) { + } + + public func startScan( + _ viewController: UIViewController? = nil, + successHandler: @escaping ([[String: Any]]) -> Void = { _ in }, + errorHandler: @escaping (String) -> Void = { _ in }, + cancelHandler: @escaping () -> Void = {}, + responseType: String? = ResponseType.imageFilePath, + croppedImageQuality: Int? = 100, + extractBarcodes: Bool = false, + barcodeFormats: [String] = [] + ) { + self.viewController = viewController + self.successHandler = successHandler + self.errorHandler = errorHandler + self.cancelHandler = cancelHandler + self.responseType = responseType ?? ResponseType.imageFilePath + self.croppedImageQuality = croppedImageQuality ?? 100 + self.extractBarcodes = extractBarcodes + self.barcodeFormats = barcodeFormats + + self.startScan() + } + + public func documentCameraViewController( + _ controller: VNDocumentCameraViewController, + didFinishWith scan: VNDocumentCameraScan + ) { + var processedResults: [[String: Any]] = [] + + // Process every scanned page and produce a normalized page payload. + for pageNumber in 0 ..< scan.pageCount { + let scannedImage: UIImage = scan.imageOfPage(at: pageNumber) + + // Convert UIImage to JPEG data based on requested quality. + guard let scannedDocumentImage: Data = scannedImage + .jpegData(compressionQuality: CGFloat(self.croppedImageQuality) / CGFloat(100)) else { goBackToPreviousView(controller) - self.cancelHandler() - } + self.errorHandler("Unable to get scanned document in jpeg format") + return + } - public func documentCameraViewController( - _ controller: VNDocumentCameraViewController, - didFailWithError error: Error - ) { + let imageIdentifier: String + switch responseType { + case ResponseType.base64: + // Return page as base64. + imageIdentifier = scannedDocumentImage.base64EncodedString() + case ResponseType.imageFilePath: + do { + // Persist page to disk and return file URI. + let croppedImageFilePath = FileUtil().createImageFile(pageNumber) + try scannedDocumentImage.write(to: croppedImageFilePath) + imageIdentifier = croppedImageFilePath.absoluteString + } catch { + goBackToPreviousView(controller) + self.errorHandler("Unable to save scanned image: \(error.localizedDescription)") + return + } + default: goBackToPreviousView(controller) - self.errorHandler(error.localizedDescription) - } - - private func goBackToPreviousView(_ controller: VNDocumentCameraViewController) { - DispatchQueue.main.async { - controller.dismiss(animated: true) + self.errorHandler("responseType must be base64 or imageFilePath") + return + } + + var pageResult: [String: Any] = [ + "image": imageIdentifier + ] + + if extractBarcodes { + // Barcode extraction is optional and controlled by compile-time feature flags. + if BarcodeFeatureFlags.isEnabled { + #if DOCUMENT_SCANNER_ENABLE_BARCODE + let extracted = BarcodeExtractor.extractFromImage( + scannedImage, + allowedFormats: barcodeFormats + ) + pageResult["barcodes"] = extracted.map { + [ + "value": $0.value, + "format": $0.format + ] + } + #else + pageResult["barcodes"] = [] + #endif + } else { + pageResult["barcodes"] = [] } - } -} + } -extension UIImage { - func rotate(radians: Float) -> UIImage? { - var newSize = CGRect(origin: CGPoint.zero, size: self.size).applying(CGAffineTransform(rotationAngle: CGFloat(radians))).integral.size - // Garante que o tamanho seja inteiro - newSize.width = floor(newSize.width); - newSize.height = floor(newSize.height); + processedResults.append(pageResult) + } - UIGraphicsBeginImageContextWithOptions(newSize, false, self.scale) - let context = UIGraphicsGetCurrentContext()! + // Exit scanner UI and return payload. + goBackToPreviousView(controller) + self.successHandler(processedResults) + } - // Move a origem para o centro da imagem para girar - context.translateBy(x: newSize.width/2, y: newSize.height/2) - // Rotaciona - context.rotate(by: CGFloat(radians)) - - // Desenha a imagem antiga - self.draw(in: CGRect(x: -self.size.width/2, y: -self.size.height/2, width: self.size.width, height: self.size.height)) + public func documentCameraViewControllerDidCancel( + _ controller: VNDocumentCameraViewController + ) { + goBackToPreviousView(controller) + self.cancelHandler() + } - let newImage = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() + public func documentCameraViewController( + _ controller: VNDocumentCameraViewController, + didFailWithError error: Error + ) { + // Exit scanner UI and return the native error message. + goBackToPreviousView(controller) + self.errorHandler(error.localizedDescription) + } - return newImage + private func goBackToPreviousView(_ controller: VNDocumentCameraViewController) { + // Return to the screen that initiated scanning. + DispatchQueue.main.async { + controller.dismiss(animated: true) } + } } diff --git a/ios/DocScanner/UIImage+Barcode.swift b/ios/DocScanner/UIImage+Barcode.swift deleted file mode 100644 index f681196..0000000 --- a/ios/DocScanner/UIImage+Barcode.swift +++ /dev/null @@ -1,63 +0,0 @@ -import UIKit -import Vision - -// MARK: - Constantes do Barcode (as mesmas do Kotlin) -private let BARCODE_LARGURA_CORTE_PERCENTUAL: CGFloat = 25.0 -private let BARCODE_ALTURA_CORTE_PERCENTUAL: CGFloat = 20.0 -private let BARCODE_MARGEM_CANTO_PERCENTUAL: CGFloat = 3.0 / 100.0 // 2% em decimal - -extension UIImage { - - /** - * Função Síncrona que decodifica um código de barras ITF na área de interesse (canto superior direito). - * @return O valor do código de barras (String) ou nil. - */ - func findITFBarcodeInTopRightAreaSync() -> String? { - - let width = self.size.width - let height = self.size.height - - // 1. Cálculo do Retângulo de Interesse (ROI) - Permanece o mesmo - let larguraCorte = (width * BARCODE_LARGURA_CORTE_PERCENTUAL) / 100.0 - let alturaCorte = (height * BARCODE_ALTURA_CORTE_PERCENTUAL) / 100.0 - let posicaoX = width * BARCODE_MARGEM_CANTO_PERCENTUAL - let posicaoY = height * BARCODE_MARGEM_CANTO_PERCENTUAL - - let rectX = width - larguraCorte - posicaoX - let rectY = posicaoY - let rectWidth = larguraCorte - let rectHeight = alturaCorte - - let targetRect = CGRect(x: rectX, y: rectY, width: rectWidth, height: rectHeight) - - // 2. Crop Físico - guard let cgImage = self.cgImage?.cropping(to: targetRect) else { - return nil - } - - let request = VNDetectBarcodesRequest() - - let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) - - do { - try handler.perform([request]) - - // 4. Processar resultados: Pegar a primeira observação decodificada - // Garantimos que seja um array de observações de código de barras - guard let observations = request.results as? [VNBarcodeObservation] else { - return nil - } - - // Pegamos o primeiro resultado válido - // Opcional: Você pode querer verificar se o tipo é VNBarcodeSymbologyITF14 - // Mas, dado o crop, o primeiro resultado válido deve ser o seu código de barras. - let firstDecodedBarcode = observations.first?.payloadStringValue - - return firstDecodedBarcode - - } catch { - print("Vision barcode detection error: \(error.localizedDescription)") - return nil - } - } -} diff --git a/ios/DocumentScanner.mm b/ios/DocumentScanner.mm index 5df9887..c3234c6 100644 --- a/ios/DocumentScanner.mm +++ b/ios/DocumentScanner.mm @@ -33,6 +33,13 @@ - (void)handleScanWithOptions:(NSDictionary *)options [self.impl scanDocument:options resolve:resolve reject:reject]; } +- (void)handleBarcodeExtractionWithOptions:(NSDictionary *)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [self.impl extractBarcodesFromImages:options resolve:resolve reject:reject]; +} + #if RCT_NEW_ARCH_ENABLED - (void)scanDocument:(JS::NativeDocumentScanner::ScanDocumentOptions &)options resolve:(RCTPromiseResolveBlock)resolve @@ -48,9 +55,52 @@ - (void)scanDocument:(JS::NativeDocumentScanner::ScanDocumentOptions &)options if (options.maxNumDocuments().has_value()) { dict[@"maxNumDocuments"] = @(options.maxNumDocuments().value()); } + if (options.extractBarcodes().has_value()) { + dict[@"extractBarcodes"] = @(options.extractBarcodes().value()); + } + if (options.barcodeFormats().has_value()) { + auto formats = options.barcodeFormats().value(); + NSMutableArray *mappedFormats = [NSMutableArray arrayWithCapacity:formats.size()]; + for (const auto &format : formats) { + if (format != nil) { + [mappedFormats addObject:format]; + } + } + dict[@"barcodeFormats"] = mappedFormats; + } [self handleScanWithOptions:dict resolve:resolve reject:reject]; } +- (void)extractBarcodesFromImages: + (JS::NativeDocumentScanner::ExtractBarcodesFromImagesRequest &)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSMutableDictionary *dict = [NSMutableDictionary new]; + + auto images = options.images(); + NSMutableArray *mappedImages = [NSMutableArray arrayWithCapacity:images.size()]; + for (const auto &image : images) { + if (image != nil) { + [mappedImages addObject:image]; + } + } + dict[@"images"] = mappedImages; + + if (options.barcodeFormats().has_value()) { + auto formats = options.barcodeFormats().value(); + NSMutableArray *mappedFormats = [NSMutableArray arrayWithCapacity:formats.size()]; + for (const auto &format : formats) { + if (format != nil) { + [mappedFormats addObject:format]; + } + } + dict[@"barcodeFormats"] = mappedFormats; + } + + [self handleBarcodeExtractionWithOptions:dict resolve:resolve reject:reject]; +} + - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params { @@ -63,5 +113,12 @@ - (void)scanDocument:(JS::NativeDocumentScanner::ScanDocumentOptions &)options { [self handleScanWithOptions:options resolve:resolve reject:reject]; } + +RCT_EXPORT_METHOD(extractBarcodesFromImages:(NSDictionary *)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + [self handleBarcodeExtractionWithOptions:options resolve:resolve reject:reject]; +} #endif @end diff --git a/ios/DocumentScanner.swift b/ios/DocumentScanner.swift index 4a3b045..b7124f4 100644 --- a/ios/DocumentScanner.swift +++ b/ios/DocumentScanner.swift @@ -5,6 +5,10 @@ import React @objc(DocumentScannerImpl) public class DocumentScannerImpl: NSObject { private var docScanner: DocScanner? + private let barcodeQueue = DispatchQueue( + label: "com.preeternal.document-scanner.barcode", + qos: .userInitiated + ) @objc static func requiresMainQueueSetup() -> Bool { true } @@ -19,35 +23,29 @@ public class DocumentScannerImpl: NSObject { return } - // 1. Configuração das opções (Logica do Fork) let opts = options as? [String: Any] ?? [:] let responseType = opts["responseType"] as? String let quality = opts["croppedImageQuality"] as? Int let isBase64Response = responseType?.lowercased() == "base64" + let shouldExtractBarcodes = opts["extractBarcodes"] as? Bool ?? false + let barcodeFormats = (opts["barcodeFormats"] as? [Any] ?? []) + .compactMap { $0 as? String } DispatchQueue.main.async { self.docScanner = DocScanner() - - // 2. Inicia o Scan self.docScanner?.startScan( RCTPresentedViewController(), - - // 3. Handler de Sucesso MODIFICADO - // Agora recebe [[String: Any]] (Array de Objetos do seu Backup) successHandler: { (scannedData: [[String: Any]]) in - let fm = FileManager.default - - // 4. Sanitização (Lógica do Fork adaptada para Objetos) - // Filtra os resultados para garantir que o arquivo realmente foi criado - let sanitized: [[String: Any]] = scannedData.compactMap { item -> [String: Any]? in - - // Tenta pegar a URI do objeto - guard let uri = item["uri"] as? String else { return nil } - let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - // Se for arquivo (não base64), verifica se existe no disco (Segurança do Fork) + var sanitizedImages: [String] = [] + var extractedBarcodes: [[String: Any]] = [] + + // Keep the same sanitization guarantees as the original API. + for item in scannedData { + guard let rawImage = item["image"] as? String else { continue } + let trimmed = rawImage.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + if !isBase64Response { let path: String if let url = URL(string: trimmed), url.isFileURL { @@ -56,41 +54,147 @@ public class DocumentScannerImpl: NSObject { path = trimmed } if !fm.fileExists(atPath: path) { - return nil // Arquivo não encontrado, descarta + continue + } + } + + let sourceImageIndex = sanitizedImages.count + sanitizedImages.append(trimmed) + + guard shouldExtractBarcodes && BarcodeFeatureFlags.isEnabled else { + continue + } + + guard let pageBarcodes = item["barcodes"] as? [[String: Any]] else { + continue + } + + // Flatten page-local barcodes into API-level barcodes with sourceImageIndex. + for barcode in pageBarcodes { + guard let value = barcode["value"] as? String, + !value.isEmpty else { + continue } + + let format = (barcode["format"] as? String) ?? "unknown" + extractedBarcodes.append([ + "value": value, + "format": format, + "sourceImageIndex": sourceImageIndex + ]) } - - // Se passou na validação, retorna o objeto COMPLETO (com barcode) - // Se precisar atualizar a URI "trimmada", recriamos o objeto - var validItem = item - validItem["uri"] = trimmed - return validItem } - - // 5. Retorna para o JS - resolve([ + + var payload: [String: Any] = [ "status": "success", - "scannedImages": sanitized // Array de Objetos {uri, barcode, success} - ]) + "scannedImages": sanitizedImages + ] + + // Barcode payload is optional and only returned when explicitly requested. + if shouldExtractBarcodes { + if BarcodeFeatureFlags.isEnabled { + payload["barcodes"] = extractedBarcodes + payload["barcodeExtractionStatus"] = "success" + } else { + payload["barcodeExtractionStatus"] = "not_enabled" + } + } + + resolve(payload) self.docScanner = nil }, - errorHandler: { msg in reject("document_scan_error", msg, nil) self.docScanner = nil }, - cancelHandler: { - resolve([ + var payload: [String: Any] = [ "status": "cancel", "scannedImages": [] - ]) + ] + if shouldExtractBarcodes && !BarcodeFeatureFlags.isEnabled { + payload["barcodeExtractionStatus"] = "not_enabled" + } + resolve(payload) self.docScanner = nil }, - responseType: responseType, - croppedImageQuality: quality + croppedImageQuality: quality, + extractBarcodes: shouldExtractBarcodes, + barcodeFormats: barcodeFormats + ) + } + } + + @objc(extractBarcodesFromImages:resolve:reject:) + public func extractBarcodesFromImages( + _ options: NSDictionary, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 13.0, *) else { + reject("unsupported_ios", "iOS 13.0 or higher required", nil) + return + } + + guard BarcodeFeatureFlags.isEnabled else { + reject( + "barcode_not_enabled", + "Barcode extraction feature is disabled. Enable DOCUMENT_SCANNER_ENABLE_BARCODE=1 before pod install.", + nil ) + return + } + + let opts = options as? [String: Any] ?? [:] + let rawImages = opts["images"] as? [Any] ?? [] + let allowedFormats = (opts["barcodeFormats"] as? [Any] ?? []) + .compactMap { $0 as? String } + + if rawImages.isEmpty { + resolve([]) + return + } + + barcodeQueue.async { + #if DOCUMENT_SCANNER_ENABLE_BARCODE + var extractedBarcodes: [[String: Any]] = [] + + for (sourceImageIndex, source) in rawImages.enumerated() { + guard let imageSource = source as? String else { + continue + } + + guard let image = BarcodeImageSource.loadImage(from: imageSource) else { + continue + } + + let detected = BarcodeExtractor.extractFromImage( + image, + allowedFormats: allowedFormats + ) + + for barcode in detected where !barcode.value.isEmpty { + extractedBarcodes.append([ + "value": barcode.value, + "format": barcode.format, + "sourceImageIndex": sourceImageIndex + ]) + } + } + + DispatchQueue.main.async { + resolve(extractedBarcodes) + } + #else + DispatchQueue.main.async { + reject( + "barcode_not_enabled", + "Barcode extraction feature is disabled. Enable DOCUMENT_SCANNER_ENABLE_BARCODE=1 before pod install.", + nil + ) + } + #endif } } } diff --git a/src/NativeDocumentScanner.ts b/src/NativeDocumentScanner.ts index 2fedfe8..791d195 100644 --- a/src/NativeDocumentScanner.ts +++ b/src/NativeDocumentScanner.ts @@ -22,6 +22,40 @@ export interface ScanDocumentOptions { * @default ResponseType.ImageFilePath */ responseType?: ResponseType; + + /** + * When enabled, the module extracts barcodes from captured images as post-processing. + * @default false + */ + extractBarcodes?: boolean; + + /** + * Optional allow-list of normalized barcode formats for extraction. + * Effective only when barcode extraction is requested. + */ + barcodeFormats?: BarcodeFormat[]; +} + +/** + * Options for barcode extraction from existing images. + */ +export interface ExtractBarcodesFromImagesOptions { + /** + * Optional allow-list of normalized formats to detect. + * When omitted, all supported formats are scanned. + */ + barcodeFormats?: BarcodeFormat[]; +} + +/** + * Native request shape for extractBarcodesFromImages. + */ +export interface ExtractBarcodesFromImagesRequest + extends ExtractBarcodesFromImagesOptions { + /** + * Array of image sources. Each item can be a file path, file URI, or base64. + */ + images: string[]; } /** @@ -54,14 +88,51 @@ export enum ScanDocumentResponseStatus { Cancel = 'cancel', } +/** + * Normalized barcode format values exposed by the JS API. + */ +export type BarcodeFormat = + | 'aztec' + | 'codabar' + | 'code39' + | 'code93' + | 'code128' + | 'dataMatrix' + | 'ean8' + | 'ean13' + | 'itf' + | 'pdf417' + | 'qr' + | 'upca' + | 'upce' + | 'unknown'; + +/** + * Single extracted barcode result. + */ +export type Barcode = { + value: string; + format: BarcodeFormat; + sourceImageIndex: number; +}; + +/** + * Status for optional barcode extraction. + */ +export type BarcodeExtractionStatus = 'success' | 'not_enabled' | 'failed'; + type ScanDocumentSuccess = { status: ScanDocumentResponseStatus.Success; scannedImages: string[]; + barcodes?: Barcode[]; + barcodeExtractionStatus?: BarcodeExtractionStatus; }; type ScanDocumentCancel = { status: ScanDocumentResponseStatus.Cancel; scannedImages: []; + barcodes?: Barcode[]; + barcodeExtractionStatus?: BarcodeExtractionStatus; }; export type ScanDocumentResponse = ScanDocumentSuccess | ScanDocumentCancel; @@ -76,6 +147,15 @@ export interface Spec extends TurboModule { * @returns Promise with scan result. */ scanDocument(options: ScanDocumentOptions): Promise; + + /** + * Extracts barcodes from existing images without opening scanner UI. + * @param options Extraction request. + * @returns Promise with flattened barcode list. + */ + extractBarcodesFromImages( + options: ExtractBarcodesFromImagesRequest + ): Promise; } const DocumentScanner = diff --git a/src/index.tsx b/src/index.tsx index 606ebd4..16d00de 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,10 @@ import DocumentScanner, { ResponseType, ScanDocumentResponseStatus, + type Barcode, + type BarcodeExtractionStatus, + type BarcodeFormat, + type ExtractBarcodesFromImagesOptions, type ScanDocumentOptions, type ScanDocumentResponse, } from './NativeDocumentScanner'; @@ -14,10 +18,28 @@ export function scanDocument( return DocumentScanner.scanDocument(options); } +export function extractBarcodesFromImages( + images: string[], + options: ExtractBarcodesFromImagesOptions = {} +): Promise { + return DocumentScanner.extractBarcodesFromImages({ + images, + barcodeFormats: options.barcodeFormats, + }); +} + export { ResponseType, ScanDocumentResponseStatus }; -export type { ScanDocumentOptions, ScanDocumentResponse }; +export type { + Barcode, + BarcodeExtractionStatus, + BarcodeFormat, + ExtractBarcodesFromImagesOptions, + ScanDocumentOptions, + ScanDocumentResponse, +}; export default { + extractBarcodesFromImages, scanDocument, }; From 401979197459635cff09339accb99fb3900b442f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Eren=20Odac=C4=B1?= <33299067+ahmeterenodaci@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:44:22 +0300 Subject: [PATCH 04/17] chore: update ML Kit document scanner dependency to stable 16.0.0 (#163) (cherry picked from commit 868709cdd9b3d0412723197ed40feb6c1de3359b) --- android/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/build.gradle b/android/build.gradle index a6d5d88..41a1d6f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -87,7 +87,7 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "com.google.android.gms:play-services-mlkit-document-scanner:16.0.0-beta1" + implementation "com.google.android.gms:play-services-mlkit-document-scanner:16.0.0" if (barcodeFeatureEnabled) { implementation "com.google.mlkit:barcode-scanning:17.3.0" From 7733736766c466cc19b50038c91a14c5a263b802 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Sun, 22 Mar 2026 17:15:07 +0200 Subject: [PATCH 05/17] feat: split scan flow from barcode analysis and add analyzeScannedImages API --- README.md | 151 ++++++++--- .../scanner/DocumentScannerModule.kt | 236 +++++++++--------- ios/DocScanner/DocScanner.swift | 55 +--- ios/DocumentScanner.mm | 16 +- ios/DocumentScanner.swift | 149 ++++++----- src/NativeDocumentScanner.ts | 95 +++++-- src/index.tsx | 106 +++++++- 7 files changed, 497 insertions(+), 311 deletions(-) diff --git a/README.md b/README.md index f65def3..d73ef9e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Fork of [react-native-document-scanner-plugin](https://github.com/WebsiteBeaver/ ## Which package should I use? - **Use the upstream package** (`react-native-document-scanner-plugin`) if you want to stay on the original repository now that it also supports New Architecture. -- **Use this fork** (`@preeternal/react-native-document-scanner-plugin`) if you want quicker iteration on fixes, Expo/EAS build hardening, and a maintained release cadence. The public API is identical. +- **Use this fork** (`@preeternal/react-native-document-scanner-plugin`) if you want quicker iteration on fixes, Expo/EAS build hardening, and a maintained release cadence. The core `scanDocument(...)` API remains backward compatible. > **Attribution**: This package is a community‑maintained fork of the original project by **WebsiteBeaver**. Demo videos embedded below are from the original repository and are credited to their respective owners. @@ -169,8 +169,11 @@ export default () => { ## Documentation * [`scanDocument(...)`](#scandocument) +* [Response sanitization (since v0.2.2)](#response-sanitization-since-v022) * [Barcode extraction (optional feature)](#barcode-extraction-optional-feature) * [`extractBarcodesFromImages(...)`](#extractbarcodesfromimages) +* [`analyzeScannedImages(...)`](#analyzescannedimages) +* [`scanAndAnalyzeDocument(...)`](#scanandanalyzedocument) * [Interfaces](#interfaces) * [Enums](#enums) @@ -209,31 +212,17 @@ if (status === 'success' && scannedImages.length) { ### Barcode extraction (optional feature) -Barcode extraction is a post-processing stage that runs after document capture when you call: - -```ts -const result = await DocumentScanner.scanDocument({ - responseType: 'imageFilePath', - extractBarcodes: true, - barcodeFormats: ['ean13', 'itf'], -}) -``` - -You can also run barcode extraction directly on existing images: +Barcode extraction is an optional post-processing stage. `scanDocument(...)` stays fast and scan-only. +Run extraction after capture: ```ts +const scanResult = await DocumentScanner.scanDocument({ responseType: 'imageFilePath' }) const barcodes = await DocumentScanner.extractBarcodesFromImages( - scannedImages, - { barcodeFormats: ['ean13', 'itf'] } + scanResult.scannedImages, + { barcodeFormats: ['ean13', 'itf'], concurrency: 2 } ) ``` -`scanDocument` remains backward compatible: -- `status` and `scannedImages: string[]` are unchanged. -- Barcode data is returned in optional fields: - - `barcodes?: Barcode[]` - - `barcodeExtractionStatus?: 'success' | 'not_enabled' | 'failed'` - Barcode extraction is disabled by default at build time. Enable on Android (includes ML Kit barcode dependency only when enabled): @@ -255,8 +244,7 @@ cd ios DOCUMENT_SCANNER_ENABLE_BARCODE=1 pod install ``` -If `extractBarcodes: true` is requested but the feature is not enabled in the native build, scanning still succeeds and `barcodeExtractionStatus` is returned as `'not_enabled'`. -For `extractBarcodesFromImages(...)` in the same situation, the call rejects with `barcode_not_enabled`. +If barcode feature is not enabled in the native build, `extractBarcodesFromImages(...)` rejects with `barcode_not_enabled`. ### extractBarcodesFromImages(...) @@ -278,6 +266,55 @@ Extracts barcodes from existing images without opening scanner UI. -------------------- +### analyzeScannedImages(...) + +```typescript +analyzeScannedImages( + images: string[], + options: AnalyzeScannedImagesOptions +) => Promise +``` + +Universal post-processing over scanned images. +Right now barcode extraction is implemented; OCR/table/structured extractors are placeholders for future versions. + +```ts +const analysis = await DocumentScanner.analyzeScannedImages(scannedImages, { + extract: { barcodes: true }, + barcodeFormats: ['qr', 'ean13'], + concurrency: 2, +}) +``` + +**Returns:** Promise<AnalysisResult> + +-------------------- + +### scanAndAnalyzeDocument(...) + +```typescript +scanAndAnalyzeDocument( + options: ScanAndAnalyzeDocumentOptions +) => Promise +``` + +Convenience sugar for one-call flow: +`scanDocument(...) -> analyzeScannedImages(...)`. + +```ts +const result = await DocumentScanner.scanAndAnalyzeDocument({ + responseType: 'imageFilePath', + analysis: { + extract: { barcodes: true }, + barcodeFormats: ['qr'], + concurrency: 2, + }, +}) +``` + +**Returns:** Promise<ScanAndAnalyzeDocumentResponse> + +-------------------- ### Interfaces @@ -288,26 +325,72 @@ Extracts barcodes from existing images without opening scanner UI. | ------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | **`scannedImages`** | string[] | Array of valid file URIs or base64 strings (already sanitized by the module). | | **`status`** | ScanDocumentResponseStatus | The status lets you know if the document scan completes successfully, or if the user cancels before completing the document scan. | -| **`barcodes`** | Barcode[] | Optional list of extracted barcodes when `extractBarcodes` is requested and the native feature is enabled. | -| **`barcodeExtractionStatus`** | 'success' \\| 'not_enabled' \\| 'failed' | Optional status for barcode post-processing. | #### ScanDocumentOptions -| Prop | Type | Description | Default | -| ----------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -| **`croppedImageQuality`** | number | The quality of the cropped image from 0 - 100. 100 is the best quality. | : 100 | -| **`maxNumDocuments`** | number | Android only: The maximum number of photos an user can take (not counting photo retakes) | : undefined | -| **`responseType`** | ResponseType | The response comes back in this format on success. It can be the document scan image file paths or base64 images. | : ResponseType.ImageFilePath | -| **`extractBarcodes`** | boolean | Optional barcode extraction after document capture. Requires native barcode feature to be enabled at build time. | : false | -| **`barcodeFormats`** | Barcode['format'][] | Optional allow-list of normalized barcode formats used during extraction. When omitted, all supported formats are scanned. | : undefined | +| Prop | Type | Description | Default | +| ----------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| **`croppedImageQuality`** | number | The quality of the cropped image from 0 - 100. 100 is the best quality. | : 100 | +| **`maxNumDocuments`** | number | Android only: The maximum number of photos an user can take (not counting photo retakes). | : undefined | +| **`responseType`** | ResponseType | The response comes back in this format on success. It can be the document scan image file paths or base64 images. | : ResponseType.ImageFilePath | #### ExtractBarcodesFromImagesOptions -| Prop | Type | Description | Default | -| ------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------- | +| Prop | Type | Description | Default | +| ------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------- | | **`barcodeFormats`** | Barcode['format'][] | Optional allow-list of normalized barcode formats used during extraction. When omitted, all supported formats are scanned. | : undefined | +| **`concurrency`** | 1 \\| 2 | Maximum native worker concurrency for barcode extraction. Native implementations clamp values to 1..2. | : 2 | + + +#### AnalyzeExtractOptions + +| Prop | Type | Description | +| ------------------- | ------------------ | ----------- | +| **`barcodes`** | boolean | Enable barcode extraction stage. | +| **`text`** | boolean | Reserved for future OCR extraction. | +| **`tables`** | boolean | Reserved for future table extraction. | +| **`structuredData`**| boolean | Reserved for future structured document extraction. | + + +#### AnalyzeScannedImagesOptions + +| Prop | Type | Description | +| ------------------- | -------------------------------------------------------------- | ----------- | +| **`extract`** | AnalyzeExtractOptions | Extractor toggles for image analysis. | +| **`barcodeFormats`**| Barcode['format'][] | Optional barcode format allow-list for barcode stage. | +| **`concurrency`** | 1 \\| 2 | Optional native worker concurrency for barcode stage. | + + +#### AnalysisResult + +| Prop | Type | Description | +| ------------------- | ------------------------------------------------------------------------------------- | ----------- | +| **`status`** | 'success' \\| 'partial' \\| 'failed' \\| 'not_enabled' | Aggregate status of requested analysis stages. | +| **`barcodes`** | Barcode[] | Extracted barcodes when requested and available. | +| **`text`** | { text: string; sourceImageIndex: number; confidence?: number }[] | Reserved placeholder for OCR blocks. | +| **`tables`** | { rows: string[][]; sourceImageIndex: number }[] | Reserved placeholder for table extraction. | +| **`structuredData`**| Record<string, unknown> | Reserved placeholder for structured extraction. | + + +#### ScanAndAnalyzeDocumentOptions + +| Prop | Type | Description | +| ------------------- | -------------------------------------------------------------------- | ----------- | +| **`analysis`** | AnalyzeScannedImagesOptions | Analysis options for post-processing stage. | +| **`croppedImageQuality`** | number | Same as ScanDocumentOptions. | +| **`maxNumDocuments`** | number | Same as ScanDocumentOptions (Android only). | +| **`responseType`** | ResponseType | Same as ScanDocumentOptions. | + + +#### ScanAndAnalyzeDocumentResponse + +| Prop | Type | Description | +| ------------------- | --------------------------------------------------------------------------------- | ----------- | +| **`status`** | ScanDocumentResponseStatus | Scan status. | +| **`scannedImages`** | string[] | Captured and sanitized images. | +| **`analysis`** | AnalysisResult | Post-processing result payload. | #### Barcode @@ -395,7 +478,7 @@ export default () => { ## Migrating between upstream and this fork -Both packages expose the same public API. To switch: +Both packages are compatible for the core scan flow (`scanDocument(...)`). To switch: - **From this fork to upstream** ```bash diff --git a/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt b/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt index f0e3676..548937b 100644 --- a/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt +++ b/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt @@ -29,6 +29,10 @@ import com.preeternal.scanner.barcode.BarcodeResult import java.io.ByteArrayOutputStream import java.io.FileNotFoundException import java.lang.ref.WeakReference +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit @ReactModule(name = DocumentScannerModule.NAME) class DocumentScannerModule(reactContext: ReactApplicationContext) : @@ -47,8 +51,6 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : private var pendingPromise: Promise? = null private var pendingOptions: ReadableMap? = null private var pendingQuality: Int = 100 - private var pendingExtractBarcodes: Boolean = false - private var pendingBarcodeFormats: Set = emptySet() private var scanner: GmsDocumentScanner? = null private var hostActivityRef: WeakReference? = null @@ -72,11 +74,7 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : pendingPromise = promise pendingOptions = options - // Keep original image-quality behavior for base64 responses. pendingQuality = if (options.hasKey("croppedImageQuality")) options.getInt("croppedImageQuality") else 100 - // Barcode extraction is opt-in and post-processing only. - pendingExtractBarcodes = options.hasKey("extractBarcodes") && options.getBoolean("extractBarcodes") - pendingBarcodeFormats = parseAllowedFormats(getArrayOrNull(options, "barcodeFormats")) hostActivityRef = WeakReference(componentActivity) ensureSystemBarsVisible(componentActivity) @@ -102,17 +100,16 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : } val allowedFormats = parseAllowedFormats(getArrayOrNull(options, "barcodeFormats")) - val barcodes = WritableNativeArray() + val requestedConcurrency = getIntOrNull(options, "concurrency") ?: 2 + val concurrency = requestedConcurrency.coerceIn(1, 2) - processInputImages( + extractBarcodesInParallel( context = reactApplicationContext, images = images, - imageIndex = 0, allowedFormats = allowedFormats, - barcodes = barcodes - ) { - promise.resolve(barcodes) - } + concurrency = concurrency, + promise = promise + ) } private fun initScanner(options: ReadableMap) { @@ -136,27 +133,18 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : val options = pendingOptions val response = WritableNativeMap() val images = WritableNativeArray() - val barcodes = WritableNativeArray() - val shouldExtractBarcodes = pendingExtractBarcodes - val requestedBarcodeFormats = pendingBarcodeFormats - // Feature can be requested at runtime but still disabled at build time. - val canExtractBarcodes = shouldExtractBarcodes && barcodeExtractor.isFeatureEnabled() if (result.resultCode == Activity.RESULT_OK) { val docResult = GmsDocumentScanningResult.fromActivityResultIntent(result.data) val pages = docResult?.pages.orEmpty() val responseType = options?.getString("responseType")?.lowercase() - // Process pages sequentially to keep deterministic sourceImageIndex mapping. processPages( activity = activity, pages = pages, pageIndex = 0, responseType = responseType, images = images, - shouldExtractBarcodes = canExtractBarcodes, - allowedFormats = requestedBarcodeFormats, - barcodes = barcodes, onError = { errorMessage -> promise.reject("document_scan_error", errorMessage, null) clearPending() @@ -164,14 +152,6 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : onComplete = { response.putString("status", "success") response.putArray("scannedImages", images) - if (shouldExtractBarcodes) { - if (canExtractBarcodes) { - response.putArray("barcodes", barcodes) - response.putString("barcodeExtractionStatus", "success") - } else { - response.putString("barcodeExtractionStatus", "not_enabled") - } - } promise.resolve(response) clearPending() } @@ -179,9 +159,6 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : } else { response.putString("status", "cancel") response.putArray("scannedImages", images) - if (shouldExtractBarcodes && !canExtractBarcodes) { - response.putString("barcodeExtractionStatus", "not_enabled") - } promise.resolve(response) clearPending() } @@ -194,9 +171,6 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : pageIndex: Int, responseType: String?, images: WritableNativeArray, - shouldExtractBarcodes: Boolean, - allowedFormats: Set, - barcodes: WritableNativeArray, onError: (String) -> Unit, onComplete: () -> Unit ) { @@ -221,59 +195,22 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : pageIndex = pageIndex + 1, responseType = responseType, images = images, - shouldExtractBarcodes = shouldExtractBarcodes, - allowedFormats = allowedFormats, - barcodes = barcodes, onError = onError, onComplete = onComplete ) return } - val sourceImageIndex = images.size() images.pushString(outputImage) - - // Preserve original behavior when barcode extraction is not requested. - if (!shouldExtractBarcodes) { - processPages( - activity = activity, - pages = pages, - pageIndex = pageIndex + 1, - responseType = responseType, - images = images, - shouldExtractBarcodes = false, - allowedFormats = allowedFormats, - barcodes = barcodes, - onError = onError, - onComplete = onComplete - ) - return - } - - barcodeExtractor.extractFromSource( - context = activity, - imageSource = uri.toString(), - sourceImageIndex = sourceImageIndex, - allowedFormats = allowedFormats - ) { detected -> - // Emit normalized barcode payloads with image index linkage. - for (barcode in detected) { - barcodes.pushMap(toWritableBarcode(barcode)) - } - - processPages( - activity = activity, - pages = pages, - pageIndex = pageIndex + 1, - responseType = responseType, - images = images, - shouldExtractBarcodes = true, - allowedFormats = allowedFormats, - barcodes = barcodes, - onError = onError, - onComplete = onComplete - ) - } + processPages( + activity = activity, + pages = pages, + pageIndex = pageIndex + 1, + responseType = responseType, + images = images, + onError = onError, + onComplete = onComplete + ) } private fun mapOutputImage( @@ -298,51 +235,114 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : return map } - private fun processInputImages( + private data class IndexedImageSource( + val sourceImageIndex: Int, + val imageSource: String + ) + + private fun extractBarcodesInParallel( context: ReactApplicationContext, images: ReadableArray, - imageIndex: Int, allowedFormats: Set, - barcodes: WritableNativeArray, - onComplete: () -> Unit + concurrency: Int, + promise: Promise ) { - if (imageIndex >= images.size()) { - onComplete() - return + val validSources = mutableListOf() + for (index in 0 until images.size()) { + val imageSource = readStringAt(images, index) + if (!imageSource.isNullOrBlank()) { + validSources.add( + IndexedImageSource( + sourceImageIndex = index, + imageSource = imageSource + ) + ) + } } - val imageSource = readStringAt(images, imageIndex) - if (imageSource.isNullOrBlank()) { - processInputImages( - context = context, - images = images, - imageIndex = imageIndex + 1, - allowedFormats = allowedFormats, - barcodes = barcodes, - onComplete = onComplete - ) + if (validSources.isEmpty()) { + promise.resolve(WritableNativeArray()) return } + val workerPool = Executors.newFixedThreadPool(concurrency) + val completionLatch = CountDownLatch(validSources.size) + val collected = Collections.synchronizedList(mutableListOf()) + + for (entry in validSources) { + workerPool.execute { + try { + val detected = extractBarcodesForSource( + context = context, + imageSource = entry.imageSource, + sourceImageIndex = entry.sourceImageIndex, + allowedFormats = allowedFormats + ) + if (detected.isNotEmpty()) { + collected.addAll(detected) + } + } finally { + completionLatch.countDown() + } + } + } + + Thread { + try { + completionLatch.await() + + val sorted = collected.sortedWith( + compareBy({ it.sourceImageIndex }, { it.value }, { it.format }) + ) + val payload = WritableNativeArray() + for (barcode in sorted) { + payload.pushMap(toWritableBarcode(barcode)) + } + + reactApplicationContext.runOnUiQueueThread { + promise.resolve(payload) + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + reactApplicationContext.runOnUiQueueThread { + promise.reject("barcode_extraction_error", "Barcode extraction interrupted", e) + } + } catch (e: Exception) { + reactApplicationContext.runOnUiQueueThread { + promise.reject("barcode_extraction_error", e.message, e) + } + } finally { + workerPool.shutdown() + } + }.start() + } + + private fun extractBarcodesForSource( + context: ReactApplicationContext, + imageSource: String, + sourceImageIndex: Int, + allowedFormats: Set + ): List { + var detectedResults: List = emptyList() + val extractionLatch = CountDownLatch(1) + barcodeExtractor.extractFromSource( context = context, imageSource = imageSource, - sourceImageIndex = imageIndex, + sourceImageIndex = sourceImageIndex, allowedFormats = allowedFormats ) { detected -> - for (barcode in detected) { - barcodes.pushMap(toWritableBarcode(barcode)) - } + detectedResults = detected + extractionLatch.countDown() + } - processInputImages( - context = context, - images = images, - imageIndex = imageIndex + 1, - allowedFormats = allowedFormats, - barcodes = barcodes, - onComplete = onComplete - ) + try { + extractionLatch.await(20, TimeUnit.SECONDS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() } + + return detectedResults } private fun parseAllowedFormats(rawFormats: ReadableArray?): Set { @@ -381,6 +381,18 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : } } + private fun getIntOrNull(map: ReadableMap, key: String): Int? { + return try { + if (!map.hasKey(key) || map.isNull(key)) { + null + } else { + map.getInt(key) + } + } catch (_: Exception) { + null + } + } + private fun startScan(activity: Activity) { val currentScanner = scanner if (currentScanner == null) { @@ -418,8 +430,6 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : pendingPromise = null pendingOptions = null pendingQuality = 100 - pendingExtractBarcodes = false - pendingBarcodeFormats = emptySet() restoreSystemBars() } diff --git a/ios/DocScanner/DocScanner.swift b/ios/DocScanner/DocScanner.swift index e771fb7..03eca37 100644 --- a/ios/DocScanner/DocScanner.swift +++ b/ios/DocScanner/DocScanner.swift @@ -2,8 +2,8 @@ import UIKit import VisionKit /** - This class uses VisionKit to start a document scan. It returns scanned images in base64 or file-path format - and can optionally attach barcode extraction results for each scanned page. + This class uses VisionKit to start a document scan and returns scanned images + in base64 or file-path format. */ @available(iOS 13.0, *) public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { @@ -14,8 +14,6 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { private var cancelHandler: () -> Void private var responseType: String private var croppedImageQuality: Int - private var extractBarcodes: Bool - private var barcodeFormats: [String] public init( _ viewController: UIViewController? = nil, @@ -23,9 +21,7 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { errorHandler: @escaping (String) -> Void = { _ in }, cancelHandler: @escaping () -> Void = {}, responseType: String = ResponseType.imageFilePath, - croppedImageQuality: Int = 100, - extractBarcodes: Bool = false, - barcodeFormats: [String] = [] + croppedImageQuality: Int = 100 ) { self.viewController = viewController self.successHandler = successHandler @@ -33,8 +29,6 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { self.cancelHandler = cancelHandler self.responseType = responseType self.croppedImageQuality = croppedImageQuality - self.extractBarcodes = extractBarcodes - self.barcodeFormats = barcodeFormats } public convenience override init() { @@ -42,14 +36,12 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { } public func startScan() { - // Make sure the device supports document scanning. if !VNDocumentCameraViewController.isSupported { self.errorHandler("Document scanning is not supported on this device") return } DispatchQueue.main.async { - // Launch the native document scanner UI. let documentCameraViewController = VNDocumentCameraViewController() documentCameraViewController.delegate = self self.viewController?.present(documentCameraViewController, animated: true) @@ -62,9 +54,7 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { errorHandler: @escaping (String) -> Void = { _ in }, cancelHandler: @escaping () -> Void = {}, responseType: String? = ResponseType.imageFilePath, - croppedImageQuality: Int? = 100, - extractBarcodes: Bool = false, - barcodeFormats: [String] = [] + croppedImageQuality: Int? = 100 ) { self.viewController = viewController self.successHandler = successHandler @@ -72,8 +62,6 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { self.cancelHandler = cancelHandler self.responseType = responseType ?? ResponseType.imageFilePath self.croppedImageQuality = croppedImageQuality ?? 100 - self.extractBarcodes = extractBarcodes - self.barcodeFormats = barcodeFormats self.startScan() } @@ -84,11 +72,9 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { ) { var processedResults: [[String: Any]] = [] - // Process every scanned page and produce a normalized page payload. for pageNumber in 0 ..< scan.pageCount { let scannedImage: UIImage = scan.imageOfPage(at: pageNumber) - // Convert UIImage to JPEG data based on requested quality. guard let scannedDocumentImage: Data = scannedImage .jpegData(compressionQuality: CGFloat(self.croppedImageQuality) / CGFloat(100)) else { goBackToPreviousView(controller) @@ -99,11 +85,9 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { let imageIdentifier: String switch responseType { case ResponseType.base64: - // Return page as base64. imageIdentifier = scannedDocumentImage.base64EncodedString() case ResponseType.imageFilePath: do { - // Persist page to disk and return file URI. let croppedImageFilePath = FileUtil().createImageFile(pageNumber) try scannedDocumentImage.write(to: croppedImageFilePath) imageIdentifier = croppedImageFilePath.absoluteString @@ -118,36 +102,11 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { return } - var pageResult: [String: Any] = [ + processedResults.append([ "image": imageIdentifier - ] - - if extractBarcodes { - // Barcode extraction is optional and controlled by compile-time feature flags. - if BarcodeFeatureFlags.isEnabled { - #if DOCUMENT_SCANNER_ENABLE_BARCODE - let extracted = BarcodeExtractor.extractFromImage( - scannedImage, - allowedFormats: barcodeFormats - ) - pageResult["barcodes"] = extracted.map { - [ - "value": $0.value, - "format": $0.format - ] - } - #else - pageResult["barcodes"] = [] - #endif - } else { - pageResult["barcodes"] = [] - } - } - - processedResults.append(pageResult) + ]) } - // Exit scanner UI and return payload. goBackToPreviousView(controller) self.successHandler(processedResults) } @@ -163,13 +122,11 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { _ controller: VNDocumentCameraViewController, didFailWithError error: Error ) { - // Exit scanner UI and return the native error message. goBackToPreviousView(controller) self.errorHandler(error.localizedDescription) } private func goBackToPreviousView(_ controller: VNDocumentCameraViewController) { - // Return to the screen that initiated scanning. DispatchQueue.main.async { controller.dismiss(animated: true) } diff --git a/ios/DocumentScanner.mm b/ios/DocumentScanner.mm index c3234c6..33332e1 100644 --- a/ios/DocumentScanner.mm +++ b/ios/DocumentScanner.mm @@ -55,19 +55,6 @@ - (void)scanDocument:(JS::NativeDocumentScanner::ScanDocumentOptions &)options if (options.maxNumDocuments().has_value()) { dict[@"maxNumDocuments"] = @(options.maxNumDocuments().value()); } - if (options.extractBarcodes().has_value()) { - dict[@"extractBarcodes"] = @(options.extractBarcodes().value()); - } - if (options.barcodeFormats().has_value()) { - auto formats = options.barcodeFormats().value(); - NSMutableArray *mappedFormats = [NSMutableArray arrayWithCapacity:formats.size()]; - for (const auto &format : formats) { - if (format != nil) { - [mappedFormats addObject:format]; - } - } - dict[@"barcodeFormats"] = mappedFormats; - } [self handleScanWithOptions:dict resolve:resolve reject:reject]; } @@ -97,6 +84,9 @@ - (void)extractBarcodesFromImages: } dict[@"barcodeFormats"] = mappedFormats; } + if (options.concurrency().has_value()) { + dict[@"concurrency"] = @(options.concurrency().value()); + } [self handleBarcodeExtractionWithOptions:dict resolve:resolve reject:reject]; } diff --git a/ios/DocumentScanner.swift b/ios/DocumentScanner.swift index b7124f4..0badb4e 100644 --- a/ios/DocumentScanner.swift +++ b/ios/DocumentScanner.swift @@ -5,10 +5,6 @@ import React @objc(DocumentScannerImpl) public class DocumentScannerImpl: NSObject { private var docScanner: DocScanner? - private let barcodeQueue = DispatchQueue( - label: "com.preeternal.document-scanner.barcode", - qos: .userInitiated - ) @objc static func requiresMainQueueSetup() -> Bool { true } @@ -27,9 +23,6 @@ public class DocumentScannerImpl: NSObject { let responseType = opts["responseType"] as? String let quality = opts["croppedImageQuality"] as? Int let isBase64Response = responseType?.lowercased() == "base64" - let shouldExtractBarcodes = opts["extractBarcodes"] as? Bool ?? false - let barcodeFormats = (opts["barcodeFormats"] as? [Any] ?? []) - .compactMap { $0 as? String } DispatchQueue.main.async { self.docScanner = DocScanner() @@ -38,9 +31,7 @@ public class DocumentScannerImpl: NSObject { successHandler: { (scannedData: [[String: Any]]) in let fm = FileManager.default var sanitizedImages: [String] = [] - var extractedBarcodes: [[String: Any]] = [] - // Keep the same sanitization guarantees as the original API. for item in scannedData { guard let rawImage = item["image"] as? String else { continue } let trimmed = rawImage.trimmingCharacters(in: .whitespacesAndNewlines) @@ -58,49 +49,13 @@ public class DocumentScannerImpl: NSObject { } } - let sourceImageIndex = sanitizedImages.count sanitizedImages.append(trimmed) - - guard shouldExtractBarcodes && BarcodeFeatureFlags.isEnabled else { - continue - } - - guard let pageBarcodes = item["barcodes"] as? [[String: Any]] else { - continue - } - - // Flatten page-local barcodes into API-level barcodes with sourceImageIndex. - for barcode in pageBarcodes { - guard let value = barcode["value"] as? String, - !value.isEmpty else { - continue - } - - let format = (barcode["format"] as? String) ?? "unknown" - extractedBarcodes.append([ - "value": value, - "format": format, - "sourceImageIndex": sourceImageIndex - ]) - } } - var payload: [String: Any] = [ + resolve([ "status": "success", "scannedImages": sanitizedImages - ] - - // Barcode payload is optional and only returned when explicitly requested. - if shouldExtractBarcodes { - if BarcodeFeatureFlags.isEnabled { - payload["barcodes"] = extractedBarcodes - payload["barcodeExtractionStatus"] = "success" - } else { - payload["barcodeExtractionStatus"] = "not_enabled" - } - } - - resolve(payload) + ]) self.docScanner = nil }, errorHandler: { msg in @@ -108,20 +63,14 @@ public class DocumentScannerImpl: NSObject { self.docScanner = nil }, cancelHandler: { - var payload: [String: Any] = [ + resolve([ "status": "cancel", "scannedImages": [] - ] - if shouldExtractBarcodes && !BarcodeFeatureFlags.isEnabled { - payload["barcodeExtractionStatus"] = "not_enabled" - } - resolve(payload) + ]) self.docScanner = nil }, responseType: responseType, - croppedImageQuality: quality, - extractBarcodes: shouldExtractBarcodes, - barcodeFormats: barcodeFormats + croppedImageQuality: quality ) } } @@ -151,22 +100,36 @@ public class DocumentScannerImpl: NSObject { let allowedFormats = (opts["barcodeFormats"] as? [Any] ?? []) .compactMap { $0 as? String } + let requestedConcurrency = opts["concurrency"] as? Int ?? 2 + let concurrency = max(1, min(2, requestedConcurrency)) + if rawImages.isEmpty { resolve([]) return } - barcodeQueue.async { - #if DOCUMENT_SCANNER_ENABLE_BARCODE - var extractedBarcodes: [[String: Any]] = [] + #if DOCUMENT_SCANNER_ENABLE_BARCODE + let queue = OperationQueue() + queue.name = "com.preeternal.document-scanner.barcode" + queue.qualityOfService = .userInitiated + queue.maxConcurrentOperationCount = concurrency - for (sourceImageIndex, source) in rawImages.enumerated() { - guard let imageSource = source as? String else { - continue - } + let lock = NSLock() + let group = DispatchGroup() + var extractedBarcodes: [[String: Any]] = [] + + for (sourceImageIndex, source) in rawImages.enumerated() { + guard let imageSource = source as? String, + !imageSource.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + continue + } + + group.enter() + queue.addOperation { + defer { group.leave() } guard let image = BarcodeImageSource.loadImage(from: imageSource) else { - continue + return } let detected = BarcodeExtractor.extractFromImage( @@ -174,27 +137,55 @@ public class DocumentScannerImpl: NSObject { allowedFormats: allowedFormats ) - for barcode in detected where !barcode.value.isEmpty { - extractedBarcodes.append([ - "value": barcode.value, + guard !detected.isEmpty else { + return + } + + let mapped = detected.compactMap { barcode -> [String: Any]? in + let value = barcode.value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { + return nil + } + + return [ + "value": value, "format": barcode.format, "sourceImageIndex": sourceImageIndex - ]) + ] + } + + guard !mapped.isEmpty else { + return } - } - DispatchQueue.main.async { - resolve(extractedBarcodes) + lock.lock() + extractedBarcodes.append(contentsOf: mapped) + lock.unlock() } - #else - DispatchQueue.main.async { - reject( - "barcode_not_enabled", - "Barcode extraction feature is disabled. Enable DOCUMENT_SCANNER_ENABLE_BARCODE=1 before pod install.", - nil - ) + } + + group.notify(queue: .main) { + let sorted = extractedBarcodes.sorted { lhs, rhs in + let lhsIndex = lhs["sourceImageIndex"] as? Int ?? Int.max + let rhsIndex = rhs["sourceImageIndex"] as? Int ?? Int.max + + if lhsIndex != rhsIndex { + return lhsIndex < rhsIndex + } + + let lhsValue = lhs["value"] as? String ?? "" + let rhsValue = rhs["value"] as? String ?? "" + return lhsValue < rhsValue } - #endif + + resolve(sorted) } + #else + reject( + "barcode_not_enabled", + "Barcode extraction feature is disabled. Enable DOCUMENT_SCANNER_ENABLE_BARCODE=1 before pod install.", + nil + ) + #endif } } diff --git a/src/NativeDocumentScanner.ts b/src/NativeDocumentScanner.ts index 791d195..5e9b711 100644 --- a/src/NativeDocumentScanner.ts +++ b/src/NativeDocumentScanner.ts @@ -22,20 +22,13 @@ export interface ScanDocumentOptions { * @default ResponseType.ImageFilePath */ responseType?: ResponseType; - - /** - * When enabled, the module extracts barcodes from captured images as post-processing. - * @default false - */ - extractBarcodes?: boolean; - - /** - * Optional allow-list of normalized barcode formats for extraction. - * Effective only when barcode extraction is requested. - */ - barcodeFormats?: BarcodeFormat[]; } +/** + * Supported worker concurrency for image analysis. + */ +export type AnalysisConcurrency = 1 | 2; + /** * Options for barcode extraction from existing images. */ @@ -45,6 +38,12 @@ export interface ExtractBarcodesFromImagesOptions { * When omitted, all supported formats are scanned. */ barcodeFormats?: BarcodeFormat[]; + + /** + * Maximum native worker concurrency. The implementation clamps this value to 1..2. + * @default 2 + */ + concurrency?: AnalysisConcurrency; } /** @@ -117,26 +116,86 @@ export type Barcode = { }; /** - * Status for optional barcode extraction. + * Placeholder shape for future OCR text blocks. + */ +export type TextBlock = { + text: string; + sourceImageIndex: number; + confidence?: number; +}; + +/** + * Placeholder shape for future extracted tables. */ -export type BarcodeExtractionStatus = 'success' | 'not_enabled' | 'failed'; +export type TableBlock = { + rows: string[][]; + sourceImageIndex: number; +}; + +/** + * Extractor toggles for universal post-processing. + */ +export type AnalyzeExtractOptions = { + barcodes?: boolean; + text?: boolean; + tables?: boolean; + structuredData?: boolean; +}; + +/** + * Options for universal post-processing across scanned images. + */ +export interface AnalyzeScannedImagesOptions + extends ExtractBarcodesFromImagesOptions { + extract: AnalyzeExtractOptions; +} + +/** + * Status returned by universal post-processing. + */ +export type AnalysisResultStatus = + | 'success' + | 'partial' + | 'failed' + | 'not_enabled'; + +/** + * Universal post-processing response. + */ +export type AnalysisResult = { + status: AnalysisResultStatus; + barcodes?: Barcode[]; + text?: TextBlock[]; + tables?: TableBlock[]; + structuredData?: Record; +}; type ScanDocumentSuccess = { status: ScanDocumentResponseStatus.Success; scannedImages: string[]; - barcodes?: Barcode[]; - barcodeExtractionStatus?: BarcodeExtractionStatus; }; type ScanDocumentCancel = { status: ScanDocumentResponseStatus.Cancel; scannedImages: []; - barcodes?: Barcode[]; - barcodeExtractionStatus?: BarcodeExtractionStatus; }; export type ScanDocumentResponse = ScanDocumentSuccess | ScanDocumentCancel; +/** + * Convenience options for one-shot scan + analysis. + */ +export interface ScanAndAnalyzeDocumentOptions extends ScanDocumentOptions { + analysis: AnalyzeScannedImagesOptions; +} + +/** + * Convenience response for one-shot scan + analysis. + */ +export type ScanAndAnalyzeDocumentResponse = ScanDocumentResponse & { + analysis: AnalysisResult; +}; + /** * TurboModule spec. */ diff --git a/src/index.tsx b/src/index.tsx index 16d00de..d9f65d0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,14 +1,39 @@ import DocumentScanner, { ResponseType, ScanDocumentResponseStatus, + type AnalysisResult, + type AnalyzeScannedImagesOptions, type Barcode, - type BarcodeExtractionStatus, - type BarcodeFormat, type ExtractBarcodesFromImagesOptions, + type ScanAndAnalyzeDocumentOptions, + type ScanAndAnalyzeDocumentResponse, type ScanDocumentOptions, type ScanDocumentResponse, } from './NativeDocumentScanner'; +const DEFAULT_ANALYSIS_CONCURRENCY = 2; + +function clampAnalysisConcurrency( + value: number | undefined +): 1 | 2 | undefined { + if (value === undefined) { + return undefined; + } + + return value <= 1 ? 1 : 2; +} + +function normalizeBarcodeOptions( + options: ExtractBarcodesFromImagesOptions = {} +): ExtractBarcodesFromImagesOptions { + return { + barcodeFormats: options.barcodeFormats, + concurrency: + options.concurrency ?? + clampAnalysisConcurrency(DEFAULT_ANALYSIS_CONCURRENCY), + }; +} + export function scanDocument( options: ScanDocumentOptions = {} ): Promise { @@ -22,24 +47,95 @@ export function extractBarcodesFromImages( images: string[], options: ExtractBarcodesFromImagesOptions = {} ): Promise { + const normalizedOptions = normalizeBarcodeOptions(options); + return DocumentScanner.extractBarcodesFromImages({ images, - barcodeFormats: options.barcodeFormats, + barcodeFormats: normalizedOptions.barcodeFormats, + concurrency: normalizedOptions.concurrency, }); } +function getErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== 'object') { + return undefined; + } + + const candidate = (error as { code?: unknown }).code; + return typeof candidate === 'string' ? candidate : undefined; +} + +export async function analyzeScannedImages( + images: string[], + options: AnalyzeScannedImagesOptions +): Promise { + const extract = options.extract ?? {}; + const wantsBarcodes = !!extract.barcodes; + const wantsText = !!extract.text; + const wantsTables = !!extract.tables; + const wantsStructuredData = !!extract.structuredData; + const wantsUnsupported = wantsText || wantsTables || wantsStructuredData; + + if (!wantsBarcodes && !wantsUnsupported) { + return { status: 'success' }; + } + + if (!wantsBarcodes && wantsUnsupported) { + return { status: 'not_enabled' }; + } + + try { + const barcodes = await extractBarcodesFromImages(images, { + barcodeFormats: options.barcodeFormats, + concurrency: options.concurrency, + }); + + return { + status: wantsUnsupported ? 'partial' : 'success', + barcodes, + }; + } catch (error) { + const code = getErrorCode(error); + if (code === 'barcode_not_enabled') { + return { status: 'not_enabled' }; + } + + return { status: 'failed' }; + } +} + +export async function scanAndAnalyzeDocument( + options: ScanAndAnalyzeDocumentOptions +): Promise { + const { analysis, ...scanOptions } = options; + const scanResult = await scanDocument(scanOptions); + const analysisResult = await analyzeScannedImages( + scanResult.scannedImages, + analysis + ); + + return { + ...scanResult, + analysis: analysisResult, + }; +} + export { ResponseType, ScanDocumentResponseStatus }; export type { + AnalysisResult, + AnalyzeScannedImagesOptions, Barcode, - BarcodeExtractionStatus, - BarcodeFormat, ExtractBarcodesFromImagesOptions, + ScanAndAnalyzeDocumentOptions, + ScanAndAnalyzeDocumentResponse, ScanDocumentOptions, ScanDocumentResponse, }; export default { + analyzeScannedImages, extractBarcodesFromImages, + scanAndAnalyzeDocument, scanDocument, }; From 5203fba04372ac50c7d410afb1ae0b052857e7a4 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Sun, 22 Mar 2026 17:32:45 +0200 Subject: [PATCH 06/17] refactor(native): adopt lifecycle-safe concurrent barcode extraction (coroutines&queue invalidation) --- .../scanner/DocumentScannerModule.kt | 181 ++++++++++-------- ios/DocumentScanner.mm | 5 + ios/DocumentScanner.swift | 37 +++- 3 files changed, 132 insertions(+), 91 deletions(-) diff --git a/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt b/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt index 548937b..6d956ac 100644 --- a/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt +++ b/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt @@ -29,10 +29,20 @@ import com.preeternal.scanner.barcode.BarcodeResult import java.io.ByteArrayOutputStream import java.io.FileNotFoundException import java.lang.ref.WeakReference -import java.util.Collections -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit @ReactModule(name = DocumentScannerModule.NAME) class DocumentScannerModule(reactContext: ReactApplicationContext) : @@ -41,10 +51,12 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : companion object { const val NAME = "DocumentScanner" private const val ANDROID_15_API = 35 + private const val BARCODE_EXTRACTION_TIMEOUT_MS = 20_000L } override fun getName(): String = NAME + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val barcodeExtractor: BarcodeExtractor = BarcodeExtractorImpl() private var launcher: ActivityResultLauncher? = null @@ -102,14 +114,30 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : val allowedFormats = parseAllowedFormats(getArrayOrNull(options, "barcodeFormats")) val requestedConcurrency = getIntOrNull(options, "concurrency") ?: 2 val concurrency = requestedConcurrency.coerceIn(1, 2) + val validSources = buildValidImageSources(images) - extractBarcodesInParallel( - context = reactApplicationContext, - images = images, - allowedFormats = allowedFormats, - concurrency = concurrency, - promise = promise - ) + if (validSources.isEmpty()) { + promise.resolve(WritableNativeArray()) + return + } + + scope.launch { + try { + val extracted = extractBarcodesInParallel( + context = reactApplicationContext, + sources = validSources, + allowedFormats = allowedFormats, + concurrency = concurrency + ) + + val payload = toWritableBarcodeArray(extracted) + resolveOnUi(promise, payload) + } catch (cancelled: CancellationException) { + rejectOnUi(promise, "barcode_extraction_cancelled", "Barcode extraction cancelled", cancelled) + } catch (error: Exception) { + rejectOnUi(promise, "barcode_extraction_error", error.message ?: "Barcode extraction failed", error) + } + } } private fun initScanner(options: ReadableMap) { @@ -218,7 +246,6 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : uri: Uri, responseType: String? ): String? { - // Keep existing API contract: scannedImages is base64[] or uri[] depending on responseType. return if (responseType == "base64") { val encoded = uriToBase64(activity, uri, pendingQuality) if (encoded.isBlank()) null else encoded @@ -240,13 +267,7 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : val imageSource: String ) - private fun extractBarcodesInParallel( - context: ReactApplicationContext, - images: ReadableArray, - allowedFormats: Set, - concurrency: Int, - promise: Promise - ) { + private fun buildValidImageSources(images: ReadableArray): List { val validSources = mutableListOf() for (index in 0 until images.size()) { val imageSource = readStringAt(images, index) @@ -259,90 +280,77 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : ) } } + return validSources + } - if (validSources.isEmpty()) { - promise.resolve(WritableNativeArray()) - return - } - - val workerPool = Executors.newFixedThreadPool(concurrency) - val completionLatch = CountDownLatch(validSources.size) - val collected = Collections.synchronizedList(mutableListOf()) - - for (entry in validSources) { - workerPool.execute { - try { - val detected = extractBarcodesForSource( + private suspend fun extractBarcodesInParallel( + context: ReactApplicationContext, + sources: List, + allowedFormats: Set, + concurrency: Int + ): List = coroutineScope { + val limiter = Semaphore(concurrency) + + val tasks = sources.map { source -> + async { + limiter.withPermit { + extractBarcodesForSource( context = context, - imageSource = entry.imageSource, - sourceImageIndex = entry.sourceImageIndex, + imageSource = source.imageSource, + sourceImageIndex = source.sourceImageIndex, allowedFormats = allowedFormats ) - if (detected.isNotEmpty()) { - collected.addAll(detected) - } - } finally { - completionLatch.countDown() } } } - Thread { - try { - completionLatch.await() - - val sorted = collected.sortedWith( - compareBy({ it.sourceImageIndex }, { it.value }, { it.format }) - ) - val payload = WritableNativeArray() - for (barcode in sorted) { - payload.pushMap(toWritableBarcode(barcode)) - } - - reactApplicationContext.runOnUiQueueThread { - promise.resolve(payload) - } - } catch (e: InterruptedException) { - Thread.currentThread().interrupt() - reactApplicationContext.runOnUiQueueThread { - promise.reject("barcode_extraction_error", "Barcode extraction interrupted", e) - } - } catch (e: Exception) { - reactApplicationContext.runOnUiQueueThread { - promise.reject("barcode_extraction_error", e.message, e) - } - } finally { - workerPool.shutdown() - } - }.start() + tasks.awaitAll().flatten() } - private fun extractBarcodesForSource( + private suspend fun extractBarcodesForSource( context: ReactApplicationContext, imageSource: String, sourceImageIndex: Int, allowedFormats: Set ): List { - var detectedResults: List = emptyList() - val extractionLatch = CountDownLatch(1) - - barcodeExtractor.extractFromSource( - context = context, - imageSource = imageSource, - sourceImageIndex = sourceImageIndex, - allowedFormats = allowedFormats - ) { detected -> - detectedResults = detected - extractionLatch.countDown() + return withTimeoutOrNull(BARCODE_EXTRACTION_TIMEOUT_MS) { + suspendCancellableCoroutine { continuation -> + barcodeExtractor.extractFromSource( + context = context, + imageSource = imageSource, + sourceImageIndex = sourceImageIndex, + allowedFormats = allowedFormats + ) { detected -> + if (continuation.isActive) { + continuation.resume(detected) + } + } + } + } ?: emptyList() + } + + private fun toWritableBarcodeArray(barcodes: List): WritableNativeArray { + val sorted = barcodes.sortedWith( + compareBy({ it.sourceImageIndex }, { it.value }, { it.format }) + ) + + val payload = WritableNativeArray() + for (barcode in sorted) { + payload.pushMap(toWritableBarcode(barcode)) } + return payload + } - try { - extractionLatch.await(20, TimeUnit.SECONDS) - } catch (_: InterruptedException) { - Thread.currentThread().interrupt() + private fun resolveOnUi(promise: Promise, value: Any?) { + reactApplicationContext.runOnUiQueueThread { + promise.resolve(value) } + } - return detectedResults + private fun rejectOnUi(promise: Promise, code: String, message: String, throwable: Throwable?) { + reactApplicationContext.runOnUiQueueThread { + promise.reject(code, message, throwable) + } } private fun parseAllowedFormats(rawFormats: ReadableArray?): Set { @@ -433,6 +441,11 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : restoreSystemBars() } + override fun invalidate() { + super.invalidate() + scope.cancel() + } + private fun ensureSystemBarsVisible(activity: ComponentActivity) { if (Build.VERSION.SDK_INT < ANDROID_15_API) return if (previousFitsSystemWindows != null) return diff --git a/ios/DocumentScanner.mm b/ios/DocumentScanner.mm index 33332e1..8e1e8fe 100644 --- a/ios/DocumentScanner.mm +++ b/ios/DocumentScanner.mm @@ -40,6 +40,11 @@ - (void)handleBarcodeExtractionWithOptions:(NSDictionary *)options [self.impl extractBarcodesFromImages:options resolve:resolve reject:reject]; } +- (void)invalidate +{ + [self.impl invalidate]; +} + #if RCT_NEW_ARCH_ENABLED - (void)scanDocument:(JS::NativeDocumentScanner::ScanDocumentOptions &)options resolve:(RCTPromiseResolveBlock)resolve diff --git a/ios/DocumentScanner.swift b/ios/DocumentScanner.swift index 0badb4e..9a61707 100644 --- a/ios/DocumentScanner.swift +++ b/ios/DocumentScanner.swift @@ -5,6 +5,13 @@ import React @objc(DocumentScannerImpl) public class DocumentScannerImpl: NSObject { private var docScanner: DocScanner? + private let barcodeQueue: OperationQueue = { + let queue = OperationQueue() + queue.name = "com.preeternal.document-scanner.barcode" + queue.maxConcurrentOperationCount = 2 + queue.qualityOfService = .utility + return queue + }() @objc static func requiresMainQueueSetup() -> Bool { true } @@ -109,11 +116,7 @@ public class DocumentScannerImpl: NSObject { } #if DOCUMENT_SCANNER_ENABLE_BARCODE - let queue = OperationQueue() - queue.name = "com.preeternal.document-scanner.barcode" - queue.qualityOfService = .userInitiated - queue.maxConcurrentOperationCount = concurrency - + let requestLimiter = DispatchSemaphore(value: concurrency) let lock = NSLock() let group = DispatchGroup() var extractedBarcodes: [[String: Any]] = [] @@ -124,9 +127,22 @@ public class DocumentScannerImpl: NSObject { continue } + let operation = BlockOperation() group.enter() - queue.addOperation { - defer { group.leave() } + operation.completionBlock = { + group.leave() + } + operation.addExecutionBlock { [weak operation] in + guard let operation = operation, !operation.isCancelled else { + return + } + + requestLimiter.wait() + defer { requestLimiter.signal() } + + guard !operation.isCancelled else { + return + } guard let image = BarcodeImageSource.loadImage(from: imageSource) else { return @@ -162,6 +178,7 @@ public class DocumentScannerImpl: NSObject { extractedBarcodes.append(contentsOf: mapped) lock.unlock() } + barcodeQueue.addOperation(operation) } group.notify(queue: .main) { @@ -188,4 +205,10 @@ public class DocumentScannerImpl: NSObject { ) #endif } + + @objc + public func invalidate() { + docScanner = nil + barcodeQueue.cancelAllOperations() + } } From 21627ad09d4dadbf1239b44256a60d9bc5a90d67 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Sun, 22 Mar 2026 17:45:45 +0200 Subject: [PATCH 07/17] feat(native): improve barcode image loading for content URIs and iOS security-scoped files --- .../barcode/BarcodeImageSourceLoader.kt | 54 ++++++++---- .../Barcode/BarcodeImageSource.swift | 85 ++++++++++++++++--- 2 files changed, 113 insertions(+), 26 deletions(-) diff --git a/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeImageSourceLoader.kt b/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeImageSourceLoader.kt index 4f94f51..a72bea6 100644 --- a/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeImageSourceLoader.kt +++ b/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeImageSourceLoader.kt @@ -31,7 +31,7 @@ internal object BarcodeImageSourceLoader { } if (normalized.startsWith("file://", ignoreCase = true)) { - return loadFromFilePath(Uri.parse(normalized).path) + return loadFromFilePath(resolveFilePathFromUri(Uri.parse(normalized))) } if (File(normalized).exists()) { @@ -45,8 +45,9 @@ internal object BarcodeImageSourceLoader { val parsed = Uri.parse(normalized) return when (parsed.scheme?.lowercase()) { "content" -> loadFromUri(context, parsed) - "file" -> loadFromFilePath(parsed.path) - else -> null + "file" -> loadFromFilePath(resolveFilePathFromUri(parsed)) + null, "" -> loadFromFilePath(normalized) + else -> loadFromUri(context, parsed) } } @@ -60,31 +61,47 @@ internal object BarcodeImageSourceLoader { } private fun loadFromUri(context: Context, uri: Uri): Bitmap? { - val bitmap = context.contentResolver.openInputStream(uri)?.use { decodeSampledBitmap(it) } - ?: return null - val orientation = context.contentResolver.openInputStream(uri)?.use { - readOrientation(it) - } ?: ExifInterface.ORIENTATION_NORMAL + return try { + val bitmap = context.contentResolver.openInputStream(uri)?.use { decodeSampledBitmap(it) } + ?: return null + val orientation = context.contentResolver.openInputStream(uri)?.use { + readOrientation(it) + } ?: ExifInterface.ORIENTATION_NORMAL - return rotateBitmapIfRequired(bitmap, orientation) + rotateBitmapIfRequired(bitmap, orientation) + } catch (_: Exception) { + null + } } private fun loadFromFilePath(path: String?): Bitmap? { - if (path.isNullOrBlank()) { + val normalized = path?.trim() + if (normalized.isNullOrBlank()) { return null } - val file = File(path) + val decoded = Uri.decode(normalized) + val candidatePath = when { + File(normalized).exists() -> normalized + decoded != normalized && File(decoded).exists() -> decoded + else -> normalized + } + + val file = File(candidatePath) if (!file.exists() || !file.isFile) { return null } - val bitmap = FileInputStream(file).use { decodeSampledBitmap(it) } ?: return null - val orientation = FileInputStream(file).use { - readOrientation(it) - } + return try { + val bitmap = FileInputStream(file).use { decodeSampledBitmap(it) } ?: return null + val orientation = FileInputStream(file).use { + readOrientation(it) + } - return rotateBitmapIfRequired(bitmap, orientation) + rotateBitmapIfRequired(bitmap, orientation) + } catch (_: Exception) { + null + } } private fun decodeSampledBitmap(stream: InputStream): Bitmap? { @@ -161,4 +178,9 @@ internal object BarcodeImageSourceLoader { val compact = value.replace("\\s".toRegex(), "") return compact.length >= 32 && compact.length % 4 == 0 && base64Regex.matches(compact) } + + private fun resolveFilePathFromUri(uri: Uri): String? { + val rawPath = uri.path ?: return null + return Uri.decode(rawPath) + } } diff --git a/ios/DocScanner/Barcode/BarcodeImageSource.swift b/ios/DocScanner/Barcode/BarcodeImageSource.swift index 085ddf7..05fd0ff 100644 --- a/ios/DocScanner/Barcode/BarcodeImageSource.swift +++ b/ios/DocScanner/Barcode/BarcodeImageSource.swift @@ -8,13 +8,8 @@ enum BarcodeImageSource { return nil } - if let localFromUri = loadFromFileUri(normalized) { - return localFromUri - } - - if let localFromPath = loadFromPath(normalized) { - return localFromPath - } + if let localFromUri = loadFromFileUri(normalized) { return localFromUri } + if let localFromPath = loadFromPath(normalized) { return localFromPath } if let data = decodeBase64Data(normalized) { return UIImage(data: data) @@ -27,14 +22,35 @@ enum BarcodeImageSource { guard let url = URL(string: value), url.isFileURL else { return nil } - return UIImage(contentsOfFile: url.path) + + if let image = loadFromPath(url.path) { + return image + } + + if let data = loadDataFromSecurityScopedURL(url) { + return UIImage(data: data) + } + + return nil } private static func loadFromPath(_ value: String) -> UIImage? { - guard FileManager.default.fileExists(atPath: value) else { + let path = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { return nil } - return UIImage(contentsOfFile: value) + + if FileManager.default.fileExists(atPath: path) { + return UIImage(contentsOfFile: path) + } + + if let decoded = path.removingPercentEncoding, + decoded != path, + FileManager.default.fileExists(atPath: decoded) { + return UIImage(contentsOfFile: decoded) + } + + return nil } private static func decodeBase64Data(_ value: String) -> Data? { @@ -47,4 +63,53 @@ enum BarcodeImageSource { return Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) } + + private static func loadDataFromSecurityScopedURL(_ url: URL) -> Data? { + var startedSecurityScope = false + if url.isFileURL { + startedSecurityScope = url.startAccessingSecurityScopedResource() + } + + defer { + if startedSecurityScope { + url.stopAccessingSecurityScopedResource() + } + } + + ensureUbiquitousItemDownloadStarted(url) + + var coordinatedData: Data? + var coordinationError: NSError? + let coordinator = NSFileCoordinator(filePresenter: nil) + + coordinator.coordinate(readingItemAt: url, options: .withoutChanges, error: &coordinationError) { coordinatedURL in + coordinatedData = try? Data(contentsOf: coordinatedURL, options: [.mappedIfSafe]) + } + + if let coordinatedData { + return coordinatedData + } + + if coordinationError == nil { + return try? Data(contentsOf: url, options: [.mappedIfSafe]) + } + + return nil + } + + private static func ensureUbiquitousItemDownloadStarted(_ url: URL) { + guard url.isFileURL else { + return + } + + let keys: Set = [.isUbiquitousItemKey, .ubiquitousItemDownloadingStatusKey] + guard let values = try? url.resourceValues(forKeys: keys), + values.isUbiquitousItem == true else { + return + } + + if values.ubiquitousItemDownloadingStatus == .notDownloaded { + try? FileManager.default.startDownloadingUbiquitousItem(at: url) + } + } } From 7cef7ad9b628a6e2fb1bfec9ee5ffdf223008380 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Sun, 22 Mar 2026 19:26:53 +0200 Subject: [PATCH 08/17] refactor(flags): keep analysis features opt-in on Android and always-on on iOS --- DocumentScanner.podspec | 8 +---- README.md | 19 +++++----- android/build.gradle | 35 +++++++++++++------ .../scanner/DocumentScannerModule.kt | 2 +- ios/DocScanner/Barcode/BarcodeExtractor.swift | 2 -- .../Barcode/BarcodeFeatureFlags.swift | 7 ---- ios/DocumentScanner.swift | 17 --------- 7 files changed, 37 insertions(+), 53 deletions(-) delete mode 100644 ios/DocScanner/Barcode/BarcodeFeatureFlags.swift diff --git a/DocumentScanner.podspec b/DocumentScanner.podspec index 43df343..7cab021 100644 --- a/DocumentScanner.podspec +++ b/DocumentScanner.podspec @@ -1,11 +1,6 @@ require "json" package = JSON.parse(File.read(File.join(__dir__, "package.json"))) -barcode_enabled = ENV["DOCUMENT_SCANNER_ENABLE_BARCODE"] == "1" -swift_conditions = ['$(inherited)'] -if barcode_enabled - swift_conditions << 'DOCUMENT_SCANNER_ENABLE_BARCODE' -end Pod::Spec.new do |s| s.name = "DocumentScanner" @@ -19,8 +14,7 @@ Pod::Spec.new do |s| s.swift_version = '5.9' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', - 'SWIFT_INSTALL_OBJC_HEADER' => 'YES', - 'SWIFT_ACTIVE_COMPILATION_CONDITIONS' => swift_conditions.join(' ') + 'SWIFT_INSTALL_OBJC_HEADER' => 'YES' } s.source = { :git => "https://github.com/Preeternal/react-native-document-scanner-plugin.git", :tag => "#{s.version}" } s.source_files = "ios/**/*.{h,m,mm,swift}" diff --git a/README.md b/README.md index d73ef9e..e2c8d87 100644 --- a/README.md +++ b/README.md @@ -228,23 +228,26 @@ Barcode extraction is disabled by default at build time. Enable on Android (includes ML Kit barcode dependency only when enabled): ```bash -./gradlew :react-native-document-scanner-plugin:assemble -PenableBarcode=true +./gradlew :react-native-document-scanner-plugin:assemble -PDocumentScanner_analysisFeatures=barcode ``` or add this to `android/gradle.properties`: ```properties -DocumentScanner_enableBarcode=true +DocumentScanner_analysisFeatures=barcode ``` -Enable on iOS before `pod install`: +Why Android needs this extra flag: -```bash -cd ios -DOCUMENT_SCANNER_ENABLE_BARCODE=1 pod install -``` +- Barcode extraction relies on additional native dependencies (`com.google.mlkit:barcode-scanning` and `androidx.exifinterface`). +- Keeping it opt-in avoids adding those dependencies and their APK/AAB size impact for apps that only need core document scanning. + +iOS does not require an additional build flag for barcode extraction. + +Android flag accepts a comma-separated list: `barcode,text,tables`. +Special values: `all` (enable every analysis feature) and `none` (disable all). -If barcode feature is not enabled in the native build, `extractBarcodesFromImages(...)` rejects with `barcode_not_enabled`. +If barcode feature is not enabled in the Android native build, `extractBarcodesFromImages(...)` rejects with `barcode_not_enabled`. ### extractBarcodesFromImages(...) diff --git a/android/build.gradle b/android/build.gradle index 41a1d6f..70aab53 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -25,12 +25,30 @@ def getExtOrIntegerDefault(name) { return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["DocumentScanner_" + name]).toInteger() } -def barcodeFeatureEnabled = - (project.findProperty("enableBarcode") - ?: project.findProperty("DocumentScanner_enableBarcode") - ?: "false") - .toString() - .toBoolean() +def parseFeatureList = { rawValue -> + def raw = (rawValue ?: "").toString().toLowerCase() + def values = raw.split(",") + .collect { it.trim() } + .findAll { !it.isEmpty() } + .toSet() + + if (values.contains("none")) { + values = values - "none" + } + + if (values.contains("all")) { + values += ["barcode", "text", "tables"] + } + + return values +} + +def analysisFeaturesRaw = + (project.findProperty("DocumentScanner_analysisFeatures") + ?: "") + +def analysisFeatures = parseFeatureList(analysisFeaturesRaw) +def barcodeFeatureEnabled = analysisFeatures.contains("barcode") android { namespace "com.preeternal.scanner" @@ -40,11 +58,6 @@ android { defaultConfig { minSdkVersion getExtOrIntegerDefault("minSdkVersion") targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") - buildConfigField "boolean", "BARCODE_FEATURE_ENABLED", barcodeFeatureEnabled ? "true" : "false" - } - - buildFeatures { - buildConfig true } buildTypes { diff --git a/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt b/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt index 6d956ac..d6b1713 100644 --- a/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt +++ b/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt @@ -100,7 +100,7 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : if (!barcodeExtractor.isFeatureEnabled()) { promise.reject( "barcode_not_enabled", - "Barcode extraction feature is disabled. Enable -PenableBarcode=true to build with barcode support." + "Barcode extraction feature is disabled. Enable -PDocumentScanner_analysisFeatures=barcode to build with barcode support." ) return } diff --git a/ios/DocScanner/Barcode/BarcodeExtractor.swift b/ios/DocScanner/Barcode/BarcodeExtractor.swift index d769d2b..c64abb3 100644 --- a/ios/DocScanner/Barcode/BarcodeExtractor.swift +++ b/ios/DocScanner/Barcode/BarcodeExtractor.swift @@ -1,4 +1,3 @@ -#if DOCUMENT_SCANNER_ENABLE_BARCODE import CoreGraphics import Foundation import UIKit @@ -235,4 +234,3 @@ private extension UIImage { return rotated } } -#endif diff --git a/ios/DocScanner/Barcode/BarcodeFeatureFlags.swift b/ios/DocScanner/Barcode/BarcodeFeatureFlags.swift deleted file mode 100644 index d78be34..0000000 --- a/ios/DocScanner/Barcode/BarcodeFeatureFlags.swift +++ /dev/null @@ -1,7 +0,0 @@ -enum BarcodeFeatureFlags { - #if DOCUMENT_SCANNER_ENABLE_BARCODE - static let isEnabled = true - #else - static let isEnabled = false - #endif -} diff --git a/ios/DocumentScanner.swift b/ios/DocumentScanner.swift index 9a61707..cda9ce3 100644 --- a/ios/DocumentScanner.swift +++ b/ios/DocumentScanner.swift @@ -93,15 +93,6 @@ public class DocumentScannerImpl: NSObject { return } - guard BarcodeFeatureFlags.isEnabled else { - reject( - "barcode_not_enabled", - "Barcode extraction feature is disabled. Enable DOCUMENT_SCANNER_ENABLE_BARCODE=1 before pod install.", - nil - ) - return - } - let opts = options as? [String: Any] ?? [:] let rawImages = opts["images"] as? [Any] ?? [] let allowedFormats = (opts["barcodeFormats"] as? [Any] ?? []) @@ -115,7 +106,6 @@ public class DocumentScannerImpl: NSObject { return } - #if DOCUMENT_SCANNER_ENABLE_BARCODE let requestLimiter = DispatchSemaphore(value: concurrency) let lock = NSLock() let group = DispatchGroup() @@ -197,13 +187,6 @@ public class DocumentScannerImpl: NSObject { resolve(sorted) } - #else - reject( - "barcode_not_enabled", - "Barcode extraction feature is disabled. Enable DOCUMENT_SCANNER_ENABLE_BARCODE=1 before pod install.", - nil - ) - #endif } @objc From a96b3e74818eeeb230dd9c990f256ecbd2c2553b Mon Sep 17 00:00:00 2001 From: Preeternal Date: Sun, 22 Mar 2026 21:44:09 +0200 Subject: [PATCH 09/17] feat: add optional native analysis pipeline with barcode, OCR and document semantics --- README.md | 89 ++- android/build.gradle | 11 + .../scanner/barcode/BarcodeExtractorImpl.kt | 154 +++- .../scanner/DocumentScannerModule.kt | 498 ++++++++++++- .../scanner/analysis/DocumentSemantics.kt | 238 ++++++ .../preeternal/scanner/text/TextExtractor.kt | 33 + .../scanner/text/TextExtractorImpl.kt | 17 + .../scanner/text/TextExtractorImpl.kt | 210 ++++++ .../scanner/text/TextInputImageLoader.kt | 172 +++++ ios/DocScanner/Analysis/AnalysisModels.swift | 66 ++ .../Analysis/DocumentSemantics.swift | 325 +++++++++ .../Analysis/RecognizeDocumentsAnalyzer.swift | 615 ++++++++++++++++ ios/DocScanner/Analysis/TextExtractor.swift | 221 ++++++ ios/DocScanner/Barcode/BarcodeExtractor.swift | 116 ++- ios/DocumentScanner.mm | 105 +++ ios/DocumentScanner.swift | 676 +++++++++++++++++- src/NativeDocumentScanner.ts | 146 +++- src/index.tsx | 248 ++++++- 18 files changed, 3841 insertions(+), 99 deletions(-) create mode 100644 android/src/main/java/com/preeternal/scanner/analysis/DocumentSemantics.kt create mode 100644 android/src/main/java/com/preeternal/scanner/text/TextExtractor.kt create mode 100644 android/src/no-text/java/com/preeternal/scanner/text/TextExtractorImpl.kt create mode 100644 android/src/text/java/com/preeternal/scanner/text/TextExtractorImpl.kt create mode 100644 android/src/text/java/com/preeternal/scanner/text/TextInputImageLoader.kt create mode 100644 ios/DocScanner/Analysis/AnalysisModels.swift create mode 100644 ios/DocScanner/Analysis/DocumentSemantics.swift create mode 100644 ios/DocScanner/Analysis/RecognizeDocumentsAnalyzer.swift create mode 100644 ios/DocScanner/Analysis/TextExtractor.swift diff --git a/README.md b/README.md index e2c8d87..f659a4e 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,7 @@ export default () => { * [Response sanitization (since v0.2.2)](#response-sanitization-since-v022) * [Barcode extraction (optional feature)](#barcode-extraction-optional-feature) * [`extractBarcodesFromImages(...)`](#extractbarcodesfromimages) +* [`extractTextFromImages(...)`](#extracttextfromimages) * [`analyzeScannedImages(...)`](#analyzescannedimages) * [`scanAndAnalyzeDocument(...)`](#scanandanalyzedocument) * [Interfaces](#interfaces) @@ -269,6 +270,32 @@ Extracts barcodes from existing images without opening scanner UI. -------------------- +### extractTextFromImages(...) + +```typescript +extractTextFromImages( + images: string[], + options?: ExtractTextFromImagesOptions | undefined +) => Promise +``` + +Extracts OCR text blocks from existing images without opening scanner UI. + +| Param | Type | +| ------------- | ------------------------------------------------------------------------------------------------------ | +| **`images`** | string[] | +| **`options`** | ExtractTextFromImagesOptions | + +**Returns:** Promise<TextBlock[]> + +Notes: + +- Android: requires `DocumentScanner_analysisFeatures` to include `text` or `tables`. +- iOS: available by default (no additional build flag). On modern iOS versions the module uses `RecognizeDocumentsRequest`; older iOS versions fall back to classic OCR flow. +- Optional (`false` by default for this method): set `ocrRotate180Fallback: true` to enable adaptive OCR fallback (extra 180° pass only when first pass returns very little text). + +-------------------- + ### analyzeScannedImages(...) ```typescript @@ -279,7 +306,9 @@ analyzeScannedImages( ``` Universal post-processing over scanned images. -Right now barcode extraction is implemented; OCR/table/structured extractors are placeholders for future versions. +Barcode extraction, OCR text, tables, regions, and structured data stages run natively. +On iOS, `RecognizeDocumentsRequest` is used when available to read document structure (tables/text/data detector matches), with fallback to legacy OCR on older iOS versions. +`ocrRotate180Fallback` is enabled by default for this method (`true`) and runs an extra OCR 180° pass only when first pass is weak. ```ts const analysis = await DocumentScanner.analyzeScannedImages(scannedImages, { @@ -346,15 +375,23 @@ const result = await DocumentScanner.scanAndAnalyzeDocument({ | **`barcodeFormats`** | Barcode['format'][] | Optional allow-list of normalized barcode formats used during extraction. When omitted, all supported formats are scanned. | : undefined | | **`concurrency`** | 1 \\| 2 | Maximum native worker concurrency for barcode extraction. Native implementations clamp values to 1..2. | : 2 | +#### ExtractTextFromImagesOptions + +| Prop | Type | Description | Default | +| ------------------- | ------------------- | ----------- | ------- | +| **`concurrency`** | 1 \\| 2 | Maximum native worker concurrency for OCR extraction. Native implementations clamp values to 1..2. | : 2 | +| **`ocrRotate180Fallback`** | boolean | Adaptive OCR fallback: run an extra 180° pass only when first OCR pass returns too little text. | : false | + #### AnalyzeExtractOptions | Prop | Type | Description | | ------------------- | ------------------ | ----------- | | **`barcodes`** | boolean | Enable barcode extraction stage. | -| **`text`** | boolean | Reserved for future OCR extraction. | -| **`tables`** | boolean | Reserved for future table extraction. | -| **`structuredData`**| boolean | Reserved for future structured document extraction. | +| **`text`** | boolean | Enable OCR text extraction stage. | +| **`tables`** | boolean | Enable table inference from OCR lines. | +| **`regions`** | boolean | Enable zone/region inference from OCR blocks. | +| **`structuredData`**| boolean | Enable entity and key-value inference from OCR output. | #### AnalyzeScannedImagesOptions @@ -363,7 +400,8 @@ const result = await DocumentScanner.scanAndAnalyzeDocument({ | ------------------- | -------------------------------------------------------------- | ----------- | | **`extract`** | AnalyzeExtractOptions | Extractor toggles for image analysis. | | **`barcodeFormats`**| Barcode['format'][] | Optional barcode format allow-list for barcode stage. | -| **`concurrency`** | 1 \\| 2 | Optional native worker concurrency for barcode stage. | +| **`concurrency`** | 1 \\| 2 | Optional native worker concurrency for barcode/OCR stages. | +| **`ocrRotate180Fallback`** | boolean | Enables adaptive OCR fallback for text/semantics stages. Default: true. | #### AnalysisResult @@ -372,9 +410,11 @@ const result = await DocumentScanner.scanAndAnalyzeDocument({ | ------------------- | ------------------------------------------------------------------------------------- | ----------- | | **`status`** | 'success' \\| 'partial' \\| 'failed' \\| 'not_enabled' | Aggregate status of requested analysis stages. | | **`barcodes`** | Barcode[] | Extracted barcodes when requested and available. | -| **`text`** | { text: string; sourceImageIndex: number; confidence?: number }[] | Reserved placeholder for OCR blocks. | -| **`tables`** | { rows: string[][]; sourceImageIndex: number }[] | Reserved placeholder for table extraction. | -| **`structuredData`**| Record<string, unknown> | Reserved placeholder for structured extraction. | +| **`textBlocks`** | TextBlock[] | OCR text blocks (new canonical field). | +| **`text`** | TextBlock[] | Backward-compatible alias for `textBlocks`. | +| **`tables`** | TableBlock[] | Inferred tables from OCR lines. | +| **`regions`** | Region[] | Inferred document zones (header/footer/paragraph/etc). | +| **`structuredData`**| StructuredData | Inferred entities and key-value fields from OCR output. | #### ScanAndAnalyzeDocumentOptions @@ -404,6 +444,39 @@ const result = await DocumentScanner.scanAndAnalyzeDocument({ | **`format`** | 'aztec' \\| 'codabar' \\| 'code39' \\| 'code93' \\| 'code128' \\| 'dataMatrix' \\| 'ean8' \\| 'ean13' \\| 'itf' \\| 'pdf417' \\| 'qr' \\| 'upca' \\| 'upce' \\| 'unknown' | Normalized barcode format. | | **`sourceImageIndex`** | number | Index of the source image in `scannedImages` that produced this barcode. | +#### TextBlock + +| Prop | Type | Description | +| ------------------- | ----------------------------------------------------------------------------------------- | ----------- | +| **`text`** | string | OCR text content for the block. | +| **`sourceImageIndex`** | number | Source image index in `scannedImages`. | +| **`bbox`** | { left: number; top: number; width: number; height: number } | Optional normalized bounding box (`0..1`). | +| **`lines`** | { text: string; bbox?: { left: number; top: number; width: number; height: number } }[] | OCR lines inside the block when available. | + +#### TableBlock + +| Prop | Type | Description | +| ------------------- | ----------------------------------------------------------------------------------------- | ----------- | +| **`sourceImageIndex`** | number | Source image index in `scannedImages`. | +| **`rows`** | string[][] | Inferred table rows and cell texts. | +| **`bbox`** | { left: number; top: number; width: number; height: number } | Optional normalized table bounds. | + +#### Region + +| Prop | Type | Description | +| ------------------- | ----------------------------------------------------------------------------------------- | ----------- | +| **`type`** | 'header' \\| 'footer' \\| 'paragraph' \\| 'signature' \\| 'stamp' \\| 'unknown' | Inferred region category. | +| **`sourceImageIndex`** | number | Source image index in `scannedImages`. | +| **`bbox`** | { left: number; top: number; width: number; height: number } | Normalized region bounds (`0..1`). | +| **`score`** | number | Optional heuristic confidence score. | + +#### StructuredData + +| Prop | Type | Description | +| ------------------- | ----------------------------------------------------------------------------------------- | ----------- | +| **`entities`** | { type: 'phone' \\| 'email' \\| 'date' \\| 'amount' \\| 'id' \\| 'unknown'; value: string; sourceImageIndex: number }[] | Inferred typed entities. | +| **`fields`** | { key: string; value: string }[] | Inferred key-value fields from OCR lines. | + ### Enums diff --git a/android/build.gradle b/android/build.gradle index 70aab53..8152759 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -49,6 +49,9 @@ def analysisFeaturesRaw = def analysisFeatures = parseFeatureList(analysisFeaturesRaw) def barcodeFeatureEnabled = analysisFeatures.contains("barcode") +def textFeatureEnabled = analysisFeatures.contains("text") +def tablesFeatureEnabled = analysisFeatures.contains("tables") +def textPipelineEnabled = textFeatureEnabled || tablesFeatureEnabled android { namespace "com.preeternal.scanner" @@ -86,6 +89,11 @@ android { } else { java.srcDirs += ["src/no-barcode/java"] } + if (textPipelineEnabled) { + java.srcDirs += ["src/text/java"] + } else { + java.srcDirs += ["src/no-text/java"] + } } } } @@ -106,4 +114,7 @@ dependencies { implementation "com.google.mlkit:barcode-scanning:17.3.0" implementation "androidx.exifinterface:exifinterface:1.4.2" } + if (textPipelineEnabled) { + implementation "com.google.mlkit:text-recognition:16.0.1" + } } diff --git a/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt b/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt index f1615fc..7aa74a4 100644 --- a/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt +++ b/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt @@ -3,11 +3,13 @@ package com.preeternal.scanner.barcode import android.content.Context import android.graphics.Bitmap import android.graphics.Matrix +import android.graphics.Rect import com.google.mlkit.vision.barcode.BarcodeScanner import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage +import kotlin.math.floor import kotlin.math.roundToInt class BarcodeExtractorImpl : BarcodeExtractor { @@ -15,6 +17,7 @@ class BarcodeExtractorImpl : BarcodeExtractor { private const val CROP_WIDTH_PERCENT = 25.0f private const val CROP_HEIGHT_PERCENT = 20.0f private const val CORNER_MARGIN_PERCENT = 3.0f + private const val CENTER_BUCKETS = 24 private val formatToMlKit = mapOf( BarcodeFormats.AZTEC to Barcode.FORMAT_AZTEC, @@ -35,6 +38,11 @@ class BarcodeExtractorImpl : BarcodeExtractor { private val scannerCache = mutableMapOf() + private data class BarcodeCandidate( + val dedupKey: String, + val result: BarcodeResult + ) + override fun isFeatureEnabled(): Boolean = true override fun extractFromSource( @@ -57,6 +65,7 @@ class BarcodeExtractorImpl : BarcodeExtractor { sourceImageIndex = sourceImageIndex, allowedFormats = normalizedAllowList, attemptIndex = 0, + collected = LinkedHashMap(), callback = callback ) } @@ -67,18 +76,24 @@ class BarcodeExtractorImpl : BarcodeExtractor { sourceImageIndex: Int, allowedFormats: Set, attemptIndex: Int, + collected: LinkedHashMap, callback: (List) -> Unit ) { if (attemptIndex >= 4) { - callback(emptyList()) + callback(collected.values.toList()) + return + } + + if (attemptIndex > 0 && collected.isNotEmpty()) { + callback(collected.values.toList()) return } val angle = when (attemptIndex) { 0 -> 0f - 1 -> 90f - 2 -> -90f - else -> 180f + 1 -> 180f + 2 -> 90f + else -> -90f } val candidate = if (angle == 0f) { @@ -88,37 +103,81 @@ class BarcodeExtractorImpl : BarcodeExtractor { } if (candidate == null) { - processAttempt(scanner, sourceBitmap, sourceImageIndex, allowedFormats, attemptIndex + 1, callback) + processAttempt( + scanner, + sourceBitmap, + sourceImageIndex, + allowedFormats, + attemptIndex + 1, + collected, + callback + ) return } val roiBitmap = cropTopRightRoi(candidate) if (roiBitmap == null) { - processAttempt(scanner, sourceBitmap, sourceImageIndex, allowedFormats, attemptIndex + 1, callback) + processAttempt( + scanner, + sourceBitmap, + sourceImageIndex, + allowedFormats, + attemptIndex + 1, + collected, + callback + ) return } val input = InputImage.fromBitmap(roiBitmap, 0) scanner.process(input) .addOnSuccessListener { barcodes -> - val mapped = barcodesToResults(barcodes, sourceImageIndex, allowedFormats) - if (mapped.isNotEmpty()) { - callback(mapped) - } else { - processAttempt(scanner, sourceBitmap, sourceImageIndex, allowedFormats, attemptIndex + 1, callback) + val mapped = barcodesToCandidates( + barcodes = barcodes, + sourceImageIndex = sourceImageIndex, + allowedFormats = allowedFormats, + attemptIndex = attemptIndex, + roiWidth = roiBitmap.width, + roiHeight = roiBitmap.height + ) + for (candidateResult in mapped) { + if (!collected.containsKey(candidateResult.dedupKey)) { + collected[candidateResult.dedupKey] = candidateResult.result + } } + + processAttempt( + scanner, + sourceBitmap, + sourceImageIndex, + allowedFormats, + attemptIndex + 1, + collected, + callback + ) } .addOnFailureListener { - processAttempt(scanner, sourceBitmap, sourceImageIndex, allowedFormats, attemptIndex + 1, callback) + processAttempt( + scanner, + sourceBitmap, + sourceImageIndex, + allowedFormats, + attemptIndex + 1, + collected, + callback + ) } } - private fun barcodesToResults( + private fun barcodesToCandidates( barcodes: List, sourceImageIndex: Int, - allowedFormats: Set - ): List { - val dedup = LinkedHashMap() + allowedFormats: Set, + attemptIndex: Int, + roiWidth: Int, + roiHeight: Int + ): List { + val dedup = LinkedHashMap() for (barcode in barcodes) { val value = barcode.rawValue?.trim() @@ -131,12 +190,22 @@ class BarcodeExtractorImpl : BarcodeExtractor { continue } - val key = "$format|$value" - if (!dedup.containsKey(key)) { - dedup[key] = BarcodeResult( - value = value, - format = format, - sourceImageIndex = sourceImageIndex + val instanceKey = buildInstanceKey( + format = format, + value = value, + attemptIndex = attemptIndex, + boundingBox = barcode.boundingBox, + roiWidth = roiWidth, + roiHeight = roiHeight + ) + if (!dedup.containsKey(instanceKey)) { + dedup[instanceKey] = BarcodeCandidate( + dedupKey = instanceKey, + result = BarcodeResult( + value = value, + format = format, + sourceImageIndex = sourceImageIndex + ) ) } } @@ -144,6 +213,47 @@ class BarcodeExtractorImpl : BarcodeExtractor { return dedup.values.toList() } + private fun buildInstanceKey( + format: String, + value: String, + attemptIndex: Int, + boundingBox: Rect?, + roiWidth: Int, + roiHeight: Int + ): String { + val bucket = centerBucketKey(boundingBox, roiWidth, roiHeight) + return "$format|$value|a$attemptIndex|$bucket" + } + + private fun centerBucketKey( + boundingBox: Rect?, + imageWidth: Int, + imageHeight: Int + ): String { + if (boundingBox == null || imageWidth <= 0 || imageHeight <= 0) { + return "u" + } + + val centerX = (((boundingBox.left + boundingBox.right) * 0.5f) / imageWidth.toFloat()) + .coerceIn(0f, 1f) + val centerY = (((boundingBox.top + boundingBox.bottom) * 0.5f) / imageHeight.toFloat()) + .coerceIn(0f, 1f) + + val xBucket = quantize(centerX) + val yBucket = quantize(centerY) + return "$xBucket:$yBucket" + } + + private fun quantize(value: Float): Int { + if (CENTER_BUCKETS <= 1) { + return 0 + } + + val clamped = value.coerceIn(0f, 1f) + val scaled = floor((clamped * CENTER_BUCKETS.toFloat()).toDouble()).toInt() + return scaled.coerceIn(0, CENTER_BUCKETS - 1) + } + private fun normalizeFormat(format: Int): String { return when (format) { Barcode.FORMAT_AZTEC -> BarcodeFormats.AZTEC diff --git a/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt b/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt index d6b1713..18a1f45 100644 --- a/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt +++ b/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt @@ -22,10 +22,21 @@ import com.google.mlkit.vision.documentscanner.GmsDocumentScanner import com.google.mlkit.vision.documentscanner.GmsDocumentScannerOptions import com.google.mlkit.vision.documentscanner.GmsDocumentScanning import com.google.mlkit.vision.documentscanner.GmsDocumentScanningResult +import com.preeternal.scanner.analysis.DocumentSemantics +import com.preeternal.scanner.analysis.SemanticRegion +import com.preeternal.scanner.analysis.SemanticStructuredData +import com.preeternal.scanner.analysis.SemanticStructuredEntity +import com.preeternal.scanner.analysis.SemanticTable +import com.preeternal.scanner.analysis.SemanticTableCell import com.preeternal.scanner.barcode.BarcodeExtractor import com.preeternal.scanner.barcode.BarcodeExtractorImpl import com.preeternal.scanner.barcode.BarcodeFormats import com.preeternal.scanner.barcode.BarcodeResult +import com.preeternal.scanner.text.NormalizedBoundingBox +import com.preeternal.scanner.text.TextBlockResult +import com.preeternal.scanner.text.TextExtractor +import com.preeternal.scanner.text.TextExtractorImpl +import com.preeternal.scanner.text.TextLineResult import java.io.ByteArrayOutputStream import java.io.FileNotFoundException import java.lang.ref.WeakReference @@ -52,12 +63,26 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : const val NAME = "DocumentScanner" private const val ANDROID_15_API = 35 private const val BARCODE_EXTRACTION_TIMEOUT_MS = 20_000L + private const val TEXT_EXTRACTION_TIMEOUT_MS = 25_000L } override fun getName(): String = NAME private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val barcodeExtractor: BarcodeExtractor = BarcodeExtractorImpl() + private val textExtractor: TextExtractor = TextExtractorImpl() + + private enum class AnalysisStageStatus { + SUCCESS, + NOT_ENABLED, + FAILED, + SKIPPED + } + + private data class AnalysisStageResult( + val status: AnalysisStageStatus, + val value: T? = null + ) private var launcher: ActivityResultLauncher? = null private var pendingPromise: Promise? = null @@ -97,6 +122,12 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : } override fun extractBarcodesFromImages(options: ReadableMap, promise: Promise) { + val images = getArrayOrNull(options, "images") + if (images == null || images.size() == 0) { + promise.resolve(WritableNativeArray()) + return + } + if (!barcodeExtractor.isFeatureEnabled()) { promise.reject( "barcode_not_enabled", @@ -105,12 +136,6 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : return } - val images = getArrayOrNull(options, "images") - if (images == null || images.size() == 0) { - promise.resolve(WritableNativeArray()) - return - } - val allowedFormats = parseAllowedFormats(getArrayOrNull(options, "barcodeFormats")) val requestedConcurrency = getIntOrNull(options, "concurrency") ?: 2 val concurrency = requestedConcurrency.coerceIn(1, 2) @@ -140,6 +165,162 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : } } + override fun extractTextFromImages(options: ReadableMap, promise: Promise) { + val images = getArrayOrNull(options, "images") + if (images == null || images.size() == 0) { + promise.resolve(WritableNativeArray()) + return + } + + if (!textExtractor.isFeatureEnabled()) { + promise.reject( + "text_not_enabled", + "Text extraction feature is disabled. Enable -PDocumentScanner_analysisFeatures=text (or tables) to build with OCR support." + ) + return + } + + val requestedConcurrency = getIntOrNull(options, "concurrency") ?: 2 + val concurrency = requestedConcurrency.coerceIn(1, 2) + val ocrRotate180Fallback = getBooleanOrNull(options, "ocrRotate180Fallback") ?: false + val validSources = buildValidImageSources(images) + + if (validSources.isEmpty()) { + promise.resolve(WritableNativeArray()) + return + } + + scope.launch { + try { + val extracted = extractTextInParallel( + context = reactApplicationContext, + sources = validSources, + concurrency = concurrency, + ocrRotate180Fallback = ocrRotate180Fallback + ) + + val payload = toWritableTextBlockArray(extracted) + resolveOnUi(promise, payload) + } catch (cancelled: CancellationException) { + rejectOnUi(promise, "text_extraction_cancelled", "Text extraction cancelled", cancelled) + } catch (error: Exception) { + rejectOnUi(promise, "text_extraction_error", error.message ?: "Text extraction failed", error) + } + } + } + + override fun analyzeScannedImages(options: ReadableMap, promise: Promise) { + val images = getArrayOrNull(options, "images") + if (images == null || images.size() == 0) { + val empty = WritableNativeMap() + empty.putString("status", "success") + promise.resolve(empty) + return + } + + val wantsBarcodes = getBooleanOrNull(options, "extractBarcodes") ?: false + val wantsText = getBooleanOrNull(options, "extractText") ?: false + val wantsTables = getBooleanOrNull(options, "extractTables") ?: false + val wantsRegions = getBooleanOrNull(options, "extractRegions") ?: false + val wantsStructuredData = getBooleanOrNull(options, "extractStructuredData") ?: false + val wantsTextPipeline = wantsText || wantsTables || wantsRegions || wantsStructuredData + + if (!wantsBarcodes && !wantsTextPipeline) { + val empty = WritableNativeMap() + empty.putString("status", "success") + promise.resolve(empty) + return + } + + val allowedFormats = parseAllowedFormats(getArrayOrNull(options, "barcodeFormats")) + val requestedConcurrency = getIntOrNull(options, "concurrency") ?: 2 + val concurrency = requestedConcurrency.coerceIn(1, 2) + val ocrRotate180Fallback = getBooleanOrNull(options, "ocrRotate180Fallback") ?: false + val validSources = buildValidImageSources(images) + + if (validSources.isEmpty()) { + val empty = WritableNativeMap() + empty.putString("status", "success") + promise.resolve(empty) + return + } + + scope.launch { + try { + val (barcodeStage, textStage) = coroutineScope { + val barcodeDeferred = async { + runBarcodeAnalysisStage( + wantsStage = wantsBarcodes, + sources = validSources, + allowedFormats = allowedFormats, + concurrency = concurrency + ) + } + + val textDeferred = async { + runTextAnalysisStage( + wantsStage = wantsTextPipeline, + sources = validSources, + concurrency = concurrency, + ocrRotate180Fallback = ocrRotate180Fallback + ) + } + + Pair(barcodeDeferred.await(), textDeferred.await()) + } + + val result = WritableNativeMap() + result.putString( + "status", + mergeAnalysisStageStatuses(listOf(barcodeStage.status, textStage.status)) + ) + + if (barcodeStage.status == AnalysisStageStatus.SUCCESS) { + result.putArray("barcodes", toWritableBarcodeArray(barcodeStage.value ?: emptyList())) + } + + val textBlocks = if (textStage.status == AnalysisStageStatus.SUCCESS) { + textStage.value ?: emptyList() + } else { + emptyList() + } + + if (textStage.status == AnalysisStageStatus.SUCCESS && wantsText) { + result.putArray("textBlocks", toWritableTextBlockArray(textBlocks)) + result.putArray("text", toWritableTextBlockArray(textBlocks)) + } + + if (textStage.status == AnalysisStageStatus.SUCCESS && wantsTables) { + result.putArray( + "tables", + toWritableTableArray(DocumentSemantics.inferTables(textBlocks)) + ) + } + + if (textStage.status == AnalysisStageStatus.SUCCESS && wantsRegions) { + result.putArray( + "regions", + toWritableRegionArray(DocumentSemantics.inferRegions(textBlocks)) + ) + } + + if (textStage.status == AnalysisStageStatus.SUCCESS && wantsStructuredData) { + val structured = DocumentSemantics.inferStructuredData(textBlocks) + val mapped = toWritableStructuredData(structured) + if (mapped != null) { + result.putMap("structuredData", mapped) + } + } + + resolveOnUi(promise, result) + } catch (cancelled: CancellationException) { + rejectOnUi(promise, "analysis_cancelled", "Image analysis cancelled", cancelled) + } catch (error: Exception) { + rejectOnUi(promise, "analysis_error", error.message ?: "Image analysis failed", error) + } + } + } + private fun initScanner(options: ReadableMap) { val builder = GmsDocumentScannerOptions.Builder() .setResultFormats(GmsDocumentScannerOptions.RESULT_FORMAT_JPEG) @@ -262,6 +443,50 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : return map } + private fun toWritableTextLine(line: TextLineResult): WritableNativeMap { + val map = WritableNativeMap() + map.putString("text", line.text) + val bbox = toWritableBoundingBox(line.boundingBox) + if (bbox != null) { + map.putMap("bbox", bbox) + } + return map + } + + private fun toWritableTextBlock(block: TextBlockResult): WritableNativeMap { + val map = WritableNativeMap() + map.putString("text", block.text) + map.putInt("sourceImageIndex", block.sourceImageIndex) + + val bbox = toWritableBoundingBox(block.boundingBox) + if (bbox != null) { + map.putMap("bbox", bbox) + } + + val lines = WritableNativeArray() + for (line in block.lines) { + lines.pushMap(toWritableTextLine(line)) + } + map.putArray("lines", lines) + + return map + } + + private fun toWritableBoundingBox( + boundingBox: NormalizedBoundingBox? + ): WritableNativeMap? { + if (boundingBox == null) { + return null + } + + val map = WritableNativeMap() + map.putDouble("left", boundingBox.left) + map.putDouble("top", boundingBox.top) + map.putDouble("width", boundingBox.width) + map.putDouble("height", boundingBox.height) + return map + } + private data class IndexedImageSource( val sourceImageIndex: Int, val imageSource: String @@ -329,6 +554,127 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : } ?: emptyList() } + private suspend fun extractTextInParallel( + context: ReactApplicationContext, + sources: List, + concurrency: Int, + ocrRotate180Fallback: Boolean + ): List = coroutineScope { + val limiter = Semaphore(concurrency) + + val tasks = sources.map { source -> + async { + limiter.withPermit { + extractTextForSource( + context = context, + imageSource = source.imageSource, + sourceImageIndex = source.sourceImageIndex, + ocrRotate180Fallback = ocrRotate180Fallback + ) + } + } + } + + tasks.awaitAll().flatten() + } + + private suspend fun extractTextForSource( + context: ReactApplicationContext, + imageSource: String, + sourceImageIndex: Int, + ocrRotate180Fallback: Boolean + ): List { + return withTimeoutOrNull(TEXT_EXTRACTION_TIMEOUT_MS) { + suspendCancellableCoroutine { continuation -> + textExtractor.extractFromSource( + context = context, + imageSource = imageSource, + sourceImageIndex = sourceImageIndex, + enableRotate180Fallback = ocrRotate180Fallback + ) { extracted -> + if (continuation.isActive) { + continuation.resume(extracted) + } + } + } + } ?: emptyList() + } + + private suspend fun runBarcodeAnalysisStage( + wantsStage: Boolean, + sources: List, + allowedFormats: Set, + concurrency: Int + ): AnalysisStageResult> { + if (!wantsStage) { + return AnalysisStageResult(AnalysisStageStatus.SKIPPED) + } + if (!barcodeExtractor.isFeatureEnabled()) { + return AnalysisStageResult(AnalysisStageStatus.NOT_ENABLED) + } + + return try { + val value = extractBarcodesInParallel( + context = reactApplicationContext, + sources = sources, + allowedFormats = allowedFormats, + concurrency = concurrency + ) + AnalysisStageResult( + status = AnalysisStageStatus.SUCCESS, + value = value + ) + } catch (_: Exception) { + AnalysisStageResult(AnalysisStageStatus.FAILED) + } + } + + private suspend fun runTextAnalysisStage( + wantsStage: Boolean, + sources: List, + concurrency: Int, + ocrRotate180Fallback: Boolean + ): AnalysisStageResult> { + if (!wantsStage) { + return AnalysisStageResult(AnalysisStageStatus.SKIPPED) + } + if (!textExtractor.isFeatureEnabled()) { + return AnalysisStageResult(AnalysisStageStatus.NOT_ENABLED) + } + + return try { + val value = extractTextInParallel( + context = reactApplicationContext, + sources = sources, + concurrency = concurrency, + ocrRotate180Fallback = ocrRotate180Fallback + ) + AnalysisStageResult( + status = AnalysisStageStatus.SUCCESS, + value = value + ) + } catch (_: Exception) { + AnalysisStageResult(AnalysisStageStatus.FAILED) + } + } + + private fun mergeAnalysisStageStatuses(statuses: List): String { + val requested = statuses.filter { it != AnalysisStageStatus.SKIPPED } + if (requested.isEmpty()) { + return "success" + } + if (requested.all { it == AnalysisStageStatus.SUCCESS }) { + return "success" + } + if (requested.all { it == AnalysisStageStatus.NOT_ENABLED }) { + return "not_enabled" + } + if (requested.any { it == AnalysisStageStatus.SUCCESS }) { + return "partial" + } + return "failed" + } + private fun toWritableBarcodeArray(barcodes: List): WritableNativeArray { val sorted = barcodes.sortedWith( compareBy({ it.sourceImageIndex }, { it.value }, { it.format }) @@ -341,6 +687,134 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : return payload } + private fun toWritableRegion(region: SemanticRegion): WritableNativeMap { + val map = WritableNativeMap() + map.putString("type", region.type) + map.putInt("sourceImageIndex", region.sourceImageIndex) + map.putMap("bbox", toWritableBoundingBox(region.boundingBox)) + region.score?.let { map.putDouble("score", it) } + region.text?.let { map.putString("text", it) } + return map + } + + private fun toWritableRegionArray(regions: List): WritableNativeArray { + val payload = WritableNativeArray() + for (region in regions.sortedBy { it.sourceImageIndex }) { + payload.pushMap(toWritableRegion(region)) + } + return payload + } + + private fun toWritableTableCell(cell: SemanticTableCell): WritableNativeMap { + val map = WritableNativeMap() + map.putString("text", cell.text) + map.putInt("row", cell.row) + map.putInt("column", cell.column) + map.putInt("sourceImageIndex", cell.sourceImageIndex) + val bbox = toWritableBoundingBox(cell.boundingBox) + if (bbox != null) { + map.putMap("bbox", bbox) + } + return map + } + + private fun toWritableTable(table: SemanticTable): WritableNativeMap { + val map = WritableNativeMap() + map.putInt("sourceImageIndex", table.sourceImageIndex) + + val rows = WritableNativeArray() + for (row in table.rows) { + val rowArray = WritableNativeArray() + for (cell in row) { + rowArray.pushString(cell) + } + rows.pushArray(rowArray) + } + map.putArray("rows", rows) + + val cells = WritableNativeArray() + for (cell in table.cells) { + cells.pushMap(toWritableTableCell(cell)) + } + map.putArray("cells", cells) + + val bbox = toWritableBoundingBox(table.boundingBox) + if (bbox != null) { + map.putMap("bbox", bbox) + } + + return map + } + + private fun toWritableTableArray(tables: List): WritableNativeArray { + val payload = WritableNativeArray() + for (table in tables.sortedBy { it.sourceImageIndex }) { + payload.pushMap(toWritableTable(table)) + } + return payload + } + + private fun toWritableStructuredEntity(entity: SemanticStructuredEntity): WritableNativeMap { + val map = WritableNativeMap() + map.putString("type", entity.type) + map.putString("value", entity.value) + map.putInt("sourceImageIndex", entity.sourceImageIndex) + + val bbox = toWritableBoundingBox(entity.boundingBox) + if (bbox != null) { + map.putMap("bbox", bbox) + } + entity.confidence?.let { map.putDouble("confidence", it) } + + return map + } + + private fun toWritableStructuredData(data: SemanticStructuredData): WritableNativeMap? { + if (data.entities.isEmpty() && data.fields.isEmpty()) { + return null + } + + val map = WritableNativeMap() + + if (data.entities.isNotEmpty()) { + val entities = WritableNativeArray() + for (entity in data.entities) { + entities.pushMap(toWritableStructuredEntity(entity)) + } + map.putArray("entities", entities) + } + + if (data.fields.isNotEmpty()) { + val fields = WritableNativeArray() + for ((key, value) in data.fields) { + val entry = WritableNativeMap() + entry.putString("key", key) + entry.putString("value", value) + fields.pushMap(entry) + } + map.putArray("fields", fields) + } + + return map + } + + private fun toWritableTextBlockArray(blocks: List): WritableNativeArray { + val sorted = blocks.sortedWith( + compareBy( + { it.sourceImageIndex }, + { it.boundingBox?.top ?: Double.MAX_VALUE }, + { it.boundingBox?.left ?: Double.MAX_VALUE }, + { it.text } + ) + ) + + val payload = WritableNativeArray() + for (block in sorted) { + payload.pushMap(toWritableTextBlock(block)) + } + return payload + } + private fun resolveOnUi(promise: Promise, value: Any?) { reactApplicationContext.runOnUiQueueThread { promise.resolve(value) @@ -401,6 +875,18 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : } } + private fun getBooleanOrNull(map: ReadableMap, key: String): Boolean? { + return try { + if (!map.hasKey(key) || map.isNull(key)) { + null + } else { + map.getBoolean(key) + } + } catch (_: Exception) { + null + } + } + private fun startScan(activity: Activity) { val currentScanner = scanner if (currentScanner == null) { diff --git a/android/src/main/java/com/preeternal/scanner/analysis/DocumentSemantics.kt b/android/src/main/java/com/preeternal/scanner/analysis/DocumentSemantics.kt new file mode 100644 index 0000000..12ecd0f --- /dev/null +++ b/android/src/main/java/com/preeternal/scanner/analysis/DocumentSemantics.kt @@ -0,0 +1,238 @@ +package com.preeternal.scanner.analysis + +import com.preeternal.scanner.text.NormalizedBoundingBox +import com.preeternal.scanner.text.TextBlockResult + +data class SemanticRegion( + val type: String, + val sourceImageIndex: Int, + val boundingBox: NormalizedBoundingBox, + val score: Double? = null, + val text: String? = null +) + +data class SemanticTableCell( + val text: String, + val row: Int, + val column: Int, + val sourceImageIndex: Int, + val boundingBox: NormalizedBoundingBox? +) + +data class SemanticTable( + val sourceImageIndex: Int, + val rows: List>, + val cells: List, + val boundingBox: NormalizedBoundingBox? +) + +data class SemanticStructuredEntity( + val type: String, + val value: String, + val sourceImageIndex: Int, + val boundingBox: NormalizedBoundingBox?, + val confidence: Double? = null +) + +data class SemanticStructuredData( + val entities: List, + val fields: Map +) + +private data class TextLineEntry( + val text: String, + val sourceImageIndex: Int, + val boundingBox: NormalizedBoundingBox? +) + +object DocumentSemantics { + private val tableSplitRegex = Regex("\\s{2,}|\\t|\\|") + private val fieldRegex = Regex("^([A-Za-z0-9А-Яа-я _./-]{2,40})\\s*[:-]\\s*(.+)$") + private val phoneRegex = Regex("(?:\\+?\\d[\\d\\s().-]{7,}\\d)") + private val emailRegex = Regex("[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}", RegexOption.IGNORE_CASE) + private val dateRegex = Regex("\\b(?:\\d{1,2}[./-]\\d{1,2}[./-]\\d{2,4}|\\d{4}[./-]\\d{1,2}[./-]\\d{1,2})\\b") + private val amountRegex = Regex("\\b(?:[$€£]\\s?)?\\d{1,3}(?:[ ,]\\d{3})*(?:[.,]\\d{2})\\b") + + fun inferRegions(textBlocks: List): List { + return textBlocks.mapNotNull { block -> + val box = block.boundingBox ?: return@mapNotNull null + val bottom = box.top + box.height + val type = when { + box.top < 0.18 -> "header" + bottom > 0.84 -> "footer" + else -> "paragraph" + } + + SemanticRegion( + type = type, + sourceImageIndex = block.sourceImageIndex, + boundingBox = box, + score = 0.6, + text = block.text + ) + } + } + + fun inferTables(textBlocks: List): List { + val lines = flattenTextLines(textBlocks) + val grouped = lines.groupBy { it.sourceImageIndex } + + return grouped.entries.mapNotNull { (sourceImageIndex, sourceLines) -> + val parsedRows = sourceLines.mapNotNull { line -> + val cells = line.text + .split(tableSplitRegex) + .map { it.trim() } + .filter { it.isNotEmpty() } + + if (cells.size >= 2) { + Pair(cells, line.boundingBox) + } else { + null + } + } + + if (parsedRows.size < 2) { + return@mapNotNull null + } + + val rows = parsedRows.map { it.first } + val cells = parsedRows.flatMapIndexed { rowIndex, row -> + row.first.mapIndexed { columnIndex, value -> + SemanticTableCell( + text = value, + row = rowIndex, + column = columnIndex, + sourceImageIndex = sourceImageIndex, + boundingBox = row.second + ) + } + } + + SemanticTable( + sourceImageIndex = sourceImageIndex, + rows = rows, + cells = cells, + boundingBox = mergeBoundingBoxes(parsedRows.mapNotNull { it.second }) + ) + }.sortedBy { it.sourceImageIndex } + } + + fun inferStructuredData(textBlocks: List): SemanticStructuredData { + val lines = flattenTextLines(textBlocks) + val entities = mutableListOf() + val fields = mutableMapOf() + val dedup = mutableSetOf() + + for (line in lines) { + val text = line.text + + fieldRegex.find(text)?.let { match -> + val rawKey = match.groupValues.getOrNull(1)?.trim()?.lowercase().orEmpty() + val value = match.groupValues.getOrNull(2)?.trim().orEmpty() + val key = rawKey + .replace(Regex("[^a-z0-9а-я]+"), "_") + .trim('_') + + if (key.isNotEmpty() && value.isNotEmpty()) { + fields[key] = value + } + } + + appendEntityMatches(phoneRegex, "phone", text, line, entities, dedup) + appendEntityMatches(emailRegex, "email", text, line, entities, dedup) + appendEntityMatches(dateRegex, "date", text, line, entities, dedup) + appendEntityMatches(amountRegex, "amount", text, line, entities, dedup) + } + + return SemanticStructuredData( + entities = entities, + fields = fields + ) + } + + private fun appendEntityMatches( + regex: Regex, + type: String, + text: String, + line: TextLineEntry, + entities: MutableList, + dedup: MutableSet + ) { + regex.findAll(text).forEach { match -> + val value = match.value.trim() + if (value.isEmpty()) { + return@forEach + } + + val dedupKey = "$type|${line.sourceImageIndex}|$value" + if (!dedup.add(dedupKey)) { + return@forEach + } + + entities.add( + SemanticStructuredEntity( + type = type, + value = value, + sourceImageIndex = line.sourceImageIndex, + boundingBox = line.boundingBox + ) + ) + } + } + + private fun flattenTextLines(textBlocks: List): List { + val lines = mutableListOf() + + for (block in textBlocks) { + if (block.lines.isNotEmpty()) { + for (line in block.lines) { + val normalizedText = line.text.trim() + if (normalizedText.isEmpty()) { + continue + } + + lines.add( + TextLineEntry( + text = normalizedText, + sourceImageIndex = block.sourceImageIndex, + boundingBox = line.boundingBox ?: block.boundingBox + ) + ) + } + } else { + val normalizedText = block.text.trim() + if (normalizedText.isEmpty()) { + continue + } + + lines.add( + TextLineEntry( + text = normalizedText, + sourceImageIndex = block.sourceImageIndex, + boundingBox = block.boundingBox + ) + ) + } + } + + return lines + } + + private fun mergeBoundingBoxes(boxes: List): NormalizedBoundingBox? { + if (boxes.isEmpty()) { + return null + } + + val left = boxes.minOf { it.left } + val top = boxes.minOf { it.top } + val right = boxes.maxOf { it.left + it.width } + val bottom = boxes.maxOf { it.top + it.height } + + return NormalizedBoundingBox( + left = left, + top = top, + width = (right - left).coerceAtLeast(0.0), + height = (bottom - top).coerceAtLeast(0.0) + ) + } +} diff --git a/android/src/main/java/com/preeternal/scanner/text/TextExtractor.kt b/android/src/main/java/com/preeternal/scanner/text/TextExtractor.kt new file mode 100644 index 0000000..45ccc9f --- /dev/null +++ b/android/src/main/java/com/preeternal/scanner/text/TextExtractor.kt @@ -0,0 +1,33 @@ +package com.preeternal.scanner.text + +import android.content.Context + +data class NormalizedBoundingBox( + val left: Double, + val top: Double, + val width: Double, + val height: Double +) + +data class TextLineResult( + val text: String, + val boundingBox: NormalizedBoundingBox? +) + +data class TextBlockResult( + val text: String, + val sourceImageIndex: Int, + val boundingBox: NormalizedBoundingBox?, + val lines: List +) + +interface TextExtractor { + fun isFeatureEnabled(): Boolean + fun extractFromSource( + context: Context, + imageSource: String, + sourceImageIndex: Int, + enableRotate180Fallback: Boolean, + callback: (List) -> Unit + ) +} diff --git a/android/src/no-text/java/com/preeternal/scanner/text/TextExtractorImpl.kt b/android/src/no-text/java/com/preeternal/scanner/text/TextExtractorImpl.kt new file mode 100644 index 0000000..65846f1 --- /dev/null +++ b/android/src/no-text/java/com/preeternal/scanner/text/TextExtractorImpl.kt @@ -0,0 +1,17 @@ +package com.preeternal.scanner.text + +import android.content.Context + +class TextExtractorImpl : TextExtractor { + override fun isFeatureEnabled(): Boolean = false + + override fun extractFromSource( + context: Context, + imageSource: String, + sourceImageIndex: Int, + enableRotate180Fallback: Boolean, + callback: (List) -> Unit + ) { + callback(emptyList()) + } +} diff --git a/android/src/text/java/com/preeternal/scanner/text/TextExtractorImpl.kt b/android/src/text/java/com/preeternal/scanner/text/TextExtractorImpl.kt new file mode 100644 index 0000000..3eace9c --- /dev/null +++ b/android/src/text/java/com/preeternal/scanner/text/TextExtractorImpl.kt @@ -0,0 +1,210 @@ +package com.preeternal.scanner.text + +import android.content.Context +import android.graphics.Rect +import com.google.mlkit.vision.text.Text +import com.google.mlkit.vision.text.TextRecognition +import com.google.mlkit.vision.text.latin.TextRecognizerOptions +import kotlin.math.floor + +class TextExtractorImpl : TextExtractor { + private companion object { + private const val OCR_ROTATE_FALLBACK_DEGREES = 180 + private const val MIN_TEXT_CHARS_FOR_SINGLE_PASS = 24 + private const val CENTER_BUCKETS = 24 + private val WHITESPACE_REGEX = Regex("\\s+") + } + + private val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) + + override fun isFeatureEnabled(): Boolean = true + + override fun extractFromSource( + context: Context, + imageSource: String, + sourceImageIndex: Int, + enableRotate180Fallback: Boolean, + callback: (List) -> Unit + ) { + val inputImage = TextInputImageLoader.load(context, imageSource) ?: run { + callback(emptyList()) + return + } + + recognize(inputImage, sourceImageIndex) { firstPass -> + if (!enableRotate180Fallback || !shouldRunRotateFallback(firstPass)) { + callback(firstPass) + return@recognize + } + + val rotatedInput = TextInputImageLoader.loadWithAdditionalRotation( + context = context, + imageSource = imageSource, + additionalRotationDegrees = OCR_ROTATE_FALLBACK_DEGREES + ) + if (rotatedInput == null) { + callback(firstPass) + return@recognize + } + + recognize(rotatedInput, sourceImageIndex) { secondPass -> + if (totalCharacterCount(secondPass) > totalCharacterCount(firstPass)) { + callback(secondPass) + } else { + callback(firstPass) + } + } + } + } + + private fun recognize( + inputImage: com.google.mlkit.vision.common.InputImage, + sourceImageIndex: Int, + callback: (List) -> Unit + ) { + recognizer.process(inputImage) + .addOnSuccessListener { text -> + callback( + mapTextBlocks( + text = text, + sourceImageIndex = sourceImageIndex, + imageWidth = inputImage.width, + imageHeight = inputImage.height + ) + ) + } + .addOnFailureListener { + callback(emptyList()) + } + } + + private fun shouldRunRotateFallback(blocks: List): Boolean { + if (blocks.isEmpty()) { + return true + } + + return totalCharacterCount(blocks) < MIN_TEXT_CHARS_FOR_SINGLE_PASS + } + + private fun totalCharacterCount(blocks: List): Int { + var total = 0 + for (block in blocks) { + total += block.text.length + } + return total + } + + private fun mapTextBlocks( + text: Text, + sourceImageIndex: Int, + imageWidth: Int, + imageHeight: Int + ): List { + val mapped = mutableListOf() + val deduplicated = mutableSetOf() + + for (block in text.textBlocks) { + val blockText = block.text.trim() + if (blockText.isEmpty()) { + continue + } + + val lines = block.lines.mapNotNull { line -> + val lineText = line.text.trim() + if (lineText.isEmpty()) { + return@mapNotNull null + } + + TextLineResult( + text = lineText, + boundingBox = normalizeBoundingBox(line.boundingBox, imageWidth, imageHeight) + ) + } + + val mappedBlock = TextBlockResult( + text = blockText, + sourceImageIndex = sourceImageIndex, + boundingBox = normalizeBoundingBox(block.boundingBox, imageWidth, imageHeight), + lines = lines + ) + + val dedupKey = buildTextBlockDedupKey(mappedBlock) + if (dedupKey != null && !deduplicated.add(dedupKey)) { + continue + } + + mapped.add(mappedBlock) + } + + return mapped.sortedWith( + compareBy( + { it.sourceImageIndex }, + { it.boundingBox?.top ?: Double.MAX_VALUE }, + { it.boundingBox?.left ?: Double.MAX_VALUE }, + { it.text } + ) + ) + } + + private fun normalizeBoundingBox( + rect: Rect?, + imageWidth: Int, + imageHeight: Int + ): NormalizedBoundingBox? { + if (rect == null || imageWidth <= 0 || imageHeight <= 0) { + return null + } + + val width = (rect.width().toDouble() / imageWidth.toDouble()).coerceIn(0.0, 1.0) + val height = (rect.height().toDouble() / imageHeight.toDouble()).coerceIn(0.0, 1.0) + val left = (rect.left.toDouble() / imageWidth.toDouble()).coerceIn(0.0, 1.0) + val top = (rect.top.toDouble() / imageHeight.toDouble()).coerceIn(0.0, 1.0) + + return NormalizedBoundingBox( + left = left, + top = top, + width = width, + height = height + ) + } + + private fun buildTextBlockDedupKey(block: TextBlockResult): String? { + val normalizedText = normalizeTextForDedup(block.text) + if (normalizedText.isEmpty()) { + return null + } + + val bucket = centerBucketKey(block.boundingBox) ?: return null + return "${block.sourceImageIndex}|$normalizedText|$bucket" + } + + private fun normalizeTextForDedup(value: String): String { + return value + .trim() + .lowercase() + .split(WHITESPACE_REGEX) + .filter { it.isNotEmpty() } + .joinToString(" ") + } + + private fun centerBucketKey(boundingBox: NormalizedBoundingBox?): String? { + if (boundingBox == null) { + return null + } + + val centerX = (boundingBox.left + (boundingBox.width * 0.5)).coerceIn(0.0, 1.0) + val centerY = (boundingBox.top + (boundingBox.height * 0.5)).coerceIn(0.0, 1.0) + val xBucket = quantize(centerX) + val yBucket = quantize(centerY) + return "$xBucket:$yBucket" + } + + private fun quantize(value: Double): Int { + if (CENTER_BUCKETS <= 1) { + return 0 + } + + val scaled = floor(value.coerceIn(0.0, 1.0) * CENTER_BUCKETS.toDouble()).toInt() + return scaled.coerceIn(0, CENTER_BUCKETS - 1) + } +} diff --git a/android/src/text/java/com/preeternal/scanner/text/TextInputImageLoader.kt b/android/src/text/java/com/preeternal/scanner/text/TextInputImageLoader.kt new file mode 100644 index 0000000..171d73a --- /dev/null +++ b/android/src/text/java/com/preeternal/scanner/text/TextInputImageLoader.kt @@ -0,0 +1,172 @@ +package com.preeternal.scanner.text + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Base64 +import com.google.mlkit.vision.common.InputImage +import java.io.File +import java.io.InputStream + +internal object TextInputImageLoader { + private val base64Regex = Regex("^[A-Za-z0-9+/=\\s]+$") + + fun load(context: Context, imageSource: String): InputImage? { + val normalized = imageSource.trim() + if (normalized.isEmpty()) { + return null + } + + if (normalized.startsWith("data:", ignoreCase = true) && normalized.contains("base64,")) { + val payload = normalized.substringAfter("base64,", "") + return decodeBase64(payload) + } + + if (normalized.startsWith("content://", ignoreCase = true)) { + return fromUri(context, Uri.parse(normalized)) + } + + if (normalized.startsWith("file://", ignoreCase = true)) { + return fromUri(context, Uri.parse(normalized)) + } + + if (File(normalized).exists()) { + return fromUri(context, Uri.fromFile(File(normalized))) + } + + if (looksLikeBase64(normalized)) { + return decodeBase64(normalized) + } + + val parsed = Uri.parse(normalized) + return when (parsed.scheme?.lowercase()) { + "content", "file" -> fromUri(context, parsed) + null, "" -> { + val file = File(normalized) + if (file.exists()) { + fromUri(context, Uri.fromFile(file)) + } else { + null + } + } + else -> fromUri(context, parsed) + } + } + + fun loadWithAdditionalRotation( + context: Context, + imageSource: String, + additionalRotationDegrees: Int + ): InputImage? { + val normalizedDegrees = ((additionalRotationDegrees % 360) + 360) % 360 + if (normalizedDegrees == 0) { + return load(context, imageSource) + } + + val bitmap = loadBitmap(context, imageSource) ?: return null + return try { + InputImage.fromBitmap(bitmap, normalizedDegrees) + } catch (_: Exception) { + null + } + } + + private fun fromUri(context: Context, uri: Uri): InputImage? { + return try { + InputImage.fromFilePath(context, uri) + } catch (_: Exception) { + null + } + } + + private fun decodeBase64(payload: String): InputImage? { + return try { + val decoded = Base64.decode(payload, Base64.DEFAULT) + val bitmap = BitmapFactory.decodeByteArray(decoded, 0, decoded.size) ?: return null + InputImage.fromBitmap(bitmap, 0) + } catch (_: Exception) { + null + } + } + + private fun looksLikeBase64(value: String): Boolean { + val compact = value.replace("\\s".toRegex(), "") + return compact.length >= 32 && compact.length % 4 == 0 && base64Regex.matches(compact) + } + + private fun loadBitmap(context: Context, imageSource: String): Bitmap? { + val normalized = imageSource.trim() + if (normalized.isEmpty()) { + return null + } + + if (normalized.startsWith("data:", ignoreCase = true) && normalized.contains("base64,")) { + val payload = normalized.substringAfter("base64,", "") + return decodeBitmapFromBase64(payload) + } + + if (normalized.startsWith("content://", ignoreCase = true)) { + return decodeBitmapFromUri(context, Uri.parse(normalized)) + } + + if (normalized.startsWith("file://", ignoreCase = true)) { + return decodeBitmapFromUri(context, Uri.parse(normalized)) + } + + if (File(normalized).exists()) { + return decodeBitmapFromFile(normalized) + } + + if (looksLikeBase64(normalized)) { + return decodeBitmapFromBase64(normalized) + } + + val parsed = Uri.parse(normalized) + return when (parsed.scheme?.lowercase()) { + "content", "file" -> decodeBitmapFromUri(context, parsed) + null, "" -> { + val file = File(normalized) + if (file.exists()) { + decodeBitmapFromFile(file.path) + } else { + null + } + } + else -> decodeBitmapFromUri(context, parsed) + } + } + + private fun decodeBitmapFromUri(context: Context, uri: Uri): Bitmap? { + return try { + context.contentResolver.openInputStream(uri)?.use { decodeBitmapFromStream(it) } + } catch (_: Exception) { + null + } + } + + private fun decodeBitmapFromFile(path: String): Bitmap? { + return try { + BitmapFactory.decodeFile(path) + } catch (_: Exception) { + null + } + } + + private fun decodeBitmapFromStream(stream: InputStream): Bitmap? { + return try { + BitmapFactory.decodeStream(stream) + } catch (_: Exception) { + null + } + } + + private fun decodeBitmapFromBase64(payload: String): Bitmap? { + return try { + val decoded = Base64.decode(payload, Base64.DEFAULT) + BitmapFactory.decodeByteArray(decoded, 0, decoded.size) + } catch (_: IllegalArgumentException) { + null + } + } +} diff --git a/ios/DocScanner/Analysis/AnalysisModels.swift b/ios/DocScanner/Analysis/AnalysisModels.swift new file mode 100644 index 0000000..ebac6e7 --- /dev/null +++ b/ios/DocScanner/Analysis/AnalysisModels.swift @@ -0,0 +1,66 @@ +import Foundation + +struct AnalysisBarcode { + let value: String + let format: String + let sourceImageIndex: Int + let boundingBox: AnalysisBoundingBox? +} + +struct AnalysisBoundingBox { + let left: Double + let top: Double + let width: Double + let height: Double +} + +struct AnalysisTextLine { + let text: String + let sourceImageIndex: Int + let boundingBox: AnalysisBoundingBox? + let confidence: Double? +} + +struct AnalysisTextBlock { + let text: String + let sourceImageIndex: Int + let boundingBox: AnalysisBoundingBox? + let confidence: Double? + let lines: [AnalysisTextLine] +} + +struct AnalysisRegion { + let type: String + let sourceImageIndex: Int + let boundingBox: AnalysisBoundingBox + let score: Double? + let text: String? +} + +struct AnalysisTableCell { + let text: String + let row: Int + let column: Int + let sourceImageIndex: Int + let boundingBox: AnalysisBoundingBox? +} + +struct AnalysisTable { + let sourceImageIndex: Int + let rows: [[String]] + let cells: [AnalysisTableCell] + let boundingBox: AnalysisBoundingBox? +} + +struct AnalysisStructuredEntity { + let type: String + let value: String + let sourceImageIndex: Int + let boundingBox: AnalysisBoundingBox? + let confidence: Double? +} + +struct AnalysisStructuredData { + let entities: [AnalysisStructuredEntity] + let fields: [String: String] +} diff --git a/ios/DocScanner/Analysis/DocumentSemantics.swift b/ios/DocScanner/Analysis/DocumentSemantics.swift new file mode 100644 index 0000000..16159b9 --- /dev/null +++ b/ios/DocScanner/Analysis/DocumentSemantics.swift @@ -0,0 +1,325 @@ +import Foundation + +private struct TextLineEntry { + let text: String + let sourceImageIndex: Int + let boundingBox: AnalysisBoundingBox? +} + +enum DocumentSemantics { + private static let fieldRegex = try? NSRegularExpression( + pattern: "^([A-Za-z0-9А-Яа-я _./-]{2,40})\\s*[:-]\\s*(.+)$", + options: [] + ) + + private static let phoneRegex = try? NSRegularExpression( + pattern: "(?:\\+?\\d[\\d\\s().-]{7,}\\d)", + options: [] + ) + + private static let emailRegex = try? NSRegularExpression( + pattern: "[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}", + options: [.caseInsensitive] + ) + + private static let dateRegex = try? NSRegularExpression( + pattern: "\\b(?:\\d{1,2}[./-]\\d{1,2}[./-]\\d{2,4}|\\d{4}[./-]\\d{1,2}[./-]\\d{1,2})\\b", + options: [] + ) + + private static let amountRegex = try? NSRegularExpression( + pattern: "\\b(?:[$€£]\\s?)?\\d{1,3}(?:[ ,]\\d{3})*(?:[.,]\\d{2})\\b", + options: [] + ) + + private static let tableSplitRegex = try? NSRegularExpression( + pattern: "\\s{2,}|\\t|\\|", + options: [] + ) + + static func inferRegions(from textBlocks: [AnalysisTextBlock]) -> [AnalysisRegion] { + var regions: [AnalysisRegion] = [] + + for block in textBlocks { + guard let box = block.boundingBox else { + continue + } + + let bottom = box.top + box.height + let type: String + if box.top < 0.18 { + type = "header" + } else if bottom > 0.84 { + type = "footer" + } else { + type = "paragraph" + } + + regions.append( + AnalysisRegion( + type: type, + sourceImageIndex: block.sourceImageIndex, + boundingBox: box, + score: 0.6, + text: block.text + ) + ) + } + + return regions + } + + static func inferTables(from textBlocks: [AnalysisTextBlock]) -> [AnalysisTable] { + let lines = flattenTextLines(from: textBlocks) + let grouped = Dictionary(grouping: lines) { $0.sourceImageIndex } + var tables: [AnalysisTable] = [] + + for (sourceImageIndex, sourceLines) in grouped { + var parsedRows: [(cells: [String], boundingBox: AnalysisBoundingBox?)] = [] + + for line in sourceLines { + let cells = splitTableCells(line.text) + if cells.count >= 2 { + parsedRows.append((cells: cells, boundingBox: line.boundingBox)) + } + } + + if parsedRows.count < 2 { + continue + } + + let rows = parsedRows.map(\.cells) + var cells: [AnalysisTableCell] = [] + + for (rowIndex, row) in parsedRows.enumerated() { + for (columnIndex, value) in row.cells.enumerated() { + cells.append( + AnalysisTableCell( + text: value, + row: rowIndex, + column: columnIndex, + sourceImageIndex: sourceImageIndex, + boundingBox: row.boundingBox + ) + ) + } + } + + tables.append( + AnalysisTable( + sourceImageIndex: sourceImageIndex, + rows: rows, + cells: cells, + boundingBox: mergeBoundingBoxes(parsedRows.compactMap(\.boundingBox)) + ) + ) + } + + return tables.sorted { lhs, rhs in + lhs.sourceImageIndex < rhs.sourceImageIndex + } + } + + static func inferStructuredData(from textBlocks: [AnalysisTextBlock]) -> AnalysisStructuredData { + let lines = flattenTextLines(from: textBlocks) + var entities: [AnalysisStructuredEntity] = [] + var fields: [String: String] = [:] + var dedup = Set() + + for line in lines { + let text = line.text + + if let match = firstMatchGroups(using: fieldRegex, in: text), match.count >= 2 { + let rawKey = match[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let value = match[1].trimmingCharacters(in: .whitespacesAndNewlines) + let key = rawKey + .replacingOccurrences(of: "[^a-z0-9а-я]+", with: "_", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "_")) + if !key.isEmpty && !value.isEmpty { + fields[key] = value + } + } + + appendEntityMatches( + using: phoneRegex, + type: "phone", + text: text, + line: line, + entities: &entities, + dedup: &dedup + ) + appendEntityMatches( + using: emailRegex, + type: "email", + text: text, + line: line, + entities: &entities, + dedup: &dedup + ) + appendEntityMatches( + using: dateRegex, + type: "date", + text: text, + line: line, + entities: &entities, + dedup: &dedup + ) + appendEntityMatches( + using: amountRegex, + type: "amount", + text: text, + line: line, + entities: &entities, + dedup: &dedup + ) + } + + return AnalysisStructuredData(entities: entities, fields: fields) + } + + private static func flattenTextLines(from textBlocks: [AnalysisTextBlock]) -> [TextLineEntry] { + var lines: [TextLineEntry] = [] + + for block in textBlocks { + if !block.lines.isEmpty { + for line in block.lines { + let text = line.text.trimmingCharacters(in: .whitespacesAndNewlines) + if text.isEmpty { + continue + } + lines.append( + TextLineEntry( + text: text, + sourceImageIndex: block.sourceImageIndex, + boundingBox: line.boundingBox ?? block.boundingBox + ) + ) + } + continue + } + + let text = block.text.trimmingCharacters(in: .whitespacesAndNewlines) + if text.isEmpty { + continue + } + lines.append( + TextLineEntry( + text: text, + sourceImageIndex: block.sourceImageIndex, + boundingBox: block.boundingBox + ) + ) + } + + return lines + } + + private static func mergeBoundingBoxes(_ boxes: [AnalysisBoundingBox]) -> AnalysisBoundingBox? { + guard !boxes.isEmpty else { + return nil + } + + let left = boxes.map(\.left).min() ?? 0 + let top = boxes.map(\.top).min() ?? 0 + let right = boxes.map { $0.left + $0.width }.max() ?? 0 + let bottom = boxes.map { $0.top + $0.height }.max() ?? 0 + + return AnalysisBoundingBox( + left: left, + top: top, + width: max(0, right - left), + height: max(0, bottom - top) + ) + } + + private static func splitTableCells(_ text: String) -> [String] { + guard let regex = tableSplitRegex else { + return [text] + } + + let ns = text as NSString + let fullRange = NSRange(location: 0, length: ns.length) + let normalized = regex.stringByReplacingMatches( + in: text, + options: [], + range: fullRange, + withTemplate: "\u{001F}" + ) + + return normalized + .split(separator: "\u{001F}") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + private static func firstMatchGroups( + using regex: NSRegularExpression?, + in text: String + ) -> [String]? { + guard let regex else { + return nil + } + + let ns = text as NSString + let range = NSRange(location: 0, length: ns.length) + guard let match = regex.firstMatch(in: text, options: [], range: range) else { + return nil + } + + var groups: [String] = [] + if match.numberOfRanges <= 1 { + return groups + } + + for index in 1.. + ) { + guard let regex else { + return + } + + let ns = text as NSString + let range = NSRange(location: 0, length: ns.length) + let matches = regex.matches(in: text, options: [], range: range) + + for match in matches { + let value = ns.substring(with: match.range).trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { + continue + } + + let dedupKey = "\(type)|\(line.sourceImageIndex)|\(value)" + if dedup.contains(dedupKey) { + continue + } + dedup.insert(dedupKey) + + entities.append( + AnalysisStructuredEntity( + type: type, + value: value, + sourceImageIndex: line.sourceImageIndex, + boundingBox: line.boundingBox, + confidence: nil + ) + ) + } + } +} diff --git a/ios/DocScanner/Analysis/RecognizeDocumentsAnalyzer.swift b/ios/DocScanner/Analysis/RecognizeDocumentsAnalyzer.swift new file mode 100644 index 0000000..80a4c9c --- /dev/null +++ b/ios/DocScanner/Analysis/RecognizeDocumentsAnalyzer.swift @@ -0,0 +1,615 @@ +import DataDetection +import Foundation +import UIKit +import Vision + +@available(iOS 26.0, *) +enum RecognizeDocumentsAnalyzer { + struct Options { + let includeBarcodes: Bool + let includeText: Bool + let includeTables: Bool + let includeRegions: Bool + let includeStructuredData: Bool + let allowedBarcodeFormats: [String] + } + + struct PageAnalysis { + let barcodes: [AnalysisBarcode] + let textBlocks: [AnalysisTextBlock] + let tables: [AnalysisTable] + let regions: [AnalysisRegion] + let structuredData: AnalysisStructuredData + } + + static func analyzeImageBlocking( + _ image: UIImage, + sourceImageIndex: Int, + options: Options + ) -> PageAnalysis { + let semaphore = DispatchSemaphore(value: 0) + var output = PageAnalysis( + barcodes: [], + textBlocks: [], + tables: [], + regions: [], + structuredData: AnalysisStructuredData(entities: [], fields: [:]) + ) + + Task { + output = await analyzeImage( + image, + sourceImageIndex: sourceImageIndex, + options: options + ) + semaphore.signal() + } + + semaphore.wait() + return output + } + + private static func analyzeImage( + _ image: UIImage, + sourceImageIndex: Int, + options: Options + ) async -> PageAnalysis { + guard let cgImage = image.cgImage else { + return PageAnalysis( + barcodes: [], + textBlocks: [], + tables: [], + regions: [], + structuredData: AnalysisStructuredData(entities: [], fields: [:]) + ) + } + + var request = RecognizeDocumentsRequest() + var barcodeOptions = request.barcodeDetectionOptions + barcodeOptions.enabled = options.includeBarcodes + + let allowedFormats = Set(options.allowedBarcodeFormats.map { + $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + }) + let allowedSymbologies = symbologies(from: allowedFormats) + if !allowedSymbologies.isEmpty { + barcodeOptions.symbologies = allowedSymbologies + } + request.barcodeDetectionOptions = barcodeOptions + + let handler = ImageRequestHandler(cgImage) + + let observations: [DocumentObservation] + do { + observations = try await handler.perform(request) + } catch { + return PageAnalysis( + barcodes: [], + textBlocks: [], + tables: [], + regions: [], + structuredData: AnalysisStructuredData(entities: [], fields: [:]) + ) + } + + var textBlocks: [AnalysisTextBlock] = [] + var tables: [AnalysisTable] = [] + var barcodes: [AnalysisBarcode] = [] + var entities: [AnalysisStructuredEntity] = [] + var fields: [String: String] = [:] + var dedupEntities = Set() + var dedupBarcodes = Set() + var dedupTextBlocks = Set() + + for observation in observations { + let document = observation.document + + if options.includeText || options.includeRegions || options.includeStructuredData { + let extractedText = extractTextBlocks(from: document, sourceImageIndex: sourceImageIndex) + appendDeduplicatedTextBlocks( + extractedText, + dedup: &dedupTextBlocks, + output: &textBlocks + ) + } + + if options.includeTables || options.includeStructuredData { + let extractedTables = extractTables( + from: document, + sourceImageIndex: sourceImageIndex + ) + tables.append(contentsOf: extractedTables.tables) + for (key, value) in extractedTables.fields { + fields[key] = value + } + } + + if options.includeBarcodes { + for barcode in document.barcodes { + let value = normalizedBarcodeValue(from: barcode) + guard !value.isEmpty else { + continue + } + + let format = normalizeBarcodeSymbology(barcode.symbology) + if !allowedFormats.isEmpty && !allowedFormats.contains(format) { + continue + } + + let boundingBox = toBoundingBox(barcode.boundingRegion.boundingBox) + let dedupKey = barcodeInstanceKey( + format: format, + value: value, + sourceImageIndex: sourceImageIndex, + boundingBox: boundingBox + ) + if !dedupBarcodes.insert(dedupKey).inserted { + continue + } + + barcodes.append( + AnalysisBarcode( + value: value, + format: format, + sourceImageIndex: sourceImageIndex, + boundingBox: boundingBox + ) + ) + } + } + + if options.includeStructuredData { + collectStructuredEntities( + from: document, + sourceImageIndex: sourceImageIndex, + entities: &entities, + dedup: &dedupEntities + ) + } + } + + let sortedTextBlocks = textBlocks.sorted { lhs, rhs in + if lhs.sourceImageIndex != rhs.sourceImageIndex { + return lhs.sourceImageIndex < rhs.sourceImageIndex + } + + let lhsTop = lhs.boundingBox?.top ?? .greatestFiniteMagnitude + let rhsTop = rhs.boundingBox?.top ?? .greatestFiniteMagnitude + if lhsTop != rhsTop { + return lhsTop < rhsTop + } + + let lhsLeft = lhs.boundingBox?.left ?? .greatestFiniteMagnitude + let rhsLeft = rhs.boundingBox?.left ?? .greatestFiniteMagnitude + if lhsLeft != rhsLeft { + return lhsLeft < rhsLeft + } + + return lhs.text < rhs.text + } + + let regions = options.includeRegions + ? DocumentSemantics.inferRegions(from: sortedTextBlocks) + : [] + + return PageAnalysis( + barcodes: barcodes.sorted { lhs, rhs in + if lhs.sourceImageIndex != rhs.sourceImageIndex { + return lhs.sourceImageIndex < rhs.sourceImageIndex + } + if lhs.value != rhs.value { + return lhs.value < rhs.value + } + return lhs.format < rhs.format + }, + textBlocks: sortedTextBlocks, + tables: tables.sorted { lhs, rhs in + lhs.sourceImageIndex < rhs.sourceImageIndex + }, + regions: regions, + structuredData: AnalysisStructuredData( + entities: entities, + fields: fields + ) + ) + } + + private static func extractTextBlocks( + from document: DocumentObservation.Container, + sourceImageIndex: Int + ) -> [AnalysisTextBlock] { + var blocks: [AnalysisTextBlock] = [] + + let paragraphs = document.paragraphs.isEmpty ? [document.text] : document.paragraphs + + for paragraph in paragraphs { + let text = paragraph.transcript.trimmingCharacters(in: .whitespacesAndNewlines) + if text.isEmpty { + continue + } + + let lines: [AnalysisTextLine] = paragraph.lines.compactMap { line in + let lineText = line.transcript.trimmingCharacters(in: .whitespacesAndNewlines) + if lineText.isEmpty { + return nil + } + + return AnalysisTextLine( + text: lineText, + sourceImageIndex: sourceImageIndex, + boundingBox: toBoundingBox(line.boundingRegion.boundingBox), + confidence: Double(line.confidence) + ) + } + + blocks.append( + AnalysisTextBlock( + text: text, + sourceImageIndex: sourceImageIndex, + boundingBox: toBoundingBox(paragraph.boundingRegion.boundingBox), + confidence: nil, + lines: lines + ) + ) + } + + if let title = document.title { + let titleText = title.transcript.trimmingCharacters(in: .whitespacesAndNewlines) + if !titleText.isEmpty { + blocks.insert( + AnalysisTextBlock( + text: titleText, + sourceImageIndex: sourceImageIndex, + boundingBox: toBoundingBox(title.boundingRegion.boundingBox), + confidence: nil, + lines: [] + ), + at: 0 + ) + } + } + + return blocks + } + + private static func appendDeduplicatedTextBlocks( + _ blocks: [AnalysisTextBlock], + dedup: inout Set, + output: inout [AnalysisTextBlock] + ) { + for block in blocks { + if let dedupKey = textBlockInstanceKey(block), + !dedup.insert(dedupKey).inserted { + continue + } + output.append(block) + } + } + + private struct TableExtractionResult { + let tables: [AnalysisTable] + let fields: [String: String] + } + + private static func extractTables( + from document: DocumentObservation.Container, + sourceImageIndex: Int + ) -> TableExtractionResult { + var outputTables: [AnalysisTable] = [] + var fields: [String: String] = [:] + + for table in document.tables { + var rows: [[String]] = [] + var cells: [AnalysisTableCell] = [] + + for (rowIndex, row) in table.rows.enumerated() { + var rowValues: [String] = [] + + for (columnIndex, cell) in row.enumerated() { + let value = cell.content.text.transcript.trimmingCharacters(in: .whitespacesAndNewlines) + rowValues.append(value) + + cells.append( + AnalysisTableCell( + text: value, + row: rowIndex, + column: columnIndex, + sourceImageIndex: sourceImageIndex, + boundingBox: toBoundingBox(cell.content.boundingRegion.boundingBox) + ) + ) + } + + if rowValues.count >= 2 { + let key = rowValues[0] + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "[^a-z0-9а-я]+", with: "_", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "_")) + let value = rowValues[1].trimmingCharacters(in: .whitespacesAndNewlines) + if !key.isEmpty && !value.isEmpty { + fields[key] = value + } + } + + rows.append(rowValues) + } + + outputTables.append( + AnalysisTable( + sourceImageIndex: sourceImageIndex, + rows: rows, + cells: cells, + boundingBox: toBoundingBox(table.boundingRegion.boundingBox) + ) + ) + } + + return TableExtractionResult(tables: outputTables, fields: fields) + } + + private static func collectStructuredEntities( + from document: DocumentObservation.Container, + sourceImageIndex: Int, + entities: inout [AnalysisStructuredEntity], + dedup: inout Set + ) { + appendEntities( + from: document.text.detectedData, + sourceImageIndex: sourceImageIndex, + entities: &entities, + dedup: &dedup + ) + + for paragraph in document.paragraphs { + appendEntities( + from: paragraph.detectedData, + sourceImageIndex: sourceImageIndex, + entities: &entities, + dedup: &dedup + ) + } + + for table in document.tables { + for row in table.rows { + for cell in row { + appendEntities( + from: cell.content.text.detectedData, + sourceImageIndex: sourceImageIndex, + entities: &entities, + dedup: &dedup + ) + } + } + } + } + + private static func appendEntities( + from matches: [DocumentObservation.Container.DataDetectorMatch], + sourceImageIndex: Int, + entities: inout [AnalysisStructuredEntity], + dedup: inout Set + ) { + for data in matches { + let mapped: (type: String, value: String)? + + switch data.match.details { + case .emailAddress(let email): + mapped = ("email", email.emailAddress) + case .phoneNumber(let phone): + mapped = ("phone", phone.phoneNumber) + case .calendarEvent(let event): + if let startDate = event.startDate { + mapped = ("date", iso8601String(from: startDate)) + } else { + mapped = nil + } + case .moneyAmount(let money): + mapped = ("amount", "\(money.currency.identifier) \(money.amount)") + case .shipmentTrackingNumber(let tracking): + mapped = ("id", tracking.trackingNumber) + case .flightNumber(let flight): + mapped = ("id", "\(flight.airlineCode)\(flight.flightNumber)") + case .paymentIdentifier(let payment): + mapped = ("id", payment.identifier) + default: + mapped = nil + } + + guard let mapped else { + continue + } + + let value = mapped.value.trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { + continue + } + + let dedupKey = "\(mapped.type)|\(sourceImageIndex)|\(value)" + if !dedup.insert(dedupKey).inserted { + continue + } + + entities.append( + AnalysisStructuredEntity( + type: mapped.type, + value: value, + sourceImageIndex: sourceImageIndex, + boundingBox: toBoundingBox(data.boundingRegion.boundingBox), + confidence: nil + ) + ) + } + } + + private static func normalizedBarcodeValue(from barcode: BarcodeObservation) -> String { + if let payload = barcode.payloadString?.trimmingCharacters(in: .whitespacesAndNewlines), + !payload.isEmpty { + return payload + } + + if let payloadData = barcode.payloadData, + let text = String(data: payloadData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty { + return text + } + + return "" + } + + private static func symbologies(from formats: Set) -> [BarcodeSymbology] { + if formats.isEmpty { + return [] + } + + var values = Set() + + for format in formats { + switch format { + case "aztec": + values.insert(.aztec) + case "codabar": + values.insert(.codabar) + case "code39": + values.insert(.code39) + values.insert(.code39Checksum) + values.insert(.code39FullASCII) + values.insert(.code39FullASCIIChecksum) + case "code93": + values.insert(.code93) + values.insert(.code93i) + case "code128": + values.insert(.code128) + case "dataMatrix": + values.insert(.dataMatrix) + case "ean8": + values.insert(.ean8) + case "ean13": + values.insert(.ean13) + case "itf": + values.insert(.i2of5) + values.insert(.i2of5Checksum) + values.insert(.itf14) + case "pdf417": + values.insert(.pdf417) + values.insert(.microPDF417) + case "qr": + values.insert(.qr) + values.insert(.microQR) + case "upce": + values.insert(.upce) + default: + continue + } + } + + return Array(values) + } + + private static func normalizeBarcodeSymbology(_ symbology: BarcodeSymbology) -> String { + switch symbology { + case .aztec: + return "aztec" + case .codabar: + return "codabar" + case .code39, .code39Checksum, .code39FullASCII, .code39FullASCIIChecksum: + return "code39" + case .code93, .code93i: + return "code93" + case .code128: + return "code128" + case .dataMatrix: + return "dataMatrix" + case .ean8: + return "ean8" + case .ean13: + return "ean13" + case .i2of5, .i2of5Checksum, .itf14: + return "itf" + case .pdf417, .microPDF417: + return "pdf417" + case .qr, .microQR: + return "qr" + case .upce: + return "upce" + default: + return "unknown" + } + } + + private static func barcodeInstanceKey( + format: String, + value: String, + sourceImageIndex: Int, + boundingBox: AnalysisBoundingBox + ) -> String { + let centerX = boundingBox.left + (boundingBox.width * 0.5) + let centerY = boundingBox.top + (boundingBox.height * 0.5) + let xBucket = quantize(centerX, bucketCount: 24) + let yBucket = quantize(centerY, bucketCount: 24) + return "\(format)|\(sourceImageIndex)|\(value)|\(xBucket):\(yBucket)" + } + + private static func textBlockInstanceKey(_ block: AnalysisTextBlock) -> String? { + let normalizedText = normalizeTextForDedup(block.text) + guard !normalizedText.isEmpty else { + return nil + } + guard let boundingBox = block.boundingBox else { + return nil + } + + let centerX = boundingBox.left + (boundingBox.width * 0.5) + let centerY = boundingBox.top + (boundingBox.height * 0.5) + let xBucket = quantize(centerX, bucketCount: 24) + let yBucket = quantize(centerY, bucketCount: 24) + return "\(block.sourceImageIndex)|\(normalizedText)|\(xBucket):\(yBucket)" + } + + private static func normalizeTextForDedup(_ value: String) -> String { + let parts = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + return parts.joined(separator: " ") + } + + private static func quantize(_ value: Double, bucketCount: Int) -> Int { + guard bucketCount > 1 else { + return 0 + } + + let clamped = min(max(value, 0), 1) + let scaled = Int(floor(clamped * Double(bucketCount))) + return min(bucketCount - 1, max(0, scaled)) + } + + private static func toBoundingBox(_ rect: NormalizedRect) -> AnalysisBoundingBox { + let cgRect = rect.cgRect + let left = cgRect.minX.clamped(to: 0 ... 1) + let top = (1 - cgRect.maxY).clamped(to: 0 ... 1) + let width = cgRect.width.clamped(to: 0 ... 1) + let height = cgRect.height.clamped(to: 0 ... 1) + + return AnalysisBoundingBox( + left: Double(left), + top: Double(top), + width: Double(width), + height: Double(height) + ) + } + + private static func iso8601String(from date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: date) + } +} + +private extension CGFloat { + func clamped(to range: ClosedRange) -> CGFloat { + return Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} diff --git a/ios/DocScanner/Analysis/TextExtractor.swift b/ios/DocScanner/Analysis/TextExtractor.swift new file mode 100644 index 0000000..b7a629b --- /dev/null +++ b/ios/DocScanner/Analysis/TextExtractor.swift @@ -0,0 +1,221 @@ +import CoreGraphics +import Foundation +import UIKit +import Vision + +enum TextExtractor { + private enum FallbackThreshold { + static let minimumTotalCharacters = 24 + } + + private enum DedupThreshold { + static let centerBucketCount = 24 + } + + static func extractFromImage( + _ image: UIImage, + sourceImageIndex: Int, + enableRotate180Fallback: Bool = false + ) -> [AnalysisTextBlock] { + let firstPass = extractSinglePass( + image, + sourceImageIndex: sourceImageIndex + ) + + guard enableRotate180Fallback else { + return firstPass + } + guard shouldRunRotateFallback(firstPass) else { + return firstPass + } + guard let rotated = rotate180(image) else { + return firstPass + } + + let secondPass = extractSinglePass( + rotated, + sourceImageIndex: sourceImageIndex + ) + + return totalCharacterCount(secondPass) > totalCharacterCount(firstPass) + ? secondPass + : firstPass + } + + private static func extractSinglePass( + _ image: UIImage, + sourceImageIndex: Int + ) -> [AnalysisTextBlock] { + guard let cgImage = image.cgImage else { + return [] + } + + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.usesLanguageCorrection = false + + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) + do { + try handler.perform([request]) + } catch { + return [] + } + + guard let observations = request.results as? [VNRecognizedTextObservation] else { + return [] + } + + var blocks: [AnalysisTextBlock] = [] + var deduplicated = Set() + + for observation in observations { + guard let candidate = observation.topCandidates(1).first else { + continue + } + + let text = candidate.string.trimmingCharacters(in: .whitespacesAndNewlines) + if text.isEmpty { + continue + } + + let bbox = normalizeBoundingBox(observation.boundingBox) + let confidence = Double(candidate.confidence) + let line = AnalysisTextLine( + text: text, + sourceImageIndex: sourceImageIndex, + boundingBox: bbox, + confidence: confidence + ) + + let block = AnalysisTextBlock( + text: text, + sourceImageIndex: sourceImageIndex, + boundingBox: bbox, + confidence: confidence, + lines: [line] + ) + + if let dedupKey = textBlockInstanceKey(block), + !deduplicated.insert(dedupKey).inserted { + continue + } + + blocks.append(block) + } + + return blocks.sorted { lhs, rhs in + if lhs.sourceImageIndex != rhs.sourceImageIndex { + return lhs.sourceImageIndex < rhs.sourceImageIndex + } + + let lhsTop = lhs.boundingBox?.top ?? .greatestFiniteMagnitude + let rhsTop = rhs.boundingBox?.top ?? .greatestFiniteMagnitude + if lhsTop != rhsTop { + return lhsTop < rhsTop + } + + let lhsLeft = lhs.boundingBox?.left ?? .greatestFiniteMagnitude + let rhsLeft = rhs.boundingBox?.left ?? .greatestFiniteMagnitude + if lhsLeft != rhsLeft { + return lhsLeft < rhsLeft + } + + return lhs.text < rhs.text + } + } + + private static func shouldRunRotateFallback(_ blocks: [AnalysisTextBlock]) -> Bool { + if blocks.isEmpty { + return true + } + + return totalCharacterCount(blocks) < FallbackThreshold.minimumTotalCharacters + } + + private static func totalCharacterCount(_ blocks: [AnalysisTextBlock]) -> Int { + return blocks.reduce(0) { partial, block in + partial + block.text.count + } + } + + private static func textBlockInstanceKey(_ block: AnalysisTextBlock) -> String? { + let normalizedText = normalizeTextForDedup(block.text) + guard !normalizedText.isEmpty else { + return nil + } + guard let boundingBox = block.boundingBox else { + return nil + } + + let centerX = boundingBox.left + (boundingBox.width * 0.5) + let centerY = boundingBox.top + (boundingBox.height * 0.5) + let xBucket = quantize(centerX, bucketCount: DedupThreshold.centerBucketCount) + let yBucket = quantize(centerY, bucketCount: DedupThreshold.centerBucketCount) + return "\(block.sourceImageIndex)|\(normalizedText)|\(xBucket):\(yBucket)" + } + + private static func normalizeTextForDedup(_ value: String) -> String { + let parts = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + return parts.joined(separator: " ") + } + + private static func quantize(_ value: Double, bucketCount: Int) -> Int { + guard bucketCount > 1 else { + return 0 + } + + let clamped = min(max(value, 0), 1) + let scaled = Int(floor(clamped * Double(bucketCount))) + return min(bucketCount - 1, max(0, scaled)) + } + + private static func rotate180(_ image: UIImage) -> UIImage? { + guard let cgImage = image.cgImage else { + return nil + } + + let size = CGSize(width: cgImage.width, height: cgImage.height) + UIGraphicsBeginImageContextWithOptions(size, false, image.scale) + guard let context = UIGraphicsGetCurrentContext() else { + UIGraphicsEndImageContext() + return nil + } + + context.translateBy(x: size.width / 2, y: size.height / 2) + context.rotate(by: .pi) + image.draw(in: CGRect( + x: -size.width / 2, + y: -size.height / 2, + width: size.width, + height: size.height + )) + + let rotated = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return rotated + } + + private static func normalizeBoundingBox(_ rect: CGRect) -> AnalysisBoundingBox { + let left = rect.minX.clamped(to: 0.0 ... 1.0) + let top = (1.0 - rect.maxY).clamped(to: 0.0 ... 1.0) + let width = rect.width.clamped(to: 0.0 ... 1.0) + let height = rect.height.clamped(to: 0.0 ... 1.0) + + return AnalysisBoundingBox( + left: Double(left), + top: Double(top), + width: Double(width), + height: Double(height) + ) + } +} + +private extension CGFloat { + func clamped(to range: ClosedRange) -> CGFloat { + return Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} diff --git a/ios/DocScanner/Barcode/BarcodeExtractor.swift b/ios/DocScanner/Barcode/BarcodeExtractor.swift index c64abb3..eee8537 100644 --- a/ios/DocScanner/Barcode/BarcodeExtractor.swift +++ b/ios/DocScanner/Barcode/BarcodeExtractor.swift @@ -28,6 +28,7 @@ private let supportedNormalizedFormats: Set = [ struct ExtractedBarcode { let value: String let format: String + let instanceKey: String } enum BarcodeExtractor { @@ -36,37 +37,84 @@ enum BarcodeExtractor { allowedFormats: [String] = [] ) -> [ExtractedBarcode] { let normalizedAllowedFormats = normalizeAllowedFormats(allowedFormats) - let candidates = rotationCandidates(for: image) + var deduplicated = Set() + var aggregated: [ExtractedBarcode] = [] + + let firstPass = detectInTopRightROI( + image, + allowedFormats: normalizedAllowedFormats, + attemptIndex: 0 + ) + appendUnique( + firstPass, + deduplicated: &deduplicated, + aggregated: &aggregated + ) + if !aggregated.isEmpty { + return aggregated + } - for candidate in candidates { - let detected = detectInTopRightROI(candidate, allowedFormats: normalizedAllowedFormats) - if !detected.isEmpty { - return detected + if let rotated180 = image.rotated(by: .pi) { + let secondPass = detectInTopRightROI( + rotated180, + allowedFormats: normalizedAllowedFormats, + attemptIndex: 1 + ) + appendUnique( + secondPass, + deduplicated: &deduplicated, + aggregated: &aggregated + ) + if !aggregated.isEmpty { + return aggregated } } - return [] - } - - private static func rotationCandidates(for image: UIImage) -> [UIImage] { - var candidates: [UIImage] = [image] - if let rotated90 = image.rotated(by: .pi / 2) { - candidates.append(rotated90) + let thirdPass = detectInTopRightROI( + rotated90, + allowedFormats: normalizedAllowedFormats, + attemptIndex: 2 + ) + appendUnique( + thirdPass, + deduplicated: &deduplicated, + aggregated: &aggregated + ) } + if let rotatedMinus90 = image.rotated(by: -.pi / 2) { - candidates.append(rotatedMinus90) - } - if let rotated180 = image.rotated(by: .pi) { - candidates.append(rotated180) + let fourthPass = detectInTopRightROI( + rotatedMinus90, + allowedFormats: normalizedAllowedFormats, + attemptIndex: 3 + ) + appendUnique( + fourthPass, + deduplicated: &deduplicated, + aggregated: &aggregated + ) } - return candidates + return aggregated + } + + private static func appendUnique( + _ detected: [ExtractedBarcode], + deduplicated: inout Set, + aggregated: inout [ExtractedBarcode] + ) { + for barcode in detected { + if deduplicated.insert(barcode.instanceKey).inserted { + aggregated.append(barcode) + } + } } private static func detectInTopRightROI( _ image: UIImage, - allowedFormats: Set + allowedFormats: Set, + attemptIndex: Int ) -> [ExtractedBarcode] { guard let cgImage = image.cgImage else { return [] } @@ -77,7 +125,7 @@ enum BarcodeExtractor { let request = VNDetectBarcodesRequest() if !allowedFormats.isEmpty { - let requestedSymbologies = request.supportedSymbologies.filter { + let requestedSymbologies = VNDetectBarcodesRequest.supportedSymbologies.filter { allowedFormats.contains(normalizeFormat($0)) } if !requestedSymbologies.isEmpty { @@ -111,15 +159,41 @@ enum BarcodeExtractor { continue } - let dedupKey = "\(normalizedFormat)|\(payload)" + let centerBucket = bucketKey(for: observation.boundingBox) + let dedupKey = "\(normalizedFormat)|\(payload)|a\(attemptIndex)|\(centerBucket)" if deduplicated.insert(dedupKey).inserted { - results.append(ExtractedBarcode(value: payload, format: normalizedFormat)) + results.append( + ExtractedBarcode( + value: payload, + format: normalizedFormat, + instanceKey: dedupKey + ) + ) } } return results } + private static func bucketKey(for rect: CGRect, bucketCount: Int = 24) -> String { + let centerX = min(max((rect.minX + rect.maxX) * 0.5, 0), 1) + let centerY = min(max((rect.minY + rect.maxY) * 0.5, 0), 1) + + let xBucket = quantize(centerX, bucketCount: bucketCount) + let yBucket = quantize(centerY, bucketCount: bucketCount) + return "\(xBucket):\(yBucket)" + } + + private static func quantize(_ value: CGFloat, bucketCount: Int) -> Int { + guard bucketCount > 1 else { + return 0 + } + + let clamped = min(max(value, 0), 1) + let scaled = Int(floor(clamped * CGFloat(bucketCount))) + return min(bucketCount - 1, max(0, scaled)) + } + private static func roiRect(forWidth width: CGFloat, height: CGFloat) -> CGRect { let cropWidth = width * BarcodeExtractionConstants.cropWidthPercent / 100.0 let cropHeight = height * BarcodeExtractionConstants.cropHeightPercent / 100.0 diff --git a/ios/DocumentScanner.mm b/ios/DocumentScanner.mm index 8e1e8fe..b4fac13 100644 --- a/ios/DocumentScanner.mm +++ b/ios/DocumentScanner.mm @@ -40,6 +40,20 @@ - (void)handleBarcodeExtractionWithOptions:(NSDictionary *)options [self.impl extractBarcodesFromImages:options resolve:resolve reject:reject]; } +- (void)handleTextExtractionWithOptions:(NSDictionary *)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [self.impl extractTextFromImages:options resolve:resolve reject:reject]; +} + +- (void)handleAnalyzeWithOptions:(NSDictionary *)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [self.impl analyzeScannedImages:options resolve:resolve reject:reject]; +} + - (void)invalidate { [self.impl invalidate]; @@ -96,6 +110,83 @@ - (void)extractBarcodesFromImages: [self handleBarcodeExtractionWithOptions:dict resolve:resolve reject:reject]; } +- (void)extractTextFromImages: + (JS::NativeDocumentScanner::ExtractTextFromImagesRequest &)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSMutableDictionary *dict = [NSMutableDictionary new]; + + auto images = options.images(); + NSMutableArray *mappedImages = [NSMutableArray arrayWithCapacity:images.size()]; + for (const auto &image : images) { + if (image != nil) { + [mappedImages addObject:image]; + } + } + dict[@"images"] = mappedImages; + + if (options.concurrency().has_value()) { + dict[@"concurrency"] = @(options.concurrency().value()); + } + if (options.ocrRotate180Fallback().has_value()) { + dict[@"ocrRotate180Fallback"] = @(options.ocrRotate180Fallback().value()); + } + + [self handleTextExtractionWithOptions:dict resolve:resolve reject:reject]; +} + +- (void)analyzeScannedImages: + (JS::NativeDocumentScanner::AnalyzeScannedImagesRequest &)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSMutableDictionary *dict = [NSMutableDictionary new]; + + auto images = options.images(); + NSMutableArray *mappedImages = [NSMutableArray arrayWithCapacity:images.size()]; + for (const auto &image : images) { + if (image != nil) { + [mappedImages addObject:image]; + } + } + dict[@"images"] = mappedImages; + + if (options.extractBarcodes().has_value()) { + dict[@"extractBarcodes"] = @(options.extractBarcodes().value()); + } + if (options.extractText().has_value()) { + dict[@"extractText"] = @(options.extractText().value()); + } + if (options.extractTables().has_value()) { + dict[@"extractTables"] = @(options.extractTables().value()); + } + if (options.extractRegions().has_value()) { + dict[@"extractRegions"] = @(options.extractRegions().value()); + } + if (options.extractStructuredData().has_value()) { + dict[@"extractStructuredData"] = @(options.extractStructuredData().value()); + } + if (options.barcodeFormats().has_value()) { + auto formats = options.barcodeFormats().value(); + NSMutableArray *mappedFormats = [NSMutableArray arrayWithCapacity:formats.size()]; + for (const auto &format : formats) { + if (format != nil) { + [mappedFormats addObject:format]; + } + } + dict[@"barcodeFormats"] = mappedFormats; + } + if (options.concurrency().has_value()) { + dict[@"concurrency"] = @(options.concurrency().value()); + } + if (options.ocrRotate180Fallback().has_value()) { + dict[@"ocrRotate180Fallback"] = @(options.ocrRotate180Fallback().value()); + } + + [self handleAnalyzeWithOptions:dict resolve:resolve reject:reject]; +} + - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params { @@ -115,5 +206,19 @@ - (void)extractBarcodesFromImages: { [self handleBarcodeExtractionWithOptions:options resolve:resolve reject:reject]; } + +RCT_EXPORT_METHOD(extractTextFromImages:(NSDictionary *)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + [self handleTextExtractionWithOptions:options resolve:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(analyzeScannedImages:(NSDictionary *)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + [self handleAnalyzeWithOptions:options resolve:resolve reject:reject]; +} #endif @end diff --git a/ios/DocumentScanner.swift b/ios/DocumentScanner.swift index cda9ce3..6bcfa99 100644 --- a/ios/DocumentScanner.swift +++ b/ios/DocumentScanner.swift @@ -5,14 +5,26 @@ import React @objc(DocumentScannerImpl) public class DocumentScannerImpl: NSObject { private var docScanner: DocScanner? - private let barcodeQueue: OperationQueue = { + private let analysisQueue: OperationQueue = { let queue = OperationQueue() - queue.name = "com.preeternal.document-scanner.barcode" + queue.name = "com.preeternal.document-scanner.analysis" queue.maxConcurrentOperationCount = 2 queue.qualityOfService = .utility return queue }() + private enum AnalysisStageStatus { + case success + case notEnabled + case failed + case skipped + } + + private struct IndexedImageSource { + let sourceImageIndex: Int + let imageSource: String + } + @objc static func requiresMainQueueSetup() -> Bool { true } @objc(scanDocument:resolve:reject:) @@ -95,28 +107,426 @@ public class DocumentScannerImpl: NSObject { let opts = options as? [String: Any] ?? [:] let rawImages = opts["images"] as? [Any] ?? [] + let sources = buildImageSources(rawImages) let allowedFormats = (opts["barcodeFormats"] as? [Any] ?? []) .compactMap { $0 as? String } let requestedConcurrency = opts["concurrency"] as? Int ?? 2 let concurrency = max(1, min(2, requestedConcurrency)) - if rawImages.isEmpty { + if sources.isEmpty { resolve([]) return } + if #available(iOS 26.0, *) { + let modernOptions = RecognizeDocumentsAnalyzer.Options( + includeBarcodes: true, + includeText: false, + includeTables: false, + includeRegions: false, + includeStructuredData: false, + allowedBarcodeFormats: allowedFormats + ) + + performModernAnalysis( + sources: sources, + options: modernOptions, + concurrency: concurrency + ) { analysis in + resolve(analysis.barcodes.map(self.toDictionary)) + } + return + } + + // TODO(preeternal): Remove this legacy barcode path when iOS 26+ becomes the practical baseline. + performBarcodeExtraction( + sources: sources, + allowedFormats: allowedFormats, + concurrency: concurrency + ) { extractedBarcodes in + resolve(extractedBarcodes) + } + } + + @objc(extractTextFromImages:resolve:reject:) + public func extractTextFromImages( + _ options: NSDictionary, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 13.0, *) else { + reject("unsupported_ios", "iOS 13.0 or higher required", nil) + return + } + + let opts = options as? [String: Any] ?? [:] + let rawImages = opts["images"] as? [Any] ?? [] + let sources = buildImageSources(rawImages) + let ocrRotate180Fallback = opts["ocrRotate180Fallback"] as? Bool ?? false + let requestedConcurrency = opts["concurrency"] as? Int ?? 2 + let concurrency = max(1, min(2, requestedConcurrency)) + + if sources.isEmpty { + resolve([]) + return + } + + if #available(iOS 26.0, *) { + let modernOptions = RecognizeDocumentsAnalyzer.Options( + includeBarcodes: false, + includeText: true, + includeTables: false, + includeRegions: false, + includeStructuredData: false, + allowedBarcodeFormats: [] + ) + + performModernAnalysis( + sources: sources, + options: modernOptions, + concurrency: concurrency + ) { analysis in + resolve(self.toDictionaryArray(analysis.textBlocks)) + } + return + } + + // TODO(preeternal): Remove this legacy OCR path when iOS 26+ becomes the practical baseline. + performTextExtraction( + sources: sources, + ocrRotate180Fallback: ocrRotate180Fallback, + concurrency: concurrency + ) { textBlocks in + resolve(self.toDictionaryArray(textBlocks)) + } + } + + @objc(analyzeScannedImages:resolve:reject:) + public func analyzeScannedImages( + _ options: NSDictionary, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 13.0, *) else { + reject("unsupported_ios", "iOS 13.0 or higher required", nil) + return + } + + let opts = options as? [String: Any] ?? [:] + let rawImages = opts["images"] as? [Any] ?? [] + let sources = buildImageSources(rawImages) + + let wantsBarcodes = opts["extractBarcodes"] as? Bool ?? false + let wantsText = opts["extractText"] as? Bool ?? false + let wantsTables = opts["extractTables"] as? Bool ?? false + let wantsRegions = opts["extractRegions"] as? Bool ?? false + let wantsStructuredData = opts["extractStructuredData"] as? Bool ?? false + let wantsTextPipeline = wantsText || wantsTables || wantsRegions || wantsStructuredData + let ocrRotate180Fallback = opts["ocrRotate180Fallback"] as? Bool ?? false + + if sources.isEmpty || (!wantsBarcodes && !wantsTextPipeline) { + resolve(["status": "success"]) + return + } + + let requestedConcurrency = opts["concurrency"] as? Int ?? 2 + let concurrency = max(1, min(2, requestedConcurrency)) + let allowedFormats = (opts["barcodeFormats"] as? [Any] ?? []) + .compactMap { $0 as? String } + + if #available(iOS 26.0, *) { + let modernOptions = RecognizeDocumentsAnalyzer.Options( + includeBarcodes: wantsBarcodes, + includeText: wantsText, + includeTables: wantsTables, + includeRegions: wantsRegions, + includeStructuredData: wantsStructuredData, + allowedBarcodeFormats: allowedFormats + ) + + performModernAnalysis( + sources: sources, + options: modernOptions, + concurrency: concurrency + ) { analysis in + var response: [String: Any] = [ + "status": "success" + ] + + if wantsBarcodes { + response["barcodes"] = analysis.barcodes.map(self.toDictionary) + } + + if wantsText { + let mappedText = self.toDictionaryArray(analysis.textBlocks) + response["textBlocks"] = mappedText + response["text"] = mappedText + } + + if wantsTables { + response["tables"] = analysis.tables.map(self.toDictionary) + } + + if wantsRegions { + response["regions"] = analysis.regions.map(self.toDictionary) + } + + if wantsStructuredData { + let mapped = self.toDictionary(analysis.structuredData) + if mapped["entities"] != nil || mapped["fields"] != nil { + response["structuredData"] = mapped + } + } + + resolve(response) + } + return + } + + // TODO(preeternal): Remove this legacy mixed analysis fallback when iOS 26+ becomes the practical baseline. + let group = DispatchGroup() + var barcodeStage: AnalysisStageStatus = .skipped + var textStage: AnalysisStageStatus = .skipped + var barcodes: [[String: Any]] = [] + var textBlocks: [AnalysisTextBlock] = [] + + if wantsBarcodes { + barcodeStage = .failed + group.enter() + performBarcodeExtraction( + sources: sources, + allowedFormats: allowedFormats, + concurrency: concurrency + ) { extracted in + barcodes = extracted + barcodeStage = .success + group.leave() + } + } + + if wantsTextPipeline { + textStage = .failed + group.enter() + performTextExtraction( + sources: sources, + ocrRotate180Fallback: ocrRotate180Fallback, + concurrency: concurrency + ) { extracted in + textBlocks = extracted + textStage = .success + group.leave() + } + } + + group.notify(queue: .main) { + var response: [String: Any] = [ + "status": self.mergeStageStatuses([barcodeStage, textStage]) + ] + + if wantsBarcodes && barcodeStage == .success { + response["barcodes"] = barcodes + } + + if textStage == .success { + if wantsText { + let mappedText = self.toDictionaryArray(textBlocks) + response["textBlocks"] = mappedText + response["text"] = mappedText + } + + if wantsTables { + let tables = DocumentSemantics.inferTables(from: textBlocks) + response["tables"] = tables.map(self.toDictionary) + } + + if wantsRegions { + let regions = DocumentSemantics.inferRegions(from: textBlocks) + response["regions"] = regions.map(self.toDictionary) + } + + if wantsStructuredData { + let structured = DocumentSemantics.inferStructuredData(from: textBlocks) + let mapped = self.toDictionary(structured) + if mapped["entities"] != nil || mapped["fields"] != nil { + response["structuredData"] = mapped + } + } + } + + resolve(response) + } + } + + @objc + public func invalidate() { + docScanner = nil + analysisQueue.cancelAllOperations() + } + + private func buildImageSources(_ rawImages: [Any]) -> [IndexedImageSource] { + return rawImages.enumerated().compactMap { index, source in + guard let imageSource = source as? String else { + return nil + } + let normalized = imageSource.trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.isEmpty { + return nil + } + return IndexedImageSource(sourceImageIndex: index, imageSource: normalized) + } + } + + @available(iOS 26.0, *) + private struct ModernAnalysisResult { + var barcodes: [AnalysisBarcode] = [] + var textBlocks: [AnalysisTextBlock] = [] + var tables: [AnalysisTable] = [] + var regions: [AnalysisRegion] = [] + var structuredData: AnalysisStructuredData = AnalysisStructuredData(entities: [], fields: [:]) + } + + @available(iOS 26.0, *) + private func performModernAnalysis( + sources: [IndexedImageSource], + options: RecognizeDocumentsAnalyzer.Options, + concurrency: Int, + completion: @escaping (ModernAnalysisResult) -> Void + ) { + if sources.isEmpty { + DispatchQueue.main.async { + completion(ModernAnalysisResult()) + } + return + } + let requestLimiter = DispatchSemaphore(value: concurrency) let lock = NSLock() let group = DispatchGroup() - var extractedBarcodes: [[String: Any]] = [] + var pages: [RecognizeDocumentsAnalyzer.PageAnalysis] = [] - for (sourceImageIndex, source) in rawImages.enumerated() { - guard let imageSource = source as? String, - !imageSource.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - continue + for source in sources { + let operation = BlockOperation() + group.enter() + operation.completionBlock = { + group.leave() } + operation.addExecutionBlock { [weak operation] in + guard let operation = operation, !operation.isCancelled else { + return + } + + requestLimiter.wait() + defer { requestLimiter.signal() } + + guard !operation.isCancelled else { + return + } + + guard let image = BarcodeImageSource.loadImage(from: source.imageSource) else { + return + } + let page = RecognizeDocumentsAnalyzer.analyzeImageBlocking( + image, + sourceImageIndex: source.sourceImageIndex, + options: options + ) + + lock.lock() + pages.append(page) + lock.unlock() + } + analysisQueue.addOperation(operation) + } + + group.notify(queue: .main) { + var merged = ModernAnalysisResult() + var entities: [AnalysisStructuredEntity] = [] + var fields: [String: String] = [:] + + let sortedPages = pages.sorted { lhs, rhs in + let lhsIndex = lhs.textBlocks.first?.sourceImageIndex ?? + lhs.tables.first?.sourceImageIndex ?? + lhs.barcodes.first?.sourceImageIndex ?? Int.max + let rhsIndex = rhs.textBlocks.first?.sourceImageIndex ?? + rhs.tables.first?.sourceImageIndex ?? + rhs.barcodes.first?.sourceImageIndex ?? Int.max + return lhsIndex < rhsIndex + } + + for page in sortedPages { + merged.barcodes.append(contentsOf: page.barcodes) + merged.textBlocks.append(contentsOf: page.textBlocks) + merged.tables.append(contentsOf: page.tables) + merged.regions.append(contentsOf: page.regions) + entities.append(contentsOf: page.structuredData.entities) + for (key, value) in page.structuredData.fields { + fields[key] = value + } + } + + merged.barcodes.sort { lhs, rhs in + if lhs.sourceImageIndex != rhs.sourceImageIndex { + return lhs.sourceImageIndex < rhs.sourceImageIndex + } + if lhs.value != rhs.value { + return lhs.value < rhs.value + } + return lhs.format < rhs.format + } + + merged.textBlocks.sort { lhs, rhs in + if lhs.sourceImageIndex != rhs.sourceImageIndex { + return lhs.sourceImageIndex < rhs.sourceImageIndex + } + let lhsTop = lhs.boundingBox?.top ?? .greatestFiniteMagnitude + let rhsTop = rhs.boundingBox?.top ?? .greatestFiniteMagnitude + if lhsTop != rhsTop { + return lhsTop < rhsTop + } + let lhsLeft = lhs.boundingBox?.left ?? .greatestFiniteMagnitude + let rhsLeft = rhs.boundingBox?.left ?? .greatestFiniteMagnitude + if lhsLeft != rhsLeft { + return lhsLeft < rhsLeft + } + return lhs.text < rhs.text + } + + merged.tables.sort { lhs, rhs in + lhs.sourceImageIndex < rhs.sourceImageIndex + } + merged.regions.sort { lhs, rhs in + lhs.sourceImageIndex < rhs.sourceImageIndex + } + merged.structuredData = AnalysisStructuredData( + entities: entities, + fields: fields + ) + + completion(merged) + } + } + + private func performBarcodeExtraction( + sources: [IndexedImageSource], + allowedFormats: [String], + concurrency: Int, + completion: @escaping ([[String: Any]]) -> Void + ) { + if sources.isEmpty { + DispatchQueue.main.async { + completion([]) + } + return + } + + let requestLimiter = DispatchSemaphore(value: concurrency) + let lock = NSLock() + let group = DispatchGroup() + var extractedBarcodes: [[String: Any]] = [] + + for source in sources { let operation = BlockOperation() group.enter() operation.completionBlock = { @@ -134,7 +544,7 @@ public class DocumentScannerImpl: NSObject { return } - guard let image = BarcodeImageSource.loadImage(from: imageSource) else { + guard let image = BarcodeImageSource.loadImage(from: source.imageSource) else { return } @@ -156,7 +566,7 @@ public class DocumentScannerImpl: NSObject { return [ "value": value, "format": barcode.format, - "sourceImageIndex": sourceImageIndex + "sourceImageIndex": source.sourceImageIndex ] } @@ -168,7 +578,7 @@ public class DocumentScannerImpl: NSObject { extractedBarcodes.append(contentsOf: mapped) lock.unlock() } - barcodeQueue.addOperation(operation) + analysisQueue.addOperation(operation) } group.notify(queue: .main) { @@ -185,13 +595,247 @@ public class DocumentScannerImpl: NSObject { return lhsValue < rhsValue } - resolve(sorted) + completion(sorted) } } - @objc - public func invalidate() { - docScanner = nil - barcodeQueue.cancelAllOperations() + private func performTextExtraction( + sources: [IndexedImageSource], + ocrRotate180Fallback: Bool, + concurrency: Int, + completion: @escaping ([AnalysisTextBlock]) -> Void + ) { + if sources.isEmpty { + DispatchQueue.main.async { + completion([]) + } + return + } + + let requestLimiter = DispatchSemaphore(value: concurrency) + let lock = NSLock() + let group = DispatchGroup() + var extractedTextBlocks: [AnalysisTextBlock] = [] + + for source in sources { + let operation = BlockOperation() + group.enter() + operation.completionBlock = { + group.leave() + } + operation.addExecutionBlock { [weak operation] in + guard let operation = operation, !operation.isCancelled else { + return + } + + requestLimiter.wait() + defer { requestLimiter.signal() } + + guard !operation.isCancelled else { + return + } + + guard let image = BarcodeImageSource.loadImage(from: source.imageSource) else { + return + } + + let detected = TextExtractor.extractFromImage( + image, + sourceImageIndex: source.sourceImageIndex, + enableRotate180Fallback: ocrRotate180Fallback + ) + + guard !detected.isEmpty else { + return + } + + lock.lock() + extractedTextBlocks.append(contentsOf: detected) + lock.unlock() + } + analysisQueue.addOperation(operation) + } + + group.notify(queue: .main) { + let sorted = extractedTextBlocks.sorted { lhs, rhs in + if lhs.sourceImageIndex != rhs.sourceImageIndex { + return lhs.sourceImageIndex < rhs.sourceImageIndex + } + + let lhsTop = lhs.boundingBox?.top ?? .greatestFiniteMagnitude + let rhsTop = rhs.boundingBox?.top ?? .greatestFiniteMagnitude + if lhsTop != rhsTop { + return lhsTop < rhsTop + } + + let lhsLeft = lhs.boundingBox?.left ?? .greatestFiniteMagnitude + let rhsLeft = rhs.boundingBox?.left ?? .greatestFiniteMagnitude + if lhsLeft != rhsLeft { + return lhsLeft < rhsLeft + } + + return lhs.text < rhs.text + } + + completion(sorted) + } + } + + private func mergeStageStatuses(_ statuses: [AnalysisStageStatus]) -> String { + let requested = statuses.filter { $0 != .skipped } + if requested.isEmpty { + return "success" + } + if requested.allSatisfy({ $0 == .success }) { + return "success" + } + if requested.allSatisfy({ $0 == .notEnabled }) { + return "not_enabled" + } + if requested.contains(.success) { + return "partial" + } + return "failed" + } + + private func toDictionaryArray(_ textBlocks: [AnalysisTextBlock]) -> [[String: Any]] { + return textBlocks.map(toDictionary) + } + + private func toDictionary(_ barcode: AnalysisBarcode) -> [String: Any] { + var map: [String: Any] = [ + "value": barcode.value, + "format": barcode.format, + "sourceImageIndex": barcode.sourceImageIndex + ] + + if let boundingBox = barcode.boundingBox { + map["bbox"] = toDictionary(boundingBox) + } + + return map + } + + private func toDictionary(_ textBlock: AnalysisTextBlock) -> [String: Any] { + var map: [String: Any] = [ + "text": textBlock.text, + "sourceImageIndex": textBlock.sourceImageIndex, + "lines": textBlock.lines.map(toDictionary) + ] + + if let boundingBox = textBlock.boundingBox { + map["bbox"] = toDictionary(boundingBox) + } + if let confidence = textBlock.confidence { + map["confidence"] = confidence + } + + return map + } + + private func toDictionary(_ textLine: AnalysisTextLine) -> [String: Any] { + var map: [String: Any] = [ + "text": textLine.text + ] + + if let boundingBox = textLine.boundingBox { + map["bbox"] = toDictionary(boundingBox) + } + if let confidence = textLine.confidence { + map["confidence"] = confidence + } + + return map + } + + private func toDictionary(_ region: AnalysisRegion) -> [String: Any] { + var map: [String: Any] = [ + "type": region.type, + "sourceImageIndex": region.sourceImageIndex, + "bbox": toDictionary(region.boundingBox) + ] + + if let score = region.score { + map["score"] = score + } + if let text = region.text, !text.isEmpty { + map["text"] = text + } + + return map + } + + private func toDictionary(_ table: AnalysisTable) -> [String: Any] { + var map: [String: Any] = [ + "sourceImageIndex": table.sourceImageIndex, + "rows": table.rows, + "cells": table.cells.map(toDictionary) + ] + + if let boundingBox = table.boundingBox { + map["bbox"] = toDictionary(boundingBox) + } + + return map + } + + private func toDictionary(_ cell: AnalysisTableCell) -> [String: Any] { + var map: [String: Any] = [ + "text": cell.text, + "row": cell.row, + "column": cell.column, + "sourceImageIndex": cell.sourceImageIndex + ] + + if let boundingBox = cell.boundingBox { + map["bbox"] = toDictionary(boundingBox) + } + + return map + } + + private func toDictionary(_ structuredData: AnalysisStructuredData) -> [String: Any] { + var map: [String: Any] = [:] + + if !structuredData.entities.isEmpty { + map["entities"] = structuredData.entities.map(toDictionary) + } + if !structuredData.fields.isEmpty { + let fields = structuredData.fields.map { key, value in + [ + "key": key, + "value": value + ] + } + map["fields"] = fields + } + + return map + } + + private func toDictionary(_ entity: AnalysisStructuredEntity) -> [String: Any] { + var map: [String: Any] = [ + "type": entity.type, + "value": entity.value, + "sourceImageIndex": entity.sourceImageIndex + ] + + if let boundingBox = entity.boundingBox { + map["bbox"] = toDictionary(boundingBox) + } + if let confidence = entity.confidence { + map["confidence"] = confidence + } + + return map + } + + private func toDictionary(_ boundingBox: AnalysisBoundingBox) -> [String: Any] { + return [ + "left": boundingBox.left, + "top": boundingBox.top, + "width": boundingBox.width, + "height": boundingBox.height + ] } } diff --git a/src/NativeDocumentScanner.ts b/src/NativeDocumentScanner.ts index 5e9b711..1de82e6 100644 --- a/src/NativeDocumentScanner.ts +++ b/src/NativeDocumentScanner.ts @@ -57,6 +57,35 @@ export interface ExtractBarcodesFromImagesRequest images: string[]; } +/** + * Options for OCR extraction from existing images. + */ +export interface ExtractTextFromImagesOptions { + /** + * Maximum native worker concurrency. The implementation clamps this value to 1..2. + * @default 2 + */ + concurrency?: AnalysisConcurrency; + + /** + * Enables an adaptive OCR fallback: run an additional 180° pass only when + * the first pass returns no or very little text. + * @default false + */ + ocrRotate180Fallback?: boolean; +} + +/** + * Native request shape for extractTextFromImages. + */ +export interface ExtractTextFromImagesRequest + extends ExtractTextFromImagesOptions { + /** + * Array of image sources. Each item can be a file path, file URI, or base64. + */ + images: string[]; +} + /** * Response type for scanned images. */ @@ -115,21 +144,82 @@ export type Barcode = { sourceImageIndex: number; }; -/** - * Placeholder shape for future OCR text blocks. - */ +export type NormalizedBoundingBox = { + left: number; + top: number; + width: number; + height: number; +}; + +export type TextLine = { + text: string; + bbox?: NormalizedBoundingBox; + confidence?: number; +}; + export type TextBlock = { text: string; sourceImageIndex: number; + bbox?: NormalizedBoundingBox; confidence?: number; + lines?: TextLine[]; +}; + +export type TableCell = { + text: string; + row: number; + column: number; + sourceImageIndex: number; + bbox?: NormalizedBoundingBox; }; -/** - * Placeholder shape for future extracted tables. - */ export type TableBlock = { rows: string[][]; sourceImageIndex: number; + bbox?: NormalizedBoundingBox; + cells?: TableCell[]; +}; + +export type RegionType = + | 'header' + | 'footer' + | 'paragraph' + | 'signature' + | 'stamp' + | 'unknown'; + +export type Region = { + type: RegionType; + sourceImageIndex: number; + bbox: NormalizedBoundingBox; + score?: number; + text?: string; +}; + +export type StructuredEntityType = + | 'phone' + | 'email' + | 'date' + | 'amount' + | 'id' + | 'unknown'; + +export type StructuredEntity = { + type: StructuredEntityType; + value: string; + sourceImageIndex: number; + bbox?: NormalizedBoundingBox; + confidence?: number; +}; + +export type StructuredField = { + key: string; + value: string; +}; + +export type StructuredData = { + entities?: StructuredEntity[]; + fields?: StructuredField[]; }; /** @@ -139,6 +229,7 @@ export type AnalyzeExtractOptions = { barcodes?: boolean; text?: boolean; tables?: boolean; + regions?: boolean; structuredData?: boolean; }; @@ -148,6 +239,27 @@ export type AnalyzeExtractOptions = { export interface AnalyzeScannedImagesOptions extends ExtractBarcodesFromImagesOptions { extract: AnalyzeExtractOptions; + + /** + * Enables adaptive OCR fallback for text/semantics stages. + * @default true + */ + ocrRotate180Fallback?: boolean; +} + +/** + * Native request shape for analyzeScannedImages. + * Flattened to keep native codegen interop simple across architectures. + */ +export interface AnalyzeScannedImagesRequest + extends ExtractBarcodesFromImagesOptions { + images: string[]; + extractBarcodes?: boolean; + extractText?: boolean; + extractTables?: boolean; + extractRegions?: boolean; + extractStructuredData?: boolean; + ocrRotate180Fallback?: boolean; } /** @@ -166,8 +278,10 @@ export type AnalysisResult = { status: AnalysisResultStatus; barcodes?: Barcode[]; text?: TextBlock[]; + textBlocks?: TextBlock[]; tables?: TableBlock[]; - structuredData?: Record; + regions?: Region[]; + structuredData?: StructuredData; }; type ScanDocumentSuccess = { @@ -215,6 +329,24 @@ export interface Spec extends TurboModule { extractBarcodesFromImages( options: ExtractBarcodesFromImagesRequest ): Promise; + + /** + * Extracts OCR text blocks from existing images without opening scanner UI. + * @param options Extraction request. + * @returns Promise with flattened text block list. + */ + extractTextFromImages( + options: ExtractTextFromImagesRequest + ): Promise; + + /** + * Runs unified image analysis natively (barcode/OCR/tables/regions/structured data). + * @param options Flattened analysis request. + * @returns Promise with aggregate analysis result. + */ + analyzeScannedImages( + options: AnalyzeScannedImagesRequest + ): Promise; } const DocumentScanner = diff --git a/src/index.tsx b/src/index.tsx index d9f65d0..6c53227 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,12 @@ import DocumentScanner, { type AnalyzeScannedImagesOptions, type Barcode, type ExtractBarcodesFromImagesOptions, + type ExtractTextFromImagesOptions, + type Region, + type StructuredData, + type StructuredEntity, + type TableBlock, + type TextBlock, type ScanAndAnalyzeDocumentOptions, type ScanAndAnalyzeDocumentResponse, type ScanDocumentOptions, @@ -13,6 +19,29 @@ import DocumentScanner, { const DEFAULT_ANALYSIS_CONCURRENCY = 2; +type StageStatus = 'success' | 'not_enabled' | 'failed' | 'skipped'; + +type StageResult = { + status: StageStatus; + value?: T; +}; + +type NativeAnalyzeFn = (options: { + images: string[]; + extractBarcodes?: boolean; + extractText?: boolean; + extractTables?: boolean; + extractRegions?: boolean; + extractStructuredData?: boolean; + barcodeFormats?: AnalyzeScannedImagesOptions['barcodeFormats']; + concurrency?: AnalyzeScannedImagesOptions['concurrency']; + ocrRotate180Fallback?: boolean; +}) => Promise; + +/** + * Clamps analysis worker count to the supported native range (1..2). + * `undefined` is preserved so native defaults may apply. + */ function clampAnalysisConcurrency( value: number | undefined ): 1 | 2 | undefined { @@ -23,6 +52,10 @@ function clampAnalysisConcurrency( return value <= 1 ? 1 : 2; } +/** + * Normalizes barcode extraction options for stable cross-platform defaults. + * This helper is internal and not part of the public API contract. + */ function normalizeBarcodeOptions( options: ExtractBarcodesFromImagesOptions = {} ): ExtractBarcodesFromImagesOptions { @@ -34,6 +67,21 @@ function normalizeBarcodeOptions( }; } +/** + * Normalizes OCR extraction options for stable cross-platform defaults. + * This helper is internal and not part of the public API contract. + */ +function normalizeTextOptions( + options: ExtractTextFromImagesOptions = {} +): ExtractTextFromImagesOptions { + return { + ocrRotate180Fallback: options.ocrRotate180Fallback ?? false, + concurrency: + options.concurrency ?? + clampAnalysisConcurrency(DEFAULT_ANALYSIS_CONCURRENCY), + }; +} + export function scanDocument( options: ScanDocumentOptions = {} ): Promise { @@ -43,6 +91,9 @@ export function scanDocument( return DocumentScanner.scanDocument(options); } +/** + * Extracts barcodes from captured image sources without opening scanner UI. + */ export function extractBarcodesFromImages( images: string[], options: ExtractBarcodesFromImagesOptions = {} @@ -56,6 +107,26 @@ export function extractBarcodesFromImages( }); } +/** + * Extracts OCR text blocks from captured image sources without opening scanner UI. + */ +export function extractTextFromImages( + images: string[], + options: ExtractTextFromImagesOptions = {} +): Promise { + const normalizedOptions = normalizeTextOptions(options); + + return DocumentScanner.extractTextFromImages({ + images, + concurrency: normalizedOptions.concurrency, + ocrRotate180Fallback: normalizedOptions.ocrRotate180Fallback, + }); +} + +/** + * Extracts a string error code from unknown thrown values. + * Internal helper for stage status mapping. + */ function getErrorCode(error: unknown): string | undefined { if (!error || typeof error !== 'object') { return undefined; @@ -65,6 +136,106 @@ function getErrorCode(error: unknown): string | undefined { return typeof candidate === 'string' ? candidate : undefined; } +/** + * Aggregates per-stage statuses into one public `AnalysisResult.status` value. + * Rules are ordered to preserve backward-compatible semantics. + */ +function mergeStageStatuses(statuses: StageStatus[]): AnalysisResult['status'] { + const requested = statuses.filter((status) => status !== 'skipped'); + if (requested.length === 0) { + return 'success'; + } + if (requested.every((status) => status === 'success')) { + return 'success'; + } + if (requested.every((status) => status === 'not_enabled')) { + return 'not_enabled'; + } + if (requested.some((status) => status === 'success')) { + return 'partial'; + } + return 'failed'; +} + +/** + * Returns native unified analysis entry point if available in the compiled + * native module. Missing method indicates stale native artifacts. + */ +function nativeAnalyzeScannedImages(): NativeAnalyzeFn | undefined { + const module = DocumentScanner as unknown as { + analyzeScannedImages?: NativeAnalyzeFn; + }; + + return module.analyzeScannedImages; +} + +/** + * Runs barcode stage using public extraction API and maps native errors to a + * normalized internal stage status. + */ +async function runBarcodeStage( + images: string[], + options: AnalyzeScannedImagesOptions, + wantsStage: boolean +): Promise> { + if (!wantsStage) { + return { status: 'skipped' }; + } + + try { + const value = await extractBarcodesFromImages(images, { + barcodeFormats: options.barcodeFormats, + concurrency: options.concurrency, + }); + return { + status: 'success', + value, + }; + } catch (error) { + return { + status: + getErrorCode(error) === 'barcode_not_enabled' + ? 'not_enabled' + : 'failed', + }; + } +} + +/** + * Runs OCR text stage using public extraction API and maps native errors to a + * normalized internal stage status. + */ +async function runTextStage( + images: string[], + options: AnalyzeScannedImagesOptions, + ocrRotate180Fallback: boolean, + wantsStage: boolean +): Promise> { + if (!wantsStage) { + return { status: 'skipped' }; + } + + try { + const value = await extractTextFromImages(images, { + concurrency: options.concurrency, + ocrRotate180Fallback, + }); + return { + status: 'success', + value, + }; + } catch (error) { + return { + status: + getErrorCode(error) === 'text_not_enabled' ? 'not_enabled' : 'failed', + }; + } +} + +/** + * Runs unified post-processing. Prefers native aggregate method and falls back + * to staged extraction when native artifacts are stale. + */ export async function analyzeScannedImages( images: string[], options: AnalyzeScannedImagesOptions @@ -73,37 +244,69 @@ export async function analyzeScannedImages( const wantsBarcodes = !!extract.barcodes; const wantsText = !!extract.text; const wantsTables = !!extract.tables; + const wantsRegions = !!extract.regions; const wantsStructuredData = !!extract.structuredData; - const wantsUnsupported = wantsText || wantsTables || wantsStructuredData; + const wantsTextPipeline = + wantsText || wantsTables || wantsRegions || wantsStructuredData; + const ocrRotate180Fallback = options.ocrRotate180Fallback ?? true; - if (!wantsBarcodes && !wantsUnsupported) { + if (!wantsBarcodes && !wantsTextPipeline) { return { status: 'success' }; } - if (!wantsBarcodes && wantsUnsupported) { - return { status: 'not_enabled' }; + const nativeAnalyze = nativeAnalyzeScannedImages(); + if (nativeAnalyze) { + try { + return await nativeAnalyze({ + images, + extractBarcodes: wantsBarcodes, + extractText: wantsText, + extractTables: wantsTables, + extractRegions: wantsRegions, + extractStructuredData: wantsStructuredData, + barcodeFormats: options.barcodeFormats, + concurrency: options.concurrency, + ocrRotate180Fallback, + }); + } catch (_error) { + // Fallback for stale native artifacts. + } } - try { - const barcodes = await extractBarcodesFromImages(images, { - barcodeFormats: options.barcodeFormats, - concurrency: options.concurrency, - }); + const [barcodeStage, textStage] = await Promise.all([ + runBarcodeStage(images, options, wantsBarcodes), + runTextStage(images, options, ocrRotate180Fallback, wantsText), + ]); - return { - status: wantsUnsupported ? 'partial' : 'success', - barcodes, - }; - } catch (error) { - const code = getErrorCode(error); - if (code === 'barcode_not_enabled') { - return { status: 'not_enabled' }; - } + const semanticsStage: StageStatus = + wantsTables || wantsRegions || wantsStructuredData + ? 'not_enabled' + : 'skipped'; + + const status = mergeStageStatuses([ + barcodeStage.status, + textStage.status, + semanticsStage, + ]); - return { status: 'failed' }; + const result: AnalysisResult = { status }; + + if (barcodeStage.status === 'success') { + result.barcodes = barcodeStage.value ?? []; + } + + if (textStage.status === 'success') { + const textBlocks = textStage.value ?? []; + result.textBlocks = textBlocks; + result.text = textBlocks; } + + return result; } +/** + * Convenience sugar for one-call scan + analysis flow. + */ export async function scanAndAnalyzeDocument( options: ScanAndAnalyzeDocumentOptions ): Promise { @@ -127,6 +330,12 @@ export type { AnalyzeScannedImagesOptions, Barcode, ExtractBarcodesFromImagesOptions, + ExtractTextFromImagesOptions, + Region, + StructuredData, + StructuredEntity, + TableBlock, + TextBlock, ScanAndAnalyzeDocumentOptions, ScanAndAnalyzeDocumentResponse, ScanDocumentOptions, @@ -136,6 +345,7 @@ export type { export default { analyzeScannedImages, extractBarcodesFromImages, + extractTextFromImages, scanAndAnalyzeDocument, scanDocument, }; From 281c6d25d4fbf0db3d6a9ed7e6f9f6250997e0da Mon Sep 17 00:00:00 2001 From: Preeternal Date: Sun, 22 Mar 2026 22:00:40 +0200 Subject: [PATCH 10/17] chore(android): reduce barcode timeout to 10s and keep best-effort extraction behavior --- README.md | 3 +++ .../preeternal/scanner/DocumentScannerModule.kt | 17 ++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f659a4e..79e9aa0 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,9 @@ Special values: `all` (enable every analysis feature) and `none` (disable all). If barcode feature is not enabled in the Android native build, `extractBarcodesFromImages(...)` rejects with `barcode_not_enabled`. +When enabled, barcode extraction is best-effort: Android uses an internal per-image timeout (10 seconds). +If a page times out, the method still resolves with what was found so far (possibly `[]`) and does not throw a timeout error. + ### extractBarcodesFromImages(...) ```typescript diff --git a/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt b/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt index 18a1f45..344f835 100644 --- a/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt +++ b/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt @@ -6,6 +6,7 @@ import android.graphics.BitmapFactory import android.net.Uri import android.os.Build import android.util.Base64 +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest @@ -62,7 +63,7 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : companion object { const val NAME = "DocumentScanner" private const val ANDROID_15_API = 35 - private const val BARCODE_EXTRACTION_TIMEOUT_MS = 20_000L + private const val BARCODE_EXTRACTION_TIMEOUT_MS = 10_000L private const val TEXT_EXTRACTION_TIMEOUT_MS = 25_000L } @@ -538,7 +539,7 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : sourceImageIndex: Int, allowedFormats: Set ): List { - return withTimeoutOrNull(BARCODE_EXTRACTION_TIMEOUT_MS) { + val detected = withTimeoutOrNull(BARCODE_EXTRACTION_TIMEOUT_MS) { suspendCancellableCoroutine { continuation -> barcodeExtractor.extractFromSource( context = context, @@ -551,7 +552,17 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : } } } - } ?: emptyList() + } + + if (detected == null) { + Log.w( + NAME, + "Barcode extraction timed out after ${BARCODE_EXTRACTION_TIMEOUT_MS}ms for sourceImageIndex=$sourceImageIndex" + ) + return emptyList() + } + + return detected } private suspend fun extractTextInParallel( From 433925066fe6f5c82adf37080a7ec19faa5949f0 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Sat, 28 Mar 2026 18:32:53 +0200 Subject: [PATCH 11/17] feat(semantics): unify structuredData normalization across iOS and Android --- README.md | 12 + .../scanner/DocumentScannerModule.kt | 11 +- .../scanner/analysis/DocumentSemantics.kt | 68 +++- .../analysis/StructuredDataNormalizer.kt | 255 ++++++++++++ .../Analysis/DocumentSemantics.swift | 75 ++-- .../Analysis/RecognizeDocumentsAnalyzer.swift | 81 ++-- .../Analysis/StructuredDataNormalizer.swift | 373 ++++++++++++++++++ ios/DocumentScanner.swift | 15 +- 8 files changed, 821 insertions(+), 69 deletions(-) create mode 100644 android/src/main/java/com/preeternal/scanner/analysis/StructuredDataNormalizer.kt create mode 100644 ios/DocScanner/Analysis/StructuredDataNormalizer.swift diff --git a/README.md b/README.md index 79e9aa0..247a942 100644 --- a/README.md +++ b/README.md @@ -480,6 +480,18 @@ const result = await DocumentScanner.scanAndAnalyzeDocument({ | **`entities`** | { type: 'phone' \\| 'email' \\| 'date' \\| 'amount' \\| 'id' \\| 'unknown'; value: string; sourceImageIndex: number }[] | Inferred typed entities. | | **`fields`** | { key: string; value: string }[] | Inferred key-value fields from OCR lines. | +Normalization guarantees: + +- `date` values are normalized to ISO-like `YYYY-MM-DD` when parser confidence is sufficient. +- `amount` values are normalized to `CODE value` (for example `USD 1234.56`) when currency can be inferred, otherwise numeric normalized form is returned. +- `phone` values are normalized to compact digit form (`+` prefix preserved when present in source). +- `email` values are lowercased, `id` values are uppercased/compacted. +- `fields[].key` is sanitized to lowercase snake-like form on both platforms. + +Best-effort quality note: + +- Output shape is stable across platforms, but extraction quality can differ by OS/runtime capabilities (for example iOS modern `RecognizeDocumentsRequest` + data detectors vs regex heuristics on older/other paths). + ### Enums diff --git a/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt b/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt index 344f835..467e860 100644 --- a/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt +++ b/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt @@ -789,7 +789,14 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : if (data.entities.isNotEmpty()) { val entities = WritableNativeArray() - for (entity in data.entities) { + val sortedEntities = data.entities.sortedWith( + compareBy( + { it.sourceImageIndex }, + { it.type }, + { it.value } + ) + ) + for (entity in sortedEntities) { entities.pushMap(toWritableStructuredEntity(entity)) } map.putArray("entities", entities) @@ -797,7 +804,7 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : if (data.fields.isNotEmpty()) { val fields = WritableNativeArray() - for ((key, value) in data.fields) { + for ((key, value) in data.fields.toSortedMap()) { val entry = WritableNativeMap() entry.putString("key", key) entry.putString("value", value) diff --git a/android/src/main/java/com/preeternal/scanner/analysis/DocumentSemantics.kt b/android/src/main/java/com/preeternal/scanner/analysis/DocumentSemantics.kt index 12ecd0f..629d1df 100644 --- a/android/src/main/java/com/preeternal/scanner/analysis/DocumentSemantics.kt +++ b/android/src/main/java/com/preeternal/scanner/analysis/DocumentSemantics.kt @@ -127,14 +127,21 @@ object DocumentSemantics { val text = line.text fieldRegex.find(text)?.let { match -> - val rawKey = match.groupValues.getOrNull(1)?.trim()?.lowercase().orEmpty() + val rawKey = match.groupValues.getOrNull(1)?.trim().orEmpty() val value = match.groupValues.getOrNull(2)?.trim().orEmpty() - val key = rawKey - .replace(Regex("[^a-z0-9а-я]+"), "_") - .trim('_') + val key = StructuredDataNormalizer.normalizeFieldKey(rawKey) if (key.isNotEmpty() && value.isNotEmpty()) { fields[key] = value + if (StructuredDataNormalizer.isLikelyIdField(key)) { + appendEntityValue( + type = "id", + rawValue = value, + line = line, + entities = entities, + dedup = dedup + ) + } } } @@ -159,25 +166,46 @@ object DocumentSemantics { dedup: MutableSet ) { regex.findAll(text).forEach { match -> - val value = match.value.trim() - if (value.isEmpty()) { - return@forEach - } + appendEntityValue( + type = type, + rawValue = match.value, + line = line, + entities = entities, + dedup = dedup + ) + } + } - val dedupKey = "$type|${line.sourceImageIndex}|$value" - if (!dedup.add(dedupKey)) { - return@forEach - } + private fun appendEntityValue( + type: String, + rawValue: String, + line: TextLineEntry, + entities: MutableList, + dedup: MutableSet + ) { + val normalizedValue = StructuredDataNormalizer.normalizeEntityValue(type, rawValue) + if (normalizedValue.isEmpty()) { + return + } - entities.add( - SemanticStructuredEntity( - type = type, - value = value, - sourceImageIndex = line.sourceImageIndex, - boundingBox = line.boundingBox - ) - ) + val dedupValue = StructuredDataNormalizer.normalizedEntityDedupValue(type, normalizedValue) + if (dedupValue.isEmpty()) { + return } + + val dedupKey = "$type|${line.sourceImageIndex}|$dedupValue" + if (!dedup.add(dedupKey)) { + return + } + + entities.add( + SemanticStructuredEntity( + type = type, + value = normalizedValue, + sourceImageIndex = line.sourceImageIndex, + boundingBox = line.boundingBox + ) + ) } private fun flattenTextLines(textBlocks: List): List { diff --git a/android/src/main/java/com/preeternal/scanner/analysis/StructuredDataNormalizer.kt b/android/src/main/java/com/preeternal/scanner/analysis/StructuredDataNormalizer.kt new file mode 100644 index 0000000..8bf774c --- /dev/null +++ b/android/src/main/java/com/preeternal/scanner/analysis/StructuredDataNormalizer.kt @@ -0,0 +1,255 @@ +package com.preeternal.scanner.analysis + +import java.math.BigDecimal +import java.util.Locale + +object StructuredDataNormalizer { + private val fieldKeySanitizeRegex = Regex("[^a-z0-9а-я]+") + private val whitespaceRegex = Regex("\\s+") + private val dateYmdRegex = Regex("^\\s*(\\d{4})[./-](\\d{1,2})[./-](\\d{1,2})\\s*$") + private val dateDmyRegex = Regex("^\\s*(\\d{1,2})[./-](\\d{1,2})[./-](\\d{2,4})\\s*$") + private val numericCandidateRegex = Regex("[-+]?\\d[\\d.,\\s]*\\d|[-+]?\\d") + private val currencyCodeRegex = Regex("\\b(usd|eur|gbp|uah|rub|brl)\\b", RegexOption.IGNORE_CASE) + private val nonIdSymbolRegex = Regex("[^A-Z0-9_-]") + private val nonIdDedupRegex = Regex("[^A-Z0-9]") + + private val idFieldHints = setOf( + "id", + "tracking", + "reference", + "ref", + "invoice", + "order", + "shipment", + "waybill", + "awb", + "consignment", + "номер" + ) + + fun normalizeFieldKey(raw: String): String { + return raw + .trim() + .lowercase(Locale.ROOT) + .replace(fieldKeySanitizeRegex, "_") + .trim('_') + } + + fun isLikelyIdField(normalizedFieldKey: String): Boolean { + if (normalizedFieldKey.isEmpty()) { + return false + } + return idFieldHints.any { hint -> + normalizedFieldKey == hint || + normalizedFieldKey.startsWith("${hint}_") || + normalizedFieldKey.endsWith("_$hint") || + normalizedFieldKey.contains("_${hint}_") + } + } + + fun normalizeEntityValue(type: String, rawValue: String): String { + val trimmed = rawValue.trim() + if (trimmed.isEmpty()) { + return "" + } + + return when (type) { + "phone" -> normalizePhone(trimmed) + "email" -> trimmed.lowercase(Locale.ROOT) + "date" -> normalizeDate(trimmed) + "amount" -> normalizeAmount(trimmed) + "id" -> normalizeId(trimmed) + else -> normalizeWhitespace(trimmed) + } + } + + fun normalizedEntityDedupValue(type: String, normalizedValue: String): String { + val value = normalizedValue.trim() + if (value.isEmpty()) { + return "" + } + + return when (type) { + "phone" -> value.filter { it.isDigit() } + "email" -> value.lowercase(Locale.ROOT) + "id" -> value.uppercase(Locale.ROOT).replace(nonIdDedupRegex, "") + "amount", "date" -> value + else -> normalizeWhitespace(value).lowercase(Locale.ROOT) + } + } + + private fun normalizeWhitespace(value: String): String { + return value.trim().replace(whitespaceRegex, " ") + } + + private fun normalizePhone(value: String): String { + val hasPlusPrefix = value.trimStart().startsWith("+") + val digits = value.filter { it.isDigit() } + if (digits.length < 8) { + return normalizeWhitespace(value) + } + return if (hasPlusPrefix) "+$digits" else digits + } + + private fun normalizeId(value: String): String { + val compact = value + .trim() + .uppercase(Locale.ROOT) + .replace(whitespaceRegex, "") + .replace(nonIdSymbolRegex, "") + if (compact.isEmpty()) { + return value.trim().uppercase(Locale.ROOT) + } + return compact + } + + private fun normalizeDate(value: String): String { + dateYmdRegex.matchEntire(value)?.let { match -> + val year = match.groupValues[1].toIntOrNull() ?: return@let + val month = match.groupValues[2].toIntOrNull() ?: return@let + val day = match.groupValues[3].toIntOrNull() ?: return@let + if (isValidDate(year, month, day)) { + return formatIsoDate(year, month, day) + } + } + + dateDmyRegex.matchEntire(value)?.let { match -> + val first = match.groupValues[1].toIntOrNull() ?: return@let + val second = match.groupValues[2].toIntOrNull() ?: return@let + val year = normalizeYear(match.groupValues[3].toIntOrNull() ?: return@let) + + val (month, day) = when { + first > 12 && second in 1..12 -> second to first + second > 12 && first in 1..12 -> first to second + else -> second to first + } + + if (isValidDate(year, month, day)) { + return formatIsoDate(year, month, day) + } + } + + return value + } + + private fun normalizeAmount(value: String): String { + val currency = detectCurrency(value) + val numericCandidate = numericCandidateRegex.find(value)?.value + ?.replace(whitespaceRegex, "") + ?: return normalizeWhitespace(value) + + val normalizedNumber = normalizeNumericString(numericCandidate) ?: return normalizeWhitespace(value) + return if (currency != null) { + "$currency $normalizedNumber" + } else { + normalizedNumber + } + } + + private fun detectCurrency(value: String): String? { + if (value.contains("$")) { + return "USD" + } + if (value.contains("€")) { + return "EUR" + } + if (value.contains("£")) { + return "GBP" + } + + val code = currencyCodeRegex.find(value)?.groupValues?.getOrNull(1)?.uppercase(Locale.ROOT) + return code + } + + private fun normalizeNumericString(raw: String): String? { + val value = raw.trim() + if (value.isEmpty()) { + return null + } + + val lastDot = value.lastIndexOf('.') + val lastComma = value.lastIndexOf(',') + + val decimalSeparator: Char? = when { + lastDot >= 0 && lastComma >= 0 -> if (lastDot > lastComma) '.' else ',' + lastDot >= 0 -> inferSingleSeparatorAsDecimal(value, '.') + lastComma >= 0 -> inferSingleSeparatorAsDecimal(value, ',') + else -> null + } + + val normalized = buildString(value.length) { + value.forEach { char -> + when { + char.isDigit() -> append(char) + (char == '+' || char == '-') && isEmpty() -> append(char) + decimalSeparator != null && char == decimalSeparator -> append('.') + } + } + } + + if (normalized.isEmpty() || normalized == "-" || normalized == "+") { + return null + } + + return try { + BigDecimal(normalized).stripTrailingZeros().toPlainString() + } catch (_: NumberFormatException) { + null + } + } + + private fun inferSingleSeparatorAsDecimal(value: String, separator: Char): Char? { + val count = value.count { it == separator } + if (count != 1) { + return null + } + + val index = value.indexOf(separator) + if (index <= 0 || index >= value.length - 1) { + return null + } + + val digitsAfter = value.substring(index + 1).count { it.isDigit() } + return if (digitsAfter in 1..2) separator else null + } + + private fun normalizeYear(value: Int): Int { + if (value >= 100) { + return value + } + return if (value <= 69) { + 2000 + value + } else { + 1900 + value + } + } + + private fun formatIsoDate(year: Int, month: Int, day: Int): String { + return "%04d-%02d-%02d".format(Locale.US, year, month, day) + } + + private fun isValidDate(year: Int, month: Int, day: Int): Boolean { + if (year !in 1900..2100) { + return false + } + if (month !in 1..12) { + return false + } + if (day < 1) { + return false + } + + val daysInMonth = when (month) { + 1, 3, 5, 7, 8, 10, 12 -> 31 + 4, 6, 9, 11 -> 30 + 2 -> if (isLeapYear(year)) 29 else 28 + else -> 0 + } + + return day <= daysInMonth + } + + private fun isLeapYear(year: Int): Boolean { + return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) + } +} diff --git a/ios/DocScanner/Analysis/DocumentSemantics.swift b/ios/DocScanner/Analysis/DocumentSemantics.swift index 16159b9..761cf43 100644 --- a/ios/DocScanner/Analysis/DocumentSemantics.swift +++ b/ios/DocScanner/Analysis/DocumentSemantics.swift @@ -130,13 +130,20 @@ enum DocumentSemantics { let text = line.text if let match = firstMatchGroups(using: fieldRegex, in: text), match.count >= 2 { - let rawKey = match[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let rawKey = match[0].trimmingCharacters(in: .whitespacesAndNewlines) let value = match[1].trimmingCharacters(in: .whitespacesAndNewlines) - let key = rawKey - .replacingOccurrences(of: "[^a-z0-9а-я]+", with: "_", options: .regularExpression) - .trimmingCharacters(in: CharacterSet(charactersIn: "_")) + let key = StructuredDataNormalizer.normalizeFieldKey(rawKey) if !key.isEmpty && !value.isEmpty { fields[key] = value + if StructuredDataNormalizer.isLikelyIdField(key) { + appendEntityValue( + type: "id", + rawValue: value, + line: line, + entities: &entities, + dedup: &dedup + ) + } } } @@ -300,26 +307,50 @@ enum DocumentSemantics { let matches = regex.matches(in: text, options: [], range: range) for match in matches { - let value = ns.substring(with: match.range).trimmingCharacters(in: .whitespacesAndNewlines) - if value.isEmpty { - continue - } + appendEntityValue( + type: type, + rawValue: ns.substring(with: match.range), + line: line, + entities: &entities, + dedup: &dedup + ) + } + } - let dedupKey = "\(type)|\(line.sourceImageIndex)|\(value)" - if dedup.contains(dedupKey) { - continue - } - dedup.insert(dedupKey) + private static func appendEntityValue( + type: String, + rawValue: String, + line: TextLineEntry, + entities: inout [AnalysisStructuredEntity], + dedup: inout Set + ) { + let normalizedValue = StructuredDataNormalizer.normalizeEntityValue(type: type, value: rawValue) + if normalizedValue.isEmpty { + return + } - entities.append( - AnalysisStructuredEntity( - type: type, - value: value, - sourceImageIndex: line.sourceImageIndex, - boundingBox: line.boundingBox, - confidence: nil - ) - ) + let dedupValue = StructuredDataNormalizer.normalizedEntityDedupValue( + type: type, + normalizedValue: normalizedValue + ) + if dedupValue.isEmpty { + return } + + let dedupKey = "\(type)|\(line.sourceImageIndex)|\(dedupValue)" + if dedup.contains(dedupKey) { + return + } + dedup.insert(dedupKey) + + entities.append( + AnalysisStructuredEntity( + type: type, + value: normalizedValue, + sourceImageIndex: line.sourceImageIndex, + boundingBox: line.boundingBox, + confidence: nil + ) + ) } } diff --git a/ios/DocScanner/Analysis/RecognizeDocumentsAnalyzer.swift b/ios/DocScanner/Analysis/RecognizeDocumentsAnalyzer.swift index 80a4c9c..d1fca41 100644 --- a/ios/DocScanner/Analysis/RecognizeDocumentsAnalyzer.swift +++ b/ios/DocScanner/Analysis/RecognizeDocumentsAnalyzer.swift @@ -168,6 +168,19 @@ enum RecognizeDocumentsAnalyzer { } } + if options.includeStructuredData { + for (key, value) in fields where StructuredDataNormalizer.isLikelyIdField(key) { + appendNormalizedEntity( + type: "id", + rawValue: value, + sourceImageIndex: sourceImageIndex, + boundingBox: nil, + entities: &entities, + dedup: &dedupEntities + ) + } + } + let sortedTextBlocks = textBlocks.sorted { lhs, rhs in if lhs.sourceImageIndex != rhs.sourceImageIndex { return lhs.sourceImageIndex < rhs.sourceImageIndex @@ -321,11 +334,9 @@ enum RecognizeDocumentsAnalyzer { } if rowValues.count >= 2 { - let key = rowValues[0] - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .replacingOccurrences(of: "[^a-z0-9а-я]+", with: "_", options: .regularExpression) - .trimmingCharacters(in: CharacterSet(charactersIn: "_")) + let key = StructuredDataNormalizer.normalizeFieldKey( + rowValues[0].trimmingCharacters(in: .whitespacesAndNewlines) + ) let value = rowValues[1].trimmingCharacters(in: .whitespacesAndNewlines) if !key.isEmpty && !value.isEmpty { fields[key] = value @@ -420,26 +431,52 @@ enum RecognizeDocumentsAnalyzer { continue } - let value = mapped.value.trimmingCharacters(in: .whitespacesAndNewlines) - if value.isEmpty { - continue - } + appendNormalizedEntity( + type: mapped.type, + rawValue: mapped.value, + sourceImageIndex: sourceImageIndex, + boundingBox: toBoundingBox(data.boundingRegion.boundingBox), + entities: &entities, + dedup: &dedup + ) + } + } - let dedupKey = "\(mapped.type)|\(sourceImageIndex)|\(value)" - if !dedup.insert(dedupKey).inserted { - continue - } + private static func appendNormalizedEntity( + type: String, + rawValue: String, + sourceImageIndex: Int, + boundingBox: AnalysisBoundingBox?, + entities: inout [AnalysisStructuredEntity], + dedup: inout Set + ) { + let value = StructuredDataNormalizer.normalizeEntityValue(type: type, value: rawValue) + if value.isEmpty { + return + } - entities.append( - AnalysisStructuredEntity( - type: mapped.type, - value: value, - sourceImageIndex: sourceImageIndex, - boundingBox: toBoundingBox(data.boundingRegion.boundingBox), - confidence: nil - ) - ) + let dedupValue = StructuredDataNormalizer.normalizedEntityDedupValue( + type: type, + normalizedValue: value + ) + if dedupValue.isEmpty { + return + } + + let dedupKey = "\(type)|\(sourceImageIndex)|\(dedupValue)" + if !dedup.insert(dedupKey).inserted { + return } + + entities.append( + AnalysisStructuredEntity( + type: type, + value: value, + sourceImageIndex: sourceImageIndex, + boundingBox: boundingBox, + confidence: nil + ) + ) } private static func normalizedBarcodeValue(from barcode: BarcodeObservation) -> String { diff --git a/ios/DocScanner/Analysis/StructuredDataNormalizer.swift b/ios/DocScanner/Analysis/StructuredDataNormalizer.swift new file mode 100644 index 0000000..a525d47 --- /dev/null +++ b/ios/DocScanner/Analysis/StructuredDataNormalizer.swift @@ -0,0 +1,373 @@ +import Foundation + +enum StructuredDataNormalizer { + private static let fieldKeySanitizeRegex = try? NSRegularExpression( + pattern: "[^a-z0-9а-я]+", + options: [] + ) + + private static let currencyCodeRegex = try? NSRegularExpression( + pattern: "\\b(usd|eur|gbp|uah|rub|brl)\\b", + options: [.caseInsensitive] + ) + + private static let numericCandidateRegex = try? NSRegularExpression( + pattern: "[-+]?\\d[\\d.,\\s]*\\d|[-+]?\\d", + options: [] + ) + + private static let nonIdSymbolRegex = try? NSRegularExpression( + pattern: "[^A-Z0-9_-]", + options: [] + ) + + private static let nonIdDedupRegex = try? NSRegularExpression( + pattern: "[^A-Z0-9]", + options: [] + ) + + private static let idFieldHints: Set = [ + "id", + "tracking", + "reference", + "ref", + "invoice", + "order", + "shipment", + "waybill", + "awb", + "consignment", + "номер" + ] + + static func normalizeFieldKey(_ raw: String) -> String { + let normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard let fieldKeySanitizeRegex else { + return normalized + } + + let ns = normalized as NSString + let range = NSRange(location: 0, length: ns.length) + let replaced = fieldKeySanitizeRegex.stringByReplacingMatches( + in: normalized, + options: [], + range: range, + withTemplate: "_" + ) + return replaced.trimmingCharacters(in: CharacterSet(charactersIn: "_")) + } + + static func isLikelyIdField(_ normalizedFieldKey: String) -> Bool { + if normalizedFieldKey.isEmpty { + return false + } + + for hint in idFieldHints { + if normalizedFieldKey == hint || + normalizedFieldKey.hasPrefix("\(hint)_") || + normalizedFieldKey.hasSuffix("_\(hint)") || + normalizedFieldKey.contains("_\(hint)_") { + return true + } + } + + return false + } + + static func normalizeEntityValue(type: String, value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return "" + } + + switch type { + case "phone": + return normalizePhone(trimmed) + case "email": + return trimmed.lowercased() + case "date": + return normalizeDate(trimmed) + case "amount": + return normalizeAmount(trimmed) + case "id": + return normalizeId(trimmed) + default: + return normalizeWhitespace(trimmed) + } + } + + static func normalizedEntityDedupValue(type: String, normalizedValue: String) -> String { + let value = normalizedValue.trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { + return "" + } + + switch type { + case "phone": + return value.filter(\.isNumber) + case "email": + return value.lowercased() + case "id": + return replaceRegexMatches( + in: value.uppercased(), + regex: nonIdDedupRegex, + with: "" + ) + case "amount", "date": + return value + default: + return normalizeWhitespace(value).lowercased() + } + } + + private static func normalizePhone(_ value: String) -> String { + let hasPlusPrefix = value.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("+") + let digits = value.filter(\.isNumber) + if digits.count < 8 { + return normalizeWhitespace(value) + } + return hasPlusPrefix ? "+\(digits)" : digits + } + + private static func normalizeId(_ value: String) -> String { + let upper = value.uppercased() + let noWhitespace = upper + .components(separatedBy: .whitespacesAndNewlines) + .joined() + let compact = replaceRegexMatches( + in: noWhitespace, + regex: nonIdSymbolRegex, + with: "" + ) + if compact.isEmpty { + return upper.trimmingCharacters(in: .whitespacesAndNewlines) + } + return compact + } + + private static func normalizeDate(_ value: String) -> String { + let separators = CharacterSet(charactersIn: "./-") + let parts = value.components(separatedBy: separators).filter { !$0.isEmpty } + if parts.count != 3 { + return value + } + + if parts[0].count == 4, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]), + isValidDate(year: year, month: month, day: day) { + return formatIsoDate(year: year, month: month, day: day) + } + + guard let first = Int(parts[0]), + let second = Int(parts[1]), + let rawYear = Int(parts[2]) else { + return value + } + + let year = normalizeYear(rawYear) + let month: Int + let day: Int + + if first > 12, (1...12).contains(second) { + month = second + day = first + } else if second > 12, (1...12).contains(first) { + month = first + day = second + } else { + month = second + day = first + } + + guard isValidDate(year: year, month: month, day: day) else { + return value + } + + return formatIsoDate(year: year, month: month, day: day) + } + + private static func normalizeAmount(_ value: String) -> String { + let currency = detectCurrency(value) + guard let numericCandidate = firstMatch(in: value, regex: numericCandidateRegex)? + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "\u{00A0}", with: "") else { + return normalizeWhitespace(value) + } + + guard let normalizedNumber = normalizeNumericString(numericCandidate) else { + return normalizeWhitespace(value) + } + + if let currency { + return "\(currency) \(normalizedNumber)" + } + + return normalizedNumber + } + + private static func detectCurrency(_ value: String) -> String? { + if value.contains("$") { + return "USD" + } + if value.contains("€") { + return "EUR" + } + if value.contains("£") { + return "GBP" + } + + guard let code = firstMatch(in: value, regex: currencyCodeRegex) else { + return nil + } + return code.uppercased() + } + + private static func normalizeNumericString(_ raw: String) -> String? { + let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { + return nil + } + + let lastDot = value.lastIndex(of: ".") + let lastComma = value.lastIndex(of: ",") + + let decimalSeparator: Character? = { + if let lastDot, let lastComma { + return lastDot > lastComma ? "." : "," + } + if lastDot != nil { + return inferSingleSeparatorAsDecimal(in: value, separator: ".") + } + if lastComma != nil { + return inferSingleSeparatorAsDecimal(in: value, separator: ",") + } + return nil + }() + + var normalized = "" + for char in value { + if char.isNumber { + normalized.append(char) + continue + } + if (char == "+" || char == "-"), normalized.isEmpty { + normalized.append(char) + continue + } + if let decimalSeparator, char == decimalSeparator { + normalized.append(".") + } + } + + if normalized.isEmpty || normalized == "+" || normalized == "-" { + return nil + } + + let decimal = NSDecimalNumber(string: normalized, locale: Locale(identifier: "en_US_POSIX")) + if decimal == NSDecimalNumber.notANumber { + return nil + } + + return decimal.stringValue + } + + private static func inferSingleSeparatorAsDecimal(in value: String, separator: Character) -> Character? { + let count = value.filter { $0 == separator }.count + if count != 1 { + return nil + } + + guard let separatorIndex = value.firstIndex(of: separator), + separatorIndex != value.startIndex else { + return nil + } + + let nextIndex = value.index(after: separatorIndex) + if nextIndex >= value.endIndex { + return nil + } + + let digitsAfter = value[nextIndex...].filter(\.isNumber).count + return (1...2).contains(digitsAfter) ? separator : nil + } + + private static func normalizeWhitespace(_ value: String) -> String { + return value + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + private static func normalizeYear(_ value: Int) -> Int { + if value >= 100 { + return value + } + return value <= 69 ? 2000 + value : 1900 + value + } + + private static func isValidDate(year: Int, month: Int, day: Int) -> Bool { + if !(1900...2100).contains(year) { + return false + } + if !(1...12).contains(month) { + return false + } + if day < 1 { + return false + } + + let daysInMonth: Int + switch month { + case 1, 3, 5, 7, 8, 10, 12: + daysInMonth = 31 + case 4, 6, 9, 11: + daysInMonth = 30 + case 2: + daysInMonth = isLeapYear(year) ? 29 : 28 + default: + daysInMonth = 0 + } + + return day <= daysInMonth + } + + private static func isLeapYear(_ year: Int) -> Bool { + return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) + } + + private static func formatIsoDate(year: Int, month: Int, day: Int) -> String { + return String(format: "%04d-%02d-%02d", year, month, day) + } + + private static func firstMatch(in text: String, regex: NSRegularExpression?) -> String? { + guard let regex else { + return nil + } + let ns = text as NSString + let range = NSRange(location: 0, length: ns.length) + guard let match = regex.firstMatch(in: text, options: [], range: range) else { + return nil + } + return ns.substring(with: match.range) + } + + private static func replaceRegexMatches( + in text: String, + regex: NSRegularExpression?, + with replacement: String + ) -> String { + guard let regex else { + return text + } + let ns = text as NSString + let range = NSRange(location: 0, length: ns.length) + return regex.stringByReplacingMatches( + in: text, + options: [], + range: range, + withTemplate: replacement + ) + } +} diff --git a/ios/DocumentScanner.swift b/ios/DocumentScanner.swift index 6bcfa99..8f21468 100644 --- a/ios/DocumentScanner.swift +++ b/ios/DocumentScanner.swift @@ -798,13 +798,22 @@ public class DocumentScannerImpl: NSObject { var map: [String: Any] = [:] if !structuredData.entities.isEmpty { - map["entities"] = structuredData.entities.map(toDictionary) + let sortedEntities = structuredData.entities.sorted { lhs, rhs in + if lhs.sourceImageIndex != rhs.sourceImageIndex { + return lhs.sourceImageIndex < rhs.sourceImageIndex + } + if lhs.type != rhs.type { + return lhs.type < rhs.type + } + return lhs.value < rhs.value + } + map["entities"] = sortedEntities.map(toDictionary) } if !structuredData.fields.isEmpty { - let fields = structuredData.fields.map { key, value in + let fields = structuredData.fields.keys.sorted().map { key in [ "key": key, - "value": value + "value": structuredData.fields[key] ?? "" ] } map["fields"] = fields From e46dff58b0011e25dfc525881e39ea97bd497a4a Mon Sep 17 00:00:00 2001 From: Preeternal Date: Sat, 28 Mar 2026 20:03:34 +0200 Subject: [PATCH 12/17] feat(example): full example, add Files+Photos pickers for local image analysis demo --- example/README.md | 124 ++- example/android/gradle.properties | 4 + example/ios/DocumentScannerExample/Info.plist | 2 + .../PrivacyInfo.xcprivacy | 1 + example/ios/Podfile.lock | 56 ++ example/package.json | 4 +- example/src/App.tsx | 853 +++++++++++++++++- yarn.lock | 22 + 8 files changed, 977 insertions(+), 89 deletions(-) diff --git a/example/README.md b/example/README.md index 3e2c3f8..4637a96 100644 --- a/example/README.md +++ b/example/README.md @@ -1,97 +1,89 @@ -This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli). +# Document Scanner Example App -# Getting Started +This workspace app demonstrates the full API surface of +`@preeternal/react-native-document-scanner-plugin`. -> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding. +## What this example covers -## Step 1: Start Metro +- `pickImagesFromDevice()` via `@react-native-documents/picker` +- `pickImagesFromGallery()` via `react-native-image-picker` +- `scanDocument(...)` +- `extractBarcodesFromImages(...)` +- `extractTextFromImages(...)` +- `analyzeScannedImages(...)` +- `scanAndAnalyzeDocument(...)` -First, you will need to run **Metro**, the JavaScript build tool for React Native. +You can test post-processing API without opening scanner UI: +pick images from Files or directly from Photos and run extraction/analysis. -To start the Metro dev server, run the following command from the root of your React Native project: +The screen also lets you interactively change: -```sh -# Using npm -npm start +- `responseType` (`imageFilePath` / `base64`) +- analysis `concurrency` (`1` / `2`) +- `ocrRotate180Fallback` +- `extract` stage toggles (`barcodes`, `text`, `tables`, `regions`, `structuredData`) +- barcode format allow-list -# OR using Yarn -yarn start +## Run from repo root + +```bash +yarn install ``` -## Step 2: Build and run your app +Terminal 1: -With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app: +```bash +yarn example start +``` -### Android +Terminal 2: -```sh -# Using npm -npm run android +```bash +# Android +yarn example android -# OR using Yarn -yarn android +# iOS +yarn example ios ``` -### iOS +## iOS setup -For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps). +Install pods before the first run (or after native dependency changes): -The first time you create a new project, run the Ruby bundler to install CocoaPods itself: - -```sh +```bash +cd example/ios bundle install -``` - -Then, and every time you update your native dependencies, run: - -```sh bundle exec pod install ``` -For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html). - -```sh -# Using npm -npm run ios - -# OR using Yarn -yarn ios -``` - -If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device. - -This is one way to run your app — you can also build it directly from Android Studio or Xcode. - -## Step 3: Modify your app +After adding/updating native picker dependencies (`@react-native-documents/picker`, `react-native-image-picker`), run pod install again. -Now that you have successfully run the app, let's make changes! +## Android analysis feature flags -Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh). +`example/android/gradle.properties` sets: -When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload: - -- **Android**: Press the R key twice or select **"Reload"** from the **Dev Menu**, accessed via Ctrl + M (Windows/Linux) or Cmd ⌘ + M (macOS). -- **iOS**: Press R in iOS Simulator. - -## Congratulations! :tada: - -You've successfully run and modified your React Native App. :partying_face: +```properties +DocumentScanner_analysisFeatures=all +``` -### Now what? +So the example app includes all optional native analysis stages by default. -- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps). -- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started). +Why this exists on Android: -# Troubleshooting +- some analysis stages (barcode/OCR) require extra native dependencies; +- opt-in flags let production apps avoid extra APK/AAB size when they only need scan-only flow. -If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page. +You can change this value for testing: -# Learn More +- `none` for scan-only build +- `barcode` +- `text` +- `tables` +- `all` -To learn more about React Native, take a look at the following resources: +## Notes -- [React Native Website](https://reactnative.dev) - learn more about React Native. -- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment. -- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**. -- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts. -- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native. +- Picker mode uses native document provider URIs (`content://` / `file://`), so extraction works on existing files from device storage. +- Gallery mode uses `react-native-image-picker` and reads images from the Photos library. +- On Android, if a stage is not enabled in build flags, extraction methods return feature-disabled errors/status. +- On iOS, barcode/text analysis is available without extra build flags. diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 5e24e3a..2efe036 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -37,3 +37,7 @@ newArchEnabled=true # Use this property to enable or disable the Hermes JS engine. # If set to false, you will be using JSC instead. hermesEnabled=true + +# Enable all optional analysis features for the local example app. +# Supported values: none | barcode | text | tables | all +DocumentScanner_analysisFeatures=all diff --git a/example/ios/DocumentScannerExample/Info.plist b/example/ios/DocumentScannerExample/Info.plist index 05add9d..67c1cab 100644 --- a/example/ios/DocumentScannerExample/Info.plist +++ b/example/ios/DocumentScannerExample/Info.plist @@ -34,6 +34,8 @@ NSCameraUsageDescription + NSPhotoLibraryUsageDescription + Used in example app to pick images from the photo library for analysis. NSLocationWhenInUseUsageDescription UILaunchStoryboardName diff --git a/example/ios/DocumentScannerExample/PrivacyInfo.xcprivacy b/example/ios/DocumentScannerExample/PrivacyInfo.xcprivacy index 41b8317..5b037f0 100644 --- a/example/ios/DocumentScannerExample/PrivacyInfo.xcprivacy +++ b/example/ios/DocumentScannerExample/PrivacyInfo.xcprivacy @@ -10,6 +10,7 @@ NSPrivacyAccessedAPITypeReasons C617.1 + 3B52.1 diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 891083c..1d6d59f 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1356,6 +1356,54 @@ PODS: - React-jsiexecutor - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core + - react-native-document-picker (12.0.1): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - react-native-image-picker (8.2.1): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - React-NativeModulesApple (0.79.2): - glog - hermes-engine @@ -1723,6 +1771,8 @@ DEPENDENCIES: - React-logger (from `../node_modules/react-native/ReactCommon/logger`) - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - "react-native-document-picker (from `../node_modules/@react-native-documents/picker`)" + - react-native-image-picker (from `../node_modules/react-native-image-picker`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) @@ -1840,6 +1890,10 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon" React-microtasksnativemodule: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + react-native-document-picker: + :path: "../node_modules/@react-native-documents/picker" + react-native-image-picker: + :path: "../node_modules/react-native-image-picker" React-NativeModulesApple: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" React-oscompat: @@ -1945,6 +1999,8 @@ SPEC CHECKSUMS: React-logger: 8edfcedc100544791cd82692ca5a574240a16219 React-Mapbuffer: c3f4b608e4a59dd2f6a416ef4d47a14400194468 React-microtasksnativemodule: 054f34e9b82f02bd40f09cebd4083828b5b2beb6 + react-native-document-picker: 686c62761cf3bbb914588c252eca45fc1991436c + react-native-image-picker: db0b3e3c7ae57680876318d4f42e7dbedd72244a React-NativeModulesApple: 2c4377e139522c3d73f5df582e4f051a838ff25e React-oscompat: ef5df1c734f19b8003e149317d041b8ce1f7d29c React-perflogger: 9a151e0b4c933c9205fd648c246506a83f31395d diff --git a/example/package.json b/example/package.json index c16892f..f06bd9b 100644 --- a/example/package.json +++ b/example/package.json @@ -10,8 +10,10 @@ "build:ios": "react-native build-ios --mode Debug" }, "dependencies": { + "@react-native-documents/picker": "^12.0.1", "react": "19.0.0", - "react-native": "0.79.2" + "react-native": "0.79.2", + "react-native-image-picker": "^8.2.1" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/example/src/App.tsx b/example/src/App.tsx index f9b0411..e716c99 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,33 +1,842 @@ -import { useState, useEffect } from 'react'; -import { Image, StyleSheet } from 'react-native'; -import DocumentScanner from '@preeternal/react-native-document-scanner-plugin'; +import { useMemo, useState } from 'react'; +import { + ActivityIndicator, + Image, + Pressable, + SafeAreaView, + ScrollView, + StyleSheet, + Switch, + Text, + View, +} from 'react-native'; +import DocumentScanner, { + ResponseType, + type AnalysisResult, + type AnalyzeScannedImagesOptions, + type Barcode, + type ScanAndAnalyzeDocumentResponse, + type TextBlock, +} from '@preeternal/react-native-document-scanner-plugin'; +import { + errorCodes as pickerErrorCodes, + isErrorWithCode, + pick, + types as pickerTypes, +} from '@react-native-documents/picker'; +import { launchImageLibrary } from 'react-native-image-picker'; -export default () => { - const [scannedImage, setScannedImage] = useState(); +type ExtractToggles = { + barcodes: boolean; + text: boolean; + tables: boolean; + regions: boolean; + structuredData: boolean; +}; + +type ActionKey = + | 'pickGallery' + | 'pickImages' + | 'scan' + | 'extractBarcodes' + | 'extractText' + | 'analyze' + | 'scanAndAnalyze'; + +type BarcodeFormatValue = Barcode['format']; +type PageSource = 'scanner' | 'picker' | 'gallery'; + +const BARCODE_FORMAT_OPTIONS: BarcodeFormatValue[] = [ + 'qr', + 'ean13', + 'ean8', + 'upca', + 'upce', + 'code128', + 'itf', + 'pdf417', + 'aztec', + 'dataMatrix', +]; + +const INITIAL_EXTRACT: ExtractToggles = { + barcodes: true, + text: true, + tables: true, + regions: true, + structuredData: true, +}; + +const INITIAL_BARCODE_FORMATS: BarcodeFormatValue[] = ['qr', 'ean13']; + +function formatError(error: unknown): string { + if (typeof error === 'string') { + return error; + } + + if (error && typeof error === 'object') { + const native = error as { code?: unknown; message?: unknown }; + const code = typeof native.code === 'string' ? native.code : undefined; + const message = + typeof native.message === 'string' ? native.message : undefined; + + if (code || message) { + return [code, message].filter(Boolean).join(': '); + } + } + + return 'Unknown error'; +} + +function toDisplayImageUri(source: string, responseType: ResponseType): string { + if (responseType === ResponseType.Base64) { + return source.startsWith('data:') + ? source + : `data:image/jpeg;base64,${source}`; + } + + if ( + source.startsWith('file://') || + source.startsWith('content://') || + source.startsWith('data:') || + source.startsWith('http://') || + source.startsWith('https://') + ) { + return source; + } + + if (source.startsWith('/')) { + return `file://${source}`; + } + + return source; +} + +function jsonPreview(value: unknown): string { + const raw = JSON.stringify(value, null, 2); + if (!raw) { + return ''; + } + + if (raw.length <= 4000) { + return raw; + } + + return `${raw.slice(0, 4000)}\n...truncated`; +} + +function toAnalyzeOptions( + extract: ExtractToggles, + concurrency: 1 | 2, + barcodeFormats: BarcodeFormatValue[], + ocrRotate180Fallback: boolean +): AnalyzeScannedImagesOptions { + return { + extract, + concurrency, + barcodeFormats: barcodeFormats.length > 0 ? barcodeFormats : undefined, + ocrRotate180Fallback, + }; +} + +function ActionButton(props: { + title: string; + onPress: () => void; + disabled?: boolean; +}) { + const { title, onPress, disabled = false } = props; + + return ( + + {title} + + ); +} + +function ToggleChip(props: { + label: string; + active: boolean; + onPress: () => void; +}) { + const { label, active, onPress } = props; + + return ( + + + {label} + + + ); +} + +function JsonCard(props: { title: string; value: unknown }) { + const { title, value } = props; + + return ( + + {title} + + {jsonPreview(value)} + + + ); +} + +export default function App() { + const [responseType, setResponseType] = useState( + ResponseType.ImageFilePath + ); + const [concurrency, setConcurrency] = useState<1 | 2>(2); + const [ocrRotate180Fallback, setOcrRotate180Fallback] = + useState(true); + const [extract, setExtract] = useState(INITIAL_EXTRACT); + const [barcodeFormats, setBarcodeFormats] = useState( + INITIAL_BARCODE_FORMATS + ); + + const [activeAction, setActiveAction] = useState(null); + const [lastError, setLastError] = useState(null); + + const [scanStatus, setScanStatus] = useState(null); + const [pageSource, setPageSource] = useState(null); + const [scannedImages, setScannedImages] = useState([]); + const [imagesResponseType, setImagesResponseType] = useState( + ResponseType.ImageFilePath + ); + const [selectedImageIndex, setSelectedImageIndex] = useState(0); + + const [barcodes, setBarcodes] = useState([]); + const [textBlocks, setTextBlocks] = useState([]); + const [analysis, setAnalysis] = useState(null); + const [scanAndAnalyzeResult, setScanAndAnalyzeResult] = + useState(null); + + const analysisOptions = useMemo( + () => + toAnalyzeOptions( + extract, + concurrency, + barcodeFormats, + ocrRotate180Fallback + ), + [extract, concurrency, barcodeFormats, ocrRotate180Fallback] + ); + + const selectedImageSource = scannedImages[selectedImageIndex]; + const selectedImageUri = selectedImageSource + ? toDisplayImageUri(selectedImageSource, imagesResponseType) + : null; + + const isBusy = activeAction !== null; + + const runAction = async (key: ActionKey, task: () => Promise) => { + if (isBusy) { + return; + } + + setActiveAction(key); + setLastError(null); + + try { + await task(); + } catch (error) { + setLastError(formatError(error)); + } finally { + setActiveAction(null); + } + }; + + const toggleExtract = (name: keyof ExtractToggles) => { + setExtract((prev) => ({ + ...prev, + [name]: !prev[name], + })); + }; + + const toggleBarcodeFormat = (format: BarcodeFormatValue) => { + setBarcodeFormats((prev) => + prev.includes(format) + ? prev.filter((candidate) => candidate !== format) + : [...prev, format] + ); + }; + + const clearResults = () => { + setLastError(null); + setScanStatus(null); + setPageSource(null); + setScannedImages([]); + setSelectedImageIndex(0); + setBarcodes([]); + setTextBlocks([]); + setAnalysis(null); + setScanAndAnalyzeResult(null); + }; const scanDocument = async () => { - // start the document scanner - const { scannedImages } = await DocumentScanner.scanDocument(); - - // check if undefined - if (scannedImages) { - console.log('scannedImages', scannedImages); - // get back an array with scanned image file paths - if (scannedImages.length > 0) { - // set the img src, so we can view the first scanned image - setScannedImage(scannedImages[0]); + await runAction('scan', async () => { + const result = await DocumentScanner.scanDocument({ responseType }); + + setScanStatus(result.status); + setPageSource('scanner'); + setScannedImages(result.scannedImages); + setImagesResponseType(responseType); + setSelectedImageIndex(0); + + setBarcodes([]); + setTextBlocks([]); + setAnalysis(null); + setScanAndAnalyzeResult(null); + }); + }; + + const pickImagesFromDevice = async () => { + await runAction('pickImages', async () => { + try { + const picked = await pick({ + mode: 'open', + allowMultiSelection: true, + type: [pickerTypes.images], + }); + + const pickedUris = picked + .map((item) => + typeof item.uri === 'string' ? item.uri.trim() : undefined + ) + .filter((uri): uri is string => !!uri); + + if (pickedUris.length === 0) { + setLastError('Picker returned no readable image URIs.'); + return; + } + + setScanStatus('picked'); + setPageSource('picker'); + setScannedImages(pickedUris); + setImagesResponseType(ResponseType.ImageFilePath); + setSelectedImageIndex(0); + + setBarcodes([]); + setTextBlocks([]); + setAnalysis(null); + setScanAndAnalyzeResult(null); + } catch (error) { + if ( + isErrorWithCode(error) && + error.code === pickerErrorCodes.OPERATION_CANCELED + ) { + return; + } + throw error; + } + }); + }; + + const pickImagesFromGallery = async () => { + await runAction('pickGallery', async () => { + const response = await launchImageLibrary({ + mediaType: 'photo', + selectionLimit: 0, + includeBase64: false, + }); + + if (response.didCancel) { + return; + } + + if (response.errorCode) { + const message = response.errorMessage + ? `${response.errorCode}: ${response.errorMessage}` + : response.errorCode; + setLastError(message); + return; + } + + const pickedUris = (response.assets ?? []) + .map((item) => + typeof item.uri === 'string' ? item.uri.trim() : undefined + ) + .filter((uri): uri is string => !!uri); + + if (pickedUris.length === 0) { + setLastError('Gallery returned no readable image URIs.'); + return; } + + setScanStatus('picked'); + setPageSource('gallery'); + setScannedImages(pickedUris); + setImagesResponseType(ResponseType.ImageFilePath); + setSelectedImageIndex(0); + + setBarcodes([]); + setTextBlocks([]); + setAnalysis(null); + setScanAndAnalyzeResult(null); + }); + }; + + const extractBarcodes = async () => { + if (scannedImages.length === 0) { + setLastError('Scan a document first.'); + return; } + + await runAction('extractBarcodes', async () => { + const result = await DocumentScanner.extractBarcodesFromImages( + scannedImages, + { + concurrency, + barcodeFormats: + barcodeFormats.length > 0 ? barcodeFormats : undefined, + } + ); + + setBarcodes(result); + }); }; - useEffect(() => { - // call scanDocument on load - scanDocument(); - }, []); + const extractText = async () => { + if (scannedImages.length === 0) { + setLastError('Scan a document first.'); + return; + } - return ; -}; + await runAction('extractText', async () => { + const result = await DocumentScanner.extractTextFromImages( + scannedImages, + { + concurrency, + ocrRotate180Fallback, + } + ); + + setTextBlocks(result); + }); + }; + + const analyzeImages = async () => { + if (scannedImages.length === 0) { + setLastError('Scan a document first.'); + return; + } + + await runAction('analyze', async () => { + const result = await DocumentScanner.analyzeScannedImages( + scannedImages, + analysisOptions + ); + + setAnalysis(result); + if (result.barcodes) { + setBarcodes(result.barcodes); + } + + const textResult = result.textBlocks ?? result.text; + if (textResult) { + setTextBlocks(textResult); + } + }); + }; + + const scanAndAnalyze = async () => { + await runAction('scanAndAnalyze', async () => { + const result = await DocumentScanner.scanAndAnalyzeDocument({ + responseType, + analysis: analysisOptions, + }); + + setScanAndAnalyzeResult(result); + setScanStatus(result.status); + setPageSource('scanner'); + setScannedImages(result.scannedImages); + setImagesResponseType(responseType); + setSelectedImageIndex(0); + + setAnalysis(result.analysis); + setBarcodes(result.analysis.barcodes ?? []); + setTextBlocks(result.analysis.textBlocks ?? result.analysis.text ?? []); + }); + }; + + return ( + + + Document Scanner Example + + Full demo for scan + files/gallery pickers + barcode + OCR + + semantics. + + + + scanDocument options + responseType + + setResponseType(ResponseType.ImageFilePath)} + /> + setResponseType(ResponseType.Base64)} + /> + + + analysis concurrency + + setConcurrency(1)} + /> + setConcurrency(2)} + /> + + + + ocrRotate180Fallback + + + + + + analyzeScannedImages extract + + {(Object.keys(extract) as Array).map( + (key) => ( + toggleExtract(key)} + /> + ) + )} + + + + barcodeFormats allow-list (empty = all) + + + {BARCODE_FORMAT_OPTIONS.map((format) => ( + toggleBarcodeFormat(format)} + /> + ))} + + + Selected:{' '} + {barcodeFormats.length > 0 ? barcodeFormats.join(', ') : 'all'} + + + + + Actions + + + + + + + + + + + + {isBusy && ( + + + Running: {activeAction} + + )} + + {lastError && ( + + {lastError} + + )} + + + + Summary + + scan status: {scanStatus ?? 'n/a'} + + source: {pageSource ?? 'n/a'} + + scannedImages: {scannedImages.length} + + barcodes: {barcodes.length} + + textBlocks: {textBlocks.length} + + + analysis status: {analysis?.status ?? 'n/a'} + + + + + Pages (scanner or picker) + {scannedImages.length === 0 && ( + + No pages yet. Use scanner, Files picker, or gallery picker. + + )} + + {scannedImages.length > 0 && ( + <> + + + {scannedImages.map((source, index) => { + const uri = toDisplayImageUri(source, imagesResponseType); + return ( + setSelectedImageIndex(index)} + > + + #{index} + + ); + })} + + + + {selectedImageUri && ( + + )} + + )} + + + {barcodes.length > 0 && } + {textBlocks.length > 0 && ( + + )} + {analysis && } + {scanAndAnalyzeResult && ( + + )} + + + ); +} const styles = StyleSheet.create({ - image: { width: '100%', height: '100%' }, + safeArea: { + flex: 1, + backgroundColor: '#f4f7fb', + }, + content: { + padding: 16, + paddingBottom: 32, + gap: 12, + }, + title: { + fontSize: 28, + fontWeight: '700', + color: '#152340', + }, + subtitle: { + fontSize: 14, + lineHeight: 20, + color: '#42567c', + }, + card: { + backgroundColor: '#ffffff', + borderRadius: 14, + padding: 14, + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#dbe3f1', + gap: 10, + }, + cardTitle: { + fontSize: 16, + fontWeight: '700', + color: '#1c2f57', + }, + label: { + fontSize: 13, + fontWeight: '600', + color: '#334c7f', + }, + rowWrap: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + switchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + chip: { + borderRadius: 999, + paddingHorizontal: 12, + paddingVertical: 8, + borderWidth: 1, + }, + chipActive: { + backgroundColor: '#0b6dff', + borderColor: '#0b6dff', + }, + chipInactive: { + backgroundColor: '#ffffff', + borderColor: '#c7d4ea', + }, + chipText: { + fontSize: 12, + fontWeight: '600', + color: '#31538f', + }, + chipTextActive: { + color: '#ffffff', + }, + hint: { + fontSize: 12, + color: '#4b5f85', + }, + actionsGrid: { + gap: 8, + }, + actionButton: { + backgroundColor: '#0b6dff', + borderRadius: 10, + paddingVertical: 12, + paddingHorizontal: 14, + }, + actionButtonDisabled: { + opacity: 0.45, + }, + actionButtonText: { + color: '#ffffff', + fontSize: 14, + fontWeight: '700', + textAlign: 'center', + }, + busyRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginTop: 2, + }, + busyText: { + color: '#24467a', + fontSize: 13, + fontWeight: '600', + }, + errorText: { + color: '#b4233a', + fontSize: 13, + lineHeight: 18, + fontWeight: '600', + }, + summaryLine: { + color: '#203a68', + fontSize: 13, + }, + placeholderText: { + color: '#58709a', + fontSize: 13, + }, + thumbnailRow: { + flexDirection: 'row', + gap: 10, + }, + thumbnail: { + width: 96, + borderRadius: 10, + borderWidth: 2, + borderColor: 'transparent', + overflow: 'hidden', + backgroundColor: '#eef3fd', + }, + thumbnailActive: { + borderColor: '#0b6dff', + }, + thumbnailImage: { + width: 96, + height: 96, + backgroundColor: '#d7e2f7', + }, + thumbnailText: { + textAlign: 'center', + color: '#25457a', + fontWeight: '700', + paddingVertical: 6, + }, + previewImage: { + width: '100%', + height: 420, + borderRadius: 12, + resizeMode: 'contain', + backgroundColor: '#e8eef9', + }, + jsonText: { + fontFamily: 'Courier', + fontSize: 12, + lineHeight: 18, + color: '#243c69', + }, }); diff --git a/yarn.lock b/yarn.lock index aacc1aa..566a7dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2505,6 +2505,7 @@ __metadata: "@react-native-community/cli": "npm:18.0.0" "@react-native-community/cli-platform-android": "npm:18.0.0" "@react-native-community/cli-platform-ios": "npm:18.0.0" + "@react-native-documents/picker": "npm:^12.0.1" "@react-native/babel-preset": "npm:0.79.2" "@react-native/metro-config": "npm:0.79.2" "@react-native/typescript-config": "npm:0.79.2" @@ -2512,6 +2513,7 @@ __metadata: react: "npm:19.0.0" react-native: "npm:0.79.2" react-native-builder-bob: "npm:^0.40.13" + react-native-image-picker: "npm:^8.2.1" react-native-monorepo-config: "npm:^0.1.9" languageName: unknown linkType: soft @@ -2894,6 +2896,16 @@ __metadata: languageName: node linkType: hard +"@react-native-documents/picker@npm:^12.0.1": + version: 12.0.1 + resolution: "@react-native-documents/picker@npm:12.0.1" + peerDependencies: + react: "*" + react-native: ">=0.79.0" + checksum: 10c0/1077f80fdb76fcad283997f56a7f2eb263ace2a73da788e4b29dc926a78149c04a6eea1d4aeddef75c9f176fbbc9206a1c64c10c2d0c85c86cca85be023f7c00 + languageName: node + linkType: hard + "@react-native/assets-registry@npm:0.79.2": version: 0.79.2 resolution: "@react-native/assets-registry@npm:0.79.2" @@ -10329,6 +10341,16 @@ __metadata: languageName: node linkType: hard +"react-native-image-picker@npm:^8.2.1": + version: 8.2.1 + resolution: "react-native-image-picker@npm:8.2.1" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10c0/82b6c51ece8d16806975aff7675d26280fdd2391c05ca9415620195cc6643ca0db4601d3b24e117d7f5331c2e55ba7c03090d276e55b3b5687a636245428a047 + languageName: node + linkType: hard + "react-native-monorepo-config@npm:^0.1.8, react-native-monorepo-config@npm:^0.1.9": version: 0.1.9 resolution: "react-native-monorepo-config@npm:0.1.9" From e62af3f65f64ba7ae80428f816cedd63d92c0611 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Sun, 19 Apr 2026 21:09:34 +0300 Subject: [PATCH 13/17] feat(release): harden scan lifecycle and align analysis behavior - finalize v0.3.0 notes with capture+analysis scope and backward-compatibility statement - document Android analysis feature gating and iOS modern/fallback behavior - harden Android scanDocument lifecycle: sync launcher init and stronger invalidate cleanup - replace recursive page mapping with iterative processing in Android scanDocument flow - add barcode extractor release path and scanner cache cleanup on module shutdown - align analyzeScannedImages OCR rotate180 defaults across JS/native (default true) - keep extractTextFromImages OCR rotate180 default false for backward compatibility - add Android per-image timeouts: barcodeTimeoutMs and textTimeoutMs with native clamping - improve iOS resilience: regex compile failure reporting and guarded PhotoKit timeout/cancel - update TS/README docs for advanced options and Android-only notes - add baseline JS tests for analysis options normalization and forwarding --- .github/workflows/ci.yml | 123 ++- .gitignore | 3 + CHANGELOG.md | 86 ++ CONTRIBUTING.md | 81 ++ README.md | 801 ++++++++++-------- .../scanner/barcode/BarcodeExtractorImpl.kt | 213 +++-- .../barcode/BarcodeImageSourceLoader.kt | 42 +- .../preeternal/scanner/DocScannerDebugLog.kt | 83 ++ .../scanner/DocumentScannerModule.kt | 299 +++++-- .../scanner/barcode/BarcodeExtractor.kt | 6 + app.plugin.js | 84 ++ .../project.pbxproj | 4 +- example/ios/Podfile | 12 + example/ios/Podfile.lock | 647 +++++++------- example/package.json | 13 +- example/src/App.tsx | 51 +- .../Analysis/DocumentSemantics.swift | 32 +- .../Analysis/RecognizeDocumentsAnalyzer.swift | 30 +- .../Analysis/StructuredDataNormalizer.swift | 29 +- ios/DocScanner/Analysis/TextExtractor.swift | 5 +- ios/DocScanner/Barcode/BarcodeExtractor.swift | 501 ++++++++++- .../Barcode/BarcodeImageSource.swift | 112 ++- ios/DocScanner/DebugLog.swift | 33 + ios/DocScanner/DocScanner.swift | 24 + ios/DocumentScanner.swift | 209 ++++- package.json | 4 +- review.md | 283 +++++++ scripts/check-16kb.sh | 89 ++ src/NativeDocumentScanner.ts | 25 + src/__tests__/index.test.tsx | 92 +- src/index.tsx | 10 + tsconfig.json | 1 + yarn.lock | 441 +++++++++- 33 files changed, 3546 insertions(+), 922 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 android/src/main/java/com/preeternal/scanner/DocScannerDebugLog.kt create mode 100644 app.plugin.js create mode 100644 ios/DocScanner/DebugLog.swift create mode 100644 review.md create mode 100755 scripts/check-16kb.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11e7cd4..e2e82ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,10 @@ on: types: - checks_requested +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: lint: runs-on: ubuntu-latest @@ -52,10 +56,110 @@ jobs: - name: Build package run: yarn prepare + expo-prebuild: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + features: [none, barcode, 'barcode,text', all] + env: + EXPO_WORK_DIR: ${{ github.workspace }}/.tmp/expo-prebuild-${{ matrix.features }} + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup + uses: ./.github/actions/setup + + - name: Expo prebuild smoke test (analysisFeatures=${{ matrix.features }}) + env: + DS_FEATURES: ${{ matrix.features }} + run: | + set -euo pipefail + + PACKAGE_TGZ="$(npm pack --silent --ignore-scripts | tail -n1)" + rm -rf "$EXPO_WORK_DIR" + mkdir -p "$EXPO_WORK_DIR" + + npx --yes create-expo-app@latest "$EXPO_WORK_DIR/app" --template blank-typescript --yes --no-install + + cd "$EXPO_WORK_DIR/app" + npm install + npm install --ignore-scripts "$GITHUB_WORKSPACE/$PACKAGE_TGZ" + + node <<'NODE' + const fs = require('node:fs'); + const path = 'app.json'; + const app = JSON.parse(fs.readFileSync(path, 'utf8')); + app.expo = app.expo || {}; + app.expo.plugins = [ + ['@preeternal/react-native-document-scanner-plugin', { analysisFeatures: process.env.DS_FEATURES }], + ]; + fs.writeFileSync(path, JSON.stringify(app, null, 2) + '\n'); + NODE + + CI=1 npx expo config --json > "$EXPO_WORK_DIR/expo-config.json" + node -e "JSON.parse(require(\"node:fs\").readFileSync(process.argv[1], \"utf8\"))" "$EXPO_WORK_DIR/expo-config.json" + + CI=1 npx expo prebuild --platform android --clean --no-install 2>&1 | tee "$EXPO_WORK_DIR/prebuild.log" + + grep -q "^DocumentScanner_analysisFeatures=${DS_FEATURES}$" android/gradle.properties + + - name: Print Expo prebuild diagnostics (${{ matrix.features }}) + if: always() + run: | + set -euo pipefail + + echo "::group::app.json" + if [[ -f "$EXPO_WORK_DIR/app/app.json" ]]; then + cat "$EXPO_WORK_DIR/app/app.json" + else + echo "Missing: $EXPO_WORK_DIR/app/app.json" + fi + echo "::endgroup::" + + echo "::group::expo config" + if [[ -f "$EXPO_WORK_DIR/expo-config.json" ]]; then + cat "$EXPO_WORK_DIR/expo-config.json" + else + echo "Missing: $EXPO_WORK_DIR/expo-config.json" + fi + echo "::endgroup::" + + echo "::group::android/gradle.properties (DocumentScanner lines)" + if [[ -f "$EXPO_WORK_DIR/app/android/gradle.properties" ]]; then + grep -n "DocumentScanner" "$EXPO_WORK_DIR/app/android/gradle.properties" || echo "(no DocumentScanner entries)" + else + echo "Missing: $EXPO_WORK_DIR/app/android/gradle.properties" + fi + echo "::endgroup::" + + - name: Upload Expo prebuild diagnostics (${{ matrix.features }}) + if: always() + uses: actions/upload-artifact@v4 + with: + name: expo-prebuild-${{ matrix.features }}-diagnostics + if-no-files-found: warn + path: | + ${{ env.EXPO_WORK_DIR }}/prebuild.log + ${{ env.EXPO_WORK_DIR }}/app/app.json + ${{ env.EXPO_WORK_DIR }}/expo-config.json + ${{ env.EXPO_WORK_DIR }}/app/android/gradle.properties + + - name: Cleanup Expo prebuild workspace (${{ matrix.features }}) + if: always() + run: rm -rf "$EXPO_WORK_DIR" + build-android: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + features: [none, barcode, text, all] env: - TURBO_CACHE_DIR: .turbo/android + TURBO_CACHE_DIR: .turbo/android-${{ matrix.features }} + steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -63,15 +167,15 @@ jobs: - name: Setup uses: ./.github/actions/setup - - name: Cache turborepo for Android + - name: Cache turborepo for Android (${{ matrix.features }}) uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: path: ${{ env.TURBO_CACHE_DIR }} - key: ${{ runner.os }}-turborepo-android-${{ hashFiles('yarn.lock') }} + key: ${{ runner.os }}-turborepo-android-${{ matrix.features }}-${{ hashFiles('yarn.lock') }} restore-keys: | - ${{ runner.os }}-turborepo-android- + ${{ runner.os }}-turborepo-android-${{ matrix.features }}- - - name: Check turborepo cache for Android + - name: Check turborepo cache for Android (${{ matrix.features }}) run: | TURBO_CACHE_STATUS=$(node -p "($(yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}" --dry=json)).tasks.find(t => t.task === 'build:android').cache.status") @@ -98,16 +202,21 @@ jobs: path: | ~/.gradle/wrapper ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('example/android/gradle/wrapper/gradle-wrapper.properties') }} + key: ${{ runner.os }}-gradle-${{ matrix.features }}-${{ hashFiles('example/android/gradle/wrapper/gradle-wrapper.properties') }} restore-keys: | + ${{ runner.os }}-gradle-${{ matrix.features }}- ${{ runner.os }}-gradle- - - name: Build example for Android + - name: Build example for Android (features=${{ matrix.features }}) env: JAVA_OPTS: "-XX:MaxHeapSize=6g" + ORG_GRADLE_PROJECT_DocumentScanner_analysisFeatures: ${{ matrix.features }} run: | yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}" + - name: Verify 16KB page alignment + run: bash scripts/check-16kb.sh + build-ios: runs-on: macos-latest env: diff --git a/.gitignore b/.gitignore index 67f3212..f02babc 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,6 @@ android/generated # React Native Nitro Modules nitrogen/ + +#Temp files +*.tmp diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6f65987 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,86 @@ +# Releases + +## v0.3.0 – Capture + Analysis Pipeline Release + +## Highlights + +- Added single-call capture + analysis pipeline: + - `scanAndAnalyzeDocument(options?)` +- Added new public analysis APIs for existing images: + - `extractBarcodesFromImages(images, options?)` + - `extractTextFromImages(images, options?)` + - `analyzeScannedImages(images, options?)` +- Added document semantics extraction: + - tables, regions, structured entities and key-value fields +- Improved iOS analysis pipeline: modern Vision path on iOS 26+, with fallback paths for earlier iOS versions and Simulator environments. +- Added Android analysis feature gating via Gradle property (`DocumentScanner_analysisFeatures`), so barcode/text analysis can be explicitly enabled for Android builds. + +## Additional Notes + +- Core `scanDocument(...)` flow remains supported and backward-compatible. +- This release expands the package from capture-only usage to capture + analysis workflows for scanner output, gallery images and file-based pipelines. +- No breaking changes are expected for existing `scanDocument(...)` integrations. + +### Technical hardening (legacy API) + +- Android (`scanDocument` flow): synchronized `launcher` initialization to avoid edge-case double registration on concurrent calls. +- Android (`scanDocument` flow): stronger `invalidate()` cleanup (`launcher.unregister()`, pending-state reset). +- Android (`scanDocument` flow): iterative page mapping in scan result processing (reduced recursion risk). + +--- + +## v0.2.2 – Cross-Platform Polish + +## Highlights + +- Ensure Google’s document scanner respects system bars on Android 15+ and restore window flags when returning to React Native (Fix #2). +- Prevent repeated scans from crashing the Android bridge (`ObjectAlreadyConsumedException`) and validate returned URIs before resolving promises (Fix #142). +- Align Android/iOS responses: base64 conversion is guarded, file paths are checked for readability, and iOS trims/validates `file://` URLs (Fix #142). +- Restore DocumentScanner Objective‑C bridge so both old and new architectures build cleanly without warnings. + +## Additional Notes + +- Covers testing on both architectures, with New Arch guarding the TurboModule entry point while legacy builds continue to use the classic bridge. +- No API changes; consumers can upgrade directly. + +### Docs: Sanitized scanner response + +- Since v0.2.2 the module sanitizes scannedImages on both platforms. You no longer need to post‑filter in JS. +- Android: returns only non‑empty base64 strings or readable URIs (verified via ContentResolver). +- iOS: trims strings, normalizes file:// URLs to file paths and checks file existence. +- Backward‑compatible; no API changes. + +Example: + +```js +const { status, scannedImages } = await DocumentScanner.scanDocument({ responseType: 'imageFilePath' }) +if (status === 'success' && scannedImages.length) { + // All items are already valid + setImage(scannedImages[0]) +} +``` + +--- + +## v0.2.1 – Expo EAS iOS Swift Header Fix + +- Fix: Reliable import of generated Swift header (DocumentScanner-Swift.h) using conditional __has_include + VisionKit import so Expo EAS cloud builds no longer fail with ‘DocumentScanner-Swift.h’ or VNDocumentCameraViewControllerDelegate not found. +- Ensures consistent iOS 13+ VisionKit availability for Expo SDK 53 development & production builds. + +--- + +## v0.2.0 – TurboModule support & improvements + +### Added + +- Full support for React Native New Architecture (TurboModules). +- Prepared builds with react-native-builder-bob. + +### Changed + +- Updated dependencies to React Native 0.79.2 and React 19.0.0. +- Improved TypeScript definitions. + +### Fixed + +- Minor issues in Android and iOS build configurations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 250377f..57359b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,6 +119,87 @@ The `package.json` file contains various scripts for common tasks: - `yarn example android`: run the example app on Android. - `yarn example ios`: run the example app on iOS. +### Native debug logs (iOS) + +iOS native logs in this project are opt-in. + +- `DOCUMENT_SCANNER_DEBUG_LOGS=1` enables high-level logs. +- `DOCUMENT_SCANNER_TRACE_LOGS=1` enables verbose trace logs (and high-level logs). + +Example for physical device launch via `devicectl`: + +```sh +REPO="$(git rev-parse --show-toplevel)" +mkdir -p "$REPO/logs" + +xcrun devicectl device process launch \ + --device \ + --terminate-existing \ + --console \ + --environment-variables '{"DOCUMENT_SCANNER_TRACE_LOGS":"1"}' \ + preeternal.scanner.example 2>&1 \ +| grep --line-buffered '\[DocumentScanner\]' \ +| tee "$REPO/logs/ios-trace.log" +``` + +Example for Simulator (`log stream`), after app launch with env flag: + +```sh +xcrun simctl spawn booted log stream --level debug \ + --predicate 'eventMessage CONTAINS[c] "[DocumentScanner]"' +``` + +### Native debug logs (Android) + +Android native logs are also opt-in. + +Enable one of the flags before launching the app: + +- `debug.document_scanner.debug_logs=1` enables high-level logs. +- `debug.document_scanner.trace_logs=1` enables verbose trace logs (and high-level logs). + +```sh +adb shell setprop debug.document_scanner.debug_logs 1 +adb shell setprop debug.document_scanner.trace_logs 1 +``` + +After changing Android debug properties, restart the app process to apply them: + +```sh +adb shell am force-stop +``` + +If you changed native code, rebuild/reinstall the app before running log capture. + +Clear/disable flags: + +```sh +adb shell setprop debug.document_scanner.debug_logs 0 +adb shell setprop debug.document_scanner.trace_logs 0 +adb shell am force-stop +``` + +Barcode run to file: + +```sh +REPO="$(git rev-parse --show-toplevel)" +mkdir -p "$REPO/logs" +adb logcat -c +adb logcat -v time DocumentScanner:D DocumentScannerBarcode:V '*:S' \ +| grep --line-buffered -Ei 'extractBarcodes|runBarcodeAnalysisStage|Barcode|DocumentScannerBarcode' \ +| tee "$REPO/logs/android-barcode.log" +``` + +Text run to file: + +```sh +REPO="$(git rev-parse --show-toplevel)" +mkdir -p "$REPO/logs" +adb logcat -c +adb logcat -v time DocumentScanner:D DocumentScannerBarcode:V ReactNative:W AndroidRuntime:E '*:S' \ +| tee "$REPO/logs/android-text-full-unfiltered.log" +``` + ### Sending a pull request > **Working on your first pull request?** You can learn how from this _free_ series: [How to Contribute to an Open Source Project on GitHub](https://app.egghead.io/playlists/how-to-contribute-to-an-open-source-project-on-github). diff --git a/README.md b/README.md index 247a942..84129f7 100644 --- a/README.md +++ b/README.md @@ -3,26 +3,62 @@ [![npm version](https://img.shields.io/npm/v/@preeternal/react-native-document-scanner-plugin.svg)](https://www.npmjs.com/package/@preeternal/react-native-document-scanner-plugin) [![npm downloads](https://img.shields.io/npm/dm/@preeternal/react-native-document-scanner-plugin.svg)](https://www.npmjs.com/package/@preeternal/react-native-document-scanner-plugin) +React Native document scanning for **capture-first** and **analysis-ready** workflows. -> ### Heads‑up: Upstream now supports New Architecture -> The original project, [WebsiteBeaver/react-native-document-scanner-plugin](https://github.com/WebsiteBeaver/react-native-document-scanner-plugin), now ships **New Architecture (TurboModule)** support as well. -> This fork remains **actively maintained** and API‑compatible. If you prefer the upstream package, you can safely use it; if you already rely on this fork, you can continue without changes. +This fork started as a maintained alternative to the original `react-native-document-scanner-plugin`, but it has grown beyond a compatibility fork. +Today it gives you a clean separation between: -Fork of [react-native-document-scanner-plugin](https://github.com/WebsiteBeaver/react-native-document-scanner-plugin) with New Architecture (TurboModule) support and active maintenance. +- **capture** — scan documents from camera UI; +- **analysis** — extract barcode / OCR / semantics from existing images; +- **combined flows** — scan and analyze in one call when your product needs both. -## Which package should I use? +It is designed for apps that need more than “just scan a page”: -- **Use the upstream package** (`react-native-document-scanner-plugin`) if you want to stay on the original repository now that it also supports New Architecture. -- **Use this fork** (`@preeternal/react-native-document-scanner-plugin`) if you want quicker iteration on fixes, Expo/EAS build hardening, and a maintained release cadence. The core `scanDocument(...)` API remains backward compatible. +- e-sign and contract flows +- receipts and expense capture +- logistics / proof-of-delivery +- KYC and onboarding +- forms, IDs, invoices, and document intake -> **Attribution**: This package is a community‑maintained fork of the original project by **WebsiteBeaver**. Demo videos embedded below are from the original repository and are credited to their respective owners. +## Why this fork -This is a React Native plugin that lets you scan documents using Android and iOS. You can use it to create -apps that let users scan notes, homework, business cards, receipts, or anything with a rectangular shape. +The upstream package now supports New Architecture too, which is great. +This package stays API-compatible for the core scan flow, while focusing on faster iteration and broader document-processing scenarios. -| iOS | Android | -| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| ![Dollar-iOS](https://user-images.githubusercontent.com/26162804/160485984-e6c46563-56ee-4be9-b241-34a186e0029d.gif) | ![Dollar Android](https://user-images.githubusercontent.com/26162804/160306955-af9c5dd6-5cdf-4e2c-8770-c734a594985d.gif) | +### What is different from upstream now + +This is no longer only about TurboModules parity. +The fork has expanded into a broader mobile document workflow SDK with these additions: + +- **separate public analysis APIs** for existing images: + - `extractBarcodesFromImages(...)` + - `extractTextFromImages(...)` + - `analyzeScannedImages(...)` +- **combined capture + analysis flow** via `scanAndAnalyzeDocument(...)` +- **optional post-processing barcode extraction** with Android build-time feature gating +- **OCR extraction** with adaptive 180° fallback support +- **semantics-oriented analysis output**: + - text blocks + - inferred tables + - regions + - structured data / key-value style fields +- **response sanitization** on both platforms +- **Expo / EAS / CI hardening** +- **opt-in native debug logs** on iOS and Android +- ongoing maintenance and release cadence + +## What this package is good at + +Use it when you want a document capture layer that can also become the first stage of a richer pipeline: + +- camera capture +- perspective-corrected scan results +- analysis from already existing images +- barcode extraction +- OCR extraction +- structured downstream payloads for app/backend workflows + +Use your own or third-party tooling for final document authoring such as advanced PDF generation, searchable PDF composition, RTF export, signing, or storage pipelines. ## Installation @@ -30,17 +66,12 @@ apps that let users scan notes, homework, business cards, receipts, or anything yarn add @preeternal/react-native-document-scanner-plugin ``` -After installing the plugin, you need to follow the steps below - ### iOS -1. Open `ios/Podfile` and set `platform :ios` to `13` or higher - -2. iOS requires the following usage description be added and filled out for your app in `Info.plist`: - -- `NSCameraUsageDescription` (`Privacy - Camera Usage Description`) - -3. Install pods by running +1. Open `ios/Podfile` and set `platform :ios` to `13` or higher. +2. Add camera usage description to `Info.plist`: + - `NSCameraUsageDescription` +3. Install pods: ```bash cd ios && bundle exec pod install && cd .. @@ -48,210 +79,315 @@ cd ios && bundle exec pod install && cd .. ### Android -**Note:** You don't need to prompt the user to accept camera permissions for this plugin to work unless you're using another plugin that requires the user to accept camera permissions. See [Android Camera Permissions](#android-camera-permissions). - -## Examples +You do not need to request camera permission unless another plugin adds camera permission requirements to your app manifest. +See [Android Camera Permissions](#android-camera-permissions) below. -> Demo media in this README is courtesy of the original project (WebsiteBeaver). +## Quick start -* [Basic Example](#basic-example) -* [Limit Number of Scans](#limit-number-of-scans) +### 1. Scan a document -### Basic Example - -```javascript -import React, { useState, useEffect } from 'react' +```tsx +import React, { useEffect, useState } from 'react' import { Image } from 'react-native' import DocumentScanner from '@preeternal/react-native-document-scanner-plugin' -export default () => { - const [scannedImage, setScannedImage] = useState(); - - const scanDocument = async () => { - // start the document scanner - const { scannedImages } = await DocumentScanner.scanDocument() - - // get back an array with scanned image file paths - if (scannedImages.length > 0) { - // set the img src, so we can view the first scanned image - setScannedImage(scannedImages[0]) - } - } +export default function App() { + const [image, setImage] = useState() useEffect(() => { - // call scanDocument on load - scanDocument() - }, []); + const run = async () => { + const { status, scannedImages } = await DocumentScanner.scanDocument({ + responseType: 'imageFilePath', + }) + + if (status === 'success' && scannedImages.length > 0) { + setImage(scannedImages[0]) + } + } + + run() + }, []) return ( ) } ``` -Here's what this example looks like with several items +### 2. Extract barcodes from existing images - - - - - +```ts +const result = await DocumentScanner.scanDocument({ responseType: 'imageFilePath' }) - +const barcodes = await DocumentScanner.extractBarcodesFromImages( + result.scannedImages, + { + barcodeFormats: ['ean13', 'itf'], + concurrency: 2, + } +) +``` - +### 3. Extract OCR text from existing images - +```ts +const textBlocks = await DocumentScanner.extractTextFromImages(images, { + concurrency: 2, + ocrRotate180Fallback: true, +}) +``` - +If you only need plain text per page, use the `text` field on each block — it is already the full concatenated string for that image: - +```ts +const pages = textBlocks.map((block) => ({ + pageIndex: block.sourceImageIndex, + text: block.text, +})) +// [{ pageIndex: 0, text: "Invoice #1234\nTotal: $99.00\n..." }, ...] +``` - +The `lines` and `bbox` fields are there for advanced layout use cases (PDF composition, field highlighting, etc.) and can be ignored when you only need the text. - +### 4. Run full analysis over scanned images -### Limit Number of Scans +```ts +const analysis = await DocumentScanner.analyzeScannedImages(images, { + extract: { + barcodes: true, + text: true, + tables: true, + structuredData: true, + }, + concurrency: 2, +}) +``` -You can limit the number of scans. For example if your app lets a user scan a business -card you might want them to only capture the front and back. In this case you can set -maxNumDocuments to 2. This only works on Android. +### 5. Scan and analyze in one call -```javascript -import React, { useState, useEffect } from 'react' -import { Image } from 'react-native' -import DocumentScanner from '@preeternal/react-native-document-scanner-plugin' +```ts +const result = await DocumentScanner.scanAndAnalyzeDocument({ + responseType: 'imageFilePath', + analysis: { + extract: { + barcodes: true, + text: true, + }, + concurrency: 2, + }, +}) +``` -export default () => { - const [scannedImage, setScannedImage] = useState(); - - const scanDocument = async () => { - // start the document scanner - const { scannedImages } = await DocumentScanner.scanDocument({ - maxNumDocuments: 2 - }) - - // get back an array with scanned image file paths - if (scannedImages.length > 0) { - // set the img src, so we can view the first scanned image - setScannedImage(scannedImages[0]) - } - } +## API overview - useEffect(() => { - // call scanDocument on load - scanDocument() - }, []); +### Capture - return ( - - ) -} -``` +- `scanDocument(options?)` - +Open native document scanner UI and return sanitized images. -## Differences from the original +### Targeted analysis -- New Architecture (TurboModule) support — **now also available upstream**; this fork shipped it earlier and keeps parity. -- Optional post-processing barcode extraction with compile-time feature gating. -- Additional hardening for Expo/EAS and CI examples. -- Minor documentation updates and ongoing maintenance. +- `extractBarcodesFromImages(images, options?)` +- `extractTextFromImages(images, options?)` -## Documentation +Run only the stage you need for lower latency and smaller payloads. -* [`scanDocument(...)`](#scandocument) -* [Response sanitization (since v0.2.2)](#response-sanitization-since-v022) -* [Barcode extraction (optional feature)](#barcode-extraction-optional-feature) -* [`extractBarcodesFromImages(...)`](#extractbarcodesfromimages) -* [`extractTextFromImages(...)`](#extracttextfromimages) -* [`analyzeScannedImages(...)`](#analyzescannedimages) -* [`scanAndAnalyzeDocument(...)`](#scanandanalyzedocument) -* [Interfaces](#interfaces) -* [Enums](#enums) +### Combined analysis -### scanDocument(...) +- `analyzeScannedImages(images, options)` +- `scanAndAnalyzeDocument(options)` -```typescript -scanDocument(options?: ScanDocumentOptions | undefined) => Promise -``` +Use these when your app needs richer OCR and document understanding output. -Opens the camera, and starts the document scan +## Recommended usage modes -| Param | Type | -| ------------- | ------------------------------------------------------------------- | -| **`options`** | ScanDocumentOptions | +### Simple mode -**Returns:** Promise<ScanDocumentResponse> +Request only the data your screen or business flow needs. +Examples: --------------------- +```ts +{ extract: { barcodes: true } } +``` -### Response sanitization (since v0.2.2) +```ts +{ extract: { text: true } } +``` -The module now sanitizes results on both platforms, so you no longer need to post‑filter `scannedImages` in JS: +This keeps latency and payload size lower. -- Android: for `responseType: 'base64'` only non‑empty base64 strings are returned; for URI responses the module verifies the URI is readable via `ContentResolver` and drops unreachable items. -- iOS: trims strings, normalizes `file://` URLs to filesystem paths and checks file existence before returning. +### Full mode -As a result `scannedImages` contains only valid items. Example: +Request richer OCR semantics when you need downstream processing: ```ts -const { status, scannedImages } = await DocumentScanner.scanDocument({ responseType: 'imageFilePath' }) -if (status === 'success' && scannedImages.length) { - // All items are valid URIs or base64 strings depending on responseType - setImage(scannedImages[0]) +{ + extract: { + text: true, + tables: true, + regions: true, + structuredData: true, + } } ``` -### Barcode extraction (optional feature) +This is useful for receipts, forms, invoice parsing, document intake, or backend enrichment. -Barcode extraction is an optional post-processing stage. `scanDocument(...)` stays fast and scan-only. -Run extraction after capture: +### Analysis guidance -```ts -const scanResult = await DocumentScanner.scanDocument({ responseType: 'imageFilePath' }) -const barcodes = await DocumentScanner.extractBarcodesFromImages( - scanResult.scannedImages, - { barcodeFormats: ['ean13', 'itf'], concurrency: 2 } -) -``` +Use `analyzeScannedImages(...)` when your pipeline needs structured OCR metadata rather than just raw capture output. + +Typical patterns: + +- **UI-driven flows** (fast, lightweight): + request only what is needed: + - `extract: { barcodes: true }` + - `extract: { text: true }` + +- **Document-processing flows** (backend enrichment, intake pipelines): + request richer semantics: + - `text` + - `tables` + - `regions` + - `structuredData` + +Notes: + +- Full OCR payload is intended as a downstream foundation for searchable PDF generation, backend enrichment, invoice parsing, receipt processing, or custom document workflows. +- This library returns capture and analysis artifacts. Final document authoring (PDF composition, signing workflows, storage pipelines, or export formats such as RTF/searchable PDF) is expected to be handled by your app or third‑party tooling. + +## Android feature gating for analysis -Barcode extraction is disabled by default at build time. +Barcode / OCR / table-related analysis features are opt-in on Android. +This keeps the core scanning package lean for apps that only need capture. -Enable on Android (includes ML Kit barcode dependency only when enabled): +Enable features when building Android: ```bash ./gradlew :react-native-document-scanner-plugin:assemble -PDocumentScanner_analysisFeatures=barcode ``` -or add this to `android/gradle.properties`: +Or configure `android/gradle.properties`: ```properties DocumentScanner_analysisFeatures=barcode ``` -Why Android needs this extra flag: +Accepted values: -- Barcode extraction relies on additional native dependencies (`com.google.mlkit:barcode-scanning` and `androidx.exifinterface`). -- Keeping it opt-in avoids adding those dependencies and their APK/AAB size impact for apps that only need core document scanning. +- `barcode` +- `text` +- `tables` +- comma-separated combinations such as `barcode,text` +- `all` +- `none` -iOS does not require an additional build flag for barcode extraction. +If a feature is not enabled in the Android native build, the corresponding API returns `not_enabled` style behavior or rejects with a feature-specific error such as `barcode_not_enabled`. -Android flag accepts a comma-separated list: `barcode,text,tables`. -Special values: `all` (enable every analysis feature) and `none` (disable all). +### Expo / EAS: configure via app.json -If barcode feature is not enabled in the Android native build, `extractBarcodesFromImages(...)` rejects with `barcode_not_enabled`. +For Expo managed and bare workflows, use the built-in config plugin instead of editing `gradle.properties` manually: -When enabled, barcode extraction is best-effort: Android uses an internal per-image timeout (10 seconds). -If a page times out, the method still resolves with what was found so far (possibly `[]`) and does not throw a timeout error. +```json +{ + "expo": { + "plugins": [ + [ + "@preeternal/react-native-document-scanner-plugin", + { + "analysisFeatures": "barcode,text" + } + ] + ] + } +} +``` + +Accepted values for `analysisFeatures`: `barcode`, `text`, `tables`, comma-separated combinations, `all`, or `none` (default when omitted). + +The plugin writes `DocumentScanner_analysisFeatures` to `android/gradle.properties` during `expo prebuild` / EAS build. iOS requires no configuration — analysis features are always available. + +## iOS behavior: real device vs simulator + +The library supports both, but analysis quality can differ by environment. + +| Environment | Primary API path | Notes | +| ----------- | ---------------- | ----- | +| Real device (modern iOS) | `RecognizeDocumentsRequest` when available | Best reference for final quality validation | +| Simulator (modern iOS) | Modern path may run, but Vision results can differ | Use for integration, not final barcode quality decisions | +| Older iOS versions | Legacy Vision fallback path | Stable compatibility-focused behavior | + +### Practical guidance + +- Use Simulator for fast integration work. +- Validate barcode and OCR quality on a real device before release. +- If Simulator and device differ, trust real-device output. + +### Why this matters + +Apple Vision APIs can behave differently between Simulator and real hardware, especially for barcode detection. + +Practical expectations: + +- Simulator is reliable for integration and iteration. +- Real devices should be used for validating OCR/barcode quality before release. +- If Simulator and device outputs differ, treat real-device output as the source of truth for tuning and production decisions. + +## Response sanitization + +Returned `scannedImages` are sanitized natively on both platforms, so you do not need to manually post-filter invalid entries in JS. + +- Android: + - base64 responses are filtered to non-empty strings + - URI responses are checked for readability through `ContentResolver` +- iOS: + - strings are trimmed + - `file://` URLs are normalized + - file existence is checked before returning + +Example: + +```ts +const { status, scannedImages } = await DocumentScanner.scanDocument({ + responseType: 'imageFilePath', +}) + +if (status === 'success' && scannedImages.length > 0) { + // safe to use + setImage(scannedImages[0]) +} +``` + +## Documentation + +- [`scanDocument(...)`](#scandocument) +- [`extractBarcodesFromImages(...)`](#extractbarcodesfromimages) +- [`extractTextFromImages(...)`](#extracttextfromimages) +- [`analyzeScannedImages(...)`](#analyzescannedimages) +- [`scanAndAnalyzeDocument(...)`](#scanandanalyzedocument) +- [Response sanitization](#response-sanitization) +- [Interfaces](#interfaces) +- [Enums](#enums) +- [Android Camera Permissions](#android-camera-permissions) + +### scanDocument(...) + +```typescript +scanDocument(options?: ScanDocumentOptions | undefined) => Promise +``` + +Opens native camera UI and starts document scanning. + +| Param | Type | +| ------------- | ------------------------------------------------------------------- | +| **`options`** | ScanDocumentOptions | + +**Returns:** Promise<ScanDocumentResponse> ### extractBarcodesFromImages(...) @@ -267,12 +403,10 @@ Extracts barcodes from existing images without opening scanner UI. | Param | Type | | ------------- | ------------------------------------------------------------------------------------------------------ | | **`images`** | string[] | -| **`options`** | ExtractBarcodesFromImagesOptions | +| **`options`** | ExtractBarcodesFromImagesOptions | **Returns:** Promise<Barcode[]> --------------------- - ### extractTextFromImages(...) ```typescript @@ -287,17 +421,16 @@ Extracts OCR text blocks from existing images without opening scanner UI. | Param | Type | | ------------- | ------------------------------------------------------------------------------------------------------ | | **`images`** | string[] | -| **`options`** | ExtractTextFromImagesOptions | +| **`options`** | ExtractTextFromImagesOptions | **Returns:** Promise<TextBlock[]> Notes: -- Android: requires `DocumentScanner_analysisFeatures` to include `text` or `tables`. -- iOS: available by default (no additional build flag). On modern iOS versions the module uses `RecognizeDocumentsRequest`; older iOS versions fall back to classic OCR flow. -- Optional (`false` by default for this method): set `ocrRotate180Fallback: true` to enable adaptive OCR fallback (extra 180° pass only when first pass returns very little text). - --------------------- +- Android requires `DocumentScanner_analysisFeatures` to include `text` or `tables`. +- iOS is available by default. +- `ocrRotate180Fallback` is optional for this method and defaults to `false`. +- `textTimeoutMs` (Android-only) can override per-image OCR timeout. Default: `25000ms`. ### analyzeScannedImages(...) @@ -309,9 +442,15 @@ analyzeScannedImages( ``` Universal post-processing over scanned images. -Barcode extraction, OCR text, tables, regions, and structured data stages run natively. -On iOS, `RecognizeDocumentsRequest` is used when available to read document structure (tables/text/data detector matches), with fallback to legacy OCR on older iOS versions. -`ocrRotate180Fallback` is enabled by default for this method (`true`) and runs an extra OCR 180° pass only when first pass is weak. +Barcode extraction, OCR text, table inference, region inference, and structured data extraction run natively. + +`ocrRotate180Fallback` is enabled by default for this method (`true`) and only performs an extra 180° OCR pass when the first pass is weak. + +Advanced timeout options: + +- `barcodeTimeoutMs` (Android-only): per-image barcode extraction timeout. Default `10000ms`. +- `textTimeoutMs` (Android-only): per-image OCR extraction timeout. Default `25000ms`. +- Values are clamped natively to a safe range. ```ts const analysis = await DocumentScanner.analyzeScannedImages(scannedImages, { @@ -323,8 +462,6 @@ const analysis = await DocumentScanner.analyzeScannedImages(scannedImages, { **Returns:** Promise<AnalysisResult> --------------------- - ### scanAndAnalyzeDocument(...) ```typescript @@ -333,8 +470,11 @@ scanAndAnalyzeDocument( ) => Promise ``` -Convenience sugar for one-call flow: -`scanDocument(...) -> analyzeScannedImages(...)`. +Convenience API for one-call flow: + +```ts +scanDocument(...) -> analyzeScannedImages(...) +``` ```ts const result = await DocumentScanner.scanAndAnalyzeDocument({ @@ -349,219 +489,184 @@ const result = await DocumentScanner.scanAndAnalyzeDocument({ **Returns:** Promise<ScanAndAnalyzeDocumentResponse> --------------------- - -### Interfaces - - -#### ScanDocumentResponse - -| Prop | Type | Description | -| ------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| **`scannedImages`** | string[] | Array of valid file URIs or base64 strings (already sanitized by the module). | -| **`status`** | ScanDocumentResponseStatus | The status lets you know if the document scan completes successfully, or if the user cancels before completing the document scan. | +## Interfaces +### ScanDocumentResponse -#### ScanDocumentOptions +- `scannedImages`: `string[]` — Array of valid file URIs or base64 strings, already sanitized by the native module. +- `status`: [`ScanDocumentResponseStatus`](#scandocumentresponsestatus) — Indicates whether scan completed successfully or was cancelled by the user. -| Prop | Type | Description | Default | -| ----------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -| **`croppedImageQuality`** | number | The quality of the cropped image from 0 - 100. 100 is the best quality. | : 100 | -| **`maxNumDocuments`** | number | Android only: The maximum number of photos an user can take (not counting photo retakes). | : undefined | -| **`responseType`** | ResponseType | The response comes back in this format on success. It can be the document scan image file paths or base64 images. | : ResponseType.ImageFilePath | +### ScanDocumentOptions +- `croppedImageQuality`: `number` — Cropped image quality from `0` to `100`. Default: `100`. +- `maxNumDocuments`: `number` — Android only: maximum number of captured pages. Default: `undefined`. +- `responseType`: [`ResponseType`](#responsetype) — Result format on success. Default: `ResponseType.ImageFilePath`. -#### ExtractBarcodesFromImagesOptions +### ExtractBarcodesFromImagesOptions -| Prop | Type | Description | Default | -| ------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------- | -| **`barcodeFormats`** | Barcode['format'][] | Optional allow-list of normalized barcode formats used during extraction. When omitted, all supported formats are scanned. | : undefined | -| **`concurrency`** | 1 \\| 2 | Maximum native worker concurrency for barcode extraction. Native implementations clamp values to 1..2. | : 2 | +- `barcodeFormats`: [`Barcode`](#barcode)`['format'][]` — Optional allow-list of normalized barcode formats. Default: `undefined`. +- `concurrency`: `1 | 2` — Maximum native worker concurrency. Clamped to `1..2`. Default: `2`. +- `barcodeTimeoutMs`: `number` — Android-only per-image barcode extraction timeout (milliseconds). Default: `10000`. -#### ExtractTextFromImagesOptions +### ExtractTextFromImagesOptions -| Prop | Type | Description | Default | -| ------------------- | ------------------- | ----------- | ------- | -| **`concurrency`** | 1 \\| 2 | Maximum native worker concurrency for OCR extraction. Native implementations clamp values to 1..2. | : 2 | -| **`ocrRotate180Fallback`** | boolean | Adaptive OCR fallback: run an extra 180° pass only when first OCR pass returns too little text. | : false | +- `concurrency`: `1 | 2` — Maximum native worker concurrency. Clamped to `1..2`. Default: `2`. +- `ocrRotate180Fallback`: `boolean` — Run an extra 180° OCR pass only when the first pass returns too little text. Default: `false`. +- `textTimeoutMs`: `number` — Android-only per-image OCR extraction timeout (milliseconds). Default: `25000`. +### AnalyzeExtractOptions -#### AnalyzeExtractOptions +- `barcodes`: `boolean` — Enable barcode extraction stage. +- `text`: `boolean` — Enable OCR text extraction stage. +- `tables`: `boolean` — Enable table inference from OCR lines. +- `regions`: `boolean` — Enable zone or region inference from OCR blocks. +- `structuredData`: `boolean` — Enable entity and key-value inference from OCR output. -| Prop | Type | Description | -| ------------------- | ------------------ | ----------- | -| **`barcodes`** | boolean | Enable barcode extraction stage. | -| **`text`** | boolean | Enable OCR text extraction stage. | -| **`tables`** | boolean | Enable table inference from OCR lines. | -| **`regions`** | boolean | Enable zone/region inference from OCR blocks. | -| **`structuredData`**| boolean | Enable entity and key-value inference from OCR output. | +### AnalyzeScannedImagesOptions +- `extract`: [`AnalyzeExtractOptions`](#analyzeextractoptions) — Extractor toggles for image analysis. +- `barcodeFormats`: [`Barcode`](#barcode)`['format'][]` — Optional barcode format allow-list. +- `concurrency`: `1 | 2` — Native worker concurrency for barcode/OCR stages. +- `ocrRotate180Fallback`: `boolean` — Adaptive OCR fallback for text/semantics stages. Defaults to `true`. +- `barcodeTimeoutMs`: `number` — Android-only per-image barcode extraction timeout (milliseconds). Default: `10000`. +- `textTimeoutMs`: `number` — Android-only per-image OCR extraction timeout (milliseconds). Default: `25000`. -#### AnalyzeScannedImagesOptions +### AnalysisResult -| Prop | Type | Description | -| ------------------- | -------------------------------------------------------------- | ----------- | -| **`extract`** | AnalyzeExtractOptions | Extractor toggles for image analysis. | -| **`barcodeFormats`**| Barcode['format'][] | Optional barcode format allow-list for barcode stage. | -| **`concurrency`** | 1 \\| 2 | Optional native worker concurrency for barcode/OCR stages. | -| **`ocrRotate180Fallback`** | boolean | Enables adaptive OCR fallback for text/semantics stages. Default: true. | +- `status`: `'success' | 'partial' | 'failed' | 'not_enabled'` — Aggregate status of requested analysis stages. +- `barcodes`: [`Barcode`](#barcode)`[]` — Extracted barcodes when requested and available. +- `textBlocks`: [`TextBlock`](#textblock)`[]` — Canonical OCR text block field. +- `text`: [`TextBlock`](#textblock)`[]` — Backward-compatible alias for `textBlocks`. +- `tables`: [`TableBlock`](#tableblock)`[]` — Inferred tables from OCR lines. +- `regions`: [`Region`](#region)`[]` — Inferred document zones. +- `structuredData`: [`StructuredData`](#structureddata) — Inferred entities and key-value style fields. +### ScanAndAnalyzeDocumentOptions -#### AnalysisResult +- `analysis`: [`AnalyzeScannedImagesOptions`](#analyzescannedimagesoptions) — Analysis options for post-processing. +- `croppedImageQuality`: `number` — Same as `ScanDocumentOptions`. +- `maxNumDocuments`: `number` — Same as `ScanDocumentOptions` (Android only). +- `responseType`: [`ResponseType`](#responsetype) — Same as `ScanDocumentOptions`. -| Prop | Type | Description | -| ------------------- | ------------------------------------------------------------------------------------- | ----------- | -| **`status`** | 'success' \\| 'partial' \\| 'failed' \\| 'not_enabled' | Aggregate status of requested analysis stages. | -| **`barcodes`** | Barcode[] | Extracted barcodes when requested and available. | -| **`textBlocks`** | TextBlock[] | OCR text blocks (new canonical field). | -| **`text`** | TextBlock[] | Backward-compatible alias for `textBlocks`. | -| **`tables`** | TableBlock[] | Inferred tables from OCR lines. | -| **`regions`** | Region[] | Inferred document zones (header/footer/paragraph/etc). | -| **`structuredData`**| StructuredData | Inferred entities and key-value fields from OCR output. | +### ScanAndAnalyzeDocumentResponse +- `status`: [`ScanDocumentResponseStatus`](#scandocumentresponsestatus) — Scan status. +- `scannedImages`: `string[]` — Captured and sanitized images. +- `analysis`: [`AnalysisResult`](#analysisresult) — Post-processing result payload. -#### ScanAndAnalyzeDocumentOptions +### Barcode -| Prop | Type | Description | -| ------------------- | -------------------------------------------------------------------- | ----------- | -| **`analysis`** | AnalyzeScannedImagesOptions | Analysis options for post-processing stage. | -| **`croppedImageQuality`** | number | Same as ScanDocumentOptions. | -| **`maxNumDocuments`** | number | Same as ScanDocumentOptions (Android only). | -| **`responseType`** | ResponseType | Same as ScanDocumentOptions. | +- `value`: `string` — Decoded barcode payload. +- `format`: `'aztec' | 'codabar' | 'code39' | 'code93' | 'code128' | 'dataMatrix' | 'ean8' | 'ean13' | 'itf' | 'pdf417' | 'qr' | 'upca' | 'upce' | 'unknown'` — Normalized barcode format. +- `sourceImageIndex`: `number` — Index of source image in `scannedImages`. +### TextBlock -#### ScanAndAnalyzeDocumentResponse +- `text`: `string` — Full OCR text for this page. One block per source image. +- `sourceImageIndex`: `number` — Source image index in `scannedImages`. +- `bbox`: `{ left: number; top: number; width: number; height: number }` — Optional normalized bounding box (`0..1`). +- `lines`: `{ text: string; bbox?: { left: number; top: number; width: number; height: number } }[]` — OCR lines inside the block when available. -| Prop | Type | Description | -| ------------------- | --------------------------------------------------------------------------------- | ----------- | -| **`status`** | ScanDocumentResponseStatus | Scan status. | -| **`scannedImages`** | string[] | Captured and sanitized images. | -| **`analysis`** | AnalysisResult | Post-processing result payload. | +### TableBlock +- `sourceImageIndex`: `number` — Source image index in `scannedImages`. +- `rows`: `string[][]` — Inferred table rows and cell texts. +- `bbox`: `{ left: number; top: number; width: number; height: number }` — Optional normalized table bounds. -#### Barcode +### Region -| Prop | Type | Description | -| ------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| **`value`** | string | Decoded barcode payload. | -| **`format`** | 'aztec' \\| 'codabar' \\| 'code39' \\| 'code93' \\| 'code128' \\| 'dataMatrix' \\| 'ean8' \\| 'ean13' \\| 'itf' \\| 'pdf417' \\| 'qr' \\| 'upca' \\| 'upce' \\| 'unknown' | Normalized barcode format. | -| **`sourceImageIndex`** | number | Index of the source image in `scannedImages` that produced this barcode. | +- `type`: `'header' | 'footer' | 'paragraph' | 'signature' | 'stamp' | 'unknown'` — Inferred region category. +- `sourceImageIndex`: `number` — Source image index in `scannedImages`. +- `bbox`: `{ left: number; top: number; width: number; height: number }` — Normalized region bounds (`0..1`). +- `score`: `number` — Optional heuristic confidence score. -#### TextBlock +### StructuredData -| Prop | Type | Description | -| ------------------- | ----------------------------------------------------------------------------------------- | ----------- | -| **`text`** | string | OCR text content for the block. | -| **`sourceImageIndex`** | number | Source image index in `scannedImages`. | -| **`bbox`** | { left: number; top: number; width: number; height: number } | Optional normalized bounding box (`0..1`). | -| **`lines`** | { text: string; bbox?: { left: number; top: number; width: number; height: number } }[] | OCR lines inside the block when available. | - -#### TableBlock - -| Prop | Type | Description | -| ------------------- | ----------------------------------------------------------------------------------------- | ----------- | -| **`sourceImageIndex`** | number | Source image index in `scannedImages`. | -| **`rows`** | string[][] | Inferred table rows and cell texts. | -| **`bbox`** | { left: number; top: number; width: number; height: number } | Optional normalized table bounds. | - -#### Region - -| Prop | Type | Description | -| ------------------- | ----------------------------------------------------------------------------------------- | ----------- | -| **`type`** | 'header' \\| 'footer' \\| 'paragraph' \\| 'signature' \\| 'stamp' \\| 'unknown' | Inferred region category. | -| **`sourceImageIndex`** | number | Source image index in `scannedImages`. | -| **`bbox`** | { left: number; top: number; width: number; height: number } | Normalized region bounds (`0..1`). | -| **`score`** | number | Optional heuristic confidence score. | - -#### StructuredData - -| Prop | Type | Description | -| ------------------- | ----------------------------------------------------------------------------------------- | ----------- | -| **`entities`** | { type: 'phone' \\| 'email' \\| 'date' \\| 'amount' \\| 'id' \\| 'unknown'; value: string; sourceImageIndex: number }[] | Inferred typed entities. | -| **`fields`** | { key: string; value: string }[] | Inferred key-value fields from OCR lines. | +- `entities`: `{ type: 'phone' | 'email' | 'date' | 'amount' | 'id' | 'unknown'; value: string; sourceImageIndex: number }[]` — Inferred typed entities. +- `fields`: `{ key: string; value: string }[]` — Inferred key-value fields from OCR lines. Normalization guarantees: - `date` values are normalized to ISO-like `YYYY-MM-DD` when parser confidence is sufficient. -- `amount` values are normalized to `CODE value` (for example `USD 1234.56`) when currency can be inferred, otherwise numeric normalized form is returned. -- `phone` values are normalized to compact digit form (`+` prefix preserved when present in source). -- `email` values are lowercased, `id` values are uppercased/compacted. +- `amount` values are normalized to `CODE value` when currency can be inferred. +- `phone` values are normalized to compact digit form, preserving `+` when present. +- `email` values are lowercased. +- `id` values are uppercased and compacted. - `fields[].key` is sanitized to lowercase snake-like form on both platforms. Best-effort quality note: -- Output shape is stable across platforms, but extraction quality can differ by OS/runtime capabilities (for example iOS modern `RecognizeDocumentsRequest` + data detectors vs regex heuristics on older/other paths). - - -### Enums +- Output shape is stable across platforms. +- Extraction quality may still vary depending on OS/runtime capabilities. +## Enums -#### ScanDocumentResponseStatus +### ScanDocumentResponseStatus -| Members | Value | Description | -| ------------- | ---------------------- | --------------------------------------------------------------------------------------------------------- | -| **`Success`** | 'success' | The status comes back as success if the document scan completes successfully. | -| **`Cancel`** | 'cancel' | The status comes back as cancel if the user closes out of the camera before completing the document scan. | +| Member | Value | Description | +| :-------------- | :---------------- | :------------------------------------------------- | +| `Success` | `'success'` | Scan completed successfully. | +| `Cancel` | `'cancel'` | User closed the scanner before completing the flow.| +### ResponseType -#### ResponseType +| Member | Value | Description | +| :-------------- | :---------------- | :----------------------------------------------- | +| `Base64` | `'base64'` | Return scanned images as base64 strings. | +| `ImageFilePath` | `'imageFilePath'` | Return scanned images as image file paths. | -| Members | Value | Description | -| ------------------- | ---------------------------- | ------------------------------------------------------------------------------- | -| **`Base64`** | 'base64' | Use this response type if you want document scan returned as base64 images. | -| **`ImageFilePath`** | 'imageFilePath' | Use this response type if you want document scan returned as inmage file paths. | +## Common mistakes +### Android Camera Permissions -## Common Mistakes - -* [Android Camera Permissions](#android-camera-permissions) +You do not need to request camera permissions unless another camera plugin adds: -### Android Camera Permissions +```xml + +``` -You don't need to request camera permissions unless you're using another camera plugin that adds `` to the application's `AndroidManifest.xml`. +If that permission is present and not granted, you can get errors like: -In that case if you don't request camera permissions you get this error -`Error: error - error opening camera: Permission Denial: starting Intent { act=android.media.action.IMAGE_CAPTURE` +```txt +Error: error - error opening camera: Permission Denial: starting Intent { act=android.media.action.IMAGE_CAPTURE +``` -Here's an example of how to request camera permissions. +Example permission request flow: -```javascript -import React, { useState, useEffect } from 'react' -import { Platform, PermissionsAndroid, Image, Alert } from 'react-native' +```tsx +import React, { useEffect, useState } from 'react' +import { Alert, Image, PermissionsAndroid, Platform } from 'react-native' import DocumentScanner from '@preeternal/react-native-document-scanner-plugin' -export default () => { - const [scannedImage, setScannedImage] = useState(); - - const scanDocument = async () => { - // prompt user to accept camera permission request if they haven't already - if (Platform.OS === 'android' && await PermissionsAndroid.request( - PermissionsAndroid.PERMISSIONS.CAMERA - ) !== PermissionsAndroid.RESULTS.GRANTED) { - Alert.alert('Error', 'User must grant camera permissions to use document scanner.') - return - } +export default function App() { + const [image, setImage] = useState() - // start the document scanner - const { scannedImages } = await DocumentScanner.scanDocument() - - // get back an array with scanned image file paths - if (scannedImages.length > 0) { - // set the img src, so we can view the first scanned image - setScannedImage(scannedImages[0]) + useEffect(() => { + const run = async () => { + if ( + Platform.OS === 'android' && + (await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.CAMERA)) !== + PermissionsAndroid.RESULTS.GRANTED + ) { + Alert.alert('Error', 'User must grant camera permissions to use document scanner.') + return + } + + const { status, scannedImages } = await DocumentScanner.scanDocument() + + if (status === 'success' && scannedImages.length > 0) { + setImage(scannedImages[0]) + } } - } - useEffect(() => { - // call scanDocument on load - scanDocument() - }, []); + run() + }, []) return ( ) } @@ -569,33 +674,51 @@ export default () => { ## Migrating between upstream and this fork -Both packages are compatible for the core scan flow (`scanDocument(...)`). To switch: +Both packages are compatible for the core scan flow (`scanDocument(...)`). + +### From this fork to upstream + +```bash +yarn remove @preeternal/react-native-document-scanner-plugin +yarn add react-native-document-scanner-plugin +cd ios && pod install && cd - +``` + +### From upstream to this fork + +```bash +yarn remove react-native-document-scanner-plugin +yarn add @preeternal/react-native-document-scanner-plugin +cd ios && pod install && cd - +``` + +## Roadmap direction -- **From this fork to upstream** - ```bash - yarn remove @preeternal/react-native-document-scanner-plugin - yarn add react-native-document-scanner-plugin - cd ios && pod install && cd - - ``` +The current direction is to keep the package strong in the **capture + analysis** layer rather than turning it into a full document platform. -- **From upstream to this fork** - ```bash - yarn remove react-native-document-scanner-plugin - yarn add @preeternal/react-native-document-scanner-plugin - cd ios && pod install && cd - - ``` +Areas that fit this package especially well: + +- multi-page document flows +- image normalization and cleanup +- richer structured result payloads +- barcode / OCR / semantics as focused post-processing stages +- integration examples for upload, intake, logistics, and document workflows ## Contributing - [Development workflow](CONTRIBUTING.md#development-workflow) +- [Native debug logs (iOS)](CONTRIBUTING.md#native-debug-logs-ios) +- [Native debug logs (Android)](CONTRIBUTING.md#native-debug-logs-android) - [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request) - [Code of conduct](CODE_OF_CONDUCT.md) ## Credits -This project builds on the excellent work by [WebsiteBeaver/react-native-document-scanner-plugin](https://github.com/WebsiteBeaver/react-native-document-scanner-plugin). The original repository is MIT‑licensed; original copyright notices are preserved in this fork’s LICENSE. +This project builds on the excellent work by [WebsiteBeaver/react-native-document-scanner-plugin](https://github.com/WebsiteBeaver/react-native-document-scanner-plugin). +The original repository is MIT-licensed, and original copyright notices are preserved in this fork’s LICENSE. Barcode extraction logic in this repository was adapted from: + - [VictorAugustoDn/react-native-concluir-guias](https://github.com/VictorAugustoDn/react-native-concluir-guias) ## License diff --git a/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt b/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt index 7aa74a4..e34bb91 100644 --- a/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt +++ b/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt @@ -4,16 +4,19 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.Matrix import android.graphics.Rect +import com.google.android.gms.tasks.Tasks import com.google.mlkit.vision.barcode.BarcodeScanner import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage +import com.preeternal.scanner.DocScannerDebugLog import kotlin.math.floor import kotlin.math.roundToInt class BarcodeExtractorImpl : BarcodeExtractor { companion object { + private const val TAG = "DocumentScannerBarcode" private const val CROP_WIDTH_PERCENT = 25.0f private const val CROP_HEIGHT_PERCENT = 20.0f private const val CORNER_MARGIN_PERCENT = 3.0f @@ -38,6 +41,18 @@ class BarcodeExtractorImpl : BarcodeExtractor { private val scannerCache = mutableMapOf() + private fun logDebug(message: String) { + DocScannerDebugLog.debug(TAG, message) + } + + private fun logWarn(message: String) { + DocScannerDebugLog.warn(TAG, message) + } + + private fun logTrace(message: String) { + DocScannerDebugLog.trace(TAG, message) + } + private data class BarcodeCandidate( val dedupKey: String, val result: BarcodeResult @@ -54,119 +69,107 @@ class BarcodeExtractorImpl : BarcodeExtractor { ) { val normalizedAllowList = BarcodeFormats.normalizeRequestedFormats(allowedFormats.toList()) val sourceBitmap = BarcodeImageSourceLoader.loadBitmap(context, imageSource) ?: run { + logDebug("extractFromSource index=$sourceImageIndex image load failed sourceLength=${imageSource.length}") callback(emptyList()) return } + logDebug( + "extractFromSource index=$sourceImageIndex bitmap=${sourceBitmap.width}x${sourceBitmap.height} allowedFormats=${normalizedAllowList.sorted()}" + ) val scanner = scannerForAllowedFormats(normalizedAllowList) - processAttempt( + val detected = processAttempts( scanner = scanner, sourceBitmap = sourceBitmap, sourceImageIndex = sourceImageIndex, - allowedFormats = normalizedAllowList, - attemptIndex = 0, - collected = LinkedHashMap(), - callback = callback + allowedFormats = normalizedAllowList ) + callback(detected) } - private fun processAttempt( + private fun processAttempts( scanner: BarcodeScanner, sourceBitmap: Bitmap, sourceImageIndex: Int, - allowedFormats: Set, - attemptIndex: Int, - collected: LinkedHashMap, - callback: (List) -> Unit - ) { - if (attemptIndex >= 4) { - callback(collected.values.toList()) - return - } + allowedFormats: Set + ): List { + val collected = LinkedHashMap() + var exhaustedAttempts = true + + for (attemptIndex in 0 until 8) { + if (attemptIndex > 0 && collected.isNotEmpty()) { + logDebug("attempt=$attemptIndex stop: early success collected=${collected.size}") + exhaustedAttempts = false + break + } - if (attemptIndex > 0 && collected.isNotEmpty()) { - callback(collected.values.toList()) - return - } + val rotationIndex = attemptIndex % 4 + val useRoi = attemptIndex < 4 - val angle = when (attemptIndex) { - 0 -> 0f - 1 -> 180f - 2 -> 90f - else -> -90f - } + val angle = when (rotationIndex) { + 0 -> 0f + 1 -> 180f + 2 -> 90f + else -> -90f + } - val candidate = if (angle == 0f) { - sourceBitmap - } else { - rotateBitmap(sourceBitmap, angle) - } + val candidate = if (angle == 0f) { + sourceBitmap + } else { + rotateBitmap(sourceBitmap, angle) + } - if (candidate == null) { - processAttempt( - scanner, - sourceBitmap, - sourceImageIndex, - allowedFormats, - attemptIndex + 1, - collected, - callback - ) - return - } + if (candidate == null) { + logTrace("attempt=$attemptIndex rotate failed angle=$angle") + continue + } - val roiBitmap = cropTopRightRoi(candidate) - if (roiBitmap == null) { - processAttempt( - scanner, - sourceBitmap, - sourceImageIndex, - allowedFormats, - attemptIndex + 1, - collected, - callback - ) - return - } + val targetBitmap = if (useRoi) { + cropTopRightRoi(candidate) + } else { + candidate + } - val input = InputImage.fromBitmap(roiBitmap, 0) - scanner.process(input) - .addOnSuccessListener { barcodes -> - val mapped = barcodesToCandidates( - barcodes = barcodes, - sourceImageIndex = sourceImageIndex, - allowedFormats = allowedFormats, - attemptIndex = attemptIndex, - roiWidth = roiBitmap.width, - roiHeight = roiBitmap.height - ) - for (candidateResult in mapped) { - if (!collected.containsKey(candidateResult.dedupKey)) { - collected[candidateResult.dedupKey] = candidateResult.result - } - } + if (targetBitmap == null) { + logTrace("attempt=$attemptIndex target bitmap null useRoi=$useRoi") + continue + } - processAttempt( - scanner, - sourceBitmap, - sourceImageIndex, - allowedFormats, - attemptIndex + 1, - collected, - callback - ) + logTrace( + "attempt=$attemptIndex useRoi=$useRoi angle=$angle target=${targetBitmap.width}x${targetBitmap.height} collectedBefore=${collected.size}" + ) + + val input = InputImage.fromBitmap(targetBitmap, 0) + val barcodes = try { + Tasks.await(scanner.process(input)) + } catch (error: Exception) { + logWarn("attempt=$attemptIndex failure: ${error.message}") + continue } - .addOnFailureListener { - processAttempt( - scanner, - sourceBitmap, - sourceImageIndex, - allowedFormats, - attemptIndex + 1, - collected, - callback - ) + + val mapped = barcodesToCandidates( + barcodes = barcodes, + sourceImageIndex = sourceImageIndex, + allowedFormats = allowedFormats, + attemptIndex = attemptIndex, + roiWidth = targetBitmap.width, + roiHeight = targetBitmap.height + ) + logTrace( + "attempt=$attemptIndex success rawBarcodes=${barcodes.size} mapped=${mapped.size}" + ) + for (candidateResult in mapped) { + if (!collected.containsKey(candidateResult.dedupKey)) { + collected[candidateResult.dedupKey] = candidateResult.result + } } + logTrace("attempt=$attemptIndex collectedAfter=${collected.size}") + } + + if (exhaustedAttempts) { + logDebug("attempt=8 stop: max attempts reached collected=${collected.size}") + } + return collected.values.toList() } private fun barcodesToCandidates( @@ -178,15 +181,19 @@ class BarcodeExtractorImpl : BarcodeExtractor { roiHeight: Int ): List { val dedup = LinkedHashMap() + var filteredEmpty = 0 + var filteredAllow = 0 for (barcode in barcodes) { val value = barcode.rawValue?.trim() if (value.isNullOrEmpty()) { + filteredEmpty += 1 continue } val format = normalizeFormat(barcode.format) if (allowedFormats.isNotEmpty() && !allowedFormats.contains(format)) { + filteredAllow += 1 continue } @@ -210,6 +217,10 @@ class BarcodeExtractorImpl : BarcodeExtractor { } } + logTrace( + "attempt=$attemptIndex candidates dedup=${dedup.size} filteredEmpty=$filteredEmpty filteredAllow=$filteredAllow" + ) + return dedup.values.toList() } @@ -294,12 +305,18 @@ class BarcodeExtractorImpl : BarcodeExtractor { val finalHeight = bottom - top if (finalWidth <= 1 || finalHeight <= 1) { + logTrace("cropTopRightRoi invalid final size ${finalWidth}x${finalHeight}") return null } + logTrace( + "cropTopRightRoi source=${width}x$height rect=($left,$top)-($right,$bottom)" + ) + return try { Bitmap.createBitmap(bitmap, left, top, finalWidth, finalHeight) } catch (_: IllegalArgumentException) { + logWarn("cropTopRightRoi createBitmap failed") null } } @@ -312,6 +329,7 @@ class BarcodeExtractorImpl : BarcodeExtractor { return try { Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true) } catch (_: Exception) { + logWarn("rotateBitmap failed angle=$angle") null } } @@ -334,7 +352,22 @@ class BarcodeExtractorImpl : BarcodeExtractor { val rest = requestedFormats.drop(1).toIntArray() optionsBuilder.setBarcodeFormats(first, *rest) } + logDebug( + "createScanner allowedFormats=${allowedFormats.sorted()} mlkitFormats=${requestedFormats.sorted()}" + ) return BarcodeScanning.getClient(optionsBuilder.build()) } + + override fun release() { + for ((key, scanner) in scannerCache) { + runCatching { + scanner.close() + }.onFailure { error -> + logWarn("release scanner key=$key failed: ${error.message}") + } + } + scannerCache.clear() + logDebug("release scanner cache cleared") + } } diff --git a/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeImageSourceLoader.kt b/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeImageSourceLoader.kt index a72bea6..44029cf 100644 --- a/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeImageSourceLoader.kt +++ b/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeImageSourceLoader.kt @@ -7,39 +7,63 @@ import android.graphics.Matrix import android.net.Uri import android.util.Base64 import androidx.exifinterface.media.ExifInterface +import com.preeternal.scanner.DocScannerDebugLog import java.io.File import java.io.FileInputStream import java.io.InputStream internal object BarcodeImageSourceLoader { + private const val TAG = "DocumentScannerBarcode" private const val MAX_DECODE_DIMENSION = 2048 private val base64Regex = Regex("^[A-Za-z0-9+/=\\s]+$") + private fun logDebug(message: String) { + DocScannerDebugLog.debug(TAG, message) + } + + private fun logWarn(message: String) { + DocScannerDebugLog.warn(TAG, message) + } + fun loadBitmap(context: Context, imageSource: String): Bitmap? { val normalized = imageSource.trim() if (normalized.isEmpty()) { + logDebug("loadBitmap empty source") return null } + logDebug( + "loadBitmap start length=${normalized.length} scheme=${Uri.parse(normalized).scheme ?: "path-or-base64"}" + ) if (normalized.startsWith("data:", ignoreCase = true) && normalized.contains("base64,")) { val payload = normalized.substringAfter("base64,", "") - return decodeFromBase64(payload) + return decodeFromBase64(payload)?.also { + logDebug("loadBitmap decoded data: base64 size=${it.width}x${it.height}") + } } if (normalized.startsWith("content://", ignoreCase = true)) { - return loadFromUri(context, Uri.parse(normalized)) + return loadFromUri(context, Uri.parse(normalized))?.also { + logDebug("loadBitmap decoded content:// size=${it.width}x${it.height}") + } } if (normalized.startsWith("file://", ignoreCase = true)) { - return loadFromFilePath(resolveFilePathFromUri(Uri.parse(normalized))) + return loadFromFilePath(resolveFilePathFromUri(Uri.parse(normalized)))?.also { + logDebug("loadBitmap decoded file:// size=${it.width}x${it.height}") + } } if (File(normalized).exists()) { - return loadFromFilePath(normalized) + return loadFromFilePath(normalized)?.also { + logDebug("loadBitmap decoded path size=${it.width}x${it.height}") + } } if (looksLikeBase64(normalized)) { - return decodeFromBase64(normalized) + return decodeFromBase64(normalized)?.also { + logDebug("loadBitmap decoded plain-base64 size=${it.width}x${it.height}") + } } val parsed = Uri.parse(normalized) @@ -48,6 +72,8 @@ internal object BarcodeImageSourceLoader { "file" -> loadFromFilePath(resolveFilePathFromUri(parsed)) null, "" -> loadFromFilePath(normalized) else -> loadFromUri(context, parsed) + }?.also { + logDebug("loadBitmap decoded fallback size=${it.width}x${it.height}") } } @@ -56,6 +82,7 @@ internal object BarcodeImageSourceLoader { val decoded = Base64.decode(payload, Base64.DEFAULT) decodeSampledBitmap(decoded) } catch (_: IllegalArgumentException) { + logWarn("decodeFromBase64 failed") null } } @@ -70,6 +97,7 @@ internal object BarcodeImageSourceLoader { rotateBitmapIfRequired(bitmap, orientation) } catch (_: Exception) { + logWarn("loadFromUri failed uri=$uri") null } } @@ -89,6 +117,7 @@ internal object BarcodeImageSourceLoader { val file = File(candidatePath) if (!file.exists() || !file.isFile) { + logDebug("loadFromFilePath missing file path=$candidatePath") return null } @@ -100,6 +129,7 @@ internal object BarcodeImageSourceLoader { rotateBitmapIfRequired(bitmap, orientation) } catch (_: Exception) { + logWarn("loadFromFilePath failed path=$candidatePath") null } } @@ -120,6 +150,7 @@ internal object BarcodeImageSourceLoader { BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) if (bounds.outWidth <= 0 || bounds.outHeight <= 0) { + logDebug("decodeSampledBitmap invalid bounds") return null } @@ -170,6 +201,7 @@ internal object BarcodeImageSourceLoader { return try { Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true) } catch (_: Exception) { + logWarn("rotateBitmap failed angle=$angle") null } } diff --git a/android/src/main/java/com/preeternal/scanner/DocScannerDebugLog.kt b/android/src/main/java/com/preeternal/scanner/DocScannerDebugLog.kt new file mode 100644 index 0000000..d07377b --- /dev/null +++ b/android/src/main/java/com/preeternal/scanner/DocScannerDebugLog.kt @@ -0,0 +1,83 @@ +package com.preeternal.scanner + +import android.util.Log + +internal object DocScannerDebugLog { + private const val MAIN_TAG = "DocumentScanner" + private const val BARCODE_TAG = "DocumentScannerBarcode" + + private const val DEBUG_ENV = "DOCUMENT_SCANNER_DEBUG_LOGS" + private const val TRACE_ENV = "DOCUMENT_SCANNER_TRACE_LOGS" + + private const val DEBUG_PROP = "debug.document_scanner.debug_logs" + private const val TRACE_PROP = "debug.document_scanner.trace_logs" + + private data class FixedFlags( + val debugEnabled: Boolean, + val traceEnabled: Boolean + ) + + // Resolve env/system-property flags once to avoid reflection in hot logging paths. + private val fixedFlags: FixedFlags by lazy(LazyThreadSafetyMode.PUBLICATION) { + val debug = flagEnabled(DEBUG_ENV) || propertyEnabled(DEBUG_PROP) + val trace = flagEnabled(TRACE_ENV) || propertyEnabled(TRACE_PROP) + FixedFlags( + debugEnabled = debug || trace, + traceEnabled = trace + ) + } + + fun debug(tag: String, message: String) { + if (isDebugEnabled()) { + Log.d(tag, message) + } + } + + fun warn(tag: String, message: String) { + if (isDebugEnabled()) { + Log.w(tag, message) + } + } + + fun trace(tag: String, message: String) { + if (isTraceEnabled()) { + Log.v(tag, message) + } + } + + private fun isDebugEnabled(): Boolean { + return fixedFlags.debugEnabled || + Log.isLoggable(MAIN_TAG, Log.DEBUG) || + Log.isLoggable(BARCODE_TAG, Log.DEBUG) + } + + private fun isTraceEnabled(): Boolean { + return fixedFlags.traceEnabled || + Log.isLoggable(MAIN_TAG, Log.VERBOSE) || + Log.isLoggable(BARCODE_TAG, Log.VERBOSE) + } + + private fun flagEnabled(name: String): Boolean { + val value = System.getenv(name) + return isTruthy(value) + } + + private fun propertyEnabled(name: String): Boolean { + val value = readSystemProperty(name) + return isTruthy(value) + } + + private fun isTruthy(value: String?): Boolean { + return value == "1" || value.equals("true", ignoreCase = true) + } + + private fun readSystemProperty(name: String): String? { + return try { + val clazz = Class.forName("android.os.SystemProperties") + val method = clazz.getMethod("get", String::class.java) + method.invoke(null, name) as? String + } catch (_: Exception) { + null + } + } +} diff --git a/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt b/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt index 467e860..d1afa70 100644 --- a/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt +++ b/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt @@ -6,7 +6,6 @@ import android.graphics.BitmapFactory import android.net.Uri import android.os.Build import android.util.Base64 -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest @@ -65,6 +64,8 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : private const val ANDROID_15_API = 35 private const val BARCODE_EXTRACTION_TIMEOUT_MS = 10_000L private const val TEXT_EXTRACTION_TIMEOUT_MS = 25_000L + private const val MIN_EXTRACTION_TIMEOUT_MS = 1_000L + private const val MAX_EXTRACTION_TIMEOUT_MS = 120_000L } override fun getName(): String = NAME @@ -86,6 +87,7 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : ) private var launcher: ActivityResultLauncher? = null + private val launcherInitLock = Any() private var pendingPromise: Promise? = null private var pendingOptions: ReadableMap? = null private var pendingQuality: Int = 100 @@ -94,7 +96,16 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : private var hostActivityRef: WeakReference? = null private var previousFitsSystemWindows: Boolean? = null + private fun logDebug(message: String) { + DocScannerDebugLog.debug(NAME, message) + } + + private fun logWarn(message: String) { + DocScannerDebugLog.warn(NAME, message) + } + override fun scanDocument(options: ReadableMap, promise: Promise) { + logDebug("scanDocument invoked") val activity = currentActivity if (activity == null) { promise.reject("no_activity", "Activity not available") @@ -113,6 +124,9 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : pendingPromise = promise pendingOptions = options pendingQuality = if (options.hasKey("croppedImageQuality")) options.getInt("croppedImageQuality") else 100 + logDebug( + "scanDocument options responseType=${getStringOrNull(options, "responseType") ?: "default"} croppedImageQuality=$pendingQuality" + ) hostActivityRef = WeakReference(componentActivity) ensureSystemBarsVisible(componentActivity) @@ -123,8 +137,10 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : } override fun extractBarcodesFromImages(options: ReadableMap, promise: Promise) { + logDebug("extractBarcodesFromImages invoked") val images = getArrayOrNull(options, "images") if (images == null || images.size() == 0) { + logDebug("extractBarcodesFromImages no images, resolve []") promise.resolve(WritableNativeArray()) return } @@ -140,9 +156,17 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : val allowedFormats = parseAllowedFormats(getArrayOrNull(options, "barcodeFormats")) val requestedConcurrency = getIntOrNull(options, "concurrency") ?: 2 val concurrency = requestedConcurrency.coerceIn(1, 2) + val timeoutMs = resolveTimeoutMs( + getIntOrNull(options, "barcodeTimeoutMs"), + BARCODE_EXTRACTION_TIMEOUT_MS + ) val validSources = buildValidImageSources(images) + logDebug( + "extractBarcodesFromImages images=${images.size()} validSources=${validSources.size} concurrency=$concurrency timeoutMs=$timeoutMs allowedFormats=${allowedFormats.sorted()}" + ) if (validSources.isEmpty()) { + logDebug("extractBarcodesFromImages no valid sources, resolve []") promise.resolve(WritableNativeArray()) return } @@ -153,22 +177,28 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : context = reactApplicationContext, sources = validSources, allowedFormats = allowedFormats, - concurrency = concurrency + concurrency = concurrency, + timeoutMs = timeoutMs ) val payload = toWritableBarcodeArray(extracted) + logDebug("extractBarcodesFromImages resolved barcodes=${extracted.size}") resolveOnUi(promise, payload) } catch (cancelled: CancellationException) { + logWarn("extractBarcodesFromImages cancelled: ${cancelled.message}") rejectOnUi(promise, "barcode_extraction_cancelled", "Barcode extraction cancelled", cancelled) } catch (error: Exception) { + logWarn("extractBarcodesFromImages error: ${error.message}") rejectOnUi(promise, "barcode_extraction_error", error.message ?: "Barcode extraction failed", error) } } } override fun extractTextFromImages(options: ReadableMap, promise: Promise) { + logDebug("extractTextFromImages invoked") val images = getArrayOrNull(options, "images") if (images == null || images.size() == 0) { + logDebug("extractTextFromImages no images, resolve []") promise.resolve(WritableNativeArray()) return } @@ -184,9 +214,17 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : val requestedConcurrency = getIntOrNull(options, "concurrency") ?: 2 val concurrency = requestedConcurrency.coerceIn(1, 2) val ocrRotate180Fallback = getBooleanOrNull(options, "ocrRotate180Fallback") ?: false + val timeoutMs = resolveTimeoutMs( + getIntOrNull(options, "textTimeoutMs"), + TEXT_EXTRACTION_TIMEOUT_MS + ) val validSources = buildValidImageSources(images) + logDebug( + "extractTextFromImages images=${images.size()} validSources=${validSources.size} concurrency=$concurrency timeoutMs=$timeoutMs rotate180=$ocrRotate180Fallback" + ) if (validSources.isEmpty()) { + logDebug("extractTextFromImages no valid sources, resolve []") promise.resolve(WritableNativeArray()) return } @@ -197,24 +235,30 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : context = reactApplicationContext, sources = validSources, concurrency = concurrency, + timeoutMs = timeoutMs, ocrRotate180Fallback = ocrRotate180Fallback ) val payload = toWritableTextBlockArray(extracted) + logDebug("extractTextFromImages resolved textBlocks=${extracted.size}") resolveOnUi(promise, payload) } catch (cancelled: CancellationException) { + logWarn("extractTextFromImages cancelled: ${cancelled.message}") rejectOnUi(promise, "text_extraction_cancelled", "Text extraction cancelled", cancelled) } catch (error: Exception) { + logWarn("extractTextFromImages error: ${error.message}") rejectOnUi(promise, "text_extraction_error", error.message ?: "Text extraction failed", error) } } } override fun analyzeScannedImages(options: ReadableMap, promise: Promise) { + logDebug("analyzeScannedImages invoked") val images = getArrayOrNull(options, "images") if (images == null || images.size() == 0) { val empty = WritableNativeMap() empty.putString("status", "success") + logDebug("analyzeScannedImages no images, resolve success") promise.resolve(empty) return } @@ -225,10 +269,14 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : val wantsRegions = getBooleanOrNull(options, "extractRegions") ?: false val wantsStructuredData = getBooleanOrNull(options, "extractStructuredData") ?: false val wantsTextPipeline = wantsText || wantsTables || wantsRegions || wantsStructuredData + logDebug( + "analyzeScannedImages flags barcodes=$wantsBarcodes text=$wantsText tables=$wantsTables regions=$wantsRegions structured=$wantsStructuredData" + ) if (!wantsBarcodes && !wantsTextPipeline) { val empty = WritableNativeMap() empty.putString("status", "success") + logDebug("analyzeScannedImages nothing requested, resolve success") promise.resolve(empty) return } @@ -236,12 +284,24 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : val allowedFormats = parseAllowedFormats(getArrayOrNull(options, "barcodeFormats")) val requestedConcurrency = getIntOrNull(options, "concurrency") ?: 2 val concurrency = requestedConcurrency.coerceIn(1, 2) - val ocrRotate180Fallback = getBooleanOrNull(options, "ocrRotate180Fallback") ?: false + val ocrRotate180Fallback = getBooleanOrNull(options, "ocrRotate180Fallback") ?: true + val barcodeTimeoutMs = resolveTimeoutMs( + getIntOrNull(options, "barcodeTimeoutMs"), + BARCODE_EXTRACTION_TIMEOUT_MS + ) + val textTimeoutMs = resolveTimeoutMs( + getIntOrNull(options, "textTimeoutMs"), + TEXT_EXTRACTION_TIMEOUT_MS + ) val validSources = buildValidImageSources(images) + logDebug( + "analyzeScannedImages images=${images.size()} validSources=${validSources.size} concurrency=$concurrency barcodeTimeoutMs=$barcodeTimeoutMs textTimeoutMs=$textTimeoutMs rotate180=$ocrRotate180Fallback allowedFormats=${allowedFormats.sorted()}" + ) if (validSources.isEmpty()) { val empty = WritableNativeMap() empty.putString("status", "success") + logDebug("analyzeScannedImages no valid sources, resolve success") promise.resolve(empty) return } @@ -254,7 +314,8 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : wantsStage = wantsBarcodes, sources = validSources, allowedFormats = allowedFormats, - concurrency = concurrency + concurrency = concurrency, + timeoutMs = barcodeTimeoutMs ) } @@ -263,6 +324,7 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : wantsStage = wantsTextPipeline, sources = validSources, concurrency = concurrency, + timeoutMs = textTimeoutMs, ocrRotate180Fallback = ocrRotate180Fallback ) } @@ -275,6 +337,9 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : "status", mergeAnalysisStageStatuses(listOf(barcodeStage.status, textStage.status)) ) + logDebug( + "analyzeScannedImages stage status barcode=${barcodeStage.status} text=${textStage.status}" + ) if (barcodeStage.status == AnalysisStageStatus.SUCCESS) { result.putArray("barcodes", toWritableBarcodeArray(barcodeStage.value ?: emptyList())) @@ -314,9 +379,14 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : } resolveOnUi(promise, result) + logDebug( + "analyzeScannedImages resolved status=${result.getString("status")} barcodes=${if (barcodeStage.status == AnalysisStageStatus.SUCCESS) (barcodeStage.value ?: emptyList()).size else 0} textBlocks=${textBlocks.size}" + ) } catch (cancelled: CancellationException) { + logWarn("analyzeScannedImages cancelled: ${cancelled.message}") rejectOnUi(promise, "analysis_cancelled", "Image analysis cancelled", cancelled) } catch (error: Exception) { + logWarn("analyzeScannedImages error: ${error.message}") rejectOnUi(promise, "analysis_error", error.message ?: "Image analysis failed", error) } } @@ -334,43 +404,45 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : } private fun initLauncher(activity: ComponentActivity) { - if (launcher != null) return - launcher = activity.activityResultRegistry.register( - "document-scanner", - ActivityResultContracts.StartIntentSenderForResult() - ) { result -> - val promise = pendingPromise ?: return@register - val options = pendingOptions - val response = WritableNativeMap() - val images = WritableNativeArray() - - if (result.resultCode == Activity.RESULT_OK) { - val docResult = GmsDocumentScanningResult.fromActivityResultIntent(result.data) - val pages = docResult?.pages.orEmpty() - val responseType = options?.getString("responseType")?.lowercase() - - processPages( - activity = activity, - pages = pages, - pageIndex = 0, - responseType = responseType, - images = images, - onError = { errorMessage -> - promise.reject("document_scan_error", errorMessage, null) - clearPending() - }, - onComplete = { - response.putString("status", "success") - response.putArray("scannedImages", images) - promise.resolve(response) - clearPending() - } - ) - } else { - response.putString("status", "cancel") - response.putArray("scannedImages", images) - promise.resolve(response) - clearPending() + synchronized(launcherInitLock) { + if (launcher != null) return + launcher = activity.activityResultRegistry.register( + "document-scanner", + ActivityResultContracts.StartIntentSenderForResult() + ) { result -> + val promise = pendingPromise ?: return@register + val options = pendingOptions + val response = WritableNativeMap() + val images = WritableNativeArray() + + if (result.resultCode == Activity.RESULT_OK) { + val docResult = GmsDocumentScanningResult.fromActivityResultIntent(result.data) + val pages = docResult?.pages.orEmpty() + val responseType = options?.getString("responseType")?.lowercase() + + processPages( + activity = activity, + pages = pages, + pageIndex = 0, + responseType = responseType, + images = images, + onError = { errorMessage -> + promise.reject("document_scan_error", errorMessage, null) + clearPending() + }, + onComplete = { + response.putString("status", "success") + response.putArray("scannedImages", images) + promise.resolve(response) + clearPending() + } + ) + } else { + response.putString("status", "cancel") + response.putArray("scannedImages", images) + promise.resolve(response) + clearPending() + } } } } @@ -384,43 +456,23 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : onError: (String) -> Unit, onComplete: () -> Unit ) { - if (pageIndex >= pages.size) { - onComplete() - return - } - - val uri = pages[pageIndex].imageUri - - val outputImage = try { - mapOutputImage(activity, uri, responseType) - } catch (e: FileNotFoundException) { - onError(e.message ?: "Unable to read scanned image") - return - } + var currentIndex = pageIndex + while (currentIndex < pages.size) { + val uri = pages[currentIndex].imageUri + val outputImage = try { + mapOutputImage(activity, uri, responseType) + } catch (e: FileNotFoundException) { + onError(e.message ?: "Unable to read scanned image") + return + } - if (outputImage == null || outputImage.isBlank()) { - processPages( - activity = activity, - pages = pages, - pageIndex = pageIndex + 1, - responseType = responseType, - images = images, - onError = onError, - onComplete = onComplete - ) - return + if (!outputImage.isNullOrBlank()) { + images.pushString(outputImage) + } + currentIndex += 1 } - images.pushString(outputImage) - processPages( - activity = activity, - pages = pages, - pageIndex = pageIndex + 1, - responseType = responseType, - images = images, - onError = onError, - onComplete = onComplete - ) + onComplete() } private fun mapOutputImage( @@ -504,8 +556,11 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : imageSource = imageSource ) ) + } else { + logDebug("buildValidImageSources skip source[$index] empty or invalid") } } + logDebug("buildValidImageSources mapped=${validSources.size} from raw=${images.size()}") return validSources } @@ -513,8 +568,12 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : context: ReactApplicationContext, sources: List, allowedFormats: Set, - concurrency: Int + concurrency: Int, + timeoutMs: Long ): List = coroutineScope { + logDebug( + "extractBarcodesInParallel start sources=${sources.size} concurrency=$concurrency allowedFormats=${allowedFormats.sorted()}" + ) val limiter = Semaphore(concurrency) val tasks = sources.map { source -> @@ -524,22 +583,29 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : context = context, imageSource = source.imageSource, sourceImageIndex = source.sourceImageIndex, - allowedFormats = allowedFormats + allowedFormats = allowedFormats, + timeoutMs = timeoutMs ) } } } - tasks.awaitAll().flatten() + val merged = tasks.awaitAll().flatten() + logDebug("extractBarcodesInParallel completed total=${merged.size}") + merged } private suspend fun extractBarcodesForSource( context: ReactApplicationContext, imageSource: String, sourceImageIndex: Int, - allowedFormats: Set + allowedFormats: Set, + timeoutMs: Long ): List { - val detected = withTimeoutOrNull(BARCODE_EXTRACTION_TIMEOUT_MS) { + logDebug( + "extractBarcodesForSource start index=$sourceImageIndex sourceLength=${imageSource.length} allowedFormats=${allowedFormats.sorted()}" + ) + val detected = withTimeoutOrNull(timeoutMs) { suspendCancellableCoroutine { continuation -> barcodeExtractor.extractFromSource( context = context, @@ -555,13 +621,13 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : } if (detected == null) { - Log.w( - NAME, - "Barcode extraction timed out after ${BARCODE_EXTRACTION_TIMEOUT_MS}ms for sourceImageIndex=$sourceImageIndex" + logWarn( + "Barcode extraction timed out after ${timeoutMs}ms for sourceImageIndex=$sourceImageIndex" ) return emptyList() } + logDebug("extractBarcodesForSource completed index=$sourceImageIndex detected=${detected.size}") return detected } @@ -569,8 +635,12 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : context: ReactApplicationContext, sources: List, concurrency: Int, + timeoutMs: Long, ocrRotate180Fallback: Boolean ): List = coroutineScope { + logDebug( + "extractTextInParallel start sources=${sources.size} concurrency=$concurrency rotate180=$ocrRotate180Fallback" + ) val limiter = Semaphore(concurrency) val tasks = sources.map { source -> @@ -580,22 +650,29 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : context = context, imageSource = source.imageSource, sourceImageIndex = source.sourceImageIndex, + timeoutMs = timeoutMs, ocrRotate180Fallback = ocrRotate180Fallback ) } } } - tasks.awaitAll().flatten() + val merged = tasks.awaitAll().flatten() + logDebug("extractTextInParallel completed total=${merged.size}") + merged } private suspend fun extractTextForSource( context: ReactApplicationContext, imageSource: String, sourceImageIndex: Int, + timeoutMs: Long, ocrRotate180Fallback: Boolean ): List { - return withTimeoutOrNull(TEXT_EXTRACTION_TIMEOUT_MS) { + logDebug( + "extractTextForSource start index=$sourceImageIndex sourceLength=${imageSource.length} rotate180=$ocrRotate180Fallback" + ) + val detected = withTimeoutOrNull(timeoutMs) { suspendCancellableCoroutine { continuation -> textExtractor.extractFromSource( context = context, @@ -608,19 +685,32 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : } } } - } ?: emptyList() + } + + if (detected == null) { + logWarn( + "Text extraction timed out after ${timeoutMs}ms for sourceImageIndex=$sourceImageIndex" + ) + return emptyList() + } + + logDebug("extractTextForSource completed index=$sourceImageIndex detected=${detected.size}") + return detected } private suspend fun runBarcodeAnalysisStage( wantsStage: Boolean, sources: List, allowedFormats: Set, - concurrency: Int + concurrency: Int, + timeoutMs: Long ): AnalysisStageResult> { if (!wantsStage) { + logDebug("runBarcodeAnalysisStage skipped") return AnalysisStageResult(AnalysisStageStatus.SKIPPED) } if (!barcodeExtractor.isFeatureEnabled()) { + logDebug("runBarcodeAnalysisStage not enabled") return AnalysisStageResult(AnalysisStageStatus.NOT_ENABLED) } @@ -629,13 +719,15 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : context = reactApplicationContext, sources = sources, allowedFormats = allowedFormats, - concurrency = concurrency + concurrency = concurrency, + timeoutMs = timeoutMs ) AnalysisStageResult( status = AnalysisStageStatus.SUCCESS, value = value ) - } catch (_: Exception) { + } catch (error: Exception) { + logWarn("runBarcodeAnalysisStage failed: ${error.message}") AnalysisStageResult(AnalysisStageStatus.FAILED) } } @@ -644,12 +736,15 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : wantsStage: Boolean, sources: List, concurrency: Int, + timeoutMs: Long, ocrRotate180Fallback: Boolean ): AnalysisStageResult> { if (!wantsStage) { + logDebug("runTextAnalysisStage skipped") return AnalysisStageResult(AnalysisStageStatus.SKIPPED) } if (!textExtractor.isFeatureEnabled()) { + logDebug("runTextAnalysisStage not enabled") return AnalysisStageResult(AnalysisStageStatus.NOT_ENABLED) } @@ -658,13 +753,15 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : context = reactApplicationContext, sources = sources, concurrency = concurrency, + timeoutMs = timeoutMs, ocrRotate180Fallback = ocrRotate180Fallback ) AnalysisStageResult( status = AnalysisStageStatus.SUCCESS, value = value ) - } catch (_: Exception) { + } catch (error: Exception) { + logWarn("runTextAnalysisStage failed: ${error.message}") AnalysisStageResult(AnalysisStageStatus.FAILED) } } @@ -893,6 +990,11 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : } } + private fun resolveTimeoutMs(rawValue: Int?, fallback: Long): Long { + val candidate = rawValue?.toLong() ?: fallback + return candidate.coerceIn(MIN_EXTRACTION_TIMEOUT_MS, MAX_EXTRACTION_TIMEOUT_MS) + } + private fun getBooleanOrNull(map: ReadableMap, key: String): Boolean? { return try { if (!map.hasKey(key) || map.isNull(key)) { @@ -905,6 +1007,18 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : } } + private fun getStringOrNull(map: ReadableMap, key: String): String? { + return try { + if (!map.hasKey(key) || map.isNull(key)) { + null + } else { + map.getString(key) + } + } catch (_: Exception) { + null + } + } + private fun startScan(activity: Activity) { val currentScanner = scanner if (currentScanner == null) { @@ -947,6 +1061,11 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : override fun invalidate() { super.invalidate() + launcher?.unregister() + launcher = null + scanner = null + barcodeExtractor.release() + clearPending() scope.cancel() } diff --git a/android/src/main/java/com/preeternal/scanner/barcode/BarcodeExtractor.kt b/android/src/main/java/com/preeternal/scanner/barcode/BarcodeExtractor.kt index f0f78e6..f30dfc5 100644 --- a/android/src/main/java/com/preeternal/scanner/barcode/BarcodeExtractor.kt +++ b/android/src/main/java/com/preeternal/scanner/barcode/BarcodeExtractor.kt @@ -17,4 +17,10 @@ interface BarcodeExtractor { allowedFormats: Set, callback: (List) -> Unit ) + + /** + * Releases implementation-specific resources (for example cached ML Kit scanners). + * Default no-op for feature-disabled stubs. + */ + fun release() {} } diff --git a/app.plugin.js b/app.plugin.js new file mode 100644 index 0000000..5412646 --- /dev/null +++ b/app.plugin.js @@ -0,0 +1,84 @@ +const pkg = require('./package.json'); +let configPlugins; +try { + configPlugins = require('@expo/config-plugins'); +} catch (error) { + if (error && error.code !== 'MODULE_NOT_FOUND') { + throw error; + } + configPlugins = require('expo/config-plugins'); +} + +const { createRunOncePlugin } = configPlugins; +const withAndroidGradleProperties = + typeof configPlugins.withAndroidGradleProperties === 'function' + ? configPlugins.withAndroidGradleProperties + : configPlugins.withGradleProperties; + +if (typeof withAndroidGradleProperties !== 'function') { + throw new Error( + `${pkg.name}: incompatible expo config-plugins API (missing Gradle properties helper).` + ); +} + +const VALID_FEATURES = new Set(['barcode', 'text', 'tables']); +const PROPERTY_KEY = 'DocumentScanner_analysisFeatures'; + +function normalizeAnalysisFeatures(raw) { + if (raw == null || raw === '') { + return 'none'; + } + + const value = String(raw).trim().toLowerCase(); + + if (value === 'none') { + return 'none'; + } + + if (value === 'all') { + return 'all'; + } + + const features = value + .split(',') + .map((f) => f.trim()) + .filter(Boolean); + + const invalid = features.filter((f) => !VALID_FEATURES.has(f)); + if (invalid.length > 0) { + throw new Error( + `${pkg.name}: invalid analysisFeatures value(s): ${invalid.join(', ')}. ` + + `Expected comma-separated combination of 'barcode', 'text', 'tables', or 'all' / 'none'.` + ); + } + + return features.join(','); +} + +function withDocumentScannerAnalysisFeatures(config, props = {}) { + const analysisFeatures = normalizeAnalysisFeatures(props.analysisFeatures); + + return withAndroidGradleProperties(config, (mod) => { + const existing = mod.modResults.find( + (item) => item.type === 'property' && item.key === PROPERTY_KEY + ); + + if (existing) { + existing.value = analysisFeatures; + } else { + mod.modResults.push({ + type: 'property', + key: PROPERTY_KEY, + value: analysisFeatures, + }); + } + + return mod; + }); +} + +module.exports = createRunOncePlugin( + withDocumentScannerAnalysisFeatures, + pkg.name, + pkg.version +); diff --git a/example/ios/DocumentScannerExample.xcodeproj/project.pbxproj b/example/ios/DocumentScannerExample.xcodeproj/project.pbxproj index b1ee898..39e000c 100644 --- a/example/ios/DocumentScannerExample.xcodeproj/project.pbxproj +++ b/example/ios/DocumentScannerExample.xcodeproj/project.pbxproj @@ -260,7 +260,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = P5S2WJ5TJS; ENABLE_BITCODE = NO; INFOPLIST_FILE = DocumentScannerExample/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -289,7 +289,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = P5S2WJ5TJS; INFOPLIST_FILE = DocumentScannerExample/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/example/ios/Podfile b/example/ios/Podfile index 4dfdd81..b4c52a7 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -33,5 +33,17 @@ target 'DocumentScannerExample' do :mac_catalyst_enabled => false, # :ccache_enabled => true ) + + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES' + end + + if ['fmt', 'RCT-Folly', 'Yoga'].include?(target.name) + target.build_configurations.each do |config| + config.build_settings['CLANG_CXX_LANGUAGE_STANDARD'] = 'gnu++17' + end + end + end end end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 1d6d59f..3f7bee0 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -26,12 +26,12 @@ PODS: - Yoga - DoubleConversion (1.1.6) - fast_float (6.1.4) - - FBLazyVector (0.79.2) + - FBLazyVector (0.79.7) - fmt (11.0.2) - glog (0.3.5) - - hermes-engine (0.79.2): - - hermes-engine/Pre-built (= 0.79.2) - - hermes-engine/Pre-built (0.79.2) + - hermes-engine (0.79.7): + - hermes-engine/Pre-built (= 0.79.7) + - hermes-engine/Pre-built (0.79.7) - RCT-Folly (2024.11.18.00): - boost - DoubleConversion @@ -51,32 +51,32 @@ PODS: - fast_float (= 6.1.4) - fmt (= 11.0.2) - glog - - RCTDeprecation (0.79.2) - - RCTRequired (0.79.2) - - RCTTypeSafety (0.79.2): - - FBLazyVector (= 0.79.2) - - RCTRequired (= 0.79.2) - - React-Core (= 0.79.2) - - React (0.79.2): - - React-Core (= 0.79.2) - - React-Core/DevSupport (= 0.79.2) - - React-Core/RCTWebSocket (= 0.79.2) - - React-RCTActionSheet (= 0.79.2) - - React-RCTAnimation (= 0.79.2) - - React-RCTBlob (= 0.79.2) - - React-RCTImage (= 0.79.2) - - React-RCTLinking (= 0.79.2) - - React-RCTNetwork (= 0.79.2) - - React-RCTSettings (= 0.79.2) - - React-RCTText (= 0.79.2) - - React-RCTVibration (= 0.79.2) - - React-callinvoker (0.79.2) - - React-Core (0.79.2): + - RCTDeprecation (0.79.7) + - RCTRequired (0.79.7) + - RCTTypeSafety (0.79.7): + - FBLazyVector (= 0.79.7) + - RCTRequired (= 0.79.7) + - React-Core (= 0.79.7) + - React (0.79.7): + - React-Core (= 0.79.7) + - React-Core/DevSupport (= 0.79.7) + - React-Core/RCTWebSocket (= 0.79.7) + - React-RCTActionSheet (= 0.79.7) + - React-RCTAnimation (= 0.79.7) + - React-RCTBlob (= 0.79.7) + - React-RCTImage (= 0.79.7) + - React-RCTLinking (= 0.79.7) + - React-RCTNetwork (= 0.79.7) + - React-RCTSettings (= 0.79.7) + - React-RCTText (= 0.79.7) + - React-RCTVibration (= 0.79.7) + - React-callinvoker (0.79.7) + - React-Core (0.79.7): - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) - RCTDeprecation - - React-Core/Default (= 0.79.2) + - React-Core/Default (= 0.79.7) - React-cxxreact - React-featureflags - React-hermes @@ -89,7 +89,7 @@ PODS: - React-utils - SocketRocket (= 0.7.1) - Yoga - - React-Core/CoreModulesHeaders (0.79.2): + - React-Core/CoreModulesHeaders (0.79.7): - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -107,7 +107,7 @@ PODS: - React-utils - SocketRocket (= 0.7.1) - Yoga - - React-Core/Default (0.79.2): + - React-Core/Default (0.79.7): - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -124,13 +124,13 @@ PODS: - React-utils - SocketRocket (= 0.7.1) - Yoga - - React-Core/DevSupport (0.79.2): + - React-Core/DevSupport (0.79.7): - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) - RCTDeprecation - - React-Core/Default (= 0.79.2) - - React-Core/RCTWebSocket (= 0.79.2) + - React-Core/Default (= 0.79.7) + - React-Core/RCTWebSocket (= 0.79.7) - React-cxxreact - React-featureflags - React-hermes @@ -143,7 +143,7 @@ PODS: - React-utils - SocketRocket (= 0.7.1) - Yoga - - React-Core/RCTActionSheetHeaders (0.79.2): + - React-Core/RCTActionSheetHeaders (0.79.7): - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -161,7 +161,7 @@ PODS: - React-utils - SocketRocket (= 0.7.1) - Yoga - - React-Core/RCTAnimationHeaders (0.79.2): + - React-Core/RCTAnimationHeaders (0.79.7): - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -179,7 +179,7 @@ PODS: - React-utils - SocketRocket (= 0.7.1) - Yoga - - React-Core/RCTBlobHeaders (0.79.2): + - React-Core/RCTBlobHeaders (0.79.7): - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -197,7 +197,7 @@ PODS: - React-utils - SocketRocket (= 0.7.1) - Yoga - - React-Core/RCTImageHeaders (0.79.2): + - React-Core/RCTImageHeaders (0.79.7): - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -215,7 +215,7 @@ PODS: - React-utils - SocketRocket (= 0.7.1) - Yoga - - React-Core/RCTLinkingHeaders (0.79.2): + - React-Core/RCTLinkingHeaders (0.79.7): - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -233,7 +233,7 @@ PODS: - React-utils - SocketRocket (= 0.7.1) - Yoga - - React-Core/RCTNetworkHeaders (0.79.2): + - React-Core/RCTNetworkHeaders (0.79.7): - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -251,7 +251,7 @@ PODS: - React-utils - SocketRocket (= 0.7.1) - Yoga - - React-Core/RCTSettingsHeaders (0.79.2): + - React-Core/RCTSettingsHeaders (0.79.7): - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -269,7 +269,7 @@ PODS: - React-utils - SocketRocket (= 0.7.1) - Yoga - - React-Core/RCTTextHeaders (0.79.2): + - React-Core/RCTTextHeaders (0.79.7): - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -287,7 +287,7 @@ PODS: - React-utils - SocketRocket (= 0.7.1) - Yoga - - React-Core/RCTVibrationHeaders (0.79.2): + - React-Core/RCTVibrationHeaders (0.79.7): - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -305,12 +305,12 @@ PODS: - React-utils - SocketRocket (= 0.7.1) - Yoga - - React-Core/RCTWebSocket (0.79.2): + - React-Core/RCTWebSocket (0.79.7): - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) - RCTDeprecation - - React-Core/Default (= 0.79.2) + - React-Core/Default (= 0.79.7) - React-cxxreact - React-featureflags - React-hermes @@ -323,23 +323,23 @@ PODS: - React-utils - SocketRocket (= 0.7.1) - Yoga - - React-CoreModules (0.79.2): + - React-CoreModules (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) - RCT-Folly (= 2024.11.18.00) - - RCTTypeSafety (= 0.79.2) - - React-Core/CoreModulesHeaders (= 0.79.2) - - React-jsi (= 0.79.2) + - RCTTypeSafety (= 0.79.7) + - React-Core/CoreModulesHeaders (= 0.79.7) + - React-jsi (= 0.79.7) - React-jsinspector - React-jsinspectortracing - React-NativeModulesApple - React-RCTBlob - React-RCTFBReactNativeSpec - - React-RCTImage (= 0.79.2) + - React-RCTImage (= 0.79.7) - ReactCommon - SocketRocket (= 0.7.1) - - React-cxxreact (0.79.2): + - React-cxxreact (0.79.7): - boost - DoubleConversion - fast_float (= 6.1.4) @@ -347,17 +347,17 @@ PODS: - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) - - React-callinvoker (= 0.79.2) - - React-debug (= 0.79.2) - - React-jsi (= 0.79.2) + - React-callinvoker (= 0.79.7) + - React-debug (= 0.79.7) + - React-jsi (= 0.79.7) - React-jsinspector - React-jsinspectortracing - - React-logger (= 0.79.2) - - React-perflogger (= 0.79.2) - - React-runtimeexecutor (= 0.79.2) - - React-timing (= 0.79.2) - - React-debug (0.79.2) - - React-defaultsnativemodule (0.79.2): + - React-logger (= 0.79.7) + - React-perflogger (= 0.79.7) + - React-runtimeexecutor (= 0.79.7) + - React-timing (= 0.79.7) + - React-debug (0.79.7) + - React-defaultsnativemodule (0.79.7): - hermes-engine - RCT-Folly - React-domnativemodule @@ -368,7 +368,7 @@ PODS: - React-jsiexecutor - React-microtasksnativemodule - React-RCTFBReactNativeSpec - - React-domnativemodule (0.79.2): + - React-domnativemodule (0.79.7): - hermes-engine - RCT-Folly - React-Fabric @@ -380,7 +380,7 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - Yoga - - React-Fabric (0.79.2): + - React-Fabric (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -392,22 +392,22 @@ PODS: - React-Core - React-cxxreact - React-debug - - React-Fabric/animations (= 0.79.2) - - React-Fabric/attributedstring (= 0.79.2) - - React-Fabric/componentregistry (= 0.79.2) - - React-Fabric/componentregistrynative (= 0.79.2) - - React-Fabric/components (= 0.79.2) - - React-Fabric/consistency (= 0.79.2) - - React-Fabric/core (= 0.79.2) - - React-Fabric/dom (= 0.79.2) - - React-Fabric/imagemanager (= 0.79.2) - - React-Fabric/leakchecker (= 0.79.2) - - React-Fabric/mounting (= 0.79.2) - - React-Fabric/observers (= 0.79.2) - - React-Fabric/scheduler (= 0.79.2) - - React-Fabric/telemetry (= 0.79.2) - - React-Fabric/templateprocessor (= 0.79.2) - - React-Fabric/uimanager (= 0.79.2) + - React-Fabric/animations (= 0.79.7) + - React-Fabric/attributedstring (= 0.79.7) + - React-Fabric/componentregistry (= 0.79.7) + - React-Fabric/componentregistrynative (= 0.79.7) + - React-Fabric/components (= 0.79.7) + - React-Fabric/consistency (= 0.79.7) + - React-Fabric/core (= 0.79.7) + - React-Fabric/dom (= 0.79.7) + - React-Fabric/imagemanager (= 0.79.7) + - React-Fabric/leakchecker (= 0.79.7) + - React-Fabric/mounting (= 0.79.7) + - React-Fabric/observers (= 0.79.7) + - React-Fabric/scheduler (= 0.79.7) + - React-Fabric/telemetry (= 0.79.7) + - React-Fabric/templateprocessor (= 0.79.7) + - React-Fabric/uimanager (= 0.79.7) - React-featureflags - React-graphics - React-hermes @@ -418,7 +418,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/animations (0.79.2): + - React-Fabric/animations (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -440,7 +440,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/attributedstring (0.79.2): + - React-Fabric/attributedstring (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -462,7 +462,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/componentregistry (0.79.2): + - React-Fabric/componentregistry (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -484,7 +484,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/componentregistrynative (0.79.2): + - React-Fabric/componentregistrynative (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -506,7 +506,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/components (0.79.2): + - React-Fabric/components (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -518,10 +518,10 @@ PODS: - React-Core - React-cxxreact - React-debug - - React-Fabric/components/legacyviewmanagerinterop (= 0.79.2) - - React-Fabric/components/root (= 0.79.2) - - React-Fabric/components/scrollview (= 0.79.2) - - React-Fabric/components/view (= 0.79.2) + - React-Fabric/components/legacyviewmanagerinterop (= 0.79.7) + - React-Fabric/components/root (= 0.79.7) + - React-Fabric/components/scrollview (= 0.79.7) + - React-Fabric/components/view (= 0.79.7) - React-featureflags - React-graphics - React-hermes @@ -532,7 +532,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/components/legacyviewmanagerinterop (0.79.2): + - React-Fabric/components/legacyviewmanagerinterop (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -554,7 +554,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/components/root (0.79.2): + - React-Fabric/components/root (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -576,7 +576,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/components/scrollview (0.79.2): + - React-Fabric/components/scrollview (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -598,7 +598,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/components/view (0.79.2): + - React-Fabric/components/view (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -622,7 +622,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - Yoga - - React-Fabric/consistency (0.79.2): + - React-Fabric/consistency (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -644,7 +644,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/core (0.79.2): + - React-Fabric/core (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -666,7 +666,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/dom (0.79.2): + - React-Fabric/dom (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -688,7 +688,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/imagemanager (0.79.2): + - React-Fabric/imagemanager (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -710,7 +710,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/leakchecker (0.79.2): + - React-Fabric/leakchecker (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -732,7 +732,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/mounting (0.79.2): + - React-Fabric/mounting (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -754,7 +754,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/observers (0.79.2): + - React-Fabric/observers (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -766,7 +766,7 @@ PODS: - React-Core - React-cxxreact - React-debug - - React-Fabric/observers/events (= 0.79.2) + - React-Fabric/observers/events (= 0.79.7) - React-featureflags - React-graphics - React-hermes @@ -777,7 +777,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/observers/events (0.79.2): + - React-Fabric/observers/events (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -799,7 +799,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/scheduler (0.79.2): + - React-Fabric/scheduler (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -823,7 +823,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/telemetry (0.79.2): + - React-Fabric/telemetry (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -845,7 +845,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/templateprocessor (0.79.2): + - React-Fabric/templateprocessor (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -867,7 +867,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/uimanager (0.79.2): + - React-Fabric/uimanager (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -879,7 +879,7 @@ PODS: - React-Core - React-cxxreact - React-debug - - React-Fabric/uimanager/consistency (= 0.79.2) + - React-Fabric/uimanager/consistency (= 0.79.7) - React-featureflags - React-graphics - React-hermes @@ -891,7 +891,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-Fabric/uimanager/consistency (0.79.2): + - React-Fabric/uimanager/consistency (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -914,7 +914,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon/turbomodule/core - - React-FabricComponents (0.79.2): + - React-FabricComponents (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -927,8 +927,8 @@ PODS: - React-cxxreact - React-debug - React-Fabric - - React-FabricComponents/components (= 0.79.2) - - React-FabricComponents/textlayoutmanager (= 0.79.2) + - React-FabricComponents/components (= 0.79.7) + - React-FabricComponents/textlayoutmanager (= 0.79.7) - React-featureflags - React-graphics - React-hermes @@ -940,7 +940,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - Yoga - - React-FabricComponents/components (0.79.2): + - React-FabricComponents/components (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -953,15 +953,15 @@ PODS: - React-cxxreact - React-debug - React-Fabric - - React-FabricComponents/components/inputaccessory (= 0.79.2) - - React-FabricComponents/components/iostextinput (= 0.79.2) - - React-FabricComponents/components/modal (= 0.79.2) - - React-FabricComponents/components/rncore (= 0.79.2) - - React-FabricComponents/components/safeareaview (= 0.79.2) - - React-FabricComponents/components/scrollview (= 0.79.2) - - React-FabricComponents/components/text (= 0.79.2) - - React-FabricComponents/components/textinput (= 0.79.2) - - React-FabricComponents/components/unimplementedview (= 0.79.2) + - React-FabricComponents/components/inputaccessory (= 0.79.7) + - React-FabricComponents/components/iostextinput (= 0.79.7) + - React-FabricComponents/components/modal (= 0.79.7) + - React-FabricComponents/components/rncore (= 0.79.7) + - React-FabricComponents/components/safeareaview (= 0.79.7) + - React-FabricComponents/components/scrollview (= 0.79.7) + - React-FabricComponents/components/text (= 0.79.7) + - React-FabricComponents/components/textinput (= 0.79.7) + - React-FabricComponents/components/unimplementedview (= 0.79.7) - React-featureflags - React-graphics - React-hermes @@ -973,7 +973,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - Yoga - - React-FabricComponents/components/inputaccessory (0.79.2): + - React-FabricComponents/components/inputaccessory (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -997,7 +997,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - Yoga - - React-FabricComponents/components/iostextinput (0.79.2): + - React-FabricComponents/components/iostextinput (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -1021,7 +1021,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - Yoga - - React-FabricComponents/components/modal (0.79.2): + - React-FabricComponents/components/modal (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -1045,7 +1045,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - Yoga - - React-FabricComponents/components/rncore (0.79.2): + - React-FabricComponents/components/rncore (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -1069,7 +1069,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - Yoga - - React-FabricComponents/components/safeareaview (0.79.2): + - React-FabricComponents/components/safeareaview (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -1093,7 +1093,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - Yoga - - React-FabricComponents/components/scrollview (0.79.2): + - React-FabricComponents/components/scrollview (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -1117,7 +1117,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - Yoga - - React-FabricComponents/components/text (0.79.2): + - React-FabricComponents/components/text (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -1141,7 +1141,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - Yoga - - React-FabricComponents/components/textinput (0.79.2): + - React-FabricComponents/components/textinput (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -1165,7 +1165,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - Yoga - - React-FabricComponents/components/unimplementedview (0.79.2): + - React-FabricComponents/components/unimplementedview (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -1189,7 +1189,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - Yoga - - React-FabricComponents/textlayoutmanager (0.79.2): + - React-FabricComponents/textlayoutmanager (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -1213,30 +1213,30 @@ PODS: - React-utils - ReactCommon/turbomodule/core - Yoga - - React-FabricImage (0.79.2): + - React-FabricImage (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) - - RCTRequired (= 0.79.2) - - RCTTypeSafety (= 0.79.2) + - RCTRequired (= 0.79.7) + - RCTTypeSafety (= 0.79.7) - React-Fabric - React-featureflags - React-graphics - React-hermes - React-ImageManager - React-jsi - - React-jsiexecutor (= 0.79.2) + - React-jsiexecutor (= 0.79.7) - React-logger - React-rendererdebug - React-utils - ReactCommon - Yoga - - React-featureflags (0.79.2): + - React-featureflags (0.79.7): - RCT-Folly (= 2024.11.18.00) - - React-featureflagsnativemodule (0.79.2): + - React-featureflagsnativemodule (0.79.7): - hermes-engine - RCT-Folly - React-featureflags @@ -1245,7 +1245,7 @@ PODS: - React-jsiexecutor - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - - React-graphics (0.79.2): + - React-graphics (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -1256,21 +1256,21 @@ PODS: - React-jsi - React-jsiexecutor - React-utils - - React-hermes (0.79.2): + - React-hermes (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) - - React-cxxreact (= 0.79.2) + - React-cxxreact (= 0.79.7) - React-jsi - - React-jsiexecutor (= 0.79.2) + - React-jsiexecutor (= 0.79.7) - React-jsinspector - React-jsinspectortracing - - React-perflogger (= 0.79.2) + - React-perflogger (= 0.79.7) - React-runtimeexecutor - - React-idlecallbacksnativemodule (0.79.2): + - React-idlecallbacksnativemodule (0.79.7): - glog - hermes-engine - RCT-Folly @@ -1280,7 +1280,7 @@ PODS: - React-RCTFBReactNativeSpec - React-runtimescheduler - ReactCommon/turbomodule/core - - React-ImageManager (0.79.2): + - React-ImageManager (0.79.7): - glog - RCT-Folly/Fabric - React-Core/Default @@ -1289,7 +1289,7 @@ PODS: - React-graphics - React-rendererdebug - React-utils - - React-jserrorhandler (0.79.2): + - React-jserrorhandler (0.79.7): - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -1298,7 +1298,7 @@ PODS: - React-featureflags - React-jsi - ReactCommon/turbomodule/bridging - - React-jsi (0.79.2): + - React-jsi (0.79.7): - boost - DoubleConversion - fast_float (= 6.1.4) @@ -1306,19 +1306,19 @@ PODS: - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) - - React-jsiexecutor (0.79.2): + - React-jsiexecutor (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) - - React-cxxreact (= 0.79.2) - - React-jsi (= 0.79.2) + - React-cxxreact (= 0.79.7) + - React-jsi (= 0.79.7) - React-jsinspector - React-jsinspectortracing - - React-perflogger (= 0.79.2) - - React-jsinspector (0.79.2): + - React-perflogger (= 0.79.7) + - React-jsinspector (0.79.7): - DoubleConversion - glog - hermes-engine @@ -1326,29 +1326,29 @@ PODS: - React-featureflags - React-jsi - React-jsinspectortracing - - React-perflogger (= 0.79.2) - - React-runtimeexecutor (= 0.79.2) - - React-jsinspectortracing (0.79.2): + - React-perflogger (= 0.79.7) + - React-runtimeexecutor (= 0.79.7) + - React-jsinspectortracing (0.79.7): - RCT-Folly - React-oscompat - - React-jsitooling (0.79.2): + - React-jsitooling (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) - glog - RCT-Folly (= 2024.11.18.00) - - React-cxxreact (= 0.79.2) - - React-jsi (= 0.79.2) + - React-cxxreact (= 0.79.7) + - React-jsi (= 0.79.7) - React-jsinspector - React-jsinspectortracing - - React-jsitracing (0.79.2): + - React-jsitracing (0.79.7): - React-jsi - - React-logger (0.79.2): + - React-logger (0.79.7): - glog - - React-Mapbuffer (0.79.2): + - React-Mapbuffer (0.79.7): - glog - React-debug - - React-microtasksnativemodule (0.79.2): + - React-microtasksnativemodule (0.79.7): - hermes-engine - RCT-Folly - React-hermes @@ -1404,7 +1404,82 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - React-NativeModulesApple (0.79.2): + - react-native-safe-area-context (5.7.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - react-native-safe-area-context/common (= 5.7.0) + - react-native-safe-area-context/fabric (= 5.7.0) + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - react-native-safe-area-context/common (5.7.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - react-native-safe-area-context/fabric (5.7.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - react-native-safe-area-context/common + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - React-NativeModulesApple (0.79.7): - glog - hermes-engine - React-callinvoker @@ -1417,20 +1492,20 @@ PODS: - React-runtimeexecutor - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - React-oscompat (0.79.2) - - React-perflogger (0.79.2): + - React-oscompat (0.79.7) + - React-perflogger (0.79.7): - DoubleConversion - RCT-Folly (= 2024.11.18.00) - - React-performancetimeline (0.79.2): + - React-performancetimeline (0.79.7): - RCT-Folly (= 2024.11.18.00) - React-cxxreact - React-featureflags - React-jsinspectortracing - React-perflogger - React-timing - - React-RCTActionSheet (0.79.2): - - React-Core/RCTActionSheetHeaders (= 0.79.2) - - React-RCTAnimation (0.79.2): + - React-RCTActionSheet (0.79.7): + - React-Core/RCTActionSheetHeaders (= 0.79.7) + - React-RCTAnimation (0.79.7): - RCT-Folly (= 2024.11.18.00) - RCTTypeSafety - React-Core/RCTAnimationHeaders @@ -1438,7 +1513,7 @@ PODS: - React-NativeModulesApple - React-RCTFBReactNativeSpec - ReactCommon - - React-RCTAppDelegate (0.79.2): + - React-RCTAppDelegate (0.79.7): - hermes-engine - RCT-Folly (= 2024.11.18.00) - RCTRequired @@ -1464,7 +1539,7 @@ PODS: - React-runtimescheduler - React-utils - ReactCommon - - React-RCTBlob (0.79.2): + - React-RCTBlob (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) @@ -1478,7 +1553,7 @@ PODS: - React-RCTFBReactNativeSpec - React-RCTNetwork - ReactCommon - - React-RCTFabric (0.79.2): + - React-RCTFabric (0.79.7): - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -1504,7 +1579,7 @@ PODS: - React-runtimescheduler - React-utils - Yoga - - React-RCTFBReactNativeSpec (0.79.2): + - React-RCTFBReactNativeSpec (0.79.7): - hermes-engine - RCT-Folly - RCTRequired @@ -1515,7 +1590,7 @@ PODS: - React-jsiexecutor - React-NativeModulesApple - ReactCommon - - React-RCTImage (0.79.2): + - React-RCTImage (0.79.7): - RCT-Folly (= 2024.11.18.00) - RCTTypeSafety - React-Core/RCTImageHeaders @@ -1524,14 +1599,14 @@ PODS: - React-RCTFBReactNativeSpec - React-RCTNetwork - ReactCommon - - React-RCTLinking (0.79.2): - - React-Core/RCTLinkingHeaders (= 0.79.2) - - React-jsi (= 0.79.2) + - React-RCTLinking (0.79.7): + - React-Core/RCTLinkingHeaders (= 0.79.7) + - React-jsi (= 0.79.7) - React-NativeModulesApple - React-RCTFBReactNativeSpec - ReactCommon - - ReactCommon/turbomodule/core (= 0.79.2) - - React-RCTNetwork (0.79.2): + - ReactCommon/turbomodule/core (= 0.79.7) + - React-RCTNetwork (0.79.7): - RCT-Folly (= 2024.11.18.00) - RCTTypeSafety - React-Core/RCTNetworkHeaders @@ -1539,7 +1614,7 @@ PODS: - React-NativeModulesApple - React-RCTFBReactNativeSpec - ReactCommon - - React-RCTRuntime (0.79.2): + - React-RCTRuntime (0.79.7): - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -1552,7 +1627,7 @@ PODS: - React-RuntimeApple - React-RuntimeCore - React-RuntimeHermes - - React-RCTSettings (0.79.2): + - React-RCTSettings (0.79.7): - RCT-Folly (= 2024.11.18.00) - RCTTypeSafety - React-Core/RCTSettingsHeaders @@ -1560,28 +1635,28 @@ PODS: - React-NativeModulesApple - React-RCTFBReactNativeSpec - ReactCommon - - React-RCTText (0.79.2): - - React-Core/RCTTextHeaders (= 0.79.2) + - React-RCTText (0.79.7): + - React-Core/RCTTextHeaders (= 0.79.7) - Yoga - - React-RCTVibration (0.79.2): + - React-RCTVibration (0.79.7): - RCT-Folly (= 2024.11.18.00) - React-Core/RCTVibrationHeaders - React-jsi - React-NativeModulesApple - React-RCTFBReactNativeSpec - ReactCommon - - React-rendererconsistency (0.79.2) - - React-renderercss (0.79.2): + - React-rendererconsistency (0.79.7) + - React-renderercss (0.79.7): - React-debug - React-utils - - React-rendererdebug (0.79.2): + - React-rendererdebug (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) - RCT-Folly (= 2024.11.18.00) - React-debug - - React-rncore (0.79.2) - - React-RuntimeApple (0.79.2): + - React-rncore (0.79.7) + - React-RuntimeApple (0.79.7): - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) - React-callinvoker @@ -1603,7 +1678,7 @@ PODS: - React-RuntimeHermes - React-runtimescheduler - React-utils - - React-RuntimeCore (0.79.2): + - React-RuntimeCore (0.79.7): - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -1620,9 +1695,9 @@ PODS: - React-runtimeexecutor - React-runtimescheduler - React-utils - - React-runtimeexecutor (0.79.2): - - React-jsi (= 0.79.2) - - React-RuntimeHermes (0.79.2): + - React-runtimeexecutor (0.79.7): + - React-jsi (= 0.79.7) + - React-RuntimeHermes (0.79.7): - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) - React-featureflags @@ -1634,7 +1709,7 @@ PODS: - React-jsitracing - React-RuntimeCore - React-utils - - React-runtimescheduler (0.79.2): + - React-runtimescheduler (0.79.7): - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -1651,17 +1726,17 @@ PODS: - React-runtimeexecutor - React-timing - React-utils - - React-timing (0.79.2) - - React-utils (0.79.2): + - React-timing (0.79.7) + - React-utils (0.79.7): - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) - React-debug - React-hermes - - React-jsi (= 0.79.2) - - ReactAppDependencyProvider (0.79.2): + - React-jsi (= 0.79.7) + - ReactAppDependencyProvider (0.79.7): - ReactCodegen - - ReactCodegen (0.79.2): + - ReactCodegen (0.79.7): - DoubleConversion - glog - hermes-engine @@ -1683,49 +1758,49 @@ PODS: - React-utils - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - ReactCommon (0.79.2): - - ReactCommon/turbomodule (= 0.79.2) - - ReactCommon/turbomodule (0.79.2): + - ReactCommon (0.79.7): + - ReactCommon/turbomodule (= 0.79.7) + - ReactCommon/turbomodule (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) - - React-callinvoker (= 0.79.2) - - React-cxxreact (= 0.79.2) - - React-jsi (= 0.79.2) - - React-logger (= 0.79.2) - - React-perflogger (= 0.79.2) - - ReactCommon/turbomodule/bridging (= 0.79.2) - - ReactCommon/turbomodule/core (= 0.79.2) - - ReactCommon/turbomodule/bridging (0.79.2): + - React-callinvoker (= 0.79.7) + - React-cxxreact (= 0.79.7) + - React-jsi (= 0.79.7) + - React-logger (= 0.79.7) + - React-perflogger (= 0.79.7) + - ReactCommon/turbomodule/bridging (= 0.79.7) + - ReactCommon/turbomodule/core (= 0.79.7) + - ReactCommon/turbomodule/bridging (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) - - React-callinvoker (= 0.79.2) - - React-cxxreact (= 0.79.2) - - React-jsi (= 0.79.2) - - React-logger (= 0.79.2) - - React-perflogger (= 0.79.2) - - ReactCommon/turbomodule/core (0.79.2): + - React-callinvoker (= 0.79.7) + - React-cxxreact (= 0.79.7) + - React-jsi (= 0.79.7) + - React-logger (= 0.79.7) + - React-perflogger (= 0.79.7) + - ReactCommon/turbomodule/core (0.79.7): - DoubleConversion - fast_float (= 6.1.4) - fmt (= 11.0.2) - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) - - React-callinvoker (= 0.79.2) - - React-cxxreact (= 0.79.2) - - React-debug (= 0.79.2) - - React-featureflags (= 0.79.2) - - React-jsi (= 0.79.2) - - React-logger (= 0.79.2) - - React-perflogger (= 0.79.2) - - React-utils (= 0.79.2) + - React-callinvoker (= 0.79.7) + - React-cxxreact (= 0.79.7) + - React-debug (= 0.79.7) + - React-featureflags (= 0.79.7) + - React-jsi (= 0.79.7) + - React-logger (= 0.79.7) + - React-perflogger (= 0.79.7) + - React-utils (= 0.79.7) - SocketRocket (0.7.1) - Yoga (0.0.0) @@ -1773,6 +1848,7 @@ DEPENDENCIES: - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - "react-native-document-picker (from `../node_modules/@react-native-documents/picker`)" - react-native-image-picker (from `../node_modules/react-native-image-picker`) + - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) @@ -1827,7 +1903,7 @@ EXTERNAL SOURCES: :podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec" hermes-engine: :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" - :tag: hermes-2025-03-03-RNv0.79.0-bc17d964d03743424823d7dd1a9f37633459c5c5 + :tag: hermes-2025-06-04-RNv0.79.3-7f9a871eefeb2c3852365ee80f0b6733ec12ac3b RCT-Folly: :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" RCTDeprecation: @@ -1894,6 +1970,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-documents/picker" react-native-image-picker: :path: "../node_modules/react-native-image-picker" + react-native-safe-area-context: + :path: "../node_modules/react-native-safe-area-context" React-NativeModulesApple: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" React-oscompat: @@ -1964,77 +2042,78 @@ SPEC CHECKSUMS: DocumentScanner: ab4f56ed3b32b95c86df9f4ec53666064f5e354e DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6 - FBLazyVector: 84b955f7b4da8b895faf5946f73748267347c975 + FBLazyVector: b60fe06f0f15b7d7408f169442176e69e8eeacde fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 - hermes-engine: 314be5250afa5692b57b4dd1705959e1973a8ebe + hermes-engine: 13c84524b3b6e884b2cf3b3b1e002ffd147d88a3 RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82 - RCTDeprecation: 83ffb90c23ee5cea353bd32008a7bca100908f8c - RCTRequired: eb7c0aba998009f47a540bec9e9d69a54f68136e - RCTTypeSafety: 659ae318c09de0477fd27bbc9e140071c7ea5c93 - React: c2d3aa44c49bb34e4dfd49d3ee92da5ebacc1c1c - React-callinvoker: 1bdfb7549b5af266d85757193b5069f60659ef9d - React-Core: 10597593fdbae06f0089881e025a172e51d4a769 - React-CoreModules: 6907b255529dd46895cf687daa67b24484a612c2 - React-cxxreact: a9f5b8180d6955bc3f6a3fcd657c4d9b4d95c1f6 - React-debug: e74e76912b91e08d580c481c34881899ccf63da9 - React-defaultsnativemodule: 11f6ee2cf69bf3af9d0f28a6253def33d21b5266 - React-domnativemodule: f940bbc4fa9e134190acbf3a4a9f95621b5a8f51 - React-Fabric: 6f5c357bf3a42ff11f8844ad3fc7a1eb04f4b9de - React-FabricComponents: 10e0c0209822ac9e69412913a8af1ca33573379b - React-FabricImage: f582e764072dfa4715ae8c42979a5bace9cbcc12 - React-featureflags: d5facceff8f8f6de430e0acecf4979a9a0839ba9 - React-featureflagsnativemodule: a7dd141f1ef4b7c1331af0035689fbc742a49ff4 - React-graphics: 36ae3407172c1c77cea29265d2b12b90aaef6aa0 - React-hermes: 9116d4e6d07abeb519a2852672de087f44da8f12 - React-idlecallbacksnativemodule: ae7f5ffc6cf2d2058b007b78248e5b08172ad5c3 - React-ImageManager: 9daee0dc99ad6a001d4b9e691fbf37107e2b7b54 - React-jserrorhandler: 1e6211581071edaf4ecd5303147328120c73f4dc - React-jsi: 753ba30c902f3a41fa7f956aca8eea3317a44ee6 - React-jsiexecutor: 47520714aa7d9589c51c0f3713dfbfca4895d4f9 - React-jsinspector: cfd27107f6d6f1076a57d88c932401251560fe5f - React-jsinspectortracing: 76a7d791f3c0c09a0d2bf6f46dfb0e79a4fcc0ac - React-jsitooling: 995e826570dd58f802251490486ebd3244a037ab - React-jsitracing: 094ae3d8c123cea67b50211c945b7c0443d3e97b - React-logger: 8edfcedc100544791cd82692ca5a574240a16219 - React-Mapbuffer: c3f4b608e4a59dd2f6a416ef4d47a14400194468 - React-microtasksnativemodule: 054f34e9b82f02bd40f09cebd4083828b5b2beb6 + RCTDeprecation: b9b1716eec53aaeaa859aa45e27de7b615cff732 + RCTRequired: 862acb469086f601f81aab298e00c2a13d572099 + RCTTypeSafety: 45120b9028ec6819266f3ef7a6b8e8a9f0a083d4 + React: 49e89943d7b1bc95c5895a05c6cf106ba112d037 + React-callinvoker: 9baafac613e363728c47ceaf65b9597bd1b96df0 + React-Core: 2f642fff28911adf30dd5169a7d9d2b95dc59bc9 + React-CoreModules: a499af0e4a8dfa78cbc04c131b59fa3286210148 + React-cxxreact: 1a485bb66f4bdde62f89eb0e5db1262182fc40e0 + React-debug: 40119ee63d9fb04f8b11d460c3c30b5997d9d737 + React-defaultsnativemodule: d578aae775984b79f126a0ffd8481b70c0f145fc + React-domnativemodule: 2351486bd32ead37c242b00a71cbdff8dabb274c + React-Fabric: 3e4c67dd7918274fe00fdc34a56696095e4d83f0 + React-FabricComponents: c82dfc3d8e1faf5cd87b08f5f9e2be0fe9b775f3 + React-FabricImage: a296eafd939fb33e0acf09d33239408fe4624582 + React-featureflags: 3fdf55dee69c3e165411fbde4f9f3acb9e428dfd + React-featureflagsnativemodule: 1139ddc9fc4c703ef9165a50c83cdca710baa674 + React-graphics: 4d1c605d894e9c9a887bb6934d9cca89d2ff36f5 + React-hermes: 59edcac48ec38831b40cf28ed34b6d4d735cbdd3 + React-idlecallbacksnativemodule: 23c6b73801992033086c2e8b9519bbf6aaf84165 + React-ImageManager: 2b2c8c39cc29e55a59dc1a0a9427ec7f89aae02b + React-jserrorhandler: 7d2f6eb3821818dc914a278f06586f047d623d72 + React-jsi: f6fee849355b8819936b02d5c5b23a55a3d69540 + React-jsiexecutor: cd6fd873482d7183206eb612ce362d02c6fb8556 + React-jsinspector: d2b58ae4a2fa080b3fcc4a5a81dfbd7727654a6d + React-jsinspectortracing: d3c7d7ec87d24f4c3832ecf141afcbf8844eee80 + React-jsitooling: bfde104a6ea4540830a246c6ffd9f1d07bf677b0 + React-jsitracing: 826dc37b6d98de03e0e64d8d6017b40af766dbf0 + React-logger: fbdc0814b62fefa412a90af7cacc666387f1bc9a + React-Mapbuffer: cfe4726ba1929b9dab4645c78376a9165375d62f + React-microtasksnativemodule: c99b8f1240609566de105b73f2569c0f22880ee1 react-native-document-picker: 686c62761cf3bbb914588c252eca45fc1991436c react-native-image-picker: db0b3e3c7ae57680876318d4f42e7dbedd72244a - React-NativeModulesApple: 2c4377e139522c3d73f5df582e4f051a838ff25e - React-oscompat: ef5df1c734f19b8003e149317d041b8ce1f7d29c - React-perflogger: 9a151e0b4c933c9205fd648c246506a83f31395d - React-performancetimeline: 5b0dfc0acba29ea0269ddb34cd6dd59d3b8a1c66 - React-RCTActionSheet: a499b0d6d9793886b67ba3e16046a3fef2cdbbc3 - React-RCTAnimation: cc64adc259aabc3354b73065e2231d796dfce576 - React-RCTAppDelegate: 9d523da768f1c9e84c5f3b7e3624d097dfb0e16b - React-RCTBlob: e727f53eeefded7e6432eb76bd22b57bc880e5d1 - React-RCTFabric: 58590aa4fdb4ad546c06a7449b486cf6844e991f - React-RCTFBReactNativeSpec: 9064c63d99e467a3893e328ba3612745c3c3a338 - React-RCTImage: 7159cbdbb18a09d97ba1a611416eced75b3ccb29 - React-RCTLinking: 46293afdb859bccc63e1d3dedc6901a3c04ef360 - React-RCTNetwork: 4a6cd18f5bcd0363657789c64043123a896b1170 - React-RCTRuntime: 5ab904fd749aa52f267ef771d265612582a17880 - React-RCTSettings: 61e361dc85136d1cb0e148b7541993d2ee950ea7 - React-RCTText: abd1e196c3167175e6baef18199c6d9d8ac54b4e - React-RCTVibration: 490e0dcb01a3fe4a0dfb7bc51ad5856d8b84f343 - React-rendererconsistency: 351fdbc5c1fe4da24243d939094a80f0e149c7a1 - React-renderercss: 3438814bee838ae7840a633ab085ac81699fd5cf - React-rendererdebug: 0ac2b9419ad6f88444f066d4b476180af311fb1e - React-rncore: 57ed480649bb678d8bdc386d20fee8bf2b0c307c - React-RuntimeApple: 8b7a9788f31548298ba1990620fe06b40de65ad7 - React-RuntimeCore: e03d96fbd57ce69fd9bca8c925942194a5126dbc - React-runtimeexecutor: d60846710facedd1edb70c08b738119b3ee2c6c2 - React-RuntimeHermes: aab794755d9f6efd249b61f3af4417296904e3ba - React-runtimescheduler: c3cd124fa5db7c37f601ee49ca0d97019acd8788 - React-timing: a90f4654cbda9c628614f9bee68967f1768bd6a5 - React-utils: a612d50555b6f0f90c74b7d79954019ad47f5de6 - ReactAppDependencyProvider: 04d5eb15eb46be6720e17a4a7fa92940a776e584 - ReactCodegen: c63eda03ba1d94353fb97b031fc84f75a0d125ba - ReactCommon: 76d2dc87136d0a667678668b86f0fca0c16fdeb0 + react-native-safe-area-context: 26634d9b636a98ceee20cb6fa5dc946922f1e90f + React-NativeModulesApple: 74051ff264ce0b89ac799b56314f8f440a95ee4d + React-oscompat: 0047f0ce53a328ce225777a6c617970556602c7c + React-perflogger: bf00d816c3c3ad91658b44f93ce9ee343b4c70df + React-performancetimeline: 75139687f2db7ca68fd0377466949af0b3408995 + React-RCTActionSheet: 9b3b04bbe75241c964d59a3ec18716d0bec22b2c + React-RCTAnimation: 2ffefa3b11df9aa1248182fac5b74e6a9b5ebd21 + React-RCTAppDelegate: 535dd2b407b204bbdd0c46d4c7050ee1a28fa6f9 + React-RCTBlob: 3cf08a694a3f7b082e23717c7587b8d914685582 + React-RCTFabric: 2026fe43aa1142896e7e68237f6e88e283f8cab3 + React-RCTFBReactNativeSpec: 43cea3b898e0e7e81abfeafd2dd4084dd8afe662 + React-RCTImage: 4eaca67175ed2a33d39eeb8bad0e724c849bdc11 + React-RCTLinking: 5133d7bf648707bc3fb65fb7ab3cfa7f445e9893 + React-RCTNetwork: 708cf70b59721a23cc362cde08dd204375c9d0ac + React-RCTRuntime: b361aa130a54eeb9bd3129fe0335513c981353b3 + React-RCTSettings: 6d413683ae5a36e80c7965f1ff8efa893b211a17 + React-RCTText: 0bd1e63583ba3dab0358f62ade056732b76463f8 + React-RCTVibration: bab25bb8bc2f017e4729deb59d26bb32240b16db + React-rendererconsistency: 32ded9b29e3dc4c2607b2c2f774bc16320fbf7ed + React-renderercss: a1e299ffd55c69fbc57e4a77a0e4dbf4a8069ada + React-rendererdebug: b640acffa5b80be66f7c5e463ffef60fa74fbbce + React-rncore: 6faaf52d39dca54cb3e63fd45cd4bd2004bceba7 + React-RuntimeApple: c734fb41922e0d6afbcbf6326b04c41c44d4e166 + React-RuntimeCore: b84b58450e647cb1e912857b81b9761317e29af0 + React-runtimeexecutor: e6aceac245c2e4f0dca15c2c57182d1dabc90e11 + React-RuntimeHermes: 9ae57e883b94d3da384b52ab3e4b22a7ac49d639 + React-runtimescheduler: e7a8619f43121a426f1ea44a6a76b22259bc56cc + React-timing: 10c2421a49f1c2906d6bb95027248a4543ac113f + React-utils: 9ec29e69f3e6347a4c93745f84c2a214b5b1ce64 + ReactAppDependencyProvider: b203bace11326361b7f0513b3f5854cd340aa929 + ReactCodegen: 6a29ad4365aeeceecb951cfd6546a99ce617cc50 + ReactCommon: 4e460daed3ccb6af9e3de3176920362d8b888a17 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - Yoga: c758bfb934100bb4bf9cbaccb52557cee35e8bdf + Yoga: 9663a18d096f7f7e17a535cf00849db756709955 -PODFILE CHECKSUM: 3bac4b33e4363d4fdab8ce1512515eb7df56f4db +PODFILE CHECKSUM: 49679691b08229b743ca5d4c537d763636d434d6 COCOAPODS: 1.15.2 diff --git a/example/package.json b/example/package.json index f06bd9b..1b9d57c 100644 --- a/example/package.json +++ b/example/package.json @@ -12,19 +12,20 @@ "dependencies": { "@react-native-documents/picker": "^12.0.1", "react": "19.0.0", - "react-native": "0.79.2", - "react-native-image-picker": "^8.2.1" + "react-native": "0.79.7", + "react-native-image-picker": "^8.2.1", + "react-native-safe-area-context": "^5.7.0" }, "devDependencies": { - "@babel/core": "^7.25.2", + "@babel/core": "^7.29.0", "@babel/preset-env": "^7.25.3", "@babel/runtime": "^7.25.0", "@react-native-community/cli": "18.0.0", "@react-native-community/cli-platform-android": "18.0.0", "@react-native-community/cli-platform-ios": "18.0.0", - "@react-native/babel-preset": "0.79.2", - "@react-native/metro-config": "0.79.2", - "@react-native/typescript-config": "0.79.2", + "@react-native/babel-preset": "0.79.7", + "@react-native/metro-config": "0.79.7", + "@react-native/typescript-config": "0.79.7", "@types/react": "^19.0.0", "react-native-builder-bob": "^0.40.13", "react-native-monorepo-config": "^0.1.9" diff --git a/example/src/App.tsx b/example/src/App.tsx index e716c99..be6ae6b 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -3,13 +3,17 @@ import { ActivityIndicator, Image, Pressable, - SafeAreaView, ScrollView, StyleSheet, Switch, Text, View, } from 'react-native'; +import { + SafeAreaProvider, + SafeAreaView, + useSafeAreaInsets, +} from 'react-native-safe-area-context'; import DocumentScanner, { ResponseType, type AnalysisResult, @@ -67,7 +71,7 @@ const INITIAL_EXTRACT: ExtractToggles = { structuredData: true, }; -const INITIAL_BARCODE_FORMATS: BarcodeFormatValue[] = ['qr', 'ean13']; +const INITIAL_BARCODE_FORMATS: BarcodeFormatValue[] = []; function formatError(error: unknown): string { if (typeof error === 'string') { @@ -112,6 +116,21 @@ function toDisplayImageUri(source: string, responseType: ResponseType): string { return source; } +function imageSourceScheme(source: string | undefined): string { + if (!source) { + return 'n/a'; + } + const normalized = source.trim(); + const schemeEnd = normalized.indexOf('://'); + if (schemeEnd > 0) { + return normalized.slice(0, schemeEnd).toLowerCase(); + } + if (normalized.startsWith('/')) { + return 'path'; + } + return 'unknown'; +} + function jsonPreview(value: unknown): string { const raw = JSON.stringify(value, null, 2); if (!raw) { @@ -189,7 +208,8 @@ function JsonCard(props: { title: string; value: unknown }) { ); } -export default function App() { +function AppContent() { + const insets = useSafeAreaInsets(); const [responseType, setResponseType] = useState( ResponseType.ImageFilePath ); @@ -230,6 +250,7 @@ export default function App() { ); const selectedImageSource = scannedImages[selectedImageIndex]; + const firstImageScheme = imageSourceScheme(scannedImages[0]); const selectedImageUri = selectedImageSource ? toDisplayImageUri(selectedImageSource, imagesResponseType) : null; @@ -467,8 +488,13 @@ export default function App() { }; return ( - - + + Document Scanner Example Full demo for scan + files/gallery pickers + barcode + OCR + @@ -613,6 +639,13 @@ export default function App() { scan status: {scanStatus ?? 'n/a'} source: {pageSource ?? 'n/a'} + + barcodeFormats:{' '} + {barcodeFormats.length > 0 ? barcodeFormats.join(', ') : 'all'} + + + first image scheme: {firstImageScheme} + scannedImages: {scannedImages.length} @@ -683,6 +716,14 @@ export default function App() { ); } +export default function App() { + return ( + + + + ); +} + const styles = StyleSheet.create({ safeArea: { flex: 1, diff --git a/ios/DocScanner/Analysis/DocumentSemantics.swift b/ios/DocScanner/Analysis/DocumentSemantics.swift index 761cf43..915b84d 100644 --- a/ios/DocScanner/Analysis/DocumentSemantics.swift +++ b/ios/DocScanner/Analysis/DocumentSemantics.swift @@ -7,36 +7,56 @@ private struct TextLineEntry { } enum DocumentSemantics { - private static let fieldRegex = try? NSRegularExpression( + private static let fieldRegex = compileRegex( + name: "fieldRegex", pattern: "^([A-Za-z0-9А-Яа-я _./-]{2,40})\\s*[:-]\\s*(.+)$", options: [] ) - private static let phoneRegex = try? NSRegularExpression( + private static let phoneRegex = compileRegex( + name: "phoneRegex", pattern: "(?:\\+?\\d[\\d\\s().-]{7,}\\d)", options: [] ) - private static let emailRegex = try? NSRegularExpression( + private static let emailRegex = compileRegex( + name: "emailRegex", pattern: "[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}", options: [.caseInsensitive] ) - private static let dateRegex = try? NSRegularExpression( + private static let dateRegex = compileRegex( + name: "dateRegex", pattern: "\\b(?:\\d{1,2}[./-]\\d{1,2}[./-]\\d{2,4}|\\d{4}[./-]\\d{1,2}[./-]\\d{1,2})\\b", options: [] ) - private static let amountRegex = try? NSRegularExpression( + private static let amountRegex = compileRegex( + name: "amountRegex", pattern: "\\b(?:[$€£]\\s?)?\\d{1,3}(?:[ ,]\\d{3})*(?:[.,]\\d{2})\\b", options: [] ) - private static let tableSplitRegex = try? NSRegularExpression( + private static let tableSplitRegex = compileRegex( + name: "tableSplitRegex", pattern: "\\s{2,}|\\t|\\|", options: [] ) + private static func compileRegex( + name: String, + pattern: String, + options: NSRegularExpression.Options + ) -> NSRegularExpression? { + do { + return try NSRegularExpression(pattern: pattern, options: options) + } catch { + assertionFailure("Invalid regex '\(name)': \(error)") + NSLog("[DocumentScanner][DocumentSemantics] regex compile failed name=\(name) error=\(error.localizedDescription)") + return nil + } + } + static func inferRegions(from textBlocks: [AnalysisTextBlock]) -> [AnalysisRegion] { var regions: [AnalysisRegion] = [] diff --git a/ios/DocScanner/Analysis/RecognizeDocumentsAnalyzer.swift b/ios/DocScanner/Analysis/RecognizeDocumentsAnalyzer.swift index d1fca41..5f77502 100644 --- a/ios/DocScanner/Analysis/RecognizeDocumentsAnalyzer.swift +++ b/ios/DocScanner/Analysis/RecognizeDocumentsAnalyzer.swift @@ -5,6 +5,10 @@ import Vision @available(iOS 26.0, *) enum RecognizeDocumentsAnalyzer { + private static func log(_ message: @autoclosure () -> String) { + DocScannerDebugLog.log("RecognizeDocumentsAnalyzer", message()) + } + struct Options { let includeBarcodes: Bool let includeText: Bool @@ -27,6 +31,9 @@ enum RecognizeDocumentsAnalyzer { sourceImageIndex: Int, options: Options ) -> PageAnalysis { + log( + "analyzeImageBlocking start sourceImageIndex=\(sourceImageIndex) include(barcodes=\(options.includeBarcodes), text=\(options.includeText), tables=\(options.includeTables), regions=\(options.includeRegions), structured=\(options.includeStructuredData)) allowedFormats=\(options.allowedBarcodeFormats)" + ) let semaphore = DispatchSemaphore(value: 0) var output = PageAnalysis( barcodes: [], @@ -46,6 +53,9 @@ enum RecognizeDocumentsAnalyzer { } semaphore.wait() + log( + "analyzeImageBlocking done sourceImageIndex=\(sourceImageIndex) barcodes=\(output.barcodes.count) textBlocks=\(output.textBlocks.count) tables=\(output.tables.count) regions=\(output.regions.count)" + ) return output } @@ -54,7 +64,12 @@ enum RecognizeDocumentsAnalyzer { sourceImageIndex: Int, options: Options ) async -> PageAnalysis { - guard let cgImage = image.cgImage else { + let preparedImage = image.normalizedForVision() + log( + "analyzeImage start sourceImageIndex=\(sourceImageIndex) image=\(Int(preparedImage.size.width))x\(Int(preparedImage.size.height))" + ) + guard let cgImage = preparedImage.cgImage else { + log("analyzeImage sourceImageIndex=\(sourceImageIndex) missing cgImage") return PageAnalysis( barcodes: [], textBlocks: [], @@ -75,6 +90,9 @@ enum RecognizeDocumentsAnalyzer { if !allowedSymbologies.isEmpty { barcodeOptions.symbologies = allowedSymbologies } + log( + "analyzeImage sourceImageIndex=\(sourceImageIndex) allowedFormatsNormalized=\(Array(allowedFormats).sorted()) allowedSymbologies=\(allowedSymbologies)" + ) request.barcodeDetectionOptions = barcodeOptions let handler = ImageRequestHandler(cgImage) @@ -83,6 +101,7 @@ enum RecognizeDocumentsAnalyzer { do { observations = try await handler.perform(request) } catch { + log("analyzeImage sourceImageIndex=\(sourceImageIndex) request failed error=\(error.localizedDescription)") return PageAnalysis( barcodes: [], textBlocks: [], @@ -91,6 +110,7 @@ enum RecognizeDocumentsAnalyzer { structuredData: AnalysisStructuredData(entities: [], fields: [:]) ) } + log("analyzeImage sourceImageIndex=\(sourceImageIndex) observations=\(observations.count)") var textBlocks: [AnalysisTextBlock] = [] var tables: [AnalysisTable] = [] @@ -100,6 +120,7 @@ enum RecognizeDocumentsAnalyzer { var dedupEntities = Set() var dedupBarcodes = Set() var dedupTextBlocks = Set() + var barcodesFilteredByAllowList = 0 for observation in observations { let document = observation.document @@ -133,6 +154,7 @@ enum RecognizeDocumentsAnalyzer { let format = normalizeBarcodeSymbology(barcode.symbology) if !allowedFormats.isEmpty && !allowedFormats.contains(format) { + barcodesFilteredByAllowList += 1 continue } @@ -205,7 +227,7 @@ enum RecognizeDocumentsAnalyzer { ? DocumentSemantics.inferRegions(from: sortedTextBlocks) : [] - return PageAnalysis( + let result = PageAnalysis( barcodes: barcodes.sorted { lhs, rhs in if lhs.sourceImageIndex != rhs.sourceImageIndex { return lhs.sourceImageIndex < rhs.sourceImageIndex @@ -225,6 +247,10 @@ enum RecognizeDocumentsAnalyzer { fields: fields ) ) + log( + "analyzeImage sourceImageIndex=\(sourceImageIndex) result barcodes=\(barcodes.count) filteredByAllowList=\(barcodesFilteredByAllowList) textBlocks=\(sortedTextBlocks.count) tables=\(tables.count) regions=\(regions.count)" + ) + return result } private static func extractTextBlocks( diff --git a/ios/DocScanner/Analysis/StructuredDataNormalizer.swift b/ios/DocScanner/Analysis/StructuredDataNormalizer.swift index a525d47..99588c4 100644 --- a/ios/DocScanner/Analysis/StructuredDataNormalizer.swift +++ b/ios/DocScanner/Analysis/StructuredDataNormalizer.swift @@ -1,31 +1,50 @@ import Foundation enum StructuredDataNormalizer { - private static let fieldKeySanitizeRegex = try? NSRegularExpression( + private static let fieldKeySanitizeRegex = compileRegex( + name: "fieldKeySanitizeRegex", pattern: "[^a-z0-9а-я]+", options: [] ) - private static let currencyCodeRegex = try? NSRegularExpression( + private static let currencyCodeRegex = compileRegex( + name: "currencyCodeRegex", pattern: "\\b(usd|eur|gbp|uah|rub|brl)\\b", options: [.caseInsensitive] ) - private static let numericCandidateRegex = try? NSRegularExpression( + private static let numericCandidateRegex = compileRegex( + name: "numericCandidateRegex", pattern: "[-+]?\\d[\\d.,\\s]*\\d|[-+]?\\d", options: [] ) - private static let nonIdSymbolRegex = try? NSRegularExpression( + private static let nonIdSymbolRegex = compileRegex( + name: "nonIdSymbolRegex", pattern: "[^A-Z0-9_-]", options: [] ) - private static let nonIdDedupRegex = try? NSRegularExpression( + private static let nonIdDedupRegex = compileRegex( + name: "nonIdDedupRegex", pattern: "[^A-Z0-9]", options: [] ) + private static func compileRegex( + name: String, + pattern: String, + options: NSRegularExpression.Options + ) -> NSRegularExpression? { + do { + return try NSRegularExpression(pattern: pattern, options: options) + } catch { + assertionFailure("Invalid regex '\(name)': \(error)") + NSLog("[DocumentScanner][StructuredDataNormalizer] regex compile failed name=\(name) error=\(error.localizedDescription)") + return nil + } + } + private static let idFieldHints: Set = [ "id", "tracking", diff --git a/ios/DocScanner/Analysis/TextExtractor.swift b/ios/DocScanner/Analysis/TextExtractor.swift index b7a629b..3dd6be0 100644 --- a/ios/DocScanner/Analysis/TextExtractor.swift +++ b/ios/DocScanner/Analysis/TextExtractor.swift @@ -17,8 +17,9 @@ enum TextExtractor { sourceImageIndex: Int, enableRotate180Fallback: Bool = false ) -> [AnalysisTextBlock] { + let preparedImage = image.normalizedForVision() let firstPass = extractSinglePass( - image, + preparedImage, sourceImageIndex: sourceImageIndex ) @@ -28,7 +29,7 @@ enum TextExtractor { guard shouldRunRotateFallback(firstPass) else { return firstPass } - guard let rotated = rotate180(image) else { + guard let rotated = rotate180(preparedImage) else { return firstPass } diff --git a/ios/DocScanner/Barcode/BarcodeExtractor.swift b/ios/DocScanner/Barcode/BarcodeExtractor.swift index eee8537..d6d6777 100644 --- a/ios/DocScanner/Barcode/BarcodeExtractor.swift +++ b/ios/DocScanner/Barcode/BarcodeExtractor.swift @@ -1,4 +1,5 @@ import CoreGraphics +import CoreImage import Foundation import UIKit import Vision @@ -32,17 +33,33 @@ struct ExtractedBarcode { } enum BarcodeExtractor { + private static func log(_ message: @autoclosure () -> String) { + DocScannerDebugLog.log("BarcodeExtractor", message()) + } + + private static func trace(_ message: @autoclosure () -> String) { + DocScannerDebugLog.trace("BarcodeExtractor", message()) + } + static func extractFromImage( _ image: UIImage, allowedFormats: [String] = [] ) -> [ExtractedBarcode] { + let preparedImage = image.normalizedForVision() let normalizedAllowedFormats = normalizeAllowedFormats(allowedFormats) + // When Vision cannot create an inference context for a given image, + // short-circuit remaining Vision attempts for this image and continue with fallbacks. + var visionEnabled = true + log( + "extract start image=\(Int(preparedImage.size.width))x\(Int(preparedImage.size.height)) allowedRaw=\(allowedFormats) allowedNormalized=\(Array(normalizedAllowedFormats).sorted())" + ) var deduplicated = Set() var aggregated: [ExtractedBarcode] = [] let firstPass = detectInTopRightROI( - image, + preparedImage, allowedFormats: normalizedAllowedFormats, + visionEnabled: &visionEnabled, attemptIndex: 0 ) appendUnique( @@ -51,13 +68,15 @@ enum BarcodeExtractor { aggregated: &aggregated ) if !aggregated.isEmpty { + log("extract resolved after attempt=0 total=\(aggregated.count)") return aggregated } - if let rotated180 = image.rotated(by: .pi) { + if let rotated180 = preparedImage.rotated(by: .pi) { let secondPass = detectInTopRightROI( rotated180, allowedFormats: normalizedAllowedFormats, + visionEnabled: &visionEnabled, attemptIndex: 1 ) appendUnique( @@ -66,36 +85,84 @@ enum BarcodeExtractor { aggregated: &aggregated ) if !aggregated.isEmpty { + log("extract resolved after attempt=1 total=\(aggregated.count)") return aggregated } } - if let rotated90 = image.rotated(by: .pi / 2) { - let thirdPass = detectInTopRightROI( - rotated90, - allowedFormats: normalizedAllowedFormats, - attemptIndex: 2 - ) - appendUnique( - thirdPass, - deduplicated: &deduplicated, - aggregated: &aggregated - ) + if !aggregated.isEmpty { + log("extract resolved in ROI stage total=\(aggregated.count)") + return aggregated + } + + // Gallery images often don't place barcodes in the expected corner ROI. + // Run full-frame fallback only when ROI pipeline found nothing. + let fullFrameFirstPass = detectInFullFrame( + preparedImage, + allowedFormats: normalizedAllowedFormats, + visionEnabled: &visionEnabled, + attemptIndex: 2 + ) + appendUnique( + fullFrameFirstPass, + deduplicated: &deduplicated, + aggregated: &aggregated + ) + if !aggregated.isEmpty { + log("extract resolved full-frame stage total=\(aggregated.count)") + return aggregated } - if let rotatedMinus90 = image.rotated(by: -.pi / 2) { - let fourthPass = detectInTopRightROI( - rotatedMinus90, + if let rotated180 = preparedImage.rotated(by: .pi) { + let fullFrameSecondPass = detectInFullFrame( + rotated180, allowedFormats: normalizedAllowedFormats, + visionEnabled: &visionEnabled, attemptIndex: 3 ) appendUnique( - fourthPass, + fullFrameSecondPass, deduplicated: &deduplicated, aggregated: &aggregated ) + if !aggregated.isEmpty { + log("extract resolved after full-frame 180 total=\(aggregated.count)") + return aggregated + } } +#if targetEnvironment(simulator) + // Simulator Vision barcode detection can miss valid 1D codes in static gallery assets. + // When all barcode passes fail, recover common 1D formats from OCR with checksum validation. + let ocr1D = detectWithNumericOcrFallback( + preparedImage, + allowedFormats: normalizedAllowedFormats, + attemptIndex: 99 + ) + appendUnique( + ocr1D, + deduplicated: &deduplicated, + aggregated: &aggregated + ) + if !aggregated.isEmpty { + log("extract resolved with OCR 1D fallback total=\(aggregated.count)") + return aggregated + } +#endif + + // Extra QR-only fallback via CoreImage. This helps when Vision barcode + // request misses gallery-generated QR assets. + let coreImageQr = detectQrWithCoreImage( + preparedImage, + attemptIndex: 100 + ) + appendUnique( + coreImageQr, + deduplicated: &deduplicated, + aggregated: &aggregated + ) + log("extract completed total=\(aggregated.count)") + return aggregated } @@ -104,63 +171,167 @@ enum BarcodeExtractor { deduplicated: inout Set, aggregated: inout [ExtractedBarcode] ) { + let before = aggregated.count for barcode in detected { if deduplicated.insert(barcode.instanceKey).inserted { aggregated.append(barcode) } } + let added = aggregated.count - before + trace("appendUnique detected=\(detected.count) added=\(added) total=\(aggregated.count)") } private static func detectInTopRightROI( _ image: UIImage, allowedFormats: Set, + visionEnabled: inout Bool, attemptIndex: Int ) -> [ExtractedBarcode] { - guard let cgImage = image.cgImage else { return [] } + guard let cgImage = image.cgImage else { + trace("attempt=\(attemptIndex) roi skip: missing cgImage") + return [] + } let roi = roiRect(forWidth: CGFloat(cgImage.width), height: CGFloat(cgImage.height)).integral - guard roi.width > 1, roi.height > 1 else { return [] } + guard roi.width > 1, roi.height > 1 else { + trace("attempt=\(attemptIndex) roi skip: invalid roi \(roi)") + return [] + } - guard let croppedCgImage = cgImage.cropping(to: roi) else { return [] } + guard let croppedCgImage = cgImage.cropping(to: roi) else { + trace("attempt=\(attemptIndex) roi crop failed roi=\(roi)") + return [] + } + trace( + "attempt=\(attemptIndex) roi detect source=\(cgImage.width)x\(cgImage.height) roi=\(Int(roi.origin.x)),\(Int(roi.origin.y)),\(Int(roi.size.width))x\(Int(roi.size.height))" + ) + + return detectInCgImage( + croppedCgImage, + allowedFormats: allowedFormats, + visionEnabled: &visionEnabled, + attemptIndex: attemptIndex + ) + } + + private static func detectInFullFrame( + _ image: UIImage, + allowedFormats: Set, + visionEnabled: inout Bool, + attemptIndex: Int + ) -> [ExtractedBarcode] { + guard let cgImage = image.cgImage else { + trace("attempt=\(attemptIndex) full-frame skip: missing cgImage") + return [] + } + trace("attempt=\(attemptIndex) full-frame detect size=\(cgImage.width)x\(cgImage.height)") + + return detectInCgImage( + cgImage, + allowedFormats: allowedFormats, + visionEnabled: &visionEnabled, + attemptIndex: attemptIndex + ) + } + + private static func detectInCgImage( + _ cgImage: CGImage, + allowedFormats: Set, + visionEnabled: inout Bool, + attemptIndex: Int, + forcedSymbologies: [VNBarcodeSymbology]? = nil, + regionOfInterest: CGRect? = nil + ) -> [ExtractedBarcode] { + if !visionEnabled { + trace("attempt=\(attemptIndex) skip Vision detect: disabled after fatal inference-context failure") + return [] + } let request = VNDetectBarcodesRequest() - if !allowedFormats.isEmpty { - let requestedSymbologies = VNDetectBarcodesRequest.supportedSymbologies.filter { - allowedFormats.contains(normalizeFormat($0)) + var configuredSymbologies = 0 + if let forcedSymbologies { + let supported = Set(VNDetectBarcodesRequest.supportedSymbologies) + let filtered = forcedSymbologies.filter { supported.contains($0) } + if !filtered.isEmpty { + request.symbologies = filtered + configuredSymbologies = filtered.count } - if !requestedSymbologies.isEmpty { - request.symbologies = requestedSymbologies + } else { + if allowedFormats.isEmpty { + request.symbologies = VNDetectBarcodesRequest.supportedSymbologies + configuredSymbologies = request.symbologies.count + } else { + let requestedSymbologies = VNDetectBarcodesRequest.supportedSymbologies.filter { + allowedFormats.contains(normalizeFormat($0)) + } + if !requestedSymbologies.isEmpty { + request.symbologies = requestedSymbologies + configuredSymbologies = requestedSymbologies.count + } } } - let handler = VNImageRequestHandler(cgImage: croppedCgImage, options: [:]) + if let regionOfInterest { + request.regionOfInterest = regionOfInterest + } + +#if targetEnvironment(simulator) + request.usesCPUOnly = true +#endif + + let roiDescription = regionOfInterest.map { + String( + format: "%.2f,%.2f,%.2f,%.2f", + $0.origin.x, + $0.origin.y, + $0.size.width, + $0.size.height + ) + } ?? "full" + + trace( + "attempt=\(attemptIndex) perform detect allowedFormats=\(Array(allowedFormats).sorted()) configuredSymbologies=\(configuredSymbologies) forced=\(forcedSymbologies != nil) roi=\(roiDescription)" + ) + + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) do { try handler.perform([request]) } catch { + trace("attempt=\(attemptIndex) Vision perform failed error=\(error.localizedDescription)") + if isFatalInferenceContextError(error) { + visionEnabled = false + log("disabling Vision for current image due to fatal inference-context failure (attempt=\(attemptIndex))") + } return [] } guard let observations = request.results as? [VNBarcodeObservation] else { + trace("attempt=\(attemptIndex) no observations array") return [] } + trace("attempt=\(attemptIndex) observations=\(observations.count)") var deduplicated = Set() var results: [ExtractedBarcode] = [] + var filteredByAllowed = 0 + var filteredEmptyPayload = 0 for observation in observations { guard let payload = observation.payloadStringValue?.trimmingCharacters(in: .whitespacesAndNewlines), !payload.isEmpty else { + filteredEmptyPayload += 1 continue } let normalizedFormat = normalizeFormat(observation.symbology) if !allowedFormats.isEmpty && !allowedFormats.contains(normalizedFormat) { + filteredByAllowed += 1 continue } let centerBucket = bucketKey(for: observation.boundingBox) - let dedupKey = "\(normalizedFormat)|\(payload)|a\(attemptIndex)|\(centerBucket)" + let dedupKey = "\(normalizedFormat)|\(payload)|\(centerBucket)" if deduplicated.insert(dedupKey).inserted { results.append( ExtractedBarcode( @@ -171,10 +342,18 @@ enum BarcodeExtractor { ) } } + trace( + "attempt=\(attemptIndex) results=\(results.count) filtered(emptyPayload=\(filteredEmptyPayload), byAllowed=\(filteredByAllowed))" + ) return results } + private static func isFatalInferenceContextError(_ error: Error) -> Bool { + let message = error.localizedDescription.lowercased() + return message.contains("inference context") + } + private static func bucketKey(for rect: CGRect, bucketCount: Int = 24) -> String { let centerX = min(max((rect.minX + rect.maxX) * 0.5, 0), 1) let centerY = min(max((rect.minY + rect.maxY) * 0.5, 0), 1) @@ -274,9 +453,275 @@ enum BarcodeExtractor { if raw.contains("upce") { return "upce" } return "unknown" } + + private static func detectQrWithCoreImage( + _ image: UIImage, + attemptIndex: Int + ) -> [ExtractedBarcode] { + guard let ciImage = image.ciImage ?? CIImage(image: image) else { + trace("attempt=\(attemptIndex) CoreImage QR skip: cannot build CIImage") + return [] + } + + let detector = CIDetector( + ofType: CIDetectorTypeQRCode, + context: nil, + options: [CIDetectorAccuracy: CIDetectorAccuracyHigh] + ) + guard let features = detector?.features(in: ciImage) as? [CIQRCodeFeature] else { + trace("attempt=\(attemptIndex) CoreImage QR no features") + return [] + } + trace("attempt=\(attemptIndex) CoreImage QR features=\(features.count)") + + var deduplicated = Set() + var results: [ExtractedBarcode] = [] + let extent = ciImage.extent + let width = max(1.0, extent.width) + let height = max(1.0, extent.height) + + for feature in features { + let value = feature.messageString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if value.isEmpty { + continue + } + + let bounds = feature.bounds + let centerX = (bounds.midX - extent.minX) / width + let centerY = (bounds.midY - extent.minY) / height + let xBucket = quantize(centerX, bucketCount: 24) + let yBucket = quantize(centerY, bucketCount: 24) + let dedupKey = "qr|\(value)|\(xBucket):\(yBucket)" + + if deduplicated.insert(dedupKey).inserted { + trace("attempt=\(attemptIndex) CoreImage QR value=\(value)") + results.append( + ExtractedBarcode( + value: value, + format: "qr", + instanceKey: dedupKey + ) + ) + } + } + + trace("attempt=\(attemptIndex) CoreImage QR results=\(results.count)") + return results + } + + private static func detectWithNumericOcrFallback( + _ image: UIImage, + allowedFormats: Set, + attemptIndex: Int + ) -> [ExtractedBarcode] { + let supportedFormats: Set = ["ean8", "ean13", "upca", "itf"] + let targetFormats: Set = { + if allowedFormats.isEmpty { + return supportedFormats + } + return allowedFormats.intersection(supportedFormats) + }() + + guard !targetFormats.isEmpty else { + trace("attempt=\(attemptIndex) OCR 1D skip: no supported target formats") + return [] + } + + guard let cgImage = image.cgImage else { + trace("attempt=\(attemptIndex) OCR 1D skip: missing cgImage") + return [] + } + + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.usesLanguageCorrection = false + request.minimumTextHeight = 0.02 + + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) + do { + try handler.perform([request]) + } catch { + trace("attempt=\(attemptIndex) OCR 1D perform failed error=\(error.localizedDescription)") + return [] + } + + guard let observations = request.results as? [VNRecognizedTextObservation], !observations.isEmpty else { + trace("attempt=\(attemptIndex) OCR 1D no observations") + return [] + } + + var rawCandidates: [String] = [] + rawCandidates.reserveCapacity(observations.count * 3) + + for observation in observations { + for candidate in observation.topCandidates(3) { + rawCandidates.append(candidate.string) + } + } + + let combinedTopLineOrder = observations + .compactMap { observation -> (box: CGRect, text: String)? in + guard let top = observation.topCandidates(1).first else { + return nil + } + return (observation.boundingBox, top.string) + } + .sorted { lhs, rhs in + let lhsMidY = lhs.box.midY + let rhsMidY = rhs.box.midY + if abs(lhsMidY - rhsMidY) > 0.03 { + return lhsMidY > rhsMidY + } + return lhs.box.minX < rhs.box.minX + } + .map(\.text) + .joined(separator: " ") + + if !combinedTopLineOrder.isEmpty { + rawCandidates.append(combinedTopLineOrder) + } + + var dedup = Set() + var results: [ExtractedBarcode] = [] + + for raw in rawCandidates { + let digits = digitsOnly(raw) + guard digits.count >= 8 else { + continue + } + + for detected in parseGtinCandidates( + from: digits, + allowedFormats: targetFormats + ) { + let dedupKey = "\(detected.format)|\(detected.value)|ocr" + if dedup.insert(dedupKey).inserted { + results.append( + ExtractedBarcode( + value: detected.value, + format: detected.format, + instanceKey: dedupKey + ) + ) + } + } + } + + trace("attempt=\(attemptIndex) OCR 1D results=\(results.count)") + return results + } + + private static func parseGtinCandidates( + from digits: String, + allowedFormats: Set + ) -> [(value: String, format: String)] { + let allowedLengths = candidateLengths(forAllowedFormats: allowedFormats) + guard !allowedLengths.isEmpty else { + return [] + } + + let scalars = Array(digits) + guard scalars.count >= allowedLengths.min() ?? 0 else { + return [] + } + + var dedup = Set() + var output: [(value: String, format: String)] = [] + + for length in allowedLengths where scalars.count >= length { + for start in 0...(scalars.count - length) { + let candidate = String(scalars[start..<(start + length)]) + guard hasValidGTINCheckDigit(candidate) else { + continue + } + guard let format = formatForValidatedCandidate( + candidate, + allowedFormats: allowedFormats + ) else { + continue + } + + let dedupKey = "\(format)|\(candidate)" + if dedup.insert(dedupKey).inserted { + output.append((candidate, format)) + } + } + } + + return output + } + + private static func candidateLengths(forAllowedFormats allowedFormats: Set) -> [Int] { + var lengths = Set() + if allowedFormats.contains("itf") { + lengths.insert(14) + } + if allowedFormats.contains("ean13") { + lengths.insert(13) + } + if allowedFormats.contains("upca") { + lengths.insert(12) + } + if allowedFormats.contains("ean8") { + lengths.insert(8) + } + return lengths.sorted(by: >) + } + + private static func formatForValidatedCandidate( + _ value: String, + allowedFormats: Set + ) -> String? { + switch value.count { + case 14 where allowedFormats.contains("itf"): + return "itf" + case 13 where allowedFormats.contains("ean13"): + return "ean13" + case 12 where allowedFormats.contains("upca"): + return "upca" + case 8 where allowedFormats.contains("ean8"): + return "ean8" + default: + return nil + } + } + + private static func hasValidGTINCheckDigit(_ value: String) -> Bool { + let digits = value.compactMap(\.wholeNumberValue) + guard digits.count == value.count, + [8, 12, 13, 14].contains(digits.count), + let checkDigit = digits.last else { + return false + } + + let payload = digits.dropLast().reversed() + let weightedSum = payload.enumerated().reduce(0) { partial, entry in + let (index, digit) = entry + let weight = (index % 2 == 0) ? 3 : 1 + return partial + (digit * weight) + } + let computed = (10 - (weightedSum % 10)) % 10 + return computed == checkDigit + } + + private static func digitsOnly(_ value: String) -> String { + let scalars = value.unicodeScalars.filter { CharacterSet.decimalDigits.contains($0) } + return String(String.UnicodeScalarView(scalars)) + } } -private extension UIImage { +extension UIImage { + func normalizedForVision() -> UIImage { + if imageOrientation == .up { + return self + } + + UIGraphicsBeginImageContextWithOptions(size, false, scale) + defer { UIGraphicsEndImageContext() } + draw(in: CGRect(origin: .zero, size: size)) + return UIGraphicsGetImageFromCurrentImageContext() ?? self + } + func rotated(by radians: CGFloat) -> UIImage? { var newSize = CGRect(origin: .zero, size: size) .applying(CGAffineTransform(rotationAngle: radians)) diff --git a/ios/DocScanner/Barcode/BarcodeImageSource.swift b/ios/DocScanner/Barcode/BarcodeImageSource.swift index 05fd0ff..43fe3cf 100644 --- a/ios/DocScanner/Barcode/BarcodeImageSource.swift +++ b/ios/DocScanner/Barcode/BarcodeImageSource.swift @@ -1,20 +1,59 @@ import Foundation +import Photos import UIKit enum BarcodeImageSource { + private static let photoKitRequestTimeoutSeconds: TimeInterval = 15 + static func loadImage(from imageSource: String) -> UIImage? { let normalized = imageSource.trimmingCharacters(in: .whitespacesAndNewlines) if normalized.isEmpty { + DocScannerDebugLog.log("BarcodeImageSource", "empty source string") return nil } - if let localFromUri = loadFromFileUri(normalized) { return localFromUri } - if let localFromPath = loadFromPath(normalized) { return localFromPath } + let scheme = URL(string: normalized)?.scheme?.lowercased() ?? "path-or-base64" + DocScannerDebugLog.log( + "BarcodeImageSource", + "load start scheme=\(scheme) length=\(normalized.count)" + ) + + if let localFromUri = loadFromFileUri(normalized) { + DocScannerDebugLog.log( + "BarcodeImageSource", + "loaded from file:// uri size=\(Int(localFromUri.size.width))x\(Int(localFromUri.size.height))" + ) + return localFromUri + } + if let localFromPath = loadFromPath(normalized) { + DocScannerDebugLog.log( + "BarcodeImageSource", + "loaded from path size=\(Int(localFromPath.size.width))x\(Int(localFromPath.size.height))" + ) + return localFromPath + } + if let photoKitImage = loadFromPhotoKitLocalIdentifier(normalized) { + DocScannerDebugLog.log( + "BarcodeImageSource", + "loaded from PhotoKit size=\(Int(photoKitImage.size.width))x\(Int(photoKitImage.size.height))" + ) + return photoKitImage + } if let data = decodeBase64Data(normalized) { - return UIImage(data: data) + let image = UIImage(data: data) + if let image { + DocScannerDebugLog.log( + "BarcodeImageSource", + "loaded from base64 size=\(Int(image.size.width))x\(Int(image.size.height))" + ) + } else { + DocScannerDebugLog.log("BarcodeImageSource", "base64 decode ok, UIImage init failed") + } + return image } + DocScannerDebugLog.log("BarcodeImageSource", "all loaders failed") return nil } @@ -28,6 +67,7 @@ enum BarcodeImageSource { } if let data = loadDataFromSecurityScopedURL(url) { + DocScannerDebugLog.log("BarcodeImageSource", "loaded file:// via security-scoped data") return UIImage(data: data) } @@ -53,6 +93,65 @@ enum BarcodeImageSource { return nil } + private static func loadFromPhotoKitLocalIdentifier(_ value: String) -> UIImage? { + guard let url = URL(string: value), + let scheme = url.scheme?.lowercased(), + scheme == "ph" || scheme == "photos" else { + return nil + } + + var localIdentifier = value.replacingOccurrences(of: "\(scheme)://", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + if localIdentifier.hasPrefix("/") { + localIdentifier.removeFirst() + } + if let decoded = localIdentifier.removingPercentEncoding, !decoded.isEmpty { + localIdentifier = decoded + } + guard !localIdentifier.isEmpty else { + return nil + } + + let fetchResult = PHAsset.fetchAssets( + withLocalIdentifiers: [localIdentifier], + options: nil + ) + guard let asset = fetchResult.firstObject else { + DocScannerDebugLog.log("BarcodeImageSource", "PhotoKit asset not found for local identifier") + return nil + } + + let options = PHImageRequestOptions() + options.isSynchronous = false + options.isNetworkAccessAllowed = true + options.deliveryMode = .highQualityFormat + options.version = .current + + var imageData: Data? + let semaphore = DispatchSemaphore(value: 0) + let imageManager = PHImageManager.default() + let requestId = imageManager.requestImageDataAndOrientation( + for: asset, + options: options + ) { data, _, _, _ in + imageData = data + semaphore.signal() + } + + let waitResult = semaphore.wait(timeout: .now() + photoKitRequestTimeoutSeconds) + if waitResult == .timedOut { + imageManager.cancelImageRequest(requestId) + DocScannerDebugLog.log("BarcodeImageSource", "PhotoKit request timed out") + return nil + } + + guard let imageData else { + DocScannerDebugLog.log("BarcodeImageSource", "PhotoKit returned no image data") + return nil + } + return UIImage(data: imageData) + } + private static func decodeBase64Data(_ value: String) -> Data? { let payload: String if let marker = value.range(of: "base64,") { @@ -61,7 +160,11 @@ enum BarcodeImageSource { payload = value } - return Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) + let decoded = Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) + if decoded == nil { + DocScannerDebugLog.log("BarcodeImageSource", "base64 decode failed") + } + return decoded } private static func loadDataFromSecurityScopedURL(_ url: URL) -> Data? { @@ -94,6 +197,7 @@ enum BarcodeImageSource { return try? Data(contentsOf: url, options: [.mappedIfSafe]) } + DocScannerDebugLog.log("BarcodeImageSource", "security-scoped read failed for url=\(url.absoluteString)") return nil } diff --git a/ios/DocScanner/DebugLog.swift b/ios/DocScanner/DebugLog.swift new file mode 100644 index 0000000..f0ed29a --- /dev/null +++ b/ios/DocScanner/DebugLog.swift @@ -0,0 +1,33 @@ +import Foundation + +enum DocScannerDebugLog { + private static var isEnabled: Bool { + let env = ProcessInfo.processInfo.environment + return env["DOCUMENT_SCANNER_DEBUG_LOGS"] == "1" || + env["DOCUMENT_SCANNER_TRACE_LOGS"] == "1" + } + + private static var isTraceEnabled: Bool { + guard isEnabled else { + return false + } + + return ProcessInfo.processInfo.environment["DOCUMENT_SCANNER_TRACE_LOGS"] == "1" + } + + static func log(_ scope: String, _ message: @autoclosure () -> String) { + guard isEnabled else { + return + } + + NSLog("[DocumentScanner][\(scope)] \(message())") + } + + static func trace(_ scope: String, _ message: @autoclosure () -> String) { + guard isTraceEnabled else { + return + } + + NSLog("[DocumentScanner][\(scope)][trace] \(message())") + } +} diff --git a/ios/DocScanner/DocScanner.swift b/ios/DocScanner/DocScanner.swift index 03eca37..67e1aeb 100644 --- a/ios/DocScanner/DocScanner.swift +++ b/ios/DocScanner/DocScanner.swift @@ -15,6 +15,10 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { private var responseType: String private var croppedImageQuality: Int + private func log(_ message: @autoclosure () -> String) { + DocScannerDebugLog.log("DocScanner", message()) + } + public init( _ viewController: UIViewController? = nil, successHandler: @escaping ([[String: Any]]) -> Void = { _ in }, @@ -37,11 +41,13 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { public func startScan() { if !VNDocumentCameraViewController.isSupported { + log("startScan unsupported device") self.errorHandler("Document scanning is not supported on this device") return } DispatchQueue.main.async { + self.log("startScan presenting camera") let documentCameraViewController = VNDocumentCameraViewController() documentCameraViewController.delegate = self self.viewController?.present(documentCameraViewController, animated: true) @@ -62,6 +68,9 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { self.cancelHandler = cancelHandler self.responseType = responseType ?? ResponseType.imageFilePath self.croppedImageQuality = croppedImageQuality ?? 100 + log( + "configure responseType=\(self.responseType) croppedImageQuality=\(self.croppedImageQuality)" + ) self.startScan() } @@ -71,33 +80,45 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { didFinishWith scan: VNDocumentCameraScan ) { var processedResults: [[String: Any]] = [] + log( + "didFinishWith pageCount=\(scan.pageCount) responseType=\(responseType) quality=\(croppedImageQuality)" + ) for pageNumber in 0 ..< scan.pageCount { let scannedImage: UIImage = scan.imageOfPage(at: pageNumber) + log( + "page[\(pageNumber)] source size=\(Int(scannedImage.size.width))x\(Int(scannedImage.size.height)) scale=\(scannedImage.scale)" + ) guard let scannedDocumentImage: Data = scannedImage .jpegData(compressionQuality: CGFloat(self.croppedImageQuality) / CGFloat(100)) else { goBackToPreviousView(controller) + log("page[\(pageNumber)] jpeg encode failed") self.errorHandler("Unable to get scanned document in jpeg format") return } + log("page[\(pageNumber)] jpeg bytes=\(scannedDocumentImage.count)") let imageIdentifier: String switch responseType { case ResponseType.base64: imageIdentifier = scannedDocumentImage.base64EncodedString() + log("page[\(pageNumber)] encoded as base64 length=\(imageIdentifier.count)") case ResponseType.imageFilePath: do { let croppedImageFilePath = FileUtil().createImageFile(pageNumber) try scannedDocumentImage.write(to: croppedImageFilePath) imageIdentifier = croppedImageFilePath.absoluteString + log("page[\(pageNumber)] saved file=\(croppedImageFilePath.lastPathComponent)") } catch { goBackToPreviousView(controller) + log("page[\(pageNumber)] save failed error=\(error.localizedDescription)") self.errorHandler("Unable to save scanned image: \(error.localizedDescription)") return } default: goBackToPreviousView(controller) + log("page[\(pageNumber)] invalid responseType=\(responseType)") self.errorHandler("responseType must be base64 or imageFilePath") return } @@ -108,6 +129,7 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { } goBackToPreviousView(controller) + log("didFinishWith resolved pages=\(processedResults.count)") self.successHandler(processedResults) } @@ -115,6 +137,7 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { _ controller: VNDocumentCameraViewController ) { goBackToPreviousView(controller) + log("documentCameraViewControllerDidCancel") self.cancelHandler() } @@ -123,6 +146,7 @@ public class DocScanner: NSObject, VNDocumentCameraViewControllerDelegate { didFailWithError error: Error ) { goBackToPreviousView(controller) + log("documentCameraViewController didFailWithError=\(error.localizedDescription)") self.errorHandler(error.localizedDescription) } diff --git a/ios/DocumentScanner.swift b/ios/DocumentScanner.swift index 8f21468..3c0356d 100644 --- a/ios/DocumentScanner.swift +++ b/ios/DocumentScanner.swift @@ -25,6 +25,10 @@ public class DocumentScannerImpl: NSObject { let imageSource: String } + private func log(_ scope: String, _ message: @autoclosure () -> String) { + DocScannerDebugLog.log(scope, message()) + } + @objc static func requiresMainQueueSetup() -> Bool { true } @objc(scanDocument:resolve:reject:) @@ -33,6 +37,7 @@ public class DocumentScannerImpl: NSObject { resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { + log("scanDocument", "invoked") guard #available(iOS 13.0, *) else { reject("unsupported_ios", "iOS 13.0 or higher required", nil) return @@ -42,12 +47,17 @@ public class DocumentScannerImpl: NSObject { let responseType = opts["responseType"] as? String let quality = opts["croppedImageQuality"] as? Int let isBase64Response = responseType?.lowercased() == "base64" + log( + "scanDocument", + "responseType=\(responseType ?? "default") quality=\(quality ?? 100) base64=\(isBase64Response)" + ) DispatchQueue.main.async { self.docScanner = DocScanner() self.docScanner?.startScan( RCTPresentedViewController(), successHandler: { (scannedData: [[String: Any]]) in + self.log("scanDocument", "native scanner returned pages=\(scannedData.count)") let fm = FileManager.default var sanitizedImages: [String] = [] @@ -64,6 +74,7 @@ public class DocumentScannerImpl: NSObject { path = trimmed } if !fm.fileExists(atPath: path) { + self.log("scanDocument", "skip non-existing file path for scanned page") continue } } @@ -75,13 +86,16 @@ public class DocumentScannerImpl: NSObject { "status": "success", "scannedImages": sanitizedImages ]) + self.log("scanDocument", "resolved scannedImages=\(sanitizedImages.count)") self.docScanner = nil }, errorHandler: { msg in + self.log("scanDocument", "error=\(msg)") reject("document_scan_error", msg, nil) self.docScanner = nil }, cancelHandler: { + self.log("scanDocument", "cancelled") resolve([ "status": "cancel", "scannedImages": [] @@ -100,6 +114,7 @@ public class DocumentScannerImpl: NSObject { resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { + log("extractBarcodesFromImages", "invoked") guard #available(iOS 13.0, *) else { reject("unsupported_ios", "iOS 13.0 or higher required", nil) return @@ -113,8 +128,13 @@ public class DocumentScannerImpl: NSObject { let requestedConcurrency = opts["concurrency"] as? Int ?? 2 let concurrency = max(1, min(2, requestedConcurrency)) + log( + "extractBarcodesFromImages", + "rawImages=\(rawImages.count) validSources=\(sources.count) concurrency=\(concurrency) allowedFormats=\(allowedFormats)" + ) if sources.isEmpty { + log("extractBarcodesFromImages", "no valid sources, resolve []") resolve([]) return } @@ -134,17 +154,45 @@ public class DocumentScannerImpl: NSObject { options: modernOptions, concurrency: concurrency ) { analysis in - resolve(analysis.barcodes.map(self.toDictionary)) + let modernBarcodes = analysis.barcodes.map(self.toDictionary) + self.log( + "extractBarcodesFromImages", + "modern analysis barcodes=\(modernBarcodes.count)" + ) + if !modernBarcodes.isEmpty { + self.log("extractBarcodesFromImages", "resolved with modern barcodes") + resolve(modernBarcodes) + return + } + + // Fallback for non-document gallery assets where RecognizeDocuments may miss 1D barcodes. + self.log("extractBarcodesFromImages", "modern empty, starting legacy fallback") + self.performBarcodeExtraction( + sources: sources, + allowedFormats: allowedFormats, + concurrency: concurrency + ) { legacyBarcodes in + self.log( + "extractBarcodesFromImages", + "legacy fallback barcodes=\(legacyBarcodes.count)" + ) + resolve(legacyBarcodes) + } } return } // TODO(preeternal): Remove this legacy barcode path when iOS 26+ becomes the practical baseline. + log("extractBarcodesFromImages", "using legacy path (iOS < 26)") performBarcodeExtraction( sources: sources, allowedFormats: allowedFormats, concurrency: concurrency ) { extractedBarcodes in + self.log( + "extractBarcodesFromImages", + "legacy path resolved barcodes=\(extractedBarcodes.count)" + ) resolve(extractedBarcodes) } } @@ -155,6 +203,7 @@ public class DocumentScannerImpl: NSObject { resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { + log("extractTextFromImages", "invoked") guard #available(iOS 13.0, *) else { reject("unsupported_ios", "iOS 13.0 or higher required", nil) return @@ -166,8 +215,13 @@ public class DocumentScannerImpl: NSObject { let ocrRotate180Fallback = opts["ocrRotate180Fallback"] as? Bool ?? false let requestedConcurrency = opts["concurrency"] as? Int ?? 2 let concurrency = max(1, min(2, requestedConcurrency)) + log( + "extractTextFromImages", + "rawImages=\(rawImages.count) validSources=\(sources.count) concurrency=\(concurrency) rotate180=\(ocrRotate180Fallback)" + ) if sources.isEmpty { + log("extractTextFromImages", "no valid sources, resolve []") resolve([]) return } @@ -187,6 +241,7 @@ public class DocumentScannerImpl: NSObject { options: modernOptions, concurrency: concurrency ) { analysis in + self.log("extractTextFromImages", "modern textBlocks=\(analysis.textBlocks.count)") resolve(self.toDictionaryArray(analysis.textBlocks)) } return @@ -198,6 +253,7 @@ public class DocumentScannerImpl: NSObject { ocrRotate180Fallback: ocrRotate180Fallback, concurrency: concurrency ) { textBlocks in + self.log("extractTextFromImages", "legacy textBlocks=\(textBlocks.count)") resolve(self.toDictionaryArray(textBlocks)) } } @@ -208,6 +264,7 @@ public class DocumentScannerImpl: NSObject { resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { + log("analyzeScannedImages", "invoked") guard #available(iOS 13.0, *) else { reject("unsupported_ios", "iOS 13.0 or higher required", nil) return @@ -223,9 +280,14 @@ public class DocumentScannerImpl: NSObject { let wantsRegions = opts["extractRegions"] as? Bool ?? false let wantsStructuredData = opts["extractStructuredData"] as? Bool ?? false let wantsTextPipeline = wantsText || wantsTables || wantsRegions || wantsStructuredData - let ocrRotate180Fallback = opts["ocrRotate180Fallback"] as? Bool ?? false + let ocrRotate180Fallback = opts["ocrRotate180Fallback"] as? Bool ?? true + log( + "analyzeScannedImages", + "rawImages=\(rawImages.count) validSources=\(sources.count) extract(barcodes=\(wantsBarcodes), text=\(wantsText), tables=\(wantsTables), regions=\(wantsRegions), structured=\(wantsStructuredData)) rotate180=\(ocrRotate180Fallback)" + ) if sources.isEmpty || (!wantsBarcodes && !wantsTextPipeline) { + log("analyzeScannedImages", "nothing to analyze, resolve success") resolve(["status": "success"]) return } @@ -234,6 +296,10 @@ public class DocumentScannerImpl: NSObject { let concurrency = max(1, min(2, requestedConcurrency)) let allowedFormats = (opts["barcodeFormats"] as? [Any] ?? []) .compactMap { $0 as? String } + log( + "analyzeScannedImages", + "concurrency=\(concurrency) allowedFormats=\(allowedFormats)" + ) if #available(iOS 26.0, *) { let modernOptions = RecognizeDocumentsAnalyzer.Options( @@ -250,36 +316,66 @@ public class DocumentScannerImpl: NSObject { options: modernOptions, concurrency: concurrency ) { analysis in - var response: [String: Any] = [ - "status": "success" - ] + self.log( + "analyzeScannedImages", + "modern result barcodes=\(analysis.barcodes.count) textBlocks=\(analysis.textBlocks.count) tables=\(analysis.tables.count) regions=\(analysis.regions.count)" + ) + let finalize: ([[String: Any]]) -> Void = { barcodePayload in + var response: [String: Any] = [ + "status": "success" + ] - if wantsBarcodes { - response["barcodes"] = analysis.barcodes.map(self.toDictionary) - } + if wantsBarcodes { + response["barcodes"] = barcodePayload + } - if wantsText { - let mappedText = self.toDictionaryArray(analysis.textBlocks) - response["textBlocks"] = mappedText - response["text"] = mappedText - } + if wantsText { + let mappedText = self.toDictionaryArray(analysis.textBlocks) + response["textBlocks"] = mappedText + response["text"] = mappedText + } - if wantsTables { - response["tables"] = analysis.tables.map(self.toDictionary) - } + if wantsTables { + response["tables"] = analysis.tables.map(self.toDictionary) + } - if wantsRegions { - response["regions"] = analysis.regions.map(self.toDictionary) - } + if wantsRegions { + response["regions"] = analysis.regions.map(self.toDictionary) + } - if wantsStructuredData { - let mapped = self.toDictionary(analysis.structuredData) - if mapped["entities"] != nil || mapped["fields"] != nil { - response["structuredData"] = mapped + if wantsStructuredData { + let mapped = self.toDictionary(analysis.structuredData) + if mapped["entities"] != nil || mapped["fields"] != nil { + response["structuredData"] = mapped + } + } + + resolve(response) + self.log( + "analyzeScannedImages", + "resolved status=success barcodes=\(barcodePayload.count)" + ) + } + + let modernBarcodes = analysis.barcodes.map(self.toDictionary) + if wantsBarcodes && modernBarcodes.isEmpty { + // Fallback for non-document gallery assets where RecognizeDocuments may miss 1D barcodes. + self.log("analyzeScannedImages", "modern barcodes empty, starting legacy barcode fallback") + self.performBarcodeExtraction( + sources: sources, + allowedFormats: allowedFormats, + concurrency: concurrency + ) { legacyBarcodes in + self.log( + "analyzeScannedImages", + "legacy barcode fallback count=\(legacyBarcodes.count)" + ) + finalize(legacyBarcodes) } + return } - resolve(response) + finalize(modernBarcodes) } return } @@ -301,6 +397,10 @@ public class DocumentScannerImpl: NSObject { ) { extracted in barcodes = extracted barcodeStage = .success + self.log( + "analyzeScannedImages", + "legacy barcode stage finished count=\(extracted.count)" + ) group.leave() } } @@ -315,6 +415,10 @@ public class DocumentScannerImpl: NSObject { ) { extracted in textBlocks = extracted textStage = .success + self.log( + "analyzeScannedImages", + "legacy text stage finished count=\(extracted.count)" + ) group.leave() } } @@ -355,26 +459,36 @@ public class DocumentScannerImpl: NSObject { } resolve(response) + self.log( + "analyzeScannedImages", + "legacy resolved status=\(response["status"] as? String ?? "unknown") barcodes=\(barcodes.count) textBlocks=\(textBlocks.count)" + ) } } @objc public func invalidate() { + log("lifecycle", "invalidate called, cancelling analysis queue") docScanner = nil analysisQueue.cancelAllOperations() } private func buildImageSources(_ rawImages: [Any]) -> [IndexedImageSource] { - return rawImages.enumerated().compactMap { index, source in + let mapped = rawImages.enumerated().compactMap { entry -> IndexedImageSource? in + let (index, source) = entry guard let imageSource = source as? String else { + self.log("sources", "skip source[\(index)] not a string") return nil } let normalized = imageSource.trimmingCharacters(in: .whitespacesAndNewlines) if normalized.isEmpty { + self.log("sources", "skip source[\(index)] empty") return nil } return IndexedImageSource(sourceImageIndex: index, imageSource: normalized) } + log("sources", "mapped valid sources=\(mapped.count) from raw=\(rawImages.count)") + return mapped } @available(iOS 26.0, *) @@ -393,6 +507,10 @@ public class DocumentScannerImpl: NSObject { concurrency: Int, completion: @escaping (ModernAnalysisResult) -> Void ) { + log( + "performModernAnalysis", + "start sources=\(sources.count) concurrency=\(concurrency) include(barcodes=\(options.includeBarcodes), text=\(options.includeText), tables=\(options.includeTables), regions=\(options.includeRegions), structured=\(options.includeStructuredData)) allowedFormats=\(options.allowedBarcodeFormats)" + ) if sources.isEmpty { DispatchQueue.main.async { completion(ModernAnalysisResult()) @@ -424,14 +542,23 @@ public class DocumentScannerImpl: NSObject { } guard let image = BarcodeImageSource.loadImage(from: source.imageSource) else { + self.log("performModernAnalysis", "source[\(source.sourceImageIndex)] image load failed") return } + self.log( + "performModernAnalysis", + "source[\(source.sourceImageIndex)] image loaded size=\(Int(image.size.width))x\(Int(image.size.height))" + ) let page = RecognizeDocumentsAnalyzer.analyzeImageBlocking( image, sourceImageIndex: source.sourceImageIndex, options: options ) + self.log( + "performModernAnalysis", + "source[\(source.sourceImageIndex)] page result barcodes=\(page.barcodes.count) textBlocks=\(page.textBlocks.count) tables=\(page.tables.count) regions=\(page.regions.count)" + ) lock.lock() pages.append(page) @@ -503,6 +630,10 @@ public class DocumentScannerImpl: NSObject { entities: entities, fields: fields ) + self.log( + "performModernAnalysis", + "merged barcodes=\(merged.barcodes.count) textBlocks=\(merged.textBlocks.count) tables=\(merged.tables.count) regions=\(merged.regions.count)" + ) completion(merged) } @@ -514,6 +645,10 @@ public class DocumentScannerImpl: NSObject { concurrency: Int, completion: @escaping ([[String: Any]]) -> Void ) { + log( + "performBarcodeExtraction", + "start sources=\(sources.count) concurrency=\(concurrency) allowedFormats=\(allowedFormats)" + ) if sources.isEmpty { DispatchQueue.main.async { completion([]) @@ -545,13 +680,22 @@ public class DocumentScannerImpl: NSObject { } guard let image = BarcodeImageSource.loadImage(from: source.imageSource) else { + self.log("performBarcodeExtraction", "source[\(source.sourceImageIndex)] image load failed") return } + self.log( + "performBarcodeExtraction", + "source[\(source.sourceImageIndex)] image loaded size=\(Int(image.size.width))x\(Int(image.size.height))" + ) let detected = BarcodeExtractor.extractFromImage( image, allowedFormats: allowedFormats ) + self.log( + "performBarcodeExtraction", + "source[\(source.sourceImageIndex)] raw detected=\(detected.count)" + ) guard !detected.isEmpty else { return @@ -571,6 +715,10 @@ public class DocumentScannerImpl: NSObject { } guard !mapped.isEmpty else { + self.log( + "performBarcodeExtraction", + "source[\(source.sourceImageIndex)] mapped empty after sanitization" + ) return } @@ -596,6 +744,7 @@ public class DocumentScannerImpl: NSObject { } completion(sorted) + self.log("performBarcodeExtraction", "completed total barcodes=\(sorted.count)") } } @@ -605,6 +754,10 @@ public class DocumentScannerImpl: NSObject { concurrency: Int, completion: @escaping ([AnalysisTextBlock]) -> Void ) { + log( + "performTextExtraction", + "start sources=\(sources.count) concurrency=\(concurrency) rotate180=\(ocrRotate180Fallback)" + ) if sources.isEmpty { DispatchQueue.main.async { completion([]) @@ -636,6 +789,7 @@ public class DocumentScannerImpl: NSObject { } guard let image = BarcodeImageSource.loadImage(from: source.imageSource) else { + self.log("performTextExtraction", "source[\(source.sourceImageIndex)] image load failed") return } @@ -644,6 +798,10 @@ public class DocumentScannerImpl: NSObject { sourceImageIndex: source.sourceImageIndex, enableRotate180Fallback: ocrRotate180Fallback ) + self.log( + "performTextExtraction", + "source[\(source.sourceImageIndex)] text blocks=\(detected.count)" + ) guard !detected.isEmpty else { return @@ -678,6 +836,7 @@ public class DocumentScannerImpl: NSObject { } completion(sorted) + self.log("performTextExtraction", "completed total textBlocks=\(sorted.count)") } } diff --git a/package.json b/package.json index 882ef5e..e164e4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@preeternal/react-native-document-scanner-plugin", - "version": "0.2.2", + "version": "0.3.0", "description": "Fork of react-native-document-scanner-plugin with New Architecture (TurboModule) support and active maintenance.", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -12,12 +12,14 @@ }, "./package.json": "./package.json" }, + "expo": "./app.plugin.js", "files": [ "src", "lib", "android", "ios", "cpp", + "app.plugin.js", "*.podspec", "react-native.config.js", "!ios/build", diff --git a/review.md b/review.md new file mode 100644 index 0000000..4ce8c2e --- /dev/null +++ b/review.md @@ -0,0 +1,283 @@ +Создано задач: 5 + +Full code review of new features + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Barcode) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/text) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-barcode) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-text) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode/java) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/text/java) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-barcode/java) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-text/java) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/analysis) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/barcode) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/text) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode/java/com) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/text/java/com) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-barcode/java/com) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-text/java/com) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode/java/com/preeternal) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/text/java/com/preeternal) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-barcode/java/com/preeternal) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-text/java/com/preeternal) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode/java/com/preeternal/scanner) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/text/java/com/preeternal/scanner) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-barcode/java/com/preeternal/scanner) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-text/java/com/preeternal/scanner) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode/java/com/preeternal/scanner/barcode) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/text/java/com/preeternal/scanner/text) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-barcode/java/com/preeternal/scanner/barcode) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-text/java/com/preeternal/scanner/text) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/src/index.tsx) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/src/__tests__/index.test.tsx) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/src/NativeDocumentScanner.ts#1-1), lines 1 to 300 + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/src/NativeDocumentScanner.ts#300-300), lines 300 to 356 + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/package.json#1-1), lines 1 to 100 + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocumentScanner.swift#1-1), lines 1 to 800 + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocumentScanner.h) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocumentScanner.mm) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocumentScanner.swift#800-800), lines 800 to 1010 + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/DebugLog.swift) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/ResponseType.swift) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Errors.swift) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/DocScanner.swift) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/FileUtil.swift) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/RecognizeDocumentsAnalyzer.swift#1-1), lines 1 to 600 + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/AnalysisModels.swift) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Barcode/BarcodeExtractor.swift#1-1), lines 1 to 400 + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Barcode/BarcodeExtractor.swift#400-400), lines 400 to 600 + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Barcode/BarcodeImageSource.swift) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/TextExtractor.swift) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/DocumentSemantics.swift) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/StructuredDataNormalizer.swift) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/DocScannerDebugLog.kt) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt#1-1), lines 1 to 800 + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/analysis/DocumentSemantics.kt) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/barcode/BarcodeExtractor.kt) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/barcode/BarcodeFormats.kt) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/build.gradle) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/analysis/StructuredDataNormalizer.kt) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/text/java/com/preeternal/scanner/text/TextExtractorImpl.kt) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-text/java/com/preeternal/scanner/text/TextExtractorImpl.kt) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeImageSourceLoader.kt) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/text/java/com/preeternal/scanner/text/TextInputImageLoader.kt) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt#500-500), lines 500 to 800 + +Searched for regex `data class TextBlockResult|data class TextLineResult|data class NormalizedBoundingBox|data class TextExtractor` (`**/android/**`), 3 results + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt#500-500), lines 500 to 600 + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/text/TextExtractor.kt) + +Searched for regex `clearPending|parseAllowedFormats|getArrayOrNull|getIntOrNull|getBooleanOrNull|readStringAt|canReadUri|uriToBase64|toWritableTableArray|toWritableStructuredData|resolveOnUi|rejectOnUi|ensureSystemBarsVisible|startScan` (`**/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt`), 20 results + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt#1081-1081), lines 1081 to 1081 + +Searched for regex `private fun.*String|private fun.*Array|private fun.*Map` (`**/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt`), 20 results + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt#780-780), lines 780 to 1050 + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt#1050-1050), lines 1050 to 1081 + +Read [](file:///Users/preeternal/Library/Application%20Support/Code/User/workspaceStorage/25fdb886f9420bcb97243c8660fa61cb/GitHub.copilot-chat/chat-session-resources/f3487d33-d58f-4084-84be-1f0f505039c4/toolu_bdrk_01AnVhsPVkVstBMNovMGerkV__vscode-1776525468529/content.txt) + +Запуск: *Summarize findings* (5/5) + +Вот теперь давай посмотрю некоторые ключевые места более детально, чтобы подтвердить самые критичные находки. + +Verify critical Android issues + +Searched for files matching `**/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt`, 1 match + +Searched for files matching `**/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt`, 1 match + +Searched for files matching `**/ios/DocScanner/DocScanner.swift`, 1 match + +Searched for files matching `**/ios/DocScanner/Barcode/BarcodeExtractor.swift`, 1 match + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/DocScanner.swift) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Barcode/BarcodeExtractor.swift) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/AnalysisModels.swift) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/TextExtractor.swift) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/RecognizeDocumentsAnalyzer.swift) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/DocumentSemantics.swift) + +Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/StructuredDataNormalizer.swift) + +Выполнено: *Summarize findings* (5/5) + +--- + +## Code Review: v0.2.2 → v0.3.0 + +### Общая оценка + +| Категория | Оценка | Комментарий | +|-----------|--------|-------------| +| API Design / TypeScript | **A** | Хорошо типизированные, консистентные API с JSDoc | +| Кроссплатформенная консистентность | **A** | Одинаковые response shape, форматы, нормализация | +| iOS реализация | **B−** | Чистая архитектура, но есть нюансы с fallback-цепочками | +| Android реализация | **B−** | Чистый Kotlin, но утечки ресурсов | +| Безопасность | **B+** | Валидация base64/путей на месте, мелкие gap'ы | +| Тесты | **F** | Нет тестов (только `it.todo('write a test')`) | +| Код-качество | **A−** | Хорошая модуляризация, TODO'шки, silent regex | + +--- + +### Критические находки + +#### 1. Android: Утечка памяти в `scannerCache` + +В BarcodeExtractorImpl.kt — `scannerCache = mutableMapOf()` **неограничен и никогда не очищается**. Каждая уникальная комбинация форматов создаёт новую запись. На практике комбинаций мало, но формально это leak. + +**Рекомендация:** Либо ограничить размер (LRU), либо очищать в `invalidate()`, либо просто задокументировать, что на практике ключей ≤ 5. + +#### 2. Android: Рекурсивная `processPages()` + +В DocumentScannerModule.kt — `processPages()` вызывает сама себя с `pageIndex + 1`. При 100+ страницах — потенциальный stack overflow. + +**Рекомендация:** Заменить на итеративную реализацию (цикл `for`). + +#### 3. Android: `launcher` не очищается в `invalidate()` + +`invalidate()` отменяет только `scope.cancel()`, но `launcher` не обнуляется. При пересоздании модуля может накапливаться мусор. + +#### 4. Android: Рекурсивная `processAttempt()` (8 уровней) + +В BarcodeExtractorImpl.kt — рекурсия до 8 вызовов. На практике 8 уровней стек не убьют, но это код-запах. Рассмотреть итеративную версию в будущем. + +--- + +### Средние находки + +#### 5. iOS: 11 regex с молчаливым отказом (`try?`) + +В DocumentSemantics.swift (6 regex) и StructuredDataNormalizer.swift (5 regex) — все компилируются через `try?`. Если regex невалиден, он станет `nil` и весь matching тихо отключится **без единого лога**. + +**Рекомендация:** Эти regex — `static let`, компилируются один раз. Паттерны фиксированные, так что вероятность ошибки низкая. Но для safety стоит добавить `assertionFailure` в debug-билдах или хотя бы `DebugLog.warning()` при `nil`. + +#### 6. Таймауты не конфигурируемые + +```kotlin +private const val BARCODE_EXTRACTION_TIMEOUT_MS = 10_000L // 10 сек +private const val TEXT_EXTRACTION_TIMEOUT_MS = 25_000L // 25 сек +``` + +На старых устройствах может не хватить. Сейчас не критично, но стоит держать в уме для v0.4+. + +#### 7. iOS: `synchronous: true` в PhotoKit загрузке + +`PHImageRequestOptions` с `synchronous: true` блокирует текущий поток. Хоть это и вызывается на фоновой очереди, для больших изображений может тормозить. + +--- + +### Положительные моменты + +- **API design отличный**: JSDoc, чёткие типы, `ScanDocumentResponseStatus`, backward-compatible fallback в `analyzeScannedImages()` +- **Feature gating на Android** через Gradle sourceSets — элегантно. `no-barcode/` и `no-text/` stub'ы обеспечивают type-safety без лишних зависимостей +- **Debug logging opt-in** — правильно сделан через environment vars и system properties, lazy evaluation через `@autoclosure` +- **Нормализация данных**: дедупликация barcode/text по пространственному хешу (24×24 grid), детерминированная сортировка +- **Кроссплатформа**: одинаковые response shape, error codes, нормализация дат и валют +- **Безопасность**: валидация base64, проверка существования файлов, security-scoped resource access на iOS + +--- + +### Рекомендации по приоритету + +**До релиза (блокеры):** +1. Очистка `scannerCache` в `invalidate()` на Android +2. `processPages()` → итерация вместо рекурсии + +**Желательно до релиза:** +3. Очистка `launcher = null` в `invalidate()` +4. Хотя бы минимальные unit-тесты для TypeScript слоя + +**После релиза (v0.4+):** +5. Убрать legacy iOS barcode/OCR пути когда iOS 26+ станет baseline +6. Сделать таймауты конфигурируемыми +7. Добавить `assertionFailure` для regex компиляции +8. Заменить рекурсию в `processAttempt()` на итерацию \ No newline at end of file diff --git a/scripts/check-16kb.sh b/scripts/check-16kb.sh new file mode 100755 index 0000000..387ad4e --- /dev/null +++ b/scripts/check-16kb.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Verify 16KB page alignment for all .so files in the Android build output. +# Scanner has no custom NDK code — this checks MLKit and RN .so deps bundled +# into the example APK, which must be 16KB-aligned for Android 15+ compatibility. +set -euo pipefail + +readelf_cmd="" +if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + if command -v llvm-readelf >/dev/null 2>&1; then + readelf_cmd="llvm-readelf" + elif command -v readelf >/dev/null 2>&1; then + readelf_cmd="readelf" + elif command -v greadelf >/dev/null 2>&1; then + readelf_cmd="greadelf" + fi +else + find_readelf() { + local candidates=( + llvm-readelf + readelf + greadelf + /opt/homebrew/opt/llvm/bin/llvm-readelf + /usr/local/opt/llvm/bin/llvm-readelf + /opt/homebrew/bin/greadelf + /usr/local/bin/greadelf + ) + for candidate in "${candidates[@]}"; do + if command -v "$candidate" >/dev/null 2>&1; then + echo "$candidate" + return 0 + fi + if [ -x "$candidate" ]; then + echo "$candidate" + return 0 + fi + done + return 1 + } + readelf_cmd="$(find_readelf || true)" +fi + +if [ -z "$readelf_cmd" ]; then + echo "ERROR: neither llvm-readelf, readelf, nor greadelf found in PATH" >&2 + if [ "${GITHUB_ACTIONS:-}" != "true" ]; then + echo "Tip: brew install llvm (llvm-readelf) or binutils (greadelf), or export PATH to include them." >&2 + fi + exit 1 +fi + +files=() +if command -v rg >/dev/null 2>&1; then + while IFS= read -r line; do + [ -n "$line" ] && files+=("$line") + done < <(rg --files -g "*.so" example/android/app/build 2>/dev/null || true) +else + while IFS= read -r line; do + [ -n "$line" ] && files+=("$line") + done < <(find example/android/app/build -name "*.so" 2>/dev/null || true) +fi + +if [ "${#files[@]}" -eq 0 ]; then + echo "NOTE: no .so files found in example/android/app/build — skipping alignment check" + echo "(build Android first to get a meaningful check)" + exit 0 +fi + +failed=() +for so in "${files[@]}"; do + if ! "$readelf_cmd" -l -W "$so" 2>/dev/null | awk ' + $1=="LOAD" { load=1; if ($NF!="0x4000") bad=1 } + END { if (!load) exit 2; exit bad } + '; then + status=$? + if [ "$status" -ne 2 ]; then + failed+=("$so") + fi + fi +done + +if [ "${#failed[@]}" -gt 0 ]; then + echo "ERROR: the following .so files are not 16KB-aligned (expected LOAD alignment 0x4000):" >&2 + for f in "${failed[@]}"; do + echo " $f" >&2 + "$readelf_cmd" -l -W "$f" 2>/dev/null | grep -E "LOAD|Align" || true + done + exit 1 +fi + +echo "OK: 16KB alignment verified (${#files[@]} .so file(s) checked)" diff --git a/src/NativeDocumentScanner.ts b/src/NativeDocumentScanner.ts index 1de82e6..273c8bb 100644 --- a/src/NativeDocumentScanner.ts +++ b/src/NativeDocumentScanner.ts @@ -44,6 +44,13 @@ export interface ExtractBarcodesFromImagesOptions { * @default 2 */ concurrency?: AnalysisConcurrency; + + /** + * Android-only. Per-image barcode extraction timeout in milliseconds. + * Clamped natively to a safe range. + * @default 10000 + */ + barcodeTimeoutMs?: number; } /** @@ -73,6 +80,13 @@ export interface ExtractTextFromImagesOptions { * @default false */ ocrRotate180Fallback?: boolean; + + /** + * Android-only. Per-image OCR extraction timeout in milliseconds. + * Clamped natively to a safe range. + * @default 25000 + */ + textTimeoutMs?: number; } /** @@ -240,6 +254,13 @@ export interface AnalyzeScannedImagesOptions extends ExtractBarcodesFromImagesOptions { extract: AnalyzeExtractOptions; + /** + * Android-only. Per-image OCR extraction timeout in milliseconds for text-related stages. + * Clamped natively to a safe range. + * @default 25000 + */ + textTimeoutMs?: number; + /** * Enables adaptive OCR fallback for text/semantics stages. * @default true @@ -260,6 +281,10 @@ export interface AnalyzeScannedImagesRequest extractRegions?: boolean; extractStructuredData?: boolean; ocrRotate180Fallback?: boolean; + /** + * Android-only. Per-image OCR extraction timeout in milliseconds for text-related stages. + */ + textTimeoutMs?: number; } /** diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index bf84291..8056bc2 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -1 +1,91 @@ -it.todo('write a test'); +import ScannerApi from '../index'; +import NativeScanner from '../NativeDocumentScanner'; + +jest.mock('../NativeDocumentScanner', () => { + const module = { + scanDocument: jest.fn(), + extractBarcodesFromImages: jest.fn(), + extractTextFromImages: jest.fn(), + analyzeScannedImages: jest.fn(), + }; + + return { + __esModule: true, + default: module, + ResponseType: { + Base64: 'base64', + ImageFilePath: 'imageFilePath', + }, + ScanDocumentResponseStatus: { + Success: 'success', + Cancel: 'cancel', + }, + }; +}); + +const native = NativeScanner as unknown as { + extractBarcodesFromImages: jest.Mock; + extractTextFromImages: jest.Mock; + analyzeScannedImages: jest.Mock; +}; + +describe('public analysis API', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('normalizes barcode options and forwards timeout', async () => { + native.extractBarcodesFromImages.mockResolvedValue([]); + + await ScannerApi.extractBarcodesFromImages(['file://a.jpg'], { + barcodeFormats: ['ean13'], + barcodeTimeoutMs: 9000, + }); + + expect(native.extractBarcodesFromImages).toHaveBeenCalledWith({ + images: ['file://a.jpg'], + barcodeFormats: ['ean13'], + barcodeTimeoutMs: 9000, + concurrency: 2, + }); + }); + + it('normalizes text options and forwards timeout', async () => { + native.extractTextFromImages.mockResolvedValue([]); + + await ScannerApi.extractTextFromImages(['file://b.jpg'], { + textTimeoutMs: 20000, + }); + + expect(native.extractTextFromImages).toHaveBeenCalledWith({ + images: ['file://b.jpg'], + concurrency: 2, + textTimeoutMs: 20000, + ocrRotate180Fallback: false, + }); + }); + + it('forwards timeout options to native analyze method', async () => { + native.analyzeScannedImages.mockResolvedValue({ status: 'success' }); + + await ScannerApi.analyzeScannedImages(['file://c.jpg'], { + extract: { barcodes: true, text: true }, + barcodeTimeoutMs: 12000, + textTimeoutMs: 30000, + }); + + expect(native.analyzeScannedImages).toHaveBeenCalledWith({ + images: ['file://c.jpg'], + extractBarcodes: true, + extractText: true, + extractTables: false, + extractRegions: false, + extractStructuredData: false, + barcodeFormats: undefined, + concurrency: undefined, + barcodeTimeoutMs: 12000, + textTimeoutMs: 30000, + ocrRotate180Fallback: true, + }); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 6c53227..f436ed6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -35,6 +35,8 @@ type NativeAnalyzeFn = (options: { extractStructuredData?: boolean; barcodeFormats?: AnalyzeScannedImagesOptions['barcodeFormats']; concurrency?: AnalyzeScannedImagesOptions['concurrency']; + barcodeTimeoutMs?: AnalyzeScannedImagesOptions['barcodeTimeoutMs']; + textTimeoutMs?: AnalyzeScannedImagesOptions['textTimeoutMs']; ocrRotate180Fallback?: boolean; }) => Promise; @@ -61,6 +63,7 @@ function normalizeBarcodeOptions( ): ExtractBarcodesFromImagesOptions { return { barcodeFormats: options.barcodeFormats, + barcodeTimeoutMs: options.barcodeTimeoutMs, concurrency: options.concurrency ?? clampAnalysisConcurrency(DEFAULT_ANALYSIS_CONCURRENCY), @@ -76,6 +79,7 @@ function normalizeTextOptions( ): ExtractTextFromImagesOptions { return { ocrRotate180Fallback: options.ocrRotate180Fallback ?? false, + textTimeoutMs: options.textTimeoutMs, concurrency: options.concurrency ?? clampAnalysisConcurrency(DEFAULT_ANALYSIS_CONCURRENCY), @@ -103,6 +107,7 @@ export function extractBarcodesFromImages( return DocumentScanner.extractBarcodesFromImages({ images, barcodeFormats: normalizedOptions.barcodeFormats, + barcodeTimeoutMs: normalizedOptions.barcodeTimeoutMs, concurrency: normalizedOptions.concurrency, }); } @@ -119,6 +124,7 @@ export function extractTextFromImages( return DocumentScanner.extractTextFromImages({ images, concurrency: normalizedOptions.concurrency, + textTimeoutMs: normalizedOptions.textTimeoutMs, ocrRotate180Fallback: normalizedOptions.ocrRotate180Fallback, }); } @@ -186,6 +192,7 @@ async function runBarcodeStage( const value = await extractBarcodesFromImages(images, { barcodeFormats: options.barcodeFormats, concurrency: options.concurrency, + barcodeTimeoutMs: options.barcodeTimeoutMs, }); return { status: 'success', @@ -218,6 +225,7 @@ async function runTextStage( try { const value = await extractTextFromImages(images, { concurrency: options.concurrency, + textTimeoutMs: options.textTimeoutMs, ocrRotate180Fallback, }); return { @@ -266,6 +274,8 @@ export async function analyzeScannedImages( extractStructuredData: wantsStructuredData, barcodeFormats: options.barcodeFormats, concurrency: options.concurrency, + barcodeTimeoutMs: options.barcodeTimeoutMs, + textTimeoutMs: options.textTimeoutMs, ocrRotate180Fallback, }); } catch (_error) { diff --git a/tsconfig.json b/tsconfig.json index 14a5da6..9e1d0db 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,7 @@ "skipLibCheck": true, "strict": true, "target": "ESNext", + "types": ["jest", "node"], "verbatimModuleSyntax": true } } diff --git a/yarn.lock b/yarn.lock index 566a7dd..a6fe787 100644 --- a/yarn.lock +++ b/yarn.lock @@ -42,6 +42,17 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/code-frame@npm:7.29.0" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.28.5" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10c0/d34cc504e7765dfb576a663d97067afb614525806b5cad1a5cc1a7183b916fec8ff57fa233585e3926fd5a9e6b31aae6df91aa81ae9775fb7a28f658d3346f0d + languageName: node + linkType: hard + "@babel/compat-data@npm:^7.27.2, @babel/compat-data@npm:^7.27.7, @babel/compat-data@npm:^7.28.0": version: 7.28.0 resolution: "@babel/compat-data@npm:7.28.0" @@ -49,6 +60,13 @@ __metadata: languageName: node linkType: hard +"@babel/compat-data@npm:^7.28.6": + version: 7.29.0 + resolution: "@babel/compat-data@npm:7.29.0" + checksum: 10c0/08f348554989d23aa801bf1405aa34b15e841c0d52d79da7e524285c77a5f9d298e70e11d91cc578d8e2c9542efc586d50c5f5cf8e1915b254a9dcf786913a94 + languageName: node + linkType: hard + "@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.23.9, @babel/core@npm:^7.25.2": version: 7.28.3 resolution: "@babel/core@npm:7.28.3" @@ -72,6 +90,29 @@ __metadata: languageName: node linkType: hard +"@babel/core@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/core@npm:7.29.0" + dependencies: + "@babel/code-frame": "npm:^7.29.0" + "@babel/generator": "npm:^7.29.0" + "@babel/helper-compilation-targets": "npm:^7.28.6" + "@babel/helper-module-transforms": "npm:^7.28.6" + "@babel/helpers": "npm:^7.28.6" + "@babel/parser": "npm:^7.29.0" + "@babel/template": "npm:^7.28.6" + "@babel/traverse": "npm:^7.29.0" + "@babel/types": "npm:^7.29.0" + "@jridgewell/remapping": "npm:^2.3.5" + convert-source-map: "npm:^2.0.0" + debug: "npm:^4.1.0" + gensync: "npm:^1.0.0-beta.2" + json5: "npm:^2.2.3" + semver: "npm:^6.3.1" + checksum: 10c0/5127d2e8e842ae409e11bcbb5c2dff9874abf5415e8026925af7308e903f4f43397341467a130490d1a39884f461bc2b67f3063bce0be44340db89687fd852aa + languageName: node + linkType: hard + "@babel/eslint-parser@npm:^7.25.1": version: 7.28.0 resolution: "@babel/eslint-parser@npm:7.28.0" @@ -99,6 +140,19 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.29.0": + version: 7.29.1 + resolution: "@babel/generator@npm:7.29.1" + dependencies: + "@babel/parser": "npm:^7.29.0" + "@babel/types": "npm:^7.29.0" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10c0/349086e6876258ef3fb2823030fee0f6c0eb9c3ebe35fc572e16997f8c030d765f636ddc6299edae63e760ea6658f8ee9a2edfa6d6b24c9a80c917916b973551 + languageName: node + linkType: hard + "@babel/helper-annotate-as-pure@npm:^7.27.1, @babel/helper-annotate-as-pure@npm:^7.27.3": version: 7.27.3 resolution: "@babel/helper-annotate-as-pure@npm:7.27.3" @@ -121,6 +175,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-compilation-targets@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-compilation-targets@npm:7.28.6" + dependencies: + "@babel/compat-data": "npm:^7.28.6" + "@babel/helper-validator-option": "npm:^7.27.1" + browserslist: "npm:^4.24.0" + lru-cache: "npm:^5.1.1" + semver: "npm:^6.3.1" + checksum: 10c0/3fcdf3b1b857a1578e99d20508859dbd3f22f3c87b8a0f3dc540627b4be539bae7f6e61e49d931542fe5b557545347272bbdacd7f58a5c77025a18b745593a50 + languageName: node + linkType: hard + "@babel/helper-create-class-features-plugin@npm:^7.27.1, @babel/helper-create-class-features-plugin@npm:^7.28.3": version: 7.28.3 resolution: "@babel/helper-create-class-features-plugin@npm:7.28.3" @@ -193,6 +260,16 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-imports@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-module-imports@npm:7.28.6" + dependencies: + "@babel/traverse": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + checksum: 10c0/b49d8d8f204d9dbfd5ac70c54e533e5269afb3cea966a9d976722b13e9922cc773a653405f53c89acb247d5aebdae4681d631a3ae3df77ec046b58da76eda2ac + languageName: node + linkType: hard + "@babel/helper-module-transforms@npm:^7.27.1, @babel/helper-module-transforms@npm:^7.28.3": version: 7.28.3 resolution: "@babel/helper-module-transforms@npm:7.28.3" @@ -206,6 +283,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-transforms@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-module-transforms@npm:7.28.6" + dependencies: + "@babel/helper-module-imports": "npm:^7.28.6" + "@babel/helper-validator-identifier": "npm:^7.28.5" + "@babel/traverse": "npm:^7.28.6" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/6f03e14fc30b287ce0b839474b5f271e72837d0cafe6b172d759184d998fbee3903a035e81e07c2c596449e504f453463d58baa65b6f40a37ded5bec74620b2b + languageName: node + linkType: hard + "@babel/helper-optimise-call-expression@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-optimise-call-expression@npm:7.27.1" @@ -272,6 +362,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/helper-validator-identifier@npm:7.28.5" + checksum: 10c0/42aaebed91f739a41f3d80b72752d1f95fd7c72394e8e4bd7cdd88817e0774d80a432451bcba17c2c642c257c483bf1d409dd4548883429ea9493a3bc4ab0847 + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-validator-option@npm:7.27.1" @@ -300,6 +397,16 @@ __metadata: languageName: node linkType: hard +"@babel/helpers@npm:^7.28.6": + version: 7.29.2 + resolution: "@babel/helpers@npm:7.29.2" + dependencies: + "@babel/template": "npm:^7.28.6" + "@babel/types": "npm:^7.29.0" + checksum: 10c0/dab0e65b9318b2502a62c58bc0913572318595eec0482c31f0ad416b72636e6698a1d7c57cd2791d4528eb8c548bca88d338dc4d2a55a108dc1f6702f9bc5512 + languageName: node + linkType: hard + "@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.25.3, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.28.3": version: 7.28.3 resolution: "@babel/parser@npm:7.28.3" @@ -311,6 +418,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": + version: 7.29.2 + resolution: "@babel/parser@npm:7.29.2" + dependencies: + "@babel/types": "npm:^7.29.0" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/e5a4e69e3ac7acdde995f37cf299a68458cfe7009dff66bd0962fd04920bef287201169006af365af479c08ff216bfefbb595e331f87f6ae7283858aebbc3317 + languageName: node + linkType: hard + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.27.1" @@ -1501,6 +1619,17 @@ __metadata: languageName: node linkType: hard +"@babel/template@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/template@npm:7.28.6" + dependencies: + "@babel/code-frame": "npm:^7.28.6" + "@babel/parser": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + checksum: 10c0/66d87225ed0bc77f888181ae2d97845021838c619944877f7c4398c6748bcf611f216dfd6be74d39016af502bca876e6ce6873db3c49e4ac354c56d34d57e9f5 + languageName: node + linkType: hard + "@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3, @babel/traverse@npm:^7.25.3, @babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.28.0, @babel/traverse@npm:^7.28.3": version: 7.28.3 resolution: "@babel/traverse@npm:7.28.3" @@ -1516,6 +1645,21 @@ __metadata: languageName: node linkType: hard +"@babel/traverse@npm:^7.28.6, @babel/traverse@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/traverse@npm:7.29.0" + dependencies: + "@babel/code-frame": "npm:^7.29.0" + "@babel/generator": "npm:^7.29.0" + "@babel/helper-globals": "npm:^7.28.0" + "@babel/parser": "npm:^7.29.0" + "@babel/template": "npm:^7.28.6" + "@babel/types": "npm:^7.29.0" + debug: "npm:^4.3.1" + checksum: 10c0/f63ef6e58d02a9fbf3c0e2e5f1c877da3e0bc57f91a19d2223d53e356a76859cbaf51171c9211c71816d94a0e69efa2732fd27ffc0e1bbc84b636e60932333eb + languageName: node + linkType: hard + "@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.25.2, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": version: 7.28.2 resolution: "@babel/types@npm:7.28.2" @@ -1526,6 +1670,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/types@npm:7.29.0" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.28.5" + checksum: 10c0/23cc3466e83bcbfab8b9bd0edaafdb5d4efdb88b82b3be6728bbade5ba2f0996f84f63b1c5f7a8c0d67efded28300898a5f930b171bb40b311bca2029c4e9b4f + languageName: node + linkType: hard + "@bcoe/v8-coverage@npm:^0.2.3": version: 0.2.3 resolution: "@bcoe/v8-coverage@npm:0.2.3" @@ -2237,6 +2391,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/remapping@npm:^2.3.5": + version: 2.3.5 + resolution: "@jridgewell/remapping@npm:2.3.5" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/3de494219ffeb2c5c38711d0d7bb128097edf91893090a2dbc8ee0b55d092bb7347b1fd0f478486c5eab010e855c73927b1666f2107516d472d24a73017d1194 + languageName: node + linkType: hard + "@jridgewell/resolve-uri@npm:^3.1.0": version: 3.1.2 resolution: "@jridgewell/resolve-uri@npm:3.1.2" @@ -2499,22 +2663,23 @@ __metadata: version: 0.0.0-use.local resolution: "@preeternal/react-native-document-scanner-plugin-example@workspace:example" dependencies: - "@babel/core": "npm:^7.25.2" + "@babel/core": "npm:^7.29.0" "@babel/preset-env": "npm:^7.25.3" "@babel/runtime": "npm:^7.25.0" "@react-native-community/cli": "npm:18.0.0" "@react-native-community/cli-platform-android": "npm:18.0.0" "@react-native-community/cli-platform-ios": "npm:18.0.0" "@react-native-documents/picker": "npm:^12.0.1" - "@react-native/babel-preset": "npm:0.79.2" - "@react-native/metro-config": "npm:0.79.2" - "@react-native/typescript-config": "npm:0.79.2" + "@react-native/babel-preset": "npm:0.79.7" + "@react-native/metro-config": "npm:0.79.7" + "@react-native/typescript-config": "npm:0.79.7" "@types/react": "npm:^19.0.0" react: "npm:19.0.0" - react-native: "npm:0.79.2" + react-native: "npm:0.79.7" react-native-builder-bob: "npm:^0.40.13" react-native-image-picker: "npm:^8.2.1" react-native-monorepo-config: "npm:^0.1.9" + react-native-safe-area-context: "npm:^5.7.0" languageName: unknown linkType: soft @@ -2913,6 +3078,13 @@ __metadata: languageName: node linkType: hard +"@react-native/assets-registry@npm:0.79.7": + version: 0.79.7 + resolution: "@react-native/assets-registry@npm:0.79.7" + checksum: 10c0/cd60c0dc0a7a056cea6b7aa745020866e428952e2a68deaa560d8e44714727acad52a4b9b856da9dc00478efe7f837eee4f9a5a3d40fedc71f2121310ce1a2d5 + languageName: node + linkType: hard + "@react-native/babel-plugin-codegen@npm:0.79.2": version: 0.79.2 resolution: "@react-native/babel-plugin-codegen@npm:0.79.2" @@ -2923,6 +3095,16 @@ __metadata: languageName: node linkType: hard +"@react-native/babel-plugin-codegen@npm:0.79.7": + version: 0.79.7 + resolution: "@react-native/babel-plugin-codegen@npm:0.79.7" + dependencies: + "@babel/traverse": "npm:^7.25.3" + "@react-native/codegen": "npm:0.79.7" + checksum: 10c0/245613053a5f0c938e734a6adae6839e7f8f23f9a0181b415385c549c176df19823f85145006c9c8f8d81063d83ccf6e55905f6532b777018389b95ff323494b + languageName: node + linkType: hard + "@react-native/babel-preset@npm:0.79.2": version: 0.79.2 resolution: "@react-native/babel-preset@npm:0.79.2" @@ -2978,6 +3160,61 @@ __metadata: languageName: node linkType: hard +"@react-native/babel-preset@npm:0.79.7": + version: 0.79.7 + resolution: "@react-native/babel-preset@npm:0.79.7" + dependencies: + "@babel/core": "npm:^7.25.2" + "@babel/plugin-proposal-export-default-from": "npm:^7.24.7" + "@babel/plugin-syntax-dynamic-import": "npm:^7.8.3" + "@babel/plugin-syntax-export-default-from": "npm:^7.24.7" + "@babel/plugin-syntax-nullish-coalescing-operator": "npm:^7.8.3" + "@babel/plugin-syntax-optional-chaining": "npm:^7.8.3" + "@babel/plugin-transform-arrow-functions": "npm:^7.24.7" + "@babel/plugin-transform-async-generator-functions": "npm:^7.25.4" + "@babel/plugin-transform-async-to-generator": "npm:^7.24.7" + "@babel/plugin-transform-block-scoping": "npm:^7.25.0" + "@babel/plugin-transform-class-properties": "npm:^7.25.4" + "@babel/plugin-transform-classes": "npm:^7.25.4" + "@babel/plugin-transform-computed-properties": "npm:^7.24.7" + "@babel/plugin-transform-destructuring": "npm:^7.24.8" + "@babel/plugin-transform-flow-strip-types": "npm:^7.25.2" + "@babel/plugin-transform-for-of": "npm:^7.24.7" + "@babel/plugin-transform-function-name": "npm:^7.25.1" + "@babel/plugin-transform-literals": "npm:^7.25.2" + "@babel/plugin-transform-logical-assignment-operators": "npm:^7.24.7" + "@babel/plugin-transform-modules-commonjs": "npm:^7.24.8" + "@babel/plugin-transform-named-capturing-groups-regex": "npm:^7.24.7" + "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.24.7" + "@babel/plugin-transform-numeric-separator": "npm:^7.24.7" + "@babel/plugin-transform-object-rest-spread": "npm:^7.24.7" + "@babel/plugin-transform-optional-catch-binding": "npm:^7.24.7" + "@babel/plugin-transform-optional-chaining": "npm:^7.24.8" + "@babel/plugin-transform-parameters": "npm:^7.24.7" + "@babel/plugin-transform-private-methods": "npm:^7.24.7" + "@babel/plugin-transform-private-property-in-object": "npm:^7.24.7" + "@babel/plugin-transform-react-display-name": "npm:^7.24.7" + "@babel/plugin-transform-react-jsx": "npm:^7.25.2" + "@babel/plugin-transform-react-jsx-self": "npm:^7.24.7" + "@babel/plugin-transform-react-jsx-source": "npm:^7.24.7" + "@babel/plugin-transform-regenerator": "npm:^7.24.7" + "@babel/plugin-transform-runtime": "npm:^7.24.7" + "@babel/plugin-transform-shorthand-properties": "npm:^7.24.7" + "@babel/plugin-transform-spread": "npm:^7.24.7" + "@babel/plugin-transform-sticky-regex": "npm:^7.24.7" + "@babel/plugin-transform-typescript": "npm:^7.25.2" + "@babel/plugin-transform-unicode-regex": "npm:^7.24.7" + "@babel/template": "npm:^7.25.0" + "@react-native/babel-plugin-codegen": "npm:0.79.7" + babel-plugin-syntax-hermes-parser: "npm:0.25.1" + babel-plugin-transform-flow-enums: "npm:^0.0.2" + react-refresh: "npm:^0.14.0" + peerDependencies: + "@babel/core": "*" + checksum: 10c0/62783ab62de8518c8ebd23156a857b0a9052d436d1acc9805758d313051a3cb83a4c669ba7134d00210576b127f524ba70ef6f038f6df1e4b6590fe27ffa55f7 + languageName: node + linkType: hard + "@react-native/codegen@npm:0.79.2": version: 0.79.2 resolution: "@react-native/codegen@npm:0.79.2" @@ -2993,6 +3230,23 @@ __metadata: languageName: node linkType: hard +"@react-native/codegen@npm:0.79.7": + version: 0.79.7 + resolution: "@react-native/codegen@npm:0.79.7" + dependencies: + "@babel/core": "npm:^7.25.2" + "@babel/parser": "npm:^7.25.3" + glob: "npm:^7.1.1" + hermes-parser: "npm:0.25.1" + invariant: "npm:^2.2.4" + nullthrows: "npm:^1.1.1" + yargs: "npm:^17.6.2" + peerDependencies: + "@babel/core": "*" + checksum: 10c0/83b050e6dc03bfa593863c43e41cdcd2911ef192dfeb1a43ae5dd30a26d23fe05b960e2fdf20785a20954fccb0c78b300845ebc86f0131ed1b81495331375a25 + languageName: node + linkType: hard + "@react-native/community-cli-plugin@npm:0.79.2": version: 0.79.2 resolution: "@react-native/community-cli-plugin@npm:0.79.2" @@ -3014,6 +3268,27 @@ __metadata: languageName: node linkType: hard +"@react-native/community-cli-plugin@npm:0.79.7": + version: 0.79.7 + resolution: "@react-native/community-cli-plugin@npm:0.79.7" + dependencies: + "@react-native/dev-middleware": "npm:0.79.7" + chalk: "npm:^4.0.0" + debug: "npm:^2.2.0" + invariant: "npm:^2.2.4" + metro: "npm:^0.82.0" + metro-config: "npm:^0.82.0" + metro-core: "npm:^0.82.0" + semver: "npm:^7.1.3" + peerDependencies: + "@react-native-community/cli": "*" + peerDependenciesMeta: + "@react-native-community/cli": + optional: true + checksum: 10c0/820265f72f70dcc85d7be27d7e1bc703aa17d90672ca29a4d448376ef03e5d17fedd5c814eecb671337cbcfa0781ba32a3a3767852a4a53dc513c365c984ddbb + languageName: node + linkType: hard + "@react-native/debugger-frontend@npm:0.79.2": version: 0.79.2 resolution: "@react-native/debugger-frontend@npm:0.79.2" @@ -3021,6 +3296,13 @@ __metadata: languageName: node linkType: hard +"@react-native/debugger-frontend@npm:0.79.7": + version: 0.79.7 + resolution: "@react-native/debugger-frontend@npm:0.79.7" + checksum: 10c0/e5d5368d13a9366af5f04f94a49a6d5491fbbe9fa3fa8293207eec3dc49f483bc91950f6d0f5645577826fbab32e06791ff076dcf178f2746655a59c80f3bb0a + languageName: node + linkType: hard + "@react-native/dev-middleware@npm:0.79.2": version: 0.79.2 resolution: "@react-native/dev-middleware@npm:0.79.2" @@ -3040,6 +3322,25 @@ __metadata: languageName: node linkType: hard +"@react-native/dev-middleware@npm:0.79.7": + version: 0.79.7 + resolution: "@react-native/dev-middleware@npm:0.79.7" + dependencies: + "@isaacs/ttlcache": "npm:^1.4.1" + "@react-native/debugger-frontend": "npm:0.79.7" + chrome-launcher: "npm:^0.15.2" + chromium-edge-launcher: "npm:^0.2.0" + connect: "npm:^3.6.5" + debug: "npm:^2.2.0" + invariant: "npm:^2.2.4" + nullthrows: "npm:^1.1.1" + open: "npm:^7.0.3" + serve-static: "npm:^1.16.2" + ws: "npm:^6.2.3" + checksum: 10c0/15a25929a715365fc28fdaef93556884419a86deb1030d8faf6c3431bc2d79a203f91f4eec75a6e40cf37ac45549e39725d18ce0f5c9e820d01e7fc99fb03af8 + languageName: node + linkType: hard + "@react-native/eslint-config@npm:^0.78.0": version: 0.78.3 resolution: "@react-native/eslint-config@npm:0.78.3" @@ -3077,6 +3378,13 @@ __metadata: languageName: node linkType: hard +"@react-native/gradle-plugin@npm:0.79.7": + version: 0.79.7 + resolution: "@react-native/gradle-plugin@npm:0.79.7" + checksum: 10c0/1eb0a88a3778d1d6bb511d80d88bc8195a59c9e3e9164838023b2e8632ea30afcf5a9f9d2a41fc04a3c451d8428d80f5c7f548b63228dccdef2d369955b7c9c4 + languageName: node + linkType: hard + "@react-native/js-polyfills@npm:0.79.2": version: 0.79.2 resolution: "@react-native/js-polyfills@npm:0.79.2" @@ -3084,29 +3392,36 @@ __metadata: languageName: node linkType: hard -"@react-native/metro-babel-transformer@npm:0.79.2": - version: 0.79.2 - resolution: "@react-native/metro-babel-transformer@npm:0.79.2" +"@react-native/js-polyfills@npm:0.79.7": + version: 0.79.7 + resolution: "@react-native/js-polyfills@npm:0.79.7" + checksum: 10c0/58568f1efa75b36a660058091fd085fbbafdc95d8ad4e0e88d6bc43c897b0e7c316ca975652ea4be4f5bb1d3b0dd39bef3eb66010b75bd4c52bd5aa015e603d0 + languageName: node + linkType: hard + +"@react-native/metro-babel-transformer@npm:0.79.7": + version: 0.79.7 + resolution: "@react-native/metro-babel-transformer@npm:0.79.7" dependencies: "@babel/core": "npm:^7.25.2" - "@react-native/babel-preset": "npm:0.79.2" + "@react-native/babel-preset": "npm:0.79.7" hermes-parser: "npm:0.25.1" nullthrows: "npm:^1.1.1" peerDependencies: "@babel/core": "*" - checksum: 10c0/445c8562ca9dbb470d611e0463abb1a3089b400cb8d2c5f0ce0d594b160ab1f5ab8f50929b8e5f602654dd7e3c4b23ae865ea190e72df2634664cfe9db291131 + checksum: 10c0/e91dd78c27d6c41c163ac674b9af997b5e39ad9b9b91870b7a53e46af9164d6aec41ab3fbfdbe87373489ce4ac094f0ff9f45739b423d11cbc251698b40ac1fd languageName: node linkType: hard -"@react-native/metro-config@npm:0.79.2": - version: 0.79.2 - resolution: "@react-native/metro-config@npm:0.79.2" +"@react-native/metro-config@npm:0.79.7": + version: 0.79.7 + resolution: "@react-native/metro-config@npm:0.79.7" dependencies: - "@react-native/js-polyfills": "npm:0.79.2" - "@react-native/metro-babel-transformer": "npm:0.79.2" + "@react-native/js-polyfills": "npm:0.79.7" + "@react-native/metro-babel-transformer": "npm:0.79.7" metro-config: "npm:^0.82.0" metro-runtime: "npm:^0.82.0" - checksum: 10c0/752ca47c168ba257957c07ef4d8aee09a9451e0db5bf3e77cfb4f4bf4eb3f3c7839392e7cf938dd559ceb0c064bb67c6e0719c82018579608c8a44d58bec7158 + checksum: 10c0/76fcaa764f12295ff3e6a12a72f55a02ad80115d2b37b2559a5b957a61033836b4dc3b2916de741b28945f3a41cb8050cab04b51ff6b6da97316d6c77a5afff3 languageName: node linkType: hard @@ -3117,10 +3432,17 @@ __metadata: languageName: node linkType: hard -"@react-native/typescript-config@npm:0.79.2": - version: 0.79.2 - resolution: "@react-native/typescript-config@npm:0.79.2" - checksum: 10c0/133ad69af4429dd3e6ff0dc001e84e1381432c8e37b77b1a8c1632e411f23c3d1428f874fcdcfdfb0da54f22b59f06ba97266740664d1cb23e13a6222c2df47b +"@react-native/normalize-colors@npm:0.79.7": + version: 0.79.7 + resolution: "@react-native/normalize-colors@npm:0.79.7" + checksum: 10c0/717fe5813d2776903c2b20d613040f99bbe8e0073b8f195700d39246f0d14c8508371475118a809340bb91aee1696f9a2dabd4598b8b46ad32e5d44bbedbaca1 + languageName: node + linkType: hard + +"@react-native/typescript-config@npm:0.79.7": + version: 0.79.7 + resolution: "@react-native/typescript-config@npm:0.79.7" + checksum: 10c0/9129c5c5c4bf51432f560b2d32eb52870d118d151ab93bae91b81c31a77d6c48c9aeb8d59a40e1e5d8a8ae90ae9e3b874d7844d1ba58e4376c6e9bfaacd0c724 languageName: node linkType: hard @@ -3141,6 +3463,23 @@ __metadata: languageName: node linkType: hard +"@react-native/virtualized-lists@npm:0.79.7": + version: 0.79.7 + resolution: "@react-native/virtualized-lists@npm:0.79.7" + dependencies: + invariant: "npm:^2.2.4" + nullthrows: "npm:^1.1.1" + peerDependencies: + "@types/react": ^19.0.0 + react: "*" + react-native: "*" + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/27639611a69d2ea7f7ae376cb777c06985fae77196bb4c88b8e57485af143e286049290b121a0752da5c95719ac9511306a5ea611ea1dda7041ef3fcafe6e30b + languageName: node + linkType: hard + "@release-it/conventional-changelog@npm:^9.0.2": version: 9.0.4 resolution: "@release-it/conventional-changelog@npm:9.0.4" @@ -10361,6 +10700,16 @@ __metadata: languageName: node linkType: hard +"react-native-safe-area-context@npm:^5.7.0": + version: 5.7.0 + resolution: "react-native-safe-area-context@npm:5.7.0" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10c0/c3799e17321b41df1e0a10492c98472f8f8225ef0bbaf8146c4a9acb9519aae9ac11429059143c215e4402c2808e8445274850a339f8477522ded2461e18da80 + languageName: node + linkType: hard + "react-native@npm:0.79.2": version: 0.79.2 resolution: "react-native@npm:0.79.2" @@ -10413,6 +10762,58 @@ __metadata: languageName: node linkType: hard +"react-native@npm:0.79.7": + version: 0.79.7 + resolution: "react-native@npm:0.79.7" + dependencies: + "@jest/create-cache-key-function": "npm:^29.7.0" + "@react-native/assets-registry": "npm:0.79.7" + "@react-native/codegen": "npm:0.79.7" + "@react-native/community-cli-plugin": "npm:0.79.7" + "@react-native/gradle-plugin": "npm:0.79.7" + "@react-native/js-polyfills": "npm:0.79.7" + "@react-native/normalize-colors": "npm:0.79.7" + "@react-native/virtualized-lists": "npm:0.79.7" + abort-controller: "npm:^3.0.0" + anser: "npm:^1.4.9" + ansi-regex: "npm:^5.0.0" + babel-jest: "npm:^29.7.0" + babel-plugin-syntax-hermes-parser: "npm:0.25.1" + base64-js: "npm:^1.5.1" + chalk: "npm:^4.0.0" + commander: "npm:^12.0.0" + event-target-shim: "npm:^5.0.1" + flow-enums-runtime: "npm:^0.0.6" + glob: "npm:^7.1.1" + invariant: "npm:^2.2.4" + jest-environment-node: "npm:^29.7.0" + memoize-one: "npm:^5.0.0" + metro-runtime: "npm:^0.82.0" + metro-source-map: "npm:^0.82.0" + nullthrows: "npm:^1.1.1" + pretty-format: "npm:^29.7.0" + promise: "npm:^8.3.0" + react-devtools-core: "npm:^6.1.1" + react-refresh: "npm:^0.14.0" + regenerator-runtime: "npm:^0.13.2" + scheduler: "npm:0.25.0" + semver: "npm:^7.1.3" + stacktrace-parser: "npm:^0.1.10" + whatwg-fetch: "npm:^3.0.0" + ws: "npm:^6.2.3" + yargs: "npm:^17.6.2" + peerDependencies: + "@types/react": ^19.0.0 + react: ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + bin: + react-native: cli.js + checksum: 10c0/7071933ed1398bf3fb9ab6fff476b923c103856c76c0e68c719e3e73f2bf380a24cf4ef11fe7c4c83f7cde144c13d17633100a7b7d04b28f39e119dd91ef798a + languageName: node + linkType: hard + "react-refresh@npm:^0.14.0": version: 0.14.2 resolution: "react-refresh@npm:0.14.2" From ed387a8c1ac9994428ee62f61cb28534643ead5f Mon Sep 17 00:00:00 2001 From: Preeternal Date: Sun, 19 Apr 2026 21:42:55 +0300 Subject: [PATCH 14/17] ci: fix --- .github/workflows/ci.yml | 43 ++++++++++++++++--- README.md | 11 +++++ scripts/check-16kb.sh | 89 ---------------------------------------- 3 files changed, 48 insertions(+), 95 deletions(-) delete mode 100755 scripts/check-16kb.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2e82ee..9fd53d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,12 +99,45 @@ jobs: fs.writeFileSync(path, JSON.stringify(app, null, 2) + '\n'); NODE - CI=1 npx expo config --json > "$EXPO_WORK_DIR/expo-config.json" - node -e "JSON.parse(require(\"node:fs\").readFileSync(process.argv[1], \"utf8\"))" "$EXPO_WORK_DIR/expo-config.json" + CI=1 npx expo config --json > "$EXPO_WORK_DIR/expo-config.json" 2> "$EXPO_WORK_DIR/expo-config.stderr.log" || { + echo "::error::expo config failed for analysisFeatures='${DS_FEATURES}'" + if [[ -f "$EXPO_WORK_DIR/expo-config.stderr.log" ]]; then + echo "::group::expo config stderr" + cat "$EXPO_WORK_DIR/expo-config.stderr.log" + echo "::endgroup::" + fi + if [[ -f "$EXPO_WORK_DIR/expo-config.json" ]]; then + echo "::group::expo config stdout" + cat "$EXPO_WORK_DIR/expo-config.json" + echo "::endgroup::" + fi + exit 1 + } + + node -e "JSON.parse(require(\"node:fs\").readFileSync(process.argv[1], \"utf8\"))" "$EXPO_WORK_DIR/expo-config.json" || { + echo "::error::expo config output is not valid JSON for analysisFeatures='${DS_FEATURES}'" + echo "::group::expo config stdout (raw)" + cat "$EXPO_WORK_DIR/expo-config.json" || true + echo "::endgroup::" + if [[ -f "$EXPO_WORK_DIR/expo-config.stderr.log" ]]; then + echo "::group::expo config stderr" + cat "$EXPO_WORK_DIR/expo-config.stderr.log" + echo "::endgroup::" + fi + exit 1 + } CI=1 npx expo prebuild --platform android --clean --no-install 2>&1 | tee "$EXPO_WORK_DIR/prebuild.log" - grep -q "^DocumentScanner_analysisFeatures=${DS_FEATURES}$" android/gradle.properties + ACTUAL_FEATURES="$(awk -F= '/^DocumentScanner_analysisFeatures=/{print $2}' android/gradle.properties | tail -n1 | tr -d '\r')" + if [[ -z "$ACTUAL_FEATURES" ]]; then + echo "::error::Missing DocumentScanner_analysisFeatures in android/gradle.properties" + exit 1 + fi + if [[ "$ACTUAL_FEATURES" != "$DS_FEATURES" ]]; then + echo "::error::DocumentScanner_analysisFeatures mismatch. expected='${DS_FEATURES}' actual='${ACTUAL_FEATURES}'" + exit 1 + fi - name: Print Expo prebuild diagnostics (${{ matrix.features }}) if: always() @@ -145,6 +178,7 @@ jobs: ${{ env.EXPO_WORK_DIR }}/prebuild.log ${{ env.EXPO_WORK_DIR }}/app/app.json ${{ env.EXPO_WORK_DIR }}/expo-config.json + ${{ env.EXPO_WORK_DIR }}/expo-config.stderr.log ${{ env.EXPO_WORK_DIR }}/app/android/gradle.properties - name: Cleanup Expo prebuild workspace (${{ matrix.features }}) @@ -214,9 +248,6 @@ jobs: run: | yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}" - - name: Verify 16KB page alignment - run: bash scripts/check-16kb.sh - build-ios: runs-on: macos-latest env: diff --git a/README.md b/README.md index 84129f7..378d3fe 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,17 @@ It is designed for apps that need more than “just scan a page”: - KYC and onboarding - forms, IDs, invoices, and document intake +## Demo + +```md + +``` + ## Why this fork The upstream package now supports New Architecture too, which is great. diff --git a/scripts/check-16kb.sh b/scripts/check-16kb.sh deleted file mode 100755 index 387ad4e..0000000 --- a/scripts/check-16kb.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env bash -# Verify 16KB page alignment for all .so files in the Android build output. -# Scanner has no custom NDK code — this checks MLKit and RN .so deps bundled -# into the example APK, which must be 16KB-aligned for Android 15+ compatibility. -set -euo pipefail - -readelf_cmd="" -if [ "${GITHUB_ACTIONS:-}" = "true" ]; then - if command -v llvm-readelf >/dev/null 2>&1; then - readelf_cmd="llvm-readelf" - elif command -v readelf >/dev/null 2>&1; then - readelf_cmd="readelf" - elif command -v greadelf >/dev/null 2>&1; then - readelf_cmd="greadelf" - fi -else - find_readelf() { - local candidates=( - llvm-readelf - readelf - greadelf - /opt/homebrew/opt/llvm/bin/llvm-readelf - /usr/local/opt/llvm/bin/llvm-readelf - /opt/homebrew/bin/greadelf - /usr/local/bin/greadelf - ) - for candidate in "${candidates[@]}"; do - if command -v "$candidate" >/dev/null 2>&1; then - echo "$candidate" - return 0 - fi - if [ -x "$candidate" ]; then - echo "$candidate" - return 0 - fi - done - return 1 - } - readelf_cmd="$(find_readelf || true)" -fi - -if [ -z "$readelf_cmd" ]; then - echo "ERROR: neither llvm-readelf, readelf, nor greadelf found in PATH" >&2 - if [ "${GITHUB_ACTIONS:-}" != "true" ]; then - echo "Tip: brew install llvm (llvm-readelf) or binutils (greadelf), or export PATH to include them." >&2 - fi - exit 1 -fi - -files=() -if command -v rg >/dev/null 2>&1; then - while IFS= read -r line; do - [ -n "$line" ] && files+=("$line") - done < <(rg --files -g "*.so" example/android/app/build 2>/dev/null || true) -else - while IFS= read -r line; do - [ -n "$line" ] && files+=("$line") - done < <(find example/android/app/build -name "*.so" 2>/dev/null || true) -fi - -if [ "${#files[@]}" -eq 0 ]; then - echo "NOTE: no .so files found in example/android/app/build — skipping alignment check" - echo "(build Android first to get a meaningful check)" - exit 0 -fi - -failed=() -for so in "${files[@]}"; do - if ! "$readelf_cmd" -l -W "$so" 2>/dev/null | awk ' - $1=="LOAD" { load=1; if ($NF!="0x4000") bad=1 } - END { if (!load) exit 2; exit bad } - '; then - status=$? - if [ "$status" -ne 2 ]; then - failed+=("$so") - fi - fi -done - -if [ "${#failed[@]}" -gt 0 ]; then - echo "ERROR: the following .so files are not 16KB-aligned (expected LOAD alignment 0x4000):" >&2 - for f in "${failed[@]}"; do - echo " $f" >&2 - "$readelf_cmd" -l -W "$f" 2>/dev/null | grep -E "LOAD|Align" || true - done - exit 1 -fi - -echo "OK: 16KB alignment verified (${#files[@]} .so file(s) checked)" From 95d8ca0801b3246f6d81641f4ffa085cf22caf15 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Sun, 19 Apr 2026 21:47:45 +0300 Subject: [PATCH 15/17] ci: expo fix --- package.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e164e4f..ebabd26 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,15 @@ "types": "./lib/typescript/src/index.d.ts", "default": "./lib/module/index.js" }, + "./app.plugin.js": "./app.plugin.js", + "./app.plugin": "./app.plugin.js", "./package.json": "./package.json" }, - "expo": "./app.plugin.js", + "expo": { + "plugins": [ + "./app.plugin.js" + ] + }, "files": [ "src", "lib", From f007bb389d165de749f45f28c3930d2073c06dc4 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Sun, 19 Apr 2026 22:09:35 +0300 Subject: [PATCH 16/17] ci: ios fix --- README.md | 9 +-------- example/ios/Podfile.lock | 8 ++++---- example/package.json | 2 +- yarn.lock | 10 +++++----- 4 files changed, 11 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 378d3fe..7c3b79a 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,7 @@ It is designed for apps that need more than “just scan a page”: ## Demo -```md - -``` +https://github.com/user-attachments/assets/fda6d4d3-ac87-41d6-a04c-11f0ce08f5e8 ## Why this fork diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 3f7bee0..686650a 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,6 +1,6 @@ PODS: - boost (1.84.0) - - DocumentScanner (0.2.2): + - DocumentScanner (0.3.0): - DoubleConversion - glog - hermes-engine @@ -1356,7 +1356,7 @@ PODS: - React-jsiexecutor - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - - react-native-document-picker (12.0.1): + - react-native-document-picker (11.0.3): - DoubleConversion - glog - hermes-engine @@ -2039,7 +2039,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - DocumentScanner: ab4f56ed3b32b95c86df9f4ec53666064f5e354e + DocumentScanner: 6263e67b4009293bd1471fad908c4585cd44504d DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6 FBLazyVector: b60fe06f0f15b7d7408f169442176e69e8eeacde @@ -2077,7 +2077,7 @@ SPEC CHECKSUMS: React-logger: fbdc0814b62fefa412a90af7cacc666387f1bc9a React-Mapbuffer: cfe4726ba1929b9dab4645c78376a9165375d62f React-microtasksnativemodule: c99b8f1240609566de105b73f2569c0f22880ee1 - react-native-document-picker: 686c62761cf3bbb914588c252eca45fc1991436c + react-native-document-picker: 169fadd7445c3930416cc3eb241dd4e1dabdae91 react-native-image-picker: db0b3e3c7ae57680876318d4f42e7dbedd72244a react-native-safe-area-context: 26634d9b636a98ceee20cb6fa5dc946922f1e90f React-NativeModulesApple: 74051ff264ce0b89ac799b56314f8f440a95ee4d diff --git a/example/package.json b/example/package.json index 1b9d57c..b80b81e 100644 --- a/example/package.json +++ b/example/package.json @@ -10,7 +10,7 @@ "build:ios": "react-native build-ios --mode Debug" }, "dependencies": { - "@react-native-documents/picker": "^12.0.1", + "@react-native-documents/picker": "^11.0.3", "react": "19.0.0", "react-native": "0.79.7", "react-native-image-picker": "^8.2.1", diff --git a/yarn.lock b/yarn.lock index a6fe787..106152e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2669,7 +2669,7 @@ __metadata: "@react-native-community/cli": "npm:18.0.0" "@react-native-community/cli-platform-android": "npm:18.0.0" "@react-native-community/cli-platform-ios": "npm:18.0.0" - "@react-native-documents/picker": "npm:^12.0.1" + "@react-native-documents/picker": "npm:^11.0.3" "@react-native/babel-preset": "npm:0.79.7" "@react-native/metro-config": "npm:0.79.7" "@react-native/typescript-config": "npm:0.79.7" @@ -3061,13 +3061,13 @@ __metadata: languageName: node linkType: hard -"@react-native-documents/picker@npm:^12.0.1": - version: 12.0.1 - resolution: "@react-native-documents/picker@npm:12.0.1" +"@react-native-documents/picker@npm:^11.0.3": + version: 11.0.3 + resolution: "@react-native-documents/picker@npm:11.0.3" peerDependencies: react: "*" react-native: ">=0.79.0" - checksum: 10c0/1077f80fdb76fcad283997f56a7f2eb263ace2a73da788e4b29dc926a78149c04a6eea1d4aeddef75c9f176fbbc9206a1c64c10c2d0c85c86cca85be023f7c00 + checksum: 10c0/d7c2336bced7736b74d593f188419fe9b1497175e3707b63bee672300d9a05765145badf5114f7e6ff901208497dafd2e9107fd612281643628a0bed549a6278 languageName: node linkType: hard From a58ae3f261d45a1328b8d8a5a026c44d9c594c47 Mon Sep 17 00:00:00 2001 From: Preeternal Date: Sun, 19 Apr 2026 22:38:13 +0300 Subject: [PATCH 17/17] ci: fix --- example/ios/DocumentScannerExample/Info.plist | 2 +- review.md | 283 ------------------ 2 files changed, 1 insertion(+), 284 deletions(-) delete mode 100644 review.md diff --git a/example/ios/DocumentScannerExample/Info.plist b/example/ios/DocumentScannerExample/Info.plist index 67c1cab..4c5841e 100644 --- a/example/ios/DocumentScannerExample/Info.plist +++ b/example/ios/DocumentScannerExample/Info.plist @@ -33,7 +33,7 @@ NSCameraUsageDescription - + Used in example app to make photos for analysis NSPhotoLibraryUsageDescription Used in example app to pick images from the photo library for analysis. NSLocationWhenInUseUsageDescription diff --git a/review.md b/review.md deleted file mode 100644 index 4ce8c2e..0000000 --- a/review.md +++ /dev/null @@ -1,283 +0,0 @@ -Создано задач: 5 - -Full code review of new features - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Barcode) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/text) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-barcode) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-text) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode/java) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/text/java) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-barcode/java) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-text/java) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/analysis) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/barcode) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/text) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode/java/com) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/text/java/com) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-barcode/java/com) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-text/java/com) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode/java/com/preeternal) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/text/java/com/preeternal) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-barcode/java/com/preeternal) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-text/java/com/preeternal) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode/java/com/preeternal/scanner) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/text/java/com/preeternal/scanner) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-barcode/java/com/preeternal/scanner) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-text/java/com/preeternal/scanner) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode/java/com/preeternal/scanner/barcode) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/text/java/com/preeternal/scanner/text) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-barcode/java/com/preeternal/scanner/barcode) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-text/java/com/preeternal/scanner/text) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/src/index.tsx) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/src/__tests__/index.test.tsx) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/src/NativeDocumentScanner.ts#1-1), lines 1 to 300 - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/src/NativeDocumentScanner.ts#300-300), lines 300 to 356 - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/package.json#1-1), lines 1 to 100 - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocumentScanner.swift#1-1), lines 1 to 800 - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocumentScanner.h) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocumentScanner.mm) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocumentScanner.swift#800-800), lines 800 to 1010 - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/DebugLog.swift) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/ResponseType.swift) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Errors.swift) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/DocScanner.swift) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/FileUtil.swift) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/RecognizeDocumentsAnalyzer.swift#1-1), lines 1 to 600 - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/AnalysisModels.swift) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Barcode/BarcodeExtractor.swift#1-1), lines 1 to 400 - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Barcode/BarcodeExtractor.swift#400-400), lines 400 to 600 - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Barcode/BarcodeImageSource.swift) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/TextExtractor.swift) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/DocumentSemantics.swift) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/StructuredDataNormalizer.swift) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/DocScannerDebugLog.kt) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt#1-1), lines 1 to 800 - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/analysis/DocumentSemantics.kt) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/barcode/BarcodeExtractor.kt) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/barcode/BarcodeFormats.kt) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/build.gradle) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/analysis/StructuredDataNormalizer.kt) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/text/java/com/preeternal/scanner/text/TextExtractorImpl.kt) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/no-text/java/com/preeternal/scanner/text/TextExtractorImpl.kt) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeImageSourceLoader.kt) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/text/java/com/preeternal/scanner/text/TextInputImageLoader.kt) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt#500-500), lines 500 to 800 - -Searched for regex `data class TextBlockResult|data class TextLineResult|data class NormalizedBoundingBox|data class TextExtractor` (`**/android/**`), 3 results - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt#500-500), lines 500 to 600 - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/text/TextExtractor.kt) - -Searched for regex `clearPending|parseAllowedFormats|getArrayOrNull|getIntOrNull|getBooleanOrNull|readStringAt|canReadUri|uriToBase64|toWritableTableArray|toWritableStructuredData|resolveOnUi|rejectOnUi|ensureSystemBarsVisible|startScan` (`**/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt`), 20 results - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt#1081-1081), lines 1081 to 1081 - -Searched for regex `private fun.*String|private fun.*Array|private fun.*Map` (`**/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt`), 20 results - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt#780-780), lines 780 to 1050 - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt#1050-1050), lines 1050 to 1081 - -Read [](file:///Users/preeternal/Library/Application%20Support/Code/User/workspaceStorage/25fdb886f9420bcb97243c8660fa61cb/GitHub.copilot-chat/chat-session-resources/f3487d33-d58f-4084-84be-1f0f505039c4/toolu_bdrk_01AnVhsPVkVstBMNovMGerkV__vscode-1776525468529/content.txt) - -Запуск: *Summarize findings* (5/5) - -Вот теперь давай посмотрю некоторые ключевые места более детально, чтобы подтвердить самые критичные находки. - -Verify critical Android issues - -Searched for files matching `**/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt`, 1 match - -Searched for files matching `**/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt`, 1 match - -Searched for files matching `**/ios/DocScanner/DocScanner.swift`, 1 match - -Searched for files matching `**/ios/DocScanner/Barcode/BarcodeExtractor.swift`, 1 match - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/android/src/main/java/com/preeternal/scanner/DocumentScannerModule.kt) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/DocScanner.swift) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Barcode/BarcodeExtractor.swift) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/AnalysisModels.swift) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/TextExtractor.swift) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/RecognizeDocumentsAnalyzer.swift) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/DocumentSemantics.swift) - -Read [](file:///Users/preeternal/Projects/react-native-document-scanner-plugin/ios/DocScanner/Analysis/StructuredDataNormalizer.swift) - -Выполнено: *Summarize findings* (5/5) - ---- - -## Code Review: v0.2.2 → v0.3.0 - -### Общая оценка - -| Категория | Оценка | Комментарий | -|-----------|--------|-------------| -| API Design / TypeScript | **A** | Хорошо типизированные, консистентные API с JSDoc | -| Кроссплатформенная консистентность | **A** | Одинаковые response shape, форматы, нормализация | -| iOS реализация | **B−** | Чистая архитектура, но есть нюансы с fallback-цепочками | -| Android реализация | **B−** | Чистый Kotlin, но утечки ресурсов | -| Безопасность | **B+** | Валидация base64/путей на месте, мелкие gap'ы | -| Тесты | **F** | Нет тестов (только `it.todo('write a test')`) | -| Код-качество | **A−** | Хорошая модуляризация, TODO'шки, silent regex | - ---- - -### Критические находки - -#### 1. Android: Утечка памяти в `scannerCache` - -В BarcodeExtractorImpl.kt — `scannerCache = mutableMapOf()` **неограничен и никогда не очищается**. Каждая уникальная комбинация форматов создаёт новую запись. На практике комбинаций мало, но формально это leak. - -**Рекомендация:** Либо ограничить размер (LRU), либо очищать в `invalidate()`, либо просто задокументировать, что на практике ключей ≤ 5. - -#### 2. Android: Рекурсивная `processPages()` - -В DocumentScannerModule.kt — `processPages()` вызывает сама себя с `pageIndex + 1`. При 100+ страницах — потенциальный stack overflow. - -**Рекомендация:** Заменить на итеративную реализацию (цикл `for`). - -#### 3. Android: `launcher` не очищается в `invalidate()` - -`invalidate()` отменяет только `scope.cancel()`, но `launcher` не обнуляется. При пересоздании модуля может накапливаться мусор. - -#### 4. Android: Рекурсивная `processAttempt()` (8 уровней) - -В BarcodeExtractorImpl.kt — рекурсия до 8 вызовов. На практике 8 уровней стек не убьют, но это код-запах. Рассмотреть итеративную версию в будущем. - ---- - -### Средние находки - -#### 5. iOS: 11 regex с молчаливым отказом (`try?`) - -В DocumentSemantics.swift (6 regex) и StructuredDataNormalizer.swift (5 regex) — все компилируются через `try?`. Если regex невалиден, он станет `nil` и весь matching тихо отключится **без единого лога**. - -**Рекомендация:** Эти regex — `static let`, компилируются один раз. Паттерны фиксированные, так что вероятность ошибки низкая. Но для safety стоит добавить `assertionFailure` в debug-билдах или хотя бы `DebugLog.warning()` при `nil`. - -#### 6. Таймауты не конфигурируемые - -```kotlin -private const val BARCODE_EXTRACTION_TIMEOUT_MS = 10_000L // 10 сек -private const val TEXT_EXTRACTION_TIMEOUT_MS = 25_000L // 25 сек -``` - -На старых устройствах может не хватить. Сейчас не критично, но стоит держать в уме для v0.4+. - -#### 7. iOS: `synchronous: true` в PhotoKit загрузке - -`PHImageRequestOptions` с `synchronous: true` блокирует текущий поток. Хоть это и вызывается на фоновой очереди, для больших изображений может тормозить. - ---- - -### Положительные моменты - -- **API design отличный**: JSDoc, чёткие типы, `ScanDocumentResponseStatus`, backward-compatible fallback в `analyzeScannedImages()` -- **Feature gating на Android** через Gradle sourceSets — элегантно. `no-barcode/` и `no-text/` stub'ы обеспечивают type-safety без лишних зависимостей -- **Debug logging opt-in** — правильно сделан через environment vars и system properties, lazy evaluation через `@autoclosure` -- **Нормализация данных**: дедупликация barcode/text по пространственному хешу (24×24 grid), детерминированная сортировка -- **Кроссплатформа**: одинаковые response shape, error codes, нормализация дат и валют -- **Безопасность**: валидация base64, проверка существования файлов, security-scoped resource access на iOS - ---- - -### Рекомендации по приоритету - -**До релиза (блокеры):** -1. Очистка `scannerCache` в `invalidate()` на Android -2. `processPages()` → итерация вместо рекурсии - -**Желательно до релиза:** -3. Очистка `launcher = null` в `invalidate()` -4. Хотя бы минимальные unit-тесты для TypeScript слоя - -**После релиза (v0.4+):** -5. Убрать legacy iOS barcode/OCR пути когда iOS 26+ станет baseline -6. Сделать таймауты конфигурируемыми -7. Добавить `assertionFailure` для regex компиляции -8. Заменить рекурсию в `processAttempt()` на итерацию \ No newline at end of file