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.
@@ -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.66.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