@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.
Files changed (108) hide show
  1. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +76 -24
  2. package/android/src/main/java/com/trustchex/reactnativesdk/mlkit/MLKitModule.kt +1 -0
  3. package/ios/Camera/TrustchexCameraView.swift +20 -15
  4. package/lib/module/Screens/Dynamic/LivenessDetectionScreen.js +3 -2
  5. package/lib/module/Screens/Static/QrCodeScanningScreen.js +12 -2
  6. package/lib/module/Screens/Static/ResultScreen.js +145 -113
  7. package/lib/module/Shared/Components/DebugOverlay.js +5 -2
  8. package/lib/module/Shared/Components/DiagnosticReportButton.js +64 -0
  9. package/lib/module/Shared/Components/IdentityDocumentCamera.js +185 -30
  10. package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +8 -1
  11. package/lib/module/Shared/Components/NavigationManager.js +7 -9
  12. package/lib/module/Shared/EIDReader/eidReader.js +159 -38
  13. package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +41 -0
  14. package/lib/module/Shared/Libs/SignalingClient.js +6 -5
  15. package/lib/module/Shared/Libs/crypto.utils.js +25 -3
  16. package/lib/module/Shared/Libs/deeplink.utils.js +37 -7
  17. package/lib/module/Shared/Libs/diagnosticReport.js +133 -0
  18. package/lib/module/Shared/Libs/diagnostics.js +171 -0
  19. package/lib/module/Shared/Libs/http-client.js +19 -6
  20. package/lib/module/Shared/Libs/mrz.utils.js +193 -33
  21. package/lib/module/Shared/Libs/mrzFrameAggregator.js +74 -3
  22. package/lib/module/Shared/Libs/native-device-info.utils.js +67 -0
  23. package/lib/module/Shared/Libs/promise.utils.js +4 -3
  24. package/lib/module/Shared/Libs/sendDiagnosticReport.js +130 -0
  25. package/lib/module/Shared/Libs/shuffle.utils.js +17 -0
  26. package/lib/module/Shared/Services/AnalyticsService.js +8 -2
  27. package/lib/module/Shared/Services/DataUploadService.js +114 -127
  28. package/lib/module/Shared/Services/VideoSessionService.js +4 -3
  29. package/lib/module/Translation/Resources/en.js +6 -0
  30. package/lib/module/Translation/Resources/tr.js +6 -0
  31. package/lib/module/Trustchex.js +20 -4
  32. package/lib/module/version.js +1 -1
  33. package/lib/typescript/src/Screens/Dynamic/LivenessDetectionScreen.d.ts.map +1 -1
  34. package/lib/typescript/src/Screens/Static/QrCodeScanningScreen.d.ts.map +1 -1
  35. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  36. package/lib/typescript/src/Shared/Components/DebugOverlay.d.ts.map +1 -1
  37. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts +20 -0
  38. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts.map +1 -0
  39. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  40. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts +10 -0
  41. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts.map +1 -1
  42. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +1 -1
  43. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -1
  44. package/lib/typescript/src/Shared/Components/NavigationManager.d.ts.map +1 -1
  45. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts +10 -0
  46. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts.map +1 -1
  47. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
  48. package/lib/typescript/src/Shared/Libs/SignalingClient.d.ts.map +1 -1
  49. package/lib/typescript/src/Shared/Libs/crypto.utils.d.ts.map +1 -1
  50. package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts +1 -0
  51. package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts.map +1 -1
  52. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts +48 -0
  53. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts.map +1 -0
  54. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts +145 -0
  55. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts.map +1 -0
  56. package/lib/typescript/src/Shared/Libs/http-client.d.ts +2 -0
  57. package/lib/typescript/src/Shared/Libs/http-client.d.ts.map +1 -1
  58. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  59. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +55 -0
  60. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
  61. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts +33 -0
  62. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts.map +1 -1
  63. package/lib/typescript/src/Shared/Libs/promise.utils.d.ts.map +1 -1
  64. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts +30 -0
  65. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts.map +1 -0
  66. package/lib/typescript/src/Shared/Libs/shuffle.utils.d.ts +3 -0
  67. package/lib/typescript/src/Shared/Libs/shuffle.utils.d.ts.map +1 -0
  68. package/lib/typescript/src/Shared/Services/AnalyticsService.d.ts.map +1 -1
  69. package/lib/typescript/src/Shared/Services/DataUploadService.d.ts.map +1 -1
  70. package/lib/typescript/src/Shared/Services/VideoSessionService.d.ts.map +1 -1
  71. package/lib/typescript/src/Translation/Resources/en.d.ts +6 -0
  72. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  73. package/lib/typescript/src/Translation/Resources/tr.d.ts +6 -0
  74. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  75. package/lib/typescript/src/Trustchex.d.ts.map +1 -1
  76. package/lib/typescript/src/version.d.ts +1 -1
  77. package/package.json +17 -3
  78. package/src/Screens/Dynamic/LivenessDetectionScreen.tsx +4 -3
  79. package/src/Screens/Static/QrCodeScanningScreen.tsx +19 -2
  80. package/src/Screens/Static/ResultScreen.tsx +172 -162
  81. package/src/Shared/Components/DebugOverlay.tsx +5 -2
  82. package/src/Shared/Components/DiagnosticReportButton.tsx +77 -0
  83. package/src/Shared/Components/IdentityDocumentCamera.tsx +200 -29
  84. package/src/Shared/Components/IdentityDocumentCamera.types.ts +6 -0
  85. package/src/Shared/Components/IdentityDocumentCamera.utils.ts +9 -1
  86. package/src/Shared/Components/NavigationManager.tsx +7 -7
  87. package/src/Shared/Components/TrustchexCamera.tsx +6 -0
  88. package/src/Shared/EIDReader/eidReader.ts +181 -52
  89. package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +41 -0
  90. package/src/Shared/Libs/SignalingClient.ts +10 -5
  91. package/src/Shared/Libs/crypto.utils.ts +41 -4
  92. package/src/Shared/Libs/deeplink.utils.ts +39 -7
  93. package/src/Shared/Libs/diagnosticReport.ts +206 -0
  94. package/src/Shared/Libs/diagnostics.ts +251 -0
  95. package/src/Shared/Libs/http-client.ts +24 -8
  96. package/src/Shared/Libs/mrz.utils.ts +200 -34
  97. package/src/Shared/Libs/mrzFrameAggregator.ts +112 -2
  98. package/src/Shared/Libs/native-device-info.utils.ts +116 -0
  99. package/src/Shared/Libs/promise.utils.ts +11 -5
  100. package/src/Shared/Libs/sendDiagnosticReport.ts +165 -0
  101. package/src/Shared/Libs/shuffle.utils.ts +15 -0
  102. package/src/Shared/Services/AnalyticsService.ts +17 -1
  103. package/src/Shared/Services/DataUploadService.ts +155 -166
  104. package/src/Shared/Services/VideoSessionService.ts +15 -3
  105. package/src/Translation/Resources/en.ts +7 -0
  106. package/src/Translation/Resources/tr.ts +7 -0
  107. package/src/Trustchex.tsx +26 -3
  108. 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
- console.log(`[HTTP] ${httpMethod} ${url}`);
60
+ debugLog('HTTP', `${httpMethod} ${url}`);
51
61
  if (body) {
52
- console.log('[HTTP] Request body:', JSON.stringify(body).substring(0, 200) + '...');
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
- console.log(`[HTTP] Response status: ${statusCode} ${response.ok ? '✓' : '✗'}`);
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
- console.log('[HTTP] Response body:', JSON.stringify(responseJson).substring(0, 200) + '...');
82
+ debugLog('HTTP', 'Response body:', JSON.stringify(responseJson).substring(0, 200) + '...');
70
83
  }
71
84
  } catch (error) {
72
85
  // Invalid JSON response
73
- console.log('[HTTP] Response body: (non-JSON)');
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, padded to 30.
295
- l2 = pad(l2m, 30);
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 = pad(flat.slice(after, after + 30), 30);
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
- const pad30 = s => s.length >= 30 ? s.slice(0, 30) : s.padEnd(30, '<');
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
- const buffers = Array.from({
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',