react-native-rectangle-doc-scanner 7.54.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
@@ -636,9 +661,14 @@ class CameraController(
636
661
  if (viewWidth == 0f || viewHeight == 0f) return
637
662
 
638
663
  val rotationDegrees = computeRotationDegrees()
664
+ val transformRotation = if (useFrontCamera) {
665
+ rotationDegrees
666
+ } else {
667
+ (360 - rotationDegrees) % 360
668
+ }
639
669
  Log.d(
640
670
  TAG,
641
- "[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}"
642
672
  )
643
673
 
644
674
  val matrix = Matrix()
@@ -646,7 +676,7 @@ class CameraController(
646
676
  val centerX = viewRect.centerX()
647
677
  val centerY = viewRect.centerY()
648
678
 
649
- val isSwapped = rotationDegrees == 90 || rotationDegrees == 270
679
+ val isSwapped = transformRotation == 90 || transformRotation == 270
650
680
  val bufferWidth = if (isSwapped) preview.height.toFloat() else preview.width.toFloat()
651
681
  val bufferHeight = if (isSwapped) preview.width.toFloat() else preview.height.toFloat()
652
682
  val bufferRect = RectF(0f, 0f, bufferWidth, bufferHeight)
@@ -655,14 +685,15 @@ class CameraController(
655
685
  matrix.setRectToRect(viewRect, bufferRect, Matrix.ScaleToFit.FILL)
656
686
  val scale = max(viewWidth / bufferWidth, viewHeight / bufferHeight)
657
687
  matrix.postScale(scale, scale, centerX, centerY)
658
- if (rotationDegrees != 0) {
659
- matrix.postRotate(rotationDegrees.toFloat(), centerX, centerY)
688
+ if (transformRotation != 0) {
689
+ matrix.postRotate(transformRotation.toFloat(), centerX, centerY)
660
690
  }
661
691
 
662
692
  previewView.setTransform(matrix)
663
693
  latestTransform = Matrix(matrix)
664
694
  latestBufferWidth = preview.width
665
695
  latestBufferHeight = preview.height
696
+ latestTransformRotation = transformRotation
666
697
 
667
698
  val pts = floatArrayOf(
668
699
  0f, 0f,
@@ -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.54.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,