react-native-document-processor 0.0.0-stage → 0.1.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 +203 -2
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/vn/money24h/documentprocessor/BarcodeImageScanner.java +81 -0
- package/android/src/main/java/vn/money24h/documentprocessor/DocumentCapture.java +163 -0
- package/android/src/main/java/vn/money24h/documentprocessor/DocumentProcessorModule.java +345 -0
- package/android/src/main/java/vn/money24h/documentprocessor/DocumentProcessorPackage.java +20 -0
- package/android/src/main/java/vn/money24h/documentprocessor/DocumentScanLog.java +27 -0
- package/android/src/main/java/vn/money24h/documentprocessor/JpegPdfWriter.java +93 -0
- package/ios/DPDocumentCapture.h +9 -0
- package/ios/DPDocumentCapture.m +128 -0
- package/ios/DocumentProcessor.m +348 -0
- package/package.json +31 -4
- package/react-native-document-processor.podspec +18 -0
- package/src/index.ts +192 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
#import <React/RCTBridgeModule.h>
|
|
2
|
+
#import <React/RCTInvalidating.h>
|
|
3
|
+
#import <ImageIO/ImageIO.h>
|
|
4
|
+
#import <UIKit/UIKit.h>
|
|
5
|
+
#import <Vision/Vision.h>
|
|
6
|
+
#import "DPDocumentCapture.h"
|
|
7
|
+
|
|
8
|
+
static NSError *DPError(NSString *message) {
|
|
9
|
+
return [NSError errorWithDomain:@"DocumentProcessor" code:1
|
|
10
|
+
userInfo:@{NSLocalizedDescriptionKey: message}];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static UIImage *DPDecodeImage(NSString *uri) {
|
|
14
|
+
NSURL *url = [uri hasPrefix:@"/"] ? [NSURL fileURLWithPath:uri] : [NSURL URLWithString:uri];
|
|
15
|
+
if (!url.isFileURL) { return nil; }
|
|
16
|
+
CGImageSourceRef source = CGImageSourceCreateWithURL((__bridge CFURLRef)url, NULL);
|
|
17
|
+
NSDictionary *options = @{
|
|
18
|
+
(id)kCGImageSourceCreateThumbnailFromImageAlways: @YES,
|
|
19
|
+
(id)kCGImageSourceCreateThumbnailWithTransform: @YES,
|
|
20
|
+
(id)kCGImageSourceThumbnailMaxPixelSize: @4096,
|
|
21
|
+
(id)kCGImageSourceShouldCacheImmediately: @YES
|
|
22
|
+
};
|
|
23
|
+
// Bound memory and normalize EXIF, shared by image scanning and document pages.
|
|
24
|
+
CGImageRef bitmap = source ? CGImageSourceCreateThumbnailAtIndex(source, 0,
|
|
25
|
+
(__bridge CFDictionaryRef)options) : NULL;
|
|
26
|
+
if (source) { CFRelease(source); }
|
|
27
|
+
if (!bitmap) { return nil; }
|
|
28
|
+
UIImage *image = [UIImage imageWithCGImage:bitmap];
|
|
29
|
+
CGImageRelease(bitmap);
|
|
30
|
+
return image;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static NSDictionary<NSString *, NSArray<VNBarcodeSymbology> *> *DPBarcodeFormats(void) {
|
|
34
|
+
return @{
|
|
35
|
+
@"qr": @[VNBarcodeSymbologyQR],
|
|
36
|
+
@"ean-13": @[VNBarcodeSymbologyEAN13],
|
|
37
|
+
@"ean-8": @[VNBarcodeSymbologyEAN8],
|
|
38
|
+
// Vision reports UPC-A as EAN-13 with a leading zero.
|
|
39
|
+
@"upc-a": @[VNBarcodeSymbologyEAN13],
|
|
40
|
+
@"upc-e": @[VNBarcodeSymbologyUPCE],
|
|
41
|
+
@"code-128": @[VNBarcodeSymbologyCode128],
|
|
42
|
+
@"code-39": @[VNBarcodeSymbologyCode39, VNBarcodeSymbologyCode39Checksum,
|
|
43
|
+
VNBarcodeSymbologyCode39FullASCII, VNBarcodeSymbologyCode39FullASCIIChecksum],
|
|
44
|
+
@"code-93": @[VNBarcodeSymbologyCode93, VNBarcodeSymbologyCode93i],
|
|
45
|
+
@"codabar": @[VNBarcodeSymbologyCodabar],
|
|
46
|
+
@"itf": @[VNBarcodeSymbologyI2of5, VNBarcodeSymbologyI2of5Checksum, VNBarcodeSymbologyITF14],
|
|
47
|
+
@"pdf-417": @[VNBarcodeSymbologyPDF417],
|
|
48
|
+
@"aztec": @[VNBarcodeSymbologyAztec],
|
|
49
|
+
@"data-matrix": @[VNBarcodeSymbologyDataMatrix]
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static NSArray<VNBarcodeObservation *> *DPReadBarcodes(CGImageRef image,
|
|
54
|
+
NSArray<VNBarcodeSymbology> *symbologies, NSError **error) {
|
|
55
|
+
VNDetectBarcodesRequest *request = [VNDetectBarcodesRequest new];
|
|
56
|
+
request.symbologies = symbologies;
|
|
57
|
+
VNImageRequestHandler *handler = [[VNImageRequestHandler alloc] initWithCGImage:image options:@{}];
|
|
58
|
+
return [handler performRequests:@[request] error:error] ? (request.results ?: @[]) : nil;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static BOOL DPHasDocument(NSArray<NSString *> *values) {
|
|
62
|
+
for (NSString *value in values) {
|
|
63
|
+
if ([value rangeOfString:@"^doc\\|[^|]+\\|[0-9]+$"
|
|
64
|
+
options:NSRegularExpressionSearch].location != NSNotFound) {
|
|
65
|
+
return YES;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return NO;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static UIImage *DPResize(UIImage *image, CGFloat maxDimension) {
|
|
72
|
+
CGFloat scale = MIN(1, maxDimension / MAX(image.size.width, image.size.height));
|
|
73
|
+
CGSize size = CGSizeMake(MAX(1, round(image.size.width * scale)),
|
|
74
|
+
MAX(1, round(image.size.height * scale)));
|
|
75
|
+
UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat defaultFormat];
|
|
76
|
+
format.scale = 1;
|
|
77
|
+
format.opaque = YES;
|
|
78
|
+
UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:size format:format];
|
|
79
|
+
return [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) {
|
|
80
|
+
[[UIColor whiteColor] setFill];
|
|
81
|
+
UIRectFill((CGRect){CGPointZero, size});
|
|
82
|
+
[image drawInRect:(CGRect){CGPointZero, size}];
|
|
83
|
+
}];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static BOOL DPReadQR(CGImageRef image, NSMutableOrderedSet<NSString *> *values) {
|
|
87
|
+
NSError *error = nil;
|
|
88
|
+
NSArray<VNBarcodeObservation *> *observations = DPReadBarcodes(image, @[VNBarcodeSymbologyQR], &error);
|
|
89
|
+
if (!observations) { return NO; }
|
|
90
|
+
for (VNBarcodeObservation *observation in observations) {
|
|
91
|
+
if (observation.payloadStringValue.length) {
|
|
92
|
+
[values addObject:observation.payloadStringValue];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return YES;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@interface DPSession : NSObject
|
|
99
|
+
@property(nonatomic, strong) NSURL *directory;
|
|
100
|
+
@property(nonatomic, strong) NSURL *pdfURL;
|
|
101
|
+
@property(nonatomic, assign) CGContextRef pdf;
|
|
102
|
+
@property(nonatomic, assign) NSUInteger pageCount;
|
|
103
|
+
- (void)close;
|
|
104
|
+
@end
|
|
105
|
+
|
|
106
|
+
@implementation DPSession
|
|
107
|
+
- (void)close {
|
|
108
|
+
if (_pdf) {
|
|
109
|
+
CGPDFContextClose(_pdf);
|
|
110
|
+
CGContextRelease(_pdf);
|
|
111
|
+
_pdf = NULL;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
- (void)dealloc { [self close]; }
|
|
115
|
+
@end
|
|
116
|
+
|
|
117
|
+
@interface DocumentProcessor : NSObject <RCTBridgeModule, RCTInvalidating>
|
|
118
|
+
@end
|
|
119
|
+
|
|
120
|
+
@implementation DocumentProcessor {
|
|
121
|
+
dispatch_queue_t _worker;
|
|
122
|
+
NSMutableDictionary<NSString *, DPSession *> *_sessions;
|
|
123
|
+
DPDocumentCapture *_capture;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
RCT_EXPORT_MODULE(DocumentProcessor)
|
|
127
|
+
|
|
128
|
+
+ (BOOL)requiresMainQueueSetup { return NO; }
|
|
129
|
+
|
|
130
|
+
- (instancetype)init {
|
|
131
|
+
if ((self = [super init])) {
|
|
132
|
+
_worker = dispatch_queue_create("vn.money24h.documentprocessor", DISPATCH_QUEUE_SERIAL);
|
|
133
|
+
_sessions = [NSMutableDictionary new];
|
|
134
|
+
_capture = [[DPDocumentCapture alloc] initWithWorker:_worker];
|
|
135
|
+
}
|
|
136
|
+
return self;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
- (dispatch_queue_t)methodQueue { return _worker; }
|
|
140
|
+
|
|
141
|
+
RCT_EXPORT_METHOD(scanDocument:(NSDictionary *)options
|
|
142
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
143
|
+
rejecter:(RCTPromiseRejectBlock)reject) {
|
|
144
|
+
[_capture scanWithQuality:options[@"croppedImageQuality"] ? [options[@"croppedImageQuality"] doubleValue] : 100
|
|
145
|
+
resolve:resolve reject:reject];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
RCT_EXPORT_METHOD(scanBarcodes:(NSString *)uri
|
|
149
|
+
formats:(NSArray<NSString *> *)formats
|
|
150
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
151
|
+
rejecter:(RCTPromiseRejectBlock)reject) {
|
|
152
|
+
@autoreleasepool {
|
|
153
|
+
NSDictionary *mapping = DPBarcodeFormats();
|
|
154
|
+
NSMutableOrderedSet<VNBarcodeSymbology> *symbologies = [NSMutableOrderedSet new];
|
|
155
|
+
for (NSString *format in formats) {
|
|
156
|
+
if (!mapping[format]) {
|
|
157
|
+
reject(@"DOCUMENT_PROCESSOR_BARCODE_FORMAT", @"Unsupported barcode format.", nil);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
[symbologies addObjectsFromArray:mapping[format]];
|
|
161
|
+
}
|
|
162
|
+
if (!symbologies.count) {
|
|
163
|
+
reject(@"DOCUMENT_PROCESSOR_BARCODE_FORMAT", @"At least one barcode format is required.", nil);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
UIImage *image = DPDecodeImage(uri);
|
|
167
|
+
if (!image) {
|
|
168
|
+
reject(@"DOCUMENT_PROCESSOR_IMAGE", @"Cannot decode local image.", nil);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
NSError *error = nil;
|
|
172
|
+
NSArray<VNBarcodeObservation *> *observations = DPReadBarcodes(image.CGImage, symbologies.array, &error);
|
|
173
|
+
if (!observations) {
|
|
174
|
+
reject(@"DOCUMENT_PROCESSOR_BARCODE_SCAN", @"Cannot scan image codes.", error);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
NSMutableOrderedSet<NSString *> *values = [NSMutableOrderedSet new];
|
|
178
|
+
NSMutableOrderedSet<NSDictionary *> *codes = [NSMutableOrderedSet new];
|
|
179
|
+
for (VNBarcodeObservation *observation in observations) {
|
|
180
|
+
NSString *value = observation.payloadStringValue;
|
|
181
|
+
if (!value.length) { continue; }
|
|
182
|
+
NSString *detectedFormat = nil;
|
|
183
|
+
if ([observation.symbology isEqualToString:VNBarcodeSymbologyEAN13]) {
|
|
184
|
+
if (value.length == 13 && [value hasPrefix:@"0"] && [formats containsObject:@"upc-a"]) {
|
|
185
|
+
detectedFormat = @"upc-a";
|
|
186
|
+
value = [value substringFromIndex:1];
|
|
187
|
+
} else if ([formats containsObject:@"ean-13"]) {
|
|
188
|
+
detectedFormat = @"ean-13";
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
for (NSString *format in formats) {
|
|
192
|
+
if ([mapping[format] containsObject:observation.symbology]) {
|
|
193
|
+
detectedFormat = format;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (detectedFormat) {
|
|
199
|
+
[values addObject:value];
|
|
200
|
+
[codes addObject:@{@"value": value, @"format": detectedFormat}];
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
resolve(@{@"values": values.array, @"codes": codes.array});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
RCT_EXPORT_METHOD(beginSession:(RCTPromiseResolveBlock)resolve
|
|
208
|
+
rejecter:(RCTPromiseRejectBlock)reject) {
|
|
209
|
+
NSString *identifier = [NSUUID UUID].UUIDString;
|
|
210
|
+
NSURL *cache = [[NSFileManager defaultManager] URLsForDirectory:NSCachesDirectory
|
|
211
|
+
inDomains:NSUserDomainMask].firstObject;
|
|
212
|
+
DPSession *session = [DPSession new];
|
|
213
|
+
session.directory = [cache URLByAppendingPathComponent:
|
|
214
|
+
[@"document-processor-" stringByAppendingString:identifier] isDirectory:YES];
|
|
215
|
+
session.pdfURL = [session.directory URLByAppendingPathComponent:@"document.pdf"];
|
|
216
|
+
NSError *error = nil;
|
|
217
|
+
if (!cache || ![[NSFileManager defaultManager] createDirectoryAtURL:session.directory
|
|
218
|
+
withIntermediateDirectories:YES attributes:nil error:&error]) {
|
|
219
|
+
reject(@"DOCUMENT_PROCESSOR_CREATE", @"Cannot create document cache.", error);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
session.pdf = CGPDFContextCreateWithURL((__bridge CFURLRef)session.pdfURL, NULL, NULL);
|
|
223
|
+
if (!session.pdf) {
|
|
224
|
+
[[NSFileManager defaultManager] removeItemAtURL:session.directory error:nil];
|
|
225
|
+
reject(@"DOCUMENT_PROCESSOR_CREATE", @"Cannot create PDF.", nil);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
_sessions[identifier] = session;
|
|
229
|
+
resolve(identifier);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
RCT_EXPORT_METHOD(appendPage:(NSString *)identifier
|
|
233
|
+
uri:(NSString *)uri
|
|
234
|
+
scanQr:(BOOL)scanQr
|
|
235
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
236
|
+
rejecter:(RCTPromiseRejectBlock)reject) {
|
|
237
|
+
@autoreleasepool {
|
|
238
|
+
DPSession *session = _sessions[identifier];
|
|
239
|
+
if (!session.pdf) {
|
|
240
|
+
reject(@"DOCUMENT_PROCESSOR_SESSION", @"Session is closed or missing.", nil);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
UIImage *image = DPDecodeImage(uri);
|
|
244
|
+
if (!image) {
|
|
245
|
+
reject(@"DOCUMENT_PROCESSOR_IMAGE", @"Cannot decode document image.", nil);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
NSMutableOrderedSet<NSString *> *values = [NSMutableOrderedSet new];
|
|
249
|
+
BOOL hasError = scanQr && !DPReadQR(image.CGImage, values);
|
|
250
|
+
if (scanQr && !DPHasDocument(values.array)) {
|
|
251
|
+
size_t width = CGImageGetWidth(image.CGImage);
|
|
252
|
+
size_t height = CGImageGetHeight(image.CGImage);
|
|
253
|
+
size_t cropWidth = (size_t)ceil(width * 0.6);
|
|
254
|
+
size_t cropHeight = (size_t)ceil(height * 0.6);
|
|
255
|
+
CGPoint offsets[] = {{width - cropWidth, 0}, {0, 0},
|
|
256
|
+
{width - cropWidth, height - cropHeight}, {0, height - cropHeight}};
|
|
257
|
+
for (int index = 0; index < 4; index++) {
|
|
258
|
+
@autoreleasepool {
|
|
259
|
+
CGImageRef crop = CGImageCreateWithImageInRect(image.CGImage,
|
|
260
|
+
(CGRect){offsets[index], CGSizeMake(cropWidth, cropHeight)});
|
|
261
|
+
if (!crop || !DPReadQR(crop, values)) { hasError = YES; }
|
|
262
|
+
if (crop) { CGImageRelease(crop); }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
NSMutableDictionary *page = [@{
|
|
268
|
+
@"page": @(session.pageCount + 1),
|
|
269
|
+
@"rawValues": values.array,
|
|
270
|
+
@"status": hasError ? @"error" : (values.count ? @"decoded" : @"not-detected")
|
|
271
|
+
} mutableCopy];
|
|
272
|
+
// Optional previews must never discard decoded QR data on write failure.
|
|
273
|
+
if (DPHasDocument(values.array)) {
|
|
274
|
+
@autoreleasepool {
|
|
275
|
+
NSData *preview = UIImageJPEGRepresentation(DPResize(image, 1000), 0.85);
|
|
276
|
+
NSURL *previewURL = [session.directory URLByAppendingPathComponent:
|
|
277
|
+
[NSString stringWithFormat:@"page-%lu.jpg", (unsigned long)session.pageCount + 1]];
|
|
278
|
+
if ([preview writeToURL:previewURL options:NSDataWritingAtomic error:nil]) {
|
|
279
|
+
page[@"previewUri"] = previewURL.absoluteString;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Feed compressed JPEG data to Core Graphics, without a resized image file.
|
|
285
|
+
NSData *jpeg = UIImageJPEGRepresentation(DPResize(image, 3000), 0.92);
|
|
286
|
+
CGDataProviderRef provider = jpeg ? CGDataProviderCreateWithCFData((__bridge CFDataRef)jpeg) : NULL;
|
|
287
|
+
CGImageRef pdfImage = provider ? CGImageCreateWithJPEGDataProvider(provider, NULL, NO,
|
|
288
|
+
kCGRenderingIntentDefault) : NULL;
|
|
289
|
+
if (provider) { CGDataProviderRelease(provider); }
|
|
290
|
+
if (!pdfImage) {
|
|
291
|
+
reject(@"DOCUMENT_PROCESSOR_PDF", @"Cannot encode PDF page.", DPError(@"JPEG encoding failed."));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
CGRect bounds = CGRectMake(0, 0, CGImageGetWidth(pdfImage) * 72.0 / 150.0,
|
|
295
|
+
CGImageGetHeight(pdfImage) * 72.0 / 150.0);
|
|
296
|
+
NSData *mediaBox = [NSData dataWithBytes:&bounds length:sizeof(bounds)];
|
|
297
|
+
CGPDFContextBeginPage(session.pdf, (__bridge CFDictionaryRef)@{(id)kCGPDFContextMediaBox: mediaBox});
|
|
298
|
+
CGContextDrawImage(session.pdf, bounds, pdfImage);
|
|
299
|
+
CGPDFContextEndPage(session.pdf);
|
|
300
|
+
CGImageRelease(pdfImage);
|
|
301
|
+
session.pageCount++;
|
|
302
|
+
resolve(page);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
RCT_EXPORT_METHOD(finishSession:(NSString *)identifier
|
|
307
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
308
|
+
rejecter:(RCTPromiseRejectBlock)reject) {
|
|
309
|
+
DPSession *session = _sessions[identifier];
|
|
310
|
+
if (!session.pdf || !session.pageCount) {
|
|
311
|
+
reject(@"DOCUMENT_PROCESSOR_SESSION", @"Session is empty or closed.", nil);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
[session close];
|
|
315
|
+
CGPDFDocumentRef document = CGPDFDocumentCreateWithURL((__bridge CFURLRef)session.pdfURL);
|
|
316
|
+
BOOL valid = document && CGPDFDocumentGetNumberOfPages(document) == session.pageCount;
|
|
317
|
+
if (document) { CGPDFDocumentRelease(document); }
|
|
318
|
+
if (!valid) {
|
|
319
|
+
reject(@"DOCUMENT_PROCESSOR_PDF", @"Cannot finalize PDF.", nil);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
resolve(@{@"pdfUri": session.pdfURL.absoluteString, @"directoryUri": session.directory.absoluteString});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
RCT_EXPORT_METHOD(releaseSession:(NSString *)identifier
|
|
326
|
+
deleteFiles:(BOOL)deleteFiles
|
|
327
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
328
|
+
rejecter:(RCTPromiseRejectBlock)reject) {
|
|
329
|
+
DPSession *session = _sessions[identifier];
|
|
330
|
+
[session close];
|
|
331
|
+
if (deleteFiles && session) {
|
|
332
|
+
[[NSFileManager defaultManager] removeItemAtURL:session.directory error:nil];
|
|
333
|
+
}
|
|
334
|
+
[_sessions removeObjectForKey:identifier];
|
|
335
|
+
resolve(nil);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
- (void)invalidate {
|
|
339
|
+
[_capture invalidate];
|
|
340
|
+
dispatch_async(_worker, ^{
|
|
341
|
+
for (DPSession *session in self->_sessions.allValues) {
|
|
342
|
+
[session close];
|
|
343
|
+
[[NSFileManager defaultManager] removeItemAtURL:session.directory error:nil];
|
|
344
|
+
}
|
|
345
|
+
[self->_sessions removeAllObjects];
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
@end
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-document-processor",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Native image-to-PDF and QR processing for 24hMoney",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"react-native": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"license": "UNLICENSED",
|
|
9
|
+
"author": "24hMoney",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/quocvu12345/react-native-document-processor.git"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/quocvu12345/react-native-document-processor#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/quocvu12345/react-native-document-processor/issues"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public",
|
|
20
|
+
"registry": "https://registry.npmjs.org/"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"src/index.ts",
|
|
24
|
+
"ios",
|
|
25
|
+
"android/build.gradle",
|
|
26
|
+
"android/src/main",
|
|
27
|
+
"react-native-document-processor.podspec"
|
|
28
|
+
],
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"react": "*",
|
|
31
|
+
"react-native": ">=0.73"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
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 = package["name"]
|
|
7
|
+
s.version = package["version"]
|
|
8
|
+
s.summary = package["description"]
|
|
9
|
+
s.homepage = package["homepage"]
|
|
10
|
+
s.license = package["license"]
|
|
11
|
+
s.author = package["author"]
|
|
12
|
+
s.source = { :git => "https://github.com/quocvu12345/react-native-document-processor.git",
|
|
13
|
+
:tag => "v#{s.version}" }
|
|
14
|
+
s.platforms = { :ios => "15.1" }
|
|
15
|
+
s.source_files = "ios/**/*.{h,m}"
|
|
16
|
+
s.frameworks = "Vision", "VisionKit", "ImageIO", "UIKit", "CoreGraphics"
|
|
17
|
+
s.dependency "React-Core"
|
|
18
|
+
end
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { NativeModules } from 'react-native';
|
|
2
|
+
|
|
3
|
+
export type ProcessedDocumentPage = {
|
|
4
|
+
page: number;
|
|
5
|
+
rawValues: string[];
|
|
6
|
+
status: 'decoded' | 'not-detected' | 'error';
|
|
7
|
+
previewUri?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ProcessedDocument = {
|
|
11
|
+
pdfUri: string;
|
|
12
|
+
/** Caller owns this directory after success, including PDF and previews. */
|
|
13
|
+
directoryUri: string;
|
|
14
|
+
pages: ProcessedDocumentPage[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type ProcessorModule = {
|
|
18
|
+
scanBarcodes(
|
|
19
|
+
uri: string,
|
|
20
|
+
formats: readonly ImageBarcodeFormat[],
|
|
21
|
+
): Promise<ImageBarcodeResult>;
|
|
22
|
+
scanDocument(options: ScanDocumentOptions): Promise<DocumentCapture>;
|
|
23
|
+
beginSession(): Promise<string>;
|
|
24
|
+
appendPage(
|
|
25
|
+
session: string,
|
|
26
|
+
uri: string,
|
|
27
|
+
scanQr: boolean,
|
|
28
|
+
): Promise<ProcessedDocumentPage>;
|
|
29
|
+
finishSession(session: string): Promise<Omit<ProcessedDocument, 'pages'>>;
|
|
30
|
+
releaseSession(session: string, deleteFiles: boolean): Promise<void>;
|
|
31
|
+
/** Android diagnostics, enabled with log.tag.DocumentScan=DEBUG. */
|
|
32
|
+
logEvent?(event: string, details: string): void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type ProcessOptions = {
|
|
36
|
+
scanQr?: boolean;
|
|
37
|
+
onProgress?: (completed: number, total: number) => void;
|
|
38
|
+
isCancelled?: () => boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type ScanDocumentOptions = {
|
|
42
|
+
/** iOS JPEG quality, 0–100. Android returns the scanner's original JPEG. */
|
|
43
|
+
croppedImageQuality?: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type DocumentCapture = {
|
|
47
|
+
status: 'success' | 'cancel';
|
|
48
|
+
/** Caller owns these local files and must remove them after use. */
|
|
49
|
+
scannedImages: string[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export async function scanDocument(
|
|
53
|
+
options: ScanDocumentOptions = {},
|
|
54
|
+
): Promise<DocumentCapture> {
|
|
55
|
+
const native = getModule();
|
|
56
|
+
if (typeof native?.scanDocument !== 'function') {
|
|
57
|
+
throw new Error('Cần cập nhật ứng dụng để quét tài liệu.');
|
|
58
|
+
}
|
|
59
|
+
const quality = options.croppedImageQuality ?? 100;
|
|
60
|
+
if (!Number.isFinite(quality) || quality < 0 || quality > 100) {
|
|
61
|
+
throw new Error('DOCUMENT_PROCESSOR_INVALID_QUALITY');
|
|
62
|
+
}
|
|
63
|
+
return native.scanDocument({ croppedImageQuality: quality });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const getModule = (): ProcessorModule | undefined =>
|
|
67
|
+
NativeModules.DocumentProcessor;
|
|
68
|
+
|
|
69
|
+
const localImageUri = (path: string) =>
|
|
70
|
+
path.startsWith('/')
|
|
71
|
+
? `file://${path.split('/').map(encodeURIComponent).join('/')}`
|
|
72
|
+
: path;
|
|
73
|
+
|
|
74
|
+
const BARCODE_FORMATS = [
|
|
75
|
+
'qr',
|
|
76
|
+
'ean-13',
|
|
77
|
+
'ean-8',
|
|
78
|
+
'upc-a',
|
|
79
|
+
'upc-e',
|
|
80
|
+
'code-128',
|
|
81
|
+
'code-39',
|
|
82
|
+
'code-93',
|
|
83
|
+
'codabar',
|
|
84
|
+
'itf',
|
|
85
|
+
'pdf-417',
|
|
86
|
+
'aztec',
|
|
87
|
+
'data-matrix',
|
|
88
|
+
] as const;
|
|
89
|
+
|
|
90
|
+
export type ImageBarcodeFormat = (typeof BARCODE_FORMATS)[number];
|
|
91
|
+
export type ImageBarcodeResult = {
|
|
92
|
+
/** Unique raw payloads; leading zeroes and whitespace are preserved. */
|
|
93
|
+
values: string[];
|
|
94
|
+
codes: { value: string; format: ImageBarcodeFormat }[];
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Decode a local image without creating a PDF, preview, or temporary image. */
|
|
98
|
+
export async function scanBarcodes(
|
|
99
|
+
uri: string,
|
|
100
|
+
options: { formats?: readonly ImageBarcodeFormat[] } = {},
|
|
101
|
+
): Promise<ImageBarcodeResult> {
|
|
102
|
+
const native = getModule();
|
|
103
|
+
if (typeof native?.scanBarcodes !== 'function') {
|
|
104
|
+
throw new Error('Cần cập nhật ứng dụng để quét mã từ ảnh.');
|
|
105
|
+
}
|
|
106
|
+
if (typeof uri !== 'string' || !/^(\/|file:\/\/|content:\/\/)/.test(uri)) {
|
|
107
|
+
throw new Error('DOCUMENT_PROCESSOR_INVALID_IMAGE_URI');
|
|
108
|
+
}
|
|
109
|
+
const formats = options.formats ?? BARCODE_FORMATS;
|
|
110
|
+
if (
|
|
111
|
+
!formats.length ||
|
|
112
|
+
formats.some(format => !BARCODE_FORMATS.includes(format))
|
|
113
|
+
) {
|
|
114
|
+
throw new Error('DOCUMENT_PROCESSOR_INVALID_BARCODE_FORMAT');
|
|
115
|
+
}
|
|
116
|
+
return native.scanBarcodes(localImageUri(uri), [...new Set(formats)]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** QR only: ignore barcode-like text/1D codes on document pages. */
|
|
120
|
+
export const scanQrImage = (uri: string): Promise<ImageBarcodeResult> =>
|
|
121
|
+
scanBarcodes(uri, { formats: ['qr'] });
|
|
122
|
+
|
|
123
|
+
/** Diagnostics must never interrupt a scan, including on older binaries/iOS. */
|
|
124
|
+
export const logDocumentEvent = (event: string, data: unknown): void => {
|
|
125
|
+
try {
|
|
126
|
+
const native = getModule();
|
|
127
|
+
if (typeof native?.logEvent === 'function') {
|
|
128
|
+
native.logEvent(event, JSON.stringify(data) ?? 'null');
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// A diagnostic payload or unavailable bridge must not fail the document flow.
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const isDocumentProcessorAvailable = () => {
|
|
136
|
+
const native = getModule();
|
|
137
|
+
return (
|
|
138
|
+
typeof native?.beginSession === 'function' &&
|
|
139
|
+
typeof native.appendPage === 'function' &&
|
|
140
|
+
typeof native.finishSession === 'function' &&
|
|
141
|
+
typeof native.releaseSession === 'function'
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** One native call per page: decode, QR, preview, append to PDF. No base64. */
|
|
146
|
+
export async function processDocumentImages(
|
|
147
|
+
paths: readonly string[],
|
|
148
|
+
options: ProcessOptions = {},
|
|
149
|
+
): Promise<ProcessedDocument> {
|
|
150
|
+
const native = getModule();
|
|
151
|
+
if (!native || !isDocumentProcessorAvailable()) {
|
|
152
|
+
throw new Error('DOCUMENT_PROCESSOR_UNAVAILABLE');
|
|
153
|
+
}
|
|
154
|
+
if (!paths.length) {
|
|
155
|
+
throw new Error('DOCUMENT_PROCESSOR_EMPTY');
|
|
156
|
+
}
|
|
157
|
+
const assertActive = () => {
|
|
158
|
+
if (options.isCancelled?.()) {
|
|
159
|
+
throw new Error('DOCUMENT_QR_SCAN_CANCELLED');
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
assertActive();
|
|
163
|
+
const session = await native.beginSession();
|
|
164
|
+
let transferred = false;
|
|
165
|
+
try {
|
|
166
|
+
const pages: ProcessedDocumentPage[] = [];
|
|
167
|
+
options.onProgress?.(0, paths.length);
|
|
168
|
+
for (const path of paths) {
|
|
169
|
+
assertActive();
|
|
170
|
+
pages.push(
|
|
171
|
+
await native.appendPage(
|
|
172
|
+
session,
|
|
173
|
+
localImageUri(path),
|
|
174
|
+
options.scanQr !== false,
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
assertActive();
|
|
178
|
+
options.onProgress?.(pages.length, paths.length);
|
|
179
|
+
}
|
|
180
|
+
assertActive();
|
|
181
|
+
const output = await native.finishSession(session);
|
|
182
|
+
assertActive();
|
|
183
|
+
// Transfer file ownership only after PDF finalization and cancellation checks.
|
|
184
|
+
await native.releaseSession(session, false);
|
|
185
|
+
transferred = true;
|
|
186
|
+
return { ...output, pages };
|
|
187
|
+
} finally {
|
|
188
|
+
if (!transferred) {
|
|
189
|
+
await native.releaseSession(session, true);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|