@trustchex/react-native-sdk 1.472.0 → 1.475.1

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.
Files changed (30) hide show
  1. package/android/build.gradle +3 -3
  2. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +147 -9
  3. package/ios/Camera/TrustchexCameraView.swift +92 -19
  4. package/lib/module/Screens/Debug/MRZTestScreen.js +121 -147
  5. package/lib/module/Screens/Static/ResultScreen.js +5 -0
  6. package/lib/module/Shared/Components/IdentityDocumentCamera.js +38 -4
  7. package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
  8. package/lib/module/Shared/Libs/mrz.utils.js +639 -16
  9. package/lib/module/Shared/Libs/mrzFrameAggregator.js +301 -0
  10. package/lib/module/Shared/Libs/mrzOcrIntegration.js +109 -0
  11. package/lib/module/version.js +1 -1
  12. package/lib/typescript/src/Screens/Debug/MRZTestScreen.d.ts.map +1 -1
  13. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  14. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  15. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts +8 -0
  16. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  17. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +76 -0
  18. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -0
  19. package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts +73 -0
  20. package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts.map +1 -0
  21. package/lib/typescript/src/version.d.ts +1 -1
  22. package/package.json +15 -10
  23. package/src/Screens/Debug/MRZTestScreen.tsx +137 -166
  24. package/src/Screens/Static/ResultScreen.tsx +5 -0
  25. package/src/Shared/Components/IdentityDocumentCamera.tsx +46 -6
  26. package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
  27. package/src/Shared/Libs/mrz.utils.ts +704 -11
  28. package/src/Shared/Libs/mrzFrameAggregator.ts +370 -0
  29. package/src/Shared/Libs/mrzOcrIntegration.ts +175 -0
  30. package/src/version.ts +1 -1
@@ -4,99 +4,80 @@ import React, { useState, useRef, useCallback } from 'react';
4
4
  import { View, StyleSheet, Text, ScrollView, StatusBar, TouchableOpacity } from 'react-native';
5
5
  import { SafeAreaView } from 'react-native-safe-area-context';
6
6
  import { TrustchexCamera } from "../../Shared/Components/TrustchexCamera.js";
7
- import mrzUtils from "../../Shared/Libs/mrz.utils.js";
7
+ import { MRZFrameAggregator } from "../../Shared/Libs/mrzFrameAggregator.js";
8
8
  import { useKeepAwake } from "../../Shared/Libs/native-keep-awake.utils.js";
