@trustchex/react-native-sdk 1.486.0 → 1.488.0

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 (70) hide show
  1. package/lib/module/Shared/Components/EIDScanner.js +97 -37
  2. package/lib/module/Shared/Components/IdentityDocumentCamera.constants.js +9 -1
  3. package/lib/module/Shared/EIDReader/cardFileInputStream.js +25 -2
  4. package/lib/module/Shared/EIDReader/defaultFileSystem.js +56 -6
  5. package/lib/module/Shared/EIDReader/eidReader.js +124 -14
  6. package/lib/module/Shared/EIDReader/lds/icao/dg11File.js +23 -6
  7. package/lib/module/Shared/EIDReader/nfcManagerCardService.js +79 -9
  8. package/lib/module/Shared/EIDReader/protocol/paceDH.js +101 -0
  9. package/lib/module/Shared/EIDReader/protocol/paceErrors.js +34 -0
  10. package/lib/module/Shared/EIDReader/protocol/paceProtocol.js +53 -4
  11. package/lib/module/Shared/EIDReader/protocol/readBinaryAPDUSender.js +7 -3
  12. package/lib/module/Shared/Libs/country-display.utils.js +13 -1
  13. package/lib/module/Shared/Libs/diagnosticReport.js +24 -1
  14. package/lib/module/Shared/Libs/diagnostics.js +30 -8
  15. package/lib/module/Shared/Libs/mrz.utils.js +172 -25
  16. package/lib/module/Shared/Libs/mrzFrameAggregator.js +29 -1
  17. package/lib/module/Shared/Libs/mrzTransliteration.js +14 -1
  18. package/lib/module/Translation/Resources/en.js +12 -0
  19. package/lib/module/Translation/Resources/tr.js +12 -0
  20. package/lib/module/version.js +1 -1
  21. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
  22. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.constants.d.ts.map +1 -1
  23. package/lib/typescript/src/Shared/EIDReader/cardFileInputStream.d.ts +3 -0
  24. package/lib/typescript/src/Shared/EIDReader/cardFileInputStream.d.ts.map +1 -1
  25. package/lib/typescript/src/Shared/EIDReader/defaultFileSystem.d.ts +4 -0
  26. package/lib/typescript/src/Shared/EIDReader/defaultFileSystem.d.ts.map +1 -1
  27. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
  28. package/lib/typescript/src/Shared/EIDReader/lds/icao/dg11File.d.ts.map +1 -1
  29. package/lib/typescript/src/Shared/EIDReader/nfcManagerCardService.d.ts +9 -1
  30. package/lib/typescript/src/Shared/EIDReader/nfcManagerCardService.d.ts.map +1 -1
  31. package/lib/typescript/src/Shared/EIDReader/protocol/paceDH.d.ts +56 -0
  32. package/lib/typescript/src/Shared/EIDReader/protocol/paceDH.d.ts.map +1 -0
  33. package/lib/typescript/src/Shared/EIDReader/protocol/paceErrors.d.ts +26 -0
  34. package/lib/typescript/src/Shared/EIDReader/protocol/paceErrors.d.ts.map +1 -0
  35. package/lib/typescript/src/Shared/EIDReader/protocol/paceProtocol.d.ts.map +1 -1
  36. package/lib/typescript/src/Shared/EIDReader/protocol/readBinaryAPDUSender.d.ts.map +1 -1
  37. package/lib/typescript/src/Shared/Libs/country-display.utils.d.ts.map +1 -1
  38. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts.map +1 -1
  39. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts +9 -1
  40. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts.map +1 -1
  41. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts +1 -0
  42. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  43. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
  44. package/lib/typescript/src/Shared/Libs/mrzTransliteration.d.ts.map +1 -1
  45. package/lib/typescript/src/Translation/Resources/en.d.ts +12 -0
  46. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  47. package/lib/typescript/src/Translation/Resources/tr.d.ts +12 -0
  48. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  49. package/lib/typescript/src/version.d.ts +1 -1
  50. package/package.json +1 -1
  51. package/src/Shared/Components/EIDScanner.tsx +149 -68
  52. package/src/Shared/Components/IdentityDocumentCamera.constants.ts +9 -1
  53. package/src/Shared/EIDReader/cardFileInputStream.ts +30 -6
  54. package/src/Shared/EIDReader/defaultFileSystem.ts +75 -17
  55. package/src/Shared/EIDReader/eidReader.ts +154 -21
  56. package/src/Shared/EIDReader/lds/icao/dg11File.ts +28 -13
  57. package/src/Shared/EIDReader/nfcManagerCardService.ts +91 -13
  58. package/src/Shared/EIDReader/protocol/paceDH.ts +183 -0
  59. package/src/Shared/EIDReader/protocol/paceErrors.ts +41 -0
  60. package/src/Shared/EIDReader/protocol/paceProtocol.ts +72 -6
  61. package/src/Shared/EIDReader/protocol/readBinaryAPDUSender.ts +7 -4
  62. package/src/Shared/Libs/country-display.utils.ts +12 -1
  63. package/src/Shared/Libs/diagnosticReport.ts +32 -1
  64. package/src/Shared/Libs/diagnostics.ts +38 -14
  65. package/src/Shared/Libs/mrz.utils.ts +193 -28
  66. package/src/Shared/Libs/mrzFrameAggregator.ts +38 -3
  67. package/src/Shared/Libs/mrzTransliteration.ts +16 -3
  68. package/src/Translation/Resources/en.ts +19 -0
  69. package/src/Translation/Resources/tr.ts +19 -0
  70. package/src/version.ts +1 -1
