diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11e7cd4..9fd53d3 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,144 @@ 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" 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" + + 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() + 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 }}/expo-config.stderr.log + ${{ 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 +201,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,13 +236,15 @@ 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 }}" 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 b8e070a..7c3b79a 100644 --- a/README.md +++ b/README.md @@ -3,26 +3,66 @@ [![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 public API is identical. +- 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. +## Demo -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. +https://github.com/user-attachments/assets/fda6d4d3-ac87-41d6-a04c-11f0ce08f5e8 -| 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) | +## Why this fork + +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. + +### 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 +70,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,153 +83,309 @@ 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 - -> Demo media in this README is courtesy of the original project (WebsiteBeaver). +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. -* [Basic Example](#basic-example) -* [Limit Number of Scans](#limit-number-of-scans) +## Quick start -### Basic Example +### 1. Scan a document -```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 - +```ts +const analysis = await DocumentScanner.analyzeScannedImages(images, { + extract: { + barcodes: true, + text: true, + tables: true, + structuredData: true, + }, + concurrency: 2, +}) +``` - +### 5. Scan and analyze in one call - +```ts +const result = await DocumentScanner.scanAndAnalyzeDocument({ + responseType: 'imageFilePath', + analysis: { + extract: { + barcodes: true, + text: true, + }, + concurrency: 2, + }, +}) +``` - +## API overview - +### Capture -### Limit Number of Scans +- `scanDocument(options?)` -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. +Open native document scanner UI and return sanitized images. -```javascript -import React, { useState, useEffect } from 'react' -import { Image } from 'react-native' -import DocumentScanner from '@preeternal/react-native-document-scanner-plugin' +### Targeted analysis -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]) - } +- `extractBarcodesFromImages(images, options?)` +- `extractTextFromImages(images, options?)` + +Run only the stage you need for lower latency and smaller payloads. + +### Combined analysis + +- `analyzeScannedImages(images, options)` +- `scanAndAnalyzeDocument(options)` + +Use these when your app needs richer OCR and document understanding output. + +## Recommended usage modes + +### Simple mode + +Request only the data your screen or business flow needs. +Examples: + +```ts +{ extract: { barcodes: true } } +``` + +```ts +{ extract: { text: true } } +``` + +This keeps latency and payload size lower. + +### Full mode + +Request richer OCR semantics when you need downstream processing: + +```ts +{ + extract: { + text: true, + tables: true, + regions: true, + structuredData: true, } +} +``` - useEffect(() => { - // call scanDocument on load - scanDocument() - }, []); +This is useful for receipts, forms, invoice parsing, document intake, or backend enrichment. - return ( - - ) +### Analysis guidance + +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 / OCR / table-related analysis features are opt-in on Android. +This keeps the core scanning package lean for apps that only need capture. + +Enable features when building Android: + +```bash +./gradlew :react-native-document-scanner-plugin:assemble -PDocumentScanner_analysisFeatures=barcode +``` + +Or configure `android/gradle.properties`: + +```properties +DocumentScanner_analysisFeatures=barcode +``` + +Accepted values: + +- `barcode` +- `text` +- `tables` +- comma-separated combinations such as `barcode,text` +- `all` +- `none` + +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`. + +### Expo / EAS: configure via app.json + +For Expo managed and bare workflows, use the built-in config plugin instead of editing `gradle.properties` manually: + +```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). -## Differences from the original +The plugin writes `DocumentScanner_analysisFeatures` to `android/gradle.properties` during `expo prebuild` / EAS build. iOS requires no configuration — analysis features are always available. -- New Architecture (TurboModule) support — **now also available upstream**; this fork shipped it earlier and keeps parity. -- Additional hardening for Expo/EAS and CI examples. -- Minor documentation updates and ongoing maintenance. +## iOS behavior: real device vs simulator -## Documentation +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. -* [`scanDocument(...)`](#scandocument) -* [Interfaces](#interfaces) -* [Enums](#enums) +### Why this matters -### Response sanitization (since v0.2.2) +Apple Vision APIs can behave differently between Simulator and real hardware, especially for barcode detection. -The module now sanitizes results on both platforms, so you no longer need to post‑filter `scannedImages` in JS: +Practical expectations: -- 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. +- 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. -As a result `scannedImages` contains only valid items. Example: +## 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) { - // All items are valid URIs or base64 strings depending on responseType +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 the camera, and starts the document scan +Opens native camera UI and starts document scanning. | Param | Type | | ------------- | ------------------------------------------------------------------- | @@ -202,98 +393,284 @@ Opens the camera, and starts the document scan **Returns:** Promise<ScanDocumentResponse> --------------------- +### extractBarcodesFromImages(...) + +```typescript +extractBarcodesFromImages( + images: string[], + options?: ExtractBarcodesFromImagesOptions | undefined +) => Promise +``` + +Extracts barcodes from existing images without opening scanner UI. + +| Param | Type | +| ------------- | ------------------------------------------------------------------------------------------------------ | +| **`images`** | string[] | +| **`options`** | ExtractBarcodesFromImagesOptions | + +**Returns:** Promise<Barcode[]> + +### 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 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(...) + +```typescript +analyzeScannedImages( + images: string[], + options: AnalyzeScannedImagesOptions +) => Promise +``` + +Universal post-processing over scanned images. +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, { + extract: { barcodes: true }, + barcodeFormats: ['qr', 'ean13'], + concurrency: 2, +}) +``` + +**Returns:** Promise<AnalysisResult> + +### scanAndAnalyzeDocument(...) + +```typescript +scanAndAnalyzeDocument( + options: ScanAndAnalyzeDocumentOptions +) => Promise +``` + +Convenience API for one-call flow: + +```ts +scanDocument(...) -> analyzeScannedImages(...) +``` + +```ts +const result = await DocumentScanner.scanAndAnalyzeDocument({ + responseType: 'imageFilePath', + analysis: { + extract: { barcodes: true }, + barcodeFormats: ['qr'], + concurrency: 2, + }, +}) +``` + +**Returns:** Promise<ScanAndAnalyzeDocumentResponse> + +## Interfaces + +### ScanDocumentResponse + +- `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. + +### 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 + +- `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 + +- `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 + +- `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. + +### 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`. + +### AnalysisResult + +- `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 + +- `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`. + +### ScanAndAnalyzeDocumentResponse + +- `status`: [`ScanDocumentResponseStatus`](#scandocumentresponsestatus) — Scan status. +- `scannedImages`: `string[]` — Captured and sanitized images. +- `analysis`: [`AnalysisResult`](#analysisresult) — Post-processing result payload. + +### Barcode +- `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`. -### Interfaces +### TextBlock +- `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. -#### ScanDocumentResponse +### TableBlock -| 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. | +- `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 -#### ScanDocumentOptions +- `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. -| 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 | +### StructuredData +- `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 +Normalization guarantees: +- `date` values are normalized to ISO-like `YYYY-MM-DD` when parser confidence is sufficient. +- `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. -#### ScanDocumentResponseStatus +Best-effort quality note: -| 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. | +- Output shape is stable across platforms. +- Extraction quality may still vary depending on OS/runtime capabilities. +## Enums -#### ResponseType +### ScanDocumentResponseStatus -| 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. | +| Member | Value | Description | +| :-------------- | :---------------- | :------------------------------------------------- | +| `Success` | `'success'` | Scan completed successfully. | +| `Cancel` | `'cancel'` | User closed the scanner before completing the flow.| +### ResponseType -## Common Mistakes +| Member | Value | Description | +| :-------------- | :---------------- | :----------------------------------------------- | +| `Base64` | `'base64'` | Return scanned images as base64 strings. | +| `ImageFilePath` | `'imageFilePath'` | Return scanned images as image file paths. | -* [Android Camera Permissions](#android-camera-permissions) +## Common mistakes ### Android Camera Permissions -You don't need to request camera permissions unless you're using another camera plugin that adds `` to the application's `AndroidManifest.xml`. +You do not need to request camera permissions unless another camera plugin adds: -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` +```xml + +``` -Here's an example of how to request camera permissions. +If that permission is present and not granted, you can get errors like: -```javascript -import React, { useState, useEffect } from 'react' -import { Platform, PermissionsAndroid, Image, Alert } from 'react-native' -import DocumentScanner from '@preeternal/react-native-document-scanner-plugin' +```txt +Error: error - error opening camera: Permission Denial: starting Intent { act=android.media.action.IMAGE_CAPTURE +``` -export default () => { - const [scannedImage, setScannedImage] = useState(); +Example permission request flow: - 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 - } +```tsx +import React, { useEffect, useState } from 'react' +import { Alert, Image, PermissionsAndroid, Platform } from 'react-native' +import DocumentScanner from '@preeternal/react-native-document-scanner-plugin' - // 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 () => { + 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]) + } + } + + run() + }, []) return ( ) } @@ -301,31 +678,52 @@ 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(...)`). + +### 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 -- **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 - - ``` +```bash +yarn remove react-native-document-scanner-plugin +yarn add @preeternal/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 + +The current direction is to keep the package strong in the **capture + analysis** layer rather than turning it into a full document platform. + +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/build.gradle b/android/build.gradle index 52eb079..8152759 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -25,6 +25,34 @@ def getExtOrIntegerDefault(name) { return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["DocumentScanner_" + name]).toInteger() } +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") +def textFeatureEnabled = analysisFeatures.contains("text") +def tablesFeatureEnabled = analysisFeatures.contains("tables") +def textPipelineEnabled = textFeatureEnabled || tablesFeatureEnabled + android { namespace "com.preeternal.scanner" @@ -35,10 +63,6 @@ android { targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") } - buildFeatures { - buildConfig true - } - buildTypes { release { minifyEnabled false @@ -60,6 +84,16 @@ android { "generated/java", "generated/jni" ] + if (barcodeFeatureEnabled) { + java.srcDirs += ["src/barcode/java"] + } else { + java.srcDirs += ["src/no-barcode/java"] + } + if (textPipelineEnabled) { + java.srcDirs += ["src/text/java"] + } else { + java.srcDirs += ["src/no-text/java"] + } } } } @@ -74,5 +108,13 @@ 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" + 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 new file mode 100644 index 0000000..e34bb91 --- /dev/null +++ b/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeExtractorImpl.kt @@ -0,0 +1,373 @@ +package com.preeternal.scanner.barcode + +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 + private const val CENTER_BUCKETS = 24 + + 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() + + 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 + ) + + 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 { + 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) + val detected = processAttempts( + scanner = scanner, + sourceBitmap = sourceBitmap, + sourceImageIndex = sourceImageIndex, + allowedFormats = normalizedAllowList + ) + callback(detected) + } + + private fun processAttempts( + scanner: BarcodeScanner, + sourceBitmap: Bitmap, + sourceImageIndex: Int, + 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 + } + + val rotationIndex = attemptIndex % 4 + val useRoi = attemptIndex < 4 + + val angle = when (rotationIndex) { + 0 -> 0f + 1 -> 180f + 2 -> 90f + else -> -90f + } + + val candidate = if (angle == 0f) { + sourceBitmap + } else { + rotateBitmap(sourceBitmap, angle) + } + + if (candidate == null) { + logTrace("attempt=$attemptIndex rotate failed angle=$angle") + continue + } + + val targetBitmap = if (useRoi) { + cropTopRightRoi(candidate) + } else { + candidate + } + + if (targetBitmap == null) { + logTrace("attempt=$attemptIndex target bitmap null useRoi=$useRoi") + continue + } + + 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 + } + + 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( + barcodes: List, + sourceImageIndex: Int, + allowedFormats: Set, + attemptIndex: Int, + roiWidth: Int, + 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 + } + + 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 + ) + ) + } + } + + logTrace( + "attempt=$attemptIndex candidates dedup=${dedup.size} filteredEmpty=$filteredEmpty filteredAllow=$filteredAllow" + ) + + 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 + 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) { + 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 + } + } + + 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) { + logWarn("rotateBitmap failed angle=$angle") + 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) + } + 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 new file mode 100644 index 0000000..44029cf --- /dev/null +++ b/android/src/barcode/java/com/preeternal/scanner/barcode/BarcodeImageSourceLoader.kt @@ -0,0 +1,218 @@ +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 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)?.also { + logDebug("loadBitmap decoded data: base64 size=${it.width}x${it.height}") + } + } + + if (normalized.startsWith("content://", ignoreCase = true)) { + 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)))?.also { + logDebug("loadBitmap decoded file:// size=${it.width}x${it.height}") + } + } + + if (File(normalized).exists()) { + return loadFromFilePath(normalized)?.also { + logDebug("loadBitmap decoded path size=${it.width}x${it.height}") + } + } + + if (looksLikeBase64(normalized)) { + return decodeFromBase64(normalized)?.also { + logDebug("loadBitmap decoded plain-base64 size=${it.width}x${it.height}") + } + } + + val parsed = Uri.parse(normalized) + return when (parsed.scheme?.lowercase()) { + "content" -> loadFromUri(context, parsed) + "file" -> loadFromFilePath(resolveFilePathFromUri(parsed)) + null, "" -> loadFromFilePath(normalized) + else -> loadFromUri(context, parsed) + }?.also { + logDebug("loadBitmap decoded fallback size=${it.width}x${it.height}") + } + } + + private fun decodeFromBase64(payload: String): Bitmap? { + return try { + val decoded = Base64.decode(payload, Base64.DEFAULT) + decodeSampledBitmap(decoded) + } catch (_: IllegalArgumentException) { + logWarn("decodeFromBase64 failed") + null + } + } + + private fun loadFromUri(context: Context, uri: Uri): Bitmap? { + 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 + + rotateBitmapIfRequired(bitmap, orientation) + } catch (_: Exception) { + logWarn("loadFromUri failed uri=$uri") + null + } + } + + private fun loadFromFilePath(path: String?): Bitmap? { + val normalized = path?.trim() + if (normalized.isNullOrBlank()) { + return null + } + + 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) { + logDebug("loadFromFilePath missing file path=$candidatePath") + return null + } + + return try { + val bitmap = FileInputStream(file).use { decodeSampledBitmap(it) } ?: return null + val orientation = FileInputStream(file).use { + readOrientation(it) + } + + rotateBitmapIfRequired(bitmap, orientation) + } catch (_: Exception) { + logWarn("loadFromFilePath failed path=$candidatePath") + null + } + } + + 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) { + logDebug("decodeSampledBitmap invalid bounds") + 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) { + logWarn("rotateBitmap failed angle=$angle") + 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 resolveFilePathFromUri(uri: Uri): String? { + val rawPath = uri.path ?: return null + return Uri.decode(rawPath) + } +} 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 70d45ae..d1afa70 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,9 +22,38 @@ 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 +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) : @@ -32,11 +62,32 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : companion object { const val NAME = "DocumentScanner" 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 + 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 val launcherInitLock = Any() private var pendingPromise: Promise? = null private var pendingOptions: ReadableMap? = null private var pendingQuality: Int = 100 @@ -45,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") @@ -63,18 +123,275 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : pendingPromise = promise pendingOptions = options - pendingQuality = - if (options.hasKey("croppedImageQuality")) options.getInt("croppedImageQuality") else 100 + 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) initLauncher(componentActivity) initScanner(options) - startScan(activity) } + 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 + } + + if (!barcodeExtractor.isFeatureEnabled()) { + promise.reject( + "barcode_not_enabled", + "Barcode extraction feature is disabled. Enable -PDocumentScanner_analysisFeatures=barcode to build with barcode support." + ) + return + } + + 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 + } + + scope.launch { + try { + val extracted = extractBarcodesInParallel( + context = reactApplicationContext, + sources = validSources, + allowedFormats = allowedFormats, + 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 + } + + 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 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 + } + + scope.launch { + try { + val extracted = extractTextInParallel( + 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 + } + + 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 + 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 + } + + val allowedFormats = parseAllowedFormats(getArrayOrNull(options, "barcodeFormats")) + val requestedConcurrency = getIntOrNull(options, "concurrency") ?: 2 + val concurrency = requestedConcurrency.coerceIn(1, 2) + 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 + } + + scope.launch { + try { + val (barcodeStage, textStage) = coroutineScope { + val barcodeDeferred = async { + runBarcodeAnalysisStage( + wantsStage = wantsBarcodes, + sources = validSources, + allowedFormats = allowedFormats, + concurrency = concurrency, + timeoutMs = barcodeTimeoutMs + ) + } + + val textDeferred = async { + runTextAnalysisStage( + wantsStage = wantsTextPipeline, + sources = validSources, + concurrency = concurrency, + timeoutMs = textTimeoutMs, + ocrRotate180Fallback = ocrRotate180Fallback + ) + } + + Pair(barcodeDeferred.await(), textDeferred.await()) + } + + val result = WritableNativeMap() + result.putString( + "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())) + } + + 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) + 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) + } + } + } + private fun initScanner(options: ReadableMap) { val builder = GmsDocumentScannerOptions.Builder() .setResultFormats(GmsDocumentScannerOptions.RESULT_FORMAT_JPEG) @@ -87,50 +404,618 @@ 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 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) + 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() - return@register } + ) + } else { + response.putString("status", "cancel") + response.putArray("scannedImages", images) + promise.resolve(response) + clearPending() + } + } + } + } - if (encoded.isNotBlank()) { - images.pushString(encoded) - } - } else if (uri != null && canReadUri(activity, uri)) { - images.pushString(uri.toString()) + private fun processPages( + activity: Activity, + pages: List, + pageIndex: Int, + responseType: String?, + images: WritableNativeArray, + onError: (String) -> Unit, + onComplete: () -> Unit + ) { + 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.isNullOrBlank()) { + images.pushString(outputImage) + } + currentIndex += 1 + } + + onComplete() + } + + private fun mapOutputImage( + activity: Activity, + uri: Uri, + responseType: String? + ): String? { + 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 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 + ) + + private fun buildValidImageSources(images: ReadableArray): List { + 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 + ) + ) + } else { + logDebug("buildValidImageSources skip source[$index] empty or invalid") + } + } + logDebug("buildValidImageSources mapped=${validSources.size} from raw=${images.size()}") + return validSources + } + + private suspend fun extractBarcodesInParallel( + context: ReactApplicationContext, + sources: List, + allowedFormats: Set, + 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 -> + async { + limiter.withPermit { + extractBarcodesForSource( + context = context, + imageSource = source.imageSource, + sourceImageIndex = source.sourceImageIndex, + allowedFormats = allowedFormats, + timeoutMs = timeoutMs + ) + } + } + } + + val merged = tasks.awaitAll().flatten() + logDebug("extractBarcodesInParallel completed total=${merged.size}") + merged + } + + private suspend fun extractBarcodesForSource( + context: ReactApplicationContext, + imageSource: String, + sourceImageIndex: Int, + allowedFormats: Set, + timeoutMs: Long + ): List { + logDebug( + "extractBarcodesForSource start index=$sourceImageIndex sourceLength=${imageSource.length} allowedFormats=${allowedFormats.sorted()}" + ) + val detected = withTimeoutOrNull(timeoutMs) { + suspendCancellableCoroutine { continuation -> + barcodeExtractor.extractFromSource( + context = context, + imageSource = imageSource, + sourceImageIndex = sourceImageIndex, + allowedFormats = allowedFormats + ) { detected -> + if (continuation.isActive) { + continuation.resume(detected) } } + } + } + + if (detected == null) { + logWarn( + "Barcode extraction timed out after ${timeoutMs}ms for sourceImageIndex=$sourceImageIndex" + ) + return emptyList() + } - response.putString("status", "success") + logDebug("extractBarcodesForSource completed index=$sourceImageIndex detected=${detected.size}") + return detected + } + + private suspend fun extractTextInParallel( + 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 -> + async { + limiter.withPermit { + extractTextForSource( + context = context, + imageSource = source.imageSource, + sourceImageIndex = source.sourceImageIndex, + timeoutMs = timeoutMs, + ocrRotate180Fallback = ocrRotate180Fallback + ) + } + } + } + + 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 { + logDebug( + "extractTextForSource start index=$sourceImageIndex sourceLength=${imageSource.length} rotate180=$ocrRotate180Fallback" + ) + val detected = withTimeoutOrNull(timeoutMs) { + suspendCancellableCoroutine { continuation -> + textExtractor.extractFromSource( + context = context, + imageSource = imageSource, + sourceImageIndex = sourceImageIndex, + enableRotate180Fallback = ocrRotate180Fallback + ) { extracted -> + if (continuation.isActive) { + continuation.resume(extracted) + } + } + } + } + + 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, + timeoutMs: Long + ): AnalysisStageResult> { + if (!wantsStage) { + logDebug("runBarcodeAnalysisStage skipped") + return AnalysisStageResult(AnalysisStageStatus.SKIPPED) + } + if (!barcodeExtractor.isFeatureEnabled()) { + logDebug("runBarcodeAnalysisStage not enabled") + return AnalysisStageResult(AnalysisStageStatus.NOT_ENABLED) + } + + return try { + val value = extractBarcodesInParallel( + context = reactApplicationContext, + sources = sources, + allowedFormats = allowedFormats, + concurrency = concurrency, + timeoutMs = timeoutMs + ) + AnalysisStageResult( + status = AnalysisStageStatus.SUCCESS, + value = value + ) + } catch (error: Exception) { + logWarn("runBarcodeAnalysisStage failed: ${error.message}") + AnalysisStageResult(AnalysisStageStatus.FAILED) + } + } + + private suspend fun runTextAnalysisStage( + 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) + } + + return try { + val value = extractTextInParallel( + context = reactApplicationContext, + sources = sources, + concurrency = concurrency, + timeoutMs = timeoutMs, + ocrRotate180Fallback = ocrRotate180Fallback + ) + AnalysisStageResult( + status = AnalysisStageStatus.SUCCESS, + value = value + ) + } catch (error: Exception) { + logWarn("runTextAnalysisStage failed: ${error.message}") + 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 }) + ) + + val payload = WritableNativeArray() + for (barcode in sorted) { + payload.pushMap(toWritableBarcode(barcode)) + } + 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() + val sortedEntities = data.entities.sortedWith( + compareBy( + { it.sourceImageIndex }, + { it.type }, + { it.value } + ) + ) + for (entity in sortedEntities) { + entities.pushMap(toWritableStructuredEntity(entity)) + } + map.putArray("entities", entities) + } + + if (data.fields.isNotEmpty()) { + val fields = WritableNativeArray() + for ((key, value) in data.fields.toSortedMap()) { + 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) + } + } + + private fun rejectOnUi(promise: Promise, code: String, message: String, throwable: Throwable?) { + reactApplicationContext.runOnUiQueueThread { + promise.reject(code, message, throwable) + } + } + + 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 { - response.putString("status", "cancel") + map.getArray(key) } + } catch (_: Exception) { + null + } + } - response.putArray("scannedImages", images) - promise.resolve(response) + 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 + } + } - clearPending() + 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)) { + null + } else { + map.getBoolean(key) + } + } catch (_: Exception) { + null + } + } + + 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 } } @@ -160,7 +1045,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) @@ -173,6 +1059,16 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) : restoreSystemBars() } + override fun invalidate() { + super.invalidate() + launcher?.unregister() + launcher = null + scanner = null + barcodeExtractor.release() + clearPending() + scope.cancel() + } + private fun ensureSystemBarsVisible(activity: ComponentActivity) { if (Build.VERSION.SDK_INT < ANDROID_15_API) return if (previousFitsSystemWindows != null) return 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..629d1df --- /dev/null +++ b/android/src/main/java/com/preeternal/scanner/analysis/DocumentSemantics.kt @@ -0,0 +1,266 @@ +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().orEmpty() + val value = match.groupValues.getOrNull(2)?.trim().orEmpty() + 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 + ) + } + } + } + + 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 -> + appendEntityValue( + type = type, + rawValue = match.value, + line = line, + entities = entities, + dedup = dedup + ) + } + } + + private fun appendEntityValue( + type: String, + rawValue: String, + line: TextLineEntry, + entities: MutableList, + dedup: MutableSet + ) { + val normalizedValue = StructuredDataNormalizer.normalizeEntityValue(type, rawValue) + if (normalizedValue.isEmpty()) { + return + } + + 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 { + 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/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/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..f30dfc5 --- /dev/null +++ b/android/src/main/java/com/preeternal/scanner/barcode/BarcodeExtractor.kt @@ -0,0 +1,26 @@ +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 + ) + + /** + * Releases implementation-specific resources (for example cached ML Kit scanners). + * Default no-op for feature-disabled stubs. + */ + fun release() {} +} 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/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-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/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/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/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.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/DocumentScannerExample/Info.plist b/example/ios/DocumentScannerExample/Info.plist index 05add9d..4c5841e 100644 --- a/example/ios/DocumentScannerExample/Info.plist +++ b/example/ios/DocumentScannerExample/Info.plist @@ -33,7 +33,9 @@ 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 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 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 891083c..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 @@ -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 @@ -1356,7 +1356,130 @@ PODS: - React-jsiexecutor - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - - React-NativeModulesApple (0.79.2): + - react-native-document-picker (11.0.3): + - 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-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 @@ -1369,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 @@ -1390,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 @@ -1416,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) @@ -1430,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) @@ -1456,7 +1579,7 @@ PODS: - React-runtimescheduler - React-utils - Yoga - - React-RCTFBReactNativeSpec (0.79.2): + - React-RCTFBReactNativeSpec (0.79.7): - hermes-engine - RCT-Folly - RCTRequired @@ -1467,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 @@ -1476,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 @@ -1491,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) @@ -1504,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 @@ -1512,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 @@ -1555,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) @@ -1572,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 @@ -1586,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) @@ -1603,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 @@ -1635,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) @@ -1723,6 +1846,9 @@ 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-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`) @@ -1777,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: @@ -1840,6 +1966,12 @@ 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-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: @@ -1907,78 +2039,81 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - DocumentScanner: ab4f56ed3b32b95c86df9f4ec53666064f5e354e + DocumentScanner: 6263e67b4009293bd1471fad908c4585cd44504d 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 - 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 + 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: 169fadd7445c3930416cc3eb241dd4e1dabdae91 + react-native-image-picker: db0b3e3c7ae57680876318d4f42e7dbedd72244a + 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 c16892f..b80b81e 100644 --- a/example/package.json +++ b/example/package.json @@ -10,19 +10,22 @@ "build:ios": "react-native build-ios --mode Debug" }, "dependencies": { + "@react-native-documents/picker": "^11.0.3", "react": "19.0.0", - "react-native": "0.79.2" + "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 f9b0411..be6ae6b 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,33 +1,883 @@ -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, + ScrollView, + StyleSheet, + Switch, + Text, + View, +} from 'react-native'; +import { + SafeAreaProvider, + SafeAreaView, + useSafeAreaInsets, +} from 'react-native-safe-area-context'; +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[] = []; + +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 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) { + 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)} + + + ); +} + +function AppContent() { + const insets = useSafeAreaInsets(); + 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 firstImageScheme = imageSourceScheme(scannedImages[0]); + 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'} + + barcodeFormats:{' '} + {barcodeFormats.length > 0 ? barcodeFormats.join(', ') : 'all'} + + + first image scheme: {firstImageScheme} + + + 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 && ( + + )} + + + ); +} + +export default function App() { + return ( + + + + ); +} 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/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..915b84d --- /dev/null +++ b/ios/DocScanner/Analysis/DocumentSemantics.swift @@ -0,0 +1,376 @@ +import Foundation + +private struct TextLineEntry { + let text: String + let sourceImageIndex: Int + let boundingBox: AnalysisBoundingBox? +} + +enum DocumentSemantics { + private static let fieldRegex = compileRegex( + name: "fieldRegex", + pattern: "^([A-Za-z0-9А-Яа-я _./-]{2,40})\\s*[:-]\\s*(.+)$", + options: [] + ) + + private static let phoneRegex = compileRegex( + name: "phoneRegex", + pattern: "(?:\\+?\\d[\\d\\s().-]{7,}\\d)", + options: [] + ) + + private static let emailRegex = compileRegex( + name: "emailRegex", + pattern: "[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}", + options: [.caseInsensitive] + ) + + 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 = compileRegex( + name: "amountRegex", + pattern: "\\b(?:[$€£]\\s?)?\\d{1,3}(?:[ ,]\\d{3})*(?:[.,]\\d{2})\\b", + options: [] + ) + + 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] = [] + + 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) + let value = match[1].trimmingCharacters(in: .whitespacesAndNewlines) + 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 + ) + } + } + } + + 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 { + appendEntityValue( + type: type, + rawValue: ns.substring(with: match.range), + line: line, + entities: &entities, + dedup: &dedup + ) + } + } + + 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 + } + + 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 new file mode 100644 index 0000000..5f77502 --- /dev/null +++ b/ios/DocScanner/Analysis/RecognizeDocumentsAnalyzer.swift @@ -0,0 +1,678 @@ +import DataDetection +import Foundation +import UIKit +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 + 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 { + 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: [], + textBlocks: [], + tables: [], + regions: [], + structuredData: AnalysisStructuredData(entities: [], fields: [:]) + ) + + Task { + output = await analyzeImage( + image, + sourceImageIndex: sourceImageIndex, + options: options + ) + semaphore.signal() + } + + 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 + } + + private static func analyzeImage( + _ image: UIImage, + sourceImageIndex: Int, + options: Options + ) async -> PageAnalysis { + 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: [], + 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 + } + log( + "analyzeImage sourceImageIndex=\(sourceImageIndex) allowedFormatsNormalized=\(Array(allowedFormats).sorted()) allowedSymbologies=\(allowedSymbologies)" + ) + request.barcodeDetectionOptions = barcodeOptions + + let handler = ImageRequestHandler(cgImage) + + let observations: [DocumentObservation] + do { + observations = try await handler.perform(request) + } catch { + log("analyzeImage sourceImageIndex=\(sourceImageIndex) request failed error=\(error.localizedDescription)") + return PageAnalysis( + barcodes: [], + textBlocks: [], + tables: [], + regions: [], + structuredData: AnalysisStructuredData(entities: [], fields: [:]) + ) + } + log("analyzeImage sourceImageIndex=\(sourceImageIndex) observations=\(observations.count)") + + 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() + var barcodesFilteredByAllowList = 0 + + 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) { + barcodesFilteredByAllowList += 1 + 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 + ) + } + } + + 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 + } + + 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) + : [] + + let result = 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 + ) + ) + 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( + 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 = StructuredDataNormalizer.normalizeFieldKey( + rowValues[0].trimmingCharacters(in: .whitespacesAndNewlines) + ) + 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 + } + + appendNormalizedEntity( + type: mapped.type, + rawValue: mapped.value, + sourceImageIndex: sourceImageIndex, + boundingBox: toBoundingBox(data.boundingRegion.boundingBox), + entities: &entities, + dedup: &dedup + ) + } + } + + 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 + } + + 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 { + 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/StructuredDataNormalizer.swift b/ios/DocScanner/Analysis/StructuredDataNormalizer.swift new file mode 100644 index 0000000..99588c4 --- /dev/null +++ b/ios/DocScanner/Analysis/StructuredDataNormalizer.swift @@ -0,0 +1,392 @@ +import Foundation + +enum StructuredDataNormalizer { + private static let fieldKeySanitizeRegex = compileRegex( + name: "fieldKeySanitizeRegex", + pattern: "[^a-z0-9а-я]+", + options: [] + ) + + private static let currencyCodeRegex = compileRegex( + name: "currencyCodeRegex", + pattern: "\\b(usd|eur|gbp|uah|rub|brl)\\b", + options: [.caseInsensitive] + ) + + private static let numericCandidateRegex = compileRegex( + name: "numericCandidateRegex", + pattern: "[-+]?\\d[\\d.,\\s]*\\d|[-+]?\\d", + options: [] + ) + + private static let nonIdSymbolRegex = compileRegex( + name: "nonIdSymbolRegex", + pattern: "[^A-Z0-9_-]", + options: [] + ) + + 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", + "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/DocScanner/Analysis/TextExtractor.swift b/ios/DocScanner/Analysis/TextExtractor.swift new file mode 100644 index 0000000..3dd6be0 --- /dev/null +++ b/ios/DocScanner/Analysis/TextExtractor.swift @@ -0,0 +1,222 @@ +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 preparedImage = image.normalizedForVision() + let firstPass = extractSinglePass( + preparedImage, + sourceImageIndex: sourceImageIndex + ) + + guard enableRotate180Fallback else { + return firstPass + } + guard shouldRunRotateFallback(firstPass) else { + return firstPass + } + guard let rotated = rotate180(preparedImage) 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 new file mode 100644 index 0000000..d6d6777 --- /dev/null +++ b/ios/DocScanner/Barcode/BarcodeExtractor.swift @@ -0,0 +1,755 @@ +import CoreGraphics +import CoreImage +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 + let instanceKey: String +} + +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( + preparedImage, + allowedFormats: normalizedAllowedFormats, + visionEnabled: &visionEnabled, + attemptIndex: 0 + ) + appendUnique( + firstPass, + deduplicated: &deduplicated, + aggregated: &aggregated + ) + if !aggregated.isEmpty { + log("extract resolved after attempt=0 total=\(aggregated.count)") + return aggregated + } + + if let rotated180 = preparedImage.rotated(by: .pi) { + let secondPass = detectInTopRightROI( + rotated180, + allowedFormats: normalizedAllowedFormats, + visionEnabled: &visionEnabled, + attemptIndex: 1 + ) + appendUnique( + secondPass, + deduplicated: &deduplicated, + aggregated: &aggregated + ) + if !aggregated.isEmpty { + log("extract resolved after attempt=1 total=\(aggregated.count)") + return 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 rotated180 = preparedImage.rotated(by: .pi) { + let fullFrameSecondPass = detectInFullFrame( + rotated180, + allowedFormats: normalizedAllowedFormats, + visionEnabled: &visionEnabled, + attemptIndex: 3 + ) + appendUnique( + 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 + } + + private static func appendUnique( + _ detected: [ExtractedBarcode], + 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 { + 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 { + trace("attempt=\(attemptIndex) roi skip: invalid roi \(roi)") + 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() + 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 + } + } 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 + } + } + } + + 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)|\(centerBucket)" + if deduplicated.insert(dedupKey).inserted { + results.append( + ExtractedBarcode( + value: payload, + format: normalizedFormat, + instanceKey: dedupKey + ) + ) + } + } + 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) + + 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 + 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 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)) + } +} + +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)) + .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 + } +} diff --git a/ios/DocScanner/Barcode/BarcodeImageSource.swift b/ios/DocScanner/Barcode/BarcodeImageSource.swift new file mode 100644 index 0000000..43fe3cf --- /dev/null +++ b/ios/DocScanner/Barcode/BarcodeImageSource.swift @@ -0,0 +1,219 @@ +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 + } + + 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) { + 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 + } + + private static func loadFromFileUri(_ value: String) -> UIImage? { + guard let url = URL(string: value), url.isFileURL else { + return nil + } + + if let image = loadFromPath(url.path) { + return image + } + + if let data = loadDataFromSecurityScopedURL(url) { + DocScannerDebugLog.log("BarcodeImageSource", "loaded file:// via security-scoped data") + return UIImage(data: data) + } + + return nil + } + + private static func loadFromPath(_ value: String) -> UIImage? { + let path = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { + return nil + } + + 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 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,") { + payload = String(value[marker.upperBound...]) + } else { + payload = value + } + + 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? { + 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]) + } + + DocScannerDebugLog.log("BarcodeImageSource", "security-scoped read failed for url=\(url.absoluteString)") + 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) + } + } +} 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 251c633..67e1aeb 100644 --- a/ios/DocScanner/DocScanner.swift +++ b/ios/DocScanner/DocScanner.swift @@ -2,209 +2,157 @@ import UIKit import VisionKit /** - 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 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 { - - /** @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 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 - - @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 }, - 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 - } - - /** - constructor for DocScanner - - @return Returns a DocScanner - */ - public convenience override init() { - self.init(nil) - } - - /** - 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) - } - } - - /** - 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 - */ - public func startScan( - _ viewController: UIViewController? = nil, - successHandler: @escaping ([String]) -> 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 func log(_ message: @autoclosure () -> String) { + DocScannerDebugLog.log("DocScanner", message()) + } + + 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 + ) { + 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) + } + + public func startScan() { + if !VNDocumentCameraViewController.isSupported { + log("startScan unsupported device") + self.errorHandler("Document scanning is not supported on this device") + return } - - /** - 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 - */ - public func documentCameraViewController( - _ controller: VNDocumentCameraViewController, - didFinishWith scan: VNDocumentCameraScan - ) { - var results: [String] = [] - - // 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) - .jpegData(compressionQuality: CGFloat(self.croppedImageQuality) / CGFloat(100)) else { - goBackToPreviousView(controller) - self.errorHandler("Unable to get scanned document in jpeg format") - return - } - - switch responseType { - case ResponseType.base64: - // convert scan jpeg data to base64 - let base64EncodedImage: String = scannedDocumentImage.base64EncodedString() - results.append(base64EncodedImage) - 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) - } catch { - goBackToPreviousView(controller) - self.errorHandler("Unable to save scanned image: \(error.localizedDescription)") - return - } - default: - self.errorHandler( - "responseType must be \(ResponseType.base64) or \(ResponseType.imageFilePath)" - ) - } - - } - - // exit document scanner - goBackToPreviousView(controller) - - // return scanned document results - self.successHandler(results) + + DispatchQueue.main.async { + self.log("startScan presenting camera") + let documentCameraViewController = VNDocumentCameraViewController() + documentCameraViewController.delegate = self + self.viewController?.present(documentCameraViewController, animated: true) } - - /** - 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 + } + + 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 + ) { + self.viewController = viewController + self.successHandler = successHandler + self.errorHandler = errorHandler + self.cancelHandler = cancelHandler + self.responseType = responseType ?? ResponseType.imageFilePath + self.croppedImageQuality = croppedImageQuality ?? 100 + log( + "configure responseType=\(self.responseType) croppedImageQuality=\(self.croppedImageQuality)" + ) + + self.startScan() + } + + public func documentCameraViewController( + _ controller: VNDocumentCameraViewController, + 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) - self.cancelHandler() - } + log("page[\(pageNumber)] jpeg encode failed") + self.errorHandler("Unable to get scanned document in jpeg format") + return + } + log("page[\(pageNumber)] jpeg bytes=\(scannedDocumentImage.count)") - /** - 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 + 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) - - // return the error message - self.errorHandler(error.localizedDescription) + log("page[\(pageNumber)] invalid responseType=\(responseType)") + self.errorHandler("responseType must be base64 or imageFilePath") + return + } + + processedResults.append([ + "image": imageIdentifier + ]) } - - /** - 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) - } + + goBackToPreviousView(controller) + log("didFinishWith resolved pages=\(processedResults.count)") + self.successHandler(processedResults) + } + + public func documentCameraViewControllerDidCancel( + _ controller: VNDocumentCameraViewController + ) { + goBackToPreviousView(controller) + log("documentCameraViewControllerDidCancel") + self.cancelHandler() + } + + public func documentCameraViewController( + _ controller: VNDocumentCameraViewController, + didFailWithError error: Error + ) { + goBackToPreviousView(controller) + log("documentCameraViewController didFailWithError=\(error.localizedDescription)") + self.errorHandler(error.localizedDescription) + } + + private func goBackToPreviousView(_ controller: VNDocumentCameraViewController) { + DispatchQueue.main.async { + controller.dismiss(animated: true) } + } } diff --git a/ios/DocumentScanner.mm b/ios/DocumentScanner.mm index 5df9887..b4fac13 100644 --- a/ios/DocumentScanner.mm +++ b/ios/DocumentScanner.mm @@ -33,6 +33,32 @@ - (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]; +} + +- (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]; +} + #if RCT_NEW_ARCH_ENABLED - (void)scanDocument:(JS::NativeDocumentScanner::ScanDocumentOptions &)options resolve:(RCTPromiseResolveBlock)resolve @@ -51,6 +77,116 @@ - (void)scanDocument:(JS::NativeDocumentScanner::ScanDocumentOptions &)options [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; + } + if (options.concurrency().has_value()) { + dict[@"concurrency"] = @(options.concurrency().value()); + } + + [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 { @@ -63,5 +199,26 @@ - (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]; +} + +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 43af2f8..3c0356d 100644 --- a/ios/DocumentScanner.swift +++ b/ios/DocumentScanner.swift @@ -5,6 +5,29 @@ import React @objc(DocumentScannerImpl) public class DocumentScannerImpl: NSObject { private var docScanner: DocScanner? + private let analysisQueue: OperationQueue = { + let queue = OperationQueue() + 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 + } + + private func log(_ scope: String, _ message: @autoclosure () -> String) { + DocScannerDebugLog.log(scope, message()) + } @objc static func requiresMainQueueSetup() -> Bool { true } @@ -14,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 @@ -23,16 +47,25 @@ 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: { images in + successHandler: { (scannedData: [[String: Any]]) in + self.log("scanDocument", "native scanner returned pages=\(scannedData.count)") let fm = FileManager.default - let sanitized: [String] = images.compactMap { raw -> String? in - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } + var sanitizedImages: [String] = [] + + 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 { @@ -41,22 +74,28 @@ public class DocumentScannerImpl: NSObject { path = trimmed } if !fm.fileExists(atPath: path) { - return nil + self.log("scanDocument", "skip non-existing file path for scanned page") + continue } } - return trimmed + + sanitizedImages.append(trimmed) } + resolve([ "status": "success", - "scannedImages": sanitized + "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": [] @@ -68,4 +107,903 @@ public class DocumentScannerImpl: NSObject { ) } } + + @objc(extractBarcodesFromImages:resolve:reject:) + public func extractBarcodesFromImages( + _ options: NSDictionary, + 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 + } + + 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)) + log( + "extractBarcodesFromImages", + "rawImages=\(rawImages.count) validSources=\(sources.count) concurrency=\(concurrency) allowedFormats=\(allowedFormats)" + ) + + if sources.isEmpty { + log("extractBarcodesFromImages", "no valid sources, resolve []") + 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 + 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) + } + } + + @objc(extractTextFromImages:resolve:reject:) + public func extractTextFromImages( + _ options: NSDictionary, + 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 + } + + 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)) + log( + "extractTextFromImages", + "rawImages=\(rawImages.count) validSources=\(sources.count) concurrency=\(concurrency) rotate180=\(ocrRotate180Fallback)" + ) + + if sources.isEmpty { + log("extractTextFromImages", "no valid sources, resolve []") + 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 + self.log("extractTextFromImages", "modern textBlocks=\(analysis.textBlocks.count)") + 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 + self.log("extractTextFromImages", "legacy textBlocks=\(textBlocks.count)") + resolve(self.toDictionaryArray(textBlocks)) + } + } + + @objc(analyzeScannedImages:resolve:reject:) + public func analyzeScannedImages( + _ options: NSDictionary, + 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 + } + + 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 ?? 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 + } + + let requestedConcurrency = opts["concurrency"] as? Int ?? 2 + 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( + includeBarcodes: wantsBarcodes, + includeText: wantsText, + includeTables: wantsTables, + includeRegions: wantsRegions, + includeStructuredData: wantsStructuredData, + allowedBarcodeFormats: allowedFormats + ) + + performModernAnalysis( + sources: sources, + options: modernOptions, + concurrency: concurrency + ) { analysis in + 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"] = barcodePayload + } + + 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) + 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 + } + + finalize(modernBarcodes) + } + 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 + self.log( + "analyzeScannedImages", + "legacy barcode stage finished count=\(extracted.count)" + ) + group.leave() + } + } + + if wantsTextPipeline { + textStage = .failed + group.enter() + performTextExtraction( + sources: sources, + ocrRotate180Fallback: ocrRotate180Fallback, + concurrency: concurrency + ) { extracted in + textBlocks = extracted + textStage = .success + self.log( + "analyzeScannedImages", + "legacy text stage finished count=\(extracted.count)" + ) + 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) + 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] { + 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, *) + 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 + ) { + 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()) + } + return + } + + let requestLimiter = DispatchSemaphore(value: concurrency) + let lock = NSLock() + let group = DispatchGroup() + var pages: [RecognizeDocumentsAnalyzer.PageAnalysis] = [] + + 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 { + 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) + 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 + ) + self.log( + "performModernAnalysis", + "merged barcodes=\(merged.barcodes.count) textBlocks=\(merged.textBlocks.count) tables=\(merged.tables.count) regions=\(merged.regions.count)" + ) + + completion(merged) + } + } + + private func performBarcodeExtraction( + sources: [IndexedImageSource], + allowedFormats: [String], + concurrency: Int, + completion: @escaping ([[String: Any]]) -> Void + ) { + log( + "performBarcodeExtraction", + "start sources=\(sources.count) concurrency=\(concurrency) allowedFormats=\(allowedFormats)" + ) + 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 = { + 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 { + 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 + } + + 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": source.sourceImageIndex + ] + } + + guard !mapped.isEmpty else { + self.log( + "performBarcodeExtraction", + "source[\(source.sourceImageIndex)] mapped empty after sanitization" + ) + return + } + + lock.lock() + extractedBarcodes.append(contentsOf: mapped) + lock.unlock() + } + analysisQueue.addOperation(operation) + } + + 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 + } + + completion(sorted) + self.log("performBarcodeExtraction", "completed total barcodes=\(sorted.count)") + } + } + + private func performTextExtraction( + sources: [IndexedImageSource], + ocrRotate180Fallback: Bool, + concurrency: Int, + completion: @escaping ([AnalysisTextBlock]) -> Void + ) { + log( + "performTextExtraction", + "start sources=\(sources.count) concurrency=\(concurrency) rotate180=\(ocrRotate180Fallback)" + ) + 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 { + self.log("performTextExtraction", "source[\(source.sourceImageIndex)] image load failed") + return + } + + let detected = TextExtractor.extractFromImage( + image, + sourceImageIndex: source.sourceImageIndex, + enableRotate180Fallback: ocrRotate180Fallback + ) + self.log( + "performTextExtraction", + "source[\(source.sourceImageIndex)] text blocks=\(detected.count)" + ) + + 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) + self.log("performTextExtraction", "completed total textBlocks=\(sorted.count)") + } + } + + 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 { + 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.keys.sorted().map { key in + [ + "key": key, + "value": structuredData.fields[key] ?? "" + ] + } + 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/package.json b/package.json index 882ef5e..ebabd26 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", @@ -10,14 +10,22 @@ "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": { + "plugins": [ + "./app.plugin.js" + ] + }, "files": [ "src", "lib", "android", "ios", "cpp", + "app.plugin.js", "*.podspec", "react-native.config.js", "!ios/build", diff --git a/src/NativeDocumentScanner.ts b/src/NativeDocumentScanner.ts index 2fedfe8..273c8bb 100644 --- a/src/NativeDocumentScanner.ts +++ b/src/NativeDocumentScanner.ts @@ -24,6 +24,82 @@ export interface ScanDocumentOptions { responseType?: ResponseType; } +/** + * Supported worker concurrency for image analysis. + */ +export type AnalysisConcurrency = 1 | 2; + +/** + * 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[]; + + /** + * Maximum native worker concurrency. The implementation clamps this value to 1..2. + * @default 2 + */ + concurrency?: AnalysisConcurrency; + + /** + * Android-only. Per-image barcode extraction timeout in milliseconds. + * Clamped natively to a safe range. + * @default 10000 + */ + barcodeTimeoutMs?: number; +} + +/** + * 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[]; +} + +/** + * 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; + + /** + * Android-only. Per-image OCR extraction timeout in milliseconds. + * Clamped natively to a safe range. + * @default 25000 + */ + textTimeoutMs?: number; +} + +/** + * 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. */ @@ -54,6 +130,185 @@ 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; +}; + +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; +}; + +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[]; +}; + +/** + * Extractor toggles for universal post-processing. + */ +export type AnalyzeExtractOptions = { + barcodes?: boolean; + text?: boolean; + tables?: boolean; + regions?: boolean; + structuredData?: boolean; +}; + +/** + * Options for universal post-processing across scanned images. + */ +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 + */ + 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; + /** + * Android-only. Per-image OCR extraction timeout in milliseconds for text-related stages. + */ + textTimeoutMs?: number; +} + +/** + * 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[]; + textBlocks?: TextBlock[]; + tables?: TableBlock[]; + regions?: Region[]; + structuredData?: StructuredData; +}; + type ScanDocumentSuccess = { status: ScanDocumentResponseStatus.Success; scannedImages: string[]; @@ -66,6 +321,20 @@ type ScanDocumentCancel = { 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. */ @@ -76,6 +345,33 @@ 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; + + /** + * 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/__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 606ebd4..f436ed6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,10 +1,91 @@ import DocumentScanner, { ResponseType, ScanDocumentResponseStatus, + type AnalysisResult, + type AnalyzeScannedImagesOptions, + type Barcode, + type ExtractBarcodesFromImagesOptions, + type ExtractTextFromImagesOptions, + type Region, + type StructuredData, + type StructuredEntity, + type TableBlock, + type TextBlock, + type ScanAndAnalyzeDocumentOptions, + type ScanAndAnalyzeDocumentResponse, type ScanDocumentOptions, type ScanDocumentResponse, } from './NativeDocumentScanner'; +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']; + barcodeTimeoutMs?: AnalyzeScannedImagesOptions['barcodeTimeoutMs']; + textTimeoutMs?: AnalyzeScannedImagesOptions['textTimeoutMs']; + 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 { + if (value === undefined) { + return undefined; + } + + 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 { + return { + barcodeFormats: options.barcodeFormats, + barcodeTimeoutMs: options.barcodeTimeoutMs, + concurrency: + options.concurrency ?? + clampAnalysisConcurrency(DEFAULT_ANALYSIS_CONCURRENCY), + }; +} + +/** + * 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, + textTimeoutMs: options.textTimeoutMs, + concurrency: + options.concurrency ?? + clampAnalysisConcurrency(DEFAULT_ANALYSIS_CONCURRENCY), + }; +} + export function scanDocument( options: ScanDocumentOptions = {} ): Promise { @@ -14,10 +95,267 @@ export function scanDocument( return DocumentScanner.scanDocument(options); } +/** + * Extracts barcodes from captured image sources without opening scanner UI. + */ +export function extractBarcodesFromImages( + images: string[], + options: ExtractBarcodesFromImagesOptions = {} +): Promise { + const normalizedOptions = normalizeBarcodeOptions(options); + + return DocumentScanner.extractBarcodesFromImages({ + images, + barcodeFormats: normalizedOptions.barcodeFormats, + barcodeTimeoutMs: normalizedOptions.barcodeTimeoutMs, + concurrency: normalizedOptions.concurrency, + }); +} + +/** + * 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, + textTimeoutMs: normalizedOptions.textTimeoutMs, + 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; + } + + const candidate = (error as { code?: unknown }).code; + 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, + barcodeTimeoutMs: options.barcodeTimeoutMs, + }); + 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, + textTimeoutMs: options.textTimeoutMs, + 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 +): Promise { + const extract = options.extract ?? {}; + const wantsBarcodes = !!extract.barcodes; + const wantsText = !!extract.text; + const wantsTables = !!extract.tables; + const wantsRegions = !!extract.regions; + const wantsStructuredData = !!extract.structuredData; + const wantsTextPipeline = + wantsText || wantsTables || wantsRegions || wantsStructuredData; + const ocrRotate180Fallback = options.ocrRotate180Fallback ?? true; + + if (!wantsBarcodes && !wantsTextPipeline) { + return { status: 'success' }; + } + + 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, + barcodeTimeoutMs: options.barcodeTimeoutMs, + textTimeoutMs: options.textTimeoutMs, + ocrRotate180Fallback, + }); + } catch (_error) { + // Fallback for stale native artifacts. + } + } + + const [barcodeStage, textStage] = await Promise.all([ + runBarcodeStage(images, options, wantsBarcodes), + runTextStage(images, options, ocrRotate180Fallback, wantsText), + ]); + + const semanticsStage: StageStatus = + wantsTables || wantsRegions || wantsStructuredData + ? 'not_enabled' + : 'skipped'; + + const status = mergeStageStatuses([ + barcodeStage.status, + textStage.status, + semanticsStage, + ]); + + 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 { + 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 { ScanDocumentOptions, ScanDocumentResponse }; +export type { + AnalysisResult, + AnalyzeScannedImagesOptions, + Barcode, + ExtractBarcodesFromImagesOptions, + ExtractTextFromImagesOptions, + Region, + StructuredData, + StructuredEntity, + TableBlock, + TextBlock, + ScanAndAnalyzeDocumentOptions, + ScanAndAnalyzeDocumentResponse, + ScanDocumentOptions, + ScanDocumentResponse, +}; export default { + analyzeScannedImages, + extractBarcodesFromImages, + extractTextFromImages, + scanAndAnalyzeDocument, scanDocument, }; 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 aacc1aa..106152e 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,20 +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/babel-preset": "npm:0.79.2" - "@react-native/metro-config": "npm:0.79.2" - "@react-native/typescript-config": "npm:0.79.2" + "@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" "@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 @@ -2894,6 +3061,16 @@ __metadata: languageName: node linkType: hard +"@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/d7c2336bced7736b74d593f188419fe9b1497175e3707b63bee672300d9a05765145badf5114f7e6ff901208497dafd2e9107fd612281643628a0bed549a6278 + 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" @@ -2901,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" @@ -2911,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" @@ -2966,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" @@ -2981,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" @@ -3002,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" @@ -3009,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" @@ -3028,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" @@ -3065,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" @@ -3072,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 @@ -3105,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 @@ -3129,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" @@ -10329,6 +10680,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" @@ -10339,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" @@ -10391,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"