react-native-rectangle-doc-scanner 0.66.0 → 0.70.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/README.md +97 -168
- package/android/build.gradle +55 -0
- package/android/consumer-rules.pro +1 -0
- package/android/proguard-rules.pro +1 -0
- package/android/src/main/AndroidManifest.xml +11 -0
- package/android/src/main/java/com/reactnativerectangledocscanner/RNRDocScannerModule.kt +37 -0
- package/android/src/main/java/com/reactnativerectangledocscanner/RNRDocScannerPackage.kt +16 -0
- package/android/src/main/java/com/reactnativerectangledocscanner/RNRDocScannerView.kt +536 -0
- package/android/src/main/java/com/reactnativerectangledocscanner/RNRDocScannerViewManager.kt +50 -0
- package/dist/DocScanner.d.ts +12 -7
- package/dist/DocScanner.js +97 -42
- package/dist/FullDocScanner.d.ts +3 -0
- package/dist/FullDocScanner.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/utils/overlay.js +77 -48
- package/docs/native-module-architecture.md +178 -0
- package/ios/RNRDocScannerModule.swift +49 -0
- package/ios/RNRDocScannerView.swift +477 -0
- package/ios/RNRDocScannerViewManager.m +21 -0
- package/ios/RNRDocScannerViewManager.swift +47 -0
- package/package.json +6 -5
- package/react-native-rectangle-doc-scanner.podspec +22 -0
- package/src/DocScanner.tsx +153 -76
- package/src/FullDocScanner.tsx +10 -0
- package/src/external.d.ts +12 -45
- package/src/index.ts +1 -1
- package/src/utils/overlay.tsx +83 -54
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import CoreImage
|
|
3
|
+
import Foundation
|
|
4
|
+
import React
|
|
5
|
+
import UIKit
|
|
6
|
+
import Vision
|
|
7
|
+
|
|
8
|
+
@objc(RNRDocScannerView)
|
|
9
|
+
class RNRDocScannerView: UIView, AVCaptureVideoDataOutputSampleBufferDelegate, AVCapturePhotoCaptureDelegate {
|
|
10
|
+
@objc var detectionCountBeforeCapture: NSNumber = 8
|
|
11
|
+
@objc var autoCapture: Bool = true
|
|
12
|
+
@objc var enableTorch: Bool = false {
|
|
13
|
+
didSet {
|
|
14
|
+
updateTorchMode()
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
@objc var quality: NSNumber = 90
|
|
18
|
+
@objc var useBase64: Bool = false
|
|
19
|
+
|
|
20
|
+
@objc var onRectangleDetect: RCTDirectEventBlock?
|
|
21
|
+
@objc var onPictureTaken: RCTDirectEventBlock?
|
|
22
|
+
|
|
23
|
+
private let session = AVCaptureSession()
|
|
24
|
+
private let sessionQueue = DispatchQueue(label: "com.reactnative.rectangledocscanner.session")
|
|
25
|
+
private let analysisQueue = DispatchQueue(label: "com.reactnative.rectangledocscanner.analysis")
|
|
26
|
+
private let ciContext = CIContext()
|
|
27
|
+
|
|
28
|
+
private var previewLayer: AVCaptureVideoPreviewLayer?
|
|
29
|
+
private let videoOutput = AVCaptureVideoDataOutput()
|
|
30
|
+
private let photoOutput = AVCapturePhotoOutput()
|
|
31
|
+
|
|
32
|
+
private var currentStableCounter: Int = 0
|
|
33
|
+
private var isProcessingFrame = false
|
|
34
|
+
private var isCaptureInFlight = false
|
|
35
|
+
private var lastObservation: VNRectangleObservation?
|
|
36
|
+
private var lastFrameSize: CGSize = .zero
|
|
37
|
+
private var photoCaptureCompletion: ((Result<RNRDocScannerCaptureResult, Error>) -> Void)?
|
|
38
|
+
|
|
39
|
+
override init(frame: CGRect) {
|
|
40
|
+
super.init(frame: frame)
|
|
41
|
+
commonInit()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
required init?(coder: NSCoder) {
|
|
45
|
+
super.init(coder: coder)
|
|
46
|
+
commonInit()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private func commonInit() {
|
|
50
|
+
backgroundColor = .black
|
|
51
|
+
configurePreviewLayer()
|
|
52
|
+
configureSession()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private func configurePreviewLayer() {
|
|
56
|
+
let layer = AVCaptureVideoPreviewLayer(session: session)
|
|
57
|
+
layer.videoGravity = .resizeAspectFill
|
|
58
|
+
self.layer.insertSublayer(layer, at: 0)
|
|
59
|
+
previewLayer = layer
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private func configureSession() {
|
|
63
|
+
sessionQueue.async { [weak self] in
|
|
64
|
+
guard let self else { return }
|
|
65
|
+
|
|
66
|
+
session.beginConfiguration()
|
|
67
|
+
session.sessionPreset = .photo
|
|
68
|
+
|
|
69
|
+
defer {
|
|
70
|
+
session.commitConfiguration()
|
|
71
|
+
if !session.isRunning {
|
|
72
|
+
session.startRunning()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
guard
|
|
77
|
+
let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
|
|
78
|
+
let videoInput = try? AVCaptureDeviceInput(device: videoDevice),
|
|
79
|
+
session.canAddInput(videoInput)
|
|
80
|
+
else {
|
|
81
|
+
NSLog("[RNRDocScanner] Unable to create AVCaptureDeviceInput")
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
session.addInput(videoInput)
|
|
86
|
+
|
|
87
|
+
if session.canAddOutput(photoOutput) {
|
|
88
|
+
photoOutput.isHighResolutionCaptureEnabled = true
|
|
89
|
+
session.addOutput(photoOutput)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
videoOutput.videoSettings = [
|
|
93
|
+
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
|
|
94
|
+
]
|
|
95
|
+
videoOutput.alwaysDiscardsLateVideoFrames = true
|
|
96
|
+
videoOutput.setSampleBufferDelegate(self, queue: analysisQueue)
|
|
97
|
+
|
|
98
|
+
if session.canAddOutput(videoOutput) {
|
|
99
|
+
session.addOutput(videoOutput)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
override func layoutSubviews() {
|
|
105
|
+
super.layoutSubviews()
|
|
106
|
+
previewLayer?.frame = bounds
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private func updateTorchMode() {
|
|
110
|
+
sessionQueue.async { [weak self] in
|
|
111
|
+
guard
|
|
112
|
+
let self,
|
|
113
|
+
let device = self.videoDevice(for: .back),
|
|
114
|
+
device.hasTorch
|
|
115
|
+
else {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
do {
|
|
120
|
+
try device.lockForConfiguration()
|
|
121
|
+
device.torchMode = self.enableTorch ? .on : .off
|
|
122
|
+
device.unlockForConfiguration()
|
|
123
|
+
} catch {
|
|
124
|
+
NSLog("[RNRDocScanner] Failed to update torch mode: \(error)")
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private func videoDevice(for position: AVCaptureDevice.Position) -> AVCaptureDevice? {
|
|
130
|
+
if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) {
|
|
131
|
+
return device
|
|
132
|
+
}
|
|
133
|
+
return AVCaptureDevice.devices(for: .video).first(where: { $0.position == position })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// MARK: - Detection
|
|
137
|
+
|
|
138
|
+
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
|
|
139
|
+
if isProcessingFrame {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
isProcessingFrame = true
|
|
147
|
+
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
|
|
148
|
+
let frameSize = CGSize(width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
|
149
|
+
lastFrameSize = frameSize
|
|
150
|
+
let orientation = currentExifOrientation()
|
|
151
|
+
|
|
152
|
+
defer {
|
|
153
|
+
CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly)
|
|
154
|
+
isProcessingFrame = false
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let request = VNDetectRectanglesRequest { [weak self] request, error in
|
|
158
|
+
guard let self else { return }
|
|
159
|
+
|
|
160
|
+
if let error {
|
|
161
|
+
NSLog("[RNRDocScanner] detection error: \(error)")
|
|
162
|
+
self.lastObservation = nil
|
|
163
|
+
self.handleDetectedRectangle(nil, frameSize: frameSize)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
guard let observation = (request.results as? [VNRectangleObservation])?.first else {
|
|
168
|
+
self.lastObservation = nil
|
|
169
|
+
self.handleDetectedRectangle(nil, frameSize: frameSize)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
self.lastObservation = observation
|
|
174
|
+
self.handleDetectedRectangle(observation, frameSize: frameSize)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
request.maximumObservations = 1
|
|
178
|
+
request.minimumConfidence = 0.6
|
|
179
|
+
request.minimumAspectRatio = 0.3
|
|
180
|
+
request.maximumAspectRatio = 1.0
|
|
181
|
+
request.minimumSize = 0.15
|
|
182
|
+
|
|
183
|
+
let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: orientation, options: [:])
|
|
184
|
+
do {
|
|
185
|
+
try handler.perform([request])
|
|
186
|
+
} catch {
|
|
187
|
+
NSLog("[RNRDocScanner] Failed to run Vision request: \(error)")
|
|
188
|
+
lastObservation = nil
|
|
189
|
+
handleDetectedRectangle(nil, frameSize: frameSize)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
func handleDetectedRectangle(_ rectangle: VNRectangleObservation?, frameSize: CGSize) {
|
|
194
|
+
guard let onRectangleDetect else { return }
|
|
195
|
+
|
|
196
|
+
let payload: [String: Any?]
|
|
197
|
+
if let rectangle {
|
|
198
|
+
let points = [
|
|
199
|
+
pointForOverlay(from: rectangle.topLeft, frameSize: frameSize),
|
|
200
|
+
pointForOverlay(from: rectangle.topRight, frameSize: frameSize),
|
|
201
|
+
pointForOverlay(from: rectangle.bottomRight, frameSize: frameSize),
|
|
202
|
+
pointForOverlay(from: rectangle.bottomLeft, frameSize: frameSize),
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
currentStableCounter = min(currentStableCounter + 1, Int(truncating: detectionCountBeforeCapture))
|
|
206
|
+
payload = [
|
|
207
|
+
"rectangleCoordinates": [
|
|
208
|
+
"topLeft": ["x": points[0].x, "y": points[0].y],
|
|
209
|
+
"topRight": ["x": points[1].x, "y": points[1].y],
|
|
210
|
+
"bottomRight": ["x": points[2].x, "y": points[2].y],
|
|
211
|
+
"bottomLeft": ["x": points[3].x, "y": points[3].y],
|
|
212
|
+
],
|
|
213
|
+
"stableCounter": currentStableCounter,
|
|
214
|
+
"frameWidth": frameSize.width,
|
|
215
|
+
"frameHeight": frameSize.height,
|
|
216
|
+
]
|
|
217
|
+
} else {
|
|
218
|
+
currentStableCounter = 0
|
|
219
|
+
payload = [
|
|
220
|
+
"rectangleCoordinates": NSNull(),
|
|
221
|
+
"stableCounter": currentStableCounter,
|
|
222
|
+
"frameWidth": frameSize.width,
|
|
223
|
+
"frameHeight": frameSize.height,
|
|
224
|
+
]
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
DispatchQueue.main.async {
|
|
228
|
+
onRectangleDetect(payload.compactMapValues { $0 })
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private func pointForOverlay(from normalizedPoint: CGPoint, frameSize: CGSize) -> CGPoint {
|
|
233
|
+
CGPoint(x: normalizedPoint.x * frameSize.width, y: (1 - normalizedPoint.y) * frameSize.height)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// MARK: - Capture
|
|
237
|
+
|
|
238
|
+
func capture(completion: @escaping (Result<RNRDocScannerCaptureResult, Error>) -> Void) {
|
|
239
|
+
sessionQueue.async { [weak self] in
|
|
240
|
+
guard let self else { return }
|
|
241
|
+
|
|
242
|
+
if isCaptureInFlight {
|
|
243
|
+
completion(.failure(RNRDocScannerError.captureInProgress))
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
guard photoOutput.connection(with: .video) != nil else {
|
|
248
|
+
completion(.failure(RNRDocScannerError.captureUnavailable))
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
isCaptureInFlight = true
|
|
253
|
+
photoCaptureCompletion = completion
|
|
254
|
+
|
|
255
|
+
let settings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
|
|
256
|
+
settings.isHighResolutionPhotoEnabled = photoOutput.isHighResolutionCaptureEnabled
|
|
257
|
+
if photoOutput.supportedFlashModes.contains(.on) {
|
|
258
|
+
settings.flashMode = enableTorch ? .on : .off
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
photoOutput.capturePhoto(with: settings, delegate: self)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
func resetStability() {
|
|
266
|
+
currentStableCounter = 0
|
|
267
|
+
lastObservation = nil
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// MARK: - AVCapturePhotoCaptureDelegate
|
|
271
|
+
|
|
272
|
+
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
|
|
273
|
+
guard let completion = photoCaptureCompletion else {
|
|
274
|
+
isCaptureInFlight = false
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if let error {
|
|
279
|
+
finishCapture(result: .failure(error))
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
guard let data = photo.fileDataRepresentation() else {
|
|
284
|
+
finishCapture(result: .failure(RNRDocScannerError.imageCreationFailed))
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let dimensions = photoDimensions(photo: photo)
|
|
289
|
+
do {
|
|
290
|
+
let original = try serializeImageData(data, suffix: "original")
|
|
291
|
+
let croppedString: String?
|
|
292
|
+
|
|
293
|
+
if let croppedData = generateCroppedImage(from: data) {
|
|
294
|
+
croppedString = try serializeImageData(croppedData, suffix: "cropped").string
|
|
295
|
+
} else {
|
|
296
|
+
croppedString = original.string
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let result = RNRDocScannerCaptureResult(
|
|
300
|
+
croppedImage: croppedString,
|
|
301
|
+
originalImage: original.string,
|
|
302
|
+
width: dimensions.width,
|
|
303
|
+
height: dimensions.height
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
finishCapture(result: .success(result))
|
|
307
|
+
} catch {
|
|
308
|
+
finishCapture(result: .failure(error))
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
func photoOutput(_ output: AVCapturePhotoOutput, didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, error: Error?) {
|
|
313
|
+
if let error, isCaptureInFlight {
|
|
314
|
+
finishCapture(result: .failure(error))
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private func finishCapture(result: Result<RNRDocScannerCaptureResult, Error>) {
|
|
319
|
+
let completion = photoCaptureCompletion
|
|
320
|
+
photoCaptureCompletion = nil
|
|
321
|
+
isCaptureInFlight = false
|
|
322
|
+
|
|
323
|
+
DispatchQueue.main.async {
|
|
324
|
+
switch result {
|
|
325
|
+
case let .success(payload):
|
|
326
|
+
completion?(.success(payload))
|
|
327
|
+
self.emitPictureTaken(payload)
|
|
328
|
+
case let .failure(error):
|
|
329
|
+
completion?(.failure(error))
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private func emitPictureTaken(_ result: RNRDocScannerCaptureResult) {
|
|
335
|
+
guard let onPictureTaken else { return }
|
|
336
|
+
let payload: [String: Any] = [
|
|
337
|
+
"croppedImage": result.croppedImage ?? NSNull(),
|
|
338
|
+
"initialImage": result.originalImage,
|
|
339
|
+
"width": result.width,
|
|
340
|
+
"height": result.height,
|
|
341
|
+
]
|
|
342
|
+
onPictureTaken(payload)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// MARK: - Helpers
|
|
346
|
+
|
|
347
|
+
private func currentExifOrientation() -> CGImagePropertyOrientation {
|
|
348
|
+
switch UIDevice.current.orientation {
|
|
349
|
+
case .landscapeLeft:
|
|
350
|
+
return .up
|
|
351
|
+
case .landscapeRight:
|
|
352
|
+
return .down
|
|
353
|
+
case .portraitUpsideDown:
|
|
354
|
+
return .left
|
|
355
|
+
default:
|
|
356
|
+
return .right
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private func photoDimensions(photo: AVCapturePhoto) -> CGSize {
|
|
361
|
+
if let pixelBuffer = photo.pixelBuffer {
|
|
362
|
+
return CGSize(width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let width = photo.metadata[kCGImagePropertyPixelWidth as String] as? Int ?? Int(lastFrameSize.width)
|
|
366
|
+
let height = photo.metadata[kCGImagePropertyPixelHeight as String] as? Int ?? Int(lastFrameSize.height)
|
|
367
|
+
return CGSize(width: CGFloat(width), height: CGFloat(height))
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private func serializeImageData(_ data: Data, suffix: String) throws -> (string: String, url: URL?) {
|
|
371
|
+
let filename = "docscan-\(UUID().uuidString)-\(suffix).jpg"
|
|
372
|
+
let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
|
|
373
|
+
do {
|
|
374
|
+
try data.write(to: url, options: .atomic)
|
|
375
|
+
} catch {
|
|
376
|
+
throw RNRDocScannerError.fileWriteFailed
|
|
377
|
+
}
|
|
378
|
+
return (url.absoluteString, url)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private func generateCroppedImage(from data: Data) -> Data? {
|
|
382
|
+
guard let ciImage = CIImage(data: data) else {
|
|
383
|
+
return nil
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
var observation: VNRectangleObservation? = nil
|
|
387
|
+
let request = VNDetectRectanglesRequest { request, _ in
|
|
388
|
+
observation = (request.results as? [VNRectangleObservation])?.first
|
|
389
|
+
}
|
|
390
|
+
request.maximumObservations = 1
|
|
391
|
+
request.minimumConfidence = 0.6
|
|
392
|
+
|
|
393
|
+
let handler = VNImageRequestHandler(ciImage: ciImage, options: [:])
|
|
394
|
+
try? handler.perform([request])
|
|
395
|
+
|
|
396
|
+
guard let targetObservation = observation ?? lastObservation else {
|
|
397
|
+
return nil
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let size = ciImage.extent.size
|
|
401
|
+
let topLeft = normalizedPoint(targetObservation.topLeft, in: size, flipY: false)
|
|
402
|
+
let topRight = normalizedPoint(targetObservation.topRight, in: size, flipY: false)
|
|
403
|
+
let bottomLeft = normalizedPoint(targetObservation.bottomLeft, in: size, flipY: false)
|
|
404
|
+
let bottomRight = normalizedPoint(targetObservation.bottomRight, in: size, flipY: false)
|
|
405
|
+
|
|
406
|
+
guard let filter = CIFilter(name: "CIPerspectiveCorrection") else {
|
|
407
|
+
return nil
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
filter.setValue(ciImage, forKey: kCIInputImageKey)
|
|
411
|
+
filter.setValue(CIVector(cgPoint: topLeft), forKey: "inputTopLeft")
|
|
412
|
+
filter.setValue(CIVector(cgPoint: topRight), forKey: "inputTopRight")
|
|
413
|
+
filter.setValue(CIVector(cgPoint: bottomLeft), forKey: "inputBottomLeft")
|
|
414
|
+
filter.setValue(CIVector(cgPoint: bottomRight), forKey: "inputBottomRight")
|
|
415
|
+
|
|
416
|
+
guard let corrected = filter.outputImage else {
|
|
417
|
+
return nil
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
guard let cgImage = ciContext.createCGImage(corrected, from: corrected.extent) else {
|
|
421
|
+
return nil
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
let cropped = UIImage(cgImage: cgImage)
|
|
425
|
+
return cropped.jpegData(compressionQuality: CGFloat(max(0.05, min(1.0, quality.doubleValue / 100.0))))
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private func normalizedPoint(_ point: CGPoint, in size: CGSize, flipY: Bool) -> CGPoint {
|
|
429
|
+
let yValue = flipY ? (1 - point.y) : point.y
|
|
430
|
+
return CGPoint(x: point.x * size.width, y: yValue * size.height)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
struct RNRDocScannerCaptureResult {
|
|
435
|
+
let croppedImage: String?
|
|
436
|
+
let originalImage: String
|
|
437
|
+
let width: CGFloat
|
|
438
|
+
let height: CGFloat
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
enum RNRDocScannerError: Error {
|
|
442
|
+
case captureInProgress
|
|
443
|
+
case captureUnavailable
|
|
444
|
+
case imageCreationFailed
|
|
445
|
+
case fileWriteFailed
|
|
446
|
+
case viewNotFound
|
|
447
|
+
|
|
448
|
+
var code: String {
|
|
449
|
+
switch self {
|
|
450
|
+
case .captureInProgress:
|
|
451
|
+
return "capture_in_progress"
|
|
452
|
+
case .captureUnavailable:
|
|
453
|
+
return "capture_unavailable"
|
|
454
|
+
case .imageCreationFailed:
|
|
455
|
+
return "image_creation_failed"
|
|
456
|
+
case .fileWriteFailed:
|
|
457
|
+
return "file_write_failed"
|
|
458
|
+
case .viewNotFound:
|
|
459
|
+
return "view_not_found"
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
var message: String {
|
|
464
|
+
switch self {
|
|
465
|
+
case .captureInProgress:
|
|
466
|
+
return "A capture request is already in flight."
|
|
467
|
+
case .captureUnavailable:
|
|
468
|
+
return "Photo output is not configured yet."
|
|
469
|
+
case .imageCreationFailed:
|
|
470
|
+
return "Unable to create image data from capture."
|
|
471
|
+
case .fileWriteFailed:
|
|
472
|
+
return "Failed to persist captured image to disk."
|
|
473
|
+
case .viewNotFound:
|
|
474
|
+
return "Unable to locate the native DocScanner view."
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#import <React/RCTBridge.h>
|
|
2
|
+
#import <React/RCTUIManager.h>
|
|
3
|
+
#import <React/RCTViewManager.h>
|
|
4
|
+
|
|
5
|
+
#import "react-native-rectangle-doc-scanner-Swift.h"
|
|
6
|
+
|
|
7
|
+
@interface RCT_EXTERN_MODULE(RNRDocScannerViewManager, RCTViewManager)
|
|
8
|
+
RCT_EXPORT_VIEW_PROPERTY(detectionCountBeforeCapture, NSNumber)
|
|
9
|
+
RCT_EXPORT_VIEW_PROPERTY(autoCapture, BOOL)
|
|
10
|
+
RCT_EXPORT_VIEW_PROPERTY(enableTorch, BOOL)
|
|
11
|
+
RCT_EXPORT_VIEW_PROPERTY(quality, NSNumber)
|
|
12
|
+
RCT_EXPORT_VIEW_PROPERTY(useBase64, BOOL)
|
|
13
|
+
RCT_EXPORT_VIEW_PROPERTY(onRectangleDetect, RCTDirectEventBlock)
|
|
14
|
+
RCT_EXPORT_VIEW_PROPERTY(onPictureTaken, RCTDirectEventBlock)
|
|
15
|
+
|
|
16
|
+
RCT_EXTERN_METHOD(capture:(nonnull NSNumber *)reactTag
|
|
17
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
18
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
19
|
+
|
|
20
|
+
RCT_EXTERN_METHOD(reset:(nonnull NSNumber *)reactTag)
|
|
21
|
+
@end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import React
|
|
3
|
+
|
|
4
|
+
@objc(RNRDocScannerViewManager)
|
|
5
|
+
class RNRDocScannerViewManager: RCTViewManager {
|
|
6
|
+
override static func requiresMainQueueSetup() -> Bool {
|
|
7
|
+
true
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
override func view() -> UIView! {
|
|
11
|
+
RNRDocScannerView()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@objc func capture(_ reactTag: NSNumber, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
|
|
15
|
+
bridge.uiManager.addUIBlock { _, viewRegistry in
|
|
16
|
+
guard let view = viewRegistry?[reactTag] as? RNRDocScannerView else {
|
|
17
|
+
reject(RNRDocScannerError.viewNotFound.code, RNRDocScannerError.viewNotFound.message, nil)
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
view.capture { result in
|
|
22
|
+
switch result {
|
|
23
|
+
case let .success(payload):
|
|
24
|
+
resolve([
|
|
25
|
+
"croppedImage": payload.croppedImage ?? NSNull(),
|
|
26
|
+
"initialImage": payload.originalImage,
|
|
27
|
+
"width": payload.width,
|
|
28
|
+
"height": payload.height,
|
|
29
|
+
])
|
|
30
|
+
case let .failure(error as RNRDocScannerError):
|
|
31
|
+
reject(error.code, error.message, error)
|
|
32
|
+
case let .failure(error):
|
|
33
|
+
reject("capture_failed", error.localizedDescription, error)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@objc func reset(_ reactTag: NSNumber) {
|
|
40
|
+
bridge.uiManager.addUIBlock { _, viewRegistry in
|
|
41
|
+
guard let view = viewRegistry?[reactTag] as? RNRDocScannerView else {
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
view.resetStability()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-rectangle-doc-scanner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.70.0",
|
|
4
|
+
"description": "Native-backed document scanner for React Native with customizable overlays.",
|
|
5
|
+
"license": "MIT",
|
|
4
6
|
"main": "dist/index.js",
|
|
5
7
|
"types": "dist/index.d.ts",
|
|
6
8
|
"repository": {
|
|
@@ -18,14 +20,13 @@
|
|
|
18
20
|
"peerDependencies": {
|
|
19
21
|
"@shopify/react-native-skia": "*",
|
|
20
22
|
"react": "*",
|
|
21
|
-
"react-native": "*"
|
|
23
|
+
"react-native": "*",
|
|
24
|
+
"react-native-perspective-image-cropper": "*"
|
|
22
25
|
},
|
|
23
26
|
"devDependencies": {
|
|
24
27
|
"@types/react": "^18.2.41",
|
|
25
28
|
"@types/react-native": "0.73.0",
|
|
26
29
|
"typescript": "^5.3.3"
|
|
27
30
|
},
|
|
28
|
-
"dependencies": {
|
|
29
|
-
"react-native-document-scanner-plugin": "^1.0.1"
|
|
30
|
-
}
|
|
31
|
+
"dependencies": {}
|
|
31
32
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = 'react-native-rectangle-doc-scanner'
|
|
7
|
+
s.version = package['version']
|
|
8
|
+
s.summary = package.fetch('description', 'Document scanner with native camera overlay support for React Native.')
|
|
9
|
+
s.homepage = package['homepage'] || 'https://github.com/danchew90/react-native-rectangle-doc-scanner'
|
|
10
|
+
s.license = package['license'] || { :type => 'MIT' }
|
|
11
|
+
s.author = package['author'] || { 'react-native-rectangle-doc-scanner' => 'opensource@example.com' }
|
|
12
|
+
s.source = { :git => package.dig('repository', 'url') || s.homepage, :tag => "v#{s.version}" }
|
|
13
|
+
|
|
14
|
+
s.platform = :ios, '13.0'
|
|
15
|
+
s.swift_version = '5.0'
|
|
16
|
+
|
|
17
|
+
s.source_files = 'ios/**/*.{h,m,mm,swift}'
|
|
18
|
+
s.public_header_files = 'ios/**/*.h'
|
|
19
|
+
s.requires_arc = true
|
|
20
|
+
|
|
21
|
+
s.dependency 'React-Core'
|
|
22
|
+
end
|