react-native-rectangle-doc-scanner 3.105.0 → 3.107.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -120,7 +120,7 @@ Manual capture exposes an imperative `capture()` method via `ref`. Children rend
120
120
  ## Convenience APIs
121
121
 
122
122
  - `CropEditor` – wraps `react-native-perspective-image-cropper` for manual corner adjustment.
123
- - `FullDocScanner` – puts the scanner and crop editor into a single modal-like flow.
123
+ - `FullDocScanner` – puts the scanner and crop editor into a single modal-like flow. If the host app links either `expo-image-manipulator` or `react-native-image-rotate`, the confirmation screen exposes 90° rotation buttons; otherwise rotation controls remain hidden.
124
124
 
125
125
  ## License
126
126
 
package/SETUP.md CHANGED
@@ -117,6 +117,8 @@ function MyComponent() {
117
117
  }
118
118
  ```
119
119
 
120
+ > 참고: 최종 확인 화면의 회전 버튼은 프로젝트에 `expo-image-manipulator` 또는 `react-native-image-rotate` 네이티브 모듈이 연결되어 있을 때만 노출됩니다. 둘 중 하나라도 없으면 버튼이 자동으로 숨겨지고 원본 각도로 결과가 반환됩니다.
121
+
120
122
  ## API 변경사항
121
123
 
122
124
  ### FullDocScannerResult (변경됨)
@@ -42,9 +42,27 @@ const react_native_1 = require("react-native");
42
42
  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
- const ImageManipulator = __importStar(require("expo-image-manipulator"));
46
45
  const DocScanner_1 = require("./DocScanner");
46
+ let ImageManipulator = null;
47
+ let ImageRotate = null;
48
+ try {
49
+ ImageManipulator = require('expo-image-manipulator');
50
+ }
51
+ catch (error) {
52
+ console.warn('[FullDocScanner] expo-image-manipulator module unavailable. Checking fallback options.', error);
53
+ }
54
+ try {
55
+ const rotateModule = require('react-native-image-rotate');
56
+ ImageRotate = (rotateModule?.default ?? rotateModule);
57
+ }
58
+ catch (error) {
59
+ console.warn('[FullDocScanner] react-native-image-rotate module unavailable. Image rotation fallback disabled.', error);
60
+ }
61
+ const isExpoImageManipulatorAvailable = !!ImageManipulator?.manipulateAsync;
62
+ const isImageRotateAvailable = !!ImageRotate?.rotateImage;
63
+ const isImageRotationSupported = isExpoImageManipulatorAvailable || isImageRotateAvailable;
47
64
  const stripFileUri = (value) => value.replace(/^file:\/\//, '');
65
+ const ensureFileUri = (value) => (value.startsWith('file://') ? value : `file://${value}`);
48
66
  const CROPPER_TIMEOUT_MS = 8000;
49
67
  const CROPPER_TIMEOUT_CODE = 'cropper_timeout';
50
68
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -344,9 +362,29 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
344
362
  const handleFlashToggle = (0, react_1.useCallback)(() => {
345
363
  setFlashEnabled(prev => !prev);
346
364
  }, []);
