@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.
- package/lib/module/Shared/Components/EIDScanner.js +2 -1
- package/lib/module/Shared/Libs/mrz.utils.js +150 -20
- package/lib/module/Shared/Libs/mrzFrameAggregator.js +29 -1
- package/lib/module/version.js +1 -1
- package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts +1 -0
- package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
- package/lib/typescript/src/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/Shared/Components/EIDScanner.tsx +2 -1
- package/src/Shared/Libs/mrz.utils.ts +166 -22
- package/src/Shared/Libs/mrzFrameAggregator.ts +38 -3
- package/src/version.ts +1 -1
|
@@ -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
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
362
|
-
// is
|
|
363
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
package/lib/module/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"EIDScanner.d.ts","sourceRoot":"","sources":["../../../../../src/Shared/Components/EIDScanner.tsx"],"names":[],"mappings":"
|
|
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;
|
|
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;
|
|
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.
|
|
1
|
+
export declare const SDK_VERSION = "1.487.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
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 === '
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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 A–Z 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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
413
|
-
// is
|
|
414
|
-
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
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