@trustchex/react-native-sdk 1.475.1 → 1.481.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.
- package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +76 -24
- package/android/src/main/java/com/trustchex/reactnativesdk/mlkit/MLKitModule.kt +1 -0
- package/ios/Camera/TrustchexCameraView.swift +20 -15
- package/lib/module/Screens/Dynamic/LivenessDetectionScreen.js +3 -2
- package/lib/module/Screens/Static/QrCodeScanningScreen.js +12 -2
- package/lib/module/Screens/Static/ResultScreen.js +145 -113
- package/lib/module/Shared/Components/DebugOverlay.js +5 -2
- package/lib/module/Shared/Components/DiagnosticReportButton.js +64 -0
- package/lib/module/Shared/Components/IdentityDocumentCamera.js +185 -30
- package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +8 -1
- package/lib/module/Shared/Components/NavigationManager.js +7 -9
- package/lib/module/Shared/EIDReader/eidReader.js +159 -38
- package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +41 -0
- package/lib/module/Shared/Libs/SignalingClient.js +6 -5
- package/lib/module/Shared/Libs/crypto.utils.js +25 -3
- package/lib/module/Shared/Libs/deeplink.utils.js +37 -7
- package/lib/module/Shared/Libs/diagnosticReport.js +133 -0
- package/lib/module/Shared/Libs/diagnostics.js +171 -0
- package/lib/module/Shared/Libs/http-client.js +19 -6
- package/lib/module/Shared/Libs/mrz.utils.js +193 -33
- package/lib/module/Shared/Libs/mrzFrameAggregator.js +74 -3
- package/lib/module/Shared/Libs/native-device-info.utils.js +67 -0
- package/lib/module/Shared/Libs/promise.utils.js +4 -3
- package/lib/module/Shared/Libs/sendDiagnosticReport.js +130 -0
- package/lib/module/Shared/Libs/shuffle.utils.js +17 -0
- package/lib/module/Shared/Services/AnalyticsService.js +8 -2
- package/lib/module/Shared/Services/DataUploadService.js +114 -127
- package/lib/module/Shared/Services/VideoSessionService.js +4 -3
- package/lib/module/Translation/Resources/en.js +6 -0
- package/lib/module/Translation/Resources/tr.js +6 -0
- package/lib/module/Trustchex.js +20 -4
- package/lib/module/version.js +1 -1
- package/lib/typescript/src/Screens/Dynamic/LivenessDetectionScreen.d.ts.map +1 -1
- package/lib/typescript/src/Screens/Static/QrCodeScanningScreen.d.ts.map +1 -1
- package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/DebugOverlay.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/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/IdentityDocumentCamera.utils.d.ts +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.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/EIDReader/eidReader.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/SignalingClient.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/crypto.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts +1 -0
- package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts.map +1 -1
- 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/http-client.d.ts +2 -0
- package/lib/typescript/src/Shared/Libs/http-client.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +55 -0
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
- 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/promise.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/Libs/shuffle.utils.d.ts +3 -0
- package/lib/typescript/src/Shared/Libs/shuffle.utils.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Services/AnalyticsService.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Services/DataUploadService.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Services/VideoSessionService.d.ts.map +1 -1
- package/lib/typescript/src/Translation/Resources/en.d.ts +6 -0
- package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
- package/lib/typescript/src/Translation/Resources/tr.d.ts +6 -0
- package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
- package/lib/typescript/src/Trustchex.d.ts.map +1 -1
- package/lib/typescript/src/version.d.ts +1 -1
- package/package.json +17 -3
- package/src/Screens/Dynamic/LivenessDetectionScreen.tsx +4 -3
- package/src/Screens/Static/QrCodeScanningScreen.tsx +19 -2
- package/src/Screens/Static/ResultScreen.tsx +172 -162
- package/src/Shared/Components/DebugOverlay.tsx +5 -2
- package/src/Shared/Components/DiagnosticReportButton.tsx +77 -0
- package/src/Shared/Components/IdentityDocumentCamera.tsx +200 -29
- package/src/Shared/Components/IdentityDocumentCamera.types.ts +6 -0
- package/src/Shared/Components/IdentityDocumentCamera.utils.ts +9 -1
- package/src/Shared/Components/NavigationManager.tsx +7 -7
- package/src/Shared/Components/TrustchexCamera.tsx +6 -0
- package/src/Shared/EIDReader/eidReader.ts +181 -52
- package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +41 -0
- package/src/Shared/Libs/SignalingClient.ts +10 -5
- package/src/Shared/Libs/crypto.utils.ts +41 -4
- package/src/Shared/Libs/deeplink.utils.ts +39 -7
- package/src/Shared/Libs/diagnosticReport.ts +206 -0
- package/src/Shared/Libs/diagnostics.ts +251 -0
- package/src/Shared/Libs/http-client.ts +24 -8
- package/src/Shared/Libs/mrz.utils.ts +200 -34
- package/src/Shared/Libs/mrzFrameAggregator.ts +112 -2
- package/src/Shared/Libs/native-device-info.utils.ts +116 -0
- package/src/Shared/Libs/promise.utils.ts +11 -5
- package/src/Shared/Libs/sendDiagnosticReport.ts +165 -0
- package/src/Shared/Libs/shuffle.utils.ts +15 -0
- package/src/Shared/Services/AnalyticsService.ts +17 -1
- package/src/Shared/Services/DataUploadService.ts +155 -166
- package/src/Shared/Services/VideoSessionService.ts +15 -3
- package/src/Translation/Resources/en.ts +7 -0
- package/src/Translation/Resources/tr.ts +7 -0
- package/src/Trustchex.tsx +26 -3
- package/src/version.ts +1 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Diagnostics collector for the "Report a scanning issue" feature.
|
|
5
|
+
*
|
|
6
|
+
* Accumulates a PII-light technical snapshot of a single scan session — camera
|
|
7
|
+
* quality, MRZ consensus, and (the highest-value, most device-specific part) a
|
|
8
|
+
* structured per-step NFC/eID read log. The result screen serialises this into
|
|
9
|
+
* the diagnostic email the user reviews and sends to support.
|
|
10
|
+
*
|
|
11
|
+
* Design notes:
|
|
12
|
+
* - This is a MODULE-LEVEL SINGLETON so the deep camera/EID call stack can record
|
|
13
|
+
* into it without threading a prop/context through every layer. Call `reset()`
|
|
14
|
+
* at the start of a scan flow.
|
|
15
|
+
* - It stores only TECHNICAL signals (counts, timings, brightness, APDU status
|
|
16
|
+
* words, pass/fail). It deliberately holds NO identity data, images, MRZ text,
|
|
17
|
+
* keys, or nonces — the scanned identity/images shown to the user come from the
|
|
18
|
+
* result screen's own state, not from here, and are attached separately with
|
|
19
|
+
* the user's explicit review in the email draft.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A single step of the NFC/eID read, with its outcome and timing. */
|
|
23
|
+
|
|
24
|
+
const emptyCamera = () => ({
|
|
25
|
+
frames: 0,
|
|
26
|
+
frameW: 0,
|
|
27
|
+
frameH: 0,
|
|
28
|
+
avgBrightness: 0,
|
|
29
|
+
minBrightness: 0,
|
|
30
|
+
maxBrightness: 0,
|
|
31
|
+
hadLowBrightness: false,
|
|
32
|
+
hadBlurryFrames: false
|
|
33
|
+
});
|
|
34
|
+
const emptyMrz = () => ({
|
|
35
|
+
framesProcessed: 0,
|
|
36
|
+
reachedStable: false,
|
|
37
|
+
stabilityFrames: 0,
|
|
38
|
+
minContestedMargin: 0,
|
|
39
|
+
turkishLetterPrefixApplied: false
|
|
40
|
+
});
|
|
41
|
+
const emptyNfc = () => ({
|
|
42
|
+
attempted: false,
|
|
43
|
+
chipDetected: false,
|
|
44
|
+
authProtocol: 'none',
|
|
45
|
+
paceFellBackToBac: false,
|
|
46
|
+
steps: [],
|
|
47
|
+
retryCount: 0,
|
|
48
|
+
succeeded: false
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Accumulates one scan session's diagnostics. A single shared instance is
|
|
53
|
+
* exported below; tests can construct their own.
|
|
54
|
+
*/
|
|
55
|
+
export class DiagnosticsCollector {
|
|
56
|
+
startedAtMs = 0;
|
|
57
|
+
camera = emptyCamera();
|
|
58
|
+
mrz = emptyMrz();
|
|
59
|
+
nfc = emptyNfc();
|
|
60
|
+
errors = [];
|
|
61
|
+
|
|
62
|
+
/** Begin a fresh session — call when a scan flow starts. */
|
|
63
|
+
reset(nowMs) {
|
|
64
|
+
this.startedAtMs = nowMs;
|
|
65
|
+
this.documentType = undefined;
|
|
66
|
+
this.dataSource = undefined;
|
|
67
|
+
this.camera = emptyCamera();
|
|
68
|
+
this.mrz = emptyMrz();
|
|
69
|
+
this.nfc = emptyNfc();
|
|
70
|
+
this.errors = [];
|
|
71
|
+
}
|
|
72
|
+
setDocument(documentType, dataSource) {
|
|
73
|
+
if (documentType) this.documentType = documentType;
|
|
74
|
+
if (dataSource) this.dataSource = dataSource;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Merge a camera snapshot (called periodically as frames arrive). */
|
|
78
|
+
setCamera(c) {
|
|
79
|
+
this.camera = {
|
|
80
|
+
...this.camera,
|
|
81
|
+
...c
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Merge MRZ consensus metrics. `reachedStable` is STICKY (once true, stays
|
|
86
|
+
* true) so a later frame's flicker can't unset that the scan did stabilise;
|
|
87
|
+
* `maxMrzBandHeightPx` keeps the largest seen. */
|
|
88
|
+
setMrz(m) {
|
|
89
|
+
const reachedStable = this.mrz.reachedStable || m.reachedStable === true;
|
|
90
|
+
const maxMrzBandHeightPx = Math.max(this.mrz.maxMrzBandHeightPx ?? 0, m.maxMrzBandHeightPx ?? 0) || undefined;
|
|
91
|
+
this.mrz = {
|
|
92
|
+
...this.mrz,
|
|
93
|
+
...m,
|
|
94
|
+
reachedStable,
|
|
95
|
+
maxMrzBandHeightPx
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- NFC: the structured per-step log ----
|
|
100
|
+
|
|
101
|
+
nfcAttemptStarted() {
|
|
102
|
+
// A fresh attempt: bump retry count if a previous attempt left steps.
|
|
103
|
+
if (this.nfc.attempted) this.nfc.retryCount += 1;
|
|
104
|
+
this.nfc.attempted = true;
|
|
105
|
+
}
|
|
106
|
+
nfcChipDetected() {
|
|
107
|
+
this.nfc.chipDetected = true;
|
|
108
|
+
}
|
|
109
|
+
nfcSetAuth(protocol, oid) {
|
|
110
|
+
this.nfc.authProtocol = protocol;
|
|
111
|
+
if (oid) this.nfc.paceOid = oid;
|
|
112
|
+
}
|
|
113
|
+
nfcPaceFellBackToBac(reason) {
|
|
114
|
+
this.nfc.paceFellBackToBac = true;
|
|
115
|
+
if (reason && !this.nfc.paceFallbackReason) {
|
|
116
|
+
this.nfc.paceFallbackReason = reason;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Record one completed step. Keep messages short and free of PII. */
|
|
121
|
+
nfcStep(step) {
|
|
122
|
+
this.nfc.steps.push(step);
|
|
123
|
+
if (!step.ok && !this.nfc.failureStep) {
|
|
124
|
+
this.nfc.failureStep = step.step;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
nfcResult(succeeded, errorCategory) {
|
|
128
|
+
this.nfc.succeeded = succeeded;
|
|
129
|
+
if (succeeded) {
|
|
130
|
+
// The read ultimately succeeded — transient per-attempt failures (e.g. a
|
|
131
|
+
// recovered DG2 retry, or the expected EF.CardAccess 6982 on a BAC-only
|
|
132
|
+
// card) must NOT be reported as the outcome. Clear the failure markers.
|
|
133
|
+
this.nfc.failureStep = undefined;
|
|
134
|
+
this.nfc.errorCategory = undefined;
|
|
135
|
+
} else if (errorCategory) {
|
|
136
|
+
this.nfc.errorCategory = errorCategory;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
addError(e) {
|
|
140
|
+
this.errors.push(e);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Build the serialisable snapshot. */
|
|
144
|
+
snapshot(nowMs, isoNow) {
|
|
145
|
+
return {
|
|
146
|
+
schemaVersion: 1,
|
|
147
|
+
generatedAt: isoNow,
|
|
148
|
+
documentType: this.documentType,
|
|
149
|
+
dataSource: this.dataSource,
|
|
150
|
+
totalDurationMs: this.startedAtMs ? Math.max(0, nowMs - this.startedAtMs) : 0,
|
|
151
|
+
camera: {
|
|
152
|
+
...this.camera
|
|
153
|
+
},
|
|
154
|
+
mrz: {
|
|
155
|
+
...this.mrz
|
|
156
|
+
},
|
|
157
|
+
nfc: {
|
|
158
|
+
...this.nfc,
|
|
159
|
+
steps: this.nfc.steps.map(s => ({
|
|
160
|
+
...s
|
|
161
|
+
}))
|
|
162
|
+
},
|
|
163
|
+
errors: this.errors.map(e => ({
|
|
164
|
+
...e
|
|
165
|
+
}))
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Shared session-scoped collector used across the scan flow. */
|
|
171
|
+
export const diagnostics = new DiagnosticsCollector();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
import { trackApiCall, trackError } from "./analytics.utils.js";
|
|
4
|
+
import { debugLog } from "./debug.utils.js";
|
|
4
5
|
export class HttpClientError extends Error {
|
|
5
6
|
constructor(message) {
|
|
6
7
|
super(message);
|
|
@@ -36,6 +37,15 @@ export class InternalServerError extends HttpClientError {
|
|
|
36
37
|
super(message ?? 'Internal server error');
|
|
37
38
|
}
|
|
38
39
|
}
|
|
40
|
+
|
|
41
|
+
// Short-lived per-verification token issued by the backend during the session
|
|
42
|
+
// key exchange; sent on every subsequent call. Older backends don't issue one,
|
|
43
|
+
// in which case requests go out without the header (backwards compatible).
|
|
44
|
+
let sessionToken = null;
|
|
45
|
+
export const setSessionToken = token => {
|
|
46
|
+
sessionToken = token;
|
|
47
|
+
};
|
|
48
|
+
export const getSessionToken = () => sessionToken;
|
|
39
49
|
const request = async (httpMethod, url, body, simulatedResponse) => {
|
|
40
50
|
if (simulatedResponse) {
|
|
41
51
|
return new Promise(resolve => {
|
|
@@ -47,30 +57,33 @@ const request = async (httpMethod, url, body, simulatedResponse) => {
|
|
|
47
57
|
const startTime = Date.now();
|
|
48
58
|
let statusCode = 0;
|
|
49
59
|
let success = false;
|
|
50
|
-
|
|
60
|
+
debugLog('HTTP', `${httpMethod} ${url}`);
|
|
51
61
|
if (body) {
|
|
52
|
-
|
|
62
|
+
debugLog('HTTP', 'Request body:', JSON.stringify(body).substring(0, 200) + '...');
|
|
53
63
|
}
|
|
54
64
|
try {
|
|
55
65
|
const response = await fetch(url, {
|
|
56
66
|
method: httpMethod,
|
|
57
67
|
headers: {
|
|
58
|
-
'Content-Type': 'application/json'
|
|
68
|
+
'Content-Type': 'application/json',
|
|
69
|
+
...(sessionToken ? {
|
|
70
|
+
'X-Session-Token': sessionToken
|
|
71
|
+
} : {})
|
|
59
72
|
},
|
|
60
73
|
body: body ? JSON.stringify(body) : undefined
|
|
61
74
|
});
|
|
62
75
|
statusCode = response.status;
|
|
63
76
|
success = response.ok;
|
|
64
|
-
|
|
77
|
+
debugLog('HTTP', `Response status: ${statusCode} ${response.ok ? '✓' : '✗'}`);
|
|
65
78
|
let responseJson = null;
|
|
66
79
|
try {
|
|
67
80
|
responseJson = await response.json();
|
|
68
81
|
if (responseJson) {
|
|
69
|
-
|
|
82
|
+
debugLog('HTTP', 'Response body:', JSON.stringify(responseJson).substring(0, 200) + '...');
|
|
70
83
|
}
|
|
71
84
|
} catch (error) {
|
|
72
85
|
// Invalid JSON response
|
|
73
|
-
|
|
86
|
+
debugLog('HTTP', 'Response body: (non-JSON)');
|
|
74
87
|
}
|
|
75
88
|
|
|
76
89
|
// Track API call performance (non-blocking)
|
|
@@ -14,6 +14,34 @@ const RECOVERY_BUDGET_MS = 80;
|
|
|
14
14
|
let recoveryDeadline = 0;
|
|
15
15
|
const recoveryExpired = () => Date.now() > recoveryDeadline;
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Pads a TD1 line to the fixed 30-char width.
|
|
19
|
+
*
|
|
20
|
+
* The naive `s.padEnd(30, '<')` is WRONG for the date record (line 2): its last
|
|
21
|
+
* character is the composite check digit, preceded by an 11-char optional-data
|
|
22
|
+
* filler run. OCR routinely UNDER-COUNTS that low-contrast `<<<` run, so the OCR
|
|
23
|
+
* string arrives short AND ending in the composite digit (e.g. a 28-char
|
|
24
|
+
* `…TUR<<<<<<<<<6`). `padEnd` then appends fillers AFTER the digit
|
|
25
|
+
* (`…6<<`), stranding the composite mid-line: the parser absorbs it into the
|
|
26
|
+
* optional field and the composite check fails, after which a recompute writes a
|
|
27
|
+
* WRONG digit over the misaligned layout — a plausible-but-malformed MRZ.
|
|
28
|
+
*
|
|
29
|
+
* The fix is layout-aware: when a short line ends in a digit, that digit is the
|
|
30
|
+
* trailing check digit, so re-grow the filler region BEFORE it. We only do this
|
|
31
|
+
* for the date record (`isDateRecord`); the doc record (line 1) ends in optional
|
|
32
|
+
* data, not a check digit, so plain right-padding is correct there.
|
|
33
|
+
*/
|
|
34
|
+
const padTD1Line = (s, isDateRecord = false) => {
|
|
35
|
+
if (s.length >= 30) return s.slice(0, 30);
|
|
36
|
+
const last = s[s.length - 1];
|
|
37
|
+
if (isDateRecord && last >= '0' && last <= '9') {
|
|
38
|
+
// Trailing digit is the composite check digit — insert the dropped fillers
|
|
39
|
+
// BEFORE it so it lands at position 29.
|
|
40
|
+
return s.slice(0, -1).padEnd(29, '<') + last;
|
|
41
|
+
}
|
|
42
|
+
return s.padEnd(30, '<');
|
|
43
|
+
};
|
|
44
|
+
|
|
17
45
|
/**
|
|
18
46
|
* MRZ Format Types according to ICAO 9303
|
|
19
47
|
*/
|
|
@@ -291,11 +319,14 @@ const reconstructMRZCandidates = rawText => {
|
|
|
291
319
|
l1 = l1.slice(0, 2) + l1.slice(2, 5).replace(/K(?=<)|(?<=<)K|(?<=[A-Z])K$/g, '<') + l1.slice(5);
|
|
292
320
|
let l2;
|
|
293
321
|
if (l2m) {
|
|
294
|
-
// Use the matched date record
|
|
295
|
-
|
|
322
|
+
// Use the matched date record. It is the TD1 line whose trailing char is
|
|
323
|
+
// the composite check digit, so pad layout-aware: a short line ending in
|
|
324
|
+
// a digit gets its dropped filler run restored BEFORE that digit (a
|
|
325
|
+
// plain right-pad would strand the composite mid-line). See padTD1Line.
|
|
326
|
+
l2 = padTD1Line(l2m, true);
|
|
296
327
|
} else {
|
|
297
328
|
const after = flat.indexOf(l1Source) + l1Source.length;
|
|
298
|
-
l2 =
|
|
329
|
+
l2 = padTD1Line(flat.slice(after, after + 30), true);
|
|
299
330
|
}
|
|
300
331
|
// Line 3 (name): the longest mostly-letters+filler OCR line that is NOT the
|
|
301
332
|
// doc or date line. OCR mangles the name line too — a leading "O" reads as
|
|
@@ -547,6 +578,112 @@ const icaoCheckDigit = str => {
|
|
|
547
578
|
return String(sum);
|
|
548
579
|
};
|
|
549
580
|
|
|
581
|
+
/**
|
|
582
|
+
* Rebuilds the fixed-width MRZ lines from a parse result's per-field `ranges`,
|
|
583
|
+
* which carry the actual characters at known line/column spans. Works for both
|
|
584
|
+
* valid and invalid parses (the `mrz` library populates ranges either way), so
|
|
585
|
+
* callers can read the PRINTED characters — e.g. a check digit the parser
|
|
586
|
+
* rejected — straight from the reconstructed line. Lines default to all-filler.
|
|
587
|
+
*/
|
|
588
|
+
const linesFromParseRanges = (result, width, lineCount) => {
|
|
589
|
+
const buffers = Array.from({
|
|
590
|
+
length: lineCount
|
|
591
|
+
}, () => new Array(width).fill('<'));
|
|
592
|
+
for (const detail of result.details ?? []) {
|
|
593
|
+
for (const range of detail.ranges ?? []) {
|
|
594
|
+
// `raw` (the actual characters at this span) exists at runtime but isn't in
|
|
595
|
+
// the `mrz` Range type.
|
|
596
|
+
const {
|
|
597
|
+
line,
|
|
598
|
+
start,
|
|
599
|
+
raw = ''
|
|
600
|
+
} = range;
|
|
601
|
+
const buf = buffers[line];
|
|
602
|
+
if (!buf) continue;
|
|
603
|
+
for (let i = 0; i < raw.length && start + i < width; i++) {
|
|
604
|
+
buf[start + i] = raw[i];
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return buffers.map(b => b.join(''));
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Accepts a TD1 parse that the `mrz` library marks invalid SOLELY because of the
|
|
613
|
+
* Turkish e-ID document-number check-digit convention. (Full writeup: §2a in
|
|
614
|
+
* MRZ_KNOWN_ISSUES.md.)
|
|
615
|
+
*
|
|
616
|
+
* Turkish identity cards put a SERIAL LETTER at the start of the 9-char document
|
|
617
|
+
* number (e.g. "A53032352", "A49M38410"). Empirically — verified against real
|
|
618
|
+
* cards — Türkiye computes the document-number check digit over the number with
|
|
619
|
+
* that LEADING letter EXCLUDED (interior letters are kept), which diverges from
|
|
620
|
+
* strict ICAO 9303 (where A=10 is included). So a genuine card like "A53032352"
|
|
621
|
+
* (printed check digit 5) FAILS the strict check (which wants 7) even though the
|
|
622
|
+
* card is valid — `icaoCheckDigit("53032352") === "5"`.
|
|
623
|
+
*
|
|
624
|
+
* Without this, the downstream ambiguous-char recovery "fixes" the failing check
|
|
625
|
+
* by rewriting the real leading letter to a digit (A→4), corrupting the document
|
|
626
|
+
* number. This function instead recognises the legitimate Turkish reading and
|
|
627
|
+
* accepts the parse AS-IS, preserving the letter.
|
|
628
|
+
*
|
|
629
|
+
* It is deliberately narrow: it only fires for a TUR-issued TD1 whose document
|
|
630
|
+
* number starts with a letter, when the document-number check digit is failing
|
|
631
|
+
* (its signature) and the only OTHER failure permitted is the composite (the
|
|
632
|
+
* date checks must pass — an impossible date is real OCR damage, not this
|
|
633
|
+
* convention), and the leading-letter-dropped recomputation matches the printed
|
|
634
|
+
* document-number check digit.
|
|
635
|
+
*
|
|
636
|
+
* The leading serial letter MUST be preserved: the NFC eID chip derives its
|
|
637
|
+
* BAC/PACE access key from the document number exactly as stored on the chip
|
|
638
|
+
* (which includes the letter, e.g. "A53032352"). Rewriting it to a digit ("4…")
|
|
639
|
+
* — as the ambiguous-char recovery would, to satisfy strict ICAO — makes the
|
|
640
|
+
* chip read FAIL. So a real card must keep its letter even though the chip's
|
|
641
|
+
* MRZ carries a non-standard composite check digit that strict ICAO rejects.
|
|
642
|
+
*
|
|
643
|
+
* The TD1 COMPOSITE is therefore NOT required to match: on Turkish eIDs the
|
|
644
|
+
* composite is computed over the document-number region with the serial letter
|
|
645
|
+
* weighted in a way that diverges from strict ICAO (it cannot be reproduced by
|
|
646
|
+
* either including A=10 or dropping the letter), so it is not a reliable anchor
|
|
647
|
+
* here. The document-number check digit under the drop-leading-letter rule
|
|
648
|
+
* uniquely pins the numeric portion, and the letter is verbatim from OCR — the
|
|
649
|
+
* authoritative confirmation is the subsequent NFC read, which only succeeds
|
|
650
|
+
* with the correct (letter-preserving) document number.
|
|
651
|
+
*/
|
|
652
|
+
const acceptsTurkishLetterPrefix = result => {
|
|
653
|
+
if (!result || result.valid || result.format !== 'TD1') return false;
|
|
654
|
+
const fields = result.fields ?? {};
|
|
655
|
+
if (fields.issuingState !== 'TUR') return false;
|
|
656
|
+
const docNumber = fields.documentNumber ?? '';
|
|
657
|
+
// Must start with a serial LETTER (the Turkish convention this handles).
|
|
658
|
+
if (!/^[A-Z]/.test(docNumber)) return false;
|
|
659
|
+
const failing = new Set((result.details ?? []).filter(d => !d.valid).map(d => d.field));
|
|
660
|
+
// Only the document-number and/or composite checks may be off — any other
|
|
661
|
+
// failing check digit (dates) means real OCR damage, not this convention.
|
|
662
|
+
const allowed = new Set(['documentNumberCheckDigit', 'compositeCheckDigit']);
|
|
663
|
+
if (failing.size === 0) return false;
|
|
664
|
+
for (const f of failing) if (!allowed.has(f)) return false;
|
|
665
|
+
// The document-number check digit MUST be among the failures — that is the
|
|
666
|
+
// signature of the letter-prefix convention. If only the composite is off, this
|
|
667
|
+
// is a different problem (handled by fixTD1Composite / indel recovery).
|
|
668
|
+
if (!failing.has('documentNumberCheckDigit')) return false;
|
|
669
|
+
|
|
670
|
+
// Rebuild the fixed-width lines from the parse ranges. We read the PRINTED
|
|
671
|
+
// check digit straight from the line (the parsed `documentNumberCheckDigit`
|
|
672
|
+
// field is null when the check fails, so it can't be relied on here).
|
|
673
|
+
const lines = linesFromParseRanges(result, 30, 3);
|
|
674
|
+
const l1 = lines[0] ?? '';
|
|
675
|
+
if (l1.length < 30) return false;
|
|
676
|
+
|
|
677
|
+
// The printed document-number check digit (TD1 line 1, position 14) must equal
|
|
678
|
+
// the check digit computed over the document number WITHOUT its leading letter.
|
|
679
|
+
// This uniquely identifies the numeric portion; the leading letter is kept
|
|
680
|
+
// verbatim (NFC requires it). The composite is deliberately NOT checked here.
|
|
681
|
+
const printedDocCheck = l1[14];
|
|
682
|
+
const digitsAfterLetter = docNumber.slice(1);
|
|
683
|
+
if (icaoCheckDigit(digitsAfterLetter) !== printedDocCheck) return false;
|
|
684
|
+
return true;
|
|
685
|
+
};
|
|
686
|
+
|
|
550
687
|
/**
|
|
551
688
|
* When a TD1 MRZ fails ONLY on its composite check digit (every per-field check
|
|
552
689
|
* digit is valid), the composite is deterministically derivable from the other
|
|
@@ -557,6 +694,17 @@ const fixTD1Composite = lines => {
|
|
|
557
694
|
if (lines.length < 3 || lines[0].length !== 30 || lines[1].length !== 30) {
|
|
558
695
|
return null;
|
|
559
696
|
}
|
|
697
|
+
// Guard against a MISPLACED composite: when OCR drops filler(s) from the line-2
|
|
698
|
+
// optional run, a layout-naive right-pad lands the composite digit mid-line and
|
|
699
|
+
// pads the tail with "<". The result is length-30 but structurally wrong, and
|
|
700
|
+
// recomputing over it writes a bogus digit (a plausible-but-malformed MRZ). If
|
|
701
|
+
// the composite slot [29] is "<" yet a digit sits in the optional window
|
|
702
|
+
// [18..29), the line is misaligned — refuse the recompute and let the indel
|
|
703
|
+
// (filler-insertion) recovery realign it first.
|
|
704
|
+
const l2line = lines[1];
|
|
705
|
+
if (l2line[29] === '<' && /[0-9]/.test(l2line.slice(18, 29))) {
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
560
708
|
const r = parse(lines);
|
|
561
709
|
const bad = (r.details ?? []).filter(d => !d.valid);
|
|
562
710
|
if (bad.length !== 1 || bad[0].field !== 'compositeCheckDigit') return null;
|
|
@@ -580,18 +728,24 @@ const fixTD1Composite = lines => {
|
|
|
580
728
|
* - line 2 (date record): delete each char in the birth..expiry-check window
|
|
581
729
|
* [0..15) (then right-pad to 30) — undoes a spurious inserted digit;
|
|
582
730
|
* - line 1 (doc record): delete each char in the doc-number..check window
|
|
583
|
-
* [5..15) — undoes an inserted char in the serial
|
|
731
|
+
* [5..15) — undoes an inserted char in the serial;
|
|
732
|
+
* - line 2 (date record): when OCR DROPPED filler(s) from the optional run, the
|
|
733
|
+
* line arrives short — insert a "<" across the optional window [18..29) to
|
|
734
|
+
* restore width 30 with the composite at position 29.
|
|
584
735
|
* Each candidate is re-padded to 30, the composite is recomputed, and only a
|
|
585
736
|
* fully check-digit-valid parse is accepted, so a wrong edit can never pass.
|
|
586
737
|
*/
|
|
587
738
|
const recoverTD1ByIndel = lines => {
|
|
588
739
|
if (lines.length < 3) return null;
|
|
589
|
-
|
|
740
|
+
// Layout-aware: line 2 (index 1) is the date record whose trailing char is the
|
|
741
|
+
// composite check digit, so a short line must have its dropped fillers restored
|
|
742
|
+
// BEFORE that digit, not appended after it (padEnd would strand the composite).
|
|
743
|
+
const pad30 = (s, li) => s.length >= 30 ? s.slice(0, 30) : padTD1Line(s, li === 1);
|
|
590
744
|
|
|
591
745
|
// Which line/window to try edits in, based on which check digit is failing.
|
|
592
746
|
let base;
|
|
593
747
|
try {
|
|
594
|
-
base = parse(lines.map(pad30));
|
|
748
|
+
base = parse(lines.map((l, li) => pad30(l, li)));
|
|
595
749
|
} catch {
|
|
596
750
|
return null;
|
|
597
751
|
}
|
|
@@ -612,15 +766,26 @@ const recoverTD1ByIndel = lines => {
|
|
|
612
766
|
// Build all single-deletion candidates up front.
|
|
613
767
|
const candidates = [];
|
|
614
768
|
for (const [li, from, to] of regions) {
|
|
615
|
-
const original = pad30(lines[li] ?? '');
|
|
769
|
+
const original = pad30(lines[li] ?? '', li);
|
|
616
770
|
for (let i = from; i < to && i < original.length; i++) {
|
|
617
|
-
const edited = pad30(original.slice(0, i) + original.slice(i + 1));
|
|
618
|
-
const candidate = lines.map(pad30);
|
|
771
|
+
const edited = pad30(original.slice(0, i) + original.slice(i + 1), li);
|
|
772
|
+
const candidate = lines.map((l, idx) => pad30(l, idx));
|
|
619
773
|
candidate[li] = edited;
|
|
620
774
|
candidates.push(candidate);
|
|
621
775
|
}
|
|
622
776
|
}
|
|
623
777
|
|
|
778
|
+
// Dropped-filler insertion: when OCR under-counts the line-2 optional filler
|
|
779
|
+
// run, the date record arrives SHORT. The layout-aware pad30 already restores
|
|
780
|
+
// it (fillers before the trailing composite digit), so include the raw padded
|
|
781
|
+
// date record as an explicit candidate — this repairs the common Turkish-ID
|
|
782
|
+
// case (`…TUR<<<<<<<<<6` read with 9 fillers instead of 11) by INSERTION, not a
|
|
783
|
+
// bogus composite recompute over a misaligned line.
|
|
784
|
+
if ((lines[1]?.length ?? 30) < 30) {
|
|
785
|
+
const padded = lines.map((l, idx) => pad30(l, idx));
|
|
786
|
+
candidates.push(padded);
|
|
787
|
+
}
|
|
788
|
+
|
|
624
789
|
// Cheap pass first: a plain parse or a composite recompute resolves the common
|
|
625
790
|
// case (a dropped/duplicated digit) in microseconds — try ALL deletions this
|
|
626
791
|
// way before paying for any substitution search.
|
|
@@ -809,26 +974,7 @@ const correctedMrzFromParse = result => {
|
|
|
809
974
|
const width = widths[result.format ?? ''];
|
|
810
975
|
if (!width) return undefined;
|
|
811
976
|
const lineCount = result.format === 'TD1' ? 3 : 2;
|
|
812
|
-
|
|
813
|
-
length: lineCount
|
|
814
|
-
}, () => new Array(width).fill('<'));
|
|
815
|
-
for (const detail of result.details ?? []) {
|
|
816
|
-
for (const range of detail.ranges ?? []) {
|
|
817
|
-
// `raw` (the actual characters at this span) exists at runtime but isn't in
|
|
818
|
-
// the `mrz` Range type.
|
|
819
|
-
const {
|
|
820
|
-
line,
|
|
821
|
-
start,
|
|
822
|
-
raw = ''
|
|
823
|
-
} = range;
|
|
824
|
-
const buf = buffers[line];
|
|
825
|
-
if (!buf) continue;
|
|
826
|
-
for (let i = 0; i < raw.length && start + i < width; i++) {
|
|
827
|
-
buf[start + i] = raw[i];
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
return buffers.map(b => b.join('')).join('\n');
|
|
977
|
+
return linesFromParseRanges(result, width, lineCount).join('\n');
|
|
832
978
|
};
|
|
833
979
|
|
|
834
980
|
/**
|
|
@@ -862,6 +1008,10 @@ const validateMRZ = (mrzText, autocorrect = true) => {
|
|
|
862
1008
|
// line + fragments). That must NOT short-circuit the recovery/reconstruction
|
|
863
1009
|
// path below — treat a throw here as "no valid parse yet" and keep going.
|
|
864
1010
|
let result = null;
|
|
1011
|
+
// Set when a TUR card is accepted under the Turkish serial-letter convention
|
|
1012
|
+
// (strict ICAO marks it invalid, but the leading-letter-dropped check passes).
|
|
1013
|
+
// Treated as valid downstream so the real letter is preserved, not corrected.
|
|
1014
|
+
let turkishLetterPrefixAccepted = false;
|
|
865
1015
|
try {
|
|
866
1016
|
result = parse(fixedText, {
|
|
867
1017
|
autocorrect
|
|
@@ -891,6 +1041,16 @@ const validateMRZ = (mrzText, autocorrect = true) => {
|
|
|
891
1041
|
}
|
|
892
1042
|
}
|
|
893
1043
|
|
|
1044
|
+
// Turkish e-ID serial-letter convention: a genuine TUR card whose document
|
|
1045
|
+
// number starts with a serial LETTER (e.g. "A53032352") fails the strict
|
|
1046
|
+
// ICAO check (Türkiye omits the leading letter from the check-digit math). If
|
|
1047
|
+
// the card validates under that convention, accept it AS-IS — the leading
|
|
1048
|
+
// letter is REAL and must be preserved, NOT rewritten to a digit by the
|
|
1049
|
+
// ambiguous-char recovery below. See acceptsTurkishLetterPrefix.
|
|
1050
|
+
if (result && !result.valid && acceptsTurkishLetterPrefix(result)) {
|
|
1051
|
+
turkishLetterPrefixAccepted = true;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
894
1054
|
// Primary recovery: exhaustive, CHECK-DIGIT-DRIVEN correction scoped to the
|
|
895
1055
|
// fields whose check digit actually fails. It tries every OCR-ambiguous
|
|
896
1056
|
// permutation within those fields — ordered by how likely the OCR error is
|
|
@@ -899,7 +1059,7 @@ const validateMRZ = (mrzText, autocorrect = true) => {
|
|
|
899
1059
|
// digits all pass). This both (a) fixes a misread document number before the
|
|
900
1060
|
// caller advances to the NFC/next screen, and (b) prefers the genuinely
|
|
901
1061
|
// correct correction over an arbitrary check-digit-satisfying one.
|
|
902
|
-
if (!result || !result.valid) {
|
|
1062
|
+
if (!turkishLetterPrefixAccepted && (!result || !result.valid)) {
|
|
903
1063
|
const fixedLines = fixedText.split('\n').filter(l => l.trim());
|
|
904
1064
|
if (fixedLines.length >= 2) {
|
|
905
1065
|
const recovered = recoverByFailingFields(fixedLines);
|
|
@@ -912,7 +1072,7 @@ const validateMRZ = (mrzText, autocorrect = true) => {
|
|
|
912
1072
|
// Last-resort fallback: the older whole-string ambiguous sweep, only for
|
|
913
1073
|
// inputs the field-scoped recovery can't address (e.g. an unrecognised
|
|
914
1074
|
// format with no check-digit span map). Still requires a fully-valid parse.
|
|
915
|
-
if (!result || !result.valid) {
|
|
1075
|
+
if (!turkishLetterPrefixAccepted && (!result || !result.valid)) {
|
|
916
1076
|
const variants = generateAmbiguousVariants(fixedText);
|
|
917
1077
|
for (const variant of variants) {
|
|
918
1078
|
if (recoveryExpired()) break; // bail before blocking the JS thread
|
|
@@ -936,7 +1096,7 @@ const validateMRZ = (mrzText, autocorrect = true) => {
|
|
|
936
1096
|
// or broke a field with spaces). Reconstruct fixed-width line-sets for every
|
|
937
1097
|
// supported format (TD1/TD2/TD3) and validate each WITH CHECK DIGITS, keeping
|
|
938
1098
|
// only one that passes — a wrong reconstruction can never leak through.
|
|
939
|
-
if (!result || !result.valid) {
|
|
1099
|
+
if (!turkishLetterPrefixAccepted && (!result || !result.valid)) {
|
|
940
1100
|
for (const reLines of reconstructMRZCandidates(mrzText)) {
|
|
941
1101
|
// A reconstructed candidate for the wrong format (e.g. a 2-line TD2 guess
|
|
942
1102
|
// built from a TD1 frame) makes the `mrz` parser THROW on line count —
|
|
@@ -957,7 +1117,7 @@ const validateMRZ = (mrzText, autocorrect = true) => {
|
|
|
957
1117
|
}
|
|
958
1118
|
}
|
|
959
1119
|
}
|
|
960
|
-
if (!result || !result.valid) {
|
|
1120
|
+
if (!result || !result.valid && !turkishLetterPrefixAccepted) {
|
|
961
1121
|
return {
|
|
962
1122
|
valid: false,
|
|
963
1123
|
format: 'UNKNOWN',
|