react-native-rectangle-doc-scanner 11.0.0 → 11.2.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.
@@ -118,11 +118,11 @@ class CameraController(
118
118
  Log.d(TAG, "[CAMERAX] TextureView visibility: ${textureView.visibility}")
119
119
  Log.d(TAG, "[CAMERAX] TextureView isAvailable: ${textureView.isAvailable}")
120
120
 
121
- val targetRotation = textureView.display?.rotation ?: android.view.Surface.ROTATION_0
122
- Log.d(TAG, "[CAMERAX] Setting target rotation to $targetRotation")
121
+ // Force portrait orientation (app is portrait-only)
122
+ val targetRotation = android.view.Surface.ROTATION_0
123
+ Log.d(TAG, "[CAMERAX] Setting target rotation to ROTATION_0 (portrait-only app)")
123
124
 
124
125
  preview = Preview.Builder()
125
- .setTargetAspectRatio(AspectRatio.RATIO_4_3)
126
126
  .setTargetRotation(targetRotation) // Force portrait
127
127
  .build()
128
128
  .also { previewUseCase ->
@@ -185,7 +185,7 @@ class CameraController(
185
185
  // ImageAnalysis UseCase for document detection
186
186
  imageAnalyzer = ImageAnalysis.Builder()
187
187
  .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
188
- .setTargetAspectRatio(AspectRatio.RATIO_4_3)
188
+ .setTargetResolution(android.util.Size(1920, 1440)) // Higher resolution for better small-edge detection
189
189
  .setTargetRotation(targetRotation) // Match preview rotation
190
190
  .build()
191
191
  .also {
@@ -201,7 +201,6 @@ class CameraController(
201
201
  // ImageCapture UseCase
202
202
  imageCapture = ImageCapture.Builder()
203
203
  .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
204
- .setTargetAspectRatio(AspectRatio.RATIO_4_3)
205
204
  .setTargetRotation(targetRotation) // Match preview rotation
206
205
  .build()
207
206
 
@@ -388,18 +387,30 @@ class CameraController(
388
387
  mlBox: android.graphics.Rect?
389
388
  ): Rectangle? {
390
389
  return try {
391
- if (mlBox != null) {
392
- val frameWidth = if (rotation == 90 || rotation == 270) height else width
393
- val frameHeight = if (rotation == 90 || rotation == 270) width else height
394
- val padX = (mlBox.width() * 0.25f).toInt().coerceAtLeast(32)
395
- val padY = (mlBox.height() * 0.25f).toInt().coerceAtLeast(32)
396
- val roi = android.graphics.Rect(
397
- (mlBox.left - padX).coerceAtLeast(0),
398
- (mlBox.top - padY).coerceAtLeast(0),
399
- (mlBox.right + padX).coerceAtMost(frameWidth),
400
- (mlBox.bottom + padY).coerceAtMost(frameHeight)
401
- )
402
- DocumentDetector.detectRectangleInYUVWithRoi(nv21, width, height, rotation, roi)
390
+ val frameWidth = if (rotation == 90 || rotation == 270) height else width
391
+ val frameHeight = if (rotation == 90 || rotation == 270) width else height
392
+ val frameArea = frameWidth.toLong() * frameHeight.toLong()
393
+ val roiRect = mlBox?.let { box ->
394
+ val boxArea = box.width().toLong() * box.height().toLong()
395
+ val aspect = if (box.height() > 0) box.width().toDouble() / box.height().toDouble() else 0.0
396
+ val isValidSize = boxArea >= (frameArea * 0.08)
397
+ val isValidAspect = aspect in 0.4..2.5
398
+ if (!isValidSize || !isValidAspect) {
399
+ null
400
+ } else {
401
+ val padX = (box.width() * 0.25f).toInt().coerceAtLeast(32)
402
+ val padY = (box.height() * 0.25f).toInt().coerceAtLeast(32)
403
+ android.graphics.Rect(
404
+ (box.left - padX).coerceAtLeast(0),
405
+ (box.top - padY).coerceAtLeast(0),
406
+ (box.right + padX).coerceAtMost(frameWidth),
407
+ (box.bottom + padY).coerceAtMost(frameHeight)
408
+ )
409
+ }
410
+ }
411
+
412
+ if (roiRect != null) {
413
+ DocumentDetector.detectRectangleInYUVWithRoi(nv21, width, height, rotation, roiRect)
403
414
  ?: DocumentDetector.detectRectangleInYUV(nv21, width, height, rotation)
404
415
  } else {
405
416
  DocumentDetector.detectRectangleInYUV(nv21, width, height, rotation)
@@ -474,14 +485,35 @@ class CameraController(
474
485
 
475
486
  if (viewWidth <= 0 || viewHeight <= 0) return null
476
487
 
477
- // Image coordinates are already in display orientation (rotation applied before detection).
478
- val finalWidth = imageWidth
479
- val finalHeight = imageHeight
488
+ // The image coordinates are in camera sensor space. We need to transform them
489
+ // to match how the TextureView displays the image (after rotation/scaling).
490
+ val sensorOrientation = getCameraSensorOrientation()
491
+ val displayRotationDegrees = when (textureView.display?.rotation ?: Surface.ROTATION_0) {
492
+ Surface.ROTATION_0 -> 0
493
+ Surface.ROTATION_90 -> 90
494
+ Surface.ROTATION_180 -> 180
495
+ Surface.ROTATION_270 -> 270
496
+ else -> 0
497
+ }
480
498
 
481
- // Apply the same center-crop scaling as the TextureView transform.
499
+ fun rotatePoint(point: org.opencv.core.Point): org.opencv.core.Point {
500
+ return if (sensorOrientation == 90) {
501
+ org.opencv.core.Point(
502
+ point.y,
503
+ imageWidth - point.x
504
+ )
505
+ } else {
506
+ point
507
+ }
508
+ }
509
+
510
+ val finalWidth = if (sensorOrientation == 90) imageHeight else imageWidth
511
+ val finalHeight = if (sensorOrientation == 90) imageWidth else imageHeight
512
+
513
+ // Then apply fit-center scaling
482
514
  val scaleX = viewWidth / finalWidth.toFloat()
483
515
  val scaleY = viewHeight / finalHeight.toFloat()
484
- val scale = scaleX.coerceAtLeast(scaleY)
516
+ val scale = scaleX.coerceAtMost(scaleY)
485
517
 
486
518
  val scaledWidth = finalWidth * scale
487
519
  val scaledHeight = finalHeight * scale
@@ -489,9 +521,10 @@ class CameraController(
489
521
  val offsetY = (viewHeight - scaledHeight) / 2f
490
522
 
491
523
  fun transformPoint(point: org.opencv.core.Point): org.opencv.core.Point {
524
+ val rotated = rotatePoint(point)
492
525
  return org.opencv.core.Point(
493
- point.x * scale + offsetX,
494
- point.y * scale + offsetY
526
+ rotated.x * scale + offsetX,
527
+ rotated.y * scale + offsetY
495
528
  )
496
529
  }
497
530
 
@@ -502,9 +535,10 @@ class CameraController(
502
535
  transformPoint(rectangle.bottomRight)
503
536
  )
504
537
 
505
- Log.d(TAG, "[MAPPING] Image: ${imageWidth}x${imageHeight} → View: ${viewWidth.toInt()}x${viewHeight.toInt()}")
506
- Log.d(TAG, "[MAPPING] Scale: $scale, Offset: ($offsetX, $offsetY)")
538
+ Log.d(TAG, "[MAPPING] Sensor: ${sensorOrientation}°, Image: ${imageWidth}x${imageHeight} → Final: ${finalWidth}x${finalHeight}")
539
+ Log.d(TAG, "[MAPPING] View: ${viewWidth.toInt()}x${viewHeight.toInt()}, Scale: $scale, Offset: ($offsetX, $offsetY)")
507
540
  Log.d(TAG, "[MAPPING] TL: (${rectangle.topLeft.x}, ${rectangle.topLeft.y}) → " +
541
+ "Rotated: (${rotatePoint(rectangle.topLeft).x}, ${rotatePoint(rectangle.topLeft).y}) → " +
508
542
  "Final: (${result.topLeft.x}, ${result.topLeft.y})")
509
543
 
510
544
  return result
@@ -545,13 +579,17 @@ class CameraController(
545
579
  val centerX = viewWidth / 2f
546
580
  val centerY = viewHeight / 2f
547
581
 
548
- val rotationDegrees = ((sensorOrientation + displayRotationDegrees) % 360).toFloat()
582
+ // Calculate rotation from buffer to display coordinates.
583
+ // CameraX accounts for sensor orientation via targetRotation. Some tablets with landscape
584
+ // sensors report Display 90 in portrait but render upside down; add a 180° fix for that case.
585
+ val tabletUpsideDownFix = if (sensorOrientation == 0 && displayRotationDegrees == 90) 180 else 0
586
+ val rotationDegrees = ((displayRotationDegrees + tabletUpsideDownFix) % 360).toFloat()
549
587
 
550
588
  if (rotationDegrees != 0f) {
551
- Log.d(TAG, "[TRANSFORM] Applying rotation: ${rotationDegrees}°")
552
589
  matrix.postRotate(rotationDegrees, centerX, centerY)
553
590
  }
554
591
 
592
+ // After rotation, determine effective buffer size
555
593
  val rotatedBufferWidth = if (rotationDegrees == 90f || rotationDegrees == 270f) {
556
594
  bufferHeight
557
595
  } else {
@@ -563,17 +601,26 @@ class CameraController(
563
601
  bufferHeight
564
602
  }
565
603
 
566
- // Scale to fill the view while maintaining aspect ratio (center-crop).
604
+ // Scale to fit within the view while maintaining aspect ratio (no zoom/crop)
567
605
  val scaleX = viewWidth.toFloat() / rotatedBufferWidth.toFloat()
568
606
  val scaleY = viewHeight.toFloat() / rotatedBufferHeight.toFloat()
569
- val scale = scaleX.coerceAtLeast(scaleY)
607
+ val scale = scaleX.coerceAtMost(scaleY) // Use min to fit
570
608
 
571
609
  Log.d(TAG, "[TRANSFORM] Rotated buffer: ${rotatedBufferWidth}x${rotatedBufferHeight}, ScaleX: $scaleX, ScaleY: $scaleY, Using: $scale")
572
610
 
573
611
  matrix.postScale(scale, scale, centerX, centerY)
574
612
 
575
- // With center-crop, the preview fills the view bounds.
576
- previewViewport = android.graphics.RectF(0f, 0f, viewWidth.toFloat(), viewHeight.toFloat())
613
+ // Track the actual preview viewport within the view for clipping overlays.
614
+ val scaledWidth = rotatedBufferWidth * scale
615
+ val scaledHeight = rotatedBufferHeight * scale
616
+ val offsetX = (viewWidth - scaledWidth) / 2f
617
+ val offsetY = (viewHeight - scaledHeight) / 2f
618
+ previewViewport = android.graphics.RectF(
619
+ offsetX,
620
+ offsetY,
621
+ offsetX + scaledWidth,
622
+ offsetY + scaledHeight
623
+ )
577
624
 
578
625
  textureView.setTransform(matrix)
579
626
  Log.d(TAG, "[TRANSFORM] Transform applied successfully")
@@ -746,7 +746,7 @@ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), L
746
746
  if (viewWidth == 0 || viewHeight == 0 || imageWidth == 0 || imageHeight == 0) {
747
747
  return rectangle
748
748
  }
749
- val scale = max(
749
+ val scale = min(
750
750
  viewWidth.toDouble() / imageWidth.toDouble(),
751
751
  viewHeight.toDouble() / imageHeight.toDouble()
752
752
  )
@@ -589,7 +589,7 @@ class DocumentDetector {
589
589
  return rectangle
590
590
  }
591
591
 
592
- val scale = max(
592
+ val scale = min(
593
593
  viewWidth.toDouble() / imageWidth.toDouble(),
594
594
  viewHeight.toDouble() / imageHeight.toDouble()
595
595
  )
@@ -602,7 +602,10 @@ class DocumentDetector {
602
602
  fun mapPoint(point: Point): Point {
603
603
  val x = (point.x * scale) + offsetX
604
604
  val y = (point.y * scale) + offsetY
605
- return Point(x, y)
605
+ return Point(
606
+ x.coerceIn(0.0, viewWidth.toDouble()),
607
+ y.coerceIn(0.0, viewHeight.toDouble())
608
+ )
606
609
  }
607
610
 
608
611
  return Rectangle(
@@ -121,14 +121,24 @@ const mirrorRectangleHorizontally = (rectangle, imageWidth) => ({
121
121
  const mapRectangleToView = (rectangle, imageWidth, imageHeight, viewWidth, viewHeight, density) => {
122
122
  const viewWidthPx = viewWidth * density;
123
123
  const viewHeightPx = viewHeight * density;
124
- const scale = Math.max(viewWidthPx / imageWidth, viewHeightPx / imageHeight);
124
+ const scale = react_native_1.Platform.OS === 'ios'
125
+ ? Math.max(viewWidthPx / imageWidth, viewHeightPx / imageHeight)
126
+ : Math.min(viewWidthPx / imageWidth, viewHeightPx / imageHeight);
125
127
  const scaledImageWidth = imageWidth * scale;
126
128
  const scaledImageHeight = imageHeight * scale;
127
- const offsetX = (scaledImageWidth - viewWidthPx) / 2;
128
- const offsetY = (scaledImageHeight - viewHeightPx) / 2;
129
+ const offsetX = react_native_1.Platform.OS === 'ios'
130
+ ? (scaledImageWidth - viewWidthPx) / 2
131
+ : (viewWidthPx - scaledImageWidth) / 2;
132
+ const offsetY = react_native_1.Platform.OS === 'ios'
133
+ ? (scaledImageHeight - viewHeightPx) / 2
134
+ : (viewHeightPx - scaledImageHeight) / 2;
129
135
  const mapPoint = (point) => ({
130
- x: (point.x * scale - offsetX) / density,
131
- y: (point.y * scale - offsetY) / density,
136
+ x: react_native_1.Platform.OS === 'ios'
137
+ ? (point.x * scale - offsetX) / density
138
+ : (point.x * scale + offsetX) / density,
139
+ y: react_native_1.Platform.OS === 'ios'
140
+ ? (point.y * scale - offsetY) / density
141
+ : (point.y * scale + offsetY) / density,
132
142
  });
133
143
  return {
134
144
  topLeft: mapPoint(rectangle.topLeft),
@@ -418,7 +428,7 @@ const VisionCameraScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor =
418
428
  const overlayIsActive = autoCapture ? isAutoCapturing : (detectedRectangle?.stableCounter ?? 0) > 0;
419
429
  return (react_1.default.createElement(react_native_1.View, { style: styles.container, onLayout: handleLayout },
420
430
  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, clipRect: detectedRectangle?.previewViewport ?? null })),
431
+ showGrid && overlayPolygon && (react_1.default.createElement(overlay_1.ScannerOverlay, { active: overlayIsActive, color: gridColor ?? overlayColor, lineWidth: gridLineWidth, polygon: overlayPolygon, clipRect: react_native_1.Platform.OS === 'android' ? null : (detectedRectangle?.previewViewport ?? null) })),
422
432
  showManualCaptureButton && (react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.button, onPress: () => captureVision('manual') })),
423
433
  children));
424
434
  });
@@ -630,6 +640,7 @@ const NativeScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAU
630
640
  let rectangleOnScreen = normalizeRectangle(event.rectangleOnScreen ?? null);
631
641
  const density = react_native_1.PixelRatio.get();
632
642
  if (react_native_1.Platform.OS === 'android' &&
643
+ !rectangleOnScreen &&
633
644
  rectangleCoordinates &&
634
645
  event.imageSize &&
635
646
  event.previewSize &&
@@ -705,7 +716,7 @@ const NativeScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAU
705
716
  const detectionThreshold = autoCapture ? minStableFrames : 99999;
706
717
  return (react_1.default.createElement(react_native_1.View, { style: styles.container },
707
718
  react_1.default.createElement(react_native_document_scanner_1.default, { ref: scannerRef, style: styles.scanner, detectionCountBeforeCapture: detectionThreshold, overlayColor: overlayColor, enableTorch: enableTorch, quality: normalizedQuality, useBase64: useBase64, manualOnly: false, detectionConfig: detectionConfig, onPictureTaken: handlePictureTaken, onError: handleError, onRectangleDetect: handleRectangleDetect }),
708
- showGrid && overlayPolygon && (react_1.default.createElement(overlay_1.ScannerOverlay, { active: overlayIsActive, color: gridColor ?? overlayColor, lineWidth: gridLineWidth, polygon: overlayPolygon, clipRect: detectedRectangle?.previewViewport ?? null })),
719
+ showGrid && overlayPolygon && (react_1.default.createElement(overlay_1.ScannerOverlay, { active: overlayIsActive, color: gridColor ?? overlayColor, lineWidth: gridLineWidth, polygon: overlayPolygon, clipRect: react_native_1.Platform.OS === 'android' ? null : (detectedRectangle?.previewViewport ?? null) })),
709
720
  showManualCaptureButton && (react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.button, onPress: handleManualCapture })),
710
721
  children));
711
722
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "11.0.0",
3
+ "version": "11.2.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -188,15 +188,30 @@ const mapRectangleToView = (
188
188
  ): Rectangle => {
189
189
  const viewWidthPx = viewWidth * density;
190
190
  const viewHeightPx = viewHeight * density;
191
- const scale = Math.max(viewWidthPx / imageWidth, viewHeightPx / imageHeight);
191
+ const scale =
192
+ Platform.OS === 'ios'
193
+ ? Math.max(viewWidthPx / imageWidth, viewHeightPx / imageHeight)
194
+ : Math.min(viewWidthPx / imageWidth, viewHeightPx / imageHeight);
192
195
  const scaledImageWidth = imageWidth * scale;
193
196
  const scaledImageHeight = imageHeight * scale;
194
- const offsetX = (scaledImageWidth - viewWidthPx) / 2;
195
- const offsetY = (scaledImageHeight - viewHeightPx) / 2;
197
+ const offsetX =
198
+ Platform.OS === 'ios'
199
+ ? (scaledImageWidth - viewWidthPx) / 2
200
+ : (viewWidthPx - scaledImageWidth) / 2;
201
+ const offsetY =
202
+ Platform.OS === 'ios'
203
+ ? (scaledImageHeight - viewHeightPx) / 2
204
+ : (viewHeightPx - scaledImageHeight) / 2;
196
205
 
197
206
  const mapPoint = (point: Point): Point => ({
198
- x: (point.x * scale - offsetX) / density,
199
- y: (point.y * scale - offsetY) / density,
207
+ x:
208
+ Platform.OS === 'ios'
209
+ ? (point.x * scale - offsetX) / density
210
+ : (point.x * scale + offsetX) / density,
211
+ y:
212
+ Platform.OS === 'ios'
213
+ ? (point.y * scale - offsetY) / density
214
+ : (point.y * scale + offsetY) / density,
200
215
  });
201
216
 
202
217
  return {
@@ -601,7 +616,7 @@ const VisionCameraScanner = forwardRef<DocScannerHandle, Props>(
601
616
  color={gridColor ?? overlayColor}
602
617
  lineWidth={gridLineWidth}
603
618
  polygon={overlayPolygon}
604
- clipRect={detectedRectangle?.previewViewport ?? null}
619
+ clipRect={Platform.OS === 'android' ? null : (detectedRectangle?.previewViewport ?? null)}
605
620
  />
606
621
  )}
607
622
  {showManualCaptureButton && (
@@ -881,6 +896,7 @@ const NativeScanner = forwardRef<DocScannerHandle, Props>(
881
896
 
882
897
  if (
883
898
  Platform.OS === 'android' &&
899
+ !rectangleOnScreen &&
884
900
  rectangleCoordinates &&
885
901
  event.imageSize &&
886
902
  event.previewSize &&
@@ -1002,7 +1018,7 @@ const NativeScanner = forwardRef<DocScannerHandle, Props>(
1002
1018
  color={gridColor ?? overlayColor}
1003
1019
  lineWidth={gridLineWidth}
1004
1020
  polygon={overlayPolygon}
1005
- clipRect={detectedRectangle?.previewViewport ?? null}
1021
+ clipRect={Platform.OS === 'android' ? null : (detectedRectangle?.previewViewport ?? null)}
1006
1022
  />
1007
1023
  )}
1008
1024
  {showManualCaptureButton && (