@scanid-africa/mrz-scanner 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +166 -0
- package/dist/index.js +7 -0
- package/dist/native/MrzScannerNative.js +220 -0
- package/dist/shared/api-client.js +72 -0
- package/dist/shared/types.js +2 -0
- package/dist/shared/useScanner.js +95 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/native/MrzScannerNative.d.ts +18 -0
- package/dist/types/shared/api-client.d.ts +11 -0
- package/dist/types/shared/types.d.ts +66 -0
- package/dist/types/shared/useScanner.d.ts +40 -0
- package/dist/types/web/MrzScannerWeb.d.ts +27 -0
- package/dist/web/MrzScannerWeb.js +259 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# @scanid/mrz-scanner
|
|
2
|
+
|
|
3
|
+
Scanner MRZ automatique pour React Native (Expo) et React web, propulsé par [ScanID Africa](https://scanid.africa).
|
|
4
|
+
|
|
5
|
+
Scan automatique déclenché par frames — aucun bouton. Dès qu'une MRZ valide est détectée, `onSuccess` est appelé.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# npm
|
|
13
|
+
npm install @scanid/mrz-scanner
|
|
14
|
+
|
|
15
|
+
# Expo (peer deps)
|
|
16
|
+
npx expo install expo-camera expo-image-manipulator expo-haptics
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Usage rapide
|
|
22
|
+
|
|
23
|
+
### React Native / Expo
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { MrzScannerNative } from '@scanid/mrz-scanner'
|
|
27
|
+
|
|
28
|
+
<MrzScannerNative
|
|
29
|
+
api={{ mode: 'selfhosted', apiUrl: 'http://192.168.1.10:3000' }}
|
|
30
|
+
onSuccess={(result) => console.log(result.fields)}
|
|
31
|
+
onClose={() => navigation.goBack()}
|
|
32
|
+
/>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### React web / Next.js
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
// Next.js App Router — import dynamique obligatoire (SSR incompatible getUserMedia)
|
|
39
|
+
import dynamic from 'next/dynamic'
|
|
40
|
+
|
|
41
|
+
const MrzScannerWeb = dynamic(
|
|
42
|
+
() => import('@scanid/mrz-scanner').then(m => m.MrzScannerWeb),
|
|
43
|
+
{ ssr: false }
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
<MrzScannerWeb
|
|
47
|
+
api={{ mode: 'cloud', apiKey: 'sk_live_...', region: 'west-africa' }}
|
|
48
|
+
onSuccess={(result) => console.log(result.fields)}
|
|
49
|
+
width="100%"
|
|
50
|
+
height="480px"
|
|
51
|
+
/>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Configuration API
|
|
57
|
+
|
|
58
|
+
### Self-hosted (votre instance mrz-nest)
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
api={{ mode: 'selfhosted', apiUrl: 'https://your-api.com' }}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### ScanID Africa Cloud
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
api={{ mode: 'cloud', apiKey: 'sk_live_xxx', region: 'west-africa' }}
|
|
68
|
+
// regions: 'west-africa' | 'north-africa' | 'auto'
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Props
|
|
74
|
+
|
|
75
|
+
### Communes (native + web)
|
|
76
|
+
|
|
77
|
+
| Prop | Type | Défaut | Description |
|
|
78
|
+
|---|---|---|---|
|
|
79
|
+
| `api` | `ApiConfig` | requis | Config API (self-hosted ou cloud) |
|
|
80
|
+
| `onSuccess` | `(result: MrzResult) => void` | requis | Appelé quand une MRZ est détectée |
|
|
81
|
+
| `onError` | `(error: Error) => void` | — | Appelé si maxAttempts est atteint |
|
|
82
|
+
| `onClose` | `() => void` | — | Bouton fermer |
|
|
83
|
+
| `maxAttempts` | `number` | `10` | Tentatives avant abandon |
|
|
84
|
+
| `scanIntervalMs` | `number` | `1500` | Intervalle entre deux captures (ms) |
|
|
85
|
+
|
|
86
|
+
### React Native uniquement
|
|
87
|
+
|
|
88
|
+
| Prop | Défaut | Description |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `hint` | `"Alignez la zone MRZ dans le cadre"` | Texte sous le cadre |
|
|
91
|
+
| `frameColor` | `"#FFFFFF"` | Couleur du cadre |
|
|
92
|
+
| `successColor` | `"#34d399"` | Couleur du cadre en succès |
|
|
93
|
+
|
|
94
|
+
### React web uniquement
|
|
95
|
+
|
|
96
|
+
| Prop | Défaut | Description |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `width` | `"100%"` | Largeur du composant |
|
|
99
|
+
| `height` | `"480px"` | Hauteur du composant |
|
|
100
|
+
| `className` | — | Classe CSS additionnelle |
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Type MrzResult
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
interface MrzResult {
|
|
108
|
+
documentType: 'TD1' | 'TD2' | 'TD3' | 'DL'
|
|
109
|
+
documentLabel: string // "Passeport", "Carte d'identité nationale"…
|
|
110
|
+
corrected: boolean // true si mrz-fast a corrigé des erreurs OCR
|
|
111
|
+
fields: {
|
|
112
|
+
surname: string | null
|
|
113
|
+
givenNames: string | null
|
|
114
|
+
nationality: string | null
|
|
115
|
+
issuingState: string | null
|
|
116
|
+
dateOfBirth: string | null // YYMMDD
|
|
117
|
+
sex: 'male' | 'female' | 'unspecified' | null
|
|
118
|
+
expirationDate: string | null // YYMMDD
|
|
119
|
+
documentNumber: string | null
|
|
120
|
+
personalNumber: string | null
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Architecture
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
src/
|
|
131
|
+
├── shared/
|
|
132
|
+
│ ├── types.ts # Types TypeScript partagés
|
|
133
|
+
│ ├── api-client.ts # Client fetch (self-hosted + cloud)
|
|
134
|
+
│ └── useScanner.ts # Hook — boucle de scan automatique
|
|
135
|
+
├── native/
|
|
136
|
+
│ └── MrzScannerNative.tsx # Composant Expo (expo-camera)
|
|
137
|
+
└── web/
|
|
138
|
+
└── MrzScannerWeb.tsx # Composant React web (getUserMedia)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Le hook `useScanner` est partagé entre les deux plateformes. La seule
|
|
142
|
+
différence est `captureFrame` (expo-camera vs canvas) et `sendToApi`
|
|
143
|
+
(URI vs Blob).
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Flux de scan automatique
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
onCameraReady
|
|
151
|
+
↓
|
|
152
|
+
setInterval(1500ms)
|
|
153
|
+
↓
|
|
154
|
+
captureFrame() → recadrage 38% bas (zone MRZ)
|
|
155
|
+
↓
|
|
156
|
+
sendToApi() → POST /mrz/scan
|
|
157
|
+
↓
|
|
158
|
+
✅ valid → onSuccess() + stop scan
|
|
159
|
+
❌ invalid → attempts++ → retry | onError()
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Licence
|
|
165
|
+
|
|
166
|
+
MIT © ScanID Africa
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// ─── Composants ──────────────────────────────────────────────────────────────
|
|
2
|
+
export { MrzScannerNative } from './native/MrzScannerNative';
|
|
3
|
+
export { MrzScannerWeb } from './web/MrzScannerWeb';
|
|
4
|
+
// ─── Hook bas niveau (pour usage custom) ─────────────────────────────────────
|
|
5
|
+
export { useScanner } from './shared/useScanner';
|
|
6
|
+
// ─── Client API (pour usage standalone) ──────────────────────────────────────
|
|
7
|
+
export { sendImageToApi, sendUriToApi } from './shared/api-client';
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useRef, } from 'react';
|
|
3
|
+
import { ActivityIndicator, Animated, Easing, Platform, Pressable, StyleSheet, Text, View, } from 'react-native';
|
|
4
|
+
import { CameraView, useCameraPermissions } from 'expo-camera';
|
|
5
|
+
import { manipulateAsync, SaveFormat } from 'expo-image-manipulator';
|
|
6
|
+
import { useScanner } from '../shared/useScanner';
|
|
7
|
+
import { sendUriToApi } from '../shared/api-client';
|
|
8
|
+
// Tentative d'import haptics — optionnel
|
|
9
|
+
let Haptics = null;
|
|
10
|
+
try {
|
|
11
|
+
Haptics = require('expo-haptics');
|
|
12
|
+
}
|
|
13
|
+
catch (_a) { }
|
|
14
|
+
// ─── Labels d'état ────────────────────────────────────────────────────────────
|
|
15
|
+
function getStatusLabel(state, attempts, maxAttempts, hint) {
|
|
16
|
+
switch (state) {
|
|
17
|
+
case 'idle':
|
|
18
|
+
return 'Initialisation…';
|
|
19
|
+
case 'scanning':
|
|
20
|
+
return hint;
|
|
21
|
+
case 'analyzing':
|
|
22
|
+
return 'Lecture en cours…';
|
|
23
|
+
case 'success':
|
|
24
|
+
return '✓ Document reconnu !';
|
|
25
|
+
case 'failed':
|
|
26
|
+
return `Scan échoué après ${attempts}/${maxAttempts} tentatives`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// ─── Composant principal ──────────────────────────────────────────────────────
|
|
30
|
+
/**
|
|
31
|
+
* MrzScannerNative
|
|
32
|
+
*
|
|
33
|
+
* Composant React Native (Expo) de scan MRZ automatique.
|
|
34
|
+
* Démarre dès que la caméra est prête — aucun bouton requis.
|
|
35
|
+
*
|
|
36
|
+
* Usage:
|
|
37
|
+
* ```tsx
|
|
38
|
+
* <MrzScannerNative
|
|
39
|
+
* api={{ mode: 'selfhosted', apiUrl: 'http://192.168.1.10:3000' }}
|
|
40
|
+
* onSuccess={(result) => console.log(result.fields)}
|
|
41
|
+
* onClose={() => navigation.goBack()}
|
|
42
|
+
* />
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function MrzScannerNative({ api, onSuccess, onError, onClose, maxAttempts = 10, scanIntervalMs = 1500, hint = 'Alignez la zone MRZ dans le cadre', frameColor = '#FFFFFF', successColor = '#34d399', }) {
|
|
46
|
+
const [permission, requestPermission] = useCameraPermissions();
|
|
47
|
+
const cameraRef = useRef(null);
|
|
48
|
+
// Animations
|
|
49
|
+
const pulseAnim = useRef(new Animated.Value(1)).current;
|
|
50
|
+
const colorAnim = useRef(new Animated.Value(0)).current;
|
|
51
|
+
const pulseLoop = useRef(null);
|
|
52
|
+
function startPulse() {
|
|
53
|
+
pulseLoop.current = Animated.loop(Animated.sequence([
|
|
54
|
+
Animated.timing(pulseAnim, {
|
|
55
|
+
toValue: 1.025,
|
|
56
|
+
duration: 700,
|
|
57
|
+
easing: Easing.inOut(Easing.ease),
|
|
58
|
+
useNativeDriver: true,
|
|
59
|
+
}),
|
|
60
|
+
Animated.timing(pulseAnim, {
|
|
61
|
+
toValue: 1,
|
|
62
|
+
duration: 700,
|
|
63
|
+
easing: Easing.inOut(Easing.ease),
|
|
64
|
+
useNativeDriver: true,
|
|
65
|
+
}),
|
|
66
|
+
]));
|
|
67
|
+
pulseLoop.current.start();
|
|
68
|
+
}
|
|
69
|
+
function flashSuccess() {
|
|
70
|
+
var _a;
|
|
71
|
+
(_a = pulseLoop.current) === null || _a === void 0 ? void 0 : _a.stop();
|
|
72
|
+
Animated.timing(colorAnim, {
|
|
73
|
+
toValue: 1,
|
|
74
|
+
duration: 300,
|
|
75
|
+
useNativeDriver: false,
|
|
76
|
+
}).start();
|
|
77
|
+
}
|
|
78
|
+
// Capture + recadrage sur les 38% bas (zone MRZ)
|
|
79
|
+
const captureFrame = useCallback(async () => {
|
|
80
|
+
if (!cameraRef.current)
|
|
81
|
+
return null;
|
|
82
|
+
try {
|
|
83
|
+
const photo = await cameraRef.current.takePictureAsync({
|
|
84
|
+
quality: 0.85,
|
|
85
|
+
skipProcessing: true,
|
|
86
|
+
});
|
|
87
|
+
if (!photo)
|
|
88
|
+
return null;
|
|
89
|
+
const cropped = await manipulateAsync(photo.uri, [
|
|
90
|
+
{
|
|
91
|
+
crop: {
|
|
92
|
+
originX: 0,
|
|
93
|
+
originY: Math.floor(photo.height * 0.62),
|
|
94
|
+
width: photo.width,
|
|
95
|
+
height: Math.floor(photo.height * 0.38),
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
], { compress: 0.85, format: SaveFormat.JPEG });
|
|
99
|
+
return { uri: cropped.uri };
|
|
100
|
+
}
|
|
101
|
+
catch (_a) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}, []);
|
|
105
|
+
const handleSuccess = useCallback((result) => {
|
|
106
|
+
flashSuccess();
|
|
107
|
+
Haptics === null || Haptics === void 0 ? void 0 : Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
108
|
+
// Petit délai pour laisser voir le feedback visuel avant de fermer
|
|
109
|
+
setTimeout(() => onSuccess(result), 500);
|
|
110
|
+
}, [onSuccess]);
|
|
111
|
+
const { scanState, attempts, start, reset } = useScanner({
|
|
112
|
+
api,
|
|
113
|
+
maxAttempts,
|
|
114
|
+
scanIntervalMs,
|
|
115
|
+
onSuccess: handleSuccess,
|
|
116
|
+
onError,
|
|
117
|
+
captureFrame,
|
|
118
|
+
sendToApi: async (cfg, payload) => sendUriToApi(cfg, payload),
|
|
119
|
+
});
|
|
120
|
+
// Démarrer quand la caméra est prête
|
|
121
|
+
function onCameraReady() {
|
|
122
|
+
startPulse();
|
|
123
|
+
start();
|
|
124
|
+
}
|
|
125
|
+
const borderColor = colorAnim.interpolate({
|
|
126
|
+
inputRange: [0, 1],
|
|
127
|
+
outputRange: [frameColor, successColor],
|
|
128
|
+
});
|
|
129
|
+
if (!permission)
|
|
130
|
+
return _jsx(View, { style: styles.container });
|
|
131
|
+
if (!permission.granted) {
|
|
132
|
+
return (_jsxs(View, { style: styles.permContainer, children: [_jsx(Text, { style: styles.permText, children: "Acc\u00E8s \u00E0 la cam\u00E9ra requis pour scanner le document." }), _jsx(Pressable, { style: styles.permBtn, onPress: requestPermission, children: _jsx(Text, { style: styles.permBtnText, children: "Autoriser la cam\u00E9ra" }) })] }));
|
|
133
|
+
}
|
|
134
|
+
return (_jsxs(View, { style: styles.container, children: [_jsx(CameraView, { ref: cameraRef, style: StyleSheet.absoluteFill, facing: "back", onCameraReady: onCameraReady }), _jsxs(View, { style: styles.overlay, pointerEvents: "none", children: [_jsx(View, { style: styles.topMask }), _jsxs(View, { style: styles.middleRow, children: [_jsx(View, { style: styles.sideMask }), _jsxs(Animated.View, { style: [
|
|
135
|
+
styles.frame,
|
|
136
|
+
{
|
|
137
|
+
borderColor,
|
|
138
|
+
transform: [{ scale: pulseAnim }],
|
|
139
|
+
},
|
|
140
|
+
], children: [scanState === 'analyzing' && (_jsx(ActivityIndicator, { size: "small", color: "rgba(255,255,255,0.8)", style: styles.spinner })), scanState === 'success' && (_jsx(Text, { style: [styles.successIcon, { color: successColor }], children: "\u2713" }))] }), _jsx(View, { style: styles.sideMask })] }), _jsx(View, { style: styles.bottomMask })] }), _jsx(View, { style: styles.statusBar, pointerEvents: "none", children: _jsx(Text, { style: styles.statusText, children: getStatusLabel(scanState, attempts, maxAttempts, hint) }) }), scanState === 'failed' && (_jsx(View, { style: styles.retryRow, children: _jsx(Pressable, { style: styles.retryBtn, onPress: () => {
|
|
141
|
+
reset();
|
|
142
|
+
setTimeout(start, 100);
|
|
143
|
+
}, children: _jsx(Text, { style: styles.retryText, children: "R\u00E9essayer" }) }) })), onClose && (_jsx(Pressable, { style: styles.closeBtn, onPress: onClose, hitSlop: 12, children: _jsx(Text, { style: styles.closeTxt, children: "\u2715" }) }))] }));
|
|
144
|
+
}
|
|
145
|
+
// ─── Styles ───────────────────────────────────────────────────────────────────
|
|
146
|
+
const FRAME_H = 100;
|
|
147
|
+
const styles = StyleSheet.create({
|
|
148
|
+
container: { flex: 1, backgroundColor: '#000' },
|
|
149
|
+
permContainer: {
|
|
150
|
+
flex: 1,
|
|
151
|
+
justifyContent: 'center',
|
|
152
|
+
alignItems: 'center',
|
|
153
|
+
backgroundColor: '#000',
|
|
154
|
+
padding: 32,
|
|
155
|
+
},
|
|
156
|
+
permText: {
|
|
157
|
+
color: '#fff',
|
|
158
|
+
fontSize: 16,
|
|
159
|
+
textAlign: 'center',
|
|
160
|
+
marginBottom: 20,
|
|
161
|
+
},
|
|
162
|
+
permBtn: {
|
|
163
|
+
backgroundColor: '#c8ff00',
|
|
164
|
+
borderRadius: 8,
|
|
165
|
+
paddingHorizontal: 24,
|
|
166
|
+
paddingVertical: 12,
|
|
167
|
+
},
|
|
168
|
+
permBtnText: { color: '#000', fontWeight: '700', fontSize: 15 },
|
|
169
|
+
overlay: Object.assign({}, StyleSheet.absoluteFill),
|
|
170
|
+
topMask: { flex: 3, backgroundColor: 'rgba(0,0,0,0.55)' },
|
|
171
|
+
middleRow: { flexDirection: 'row', height: FRAME_H },
|
|
172
|
+
sideMask: { flex: 1, backgroundColor: 'rgba(0,0,0,0.55)' },
|
|
173
|
+
bottomMask: { flex: 2, backgroundColor: 'rgba(0,0,0,0.55)' },
|
|
174
|
+
frame: {
|
|
175
|
+
flex: 5,
|
|
176
|
+
borderWidth: 2,
|
|
177
|
+
borderRadius: 6,
|
|
178
|
+
justifyContent: 'center',
|
|
179
|
+
alignItems: 'center',
|
|
180
|
+
},
|
|
181
|
+
spinner: { position: 'absolute', top: 8, right: 8 },
|
|
182
|
+
successIcon: { fontSize: 28, fontWeight: '700' },
|
|
183
|
+
statusBar: {
|
|
184
|
+
position: 'absolute',
|
|
185
|
+
bottom: 100,
|
|
186
|
+
left: 0,
|
|
187
|
+
right: 0,
|
|
188
|
+
alignItems: 'center',
|
|
189
|
+
paddingHorizontal: 32,
|
|
190
|
+
},
|
|
191
|
+
statusText: {
|
|
192
|
+
color: '#fff',
|
|
193
|
+
fontSize: 14,
|
|
194
|
+
textAlign: 'center',
|
|
195
|
+
textShadowColor: 'rgba(0,0,0,0.8)',
|
|
196
|
+
textShadowOffset: { width: 0, height: 1 },
|
|
197
|
+
textShadowRadius: 4,
|
|
198
|
+
},
|
|
199
|
+
retryRow: {
|
|
200
|
+
position: 'absolute',
|
|
201
|
+
bottom: 50,
|
|
202
|
+
left: 0,
|
|
203
|
+
right: 0,
|
|
204
|
+
alignItems: 'center',
|
|
205
|
+
},
|
|
206
|
+
retryBtn: {
|
|
207
|
+
backgroundColor: '#c8ff00',
|
|
208
|
+
borderRadius: 8,
|
|
209
|
+
paddingHorizontal: 28,
|
|
210
|
+
paddingVertical: 12,
|
|
211
|
+
},
|
|
212
|
+
retryText: { color: '#000', fontWeight: '700', fontSize: 15 },
|
|
213
|
+
closeBtn: {
|
|
214
|
+
position: 'absolute',
|
|
215
|
+
top: Platform.OS === 'ios' ? 56 : 20,
|
|
216
|
+
right: 20,
|
|
217
|
+
padding: 10,
|
|
218
|
+
},
|
|
219
|
+
closeTxt: { color: '#fff', fontSize: 22 },
|
|
220
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const CLOUD_BASE_URL = 'https://api.scanid.africa';
|
|
2
|
+
/**
|
|
3
|
+
* Résout l'URL de l'endpoint scan selon la config (self-hosted ou cloud).
|
|
4
|
+
*/
|
|
5
|
+
function resolveEndpoint(api) {
|
|
6
|
+
var _a;
|
|
7
|
+
if (api.mode === 'selfhosted') {
|
|
8
|
+
return `${api.apiUrl.replace(/\/$/, '')}/mrz/scan`;
|
|
9
|
+
}
|
|
10
|
+
// Cloud : routing régional optionnel
|
|
11
|
+
const region = (_a = api.region) !== null && _a !== void 0 ? _a : 'auto';
|
|
12
|
+
const base = region === 'west-africa'
|
|
13
|
+
? 'https://wa.api.scanid.africa'
|
|
14
|
+
: region === 'north-africa'
|
|
15
|
+
? 'https://na.api.scanid.africa'
|
|
16
|
+
: CLOUD_BASE_URL;
|
|
17
|
+
return `${base}/mrz/scan`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Construit les headers selon le mode.
|
|
21
|
+
* Self-hosted : pas d'auth par défaut.
|
|
22
|
+
* Cloud : Bearer token depuis la clé API.
|
|
23
|
+
*/
|
|
24
|
+
function buildHeaders(api) {
|
|
25
|
+
if (api.mode === 'cloud') {
|
|
26
|
+
return { Authorization: `Bearer ${api.apiKey}` };
|
|
27
|
+
}
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Envoie un blob/File image à l'API MRZ et retourne le résultat parsé.
|
|
32
|
+
* Utilisable depuis React Native (uri → FormData) et depuis le web (File/Blob).
|
|
33
|
+
*/
|
|
34
|
+
export async function sendImageToApi(api, imageBlob, filename = 'mrz.jpg') {
|
|
35
|
+
var _a, _b;
|
|
36
|
+
const formData = new FormData();
|
|
37
|
+
formData.append('image', imageBlob, filename);
|
|
38
|
+
const response = await fetch(resolveEndpoint(api), {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: buildHeaders(api),
|
|
41
|
+
body: formData,
|
|
42
|
+
});
|
|
43
|
+
const json = await response.json();
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw new Error((_b = (_a = json === null || json === void 0 ? void 0 : json.message) !== null && _a !== void 0 ? _a : json === null || json === void 0 ? void 0 : json.error) !== null && _b !== void 0 ? _b : `Erreur API (${response.status})`);
|
|
46
|
+
}
|
|
47
|
+
return json.data;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Variante React Native : construit un FormData compatible RN
|
|
51
|
+
* à partir d'un URI local (retourné par expo-camera).
|
|
52
|
+
*/
|
|
53
|
+
export async function sendUriToApi(api, uri) {
|
|
54
|
+
var _a, _b;
|
|
55
|
+
const formData = new FormData();
|
|
56
|
+
// React Native accepte un objet { uri, name, type } dans FormData
|
|
57
|
+
formData.append('image', {
|
|
58
|
+
uri,
|
|
59
|
+
name: 'mrz.jpg',
|
|
60
|
+
type: 'image/jpeg',
|
|
61
|
+
});
|
|
62
|
+
const response = await fetch(resolveEndpoint(api), {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: buildHeaders(api),
|
|
65
|
+
body: formData,
|
|
66
|
+
});
|
|
67
|
+
const json = await response.json();
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
throw new Error((_b = (_a = json === null || json === void 0 ? void 0 : json.message) !== null && _a !== void 0 ? _a : json === null || json === void 0 ? void 0 : json.error) !== null && _b !== void 0 ? _b : `Erreur API (${response.status})`);
|
|
70
|
+
}
|
|
71
|
+
return json.data;
|
|
72
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
const DEFAULT_INTERVAL = 1500;
|
|
3
|
+
const DEFAULT_MAX_ATTEMPTS = 10;
|
|
4
|
+
/**
|
|
5
|
+
* Hook central du SDK.
|
|
6
|
+
* Gère la boucle de scan automatique :
|
|
7
|
+
* captureFrame → sendToApi → onSuccess | retry | onError
|
|
8
|
+
*
|
|
9
|
+
* Utilisé à la fois par MrzScannerNative et MrzScannerWeb,
|
|
10
|
+
* la seule différence étant captureFrame et sendToApi.
|
|
11
|
+
*/
|
|
12
|
+
export function useScanner({ api, maxAttempts = DEFAULT_MAX_ATTEMPTS, scanIntervalMs = DEFAULT_INTERVAL, onSuccess, onError, captureFrame, sendToApi, }) {
|
|
13
|
+
const [scanState, setScanState] = useState('idle');
|
|
14
|
+
const [attempts, setAttempts] = useState(0);
|
|
15
|
+
// Refs pour éviter les closures obsolètes dans setInterval
|
|
16
|
+
const intervalRef = useRef(null);
|
|
17
|
+
const isAnalyzingRef = useRef(false); // verrou — évite les appels concurrents
|
|
18
|
+
const isMountedRef = useRef(true);
|
|
19
|
+
const attemptsRef = useRef(0);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
isMountedRef.current = true;
|
|
22
|
+
return () => {
|
|
23
|
+
isMountedRef.current = false;
|
|
24
|
+
stopInterval();
|
|
25
|
+
};
|
|
26
|
+
}, []);
|
|
27
|
+
function stopInterval() {
|
|
28
|
+
if (intervalRef.current) {
|
|
29
|
+
clearInterval(intervalRef.current);
|
|
30
|
+
intervalRef.current = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const analyze = useCallback(async () => {
|
|
34
|
+
// Verrou : on ne lance pas un nouvel appel si le précédent tourne encore
|
|
35
|
+
if (isAnalyzingRef.current || !isMountedRef.current)
|
|
36
|
+
return;
|
|
37
|
+
isAnalyzingRef.current = true;
|
|
38
|
+
try {
|
|
39
|
+
const frame = await captureFrame();
|
|
40
|
+
if (!frame || !isMountedRef.current)
|
|
41
|
+
return;
|
|
42
|
+
if (isMountedRef.current)
|
|
43
|
+
setScanState('analyzing');
|
|
44
|
+
// frame est soit { uri: string } (native) soit un Blob (web)
|
|
45
|
+
const payload = 'uri' in frame ? frame.uri : frame;
|
|
46
|
+
const result = await sendToApi(api, payload);
|
|
47
|
+
if (!isMountedRef.current)
|
|
48
|
+
return;
|
|
49
|
+
// ✅ Succès
|
|
50
|
+
stopInterval();
|
|
51
|
+
setScanState('success');
|
|
52
|
+
onSuccess(result);
|
|
53
|
+
}
|
|
54
|
+
catch (_a) {
|
|
55
|
+
if (!isMountedRef.current)
|
|
56
|
+
return;
|
|
57
|
+
// Retry ou abandon
|
|
58
|
+
attemptsRef.current += 1;
|
|
59
|
+
setAttempts(attemptsRef.current);
|
|
60
|
+
if (attemptsRef.current >= maxAttempts) {
|
|
61
|
+
stopInterval();
|
|
62
|
+
setScanState('failed');
|
|
63
|
+
onError === null || onError === void 0 ? void 0 : onError(new Error(`Scan échoué après ${maxAttempts} tentatives.`));
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
setScanState('scanning');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
isAnalyzingRef.current = false;
|
|
71
|
+
}
|
|
72
|
+
}, [api, captureFrame, sendToApi, onSuccess, onError, maxAttempts]);
|
|
73
|
+
function start() {
|
|
74
|
+
if (intervalRef.current)
|
|
75
|
+
return;
|
|
76
|
+
attemptsRef.current = 0;
|
|
77
|
+
setAttempts(0);
|
|
78
|
+
setScanState('scanning');
|
|
79
|
+
// Première tentative immédiate puis intervalle
|
|
80
|
+
analyze();
|
|
81
|
+
intervalRef.current = setInterval(analyze, scanIntervalMs);
|
|
82
|
+
}
|
|
83
|
+
function stop() {
|
|
84
|
+
stopInterval();
|
|
85
|
+
setScanState('idle');
|
|
86
|
+
}
|
|
87
|
+
function reset() {
|
|
88
|
+
stopInterval();
|
|
89
|
+
isAnalyzingRef.current = false;
|
|
90
|
+
attemptsRef.current = 0;
|
|
91
|
+
setAttempts(0);
|
|
92
|
+
setScanState('idle');
|
|
93
|
+
}
|
|
94
|
+
return { scanState, attempts, start, stop, reset };
|
|
95
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { MrzScannerNative } from './native/MrzScannerNative';
|
|
2
|
+
export { MrzScannerWeb } from './web/MrzScannerWeb';
|
|
3
|
+
export { useScanner } from './shared/useScanner';
|
|
4
|
+
export { sendImageToApi, sendUriToApi } from './shared/api-client';
|
|
5
|
+
export type { MrzResult, MrzFields, DocumentType, ScanState, ApiConfig, ApiMode, SelfHostedConfig, CloudConfig, MrzScannerNativeProps, MrzScannerWebProps, MrzScannerBaseProps, } from './shared/types';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ReactElement } from 'react';
|
|
2
|
+
import type { MrzScannerNativeProps } from '../shared/types';
|
|
3
|
+
/**
|
|
4
|
+
* MrzScannerNative
|
|
5
|
+
*
|
|
6
|
+
* Composant React Native (Expo) de scan MRZ automatique.
|
|
7
|
+
* Démarre dès que la caméra est prête — aucun bouton requis.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* ```tsx
|
|
11
|
+
* <MrzScannerNative
|
|
12
|
+
* api={{ mode: 'selfhosted', apiUrl: 'http://192.168.1.10:3000' }}
|
|
13
|
+
* onSuccess={(result) => console.log(result.fields)}
|
|
14
|
+
* onClose={() => navigation.goBack()}
|
|
15
|
+
* />
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export declare function MrzScannerNative({ api, onSuccess, onError, onClose, maxAttempts, scanIntervalMs, hint, frameColor, successColor, }: MrzScannerNativeProps): ReactElement;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ApiConfig, MrzResult } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Envoie un blob/File image à l'API MRZ et retourne le résultat parsé.
|
|
4
|
+
* Utilisable depuis React Native (uri → FormData) et depuis le web (File/Blob).
|
|
5
|
+
*/
|
|
6
|
+
export declare function sendImageToApi(api: ApiConfig, imageBlob: Blob | File, filename?: string): Promise<MrzResult>;
|
|
7
|
+
/**
|
|
8
|
+
* Variante React Native : construit un FormData compatible RN
|
|
9
|
+
* à partir d'un URI local (retourné par expo-camera).
|
|
10
|
+
*/
|
|
11
|
+
export declare function sendUriToApi(api: ApiConfig, uri: string): Promise<MrzResult>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type DocumentType = 'TD1' | 'TD2' | 'TD3' | 'DL';
|
|
2
|
+
export interface MrzFields {
|
|
3
|
+
surname: string | null;
|
|
4
|
+
givenNames: string | null;
|
|
5
|
+
nationality: string | null;
|
|
6
|
+
issuingState: string | null;
|
|
7
|
+
/** Date de naissance au format YYMMDD */
|
|
8
|
+
dateOfBirth: string | null;
|
|
9
|
+
sex: 'male' | 'female' | 'unspecified' | null;
|
|
10
|
+
/** Date d'expiration au format YYMMDD */
|
|
11
|
+
expirationDate: string | null;
|
|
12
|
+
documentNumber: string | null;
|
|
13
|
+
personalNumber: string | null;
|
|
14
|
+
}
|
|
15
|
+
export interface MrzResult {
|
|
16
|
+
documentType: DocumentType;
|
|
17
|
+
documentLabel: string;
|
|
18
|
+
/** true si mrz-fast a auto-corrigé des erreurs OCR (O↔0, I↔1…) */
|
|
19
|
+
corrected: boolean;
|
|
20
|
+
fields: MrzFields;
|
|
21
|
+
}
|
|
22
|
+
export type ScanState = 'idle' | 'scanning' | 'analyzing' | 'success' | 'failed';
|
|
23
|
+
export type ApiMode = 'selfhosted' | 'cloud';
|
|
24
|
+
export interface SelfHostedConfig {
|
|
25
|
+
mode: 'selfhosted';
|
|
26
|
+
/** URL de votre instance mrz-nest, ex: "http://192.168.1.10:3000" */
|
|
27
|
+
apiUrl: string;
|
|
28
|
+
}
|
|
29
|
+
export interface CloudConfig {
|
|
30
|
+
mode: 'cloud';
|
|
31
|
+
/** Clé API obtenue sur scanid.africa */
|
|
32
|
+
apiKey: string;
|
|
33
|
+
/** Région optionnelle : "west-africa" | "north-africa" | "auto" */
|
|
34
|
+
region?: 'west-africa' | 'north-africa' | 'auto';
|
|
35
|
+
}
|
|
36
|
+
export type ApiConfig = SelfHostedConfig | CloudConfig;
|
|
37
|
+
export interface MrzScannerBaseProps {
|
|
38
|
+
/** Configuration de connexion à l'API */
|
|
39
|
+
api: ApiConfig;
|
|
40
|
+
/** Appelé dès qu'une MRZ valide est détectée */
|
|
41
|
+
onSuccess: (result: MrzResult) => void;
|
|
42
|
+
/** Appelé si le scan échoue après MAX_ATTEMPTS tentatives */
|
|
43
|
+
onError?: (error: Error) => void;
|
|
44
|
+
/** Appelé à chaque fermeture du scanner */
|
|
45
|
+
onClose?: () => void;
|
|
46
|
+
/** Nombre max de tentatives avant d'abandonner (défaut: 10) */
|
|
47
|
+
maxAttempts?: number;
|
|
48
|
+
/** Intervalle entre deux captures en ms (défaut: 1500) */
|
|
49
|
+
scanIntervalMs?: number;
|
|
50
|
+
}
|
|
51
|
+
export interface MrzScannerNativeProps extends MrzScannerBaseProps {
|
|
52
|
+
/** Texte affiché sous le cadre (défaut: "Alignez la zone MRZ dans le cadre") */
|
|
53
|
+
hint?: string;
|
|
54
|
+
/** Couleur du cadre de scan (défaut: "#FFFFFF") */
|
|
55
|
+
frameColor?: string;
|
|
56
|
+
/** Couleur du cadre en cas de succès (défaut: "#34d399") */
|
|
57
|
+
successColor?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface MrzScannerWebProps extends MrzScannerBaseProps {
|
|
60
|
+
/** Largeur du composant (défaut: "100%") */
|
|
61
|
+
width?: string | number;
|
|
62
|
+
/** Hauteur du composant (défaut: "480px") */
|
|
63
|
+
height?: string | number;
|
|
64
|
+
/** Classe CSS additionnelle sur le conteneur */
|
|
65
|
+
className?: string;
|
|
66
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ApiConfig, MrzResult, ScanState } from './types';
|
|
2
|
+
interface UseScannerOptions {
|
|
3
|
+
api: ApiConfig;
|
|
4
|
+
maxAttempts?: number;
|
|
5
|
+
scanIntervalMs?: number;
|
|
6
|
+
onSuccess: (result: MrzResult) => void;
|
|
7
|
+
onError?: (error: Error) => void;
|
|
8
|
+
/**
|
|
9
|
+
* Fonction fournie par le composant (native ou web) qui :
|
|
10
|
+
* 1. Capture une frame depuis la caméra
|
|
11
|
+
* 2. La recadre sur la zone MRZ si besoin
|
|
12
|
+
* 3. Retourne une URI (native) ou un Blob (web) prêt à envoyer
|
|
13
|
+
* Retourne null si la capture échoue (caméra pas prête, etc.)
|
|
14
|
+
*/
|
|
15
|
+
captureFrame: () => Promise<{
|
|
16
|
+
uri: string;
|
|
17
|
+
} | Blob | null>;
|
|
18
|
+
/**
|
|
19
|
+
* Fonction d'envoi adaptée à la plateforme
|
|
20
|
+
* (sendUriToApi pour native, sendImageToApi pour web)
|
|
21
|
+
*/
|
|
22
|
+
sendToApi: (api: ApiConfig, payload: string | Blob) => Promise<MrzResult>;
|
|
23
|
+
}
|
|
24
|
+
export interface ScannerControls {
|
|
25
|
+
scanState: ScanState;
|
|
26
|
+
attempts: number;
|
|
27
|
+
start: () => void;
|
|
28
|
+
stop: () => void;
|
|
29
|
+
reset: () => void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Hook central du SDK.
|
|
33
|
+
* Gère la boucle de scan automatique :
|
|
34
|
+
* captureFrame → sendToApi → onSuccess | retry | onError
|
|
35
|
+
*
|
|
36
|
+
* Utilisé à la fois par MrzScannerNative et MrzScannerWeb,
|
|
37
|
+
* la seule différence étant captureFrame et sendToApi.
|
|
38
|
+
*/
|
|
39
|
+
export declare function useScanner({ api, maxAttempts, scanIntervalMs, onSuccess, onError, captureFrame, sendToApi, }: UseScannerOptions): ScannerControls;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ReactElement } from 'react';
|
|
2
|
+
import type { MrzScannerWebProps } from '../shared/types';
|
|
3
|
+
/**
|
|
4
|
+
* MrzScannerWeb
|
|
5
|
+
*
|
|
6
|
+
* Composant React web de scan MRZ automatique via getUserMedia.
|
|
7
|
+
* Compatible Next.js (App Router) avec 'use client', Vite, CRA, etc.
|
|
8
|
+
*
|
|
9
|
+
* Next.js : importer avec dynamic() + { ssr: false }
|
|
10
|
+
* ```ts
|
|
11
|
+
* const MrzScannerWeb = dynamic(
|
|
12
|
+
* () => import('@scanid/mrz-scanner').then(m => m.MrzScannerWeb),
|
|
13
|
+
* { ssr: false }
|
|
14
|
+
* )
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Usage standard :
|
|
18
|
+
* ```tsx
|
|
19
|
+
* <MrzScannerWeb
|
|
20
|
+
* api={{ mode: 'cloud', apiKey: 'sk_...', region: 'west-africa' }}
|
|
21
|
+
* onSuccess={(r) => console.log(r.fields)}
|
|
22
|
+
* width="100%"
|
|
23
|
+
* height="480px"
|
|
24
|
+
* />
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare function MrzScannerWeb({ api, onSuccess, onError, onClose, maxAttempts, scanIntervalMs, width, height, className, }: MrzScannerWebProps): ReactElement;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState, } from 'react';
|
|
3
|
+
import { useScanner } from '../shared/useScanner';
|
|
4
|
+
import { sendImageToApi } from '../shared/api-client';
|
|
5
|
+
// ─── Label d'état ─────────────────────────────────────────────────────────────
|
|
6
|
+
function getLabel(state, attempts, max) {
|
|
7
|
+
switch (state) {
|
|
8
|
+
case 'idle': return 'Initialisation caméra…';
|
|
9
|
+
case 'scanning': return 'Placez le document dans le cadre — zone MRZ en bas';
|
|
10
|
+
case 'analyzing': return 'Analyse en cours…';
|
|
11
|
+
case 'success': return '✓ Document reconnu !';
|
|
12
|
+
case 'failed': return `Échec après ${attempts}/${max} tentatives`;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// ─── Composant ────────────────────────────────────────────────────────────────
|
|
16
|
+
/**
|
|
17
|
+
* MrzScannerWeb
|
|
18
|
+
*
|
|
19
|
+
* Composant React web de scan MRZ automatique via getUserMedia.
|
|
20
|
+
* Compatible Next.js (App Router) avec 'use client', Vite, CRA, etc.
|
|
21
|
+
*
|
|
22
|
+
* Next.js : importer avec dynamic() + { ssr: false }
|
|
23
|
+
* ```ts
|
|
24
|
+
* const MrzScannerWeb = dynamic(
|
|
25
|
+
* () => import('@scanid/mrz-scanner').then(m => m.MrzScannerWeb),
|
|
26
|
+
* { ssr: false }
|
|
27
|
+
* )
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* Usage standard :
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <MrzScannerWeb
|
|
33
|
+
* api={{ mode: 'cloud', apiKey: 'sk_...', region: 'west-africa' }}
|
|
34
|
+
* onSuccess={(r) => console.log(r.fields)}
|
|
35
|
+
* width="100%"
|
|
36
|
+
* height="480px"
|
|
37
|
+
* />
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function MrzScannerWeb({ api, onSuccess, onError, onClose, maxAttempts = 10, scanIntervalMs = 1500, width = '100%', height = '480px', className, }) {
|
|
41
|
+
const videoRef = useRef(null);
|
|
42
|
+
const canvasRef = useRef(null);
|
|
43
|
+
const streamRef = useRef(null);
|
|
44
|
+
const [camReady, setCamReady] = useState(false);
|
|
45
|
+
const [camError, setCamError] = useState(null);
|
|
46
|
+
// Démarrage de la caméra
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
let active = true;
|
|
49
|
+
async function initCamera() {
|
|
50
|
+
try {
|
|
51
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
52
|
+
video: {
|
|
53
|
+
facingMode: { ideal: 'environment' }, // caméra arrière si dispo
|
|
54
|
+
width: { ideal: 1920 },
|
|
55
|
+
height: { ideal: 1080 },
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
if (!active) {
|
|
59
|
+
stream.getTracks().forEach(t => t.stop());
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
streamRef.current = stream;
|
|
63
|
+
if (videoRef.current) {
|
|
64
|
+
videoRef.current.srcObject = stream;
|
|
65
|
+
videoRef.current.onloadedmetadata = () => {
|
|
66
|
+
var _a;
|
|
67
|
+
(_a = videoRef.current) === null || _a === void 0 ? void 0 : _a.play();
|
|
68
|
+
if (active)
|
|
69
|
+
setCamReady(true);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (active) {
|
|
75
|
+
setCamError("Impossible d'accéder à la caméra. Vérifiez les permissions.");
|
|
76
|
+
console.error('[MrzScannerWeb] Camera error:', err);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
initCamera();
|
|
81
|
+
return () => {
|
|
82
|
+
var _a;
|
|
83
|
+
active = false;
|
|
84
|
+
(_a = streamRef.current) === null || _a === void 0 ? void 0 : _a.getTracks().forEach(t => t.stop());
|
|
85
|
+
};
|
|
86
|
+
}, []);
|
|
87
|
+
/**
|
|
88
|
+
* Capture une frame depuis le flux vidéo.
|
|
89
|
+
* Recadrage automatique sur les 38% bas (zone MRZ).
|
|
90
|
+
* Retourne un Blob JPEG prêt à envoyer à l'API.
|
|
91
|
+
*/
|
|
92
|
+
const captureFrame = useCallback(async () => {
|
|
93
|
+
const video = videoRef.current;
|
|
94
|
+
const canvas = canvasRef.current;
|
|
95
|
+
if (!video || !canvas || !camReady)
|
|
96
|
+
return null;
|
|
97
|
+
const vw = video.videoWidth;
|
|
98
|
+
const vh = video.videoHeight;
|
|
99
|
+
if (!vw || !vh)
|
|
100
|
+
return null;
|
|
101
|
+
// Recadrage sur les 38% bas
|
|
102
|
+
const cropY = Math.floor(vh * 0.62);
|
|
103
|
+
const cropH = Math.floor(vh * 0.38);
|
|
104
|
+
canvas.width = vw;
|
|
105
|
+
canvas.height = cropH;
|
|
106
|
+
const ctx = canvas.getContext('2d');
|
|
107
|
+
if (!ctx)
|
|
108
|
+
return null;
|
|
109
|
+
ctx.drawImage(video, 0, cropY, vw, cropH, 0, 0, vw, cropH);
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
canvas.toBlob(resolve, 'image/jpeg', 0.85);
|
|
112
|
+
});
|
|
113
|
+
}, [camReady]);
|
|
114
|
+
const handleSuccess = useCallback((result) => {
|
|
115
|
+
onSuccess(result);
|
|
116
|
+
}, [onSuccess]);
|
|
117
|
+
const { scanState, attempts, start, reset } = useScanner({
|
|
118
|
+
api,
|
|
119
|
+
maxAttempts,
|
|
120
|
+
scanIntervalMs,
|
|
121
|
+
onSuccess: handleSuccess,
|
|
122
|
+
onError,
|
|
123
|
+
captureFrame: async () => {
|
|
124
|
+
const blob = await captureFrame();
|
|
125
|
+
return blob; // useScanner accepte Blob directement
|
|
126
|
+
},
|
|
127
|
+
sendToApi: async (cfg, payload) => sendImageToApi(cfg, payload),
|
|
128
|
+
});
|
|
129
|
+
// Démarrer dès que la caméra est prête
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (camReady)
|
|
132
|
+
start();
|
|
133
|
+
}, [camReady]);
|
|
134
|
+
// ─── Erreur caméra ──────────────────────────────────────────────────────────
|
|
135
|
+
if (camError) {
|
|
136
|
+
return (_jsx("div", { style: Object.assign(Object.assign({}, containerStyle), { width, height }), className: className, children: _jsxs("div", { style: errorStyle, children: [_jsx("p", { style: { color: '#fff', marginBottom: 16 }, children: camError }), onClose && (_jsx("button", { style: btnStyle, onClick: onClose, children: "Fermer" }))] }) }));
|
|
137
|
+
}
|
|
138
|
+
// ─── Frame principal ────────────────────────────────────────────────────────
|
|
139
|
+
const isSuccess = scanState === 'success';
|
|
140
|
+
const isFailed = scanState === 'failed';
|
|
141
|
+
return (_jsxs("div", { style: Object.assign(Object.assign({}, containerStyle), { width, height }), className: className, children: [_jsx("video", { ref: videoRef, muted: true, playsInline: true, style: videoStyle }), _jsx("canvas", { ref: canvasRef, style: { display: 'none' } }), _jsxs("div", { style: overlayStyle, children: [_jsx("div", { style: Object.assign(Object.assign({}, maskStyle), { flex: 3 }) }), _jsxs("div", { style: { display: 'flex', height: 90 }, children: [_jsx("div", { style: Object.assign(Object.assign({}, maskStyle), { flex: 1 }) }), _jsxs("div", { style: Object.assign(Object.assign({}, frameStyle), { borderColor: isSuccess ? '#34d399' : 'rgba(255,255,255,0.9)', boxShadow: isSuccess
|
|
142
|
+
? '0 0 0 2px #34d399, 0 0 20px rgba(52,211,153,0.3)'
|
|
143
|
+
: camReady
|
|
144
|
+
? '0 0 0 1px rgba(255,255,255,0.2)'
|
|
145
|
+
: 'none', transition: 'border-color 0.3s, box-shadow 0.3s', animation: scanState === 'scanning' ? 'pulse 1.4s ease-in-out infinite' : 'none' }), children: [_jsx("div", { style: Object.assign(Object.assign({}, cornerStyle), { top: 0, left: 0, borderRightWidth: 0, borderBottomWidth: 0 }) }), _jsx("div", { style: Object.assign(Object.assign({}, cornerStyle), { top: 0, right: 0, borderLeftWidth: 0, borderBottomWidth: 0 }) }), _jsx("div", { style: Object.assign(Object.assign({}, cornerStyle), { bottom: 0, left: 0, borderRightWidth: 0, borderTopWidth: 0 }) }), _jsx("div", { style: Object.assign(Object.assign({}, cornerStyle), { bottom: 0, right: 0, borderLeftWidth: 0, borderTopWidth: 0 }) }), scanState === 'analyzing' && (_jsx("div", { style: spinnerStyle })), isSuccess && (_jsx("span", { style: { color: '#34d399', fontSize: 28, fontWeight: 700 }, children: "\u2713" }))] }), _jsx("div", { style: Object.assign(Object.assign({}, maskStyle), { flex: 1 }) })] }), _jsx("div", { style: Object.assign(Object.assign({}, maskStyle), { flex: 2 }) })] }), _jsxs("div", { style: statusBarStyle, children: [_jsx("span", { style: statusTextStyle, children: getLabel(scanState, attempts, maxAttempts) }), isFailed && (_jsx("button", { style: Object.assign(Object.assign({}, btnStyle), { marginTop: 12 }), onClick: () => { reset(); setTimeout(start, 100); }, children: "R\u00E9essayer" }))] }), onClose && (_jsx("button", { style: closeBtnStyle, onClick: onClose, "aria-label": "Fermer le scanner", children: "\u2715" })), _jsx("style", { children: `
|
|
146
|
+
@keyframes pulse {
|
|
147
|
+
0%, 100% { transform: scale(1); opacity: 1; }
|
|
148
|
+
50% { transform: scale(1.015); opacity: 0.85; }
|
|
149
|
+
}
|
|
150
|
+
@keyframes spin {
|
|
151
|
+
to { transform: rotate(360deg); }
|
|
152
|
+
}
|
|
153
|
+
` })] }));
|
|
154
|
+
}
|
|
155
|
+
// ─── Styles inline ────────────────────────────────────────────────────────────
|
|
156
|
+
const containerStyle = {
|
|
157
|
+
position: 'relative',
|
|
158
|
+
overflow: 'hidden',
|
|
159
|
+
background: '#000',
|
|
160
|
+
borderRadius: 12,
|
|
161
|
+
};
|
|
162
|
+
const videoStyle = {
|
|
163
|
+
position: 'absolute',
|
|
164
|
+
inset: 0,
|
|
165
|
+
width: '100%',
|
|
166
|
+
height: '100%',
|
|
167
|
+
objectFit: 'cover',
|
|
168
|
+
};
|
|
169
|
+
const overlayStyle = {
|
|
170
|
+
position: 'absolute',
|
|
171
|
+
inset: 0,
|
|
172
|
+
display: 'flex',
|
|
173
|
+
flexDirection: 'column',
|
|
174
|
+
pointerEvents: 'none',
|
|
175
|
+
};
|
|
176
|
+
const maskStyle = {
|
|
177
|
+
background: 'rgba(0,0,0,0.55)',
|
|
178
|
+
};
|
|
179
|
+
const frameStyle = {
|
|
180
|
+
flex: 5,
|
|
181
|
+
border: '2px solid rgba(255,255,255,0.9)',
|
|
182
|
+
borderRadius: 6,
|
|
183
|
+
position: 'relative',
|
|
184
|
+
display: 'flex',
|
|
185
|
+
alignItems: 'center',
|
|
186
|
+
justifyContent: 'center',
|
|
187
|
+
};
|
|
188
|
+
const cornerStyle = {
|
|
189
|
+
position: 'absolute',
|
|
190
|
+
width: 16,
|
|
191
|
+
height: 16,
|
|
192
|
+
borderStyle: 'solid',
|
|
193
|
+
borderColor: '#fff',
|
|
194
|
+
borderWidth: 2,
|
|
195
|
+
};
|
|
196
|
+
const spinnerStyle = {
|
|
197
|
+
position: 'absolute',
|
|
198
|
+
top: 8,
|
|
199
|
+
right: 8,
|
|
200
|
+
width: 16,
|
|
201
|
+
height: 16,
|
|
202
|
+
border: '2px solid rgba(255,255,255,0.3)',
|
|
203
|
+
borderTopColor: '#fff',
|
|
204
|
+
borderRadius: '50%',
|
|
205
|
+
animation: 'spin 0.8s linear infinite',
|
|
206
|
+
};
|
|
207
|
+
const statusBarStyle = {
|
|
208
|
+
position: 'absolute',
|
|
209
|
+
bottom: 40,
|
|
210
|
+
left: 0,
|
|
211
|
+
right: 0,
|
|
212
|
+
display: 'flex',
|
|
213
|
+
flexDirection: 'column',
|
|
214
|
+
alignItems: 'center',
|
|
215
|
+
padding: '0 24px',
|
|
216
|
+
pointerEvents: 'none',
|
|
217
|
+
};
|
|
218
|
+
const statusTextStyle = {
|
|
219
|
+
color: '#fff',
|
|
220
|
+
fontSize: 13,
|
|
221
|
+
textAlign: 'center',
|
|
222
|
+
textShadow: '0 1px 4px rgba(0,0,0,0.8)',
|
|
223
|
+
};
|
|
224
|
+
const closeBtnStyle = {
|
|
225
|
+
position: 'absolute',
|
|
226
|
+
top: 16,
|
|
227
|
+
right: 16,
|
|
228
|
+
background: 'rgba(0,0,0,0.4)',
|
|
229
|
+
border: 'none',
|
|
230
|
+
color: '#fff',
|
|
231
|
+
fontSize: 18,
|
|
232
|
+
width: 36,
|
|
233
|
+
height: 36,
|
|
234
|
+
borderRadius: '50%',
|
|
235
|
+
cursor: 'pointer',
|
|
236
|
+
display: 'flex',
|
|
237
|
+
alignItems: 'center',
|
|
238
|
+
justifyContent: 'center',
|
|
239
|
+
};
|
|
240
|
+
const btnStyle = {
|
|
241
|
+
background: '#c8ff00',
|
|
242
|
+
color: '#000',
|
|
243
|
+
border: 'none',
|
|
244
|
+
borderRadius: 8,
|
|
245
|
+
padding: '10px 24px',
|
|
246
|
+
fontWeight: 700,
|
|
247
|
+
fontSize: 14,
|
|
248
|
+
cursor: 'pointer',
|
|
249
|
+
pointerEvents: 'auto',
|
|
250
|
+
};
|
|
251
|
+
const errorStyle = {
|
|
252
|
+
position: 'absolute',
|
|
253
|
+
inset: 0,
|
|
254
|
+
display: 'flex',
|
|
255
|
+
flexDirection: 'column',
|
|
256
|
+
alignItems: 'center',
|
|
257
|
+
justifyContent: 'center',
|
|
258
|
+
padding: 24,
|
|
259
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@scanid-africa/mrz-scanner",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MRZ scanner for React Native (Expo) and React web — powered by ScanID Africa API",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.js",
|
|
7
|
+
"types": "dist/types/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mrz",
|
|
18
|
+
"ocr",
|
|
19
|
+
"passport",
|
|
20
|
+
"scanner",
|
|
21
|
+
"react-native",
|
|
22
|
+
"expo",
|
|
23
|
+
"africa"
|
|
24
|
+
],
|
|
25
|
+
"author": "ScanID Africa",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/MJdiop/mrz-scanner-sdk"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"expo-camera": ">=14.0.0",
|
|
33
|
+
"expo-image-manipulator": ">=11.0.0",
|
|
34
|
+
"expo-haptics": ">=12.0.0",
|
|
35
|
+
"react": ">=18.0.0",
|
|
36
|
+
"react-native": ">=0.73.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"expo-haptics": {
|
|
40
|
+
"optional": true
|
|
41
|
+
},
|
|
42
|
+
"react-native": {
|
|
43
|
+
"optional": true
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/react": "^19.1.1",
|
|
48
|
+
"typescript": "^5.0.0"
|
|
49
|
+
}
|
|
50
|
+
}
|