react-native-rectangle-doc-scanner 3.18.0 → 3.22.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.
@@ -1,13 +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;
7
10
  };
8
- export type RectangleDetectEvent = {
9
- stableCounter: number;
10
- lastDetectionType: number;
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;
11
22
  };
12
23
  export interface DetectionConfig {
13
24
  processingWidth?: number;
@@ -18,12 +29,7 @@ export interface DetectionConfig {
18
29
  maxCenterDelta?: number;
19
30
  }
20
31
  interface Props {
21
- onCapture?: (photo: {
22
- path: string;
23
- quad: null;
24
- width: number;
25
- height: number;
26
- }) => void;
32
+ onCapture?: (photo: DocScannerCapture) => void;
27
33
  overlayColor?: string;
28
34
  autoCapture?: boolean;
29
35
  minStableFrames?: number;
@@ -37,9 +43,9 @@ interface Props {
37
43
  detectionConfig?: DetectionConfig;
38
44
  onRectangleDetect?: (event: RectangleDetectEvent) => void;
39
45
  }
40
- type DocScannerHandle = {
46
+ export type DocScannerHandle = {
41
47
  capture: () => Promise<PictureEvent>;
42
48
  reset: () => void;
43
49
  };
44
50
  export declare const DocScanner: React.ForwardRefExoticComponent<Props & React.RefAttributes<DocScannerHandle>>;
45
- export type { DocScannerHandle };
51
+ export {};
@@ -40,12 +40,39 @@ 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");
43
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
+ };
44
70
  const DEFAULT_OVERLAY_COLOR = '#0b7ef4';
45
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) => {
46
72
  const scannerRef = (0, react_1.useRef)(null);
47
73
  const captureResolvers = (0, react_1.useRef)(null);
48
74
  const [isAutoCapturing, setIsAutoCapturing] = (0, react_1.useState)(false);
75
+ const [detectedRectangle, setDetectedRectangle] = (0, react_1.useState)(null);
49
76
  (0, react_1.useEffect)(() => {
50
77
  if (!autoCapture) {
51
78
  setIsAutoCapturing(false);
@@ -60,15 +87,22 @@ exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAUL
60
87
  }, [quality]);
61
88
  const handlePictureTaken = (0, react_1.useCallback)((event) => {
62
89
  setIsAutoCapturing(false);
63
- const path = event.croppedImage ?? event.initialImage;
64
- if (path) {
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) {
65
96
  onCapture?.({
66
- path,
67
- quad: null,
97
+ path: bestPath,
98
+ initialPath,
99
+ croppedPath,
100
+ quad,
68
101
  width: event.width ?? 0,
69
102
  height: event.height ?? 0,
70
103
  });
71
104
  }
105
+ setDetectedRectangle(null);
72
106
  if (captureResolvers.current) {
73
107
  captureResolvers.current.resolve(event);
74
108
  captureResolvers.current = null;
@@ -108,15 +142,24 @@ exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAUL
108
142
  });
109
143
  }, [autoCapture, capture]);
110
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
+ };
111
152
  if (autoCapture) {
112
- if (event.stableCounter >= Math.max(minStableFrames - 1, 0)) {
153
+ if (payload.stableCounter >= Math.max(minStableFrames - 1, 0)) {
113
154
  setIsAutoCapturing(true);
114
155
  }
115
- else if (event.stableCounter === 0) {
156
+ else if (payload.stableCounter === 0) {
116
157
  setIsAutoCapturing(false);
117
158
  }
118
159
  }
119
- onRectangleDetect?.(event);
160
+ const isGoodRectangle = payload.lastDetectionType === 0;
161
+ setDetectedRectangle(isGoodRectangle && rectangleOnScreen ? payload : null);
162
+ onRectangleDetect?.(payload);
120
163
  }, [autoCapture, minStableFrames, onRectangleDetect]);
121
164
  (0, react_1.useImperativeHandle)(ref, () => ({
122
165
  capture,
@@ -127,9 +170,11 @@ exports.DocScanner = (0, react_1.forwardRef)(({ onCapture, overlayColor = DEFAUL
127
170
  }
128
171
  },
129
172
  }), [capture]);
173
+ const overlayPolygon = detectedRectangle?.rectangleOnScreen ?? null;
174
+ const overlayIsActive = autoCapture ? isAutoCapturing : (detectedRectangle?.stableCounter ?? 0) > 0;
130
175
  return (react_1.default.createElement(react_native_1.View, { style: styles.container },
131
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 }),
132
- showGrid && (react_1.default.createElement(overlay_1.ScannerOverlay, { active: autoCapture && isAutoCapturing, color: gridColor ?? overlayColor, lineWidth: gridLineWidth })),
177
+ showGrid && overlayPolygon && (react_1.default.createElement(overlay_1.ScannerOverlay, { active: overlayIsActive, color: gridColor ?? overlayColor, lineWidth: gridLineWidth, polygon: overlayPolygon })),
133
178
  !autoCapture && react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.button, onPress: handleManualCapture }),
