react-native-rectangle-doc-scanner 3.16.0 → 3.21.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/DocScanner.d.ts +19 -8
- package/dist/DocScanner.js +71 -6
- package/dist/FullDocScanner.js +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/utils/overlay.d.ts +8 -13
- package/dist/utils/overlay.js +115 -129
- package/package.json +1 -1
- package/src/DocScanner.tsx +123 -8
- package/src/FullDocScanner.tsx +6 -0
- package/src/external.d.ts +50 -0
- package/src/index.ts +6 -1
- package/src/types.ts +2 -0
- package/src/utils/overlay.tsx +198 -0
- package/vendor/react-native-document-scanner/ios/DocumentScannerView.m +50 -9
- package/vendor/react-native-document-scanner/ios/IPDFCameraViewController.m +63 -4
package/dist/DocScanner.d.ts
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
import React, { ReactNode } from 'react';
|
|
2
|
+
import type { Rectangle as NativeRectangle, RectangleEventPayload } from 'react-native-document-scanner';
|
|
3
|
+
import type { Point, Rectangle } from './types';
|
|
2
4
|
type PictureEvent = {
|
|
3
5
|
croppedImage?: string | null;
|
|
4
6
|
initialImage?: string | null;
|
|
5
7
|
width?: number;
|
|
6
8
|
height?: number;
|
|
9
|
+
rectangleCoordinates?: NativeRectangle | null;
|
|
10
|
+
};
|
|
11
|
+
export type RectangleDetectEvent = Omit<RectangleEventPayload, 'rectangleCoordinates' | 'rectangleOnScreen'> & {
|
|
12
|
+
rectangleCoordinates?: Rectangle | null;
|
|
13
|
+
rectangleOnScreen?: Rectangle | null;
|
|
14
|
+
};
|
|
15
|
+
export type DocScannerCapture = {
|
|
16
|
+
path: string;
|
|
17
|
+
initialPath: string | null;
|
|
18
|
+
croppedPath: string | null;
|
|
19
|
+
quad: Point[] | null;
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
7
22
|
};
|
|
8
23
|
export interface DetectionConfig {
|
|
9
24
|
processingWidth?: number;
|
|
@@ -14,12 +29,7 @@ export interface DetectionConfig {
|
|
|
14
29
|
maxCenterDelta?: number;
|
|
15
30
|
}
|
|
16
31
|
interface Props {
|
|
17
|
-
onCapture?: (photo:
|
|
18
|
-
path: string;
|
|
19
|
-
quad: null;
|
|
20
|
-
width: number;
|
|
21
|
-
height: number;
|
|
22
|
-
}) => void;
|
|
32
|
+
onCapture?: (photo: DocScannerCapture) => void;
|
|
23
33
|
overlayColor?: string;
|
|
24
34
|
autoCapture?: boolean;
|
|
25
35
|
minStableFrames?: number;
|
|
@@ -31,10 +41,11 @@ interface Props {
|
|
|
31
41
|
gridColor?: string;
|
|
32
42
|
gridLineWidth?: number;
|
|
33
43
|
detectionConfig?: DetectionConfig;
|
|
44
|
+
onRectangleDetect?: (event: RectangleDetectEvent) => void;
|
|
34
45
|
}
|
|
35
|
-
type DocScannerHandle = {
|
|
46
|
+
export type DocScannerHandle = {
|
|
36
47
|
capture: () => Promise<PictureEvent>;
|
|
37
48
|
reset: () => void;
|
|
38
49
|
};
|
|
39
50
|
export declare const DocScanner: React.ForwardRefExoticComponent<Props & React.RefAttributes<DocScannerHandle>>;
|
|
40
|
-
export
|
|
51
|
+
export {};
|
package/dist/DocScanner.js
CHANGED
|
@@ -40,10 +40,44 @@ exports.DocScanner = void 0;
|
|
|
40
40
|
const react_1 = __importStar(require("react"));
|
|
41
41
|
const react_native_1 = require("react-native");
|
|
42
42
|
const react_native_document_scanner_1 = __importDefault(require("react-native-document-scanner"));
|
|
43
|
+
const coordinate_1 = require("./utils/coordinate");
|
|
44
|
+
const overlay_1 = require("./utils/overlay");
|
|
45
|
+
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
46
|
+
const normalizePoint = (point) => {
|
|
47
|
+
if (!point || !isFiniteNumber(point.x) || !isFiniteNumber(point.y)) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return { x: point.x, y: point.y };
|
|
51
|
+
};
|
|
52
|
+
const normalizeRectangle = (rectangle) => {
|
|
53
|
+
if (!rectangle) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const topLeft = normalizePoint(rectangle.topLeft);
|
|
57
|
+
const topRight = normalizePoint(rectangle.topRight);
|
|
58
|
+
const bottomRight = normalizePoint(rectangle.bottomRight);
|
|
59
|
+
const bottomLeft = normalizePoint(rectangle.bottomLeft);
|
|
60
|
+
if (!topLeft || !topRight || !bottomRight || !bottomLeft) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
topLeft,
|
|
65
|
+
topRight,
|
|
66
|
+
bottomRight,
|
|
67
|
+
bottomLeft,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
43
70
|
const DEFAULT_OVERLAY_COLOR = '#0b7ef4';
|
|
44
|
-
exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAULT_OVERLAY_COLOR, autoCapture = true, minStableFrames = 8, enableTorch = false, quality = 90, useBase64 = false, children, showGrid = true, }, ref) => {
|
|
71
|
+
exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAULT_OVERLAY_COLOR, autoCapture = true, minStableFrames = 8, enableTorch = false, quality = 90, useBase64 = false, children, showGrid = true, gridColor, gridLineWidth, detectionConfig, onRectangleDetect, }, ref) => {
|
|
45
72
|
const scannerRef = (0, react_1.useRef)(null);
|
|
46
73
|
const captureResolvers = (0, react_1.useRef)(null);
|
|
74
|
+
const [isAutoCapturing, setIsAutoCapturing] = (0, react_1.useState)(false);
|
|
75
|
+
const [detectedRectangle, setDetectedRectangle] = (0, react_1.useState)(null);
|
|
76
|
+
(0, react_1.useEffect)(() => {
|
|
77
|
+
if (!autoCapture) {
|
|
78
|
+
setIsAutoCapturing(false);
|
|
79
|
+
}
|
|
80
|
+
}, [autoCapture]);
|
|
47
81
|
const normalizedQuality = (0, react_1.useMemo)(() => {
|
|
48
82
|
if (react_native_1.Platform.OS === 'ios') {
|
|
49
83
|
// iOS expects 0-1
|
|
@@ -52,15 +86,23 @@ exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAUL
|
|
|
52
86
|
return Math.min(100, Math.max(0, quality));
|
|
53
87
|
}, [quality]);
|
|
54
88
|
const handlePictureTaken = (0, react_1.useCallback)((event) => {
|
|
55
|
-
|
|
56
|
-
|
|
89
|
+
setIsAutoCapturing(false);
|
|
90
|
+
const normalizedRectangle = normalizeRectangle(event.rectangleCoordinates ?? null);
|
|
91
|
+
const quad = normalizedRectangle ? (0, coordinate_1.rectangleToQuad)(normalizedRectangle) : null;
|
|
92
|
+
const initialPath = event.initialImage ?? null;
|
|
93
|
+
const croppedPath = event.croppedImage ?? null;
|
|
94
|
+
const bestPath = croppedPath ?? initialPath;
|
|
95
|
+
if (bestPath) {
|
|
57
96
|
onCapture?.({
|
|
58
|
-
path,
|
|
59
|
-
|
|
97
|
+
path: bestPath,
|
|
98
|
+
initialPath,
|
|
99
|
+
croppedPath,
|
|
100
|
+
quad,
|
|
60
101
|
width: event.width ?? 0,
|
|
61
102
|
height: event.height ?? 0,
|
|
62
103
|
});
|
|
63
104
|
}
|
|
105
|
+
setDetectedRectangle(null);
|
|
64
106
|
if (captureResolvers.current) {
|
|
65
107
|
captureResolvers.current.resolve(event);
|
|
66
108
|
captureResolvers.current = null;
|
|
@@ -99,6 +141,26 @@ exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAUL
|
|
|
99
141
|
console.warn('[DocScanner] manual capture failed', error);
|
|
100
142
|
});
|
|
101
143
|
}, [autoCapture, capture]);
|
|
144
|
+
const handleRectangleDetect = (0, react_1.useCallback)((event) => {
|
|
145
|
+
const rectangleCoordinates = normalizeRectangle(event.rectangleCoordinates ?? null);
|
|
146
|
+
const rectangleOnScreen = normalizeRectangle(event.rectangleOnScreen ?? null);
|
|
147
|
+
const payload = {
|
|
148
|
+
...event,
|
|
149
|
+
rectangleCoordinates,
|
|
150
|
+
rectangleOnScreen,
|
|
151
|
+
};
|
|
152
|
+
if (autoCapture) {
|
|
153
|
+
if (payload.stableCounter >= Math.max(minStableFrames - 1, 0)) {
|
|
154
|
+
setIsAutoCapturing(true);
|
|
155
|
+
}
|
|
156
|
+
else if (payload.stableCounter === 0) {
|
|
157
|
+
setIsAutoCapturing(false);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const isGoodRectangle = payload.lastDetectionType === 0;
|
|
161
|
+
setDetectedRectangle(isGoodRectangle && rectangleOnScreen ? payload : null);
|
|
162
|
+
onRectangleDetect?.(payload);
|
|
163
|
+
}, [autoCapture, minStableFrames, onRectangleDetect]);
|
|
102
164
|
(0, react_1.useImperativeHandle)(ref, () => ({
|
|
103
165
|
capture,
|
|
104
166
|
reset: () => {
|
|
@@ -108,8 +170,11 @@ exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAUL
|
|
|
108
170
|
}
|
|
109
171
|
},
|
|
110
172
|
}), [capture]);
|
|
173
|
+
const overlayPolygon = detectedRectangle?.rectangleOnScreen ?? null;
|
|
174
|
+
const overlayIsActive = autoCapture ? isAutoCapturing : (detectedRectangle?.stableCounter ?? 0) > 0;
|
|
111
175
|
return (react_1.default.createElement(react_native_1.View, { style: styles.container },
|
|
112
|
-
react_1.default.createElement(react_native_document_scanner_1.default, { ref: scannerRef, style: styles.scanner, detectionCountBeforeCapture: minStableFrames, overlayColor: overlayColor, enableTorch: enableTorch, quality: normalizedQuality, useBase64: useBase64, manualOnly: !autoCapture, onPictureTaken: handlePictureTaken, onError: handleError }),
|
|
176
|
+
react_1.default.createElement(react_native_document_scanner_1.default, { ref: scannerRef, style: styles.scanner, detectionCountBeforeCapture: minStableFrames, overlayColor: overlayColor, enableTorch: enableTorch, quality: normalizedQuality, useBase64: useBase64, manualOnly: !autoCapture, detectionConfig: detectionConfig, onPictureTaken: handlePictureTaken, onError: handleError, onRectangleDetect: handleRectangleDetect }),
|
|
177
|
+
showGrid && overlayPolygon && (react_1.default.createElement(overlay_1.ScannerOverlay, { active: overlayIsActive, color: gridColor ?? overlayColor, lineWidth: gridLineWidth, polygon: overlayPolygon })),
|
|
113
178
|
!autoCapture && react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.button, onPress: handleManualCapture }),
|
|
114
179
|
children));
|
|
115
180
|
});
|
package/dist/FullDocScanner.js
CHANGED
|
@@ -73,9 +73,13 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
73
73
|
const handleCapture = (0, react_1.useCallback)((document) => {
|
|
74
74
|
const normalizedPath = stripFileUri(document.path);
|
|
75
75
|
const nextQuad = document.quad && document.quad.length === 4 ? document.quad : null;
|
|
76
|
+
const normalizedInitial = document.initialPath != null ? stripFileUri(document.initialPath) : normalizedPath;
|
|
77
|
+
const normalizedCropped = document.croppedPath != null ? stripFileUri(document.croppedPath) : null;
|
|
76
78
|
setCapturedDoc({
|
|
77
79
|
...document,
|
|
78
80
|
path: normalizedPath,
|
|
81
|
+
initialPath: normalizedInitial,
|
|
82
|
+
croppedPath: normalizedCropped,
|
|
79
83
|
quad: nextQuad,
|
|
80
84
|
});
|
|
81
85
|
setCropRectangle(nextQuad ? (0, coordinate_1.quadToRectangle)(nextQuad) : null);
|
package/dist/index.d.ts
CHANGED
|
@@ -3,5 +3,5 @@ export { CropEditor } from './CropEditor';
|
|
|
3
3
|
export { FullDocScanner } from './FullDocScanner';
|
|
4
4
|
export type { FullDocScannerResult, FullDocScannerProps, FullDocScannerStrings, } from './FullDocScanner';
|
|
5
5
|
export type { Point, Quad, Rectangle, CapturedDocument } from './types';
|
|
6
|
-
export type { DetectionConfig, DocScannerHandle } from './DocScanner';
|
|
6
|
+
export type { DetectionConfig, DocScannerHandle, DocScannerCapture, RectangleDetectEvent, } from './DocScanner';
|
|
7
7
|
export { quadToRectangle, rectangleToQuad, scaleCoordinates, scaleRectangle, } from './utils/coordinate';
|
package/dist/types.d.ts
CHANGED
package/dist/utils/overlay.d.ts
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import type {
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import type { Rectangle } from '../types';
|
|
3
|
+
export interface ScannerOverlayProps {
|
|
4
|
+
/** 자동 캡처 중임을 표시할 때 true로 설정합니다. */
|
|
5
|
+
active: boolean;
|
|
5
6
|
color?: string;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
showGrid?: boolean;
|
|
11
|
-
gridColor?: string;
|
|
12
|
-
gridLineWidth?: number;
|
|
13
|
-
};
|
|
14
|
-
export declare const Overlay: React.FC<OverlayProps>;
|
|
15
|
-
export {};
|
|
7
|
+
lineWidth?: number;
|
|
8
|
+
polygon?: Rectangle | null;
|
|
9
|
+
}
|
|
10
|
+
export declare const ScannerOverlay: React.FC<ScannerOverlayProps>;
|
package/dist/utils/overlay.js
CHANGED
|
@@ -33,150 +33,136 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.
|
|
36
|
+
exports.ScannerOverlay = void 0;
|
|
37
37
|
const react_1 = __importStar(require("react"));
|
|
38
38
|
const react_native_1 = require("react-native");
|
|
39
39
|
const react_native_skia_1 = require("@shopify/react-native-skia");
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (!hexMatch) {
|
|
47
|
-
return `rgba(231, 166, 73, ${alpha})`;
|
|
40
|
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
41
|
+
const withAlpha = (inputColor, alpha) => {
|
|
42
|
+
const parsed = (0, react_native_1.processColor)(inputColor);
|
|
43
|
+
const normalized = typeof parsed === 'number' ? parsed >>> 0 : null;
|
|
44
|
+
if (normalized == null) {
|
|
45
|
+
return inputColor;
|
|
48
46
|
}
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const g = parseInt(normalize.slice(2, 4), 16);
|
|
55
|
-
const b = parseInt(normalize.slice(4, 6), 16);
|
|
56
|
-
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
47
|
+
const r = (normalized >> 16) & 0xff;
|
|
48
|
+
const g = (normalized >> 8) & 0xff;
|
|
49
|
+
const b = normalized & 0xff;
|
|
50
|
+
const clampedAlpha = clamp(alpha, 0, 1);
|
|
51
|
+
return `rgba(${r}, ${g}, ${b}, ${clampedAlpha})`;
|
|
57
52
|
};
|
|
58
|
-
const
|
|
53
|
+
const createPolygonPath = (polygon) => {
|
|
54
|
+
if (!polygon) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
59
57
|
const path = react_native_skia_1.Skia.Path.Make();
|
|
60
|
-
path.moveTo(
|
|
61
|
-
|
|
58
|
+
path.moveTo(polygon.topLeft.x, polygon.topLeft.y);
|
|
59
|
+
path.lineTo(polygon.topRight.x, polygon.topRight.y);
|
|
60
|
+
path.lineTo(polygon.bottomRight.x, polygon.bottomRight.y);
|
|
61
|
+
path.lineTo(polygon.bottomLeft.x, polygon.bottomLeft.y);
|
|
62
62
|
path.close();
|
|
63
63
|
return path;
|
|
64
64
|
};
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
const interpolate = (a, b, t) => ({
|
|
66
|
+
x: a.x + (b.x - a.x) * t,
|
|
67
|
+
y: a.y + (b.y - a.y) * t,
|
|
68
|
+
});
|
|
69
|
+
const createLinePath = (start, end) => {
|
|
70
|
+
const path = react_native_skia_1.Skia.Path.Make();
|
|
71
|
+
path.moveTo(start.x, start.y);
|
|
72
|
+
path.lineTo(end.x, end.y);
|
|
73
|
+
return path;
|
|
74
|
+
};
|
|
75
|
+
const createGridPaths = (polygon) => {
|
|
76
|
+
if (!polygon) {
|
|
77
|
+
return [];
|
|
68
78
|
}
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
79
|
+
const lines = [];
|
|
80
|
+
const steps = [1 / 3, 2 / 3];
|
|
81
|
+
steps.forEach((t) => {
|
|
82
|
+
const horizontalStart = interpolate(polygon.topLeft, polygon.bottomLeft, t);
|
|
83
|
+
const horizontalEnd = interpolate(polygon.topRight, polygon.bottomRight, t);
|
|
84
|
+
lines.push(createLinePath(horizontalStart, horizontalEnd));
|
|
85
|
+
const verticalStart = interpolate(polygon.topLeft, polygon.topRight, t);
|
|
86
|
+
const verticalEnd = interpolate(polygon.bottomLeft, polygon.bottomRight, t);
|
|
87
|
+
lines.push(createLinePath(verticalStart, verticalEnd));
|
|
74
88
|
});
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
return lines;
|
|
90
|
+
};
|
|
91
|
+
const getPolygonMetrics = (polygon) => {
|
|
92
|
+
const minX = Math.min(polygon.topLeft.x, polygon.bottomLeft.x, polygon.topRight.x, polygon.bottomRight.x);
|
|
93
|
+
const maxX = Math.max(polygon.topLeft.x, polygon.bottomLeft.x, polygon.topRight.x, polygon.bottomRight.x);
|
|
94
|
+
const minY = Math.min(polygon.topLeft.y, polygon.topRight.y, polygon.bottomLeft.y, polygon.bottomRight.y);
|
|
95
|
+
const maxY = Math.max(polygon.topLeft.y, polygon.topRight.y, polygon.bottomLeft.y, polygon.bottomRight.y);
|
|
96
|
+
return {
|
|
97
|
+
minX,
|
|
98
|
+
maxX,
|
|
99
|
+
minY,
|
|
100
|
+
maxY,
|
|
101
|
+
width: maxX - minX,
|
|
102
|
+
height: maxY - minY,
|
|
103
|
+
centerX: minX + (maxX - minX) / 2,
|
|
104
|
+
};
|
|
90
105
|
};
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
106
|
+
const SCAN_DURATION_MS = 2200;
|
|
107
|
+
const ScannerOverlay = ({ active, color = '#0b7ef4', lineWidth = react_native_1.StyleSheet.hairlineWidth, polygon, }) => {
|
|
108
|
+
const path = (0, react_1.useMemo)(() => createPolygonPath(polygon ?? null), [polygon]);
|
|
109
|
+
const gridPaths = (0, react_1.useMemo)(() => createGridPaths(polygon ?? null), [polygon]);
|
|
110
|
+
const metrics = (0, react_1.useMemo)(() => (polygon ? getPolygonMetrics(polygon) : null), [polygon]);
|
|
111
|
+
const gradientStart = (0, react_native_skia_1.useValue)((0, react_native_skia_1.vec)(0, 0));
|
|
112
|
+
const gradientEnd = (0, react_native_skia_1.useValue)((0, react_native_skia_1.vec)(0, 0));
|
|
113
|
+
const gradientColors = (0, react_native_skia_1.useValue)([
|
|
114
|
+
react_native_skia_1.Skia.Color(withAlpha(color, 0)),
|
|
115
|
+
react_native_skia_1.Skia.Color(withAlpha(color, 0.85)),
|
|
116
|
+
react_native_skia_1.Skia.Color(withAlpha(color, 0)),
|
|
117
|
+
]);
|
|
118
|
+
const gradientPositions = (0, react_native_skia_1.useValue)([0, 0.5, 1]);
|
|
119
|
+
(0, react_1.useEffect)(() => {
|
|
120
|
+
if (!metrics) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
let frame = null;
|
|
124
|
+
const transparentColor = react_native_skia_1.Skia.Color(withAlpha(color, 0));
|
|
125
|
+
const highlightColor = react_native_skia_1.Skia.Color(withAlpha(color, 0.9));
|
|
126
|
+
const bandSize = Math.max(metrics.height * 0.25, 20);
|
|
127
|
+
const animate = () => {
|
|
128
|
+
const now = Date.now() % SCAN_DURATION_MS;
|
|
129
|
+
const progress = now / SCAN_DURATION_MS;
|
|
130
|
+
const travel = metrics.height + bandSize * 2;
|
|
131
|
+
const start = metrics.minY - bandSize + travel * progress;
|
|
132
|
+
const end = start + bandSize;
|
|
133
|
+
const clampedStart = clamp(start, metrics.minY, metrics.maxY);
|
|
134
|
+
const clampedEnd = clamp(end, metrics.minY, metrics.maxY);
|
|
135
|
+
gradientStart.current = (0, react_native_skia_1.vec)(metrics.centerX, clampedStart);
|
|
136
|
+
gradientEnd.current = (0, react_native_skia_1.vec)(metrics.centerX, clampedEnd <= clampedStart ? clampedStart + 1 : clampedEnd);
|
|
137
|
+
gradientColors.current = [transparentColor, highlightColor, transparentColor];
|
|
138
|
+
frame = requestAnimationFrame(animate);
|
|
139
|
+
};
|
|
140
|
+
gradientStart.current = (0, react_native_skia_1.vec)(metrics.centerX, metrics.minY);
|
|
141
|
+
gradientEnd.current = (0, react_native_skia_1.vec)(metrics.centerX, metrics.maxY);
|
|
142
|
+
if (active) {
|
|
143
|
+
animate();
|
|
100
144
|
}
|
|
101
145
|
else {
|
|
102
|
-
|
|
103
|
-
return { outlinePath: null, gridPaths: [] };
|
|
146
|
+
gradientColors.current = [transparentColor, transparentColor, transparentColor];
|
|
104
147
|
}
|
|
105
|
-
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
console.log('[Overlay] color:', color);
|
|
109
|
-
console.log('[Overlay] screen dimensions:', screenWidth, 'x', screenHeight);
|
|
110
|
-
console.log('[Overlay] frame dimensions:', sourceFrameSize.width, 'x', sourceFrameSize.height);
|
|
111
|
-
}
|
|
112
|
-
const isFrameLandscape = sourceFrameSize.width > sourceFrameSize.height;
|
|
113
|
-
const isScreenPortrait = screenHeight > screenWidth;
|
|
114
|
-
const needsRotation = isFrameLandscape && isScreenPortrait;
|
|
115
|
-
if (needsRotation) {
|
|
116
|
-
const scaleX = screenWidth / sourceFrameSize.height;
|
|
117
|
-
const scaleY = screenHeight / sourceFrameSize.width;
|
|
118
|
-
transformedQuad = sourceQuad.map((p) => ({
|
|
119
|
-
x: p.y * scaleX,
|
|
120
|
-
y: (sourceFrameSize.width - p.x) * scaleY,
|
|
121
|
-
}));
|
|
148
|
+
return () => {
|
|
149
|
+
if (frame !== null) {
|
|
150
|
+
cancelAnimationFrame(frame);
|
|
122
151
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
x: p.x * scaleX,
|
|
128
|
-
y: p.y * scaleY,
|
|
129
|
-
}));
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
if (!transformedQuad) {
|
|
133
|
-
return { outlinePath: null, gridPaths: [] };
|
|
134
|
-
}
|
|
135
|
-
const normalizedQuad = orderQuad(transformedQuad);
|
|
136
|
-
const skPath = buildPath(normalizedQuad);
|
|
137
|
-
const grid = [];
|
|
138
|
-
if (showGrid) {
|
|
139
|
-
const [topLeft, topRight, bottomRight, bottomLeft] = normalizedQuad;
|
|
140
|
-
const steps = [1 / 3, 2 / 3];
|
|
141
|
-
steps.forEach((t) => {
|
|
142
|
-
const start = lerp(topLeft, topRight, t);
|
|
143
|
-
const end = lerp(bottomLeft, bottomRight, t);
|
|
144
|
-
const verticalPath = react_native_skia_1.Skia.Path.Make();
|
|
145
|
-
verticalPath.moveTo(start.x, start.y);
|
|
146
|
-
verticalPath.lineTo(end.x, end.y);
|
|
147
|
-
grid.push(verticalPath);
|
|
148
|
-
});
|
|
149
|
-
steps.forEach((t) => {
|
|
150
|
-
const start = lerp(topLeft, bottomLeft, t);
|
|
151
|
-
const end = lerp(topRight, bottomRight, t);
|
|
152
|
-
const horizontalPath = react_native_skia_1.Skia.Path.Make();
|
|
153
|
-
horizontalPath.moveTo(start.x, start.y);
|
|
154
|
-
horizontalPath.lineTo(end.x, end.y);
|
|
155
|
-
grid.push(horizontalPath);
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
return { outlinePath: skPath, gridPaths: grid };
|
|
159
|
-
}, [quad, screenWidth, screenHeight, frameSize, showGrid, color]);
|
|
160
|
-
if (__DEV__) {
|
|
161
|
-
console.log('[Overlay] rendering Canvas with dimensions:', screenWidth, 'x', screenHeight);
|
|
152
|
+
};
|
|
153
|
+
}, [active, color, gradientColors, gradientEnd, gradientStart, metrics]);
|
|
154
|
+
if (!polygon || !path || !metrics) {
|
|
155
|
+
return null;
|
|
162
156
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
, {
|
|
170
|
-
|
|
171
|
-
|
|
157
|
+
const strokeColor = withAlpha(color, 0.9);
|
|
158
|
+
const fillColor = withAlpha(color, 0.18);
|
|
159
|
+
const gridColor = withAlpha(color, 0.35);
|
|
160
|
+
return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: react_native_1.StyleSheet.absoluteFillObject },
|
|
161
|
+
react_1.default.createElement(react_native_skia_1.Canvas, { style: react_native_1.StyleSheet.absoluteFillObject },
|
|
162
|
+
react_1.default.createElement(react_native_skia_1.Path, { path: path, color: fillColor, style: "fill" }),
|
|
163
|
+
gridPaths.map((gridPath, index) => (react_1.default.createElement(react_native_skia_1.Path, { key: `grid-${index}`, path: gridPath, color: gridColor, style: "stroke", strokeWidth: lineWidth }))),
|
|
164
|
+
react_1.default.createElement(react_native_skia_1.Path, { path: path, color: strokeColor, style: "stroke", strokeWidth: lineWidth }),
|
|
165
|
+
react_1.default.createElement(react_native_skia_1.Path, { path: path },
|
|
166
|
+
react_1.default.createElement(react_native_skia_1.LinearGradient, { start: gradientStart, end: gradientEnd, colors: gradientColors, positions: gradientPositions })))));
|
|
172
167
|
};
|
|
173
|
-
exports.
|
|
174
|
-
const styles = react_native_1.StyleSheet.create({
|
|
175
|
-
container: {
|
|
176
|
-
position: 'absolute',
|
|
177
|
-
top: 0,
|
|
178
|
-
left: 0,
|
|
179
|
-
right: 0,
|
|
180
|
-
bottom: 0,
|
|
181
|
-
},
|
|
182
|
-
});
|
|
168
|
+
exports.ScannerOverlay = ScannerOverlay;
|
package/package.json
CHANGED
package/src/DocScanner.tsx
CHANGED
|
@@ -2,18 +2,71 @@ import React, {
|
|
|
2
2
|
ReactNode,
|
|
3
3
|
forwardRef,
|
|
4
4
|
useCallback,
|
|
5
|
+
useEffect,
|
|
5
6
|
useImperativeHandle,
|
|
6
7
|
useMemo,
|
|
7
8
|
useRef,
|
|
9
|
+
useState,
|
|
8
10
|
} from 'react';
|
|
9
11
|
import { Platform, StyleSheet, TouchableOpacity, View } from 'react-native';
|
|
10
12
|
import DocumentScanner from 'react-native-document-scanner';
|
|
13
|
+
import type { Rectangle as NativeRectangle, RectangleEventPayload } from 'react-native-document-scanner';
|
|
14
|
+
import { rectangleToQuad } from './utils/coordinate';
|
|
15
|
+
import type { Point, Rectangle } from './types';
|
|
16
|
+
import { ScannerOverlay } from './utils/overlay';
|
|
11
17
|
|
|
12
18
|
type PictureEvent = {
|
|
13
19
|
croppedImage?: string | null;
|
|
14
20
|
initialImage?: string | null;
|
|
15
21
|
width?: number;
|
|
16
22
|
height?: number;
|
|
23
|
+
rectangleCoordinates?: NativeRectangle | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type RectangleDetectEvent = Omit<RectangleEventPayload, 'rectangleCoordinates' | 'rectangleOnScreen'> & {
|
|
27
|
+
rectangleCoordinates?: Rectangle | null;
|
|
28
|
+
rectangleOnScreen?: Rectangle | null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type DocScannerCapture = {
|
|
32
|
+
path: string;
|
|
33
|
+
initialPath: string | null;
|
|
34
|
+
croppedPath: string | null;
|
|
35
|
+
quad: Point[] | null;
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const isFiniteNumber = (value: unknown): value is number =>
|
|
41
|
+
typeof value === 'number' && Number.isFinite(value);
|
|
42
|
+
|
|
43
|
+
const normalizePoint = (point?: { x?: number; y?: number } | null): Point | null => {
|
|
44
|
+
if (!point || !isFiniteNumber(point.x) || !isFiniteNumber(point.y)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return { x: point.x, y: point.y };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const normalizeRectangle = (rectangle?: NativeRectangle | null): Rectangle | null => {
|
|
51
|
+
if (!rectangle) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const topLeft = normalizePoint(rectangle.topLeft);
|
|
56
|
+
const topRight = normalizePoint(rectangle.topRight);
|
|
57
|
+
const bottomRight = normalizePoint(rectangle.bottomRight);
|
|
58
|
+
const bottomLeft = normalizePoint(rectangle.bottomLeft);
|
|
59
|
+
|
|
60
|
+
if (!topLeft || !topRight || !bottomRight || !bottomLeft) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
topLeft,
|
|
66
|
+
topRight,
|
|
67
|
+
bottomRight,
|
|
68
|
+
bottomLeft,
|
|
69
|
+
};
|
|
17
70
|
};
|
|
18
71
|
|
|
19
72
|
export interface DetectionConfig {
|
|
@@ -26,7 +79,7 @@ export interface DetectionConfig {
|
|
|
26
79
|
}
|
|
27
80
|
|
|
28
81
|
interface Props {
|
|
29
|
-
onCapture?: (photo:
|
|
82
|
+
onCapture?: (photo: DocScannerCapture) => void;
|
|
30
83
|
overlayColor?: string;
|
|
31
84
|
autoCapture?: boolean;
|
|
32
85
|
minStableFrames?: number;
|
|
@@ -38,9 +91,10 @@ interface Props {
|
|
|
38
91
|
gridColor?: string;
|
|
39
92
|
gridLineWidth?: number;
|
|
40
93
|
detectionConfig?: DetectionConfig;
|
|
94
|
+
onRectangleDetect?: (event: RectangleDetectEvent) => void;
|
|
41
95
|
}
|
|
42
96
|
|
|
43
|
-
type DocScannerHandle = {
|
|
97
|
+
export type DocScannerHandle = {
|
|
44
98
|
capture: () => Promise<PictureEvent>;
|
|
45
99
|
reset: () => void;
|
|
46
100
|
};
|
|
@@ -59,6 +113,10 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
|
|
|
59
113
|
useBase64 = false,
|
|
60
114
|
children,
|
|
61
115
|
showGrid = true,
|
|
116
|
+
gridColor,
|
|
117
|
+
gridLineWidth,
|
|
118
|
+
detectionConfig,
|
|
119
|
+
onRectangleDetect,
|
|
62
120
|
},
|
|
63
121
|
ref,
|
|
64
122
|
) => {
|
|
@@ -67,6 +125,14 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
|
|
|
67
125
|
resolve: (value: PictureEvent) => void;
|
|
68
126
|
reject: (reason?: unknown) => void;
|
|
69
127
|
} | null>(null);
|
|
128
|
+
const [isAutoCapturing, setIsAutoCapturing] = useState(false);
|
|
129
|
+
const [detectedRectangle, setDetectedRectangle] = useState<RectangleDetectEvent | null>(null);
|
|
130
|
+
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (!autoCapture) {
|
|
133
|
+
setIsAutoCapturing(false);
|
|
134
|
+
}
|
|
135
|
+
}, [autoCapture]);
|
|
70
136
|
|
|
71
137
|
const normalizedQuality = useMemo(() => {
|
|
72
138
|
if (Platform.OS === 'ios') {
|
|
@@ -78,16 +144,28 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
|
|
|
78
144
|
|
|
79
145
|
const handlePictureTaken = useCallback(
|
|
80
146
|
(event: PictureEvent) => {
|
|
81
|
-
|
|
82
|
-
|
|
147
|
+
setIsAutoCapturing(false);
|
|
148
|
+
|
|
149
|
+
const normalizedRectangle = normalizeRectangle(event.rectangleCoordinates ?? null);
|
|
150
|
+
const quad = normalizedRectangle ? rectangleToQuad(normalizedRectangle) : null;
|
|
151
|
+
|
|
152
|
+
const initialPath = event.initialImage ?? null;
|
|
153
|
+
const croppedPath = event.croppedImage ?? null;
|
|
154
|
+
const bestPath = croppedPath ?? initialPath;
|
|
155
|
+
|
|
156
|
+
if (bestPath) {
|
|
83
157
|
onCapture?.({
|
|
84
|
-
path,
|
|
85
|
-
|
|
158
|
+
path: bestPath,
|
|
159
|
+
initialPath,
|
|
160
|
+
croppedPath,
|
|
161
|
+
quad,
|
|
86
162
|
width: event.width ?? 0,
|
|
87
163
|
height: event.height ?? 0,
|
|
88
164
|
});
|
|
89
165
|
}
|
|
90
166
|
|
|
167
|
+
setDetectedRectangle(null);
|
|
168
|
+
|
|
91
169
|
if (captureResolvers.current) {
|
|
92
170
|
captureResolvers.current.resolve(event);
|
|
93
171
|
captureResolvers.current = null;
|
|
@@ -134,6 +212,32 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
|
|
|
134
212
|
});
|
|
135
213
|
}, [autoCapture, capture]);
|
|
136
214
|
|
|
215
|
+
const handleRectangleDetect = useCallback(
|
|
216
|
+
(event: RectangleEventPayload) => {
|
|
217
|
+
const rectangleCoordinates = normalizeRectangle(event.rectangleCoordinates ?? null);
|
|
218
|
+
const rectangleOnScreen = normalizeRectangle(event.rectangleOnScreen ?? null);
|
|
219
|
+
|
|
220
|
+
const payload: RectangleDetectEvent = {
|
|
221
|
+
...event,
|
|
222
|
+
rectangleCoordinates,
|
|
223
|
+
rectangleOnScreen,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
if (autoCapture) {
|
|
227
|
+
if (payload.stableCounter >= Math.max(minStableFrames - 1, 0)) {
|
|
228
|
+
setIsAutoCapturing(true);
|
|
229
|
+
} else if (payload.stableCounter === 0) {
|
|
230
|
+
setIsAutoCapturing(false);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const isGoodRectangle = payload.lastDetectionType === 0;
|
|
235
|
+
setDetectedRectangle(isGoodRectangle && rectangleOnScreen ? payload : null);
|
|
236
|
+
onRectangleDetect?.(payload);
|
|
237
|
+
},
|
|
238
|
+
[autoCapture, minStableFrames, onRectangleDetect],
|
|
239
|
+
);
|
|
240
|
+
|
|
137
241
|
useImperativeHandle(
|
|
138
242
|
ref,
|
|
139
243
|
() => ({
|
|
@@ -148,6 +252,9 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
|
|
|
148
252
|
[capture],
|
|
149
253
|
);
|
|
150
254
|
|
|
255
|
+
const overlayPolygon = detectedRectangle?.rectangleOnScreen ?? null;
|
|
256
|
+
const overlayIsActive = autoCapture ? isAutoCapturing : (detectedRectangle?.stableCounter ?? 0) > 0;
|
|
257
|
+
|
|
151
258
|
return (
|
|
152
259
|
<View style={styles.container}>
|
|
153
260
|
<DocumentScanner
|
|
@@ -159,9 +266,19 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
|
|
|
159
266
|
quality={normalizedQuality}
|
|
160
267
|
useBase64={useBase64}
|
|
161
268
|
manualOnly={!autoCapture}
|
|
269
|
+
detectionConfig={detectionConfig}
|
|
162
270
|
onPictureTaken={handlePictureTaken}
|
|
163
271
|
onError={handleError}
|
|
272
|
+
onRectangleDetect={handleRectangleDetect}
|
|
164
273
|
/>
|
|
274
|
+
{showGrid && overlayPolygon && (
|
|
275
|
+
<ScannerOverlay
|
|
276
|
+
active={overlayIsActive}
|
|
277
|
+
color={gridColor ?? overlayColor}
|
|
278
|
+
lineWidth={gridLineWidth}
|
|
279
|
+
polygon={overlayPolygon}
|
|
280
|
+
/>
|
|
281
|
+
)}
|
|
165
282
|
{!autoCapture && <TouchableOpacity style={styles.button} onPress={handleManualCapture} />}
|
|
166
283
|
{children}
|
|
167
284
|
</View>
|
|
@@ -193,5 +310,3 @@ const styles = StyleSheet.create({
|
|
|
193
310
|
backgroundColor: '#fff',
|
|
194
311
|
},
|
|
195
312
|
});
|
|
196
|
-
|
|
197
|
-
export type { DocScannerHandle };
|
package/src/FullDocScanner.tsx
CHANGED
|
@@ -128,10 +128,16 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
128
128
|
(document: CapturedDocument) => {
|
|
129
129
|
const normalizedPath = stripFileUri(document.path);
|
|
130
130
|
const nextQuad = document.quad && document.quad.length === 4 ? (document.quad as Quad) : null;
|
|
131
|
+
const normalizedInitial =
|
|
132
|
+
document.initialPath != null ? stripFileUri(document.initialPath) : normalizedPath;
|
|
133
|
+
const normalizedCropped =
|
|
134
|
+
document.croppedPath != null ? stripFileUri(document.croppedPath) : null;
|
|
131
135
|
|
|
132
136
|
setCapturedDoc({
|
|
133
137
|
...document,
|
|
134
138
|
path: normalizedPath,
|
|
139
|
+
initialPath: normalizedInitial,
|
|
140
|
+
croppedPath: normalizedCropped,
|
|
135
141
|
quad: nextQuad,
|
|
136
142
|
});
|
|
137
143
|
setCropRectangle(nextQuad ? quadToRectangle(nextQuad) : null);
|
package/src/external.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ declare module '@shopify/react-native-skia' {
|
|
|
12
12
|
Path: {
|
|
13
13
|
Make: () => SkPath;
|
|
14
14
|
};
|
|
15
|
+
Color: (color: string | number) => number;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
export type CanvasProps = {
|
|
@@ -26,9 +27,27 @@ declare module '@shopify/react-native-skia' {
|
|
|
26
27
|
style?: 'stroke' | 'fill';
|
|
27
28
|
strokeWidth?: number;
|
|
28
29
|
color?: string;
|
|
30
|
+
children?: ReactNode;
|
|
29
31
|
};
|
|
30
32
|
|
|
31
33
|
export const Path: ComponentType<PathProps>;
|
|
34
|
+
|
|
35
|
+
export type SkiaValue<T> = {
|
|
36
|
+
current: T;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const useValue: <T>(initialValue: T) => SkiaValue<T>;
|
|
40
|
+
|
|
41
|
+
export const vec: (x: number, y: number) => { x: number; y: number };
|
|
42
|
+
|
|
43
|
+
export type LinearGradientProps = {
|
|
44
|
+
start: SkiaValue<{ x: number; y: number }> | { x: number; y: number };
|
|
45
|
+
end: SkiaValue<{ x: number; y: number }> | { x: number; y: number };
|
|
46
|
+
colors: SkiaValue<number[]> | number[];
|
|
47
|
+
positions?: SkiaValue<number[]> | number[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const LinearGradient: ComponentType<LinearGradientProps>;
|
|
32
51
|
}
|
|
33
52
|
|
|
34
53
|
declare module 'react-native-perspective-image-cropper' {
|
|
@@ -62,11 +81,33 @@ declare module 'react-native-document-scanner' {
|
|
|
62
81
|
import type { Component } from 'react';
|
|
63
82
|
import type { ViewStyle } from 'react-native';
|
|
64
83
|
|
|
84
|
+
export type RectanglePoint = {
|
|
85
|
+
x: number;
|
|
86
|
+
y: number;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type Rectangle = {
|
|
90
|
+
topLeft: RectanglePoint;
|
|
91
|
+
topRight: RectanglePoint;
|
|
92
|
+
bottomLeft: RectanglePoint;
|
|
93
|
+
bottomRight: RectanglePoint;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type RectangleEventPayload = {
|
|
97
|
+
stableCounter: number;
|
|
98
|
+
lastDetectionType: number;
|
|
99
|
+
rectangleCoordinates?: Rectangle | null;
|
|
100
|
+
rectangleOnScreen?: Rectangle | null;
|
|
101
|
+
previewSize?: { width: number; height: number };
|
|
102
|
+
imageSize?: { width: number; height: number };
|
|
103
|
+
};
|
|
104
|
+
|
|
65
105
|
export type DocumentScannerResult = {
|
|
66
106
|
croppedImage?: string | null;
|
|
67
107
|
initialImage?: string | null;
|
|
68
108
|
width?: number;
|
|
69
109
|
height?: number;
|
|
110
|
+
rectangleCoordinates?: Rectangle | null;
|
|
70
111
|
};
|
|
71
112
|
|
|
72
113
|
export interface DocumentScannerProps {
|
|
@@ -77,8 +118,17 @@ declare module 'react-native-document-scanner' {
|
|
|
77
118
|
useBase64?: boolean;
|
|
78
119
|
quality?: number;
|
|
79
120
|
manualOnly?: boolean;
|
|
121
|
+
detectionConfig?: {
|
|
122
|
+
processingWidth?: number;
|
|
123
|
+
cannyLowThreshold?: number;
|
|
124
|
+
cannyHighThreshold?: number;
|
|
125
|
+
snapDistance?: number;
|
|
126
|
+
maxAnchorMisses?: number;
|
|
127
|
+
maxCenterDelta?: number;
|
|
128
|
+
};
|
|
80
129
|
onPictureTaken?: (event: DocumentScannerResult) => void;
|
|
81
130
|
onError?: (error: Error) => void;
|
|
131
|
+
onRectangleDetect?: (event: RectangleEventPayload) => void;
|
|
82
132
|
}
|
|
83
133
|
|
|
84
134
|
export default class DocumentScanner extends Component<DocumentScannerProps> {
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,12 @@ export type {
|
|
|
11
11
|
|
|
12
12
|
// Types
|
|
13
13
|
export type { Point, Quad, Rectangle, CapturedDocument } from './types';
|
|
14
|
-
export type {
|
|
14
|
+
export type {
|
|
15
|
+
DetectionConfig,
|
|
16
|
+
DocScannerHandle,
|
|
17
|
+
DocScannerCapture,
|
|
18
|
+
RectangleDetectEvent,
|
|
19
|
+
} from './DocScanner';
|
|
15
20
|
|
|
16
21
|
// Utilities
|
|
17
22
|
export {
|
package/src/types.ts
CHANGED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import React, { useEffect, useMemo } from 'react';
|
|
2
|
+
import { View, processColor, StyleSheet } from 'react-native';
|
|
3
|
+
import {
|
|
4
|
+
Canvas,
|
|
5
|
+
LinearGradient,
|
|
6
|
+
Path,
|
|
7
|
+
Skia,
|
|
8
|
+
SkPath,
|
|
9
|
+
useValue,
|
|
10
|
+
vec,
|
|
11
|
+
} from '@shopify/react-native-skia';
|
|
12
|
+
import type { Point, Rectangle } from '../types';
|
|
13
|
+
|
|
14
|
+
export interface ScannerOverlayProps {
|
|
15
|
+
/** 자동 캡처 중임을 표시할 때 true로 설정합니다. */
|
|
16
|
+
active: boolean;
|
|
17
|
+
color?: string;
|
|
18
|
+
lineWidth?: number;
|
|
19
|
+
polygon?: Rectangle | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
|
23
|
+
|
|
24
|
+
const withAlpha = (inputColor: string, alpha: number): string => {
|
|
25
|
+
const parsed = processColor(inputColor);
|
|
26
|
+
const normalized = typeof parsed === 'number' ? parsed >>> 0 : null;
|
|
27
|
+
|
|
28
|
+
if (normalized == null) {
|
|
29
|
+
return inputColor;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const r = (normalized >> 16) & 0xff;
|
|
33
|
+
const g = (normalized >> 8) & 0xff;
|
|
34
|
+
const b = normalized & 0xff;
|
|
35
|
+
const clampedAlpha = clamp(alpha, 0, 1);
|
|
36
|
+
|
|
37
|
+
return `rgba(${r}, ${g}, ${b}, ${clampedAlpha})`;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const createPolygonPath = (polygon: Rectangle | null): SkPath | null => {
|
|
41
|
+
if (!polygon) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const path = Skia.Path.Make();
|
|
46
|
+
path.moveTo(polygon.topLeft.x, polygon.topLeft.y);
|
|
47
|
+
path.lineTo(polygon.topRight.x, polygon.topRight.y);
|
|
48
|
+
path.lineTo(polygon.bottomRight.x, polygon.bottomRight.y);
|
|
49
|
+
path.lineTo(polygon.bottomLeft.x, polygon.bottomLeft.y);
|
|
50
|
+
path.close();
|
|
51
|
+
return path;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const interpolate = (a: Point, b: Point, t: number): Point => ({
|
|
55
|
+
x: a.x + (b.x - a.x) * t,
|
|
56
|
+
y: a.y + (b.y - a.y) * t,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const createLinePath = (start: Point, end: Point): SkPath => {
|
|
60
|
+
const path = Skia.Path.Make();
|
|
61
|
+
path.moveTo(start.x, start.y);
|
|
62
|
+
path.lineTo(end.x, end.y);
|
|
63
|
+
return path;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const createGridPaths = (polygon: Rectangle | null): SkPath[] => {
|
|
67
|
+
if (!polygon) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const lines: SkPath[] = [];
|
|
72
|
+
const steps = [1 / 3, 2 / 3];
|
|
73
|
+
|
|
74
|
+
steps.forEach((t) => {
|
|
75
|
+
const horizontalStart = interpolate(polygon.topLeft, polygon.bottomLeft, t);
|
|
76
|
+
const horizontalEnd = interpolate(polygon.topRight, polygon.bottomRight, t);
|
|
77
|
+
lines.push(createLinePath(horizontalStart, horizontalEnd));
|
|
78
|
+
|
|
79
|
+
const verticalStart = interpolate(polygon.topLeft, polygon.topRight, t);
|
|
80
|
+
const verticalEnd = interpolate(polygon.bottomLeft, polygon.bottomRight, t);
|
|
81
|
+
lines.push(createLinePath(verticalStart, verticalEnd));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return lines;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const getPolygonMetrics = (polygon: Rectangle) => {
|
|
88
|
+
const minX = Math.min(polygon.topLeft.x, polygon.bottomLeft.x, polygon.topRight.x, polygon.bottomRight.x);
|
|
89
|
+
const maxX = Math.max(polygon.topLeft.x, polygon.bottomLeft.x, polygon.topRight.x, polygon.bottomRight.x);
|
|
90
|
+
const minY = Math.min(polygon.topLeft.y, polygon.topRight.y, polygon.bottomLeft.y, polygon.bottomRight.y);
|
|
91
|
+
const maxY = Math.max(polygon.topLeft.y, polygon.topRight.y, polygon.bottomLeft.y, polygon.bottomRight.y);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
minX,
|
|
95
|
+
maxX,
|
|
96
|
+
minY,
|
|
97
|
+
maxY,
|
|
98
|
+
width: maxX - minX,
|
|
99
|
+
height: maxY - minY,
|
|
100
|
+
centerX: minX + (maxX - minX) / 2,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const SCAN_DURATION_MS = 2200;
|
|
105
|
+
|
|
106
|
+
export const ScannerOverlay: React.FC<ScannerOverlayProps> = ({
|
|
107
|
+
active,
|
|
108
|
+
color = '#0b7ef4',
|
|
109
|
+
lineWidth = StyleSheet.hairlineWidth,
|
|
110
|
+
polygon,
|
|
111
|
+
}) => {
|
|
112
|
+
const path = useMemo(() => createPolygonPath(polygon ?? null), [polygon]);
|
|
113
|
+
const gridPaths = useMemo(() => createGridPaths(polygon ?? null), [polygon]);
|
|
114
|
+
const metrics = useMemo(() => (polygon ? getPolygonMetrics(polygon) : null), [polygon]);
|
|
115
|
+
|
|
116
|
+
const gradientStart = useValue(vec(0, 0));
|
|
117
|
+
const gradientEnd = useValue(vec(0, 0));
|
|
118
|
+
const gradientColors = useValue<number[]>([
|
|
119
|
+
Skia.Color(withAlpha(color, 0)),
|
|
120
|
+
Skia.Color(withAlpha(color, 0.85)),
|
|
121
|
+
Skia.Color(withAlpha(color, 0)),
|
|
122
|
+
]);
|
|
123
|
+
const gradientPositions = useValue<number[]>([0, 0.5, 1]);
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!metrics) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let frame: number | null = null;
|
|
131
|
+
const transparentColor = Skia.Color(withAlpha(color, 0));
|
|
132
|
+
const highlightColor = Skia.Color(withAlpha(color, 0.9));
|
|
133
|
+
const bandSize = Math.max(metrics.height * 0.25, 20);
|
|
134
|
+
|
|
135
|
+
const animate = () => {
|
|
136
|
+
const now = Date.now() % SCAN_DURATION_MS;
|
|
137
|
+
const progress = now / SCAN_DURATION_MS;
|
|
138
|
+
const travel = metrics.height + bandSize * 2;
|
|
139
|
+
const start = metrics.minY - bandSize + travel * progress;
|
|
140
|
+
const end = start + bandSize;
|
|
141
|
+
|
|
142
|
+
const clampedStart = clamp(start, metrics.minY, metrics.maxY);
|
|
143
|
+
const clampedEnd = clamp(end, metrics.minY, metrics.maxY);
|
|
144
|
+
|
|
145
|
+
gradientStart.current = vec(metrics.centerX, clampedStart);
|
|
146
|
+
gradientEnd.current = vec(
|
|
147
|
+
metrics.centerX,
|
|
148
|
+
clampedEnd <= clampedStart ? clampedStart + 1 : clampedEnd,
|
|
149
|
+
);
|
|
150
|
+
gradientColors.current = [transparentColor, highlightColor, transparentColor];
|
|
151
|
+
|
|
152
|
+
frame = requestAnimationFrame(animate);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
gradientStart.current = vec(metrics.centerX, metrics.minY);
|
|
156
|
+
gradientEnd.current = vec(metrics.centerX, metrics.maxY);
|
|
157
|
+
|
|
158
|
+
if (active) {
|
|
159
|
+
animate();
|
|
160
|
+
} else {
|
|
161
|
+
gradientColors.current = [transparentColor, transparentColor, transparentColor];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return () => {
|
|
165
|
+
if (frame !== null) {
|
|
166
|
+
cancelAnimationFrame(frame);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}, [active, color, gradientColors, gradientEnd, gradientStart, metrics]);
|
|
170
|
+
|
|
171
|
+
if (!polygon || !path || !metrics) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const strokeColor = withAlpha(color, 0.9);
|
|
176
|
+
const fillColor = withAlpha(color, 0.18);
|
|
177
|
+
const gridColor = withAlpha(color, 0.35);
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<View pointerEvents="none" style={StyleSheet.absoluteFillObject}>
|
|
181
|
+
<Canvas style={StyleSheet.absoluteFillObject}>
|
|
182
|
+
<Path path={path} color={fillColor} style="fill" />
|
|
183
|
+
{gridPaths.map((gridPath, index) => (
|
|
184
|
+
<Path key={`grid-${index}`} path={gridPath} color={gridColor} style="stroke" strokeWidth={lineWidth} />
|
|
185
|
+
))}
|
|
186
|
+
<Path path={path} color={strokeColor} style="stroke" strokeWidth={lineWidth} />
|
|
187
|
+
<Path path={path}>
|
|
188
|
+
<LinearGradient
|
|
189
|
+
start={gradientStart}
|
|
190
|
+
end={gradientEnd}
|
|
191
|
+
colors={gradientColors}
|
|
192
|
+
positions={gradientPositions}
|
|
193
|
+
/>
|
|
194
|
+
</Path>
|
|
195
|
+
</Canvas>
|
|
196
|
+
</View>
|
|
197
|
+
);
|
|
198
|
+
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
@implementation DocumentScannerView {
|
|
5
5
|
BOOL _hasSetupCamera;
|
|
6
|
+
IPDFRectangeType _lastDetectionType;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
- (instancetype)init {
|
|
@@ -37,6 +38,20 @@
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
|
|
41
|
+
- (NSDictionary *)dictionaryForRectangleFeature:(CIRectangleFeature *)rectangleFeature
|
|
42
|
+
{
|
|
43
|
+
if (!rectangleFeature) {
|
|
44
|
+
return nil;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return @{
|
|
48
|
+
@"topLeft": @{ @"y": @(rectangleFeature.bottomLeft.x + 30), @"x": @(rectangleFeature.bottomLeft.y)},
|
|
49
|
+
@"topRight": @{ @"y": @(rectangleFeature.topLeft.x + 30), @"x": @(rectangleFeature.topLeft.y)},
|
|
50
|
+
@"bottomLeft": @{ @"y": @(rectangleFeature.bottomRight.x), @"x": @(rectangleFeature.bottomRight.y)},
|
|
51
|
+
@"bottomRight": @{ @"y": @(rectangleFeature.topRight.x), @"x": @(rectangleFeature.topRight.y)},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
- (void) didDetectRectangle:(CIRectangleFeature *)rectangle withType:(IPDFRectangeType)type {
|
|
41
56
|
switch (type) {
|
|
42
57
|
case IPDFRectangeTypeGood:
|
|
@@ -46,15 +61,45 @@
|
|
|
46
61
|
self.stableCounter = 0;
|
|
47
62
|
break;
|
|
48
63
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
64
|
+
|
|
65
|
+
_lastDetectionType = type;
|
|
52
66
|
|
|
53
67
|
if (self.stableCounter > self.detectionCountBeforeCapture){
|
|
54
68
|
[self capture];
|
|
55
69
|
}
|
|
56
70
|
}
|
|
57
71
|
|
|
72
|
+
- (void)cameraViewController:(IPDFCameraViewController *)controller
|
|
73
|
+
didDetectRectangle:(CIRectangleFeature *)rectangle
|
|
74
|
+
withType:(IPDFRectangeType)type
|
|
75
|
+
viewCoordinates:(NSDictionary *)viewCoordinates
|
|
76
|
+
imageSize:(CGSize)imageSize
|
|
77
|
+
{
|
|
78
|
+
_lastDetectionType = type;
|
|
79
|
+
|
|
80
|
+
if (!self.onRectangleDetect) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
NSDictionary *rectangleCoordinates = [self dictionaryForRectangleFeature:rectangle];
|
|
85
|
+
NSMutableDictionary *payload = [@{
|
|
86
|
+
@"stableCounter": @(self.stableCounter),
|
|
87
|
+
@"lastDetectionType": @(_lastDetectionType),
|
|
88
|
+
@"rectangleCoordinates": rectangleCoordinates ? rectangleCoordinates : [NSNull null],
|
|
89
|
+
@"rectangleOnScreen": viewCoordinates ? viewCoordinates : [NSNull null],
|
|
90
|
+
@"previewSize": @{
|
|
91
|
+
@"width": @(self.bounds.size.width),
|
|
92
|
+
@"height": @(self.bounds.size.height)
|
|
93
|
+
},
|
|
94
|
+
@"imageSize": @{
|
|
95
|
+
@"width": @(imageSize.width),
|
|
96
|
+
@"height": @(imageSize.height)
|
|
97
|
+
}
|
|
98
|
+
} mutableCopy];
|
|
99
|
+
|
|
100
|
+
self.onRectangleDetect(payload);
|
|
101
|
+
}
|
|
102
|
+
|
|
58
103
|
- (void) capture {
|
|
59
104
|
[self captureImageWithCompletionHander:^(UIImage *croppedImage, UIImage *initialImage, CIRectangleFeature *rectangleFeature) {
|
|
60
105
|
if (self.onPictureTaken) {
|
|
@@ -77,12 +122,8 @@
|
|
|
77
122
|
while rectangleFeature returns a rectangle viewed from landscape, which explains the nonsense of the mapping below.
|
|
78
123
|
Sorry about that.
|
|
79
124
|
*/
|
|
80
|
-
NSDictionary *
|
|
81
|
-
|
|
82
|
-
@"topRight": @{ @"y": @(rectangleFeature.topLeft.x + 30), @"x": @(rectangleFeature.topLeft.y)},
|
|
83
|
-
@"bottomLeft": @{ @"y": @(rectangleFeature.bottomRight.x), @"x": @(rectangleFeature.bottomRight.y)},
|
|
84
|
-
@"bottomRight": @{ @"y": @(rectangleFeature.topRight.x), @"x": @(rectangleFeature.topRight.y)},
|
|
85
|
-
} : [NSNull null];
|
|
125
|
+
NSDictionary *rectangleCoordinatesDict = [self dictionaryForRectangleFeature:rectangleFeature];
|
|
126
|
+
id rectangleCoordinates = rectangleCoordinatesDict ? rectangleCoordinatesDict : [NSNull null];
|
|
86
127
|
if (self.useBase64) {
|
|
87
128
|
self.onPictureTaken(@{
|
|
88
129
|
@"croppedImage": [croppedImageData base64EncodedStringWithOptions:NSDataBase64Encoding64CharacterLineLength],
|
|
@@ -251,6 +251,25 @@
|
|
|
251
251
|
(CGFloat)_glkView.drawableWidth,
|
|
252
252
|
(CGFloat)_glkView.drawableHeight);
|
|
253
253
|
|
|
254
|
+
if (self.delegate && _borderDetectLastRectangleFeature) {
|
|
255
|
+
IPDFRectangeType detectionType = [self typeForRectangle:_borderDetectLastRectangleFeature];
|
|
256
|
+
|
|
257
|
+
if ([self.delegate respondsToSelector:@selector(didDetectRectangle:withType:)]) {
|
|
258
|
+
[self.delegate didDetectRectangle:_borderDetectLastRectangleFeature withType:detectionType];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if ([self.delegate respondsToSelector:@selector(cameraViewController:didDetectRectangle:withType:viewCoordinates:imageSize:)]) {
|
|
262
|
+
NSDictionary *viewCoordinates = [self viewCoordinateDictionaryForRectangleFeature:_borderDetectLastRectangleFeature
|
|
263
|
+
fromRect:fromRect
|
|
264
|
+
drawRect:drawRect];
|
|
265
|
+
[self.delegate cameraViewController:self
|
|
266
|
+
didDetectRectangle:_borderDetectLastRectangleFeature
|
|
267
|
+
withType:detectionType
|
|
268
|
+
viewCoordinates:viewCoordinates
|
|
269
|
+
imageSize:imageExtent.size];
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
254
273
|
[_coreImageContext drawImage:image inRect:drawRect fromRect:fromRect];
|
|
255
274
|
[self.context presentRenderbuffer:GL_RENDERBUFFER];
|
|
256
275
|
|
|
@@ -562,10 +581,6 @@
|
|
|
562
581
|
}
|
|
563
582
|
}
|
|
564
583
|
|
|
565
|
-
if (self.delegate) {
|
|
566
|
-
[self.delegate didDetectRectangle:biggestRectangle withType:[self typeForRectangle:biggestRectangle]];
|
|
567
|
-
}
|
|
568
|
-
|
|
569
584
|
return biggestRectangle;
|
|
570
585
|
}
|
|
571
586
|
|
|
@@ -584,6 +599,50 @@
|
|
|
584
599
|
return IPDFRectangeTypeGood;
|
|
585
600
|
}
|
|
586
601
|
|
|
602
|
+
- (NSDictionary *)viewCoordinateDictionaryForRectangleFeature:(CIRectangleFeature *)rectangle
|
|
603
|
+
fromRect:(CGRect)fromRect
|
|
604
|
+
drawRect:(CGRect)drawRect
|
|
605
|
+
{
|
|
606
|
+
if (!rectangle || !_glkView) {
|
|
607
|
+
return nil;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (CGRectIsEmpty(fromRect) || CGRectIsEmpty(drawRect)) {
|
|
611
|
+
return nil;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
CGFloat scaleFactor = _glkView.contentScaleFactor;
|
|
615
|
+
if (scaleFactor <= 0.0) {
|
|
616
|
+
scaleFactor = [UIScreen mainScreen].scale;
|
|
617
|
+
}
|
|
618
|
+
if (scaleFactor <= 0.0) {
|
|
619
|
+
scaleFactor = 1.0;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
CGFloat scaleX = drawRect.size.width / fromRect.size.width;
|
|
623
|
+
CGFloat scaleY = drawRect.size.height / fromRect.size.height;
|
|
624
|
+
|
|
625
|
+
CGPoint (^convertPoint)(CGPoint) = ^CGPoint(CGPoint point) {
|
|
626
|
+
CGFloat translatedX = (point.x - fromRect.origin.x) * scaleX;
|
|
627
|
+
CGFloat translatedY = (point.y - fromRect.origin.y) * scaleY;
|
|
628
|
+
CGFloat flippedY = drawRect.size.height - translatedY;
|
|
629
|
+
|
|
630
|
+
return CGPointMake(translatedX / scaleFactor, flippedY / scaleFactor);
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
CGPoint topLeft = convertPoint(rectangle.topLeft);
|
|
634
|
+
CGPoint topRight = convertPoint(rectangle.topRight);
|
|
635
|
+
CGPoint bottomLeft = convertPoint(rectangle.bottomLeft);
|
|
636
|
+
CGPoint bottomRight = convertPoint(rectangle.bottomRight);
|
|
637
|
+
|
|
638
|
+
return @{
|
|
639
|
+
@"topLeft": @{@"x": @(topLeft.x), @"y": @(topLeft.y)},
|
|
640
|
+
@"topRight": @{@"x": @(topRight.x), @"y": @(topRight.y)},
|
|
641
|
+
@"bottomLeft": @{@"x": @(bottomLeft.x), @"y": @(bottomLeft.y)},
|
|
642
|
+
@"bottomRight": @{@"x": @(bottomRight.x), @"y": @(bottomRight.y)}
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
587
646
|
BOOL rectangleDetectionConfidenceHighEnough(float confidence)
|
|
588
647
|
{
|
|
589
648
|
return (confidence > 1.0);
|