react-native-rectangle-doc-scanner 4.15.0 → 4.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,8 @@ def safeExtGet(prop, fallback) {
17
17
  rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
18
18
  }
19
19
 
20
+ def hasVisionCamera = rootProject.findProject(':react-native-vision-camera') != null
21
+
20
22
  android {
21
23
  compileSdkVersion safeExtGet('compileSdkVersion', 33)
22
24
 
@@ -45,6 +47,9 @@ android {
45
47
  sourceSets {
46
48
  main {
47
49
  java.srcDirs += 'src/main/kotlin'
50
+ if (hasVisionCamera) {
51
+ java.srcDirs += 'src/visioncamera/kotlin'
52
+ }
48
53
  }
49
54
  }
50
55
  }
@@ -78,4 +83,8 @@ dependencies {
78
83
  // AndroidX
79
84
  implementation 'androidx.core:core-ktx:1.10.1'
80
85
  implementation 'androidx.appcompat:appcompat:1.6.1'
86
+
87
+ if (hasVisionCamera) {
88
+ implementation project(':react-native-vision-camera')
89
+ }
81
90
  }
@@ -510,6 +510,20 @@ class CameraController(
510
510
 
511
511
  private fun computeRotationDegrees(): Int {
512
512
  val displayRotation = displayRotationDegrees()
513
+ if (sensorOrientation == 0) {
514
+ return if (useFrontCamera) {
515
+ (360 - displayRotation) % 360
516
+ } else {
517
+ displayRotation
518
+ }
519
+ }
520
+ if (sensorOrientation == 180) {
521
+ return if (useFrontCamera) {
522
+ (180 + displayRotation) % 360
523
+ } else {
524
+ (180 - displayRotation + 360) % 360
525
+ }
526
+ }
513
527
  return if (useFrontCamera) {
514
528
  (sensorOrientation + displayRotation) % 360
515
529
  } else {
@@ -5,6 +5,7 @@ import android.util.Log
5
5
  import com.facebook.react.bridge.*
6
6
  import com.facebook.react.uimanager.UIManagerModule
7
7
  import kotlinx.coroutines.*
8
+ import org.opencv.core.Point
8
9
 
9
10
  class DocumentScannerModule(reactContext: ReactApplicationContext) :
10
11
  ReactContextBaseJavaModule(reactContext) {
@@ -114,8 +115,188 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) :
114
115
  }
115
116
  }
116
117
 
118
+ /**
119
+ * Process a captured image from JS (VisionCamera path).
120
+ * Options map keys:
121
+ * - imagePath: String (required)
122
+ * - rectangleCoordinates: Map (optional)
123
+ * - rectangleWidth: Int (optional)
124
+ * - rectangleHeight: Int (optional)
125
+ * - useBase64: Boolean (optional)
126
+ * - quality: Double (optional)
127
+ * - brightness: Double (optional)
128
+ * - contrast: Double (optional)
129
+ * - saturation: Double (optional)
130
+ * - saveInAppDocument: Boolean (optional)
131
+ */
132
+ @ReactMethod
133
+ fun processImage(options: ReadableMap, promise: Promise) {
134
+ scope.launch {
135
+ try {
136
+ val imagePath = options.getString("imagePath")
137
+ ?: throw IllegalArgumentException("imagePath is required")
138
+ val rectangleMap = if (options.hasKey("rectangleCoordinates")) {
139
+ options.getMap("rectangleCoordinates")
140
+ } else {
141
+ null
142
+ }
143
+ val rectangleWidth = if (options.hasKey("rectangleWidth")) {
144
+ options.getInt("rectangleWidth")
145
+ } else {
146
+ 0
147
+ }
148
+ val rectangleHeight = if (options.hasKey("rectangleHeight")) {
149
+ options.getInt("rectangleHeight")
150
+ } else {
151
+ 0
152
+ }
153
+ val useBase64 = options.hasKey("useBase64") && options.getBoolean("useBase64")
154
+ val quality = if (options.hasKey("quality")) {
155
+ options.getDouble("quality").toFloat()
156
+ } else {
157
+ 0.95f
158
+ }
159
+ val brightness = if (options.hasKey("brightness")) {
160
+ options.getDouble("brightness").toFloat()
161
+ } else {
162
+ 0f
163
+ }
164
+ val contrast = if (options.hasKey("contrast")) {
165
+ options.getDouble("contrast").toFloat()
166
+ } else {
167
+ 1f
168
+ }
169
+ val saturation = if (options.hasKey("saturation")) {
170
+ options.getDouble("saturation").toFloat()
171
+ } else {
172
+ 1f
173
+ }
174
+ val saveInAppDocument = options.hasKey("saveInAppDocument") && options.getBoolean("saveInAppDocument")
175
+
176
+ withContext(Dispatchers.IO) {
177
+ val bitmap = BitmapFactory.decodeFile(imagePath)
178
+ ?: throw IllegalStateException("decode_failed")
179
+ val rectangle = rectangleMap?.let { mapToRectangle(it) }
180
+ val scaledRectangle = if (rectangle != null && rectangleWidth > 0 && rectangleHeight > 0) {
181
+ scaleRectangleToBitmap(rectangle, rectangleWidth, rectangleHeight, bitmap.width, bitmap.height)
182
+ } else {
183
+ rectangle
184
+ }
185
+
186
+ val processed = ImageProcessor.processImage(
187
+ imagePath = imagePath,
188
+ rectangle = scaledRectangle,
189
+ brightness = brightness,
190
+ contrast = contrast,
191
+ saturation = saturation,
192
+ shouldCrop = scaledRectangle != null
193
+ )
194
+
195
+ val outputDir = if (saveInAppDocument) {
196
+ reactApplicationContext.filesDir
197
+ } else {
198
+ reactApplicationContext.cacheDir
199
+ }
200
+
201
+ val timestamp = System.currentTimeMillis()
202
+
203
+ val result = Arguments.createMap()
204
+ if (useBase64) {
205
+ val croppedBase64 = ImageProcessor.bitmapToBase64(processed.croppedImage, quality)
206
+ val initialBase64 = ImageProcessor.bitmapToBase64(processed.initialImage, quality)
207
+ result.putString("croppedImage", croppedBase64)
208
+ result.putString("initialImage", initialBase64)
209
+ } else {
210
+ val croppedPath = ImageProcessor.saveBitmapToFile(
211
+ processed.croppedImage,
212
+ outputDir,
213
+ "cropped_img_$timestamp.jpeg",
214
+ quality
215
+ )
216
+ val initialPath = ImageProcessor.saveBitmapToFile(
217
+ processed.initialImage,
218
+ outputDir,
219
+ "initial_img_$timestamp.jpeg",
220
+ quality
221
+ )
222
+ result.putString("croppedImage", croppedPath)
223
+ result.putString("initialImage", initialPath)
224
+ }
225
+
226
+ result.putMap("rectangleCoordinates", scaledRectangle?.let { rectangleToWritableMap(it) })
227
+ result.putInt("width", processed.croppedImage.width)
228
+ result.putInt("height", processed.croppedImage.height)
229
+
230
+ withContext(Dispatchers.Main) {
231
+ promise.resolve(result)
232
+ }
233
+ }
234
+ } catch (e: Exception) {
235
+ Log.e(TAG, "Failed to process image", e)
236
+ promise.reject("PROCESSING_FAILED", "Failed to process image: ${e.message}", e)
237
+ }
238
+ }
239
+ }
240
+
117
241
  override fun onCatalystInstanceDestroy() {
118
242
  super.onCatalystInstanceDestroy()
119
243
  scope.cancel()
120
244
  }
245
+
246
+ private fun mapToRectangle(map: ReadableMap): Rectangle? {
247
+ fun toPoint(pointMap: ReadableMap?): Point? {
248
+ if (pointMap == null) return null
249
+ if (!pointMap.hasKey("x") || !pointMap.hasKey("y")) return null
250
+ return Point(pointMap.getDouble("x"), pointMap.getDouble("y"))
251
+ }
252
+
253
+ val topLeft = toPoint(map.getMap("topLeft"))
254
+ val topRight = toPoint(map.getMap("topRight"))
255
+ val bottomLeft = toPoint(map.getMap("bottomLeft"))
256
+ val bottomRight = toPoint(map.getMap("bottomRight"))
257
+
258
+ if (topLeft == null || topRight == null || bottomLeft == null || bottomRight == null) {
259
+ return null
260
+ }
261
+
262
+ return Rectangle(topLeft, topRight, bottomLeft, bottomRight)
263
+ }
264
+
265
+ private fun rectangleToWritableMap(rectangle: Rectangle): WritableMap {
266
+ val map = Arguments.createMap()
267
+ fun putPoint(key: String, point: Point) {
268
+ val pointMap = Arguments.createMap()
269
+ pointMap.putDouble("x", point.x)
270
+ pointMap.putDouble("y", point.y)
271
+ map.putMap(key, pointMap)
272
+ }
273
+ putPoint("topLeft", rectangle.topLeft)
274
+ putPoint("topRight", rectangle.topRight)
275
+ putPoint("bottomLeft", rectangle.bottomLeft)
276
+ putPoint("bottomRight", rectangle.bottomRight)
277
+ return map
278
+ }
279
+
280
+ private fun scaleRectangleToBitmap(
281
+ rectangle: Rectangle,
282
+ srcWidth: Int,
283
+ srcHeight: Int,
284
+ dstWidth: Int,
285
+ dstHeight: Int
286
+ ): Rectangle {
287
+ if (srcWidth == 0 || srcHeight == 0) return rectangle
288
+ val scaleX = dstWidth.toDouble() / srcWidth.toDouble()
289
+ val scaleY = dstHeight.toDouble() / srcHeight.toDouble()
290
+
291
+ fun mapPoint(point: Point): Point {
292
+ return Point(point.x * scaleX, point.y * scaleY)
293
+ }
294
+
295
+ return Rectangle(
296
+ mapPoint(rectangle.topLeft),
297
+ mapPoint(rectangle.topRight),
298
+ mapPoint(rectangle.bottomLeft),
299
+ mapPoint(rectangle.bottomRight)
300
+ )
301
+ }
121
302
  }
@@ -1,12 +1,20 @@
1
1
  package com.reactnativerectangledocscanner
2
2
 
3
+ import android.util.Log
3
4
  import com.facebook.react.ReactPackage
4
5
  import com.facebook.react.bridge.NativeModule
5
6
  import com.facebook.react.bridge.ReactApplicationContext
6
7
  import com.facebook.react.uimanager.ViewManager
7
8
 
8
9
  class DocumentScannerPackage : ReactPackage {
10
+ companion object {
11
+ private const val TAG = "DocumentScannerPackage"
12
+ private const val VISION_CAMERA_REGISTRY =
13
+ "com.reactnativerectangledocscanner.VisionCameraFrameProcessorRegistry"
14
+ }
15
+
9
16
  override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
17
+ registerVisionCameraPlugin()
10
18
  return listOf(DocumentScannerModule(reactContext))
11
19
  }
12
20
 
@@ -16,4 +24,16 @@ class DocumentScannerPackage : ReactPackage {
16
24
  CameraViewManager()
17
25
  )
18
26
  }
