react-native-rectangle-doc-scanner 7.53.0 → 7.57.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.
@@ -70,6 +70,7 @@ class CameraController(
70
70
  private var latestTransform: Matrix? = null
71
71
  private var latestBufferWidth = 0
72
72
  private var latestBufferHeight = 0
73
+ private var latestTransformRotation = 0
73
74
  private val objectDetector = ObjectDetection.getClient(
74
75
  ObjectDetectorOptions.Builder()
75
76
  .setDetectorMode(ObjectDetectorOptions.STREAM_MODE)
@@ -216,7 +217,7 @@ class CameraController(
216
217
  if (rectangle == null || imageWidth <= 0 || imageHeight <= 0) return null
217
218
  if (latestBufferWidth <= 0 || latestBufferHeight <= 0) return null
218
219
 
219
- val rotationDegrees = computeRotationDegrees()
220
+ val rotationDegrees = latestTransformRotation
220
221
  val inverseRotation = (360 - rotationDegrees) % 360
221
222
 
222
223
  fun rotatePoint(point: Point): Point {
@@ -276,6 +277,30 @@ class CameraController(
276
277
  )
277
278
  }
278
279
 
280
+ fun getPreviewViewport(): RectF? {
281
+ val transform = latestTransform ?: return null
282
+ if (latestBufferWidth <= 0 || latestBufferHeight <= 0) return null
283
+ val rotation = latestTransformRotation
284
+ val isSwapped = rotation == 90 || rotation == 270
285
+ val bufferWidth = if (isSwapped) latestBufferHeight.toFloat() else latestBufferWidth.toFloat()
286
+ val bufferHeight = if (isSwapped) latestBufferWidth.toFloat() else latestBufferHeight.toFloat()
287
+
288
+ val pts = floatArrayOf(
289
+ 0f, 0f,
290
+ bufferWidth, 0f,
291
+ 0f, bufferHeight,
292
+ bufferWidth, bufferHeight
293
+ )
294
+ transform.mapPoints(pts)
295
+
296
+ val minX = min(min(pts[0], pts[2]), min(pts[4], pts[6]))
297
+ val maxX = max(max(pts[0], pts[2]), max(pts[4], pts[6]))
298
+ val minY = min(min(pts[1], pts[3]), min(pts[5], pts[7]))
299
+ val maxY = max(max(pts[1], pts[3]), max(pts[5], pts[7]))
300
+
301
+ return RectF(minX, minY, maxX, maxY)
302
+ }
303
+
279
304
  private fun openCamera() {
280
305
  if (cameraDevice != null) {
281
306
  return
@@ -330,6 +355,13 @@ class CameraController(
330
355
  captureSize = chooseBestSize(captureSizes, previewAspect, null, preferClosestAspect = true)
331
356
  ?: captureSizes?.maxByOrNull { it.width * it.height }
332
357
 
358
+ val previewDiff = previewSize?.let { abs(it.width.toDouble() / it.height.toDouble() - targetPreviewAspect) }
359
+ Log.d(
360
+ TAG,
361
+ "[SIZE_SELECTION] targetAspect=$targetPreviewAspect viewAspect=$viewAspect " +
362
+ "previewAspect=$previewAspect diff=$previewDiff selected=${previewSize?.width}x${previewSize?.height}"
363
+ )
364
+
333
365
  setupImageReaders()
334
366
  Log.d(
335
367
  TAG,
@@ -429,6 +461,7 @@ class CameraController(
429
461
  val preview = previewSize ?: return
430
462
 
431
463
  surfaceTexture.setDefaultBufferSize(preview.width, preview.height)
464
+ Log.d(TAG, "[CAMERA2] SurfaceTexture defaultBufferSize=${preview.width}x${preview.height}")
432
465
  val previewSurface = Surface(surfaceTexture)
433
466
 
434
467
  val targets = mutableListOf(previewSurface)
@@ -628,9 +661,14 @@ class CameraController(
628
661
  if (viewWidth == 0f || viewHeight == 0f) return
629
662
 
630
663
  val rotationDegrees = computeRotationDegrees()
664
+ val transformRotation = if (useFrontCamera) {
665
+ rotationDegrees
666
+ } else {
667
+ (360 - rotationDegrees) % 360
668
+ }
631
669
  Log.d(
632
670
  TAG,
633
- "[TRANSFORM] rotation=$rotationDegrees view=${viewWidth}x${viewHeight} preview=${preview.width}x${preview.height}"
671
+ "[TRANSFORM] rotation=$transformRotation view=${viewWidth}x${viewHeight} preview=${preview.width}x${preview.height}"
634
672
  )
635
673
 
636
674
  val matrix = Matrix()
@@ -638,29 +676,37 @@ class CameraController(
638
676
  val centerX = viewRect.centerX()
639
677
  val centerY = viewRect.centerY()
640
678
 
641
- val bufferWidth = preview.width.toFloat()
642
- val bufferHeight = preview.height.toFloat()
679
+ val isSwapped = transformRotation == 90 || transformRotation == 270
680
+ val bufferWidth = if (isSwapped) preview.height.toFloat() else preview.width.toFloat()
681
+ val bufferHeight = if (isSwapped) preview.width.toFloat() else preview.height.toFloat()
643
682
  val bufferRect = RectF(0f, 0f, bufferWidth, bufferHeight)
644
- val rotatedRect = RectF(bufferRect)
683
+ bufferRect.offset(centerX - bufferRect.centerX(), centerY - bufferRect.centerY())
645
684
 
646
- if (rotationDegrees != 0) {
647
- matrix.postRotate(rotationDegrees.toFloat(), bufferRect.centerX(), bufferRect.centerY())
648
- matrix.mapRect(rotatedRect, bufferRect)
685
+ matrix.setRectToRect(viewRect, bufferRect, Matrix.ScaleToFit.FILL)
686
+ val scale = max(viewWidth / bufferWidth, viewHeight / bufferHeight)
687
+ matrix.postScale(scale, scale, centerX, centerY)
688
+ if (transformRotation != 0) {
689
+ matrix.postRotate(transformRotation.toFloat(), centerX, centerY)
649
690
  }
650
691
 
651
- val scale = max(viewWidth / rotatedRect.width(), viewHeight / rotatedRect.height())
652
- matrix.postScale(scale, scale, rotatedRect.centerX(), rotatedRect.centerY())
653
- matrix.postTranslate(centerX - rotatedRect.centerX(), centerY - rotatedRect.centerY())
654
-
655
692
  previewView.setTransform(matrix)
656
693
  latestTransform = Matrix(matrix)
657
694
  latestBufferWidth = preview.width
658
695
  latestBufferHeight = preview.height
696
+ latestTransformRotation = transformRotation
697
+
698
+ val pts = floatArrayOf(
699
+ 0f, 0f,
700
+ bufferWidth, 0f,
701
+ 0f, bufferHeight,
702
+ bufferWidth, bufferHeight
703
+ )
704
+ matrix.mapPoints(pts)
659
705
  Log.d(
660
706
  TAG,
661
707
  "[TRANSFORM] viewClass=${previewView.javaClass.name} isTextureView=${previewView is TextureView} " +
662
- "buffer=${preview.width}x${preview.height} rotated=${rotatedRect.width()}x${rotatedRect.height()} " +
663
- "scale=$scale center=${centerX}x${centerY} matrix=$matrix"
708
+ "buffer=${bufferWidth}x${bufferHeight} scale=$scale center=${centerX}x${centerY} matrix=$matrix " +
709
+ "pts=[${pts[0]},${pts[1]} ${pts[2]},${pts[3]} ${pts[4]},${pts[5]} ${pts[6]},${pts[7]}]"
664
710
  )
665
711
  Log.d(TAG, "[TRANSFORM] Matrix applied successfully")
666
712
  }
@@ -696,9 +742,7 @@ class CameraController(
696
742
  fun aspectDiff(size: Size): Double {
697
743
  val w = size.width.toDouble()
698
744
  val h = size.height.toDouble()
699
- val direct = abs(w / h - targetAspect)
700
- val inverted = abs(h / w - targetAspect)
701
- return min(direct, inverted)
745
+ return abs(w / h - targetAspect)
702
746
  }
703
747
 
704
748
  if (preferClosestAspect) {
@@ -470,6 +470,7 @@ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), L
470
470
  imageHeight: Int
471
471
  ) {
472
472
  val density = resources.displayMetrics.density.takeIf { it > 0f } ?: 1f
473
+ val previewViewport = cameraController?.getPreviewViewport()
473
474
  val event = Arguments.createMap().apply {
474
475
  putInt("stableCounter", stableCounter)
475
476
  putInt("lastDetectionType", quality.ordinal)
@@ -480,6 +481,14 @@ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), L
480
481
  putMap("bottomLeft", mapPointToDp(getMap("bottomLeft"), density))
481
482
  putMap("bottomRight", mapPointToDp(getMap("bottomRight"), density))
482
483
  })
484
+ previewViewport?.let {
485
+ putMap("previewViewport", Arguments.createMap().apply {
486
+ putDouble("left", it.left / density)
487
+ putDouble("top", it.top / density)
488
+ putDouble("width", it.width() / density)
489
+ putDouble("height", it.height() / density)
490
+ })
491
+ }
483
492
  putMap("previewSize", Arguments.createMap().apply {
484
493
  putInt("width", (width / density).toInt())
485
494
  putInt("height", (height / density).toInt())
@@ -12,6 +12,12 @@ type PictureEvent = {
12
12
  export type RectangleDetectEvent = Omit<RectangleEventPayload, 'rectangleCoordinates' | 'rectangleOnScreen'> & {
13
13
  rectangleCoordinates?: Rectangle | null;
14
14
  rectangleOnScreen?: Rectangle | null;
15
+ previewViewport?: {
16
+ left: number;
17
+ top: number;
18
+ width: number;
19
+ height: number;
20
+ };
15
21
  };
16
22
  export type DocScannerCapture = {
17
23
  path: string;
@@ -418,7 +418,7 @@ const VisionCameraScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor =
418
418
  const overlayIsActive = autoCapture ? isAutoCapturing : (detectedRectangle?.stableCounter ?? 0) > 0;
419
419
  return (react_1.default.createElement(react_native_1.View, { style: styles.container, onLayout: handleLayout },
420
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 })),
421
+ showGrid && overlayPolygon && (react_1.default.createElement(overlay_1.ScannerOverlay, { active: overlayIsActive, color: gridColor ?? overlayColor, lineWidth: gridLineWidth, polygon: overlayPolygon, clipRect: detectedRectangle?.previewViewport ?? null })),
422
422
  showManualCaptureButton && (react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.button, onPress: () => captureVision('manual') })),
423
423
  children));
424
424
  });
@@ -705,7 +705,7 @@ const NativeScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAU
705
705
  const detectionThreshold = autoCapture ? minStableFrames : 99999;
706
706
  return (react_1.default.createElement(react_native_1.View, { style: styles.container },
707
707
  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 })),
708
+ showGrid && overlayPolygon && (react_1.default.createElement(overlay_1.ScannerOverlay, { active: overlayIsActive, color: gridColor ?? overlayColor, lineWidth: gridLineWidth, polygon: overlayPolygon, clipRect: detectedRectangle?.previewViewport ?? null })),
709
709
  showManualCaptureButton && (react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.button, onPress: handleManualCapture })),
710
710
  children));
711
711
  });
