@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.
- package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +57 -3
- package/ios/Camera/TrustchexCameraView.swift +20 -15
- package/lib/module/Screens/Dynamic/IdentityDocumentEIDScanningScreen.js +0 -3
- package/lib/module/Screens/Dynamic/IdentityDocumentScanningScreen.js +21 -0
- package/lib/module/Screens/Dynamic/VideoCallScreen.js +1 -1
- package/lib/module/Screens/Static/ResultScreen.js +75 -16
- package/lib/module/Shared/Components/DiagnosticReportButton.js +64 -0
- package/lib/module/Shared/Components/EIDScanner.js +52 -19
- package/lib/module/Shared/Components/IdentityDocumentCamera.js +132 -8
- package/lib/module/Shared/Components/NavigationManager.js +7 -9
- package/lib/module/Shared/Contexts/AppContext.js +3 -0
- package/lib/module/Shared/EIDReader/eidReader.js +167 -30
- package/lib/module/Shared/EIDReader/lds/icao/dg11File.js +77 -0
- package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +7 -0
- package/lib/module/Shared/Libs/diagnosticReport.js +133 -0
- package/lib/module/Shared/Libs/diagnostics.js +171 -0
- package/lib/module/Shared/Libs/documentDataNormalizer.js +104 -0
- package/lib/module/Shared/Libs/mrz.utils.js +0 -5
- package/lib/module/Shared/Libs/mrzFrameAggregator.js +32 -5
- package/lib/module/Shared/Libs/mrzTransliteration.js +80 -0
- package/lib/module/Shared/Libs/native-device-info.utils.js +67 -0
- package/lib/module/Shared/Libs/sendDiagnosticReport.js +130 -0
- package/lib/module/Shared/Services/DataUploadService.js +6 -3
- package/lib/module/Shared/Types/documentData.js +1 -0
- package/lib/module/Shared/Types/documentReadResult.js +3 -0
- package/lib/module/Translation/Resources/en.js +5 -0
- package/lib/module/Translation/Resources/tr.js +5 -0
- package/lib/module/Trustchex.js +31 -4
- package/lib/module/version.js +1 -1
- package/lib/typescript/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.d.ts.map +1 -1
- package/lib/typescript/src/Screens/Dynamic/IdentityDocumentScanningScreen.d.ts.map +1 -1
- package/lib/typescript/src/Screens/Dynamic/VideoCallScreen.d.ts.map +1 -1
- package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts +20 -0
- package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts +10 -0
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/NavigationManager.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts +10 -0
- package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Contexts/AppContext.d.ts +5 -0
- package/lib/typescript/src/Shared/Contexts/AppContext.d.ts.map +1 -1
- package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts +2 -0
- package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
- package/lib/typescript/src/Shared/EIDReader/lds/icao/dg11File.d.ts +35 -0
- package/lib/typescript/src/Shared/EIDReader/lds/icao/dg11File.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts +48 -0
- package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Libs/diagnostics.d.ts +145 -0
- package/lib/typescript/src/Shared/Libs/diagnostics.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Libs/documentDataNormalizer.d.ts +12 -0
- package/lib/typescript/src/Shared/Libs/documentDataNormalizer.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +33 -0
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrzTransliteration.d.ts +3 -0
- package/lib/typescript/src/Shared/Libs/mrzTransliteration.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts +33 -0
- package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts +30 -0
- package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Services/DataUploadService.d.ts +3 -2
- package/lib/typescript/src/Shared/Services/DataUploadService.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Types/documentData.d.ts +44 -0
- package/lib/typescript/src/Shared/Types/documentData.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Types/documentReadResult.d.ts +44 -0
- package/lib/typescript/src/Shared/Types/documentReadResult.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Types/mrzFields.d.ts +1 -1
- package/lib/typescript/src/Shared/Types/mrzFields.d.ts.map +1 -1
- package/lib/typescript/src/Translation/Resources/en.d.ts +5 -0
- package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
- package/lib/typescript/src/Translation/Resources/tr.d.ts +5 -0
- package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
- package/lib/typescript/src/Trustchex.d.ts +9 -0
- package/lib/typescript/src/Trustchex.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/version.d.ts +1 -1
- package/package.json +5 -1
- package/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.tsx +0 -4
- package/src/Screens/Dynamic/IdentityDocumentScanningScreen.tsx +23 -0
- package/src/Screens/Dynamic/VideoCallScreen.tsx +5 -1
- package/src/Screens/Static/ResultScreen.tsx +85 -16
- package/src/Shared/Components/DiagnosticReportButton.tsx +77 -0
- package/src/Shared/Components/EIDScanner.tsx +67 -18
- package/src/Shared/Components/IdentityDocumentCamera.tsx +145 -7
- package/src/Shared/Components/IdentityDocumentCamera.types.ts +6 -0
- package/src/Shared/Components/NavigationManager.tsx +7 -7
- package/src/Shared/Components/TrustchexCamera.tsx +6 -0
- package/src/Shared/Contexts/AppContext.ts +8 -0
- package/src/Shared/EIDReader/eidReader.ts +193 -47
- package/src/Shared/EIDReader/lds/icao/dg11File.ts +94 -0
- package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +7 -0
- package/src/Shared/Libs/diagnosticReport.ts +206 -0
- package/src/Shared/Libs/diagnostics.ts +251 -0
- package/src/Shared/Libs/documentDataNormalizer.ts +108 -0
- package/src/Shared/Libs/mrz.utils.ts +0 -5
- package/src/Shared/Libs/mrzFrameAggregator.ts +61 -3
- package/src/Shared/Libs/mrzTransliteration.ts +131 -0
- package/src/Shared/Libs/native-device-info.utils.ts +116 -0
- package/src/Shared/Libs/sendDiagnosticReport.ts +165 -0
- package/src/Shared/Services/DataUploadService.ts +9 -3
- package/src/Shared/Types/documentData.ts +52 -0
- package/src/Shared/Types/documentReadResult.ts +47 -0
- package/src/Shared/Types/mrzFields.ts +0 -5
- package/src/Translation/Resources/en.ts +6 -0
- package/src/Translation/Resources/tr.ts +6 -0
- package/src/Trustchex.tsx +48 -3
- package/src/index.tsx +7 -0
- 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
|
|
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
|
-
|
|
374
|
+
const margin = weights[0] - weights[1];
|
|
375
|
+
if (margin < min) min = margin;
|
|
323
376
|
}
|
|
324
377
|
}
|
|
325
|
-
return
|
|
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
|
+
}
|