react-native-rectangle-doc-scanner 4.16.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.
- package/android/build.gradle +9 -0
- package/android/src/main/kotlin/com/reactnativerectangledocscanner/DocumentScannerModule.kt +181 -0
- package/android/src/main/kotlin/com/reactnativerectangledocscanner/DocumentScannerPackage.kt +20 -0
- package/android/src/visioncamera/kotlin/com/reactnativerectangledocscanner/DocumentScannerFrameProcessorPlugin.kt +44 -0
- package/android/src/visioncamera/kotlin/com/reactnativerectangledocscanner/VisionCameraFrameProcessorRegistry.kt +18 -0
- package/dist/DocScanner.js +356 -0
- package/package.json +1 -1
- package/src/DocScanner.tsx +520 -0
package/android/build.gradle
CHANGED
|
@@ -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
|
}
|
|
@@ -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
|
}
|
package/android/src/main/kotlin/com/reactnativerectangledocscanner/DocumentScannerPackage.kt
CHANGED
|
@@ -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
|
+
}
|
package/dist/DocScanner.js
CHANGED
|
@@ -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
package/src/DocScanner.tsx
CHANGED
|
@@ -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;
|