134
179
  children));
135
180
  });
@@ -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
@@ -11,6 +11,8 @@ export type Rectangle = {
11
11
  };
12
12
  export type CapturedDocument = {
13
13
  path: string;
14
+ initialPath?: string | null;
15
+ croppedPath?: string | null;
14
16
  quad: Point[] | null;
15
17
  width: number;
16
18
  height: number;
@@ -1,8 +1,10 @@
1
1
  import React from 'react';
2
+ import type { Rectangle } from '../types';
2
3
  export interface ScannerOverlayProps {
3
- /** 활성화 스캔 바가 움직이며 자동 촬영 중임을 표시합니다. */
4
+ /** 자동 캡처 중임을 표시할 true로 설정합니다. */
4
5
  active: boolean;
5
6
  color?: string;
6
7
  lineWidth?: number;
8
+ polygon?: Rectangle | null;
7
9
  }
8
10
  export declare const ScannerOverlay: React.FC<ScannerOverlayProps>;
@@ -36,103 +36,133 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.ScannerOverlay = void 0;
37
37
  const react_1 = __importStar(require("react"));
38
38
  const react_native_1 = require("react-native");
39
- const BAR_THICKNESS = 4;
40
- const ScannerOverlay = ({ active, color = 'rgba(255,255,255,0.8)', lineWidth = react_native_1.StyleSheet.hairlineWidth, }) => {
41
- const animatedValue = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
42
- const loopRef = (0, react_1.useRef)(null);
43
- const [frameHeight, setFrameHeight] = (0, react_1.useState)(0);
44
- const borderStyle = (0, react_1.useMemo)(() => ({
45
- borderColor: color,
46
- borderWidth: lineWidth,
47
- }), [color, lineWidth]);
39
+ const react_native_skia_1 = require("@shopify/react-native-skia");
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;
46
+ }
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})`;
52
+ };
53
+ const createPolygonPath = (polygon) => {
54
+ if (!polygon) {
55
+ return null;
56
+ }
57
+ const path = react_native_skia_1.Skia.Path.Make();
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
+ path.close();
63
+ return path;
64
+ };
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 [];
78
+ }
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));
88
+ });
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
+ };
105
+ };
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]);
48
119
  (0, react_1.useEffect)(() => {
49
- loopRef.current?.stop();
50
- if (!active || frameHeight <= 0) {
51
- animatedValue.stopAnimation();
52
- animatedValue.setValue(0);
120
+ if (!metrics) {
53
121
  return;
54
122
  }
55
- const loop = react_native_1.Animated.loop(react_native_1.Animated.sequence([
56
- react_native_1.Animated.timing(animatedValue, {
57
- toValue: 1,
58
- duration: 1600,
59
- easing: react_native_1.Easing.inOut(react_native_1.Easing.quad),
60
- useNativeDriver: true,
61
- }),
62
- react_native_1.Animated.timing(animatedValue, {
63
- toValue: 0,
64
- duration: 1600,
65
- easing: react_native_1.Easing.inOut(react_native_1.Easing.quad),
66
- useNativeDriver: true,
67
- }),
68
- ]));
69
- loopRef.current = loop;
70
- loop.start();
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();
144
+ }
145
+ else {
146
+ gradientColors.current = [transparentColor, transparentColor, transparentColor];
147
+ }
71
148
  return () => {
72
- loop.stop();
149
+ if (frame !== null) {
150
+ cancelAnimationFrame(frame);
151
+ }
73
152
  };
74
- }, [active, animatedValue, frameHeight]);
75
- const handleLayout = (event) => {
76
- const { height } = event.nativeEvent.layout;
77
- setFrameHeight(height);
78
- };
79
- const translateY = frameHeight <= BAR_THICKNESS
80
- ? 0
81
- : animatedValue.interpolate({
82
- inputRange: [0, 1],
83
- outputRange: [0, frameHeight - BAR_THICKNESS],
84
- });
85
- return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: react_native_1.StyleSheet.absoluteFill },
86
- react_1.default.createElement(react_native_1.View, { onLayout: handleLayout, style: [styles.frame, borderStyle] },
87
- react_1.default.createElement(react_native_1.View, { style: [
88
- styles.horizontalLine,
89
- { top: '33%', borderBottomColor: color, borderBottomWidth: lineWidth },
90
- ] }),
91
- react_1.default.createElement(react_native_1.View, { style: [
92
- styles.horizontalLine,
93
- { top: '66%', borderBottomColor: color, borderBottomWidth: lineWidth },
94
- ] }),
95
- react_1.default.createElement(react_native_1.View, { style: [
96
- styles.verticalLine,
97
- { left: '33%', borderRightColor: color, borderRightWidth: lineWidth },
98
- ] }),
99
- react_1.default.createElement(react_native_1.View, { style: [
100
- styles.verticalLine,
101
- { left: '66%', borderRightColor: color, borderRightWidth: lineWidth },
102
- ] }),
103
- react_1.default.createElement(react_native_1.Animated.View, { style: [
104
- styles.scanBar,
105
- {
106
- opacity: active ? 1 : 0,
107
- backgroundColor: color,
108
- transform: [{ translateY }],
109
- },
110
- ] }))));
153
+ }, [active, color, gradientColors, gradientEnd, gradientStart, metrics]);
154
+ if (!polygon || !path || !metrics) {
155
+ return null;
156
+ }
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 })))));
111
167
  };
112
168
  exports.ScannerOverlay = ScannerOverlay;
113
- const styles = react_native_1.StyleSheet.create({
114
- frame: {
115
- flex: 1,
116
- borderRadius: 12,
117
- overflow: 'hidden',
118
- backgroundColor: 'transparent',
119
- },
120
- horizontalLine: {
121
- position: 'absolute',
122
- right: 0,
123
- left: 0,
124
- height: 0,
125
- },
126
- verticalLine: {
127
- position: 'absolute',
128
- top: 0,
129
- bottom: 0,
130
- width: 0,
131
- },
132
- scanBar: {
133
- position: 'absolute',
134
- left: 0,
135
- right: 0,
136
- height: BAR_THICKNESS,
137
- },
138
- });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "3.18.0",
3
+ "version": "3.22.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -10,6 +10,9 @@ import React, {
10
10
  } from 'react';
11
11
  import { Platform, StyleSheet, TouchableOpacity, View } from 'react-native';
12
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';
13
16
  import { ScannerOverlay } from './utils/overlay';
14
17
 
15
18
  type PictureEvent = {
@@ -17,11 +20,53 @@ type PictureEvent = {
17
20
  initialImage?: string | null;
18
21
  width?: number;
19
22
  height?: number;
23
+ rectangleCoordinates?: NativeRectangle | null;
20
24
  };
21
25
 
22
- export type RectangleDetectEvent = {
23
- stableCounter: number;
24
- lastDetectionType: number;
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
+ };
25
70
  };
26
71
 
27
72
  export interface DetectionConfig {
@@ -34,7 +79,7 @@ export interface DetectionConfig {
34
79
  }
35
80
 
36
81
  interface Props {
37
- onCapture?: (photo: { path: string; quad: null; width: number; height: number }) => void;
82
+ onCapture?: (photo: DocScannerCapture) => void;
38
83
  overlayColor?: string;
39
84
  autoCapture?: boolean;
40
85
  minStableFrames?: number;
@@ -49,7 +94,7 @@ interface Props {
49
94
  onRectangleDetect?: (event: RectangleDetectEvent) => void;
50
95
  }
51
96
 
52
- type DocScannerHandle = {
97
+ export type DocScannerHandle = {
53
98
  capture: () => Promise<PictureEvent>;
54
99
  reset: () => void;
55
100
  };
@@ -81,6 +126,7 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
81
126
  reject: (reason?: unknown) => void;
82
127
  } | null>(null);
83
128
  const [isAutoCapturing, setIsAutoCapturing] = useState(false);
129
+ const [detectedRectangle, setDetectedRectangle] = useState<RectangleDetectEvent | null>(null);
84
130
 
85
131
  useEffect(() => {
86
132
  if (!autoCapture) {
@@ -100,16 +146,26 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
100
146
  (event: PictureEvent) => {
101
147
  setIsAutoCapturing(false);
102
148
 
103
- const path = event.croppedImage ?? event.initialImage;
104
- if (path) {
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) {
105
157
  onCapture?.({
106
- path,
107
- quad: null,
158
+ path: bestPath,
159
+ initialPath,
160
+ croppedPath,
161
+ quad,
108
162
  width: event.width ?? 0,
109
163
  height: event.height ?? 0,
110
164
  });
111
165
  }
112
166
 
167
+ setDetectedRectangle(null);
168
+
113
169
  if (captureResolvers.current) {
114
170
  captureResolvers.current.resolve(event);
115
171
  captureResolvers.current = null;
@@ -157,16 +213,27 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
157
213
  }, [autoCapture, capture]);
158
214
 
159
215
  const handleRectangleDetect = useCallback(
160
- (event: RectangleDetectEvent) => {
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
+
161
226
  if (autoCapture) {
162
- if (event.stableCounter >= Math.max(minStableFrames - 1, 0)) {
227
+ if (payload.stableCounter >= Math.max(minStableFrames - 1, 0)) {
163
228
  setIsAutoCapturing(true);
164
- } else if (event.stableCounter === 0) {
229
+ } else if (payload.stableCounter === 0) {
165
230
  setIsAutoCapturing(false);
166
231
  }
167
232
  }
168
233
 
169
- onRectangleDetect?.(event);
234
+ const isGoodRectangle = payload.lastDetectionType === 0;
235
+ setDetectedRectangle(isGoodRectangle && rectangleOnScreen ? payload : null);
236
+ onRectangleDetect?.(payload);
170
237
  },
171
238
  [autoCapture, minStableFrames, onRectangleDetect],
172
239
  );
@@ -185,6 +252,9 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
185
252
  [capture],
186
253
  );
187
254
 
255
+ const overlayPolygon = detectedRectangle?.rectangleOnScreen ?? null;
256
+ const overlayIsActive = autoCapture ? isAutoCapturing : (detectedRectangle?.stableCounter ?? 0) > 0;
257
+
188
258
  return (
189
259
  <View style={styles.container}>
190
260
  <DocumentScanner
@@ -201,11 +271,12 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>(
201
271
  onError={handleError}
202
272
  onRectangleDetect={handleRectangleDetect}
203
273
  />
204
- {showGrid && (
274
+ {showGrid && overlayPolygon && (
205
275
  <ScannerOverlay
206
- active={autoCapture && isAutoCapturing}
276
+ active={overlayIsActive}
207
277
  color={gridColor ?? overlayColor}
208
278
  lineWidth={gridLineWidth}
279
+ polygon={overlayPolygon}
209
280
  />
210
281
  )}
211
282
  {!autoCapture && <TouchableOpacity style={styles.button} onPress={handleManualCapture} />}
@@ -239,5 +310,3 @@ const styles = StyleSheet.create({
239
310
  backgroundColor: '#fff',
240
311
  },
241
312
  });
242
-
243
- export type { DocScannerHandle };
@@ -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 {
@@ -87,7 +128,7 @@ declare module 'react-native-document-scanner' {
87
128
  };
88
129
  onPictureTaken?: (event: DocumentScannerResult) => void;
89
130
  onError?: (error: Error) => void;
90
- onRectangleDetect?: (event: { stableCounter: number; lastDetectionType: number }) => void;
131
+ onRectangleDetect?: (event: RectangleEventPayload) => void;
91
132
  }
92
133
 
93
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 { DetectionConfig, DocScannerHandle } from './DocScanner';
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
@@ -11,6 +11,8 @@ export type Rectangle = {
11
11
 
12
12
  export type CapturedDocument = {
13
13
  path: string;
14
+ initialPath?: string | null;
15
+ croppedPath?: string | null;
14
16
  quad: Point[] | null;
15
17
  width: number;
16
18
  height: number;
@@ -1,143 +1,198 @@
1
- import React, { useEffect, useMemo, useRef, useState } from 'react';
2
- import { Animated, Easing, LayoutChangeEvent, StyleSheet, View } from 'react-native';
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';
3
13
 
4
14
  export interface ScannerOverlayProps {
5
- /** 활성화 스캔 바가 움직이며 자동 촬영 중임을 표시합니다. */
15
+ /** 자동 캡처 중임을 표시할 true로 설정합니다. */
6
16
  active: boolean;
7
17
  color?: string;
8
18
  lineWidth?: number;
19
+ polygon?: Rectangle | null;
9
20
  }
10
21
 
11
- const BAR_THICKNESS = 4;
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;
12
105
 
13
106
  export const ScannerOverlay: React.FC<ScannerOverlayProps> = ({
14
107
  active,
15
- color = 'rgba(255,255,255,0.8)',
108
+ color = '#0b7ef4',
16
109
  lineWidth = StyleSheet.hairlineWidth,
110
+ polygon,
17
111
  }) => {
18
- const animatedValue = useRef(new Animated.Value(0)).current;
19
- const loopRef = useRef<Animated.CompositeAnimation | null>(null);
20
- const [frameHeight, setFrameHeight] = useState(0);
21
-
22
- const borderStyle = useMemo(
23
- () => ({
24
- borderColor: color,
25
- borderWidth: lineWidth,
26
- }),
27
- [color, lineWidth],
28
- );
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]);
29
124
 
30
125
  useEffect(() => {
31
- loopRef.current?.stop();
32
- if (!active || frameHeight <= 0) {
33
- animatedValue.stopAnimation();
34
- animatedValue.setValue(0);
126
+ if (!metrics) {
35
127
  return;
36
128
  }
37
129
 
38
- const loop = Animated.loop(
39
- Animated.sequence([
40
- Animated.timing(animatedValue, {
41
- toValue: 1,
42
- duration: 1600,
43
- easing: Easing.inOut(Easing.quad),
44
- useNativeDriver: true,
45
- }),
46
- Animated.timing(animatedValue, {
47
- toValue: 0,
48
- duration: 1600,
49
- easing: Easing.inOut(Easing.quad),
50
- useNativeDriver: true,
51
- }),
52
- ]),
53
- );
54
-
55
- loopRef.current = loop;
56
- loop.start();
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
+ }
57
163
 
58
164
  return () => {
59
- loop.stop();
165
+ if (frame !== null) {
166
+ cancelAnimationFrame(frame);
167
+ }
60
168
  };
61
- }, [active, animatedValue, frameHeight]);
169
+ }, [active, color, gradientColors, gradientEnd, gradientStart, metrics]);
62
170
 
63
- const handleLayout = (event: LayoutChangeEvent) => {
64
- const { height } = event.nativeEvent.layout;
65
- setFrameHeight(height);
66
- };
171
+ if (!polygon || !path || !metrics) {
172
+ return null;
173
+ }
67
174
 
68
- const translateY =
69
- frameHeight <= BAR_THICKNESS
70
- ? 0
71
- : animatedValue.interpolate({
72
- inputRange: [0, 1],
73
- outputRange: [0, frameHeight - BAR_THICKNESS],
74
- });
175
+ const strokeColor = withAlpha(color, 0.9);
176
+ const fillColor = withAlpha(color, 0.18);
177
+ const gridColor = withAlpha(color, 0.35);
75
178
 
76
179
  return (
77
- <View pointerEvents="none" style={StyleSheet.absoluteFill}>
78
- <View onLayout={handleLayout} style={[styles.frame, borderStyle]}>
79
- <View
80
- style={[
81
- styles.horizontalLine,
82
- { top: '33%', borderBottomColor: color, borderBottomWidth: lineWidth },
83
- ]}
84
- />
85
- <View
86
- style={[
87
- styles.horizontalLine,
88
- { top: '66%', borderBottomColor: color, borderBottomWidth: lineWidth },
89
- ]}
90
- />
91
- <View
92
- style={[
93
- styles.verticalLine,
94
- { left: '33%', borderRightColor: color, borderRightWidth: lineWidth },
95
- ]}
96
- />
97
- <View
98
- style={[
99
- styles.verticalLine,
100
- { left: '66%', borderRightColor: color, borderRightWidth: lineWidth },
101
- ]}
102
- />
103
- <Animated.View
104
- style={[
105
- styles.scanBar,
106
- {
107
- opacity: active ? 1 : 0,
108
- backgroundColor: color,
109
- transform: [{ translateY }],
110
- },
111
- ]}
112
- />
113
- </View>
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>
114
196
  </View>
115
197
  );
116
198
  };
117
-
118
- const styles = StyleSheet.create({
119
- frame: {
120
- flex: 1,
121
- borderRadius: 12,
122
- overflow: 'hidden',
123
- backgroundColor: 'transparent',
124
- },
125
- horizontalLine: {
126
- position: 'absolute',
127
- right: 0,
128
- left: 0,
129
- height: 0,
130
- },
131
- verticalLine: {
132
- position: 'absolute',
133
- top: 0,
134
- bottom: 0,
135
- width: 0,
136
- },
137
- scanBar: {
138
- position: 'absolute',
139
- left: 0,
140
- right: 0,
141
- height: BAR_THICKNESS,
142
- },
143
- });
@@ -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
- if (self.onRectangleDetect) {
50
- self.onRectangleDetect(@{@"stableCounter": @(self.stableCounter), @"lastDetectionType": @(type)});
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 *rectangleCoordinates = rectangleFeature ? @{
81
- @"topLeft": @{ @"y": @(rectangleFeature.bottomLeft.x + 30), @"x": @(rectangleFeature.bottomLeft.y)},
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],
@@ -0,0 +1,64 @@
1
+ //
2
+ // IPDFCameraViewController.h
3
+ // InstaPDF
4
+ //
5
+ // Created by Maximilian Mackh on 06/01/15.
6
+ // Copyright (c) 2015 mackh ag. All rights reserved.
7
+ //
8
+
9
+ #import <UIKit/UIKit.h>
10
+
11
+ @class CIRectangleFeature;
12
+
13
+ typedef NS_ENUM(NSInteger, IPDFCameraViewType)
14
+ {
15
+ IPDFCameraViewTypeBlackAndWhite,
16
+ IPDFCameraViewTypeNormal
17
+ };
18
+
19
+ typedef NS_ENUM(NSInteger, IPDFRectangeType)
20
+ {
21
+ IPDFRectangeTypeGood,
22
+ IPDFRectangeTypeBadAngle,
23
+ IPDFRectangeTypeTooFar
24
+ };
25
+
26
+ @protocol IPDFCameraViewControllerDelegate <NSObject>
27
+
28
+ - (void)didDetectRectangle:(CIRectangleFeature *)rectangle withType:(IPDFRectangeType)type;
29
+
30
+ @optional
31
+ - (void)cameraViewController:(id)controller
32
+ didDetectRectangle:(CIRectangleFeature *)rectangle
33
+ withType:(IPDFRectangeType)type
34
+ viewCoordinates:(NSDictionary *)viewCoordinates
35
+ imageSize:(CGSize)imageSize;
36
+
37
+ @end
38
+
39
+ @interface IPDFCameraViewController : UIView
40
+
41
+ - (void)setupCameraView;
42
+
43
+ - (void)start;
44
+ - (void)stop;
45
+
46
+ @property (nonatomic, assign, getter=isBorderDetectionEnabled) BOOL enableBorderDetection;
47
+ @property (nonatomic, assign, getter=isTorchEnabled) BOOL enableTorch;
48
+ @property (nonatomic, assign, getter=isFrontCam) BOOL useFrontCam;
49
+
50
+ @property (weak, nonatomic) id<IPDFCameraViewControllerDelegate> delegate;
51
+
52
+ @property (nonatomic, assign) IPDFCameraViewType cameraViewType;
53
+
54
+ - (void)focusAtPoint:(CGPoint)point completionHandler:(void(^)(void))completionHandler;
55
+
56
+ - (void)captureImageWithCompletionHander:(void(^)(UIImage *data, UIImage *initialData, CIRectangleFeature *rectangleFeature))completionHandler;
57
+
58
+ @property (nonatomic, strong) UIColor *overlayColor;
59
+ @property (nonatomic, assign) float saturation;
60
+ @property (nonatomic, assign) float contrast;
61
+ @property (nonatomic, assign) float brightness;
62
+ @property (nonatomic, assign) NSInteger detectionRefreshRateInMS;
63
+
64
+ @end
@@ -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);