27
+
28
+ private fun registerVisionCameraPlugin() {
29
+ try {
30
+ val registryClass = Class.forName(VISION_CAMERA_REGISTRY)
31
+ val registerMethod = registryClass.getMethod("register")
32
+ registerMethod.invoke(null)
33
+ } catch (e: ClassNotFoundException) {
34
+ // VisionCamera not installed in the host app; skip registration.
35
+ } catch (e: Exception) {
36
+ Log.w(TAG, "Failed to register VisionCamera frame processor", e)
37
+ }
38
+ }
19
39
  }
@@ -0,0 +1,44 @@
1
+ package com.reactnativerectangledocscanner
2
+
3
+ import android.util.Log
4
+ import com.mrousavy.camera.frameprocessors.Frame
5
+ import com.mrousavy.camera.frameprocessors.FrameProcessorPlugin
6
+
7
+ class DocumentScannerFrameProcessorPlugin : FrameProcessorPlugin() {
8
+ override fun callback(frame: Frame, params: Map<String, Any>?): Any? {
9
+ return try {
10
+ val imageProxy = frame.imageProxy
11
+ val rotationDegrees = imageProxy.imageInfo.rotationDegrees
12
+ val frameWidth = if (rotationDegrees == 90 || rotationDegrees == 270) {
13
+ imageProxy.height
14
+ } else {
15
+ imageProxy.width
16
+ }
17
+ val frameHeight = if (rotationDegrees == 90 || rotationDegrees == 270) {
18
+ imageProxy.width
19
+ } else {
20
+ imageProxy.height
21
+ }
22
+
23
+ val nv21 = imageProxy.toNv21()
24
+ val rectangle = DocumentDetector.detectRectangleInYUV(
25
+ nv21,
26
+ imageProxy.width,
27
+ imageProxy.height,
28
+ rotationDegrees
29
+ )
30
+
31
+ val result = HashMap<String, Any?>()
32
+ result["rectangle"] = rectangle?.toMap()
33
+ result["imageWidth"] = frameWidth
34
+ result["imageHeight"] = frameHeight
35
+ result["rotation"] = rotationDegrees
36
+ result["isMirrored"] = runCatching { frame.isMirrored }.getOrDefault(false)
37
+
38
+ result
39
+ } catch (e: Throwable) {
40
+ Log.e("DocScannerVC", "Frame processor failed", e)
41
+ null
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,18 @@
1
+ package com.reactnativerectangledocscanner
2
+
3
+ import com.mrousavy.camera.frameprocessors.FrameProcessorPluginRegistry
4
+ import java.util.concurrent.atomic.AtomicBoolean
5
+
6
+ object VisionCameraFrameProcessorRegistry {
7
+ private val registered = AtomicBoolean(false)
8
+
9
+ @JvmStatic
10
+ fun register() {
11
+ if (!registered.compareAndSet(false, true)) {
12
+ return
13
+ }
14
+ FrameProcessorPluginRegistry.addFrameProcessorPlugin("DocumentScanner") { _, _ ->
15
+ DocumentScannerFrameProcessorPlugin()
16
+ }
17
+ }
18
+ }
@@ -44,6 +44,19 @@ const coordinate_1 = require("./utils/coordinate");
44
44
  const overlay_1 = require("./utils/overlay");
45
45
  const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
46
46
  const { RNPdfScannerManager } = react_native_1.NativeModules;
47
+ const safeRequire = (moduleName) => {
48
+ try {
49
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
50
+ return require(moduleName);
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ };
56
+ const visionCameraModule = react_native_1.Platform.OS === 'android' ? safeRequire('react-native-vision-camera') : null;
57
+ const reanimatedModule = react_native_1.Platform.OS === 'android' ? safeRequire('react-native-reanimated') : null;
58
+ const hasVisionCamera = react_native_1.Platform.OS === 'android' &&
59
+ Boolean(visionCameraModule?.Camera && visionCameraModule?.useCameraDevice && visionCameraModule?.useFrameProcessor);
47
60
  const normalizePoint = (point) => {
48
61
  if (!point || !isFiniteNumber(point.x) || !isFiniteNumber(point.y)) {
49
62
  return null;
@@ -69,7 +82,350 @@ const normalizeRectangle = (rectangle) => {
69
82
  };
70
83
  };
71
84
  const DEFAULT_OVERLAY_COLOR = '#0b7ef4';
85
+ const RectangleQuality = {
86
+ GOOD: 0,
87
+ BAD_ANGLE: 1,
88
+ TOO_FAR: 2,
89
+ };
90
+ const evaluateRectangleQualityInView = (rectangle, viewWidth, viewHeight) => {
91
+ if (!viewWidth || !viewHeight) {
92
+ return RectangleQuality.TOO_FAR;
93
+ }
94
+ const minDim = Math.min(viewWidth, viewHeight);
95
+ const angleThreshold = Math.max(60, minDim * 0.08);
96
+ const topYDiff = Math.abs(rectangle.topRight.y - rectangle.topLeft.y);
97
+ const bottomYDiff = Math.abs(rectangle.bottomLeft.y - rectangle.bottomRight.y);
98
+ const leftXDiff = Math.abs(rectangle.topLeft.x - rectangle.bottomLeft.x);
99
+ const rightXDiff = Math.abs(rectangle.topRight.x - rectangle.bottomRight.x);
100
+ if (topYDiff > angleThreshold ||
101
+ bottomYDiff > angleThreshold ||
102
+ leftXDiff > angleThreshold ||
103
+ rightXDiff > angleThreshold) {
104
+ return RectangleQuality.BAD_ANGLE;
105
+ }
106
+ const margin = Math.max(120, minDim * 0.12);
107
+ if (rectangle.topLeft.y > margin ||
108
+ rectangle.topRight.y > margin ||
109
+ rectangle.bottomLeft.y < viewHeight - margin ||
110
+ rectangle.bottomRight.y < viewHeight - margin) {
111
+ return RectangleQuality.TOO_FAR;
112
+ }
113
+ return RectangleQuality.GOOD;
114
+ };
115
+ const mirrorRectangleHorizontally = (rectangle, imageWidth) => ({
116
+ topLeft: { x: imageWidth - rectangle.topRight.x, y: rectangle.topRight.y },
117
+ topRight: { x: imageWidth - rectangle.topLeft.x, y: rectangle.topLeft.y },
118
+ bottomLeft: { x: imageWidth - rectangle.bottomRight.x, y: rectangle.bottomRight.y },
119
+ bottomRight: { x: imageWidth - rectangle.bottomLeft.x, y: rectangle.bottomLeft.y },
120
+ });
121
+ const mapRectangleToView = (rectangle, imageWidth, imageHeight, viewWidth, viewHeight, density) => {
122
+ const viewWidthPx = viewWidth * density;
123
+ const viewHeightPx = viewHeight * density;
124
+ const scale = Math.max(viewWidthPx / imageWidth, viewHeightPx / imageHeight);
125
+ const scaledImageWidth = imageWidth * scale;
126
+ const scaledImageHeight = imageHeight * scale;
127
+ const offsetX = (scaledImageWidth - viewWidthPx) / 2;
128
+ const offsetY = (scaledImageHeight - viewHeightPx) / 2;
129
+ const mapPoint = (point) => ({
130
+ x: (point.x * scale - offsetX) / density,
131
+ y: (point.y * scale - offsetY) / density,
132
+ });
133
+ return {
134
+ topLeft: mapPoint(rectangle.topLeft),
135
+ topRight: mapPoint(rectangle.topRight),
136
+ bottomRight: mapPoint(rectangle.bottomRight),
137
+ bottomLeft: mapPoint(rectangle.bottomLeft),
138
+ };
139
+ };
140
+ const VisionCameraScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAULT_OVERLAY_COLOR, autoCapture = true, minStableFrames = 8, enableTorch = false, quality = 90, useBase64 = false, children, showGrid = true, gridColor, gridLineWidth, detectionConfig, onRectangleDetect, showManualCaptureButton = false, }, ref) => {
141
+ const cameraRef = (0, react_1.useRef)(null);
142
+ const captureResolvers = (0, react_1.useRef)(null);
143
+ const captureOriginRef = (0, react_1.useRef)('auto');
144
+ const captureInProgressRef = (0, react_1.useRef)(false);
145
+ const stableCounterRef = (0, react_1.useRef)(0);
146
+ const lastDetectionTimestampRef = (0, react_1.useRef)(0);
147
+ const lastRectangleRef = (0, react_1.useRef)(null);
148
+ const lastImageSizeRef = (0, react_1.useRef)(null);
149
+ const rectangleClearTimeoutRef = (0, react_1.useRef)(null);
150
+ const [isAutoCapturing, setIsAutoCapturing] = (0, react_1.useState)(false);
151
+ const [detectedRectangle, setDetectedRectangle] = (0, react_1.useState)(null);
152
+ const [viewSize, setViewSize] = (0, react_1.useState)({ width: 0, height: 0 });
153
+ const [hasPermission, setHasPermission] = (0, react_1.useState)(false);
154
+ const normalizedQuality = (0, react_1.useMemo)(() => Math.min(100, Math.max(0, quality)), [quality]);
155
+ const density = react_native_1.PixelRatio.get() || 1;
156
+ const CameraComponent = visionCameraModule?.Camera;
157
+ const runOnJS = reanimatedModule?.runOnJS;
158
+ const device = visionCameraModule.useCameraDevice('back');
159
+ (0, react_1.useEffect)(() => {
160
+ let mounted = true;
161
+ const requestPermission = async () => {
162
+ try {
163
+ if (!CameraComponent?.requestCameraPermission) {
164
+ if (mounted) {
165
+ setHasPermission(true);
166
+ }
167
+ return;
168
+ }
169
+ const status = await CameraComponent.requestCameraPermission();
170
+ if (mounted) {
171
+ setHasPermission(status === 'authorized');
172
+ }
173
+ }
174
+ catch (error) {
175
+ if (mounted) {
176
+ setHasPermission(false);
177
+ }
178
+ }
179
+ };
180
+ requestPermission();
181
+ return () => {
182
+ mounted = false;
183
+ };
184
+ }, [CameraComponent]);
185
+ (0, react_1.useEffect)(() => {
186
+ if (!autoCapture) {
187
+ setIsAutoCapturing(false);
188
+ }
189
+ }, [autoCapture]);
190
+ (0, react_1.useEffect)(() => {
191
+ return () => {
192
+ if (rectangleClearTimeoutRef.current) {
193
+ clearTimeout(rectangleClearTimeoutRef.current);
194
+ }
195
+ };
196
+ }, []);
197
+ const handlePictureTaken = (0, react_1.useCallback)((event) => {
198
+ const captureError = event?.error;
199
+ if (captureError) {
200
+ console.error('[DocScanner] Native capture error received:', captureError);
201
+ captureOriginRef.current = 'auto';
202
+ setIsAutoCapturing(false);
203
+ setDetectedRectangle(null);
204
+ if (captureResolvers.current) {
205
+ captureResolvers.current.reject(new Error(String(captureError)));
206
+ captureResolvers.current = null;
207
+ }
208
+ return;
209
+ }
210
+ setIsAutoCapturing(false);
211
+ const normalizedRectangle = normalizeRectangle(event.rectangleCoordinates ?? null) ??
212
+ normalizeRectangle(event.rectangleOnScreen ?? null) ??
213
+ lastRectangleRef.current;
214
+ const quad = normalizedRectangle ? (0, coordinate_1.rectangleToQuad)(normalizedRectangle) : null;
215
+ const origin = captureOriginRef.current;
216
+ captureOriginRef.current = 'auto';
217
+ const initialPath = event.initialImage ?? null;
218
+ const croppedPath = event.croppedImage ?? null;
219
+ const editablePath = initialPath ?? croppedPath;
220
+ if (editablePath) {
221
+ onCapture?.({
222
+ path: editablePath,
223
+ initialPath,
224
+ croppedPath,
225
+ quad,
226
+ rectangle: normalizedRectangle,
227
+ width: event.width ?? 0,
228
+ height: event.height ?? 0,
229
+ origin,
230
+ });
231
+ }
232
+ setDetectedRectangle(null);
233
+ if (captureResolvers.current) {
234
+ captureResolvers.current.resolve(event);
235
+ captureResolvers.current = null;
236
+ }
237
+ }, [onCapture]);
238
+ const handleError = (0, react_1.useCallback)((error) => {
239
+ if (captureResolvers.current) {
240
+ captureResolvers.current.reject(error);
241
+ captureResolvers.current = null;
242
+ }
243
+ }, []);
244
+ const applyRectangleEvent = (0, react_1.useCallback)((payload) => {
245
+ if (autoCapture) {
246
+ if (payload.stableCounter >= Math.max(minStableFrames - 1, 0)) {
247
+ setIsAutoCapturing(true);
248
+ }
249
+ else if (payload.stableCounter === 0) {
250
+ setIsAutoCapturing(false);
251
+ }
252
+ }
253
+ const hasAnyRectangle = Boolean(payload.rectangleOnScreen || payload.rectangleCoordinates);
254
+ if (hasAnyRectangle) {
255
+ if (rectangleClearTimeoutRef.current) {
256
+ clearTimeout(rectangleClearTimeoutRef.current);
257
+ }
258
+ setDetectedRectangle(payload);
259
+ rectangleClearTimeoutRef.current = setTimeout(() => {
260
+ setDetectedRectangle(null);
261
+ rectangleClearTimeoutRef.current = null;
262
+ }, 500);
263
+ }
264
+ else {
265
+ if (rectangleClearTimeoutRef.current) {
266
+ clearTimeout(rectangleClearTimeoutRef.current);
267
+ rectangleClearTimeoutRef.current = null;
268
+ }
269
+ setDetectedRectangle(null);
270
+ }
271
+ onRectangleDetect?.(payload);
272
+ }, [autoCapture, minStableFrames, onRectangleDetect]);
273
+ const captureVision = (0, react_1.useCallback)(async (origin) => {
274
+ if (captureInProgressRef.current) {
275
+ throw new Error('capture_in_progress');
276
+ }
277
+ if (!cameraRef.current?.takePhoto) {
278
+ throw new Error('capture_not_supported');
279
+ }
280
+ if (!RNPdfScannerManager?.processImage) {
281
+ throw new Error('process_image_not_supported');
282
+ }
283
+ captureInProgressRef.current = true;
284
+ captureOriginRef.current = origin;
285
+ try {
286
+ const photo = await cameraRef.current.takePhoto({ flash: 'off' });
287
+ const imageSize = lastImageSizeRef.current;
288
+ const payload = await RNPdfScannerManager.processImage({
289
+ imagePath: photo.path,
290
+ rectangleCoordinates: lastRectangleRef.current,
291
+ rectangleWidth: imageSize?.width ?? 0,
292
+ rectangleHeight: imageSize?.height ?? 0,
293
+ useBase64,
294
+ quality: normalizedQuality,
295
+ brightness: 0,
296
+ contrast: 1,
297
+ saturation: 1,
298
+ saveInAppDocument: false,
299
+ });
300
+ handlePictureTaken(payload);
301
+ return payload;
302
+ }
303
+ catch (error) {
304
+ handleError(error);
305
+ throw error;
306
+ }
307
+ finally {
308
+ captureInProgressRef.current = false;
309
+ }
310
+ }, [handleError, handlePictureTaken, normalizedQuality, useBase64]);
311
+ const capture = (0, react_1.useCallback)(() => {
312
+ captureOriginRef.current = 'manual';
313
+ if (captureResolvers.current) {
314
+ captureOriginRef.current = 'auto';
315
+ return Promise.reject(new Error('capture_in_progress'));
316
+ }
317
+ return new Promise((resolve, reject) => {
318
+ captureResolvers.current = { resolve, reject };
319
+ captureVision('manual').catch((error) => {
320
+ captureResolvers.current = null;
321
+ captureOriginRef.current = 'auto';
322
+ reject(error);
323
+ });
324
+ });
325
+ }, [captureVision]);
326
+ (0, react_1.useImperativeHandle)(ref, () => ({
327
+ capture,
328
+ reset: () => {
329
+ if (captureResolvers.current) {
330
+ captureResolvers.current.reject(new Error('reset'));
331
+ captureResolvers.current = null;
332
+ }
333
+ if (rectangleClearTimeoutRef.current) {
334
+ clearTimeout(rectangleClearTimeoutRef.current);
335
+ rectangleClearTimeoutRef.current = null;
336
+ }
337
+ lastRectangleRef.current = null;
338
+ lastImageSizeRef.current = null;
339
+ stableCounterRef.current = 0;
340
+ setDetectedRectangle(null);
341
+ setIsAutoCapturing(false);
342
+ captureOriginRef.current = 'auto';
343
+ },
344
+ }), [capture]);
345
+ const handleVisionResult = (0, react_1.useCallback)((result) => {
346
+ const now = Date.now();
347
+ if (now - lastDetectionTimestampRef.current < 100) {
348
+ return;
349
+ }
350
+ lastDetectionTimestampRef.current = now;
351
+ const imageWidth = Number(result?.imageWidth) || 0;
352
+ const imageHeight = Number(result?.imageHeight) || 0;
353
+ const isMirrored = Boolean(result?.isMirrored);
354
+ let rectangleCoordinates = normalizeRectangle(result?.rectangle ?? null);
355
+ if (rectangleCoordinates && isMirrored && imageWidth) {
356
+ rectangleCoordinates = mirrorRectangleHorizontally(rectangleCoordinates, imageWidth);
357
+ }
358
+ const rectangleOnScreen = rectangleCoordinates && viewSize.width && viewSize.height && imageWidth && imageHeight
359
+ ? mapRectangleToView(rectangleCoordinates, imageWidth, imageHeight, viewSize.width, viewSize.height, density)
360
+ : null;
361
+ const quality = rectangleOnScreen
362
+ ? evaluateRectangleQualityInView(rectangleOnScreen, viewSize.width, viewSize.height)
363
+ : RectangleQuality.TOO_FAR;
364
+ if (!rectangleCoordinates) {
365
+ stableCounterRef.current = 0;
366
+ }
367
+ else if (quality === RectangleQuality.GOOD) {
368
+ const cap = autoCapture ? minStableFrames : 99999;
369
+ stableCounterRef.current = Math.min(stableCounterRef.current + 1, cap);
370
+ }
371
+ else if (stableCounterRef.current > 0) {
372
+ stableCounterRef.current -= 1;
373
+ }
374
+ if (rectangleCoordinates) {
375
+ lastRectangleRef.current = rectangleCoordinates;
376
+ if (imageWidth && imageHeight) {
377
+ lastImageSizeRef.current = { width: imageWidth, height: imageHeight };
378
+ }
379
+ }
380
+ const payload = {
381
+ stableCounter: stableCounterRef.current,
382
+ lastDetectionType: quality,
383
+ rectangleCoordinates,
384
+ rectangleOnScreen,
385
+ previewSize: viewSize.width && viewSize.height ? { width: viewSize.width, height: viewSize.height } : undefined,
386
+ imageSize: imageWidth && imageHeight ? { width: imageWidth, height: imageHeight } : undefined,
387
+ };
388
+ applyRectangleEvent(payload);
389
+ if (autoCapture &&
390
+ rectangleCoordinates &&
391
+ stableCounterRef.current >= minStableFrames &&
392
+ !captureInProgressRef.current) {
393
+ stableCounterRef.current = 0;
394
+ captureVision('auto').catch(() => { });
395
+ }
396
+ }, [applyRectangleEvent, autoCapture, captureVision, density, minStableFrames, viewSize]);
397
+ const plugin = (0, react_1.useMemo)(() => {
398
+ if (!visionCameraModule?.VisionCameraProxy?.initFrameProcessorPlugin) {
399
+ return null;
400
+ }
401
+ return visionCameraModule.VisionCameraProxy.initFrameProcessorPlugin('DocumentScanner', detectionConfig ?? {});
402
+ }, [detectionConfig]);
403
+ const frameProcessor = visionCameraModule.useFrameProcessor((frame) => {
404
+ 'worklet';
405
+ if (!plugin || !runOnJS) {
406
+ return;
407
+ }
408
+ const output = plugin.call(frame, null);
409
+ runOnJS(handleVisionResult)(output ?? null);
410
+ }, [plugin, runOnJS, handleVisionResult]);
411
+ const handleLayout = (0, react_1.useCallback)((event) => {
412
+ const { width, height } = event.nativeEvent.layout;
413
+ if (width && height) {
414
+ setViewSize({ width, height });
415
+ }
416
+ }, []);
417
+ const overlayPolygon = detectedRectangle?.rectangleOnScreen ?? null;
418
+ const overlayIsActive = autoCapture ? isAutoCapturing : (detectedRectangle?.stableCounter ?? 0) > 0;
419
+ return (react_1.default.createElement(react_native_1.View, { style: styles.container, onLayout: handleLayout },
420
+ CameraComponent && device && hasPermission ? (react_1.default.createElement(CameraComponent, { ref: cameraRef, style: styles.scanner, device: device, isActive: true, photo: true, torch: enableTorch ? 'on' : 'off', frameProcessor: frameProcessor, frameProcessorFps: 10 })) : (react_1.default.createElement(react_native_1.View, { style: styles.scanner })),
421
+ showGrid && overlayPolygon && (react_1.default.createElement(overlay_1.ScannerOverlay, { active: overlayIsActive, color: gridColor ?? overlayColor, lineWidth: gridLineWidth, polygon: overlayPolygon })),
422
+ showManualCaptureButton && (react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.button, onPress: () => captureVision('manual') })),
423
+ children));
424
+ });
72
425
  exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAULT_OVERLAY_COLOR, autoCapture = true, minStableFrames = 8, enableTorch = false, quality = 90, useBase64 = false, children, showGrid = true, gridColor, gridLineWidth, detectionConfig, onRectangleDetect, showManualCaptureButton = false, }, ref) => {
426
+ if (hasVisionCamera) {
427
+ return (react_1.default.createElement(VisionCameraScanner, { ref: ref, onCapture: onCapture, overlayColor: overlayColor, autoCapture: autoCapture, minStableFrames: minStableFrames, enableTorch: enableTorch, quality: quality, useBase64: useBase64, showGrid: showGrid, gridColor: gridColor, gridLineWidth: gridLineWidth, detectionConfig: detectionConfig, onRectangleDetect: onRectangleDetect, showManualCaptureButton: showManualCaptureButton }, children));
428
+ }
73
429
  const scannerRef = (0, react_1.useRef)(null);
74
430
  const captureResolvers = (0, react_1.useRef)(null);
75
431
  const [isAutoCapturing, setIsAutoCapturing] = (0, react_1.useState)(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "4.15.0",
3
+ "version": "4.17.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -10,6 +10,7 @@ import React, {
10
10
  } from 'react';
11
11
  import {
12
12
  Platform,
13
+ PixelRatio,
13
14
  StyleSheet,
14
15
  TouchableOpacity,
15
16
  View,
@@ -52,6 +53,21 @@ const isFiniteNumber = (value: unknown): value is number =>
52
53
 
53
54
  const { RNPdfScannerManager } = NativeModules;
54
55
 
56
+ const safeRequire = (moduleName: string) => {
57
+ try {
58
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
59
+ return require(moduleName);
60
+ } catch {
61
+ return null;
62
+ }
63
+ };
64
+
65
+ const visionCameraModule = Platform.OS === 'android' ? safeRequire('react-native-vision-camera') : null;
66
+ const reanimatedModule = Platform.OS === 'android' ? safeRequire('react-native-reanimated') : null;
67
+ const hasVisionCamera =
68
+ Platform.OS === 'android' &&
69
+ Boolean(visionCameraModule?.Camera && visionCameraModule?.useCameraDevice && visionCameraModule?.useFrameProcessor);
70
+
55
71
  const normalizePoint = (point?: { x?: number; y?: number } | null): Point | null => {
56
72
  if (!point || !isFiniteNumber(point.x) || !isFiniteNumber(point.y)) {
57
73
  return null;
@@ -114,6 +130,487 @@ export type DocScannerHandle = {
114
130
 
115
131
  const DEFAULT_OVERLAY_COLOR = '#0b7ef4';
116
132
 
133
+ const RectangleQuality = {
134
+ GOOD: 0,
135
+ BAD_ANGLE: 1,
136
+ TOO_FAR: 2,
137
+ } as const;
138
+
139
+ const evaluateRectangleQualityInView = (rectangle: Rectangle, viewWidth: number, viewHeight: number) => {
140
+ if (!viewWidth || !viewHeight) {
141
+ return RectangleQuality.TOO_FAR;
142
+ }
143
+
144
+ const minDim = Math.min(viewWidth, viewHeight);
145
+ const angleThreshold = Math.max(60, minDim * 0.08);
146
+ const topYDiff = Math.abs(rectangle.topRight.y - rectangle.topLeft.y);
147
+ const bottomYDiff = Math.abs(rectangle.bottomLeft.y - rectangle.bottomRight.y);
148
+ const leftXDiff = Math.abs(rectangle.topLeft.x - rectangle.bottomLeft.x);
149
+ const rightXDiff = Math.abs(rectangle.topRight.x - rectangle.bottomRight.x);
150
+
151
+ if (
152
+ topYDiff > angleThreshold ||
153
+ bottomYDiff > angleThreshold ||
154
+ leftXDiff > angleThreshold ||
155
+ rightXDiff > angleThreshold
156
+ ) {
157
+ return RectangleQuality.BAD_ANGLE;
158
+ }
159
+
160
+ const margin = Math.max(120, minDim * 0.12);
161
+ if (
162
+ rectangle.topLeft.y > margin ||
163
+ rectangle.topRight.y > margin ||
164
+ rectangle.bottomLeft.y < viewHeight - margin ||
165
+ rectangle.bottomRight.y < viewHeight - margin
166
+ ) {
167
+ return RectangleQuality.TOO_FAR;
168
+ }
169
+
170
+ return RectangleQuality.GOOD;
171
+ };
172
+
173
+ const mirrorRectangleHorizontally = (rectangle: Rectangle, imageWidth: number): Rectangle => ({
174
+ topLeft: { x: imageWidth - rectangle.topRight.x, y: rectangle.topRight.y },
175
+ topRight: { x: imageWidth - rectangle.topLeft.x, y: rectangle.topLeft.y },
176
+ bottomLeft: { x: imageWidth - rectangle.bottomRight.x, y: rectangle.bottomRight.y },
177
+ bottomRight: { x: imageWidth - rectangle.bottomLeft.x, y: rectangle.bottomLeft.y },
178
+ });
179
+
180
+ const mapRectangleToView = (
181
+ rectangle: Rectangle,
182
+ imageWidth: number,
183
+ imageHeight: number,
184
+ viewWidth: number,
185
+ viewHeight: number,
186
+ density: number,
187
+ ): Rectangle => {
188
+ const viewWidthPx = viewWidth * density;
189
+ const viewHeightPx = viewHeight * density;
190
+ const scale = Math.max(viewWidthPx / imageWidth, viewHeightPx / imageHeight);
191
+ const scaledImageWidth = imageWidth * scale;
192
+ const scaledImageHeight = imageHeight * scale;
193
+ const offsetX = (scaledImageWidth - viewWidthPx) / 2;
194
+ const offsetY = (scaledImageHeight - viewHeightPx) / 2;
195
+
196
+ const mapPoint = (point: Point): Point => ({
197
+ x: (point.x * scale - offsetX) / density,
198
+ y: (point.y * scale - offsetY) / density,
199
+ });
200
+
201
+ return {
202
+ topLeft: mapPoint(rectangle.topLeft),
203
+ topRight: mapPoint(rectangle.topRight),
204
+ bottomRight: mapPoint(rectangle.bottomRight),
205
+ bottomLeft: mapPoint(rectangle.bottomLeft),
206
+ };
207
+ };
208
+
209
+ const VisionCameraScanner = forwardRef<DocScannerHandle, Props>(
210
+ (
211
+ {
212
+ onCapture,
213
+ overlayColor = DEFAULT_OVERLAY_COLOR,
214
+ autoCapture = true,
215
+ minStableFrames = 8,
216
+ enableTorch = false,
217
+ quality = 90,
218
+ useBase64 = false,
219
+ children,
220
+ showGrid = true,
221
+ gridColor,
222
+ gridLineWidth,
223
+ detectionConfig,
224
+ onRectangleDetect,
225
+ showManualCaptureButton = false,
226
+ },
227
+ ref,
228
+ ) => {
229
+ const cameraRef = useRef<any>(null);
230
+ const captureResolvers = useRef<{
231
+ resolve: (value: PictureEvent) => void;
232
+ reject: (reason?: unknown) => void;
233
+ } | null>(null);
234
+ const captureOriginRef = useRef<'auto' | 'manual'>('auto');
235
+ const captureInProgressRef = useRef(false);
236
+ const stableCounterRef = useRef(0);
237
+ const lastDetectionTimestampRef = useRef(0);
238
+ const lastRectangleRef = useRef<Rectangle | null>(null);
239
+ const lastImageSizeRef = useRef<{ width: number; height: number } | null>(null);
240
+ const rectangleClearTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
241
+
242
+ const [isAutoCapturing, setIsAutoCapturing] = useState(false);
243
+ const [detectedRectangle, setDetectedRectangle] = useState<RectangleDetectEvent | null>(null);
244
+ const [viewSize, setViewSize] = useState({ width: 0, height: 0 });
245
+ const [hasPermission, setHasPermission] = useState(false);
246
+
247
+ const normalizedQuality = useMemo(() => Math.min(100, Math.max(0, quality)), [quality]);
248
+ const density = PixelRatio.get() || 1;
249
+
250
+ const CameraComponent = visionCameraModule?.Camera;
251
+ const runOnJS = reanimatedModule?.runOnJS;
252
+
253
+ const device = visionCameraModule.useCameraDevice('back');
254
+
255
+ useEffect(() => {
256
+ let mounted = true;
257
+
258
+ const requestPermission = async () => {
259
+ try {
260
+ if (!CameraComponent?.requestCameraPermission) {
261
+ if (mounted) {
262
+ setHasPermission(true);
263
+ }
264
+ return;
265
+ }
266
+
267
+ const status = await CameraComponent.requestCameraPermission();
268
+ if (mounted) {
269
+ setHasPermission(status === 'authorized');
270
+ }
271
+ } catch (error) {
272
+ if (mounted) {
273
+ setHasPermission(false);
274
+ }
275
+ }
276
+ };
277
+
278
+ requestPermission();
279
+
280
+ return () => {
281
+ mounted = false;
282
+ };
283
+ }, [CameraComponent]);
284
+
285
+ useEffect(() => {
286
+ if (!autoCapture) {
287
+ setIsAutoCapturing(false);
288
+ }
289
+ }, [autoCapture]);
290
+
291
+ useEffect(() => {
292
+ return () => {
293
+ if (rectangleClearTimeoutRef.current) {
294
+ clearTimeout(rectangleClearTimeoutRef.current);
295
+ }
296
+ };
297
+ }, []);
298
+
299
+ const handlePictureTaken = useCallback(
300
+ (event: PictureEvent) => {
301
+ const captureError = (event as any)?.error;
302
+ if (captureError) {
303
+ console.error('[DocScanner] Native capture error received:', captureError);
304
+ captureOriginRef.current = 'auto';
305
+ setIsAutoCapturing(false);
306
+ setDetectedRectangle(null);
307
+
308
+ if (captureResolvers.current) {
309
+ captureResolvers.current.reject(new Error(String(captureError)));
310
+ captureResolvers.current = null;
311
+ }
312
+
313
+ return;
314
+ }
315
+
316
+ setIsAutoCapturing(false);
317
+
318
+ const normalizedRectangle =
319
+ normalizeRectangle(event.rectangleCoordinates ?? null) ??
320
+ normalizeRectangle(event.rectangleOnScreen ?? null) ??
321
+ lastRectangleRef.current;
322
+ const quad = normalizedRectangle ? rectangleToQuad(normalizedRectangle) : null;
323
+ const origin = captureOriginRef.current;
324
+ captureOriginRef.current = 'auto';
325
+
326
+ const initialPath = event.initialImage ?? null;
327
+ const croppedPath = event.croppedImage ?? null;
328
+ const editablePath = initialPath ?? croppedPath;
329
+
330
+ if (editablePath) {
331
+ onCapture?.({
332
+ path: editablePath,
333
+ initialPath,
334
+ croppedPath,
335
+ quad,
336
+ rectangle: normalizedRectangle,
337
+ width: event.width ?? 0,
338
+ height: event.height ?? 0,
339
+ origin,
340
+ });
341
+ }
342
+
343
+ setDetectedRectangle(null);
344
+
345
+ if (captureResolvers.current) {
346
+ captureResolvers.current.resolve(event);
347
+ captureResolvers.current = null;
348
+ }
349
+ },
350
+ [onCapture],
351
+ );
352
+
353
+ const handleError = useCallback((error: Error) => {
354
+ if (captureResolvers.current) {
355
+ captureResolvers.current.reject(error);
356
+ captureResolvers.current = null;
357
+ }
358
+ }, []);
359
+
360
+ const applyRectangleEvent = useCallback(
361
+ (payload: RectangleDetectEvent) => {
362
+ if (autoCapture) {
363
+ if (payload.stableCounter >= Math.max(minStableFrames - 1, 0)) {
364
+ setIsAutoCapturing(true);
365
+ } else if (payload.stableCounter === 0) {
366
+ setIsAutoCapturing(false);
367
+ }
368
+ }
369
+
370
+ const hasAnyRectangle = Boolean(payload.rectangleOnScreen || payload.rectangleCoordinates);
371
+
372
+ if (hasAnyRectangle) {
373
+ if (rectangleClearTimeoutRef.current) {
374
+ clearTimeout(rectangleClearTimeoutRef.current);
375
+ }
376
+ setDetectedRectangle(payload);
377
+ rectangleClearTimeoutRef.current = setTimeout(() => {
378
+ setDetectedRectangle(null);
379
+ rectangleClearTimeoutRef.current = null;
380
+ }, 500);
381
+ } else {
382
+ if (rectangleClearTimeoutRef.current) {
383
+ clearTimeout(rectangleClearTimeoutRef.current);
384
+ rectangleClearTimeoutRef.current = null;
385
+ }
386
+ setDetectedRectangle(null);
387
+ }
388
+
389
+ onRectangleDetect?.(payload);
390
+ },
391
+ [autoCapture, minStableFrames, onRectangleDetect],
392
+ );
393
+
394
+ const captureVision = useCallback(
395
+ async (origin: 'auto' | 'manual') => {
396
+ if (captureInProgressRef.current) {
397
+ throw new Error('capture_in_progress');
398
+ }
399
+
400
+ if (!cameraRef.current?.takePhoto) {
401
+ throw new Error('capture_not_supported');
402
+ }
403
+
404
+ if (!RNPdfScannerManager?.processImage) {
405
+ throw new Error('process_image_not_supported');
406
+ }
407
+
408
+ captureInProgressRef.current = true;
409
+ captureOriginRef.current = origin;
410
+
411
+ try {
412
+ const photo = await cameraRef.current.takePhoto({ flash: 'off' });
413
+ const imageSize = lastImageSizeRef.current;
414
+ const payload = await RNPdfScannerManager.processImage({
415
+ imagePath: photo.path,
416
+ rectangleCoordinates: lastRectangleRef.current,
417
+ rectangleWidth: imageSize?.width ?? 0,
418
+ rectangleHeight: imageSize?.height ?? 0,
419
+ useBase64,
420
+ quality: normalizedQuality,
421
+ brightness: 0,
422
+ contrast: 1,
423
+ saturation: 1,
424
+ saveInAppDocument: false,
425
+ });
426
+ handlePictureTaken(payload);
427
+ return payload;
428
+ } catch (error) {
429
+ handleError(error as Error);
430
+ throw error;
431
+ } finally {
432
+ captureInProgressRef.current = false;
433
+ }
434
+ },
435
+ [handleError, handlePictureTaken, normalizedQuality, useBase64],
436
+ );
437
+
438
+ const capture = useCallback((): Promise<PictureEvent> => {
439
+ captureOriginRef.current = 'manual';
440
+
441
+ if (captureResolvers.current) {
442
+ captureOriginRef.current = 'auto';
443
+ return Promise.reject(new Error('capture_in_progress'));
444
+ }
445
+
446
+ return new Promise<PictureEvent>((resolve, reject) => {
447
+ captureResolvers.current = { resolve, reject };
448
+ captureVision('manual').catch((error) => {
449
+ captureResolvers.current = null;
450
+ captureOriginRef.current = 'auto';
451
+ reject(error);
452
+ });
453
+ });
454
+ }, [captureVision]);
455
+
456
+ useImperativeHandle(
457
+ ref,
458
+ () => ({
459
+ capture,
460
+ reset: () => {
461
+ if (captureResolvers.current) {
462
+ captureResolvers.current.reject(new Error('reset'));
463
+ captureResolvers.current = null;
464
+ }
465
+
466
+ if (rectangleClearTimeoutRef.current) {
467
+ clearTimeout(rectangleClearTimeoutRef.current);
468
+ rectangleClearTimeoutRef.current = null;
469
+ }
470
+
471
+ lastRectangleRef.current = null;
472
+ lastImageSizeRef.current = null;
473
+ stableCounterRef.current = 0;
474
+ setDetectedRectangle(null);
475
+ setIsAutoCapturing(false);
476
+ captureOriginRef.current = 'auto';
477
+ },
478
+ }),
479
+ [capture],
480
+ );
481
+
482
+ const handleVisionResult = useCallback(
483
+ (result: any) => {
484
+ const now = Date.now();
485
+ if (now - lastDetectionTimestampRef.current < 100) {
486
+ return;
487
+ }
488
+ lastDetectionTimestampRef.current = now;
489
+
490
+ const imageWidth = Number(result?.imageWidth) || 0;
491
+ const imageHeight = Number(result?.imageHeight) || 0;
492
+ const isMirrored = Boolean(result?.isMirrored);
493
+
494
+ let rectangleCoordinates = normalizeRectangle(result?.rectangle ?? null);
495
+ if (rectangleCoordinates && isMirrored && imageWidth) {
496
+ rectangleCoordinates = mirrorRectangleHorizontally(rectangleCoordinates, imageWidth);
497
+ }
498
+
499
+ const rectangleOnScreen =
500
+ rectangleCoordinates && viewSize.width && viewSize.height && imageWidth && imageHeight
501
+ ? mapRectangleToView(
502
+ rectangleCoordinates,
503
+ imageWidth,
504
+ imageHeight,
505
+ viewSize.width,
506
+ viewSize.height,
507
+ density,
508
+ )
509
+ : null;
510
+
511
+ const quality = rectangleOnScreen
512
+ ? evaluateRectangleQualityInView(rectangleOnScreen, viewSize.width, viewSize.height)
513
+ : RectangleQuality.TOO_FAR;
514
+
515
+ if (!rectangleCoordinates) {
516
+ stableCounterRef.current = 0;
517
+ } else if (quality === RectangleQuality.GOOD) {
518
+ const cap = autoCapture ? minStableFrames : 99999;
519
+ stableCounterRef.current = Math.min(stableCounterRef.current + 1, cap);
520
+ } else if (stableCounterRef.current > 0) {
521
+ stableCounterRef.current -= 1;
522
+ }
523
+
524
+ if (rectangleCoordinates) {
525
+ lastRectangleRef.current = rectangleCoordinates;
526
+ if (imageWidth && imageHeight) {
527
+ lastImageSizeRef.current = { width: imageWidth, height: imageHeight };
528
+ }
529
+ }
530
+
531
+ const payload: RectangleDetectEvent = {
532
+ stableCounter: stableCounterRef.current,
533
+ lastDetectionType: quality,
534
+ rectangleCoordinates,
535
+ rectangleOnScreen,
536
+ previewSize: viewSize.width && viewSize.height ? { width: viewSize.width, height: viewSize.height } : undefined,
537
+ imageSize: imageWidth && imageHeight ? { width: imageWidth, height: imageHeight } : undefined,
538
+ };
539
+
540
+ applyRectangleEvent(payload);
541
+
542
+ if (
543
+ autoCapture &&
544
+ rectangleCoordinates &&
545
+ stableCounterRef.current >= minStableFrames &&
546
+ !captureInProgressRef.current
547
+ ) {
548
+ stableCounterRef.current = 0;
549
+ captureVision('auto').catch(() => {});
550
+ }
551
+ },
552
+ [applyRectangleEvent, autoCapture, captureVision, density, minStableFrames, viewSize],
553
+ );
554
+
555
+ const plugin = useMemo(() => {
556
+ if (!visionCameraModule?.VisionCameraProxy?.initFrameProcessorPlugin) {
557
+ return null;
558
+ }
559
+ return visionCameraModule.VisionCameraProxy.initFrameProcessorPlugin('DocumentScanner', detectionConfig ?? {});
560
+ }, [detectionConfig]);
561
+
562
+ const frameProcessor = visionCameraModule.useFrameProcessor((frame: any) => {
563
+ 'worklet';
564
+ if (!plugin || !runOnJS) {
565
+ return;
566
+ }
567
+ const output = plugin.call(frame, null);
568
+ runOnJS(handleVisionResult)(output ?? null);
569
+ }, [plugin, runOnJS, handleVisionResult]);
570
+
571
+ const handleLayout = useCallback((event: any) => {
572
+ const { width, height } = event.nativeEvent.layout;
573
+ if (width && height) {
574
+ setViewSize({ width, height });
575
+ }
576
+ }, []);
577
+
578
+ const overlayPolygon = detectedRectangle?.rectangleOnScreen ?? null;
579
+ const overlayIsActive = autoCapture ? isAutoCapturing : (detectedRectangle?.stableCounter ?? 0) > 0;
580
+
581
+ return (
582
+ <View style={styles.container} onLayout={handleLayout}>
583
+ {CameraComponent && device && hasPermission ? (
584
+ <CameraComponent
585
+ ref={cameraRef}
586
+ style={styles.scanner}
587
+ device={device}
588
+ isActive={true}
589
+ photo={true}
590
+ torch={enableTorch ? 'on' : 'off'}
591
+ frameProcessor={frameProcessor}
592
+ frameProcessorFps={10}
593
+ />
594
+ ) : (
595
+ <View style={styles.scanner} />
596
+ )}
597
+ {showGrid && overlayPolygon && (
598
+ <ScannerOverlay
599
+ active={overlayIsActive}
600
+ color={gridColor ?? overlayColor}
601
+ lineWidth={gridLineWidth}
602
+ polygon={overlayPolygon}
603
+ />
604
+ )}
605
+ {showManualCaptureButton && (
606
+ <TouchableOpacity style={styles.button} onPress={() => captureVision('manual')} />
607
+ )}
608
+ {children}
609
+ </View>
610
+ );
611
+ },
612
+ );
613
+
117
614
  export const DocScanner = forwardRef<DocScannerHandle, Props>(
118
615
  (
119
616
  {
@@ -134,6 +631,29 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
134
631
  },
135
632
  ref,
136
633
  ) => {
634
+ if (hasVisionCamera) {
635
+ return (
636
+ <VisionCameraScanner
637
+ ref={ref}
638
+ onCapture={onCapture}
639
+ overlayColor={overlayColor}
640
+ autoCapture={autoCapture}
641
+ minStableFrames={minStableFrames}
642
+ enableTorch={enableTorch}
643
+ quality={quality}
644
+ useBase64={useBase64}
645
+ showGrid={showGrid}
646
+ gridColor={gridColor}
647
+ gridLineWidth={gridLineWidth}
648
+ detectionConfig={detectionConfig}
649
+ onRectangleDetect={onRectangleDetect}
650
+ showManualCaptureButton={showManualCaptureButton}
651
+ >
652
+ {children}
653
+ </VisionCameraScanner>
654
+ );
655
+ }
656
+
137
657
  const scannerRef = useRef<any>(null);
138
658
  const captureResolvers = useRef<{
139
659
  resolve: (value: PictureEvent) => void;