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.
@@ -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 type { DocScannerHandle };
51
+ export {};
@@ -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
- const path = event.croppedImage ?? event.initialImage;
56
- if (path) {
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
- quad: null,
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
  });
@@ -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,15 +1,10 @@
1
1
  import React from 'react';
2
- import type { Point } from '../types';
3
- type OverlayProps = {
4
- quad: Point[] | null;
2
+ import type { Rectangle } from '../types';
3
+ export interface ScannerOverlayProps {
4
+ /** 자동 캡처 중임을 표시할 때 true로 설정합니다. */
5
+ active: boolean;
5
6
  color?: string;
6
- frameSize: {
7
- width: number;
8
- height: number;
9
- } | null;
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>;
@@ -33,150 +33,136 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.Overlay = void 0;
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 lerp = (start, end, t) => ({
41
- x: start.x + (end.x - start.x) * t,
42
- y: start.y + (end.y - start.y) * t,
43
- });
44
- const withAlpha = (value, alpha) => {
45
- const hexMatch = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value.trim());
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 hex = hexMatch[1];
50
- const normalize = hex.length === 3
51
- ? hex.split('').map((ch) => ch + ch).join('')
52
- : hex;
53
- const r = parseInt(normalize.slice(0, 2), 16);
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 buildPath = (points) => {
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(points[0].x, points[0].y);
61
- points.slice(1).forEach((p) => path.lineTo(p.x, p.y));
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 orderQuad = (points) => {
66
- if (points.length !== 4) {
67
- return points;
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 center = points.reduce((acc, point) => ({ x: acc.x + point.x / 4, y: acc.y + point.y / 4 }), { x: 0, y: 0 });
70
- const sorted = [...points].sort((a, b) => {
71
- const angleA = Math.atan2(a.y - center.y, a.x - center.x);
72
- const angleB = Math.atan2(b.y - center.y, b.x - center.x);
73
- return angleA - angleB;
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
- // Ensure the first point is the top-left (smallest y, then smallest x)
76
- let startIndex = 0;
77
- for (let i = 1; i < sorted.length; i += 1) {
78
- const current = sorted[i];
79
- const candidate = sorted[startIndex];
80
- if (current.y < candidate.y || (current.y === candidate.y && current.x < candidate.x)) {
81
- startIndex = i;
82
- }
83
- }
84
- return [
85
- sorted[startIndex % 4],
86
- sorted[(startIndex + 1) % 4],
87
- sorted[(startIndex + 2) % 4],
88
- sorted[(startIndex + 3) % 4],
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 Overlay = ({ quad, color = '#e7a649', frameSize, showGrid = true, gridColor = 'rgba(231, 166, 73, 0.35)', gridLineWidth = 2, }) => {
92
- const { width: screenWidth, height: screenHeight } = (0, react_native_1.useWindowDimensions)();
93
- const fillColor = (0, react_1.useMemo)(() => withAlpha(color, 0.2), [color]);
94
- const { outlinePath, gridPaths } = (0, react_1.useMemo)(() => {
95
- let transformedQuad = null;
96
- let sourceQuad = null;
97
- let sourceFrameSize = frameSize;
98
- if (quad && frameSize) {
99
- sourceQuad = quad;
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
- // No detection yet – skip drawing
103
- return { outlinePath: null, gridPaths: [] };
146
+ gradientColors.current = [transparentColor, transparentColor, transparentColor];
104
147
  }
105
- if (sourceQuad && sourceFrameSize) {
106
- if (__DEV__) {
107
- console.log('[Overlay] drawing quad:', sourceQuad);
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
- else {
124
- const scaleX = screenWidth / sourceFrameSize.width;
125
- const scaleY = screenHeight / sourceFrameSize.height;
126
- transformedQuad = sourceQuad.map((p) => ({
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
- return (react_1.default.createElement(react_native_1.View, { style: styles.container, pointerEvents: "none" },
164
- react_1.default.createElement(react_native_skia_1.Canvas, { style: { width: screenWidth, height: screenHeight } }, outlinePath && (react_1.default.createElement(react_1.default.Fragment, null,
165
- react_1.default.createElement(react_native_skia_1.Path, { path: outlinePath, color: color, style: "stroke", strokeWidth: 8 }),
166
- react_1.default.createElement(react_native_skia_1.Path, { path: outlinePath, color: fillColor, style: "fill" }),
167
- gridPaths.map((gridPath, index) => (react_1.default.createElement(react_native_skia_1.Path
168
- // eslint-disable-next-line react/no-array-index-key
169
- , {
170
- // eslint-disable-next-line react/no-array-index-key
171
- key: `grid-${index}`, path: gridPath, color: gridColor, style: "stroke", strokeWidth: gridLineWidth }))))))));
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.Overlay = Overlay;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "3.16.0",
3
+ "version": "3.21.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -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: { path: string; quad: null; width: number; height: number }) => void;
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
- const path = event.croppedImage ?? event.initialImage;
82
- if (path) {
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
- quad: null,
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 };
@@ -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 { 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;
@@ -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
- 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],
@@ -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);