@@ -4,6 +4,7 @@ import type { SecureMessagingWrapper } from '../secureMessagingWrapper';
4
4
  import { AESSecureMessagingWrapper } from '../aesSecureMessagingWrapper';
5
5
  import { TripleDesSecureMessagingWrapper } from '../tripleDesSecureMessagingWrapper';
6
6
  import { PACEInfo } from '../paceInfo';
7
+ import { PACEUnsupportedError } from './paceErrors';
7
8
  import { PACEResult } from './paceResult';
8
9
  import { PACEKeySpec } from '../paceKeySpec';
9
10
  import { EID_CONSTANTS } from '../constants/eidConstants';
@@ -26,6 +27,60 @@ import { ec as EC } from 'elliptic';
26
27
  const _hash = require('hash.js') as any;
27
28
  const PresetCurve = _elliptic.curves.PresetCurve;
28
29
 
30
+ // brainpoolP192r1 (RFC 5639 §3.1) — PACE standardized domain parameter ID 9.
31
+ if (!_elliptic.curves.brainpoolP192r1) {
32
+ _elliptic.curves.brainpoolP192r1 = new PresetCurve({
33
+ type: 'short',
34
+ prime: null,
35
+ p: 'c302f41d932a36cda7a3463093d18db78fce476de1a86297',
36
+ a: '6a91174076b1e0e19c39c031fe8685c1cae040e5c69a28ef',
37
+ b: '469a28ef7c28cca3dc721d044f4496bcca7ef4146fbf25c9',
38
+ n: 'c302f41d932a36cda7a3462f9e9e916b5be8f1029ac4acc1',
39
+ hash: _hash.sha256,
40
+ gRed: false,
41
+ g: [
42
+ 'c0a0647eaab6a48753b033c56cb0f0900a2f5c4853375fd6',
43
+ '14b690866abd5bb88b5f4828c1490002e6773fa2fa299b8f',
44
+ ],
45
+ });
46
+ }
47
+
48
+ // brainpoolP224r1 (RFC 5639 §3.2) — PACE standardized domain parameter ID 11.
49
+ if (!_elliptic.curves.brainpoolP224r1) {
50
+ _elliptic.curves.brainpoolP224r1 = new PresetCurve({
51
+ type: 'short',
52
+ prime: null,
53
+ p: 'd7c134aa264366862a18302575d1d787b09f075797da89f57ec8c0ff',
54
+ a: '68a5e62ca9ce6c1c299803a6c1530b514e182ad8b0042a59cad29f43',
55
+ b: '2580f63ccfe44138870713b1a92369e33e2135d266dbb372386c400b',
56
+ n: 'd7c134aa264366862a18302575d0fb98d116bc4b6ddebca3a5a7939f',
57
+ hash: _hash.sha256,
58
+ gRed: false,
59
+ g: [
60
+ '0d9029ad2c7e5cf4340823b2a87dc68c9e4ce3174c1e6efdee12c07d',
61
+ '58aa56f772c0726f24c6b89e4ecdac24354b9e99caa3f6d3761402cd',
62
+ ],
63
+ });
64
+ }
65
+
66
+ // brainpoolP320r1 (RFC 5639 §3.4) — PACE standardized domain parameter ID 14.
67
+ if (!_elliptic.curves.brainpoolP320r1) {
68
+ _elliptic.curves.brainpoolP320r1 = new PresetCurve({
69
+ type: 'short',
70
+ prime: null,
71
+ p: 'd35e472036bc4fb7e13c785ed201e065f98fcfa6f6f40def4f92b9ec7893ec28fcd412b1f1b32e27',
72
+ a: '3ee30b568fbab0f883ccebd46d3f3bb8a2a73513f5eb79da66190eb085ffa9f492f375a97d860eb4',
73
+ b: '520883949dfdbc42d3ad198640688a6fe13f41349554b49acc31dccd884539816f5eb4ac8fb1f1a6',
74
+ n: 'd35e472036bc4fb7e13c785ed201e065f98fcfa5b68f12a32d482ec7ee8658e98691555b44c59311',
75
+ hash: _hash.sha384,
76
+ gRed: false,
77
+ g: [
78
+ '43bd7e9afb53d8b85289bcc48ee5bfe6f20137d10a087eb6e7871e2a10a599c710af8d0d39e20611',
79
+ '14fdd05545ec1cc8ab4093247f77275e0743ffed117182eaa9c77877aaac6ac7d35245d1692e8ee1',
80
+ ],
81
+ });
82
+ }
83
+
29
84
  if (!_elliptic.curves.brainpoolP256r1) {
30
85
  _elliptic.curves.brainpoolP256r1 = new PresetCurve({
31
86
  type: 'short',
@@ -130,12 +185,19 @@ export class PACEProtocol {
130
185
  const keyLength = PACEInfo.toKeyLength(oid);
131
186
 
132
187
  if (agreementAlg !== 'ECDH') {
133
- throw new Error(
134
- `Only ECDH key agreement is supported, found: ${agreementAlg}`
188
+ // DH-based PACE is rare in modern documents; surface it as a typed
189
+ // capability gap so the caller falls back to BAC with a precise reason.
190
+ throw new PACEUnsupportedError(
191
+ 'agreement',
192
+ `key agreement ${agreementAlg} (only ECDH implemented)`
135
193
  );
136
194
  }
137
195
  if (mappingType !== 'GM' && mappingType !== 'IM') {
138
- throw new Error(`Unsupported PACE mapping type: ${mappingType}`);
196
+ // CAM (Chip Authentication Mapping) is not implemented.
197
+ throw new PACEUnsupportedError(
198
+ 'mapping',
199
+ `mapping type ${mappingType} (only GM/IM implemented)`
200
+ );
139
201
  }
140
202
 
141
203
  // Derive static PACE key K_pi from the access key
@@ -408,8 +470,9 @@ export class PACEProtocol {
408
470
  ephemeralParams: { generatorX: string; generatorY: string; curve: string };
409
471
  }> {
410
472
  if (cipherAlg !== 'AES') {
411
- throw new Error(
412
- `PACE IM: Only AES cipher is supported in this implementation, found: ${cipherAlg}`
473
+ throw new PACEUnsupportedError(
474
+ 'cipher',
475
+ `IM mapping requires AES, found ${cipherAlg}`
413
476
  );
414
477
  }
415
478
 
@@ -940,7 +1003,10 @@ export class PACEProtocol {
940
1003
  case 18:
941
1004
  return 'p521'; // NIST P-521
942
1005
  default:
943
- throw new Error(`Unsupported domain parameter ID: ${parameterId}`);
1006
+ throw new PACEUnsupportedError(
1007
+ 'curve',
1008
+ `domain parameter ID ${parameterId}`
1009
+ );
944
1010
  }
945
1011
  }
946
1012
  }
@@ -158,11 +158,14 @@ export class ReadBinaryAPDUSender implements APDULevelReadBinaryCapable {
158
158
  commandAPDU
159
159
  );
160
160
  } catch (error) {
161
- if (this.service.getIsConnectionLost(error as Error)) {
162
- throw error;
163
- }
164
-
161
+ // A transient transceive failure (iOS RF drop during a large DG2 read)
162
+ // used to be swallowed here, leaving responseAPDU undefined — which then
163
+ // crashed downstream as a misleading "Could not read file" that the retry
164
+ // layer couldn't recognise. Rethrow so the block-read retry/backoff in
165
+ // CardFileInputStream can re-issue the SAME block (idempotent), and the
166
+ // file system can downgrade the read size if needed.
165
167
  console.debug('Exception during READ BINARY', error);
168
+ throw error;
166
169
  }
167
170
 
168
171
  const responseData = ReadBinaryAPDUSender.getResponseData(
@@ -5,6 +5,13 @@ const COUNTRY_NAME_OVERRIDES: Record<string, string> = {
5
5
  TUR: 'Türkiye',
6
6
  };
7
7
 
8
+ // ICAO/MRZ issuing-state codes that are not ISO-3166 alpha-3 and must be mapped
9
+ // before lookup. "D" is Germany's historical 1-letter MRZ code (ICAO 9303
10
+ // Part 3 §C), surfaced filler-padded as "D<<" by the chip getters.
11
+ const MRZ_CODE_ALIASES: Record<string, string> = {
12
+ D: 'DEU',
13
+ };
14
+
8
15
  const findCountryByAlpha3 = (alpha3Code: string) => {
9
16
  return [...UnicodeCountry.VALUES, ...ISOCountry.VALUES].find(
10
17
  (country) => country.toAlpha3Code() === alpha3Code
@@ -17,7 +24,11 @@ export const getLocalizedCountryName = (
17
24
  ): string => {
18
25
  if (!alpha3Code) return '';
19
26
 
20
- const normalizedCode = alpha3Code.trim().toUpperCase();
27
+ // Strip MRZ filler (the chip pads short codes, e.g. German "D" → "D<<") and
28
+ // map non-ISO MRZ codes to their alpha-3 before lookup.
29
+ const stripped = alpha3Code.replace(/</g, '').trim().toUpperCase();
30
+ if (!stripped) return '';
31
+ const normalizedCode = MRZ_CODE_ALIASES[stripped] ?? stripped;
21
32
  const overrideName = COUNTRY_NAME_OVERRIDES[normalizedCode];
22
33
 
23
34
  if (overrideName) {
@@ -31,6 +31,30 @@ const icaoCheckDigit = (str: string): string => {
31
31
  return String(sum);
32
32
  };
33
33
 
34
+ /**
35
+ * Reportable NFC error code, e.g. `NFC-AUTH-6982`. MUST match the code shown to
36
+ * the user by EIDScanner (built in eidReader.buildNfcErrorCode) so a user who
37
+ * quotes "NFC-AUTH-6982" can be matched verbatim against their diagnostics.json.
38
+ * Local copy of the category→prefix map (eidReader pulls in native NFC deps).
39
+ */
40
+ const NFC_CATEGORY_CODE: Record<string, string> = {
41
+ user_cancelled: 'CANCEL',
42
+ tag_lost: 'TAGLOST',
43
+ timeout: 'TIMEOUT',
44
+ unsupported: 'UNSUPPORTED',
45
+ not_enabled: 'DISABLED',
46
+ auth_failed: 'AUTH',
47
+ reading_error: 'READ',
48
+ };
49
+
50
+ const nfcErrorCode = (
51
+ category: string | undefined,
52
+ statusWord: string | undefined
53
+ ): string => {
54
+ const part = NFC_CATEGORY_CODE[category ?? ''] ?? 'READ';
55
+ return statusWord ? `NFC-${part}-${statusWord}` : `NFC-${part}`;
56
+ };
57
+
34
58
  export interface ScanDataForReport {
35
59
  documentType?: string;
36
60
  dataSource?: string;
@@ -166,9 +190,16 @@ export const buildDiagnosticReport = (params: {
166
190
 
167
191
  // --- compact email body (always under MAX_BODY_BYTES) ---
168
192
  const nfc = diag.nfc;
193
+ // Status word of the failing step (if any), used to build the reportable code.
194
+ const failingSw = nfc.failureStep
195
+ ? nfc.steps.find((s) => s.step === nfc.failureStep)?.sw
196
+ : undefined;
197
+ const nfcCode = nfcErrorCode(nfc.errorCategory, failingSw);
169
198
  const nfcLine = nfc.attempted
170
199
  ? `${nfc.authProtocol}${nfc.paceFellBackToBac ? '→BAC' : ''} · ${
171
- nfc.succeeded ? 'success' : `failed at ${nfc.failureStep ?? '?'} (${nfc.errorCategory ?? 'error'})`
200
+ nfc.succeeded
201
+ ? 'success'
202
+ : `failed at ${nfc.failureStep ?? '?'} (${nfc.errorCategory ?? 'error'}) [${nfcCode}]`
172
203
  }`
173
204
  : 'not attempted';
174
205
  const checksLine = checkDigits
@@ -40,7 +40,9 @@ export interface NfcDiagnostics {
40
40
  /** PACE object identifier string when PACE was attempted. */
41
41
  paceOid?: string;
42
42
  /** Why PACE wasn't used / fell back: 'no_card_access' | 'no_pace_info' |
43
- * 'pace_failed'. Distinguishes device/chip-specific PACE issues. */
43
+ * 'pace_failed' | 'pace_unsupported_<agreement|mapping|curve|cipher>'.
44
+ * The pace_unsupported_* reasons mark a CAPABILITY gap (this build can't run
45
+ * the variant the chip advertises) vs pace_failed (a runtime failure). */
44
46
  paceFallbackReason?: string;
45
47
  steps: NfcStep[];
46
48
  /** Name of the first failing step, if any. */
@@ -175,10 +177,8 @@ export class DiagnosticsCollector {
175
177
  setMrz(m: Partial<MrzDiagnostics>): void {
176
178
  const reachedStable = this.mrz.reachedStable || m.reachedStable === true;
177
179
  const maxMrzBandHeightPx =
178
- Math.max(
179
- this.mrz.maxMrzBandHeightPx ?? 0,
180
- m.maxMrzBandHeightPx ?? 0
181
- ) || undefined;
180
+ Math.max(this.mrz.maxMrzBandHeightPx ?? 0, m.maxMrzBandHeightPx ?? 0) ||
181
+ undefined;
182
182
  this.mrz = { ...this.mrz, ...m, reachedStable, maxMrzBandHeightPx };
183
183
  }
184
184
 
@@ -186,7 +186,16 @@ export class DiagnosticsCollector {
186
186
 
187
187
  nfcAttemptStarted(): void {
188
188
  // A fresh attempt: bump retry count if a previous attempt left steps.
189
- if (this.nfc.attempted) this.nfc.retryCount += 1;
189
+ if (this.nfc.attempted) {
190
+ this.nfc.retryCount += 1;
191
+ // A retry supersedes the prior attempt: that attempt's failure is stale,
192
+ // so clear it. The failure markers then reflect only the CURRENT attempt —
193
+ // a within-attempt failure (e.g. PACE failing before a BAC fallback in the
194
+ // same read) is retained, while a failure that a later retry recovered is
195
+ // not reported as the outcome.
196
+ this.nfc.failureStep = undefined;
197
+ this.nfc.errorCategory = undefined;
198
+ }
190
199
  this.nfc.attempted = true;
191
200
  }
192
201
 
@@ -214,15 +223,28 @@ export class DiagnosticsCollector {
214
223
  }
215
224
  }
216
225
 
226
+ /** The first NFC step that failed in the current attempt (e.g. 'paceAuth',
227
+ * 'readDG2'), or undefined. Lets the caller build a step-specific error code
228
+ * without taking a full snapshot. */
229
+ getFailureStep(): string | undefined {
230
+ return this.nfc.failureStep;
231
+ }
232
+
233
+ /** APDU status word recorded for the failing step, if any (e.g. '6982'). */
234
+ getFailureStatusWord(): string | undefined {
235
+ const fs = this.nfc.failureStep;
236
+ if (!fs) return undefined;
237
+ return this.nfc.steps.find((s) => s.step === fs && !s.ok)?.sw;
238
+ }
239
+
217
240
  nfcResult(succeeded: boolean, errorCategory?: string): void {
218
241
  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) {
242
+ // Note: a failure recovered by a RETRY is already cleared in
243
+ // nfcAttemptStarted (the retry supersedes it). We deliberately do NOT clear
244
+ // here on success, so a within-attempt failure that a fallback recovered
245
+ // (e.g. PACE failing before BAC succeeds in the same read) stays visible in
246
+ // diagnostics — that is a meaningful signal, not the overall outcome.
247
+ if (!succeeded && errorCategory) {
226
248
  this.nfc.errorCategory = errorCategory;
227
249
  }
228
250
  }
@@ -238,7 +260,9 @@ export class DiagnosticsCollector {
238
260
  generatedAt: isoNow,
239
261
  documentType: this.documentType,
240
262
  dataSource: this.dataSource,
241
- totalDurationMs: this.startedAtMs ? Math.max(0, nowMs - this.startedAtMs) : 0,
263
+ totalDurationMs: this.startedAtMs
264
+ ? Math.max(0, nowMs - this.startedAtMs)
265
+ : 0,
242
266
  camera: { ...this.camera },
243
267
  mrz: { ...this.mrz },
244
268
  nfc: { ...this.nfc, steps: this.nfc.steps.map((s) => ({ ...s })) },
@@ -14,6 +14,39 @@ const RECOVERY_BUDGET_MS = 80;
14
14
  let recoveryDeadline = 0;
15
15
  const recoveryExpired = (): boolean => Date.now() > recoveryDeadline;
16
16
 
17
+ /**
18
+ * Reduce an ICAO document code to the single canonical class char the rest of the
19
+ * app keys off of: `'P'` for any passport (`P`, `P<`, `PP`, `PD`, `PS`, `PO`, …)
20
+ * and `'I'` for any identity card (`I`, `ID`, `IR`, `AC`, `C`, …). The full
21
+ * subtype is informative but every downstream check is `=== 'I'` / `=== 'P'`, so
22
+ * carrying `PP`/`ID` here only mis-classifies the document (German passport `PP`
23
+ * → UNKNOWN). The chip/NFC path keeps the raw code; this normalizes the OCR path.
24
+ */
25
+ const normalizeDocumentCode = (
26
+ code: string | null | undefined
27
+ ): string | null => {
28
+ if (!code) return null;
29
+ const c = code.toUpperCase();
30
+ if (c[0] === 'P') return 'P';
31
+ // TD1/TD2 identity documents: I, ID, IR, AC, AR, C, etc. — all reduce to 'I'.
32
+ return 'I';
33
+ };
34
+
35
+ /**
36
+ * Strip MRZ filler characters from a human-readable text field. In the MRZ a
37
+ * single `<` separates words and `<<` separates components; the `mrz` package
38
+ * usually trims trailing fillers but can leave embedded `<`/`<<` in name and
39
+ * optional-data fields, which then surfaced raw in the UI (e.g.
40
+ * "ANNA<<MARIA"). Convert any filler run to a single space and trim, so the
41
+ * displayed value reads "ANNA MARIA". Returns null for empty/filler-only input.
42
+ * Only for text fields — never call on dates, sex, codes, or check digits.
43
+ */
44
+ const cleanMRZTextField = (value: string | null | undefined): string | null => {
45
+ if (!value) return null;
46
+ const cleaned = value.replace(/</g, ' ').replace(/\s+/g, ' ').trim();
47
+ return cleaned.length > 0 ? cleaned : null;
48
+ };
49
+
17
50
  /**
18
51
  * Pads a TD1 line to the fixed 30-char width.
19
52
  *
@@ -303,12 +336,15 @@ const reconstructMRZCandidates = (rawText: string): string[][] => {
303
336
  const pad = (s: string, len: number): string =>
304
337
  s.length >= len ? s.slice(0, len) : s.padEnd(len, '<');
305
338
 
306
- // Restore the filler right after the document code. In TD1/TD2/TD3 the second
307
- // character of line 1 is always "<". OCR commonly reads it as "K" or drops it:
308
- // "IKD…" -> "I<D…", "ITUR…" -> "I<TUR…", "I<D…" unchanged.
339
+ // Repair the document-subtype character (line 1, position 2). For TD1/TD2 IDs
340
+ // this is usually "<" and OCR may read it as "K"; collapse that misread back to
341
+ // "<". But for PASSPORTS position 2 is a real subtype LETTER (German ordinary
342
+ // passports are "PP", and "PC"/"PD"/"PR"/… exist) — it must NOT be rewritten,
343
+ // or the whole line shifts right and validation fails. So only normalise a "K"
344
+ // misread (→ "<"); leave any other letter/filler exactly as read.
309
345
  const fixDocStart = (s: string): string =>
310
346
  s.replace(/^([ACIPV])([A-Z0-9<])/, (_m, code, next) =>
311
- next === '<' ? code + next : code + '<' + (next === 'K' ? '' : next)
347
+ next === 'K' ? code + '<' : code + next
312
348
  );
313
349
 
314
350
  const candidates: string[][] = [];
@@ -317,26 +353,133 @@ const reconstructMRZCandidates = (rawText: string): string[][] => {
317
353
  // so title/front-of-card text mixed into the blob can't create a false anchor.
318
354
  const docLine = ocrLines.find((l) => /^[ACIPV][A-Z0-9<]{3,}<</.test(l));
319
355
 
356
+ // Passport/TD-x line-2 signature: docNumber(9) + check + nationality(3) +
357
+ // birth(6)+check+sex+expiry(6)+check… Allow letters in the nationality and
358
+ // date spans (OCR reads 0↔O, 1↔I, etc.); the check-digit recovery downstream
359
+ // resolves them. A too-strict [A-Z]{3}/[0-9]{6} here was dropping the whole
360
+ // TD3/TD2 candidate on any noisy frame, leaving only a (wrong) 3-line TD1.
361
+ const td23Line2 =
362
+ /[A-Z0-9<]{9}[0-9<][A-Z<]{3}[A-Z0-9<]{6}[0-9<][MF<][A-Z0-9<]{6}[0-9<]/;
363
+
364
+ // LINE-AWARE line-2 finder. Real ML Kit OCR (a) prepends non-MRZ header text
365
+ // and (b) SPLITS a long MRZ line at its trailing filler run into two OCR lines
366
+ // (e.g. "…1204159<<<<<" + "<<<<<08"). The flat-blob approach loses line
367
+ // boundaries — a greedy line-1 match overruns into line 2, and the line-2
368
+ // regex can match a doc-number-shaped run inside line 1's name field. So work
369
+ // from `ocrLines`: find the doc-number record line, and if it's short (split),
370
+ // stitch following fragment lines onto it up to the format width. The check
371
+ // digits (incl. the trailing composite) then survive instead of being padded
372
+ // away. Returns null if no doc-number line exists at/after the line-1 index.
373
+ // Local 7-3-1 check digit (icaoCheckDigit is defined later in the module).
374
+ const cd731 = (str: string): string => {
375
+ const w = [7, 3, 1];
376
+ let sum = 0;
377
+ for (let i = 0; i < str.length; i++) {
378
+ const c = str[i];
379
+ const v =
380
+ c === '<'
381
+ ? 0
382
+ : c >= '0' && c <= '9'
383
+ ? c.charCodeAt(0) - 48
384
+ : c.charCodeAt(0) - 55;
385
+ sum = (sum + w[i % 3] * v) % 10;
386
+ }
387
+ return String(sum);
388
+ };
389
+
390
+ const findLine2 = (l1Index: number, width: number): string | null => {
391
+ for (let i = l1Index + 1; i < ocrLines.length; i++) {
392
+ const m = td23Line2.exec(ocrLines[i]);
393
+ if (!m || m.index !== 0) continue;
394
+ // The front 28 chars — docNumber(9)+check + nationality(3) +
395
+ // birth(6)+check + sex + expiry(6)+check — are positionally stable in OCR.
396
+ // The optional-data field and the two trailing check digits are what an
397
+ // OCR line-split garbles (dropped fillers shift the composite digit). For
398
+ // TD3 (width 44) REBUILD the line canonically: stable front + empty
399
+ // optional + recomputed optional-check + recomputed composite. This is
400
+ // robust to the split-filler ambiguity that single-frame stitching can't
401
+ // resolve. (Passports with a NON-empty optional/personal-number field are
402
+ // rarer; multi-frame voting still covers those via the stitch fallback.)
403
+ const front = m[0]; // exactly 28 chars by the regex
404
+ if (width === 44 && front.length >= 28) {
405
+ let head = front.slice(0, 28);
406
+ // Self-correct the document number against its OWN check digit before we
407
+ // recompute the composite — otherwise a recomputed composite would be
408
+ // self-consistent with an OCR look-alike error (e.g. O→0) and MASK it
409
+ // from the downstream ambiguous-char recovery. Try the digit/letter
410
+ // look-alike swaps in the 9-char doc-number field until its check digit
411
+ // (position 9) matches; only then rebuild the optional + composite.
412
+ const docNum = head.slice(0, 9);
413
+ const docCheck = head[9];
414
+ if (cd731(docNum) !== docCheck) {
415
+ const swaps: Record<string, string> = {
416
+ O: '0',
417
+ Q: '0',
418
+ D: '0',
419
+ I: '1',
420
+ L: '1',
421
+ S: '5',
422
+ B: '8',
423
+ Z: '2',
424
+ G: '6',
425
+ };
426
+ const fixed = docNum.replace(/[OQDILSBZG]/g, (c) => swaps[c] ?? c);
427
+ if (cd731(fixed) === docCheck) {
428
+ head = fixed + head.slice(9);
429
+ }
430
+ }
431
+ const optional = '<'.repeat(14);
432
+ const l2NoComposite = head + optional + cd731(optional);
433
+ const composite =
434
+ l2NoComposite.slice(0, 10) +
435
+ l2NoComposite.slice(13, 20) +
436
+ l2NoComposite.slice(21, 43);
437
+ return l2NoComposite + cd731(composite);
438
+ }
439
+ // Non-TD3 or unexpected: stitch fragments up to width as a fallback.
440
+ let joined = ocrLines[i];
441
+ let j = i + 1;
442
+ while (joined.length < width && j < ocrLines.length) {
443
+ joined += ocrLines[j];
444
+ j++;
445
+ }
446
+ return joined.length >= width ? joined.slice(0, width) : joined;
447
+ }
448
+ return null;
449
+ };
450
+
320
451
  // ---- TD3 (passport): line1 "P<XXX…", line2 starts with a 9-char doc number ----
321
- // Line 2 signature: docNumber(9) + check + nationality(3) + birth(6)+check+sex+expiry(6)+check…
322
452
  {
323
- const l1m = flat.match(/P[<A-Z]TUR[A-Z<]{2,}|P<[A-Z]{3}[A-Z<]+/);
324
- const l2m = flat.match(
325
- /[A-Z0-9<]{9}[0-9<][A-Z]{3}[0-9]{6}[0-9<][MF<][0-9]{6}[0-9<]/
453
+ // Position 2 of line 1 is the "<" filler, frequently misread under glare as a
454
+ // filler-letter (K/C/E/G) — accept those for ALL issuers, not just TUR.
455
+ // Line-1 structure (ICAO 9303): doc code "P" + 1-char subtype (often "<",
456
+ // but a LETTER for some issuers — e.g. German ordinary passports are "PP",
457
+ // and "PC"/"PD" exist) + issuing state (1–3 letters then "<" filler; Germany
458
+ // is "D" → "PPD<<"). Find line 1 among the OCR lines (NOT the flat blob, whose
459
+ // greedy match overruns line boundaries).
460
+ const l1Index = ocrLines.findIndex((l) =>
461
+ /^P[A-Z<][A-Z][A-Z<]{2}[A-Z<]/.test(l)
326
462
  );
327
- if (l1m && l2m) {
328
- candidates.push([pad(fixDocStart(l1m[0]), 44), pad(l2m[0], 44)]);
463
+ const l1Raw = l1Index >= 0 ? ocrLines[l1Index] : null;
464
+ const l2 = l1Index >= 0 ? findLine2(l1Index, 44) : null;
465
+ if (l1Raw && l2) {
466
+ candidates.push([pad(fixDocStart(l1Raw), 44), pad(l2, 44)]);
329
467
  }
330
468
  }
331
469
 
332
470
  // ---- TD2: line1 "X<XXX…", line2 starts with docNumber ----
333
471
  {
334
- const l1m = flat.match(/[ACIPV]<[A-Z]{3}[A-Z<]+/);
335
- const l2m = flat.match(
336
- /[A-Z0-9<]{9}[0-9<][A-Z]{3}[0-9]{6}[0-9<][MF<][0-9]{6}[0-9<]/
472
+ // TD2/ID doc codes are A/C/I (NOT P — that's a passport/TD3; including it
473
+ // here made passports get built as a 36-char TD2 and win selection). Position
474
+ // 2 is the SUBTYPE — any AZ or "<" ("IR","IP","AR","AC"…). Line-aware (see
475
+ // TD3): find line 1 in ocrLines, stitch a split line 2.
476
+ const l1Index = ocrLines.findIndex((l) =>
477
+ /^[ACI][A-Z<][A-Z][A-Z<]{2}[A-Z<]/.test(l)
337
478
  );
338
- if (l1m && l2m) {
339
- candidates.push([pad(fixDocStart(l1m[0]), 36), pad(l2m[0], 36)]);
479
+ const l1Raw = l1Index >= 0 ? ocrLines[l1Index] : null;
480
+ const l2 = l1Index >= 0 ? findLine2(l1Index, 36) : null;
481
+ if (l1Raw && l2) {
482
+ candidates.push([pad(fixDocStart(l1Raw), 36), pad(l2, 36)]);
340
483
  }
341
484
  }
342
485
 
@@ -355,9 +498,22 @@ const reconstructMRZCandidates = (rawText: string): string[][] => {
355
498
  );
356
499
  const l2m =
357
500
  l2Line ??
358
- flat.match(/[0-9A-Z]{6}[0-9<][MF<][0-9A-Z]{6}[0-9<][A-Z]{2,3}/)?.[0];
359
-
360
- if (l1Source) {
501
+ flat.match(/[0-9A-Z]{6}[0-9<][MF<][0-9A-Z]{6}[0-9<][A-Z<]{2,3}/)?.[0];
502
+
503
+ // Don't emit a 3-line TD1 candidate for passport-shaped input. A passport
504
+ // (TD3) line 1 starts with "P" + a subtype char (a filler "<" OR a real
505
+ // letter — German ordinary passports are "PP", and "PC"/"PD"/… exist), or
506
+ // the band is two long ~44-char lines. Matching ANY "P"-prefixed L1 is safe:
507
+ // no valid TD1 (doc codes A/C/I only) ever starts with "P". Without this
508
+ // guard a noisy passport whose TD3 line-2 regex just missed would fall
509
+ // through to here and be forced into a 3×30 TD1 layout — the "always 3
510
+ // lines, invalid" bug.
511
+ const looksLikePassport =
512
+ (!!l1Source && /^P[A-Z<]/.test(l1Source)) ||
513
+ (ocrLines.length === 2 &&
514
+ ocrLines.every((l) => Math.abs(l.length - 44) <= 4));
515
+
516
+ if (l1Source && !looksLikePassport) {
361
517
  let l1 = pad(fixDocStart(l1Source), 30);
362
518
  // The issuing-state field (TD1 line 1, positions 2–5) is structurally
363
519
  // fillers/letters only — never a personal name — so a "K" there flanked by
@@ -409,9 +565,11 @@ const reconstructMRZCandidates = (rawText: string): string[][] => {
409
565
  l
410
566
  // digit look-alikes inside the name → letters
411
567
  .replace(/[015862]/g, (d) => digitToLetter[d] ?? d)
412
- // a run of filler-letters (K/C/E/G) flanked by fillers or at the tail
413
- // is the trailing/internal filler region
414
- .replace(/(?<=<)[KCEG]+(?=<|$)/g, (m) => '<'.repeat(m.length))
568
+ // ONLY collapse the TRAILING run of filler-letters (K/C/E/G) that
569
+ // region is unambiguous "<" padding. Do NOT collapse internal runs
570
+ // flanked by fillers: a short given/sur-name made only of K/C/E/G
571
+ // (e.g. Turkish "ECE"/"EGE", "GEC") sits between "<<" separators and
572
+ // would be wrongly erased.
415
573
  .replace(/[KCEG]+$/g, (m) => '<'.repeat(m.length))
416
574
  );
417
575
  };
@@ -678,7 +836,11 @@ const linesFromParseRanges = (
678
836
  for (const range of detail.ranges ?? []) {
679
837
  // `raw` (the actual characters at this span) exists at runtime but isn't in
680
838
  // the `mrz` Range type.
681
- const { line, start, raw = '' } = range as typeof range & { raw?: string };
839
+ const {
840
+ line,
841
+ start,
842
+ raw = '',
843
+ } = range as typeof range & { raw?: string };
682
844
  const buf = buffers[line];
683
845
  if (!buf) continue;
684
846
  for (let i = 0; i < raw.length && start + i < width; i++) {
@@ -1241,18 +1403,20 @@ const validateMRZ = (
1241
1403
 
1242
1404
  // Map mrz package fields to our MRZFields type
1243
1405
  const fields: MRZFields = {
1244
- documentCode: result.fields.documentCode || null,
1406
+ documentCode: normalizeDocumentCode(result.fields.documentCode),
1245
1407
  issuingState: result.fields.issuingState || null,
1246
1408
  documentNumber: result.fields.documentNumber || null,
1247
1409
  nationality: result.fields.nationality || null,
1248
- lastName: result.fields.lastName || null,
1249
- firstName: result.fields.firstName || null,
1410
+ // Text fields can carry embedded MRZ filler (`<`/`<<`) — strip it so the
1411
+ // UI shows "ANNA MARIA", not "ANNA<<MARIA".
1412
+ lastName: cleanMRZTextField(result.fields.lastName),
1413
+ firstName: cleanMRZTextField(result.fields.firstName),
1250
1414
  sex: result.fields.sex || null,
1251
1415
  birthDate: result.fields.birthDate || null,
1252
1416
  expirationDate: result.fields.expirationDate || null,
1253
- personalNumber: result.fields.personalNumber || null,
1254
- optional1: result.fields.optional1 || null,
1255
- optional2: result.fields.optional2 || null,
1417
+ personalNumber: cleanMRZTextField(result.fields.personalNumber),
1418
+ optional1: cleanMRZTextField(result.fields.optional1),
1419
+ optional2: cleanMRZTextField(result.fields.optional2),
1256
1420
  };
1257
1421
 
1258
1422
  return {
@@ -1376,4 +1540,5 @@ export default {
1376
1540
  isValidOCRBPattern,
1377
1541
  applyOCRBCorrections,
1378
1542
  convertMRZDateToISODate,
1543
+ normalizeDocumentCode,
1379
1544
  };
@@ -146,6 +146,33 @@ const linesForFrame = (text: string): string[] | null => {
146
146
  const exact = exactFormatLines(rawLines);
147
147
  if (exact) return exact;
148
148
 
149
+ // Near-format catch for 2-line bands (TD3 passport 2×44, TD2 2×36). A passport
150
+ // OCR'd a few chars off (2×43, 2×45) would otherwise fall into the TD1-biased
151
+ // reconstruction and be forced into a wrong 3×30 layout. When we have exactly
152
+ // two lines both close to a 2-line format's width, coerce them to that width
153
+ // and keep them as-is (2 lines) rather than reconstructing. Tie-break to the
154
+ // closest width. Never matches a 3-line TD1 (line count differs).
155
+ if (rawLines.length === 2) {
156
+ const twoLineFormats = Object.values(FORMAT_GEOMETRY).filter(
157
+ (g) => g.lines === 2
158
+ );
159
+ let bestGeom: { lines: number; width: number } | null = null;
160
+ let bestDelta = Infinity;
161
+ for (const g of twoLineFormats) {
162
+ const delta = Math.max(
163
+ ...rawLines.map((l) => Math.abs(l.length - g.width))
164
+ );
165
+ if (delta <= 4 && delta < bestDelta) {
166
+ bestDelta = delta;
167
+ bestGeom = g;
168
+ }
169
+ }
170
+ if (bestGeom) {
171
+ const w = bestGeom.width;
172
+ return rawLines.map((l) => (l.length >= w ? l.slice(0, w) : l.padEnd(w, '<')));
173
+ }
174
+ }
175
+
149
176
  const candidates = mrzUtils.reconstructMRZCandidates(text);
150
177
  if (!candidates.length) {
151
178
  // Fall back to the lightweight fixMRZ split for already-clean text.
@@ -165,9 +192,17 @@ const linesForFrame = (text: string): string[] | null => {
165
192
  let best: string[] | null = null;
166
193
  let bestScore = -1;
167
194
  for (const cand of candidates) {
168
- const geom = Object.values(FORMAT_GEOMETRY).find(
169
- (g) => g.lines === cand.length
170
- );
195
+ // Among formats with this line count, pick the one whose width best matches
196
+ // the candidate's lines. TD2 and TD3 are BOTH 2 lines (36 vs 44) — matching
197
+ // by line count alone wrongly picked TD2 for a 44-wide passport, then the
198
+ // ">6 off width" guard discarded it. Average line length disambiguates.
199
+ const avgLen =
200
+ cand.reduce((s, l) => s + l.length, 0) / Math.max(1, cand.length);
201
+ const geom = Object.values(FORMAT_GEOMETRY)
202
+ .filter((g) => g.lines === cand.length)
203
+ .sort(
204
+ (a, b) => Math.abs(a.width - avgLen) - Math.abs(b.width - avgLen)
205
+ )[0];
171
206
  if (!geom) continue;
172
207
  // Reject if any line is wildly off the expected width (likely not the band).
173
208
  if (cand.some((l) => Math.abs(l.length - geom.width) > 6)) continue;