react-native-rectangle-doc-scanner 10.26.0 → 10.28.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.
@@ -33,6 +33,7 @@ class CameraController(
33
33
  private var preview: Preview? = null
34
34
  private var imageAnalyzer: ImageAnalysis? = null
35
35
  private var imageCapture: ImageCapture? = null
36
+ private var previewViewport: android.graphics.RectF? = null
36
37
 
37
38
  private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
38
39
 
@@ -358,8 +359,8 @@ class CameraController(
358
359
  if (mlBox != null) {
359
360
  val frameWidth = if (rotation == 90 || rotation == 270) height else width
360
361
  val frameHeight = if (rotation == 90 || rotation == 270) width else height
361
- val padX = (mlBox.width() * 0.15f).toInt().coerceAtLeast(24)
362
- val padY = (mlBox.height() * 0.15f).toInt().coerceAtLeast(24)
362
+ val padX = (mlBox.width() * 0.25f).toInt().coerceAtLeast(32)
363
+ val padY = (mlBox.height() * 0.25f).toInt().coerceAtLeast(32)
363
364
  val roi = android.graphics.Rect(
364
365
  (mlBox.left - padX).coerceAtLeast(0),
365
366
  (mlBox.top - padY).coerceAtLeast(0),
@@ -466,13 +467,10 @@ class CameraController(
466
467
  }
467
468
 
468
469
  fun getPreviewViewport(): android.graphics.RectF? {
469
- // With TextureView, the viewport is simply the view bounds
470
- val width = textureView.width.toFloat()
471
- val height = textureView.height.toFloat()
472
-
473
- if (width <= 0 || height <= 0) return null
474
-
475
- return android.graphics.RectF(0f, 0f, width, height)
470
+ val viewWidth = textureView.width.toFloat()
471
+ val viewHeight = textureView.height.toFloat()
472
+ if (viewWidth <= 0 || viewHeight <= 0) return null
473
+ return previewViewport ?: android.graphics.RectF(0f, 0f, viewWidth, viewHeight)
476
474
  }
477
475
 
478
476
  private fun updateTextureViewTransform(bufferWidth: Int, bufferHeight: Int) {
@@ -537,6 +535,18 @@ class CameraController(
537
535
 
538
536
  matrix.postScale(scale, scale, centerX, centerY)
539
537
 
538
+ // Track the actual preview viewport within the view for clipping overlays.
539
+ val scaledWidth = rotatedBufferWidth * scale
540
+ val scaledHeight = rotatedBufferHeight * scale
541
+ val offsetX = (viewWidth - scaledWidth) / 2f
542
+ val offsetY = (viewHeight - scaledHeight) / 2f
543
+ previewViewport = android.graphics.RectF(
544
+ offsetX,
545
+ offsetY,
546
+ offsetX + scaledWidth,
547
+ offsetY + scaledHeight
548
+ )
549
+
540
550
  textureView.setTransform(matrix)
541
551
  Log.d(TAG, "[TRANSFORM] Transform applied successfully")
542
552
  }
@@ -174,7 +174,6 @@ class DocumentDetector {
174
174
  val clahe = Imgproc.createCLAHE()
175
175
  clahe.clipLimit = 2.5
176
176
  clahe.apply(grayMat, grayMat)
177
- clahe.release()
178
177
 
179
178
  // Apply a light blur to reduce noise without killing small edges.
180
179
  Imgproc.GaussianBlur(grayMat, blurredMat, Size(5.0, 5.0), 0.0)
@@ -208,8 +207,8 @@ class DocumentDetector {
208
207
 
209
208
  val median = computeMedian(blurredMat)
210
209
  val sigma = 0.33
211
- val cannyLow = max(40.0, (1.0 - sigma) * median)
212
- val cannyHigh = max(120.0, (1.0 + sigma) * median)
210
+ val cannyLow = max(25.0, (1.0 - sigma) * median)
211
+ val cannyHigh = max(80.0, (1.0 + sigma) * median)
213
212
 
214
213
  // Apply Canny edge detection with adaptive thresholds for better corner detection.
215
214
  Imgproc.Canny(blurredMat, cannyMat, cannyLow, cannyHigh)
@@ -257,7 +256,7 @@ class DocumentDetector {
257
256
 
258
257
  var largestRectangle: Rectangle? = null
259
258
  var bestScore = 0.0
260
- val minArea = max(800.0, (srcMat.rows() * srcMat.cols()) * 0.001)
259
+ val minArea = max(450.0, (srcMat.rows() * srcMat.cols()) * 0.0007)
261
260
 
262
261
  for (contour in contours) {
263
262
  val contourArea = Imgproc.contourArea(contour)
@@ -129,6 +129,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
129
129
  const [isGalleryOpen, setIsGalleryOpen] = (0, react_1.useState)(false);
130
130
  const [rectangleDetected, setRectangleDetected] = (0, react_1.useState)(false);
131
131
  const [rectangleHint, setRectangleHint] = (0, react_1.useState)(false);
132
+ const [captureReady, setCaptureReady] = (0, react_1.useState)(false);
132
133
  const [flashEnabled, setFlashEnabled] = (0, react_1.useState)(false);
133
134
  const [rotationDegrees, setRotationDegrees] = (0, react_1.useState)(0);
134
135
  const [capturedPhotos, setCapturedPhotos] = (0, react_1.useState)([]);
@@ -140,6 +141,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
140
141
  const captureInProgressRef = (0, react_1.useRef)(false);
141
142
  const rectangleCaptureTimeoutRef = (0, react_1.useRef)(null);
142
143
  const rectangleHintTimeoutRef = (0, react_1.useRef)(null);
144
+ const captureReadyTimeoutRef = (0, react_1.useRef)(null);
143
145
  const isBusinessMode = type === 'business';
144
146
  const resetScannerView = (0, react_1.useCallback)((options) => {
145
147
  setProcessing(false);
@@ -147,6 +149,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
147
149
  setRotationDegrees(0);
148
150
  setRectangleDetected(false);
149
151
  setRectangleHint(false);
152
+ setCaptureReady(false);
150
153
  captureModeRef.current = null;
151
154
  captureInProgressRef.current = false;
152
155
  if (rectangleCaptureTimeoutRef.current) {
@@ -157,6 +160,10 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
157
160
  clearTimeout(rectangleHintTimeoutRef.current);
158
161
  rectangleHintTimeoutRef.current = null;
159
162
  }
163
+ if (captureReadyTimeoutRef.current) {
164
+ clearTimeout(captureReadyTimeoutRef.current);
165
+ captureReadyTimeoutRef.current = null;
166
+ }
160
167
  if (docScannerRef.current?.reset) {
161
168
  docScannerRef.current.reset();
162
169
  }
@@ -360,9 +367,14 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
360
367
  hasRef: hasScanner,
361
368
  rectangleDetected,
362
369
  rectangleHint,
370
+ captureReady,
363
371
  currentCaptureMode: captureModeRef.current,
364
372
  captureInProgress: captureInProgressRef.current,
365
373
  });
374
+ if (react_native_1.Platform.OS === 'android' && !captureReady) {
375
+ console.log('[FullDocScanner] Capture not ready yet, skipping');
376
+ return;
377
+ }
366
378
  if (processing) {
367
379
  console.log('[FullDocScanner] Already processing, skipping manual capture');
368
380
  return;
@@ -408,7 +420,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
408
420
  emitError(error, 'Failed to capture image. Please try again.');
409
421
  }
410
422
  });
411
- }, [processing, rectangleDetected, rectangleHint, emitError]);
423
+ }, [processing, rectangleDetected, rectangleHint, captureReady, emitError]);
412
424
  const handleGalleryPick = (0, react_1.useCallback)(async () => {
413
425
  console.log('[FullDocScanner] handleGalleryPick called');
414
426
  if (processing || isGalleryOpen) {
@@ -527,7 +539,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
527
539
  const stableCounter = event.stableCounter ?? 0;
528
540
  const rectangleCoordinates = event.rectangleOnScreen ?? event.rectangleCoordinates;
529
541
  const hasRectangle = Boolean(rectangleCoordinates);
530
- const captureReady = hasRectangle && (react_native_1.Platform.OS === 'android' || (event.lastDetectionType === 0 && stableCounter >= 1));
542
+ const isStableForCapture = hasRectangle && (react_native_1.Platform.OS === 'android' || (event.lastDetectionType === 0 && stableCounter >= 1));
531
543
  const scheduleClear = (ref, clearFn) => {
532
544
  if (ref.current) {
533
545
  clearTimeout(ref.current);
@@ -540,6 +552,14 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
540
552
  if (hasRectangle) {
541
553
  scheduleClear(rectangleHintTimeoutRef, () => setRectangleHint(false));
542
554
  setRectangleHint(true);
555
+ if (react_native_1.Platform.OS === 'android') {
556
+ if (!captureReadyTimeoutRef.current) {
557
+ captureReadyTimeoutRef.current = setTimeout(() => {
558
+ setCaptureReady(true);
559
+ captureReadyTimeoutRef.current = null;
560
+ }, 1000);
561
+ }
562
+ }
543
563
  }
544
564
  else {
545
565
  if (rectangleHintTimeoutRef.current) {
@@ -547,8 +567,15 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
547
567
  rectangleHintTimeoutRef.current = null;
548
568
  }
549
569
  setRectangleHint(false);
570
+ if (react_native_1.Platform.OS === 'android') {
571
+ if (captureReadyTimeoutRef.current) {
572
+ clearTimeout(captureReadyTimeoutRef.current);
573
+ captureReadyTimeoutRef.current = null;
574
+ }
575
+ setCaptureReady(false);
576
+ }
550
577
  }
551
- if (captureReady) {
578
+ if (isStableForCapture) {
552
579
  scheduleClear(rectangleCaptureTimeoutRef, () => {
553
580
  setRectangleDetected(false);
554
581
  });
@@ -570,6 +597,9 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
570
597
  if (rectangleHintTimeoutRef.current) {
571
598
  clearTimeout(rectangleHintTimeoutRef.current);
572
599
  }
600
+ if (captureReadyTimeoutRef.current) {
601
+ clearTimeout(captureReadyTimeoutRef.current);
602
+ }
573
603
  }, []);
574
604
  const activePreviewImage = croppedImageData ? getActivePreviewImage(croppedImageData) : null;
575
605
  return (react_1.default.createElement(react_native_1.View, { style: styles.container },
@@ -642,10 +672,14 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
642
672
  enableGallery && (react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.iconButton, processing && styles.buttonDisabled], onPress: handleGalleryPick, disabled: processing, accessibilityLabel: mergedStrings.galleryButton, accessibilityRole: "button" },
643
673
  react_1.default.createElement(react_native_1.View, { style: styles.iconContainer },
644
674
  react_1.default.createElement(react_native_1.Text, { style: styles.iconText }, "\uD83D\uDDBC\uFE0F")))),
645
- react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.shutterButton, processing && styles.buttonDisabled], onPress: triggerManualCapture, disabled: processing, accessibilityLabel: mergedStrings.manualHint, accessibilityRole: "button" },
675
+ react_1.default.createElement(react_native_1.TouchableOpacity, { style: [
676
+ styles.shutterButton,
677
+ processing && styles.buttonDisabled,
678
+ react_native_1.Platform.OS === 'android' && !captureReady && styles.buttonDisabled,
679
+ ], onPress: triggerManualCapture, disabled: processing || (react_native_1.Platform.OS === 'android' && !captureReady), accessibilityLabel: mergedStrings.manualHint, accessibilityRole: "button" },
646
680
  react_1.default.createElement(react_native_1.View, { style: [
647
681
  styles.shutterInner,
648
- rectangleHint && { backgroundColor: overlayColor }
682
+ (react_native_1.Platform.OS === 'android' ? captureReady : rectangleHint) && { backgroundColor: overlayColor }
649
683
  ] })),
650
684
  react_1.default.createElement(react_native_1.View, { style: styles.rightButtonsPlaceholder }))))),
651
685
  processing && (react_1.default.createElement(react_native_1.View, { style: styles.processingOverlay },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "10.26.0",
3
+ "version": "10.28.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -192,6 +192,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
192
192
  const [isGalleryOpen, setIsGalleryOpen] = useState(false);
193
193
  const [rectangleDetected, setRectangleDetected] = useState(false);
194
194
  const [rectangleHint, setRectangleHint] = useState(false);
195
+ const [captureReady, setCaptureReady] = useState(false);
195
196
  const [flashEnabled, setFlashEnabled] = useState(false);
196
197
  const [rotationDegrees, setRotationDegrees] = useState(0);
197
198
  const [capturedPhotos, setCapturedPhotos] = useState<FullDocScannerResult[]>([]);
@@ -203,6 +204,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
203
204
  const captureInProgressRef = useRef(false);
204
205
  const rectangleCaptureTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
205
206
  const rectangleHintTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
207
+ const captureReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
206
208
 
207
209
  const isBusinessMode = type === 'business';
208
210
 
@@ -213,6 +215,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
213
215
  setRotationDegrees(0);
214
216
  setRectangleDetected(false);
215
217
  setRectangleHint(false);
218
+ setCaptureReady(false);
216
219
  captureModeRef.current = null;
217
220
  captureInProgressRef.current = false;
218
221
 
@@ -225,6 +228,10 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
225
228
  clearTimeout(rectangleHintTimeoutRef.current);
226
229
  rectangleHintTimeoutRef.current = null;
227
230
  }
231
+ if (captureReadyTimeoutRef.current) {
232
+ clearTimeout(captureReadyTimeoutRef.current);
233
+ captureReadyTimeoutRef.current = null;
234
+ }
228
235
 
229
236
  if (docScannerRef.current?.reset) {
230
237
  docScannerRef.current.reset();
@@ -505,10 +512,16 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
505
512
  hasRef: hasScanner,
506
513
  rectangleDetected,
507
514
  rectangleHint,
515
+ captureReady,
508
516
  currentCaptureMode: captureModeRef.current,
509
517
  captureInProgress: captureInProgressRef.current,
510
518
  });
511
519
 
520
+ if (Platform.OS === 'android' && !captureReady) {
521
+ console.log('[FullDocScanner] Capture not ready yet, skipping');
522
+ return;
523
+ }
524
+
512
525
  if (processing) {
513
526
  console.log('[FullDocScanner] Already processing, skipping manual capture');
514
527
  return;
@@ -567,7 +580,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
567
580
  );
568
581
  }