365
+ const rotateImageWithFallback = (0, react_1.useCallback)((uri, angle) => {
366
+ return new Promise((resolve, reject) => {
367
+ if (!ImageRotate?.rotateImage) {
368
+ reject(new Error('react-native-image-rotate unavailable'));
369
+ return;
370
+ }
371
+ ImageRotate.rotateImage(uri, angle, (rotatedUri) => resolve(rotatedUri), (error) => {
372
+ const message = typeof error === 'string'
373
+ ? error
374
+ : error && typeof error === 'object' && 'message' in error
375
+ ? String(error.message)
376
+ : 'Unknown rotation error';
377
+ reject(new Error(message));
378
+ });
379
+ });
380
+ }, []);
347
381
  const handleRotateImage = (0, react_1.useCallback)((degrees) => {
382
+ if (!isImageRotationSupported) {
383
+ console.warn('[FullDocScanner] Image rotation requested but no rotation module is available.');
384
+ return;
385
+ }
348
386
  // UI만 회전 (실제 파일 회전은 confirm 시 처리)
349
- setRotationDegrees(prev => {
387
+ setRotationDegrees((prev) => {
350
388
  const newRotation = prev + degrees;
351
389
  // -360 ~ 360 범위로 정규화
352
390
  if (newRotation <= -360)
@@ -357,9 +395,10 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
357
395
  });
358
396
  }, []);
359
397
  const handleConfirm = (0, react_1.useCallback)(async () => {
360
- if (!croppedImageData)
398
+ if (!croppedImageData) {
361
399
  return;
362
- // 회전이 없으면 기존 데이터 그대로 전달
400
+ }
401
+ // 회전이 없거나 모듈을 사용할 수 없으면 기존 데이터 그대로 전달
363
402
  if (rotationDegrees === 0) {
364
403
  onResult({
365
404
  path: croppedImageData.path,
@@ -367,31 +406,62 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
367
406
  });
368
407
  return;
369
408
  }
370
- // 회전이 있으면 실제 파일 회전 처리
371
409
  try {
372
410
  setProcessing(true);
373
411
  // 회전 각도 정규화 (0, 90, 180, 270)
374
412
  const rotationNormalized = ((rotationDegrees % 360) + 360) % 360;
375
- // expo-image-manipulator로 이미지 회전
376
- const result = await ImageManipulator.manipulateAsync(croppedImageData.path, [{ rotate: rotationNormalized }], {
377
- compress: 0.9,
378
- format: ImageManipulator.SaveFormat.JPEG,
379
- base64: true,
380
- });
381
- setProcessing(false);
382
- // 회전된 이미지로 결과 전달
413
+ if (rotationNormalized === 0) {
414
+ onResult({
415
+ path: croppedImageData.path,
416
+ base64: croppedImageData.base64,
417
+ });
418
+ return;
419
+ }
420
+ if (isExpoImageManipulatorAvailable && ImageManipulator?.manipulateAsync) {
421
+ // expo-image-manipulator로 이미지 회전
422
+ const result = await ImageManipulator.manipulateAsync(croppedImageData.path, [{ rotate: rotationNormalized }], {
423
+ compress: 0.9,
424
+ format: ImageManipulator.SaveFormat.JPEG,
425
+ base64: true,
426
+ });
427
+ onResult({
428
+ path: result.uri,
429
+ base64: result.base64,
430
+ });
431
+ return;
432
+ }
433
+ if (isImageRotateAvailable && ImageRotate?.rotateImage) {
434
+ const sourceUri = ensureFileUri(croppedImageData.path);
435
+ const rotatedUri = await rotateImageWithFallback(sourceUri, rotationNormalized);
436
+ let base64Result = croppedImageData.base64;
437
+ try {
438
+ base64Result = await react_native_fs_1.default.readFile(stripFileUri(rotatedUri), 'base64');
439
+ }
440
+ catch (readError) {
441
+ console.warn('[FullDocScanner] Failed to generate base64 for rotated image:', readError);
442
+ base64Result = undefined;
443
+ }
444
+ onResult({
445
+ path: rotatedUri,
446
+ base64: base64Result,
447
+ });
448
+ return;
449
+ }
450
+ console.warn('[FullDocScanner] Rotation requested but no supported rotation module is available. Returning original image.');
383
451
  onResult({
384
- path: result.uri,
385
- base64: result.base64,
452
+ path: croppedImageData.path,
453
+ base64: croppedImageData.base64,
386
454
  });
387
455
  }
388
456
  catch (error) {
389
457
  console.error('[FullDocScanner] Image rotation error on confirm:', error);
390
- setProcessing(false);
391
458
  const errorMessage = error && typeof error === 'object' && 'message' in error ? error.message : '';
392
459
  react_native_1.Alert.alert('회전 실패', '이미지 회전 중 오류가 발생했습니다: ' + errorMessage);
393
460
  }
394
- }, [croppedImageData, rotationDegrees, onResult]);
461
+ finally {
462
+ setProcessing(false);
463
+ }
464
+ }, [croppedImageData, rotationDegrees, onResult, rotateImageWithFallback]);
395
465
  const handleRetake = (0, react_1.useCallback)(() => {
396
466
  console.log('[FullDocScanner] Retake - clearing cropped image and resetting scanner');
397
467
  setCroppedImageData(null);
@@ -466,13 +536,13 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
466
536
  croppedImageData ? (
467
537
  // check_DP: Show confirmation screen
468
538
  react_1.default.createElement(react_native_1.View, { style: styles.confirmationContainer },
469
- react_1.default.createElement(react_native_1.View, { style: styles.rotateButtonsCenter },
539
+ isImageRotationSupported ? (react_1.default.createElement(react_native_1.View, { style: styles.rotateButtonsCenter },
470
540
  react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.rotateButtonTop, onPress: () => handleRotateImage(-90), accessibilityLabel: "\uC67C\uCABD\uC73C\uB85C 90\uB3C4 \uD68C\uC804", accessibilityRole: "button" },
471
541
  react_1.default.createElement(react_native_1.Text, { style: styles.rotateIconText }, "\u21BA"),
472
542
  react_1.default.createElement(react_native_1.Text, { style: styles.rotateButtonLabel }, "\uC88C\uB85C 90\u00B0")),
473
543
  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" },
474
544
  react_1.default.createElement(react_native_1.Text, { style: styles.rotateIconText }, "\u21BB"),
475
- react_1.default.createElement(react_native_1.Text, { style: styles.rotateButtonLabel }, "\uC6B0\uB85C 90\u00B0"))),
545
+ react_1.default.createElement(react_native_1.Text, { style: styles.rotateButtonLabel }, "\uC6B0\uB85C 90\u00B0")))) : null,
476
546
  react_1.default.createElement(react_native_1.Image, { source: { uri: croppedImageData.path }, style: [
477
547
  styles.previewImage,
478
548
  { transform: [{ rotate: `${rotationDegrees}deg` }] }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "3.105.0",
3
+ "version": "3.107.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -12,7 +12,6 @@ import {
12
12
  import { launchImageLibrary } from 'react-native-image-picker';
13
13
  import ImageCropPicker from 'react-native-image-crop-picker';
14
14
  import RNFS from 'react-native-fs';
15
- import * as ImageManipulator from 'expo-image-manipulator';
16
15
  import { DocScanner } from './DocScanner';
17
16
  import type { CapturedDocument } from './types';
18
17
  import type {
@@ -22,7 +21,44 @@ import type {
22
21
  RectangleDetectEvent,
23
22
  } from './DocScanner';
24
23
 
24
+ type ImageManipulatorModule = typeof import('expo-image-manipulator');
25
+ type ImageRotateModule = {
26
+ rotateImage: (
27
+ uri: string,
28
+ angle: number,
29
+ onSuccess: (uri: string) => void,
30
+ onError: (error: unknown) => void,
31
+ ) => void;
32
+ } | null;
33
+
34
+ let ImageManipulator: ImageManipulatorModule | null = null;
35
+ let ImageRotate: ImageRotateModule = null;
36
+
37
+ try {
38
+ ImageManipulator = require('expo-image-manipulator') as ImageManipulatorModule;
39
+ } catch (error) {
40
+ console.warn(
41
+ '[FullDocScanner] expo-image-manipulator module unavailable. Checking fallback options.',
42
+ error,
43
+ );
44
+ }
45
+
46
+ try {
47
+ const rotateModule = require('react-native-image-rotate');
48
+ ImageRotate = (rotateModule?.default ?? rotateModule) as ImageRotateModule;
49
+ } catch (error) {
50
+ console.warn(
51
+ '[FullDocScanner] react-native-image-rotate module unavailable. Image rotation fallback disabled.',
52
+ error,
53
+ );
54
+ }
55
+
56
+ const isExpoImageManipulatorAvailable = !!ImageManipulator?.manipulateAsync;
57
+ const isImageRotateAvailable = !!ImageRotate?.rotateImage;
58
+ const isImageRotationSupported = isExpoImageManipulatorAvailable || isImageRotateAvailable;
59
+
25
60
  const stripFileUri = (value: string) => value.replace(/^file:\/\//, '');
61
+ const ensureFileUri = (value: string) => (value.startsWith('file://') ? value : `file://${value}`);
26
62
 
27
63
  const CROPPER_TIMEOUT_MS = 8000;
28
64
  const CROPPER_TIMEOUT_CODE = 'cropper_timeout';
@@ -456,21 +492,57 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
456
492
  setFlashEnabled(prev => !prev);
457
493
  }, []);
458
494
 
459
- const handleRotateImage = useCallback((degrees: -90 | 90) => {
460
- // UI만 회전 (실제 파일 회전은 confirm 시 처리)
461
- setRotationDegrees(prev => {
462
- const newRotation = prev + degrees;
463
- // -360 ~ 360 범위로 정규화
464
- if (newRotation <= -360) return newRotation + 360;
465
- if (newRotation >= 360) return newRotation - 360;
466
- return newRotation;
495
+ const rotateImageWithFallback = useCallback((uri: string, angle: number) => {
496
+ return new Promise<string>((resolve, reject) => {
497
+ if (!ImageRotate?.rotateImage) {
498
+ reject(new Error('react-native-image-rotate unavailable'));
499
+ return;
500
+ }
501
+
502
+ ImageRotate.rotateImage(
503
+ uri,
504
+ angle,
505
+ (rotatedUri) => resolve(rotatedUri),
506
+ (error) => {
507
+ const message =
508
+ typeof error === 'string'
509
+ ? error
510
+ : error && typeof error === 'object' && 'message' in error
511
+ ? String((error as any).message)
512
+ : 'Unknown rotation error';
513
+ reject(new Error(message));
514
+ },
515
+ );
467
516
  });
468
517
  }, []);
469
518
 
519
+ const handleRotateImage = useCallback(
520
+ (degrees: -90 | 90) => {
521
+ if (!isImageRotationSupported) {
522
+ console.warn(
523
+ '[FullDocScanner] Image rotation requested but no rotation module is available.',
524
+ );
525
+ return;
526
+ }
527
+
528
+ // UI만 회전 (실제 파일 회전은 confirm 시 처리)
529
+ setRotationDegrees((prev) => {
530
+ const newRotation = prev + degrees;
531
+ // -360 ~ 360 범위로 정규화
532
+ if (newRotation <= -360) return newRotation + 360;
533
+ if (newRotation >= 360) return newRotation - 360;
534
+ return newRotation;
535
+ });
536
+ },
537
+ [],
538
+ );
539
+
470
540
  const handleConfirm = useCallback(async () => {
471
- if (!croppedImageData) return;
541
+ if (!croppedImageData) {
542
+ return;
543
+ }
472
544
 
473
- // 회전이 없으면 기존 데이터 그대로 전달
545
+ // 회전이 없거나 모듈을 사용할 수 없으면 기존 데이터 그대로 전달
474
546
  if (rotationDegrees === 0) {
475
547
  onResult({
476
548
  path: croppedImageData.path,
@@ -479,39 +551,75 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
479
551
  return;
480
552
  }
481
553
 
482
- // 회전이 있으면 실제 파일 회전 처리
483
554
  try {
484
555
  setProcessing(true);
485
556
 
486
557
  // 회전 각도 정규화 (0, 90, 180, 270)
487
558
  const rotationNormalized = ((rotationDegrees % 360) + 360) % 360;
488
559
 
489
- // expo-image-manipulator로 이미지 회전
490
- const result = await ImageManipulator.manipulateAsync(
491
- croppedImageData.path,
492
- [{ rotate: rotationNormalized }],
493
- {
494
- compress: 0.9,
495
- format: ImageManipulator.SaveFormat.JPEG,
496
- base64: true,
560
+ if (rotationNormalized === 0) {
561
+ onResult({
562
+ path: croppedImageData.path,
563
+ base64: croppedImageData.base64,
564
+ });
565
+ return;
566
+ }
567
+
568
+ if (isExpoImageManipulatorAvailable && ImageManipulator?.manipulateAsync) {
569
+ // expo-image-manipulator로 이미지 회전
570
+ const result = await ImageManipulator.manipulateAsync(
571
+ croppedImageData.path,
572
+ [{ rotate: rotationNormalized }],
573
+ {
574
+ compress: 0.9,
575
+ format: ImageManipulator.SaveFormat.JPEG,
576
+ base64: true,
577
+ },
578
+ );
579
+
580
+ onResult({
581
+ path: result.uri,
582
+ base64: result.base64,
583
+ });
584
+ return;
585
+ }
586
+
587
+ if (isImageRotateAvailable && ImageRotate?.rotateImage) {
588
+ const sourceUri = ensureFileUri(croppedImageData.path);
589
+ const rotatedUri = await rotateImageWithFallback(sourceUri, rotationNormalized);
590
+ let base64Result = croppedImageData.base64;
591
+
592
+ try {
593
+ base64Result = await RNFS.readFile(stripFileUri(rotatedUri), 'base64');
594
+ } catch (readError) {
595
+ console.warn('[FullDocScanner] Failed to generate base64 for rotated image:', readError);
596
+ base64Result = undefined;
497
597
  }
498
- );
499
598
 
500
- setProcessing(false);
599
+ onResult({
600
+ path: rotatedUri,
601
+ base64: base64Result,
602
+ });
603
+ return;
604
+ }
501
605
 
502
- // 회전된 이미지로 결과 전달
606
+ console.warn(
607
+ '[FullDocScanner] Rotation requested but no supported rotation module is available. Returning original image.',
608
+ );
503
609
  onResult({
504
- path: result.uri,
505
- base64: result.base64,
610
+ path: croppedImageData.path,
611
+ base64: croppedImageData.base64,
506
612
  });
507
613
  } catch (error) {
508
614
  console.error('[FullDocScanner] Image rotation error on confirm:', error);
509
- setProcessing(false);
510
615
 
511
- const errorMessage = error && typeof error === 'object' && 'message' in error ? (error as Error).message : '';
616
+ const errorMessage =
617
+ error && typeof error === 'object' && 'message' in error ? (error as Error).message : '';
512
618
  Alert.alert('회전 실패', '이미지 회전 중 오류가 발생했습니다: ' + errorMessage);
619
+ } finally {
620
+ setProcessing(false);
513
621
  }
514
- }, [croppedImageData, rotationDegrees, onResult]);
622
+ }, [croppedImageData, rotationDegrees, onResult, rotateImageWithFallback]);
515
623
 
516
624
  const handleRetake = useCallback(() => {
517
625
  console.log('[FullDocScanner] Retake - clearing cropped image and resetting scanner');
@@ -599,26 +707,28 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
599
707
  // check_DP: Show confirmation screen
600
708
  <View style={styles.confirmationContainer}>
601
709
  {/* 회전 버튼들 - 가운데 정렬 */}
602
- <View style={styles.rotateButtonsCenter}>
603
- <TouchableOpacity
604
- style={styles.rotateButtonTop}
605
- onPress={() => handleRotateImage(-90)}
606
- accessibilityLabel="왼쪽으로 90도 회전"
607
- accessibilityRole="button"
608
- >
609
- <Text style={styles.rotateIconText}>↺</Text>
610
- <Text style={styles.rotateButtonLabel}>좌로 90°</Text>
611
- </TouchableOpacity>
612
- <TouchableOpacity
613
- style={styles.rotateButtonTop}
614
- onPress={() => handleRotateImage(90)}
615
- accessibilityLabel="오른쪽으로 90도 회전"
616
- accessibilityRole="button"
617
- >
618
- <Text style={styles.rotateIconText}>↻</Text>
619
- <Text style={styles.rotateButtonLabel}>우로 90°</Text>
620
- </TouchableOpacity>
621
- </View>
710
+ {isImageRotationSupported ? (
711
+ <View style={styles.rotateButtonsCenter}>
712
+ <TouchableOpacity
713
+ style={styles.rotateButtonTop}
714
+ onPress={() => handleRotateImage(-90)}
715
+ accessibilityLabel="왼쪽으로 90도 회전"
716
+ accessibilityRole="button"
717
+ >
718
+ <Text style={styles.rotateIconText}>↺</Text>
719
+ <Text style={styles.rotateButtonLabel}>좌로 90°</Text>
720
+ </TouchableOpacity>
721
+ <TouchableOpacity
722
+ style={styles.rotateButtonTop}
723
+ onPress={() => handleRotateImage(90)}
724
+ accessibilityLabel="오른쪽으로 90도 회전"
725
+ accessibilityRole="button"
726
+ >
727
+ <Text style={styles.rotateIconText}>↻</Text>
728
+ <Text style={styles.rotateButtonLabel}>우로 90°</Text>
729
+ </TouchableOpacity>
730
+ </View>
731
+ ) : null}
622
732
  <Image
623
733
  source={{ uri: croppedImageData.path }}
624
734
  style={[