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.
@@ -24,6 +24,7 @@ export interface FullDocScannerStrings {
24
24
  second?: string;
25
25
  secondBtn?: string;
26
26
  secondPrompt?: string;
27
+ originalBtn?: string;
27
28
  }
28
29
  export interface FullDocScannerProps {
29
30
  onResult: (results: FullDocScannerResult[]) => void;
@@ -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
- setProcessing(false);
187
- // Show confirmation screen
188
- setCroppedImageData({
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
- setProcessing(false);
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
- // RNFS를 사용해서 base64 생성
313
+ setProcessing(true);
250
314
  try {
251
- const base64Data = await react_native_fs_1.default.readFile(normalizedDoc.croppedPath, 'base64');
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 generate base64:', error);
260
- // base64 생성 실패 시 경로만 저장
261
- setCroppedImageData({
262
- path: normalizedDoc.croppedPath,
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: croppedImageData.path,
398
- base64: croppedImageData.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: croppedImageData.path,
416
- base64: croppedImageData.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
- setCroppedImageData(null);
423
- setRotationDegrees(0);
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
- setCroppedImageData(null);
455
- setRotationDegrees(0);
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: croppedImageData.path }, style: [
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "3.122.0",
3
+ "version": "3.124.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -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<{path: string; base64?: string} | null>(null);
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
- setProcessing(false);
264
-
265
- // Show confirmation screen
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
- // Reset capture state when cropper fails or is cancelled
274
- captureInProgressRef.current = false;
275
- captureModeRef.current = null;
276
- setRectangleDetected(false);
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 = typeof errorMessageRaw === 'string' ? errorMessageRaw : String(errorMessageRaw);
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
- // RNFS를 사용해서 base64 생성
451
+ setProcessing(true);
349
452
  try {
350
- const base64Data = await RNFS.readFile(normalizedDoc.croppedPath, 'base64');
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 generate base64:', error);
359
- // base64 생성 실패 시 경로만 저장
360
- setCroppedImageData({
361
- path: normalizedDoc.croppedPath,
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: croppedImageData.path,
536
- base64: croppedImageData.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: croppedImageData.path,
559
- base64: croppedImageData.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
- setCroppedImageData(null);
568
- setRotationDegrees(0);
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
- setCroppedImageData(null);
603
- setRotationDegrees(0);
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
- <Image
754
- source={{ uri: croppedImageData.path }}
755
- style={[
756
- styles.previewImage,
757
- { transform: [{ rotate: `${rotationDegrees}deg` }] }
758
- ]}
759
- resizeMode="contain"
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