9
+
10
+ // All per-frame display data lives in ONE state object so a processed frame
11
+ // triggers a SINGLE re-render instead of 7 separate setState calls.
9
12
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
+ const EMPTY_VIEW = {
14
+ raw: '',
15
+ voted: 'Voting…',
16
+ valid: false,
17
+ frames: 0,
18
+ stable: false
19
+ };
20
+
21
+ // Process the live OCR stream at this cadence. The camera delivers frames faster
22
+ // than the MRZ validation/voting can run; gating here keeps the JS thread free to
23
+ // service touches (so the buttons stay responsive) while still converging fast.
24
+ const MRZ_PROCESS_INTERVAL_MS = 400;
10
25
  const MRZTestScreen = () => {
11
26
  useKeepAwake();
12
27
  const cameraRef = useRef(null);
13
- const [mrzText, setMrzText] = useState('Waiting for MRZ...');
14
- const [mrzRawText, setMrzRawText] = useState('');
15
- const [mrzValid, setMrzValid] = useState(false);
16
- const [rawHistory, setRawHistory] = useState([]);
28
+
29
+ // Single consolidated view-state; one setState per processed frame.
30
+ const [view, setView] = useState(EMPTY_VIEW);
17
31
  const [isPaused, setIsPaused] = useState(false);
18
- const [overlayBlocks, setOverlayBlocks] = useState([]);
19
- const [cameraLayout, setCameraLayout] = useState({
20
- width: 1,
21
- height: 1
22
- });
23
- const [frameSize, setFrameSize] = useState({
24
- width: 1080,
25
- height: 1920
26
- });
27
- const handleCameraLayout = useCallback(e => {
28
- const {
29
- width,
30
- height
31
- } = e.nativeEvent.layout;
32
- setCameraLayout({
33
- width,
34
- height
35
- });
36
- }, []);
32
+
33
+ // Refs for values the frame handler reads but that must NOT re-create it:
34
+ // keeping handleFrame identity-stable avoids re-subscribing the camera and
35
+ // keeps it cheap.
36
+ const pausedRef = useRef(false);
37
+ const lastProcessedAt = useRef(0);
38
+ const aggregator = useRef(new MRZFrameAggregator({
39
+ stabilityTarget: 2
40
+ }));
37
41
  const handleFrame = useCallback(event => {
38
- if (isPaused) {
39
- return;
40
- }
42
+ if (pausedRef.current) return;
43
+
44
+ // Throttle the ENTIRE handler. Drop frames that arrive inside the window
45
+ // before doing ANY work (no parsing, no setState) so touch events land.
46
+ const now = Date.now();
47
+ if (now - lastProcessedAt.current < MRZ_PROCESS_INTERVAL_MS) return;
41
48
  const frame = event.nativeEvent.frame;
42
- if (frame.width && frame.height) {
43
- setFrameSize({
44
- width: frame.width,
45
- height: frame.height
46
- });
47
- }
48
- if (frame.textBlocks && frame.textBlocks.length > 0) {
49
- const frameHeight = frame.height;
50
- const bottomThreshold = frameHeight * 0.5;
51
- const blocks = frame.textBlocks.map(block => {
52
- const blockText = block.text || '';
53
- return {
54
- ...block,
55
- isMrz: blockText.includes('<') && blockText.length >= 12 && (block.blockFrame ? block.blockFrame.y > bottomThreshold : false)
56
- };
57
- });
58
- setOverlayBlocks(blocks);
59
- } else {
60
- setOverlayBlocks([]);
61
- }
62
- if (frame.resultText) {
63
- setRawHistory(prev => {
64
- if (prev[0] === frame.resultText) {
65
- return prev;
66
- }
67
- const next = [frame.resultText, ...prev];
68
- return next.slice(0, 20);
69
- });
70
- }
71
- if (frame.resultText && frame.resultText.includes('<')) {
72
- // Extract MRZ-only text from detected blocks
73
- let mrzOnlyText = '';
74
- if (frame.textBlocks && frame.textBlocks.length > 0) {
75
- // Filter blocks that look like MRZ (contain < and are in bottom half of frame)
76
- const frameHeight = frame.height;
77
- const bottomThreshold = frameHeight * 0.5;
78
- const mrzBlocks = frame.textBlocks.filter(block => {
79
- const blockText = block.text || '';
80
- const hasFillers = blockText.includes('<');
81
- const isInBottomArea = block.blockFrame ? block.blockFrame.y > bottomThreshold : false;
82
- const isLongEnough = blockText.length >= 12;
83
- return hasFillers && isInBottomArea && isLongEnough;
84
- });
85
- if (mrzBlocks.length > 0) {
86
- mrzOnlyText = mrzBlocks.map(b => b.text).join('\n');
87
- }
88
- }
49
+ const resultText = frame.resultText;
50
+ if (!resultText || !resultText.includes('<')) return;
51
+ lastProcessedAt.current = now;
52
+
53
+ // Collect every MRZ-looking block (contains a filler) regardless of
54
+ // position/length — the band is often fragmented and may not sit in the
55
+ // bottom half. Reconstruction downstream isolates it from front-of-card text.
56
+ const mrzBlocks = (frame.textBlocks ?? []).filter(b => (b.text || '').includes('<'));
57
+ const mrzOnlyText = mrzBlocks.length ? mrzBlocks.map(b => b.text).join('\n') : resultText;
58
+ const bandHeight = mrzBlocks.length ? Math.max(0, ...mrzBlocks.map(b => b.blockFrame?.height ?? 0)) : 0;
59
+ const consensus = aggregator.current.addFrame({
60
+ text: `${mrzOnlyText}\n${resultText}`,
61
+ weight: bandHeight > 0 ? bandHeight : 1
62
+ });
89
63
 
90
- // Fallback to full text if no MRZ blocks detected
91
- if (!mrzOnlyText) {
92
- mrzOnlyText = frame.resultText;
93
- }
94
- const fixedMrz = mrzUtils.fixMRZ(mrzOnlyText);
95
- setMrzRawText(mrzOnlyText);
96
- setMrzText(fixedMrz);
97
- setMrzValid(mrzUtils.validateMRZWithCorrections(fixedMrz).valid);
98
- }
99
- }, [isPaused]);
64
+ // One setState one re-render.
65
+ setView({
66
+ raw: mrzOnlyText,
67
+ voted: consensus.mrz ?? `Voting… (${consensus.frames} frames)`,
68
+ valid: !!consensus.validation?.valid,
69
+ frames: consensus.frames,
70
+ stable: consensus.stable
71
+ });
72
+ }, []);
73
+ const togglePause = useCallback(() => {
74
+ pausedRef.current = !pausedRef.current;
75
+ setIsPaused(pausedRef.current);
76
+ }, []);
77
+ const resetVoting = useCallback(() => {
78
+ aggregator.current.reset();
79
+ setView(EMPTY_VIEW);
80
+ }, []);
100
81
  return /*#__PURE__*/_jsxs(View, {
101
82
  style: styles.container,
102
83
  children: [/*#__PURE__*/_jsx(StatusBar, {
@@ -105,7 +86,6 @@ const MRZTestScreen = () => {
105
86
  translucent: true
106
87
  }), /*#__PURE__*/_jsxs(View, {
107
88
  style: styles.cameraWrapper,
108
- onLayout: handleCameraLayout,
109
89
  children: [/*#__PURE__*/_jsx(TrustchexCamera, {
110
90
  ref: cameraRef,
111
91
  style: StyleSheet.absoluteFill,
@@ -115,28 +95,15 @@ const MRZTestScreen = () => {
115
95
  enableTextRecognition: true,
116
96
  enableBarcodeScanning: false,
117
97
  includeBase64: false,
118
- targetFps: 10,
98
+ targetFps: 5,
119
99
  onFrameAvailable: handleFrame
120
- }), overlayBlocks.map((block, i) => {
121
- const bf = block.blockFrame;
122
- if (!bf) return null;
123
- const scaleX = cameraLayout.width / frameSize.width;
124
- const scaleY = cameraLayout.height / frameSize.height;
125
- return /*#__PURE__*/_jsx(View, {
126
- style: [styles.boundingBox, block.isMrz ? styles.boundingBoxMrz : styles.boundingBoxText, {
127
- left: bf.x * scaleX,
128
- top: bf.y * scaleY,
129
- width: bf.width * scaleX,
130
- height: bf.height * scaleY
131
- }],
132
- children: block.isMrz && /*#__PURE__*/_jsx(View, {
133
- style: styles.blockLabel,
134
- children: /*#__PURE__*/_jsx(Text, {
135
- style: styles.blockLabelText,
136
- children: "MRZ"
137
- })
138
- })
139
- }, i);
100
+ }), /*#__PURE__*/_jsx(View, {
101
+ pointerEvents: "none",
102
+ style: styles.scanGuide,
103
+ children: /*#__PURE__*/_jsx(Text, {
104
+ style: styles.scanGuideLabel,
105
+ children: "Align MRZ inside this frame"
106
+ })
140
107
  })]
141
108
  }), /*#__PURE__*/_jsx(SafeAreaView, {
142
109
  style: styles.mrzPanel,
@@ -145,40 +112,47 @@ const MRZTestScreen = () => {
145
112
  style: styles.scrollView,
146
113
  children: /*#__PURE__*/_jsxs(View, {
147
114
  style: styles.panelContent,
148
- children: [/*#__PURE__*/_jsx(TouchableOpacity, {
149
- style: styles.pauseButton,
150
- onPress: () => setIsPaused(!isPaused),
151
- children: /*#__PURE__*/_jsx(Text, {
152
- style: styles.pauseButtonText,
153
- children: isPaused ? 'Resume Processing' : 'Pause Processing'
154
- })
115
+ children: [/*#__PURE__*/_jsxs(View, {
116
+ style: styles.buttonRow,
117
+ children: [/*#__PURE__*/_jsx(TouchableOpacity, {
118
+ style: styles.pauseButton,
119
+ onPress: togglePause,
120
+ children: /*#__PURE__*/_jsx(Text, {
121
+ style: styles.pauseButtonText,
122
+ children: isPaused ? 'Resume Processing' : 'Pause Processing'
123
+ })
124
+ }), /*#__PURE__*/_jsx(TouchableOpacity, {
125
+ style: styles.pauseButton,
126
+ onPress: resetVoting,
127
+ children: /*#__PURE__*/_jsx(Text, {
128
+ style: styles.pauseButtonText,
129
+ children: "Reset Voting"
130
+ })
131
+ })]
155
132
  }), /*#__PURE__*/_jsx(Text, {
156
133
  style: styles.title,
157
134
  children: "MRZ Text Read"
158
- }), /*#__PURE__*/_jsx(Text, {
159
- style: styles.sectionTitle,
160
- children: "MRZ Raw Text"
161
- }), /*#__PURE__*/_jsx(Text, {
162
- style: styles.mrzText,
163
- children: mrzRawText ? mrzRawText.split('\n').map((line, i) => `Line ${i + 1}: ${line} (${line.length} chars)`).join('\n') : 'No MRZ raw text'
164
- }), /*#__PURE__*/_jsx(Text, {
135
+ }), /*#__PURE__*/_jsxs(Text, {
165
136
  style: styles.sectionTitle,
166
- children: "MRZ Corrected Text"
137
+ children: ["\u2605 Multi-frame Voted MRZ \u2014 ", view.frames, " frames", view.valid ? ' · VALID' : '', view.stable ? ' · STABLE' : '']
167
138
  }), /*#__PURE__*/_jsx(Text, {
168
- style: [styles.mrzText, mrzValid ? styles.mrzValid : null],
169
- children: mrzText.split('\n').map((line, i) => `Line ${i + 1}: ${line} (${line.length} chars)`).join('\n')
139
+ style: [styles.mrzText, view.valid ? styles.mrzValid : null],
140
+ children: formatLines(view.voted)
170
141
  }), /*#__PURE__*/_jsx(Text, {
171
142
  style: styles.sectionTitle,
172
- children: "Raw Text History"
143
+ children: "MRZ Raw Text (this frame)"
173
144
  }), /*#__PURE__*/_jsx(Text, {
174
145
  style: styles.mrzText,
175
- children: rawHistory.length > 0 ? rawHistory.map((text, i) => `#${i + 1}: ${text.replace(/\n/g, ' ')}`).join('\n') : 'No history'
146
+ children: view.raw ? formatLines(view.raw) : 'No MRZ raw text'
176
147
  })]
177
148
  })
178
149
  })
179
150
  })]
180
151
  });
181
152
  };
153
+
154
+ /** Render MRZ text as "Line N: <chars> (N chars)" rows. */
155
+ const formatLines = text => text.split('\n').map((line, i) => `Line ${i + 1}: ${line} (${line.length} chars)`).join('\n');
182
156
  const styles = StyleSheet.create({
183
157
  container: {
184
158
  flex: 1,
@@ -188,31 +162,26 @@ const styles = StyleSheet.create({
188
162
  flex: 2,
189
163
  position: 'relative'
190
164
  },
191
- boundingBox: {
165
+ scanGuide: {
192
166
  position: 'absolute',
193
- borderWidth: 1.5
194
- },
195
- boundingBoxText: {
196
- borderColor: 'rgba(255, 255, 255, 0.25)'
197
- },
198
- boundingBoxMrz: {
199
- borderColor: '#FFA500',
167
+ // Mirrors the native OCR crop: top 25% / bottom 12% / 4% side margins.
168
+ top: '25%',
169
+ bottom: '12%',
170
+ left: '4%',
171
+ right: '4%',
200
172
  borderWidth: 2,
201
- backgroundColor: 'rgba(255, 165, 0, 0.07)'
202
- },
203
- blockLabel: {
204
- position: 'absolute',
205
- top: -14,
206
- left: 0,
207
- backgroundColor: '#FFA500',
208
- paddingHorizontal: 4,
209
- paddingVertical: 1,
210
- borderRadius: 2
173
+ borderColor: '#00E5FF',
174
+ borderRadius: 6,
175
+ justifyContent: 'flex-start',
176
+ alignItems: 'center'
211
177
  },
212
- blockLabelText: {
213
- color: '#000000',
214
- fontSize: 8,
215
- fontWeight: 'bold'
178
+ scanGuideLabel: {
179
+ color: '#00E5FF',
180
+ fontSize: 10,
181
+ fontWeight: 'bold',
182
+ marginTop: -16,
183
+ backgroundColor: 'rgba(0,0,0,0.5)',
184
+ paddingHorizontal: 4
216
185
  },
217
186
  mrzPanel: {
218
187
  flex: 1,
@@ -226,6 +195,11 @@ const styles = StyleSheet.create({
226
195
  panelContent: {
227
196
  padding: 10
228
197
  },
198
+ buttonRow: {
199
+ flexDirection: 'row',
200
+ justifyContent: 'center',
201
+ gap: 8
202
+ },
229
203
  pauseButton: {
230
204
  alignSelf: 'center',
231
205
  borderWidth: 1,
@@ -70,10 +70,15 @@ const ResultScreen = () => {
70
70
  }
71
71
  }, []);
72
72
  const formatGender = useCallback(code => {
73
+ // The eID/NFC path stores sex as "M"/"F" (ICAO getStrCode), while the
74
+ // camera MRZ path (the `mrz` npm package) stores "male"/"female". Accept
75
+ // both so the ID scan shows the gender instead of "unspecified".
73
76
  switch (code?.toUpperCase()) {
74
77
  case 'M':
78
+ case 'MALE':
75
79
  return t('eidScannerScreen.genderMale');
76
80
  case 'F':
81
+ case 'FEMALE':
77
82
  return t('eidScannerScreen.genderFemale');
78
83
  default:
79
84
  return t('eidScannerScreen.genderUnspecified');
@@ -8,6 +8,7 @@ import { TrustchexCamera } from "./TrustchexCamera.js";
8
8
  import { NativeModules } from 'react-native';
9
9
  import PermissionManager from "../Libs/permissions.utils.js";
10
10
  import mrzUtils from "../Libs/mrz.utils.js";
11
+ import { MRZFrameAggregator } from "../Libs/mrzFrameAggregator.js";
11
12
  import { useKeepAwake } from "../Libs/native-keep-awake.utils.js";
12
13
  import { useIsFocused } from '@react-navigation/native';
13
14
  import { useTranslation } from 'react-i18next';
@@ -70,6 +71,12 @@ const IdentityDocumentCamera = ({
70
71
  const lastValidMRZText = useRef(null);
71
72
  const lastValidMRZFields = useRef(null);
72
73
  const validMRZConsecutiveCount = useRef(0);
74
+ // Multi-frame per-character voting: fuses OCR across frames so a single-frame
75
+ // misread (B↔8, S↔5, <↔K, dropped/inserted digit) is outvoted instead of
76
+ // resetting progress. Drives the same `parsedMRZData`/stability the flow uses.
77
+ const mrzAggregator = useRef(new MRZFrameAggregator({
78
+ stabilityTarget: 2
79
+ }));
73
80
 
74
81
  // Document type stability tracking - require consistent detections from good quality frames
75
82
  const lastDetectedDocType = useRef('UNKNOWN');
@@ -149,6 +156,7 @@ const IdentityDocumentCamera = ({
149
156
  lastValidMRZText.current = null;
150
157
  lastValidMRZFields.current = null;
151
158
  validMRZConsecutiveCount.current = 0;
159
+ mrzAggregator.current.reset();
152
160
  cachedBarcode.current = null;
153
161
  isCompletionCallbackInvoked.current = false;
154
162
  isStepTransitionInProgress.current = false;
@@ -376,6 +384,8 @@ const IdentityDocumentCamera = ({
376
384
  if (nextStepType === 'SCAN_ID_BACK') {
377
385
  lastValidMRZText.current = null;
378
386
  lastValidMRZFields.current = null;
387
+ // New MRZ expected on the back — start voting fresh.
388
+ mrzAggregator.current.reset();
379
389
  }
380
390
  validMRZConsecutiveCount.current = 0;
381
391
  cachedBarcode.current = null; // Clear cached barcode on step change
@@ -538,13 +548,33 @@ const IdentityDocumentCamera = ({
538
548
  // Prefer MRZ-only text if available (from detected MRZ blocks),
539
549
  // otherwise fall back to all text (for backward compatibility)
540
550
  const textForValidation = scannedText?.mrzOnlyText || text;
541
- const mrzValidationResult = mrzUtils.validateMRZWithCorrections(textForValidation);
551
+
552
+ // Multi-frame per-character voting: feed this frame's OCR into the
553
+ // aggregator (weighted by the MRZ band's pixel height as a sharpness/
554
+ // closeness proxy — a taller band means more pixels per character, which
555
+ // ML Kit reads more reliably) and prefer the fused consensus. This outvotes
556
+ // single-frame OCR errors (B↔8, S↔5, <↔K, dropped/inserted digit) instead
557
+ // of letting one noisy frame reset progress.
558
+ const mrzBandHeight = scannedText?.blocks?.length ? Math.max(0, ...scannedText.blocks.map(b => b.blockFrame?.height ?? 0)) : 0;
559
+ const frameWeight = mrzBandHeight > 0 ? mrzBandHeight : 1;
560
+ const consensus = mrzAggregator.current.addFrame({
561
+ text: textForValidation,
562
+ weight: frameWeight
563
+ });
564
+
565
+ // Use the fused consensus when it is valid; otherwise fall back to a
566
+ // single-frame validation so document-type detection still works on the
567
+ // early frames. Only run the (expensive) single-frame pass when the
568
+ // consensus has NOT yet validated — once it has, the per-frame validation
569
+ // is redundant work that would needlessly load the JS thread.
570
+ const mrzValidationResult = consensus.validation?.valid && consensus.validation.fields ? consensus.validation : mrzUtils.validateMRZWithCorrections(textForValidation);
542
571
  const parsedMRZData = {
543
572
  valid: mrzValidationResult.valid,
544
573
  fields: mrzValidationResult.fields || null
545
574
  };
546
- // Extract raw MRZ lines from text if validation succeeded
547
- const mrzText = parsedMRZData.valid ? mrzUtils.fixMRZ(text) : null;
575
+ // Raw MRZ lines: prefer the fused/corrected consensus when valid, else the
576
+ // lightweight per-frame clean. correctedMrz is the noise-free recovered MRZ.
577
+ const mrzText = parsedMRZData.valid ? mrzValidationResult.correctedMrz ?? mrzUtils.fixMRZ(text) : null;
548
578
 
549
579
  // CRITICAL: Only detect document type during initial scan step
550
580
  // For SCAN_HOLOGRAM and beyond, use the locked detectedDocumentType to avoid
@@ -645,7 +675,11 @@ const IdentityDocumentCamera = ({
645
675
  // else: Invalid/no MRZ - skip frame without resetting (allows temporary OCR noise)
646
676
 
647
677
  // Check if we have enough consistent valid reads
648
- const mrzStableAndValid = validMRZConsecutiveCount.current >= REQUIRED_CONSISTENT_MRZ_READS && parsedMRZData?.valid === true && areMRZFieldsEqual(lastValidMRZFields.current, parsedMRZData?.fields);
678
+ const mrzStableAndValid =
679
+ // Either: the multi-frame voted consensus has converged (valid + stable),
680
+ consensus.stable && parsedMRZData?.valid === true ||
681
+ // or the legacy N-identical-reads path is satisfied.
682
+ validMRZConsecutiveCount.current >= REQUIRED_CONSISTENT_MRZ_READS && parsedMRZData?.valid === true && areMRZFieldsEqual(lastValidMRZFields.current, parsedMRZData?.fields);
649
683
 
650
684
  // ============================================================================
651
685
  // SCAN_ID_BACK STEP - Validate MRZ + barcode on back of ID card
@@ -0,0 +1,112 @@
1
+ # MRZ Reading & Correction — Known Issues & Limitations
2
+
3
+ This documents the **inherent limitations** and **deliberate trade-offs** of the
4
+ MRZ camera scanner (`mrz.utils.ts`, `mrzFrameAggregator.ts`, and the native
5
+ `TrustchexCameraView` scan-frame OCR). These are not bugs — they are constraints
6
+ of the ICAO 9303 standard and OCR, and the design decisions made around them.
7
+
8
+ For the camera/OCR pipeline overview, see the doc comments in
9
+ `mrzFrameAggregator.ts` and `mrz.utils.ts`.
10
+
11
+ ---
12
+
13
+ ## 1. Check-digit MOD-10 collisions: `1↔L` and `6↔G` are indistinguishable
14
+
15
+ **Issue.** ICAO's 7-3-1 check digit weights letters by value (`A`=10 … `Z`=35),
16
+ then takes the result mod 10. Some OCR look-alikes are **mod-10 equal** to the
17
+ digit they resemble:
18
+
19
+ | Look-alike | Letter value | Digit | mod 10 |
20
+ | --- | --- | --- | --- |
21
+ | `L` ↔ `1` | 21 | 1 | both ≡ **1** |
22
+ | `G` ↔ `6` | 16 | 6 | both ≡ **6** |
23
+
24
+ So a document number read as `SPECL2345` produces the **exact same check digit**
25
+ as the correct `SPEC12345`. The MRZ **validates either way** — the check digit
26
+ cannot tell them apart.
27
+
28
+ **Impact.** If OCR reads a `1` as `L` (or `6` as `G`) in a *numeric* position of a
29
+ field that also accepts letters (e.g. an alphanumeric document number), the MRZ
30
+ will validate but may carry the letter form.
31
+
32
+ **Mitigation.**
33
+
34
+ - **Multi-frame voting** (`mrzFrameAggregator`) usually resolves it: across frames
35
+ the correct glyph wins the per-character vote.
36
+ - **NFC chip read (eID)** is authoritative — when available it overrides the
37
+ camera reading.
38
+ - Other digit↔letter confusions (`0↔O/Q/D`, `2↔Z`, `5↔S`, `8↔B`, `4↔A`, `1↔I`) are
39
+ **not** mod-10 collisions and *are* recovered to the correct digit.
40
+
41
+ Covered by `mrz.ambiguous.test.ts` ("documents the … MOD-10 collision").
42
+
43
+ ---
44
+
45
+ ## 2. Unprotected fields (TD1 optional / national-ID) can't be self-corrected
46
+
47
+ **Issue.** TD1's optional-data field (which on Turkish IDs holds the 11-digit
48
+ national ID / TC number) has **no per-field check digit** — it is only covered by
49
+ the composite check digit. If OCR corrupts the optional field **and** the
50
+ composite digit is also dropped/misread (common on glossy cards), there is no
51
+ independent anchor to detect the error.
52
+
53
+ **Mitigation.** High-confidence numeric normalisation (`B→8`, `S→5`, `O→0`, …) is
54
+ applied to the all-digit optional field, and **multi-frame voting** is the
55
+ primary safeguard. A single noisy frame is not trusted; the consensus is.
56
+
57
+ ---
58
+
59
+ ## 3. Filler-letter normalisation is conservative in name fields (by design)
60
+
61
+ **Issue.** The `<` filler is frequently OCR'd as a letter (`K`, and under glare
62
+ `C`/`E`/`G`). We normalise these back to `<` **only** in structurally
63
+ unambiguous positions — the trailing filler run, the TD1 issuing-state field, and
64
+ the document-code filler.
65
+
66
+ **Deliberate trade-off.** A `K`/`C`/`E`/`G` **inside the name field** is *not*
67
+ normalised, because it cannot be distinguished from a real name letter (surnames
68
+ like `AKTAS`, `KAYA`, `CETIN`, or a 2-letter `AK` start with these). Corrupting a
69
+ real name is worse than a cosmetic filler glyph, so:
70
+
71
+ - Names are **preserved verbatim** — `AKTAS` stays `AKTAS`.
72
+ - A genuine filler misread *in the middle of a name region* may remain as a
73
+ letter (cosmetic only; the name field has no check digit, so validity is
74
+ unaffected, and the document number / dates — the identity-critical fields —
75
+ are always cleaned).
76
+
77
+ Covered by the "real letters in names are not eaten" tests in `mrz.utils.test.ts`.
78
+
79
+ ---
80
+
81
+ ## 4. OCR throughput / image-quality dependence
82
+
83
+ - **Resolution.** ML Kit needs ~16 px per character. A full-frame capture leaves a
84
+ 30–44-char MRZ line below that floor, so the native pipeline **crops to the
85
+ on-screen scan frame and upscales + grayscale/contrast-enhances** before OCR.
86
+ The MRZ band must be reasonably filled within the on-screen guide.
87
+ - **Glare / lamination.** Heavy glare on a glossy laminated card still degrades
88
+ OCR; the enhancement helps but does not eliminate it. Steadying the card and
89
+ reducing direct glare improves convergence.
90
+ - **Throughput.** OCR runs at a few frames/sec; the JS handler is throttled
91
+ (~400 ms) and the recovery search is time-bounded so the UI stays responsive.
92
+ A pathological/garbage frame fails fast (recovery budget) rather than blocking.
93
+
94
+ ---
95
+
96
+ ## 5. Structurally broken frames are (correctly) rejected
97
+
98
+ Truncated or wrong-length line-sets that cannot be repaired by ambiguous-char
99
+ swaps or ±1 indel are reported **invalid** — the flow will not advance on a
100
+ guess. This is intentional: a wrong-but-plausible reconstruction must never leak
101
+ through. Multi-frame scanning accumulates until a check-digit-valid consensus is
102
+ reached.
103
+
104
+ ---
105
+
106
+ ## 6. Platform parity
107
+
108
+ The scan-frame ROI crop + grayscale/contrast enhancement exists on **both Android
109
+ (Kotlin) and iOS (Swift / Core Image)**. The JS recovery, voting, and field
110
+ mapping are shared, so behaviour is consistent across platforms. Debug builds
111
+ require Metro to be reachable; build a **Release** variant for untethered device
112
+ testing.