@trustchex/react-native-sdk 1.478.7 → 1.483.4

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 (112) hide show
  1. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +57 -3
  2. package/ios/Camera/TrustchexCameraView.swift +20 -15
  3. package/lib/module/Screens/Dynamic/IdentityDocumentEIDScanningScreen.js +0 -3
  4. package/lib/module/Screens/Dynamic/IdentityDocumentScanningScreen.js +21 -0
  5. package/lib/module/Screens/Dynamic/VideoCallScreen.js +1 -1
  6. package/lib/module/Screens/Static/ResultScreen.js +75 -16
  7. package/lib/module/Shared/Components/DiagnosticReportButton.js +64 -0
  8. package/lib/module/Shared/Components/EIDScanner.js +52 -19
  9. package/lib/module/Shared/Components/IdentityDocumentCamera.js +132 -8
  10. package/lib/module/Shared/Components/NavigationManager.js +7 -9
  11. package/lib/module/Shared/Contexts/AppContext.js +3 -0
  12. package/lib/module/Shared/EIDReader/eidReader.js +167 -30
  13. package/lib/module/Shared/EIDReader/lds/icao/dg11File.js +77 -0
  14. package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +7 -0
  15. package/lib/module/Shared/Libs/diagnosticReport.js +133 -0
  16. package/lib/module/Shared/Libs/diagnostics.js +171 -0
  17. package/lib/module/Shared/Libs/documentDataNormalizer.js +104 -0
  18. package/lib/module/Shared/Libs/mrz.utils.js +0 -5
  19. package/lib/module/Shared/Libs/mrzFrameAggregator.js +32 -5
  20. package/lib/module/Shared/Libs/mrzTransliteration.js +80 -0
  21. package/lib/module/Shared/Libs/native-device-info.utils.js +67 -0
  22. package/lib/module/Shared/Libs/sendDiagnosticReport.js +130 -0
  23. package/lib/module/Shared/Services/DataUploadService.js +6 -3
  24. package/lib/module/Shared/Types/documentData.js +1 -0
  25. package/lib/module/Shared/Types/documentReadResult.js +3 -0
  26. package/lib/module/Translation/Resources/en.js +5 -0
  27. package/lib/module/Translation/Resources/tr.js +5 -0
  28. package/lib/module/Trustchex.js +31 -4
  29. package/lib/module/version.js +1 -1
  30. package/lib/typescript/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.d.ts.map +1 -1
  31. package/lib/typescript/src/Screens/Dynamic/IdentityDocumentScanningScreen.d.ts.map +1 -1
  32. package/lib/typescript/src/Screens/Dynamic/VideoCallScreen.d.ts.map +1 -1
  33. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  34. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts +20 -0
  35. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts.map +1 -0
  36. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
  37. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  38. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts +10 -0
  39. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts.map +1 -1
  40. package/lib/typescript/src/Shared/Components/NavigationManager.d.ts.map +1 -1
  41. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts +10 -0
  42. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts.map +1 -1
  43. package/lib/typescript/src/Shared/Contexts/AppContext.d.ts +5 -0
  44. package/lib/typescript/src/Shared/Contexts/AppContext.d.ts.map +1 -1
  45. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts +2 -0
  46. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
  47. package/lib/typescript/src/Shared/EIDReader/lds/icao/dg11File.d.ts +35 -0
  48. package/lib/typescript/src/Shared/EIDReader/lds/icao/dg11File.d.ts.map +1 -0
  49. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts +48 -0
  50. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts.map +1 -0
  51. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts +145 -0
  52. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts.map +1 -0
  53. package/lib/typescript/src/Shared/Libs/documentDataNormalizer.d.ts +12 -0
  54. package/lib/typescript/src/Shared/Libs/documentDataNormalizer.d.ts.map +1 -0
  55. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  56. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +33 -0
  57. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
  58. package/lib/typescript/src/Shared/Libs/mrzTransliteration.d.ts +3 -0
  59. package/lib/typescript/src/Shared/Libs/mrzTransliteration.d.ts.map +1 -0
  60. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts +33 -0
  61. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts.map +1 -1
  62. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts +30 -0
  63. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts.map +1 -0
  64. package/lib/typescript/src/Shared/Services/DataUploadService.d.ts +3 -2
  65. package/lib/typescript/src/Shared/Services/DataUploadService.d.ts.map +1 -1
  66. package/lib/typescript/src/Shared/Types/documentData.d.ts +44 -0
  67. package/lib/typescript/src/Shared/Types/documentData.d.ts.map +1 -0
  68. package/lib/typescript/src/Shared/Types/documentReadResult.d.ts +44 -0
  69. package/lib/typescript/src/Shared/Types/documentReadResult.d.ts.map +1 -0
  70. package/lib/typescript/src/Shared/Types/mrzFields.d.ts +1 -1
  71. package/lib/typescript/src/Shared/Types/mrzFields.d.ts.map +1 -1
  72. package/lib/typescript/src/Translation/Resources/en.d.ts +5 -0
  73. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  74. package/lib/typescript/src/Translation/Resources/tr.d.ts +5 -0
  75. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  76. package/lib/typescript/src/Trustchex.d.ts +9 -0
  77. package/lib/typescript/src/Trustchex.d.ts.map +1 -1
  78. package/lib/typescript/src/index.d.ts +2 -0
  79. package/lib/typescript/src/index.d.ts.map +1 -1
  80. package/lib/typescript/src/version.d.ts +1 -1
  81. package/package.json +5 -1
  82. package/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.tsx +0 -4
  83. package/src/Screens/Dynamic/IdentityDocumentScanningScreen.tsx +23 -0
  84. package/src/Screens/Dynamic/VideoCallScreen.tsx +5 -1
  85. package/src/Screens/Static/ResultScreen.tsx +85 -16
  86. package/src/Shared/Components/DiagnosticReportButton.tsx +77 -0
  87. package/src/Shared/Components/EIDScanner.tsx +67 -18
  88. package/src/Shared/Components/IdentityDocumentCamera.tsx +145 -7
  89. package/src/Shared/Components/IdentityDocumentCamera.types.ts +6 -0
  90. package/src/Shared/Components/NavigationManager.tsx +7 -7
  91. package/src/Shared/Components/TrustchexCamera.tsx +6 -0
  92. package/src/Shared/Contexts/AppContext.ts +8 -0
  93. package/src/Shared/EIDReader/eidReader.ts +193 -47
  94. package/src/Shared/EIDReader/lds/icao/dg11File.ts +94 -0
  95. package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +7 -0
  96. package/src/Shared/Libs/diagnosticReport.ts +206 -0
  97. package/src/Shared/Libs/diagnostics.ts +251 -0
  98. package/src/Shared/Libs/documentDataNormalizer.ts +108 -0
  99. package/src/Shared/Libs/mrz.utils.ts +0 -5
  100. package/src/Shared/Libs/mrzFrameAggregator.ts +61 -3
  101. package/src/Shared/Libs/mrzTransliteration.ts +131 -0
  102. package/src/Shared/Libs/native-device-info.utils.ts +116 -0
  103. package/src/Shared/Libs/sendDiagnosticReport.ts +165 -0
  104. package/src/Shared/Services/DataUploadService.ts +9 -3
  105. package/src/Shared/Types/documentData.ts +52 -0
  106. package/src/Shared/Types/documentReadResult.ts +47 -0
  107. package/src/Shared/Types/mrzFields.ts +0 -5
  108. package/src/Translation/Resources/en.ts +6 -0
  109. package/src/Translation/Resources/tr.ts +6 -0
  110. package/src/Trustchex.tsx +48 -3
  111. package/src/index.tsx +7 -0
  112. package/src/version.ts +1 -1
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Diagnostics collector for the "Report a scanning issue" feature.
3
+ *
4
+ * Accumulates a PII-light technical snapshot of a single scan session — camera
5
+ * quality, MRZ consensus, and (the highest-value, most device-specific part) a
6
+ * structured per-step NFC/eID read log. The result screen serialises this into
7
+ * the diagnostic email the user reviews and sends to support.
8
+ *
9
+ * Design notes:
10
+ * - This is a MODULE-LEVEL SINGLETON so the deep camera/EID call stack can record
11
+ * into it without threading a prop/context through every layer. Call `reset()`
12
+ * at the start of a scan flow.
13
+ * - It stores only TECHNICAL signals (counts, timings, brightness, APDU status
14
+ * words, pass/fail). It deliberately holds NO identity data, images, MRZ text,
15
+ * keys, or nonces — the scanned identity/images shown to the user come from the
16
+ * result screen's own state, not from here, and are attached separately with
17
+ * the user's explicit review in the email draft.
18
+ */
19
+
20
+ /** A single step of the NFC/eID read, with its outcome and timing. */
21
+ export interface NfcStep {
22
+ /** Step name: 'open' | 'selectMF' | 'readCardAccess' | 'paceAuth' | 'bacAuth'
23
+ * | 'selectApplet' | 'readDG1' | 'readDG2' | … */
24
+ step: string;
25
+ ok: boolean;
26
+ ms: number;
27
+ /** APDU status word (hex, e.g. "6982") when the step failed at the APDU level. */
28
+ sw?: string;
29
+ /** Coarse, non-PII error CATEGORY when the step failed (e.g. "auth_failed",
30
+ * "tag_lost", "reading_error") — never the raw error text. */
31
+ error?: string;
32
+ }
33
+
34
+ export interface NfcDiagnostics {
35
+ attempted: boolean;
36
+ chipDetected: boolean;
37
+ /** 'PACE' | 'BAC' | 'none' */
38
+ authProtocol: 'PACE' | 'BAC' | 'none';
39
+ paceFellBackToBac: boolean;
40
+ /** PACE object identifier string when PACE was attempted. */
41
+ paceOid?: string;
42
+ /** Why PACE wasn't used / fell back: 'no_card_access' | 'no_pace_info' |
43
+ * 'pace_failed'. Distinguishes device/chip-specific PACE issues. */
44
+ paceFallbackReason?: string;
45
+ steps: NfcStep[];
46
+ /** Name of the first failing step, if any. */
47
+ failureStep?: string;
48
+ /** Coarse category: 'auth_failed' | 'tag_lost' | 'timeout' | 'unsupported'
49
+ * | 'not_enabled' | 'user_cancelled' | 'reading_error' | null */
50
+ errorCategory?: string;
51
+ retryCount: number;
52
+ succeeded: boolean;
53
+ }
54
+
55
+ export interface CameraDiagnostics {
56
+ frames: number;
57
+ frameW: number;
58
+ frameH: number;
59
+ avgBrightness: number;
60
+ minBrightness: number;
61
+ maxBrightness: number;
62
+ hadLowBrightness: boolean;
63
+ hadBlurryFrames: boolean;
64
+ /**
65
+ * The thresholds the measured values were judged against, so support can read
66
+ * "117 measured vs 65 floor" rather than just a pass/fail flag. Recorded once.
67
+ */
68
+ thresholds?: {
69
+ brightnessLow?: number;
70
+ brightnessHigh?: number;
71
+ minBrightness?: number;
72
+ blur?: number;
73
+ };
74
+ }
75
+
76
+ export interface MrzDiagnostics {
77
+ framesProcessed: number;
78
+ reachedStable: boolean;
79
+ stabilityFrames: number;
80
+ /** Smallest winning vote margin across contested cells at acceptance. */
81
+ minContestedMargin: number;
82
+ /**
83
+ * Largest MRZ-band pixel height seen (max OCR'd text-block height). Compared
84
+ * to the ~16px/character ML Kit floor, this explains "scans on device A,
85
+ * fails on device B" — a smaller band falls below the OCR resolution floor.
86
+ */
87
+ maxMrzBandHeightPx?: number;
88
+ format?: string;
89
+ /** Per-field check-digit outcomes: 'pass' | 'fail' (no values, no PII). */
90
+ checkDigits?: Record<string, 'pass' | 'fail'>;
91
+ /** True when the Turkish leading-serial-letter check-digit convention was used
92
+ * to accept the document number (set at report-build time from the final MRZ). */
93
+ turkishLetterPrefixApplied: boolean;
94
+ }
95
+
96
+ export interface DiagnosticsSnapshot {
97
+ schemaVersion: number;
98
+ generatedAt: string;
99
+ documentType?: string;
100
+ dataSource?: string;
101
+ totalDurationMs: number;
102
+ camera: CameraDiagnostics;
103
+ mrz: MrzDiagnostics;
104
+ nfc: NfcDiagnostics;
105
+ /** Sanitised, non-PII error entries collected during the session. */
106
+ errors: Array<{ code?: string; category?: string; screen?: string }>;
107
+ }
108
+
109
+ const emptyCamera = (): CameraDiagnostics => ({
110
+ frames: 0,
111
+ frameW: 0,
112
+ frameH: 0,
113
+ avgBrightness: 0,
114
+ minBrightness: 0,
115
+ maxBrightness: 0,
116
+ hadLowBrightness: false,
117
+ hadBlurryFrames: false,
118
+ });
119
+
120
+ const emptyMrz = (): MrzDiagnostics => ({
121
+ framesProcessed: 0,
122
+ reachedStable: false,
123
+ stabilityFrames: 0,
124
+ minContestedMargin: 0,
125
+ turkishLetterPrefixApplied: false,
126
+ });
127
+
128
+ const emptyNfc = (): NfcDiagnostics => ({
129
+ attempted: false,
130
+ chipDetected: false,
131
+ authProtocol: 'none',
132
+ paceFellBackToBac: false,
133
+ steps: [],
134
+ retryCount: 0,
135
+ succeeded: false,
136
+ });
137
+
138
+ /**
139
+ * Accumulates one scan session's diagnostics. A single shared instance is
140
+ * exported below; tests can construct their own.
141
+ */
142
+ export class DiagnosticsCollector {
143
+ private startedAtMs = 0;
144
+ private documentType?: string;
145
+ private dataSource?: string;
146
+ private camera = emptyCamera();
147
+ private mrz = emptyMrz();
148
+ private nfc = emptyNfc();
149
+ private errors: DiagnosticsSnapshot['errors'] = [];
150
+
151
+ /** Begin a fresh session — call when a scan flow starts. */
152
+ reset(nowMs: number): void {
153
+ this.startedAtMs = nowMs;
154
+ this.documentType = undefined;
155
+ this.dataSource = undefined;
156
+ this.camera = emptyCamera();
157
+ this.mrz = emptyMrz();
158
+ this.nfc = emptyNfc();
159
+ this.errors = [];
160
+ }
161
+
162
+ setDocument(documentType?: string, dataSource?: string): void {
163
+ if (documentType) this.documentType = documentType;
164
+ if (dataSource) this.dataSource = dataSource;
165
+ }
166
+
167
+ /** Merge a camera snapshot (called periodically as frames arrive). */
168
+ setCamera(c: Partial<CameraDiagnostics>): void {
169
+ this.camera = { ...this.camera, ...c };
170
+ }
171
+
172
+ /** Merge MRZ consensus metrics. `reachedStable` is STICKY (once true, stays
173
+ * true) so a later frame's flicker can't unset that the scan did stabilise;
174
+ * `maxMrzBandHeightPx` keeps the largest seen. */
175
+ setMrz(m: Partial<MrzDiagnostics>): void {
176
+ const reachedStable = this.mrz.reachedStable || m.reachedStable === true;
177
+ const maxMrzBandHeightPx =
178
+ Math.max(
179
+ this.mrz.maxMrzBandHeightPx ?? 0,
180
+ m.maxMrzBandHeightPx ?? 0
181
+ ) || undefined;
182
+ this.mrz = { ...this.mrz, ...m, reachedStable, maxMrzBandHeightPx };
183
+ }
184
+
185
+ // ---- NFC: the structured per-step log ----
186
+
187
+ nfcAttemptStarted(): void {
188
+ // A fresh attempt: bump retry count if a previous attempt left steps.
189
+ if (this.nfc.attempted) this.nfc.retryCount += 1;
190
+ this.nfc.attempted = true;
191
+ }
192
+
193
+ nfcChipDetected(): void {
194
+ this.nfc.chipDetected = true;
195
+ }
196
+
197
+ nfcSetAuth(protocol: 'PACE' | 'BAC' | 'none', oid?: string): void {
198
+ this.nfc.authProtocol = protocol;
199
+ if (oid) this.nfc.paceOid = oid;
200
+ }
201
+
202
+ nfcPaceFellBackToBac(reason?: string): void {
203
+ this.nfc.paceFellBackToBac = true;
204
+ if (reason && !this.nfc.paceFallbackReason) {
205
+ this.nfc.paceFallbackReason = reason;
206
+ }
207
+ }
208
+
209
+ /** Record one completed step. Keep messages short and free of PII. */
210
+ nfcStep(step: NfcStep): void {
211
+ this.nfc.steps.push(step);
212
+ if (!step.ok && !this.nfc.failureStep) {
213
+ this.nfc.failureStep = step.step;
214
+ }
215
+ }
216
+
217
+ nfcResult(succeeded: boolean, errorCategory?: string): void {
218
+ this.nfc.succeeded = succeeded;
219
+ if (succeeded) {
220
+ // The read ultimately succeeded — transient per-attempt failures (e.g. a
221
+ // recovered DG2 retry, or the expected EF.CardAccess 6982 on a BAC-only
222
+ // card) must NOT be reported as the outcome. Clear the failure markers.
223
+ this.nfc.failureStep = undefined;
224
+ this.nfc.errorCategory = undefined;
225
+ } else if (errorCategory) {
226
+ this.nfc.errorCategory = errorCategory;
227
+ }
228
+ }
229
+
230
+ addError(e: { code?: string; category?: string; screen?: string }): void {
231
+ this.errors.push(e);
232
+ }
233
+
234
+ /** Build the serialisable snapshot. */
235
+ snapshot(nowMs: number, isoNow: string): DiagnosticsSnapshot {
236
+ return {
237
+ schemaVersion: 1,
238
+ generatedAt: isoNow,
239
+ documentType: this.documentType,
240
+ dataSource: this.dataSource,
241
+ totalDurationMs: this.startedAtMs ? Math.max(0, nowMs - this.startedAtMs) : 0,
242
+ camera: { ...this.camera },
243
+ mrz: { ...this.mrz },
244
+ nfc: { ...this.nfc, steps: this.nfc.steps.map((s) => ({ ...s })) },
245
+ errors: this.errors.map((e) => ({ ...e })),
246
+ };
247
+ }
248
+ }
249
+
250
+ /** Shared session-scoped collector used across the scan flow. */
251
+ export const diagnostics = new DiagnosticsCollector();
@@ -0,0 +1,108 @@
1
+ import type { MRZFields } from '../Types/mrzFields';
2
+ import type { DocumentData } from '../Types/documentData';
3
+ import type { MRZInfo } from '../EIDReader/lds/icao/mrzInfo';
4
+
5
+ /**
6
+ * Expand a 2-digit MRZ year to a 4-digit calendar year.
7
+ * ICAO 9303: YY >= 30 → 19YY, YY < 30 → 20YY.
8
+ * (Threshold 30 keeps all current valid documents in range.)
9
+ */
10
+ function expandYear(yy: string): string {
11
+ const n = parseInt(yy, 10);
12
+ return n >= 30 ? `19${yy}` : `20${yy}`;
13
+ }
14
+
15
+ /**
16
+ * Convert a YYMMDD MRZ date string to ISO 8601 YYYY-MM-DD.
17
+ * Returns null for empty, null, or malformed input.
18
+ */
19
+ function mrzDateToISO(raw: string | null | undefined): string | null {
20
+ if (!raw || raw.length !== 6) return null;
21
+ const yy = raw.slice(0, 2);
22
+ const mm = raw.slice(2, 4);
23
+ const dd = raw.slice(4, 6);
24
+ if (!/^\d{6}$/.test(raw)) return null;
25
+ return `${expandYear(yy)}-${mm}-${dd}`;
26
+ }
27
+
28
+ /**
29
+ * Normalize the MRZ document-code field to a single-letter form.
30
+ * The `mrz` npm package returns "ID" for TD1, while the chip returns "I".
31
+ */
32
+ function normalizeDocumentType(raw: string | null | undefined): string {
33
+ if (!raw) return 'I';
34
+ const upper = raw.trim().toUpperCase();
35
+ if (upper === 'ID') return 'I';
36
+ // TD2 can be "IR", "AC", etc — take only the first character
37
+ return upper.charAt(0);
38
+ }
39
+
40
+ /**
41
+ * Normalize the sex field to a single uppercase letter.
42
+ * NFC chip: "M" | "F" | "U" | "X"
43
+ * mrz package: "male" | "female" | "" | null
44
+ */
45
+ function normalizeSex(raw: string | null | undefined): 'M' | 'F' | 'X' | 'U' {
46
+ if (!raw) return 'U';
47
+ switch (raw.trim().toLowerCase()) {
48
+ case 'm':
49
+ case 'male':
50
+ return 'M';
51
+ case 'f':
52
+ case 'female':
53
+ return 'F';
54
+ case 'x':
55
+ case 'unspecified':
56
+ return 'X';
57
+ default:
58
+ return 'U';
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Trim a string and return null if empty or only filler chars.
64
+ */
65
+ function clean(val: string | null | undefined): string | null {
66
+ if (!val) return null;
67
+ const t = val.replace(/</g, '').trim();
68
+ return t.length > 0 ? t : null;
69
+ }
70
+
71
+ /**
72
+ * Build a DocumentData from a chip-read MRZInfo object (NFC path).
73
+ */
74
+ export function normalizeFromMRZInfo(mrz: MRZInfo): DocumentData {
75
+ const rawPersonal =
76
+ mrz.getPersonalNumber() ?? clean(mrz.getOptionalData1());
77
+
78
+ return {
79
+ documentType: normalizeDocumentType(mrz.getDocumentCode()),
80
+ issuingCountry: mrz.getIssuingState().replace(/<$/, '').trim(),
81
+ nationality: mrz.getNationality().replace(/<$/, '').trim(),
82
+ documentNumber: mrz.getDocumentNumber().trim(),
83
+ personalNumber: clean(rawPersonal),
84
+ lastName: mrz.getPrimaryIdentifier().trim(),
85
+ firstName: mrz.getSecondaryIdentifier().trim(),
86
+ sex: normalizeSex(mrz.getGender().getStrCode()),
87
+ dateOfBirth: mrzDateToISO(mrz.getDateOfBirth()),
88
+ dateOfExpiry: mrzDateToISO(mrz.getDateOfExpiry()),
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Build a DocumentData from an MRZFields object (camera OCR path).
94
+ */
95
+ export function normalizeFromMRZFields(fields: MRZFields): DocumentData {
96
+ return {
97
+ documentType: normalizeDocumentType(fields.documentCode),
98
+ issuingCountry: clean(fields.issuingState) ?? '',
99
+ nationality: clean(fields.nationality) ?? '',
100
+ documentNumber: clean(fields.documentNumber) ?? '',
101
+ personalNumber: clean(fields.personalNumber),
102
+ lastName: clean(fields.lastName) ?? '',
103
+ firstName: clean(fields.firstName) ?? '',
104
+ sex: normalizeSex(fields.sex),
105
+ dateOfBirth: mrzDateToISO(fields.birthDate),
106
+ dateOfExpiry: mrzDateToISO(fields.expirationDate),
107
+ };
108
+ }
@@ -1244,18 +1244,13 @@ const validateMRZ = (
1244
1244
  documentCode: result.fields.documentCode || null,
1245
1245
  issuingState: result.fields.issuingState || null,
1246
1246
  documentNumber: result.fields.documentNumber || null,
1247
- documentNumberCheckDigit: result.fields.documentNumberCheckDigit || null,
1248
1247
  nationality: result.fields.nationality || null,
1249
1248
  lastName: result.fields.lastName || null,
1250
1249
  firstName: result.fields.firstName || null,
1251
1250
  sex: result.fields.sex || null,
1252
1251
  birthDate: result.fields.birthDate || null,
1253
- birthDateCheckDigit: result.fields.birthDateCheckDigit || null,
1254
1252
  expirationDate: result.fields.expirationDate || null,
1255
- expirationDateCheckDigit: result.fields.expirationDateCheckDigit || null,
1256
1253
  personalNumber: result.fields.personalNumber || null,
1257
- personalNumberCheckDigit: result.fields.personalNumberCheckDigit || null,
1258
- compositeCheckDigit: result.fields.compositeCheckDigit || null,
1259
1254
  optional1: result.fields.optional1 || null,
1260
1255
  optional2: result.fields.optional2 || null,
1261
1256
  };
@@ -43,8 +43,29 @@ export interface MRZFrameInput {
43
43
  * focus-weighted voting beats unweighted.
44
44
  */
45
45
  weight?: number;
46
+ /**
47
+ * Optional OCR confidence for this frame, in [0, 1] — e.g. ML Kit's mean
48
+ * per-symbol confidence over the MRZ characters (Android bundled library only;
49
+ * iOS ML Kit exposes no symbol confidence). When present it MULTIPLIES `weight`,
50
+ * so a frame the recognizer was unsure about contributes proportionally fewer
51
+ * votes than a confident one of the same sharpness. This is the per-frame form
52
+ * of confidence-weighted voting: it can't misalign cells (unlike threading
53
+ * per-column confidence through the column-shifting line reconstruction), yet
54
+ * still lets confident frames dominate look-alike ties (A↔4, S↔5, B↔8).
55
+ *
56
+ * A confidence floor is applied so a single near-zero frame is down-weighted
57
+ * but never fully silenced (it may still hold the only vote for some cell).
58
+ */
59
+ confidence?: number;
46
60
  }
47
61
 
62
+ /**
63
+ * Floor applied to a frame's confidence multiplier. A frame ML Kit was very
64
+ * unsure about still contributes (it may carry the only reading of a cell), but
65
+ * at a fraction of a confident frame's weight.
66
+ */
67
+ const MIN_CONFIDENCE_WEIGHT = 0.25;
68
+
48
69
  export interface MRZConsensus {
49
70
  /** Best consensus MRZ (newline-joined fixed-width lines), or null if none yet. */
50
71
  mrz: string | null;
@@ -57,6 +78,18 @@ export interface MRZConsensus {
57
78
  * last `stabilityTarget` contributing frames. Callers should advance only then.
58
79
  */
59
80
  stable: boolean;
81
+ /**
82
+ * How many consecutive contributing frames the current (valid) consensus has
83
+ * stayed unchanged. Diagnostic: convergence speed across devices.
84
+ */
85
+ stableStreak: number;
86
+ /**
87
+ * Smallest winning vote margin across all CONTESTED cells of the best format
88
+ * (cells where >1 distinct char was voted). Higher = more confident reading;
89
+ * a low value at acceptance means a look-alike was barely resolved. Infinity
90
+ * when no cell is contested (a perfectly clean read). Diagnostic only.
91
+ */
92
+ minContestedMargin: number;
60
93
  }
61
94
 
62
95
  export interface MRZFrameAggregatorOptions {
@@ -244,7 +277,15 @@ export class MRZFrameAggregator {
244
277
  * consensus is returned.
245
278
  */
246
279
  addFrame(input: MRZFrameInput): MRZConsensus {
247
- const weight = input.weight && input.weight > 0 ? input.weight : 1;
280
+ const baseWeight = input.weight && input.weight > 0 ? input.weight : 1;
281
+ // Fold OCR confidence into the weight (clamped to [floor, 1]). Absent
282
+ // confidence leaves the weight unchanged, so callers without per-symbol
283
+ // confidence (e.g. iOS ML Kit) keep the prior sharpness-only behaviour.
284
+ const confidenceFactor =
285
+ typeof input.confidence === 'number' && Number.isFinite(input.confidence)
286
+ ? Math.min(1, Math.max(MIN_CONFIDENCE_WEIGHT, input.confidence))
287
+ : 1;
288
+ const weight = baseWeight * confidenceFactor;
248
289
  const lines = linesForFrame(input.text);
249
290
 
250
291
  if (lines) {
@@ -311,18 +352,30 @@ export class MRZFrameAggregator {
311
352
  * is what turns "looks valid this frame" into "the camera agrees across frames".
312
353
  */
313
354
  private consensusIsConfident(format: string): boolean {
355
+ return this.minContestedMargin(format) >= this.minMargin;
356
+ }
357
+
358
+ /**
359
+ * Smallest winning vote margin across all CONTESTED cells (where >1 distinct
360
+ * char was voted). Returns Infinity when no cell is contested. Used both to
361
+ * gate confidence and to surface a diagnostic of how narrowly a look-alike was
362
+ * resolved at acceptance.
363
+ */
364
+ private minContestedMargin(format: string): number {
314
365
  const geom = FORMAT_GEOMETRY[format];
315
366
  const tally = this.tallies[format];
367
+ let min = Infinity;
316
368
  for (let li = 0; li < geom.lines; li++) {
317
369
  for (let ci = 0; ci < geom.width; ci++) {
318
370
  const cell = tally[li][ci];
319
371
  const weights = Object.values(cell);
320
372
  if (weights.length < 2) continue; // uncontested cell — no ambiguity
321
373
  weights.sort((a, b) => b - a);
322
- if (weights[0] - weights[1] < this.minMargin) return false;
374
+ const margin = weights[0] - weights[1];
375
+ if (margin < min) min = margin;
323
376
  }
324
377
  }
325
- return true;
378
+ return min;
326
379
  }
327
380
 
328
381
  /** Read out the winning character per cell for a format. */
@@ -365,6 +418,8 @@ export class MRZFrameAggregator {
365
418
  validation: null,
366
419
  frames: this.frameCount,
367
420
  stable: false,
421
+ stableStreak: 0,
422
+ minContestedMargin: Infinity,
368
423
  };
369
424
  }
370
425
 
@@ -415,6 +470,9 @@ export class MRZFrameAggregator {
415
470
  frames: this.frameCount,
416
471
  stable:
417
472
  bestValid && confident && this.stableStreak >= this.stabilityTarget,
473
+ stableStreak: this.stableStreak,
474
+ minContestedMargin:
475
+ bestFormat != null ? this.minContestedMargin(bestFormat) : Infinity,
418
476
  };
419
477
  }
420
478
  }
@@ -0,0 +1,131 @@
1
+ import type { DocumentName } from '../Types/documentReadResult';
2
+
3
+ type DigraphMap = [string, string][];
4
+
5
+ // ICAO 9303 digraphs safe to apply universally — these appear across many
6
+ // national variants and are unlikely to collide with real letter sequences.
7
+ // SS→ß, TH→Þ etc. are excluded: too ambiguous (HASSAN, THOMAS).
8
+ const UNIVERSAL: DigraphMap = [
9
+ ['OE', 'Ö'],
10
+ ['UE', 'Ü'],
11
+ ];
12
+
13
+ const GERMAN: DigraphMap = [
14
+ ['AE', 'Ä'],
15
+ ['OE', 'Ö'],
16
+ ['UE', 'Ü'],
17
+ ['SS', 'ß'],
18
+ ];
19
+
20
+ const SWEDISH: DigraphMap = [
21
+ ['AA', 'Å'],
22
+ ['AE', 'Æ'],
23
+ ['OE', 'Ø'],
24
+ ];
25
+
26
+ const DANISH: DigraphMap = [
27
+ ['AA', 'Å'],
28
+ ['AE', 'Æ'],
29
+ ['OE', 'Ø'],
30
+ ];
31
+
32
+ const NORWEGIAN: DigraphMap = [
33
+ ['AA', 'Å'],
34
+ ['AE', 'Æ'],
35
+ ['OE', 'Ø'],
36
+ ];
37
+
38
+ const FRENCH: DigraphMap = [
39
+ ['OE', 'Œ'],
40
+ ['AE', 'Æ'],
41
+ ];
42
+
43
+ const ICELANDIC: DigraphMap = [
44
+ ['AE', 'Æ'],
45
+ ['OE', 'Ö'],
46
+ ['TH', 'Þ'],
47
+ ['D', 'Ð'],
48
+ ];
49
+
50
+ const COUNTRY_MAP: Record<string, DigraphMap> = {
51
+ DEU: GERMAN,
52
+ AUT: GERMAN,
53
+ CHE: GERMAN,
54
+ SWE: SWEDISH,
55
+ DNK: DANISH,
56
+ NOR: NORWEGIAN,
57
+ FRA: FRENCH,
58
+ LUX: FRENCH,
59
+ ISL: ICELANDIC,
60
+ };
61
+
62
+ function applyDigraphMap(token: string, map: DigraphMap): string {
63
+ let result = '';
64
+ let i = 0;
65
+ while (i < token.length) {
66
+ let matched = false;
67
+ for (const [digraph, unicode] of map) {
68
+ if (token.startsWith(digraph, i)) {
69
+ result += unicode;
70
+ i += digraph.length;
71
+ matched = true;
72
+ break;
73
+ }
74
+ }
75
+ if (!matched) {
76
+ result += token[i];
77
+ i++;
78
+ }
79
+ }
80
+ return result;
81
+ }
82
+
83
+ function convertName(rawName: string, map: DigraphMap): string {
84
+ return rawName
85
+ .split(' ')
86
+ .map((word) => applyDigraphMap(word, map))
87
+ .join(' ');
88
+ }
89
+
90
+ export function buildDocumentName(
91
+ rawFirst: string,
92
+ rawLast: string,
93
+ issuingState: string | null | undefined,
94
+ dg11First?: string | null,
95
+ dg11Last?: string | null
96
+ ): DocumentName {
97
+ if (dg11First != null && dg11Last != null && (dg11First || dg11Last)) {
98
+ return {
99
+ rawFirst,
100
+ rawLast,
101
+ displayFirst: dg11First,
102
+ displayLast: dg11Last,
103
+ source: 'dg11',
104
+ };
105
+ }
106
+
107
+ const country = (issuingState ?? '').toUpperCase().trim();
108
+ const map = COUNTRY_MAP[country];
109
+
110
+ if (map) {
111
+ return {
112
+ rawFirst,
113
+ rawLast,
114
+ displayFirst: convertName(rawFirst, map),
115
+ displayLast: convertName(rawLast, map),
116
+ source: 'reverse_table',
117
+ };
118
+ }
119
+
120
+ // No country-specific table: apply OE→Ö and UE→Ü universally.
121
+ const displayFirst = convertName(rawFirst, UNIVERSAL);
122
+ const displayLast = convertName(rawLast, UNIVERSAL);
123
+ const changed = displayFirst !== rawFirst || displayLast !== rawLast;
124
+ return {
125
+ rawFirst,
126
+ rawLast,
127
+ displayFirst,
128
+ displayLast,
129
+ source: changed ? 'reverse_table' : 'raw',
130
+ };
131
+ }