react-native-rectangle-doc-scanner 3.122.0 → 3.124.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/dist/FullDocScanner.d.ts
CHANGED
package/dist/FullDocScanner.js
CHANGED
|
@@ -43,15 +43,6 @@ const react_native_image_picker_1 = require("react-native-image-picker");
|
|
|
43
43
|
const react_native_image_crop_picker_1 = __importDefault(require("react-native-image-crop-picker"));
|
|
44
44
|
const react_native_fs_1 = __importDefault(require("react-native-fs"));
|
|
45
45
|
const DocScanner_1 = require("./DocScanner");
|
|
46
|
-
let ImageManipulator = null;
|
|
47
|
-
try {
|
|
48
|
-
ImageManipulator = require('expo-image-manipulator');
|
|
49
|
-
}
|
|
50
|
-
catch (error) {
|
|
51
|
-
console.warn('[FullDocScanner] expo-image-manipulator module unavailable.', error);
|
|
52
|
-
}
|
|
53
|
-
let expoManipulatorUnavailable = false;
|
|
54
|
-
const isExpoImageManipulatorAvailable = () => !!ImageManipulator?.manipulateAsync && !expoManipulatorUnavailable;
|
|
55
46
|
// 회전은 항상 지원됨 (회전 각도를 반환하고 tdb 앱에서 처리)
|
|
56
47
|
const isImageRotationSupported = () => true;
|
|
57
48
|
const stripFileUri = (value) => value.replace(/^file:\/\//, '');
|
|
@@ -105,6 +96,11 @@ async function withTimeout(factory) {
|
|
|
105
96
|
}
|
|
106
97
|
}
|
|
107
98
|
}
|
|
99
|
+
const AUTO_ENHANCE_PARAMS = {
|
|
100
|
+
brightness: 0.08,
|
|
101
|
+
contrast: 1.05,
|
|
102
|
+
saturation: 0.92,
|
|
103
|
+
};
|
|
108
104
|
const normalizeCapturedDocument = (document) => {
|
|
109
105
|
const { origin: _origin, ...rest } = document;
|
|
110
106
|
const normalizedPath = stripFileUri(document.initialPath ?? document.path);
|
|
@@ -125,6 +121,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
125
121
|
const [rotationDegrees, setRotationDegrees] = (0, react_1.useState)(0);
|
|
126
122
|
const [capturedPhotos, setCapturedPhotos] = (0, react_1.useState)([]);
|
|
127
123
|
const [currentPhotoIndex, setCurrentPhotoIndex] = (0, react_1.useState)(0);
|
|
124
|
+
const [scannerSession, setScannerSession] = (0, react_1.useState)(0);
|
|
128
125
|
const resolvedGridColor = gridColor ?? overlayColor;
|
|
129
126
|
const docScannerRef = (0, react_1.useRef)(null);
|
|
130
127
|
const captureModeRef = (0, react_1.useRef)(null);
|
|
@@ -132,6 +129,29 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
132
129
|
const rectangleCaptureTimeoutRef = (0, react_1.useRef)(null);
|
|
133
130
|
const rectangleHintTimeoutRef = (0, react_1.useRef)(null);
|
|
134
131
|
const isBusinessMode = type === 'business';
|
|
132
|
+
const resetScannerView = (0, react_1.useCallback)((options) => {
|
|
133
|
+
setProcessing(false);
|
|
134
|
+
setCroppedImageData(null);
|
|
135
|
+
setRotationDegrees(0);
|
|
136
|
+
setRectangleDetected(false);
|
|
137
|
+
setRectangleHint(false);
|
|
138
|
+
captureModeRef.current = null;
|
|
139
|
+
captureInProgressRef.current = false;
|
|
140
|
+
if (rectangleCaptureTimeoutRef.current) {
|
|
141
|
+
clearTimeout(rectangleCaptureTimeoutRef.current);
|
|
142
|
+
rectangleCaptureTimeoutRef.current = null;
|
|
143
|
+
}
|
|
144
|
+
if (rectangleHintTimeoutRef.current) {
|
|
145
|
+
clearTimeout(rectangleHintTimeoutRef.current);
|
|
146
|
+
rectangleHintTimeoutRef.current = null;
|
|
147
|
+
}
|
|
148
|
+
if (docScannerRef.current?.reset) {
|
|
149
|
+
docScannerRef.current.reset();
|
|
150
|
+
}
|
|
151
|
+
if (options?.remount) {
|
|
152
|
+
setScannerSession((prev) => prev + 1);
|
|
153
|
+
}
|
|
154
|
+
}, []);
|
|
135
155
|
const mergedStrings = (0, react_1.useMemo)(() => ({
|
|
136
156
|
captureHint: strings?.captureHint,
|
|
137
157
|
manualHint: strings?.manualHint,
|
|
@@ -145,7 +165,58 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
145
165
|
second: strings?.second ?? 'Back',
|
|
146
166
|
secondBtn: strings?.secondBtn ?? 'Capture Back Side?',
|
|
147
167
|
secondPrompt: strings?.secondPrompt ?? strings?.secondBtn ?? 'Capture Back Side?',
|
|
168
|
+
originalBtn: strings?.originalBtn ?? 'Use Original',
|
|
148
169
|
}), [strings]);
|
|
170
|
+
const pdfScannerManager = react_native_1.NativeModules?.RNPdfScannerManager;
|
|
171
|
+
const autoEnhancementEnabled = (0, react_1.useMemo)(() => typeof pdfScannerManager?.applyColorControls === 'function', [pdfScannerManager]);
|
|
172
|
+
const ensureBase64ForImage = (0, react_1.useCallback)(async (image) => {
|
|
173
|
+
if (image.base64) {
|
|
174
|
+
return image;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const base64Data = await react_native_fs_1.default.readFile(image.path, 'base64');
|
|
178
|
+
return {
|
|
179
|
+
...image,
|
|
180
|
+
base64: base64Data,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
console.warn('[FullDocScanner] Failed to generate base64 for image:', error);
|
|
185
|
+
return image;
|
|
186
|
+
}
|
|
187
|
+
}, []);
|
|
188
|
+
const applyAutoEnhancement = (0, react_1.useCallback)(async (image) => {
|
|
189
|
+
if (!autoEnhancementEnabled || !pdfScannerManager?.applyColorControls) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const outputPath = await pdfScannerManager.applyColorControls(image.path, AUTO_ENHANCE_PARAMS.brightness, AUTO_ENHANCE_PARAMS.contrast, AUTO_ENHANCE_PARAMS.saturation);
|
|
194
|
+
return {
|
|
195
|
+
path: stripFileUri(outputPath),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
console.error('[FullDocScanner] Auto enhancement failed:', error);
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}, [autoEnhancementEnabled, pdfScannerManager]);
|
|
203
|
+
const preparePreviewImage = (0, react_1.useCallback)(async (image) => {
|
|
204
|
+
const original = await ensureBase64ForImage(image);
|
|
205
|
+
const enhancedCandidate = await applyAutoEnhancement(original);
|
|
206
|
+
if (enhancedCandidate) {
|
|
207
|
+
const enhanced = await ensureBase64ForImage(enhancedCandidate);
|
|
208
|
+
return {
|
|
209
|
+
original,
|
|
210
|
+
enhanced,
|
|
211
|
+
useOriginal: false,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
original,
|
|
216
|
+
useOriginal: true,
|
|
217
|
+
};
|
|
218
|
+
}, [applyAutoEnhancement, ensureBase64ForImage]);
|
|
219
|
+
const getActivePreviewImage = (0, react_1.useCallback)((preview) => preview.useOriginal || !preview.enhanced ? preview.original : preview.enhanced, []);
|
|
149
220
|
const emitError = (0, react_1.useCallback)((error, fallbackMessage) => {
|
|
150
221
|
console.error('[FullDocScanner] error', error);
|
|
151
222
|
onError?.(error);
|
|
@@ -183,23 +254,16 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
183
254
|
path: croppedImage.path,
|
|
184
255
|
hasBase64: !!croppedImage.data,
|
|
185
256
|
});
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
path: croppedImage.path,
|
|
257
|
+
const sanitizedPath = stripFileUri(croppedImage.path);
|
|
258
|
+
const preview = await preparePreviewImage({
|
|
259
|
+
path: sanitizedPath,
|
|
190
260
|
base64: croppedImage.data ?? undefined,
|
|
191
261
|
});
|
|
262
|
+
setCroppedImageData(preview);
|
|
263
|
+
setProcessing(false);
|
|
192
264
|
}
|
|
193
265
|
catch (error) {
|
|
194
|
-
|
|
195
|
-
// Reset capture state when cropper fails or is cancelled
|
|
196
|
-
captureInProgressRef.current = false;
|
|
197
|
-
captureModeRef.current = null;
|
|
198
|
-
setRectangleDetected(false);
|
|
199
|
-
setRectangleHint(false);
|
|
200
|
-
if (docScannerRef.current?.reset) {
|
|
201
|
-
docScannerRef.current.reset();
|
|
202
|
-
}
|
|
266
|
+
resetScannerView({ remount: true });
|
|
203
267
|
const errorCode = error?.code;
|
|
204
268
|
const errorMessageRaw = error?.message ?? String(error);
|
|
205
269
|
const errorMessage = typeof errorMessageRaw === 'string' ? errorMessageRaw : String(errorMessageRaw);
|
|
@@ -220,7 +284,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
220
284
|
emitError(error instanceof Error ? error : new Error(errorMessage), 'Failed to crop image. Please try again.');
|
|
221
285
|
}
|
|
222
286
|
}
|
|
223
|
-
}, [cropWidth, cropHeight, emitError]);
|
|
287
|
+
}, [cropWidth, cropHeight, emitError, preparePreviewImage, resetScannerView]);
|
|
224
288
|
const handleCapture = (0, react_1.useCallback)(async (document) => {
|
|
225
289
|
console.log('[FullDocScanner] handleCapture called:', {
|
|
226
290
|
origin: document.origin,
|
|
@@ -246,27 +310,26 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
246
310
|
}
|
|
247
311
|
if (normalizedDoc.croppedPath) {
|
|
248
312
|
console.log('[FullDocScanner] Grid detected: using pre-cropped image', normalizedDoc.croppedPath);
|
|
249
|
-
|
|
313
|
+
setProcessing(true);
|
|
250
314
|
try {
|
|
251
|
-
const
|
|
252
|
-
console.log('[FullDocScanner] Generated base64 for pre-cropped image, length:', base64Data.length);
|
|
253
|
-
setCroppedImageData({
|
|
315
|
+
const preview = await preparePreviewImage({
|
|
254
316
|
path: normalizedDoc.croppedPath,
|
|
255
|
-
base64: base64Data,
|
|
256
317
|
});
|
|
318
|
+
setCroppedImageData(preview);
|
|
257
319
|
}
|
|
258
320
|
catch (error) {
|
|
259
|
-
console.error('[FullDocScanner] Failed to
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
321
|
+
console.error('[FullDocScanner] Failed to prepare preview image:', error);
|
|
322
|
+
resetScannerView({ remount: true });
|
|
323
|
+
emitError(error instanceof Error ? error : new Error(String(error)), 'Failed to process captured image.');
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
setProcessing(false);
|
|
264
327
|
}
|
|
265
328
|
return;
|
|
266
329
|
}
|
|
267
330
|
console.log('[FullDocScanner] Fallback to manual crop (no croppedPath available)');
|
|
268
331
|
await openCropper(normalizedDoc.path, { waitForPickerDismissal: false });
|
|
269
|
-
}, [openCropper]);
|
|
332
|
+
}, [emitError, openCropper, preparePreviewImage, resetScannerView]);
|
|
270
333
|
const triggerManualCapture = (0, react_1.useCallback)(() => {
|
|
271
334
|
const scannerInstance = docScannerRef.current;
|
|
272
335
|
const hasScanner = !!scannerInstance;
|
|
@@ -389,13 +452,14 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
389
452
|
if (!croppedImageData) {
|
|
390
453
|
return;
|
|
391
454
|
}
|
|
455
|
+
const activeImage = getActivePreviewImage(croppedImageData);
|
|
392
456
|
// 회전 각도 정규화 (0, 90, 180, 270)
|
|
393
457
|
const rotationNormalized = ((rotationDegrees % 360) + 360) % 360;
|
|
394
458
|
console.log('[FullDocScanner] Confirm - rotation degrees:', rotationDegrees, 'normalized:', rotationNormalized);
|
|
395
459
|
// 현재 사진을 capturedPhotos에 추가
|
|
396
460
|
const currentPhoto = {
|
|
397
|
-
path:
|
|
398
|
-
base64:
|
|
461
|
+
path: activeImage.path,
|
|
462
|
+
base64: activeImage.base64,
|
|
399
463
|
rotationDegrees: rotationNormalized,
|
|
400
464
|
};
|
|
401
465
|
const updatedPhotos = [...capturedPhotos, currentPhoto];
|
|
@@ -403,7 +467,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
403
467
|
// 결과 반환
|
|
404
468
|
console.log('[FullDocScanner] Returning results');
|
|
405
469
|
onResult(updatedPhotos);
|
|
406
|
-
}, [croppedImageData, rotationDegrees, capturedPhotos, onResult]);
|
|
470
|
+
}, [croppedImageData, rotationDegrees, capturedPhotos, getActivePreviewImage, onResult]);
|
|
407
471
|
const handleCaptureSecondPhoto = (0, react_1.useCallback)(() => {
|
|
408
472
|
console.log('[FullDocScanner] Capturing second photo');
|
|
409
473
|
if (!croppedImageData) {
|
|
@@ -411,33 +475,17 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
411
475
|
}
|
|
412
476
|
// 현재 사진(앞면)을 먼저 저장
|
|
413
477
|
const rotationNormalized = ((rotationDegrees % 360) + 360) % 360;
|
|
478
|
+
const activeImage = getActivePreviewImage(croppedImageData);
|
|
414
479
|
const currentPhoto = {
|
|
415
|
-
path:
|
|
416
|
-
base64:
|
|
480
|
+
path: activeImage.path,
|
|
481
|
+
base64: activeImage.base64,
|
|
417
482
|
rotationDegrees: rotationNormalized,
|
|
418
483
|
};
|
|
419
484
|
setCapturedPhotos([currentPhoto]);
|
|
420
485
|
setCurrentPhotoIndex(1);
|
|
421
486
|
// 확인 화면을 닫고 카메라로 돌아감
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
setProcessing(false);
|
|
425
|
-
setRectangleDetected(false);
|
|
426
|
-
setRectangleHint(false);
|
|
427
|
-
captureModeRef.current = null;
|
|
428
|
-
captureInProgressRef.current = false;
|
|
429
|
-
if (rectangleCaptureTimeoutRef.current) {
|
|
430
|
-
clearTimeout(rectangleCaptureTimeoutRef.current);
|
|
431
|
-
rectangleCaptureTimeoutRef.current = null;
|
|
432
|
-
}
|
|
433
|
-
if (rectangleHintTimeoutRef.current) {
|
|
434
|
-
clearTimeout(rectangleHintTimeoutRef.current);
|
|
435
|
-
rectangleHintTimeoutRef.current = null;
|
|
436
|
-
}
|
|
437
|
-
if (docScannerRef.current?.reset) {
|
|
438
|
-
docScannerRef.current.reset();
|
|
439
|
-
}
|
|
440
|
-
}, [croppedImageData, rotationDegrees]);
|
|
487
|
+
resetScannerView({ remount: true });
|
|
488
|
+
}, [croppedImageData, getActivePreviewImage, resetScannerView, rotationDegrees]);
|
|
441
489
|
const handleRetake = (0, react_1.useCallback)(() => {
|
|
442
490
|
console.log('[FullDocScanner] Retake - clearing cropped image and resetting scanner');
|
|
443
491
|
// Business 모드에서 두 번째 사진을 다시 찍는 경우, 첫 번째 사진 유지
|
|
@@ -451,26 +499,8 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
451
499
|
setCapturedPhotos([]);
|
|
452
500
|
setCurrentPhotoIndex(0);
|
|
453
501
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
setProcessing(false);
|
|
457
|
-
setRectangleDetected(false);
|
|
458
|
-
setRectangleHint(false);
|
|
459
|
-
captureModeRef.current = null;
|
|
460
|
-
captureInProgressRef.current = false;
|
|
461
|
-
if (rectangleCaptureTimeoutRef.current) {
|
|
462
|
-
clearTimeout(rectangleCaptureTimeoutRef.current);
|
|
463
|
-
rectangleCaptureTimeoutRef.current = null;
|
|
464
|
-
}
|
|
465
|
-
if (rectangleHintTimeoutRef.current) {
|
|
466
|
-
clearTimeout(rectangleHintTimeoutRef.current);
|
|
467
|
-
rectangleHintTimeoutRef.current = null;
|
|
468
|
-
}
|
|
469
|
-
// Reset DocScanner state
|
|
470
|
-
if (docScannerRef.current?.reset) {
|
|
471
|
-
docScannerRef.current.reset();
|
|
472
|
-
}
|
|
473
|
-
}, [capturedPhotos.length, isBusinessMode]);
|
|
502
|
+
resetScannerView({ remount: true });
|
|
503
|
+
}, [capturedPhotos.length, isBusinessMode, resetScannerView]);
|
|
474
504
|
const handleRectangleDetect = (0, react_1.useCallback)((event) => {
|
|
475
505
|
const stableCounter = event.stableCounter ?? 0;
|
|
476
506
|
const rectangleCoordinates = event.rectangleOnScreen ?? event.rectangleCoordinates;
|
|
@@ -519,6 +549,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
519
549
|
clearTimeout(rectangleHintTimeoutRef.current);
|
|
520
550
|
}
|
|
521
551
|
}, []);
|
|
552
|
+
const activePreviewImage = croppedImageData ? getActivePreviewImage(croppedImageData) : null;
|
|
522
553
|
return (react_1.default.createElement(react_native_1.View, { style: styles.container },
|
|
523
554
|
croppedImageData ? (
|
|
524
555
|
// check_DP: Show confirmation screen
|
|
@@ -539,16 +570,24 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
539
570
|
react_1.default.createElement(react_native_1.Text, { style: styles.rotateButtonLabel }, "\uC6B0\uB85C 90\u00B0")))) : null,
|
|
540
571
|
isBusinessMode && capturedPhotos.length === 0 && (react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.captureBackButton, onPress: handleCaptureSecondPhoto, accessibilityLabel: mergedStrings.secondBtn, accessibilityRole: "button" },
|
|
541
572
|
react_1.default.createElement(react_native_1.Text, { style: styles.captureBackButtonText }, mergedStrings.secondBtn))),
|
|
542
|
-
react_1.default.createElement(react_native_1.Image, { source: { uri:
|
|
573
|
+
activePreviewImage ? (react_1.default.createElement(react_native_1.Image, { source: { uri: activePreviewImage.path }, style: [
|
|
543
574
|
styles.previewImage,
|
|
544
575
|
{ transform: [{ rotate: `${rotationDegrees}deg` }] }
|
|
545
|
-
], resizeMode: "contain" }),
|
|
576
|
+
], resizeMode: "contain" })) : null,
|
|
577
|
+
croppedImageData.enhanced ? (react_1.default.createElement(react_native_1.TouchableOpacity, { style: [
|
|
578
|
+
styles.originalToggleButton,
|
|
579
|
+
croppedImageData.useOriginal && styles.originalToggleButtonActive,
|
|
580
|
+
], onPress: () => setCroppedImageData((prev) => prev ? { ...prev, useOriginal: !prev.useOriginal } : prev), accessibilityRole: "button", accessibilityLabel: mergedStrings.originalBtn },
|
|
581
|
+
react_1.default.createElement(react_native_1.Text, { style: [
|
|
582
|
+
styles.originalToggleButtonText,
|
|
583
|
+
croppedImageData.useOriginal && styles.originalToggleButtonTextActive,
|
|
584
|
+
] }, mergedStrings.originalBtn))) : null,
|
|
546
585
|
react_1.default.createElement(react_native_1.View, { style: styles.confirmationButtons },
|
|
547
586
|
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.retakeButton], onPress: handleRetake, accessibilityLabel: mergedStrings.retake, accessibilityRole: "button" },
|
|
548
587
|
react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.retake)),
|
|
549
588
|
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.confirmButtonPrimary], onPress: handleConfirm, accessibilityLabel: mergedStrings.confirm, accessibilityRole: "button" },
|
|
550
589
|
react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.confirm))))) : (react_1.default.createElement(react_native_1.View, { style: styles.flex },
|
|
551
|
-
react_1.default.createElement(DocScanner_1.DocScanner, { ref: docScannerRef, autoCapture: false, overlayColor: overlayColor, showGrid: showGrid, gridColor: resolvedGridColor, gridLineWidth: gridLineWidth, minStableFrames: minStableFrames ?? 6, detectionConfig: detectionConfig, onCapture: handleCapture, onRectangleDetect: handleRectangleDetect, showManualCaptureButton: false, enableTorch: flashEnabled },
|
|
590
|
+
react_1.default.createElement(DocScanner_1.DocScanner, { key: scannerSession, ref: docScannerRef, autoCapture: false, overlayColor: overlayColor, showGrid: showGrid, gridColor: resolvedGridColor, gridLineWidth: gridLineWidth, minStableFrames: minStableFrames ?? 6, detectionConfig: detectionConfig, onCapture: handleCapture, onRectangleDetect: handleRectangleDetect, showManualCaptureButton: false, enableTorch: flashEnabled },
|
|
552
591
|
react_1.default.createElement(react_native_1.View, { style: styles.overlayTop, pointerEvents: "box-none" },
|
|
553
592
|
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [
|
|
554
593
|
styles.iconButton,
|
|
@@ -754,6 +793,28 @@ const styles = react_native_1.StyleSheet.create({
|
|
|
754
793
|
gap: 24,
|
|
755
794
|
paddingVertical: 32,
|
|
756
795
|
},
|
|
796
|
+
originalToggleButton: {
|
|
797
|
+
alignSelf: 'center',
|
|
798
|
+
marginTop: 16,
|
|
799
|
+
paddingVertical: 10,
|
|
800
|
+
paddingHorizontal: 24,
|
|
801
|
+
borderRadius: 24,
|
|
802
|
+
borderWidth: 1,
|
|
803
|
+
borderColor: 'rgba(255,255,255,0.4)',
|
|
804
|
+
backgroundColor: 'rgba(30,30,30,0.7)',
|
|
805
|
+
},
|
|
806
|
+
originalToggleButtonActive: {
|
|
807
|
+
backgroundColor: 'rgba(255,255,255,0.15)',
|
|
808
|
+
borderColor: '#fff',
|
|
809
|
+
},
|
|
810
|
+
originalToggleButtonText: {
|
|
811
|
+
color: '#fff',
|
|
812
|
+
fontSize: 14,
|
|
813
|
+
fontWeight: '600',
|
|
814
|
+
},
|
|
815
|
+
originalToggleButtonTextActive: {
|
|
816
|
+
color: '#fff',
|
|
817
|
+
},
|
|
757
818
|
confirmButton: {
|
|
758
819
|
paddingHorizontal: 40,
|
|
759
820
|
paddingVertical: 16,
|
package/package.json
CHANGED
package/src/FullDocScanner.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
Alert,
|
|
5
5
|
Image,
|
|
6
6
|
InteractionManager,
|
|
7
|
+
NativeModules,
|
|
7
8
|
StyleSheet,
|
|
8
9
|
Text,
|
|
9
10
|
TouchableOpacity,
|
|
@@ -20,23 +21,6 @@ import type {
|
|
|
20
21
|
DocScannerCapture,
|
|
21
22
|
RectangleDetectEvent,
|
|
22
23
|
} from './DocScanner';
|
|
23
|
-
|
|
24
|
-
type ImageManipulatorModule = typeof import('expo-image-manipulator');
|
|
25
|
-
|
|
26
|
-
let ImageManipulator: ImageManipulatorModule | null = null;
|
|
27
|
-
|
|
28
|
-
try {
|
|
29
|
-
ImageManipulator = require('expo-image-manipulator') as ImageManipulatorModule;
|
|
30
|
-
} catch (error) {
|
|
31
|
-
console.warn(
|
|
32
|
-
'[FullDocScanner] expo-image-manipulator module unavailable.',
|
|
33
|
-
error,
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
let expoManipulatorUnavailable = false;
|
|
38
|
-
const isExpoImageManipulatorAvailable = () =>
|
|
39
|
-
!!ImageManipulator?.manipulateAsync && !expoManipulatorUnavailable;
|
|
40
24
|
// 회전은 항상 지원됨 (회전 각도를 반환하고 tdb 앱에서 처리)
|
|
41
25
|
const isImageRotationSupported = () => true;
|
|
42
26
|
|
|
@@ -104,6 +88,19 @@ type OpenCropperOptions = {
|
|
|
104
88
|
waitForPickerDismissal?: boolean;
|
|
105
89
|
};
|
|
106
90
|
|
|
91
|
+
type PreviewImageInfo = { path: string; base64?: string };
|
|
92
|
+
type PreviewImageData = {
|
|
93
|
+
original: PreviewImageInfo;
|
|
94
|
+
enhanced?: PreviewImageInfo;
|
|
95
|
+
useOriginal: boolean;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const AUTO_ENHANCE_PARAMS = {
|
|
99
|
+
brightness: 0.08,
|
|
100
|
+
contrast: 1.05,
|
|
101
|
+
saturation: 0.92,
|
|
102
|
+
};
|
|
103
|
+
|
|
107
104
|
const normalizeCapturedDocument = (document: DocScannerCapture): CapturedDocument => {
|
|
108
105
|
const { origin: _origin, ...rest } = document;
|
|
109
106
|
const normalizedPath = stripFileUri(document.initialPath ?? document.path);
|
|
@@ -139,6 +136,7 @@ export interface FullDocScannerStrings {
|
|
|
139
136
|
second?: string;
|
|
140
137
|
secondBtn?: string;
|
|
141
138
|
secondPrompt?: string;
|
|
139
|
+
originalBtn?: string;
|
|
142
140
|
}
|
|
143
141
|
|
|
144
142
|
export interface FullDocScannerProps {
|
|
@@ -175,7 +173,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
175
173
|
type,
|
|
176
174
|
}) => {
|
|
177
175
|
const [processing, setProcessing] = useState(false);
|
|
178
|
-
const [croppedImageData, setCroppedImageData] = useState<
|
|
176
|
+
const [croppedImageData, setCroppedImageData] = useState<PreviewImageData | null>(null);
|
|
179
177
|
const [isGalleryOpen, setIsGalleryOpen] = useState(false);
|
|
180
178
|
const [rectangleDetected, setRectangleDetected] = useState(false);
|
|
181
179
|
const [rectangleHint, setRectangleHint] = useState(false);
|
|
@@ -183,6 +181,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
183
181
|
const [rotationDegrees, setRotationDegrees] = useState(0);
|
|
184
182
|
const [capturedPhotos, setCapturedPhotos] = useState<FullDocScannerResult[]>([]);
|
|
185
183
|
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
|
184
|
+
const [scannerSession, setScannerSession] = useState(0);
|
|
186
185
|
const resolvedGridColor = gridColor ?? overlayColor;
|
|
187
186
|
const docScannerRef = useRef<DocScannerHandle | null>(null);
|
|
188
187
|
const captureModeRef = useRef<'grid' | 'no-grid' | null>(null);
|
|
@@ -192,6 +191,37 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
192
191
|
|
|
193
192
|
const isBusinessMode = type === 'business';
|
|
194
193
|
|
|
194
|
+
const resetScannerView = useCallback(
|
|
195
|
+
(options?: { remount?: boolean }) => {
|
|
196
|
+
setProcessing(false);
|
|
197
|
+
setCroppedImageData(null);
|
|
198
|
+
setRotationDegrees(0);
|
|
199
|
+
setRectangleDetected(false);
|
|
200
|
+
setRectangleHint(false);
|
|
201
|
+
captureModeRef.current = null;
|
|
202
|
+
captureInProgressRef.current = false;
|
|
203
|
+
|
|
204
|
+
if (rectangleCaptureTimeoutRef.current) {
|
|
205
|
+
clearTimeout(rectangleCaptureTimeoutRef.current);
|
|
206
|
+
rectangleCaptureTimeoutRef.current = null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (rectangleHintTimeoutRef.current) {
|
|
210
|
+
clearTimeout(rectangleHintTimeoutRef.current);
|
|
211
|
+
rectangleHintTimeoutRef.current = null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (docScannerRef.current?.reset) {
|
|
215
|
+
docScannerRef.current.reset();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (options?.remount) {
|
|
219
|
+
setScannerSession((prev) => prev + 1);
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
[],
|
|
223
|
+
);
|
|
224
|
+
|
|
195
225
|
const mergedStrings = useMemo(
|
|
196
226
|
() => ({
|
|
197
227
|
captureHint: strings?.captureHint,
|
|
@@ -206,10 +236,90 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
206
236
|
second: strings?.second ?? 'Back',
|
|
207
237
|
secondBtn: strings?.secondBtn ?? 'Capture Back Side?',
|
|
208
238
|
secondPrompt: strings?.secondPrompt ?? strings?.secondBtn ?? 'Capture Back Side?',
|
|
239
|
+
originalBtn: strings?.originalBtn ?? 'Use Original',
|
|
209
240
|
}),
|
|
210
241
|
[strings],
|
|
211
242
|
);
|
|
212
243
|
|
|
244
|
+
const pdfScannerManager = (NativeModules as any)?.RNPdfScannerManager;
|
|
245
|
+
|
|
246
|
+
const autoEnhancementEnabled = useMemo(
|
|
247
|
+
() => typeof pdfScannerManager?.applyColorControls === 'function',
|
|
248
|
+
[pdfScannerManager],
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const ensureBase64ForImage = useCallback(
|
|
252
|
+
async (image: PreviewImageInfo): Promise<PreviewImageInfo> => {
|
|
253
|
+
if (image.base64) {
|
|
254
|
+
return image;
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
const base64Data = await RNFS.readFile(image.path, 'base64');
|
|
258
|
+
return {
|
|
259
|
+
...image,
|
|
260
|
+
base64: base64Data,
|
|
261
|
+
};
|
|
262
|
+
} catch (error) {
|
|
263
|
+
console.warn('[FullDocScanner] Failed to generate base64 for image:', error);
|
|
264
|
+
return image;
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
[],
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const applyAutoEnhancement = useCallback(
|
|
271
|
+
async (image: PreviewImageInfo): Promise<PreviewImageInfo | null> => {
|
|
272
|
+
if (!autoEnhancementEnabled || !pdfScannerManager?.applyColorControls) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const outputPath: string = await pdfScannerManager.applyColorControls(
|
|
278
|
+
image.path,
|
|
279
|
+
AUTO_ENHANCE_PARAMS.brightness,
|
|
280
|
+
AUTO_ENHANCE_PARAMS.contrast,
|
|
281
|
+
AUTO_ENHANCE_PARAMS.saturation,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
path: stripFileUri(outputPath),
|
|
286
|
+
};
|
|
287
|
+
} catch (error) {
|
|
288
|
+
console.error('[FullDocScanner] Auto enhancement failed:', error);
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
[autoEnhancementEnabled, pdfScannerManager],
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const preparePreviewImage = useCallback(
|
|
296
|
+
async (image: PreviewImageInfo): Promise<PreviewImageData> => {
|
|
297
|
+
const original = await ensureBase64ForImage(image);
|
|
298
|
+
const enhancedCandidate = await applyAutoEnhancement(original);
|
|
299
|
+
|
|
300
|
+
if (enhancedCandidate) {
|
|
301
|
+
const enhanced = await ensureBase64ForImage(enhancedCandidate);
|
|
302
|
+
return {
|
|
303
|
+
original,
|
|
304
|
+
enhanced,
|
|
305
|
+
useOriginal: false,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
original,
|
|
311
|
+
useOriginal: true,
|
|
312
|
+
};
|
|
313
|
+
},
|
|
314
|
+
[applyAutoEnhancement, ensureBase64ForImage],
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const getActivePreviewImage = useCallback(
|
|
318
|
+
(preview: PreviewImageData): PreviewImageInfo =>
|
|
319
|
+
preview.useOriginal || !preview.enhanced ? preview.original : preview.enhanced,
|
|
320
|
+
[],
|
|
321
|
+
);
|
|
322
|
+
|
|
213
323
|
const emitError = useCallback(
|
|
214
324
|
(error: Error, fallbackMessage?: string) => {
|
|
215
325
|
console.error('[FullDocScanner] error', error);
|
|
@@ -260,28 +370,21 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
260
370
|
hasBase64: !!croppedImage.data,
|
|
261
371
|
});
|
|
262
372
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
setCroppedImageData({
|
|
267
|
-
path: croppedImage.path,
|
|
373
|
+
const sanitizedPath = stripFileUri(croppedImage.path);
|
|
374
|
+
const preview = await preparePreviewImage({
|
|
375
|
+
path: sanitizedPath,
|
|
268
376
|
base64: croppedImage.data ?? undefined,
|
|
269
377
|
});
|
|
270
|
-
} catch (error) {
|
|
271
|
-
setProcessing(false);
|
|
272
378
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
setRectangleHint(false);
|
|
278
|
-
if (docScannerRef.current?.reset) {
|
|
279
|
-
docScannerRef.current.reset();
|
|
280
|
-
}
|
|
379
|
+
setCroppedImageData(preview);
|
|
380
|
+
setProcessing(false);
|
|
381
|
+
} catch (error) {
|
|
382
|
+
resetScannerView({ remount: true });
|
|
281
383
|
|
|
282
384
|
const errorCode = (error as any)?.code;
|
|
283
385
|
const errorMessageRaw = (error as any)?.message ?? String(error);
|
|
284
|
-
const errorMessage =
|
|
386
|
+
const errorMessage =
|
|
387
|
+
typeof errorMessageRaw === 'string' ? errorMessageRaw : String(errorMessageRaw);
|
|
285
388
|
const normalizedMessage = errorMessage.toLowerCase();
|
|
286
389
|
const isUserCancelled =
|
|
287
390
|
errorCode === 'E_PICKER_CANCELLED' ||
|
|
@@ -309,7 +412,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
309
412
|
}
|
|
310
413
|
}
|
|
311
414
|
},
|
|
312
|
-
[cropWidth, cropHeight, emitError],
|
|
415
|
+
[cropWidth, cropHeight, emitError, preparePreviewImage, resetScannerView],
|
|
313
416
|
);
|
|
314
417
|
|
|
315
418
|
const handleCapture = useCallback(
|
|
@@ -345,21 +448,21 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
345
448
|
if (normalizedDoc.croppedPath) {
|
|
346
449
|
console.log('[FullDocScanner] Grid detected: using pre-cropped image', normalizedDoc.croppedPath);
|
|
347
450
|
|
|
348
|
-
|
|
451
|
+
setProcessing(true);
|
|
349
452
|
try {
|
|
350
|
-
const
|
|
351
|
-
console.log('[FullDocScanner] Generated base64 for pre-cropped image, length:', base64Data.length);
|
|
352
|
-
|
|
353
|
-
setCroppedImageData({
|
|
453
|
+
const preview = await preparePreviewImage({
|
|
354
454
|
path: normalizedDoc.croppedPath,
|
|
355
|
-
base64: base64Data,
|
|
356
455
|
});
|
|
456
|
+
setCroppedImageData(preview);
|
|
357
457
|
} catch (error) {
|
|
358
|
-
console.error('[FullDocScanner] Failed to
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
458
|
+
console.error('[FullDocScanner] Failed to prepare preview image:', error);
|
|
459
|
+
resetScannerView({ remount: true });
|
|
460
|
+
emitError(
|
|
461
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
462
|
+
'Failed to process captured image.',
|
|
463
|
+
);
|
|
464
|
+
} finally {
|
|
465
|
+
setProcessing(false);
|
|
363
466
|
}
|
|
364
467
|
return;
|
|
365
468
|
}
|
|
@@ -367,7 +470,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
367
470
|
console.log('[FullDocScanner] Fallback to manual crop (no croppedPath available)');
|
|
368
471
|
await openCropper(normalizedDoc.path, { waitForPickerDismissal: false });
|
|
369
472
|
},
|
|
370
|
-
[openCropper],
|
|
473
|
+
[emitError, openCropper, preparePreviewImage, resetScannerView],
|
|
371
474
|
);
|
|
372
475
|
|
|
373
476
|
const triggerManualCapture = useCallback(() => {
|
|
@@ -526,14 +629,16 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
526
629
|
return;
|
|
527
630
|
}
|
|
528
631
|
|
|
632
|
+
const activeImage = getActivePreviewImage(croppedImageData);
|
|
633
|
+
|
|
529
634
|
// 회전 각도 정규화 (0, 90, 180, 270)
|
|
530
635
|
const rotationNormalized = ((rotationDegrees % 360) + 360) % 360;
|
|
531
636
|
console.log('[FullDocScanner] Confirm - rotation degrees:', rotationDegrees, 'normalized:', rotationNormalized);
|
|
532
637
|
|
|
533
638
|
// 현재 사진을 capturedPhotos에 추가
|
|
534
639
|
const currentPhoto: FullDocScannerResult = {
|
|
535
|
-
path:
|
|
536
|
-
base64:
|
|
640
|
+
path: activeImage.path,
|
|
641
|
+
base64: activeImage.base64,
|
|
537
642
|
rotationDegrees: rotationNormalized,
|
|
538
643
|
};
|
|
539
644
|
|
|
@@ -543,7 +648,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
543
648
|
// 결과 반환
|
|
544
649
|
console.log('[FullDocScanner] Returning results');
|
|
545
650
|
onResult(updatedPhotos);
|
|
546
|
-
}, [croppedImageData, rotationDegrees, capturedPhotos, onResult]);
|
|
651
|
+
}, [croppedImageData, rotationDegrees, capturedPhotos, getActivePreviewImage, onResult]);
|
|
547
652
|
|
|
548
653
|
const handleCaptureSecondPhoto = useCallback(() => {
|
|
549
654
|
console.log('[FullDocScanner] Capturing second photo');
|
|
@@ -554,9 +659,10 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
554
659
|
|
|
555
660
|
// 현재 사진(앞면)을 먼저 저장
|
|
556
661
|
const rotationNormalized = ((rotationDegrees % 360) + 360) % 360;
|
|
662
|
+
const activeImage = getActivePreviewImage(croppedImageData);
|
|
557
663
|
const currentPhoto: FullDocScannerResult = {
|
|
558
|
-
path:
|
|
559
|
-
base64:
|
|
664
|
+
path: activeImage.path,
|
|
665
|
+
base64: activeImage.base64,
|
|
560
666
|
rotationDegrees: rotationNormalized,
|
|
561
667
|
};
|
|
562
668
|
|
|
@@ -564,25 +670,8 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
564
670
|
setCurrentPhotoIndex(1);
|
|
565
671
|
|
|
566
672
|
// 확인 화면을 닫고 카메라로 돌아감
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
setProcessing(false);
|
|
570
|
-
setRectangleDetected(false);
|
|
571
|
-
setRectangleHint(false);
|
|
572
|
-
captureModeRef.current = null;
|
|
573
|
-
captureInProgressRef.current = false;
|
|
574
|
-
if (rectangleCaptureTimeoutRef.current) {
|
|
575
|
-
clearTimeout(rectangleCaptureTimeoutRef.current);
|
|
576
|
-
rectangleCaptureTimeoutRef.current = null;
|
|
577
|
-
}
|
|
578
|
-
if (rectangleHintTimeoutRef.current) {
|
|
579
|
-
clearTimeout(rectangleHintTimeoutRef.current);
|
|
580
|
-
rectangleHintTimeoutRef.current = null;
|
|
581
|
-
}
|
|
582
|
-
if (docScannerRef.current?.reset) {
|
|
583
|
-
docScannerRef.current.reset();
|
|
584
|
-
}
|
|
585
|
-
}, [croppedImageData, rotationDegrees]);
|
|
673
|
+
resetScannerView({ remount: true });
|
|
674
|
+
}, [croppedImageData, getActivePreviewImage, resetScannerView, rotationDegrees]);
|
|
586
675
|
|
|
587
676
|
|
|
588
677
|
const handleRetake = useCallback(() => {
|
|
@@ -599,26 +688,8 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
599
688
|
setCurrentPhotoIndex(0);
|
|
600
689
|
}
|
|
601
690
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
setProcessing(false);
|
|
605
|
-
setRectangleDetected(false);
|
|
606
|
-
setRectangleHint(false);
|
|
607
|
-
captureModeRef.current = null;
|
|
608
|
-
captureInProgressRef.current = false;
|
|
609
|
-
if (rectangleCaptureTimeoutRef.current) {
|
|
610
|
-
clearTimeout(rectangleCaptureTimeoutRef.current);
|
|
611
|
-
rectangleCaptureTimeoutRef.current = null;
|
|
612
|
-
}
|
|
613
|
-
if (rectangleHintTimeoutRef.current) {
|
|
614
|
-
clearTimeout(rectangleHintTimeoutRef.current);
|
|
615
|
-
rectangleHintTimeoutRef.current = null;
|
|
616
|
-
}
|
|
617
|
-
// Reset DocScanner state
|
|
618
|
-
if (docScannerRef.current?.reset) {
|
|
619
|
-
docScannerRef.current.reset();
|
|
620
|
-
}
|
|
621
|
-
}, [capturedPhotos.length, isBusinessMode]);
|
|
691
|
+
resetScannerView({ remount: true });
|
|
692
|
+
}, [capturedPhotos.length, isBusinessMode, resetScannerView]);
|
|
622
693
|
|
|
623
694
|
const handleRectangleDetect = useCallback((event: RectangleDetectEvent) => {
|
|
624
695
|
const stableCounter = event.stableCounter ?? 0;
|
|
@@ -677,6 +748,8 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
677
748
|
[],
|
|
678
749
|
);
|
|
679
750
|
|
|
751
|
+
const activePreviewImage = croppedImageData ? getActivePreviewImage(croppedImageData) : null;
|
|
752
|
+
|
|
680
753
|
return (
|
|
681
754
|
<View style={styles.container}>
|
|
682
755
|
{croppedImageData ? (
|
|
@@ -750,14 +823,40 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
750
823
|
</TouchableOpacity>
|
|
751
824
|
)}
|
|
752
825
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
826
|
+
{activePreviewImage ? (
|
|
827
|
+
<Image
|
|
828
|
+
source={{ uri: activePreviewImage.path }}
|
|
829
|
+
style={[
|
|
830
|
+
styles.previewImage,
|
|
831
|
+
{ transform: [{ rotate: `${rotationDegrees}deg` }] }
|
|
832
|
+
]}
|
|
833
|
+
resizeMode="contain"
|
|
834
|
+
/>
|
|
835
|
+
) : null}
|
|
836
|
+
{croppedImageData.enhanced ? (
|
|
837
|
+
<TouchableOpacity
|
|
838
|
+
style={[
|
|
839
|
+
styles.originalToggleButton,
|
|
840
|
+
croppedImageData.useOriginal && styles.originalToggleButtonActive,
|
|
841
|
+
]}
|
|
842
|
+
onPress={() =>
|
|
843
|
+
setCroppedImageData((prev) =>
|
|
844
|
+
prev ? { ...prev, useOriginal: !prev.useOriginal } : prev,
|
|
845
|
+
)
|
|
846
|
+
}
|
|
847
|
+
accessibilityRole="button"
|
|
848
|
+
accessibilityLabel={mergedStrings.originalBtn}
|
|
849
|
+
>
|
|
850
|
+
<Text
|
|
851
|
+
style={[
|
|
852
|
+
styles.originalToggleButtonText,
|
|
853
|
+
croppedImageData.useOriginal && styles.originalToggleButtonTextActive,
|
|
854
|
+
]}
|
|
855
|
+
>
|
|
856
|
+
{mergedStrings.originalBtn}
|
|
857
|
+
</Text>
|
|
858
|
+
</TouchableOpacity>
|
|
859
|
+
) : null}
|
|
761
860
|
<View style={styles.confirmationButtons}>
|
|
762
861
|
<TouchableOpacity
|
|
763
862
|
style={[styles.confirmButton, styles.retakeButton]}
|
|
@@ -781,6 +880,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
781
880
|
) : (
|
|
782
881
|
<View style={styles.flex}>
|
|
783
882
|
<DocScanner
|
|
883
|
+
key={scannerSession}
|
|
784
884
|
ref={docScannerRef}
|
|
785
885
|
autoCapture={false}
|
|
786
886
|
overlayColor={overlayColor}
|
|
@@ -1062,6 +1162,28 @@ const styles = StyleSheet.create({
|
|
|
1062
1162
|
gap: 24,
|
|
1063
1163
|
paddingVertical: 32,
|
|
1064
1164
|
},
|
|
1165
|
+
originalToggleButton: {
|
|
1166
|
+
alignSelf: 'center',
|
|
1167
|
+
marginTop: 16,
|
|
1168
|
+
paddingVertical: 10,
|
|
1169
|
+
paddingHorizontal: 24,
|
|
1170
|
+
borderRadius: 24,
|
|
1171
|
+
borderWidth: 1,
|
|
1172
|
+
borderColor: 'rgba(255,255,255,0.4)',
|
|
1173
|
+
backgroundColor: 'rgba(30,30,30,0.7)',
|
|
1174
|
+
},
|
|
1175
|
+
originalToggleButtonActive: {
|
|
1176
|
+
backgroundColor: 'rgba(255,255,255,0.15)',
|
|
1177
|
+
borderColor: '#fff',
|
|
1178
|
+
},
|
|
1179
|
+
originalToggleButtonText: {
|
|
1180
|
+
color: '#fff',
|
|
1181
|
+
fontSize: 14,
|
|
1182
|
+
fontWeight: '600',
|
|
1183
|
+
},
|
|
1184
|
+
originalToggleButtonTextActive: {
|
|
1185
|
+
color: '#fff',
|
|
1186
|
+
},
|
|
1065
1187
|
confirmButton: {
|
|
1066
1188
|
paddingHorizontal: 40,
|
|
1067
1189
|
paddingVertical: 16,
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
#import "RNPdfScannerManager.h"
|
|
3
3
|
#import "DocumentScannerView.h"
|
|
4
4
|
#import <React/RCTUIManager.h>
|
|
5
|
+
#import <UIKit/UIKit.h>
|
|
6
|
+
#import <CoreImage/CoreImage.h>
|
|
5
7
|
|
|
6
8
|
@interface RNPdfScannerManager()
|
|
7
9
|
@property (strong, nonatomic) DocumentScannerView *scannerView;
|
|
@@ -87,4 +89,112 @@ RCT_EXPORT_METHOD(capture:(NSNumber * _Nullable)reactTag
|
|
|
87
89
|
return _scannerView;
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
RCT_EXPORT_METHOD(applyColorControls:(NSString *)imagePath
|
|
93
|
+
brightness:(nonnull NSNumber *)brightness
|
|
94
|
+
contrast:(nonnull NSNumber *)contrast
|
|
95
|
+
saturation:(nonnull NSNumber *)saturation
|
|
96
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
97
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
98
|
+
{
|
|
99
|
+
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
|
|
100
|
+
@autoreleasepool {
|
|
101
|
+
NSString *resolvedPath = imagePath ?: @"";
|
|
102
|
+
if (![resolvedPath length]) {
|
|
103
|
+
if (reject) {
|
|
104
|
+
reject(@"INVALID_PATH", @"Image path is empty", nil);
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
NSString *fileUri = resolvedPath;
|
|
110
|
+
if (![fileUri hasPrefix:@"file://"]) {
|
|
111
|
+
fileUri = [NSString stringWithFormat:@"file://%@", resolvedPath];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
NSURL *imageURL = [NSURL URLWithString:fileUri];
|
|
115
|
+
if (!imageURL) {
|
|
116
|
+
if (reject) {
|
|
117
|
+
reject(@"INVALID_URI", @"Unable to create URL for image path", nil);
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
CIImage *inputImage = [CIImage imageWithContentsOfURL:imageURL];
|
|
123
|
+
if (!inputImage) {
|
|
124
|
+
if (reject) {
|
|
125
|
+
reject(@"LOAD_FAILED", @"Failed to load image for color adjustment", nil);
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
CIFilter *colorControls = [CIFilter filterWithName:@"CIColorControls"];
|
|
131
|
+
if (!colorControls) {
|
|
132
|
+
if (reject) {
|
|
133
|
+
reject(@"FILTER_MISSING", @"CIColorControls filter is unavailable", nil);
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
[colorControls setValue:inputImage forKey:kCIInputImageKey];
|
|
139
|
+
[colorControls setValue:brightness forKey:@"inputBrightness"];
|
|
140
|
+
[colorControls setValue:contrast forKey:@"inputContrast"];
|
|
141
|
+
[colorControls setValue:saturation forKey:@"inputSaturation"];
|
|
142
|
+
|
|
143
|
+
CIImage *outputImage = colorControls.outputImage;
|
|
144
|
+
if (!outputImage) {
|
|
145
|
+
if (reject) {
|
|
146
|
+
reject(@"FILTER_FAILED", @"Failed to apply color controls", nil);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
CIContext *context = [CIContext contextWithOptions:nil];
|
|
152
|
+
CGRect extent = outputImage.extent;
|
|
153
|
+
CGImageRef cgImage = [context createCGImage:outputImage fromRect:extent];
|
|
154
|
+
|
|
155
|
+
if (!cgImage) {
|
|
156
|
+
if (reject) {
|
|
157
|
+
reject(@"RENDER_FAILED", @"Failed to render filtered image", nil);
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
UIImage *resultImage = [UIImage imageWithCGImage:cgImage];
|
|
163
|
+
CGImageRelease(cgImage);
|
|
164
|
+
|
|
165
|
+
if (!resultImage) {
|
|
166
|
+
if (reject) {
|
|
167
|
+
reject(@"IMAGE_CREATION_FAILED", @"Failed to create UIImage from filtered output", nil);
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
NSData *jpegData = UIImageJPEGRepresentation(resultImage, 0.98f);
|
|
173
|
+
if (!jpegData) {
|
|
174
|
+
if (reject) {
|
|
175
|
+
reject(@"ENCODE_FAILED", @"Failed to encode filtered image to JPEG", nil);
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
NSString *tempDirectory = NSTemporaryDirectory();
|
|
181
|
+
NSString *fileName = [NSString stringWithFormat:@"docscanner_enhanced_%@.jpg", [[NSUUID UUID] UUIDString]];
|
|
182
|
+
NSString *outputPath = [tempDirectory stringByAppendingPathComponent:fileName];
|
|
183
|
+
|
|
184
|
+
NSError *writeError = nil;
|
|
185
|
+
BOOL success = [jpegData writeToFile:outputPath options:NSDataWritingAtomic error:&writeError];
|
|
186
|
+
if (!success) {
|
|
187
|
+
if (reject) {
|
|
188
|
+
reject(@"WRITE_FAILED", @"Failed to write filtered image to disk", writeError);
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (resolve) {
|
|
194
|
+
resolve(outputPath);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
90
200
|
@end
|