569
582
  });
570
- }, [processing, rectangleDetected, rectangleHint, emitError]);
583
+ }, [processing, rectangleDetected, rectangleHint, captureReady, emitError]);
571
584
 
572
585
  const handleGalleryPick = useCallback(async () => {
573
586
  console.log('[FullDocScanner] handleGalleryPick called');
@@ -719,7 +732,8 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
719
732
  const stableCounter = event.stableCounter ?? 0;
720
733
  const rectangleCoordinates = event.rectangleOnScreen ?? event.rectangleCoordinates;
721
734
  const hasRectangle = Boolean(rectangleCoordinates);
722
- const captureReady = hasRectangle && (Platform.OS === 'android' || (event.lastDetectionType === 0 && stableCounter >= 1));
735
+ const isStableForCapture =
736
+ hasRectangle && (Platform.OS === 'android' || (event.lastDetectionType === 0 && stableCounter >= 1));
723
737
 
724
738
  const scheduleClear = (
725
739
  ref: React.MutableRefObject<ReturnType<typeof setTimeout> | null>,
@@ -737,15 +751,30 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
737
751
  if (hasRectangle) {
738
752
  scheduleClear(rectangleHintTimeoutRef, () => setRectangleHint(false));
739
753
  setRectangleHint(true);
754
+ if (Platform.OS === 'android') {
755
+ if (!captureReadyTimeoutRef.current) {
756
+ captureReadyTimeoutRef.current = setTimeout(() => {
757
+ setCaptureReady(true);
758
+ captureReadyTimeoutRef.current = null;
759
+ }, 1000);
760
+ }
761
+ }
740
762
  } else {
741
763
  if (rectangleHintTimeoutRef.current) {
742
764
  clearTimeout(rectangleHintTimeoutRef.current);
743
765
  rectangleHintTimeoutRef.current = null;
744
766
  }
745
767
  setRectangleHint(false);
768
+ if (Platform.OS === 'android') {
769
+ if (captureReadyTimeoutRef.current) {
770
+ clearTimeout(captureReadyTimeoutRef.current);
771
+ captureReadyTimeoutRef.current = null;
772
+ }
773
+ setCaptureReady(false);
774
+ }
746
775
  }
747
776
 
748
- if (captureReady) {
777
+ if (isStableForCapture) {
749
778
  scheduleClear(rectangleCaptureTimeoutRef, () => {
750
779
  setRectangleDetected(false);
751
780
  });
@@ -768,6 +797,9 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
768
797
  if (rectangleHintTimeoutRef.current) {
769
798
  clearTimeout(rectangleHintTimeoutRef.current);
770
799
  }
800
+ if (captureReadyTimeoutRef.current) {
801
+ clearTimeout(captureReadyTimeoutRef.current);
802
+ }
771
803
  },
772
804
  [],
773
805
  );
@@ -1000,15 +1032,19 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
1000
1032
  </TouchableOpacity>
1001
1033
  )}
1002
1034
  <TouchableOpacity
1003
- style={[styles.shutterButton, processing && styles.buttonDisabled]}
1035
+ style={[
1036
+ styles.shutterButton,
1037
+ processing && styles.buttonDisabled,
1038
+ Platform.OS === 'android' && !captureReady && styles.buttonDisabled,
1039
+ ]}
1004
1040
  onPress={triggerManualCapture}
1005
- disabled={processing}
1041
+ disabled={processing || (Platform.OS === 'android' && !captureReady)}
1006
1042
  accessibilityLabel={mergedStrings.manualHint}
1007
1043
  accessibilityRole="button"
1008
1044
  >
1009
1045
  <View style={[
1010
1046
  styles.shutterInner,
1011
- rectangleHint && { backgroundColor: overlayColor }
1047
+ (Platform.OS === 'android' ? captureReady : rectangleHint) && { backgroundColor: overlayColor }
1012
1048
  ]} />
1013
1049
  </TouchableOpacity>
1014
1050
  <View style={styles.rightButtonsPlaceholder} />