@trustchex/react-native-sdk 1.486.0 → 1.487.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.
@@ -6,6 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
6
6
  import NFCManager from 'react-native-nfc-manager';
7
7
  import DeviceInfo from 'react-native-device-info';
8
8
  import PermissionManager from "../Libs/permissions.utils.js";
9
+ import mrzUtils from "../Libs/mrz.utils.js";
9
10
  import { debugError } from "../Libs/debug.utils.js";
10
11
  import { eidReader } from "../EIDReader/eidReader.js";
11
12
  import NativeProgressBar from "./NativeProgressBar.js";
@@ -104,7 +105,7 @@ const EIDScanner = ({
104
105
  const [attemptNumber, setAttemptNumber] = useState(0);
105
106
  const getFieldsFromMRZ = useCallback(mrz => {
106
107
  return {
107
- documentCode: mrz.getDocumentCode(),
108
+ documentCode: mrzUtils.normalizeDocumentCode(mrz.getDocumentCode()),
108
109
  personalNumber: mrz.getPersonalNumber() || mrz.getOptionalData1(),
109
110
  documentNumber: mrz.getDocumentNumber(),
110
111
  firstName: mrz.getSecondaryIdentifier(),
@@ -14,6 +14,22 @@ const RECOVERY_BUDGET_MS = 80;
14
14
  let recoveryDeadline = 0;
15
15
  const recoveryExpired = () => 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 = code => {
26
+ if (!code) return null;
27
+ const c = code.toUpperCase();
28
+ if (c[0] === 'P') return 'P';
29
+ // TD1/TD2 identity documents: I, ID, IR, AC, AR, C, etc. — all reduce to 'I'.
30
+ return 'I';
31
+ };
32
+
17
33
  /**
18
34
  * Pads a TD1 line to the fixed 30-char width.
19
35
  *
@@ -271,32 +287,132 @@ const reconstructMRZCandidates = rawText => {
271
287
  const flat = ocrLines.join('');
272
288
  const pad = (s, len) => s.length >= len ? s.slice(0, len) : s.padEnd(len, '<');
273
289
 
274
- // Restore the filler right after the document code. In TD1/TD2/TD3 the second
275
- // character of line 1 is always "<". OCR commonly reads it as "K" or drops it:
276
- // "IKD…" -> "I<D…", "ITUR…" -> "I<TUR…", "I<D…" unchanged.
277
- const fixDocStart = s => s.replace(/^([ACIPV])([A-Z0-9<])/, (_m, code, next) => next === '<' ? code + next : code + '<' + (next === 'K' ? '' : next));
290
+ // Repair the document-subtype character (line 1, position 2). For TD1/TD2 IDs
291
+ // this is usually "<" and OCR may read it as "K"; collapse that misread back to
292
+ // "<". But for PASSPORTS position 2 is a real subtype LETTER (German ordinary
293
+ // passports are "PP", and "PC"/"PD"/"PR"/… exist) it must NOT be rewritten,
294
+ // or the whole line shifts right and validation fails. So only normalise a "K"
295
+ // misread (→ "<"); leave any other letter/filler exactly as read.
296
+ const fixDocStart = s => s.replace(/^([ACIPV])([A-Z0-9<])/, (_m, code, next) => next === 'K' ? code + '<' : code + next);
278
297
  const candidates = [];
279
298
 
280
299
  // Helper: the OCR line that actually starts the MRZ (doc code + state pattern),
281
300
  // so title/front-of-card text mixed into the blob can't create a false anchor.
282
301
  const docLine = ocrLines.find(l => /^[ACIPV][A-Z0-9<]{3,}<</.test(l));
283
302
 
303
+ // Passport/TD-x line-2 signature: docNumber(9) + check + nationality(3) +
304
+ // birth(6)+check+sex+expiry(6)+check… Allow letters in the nationality and
305
+ // date spans (OCR reads 0↔O, 1↔I, etc.); the check-digit recovery downstream
306
+ // resolves them. A too-strict [A-Z]{3}/[0-9]{6} here was dropping the whole
307
+ // TD3/TD2 candidate on any noisy frame, leaving only a (wrong) 3-line TD1.
308
+ const td23Line2 = /[A-Z0-9<]{9}[0-9<][A-Z<]{3}[A-Z0-9<]{6}[0-9<][MF<][A-Z0-9<]{6}[0-9<]/;
309
+
310
+ // LINE-AWARE line-2 finder. Real ML Kit OCR (a) prepends non-MRZ header text
311
+ // and (b) SPLITS a long MRZ line at its trailing filler run into two OCR lines
312
+ // (e.g. "…1204159<<<<<" + "<<<<<08"). The flat-blob approach loses line
313
+ // boundaries — a greedy line-1 match overruns into line 2, and the line-2
314
+ // regex can match a doc-number-shaped run inside line 1's name field. So work
315
+ // from `ocrLines`: find the doc-number record line, and if it's short (split),
316
+ // stitch following fragment lines onto it up to the format width. The check
317
+ // digits (incl. the trailing composite) then survive instead of being padded
318
+ // away. Returns null if no doc-number line exists at/after the line-1 index.
319
+ // Local 7-3-1 check digit (icaoCheckDigit is defined later in the module).
320
+ const cd731 = str => {
321
+ const w = [7, 3, 1];
322
+ let sum = 0;
323
+ for (let i = 0; i < str.length; i++) {
324
+ const c = str[i];
325
+ const v = c === '<' ? 0 : c >= '0' && c <= '9' ? c.charCodeAt(0) - 48 : c.charCodeAt(0) - 55;
326
+ sum = (sum + w[i % 3] * v) % 10;
327
+ }
328
+ return String(sum);
329
+ };
330
+ const findLine2 = (l1Index, width) => {
331
+ for (let i = l1Index + 1; i < ocrLines.length; i++) {
332
+ const m = td23Line2.exec(ocrLines[i]);
333
+ if (!m || m.index !== 0) continue;
334
+ // The front 28 chars — docNumber(9)+check + nationality(3) +
335
+ // birth(6)+check + sex + expiry(6)+check — are positionally stable in OCR.
336
+ // The optional-data field and the two trailing check digits are what an
337
+ // OCR line-split garbles (dropped fillers shift the composite digit). For
338
+ // TD3 (width 44) REBUILD the line canonically: stable front + empty
339
+ // optional + recomputed optional-check + recomputed composite. This is
340
+ // robust to the split-filler ambiguity that single-frame stitching can't
341
+ // resolve. (Passports with a NON-empty optional/personal-number field are
342
+ // rarer; multi-frame voting still covers those via the stitch fallback.)
343
+ const front = m[0]; // exactly 28 chars by the regex
344
+ if (width === 44 && front.length >= 28) {
345
+ let head = front.slice(0, 28);
346
+ // Self-correct the document number against its OWN check digit before we
347
+ // recompute the composite — otherwise a recomputed composite would be
348
+ // self-consistent with an OCR look-alike error (e.g. O→0) and MASK it
349
+ // from the downstream ambiguous-char recovery. Try the digit/letter
350
+ // look-alike swaps in the 9-char doc-number field until its check digit
351
+ // (position 9) matches; only then rebuild the optional + composite.
352
+ const docNum = head.slice(0, 9);
353
+ const docCheck = head[9];
354
+ if (cd731(docNum) !== docCheck) {
355
+ const swaps = {
356
+ O: '0',
357
+ Q: '0',
358
+ D: '0',
359
+ I: '1',
360
+ L: '1',
361
+ S: '5',
362
+ B: '8',
363
+ Z: '2',
364
+ G: '6'
365
+ };
366
+ const fixed = docNum.replace(/[OQDILSBZG]/g, c => swaps[c] ?? c);
367
+ if (cd731(fixed) === docCheck) {
368
+ head = fixed + head.slice(9);
369
+ }
370
+ }
371
+ const optional = '<'.repeat(14);
372
+ const l2NoComposite = head + optional + cd731(optional);
373
+ const composite = l2NoComposite.slice(0, 10) + l2NoComposite.slice(13, 20) + l2NoComposite.slice(21, 43);
374
+ return l2NoComposite + cd731(composite);
375
+ }
376
+ // Non-TD3 or unexpected: stitch fragments up to width as a fallback.
377
+ let joined = ocrLines[i];
378
+ let j = i + 1;
379
+ while (joined.length < width && j < ocrLines.length) {
380
+ joined += ocrLines[j];
381
+ j++;
382
+ }
383
+ return joined.length >= width ? joined.slice(0, width) : joined;
384
+ }
385
+ return null;
386
+ };
387
+
284
388
  // ---- TD3 (passport): line1 "P<XXX…", line2 starts with a 9-char doc number ----
285
- // Line 2 signature: docNumber(9) + check + nationality(3) + birth(6)+check+sex+expiry(6)+check…
286
389
  {
287
- const l1m = flat.match(/P[<A-Z]TUR[A-Z<]{2,}|P<[A-Z]{3}[A-Z<]+/);
288
- const l2m = flat.match(/[A-Z0-9<]{9}[0-9<][A-Z]{3}[0-9]{6}[0-9<][MF<][0-9]{6}[0-9<]/);
289
- if (l1m && l2m) {
290
- candidates.push([pad(fixDocStart(l1m[0]), 44), pad(l2m[0], 44)]);
390
+ // Position 2 of line 1 is the "<" filler, frequently misread under glare as a
391
+ // filler-letter (K/C/E/G) — accept those for ALL issuers, not just TUR.
392
+ // Line-1 structure (ICAO 9303): doc code "P" + 1-char subtype (often "<",
393
+ // but a LETTER for some issuers — e.g. German ordinary passports are "PP",
394
+ // and "PC"/"PD" exist) + issuing state (1–3 letters then "<" filler; Germany
395
+ // is "D" → "PPD<<"). Find line 1 among the OCR lines (NOT the flat blob, whose
396
+ // greedy match overruns line boundaries).
397
+ const l1Index = ocrLines.findIndex(l => /^P[A-Z<][A-Z][A-Z<]{2}[A-Z<]/.test(l));
398
+ const l1Raw = l1Index >= 0 ? ocrLines[l1Index] : null;
399
+ const l2 = l1Index >= 0 ? findLine2(l1Index, 44) : null;
400
+ if (l1Raw && l2) {
401
+ candidates.push([pad(fixDocStart(l1Raw), 44), pad(l2, 44)]);
291
402
  }
292
403
  }
293
404
 
294
405
  // ---- TD2: line1 "X<XXX…", line2 starts with docNumber ----
295
406
  {
296
- const l1m = flat.match(/[ACIPV]<[A-Z]{3}[A-Z<]+/);
297
- const l2m = flat.match(/[A-Z0-9<]{9}[0-9<][A-Z]{3}[0-9]{6}[0-9<][MF<][0-9]{6}[0-9<]/);
298
- if (l1m && l2m) {
299
- candidates.push([pad(fixDocStart(l1m[0]), 36), pad(l2m[0], 36)]);
407
+ // TD2/ID doc codes are A/C/I (NOT P — that's a passport/TD3; including it
408
+ // here made passports get built as a 36-char TD2 and win selection). Position
409
+ // 2 is the SUBTYPE — any A–Z or "<" ("IR","IP","AR","AC"…). Line-aware (see
410
+ // TD3): find line 1 in ocrLines, stitch a split line 2.
411
+ const l1Index = ocrLines.findIndex(l => /^[ACI][A-Z<][A-Z][A-Z<]{2}[A-Z<]/.test(l));
412
+ const l1Raw = l1Index >= 0 ? ocrLines[l1Index] : null;
413
+ const l2 = l1Index >= 0 ? findLine2(l1Index, 36) : null;
414
+ if (l1Raw && l2) {
415
+ candidates.push([pad(fixDocStart(l1Raw), 36), pad(l2, 36)]);
300
416
  }
301
417
  }
302
418
 
@@ -309,8 +425,18 @@ const reconstructMRZCandidates = rawText => {
309
425
  // birth(6)+check+sex+expiry(6)+check+state(3). Allow letters in date spans
310
426
  // for OCR slips; check-digit recovery resolves them.
311
427
  const l2Line = ocrLines.find(l => /^[0-9A-Z]{6}[0-9<][MF<][0-9A-Z]{6}[0-9<][A-Z<]{2,}/.test(l));
312
- const l2m = l2Line ?? flat.match(/[0-9A-Z]{6}[0-9<][MF<][0-9A-Z]{6}[0-9<][A-Z]{2,3}/)?.[0];
313
- if (l1Source) {
428
+ const l2m = l2Line ?? flat.match(/[0-9A-Z]{6}[0-9<][MF<][0-9A-Z]{6}[0-9<][A-Z<]{2,3}/)?.[0];
429
+
430
+ // Don't emit a 3-line TD1 candidate for passport-shaped input. A passport
431
+ // (TD3) line 1 starts with "P" + a subtype char (a filler "<" OR a real
432
+ // letter — German ordinary passports are "PP", and "PC"/"PD"/… exist), or
433
+ // the band is two long ~44-char lines. Matching ANY "P"-prefixed L1 is safe:
434
+ // no valid TD1 (doc codes A/C/I only) ever starts with "P". Without this
435
+ // guard a noisy passport whose TD3 line-2 regex just missed would fall
436
+ // through to here and be forced into a 3×30 TD1 layout — the "always 3
437
+ // lines, invalid" bug.
438
+ const looksLikePassport = !!l1Source && /^P[A-Z<]/.test(l1Source) || ocrLines.length === 2 && ocrLines.every(l => Math.abs(l.length - 44) <= 4);
439
+ if (l1Source && !looksLikePassport) {
314
440
  let l1 = pad(fixDocStart(l1Source), 30);
315
441
  // The issuing-state field (TD1 line 1, positions 2–5) is structurally
316
442
  // fillers/letters only — never a personal name — so a "K" there flanked by
@@ -358,9 +484,12 @@ const reconstructMRZCandidates = rawText => {
358
484
  return l
359
485
  // digit look-alikes inside the name → letters
360
486
  .replace(/[015862]/g, d => digitToLetter[d] ?? d)
361
- // a run of filler-letters (K/C/E/G) flanked by fillers or at the tail
362
- // is the trailing/internal filler region
363
- .replace(/(?<=<)[KCEG]+(?=<|$)/g, m => '<'.repeat(m.length)).replace(/[KCEG]+$/g, m => '<'.repeat(m.length));
487
+ // ONLY collapse the TRAILING run of filler-letters (K/C/E/G) that
488
+ // region is unambiguous "<" padding. Do NOT collapse internal runs
489
+ // flanked by fillers: a short given/sur-name made only of K/C/E/G
490
+ // (e.g. Turkish "ECE"/"EGE", "GEC") sits between "<<" separators and
491
+ // would be wrongly erased.
492
+ .replace(/[KCEG]+$/g, m => '<'.repeat(m.length));
364
493
  };
365
494
  const nameRuns = ocrLines.filter(isNameLike);
366
495
  const bestName = nameRuns.sort((a, b) => b.length - a.length)[0] ?? '';
@@ -1131,7 +1260,7 @@ const validateMRZ = (mrzText, autocorrect = true) => {
1131
1260
 
1132
1261
  // Map mrz package fields to our MRZFields type
1133
1262
  const fields = {
1134
- documentCode: result.fields.documentCode || null,
1263
+ documentCode: normalizeDocumentCode(result.fields.documentCode),
1135
1264
  issuingState: result.fields.issuingState || null,
1136
1265
  documentNumber: result.fields.documentNumber || null,
1137
1266
  nationality: result.fields.nationality || null,
@@ -1254,5 +1383,6 @@ export default {
1254
1383
  assessMRZQuality,
1255
1384
  isValidOCRBPattern,
1256
1385
  applyOCRBCorrections,
1257
- convertMRZDateToISODate
1386
+ convertMRZDateToISODate,
1387
+ normalizeDocumentCode
1258
1388
  };
@@ -69,6 +69,29 @@ const linesForFrame = text => {
69
69
  const rawLines = text.split('\n').map(l => l.trim().replace(/[^A-Z0-9<]/gi, '').toUpperCase()).filter(Boolean);
70
70
  const exact = exactFormatLines(rawLines);
71
71
  if (exact) return exact;
72
+
73
+ // Near-format catch for 2-line bands (TD3 passport 2×44, TD2 2×36). A passport
74
+ // OCR'd a few chars off (2×43, 2×45) would otherwise fall into the TD1-biased
75
+ // reconstruction and be forced into a wrong 3×30 layout. When we have exactly
76
+ // two lines both close to a 2-line format's width, coerce them to that width
77
+ // and keep them as-is (2 lines) rather than reconstructing. Tie-break to the
78
+ // closest width. Never matches a 3-line TD1 (line count differs).
79
+ if (rawLines.length === 2) {
80
+ const twoLineFormats = Object.values(FORMAT_GEOMETRY).filter(g => g.lines === 2);
81
+ let bestGeom = null;
82
+ let bestDelta = Infinity;
83
+ for (const g of twoLineFormats) {
84
+ const delta = Math.max(...rawLines.map(l => Math.abs(l.length - g.width)));
85
+ if (delta <= 4 && delta < bestDelta) {
86
+ bestDelta = delta;
87
+ bestGeom = g;
88
+ }
89
+ }
90
+ if (bestGeom) {
91
+ const w = bestGeom.width;
92
+ return rawLines.map(l => l.length >= w ? l.slice(0, w) : l.padEnd(w, '<'));
93
+ }
94
+ }
72
95
  const candidates = mrzUtils.reconstructMRZCandidates(text);
73
96
  if (!candidates.length) {
74
97
  // Fall back to the lightweight fixMRZ split for already-clean text.
@@ -84,7 +107,12 @@ const linesForFrame = text => {
84
107
  let best = null;
85
108
  let bestScore = -1;
86
109
  for (const cand of candidates) {
87
- const geom = Object.values(FORMAT_GEOMETRY).find(g => g.lines === cand.length);
110
+ // Among formats with this line count, pick the one whose width best matches
111
+ // the candidate's lines. TD2 and TD3 are BOTH 2 lines (36 vs 44) — matching
112
+ // by line count alone wrongly picked TD2 for a 44-wide passport, then the
113
+ // ">6 off width" guard discarded it. Average line length disambiguates.
114
+ const avgLen = cand.reduce((s, l) => s + l.length, 0) / Math.max(1, cand.length);
115
+ const geom = Object.values(FORMAT_GEOMETRY).filter(g => g.lines === cand.length).sort((a, b) => Math.abs(a.width - avgLen) - Math.abs(b.width - avgLen))[0];
88
116
  if (!geom) continue;
89
117
  // Reject if any line is wildly off the expected width (likely not the band).
90
118
  if (cand.some(l => Math.abs(l.length - geom.width) > 6)) continue;
@@ -2,4 +2,4 @@
2
2
 
3
3
  // This file is auto-generated. Do not edit manually.
4
4
  // Version is synced from package.json during build.
5
- export const SDK_VERSION = '1.486.0';
5
+ export const SDK_VERSION = '1.487.0';
@@ -1 +1 @@
1
- {"version":3,"file":"EIDScanner.d.ts","sourceRoot":"","sources":["../../../../../src/Shared/Components/EIDScanner.tsx"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAgBpD,UAAU,eAAe;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,CACd,OAAO,EAAE,SAAS,EAClB,SAAS,EAAE,MAAM,EACjB,iBAAiB,EAAE,MAAM,KACtB,IAAI,CAAC;IACV,cAAc,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;CAC/C;AAED,QAAA,MAAM,UAAU,GAAI,6FAOjB,eAAe,4CAsrBjB,CAAC;AA4NF,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"EIDScanner.d.ts","sourceRoot":"","sources":["../../../../../src/Shared/Components/EIDScanner.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAgBpD,UAAU,eAAe;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,CACd,OAAO,EAAE,SAAS,EAClB,SAAS,EAAE,MAAM,EACjB,iBAAiB,EAAE,MAAM,KACtB,IAAI,CAAC;IACV,cAAc,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;CAC/C;AAED,QAAA,MAAM,UAAU,GAAI,6FAOjB,eAAe,4CAsrBjB,CAAC;AA4NF,eAAe,UAAU,CAAC"}
@@ -33,6 +33,7 @@ declare const _default: {
33
33
  isValidOCRBPattern: (text: string) => boolean;
34
34
  applyOCRBCorrections: (text: string) => string;
35
35
  convertMRZDateToISODate: (mrzDate?: string | null) => string | null;
36
+ normalizeDocumentCode: (code: string | null | undefined) => string | null;
36
37
  };
37
38
  export default _default;
38
39
  //# sourceMappingURL=mrz.utils.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"mrz.utils.d.ts","sourceRoot":"","sources":["../../../../../src/Shared/Libs/mrz.utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AA2CpD;;GAEG;AACH,MAAM,MAAM,SAAS,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,SAAS,CAAC;AAE1D;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,SAAS,CAAC;IAClB,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;;sBAOwB,MAAM,KAAG,MAAM;2BA8gC7B,MAAM,gBACF,OAAO,KACnB,mBAAmB;0CAsKuB,MAAM,KAAG,mBAAmB;wCAj/B9B,MAAM,KAAG,MAAM,EAAE,EAAE;wCA2/BnB,MAAM,KAAG,MAAM;gCAmDvB,MAAM;;;;;+BAz1BP,MAAM,KAAG,OAAO;iCA4Bd,MAAM,KAAG,MAAM;wCA20BR,MAAM,GAAG,IAAI,KAAG,MAAM,GAAG,IAAI;;AAgBxE,wBAUE"}
1
+ {"version":3,"file":"mrz.utils.d.ts","sourceRoot":"","sources":["../../../../../src/Shared/Libs/mrz.utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AA6DpD;;GAEG;AACH,MAAM,MAAM,SAAS,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,SAAS,CAAC;AAE1D;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,SAAS,CAAC;IAClB,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;;sBAOwB,MAAM,KAAG,MAAM;2BA2oC7B,MAAM,gBACF,OAAO,KACnB,mBAAmB;0CAsKuB,MAAM,KAAG,mBAAmB;wCA9mC9B,MAAM,KAAG,MAAM,EAAE,EAAE;wCAwnCnB,MAAM,KAAG,MAAM;gCAmDvB,MAAM;;;;;+BAz1BP,MAAM,KAAG,OAAO;iCA4Bd,MAAM,KAAG,MAAM;wCA20BR,MAAM,GAAG,IAAI,KAAG,MAAM,GAAG,IAAI;kCA97ChE,MAAM,GAAG,IAAI,GAAG,SAAS,KAC9B,MAAM,GAAG,IAAI;;AA68ChB,wBAWE"}
@@ -1 +1 @@
1
- {"version":3,"file":"mrzFrameAggregator.d.ts","sourceRoot":"","sources":["../../../../../src/Shared/Libs/mrzFrameAggregator.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAkCvD,MAAM,WAAW,aAAa;IAC5B,6EAA6E;IAC7E,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;;;OAYG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AASD,MAAM,WAAW,YAAY;IAC3B,kFAAkF;IAClF,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,+EAA+E;IAC/E,UAAU,EAAE,mBAAmB,GAAG,IAAI,CAAC;IACvC,kEAAkE;IAClE,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,MAAM,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,kBAAkB,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,yBAAyB;IACxC;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mFAAmF;IACnF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAkFD;;;;GAIG;AACH,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAInC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,OAAO,CAAC,OAAO,CAAqC;IACpD,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,gBAAgB,CAAuB;IAC/C,OAAO,CAAC,YAAY,CAAK;IAEzB,OAAO,CAAC,cAAc,CAAK;gBAEf,OAAO,GAAE,yBAA8B;IAMnD,KAAK,IAAI,IAAI;IAQb;;;;;;OAMG;IACH,OAAO,CAAC,oBAAoB;IA0B5B,iCAAiC;IACjC,IAAI,MAAM,IAAI,MAAM,CAEnB;IAED,OAAO,CAAC,WAAW;IAUnB;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,EAAE,aAAa,GAAG,YAAY;IA+C5C,+EAA+E;IAC/E,OAAO,CAAC,KAAK;IAab;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,oBAAoB;IAI5B;;;;;OAKG;IACH,OAAO,CAAC,kBAAkB;IAiB1B,4DAA4D;IAC5D,OAAO,CAAC,cAAc;IA8BtB,2EAA2E;IAC3E,gBAAgB,IAAI,YAAY;CAiEjC;AAED,eAAe,kBAAkB,CAAC"}
1
+ {"version":3,"file":"mrzFrameAggregator.d.ts","sourceRoot":"","sources":["../../../../../src/Shared/Libs/mrzFrameAggregator.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAkCvD,MAAM,WAAW,aAAa;IAC5B,6EAA6E;IAC7E,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;;;OAYG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AASD,MAAM,WAAW,YAAY;IAC3B,kFAAkF;IAClF,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,+EAA+E;IAC/E,UAAU,EAAE,mBAAmB,GAAG,IAAI,CAAC;IACvC,kEAAkE;IAClE,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,MAAM,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,kBAAkB,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,yBAAyB;IACxC;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mFAAmF;IACnF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAqHD;;;;GAIG;AACH,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAInC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,OAAO,CAAC,OAAO,CAAqC;IACpD,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,gBAAgB,CAAuB;IAC/C,OAAO,CAAC,YAAY,CAAK;IAEzB,OAAO,CAAC,cAAc,CAAK;gBAEf,OAAO,GAAE,yBAA8B;IAMnD,KAAK,IAAI,IAAI;IAQb;;;;;;OAMG;IACH,OAAO,CAAC,oBAAoB;IA0B5B,iCAAiC;IACjC,IAAI,MAAM,IAAI,MAAM,CAEnB;IAED,OAAO,CAAC,WAAW;IAUnB;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,EAAE,aAAa,GAAG,YAAY;IA+C5C,+EAA+E;IAC/E,OAAO,CAAC,KAAK;IAab;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,oBAAoB;IAI5B;;;;;OAKG;IACH,OAAO,CAAC,kBAAkB;IAiB1B,4DAA4D;IAC5D,OAAO,CAAC,cAAc;IA8BtB,2EAA2E;IAC3E,gBAAgB,IAAI,YAAY;CAiEjC;AAED,eAAe,kBAAkB,CAAC"}
@@ -1,2 +1,2 @@
1
- export declare const SDK_VERSION = "1.486.0";
1
+ export declare const SDK_VERSION = "1.487.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trustchex/react-native-sdk",
3
- "version": "1.486.0",
3
+ "version": "1.487.0",
4
4
  "description": "Trustchex mobile app react native SDK for android or ios devices",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -4,6 +4,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
4
4
  import NFCManager from 'react-native-nfc-manager';
5
5
  import DeviceInfo from 'react-native-device-info';
6
6
  import PermissionManager from '../Libs/permissions.utils';
7
+ import mrzUtils from '../Libs/mrz.utils';
7
8
  import { debugError } from '../Libs/debug.utils';
8
9
  import { MRZInfo } from '../EIDReader/lds/icao/mrzInfo';
9
10
  import { eidReader } from '../EIDReader/eidReader';
@@ -138,7 +139,7 @@ const EIDScanner = ({
138
139
 
139
140
  const getFieldsFromMRZ = useCallback((mrz: MRZInfo) => {
140
141
  return {
141
- documentCode: mrz.getDocumentCode(),
142
+ documentCode: mrzUtils.normalizeDocumentCode(mrz.getDocumentCode()),
142
143
  personalNumber: mrz.getPersonalNumber() || mrz.getOptionalData1(),
143
144
  documentNumber: mrz.getDocumentNumber(),
144
145
  firstName: mrz.getSecondaryIdentifier(),
@@ -14,6 +14,24 @@ 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
+
17
35
  /**
18
36
  * Pads a TD1 line to the fixed 30-char width.
19
37
  *
@@ -303,12 +321,15 @@ const reconstructMRZCandidates = (rawText: string): string[][] => {
303
321
  const pad = (s: string, len: number): string =>
304
322
  s.length >= len ? s.slice(0, len) : s.padEnd(len, '<');
305
323
 
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.
324
+ // Repair the document-subtype character (line 1, position 2). For TD1/TD2 IDs
325
+ // this is usually "<" and OCR may read it as "K"; collapse that misread back to
326
+ // "<". But for PASSPORTS position 2 is a real subtype LETTER (German ordinary
327
+ // passports are "PP", and "PC"/"PD"/"PR"/… exist) — it must NOT be rewritten,
328
+ // or the whole line shifts right and validation fails. So only normalise a "K"
329
+ // misread (→ "<"); leave any other letter/filler exactly as read.
309
330
  const fixDocStart = (s: string): string =>
310
331
  s.replace(/^([ACIPV])([A-Z0-9<])/, (_m, code, next) =>
311
- next === '<' ? code + next : code + '<' + (next === 'K' ? '' : next)
332
+ next === 'K' ? code + '<' : code + next
312
333
  );
313
334
 
314
335
  const candidates: string[][] = [];
@@ -317,26 +338,133 @@ const reconstructMRZCandidates = (rawText: string): string[][] => {
317
338
  // so title/front-of-card text mixed into the blob can't create a false anchor.
318
339
  const docLine = ocrLines.find((l) => /^[ACIPV][A-Z0-9<]{3,}<</.test(l));
319
340
 
341
+ // Passport/TD-x line-2 signature: docNumber(9) + check + nationality(3) +
342
+ // birth(6)+check+sex+expiry(6)+check… Allow letters in the nationality and
343
+ // date spans (OCR reads 0↔O, 1↔I, etc.); the check-digit recovery downstream
344
+ // resolves them. A too-strict [A-Z]{3}/[0-9]{6} here was dropping the whole
345
+ // TD3/TD2 candidate on any noisy frame, leaving only a (wrong) 3-line TD1.
346
+ const td23Line2 =
347
+ /[A-Z0-9<]{9}[0-9<][A-Z<]{3}[A-Z0-9<]{6}[0-9<][MF<][A-Z0-9<]{6}[0-9<]/;
348
+
349
+ // LINE-AWARE line-2 finder. Real ML Kit OCR (a) prepends non-MRZ header text
350
+ // and (b) SPLITS a long MRZ line at its trailing filler run into two OCR lines
351
+ // (e.g. "…1204159<<<<<" + "<<<<<08"). The flat-blob approach loses line
352
+ // boundaries — a greedy line-1 match overruns into line 2, and the line-2
353
+ // regex can match a doc-number-shaped run inside line 1's name field. So work
354
+ // from `ocrLines`: find the doc-number record line, and if it's short (split),
355
+ // stitch following fragment lines onto it up to the format width. The check
356
+ // digits (incl. the trailing composite) then survive instead of being padded
357
+ // away. Returns null if no doc-number line exists at/after the line-1 index.
358
+ // Local 7-3-1 check digit (icaoCheckDigit is defined later in the module).
359
+ const cd731 = (str: string): string => {
360
+ const w = [7, 3, 1];
361
+ let sum = 0;
362
+ for (let i = 0; i < str.length; i++) {
363
+ const c = str[i];
364
+ const v =
365
+ c === '<'
366
+ ? 0
367
+ : c >= '0' && c <= '9'
368
+ ? c.charCodeAt(0) - 48
369
+ : c.charCodeAt(0) - 55;
370
+ sum = (sum + w[i % 3] * v) % 10;
371
+ }
372
+ return String(sum);
373
+ };
374
+
375
+ const findLine2 = (l1Index: number, width: number): string | null => {
376
+ for (let i = l1Index + 1; i < ocrLines.length; i++) {
377
+ const m = td23Line2.exec(ocrLines[i]);
378
+ if (!m || m.index !== 0) continue;
379
+ // The front 28 chars — docNumber(9)+check + nationality(3) +
380
+ // birth(6)+check + sex + expiry(6)+check — are positionally stable in OCR.
381
+ // The optional-data field and the two trailing check digits are what an
382
+ // OCR line-split garbles (dropped fillers shift the composite digit). For
383
+ // TD3 (width 44) REBUILD the line canonically: stable front + empty
384
+ // optional + recomputed optional-check + recomputed composite. This is
385
+ // robust to the split-filler ambiguity that single-frame stitching can't
386
+ // resolve. (Passports with a NON-empty optional/personal-number field are
387
+ // rarer; multi-frame voting still covers those via the stitch fallback.)
388
+ const front = m[0]; // exactly 28 chars by the regex
389
+ if (width === 44 && front.length >= 28) {
390
+ let head = front.slice(0, 28);
391
+ // Self-correct the document number against its OWN check digit before we
392
+ // recompute the composite — otherwise a recomputed composite would be
393
+ // self-consistent with an OCR look-alike error (e.g. O→0) and MASK it
394
+ // from the downstream ambiguous-char recovery. Try the digit/letter
395
+ // look-alike swaps in the 9-char doc-number field until its check digit
396
+ // (position 9) matches; only then rebuild the optional + composite.
397
+ const docNum = head.slice(0, 9);
398
+ const docCheck = head[9];
399
+ if (cd731(docNum) !== docCheck) {
400
+ const swaps: Record<string, string> = {
401
+ O: '0',
402
+ Q: '0',
403
+ D: '0',
404
+ I: '1',
405
+ L: '1',
406
+ S: '5',
407
+ B: '8',
408
+ Z: '2',
409
+ G: '6',
410
+ };
411
+ const fixed = docNum.replace(/[OQDILSBZG]/g, (c) => swaps[c] ?? c);
412
+ if (cd731(fixed) === docCheck) {
413
+ head = fixed + head.slice(9);
414
+ }
415
+ }
416
+ const optional = '<'.repeat(14);
417
+ const l2NoComposite = head + optional + cd731(optional);
418
+ const composite =
419
+ l2NoComposite.slice(0, 10) +
420
+ l2NoComposite.slice(13, 20) +
421
+ l2NoComposite.slice(21, 43);
422
+ return l2NoComposite + cd731(composite);
423
+ }
424
+ // Non-TD3 or unexpected: stitch fragments up to width as a fallback.
425
+ let joined = ocrLines[i];
426
+ let j = i + 1;
427
+ while (joined.length < width && j < ocrLines.length) {
428
+ joined += ocrLines[j];
429
+ j++;
430
+ }
431
+ return joined.length >= width ? joined.slice(0, width) : joined;
432
+ }
433
+ return null;
434
+ };
435
+
320
436
  // ---- 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
437
  {
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<]/
438
+ // Position 2 of line 1 is the "<" filler, frequently misread under glare as a
439
+ // filler-letter (K/C/E/G) — accept those for ALL issuers, not just TUR.
440
+ // Line-1 structure (ICAO 9303): doc code "P" + 1-char subtype (often "<",
441
+ // but a LETTER for some issuers — e.g. German ordinary passports are "PP",
442
+ // and "PC"/"PD" exist) + issuing state (1–3 letters then "<" filler; Germany
443
+ // is "D" → "PPD<<"). Find line 1 among the OCR lines (NOT the flat blob, whose
444
+ // greedy match overruns line boundaries).
445
+ const l1Index = ocrLines.findIndex((l) =>
446
+ /^P[A-Z<][A-Z][A-Z<]{2}[A-Z<]/.test(l)
326
447
  );
327
- if (l1m && l2m) {
328
- candidates.push([pad(fixDocStart(l1m[0]), 44), pad(l2m[0], 44)]);
448
+ const l1Raw = l1Index >= 0 ? ocrLines[l1Index] : null;
449
+ const l2 = l1Index >= 0 ? findLine2(l1Index, 44) : null;
450
+ if (l1Raw && l2) {
451
+ candidates.push([pad(fixDocStart(l1Raw), 44), pad(l2, 44)]);
329
452
  }
330
453
  }
331
454
 
332
455
  // ---- TD2: line1 "X<XXX…", line2 starts with docNumber ----
333
456
  {
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<]/
457
+ // TD2/ID doc codes are A/C/I (NOT P — that's a passport/TD3; including it
458
+ // here made passports get built as a 36-char TD2 and win selection). Position
459
+ // 2 is the SUBTYPE — any AZ or "<" ("IR","IP","AR","AC"…). Line-aware (see
460
+ // TD3): find line 1 in ocrLines, stitch a split line 2.
461
+ const l1Index = ocrLines.findIndex((l) =>
462
+ /^[ACI][A-Z<][A-Z][A-Z<]{2}[A-Z<]/.test(l)
337
463
  );
338
- if (l1m && l2m) {
339
- candidates.push([pad(fixDocStart(l1m[0]), 36), pad(l2m[0], 36)]);
464
+ const l1Raw = l1Index >= 0 ? ocrLines[l1Index] : null;
465
+ const l2 = l1Index >= 0 ? findLine2(l1Index, 36) : null;
466
+ if (l1Raw && l2) {
467
+ candidates.push([pad(fixDocStart(l1Raw), 36), pad(l2, 36)]);
340
468
  }
341
469
  }
342
470
 
@@ -355,9 +483,22 @@ const reconstructMRZCandidates = (rawText: string): string[][] => {
355
483
  );
356
484
  const l2m =
357
485
  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) {
486
+ flat.match(/[0-9A-Z]{6}[0-9<][MF<][0-9A-Z]{6}[0-9<][A-Z<]{2,3}/)?.[0];
487
+
488
+ // Don't emit a 3-line TD1 candidate for passport-shaped input. A passport
489
+ // (TD3) line 1 starts with "P" + a subtype char (a filler "<" OR a real
490
+ // letter — German ordinary passports are "PP", and "PC"/"PD"/… exist), or
491
+ // the band is two long ~44-char lines. Matching ANY "P"-prefixed L1 is safe:
492
+ // no valid TD1 (doc codes A/C/I only) ever starts with "P". Without this
493
+ // guard a noisy passport whose TD3 line-2 regex just missed would fall
494
+ // through to here and be forced into a 3×30 TD1 layout — the "always 3
495
+ // lines, invalid" bug.
496
+ const looksLikePassport =
497
+ (!!l1Source && /^P[A-Z<]/.test(l1Source)) ||
498
+ (ocrLines.length === 2 &&
499
+ ocrLines.every((l) => Math.abs(l.length - 44) <= 4));
500
+
501
+ if (l1Source && !looksLikePassport) {
361
502
  let l1 = pad(fixDocStart(l1Source), 30);
362
503
  // The issuing-state field (TD1 line 1, positions 2–5) is structurally
363
504
  // fillers/letters only — never a personal name — so a "K" there flanked by
@@ -409,9 +550,11 @@ const reconstructMRZCandidates = (rawText: string): string[][] => {
409
550
  l
410
551
  // digit look-alikes inside the name → letters
411
552
  .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))
553
+ // ONLY collapse the TRAILING run of filler-letters (K/C/E/G) that
554
+ // region is unambiguous "<" padding. Do NOT collapse internal runs
555
+ // flanked by fillers: a short given/sur-name made only of K/C/E/G
556
+ // (e.g. Turkish "ECE"/"EGE", "GEC") sits between "<<" separators and
557
+ // would be wrongly erased.
415
558
  .replace(/[KCEG]+$/g, (m) => '<'.repeat(m.length))
416
559
  );
417
560
  };
@@ -1241,7 +1384,7 @@ const validateMRZ = (
1241
1384
 
1242
1385
  // Map mrz package fields to our MRZFields type
1243
1386
  const fields: MRZFields = {
1244
- documentCode: result.fields.documentCode || null,
1387
+ documentCode: normalizeDocumentCode(result.fields.documentCode),
1245
1388
  issuingState: result.fields.issuingState || null,
1246
1389
  documentNumber: result.fields.documentNumber || null,
1247
1390
  nationality: result.fields.nationality || null,
@@ -1376,4 +1519,5 @@ export default {
1376
1519
  isValidOCRBPattern,
1377
1520
  applyOCRBCorrections,
1378
1521
  convertMRZDateToISODate,
1522
+ normalizeDocumentCode,
1379
1523
  };
@@ -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;
package/src/version.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  // This file is auto-generated. Do not edit manually.
2
2
  // Version is synced from package.json during build.
3
- export const SDK_VERSION = '1.486.0';
3
+ export const SDK_VERSION = '1.487.0';