react-native-rectangle-doc-scanner 3.123.0 → 3.125.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.18,
101
+ contrast: 1.12,
102
+ saturation: 0.78,
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
- setProcessing(false);
211
- // Show confirmation screen
212
- setCroppedImageData({
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
- // RNFS를 사용해서 base64 생성
313
+ setProcessing(true);
266
314
  try {
267
- const base64Data = await react_native_fs_1.default.readFile(normalizedDoc.croppedPath, 'base64');
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 generate base64:', error);
276
- // base64 생성 실패 시 경로만 저장
277
- setCroppedImageData({
278
- path: normalizedDoc.croppedPath,
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: croppedImageData.path,
414
- base64: croppedImageData.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: croppedImageData.path,
432
- base64: croppedImageData.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
@@ -518,12 +568,24 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
518
568
  react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.rotateButtonTop, onPress: () => handleRotateImage(90), accessibilityLabel: "\uC624\uB978\uCABD\uC73C\uB85C 90\uB3C4 \uD68C\uC804", accessibilityRole: "button" },
519
569
  react_1.default.createElement(react_native_1.Text, { style: styles.rotateIconText }, "\u21BB"),
520
570
  react_1.default.createElement(react_native_1.Text, { style: styles.rotateButtonLabel }, "\uC6B0\uB85C 90\u00B0")))) : null,
571
+ croppedImageData.enhanced ? (react_1.default.createElement(react_native_1.TouchableOpacity, { style: [
572
+ styles.originalToggleButton,
573
+ croppedImageData.useOriginal
574
+ ? styles.originalToggleButtonActive
575
+ : styles.originalToggleButtonInactive,
576
+ ], onPress: () => setCroppedImageData((prev) => prev ? { ...prev, useOriginal: !prev.useOriginal } : prev), accessibilityRole: "button", accessibilityLabel: mergedStrings.originalBtn },
577
+ react_1.default.createElement(react_native_1.Text, { style: [
578
+ styles.originalToggleButtonText,
579
+ croppedImageData.useOriginal
580
+ ? styles.originalToggleButtonTextActive
581
+ : styles.originalToggleButtonTextInactive,
582
+ ] }, mergedStrings.originalBtn))) : null,
521
583
  isBusinessMode && capturedPhotos.length === 0 && (react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.captureBackButton, onPress: handleCaptureSecondPhoto, accessibilityLabel: mergedStrings.secondBtn, accessibilityRole: "button" },
522
584
  react_1.default.createElement(react_native_1.Text, { style: styles.captureBackButtonText }, mergedStrings.secondBtn))),
523
- react_1.default.createElement(react_native_1.Image, { source: { uri: croppedImageData.path }, style: [
585
+ activePreviewImage ? (react_1.default.createElement(react_native_1.Image, { source: { uri: activePreviewImage.path }, style: [
524
586
  styles.previewImage,
525
587
  { transform: [{ rotate: `${rotationDegrees}deg` }] }
526
- ], resizeMode: "contain" }),
588
+ ], resizeMode: "contain" })) : null,
527
589
  react_1.default.createElement(react_native_1.View, { style: styles.confirmationButtons },
528
590
  react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.retakeButton], onPress: handleRetake, accessibilityLabel: mergedStrings.retake, accessibilityRole: "button" },
529
591
  react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.retake)),
