react-native-rectangle-doc-scanner 3.123.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);
|
|
@@ -169,7 +165,58 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
169
165
|
second: strings?.second ?? 'Back',
|
|
170
166
|
secondBtn: strings?.secondBtn ?? 'Capture Back Side?',
|
|
171
167
|
secondPrompt: strings?.secondPrompt ?? strings?.secondBtn ?? 'Capture Back Side?',
|
|
168
|
+
originalBtn: strings?.originalBtn ?? 'Use Original',
|
|
172
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, []);
|
|
173
220
|
const emitError = (0, react_1.useCallback)((error, fallbackMessage) => {
|
|
174
221
|
console.error('[FullDocScanner] error', error);
|
|
175
222
|
onError?.(error);
|
|
@@ -207,12 +254,13 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
207
254
|
path: croppedImage.path,
|
|
208
255
|
hasBase64: !!croppedImage.data,
|
|
209
256
|
});
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
path: croppedImage.path,
|
|
257
|
+
const sanitizedPath = stripFileUri(croppedImage.path);
|
|
258
|
+
const preview = await preparePreviewImage({
|
|
259
|
+
path: sanitizedPath,
|
|
214
260
|
base64: croppedImage.data ?? undefined,
|
|
215
261
|
});
|
|
262
|
+
setCroppedImageData(preview);
|
|
263
|
+
setProcessing(false);
|
|
216
264
|
}
|
|
217
265
|
catch (error) {
|
|
218
266
|
resetScannerView({ remount: true });
|
|
@@ -236,7 +284,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
236
284
|
emitError(error instanceof Error ? error : new Error(errorMessage), 'Failed to crop image. Please try again.');
|
|
237
285
|
}
|
|
238
286
|
}
|
|
239
|
-
}, [cropWidth, cropHeight, emitError, resetScannerView]);
|
|
287
|
+
}, [cropWidth, cropHeight, emitError, preparePreviewImage, resetScannerView]);
|
|
240
288
|
const handleCapture = (0, react_1.useCallback)(async (document) => {
|
|
241
289
|
console.log('[FullDocScanner] handleCapture called:', {
|
|
242
290
|
origin: document.origin,
|
|
@@ -262,27 +310,26 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
262
310
|
}
|
|
263
311
|
if (normalizedDoc.croppedPath) {
|
|
264
312
|
console.log('[FullDocScanner] Grid detected: using pre-cropped image', normalizedDoc.croppedPath);
|
|
265
|
-
|
|
313
|
+
setProcessing(true);
|
|
266
314
|
try {
|
|
267
|
-
const
|
|
268
|
-
console.log('[FullDocScanner] Generated base64 for pre-cropped image, length:', base64Data.length);
|
|
269
|
-
setCroppedImageData({
|
|
315
|
+
const preview = await preparePreviewImage({
|
|
270
316
|
path: normalizedDoc.croppedPath,
|
|
271
|
-
base64: base64Data,
|
|
272
317
|
});
|
|
318
|
+
setCroppedImageData(preview);
|
|
273
319
|
}
|
|
274
320
|
catch (error) {
|
|
275
|
-
console.error('[FullDocScanner] Failed to
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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);
|
|
280
327
|
}
|
|
281
328
|
return;
|
|
282
329
|
}
|
|
283
330
|
console.log('[FullDocScanner] Fallback to manual crop (no croppedPath available)');
|
|
284
331
|
await openCropper(normalizedDoc.path, { waitForPickerDismissal: false });
|
|
285
|
-
}, [openCropper]);
|
|
332
|
+
}, [emitError, openCropper, preparePreviewImage, resetScannerView]);
|
|
286
333
|
const triggerManualCapture = (0, react_1.useCallback)(() => {
|
|
287
334
|
const scannerInstance = docScannerRef.current;
|
|
288
335
|
const hasScanner = !!scannerInstance;
|
|
@@ -405,13 +452,14 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
405
452
|
if (!croppedImageData) {
|
|
406
453
|
return;
|
|
407
454
|
}
|
|
455
|
+
const activeImage = getActivePreviewImage(croppedImageData);
|
|
408
456
|
// 회전 각도 정규화 (0, 90, 180, 270)
|
|
409
457
|
const rotationNormalized = ((rotationDegrees % 360) + 360) % 360;
|
|
410
458
|
console.log('[FullDocScanner] Confirm - rotation degrees:', rotationDegrees, 'normalized:', rotationNormalized);
|
|
411
459
|
// 현재 사진을 capturedPhotos에 추가
|
|
412
460
|
const currentPhoto = {
|
|
413
|
-
path:
|
|
414
|
-
base64:
|
|
461
|
+
path: activeImage.path,
|
|
462
|
+
base64: activeImage.base64,
|
|
415
463
|
rotationDegrees: rotationNormalized,
|
|
416
464
|
};
|
|
417
465
|
const updatedPhotos = [...capturedPhotos, currentPhoto];
|
|
@@ -419,7 +467,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
419
467
|
// 결과 반환
|
|
420
468
|
console.log('[FullDocScanner] Returning results');
|
|
421
469
|
onResult(updatedPhotos);
|
|
422
|
-
}, [croppedImageData, rotationDegrees, capturedPhotos, onResult]);
|
|
470
|
+
}, [croppedImageData, rotationDegrees, capturedPhotos, getActivePreviewImage, onResult]);
|
|
423
471
|
const handleCaptureSecondPhoto = (0, react_1.useCallback)(() => {
|
|
424
472
|
console.log('[FullDocScanner] Capturing second photo');
|
|
425
473
|
if (!croppedImageData) {
|
|
@@ -427,16 +475,17 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
427
475
|
}
|
|
428
476
|
// 현재 사진(앞면)을 먼저 저장
|
|
429
477
|
const rotationNormalized = ((rotationDegrees % 360) + 360) % 360;
|
|
478
|
+
const activeImage = getActivePreviewImage(croppedImageData);
|
|
430
479
|
const currentPhoto = {
|
|
431
|
-
path:
|
|
432
|
-
base64:
|
|
480
|
+
path: activeImage.path,
|
|
481
|
+
base64: activeImage.base64,
|
|
433
482
|
rotationDegrees: rotationNormalized,
|
|
434
483
|
};
|
|
435
484
|
setCapturedPhotos([currentPhoto]);
|
|
436
485
|
setCurrentPhotoIndex(1);
|
|
437
486
|
// 확인 화면을 닫고 카메라로 돌아감
|
|
438
487
|
resetScannerView({ remount: true });
|
|
439
|
-
}, [croppedImageData, resetScannerView, rotationDegrees]);
|
|
488
|
+
}, [croppedImageData, getActivePreviewImage, resetScannerView, rotationDegrees]);
|
|
440
489
|
const handleRetake = (0, react_1.useCallback)(() => {
|
|
441
490
|
console.log('[FullDocScanner] Retake - clearing cropped image and resetting scanner');
|
|
442
491
|
// Business 모드에서 두 번째 사진을 다시 찍는 경우, 첫 번째 사진 유지
|
|
@@ -500,6 +549,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
500
549
|
clearTimeout(rectangleHintTimeoutRef.current);
|
|
501
550
|
}
|
|
502
551
|
}, []);
|
|
552
|
+
const activePreviewImage = croppedImageData ? getActivePreviewImage(croppedImageData) : null;
|
|
503
553
|
return (react_1.default.createElement(react_native_1.View, { style: styles.container },
|
|
504
554
|
croppedImageData ? (
|
|
505
555
|
// check_DP: Show confirmation screen
|
|
@@ -520,10 +570,18 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
520
570
|
react_1.default.createElement(react_native_1.Text, { style: styles.rotateButtonLabel }, "\uC6B0\uB85C 90\u00B0")))) : null,
|
|
521
571
|
isBusinessMode && capturedPhotos.length === 0 && (react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.captureBackButton, onPress: handleCaptureSecondPhoto, accessibilityLabel: mergedStrings.secondBtn, accessibilityRole: "button" },
|
|
522
572
|
react_1.default.createElement(react_native_1.Text, { style: styles.captureBackButtonText }, mergedStrings.secondBtn))),
|
|
523
|
-
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: [
|
|
524
574
|
styles.previewImage,
|
|
525
575
|
{ transform: [{ rotate: `${rotationDegrees}deg` }] }
|
|
526
|
-
], 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,
|
|
527
585
|
react_1.default.createElement(react_native_1.View, { style: styles.confirmationButtons },
|
|
528
586
|
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.retakeButton], onPress: handleRetake, accessibilityLabel: mergedStrings.retake, accessibilityRole: "button" },
|
|
529
587
|
react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.retake)),
|
|
@@ -735,6 +793,28 @@ const styles = react_native_1.StyleSheet.create({
|
|
|
735
793
|
gap: 24,
|
|
736
794
|
paddingVertical: 32,
|
|
737
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
|
+
},
|
|
738
818
|
confirmButton: {
|
|
739
819
|
paddingHorizontal: 40,
|
|
740
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);
|
|
@@ -238,10 +236,90 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
238
236
|
second: strings?.second ?? 'Back',
|
|
239
237
|
secondBtn: strings?.secondBtn ?? 'Capture Back Side?',
|
|
240
238
|
secondPrompt: strings?.secondPrompt ?? strings?.secondBtn ?? 'Capture Back Side?',
|
|
239
|
+
originalBtn: strings?.originalBtn ?? 'Use Original',
|
|
241
240
|
}),
|
|
242
241
|
[strings],
|
|
243
242
|
);
|
|
244
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
|
+
|
|
245
323
|
const emitError = useCallback(
|
|
246
324
|
(error: Error, fallbackMessage?: string) => {
|
|
247
325
|
console.error('[FullDocScanner] error', error);
|
|
@@ -292,13 +370,14 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
292
370
|
hasBase64: !!croppedImage.data,
|
|
293
371
|
});
|
|
294
372
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
setCroppedImageData({
|
|
299
|
-
path: croppedImage.path,
|
|
373
|
+
const sanitizedPath = stripFileUri(croppedImage.path);
|
|
374
|
+
const preview = await preparePreviewImage({
|
|
375
|
+
path: sanitizedPath,
|
|
300
376
|
base64: croppedImage.data ?? undefined,
|
|
301
377
|
});
|
|
378
|
+
|
|
379
|
+
setCroppedImageData(preview);
|
|
380
|
+
setProcessing(false);
|
|
302
381
|
} catch (error) {
|
|
303
382
|
resetScannerView({ remount: true });
|
|
304
383
|
|
|
@@ -333,7 +412,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
333
412
|
}
|
|
334
413
|
}
|
|
335
414
|
},
|
|
336
|
-
[cropWidth, cropHeight, emitError, resetScannerView],
|
|
415
|
+
[cropWidth, cropHeight, emitError, preparePreviewImage, resetScannerView],
|
|
337
416
|
);
|
|
338
417
|
|
|
339
418
|
const handleCapture = useCallback(
|
|
@@ -369,21 +448,21 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
369
448
|
if (normalizedDoc.croppedPath) {
|
|
370
449
|
console.log('[FullDocScanner] Grid detected: using pre-cropped image', normalizedDoc.croppedPath);
|
|
371
450
|
|
|
372
|
-
|
|
451
|
+
setProcessing(true);
|
|
373
452
|
try {
|
|
374
|
-
const
|
|
375
|
-
console.log('[FullDocScanner] Generated base64 for pre-cropped image, length:', base64Data.length);
|
|
376
|
-
|
|
377
|
-
setCroppedImageData({
|
|
453
|
+
const preview = await preparePreviewImage({
|
|
378
454
|
path: normalizedDoc.croppedPath,
|
|
379
|
-
base64: base64Data,
|
|
380
455
|
});
|
|
456
|
+
setCroppedImageData(preview);
|
|
381
457
|
} catch (error) {
|
|
382
|
-
console.error('[FullDocScanner] Failed to
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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);
|
|
387
466
|
}
|
|
388
467
|
return;
|
|
389
468
|
}
|
|
@@ -391,7 +470,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
391
470
|
console.log('[FullDocScanner] Fallback to manual crop (no croppedPath available)');
|
|
392
471
|
await openCropper(normalizedDoc.path, { waitForPickerDismissal: false });
|
|
393
472
|
},
|
|
394
|
-
[openCropper],
|
|
473
|
+
[emitError, openCropper, preparePreviewImage, resetScannerView],
|
|
395
474
|
);
|
|
396
475
|
|
|
397
476
|
const triggerManualCapture = useCallback(() => {
|
|
@@ -550,14 +629,16 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
550
629
|
return;
|
|
551
630
|
}
|
|
552
631
|
|
|
632
|
+
const activeImage = getActivePreviewImage(croppedImageData);
|
|
633
|
+
|
|
553
634
|
// 회전 각도 정규화 (0, 90, 180, 270)
|
|
554
635
|
const rotationNormalized = ((rotationDegrees % 360) + 360) % 360;
|
|
555
636
|
console.log('[FullDocScanner] Confirm - rotation degrees:', rotationDegrees, 'normalized:', rotationNormalized);
|
|
556
637
|
|
|
557
638
|
// 현재 사진을 capturedPhotos에 추가
|
|
558
639
|
const currentPhoto: FullDocScannerResult = {
|
|
559
|
-
path:
|
|
560
|
-
base64:
|
|
640
|
+
path: activeImage.path,
|
|
641
|
+
base64: activeImage.base64,
|
|
561
642
|
rotationDegrees: rotationNormalized,
|
|
562
643
|
};
|
|
563
644
|
|
|
@@ -567,7 +648,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
567
648
|
// 결과 반환
|
|
568
649
|
console.log('[FullDocScanner] Returning results');
|
|
569
650
|
onResult(updatedPhotos);
|
|
570
|
-
}, [croppedImageData, rotationDegrees, capturedPhotos, onResult]);
|
|
651
|
+
}, [croppedImageData, rotationDegrees, capturedPhotos, getActivePreviewImage, onResult]);
|
|
571
652
|
|
|
572
653
|
const handleCaptureSecondPhoto = useCallback(() => {
|
|
573
654
|
console.log('[FullDocScanner] Capturing second photo');
|
|
@@ -578,9 +659,10 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
578
659
|
|
|
579
660
|
// 현재 사진(앞면)을 먼저 저장
|
|
580
661
|
const rotationNormalized = ((rotationDegrees % 360) + 360) % 360;
|
|
662
|
+
const activeImage = getActivePreviewImage(croppedImageData);
|
|
581
663
|
const currentPhoto: FullDocScannerResult = {
|
|
582
|
-
path:
|
|
583
|
-
base64:
|
|
664
|
+
path: activeImage.path,
|
|
665
|
+
base64: activeImage.base64,
|
|
584
666
|
rotationDegrees: rotationNormalized,
|
|
585
667
|
};
|
|
586
668
|
|
|
@@ -589,7 +671,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
589
671
|
|
|
590
672
|
// 확인 화면을 닫고 카메라로 돌아감
|
|
591
673
|
resetScannerView({ remount: true });
|
|
592
|
-
}, [croppedImageData, resetScannerView, rotationDegrees]);
|
|
674
|
+
}, [croppedImageData, getActivePreviewImage, resetScannerView, rotationDegrees]);
|
|
593
675
|
|
|
594
676
|
|
|
595
677
|
const handleRetake = useCallback(() => {
|
|
@@ -666,6 +748,8 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
666
748
|
[],
|
|
667
749
|
);
|
|
668
750
|
|
|
751
|
+
const activePreviewImage = croppedImageData ? getActivePreviewImage(croppedImageData) : null;
|
|
752
|
+
|
|
669
753
|
return (
|
|
670
754
|
<View style={styles.container}>
|
|
671
755
|
{croppedImageData ? (
|
|
@@ -739,14 +823,40 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
739
823
|
</TouchableOpacity>
|
|
740
824
|
)}
|
|
741
825
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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}
|
|
750
860
|
<View style={styles.confirmationButtons}>
|
|
751
861
|
<TouchableOpacity
|
|
752
862
|
style={[styles.confirmButton, styles.retakeButton]}
|
|
@@ -1052,6 +1162,28 @@ const styles = StyleSheet.create({
|
|
|
1052
1162
|
gap: 24,
|
|
1053
1163
|
paddingVertical: 32,
|
|
1054
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
|
+
},
|
|
1055
1187
|
confirmButton: {
|
|
1056
1188
|
paddingHorizontal: 40,
|
|
1057
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
|