@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 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,2 @@
1
+ // ─── Résultat MRZ ─────────────────────────────────────────────────────────────
2
+ export {};
@@ -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
+ }