@@ -735,6 +797,33 @@ const styles = react_native_1.StyleSheet.create({
735
797
  gap: 24,
736
798
  paddingVertical: 32,
737
799
  },
800
+ originalToggleButton: {
801
+ alignSelf: 'center',
802
+ marginTop: 12,
803
+ marginBottom: 4,
804
+ paddingVertical: 10,
805
+ paddingHorizontal: 28,
806
+ borderRadius: 999,
807
+ borderWidth: 1,
808
+ },
809
+ originalToggleButtonActive: {
810
+ backgroundColor: '#3170f3',
811
+ borderColor: '#3170f3',
812
+ },
813
+ originalToggleButtonInactive: {
814
+ backgroundColor: 'rgba(255,255,255,0.12)',
815
+ borderColor: 'rgba(255,255,255,0.25)',
816
+ },
817
+ originalToggleButtonText: {
818
+ fontSize: 14,
819
+ fontWeight: '700',
820
+ },
821
+ originalToggleButtonTextActive: {
822
+ color: '#fff',
823
+ },
824
+ originalToggleButtonTextInactive: {
825
+ color: 'rgba(255,255,255,0.65)',
826
+ },
738
827
  confirmButton: {
739
828
  paddingHorizontal: 40,
740
829
  paddingVertical: 16,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "3.123.0",
3
+ "version": "3.125.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.18,
100
+ contrast: 1.12,
101
+ saturation: 0.78,
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);
@@ -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
- setProcessing(false);
296
-
297
- // Show confirmation screen
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
- // RNFS를 사용해서 base64 생성
451
+ setProcessing(true);
373
452
  try {
374
- const base64Data = await RNFS.readFile(normalizedDoc.croppedPath, 'base64');
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 generate base64:', error);
383
- // base64 생성 실패 시 경로만 저장
384
- setCroppedImageData({
385
- path: normalizedDoc.croppedPath,
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: croppedImageData.path,
560
- base64: croppedImageData.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: croppedImageData.path,
583
- base64: croppedImageData.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 ? (
@@ -727,6 +811,35 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
727
811
  </View>
728
812
  ) : null}
729
813
 
814
+ {croppedImageData.enhanced ? (
815
+ <TouchableOpacity
816
+ style={[
817
+ styles.originalToggleButton,
818
+ croppedImageData.useOriginal
819
+ ? styles.originalToggleButtonActive
820
+ : styles.originalToggleButtonInactive,
821
+ ]}
822
+ onPress={() =>
823
+ setCroppedImageData((prev) =>
824
+ prev ? { ...prev, useOriginal: !prev.useOriginal } : prev,
825
+ )
826
+ }
827
+ accessibilityRole="button"
828
+ accessibilityLabel={mergedStrings.originalBtn}
829
+ >
830
+ <Text
831
+ style={[
832
+ styles.originalToggleButtonText,
833
+ croppedImageData.useOriginal
834
+ ? styles.originalToggleButtonTextActive
835
+ : styles.originalToggleButtonTextInactive,
836
+ ]}
837
+ >
838
+ {mergedStrings.originalBtn}
839
+ </Text>
840
+ </TouchableOpacity>
841
+ ) : null}
842
+
730
843
  {/* 뒷면 촬영 버튼 - 상단에 표시 (Business 모드이고 첫 번째 사진일 때만) */}
731
844
  {isBusinessMode && capturedPhotos.length === 0 && (
732
845
  <TouchableOpacity
@@ -739,14 +852,16 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
739
852
  </TouchableOpacity>
740
853
  )}
741
854
 
742
- <Image
743
- source={{ uri: croppedImageData.path }}
744
- style={[
745
- styles.previewImage,
746
- { transform: [{ rotate: `${rotationDegrees}deg` }] }
747
- ]}
748
- resizeMode="contain"
749
- />
855
+ {activePreviewImage ? (
856
+ <Image
857
+ source={{ uri: activePreviewImage.path }}
858
+ style={[
859
+ styles.previewImage,
860
+ { transform: [{ rotate: `${rotationDegrees}deg` }] }
861
+ ]}
862
+ resizeMode="contain"
863
+ />
864
+ ) : null}
750
865
  <View style={styles.confirmationButtons}>
751
866
  <TouchableOpacity
752
867
  style={[styles.confirmButton, styles.retakeButton]}
@@ -1052,6 +1167,33 @@ const styles = StyleSheet.create({
1052
1167
  gap: 24,
1053
1168
  paddingVertical: 32,
1054
1169
  },
1170
+ originalToggleButton: {
1171
+ alignSelf: 'center',
1172
+ marginTop: 12,
1173
+ marginBottom: 4,
1174
+ paddingVertical: 10,
1175
+ paddingHorizontal: 28,
1176
+ borderRadius: 999,
1177
+ borderWidth: 1,
1178
+ },
1179
+ originalToggleButtonActive: {
1180
+ backgroundColor: '#3170f3',
1181
+ borderColor: '#3170f3',
1182
+ },
1183
+ originalToggleButtonInactive: {
1184
+ backgroundColor: 'rgba(255,255,255,0.12)',
1185
+ borderColor: 'rgba(255,255,255,0.25)',
1186
+ },
1187
+ originalToggleButtonText: {
1188
+ fontSize: 14,
1189
+ fontWeight: '700',
1190
+ },
1191
+ originalToggleButtonTextActive: {
1192
+ color: '#fff',
1193
+ },
1194
+ originalToggleButtonTextInactive: {
1195
+ color: 'rgba(255,255,255,0.65)',
1196
+ },
1055
1197
  confirmButton: {
1056
1198
  paddingHorizontal: 40,
1057
1199
  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