react-native-document-processor 0.0.0-stage → 0.2.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 +207 -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 +356 -0
- package/package.json +31 -4
- package/react-native-document-processor.podspec +18 -0
- package/src/index.ts +192 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
package vn.money24h.documentprocessor;
|
|
2
|
+
|
|
3
|
+
import android.graphics.Bitmap;
|
|
4
|
+
import android.graphics.Canvas;
|
|
5
|
+
import android.graphics.Color;
|
|
6
|
+
import android.graphics.ColorSpace;
|
|
7
|
+
import android.graphics.ImageDecoder;
|
|
8
|
+
import android.graphics.Paint;
|
|
9
|
+
import android.graphics.Rect;
|
|
10
|
+
import android.net.Uri;
|
|
11
|
+
import android.os.SystemClock;
|
|
12
|
+
import com.facebook.react.bridge.Arguments;
|
|
13
|
+
import com.facebook.react.bridge.Promise;
|
|
14
|
+
import com.facebook.react.bridge.ReactApplicationContext;
|
|
15
|
+
import com.facebook.react.bridge.ReactContextBaseJavaModule;
|
|
16
|
+
import com.facebook.react.bridge.ReactMethod;
|
|
17
|
+
import com.facebook.react.bridge.ReadableArray;
|
|
18
|
+
import com.facebook.react.bridge.ReadableMap;
|
|
19
|
+
import com.facebook.react.bridge.WritableArray;
|
|
20
|
+
import com.facebook.react.bridge.WritableMap;
|
|
21
|
+
import com.facebook.react.module.annotations.ReactModule;
|
|
22
|
+
import com.google.android.gms.tasks.Tasks;
|
|
23
|
+
import com.google.mlkit.vision.barcode.BarcodeScanner;
|
|
24
|
+
import com.google.mlkit.vision.barcode.BarcodeScannerOptions;
|
|
25
|
+
import com.google.mlkit.vision.barcode.BarcodeScanning;
|
|
26
|
+
import com.google.mlkit.vision.barcode.common.Barcode;
|
|
27
|
+
import com.google.mlkit.vision.common.InputImage;
|
|
28
|
+
import java.io.ByteArrayOutputStream;
|
|
29
|
+
import java.io.File;
|
|
30
|
+
import java.io.FileOutputStream;
|
|
31
|
+
import java.io.IOException;
|
|
32
|
+
import java.util.HashMap;
|
|
33
|
+
import java.util.LinkedHashSet;
|
|
34
|
+
import java.util.Map;
|
|
35
|
+
import java.util.Set;
|
|
36
|
+
import java.util.UUID;
|
|
37
|
+
import java.util.concurrent.ExecutorService;
|
|
38
|
+
import java.util.concurrent.Executors;
|
|
39
|
+
import java.util.concurrent.RejectedExecutionException;
|
|
40
|
+
import java.util.concurrent.atomic.AtomicBoolean;
|
|
41
|
+
import java.util.regex.Pattern;
|
|
42
|
+
import org.json.JSONArray;
|
|
43
|
+
|
|
44
|
+
@ReactModule(name = DocumentProcessorModule.NAME)
|
|
45
|
+
public final class DocumentProcessorModule extends ReactContextBaseJavaModule {
|
|
46
|
+
static final String NAME = "DocumentProcessor";
|
|
47
|
+
private static final Pattern DOCUMENT = Pattern.compile("^doc\\|[^|]+\\|[0-9]+$");
|
|
48
|
+
private final ExecutorService worker = Executors.newSingleThreadExecutor();
|
|
49
|
+
// Accessed only on worker. ML Kit callbacks run independently of this executor.
|
|
50
|
+
private final Map<String, Session> sessions = new HashMap<>();
|
|
51
|
+
private final DocumentCapture capture;
|
|
52
|
+
private final AtomicBoolean invalidated = new AtomicBoolean();
|
|
53
|
+
|
|
54
|
+
private static final class Session {
|
|
55
|
+
final File directory;
|
|
56
|
+
final File pdf;
|
|
57
|
+
final JpegPdfWriter writer;
|
|
58
|
+
final BarcodeScanner scanner;
|
|
59
|
+
final long startedAt = SystemClock.elapsedRealtime();
|
|
60
|
+
int pageCount;
|
|
61
|
+
boolean finished;
|
|
62
|
+
boolean closed;
|
|
63
|
+
|
|
64
|
+
Session(File directory) throws IOException {
|
|
65
|
+
this.directory = directory;
|
|
66
|
+
pdf = new File(directory, "document.pdf");
|
|
67
|
+
writer = new JpegPdfWriter(pdf);
|
|
68
|
+
try {
|
|
69
|
+
scanner = BarcodeScanning.getClient(new BarcodeScannerOptions.Builder()
|
|
70
|
+
.setBarcodeFormats(Barcode.FORMAT_QR_CODE).build());
|
|
71
|
+
} catch (RuntimeException error) {
|
|
72
|
+
writer.close();
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
void close() {
|
|
78
|
+
if (closed) { return; }
|
|
79
|
+
closed = true;
|
|
80
|
+
scanner.close();
|
|
81
|
+
try { writer.close(); } catch (IOException ignored) { }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface Operation { void run() throws Exception; }
|
|
86
|
+
|
|
87
|
+
public DocumentProcessorModule(ReactApplicationContext context) {
|
|
88
|
+
super(context);
|
|
89
|
+
capture = new DocumentCapture(context);
|
|
90
|
+
DocumentScanLog.event("processor.ready", "pipeline=image->QR+PDF");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@ReactMethod
|
|
94
|
+
public void logEvent(String event, String details) {
|
|
95
|
+
DocumentScanLog.event("js." + event, details);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@ReactMethod
|
|
99
|
+
public void scanDocument(ReadableMap options, Promise promise) {
|
|
100
|
+
capture.scan(promise);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@ReactMethod
|
|
104
|
+
public void scanBarcodes(String uri, ReadableArray formats, Promise promise) {
|
|
105
|
+
execute("barcode.scan", promise, () -> {
|
|
106
|
+
long startedAt = SystemClock.elapsedRealtime();
|
|
107
|
+
DocumentScanLog.event("barcode.start", "formats=" + formats);
|
|
108
|
+
Bitmap bitmap = decode(uri);
|
|
109
|
+
try {
|
|
110
|
+
WritableMap result = BarcodeImageScanner.scan(bitmap, formats);
|
|
111
|
+
DocumentScanLog.event("barcode.complete", "elapsedMs=" + (SystemClock.elapsedRealtime() - startedAt)
|
|
112
|
+
+ " width=" + bitmap.getWidth() + " height=" + bitmap.getHeight() + " result=" + result);
|
|
113
|
+
promise.resolve(result);
|
|
114
|
+
} finally {
|
|
115
|
+
bitmap.recycle();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@Override
|
|
121
|
+
public String getName() { return NAME; }
|
|
122
|
+
|
|
123
|
+
private void execute(String name, Promise promise, Operation operation) {
|
|
124
|
+
try {
|
|
125
|
+
worker.execute(() -> {
|
|
126
|
+
try {
|
|
127
|
+
operation.run();
|
|
128
|
+
} catch (Exception error) {
|
|
129
|
+
DocumentScanLog.error(name + ".error", error);
|
|
130
|
+
promise.reject("DOCUMENT_PROCESSOR_ERROR", error.getMessage(), error);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
} catch (RejectedExecutionException error) {
|
|
134
|
+
DocumentScanLog.error(name + ".closed", error);
|
|
135
|
+
promise.reject("DOCUMENT_PROCESSOR_CLOSED", "Document processor has closed.", error);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@ReactMethod
|
|
140
|
+
public void beginSession(Promise promise) {
|
|
141
|
+
execute("session.begin", promise, () -> {
|
|
142
|
+
String id = UUID.randomUUID().toString();
|
|
143
|
+
File directory = new File(getReactApplicationContext().getCacheDir(), "document-processor-" + id);
|
|
144
|
+
if (!directory.mkdirs()) { throw new IOException("Cannot create document cache."); }
|
|
145
|
+
try {
|
|
146
|
+
sessions.put(id, new Session(directory));
|
|
147
|
+
DocumentScanLog.event("session.begin", "session=" + id);
|
|
148
|
+
promise.resolve(id);
|
|
149
|
+
} catch (Exception error) {
|
|
150
|
+
deleteDirectory(directory);
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private Session activeSession(String id) throws IOException {
|
|
157
|
+
Session session = sessions.get(id);
|
|
158
|
+
if (session == null || session.finished) { throw new IOException("Session is closed or missing."); }
|
|
159
|
+
return session;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private Bitmap decode(String path) throws IOException {
|
|
163
|
+
Uri uri = path.startsWith("/") ? Uri.fromFile(new File(path)) : Uri.parse(path);
|
|
164
|
+
ImageDecoder.Source source;
|
|
165
|
+
if ("file".equals(uri.getScheme())) {
|
|
166
|
+
source = ImageDecoder.createSource(new File(uri.getPath()));
|
|
167
|
+
} else if ("content".equals(uri.getScheme())) {
|
|
168
|
+
source = ImageDecoder.createSource(getReactApplicationContext().getContentResolver(), uri);
|
|
169
|
+
} else {
|
|
170
|
+
throw new IOException("Expected a local image file or content URI.");
|
|
171
|
+
}
|
|
172
|
+
// ImageDecoder applies EXIF orientation, including mirrored images.
|
|
173
|
+
return ImageDecoder.decodeBitmap(source, (decoder, info, input) -> {
|
|
174
|
+
decoder.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE);
|
|
175
|
+
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB));
|
|
176
|
+
int width = info.getSize().getWidth();
|
|
177
|
+
int height = info.getSize().getHeight();
|
|
178
|
+
double scale = Math.min(1.0, 4096.0 / Math.max(width, height));
|
|
179
|
+
decoder.setTargetSize(Math.max(1, (int) Math.round(width * scale)),
|
|
180
|
+
Math.max(1, (int) Math.round(height * scale)));
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private boolean readQr(Session session, Bitmap bitmap, Set<String> values) {
|
|
185
|
+
try {
|
|
186
|
+
// Wait on the dedicated worker, never the UI/native modules thread.
|
|
187
|
+
// Do not recycle the input until ML Kit finishes using it.
|
|
188
|
+
for (Barcode barcode : Tasks.await(session.scanner.process(InputImage.fromBitmap(bitmap, 0)))) {
|
|
189
|
+
String value = barcode.getRawValue();
|
|
190
|
+
if (value != null && !value.isEmpty()) { values.add(value); }
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
} catch (Exception error) {
|
|
194
|
+
DocumentScanLog.error("qr.decode.error", error);
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private static boolean hasDocument(Set<String> values) {
|
|
200
|
+
for (String value : values) {
|
|
201
|
+
if (DOCUMENT.matcher(value).matches()) { return true; }
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private static Bitmap resizeOpaque(Bitmap source, int maximum) {
|
|
207
|
+
double scale = Math.min(1.0, (double) maximum / Math.max(source.getWidth(), source.getHeight()));
|
|
208
|
+
int width = Math.max(1, (int) Math.round(source.getWidth() * scale));
|
|
209
|
+
int height = Math.max(1, (int) Math.round(source.getHeight() * scale));
|
|
210
|
+
Bitmap result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
|
|
211
|
+
Canvas canvas = new Canvas(result);
|
|
212
|
+
canvas.drawColor(Color.WHITE);
|
|
213
|
+
canvas.drawBitmap(source, null, new Rect(0, 0, width, height), new Paint(Paint.FILTER_BITMAP_FLAG));
|
|
214
|
+
result.setHasAlpha(false);
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
@ReactMethod
|
|
219
|
+
public void appendPage(String id, String uri, boolean scanQr, Promise promise) {
|
|
220
|
+
execute("page.append", promise, () -> {
|
|
221
|
+
long startedAt = SystemClock.elapsedRealtime();
|
|
222
|
+
Session session = activeSession(id);
|
|
223
|
+
String trace = "session=" + id + " page=" + (session.pageCount + 1);
|
|
224
|
+
DocumentScanLog.event("page.start", trace + " scanQr=" + scanQr);
|
|
225
|
+
Bitmap bitmap = decode(uri);
|
|
226
|
+
DocumentScanLog.event("page.decoded", trace + " width=" + bitmap.getWidth()
|
|
227
|
+
+ " height=" + bitmap.getHeight() + " elapsedMs=" + (SystemClock.elapsedRealtime() - startedAt));
|
|
228
|
+
try {
|
|
229
|
+
long qrStartedAt = SystemClock.elapsedRealtime();
|
|
230
|
+
Set<String> values = new LinkedHashSet<>();
|
|
231
|
+
boolean hasError = scanQr && !readQr(session, bitmap, values);
|
|
232
|
+
int cropAttempts = 0;
|
|
233
|
+
if (scanQr && !hasDocument(values)) {
|
|
234
|
+
int width = (int) Math.ceil(bitmap.getWidth() * 0.6);
|
|
235
|
+
int height = (int) Math.ceil(bitmap.getHeight() * 0.6);
|
|
236
|
+
int right = bitmap.getWidth() - width;
|
|
237
|
+
int bottom = bitmap.getHeight() - height;
|
|
238
|
+
int[][] offsets = {{right, 0}, {0, 0}, {right, bottom}, {0, bottom}};
|
|
239
|
+
for (int[] offset : offsets) {
|
|
240
|
+
cropAttempts++;
|
|
241
|
+
Bitmap crop = Bitmap.createBitmap(bitmap, offset[0], offset[1], width, height);
|
|
242
|
+
try {
|
|
243
|
+
if (!readQr(session, crop, values)) { hasError = true; }
|
|
244
|
+
} finally {
|
|
245
|
+
if (crop != bitmap) { crop.recycle(); }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
WritableMap page = Arguments.createMap();
|
|
250
|
+
String status = hasError ? "error" : (values.isEmpty() ? "not-detected" : "decoded");
|
|
251
|
+
DocumentScanLog.event("page.qr", trace + " status=" + status + " scanQr=" + scanQr
|
|
252
|
+
+ " cropAttempts=" + cropAttempts + " elapsedMs=" + (SystemClock.elapsedRealtime() - qrStartedAt)
|
|
253
|
+
+ " rawValues=" + new JSONArray(values));
|
|
254
|
+
WritableArray rawValues = Arguments.createArray();
|
|
255
|
+
for (String value : values) { rawValues.pushString(value); }
|
|
256
|
+
page.putInt("page", session.pageCount + 1);
|
|
257
|
+
page.putArray("rawValues", rawValues);
|
|
258
|
+
page.putString("status", status);
|
|
259
|
+
if (hasDocument(values)) {
|
|
260
|
+
Bitmap preview = resizeOpaque(bitmap, 1000);
|
|
261
|
+
File file = new File(session.directory, "page-" + (session.pageCount + 1) + ".jpg");
|
|
262
|
+
try (FileOutputStream output = new FileOutputStream(file)) {
|
|
263
|
+
if (preview.compress(Bitmap.CompressFormat.JPEG, 85, output)) {
|
|
264
|
+
page.putString("previewUri", Uri.fromFile(file).toString());
|
|
265
|
+
}
|
|
266
|
+
} catch (IOException error) {
|
|
267
|
+
DocumentScanLog.error("page.preview.error", error);
|
|
268
|
+
// Optional preview failure must preserve successfully decoded QR data.
|
|
269
|
+
} finally {
|
|
270
|
+
preview.recycle();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
Bitmap pdfImage = resizeOpaque(bitmap, 3000);
|
|
274
|
+
try {
|
|
275
|
+
ByteArrayOutputStream jpeg = new ByteArrayOutputStream();
|
|
276
|
+
if (!pdfImage.compress(Bitmap.CompressFormat.JPEG, 92, jpeg)) {
|
|
277
|
+
throw new IOException("Cannot encode PDF page.");
|
|
278
|
+
}
|
|
279
|
+
session.writer.append(jpeg.toByteArray(), pdfImage.getWidth(), pdfImage.getHeight());
|
|
280
|
+
} finally {
|
|
281
|
+
pdfImage.recycle();
|
|
282
|
+
}
|
|
283
|
+
session.pageCount++;
|
|
284
|
+
DocumentScanLog.event("page.complete", trace + " elapsedMs=" + (SystemClock.elapsedRealtime() - startedAt));
|
|
285
|
+
promise.resolve(page);
|
|
286
|
+
} finally {
|
|
287
|
+
bitmap.recycle();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
@ReactMethod
|
|
293
|
+
public void finishSession(String id, Promise promise) {
|
|
294
|
+
execute("pdf.finish", promise, () -> {
|
|
295
|
+
Session session = activeSession(id);
|
|
296
|
+
session.writer.finish();
|
|
297
|
+
session.finished = true;
|
|
298
|
+
session.close();
|
|
299
|
+
WritableMap result = Arguments.createMap();
|
|
300
|
+
result.putString("pdfUri", Uri.fromFile(session.pdf).toString());
|
|
301
|
+
result.putString("directoryUri", Uri.fromFile(session.directory).toString());
|
|
302
|
+
DocumentScanLog.event("pdf.complete", "session=" + id + " pages=" + session.pageCount
|
|
303
|
+
+ " bytes=" + session.pdf.length() + " elapsedMs=" + (SystemClock.elapsedRealtime() - session.startedAt));
|
|
304
|
+
promise.resolve(result);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
@ReactMethod
|
|
309
|
+
public void releaseSession(String id, boolean deleteFiles, Promise promise) {
|
|
310
|
+
execute("session.release", promise, () -> {
|
|
311
|
+
Session session = sessions.remove(id);
|
|
312
|
+
if (session != null) {
|
|
313
|
+
session.close();
|
|
314
|
+
if (deleteFiles) { deleteDirectory(session.directory); }
|
|
315
|
+
}
|
|
316
|
+
DocumentScanLog.event("session.release", "session=" + id + " deleteFiles=" + deleteFiles);
|
|
317
|
+
promise.resolve(null);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private static void deleteDirectory(File directory) {
|
|
322
|
+
File[] children = directory.listFiles();
|
|
323
|
+
if (children != null) {
|
|
324
|
+
for (File file : children) { file.delete(); }
|
|
325
|
+
}
|
|
326
|
+
directory.delete();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
@Override
|
|
330
|
+
public void invalidate() {
|
|
331
|
+
if (!invalidated.compareAndSet(false, true)) { return; }
|
|
332
|
+
DocumentScanLog.event("processor.invalidate", "cleanup=true");
|
|
333
|
+
capture.invalidate();
|
|
334
|
+
// Queue cleanup after any in-flight ML Kit task; never interrupt/recycle its bitmap.
|
|
335
|
+
worker.execute(() -> {
|
|
336
|
+
for (Session session : sessions.values()) {
|
|
337
|
+
session.close();
|
|
338
|
+
deleteDirectory(session.directory);
|
|
339
|
+
}
|
|
340
|
+
sessions.clear();
|
|
341
|
+
});
|
|
342
|
+
worker.shutdown();
|
|
343
|
+
super.invalidate();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package vn.money24h.documentprocessor;
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.ReactPackage;
|
|
4
|
+
import com.facebook.react.bridge.NativeModule;
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext;
|
|
6
|
+
import com.facebook.react.uimanager.ViewManager;
|
|
7
|
+
import java.util.Collections;
|
|
8
|
+
import java.util.List;
|
|
9
|
+
|
|
10
|
+
public final class DocumentProcessorPackage implements ReactPackage {
|
|
11
|
+
@Override
|
|
12
|
+
public List<NativeModule> createNativeModules(ReactApplicationContext context) {
|
|
13
|
+
return Collections.singletonList(new DocumentProcessorModule(context));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@Override
|
|
17
|
+
public List<ViewManager> createViewManagers(ReactApplicationContext context) {
|
|
18
|
+
return Collections.emptyList();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
package vn.money24h.documentprocessor;
|
|
2
|
+
|
|
3
|
+
import android.util.Log;
|
|
4
|
+
|
|
5
|
+
/** Opt-in diagnostics, including in release: adb shell setprop log.tag.DocumentScan DEBUG. */
|
|
6
|
+
final class DocumentScanLog {
|
|
7
|
+
private static final String TAG = "DocumentScan";
|
|
8
|
+
|
|
9
|
+
private DocumentScanLog() { }
|
|
10
|
+
|
|
11
|
+
static void event(String event, String details) {
|
|
12
|
+
if (!Log.isLoggable(TAG, Log.DEBUG)) { return; }
|
|
13
|
+
String message = details == null ? "" : details;
|
|
14
|
+
// Stay below Logcat's UTF-8 entry limit, including Vietnamese/QR payloads.
|
|
15
|
+
int chunks = Math.max(1, (message.length() + 799) / 800);
|
|
16
|
+
for (int index = 0; index < chunks; index++) {
|
|
17
|
+
String prefix = event + (chunks > 1 ? " [" + (index + 1) + "/" + chunks + "]" : "");
|
|
18
|
+
Log.d(TAG, prefix + " " + message.substring(index * 800, Math.min(message.length(), (index + 1) * 800)));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static void error(String event, Exception error) {
|
|
23
|
+
if (Log.isLoggable(TAG, Log.DEBUG)) {
|
|
24
|
+
event(event, Log.getStackTraceString(error));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
package vn.money24h.documentprocessor;
|
|
2
|
+
|
|
3
|
+
import java.io.Closeable;
|
|
4
|
+
import java.io.File;
|
|
5
|
+
import java.io.IOException;
|
|
6
|
+
import java.io.RandomAccessFile;
|
|
7
|
+
import java.nio.charset.StandardCharsets;
|
|
8
|
+
import java.util.ArrayList;
|
|
9
|
+
import java.util.List;
|
|
10
|
+
import java.util.Locale;
|
|
11
|
+
|
|
12
|
+
/** Image-only PDF writer. Streams each JPEG immediately instead of retaining page bitmaps. */
|
|
13
|
+
final class JpegPdfWriter implements Closeable {
|
|
14
|
+
private final RandomAccessFile output;
|
|
15
|
+
private final List<Long> offsets = new ArrayList<>();
|
|
16
|
+
private final List<Integer> pages = new ArrayList<>();
|
|
17
|
+
private boolean finished;
|
|
18
|
+
|
|
19
|
+
JpegPdfWriter(File file) throws IOException {
|
|
20
|
+
output = new RandomAccessFile(file, "rw");
|
|
21
|
+
try {
|
|
22
|
+
output.setLength(0);
|
|
23
|
+
offsets.add(0L); // free object
|
|
24
|
+
offsets.add(0L); // catalog, written at finish
|
|
25
|
+
offsets.add(0L); // page tree, written at finish
|
|
26
|
+
text("%PDF-1.4\n%\u00e2\u00e3\u00cf\u00d3\n");
|
|
27
|
+
} catch (IOException error) {
|
|
28
|
+
output.close();
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private void text(String value) throws IOException {
|
|
34
|
+
output.write(value.getBytes(StandardCharsets.ISO_8859_1));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private void object(int id) throws IOException {
|
|
38
|
+
while (offsets.size() <= id) { offsets.add(0L); }
|
|
39
|
+
offsets.set(id, output.getFilePointer());
|
|
40
|
+
text(id + " 0 obj\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
void append(byte[] jpeg, int width, int height) throws IOException {
|
|
44
|
+
if (finished || width < 1 || height < 1 || jpeg.length == 0) {
|
|
45
|
+
throw new IOException("Invalid PDF page or closed writer.");
|
|
46
|
+
}
|
|
47
|
+
int imageId = offsets.size();
|
|
48
|
+
int contentId = imageId + 1;
|
|
49
|
+
int pageId = imageId + 2;
|
|
50
|
+
// The caller encodes an opaque sRGB JPEG, so DeviceRGB is explicit.
|
|
51
|
+
object(imageId);
|
|
52
|
+
text("<< /Type /XObject /Subtype /Image /Width " + width + " /Height " + height
|
|
53
|
+
+ " /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length "
|
|
54
|
+
+ jpeg.length + " >>\nstream\n");
|
|
55
|
+
output.write(jpeg);
|
|
56
|
+
text("\nendstream\nendobj\n");
|
|
57
|
+
String pageWidth = String.format(Locale.US, "%.3f", width * 72.0 / 150.0);
|
|
58
|
+
String pageHeight = String.format(Locale.US, "%.3f", height * 72.0 / 150.0);
|
|
59
|
+
byte[] content = ("q\n" + pageWidth + " 0 0 " + pageHeight
|
|
60
|
+
+ " 0 0 cm\n/Im0 Do\nQ\n").getBytes(StandardCharsets.US_ASCII);
|
|
61
|
+
object(contentId);
|
|
62
|
+
text("<< /Length " + content.length + " >>\nstream\n");
|
|
63
|
+
output.write(content);
|
|
64
|
+
text("endstream\nendobj\n");
|
|
65
|
+
object(pageId);
|
|
66
|
+
text("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + pageWidth + " " + pageHeight
|
|
67
|
+
+ "] /Resources << /XObject << /Im0 " + imageId
|
|
68
|
+
+ " 0 R >> >> /Contents " + contentId + " 0 R >>\nendobj\n");
|
|
69
|
+
pages.add(pageId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
void finish() throws IOException {
|
|
73
|
+
if (finished || pages.isEmpty()) { throw new IOException("Empty or closed PDF."); }
|
|
74
|
+
object(1);
|
|
75
|
+
text("<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
|
|
76
|
+
object(2);
|
|
77
|
+
text("<< /Type /Pages /Count " + pages.size() + " /Kids [");
|
|
78
|
+
for (int pageId : pages) { text(pageId + " 0 R "); }
|
|
79
|
+
text("] >>\nendobj\n");
|
|
80
|
+
long xref = output.getFilePointer();
|
|
81
|
+
text("xref\n0 " + offsets.size() + "\n0000000000 65535 f \n");
|
|
82
|
+
for (int id = 1; id < offsets.size(); id++) {
|
|
83
|
+
text(String.format(Locale.US, "%010d 00000 n \n", offsets.get(id)));
|
|
84
|
+
}
|
|
85
|
+
text("trailer\n<< /Size " + offsets.size() + " /Root 1 0 R >>\nstartxref\n"
|
|
86
|
+
+ xref + "\n%%EOF\n");
|
|
87
|
+
output.getFD().sync();
|
|
88
|
+
finished = true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@Override
|
|
92
|
+
public void close() throws IOException { output.close(); }
|
|
93
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#import <React/RCTBridgeModule.h>
|
|
2
|
+
|
|
3
|
+
@interface DPDocumentCapture : NSObject
|
|
4
|
+
- (instancetype)initWithWorker:(dispatch_queue_t)worker;
|
|
5
|
+
- (void)scanWithQuality:(double)quality
|
|
6
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
7
|
+
reject:(RCTPromiseRejectBlock)reject;
|
|
8
|
+
- (void)invalidate;
|
|
9
|
+
@end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#import "DPDocumentCapture.h"
|
|
2
|
+
#import <React/RCTUtils.h>
|
|
3
|
+
#import <VisionKit/VisionKit.h>
|
|
4
|
+
|
|
5
|
+
@interface DPDocumentCapture () <VNDocumentCameraViewControllerDelegate>
|
|
6
|
+
@property(atomic, assign) BOOL invalidated;
|
|
7
|
+
@end
|
|
8
|
+
|
|
9
|
+
@implementation DPDocumentCapture {
|
|
10
|
+
dispatch_queue_t _worker;
|
|
11
|
+
VNDocumentCameraViewController *_controller;
|
|
12
|
+
RCTPromiseResolveBlock _resolve;
|
|
13
|
+
RCTPromiseRejectBlock _reject;
|
|
14
|
+
double _quality;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
- (instancetype)initWithWorker:(dispatch_queue_t)worker {
|
|
18
|
+
if ((self = [super init])) { _worker = worker; }
|
|
19
|
+
return self;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// All camera/promise state is accessed on the main queue.
|
|
23
|
+
- (void)scanWithQuality:(double)quality
|
|
24
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
25
|
+
reject:(RCTPromiseRejectBlock)reject {
|
|
26
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
27
|
+
if (self.invalidated || self->_resolve) {
|
|
28
|
+
reject(@"DOCUMENT_CAPTURE_BUSY", @"Document camera is busy or closed.", nil);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (!isfinite(quality) || quality < 0 || quality > 100) {
|
|
32
|
+
reject(@"DOCUMENT_CAPTURE_QUALITY", @"Invalid JPEG quality.", nil);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
UIViewController *presenter = RCTPresentedViewController();
|
|
36
|
+
if (!VNDocumentCameraViewController.isSupported || !presenter || !presenter.view.window) {
|
|
37
|
+
reject(@"DOCUMENT_CAPTURE_UNAVAILABLE", @"Document camera is unavailable on this device.", nil);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
self->_resolve = resolve;
|
|
41
|
+
self->_reject = reject;
|
|
42
|
+
self->_quality = quality / 100.0;
|
|
43
|
+
self->_controller = [VNDocumentCameraViewController new];
|
|
44
|
+
self->_controller.delegate = self;
|
|
45
|
+
[presenter presentViewController:self->_controller animated:YES completion:nil];
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
- (void)clear {
|
|
50
|
+
_controller.delegate = nil;
|
|
51
|
+
_controller = nil;
|
|
52
|
+
_resolve = nil;
|
|
53
|
+
_reject = nil;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
- (void)documentCameraViewControllerDidCancel:(VNDocumentCameraViewController *)controller {
|
|
57
|
+
[controller dismissViewControllerAnimated:YES completion:^{
|
|
58
|
+
RCTPromiseResolveBlock resolve = self->_resolve;
|
|
59
|
+
[self clear];
|
|
60
|
+
if (resolve) { resolve(@{@"status": @"cancel", @"scannedImages": @[]}); }
|
|
61
|
+
}];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
- (void)documentCameraViewController:(VNDocumentCameraViewController *)controller
|
|
65
|
+
didFailWithError:(NSError *)error {
|
|
66
|
+
[controller dismissViewControllerAnimated:YES completion:^{
|
|
67
|
+
RCTPromiseRejectBlock reject = self->_reject;
|
|
68
|
+
[self clear];
|
|
69
|
+
if (reject) { reject(@"DOCUMENT_CAPTURE_ERROR", error.localizedDescription, error); }
|
|
70
|
+
}];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
- (void)documentCameraViewController:(VNDocumentCameraViewController *)controller
|
|
74
|
+
didFinishWithScan:(VNDocumentCameraScan *)scan {
|
|
75
|
+
double quality = _quality;
|
|
76
|
+
[controller dismissViewControllerAnimated:YES completion:^{
|
|
77
|
+
dispatch_async(self->_worker, ^{
|
|
78
|
+
NSMutableArray<NSString *> *paths = [NSMutableArray new];
|
|
79
|
+
NSError *error = nil;
|
|
80
|
+
NSURL *cache = [[NSFileManager defaultManager] URLsForDirectory:NSCachesDirectory
|
|
81
|
+
inDomains:NSUserDomainMask].firstObject;
|
|
82
|
+
for (NSUInteger index = 0; index < scan.pageCount && !self.invalidated; index++) {
|
|
83
|
+
@autoreleasepool {
|
|
84
|
+
NSURL *url = [cache URLByAppendingPathComponent:
|
|
85
|
+
[NSString stringWithFormat:@"document-capture-%@.jpg", NSUUID.UUID.UUIDString]];
|
|
86
|
+
NSData *jpeg = UIImageJPEGRepresentation([scan imageOfPageAtIndex:index], quality);
|
|
87
|
+
if (!jpeg || !url) {
|
|
88
|
+
error = [NSError errorWithDomain:@"DocumentCapture" code:1 userInfo:
|
|
89
|
+
@{NSLocalizedDescriptionKey: @"Cannot encode scanned page."}];
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
[paths addObject:url.absoluteString];
|
|
93
|
+
if (![jpeg writeToURL:url options:NSDataWritingAtomic error:&error]) { break; }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!scan.pageCount && !error) {
|
|
97
|
+
error = [NSError errorWithDomain:@"DocumentCapture" code:2 userInfo:
|
|
98
|
+
@{NSLocalizedDescriptionKey: @"Document camera returned no pages."}];
|
|
99
|
+
}
|
|
100
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
101
|
+
RCTPromiseResolveBlock resolve = self->_resolve;
|
|
102
|
+
RCTPromiseRejectBlock reject = self->_reject;
|
|
103
|
+
[self clear];
|
|
104
|
+
if (error || self.invalidated || !resolve) {
|
|
105
|
+
dispatch_async(self->_worker, ^{
|
|
106
|
+
for (NSString *path in paths) {
|
|
107
|
+
[[NSFileManager defaultManager] removeItemAtURL:[NSURL URLWithString:path] error:nil];
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
if (reject) { reject(@"DOCUMENT_CAPTURE_ERROR", error.localizedDescription, error); }
|
|
111
|
+
} else {
|
|
112
|
+
resolve(@{@"status": @"success", @"scannedImages": paths});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
- (void)invalidate {
|
|
120
|
+
self.invalidated = YES;
|
|
121
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
122
|
+
[self->_controller dismissViewControllerAnimated:NO completion:nil];
|
|
123
|
+
RCTPromiseRejectBlock reject = self->_reject;
|
|
124
|
+
[self clear];
|
|
125
|
+
if (reject) { reject(@"DOCUMENT_CAPTURE_CANCELLED", @"Document camera was closed.", nil); }
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
@end
|