@@ -5,5 +5,11 @@ export interface ScannerOverlayProps {
5
5
  color?: string;
6
6
  lineWidth?: number;
7
7
  polygon?: Rectangle | null;
8
+ clipRect?: {
9
+ left: number;
10
+ top: number;
11
+ width: number;
12
+ height: number;
13
+ } | null;
8
14
  }
9
15
  export declare const ScannerOverlay: React.FC<ScannerOverlayProps>;
@@ -78,22 +78,45 @@ const getBounds = (polygon) => {
78
78
  };
79
79
  };
80
80
  const ScannerOverlay = ({ active: _active, // kept for compatibility; no animation currently
81
- color = '#0b7ef4', lineWidth = react_native_1.StyleSheet.hairlineWidth, polygon, }) => {
82
- const points = (0, react_1.useMemo)(() => (polygon ? createPointsString(polygon) : null), [polygon]);
83
- const gridLines = (0, react_1.useMemo)(() => (polygon ? createGridLines(polygon) : []), [polygon]);
84
- const bounds = (0, react_1.useMemo)(() => (polygon ? getBounds(polygon) : null), [polygon]);
85
- if (!polygon || !points || !bounds) {
81
+ color = '#0b7ef4', lineWidth = react_native_1.StyleSheet.hairlineWidth, polygon, clipRect, }) => {
82
+ const offset = (0, react_1.useMemo)(() => (clipRect ? { x: -clipRect.left, y: -clipRect.top } : { x: 0, y: 0 }), [clipRect]);
83
+ const shiftedPolygon = (0, react_1.useMemo)(() => {
84
+ if (!polygon)
85
+ return null;
86
+ return {
87
+ topLeft: { x: polygon.topLeft.x + offset.x, y: polygon.topLeft.y + offset.y },
88
+ topRight: { x: polygon.topRight.x + offset.x, y: polygon.topRight.y + offset.y },
89
+ bottomRight: { x: polygon.bottomRight.x + offset.x, y: polygon.bottomRight.y + offset.y },
90
+ bottomLeft: { x: polygon.bottomLeft.x + offset.x, y: polygon.bottomLeft.y + offset.y },
91
+ };
92
+ }, [polygon, offset]);
93
+ const points = (0, react_1.useMemo)(() => (shiftedPolygon ? createPointsString(shiftedPolygon) : null), [shiftedPolygon]);
94
+ const gridLines = (0, react_1.useMemo)(() => (shiftedPolygon ? createGridLines(shiftedPolygon) : []), [shiftedPolygon]);
95
+ const bounds = (0, react_1.useMemo)(() => (shiftedPolygon ? getBounds(shiftedPolygon) : null), [shiftedPolygon]);
96
+ if (!shiftedPolygon || !points || !bounds) {
86
97
  return null;
87
98
  }
99
+ const containerStyle = clipRect
100
+ ? [
101
+ react_native_1.StyleSheet.absoluteFill,
102
+ {
103
+ left: clipRect.left,
104
+ top: clipRect.top,
105
+ width: clipRect.width,
106
+ height: clipRect.height,
107
+ overflow: 'hidden',
108
+ },
109
+ ]
110
+ : react_native_1.StyleSheet.absoluteFill;
88
111
  if (SvgModule) {
89
112
  const { default: Svg, Polygon, Line } = SvgModule;
90
- return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: react_native_1.StyleSheet.absoluteFill },
113
+ return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: containerStyle },
91
114
  react_1.default.createElement(Svg, { style: react_native_1.StyleSheet.absoluteFill },
92
115
  react_1.default.createElement(Polygon, { points: points, fill: color, opacity: 0.15 }),
93
116
  gridLines.map((line, index) => (react_1.default.createElement(Line, { key: `grid-${index}`, x1: line.x1, y1: line.y1, x2: line.x2, y2: line.y2, stroke: color, strokeWidth: lineWidth, opacity: 0.5 }))),
94
117
  react_1.default.createElement(Polygon, { points: points, stroke: color, strokeWidth: lineWidth, fill: "none" }))));
95
118
  }
96
- return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: react_native_1.StyleSheet.absoluteFill },
119
+ return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: containerStyle },
97
120
  react_1.default.createElement(react_native_1.View, { style: [
98
121
  styles.fallbackBox,
99
122
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "7.53.0",
3
+ "version": "7.57.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -35,6 +35,7 @@ type PictureEvent = {
35
35
  export type RectangleDetectEvent = Omit<RectangleEventPayload, 'rectangleCoordinates' | 'rectangleOnScreen'> & {
36
36
  rectangleCoordinates?: Rectangle | null;
37
37
  rectangleOnScreen?: Rectangle | null;
38
+ previewViewport?: { left: number; top: number; width: number; height: number };
38
39
  };
39
40
 
40
41
  export type DocScannerCapture = {
@@ -600,6 +601,7 @@ const VisionCameraScanner = forwardRef<DocScannerHandle, Props>(
600
601
  color={gridColor ?? overlayColor}
601
602
  lineWidth={gridLineWidth}
602
603
  polygon={overlayPolygon}
604
+ clipRect={detectedRectangle?.previewViewport ?? null}
603
605
  />
604
606
  )}
605
607
  {showManualCaptureButton && (
@@ -1000,6 +1002,7 @@ const NativeScanner = forwardRef<DocScannerHandle, Props>(
1000
1002
  color={gridColor ?? overlayColor}
1001
1003
  lineWidth={gridLineWidth}
1002
1004
  polygon={overlayPolygon}
1005
+ clipRect={detectedRectangle?.previewViewport ?? null}
1003
1006
  />
1004
1007
  )}
1005
1008
  {showManualCaptureButton && (
package/src/external.d.ts CHANGED
@@ -46,6 +46,7 @@ declare module 'react-native-document-scanner' {
46
46
  lastDetectionType: number;
47
47
  rectangleCoordinates?: Rectangle | null;
48
48
  rectangleOnScreen?: Rectangle | null;
49
+ previewViewport?: { left: number; top: number; width: number; height: number };
49
50
  previewSize?: { width: number; height: number };
50
51
  imageSize?: { width: number; height: number };
51
52
  };
@@ -1,5 +1,6 @@
1
1
  import React, { useMemo } from 'react';
2
2
  import { StyleSheet, View } from 'react-native';
3
+ import type { StyleProp, ViewStyle } from 'react-native';
3
4
  import type { Rectangle } from '../types';
4
5
 
5
6
  let SvgModule: typeof import('react-native-svg') | null = null;
@@ -78,6 +79,7 @@ export interface ScannerOverlayProps {
78
79
  color?: string;
79
80
  lineWidth?: number;
80
81
  polygon?: Rectangle | null;
82
+ clipRect?: { left: number; top: number; width: number; height: number } | null;
81
83
  }
82
84
 
83
85
  export const ScannerOverlay: React.FC<ScannerOverlayProps> = ({
@@ -85,20 +87,47 @@ export const ScannerOverlay: React.FC<ScannerOverlayProps> = ({
85
87
  color = '#0b7ef4',
86
88
  lineWidth = StyleSheet.hairlineWidth,
87
89
  polygon,
90
+ clipRect,
88
91
  }) => {
89
- const points = useMemo(() => (polygon ? createPointsString(polygon) : null), [polygon]);
90
- const gridLines = useMemo(() => (polygon ? createGridLines(polygon) : []), [polygon]);
91
- const bounds = useMemo(() => (polygon ? getBounds(polygon) : null), [polygon]);
92
-
93
- if (!polygon || !points || !bounds) {
92
+ const offset = useMemo(
93
+ () => (clipRect ? { x: -clipRect.left, y: -clipRect.top } : { x: 0, y: 0 }),
94
+ [clipRect],
95
+ );
96
+ const shiftedPolygon = useMemo(() => {
97
+ if (!polygon) return null;
98
+ return {
99
+ topLeft: { x: polygon.topLeft.x + offset.x, y: polygon.topLeft.y + offset.y },
100
+ topRight: { x: polygon.topRight.x + offset.x, y: polygon.topRight.y + offset.y },
101
+ bottomRight: { x: polygon.bottomRight.x + offset.x, y: polygon.bottomRight.y + offset.y },
102
+ bottomLeft: { x: polygon.bottomLeft.x + offset.x, y: polygon.bottomLeft.y + offset.y },
103
+ };
104
+ }, [polygon, offset]);
105
+ const points = useMemo(() => (shiftedPolygon ? createPointsString(shiftedPolygon) : null), [shiftedPolygon]);
106
+ const gridLines = useMemo(() => (shiftedPolygon ? createGridLines(shiftedPolygon) : []), [shiftedPolygon]);
107
+ const bounds = useMemo(() => (shiftedPolygon ? getBounds(shiftedPolygon) : null), [shiftedPolygon]);
108
+
109
+ if (!shiftedPolygon || !points || !bounds) {
94
110
  return null;
95
111
  }
96
112
 
113
+ const containerStyle: StyleProp<ViewStyle> = clipRect
114
+ ? [
115
+ StyleSheet.absoluteFill as ViewStyle,
116
+ {
117
+ left: clipRect.left,
118
+ top: clipRect.top,
119
+ width: clipRect.width,
120
+ height: clipRect.height,
121
+ overflow: 'hidden',
122
+ },
123
+ ]
124
+ : (StyleSheet.absoluteFill as ViewStyle);
125
+
97
126
  if (SvgModule) {
98
127
  const { default: Svg, Polygon, Line } = SvgModule;
99
128
 
100
129
  return (
101
- <View pointerEvents="none" style={StyleSheet.absoluteFill}>
130
+ <View pointerEvents="none" style={containerStyle}>
102
131
  <Svg style={StyleSheet.absoluteFill}>
103
132
  <Polygon points={points} fill={color} opacity={0.15} />
104
133
  {gridLines.map((line, index) => (
@@ -120,7 +149,7 @@ export const ScannerOverlay: React.FC<ScannerOverlayProps> = ({
120
149
  }
121
150
 
122
151
  return (
123
- <View pointerEvents="none" style={StyleSheet.absoluteFill}>
152
+ <View pointerEvents="none" style={containerStyle}>
124
153
  <View
125
154
  style={[
126
155
  styles.fallbackBox,