@trustchex/react-native-sdk 1.472.0 → 1.475.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +3 -3
- package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +147 -9
- package/ios/Camera/TrustchexCameraView.swift +92 -19
- package/lib/module/Screens/Debug/MRZTestScreen.js +121 -147
- package/lib/module/Screens/Static/ResultScreen.js +5 -0
- package/lib/module/Shared/Components/IdentityDocumentCamera.js +38 -4
- package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
- package/lib/module/Shared/Libs/mrz.utils.js +639 -16
- package/lib/module/Shared/Libs/mrzFrameAggregator.js +301 -0
- package/lib/module/Shared/Libs/mrzOcrIntegration.js +109 -0
- package/lib/module/version.js +1 -1
- package/lib/typescript/src/Screens/Debug/MRZTestScreen.d.ts.map +1 -1
- package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts +8 -0
- package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +76 -0
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts +73 -0
- package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts.map +1 -0
- package/lib/typescript/src/version.d.ts +1 -1
- package/package.json +15 -10
- package/src/Screens/Debug/MRZTestScreen.tsx +137 -166
- package/src/Screens/Static/ResultScreen.tsx +5 -0
- package/src/Shared/Components/IdentityDocumentCamera.tsx +46 -6
- package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
- package/src/Shared/Libs/mrz.utils.ts +704 -11
- package/src/Shared/Libs/mrzFrameAggregator.ts +370 -0
- package/src/Shared/Libs/mrzOcrIntegration.ts +175 -0
- package/src/version.ts +1 -1
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { parse } from 'mrz';
|
|
2
2
|
import type { MRZFields } from '../Types/mrzFields';
|
|
3
3
|
|
|
4
|
+
// Inherent limitations & deliberate trade-offs (ICAO check-digit collisions,
|
|
5
|
+
// unprotected fields, conservative filler normalisation): see MRZ_KNOWN_ISSUES.md
|
|
6
|
+
// in this directory.
|
|
7
|
+
|
|
8
|
+
// Wall-clock budget for a single validateMRZ call's recovery search. Recovery on
|
|
9
|
+
// a garbage frame can otherwise exhaustively parse thousands of permutations and
|
|
10
|
+
// block the JS thread for ~1s, freezing the UI during continuous scanning. The
|
|
11
|
+
// search loops check this deadline and bail; a real misread is found in <5ms, so
|
|
12
|
+
// a modest budget never costs a genuine correction.
|
|
13
|
+
const RECOVERY_BUDGET_MS = 80;
|
|
14
|
+
let recoveryDeadline = 0;
|
|
15
|
+
const recoveryExpired = (): boolean => Date.now() > recoveryDeadline;
|
|
16
|
+
|
|
4
17
|
/**
|
|
5
18
|
* MRZ Format Types according to ICAO 9303
|
|
6
19
|
*/
|
|
@@ -13,6 +26,13 @@ export interface MRZValidationResult {
|
|
|
13
26
|
valid: boolean;
|
|
14
27
|
format: MRZFormat;
|
|
15
28
|
fields?: MRZFields;
|
|
29
|
+
/**
|
|
30
|
+
* The corrected MRZ as newline-joined fixed-width lines, when a valid result
|
|
31
|
+
* was produced. This reflects the FULLY recovered MRZ — check-digit-driven
|
|
32
|
+
* field repair, composite recomputation and noisy-frame reconstruction all
|
|
33
|
+
* applied — not the lightweight `fixMRZ` pre-clean. Display this to the user.
|
|
34
|
+
*/
|
|
35
|
+
correctedMrz?: string;
|
|
16
36
|
error?: string;
|
|
17
37
|
}
|
|
18
38
|
|
|
@@ -78,6 +98,31 @@ const fixMRZ = (rawText: string): string => {
|
|
|
78
98
|
// e.g., "<<K<<" → "<<<<<" (clearly a filler position)
|
|
79
99
|
cleanedText = cleanedText.replace(/(<+)K(<+)/g, '$1<$2');
|
|
80
100
|
|
|
101
|
+
// Pattern 3b: "K" is the dominant OCR misread of the "<" filler glyph in OCR-B.
|
|
102
|
+
// Normalise "K" → "<" ONLY in the trailing filler region (the run after the
|
|
103
|
+
// last real data), which is unambiguously padding. A mid-line "K" is left
|
|
104
|
+
// intact: a single "<" also separates given names (e.g. "KAYA<KEMAL"), so a
|
|
105
|
+
// lone "K" beside fillers cannot be safely distinguished from a real name
|
|
106
|
+
// letter — a surname like "AKTAS" must keep its "K".
|
|
107
|
+
cleanedText = cleanedText
|
|
108
|
+
.split('\n')
|
|
109
|
+
.map((line) => {
|
|
110
|
+
if (!line.includes('<')) return line;
|
|
111
|
+
// Collapse a trailing run of fillers + K to "<" (e.g. "…TEST<KK"→"…TEST<<<").
|
|
112
|
+
let out = line.replace(/(?<=<)[<K]+$/g, (m) => '<'.repeat(m.length));
|
|
113
|
+
// Document-code filler: a TD1 line-1 starts with the doc code, a "<", a
|
|
114
|
+
// short issuing state, and then the DOCUMENT NUMBER (a long alphanumeric
|
|
115
|
+
// run). When the filler after the code is misread as "K" ("IKD<<SPEC1234…"
|
|
116
|
+
// for "I<D<<SPEC1234…"), fix it — but ONLY when that long doc-number run is
|
|
117
|
+
// present, so a short NAME line that merely starts with a doc-code letter
|
|
118
|
+
// (e.g. "AKTAS<<MEHMET" or "AK<<MERT") never matches and keeps its real "K".
|
|
119
|
+
if (/^[ACIPV]K[A-Z0-9<]{0,2}<[A-Z0-9]{6,}/.test(out)) {
|
|
120
|
+
out = out.replace(/^([ACIPV])K/, '$1<');
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
})
|
|
124
|
+
.join('\n');
|
|
125
|
+
|
|
81
126
|
// Pattern 4: Fix trailing filler area corruption at line end
|
|
82
127
|
// e.g., "TUR<<<<<KK" → "TUR<<<<<<<" (only at end after clear filler sequence)
|
|
83
128
|
cleanedText = cleanedText.replace(/(<{5,})[KGB]+$/gm, (match, fillers) => {
|
|
@@ -177,6 +222,178 @@ const fixMRZ = (rawText: string): string => {
|
|
|
177
222
|
return fixedMRZ;
|
|
178
223
|
};
|
|
179
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Reconstructs fixed-width MRZ line-sets from a noisy multi-block OCR frame
|
|
227
|
+
* where the band may be split across extra newlines, mixed with front-of-card
|
|
228
|
+
* text, broken by internal spaces, or missing its trailing fillers.
|
|
229
|
+
*
|
|
230
|
+
* Supports all ICAO 9303 machine-readable formats:
|
|
231
|
+
* - TD1: 3 lines × 30 (national ID cards)
|
|
232
|
+
* - TD2: 2 lines × 36
|
|
233
|
+
* - TD3: 2 lines × 44 (passports)
|
|
234
|
+
*
|
|
235
|
+
* It returns an array of CANDIDATE line-sets (most-likely first). Each
|
|
236
|
+
* candidate is just a structural guess — the caller MUST validate every
|
|
237
|
+
* candidate with the `mrz` parser + check-digit recovery and keep only one
|
|
238
|
+
* that passes its check digits. A wrong reconstruction therefore never leaks
|
|
239
|
+
* through; it simply fails validation and is discarded.
|
|
240
|
+
*/
|
|
241
|
+
const reconstructMRZCandidates = (rawText: string): string[][] => {
|
|
242
|
+
if (!rawText) return [];
|
|
243
|
+
const upper = applyOCRBCorrections(rawText.toUpperCase());
|
|
244
|
+
|
|
245
|
+
// Per-OCR-line strip to MRZ-legal chars (drops spaces, punctuation, « …).
|
|
246
|
+
// The "<" filler glyph in OCR-B is frequently misread as a letter — most often
|
|
247
|
+
// "K", but under glare also "C", "E" and "G". Normalise those letters to "<"
|
|
248
|
+
// ONLY in an UNAMBIGUOUS filler context, never where the letter could be part
|
|
249
|
+
// of a real name. The safe contexts are runs (the trailing/internal filler
|
|
250
|
+
// region) or a single filler-letter flanked by 2+ real fillers — NOT a lone
|
|
251
|
+
// letter next to a single "<", because a single "<" also separates given names
|
|
252
|
+
// (e.g. "KAYA<KEMAL"), so the "K" of "KEMAL" must be left intact. The
|
|
253
|
+
// check-digit recovery downstream still vets the result.
|
|
254
|
+
// Only normalise filler-letters in the TRAILING filler region — the run of
|
|
255
|
+
// characters after the last real data, which is unambiguously "<" padding. A
|
|
256
|
+
// mid-line filler-letter cannot be distinguished from a real name letter
|
|
257
|
+
// (names follow the "<<" separator and may start with K/C/E/G, e.g.
|
|
258
|
+
// "KAYA<<KEMAL"), so we never touch those — leaving them avoids corrupting
|
|
259
|
+
// names, and the trailing region is where the K↔< glare misread actually shows.
|
|
260
|
+
const FILLER_LETTERS = 'KCEG';
|
|
261
|
+
const fillerClass = `[${FILLER_LETTERS}]`;
|
|
262
|
+
const normaliseFillers = (l: string): string =>
|
|
263
|
+
// A tail of fillers and filler-letters (with at least one real "<" present)
|
|
264
|
+
// is the padding region — collapse the whole tail to "<".
|
|
265
|
+
l.replace(new RegExp(`(?<=<)(?:<|${fillerClass}){1,}$`), (m) =>
|
|
266
|
+
'<'.repeat(m.length)
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const ocrLines = upper
|
|
270
|
+
.split('\n')
|
|
271
|
+
.map((l) => normaliseFillers(l.replace(/[^A-Z0-9<]/g, '')))
|
|
272
|
+
.filter((l) => l.length > 0);
|
|
273
|
+
const flat = ocrLines.join('');
|
|
274
|
+
|
|
275
|
+
const pad = (s: string, len: number): string =>
|
|
276
|
+
s.length >= len ? s.slice(0, len) : s.padEnd(len, '<');
|
|
277
|
+
|
|
278
|
+
// Restore the filler right after the document code. In TD1/TD2/TD3 the second
|
|
279
|
+
// character of line 1 is always "<". OCR commonly reads it as "K" or drops it:
|
|
280
|
+
// "IKD…" -> "I<D…", "ITUR…" -> "I<TUR…", "I<D…" unchanged.
|
|
281
|
+
const fixDocStart = (s: string): string =>
|
|
282
|
+
s.replace(/^([ACIPV])([A-Z0-9<])/, (_m, code, next) =>
|
|
283
|
+
next === '<' ? code + next : code + '<' + (next === 'K' ? '' : next)
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const candidates: string[][] = [];
|
|
287
|
+
|
|
288
|
+
// Helper: the OCR line that actually starts the MRZ (doc code + state pattern),
|
|
289
|
+
// so title/front-of-card text mixed into the blob can't create a false anchor.
|
|
290
|
+
const docLine = ocrLines.find((l) => /^[ACIPV][A-Z0-9<]{3,}<</.test(l));
|
|
291
|
+
|
|
292
|
+
// ---- TD3 (passport): line1 "P<XXX…", line2 starts with a 9-char doc number ----
|
|
293
|
+
// Line 2 signature: docNumber(9) + check + nationality(3) + birth(6)+check+sex+expiry(6)+check…
|
|
294
|
+
{
|
|
295
|
+
const l1m = flat.match(/P[<A-Z]TUR[A-Z<]{2,}|P<[A-Z]{3}[A-Z<]+/);
|
|
296
|
+
const l2m = flat.match(
|
|
297
|
+
/[A-Z0-9<]{9}[0-9<][A-Z]{3}[0-9]{6}[0-9<][MF<][0-9]{6}[0-9<]/
|
|
298
|
+
);
|
|
299
|
+
if (l1m && l2m) {
|
|
300
|
+
candidates.push([pad(fixDocStart(l1m[0]), 44), pad(l2m[0], 44)]);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---- TD2: line1 "X<XXX…", line2 starts with docNumber ----
|
|
305
|
+
{
|
|
306
|
+
const l1m = flat.match(/[ACIPV]<[A-Z]{3}[A-Z<]+/);
|
|
307
|
+
const l2m = flat.match(
|
|
308
|
+
/[A-Z0-9<]{9}[0-9<][A-Z]{3}[0-9]{6}[0-9<][MF<][0-9]{6}[0-9<]/
|
|
309
|
+
);
|
|
310
|
+
if (l1m && l2m) {
|
|
311
|
+
candidates.push([pad(fixDocStart(l1m[0]), 36), pad(l2m[0], 36)]);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---- TD1 (national ID): 3 independent 30-char records ----
|
|
316
|
+
{
|
|
317
|
+
// Prefer the dedicated MRZ line (avoids title/front text in the blob); fall
|
|
318
|
+
// back to a blob match if OCR merged the line with adjacent content.
|
|
319
|
+
const l1Source =
|
|
320
|
+
docLine ??
|
|
321
|
+
flat.match(/[ACIPV][A-Z0-9<]{4}[A-Z0-9<]{9}[0-9<][A-Z0-9<]*/)?.[0];
|
|
322
|
+
// Line 2: the OCR line (or blob slice) that looks like the date record:
|
|
323
|
+
// birth(6)+check+sex+expiry(6)+check+state(3). Allow letters in date spans
|
|
324
|
+
// for OCR slips; check-digit recovery resolves them.
|
|
325
|
+
const l2Line = ocrLines.find((l) =>
|
|
326
|
+
/^[0-9A-Z]{6}[0-9<][MF<][0-9A-Z]{6}[0-9<][A-Z<]{2,}/.test(l)
|
|
327
|
+
);
|
|
328
|
+
const l2m =
|
|
329
|
+
l2Line ??
|
|
330
|
+
flat.match(/[0-9A-Z]{6}[0-9<][MF<][0-9A-Z]{6}[0-9<][A-Z]{2,3}/)?.[0];
|
|
331
|
+
|
|
332
|
+
if (l1Source) {
|
|
333
|
+
let l1 = pad(fixDocStart(l1Source), 30);
|
|
334
|
+
// The issuing-state field (TD1 line 1, positions 2–5) is structurally
|
|
335
|
+
// fillers/letters only — never a personal name — so a "K" there flanked by
|
|
336
|
+
// a filler is an unambiguous "<" misread and safe to normalise. This is
|
|
337
|
+
// position-scoped so it can NEVER touch the name field (positions 5+).
|
|
338
|
+
l1 =
|
|
339
|
+
l1.slice(0, 2) +
|
|
340
|
+
l1.slice(2, 5).replace(/K(?=<)|(?<=<)K|(?<=[A-Z])K$/g, '<') +
|
|
341
|
+
l1.slice(5);
|
|
342
|
+
let l2: string;
|
|
343
|
+
if (l2m) {
|
|
344
|
+
// Use the matched date record, padded to 30.
|
|
345
|
+
l2 = pad(l2m, 30);
|
|
346
|
+
} else {
|
|
347
|
+
const after = flat.indexOf(l1Source) + l1Source.length;
|
|
348
|
+
l2 = pad(flat.slice(after, after + 30), 30);
|
|
349
|
+
}
|
|
350
|
+
// Line 3 (name): the longest mostly-letters+filler OCR line that is NOT the
|
|
351
|
+
// doc or date line. OCR mangles the name line too — a leading "O" reads as
|
|
352
|
+
// "0", and the trailing "<" fillers read as K/C — so we DON'T require a
|
|
353
|
+
// pure [A-Z<] line (that wrongly drops "0ZCAN<<ERDAL…" entirely). Accept a
|
|
354
|
+
// line that is predominantly letters/fillers, then normalise it.
|
|
355
|
+
const isNameLike = (l: string): boolean => {
|
|
356
|
+
if (l === l1Source || l === l2Line || l.length < 5) return false;
|
|
357
|
+
if (!/</.test(l)) return false; // must have at least one filler
|
|
358
|
+
// Must look like "SURNAME<<NAMES…": starts with a letter (or its digit
|
|
359
|
+
// look-alike) and is dominated by letters/fillers, not a numeric record.
|
|
360
|
+
if (!/^[A-Z0-9]/.test(l)) return false;
|
|
361
|
+
const letters = (l.match(/[A-Z]/g) || []).length;
|
|
362
|
+
const digits = (l.match(/[0-9]/g) || []).length;
|
|
363
|
+
// A date/doc record is digit-heavy; a name is letter-heavy.
|
|
364
|
+
return letters >= 3 && letters >= digits;
|
|
365
|
+
};
|
|
366
|
+
// Normalise a name line: digit look-alikes back to letters (O/I/S/B/Z/G),
|
|
367
|
+
// and the filler-letter run misreads (K/C/E/G adjacent to fillers) to "<".
|
|
368
|
+
const normaliseName = (l: string): string => {
|
|
369
|
+
const digitToLetter: Record<string, string> = {
|
|
370
|
+
'0': 'O',
|
|
371
|
+
'1': 'I',
|
|
372
|
+
'5': 'S',
|
|
373
|
+
'8': 'B',
|
|
374
|
+
'2': 'Z',
|
|
375
|
+
'6': 'G',
|
|
376
|
+
};
|
|
377
|
+
return (
|
|
378
|
+
l
|
|
379
|
+
// digit look-alikes inside the name → letters
|
|
380
|
+
.replace(/[015862]/g, (d) => digitToLetter[d] ?? d)
|
|
381
|
+
// a run of filler-letters (K/C/E/G) flanked by fillers or at the tail
|
|
382
|
+
// is the trailing/internal filler region
|
|
383
|
+
.replace(/(?<=<)[KCEG]+(?=<|$)/g, (m) => '<'.repeat(m.length))
|
|
384
|
+
.replace(/[KCEG]+$/g, (m) => '<'.repeat(m.length))
|
|
385
|
+
);
|
|
386
|
+
};
|
|
387
|
+
const nameRuns = ocrLines.filter(isNameLike);
|
|
388
|
+
const bestName = nameRuns.sort((a, b) => b.length - a.length)[0] ?? '';
|
|
389
|
+
const l3 = pad(normaliseName(bestName), 30);
|
|
390
|
+
candidates.push([l1, l2, l3]);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return candidates;
|
|
395
|
+
};
|
|
396
|
+
|
|
180
397
|
const AMBIGUOUS_CHAR_MAP: Record<string, string[]> = {
|
|
181
398
|
'0': ['O', 'Q', 'D'],
|
|
182
399
|
'O': ['0', 'Q', 'D'],
|
|
@@ -206,14 +423,24 @@ const AMBIGUOUS_CHAR_MAP: Record<string, string[]> = {
|
|
|
206
423
|
*/
|
|
207
424
|
const OCRB_SPECIFIC_MAP: Record<string, string> = {
|
|
208
425
|
// Glyph confusion in OCR-B (low contrast scanning)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
426
|
+
'Ø': '0', // Slashed zero sometimes appears as capital O with slash
|
|
427
|
+
'ø': '0', // Lowercase variant
|
|
428
|
+
'œ': 'O', // Ligature
|
|
429
|
+
|
|
430
|
+
// The "<" filler chevron is frequently OCR'd as a guillemet/angle-quote glyph,
|
|
431
|
+
// especially in the trailing filler run. Map these to "<" so they KEEP their
|
|
432
|
+
// position (rather than being stripped, which would shift the line).
|
|
433
|
+
'«': '<',
|
|
434
|
+
'»': '<',
|
|
435
|
+
'‹': '<',
|
|
436
|
+
'›': '<',
|
|
437
|
+
'《': '<',
|
|
438
|
+
'》': '<',
|
|
212
439
|
|
|
213
440
|
// Common in monospace OCR
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
l: 'I', // Lowercase L as I (common with serif OCR-B variants)
|
|
441
|
+
'Ι': 'I', // Greek capital iota confused with I
|
|
442
|
+
'ι': 'i', // Greek lowercase iota
|
|
443
|
+
'l': 'I', // Lowercase L as I (common with serif OCR-B variants)
|
|
217
444
|
};
|
|
218
445
|
|
|
219
446
|
/**
|
|
@@ -342,6 +569,385 @@ const generateAmbiguousVariants = (
|
|
|
342
569
|
return entries.map((e) => e.text);
|
|
343
570
|
};
|
|
344
571
|
|
|
572
|
+
/**
|
|
573
|
+
* Maps a failing check-digit field (as reported by the `mrz` package) to the
|
|
574
|
+
* [line, start, endExclusive] span of the DATA it protects, per the ICAO 9303
|
|
575
|
+
* fixed layout for each format. Spans are 0-based, relative to fixed-width lines.
|
|
576
|
+
*/
|
|
577
|
+
const CHECK_DIGIT_FIELD_DATA_SPANS: Record<
|
|
578
|
+
string,
|
|
579
|
+
Record<string, [number, number, number]>
|
|
580
|
+
> = {
|
|
581
|
+
// TD1: line0 docNumber[5..14]; line1 birth[0..6], expiry[8..14]
|
|
582
|
+
TD1: {
|
|
583
|
+
documentNumberCheckDigit: [0, 5, 14],
|
|
584
|
+
birthDateCheckDigit: [1, 0, 6],
|
|
585
|
+
expirationDateCheckDigit: [1, 8, 14],
|
|
586
|
+
},
|
|
587
|
+
// TD2: line1 docNumber[0..9], birth[13..19], expiry[21..27]
|
|
588
|
+
TD2: {
|
|
589
|
+
documentNumberCheckDigit: [1, 0, 9],
|
|
590
|
+
birthDateCheckDigit: [1, 13, 19],
|
|
591
|
+
expirationDateCheckDigit: [1, 21, 27],
|
|
592
|
+
},
|
|
593
|
+
// TD3 (passport): line1 docNumber[0..9], birth[13..19], expiry[21..27]
|
|
594
|
+
TD3: {
|
|
595
|
+
documentNumberCheckDigit: [1, 0, 9],
|
|
596
|
+
birthDateCheckDigit: [1, 13, 19],
|
|
597
|
+
expirationDateCheckDigit: [1, 21, 27],
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Exhaustively tries every ambiguous-character permutation (0↔O, 5↔S, A↔4, …)
|
|
603
|
+
* WITHIN the data spans of the check-digit fields that are currently failing,
|
|
604
|
+
* re-parsing after each candidate, until the FULL MRZ validates (all check
|
|
605
|
+
* digits, including the composite).
|
|
606
|
+
*
|
|
607
|
+
* Because the search is scoped to the small failing fields, it is genuinely
|
|
608
|
+
* exhaustive — every permutation is tried — without the combinatorial blow-up of
|
|
609
|
+
* permuting the entire MRZ. This is what allows a misread document number
|
|
610
|
+
* (e.g. O→0, A→4, S→5) to be corrected before any downstream screen.
|
|
611
|
+
*
|
|
612
|
+
* @returns the parse result if a fully-valid MRZ is found, otherwise null.
|
|
613
|
+
*/
|
|
614
|
+
/** ICAO 9303 7-3-1 weighted check digit. */
|
|
615
|
+
const icaoCheckDigit = (str: string): string => {
|
|
616
|
+
const weights = [7, 3, 1];
|
|
617
|
+
let sum = 0;
|
|
618
|
+
for (let i = 0; i < str.length; i++) {
|
|
619
|
+
const c = str[i];
|
|
620
|
+
const v =
|
|
621
|
+
c === '<'
|
|
622
|
+
? 0
|
|
623
|
+
: c >= '0' && c <= '9'
|
|
624
|
+
? c.charCodeAt(0) - 48
|
|
625
|
+
: c.charCodeAt(0) - 55;
|
|
626
|
+
sum = (sum + weights[i % 3] * v) % 10;
|
|
627
|
+
}
|
|
628
|
+
return String(sum);
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* When a TD1 MRZ fails ONLY on its composite check digit (every per-field check
|
|
633
|
+
* digit is valid), the composite is deterministically derivable from the other
|
|
634
|
+
* fields — OCR often just drops the trailing digit. Recompute and set it.
|
|
635
|
+
* Returns the corrected parse result, or null if not applicable.
|
|
636
|
+
*/
|
|
637
|
+
const fixTD1Composite = (lines: string[]): ReturnType<typeof parse> | null => {
|
|
638
|
+
if (lines.length < 3 || lines[0].length !== 30 || lines[1].length !== 30) {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
const r = parse(lines);
|
|
642
|
+
const bad = (r.details ?? []).filter((d) => !d.valid);
|
|
643
|
+
if (bad.length !== 1 || bad[0].field !== 'compositeCheckDigit') return null;
|
|
644
|
+
|
|
645
|
+
// TD1 composite covers: line1[5..30) + line2[0..7) + line2[8..15) + line2[18..29)
|
|
646
|
+
const l1 = lines[0];
|
|
647
|
+
const l2 = lines[1];
|
|
648
|
+
const composite =
|
|
649
|
+
l1.slice(5, 30) + l2.slice(0, 7) + l2.slice(8, 15) + l2.slice(18, 29);
|
|
650
|
+
const fixedL2 = l2.slice(0, 29) + icaoCheckDigit(composite);
|
|
651
|
+
const fixed = parse([l1, fixedL2, lines[2]]);
|
|
652
|
+
return fixed.valid ? fixed : null;
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Recovers a TD1 MRZ when OCR INSERTED or DROPPED a character inside a numeric
|
|
657
|
+
* field — a real failure mode on glossy cards (e.g. expiry "360118"+check"7"
|
|
658
|
+
* read as "36011187", which shifts the nationality and overflows the line).
|
|
659
|
+
*
|
|
660
|
+
* Pure character substitution can't fix a length/alignment error, so this tries
|
|
661
|
+
* single-character edits in the relevant region and re-validates by check digit:
|
|
662
|
+
* - line 2 (date record): delete each char in the birth..expiry-check window
|
|
663
|
+
* [0..15) (then right-pad to 30) — undoes a spurious inserted digit;
|
|
664
|
+
* - line 1 (doc record): delete each char in the doc-number..check window
|
|
665
|
+
* [5..15) — undoes an inserted char in the serial.
|
|
666
|
+
* Each candidate is re-padded to 30, the composite is recomputed, and only a
|
|
667
|
+
* fully check-digit-valid parse is accepted, so a wrong edit can never pass.
|
|
668
|
+
*/
|
|
669
|
+
const recoverTD1ByIndel = (
|
|
670
|
+
lines: string[]
|
|
671
|
+
): ReturnType<typeof parse> | null => {
|
|
672
|
+
if (lines.length < 3) return null;
|
|
673
|
+
const pad30 = (s: string) =>
|
|
674
|
+
s.length >= 30 ? s.slice(0, 30) : s.padEnd(30, '<');
|
|
675
|
+
|
|
676
|
+
// Which line/window to try edits in, based on which check digit is failing.
|
|
677
|
+
let base: ReturnType<typeof parse>;
|
|
678
|
+
try {
|
|
679
|
+
base = parse(lines.map(pad30));
|
|
680
|
+
} catch {
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
if (base.format !== 'TD1') return null;
|
|
684
|
+
const failing = new Set(
|
|
685
|
+
(base.details ?? []).filter((d) => !d.valid).map((d) => d.field)
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
// (lineIndex, editStart, editEndExclusive) regions to attempt deletions in,
|
|
689
|
+
// chosen by the failing check digit. Order: date line first (most common).
|
|
690
|
+
const regions: Array<[number, number, number]> = [];
|
|
691
|
+
if (
|
|
692
|
+
failing.has('expirationDateCheckDigit') ||
|
|
693
|
+
failing.has('birthDateCheckDigit') ||
|
|
694
|
+
failing.has('compositeCheckDigit')
|
|
695
|
+
) {
|
|
696
|
+
regions.push([1, 0, 15]); // birth..expiry-check on the date record
|
|
697
|
+
}
|
|
698
|
+
if (
|
|
699
|
+
failing.has('documentNumberCheckDigit') ||
|
|
700
|
+
failing.has('compositeCheckDigit')
|
|
701
|
+
) {
|
|
702
|
+
regions.push([0, 5, 15]); // doc-number..its check on the doc record
|
|
703
|
+
}
|
|
704
|
+
if (regions.length === 0) return null;
|
|
705
|
+
|
|
706
|
+
// Build all single-deletion candidates up front.
|
|
707
|
+
const candidates: string[][] = [];
|
|
708
|
+
for (const [li, from, to] of regions) {
|
|
709
|
+
const original = pad30(lines[li] ?? '');
|
|
710
|
+
for (let i = from; i < to && i < original.length; i++) {
|
|
711
|
+
const edited = pad30(original.slice(0, i) + original.slice(i + 1));
|
|
712
|
+
const candidate = lines.map(pad30);
|
|
713
|
+
candidate[li] = edited;
|
|
714
|
+
candidates.push(candidate);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Cheap pass first: a plain parse or a composite recompute resolves the common
|
|
719
|
+
// case (a dropped/duplicated digit) in microseconds — try ALL deletions this
|
|
720
|
+
// way before paying for any substitution search.
|
|
721
|
+
for (const candidate of candidates) {
|
|
722
|
+
let r: ReturnType<typeof parse>;
|
|
723
|
+
try {
|
|
724
|
+
r = parse(candidate);
|
|
725
|
+
} catch {
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
if (r.valid) return r;
|
|
729
|
+
const composite = fixTD1Composite(candidate);
|
|
730
|
+
if (composite?.valid) return composite;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Expensive pass only if the cheap pass found nothing: a deletion may align the
|
|
734
|
+
// structure but leave a residual ambiguous char (e.g. B→8 in the TC number).
|
|
735
|
+
for (const candidate of candidates) {
|
|
736
|
+
if (recoveryExpired()) return null; // bail before blocking the JS thread
|
|
737
|
+
const subbed = recoverByFailingFields(candidate, false);
|
|
738
|
+
if (subbed?.valid) return subbed;
|
|
739
|
+
}
|
|
740
|
+
return null;
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
const recoverByFailingFields = (
|
|
744
|
+
lines: string[],
|
|
745
|
+
tryIndel: boolean = true
|
|
746
|
+
): ReturnType<typeof parse> | null => {
|
|
747
|
+
// `parse` THROWS (not returns invalid) on a wrong line count; a caller passing a
|
|
748
|
+
// noisy/fragmented line set must get null, not an exception that aborts the flow.
|
|
749
|
+
let first: ReturnType<typeof parse>;
|
|
750
|
+
try {
|
|
751
|
+
first = parse(lines);
|
|
752
|
+
} catch {
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
if (first.valid) return first;
|
|
756
|
+
|
|
757
|
+
// If only the composite check digit is off (common when OCR drops the trailing
|
|
758
|
+
// digit), recompute it deterministically before the search.
|
|
759
|
+
const compositeFixed = fixTD1Composite(lines);
|
|
760
|
+
if (compositeFixed) return compositeFixed;
|
|
761
|
+
|
|
762
|
+
const spans = CHECK_DIGIT_FIELD_DATA_SPANS[first.format ?? ''];
|
|
763
|
+
if (!spans) return null;
|
|
764
|
+
|
|
765
|
+
const isDigit = (c: string) => c >= '0' && c <= '9';
|
|
766
|
+
|
|
767
|
+
// A "change cost" for replacing `orig` with `repl` at a position that, per the
|
|
768
|
+
// MRZ field layout, is expected to hold a digit (`expectDigit`). Lower = more
|
|
769
|
+
// likely. The dominant real-world OCR error is letter↔digit confusion in
|
|
770
|
+
// numeric fields (O→0, S→5, A→4, B→8, I→1), so:
|
|
771
|
+
// - turning a misread letter into the expected digit is cheap (0)
|
|
772
|
+
// - keeping/introducing a non-digit where a digit is expected is expensive
|
|
773
|
+
const changeCost = (
|
|
774
|
+
orig: string,
|
|
775
|
+
repl: string,
|
|
776
|
+
expectDigit: boolean
|
|
777
|
+
): number => {
|
|
778
|
+
if (repl === orig) return 0; // no change always cheapest
|
|
779
|
+
const origDigit = isDigit(orig);
|
|
780
|
+
const replDigit = isDigit(repl);
|
|
781
|
+
// OCR overwhelmingly confuses a LETTER for a digit in numeric MRZ fields
|
|
782
|
+
// (O→0, S→5, A→4, B→8, I→1), rarely the reverse. So:
|
|
783
|
+
// - letter → digit : very cheap (the common real correction) => 1
|
|
784
|
+
// - letter → letter : plausible only in alpha positions => 3
|
|
785
|
+
// - digit → letter : OCR almost never turns a real digit into a letter => 8
|
|
786
|
+
// - digit → digit : possible but unusual => 4
|
|
787
|
+
if (!origDigit && replDigit) return expectDigit ? 1 : 2;
|
|
788
|
+
if (!origDigit && !replDigit) return expectDigit ? 6 : 3;
|
|
789
|
+
if (origDigit && !replDigit) return 8;
|
|
790
|
+
return 4; // digit -> digit
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
// Per-format map of which data positions are expected to be digits. Document
|
|
794
|
+
// numbers can be alphanumeric, but Turkish-style IDs use letter-then-digits,
|
|
795
|
+
// and dates/personal numbers are always digits. We treat a position as
|
|
796
|
+
// "expectDigit" when its current best guess across candidates is numeric;
|
|
797
|
+
// dates are always numeric.
|
|
798
|
+
const expectsDigitAt = (field: string, indexWithinField: number): boolean => {
|
|
799
|
+
if (
|
|
800
|
+
field === 'birthDateCheckDigit' ||
|
|
801
|
+
field === 'expirationDateCheckDigit'
|
|
802
|
+
) {
|
|
803
|
+
return true; // dates are all digits
|
|
804
|
+
}
|
|
805
|
+
if (field === 'documentNumberCheckDigit') {
|
|
806
|
+
// Common ID layout: position 0 is a letter, the rest digits.
|
|
807
|
+
return indexWithinField > 0;
|
|
808
|
+
}
|
|
809
|
+
return false;
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
const positions: Array<{
|
|
813
|
+
line: number;
|
|
814
|
+
index: number;
|
|
815
|
+
options: Array<{ ch: string; cost: number }>;
|
|
816
|
+
}> = [];
|
|
817
|
+
for (const d of first.details ?? []) {
|
|
818
|
+
if (d.valid || !spans[d.field]) continue;
|
|
819
|
+
const [ln, start, end] = spans[d.field];
|
|
820
|
+
const line = lines[ln];
|
|
821
|
+
if (line == null) continue;
|
|
822
|
+
for (let i = start; i < end; i++) {
|
|
823
|
+
const orig = line[i];
|
|
824
|
+
const alts = AMBIGUOUS_CHAR_MAP[orig];
|
|
825
|
+
if (alts && alts.length) {
|
|
826
|
+
const expectDigit = expectsDigitAt(d.field, i - start);
|
|
827
|
+
const options = [orig, ...alts]
|
|
828
|
+
.map((ch) => ({ ch, cost: changeCost(orig, ch, expectDigit) }))
|
|
829
|
+
.sort((a, b) => a.cost - b.cost);
|
|
830
|
+
positions.push({ line: ln, index: i, options });
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (positions.length === 0) return null;
|
|
835
|
+
|
|
836
|
+
const total = positions.reduce((acc, p) => acc * p.options.length, 1);
|
|
837
|
+
// The legitimate search space for a genuine OCR misread is small — a handful of
|
|
838
|
+
// ambiguous characters in the failing check-digit fields. A huge `total` means
|
|
839
|
+
// the frame is garbage (many ambiguous chars, no real MRZ); exhaustively
|
|
840
|
+
// parsing tens of thousands of permutations there would block the JS thread for
|
|
841
|
+
// ~1s per frame and freeze the UI. Cap hard so a noisy frame fails fast.
|
|
842
|
+
const MAX_PERMUTATIONS = 4096;
|
|
843
|
+
if (total > MAX_PERMUTATIONS) return null;
|
|
844
|
+
|
|
845
|
+
// Decode candidate index `n` into a per-position option choice, and compute its
|
|
846
|
+
// total change cost (sum of per-option costs). We then try candidates in
|
|
847
|
+
// ascending total cost so the most plausible OCR correction is accepted first.
|
|
848
|
+
const decode = (n: number): { indices: number[]; cost: number } => {
|
|
849
|
+
let k = n;
|
|
850
|
+
let cost = 0;
|
|
851
|
+
const indices = positions.map((p) => {
|
|
852
|
+
const idx = k % p.options.length;
|
|
853
|
+
k = Math.floor(k / p.options.length);
|
|
854
|
+
cost += p.options[idx].cost;
|
|
855
|
+
return idx;
|
|
856
|
+
});
|
|
857
|
+
return { indices, cost };
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
const order = Array.from({ length: total }, (_, n) => n);
|
|
861
|
+
const costCache = new Map<number, number>();
|
|
862
|
+
const costOf = (n: number): number => {
|
|
863
|
+
let c = costCache.get(n);
|
|
864
|
+
if (c === undefined) {
|
|
865
|
+
c = decode(n).cost;
|
|
866
|
+
costCache.set(n, c);
|
|
867
|
+
}
|
|
868
|
+
return c;
|
|
869
|
+
};
|
|
870
|
+
order.sort((a, b) => costOf(a) - costOf(b) || a - b);
|
|
871
|
+
|
|
872
|
+
for (const n of order) {
|
|
873
|
+
if (recoveryExpired()) return null; // bail before blocking the JS thread
|
|
874
|
+
const { indices } = decode(n);
|
|
875
|
+
const chars: Record<number, string[]> = {};
|
|
876
|
+
for (const p of positions) {
|
|
877
|
+
if (!chars[p.line]) chars[p.line] = lines[p.line].split('');
|
|
878
|
+
}
|
|
879
|
+
positions.forEach((p, pi) => {
|
|
880
|
+
chars[p.line][p.index] = p.options[indices[pi]].ch;
|
|
881
|
+
});
|
|
882
|
+
const candidate = lines.slice();
|
|
883
|
+
for (const lnStr of Object.keys(chars)) {
|
|
884
|
+
candidate[Number(lnStr)] = chars[Number(lnStr)].join('');
|
|
885
|
+
}
|
|
886
|
+
const r = parse(candidate);
|
|
887
|
+
if (r.valid) return r;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Substitution alone couldn't fix it. If OCR inserted/dropped a character
|
|
891
|
+
// (a length/alignment error), try single-char edits and re-validate.
|
|
892
|
+
if (tryIndel) {
|
|
893
|
+
const indel = recoverTD1ByIndel(lines);
|
|
894
|
+
if (indel?.valid) return indel;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return null;
|
|
898
|
+
};
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Rebuild the corrected fixed-width MRZ lines from a (valid) parse result.
|
|
902
|
+
* Every field's `ranges` carry the actual post-correction characters at known
|
|
903
|
+
* line/column spans, so laying each `raw` span into a per-line buffer yields the
|
|
904
|
+
* exact MRZ that satisfied the check digits — the genuinely fixed version to show
|
|
905
|
+
* the user, not the lightweight `fixMRZ` pre-clean.
|
|
906
|
+
*/
|
|
907
|
+
const correctedMrzFromParse = (
|
|
908
|
+
result: ReturnType<typeof parse>
|
|
909
|
+
): string | undefined => {
|
|
910
|
+
const widths: Record<string, number> = { TD1: 30, TD2: 36, TD3: 44 };
|
|
911
|
+
const width = widths[result.format ?? ''];
|
|
912
|
+
if (!width) return undefined;
|
|
913
|
+
const lineCount = result.format === 'TD1' ? 3 : 2;
|
|
914
|
+
const buffers: string[][] = Array.from({ length: lineCount }, () =>
|
|
915
|
+
new Array(width).fill('<')
|
|
916
|
+
);
|
|
917
|
+
for (const detail of result.details ?? []) {
|
|
918
|
+
for (const range of detail.ranges ?? []) {
|
|
919
|
+
// `raw` (the actual characters at this span) exists at runtime but isn't in
|
|
920
|
+
// the `mrz` Range type.
|
|
921
|
+
const {
|
|
922
|
+
line,
|
|
923
|
+
start,
|
|
924
|
+
raw = '',
|
|
925
|
+
} = range as typeof range & { raw?: string };
|
|
926
|
+
const buf = buffers[line];
|
|
927
|
+
if (!buf) continue;
|
|
928
|
+
for (let i = 0; i < raw.length && start + i < width; i++) {
|
|
929
|
+
buf[start + i] = raw[i];
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return buffers.map((b) => b.join('')).join('\n');
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Heuristic: does a parsed result's name look like it still carries OCR noise
|
|
938
|
+
* (a digit where a name letter belongs, or a run of filler-confusable letters
|
|
939
|
+
* that should have been "<")? Names are letters only, so any digit is noise; a
|
|
940
|
+
* 3+ run of K/C/E/G is very likely misread fillers leaking into the given names.
|
|
941
|
+
*/
|
|
942
|
+
const nameLooksNoisy = (r: ReturnType<typeof parse>): boolean => {
|
|
943
|
+
const name = `${r.fields?.lastName ?? ''} ${r.fields?.firstName ?? ''}`;
|
|
944
|
+
// A DIGIT in a name is unambiguous OCR noise (MRZ names are letters only).
|
|
945
|
+
// A 5+ run of filler-confusable letters is almost certainly misread padding —
|
|
946
|
+
// real given names like "ECE"/"GECE" are short, so keep the threshold high to
|
|
947
|
+
// avoid false positives on legitimate names.
|
|
948
|
+
return /[0-9]/.test(name) || /[KCEG]{5,}/.test(name);
|
|
949
|
+
};
|
|
950
|
+
|
|
345
951
|
/**
|
|
346
952
|
* Validates MRZ text using the mrz npm package
|
|
347
953
|
* @param mrzText Raw or cleaned MRZ text
|
|
@@ -353,17 +959,102 @@ const validateMRZ = (
|
|
|
353
959
|
autocorrect: boolean = true
|
|
354
960
|
): MRZValidationResult => {
|
|
355
961
|
try {
|
|
962
|
+
// Arm the recovery wall-clock budget for this call so the search below can't
|
|
963
|
+
// block the JS thread on a garbage frame.
|
|
964
|
+
recoveryDeadline = Date.now() + RECOVERY_BUDGET_MS;
|
|
356
965
|
const fixedText = fixMRZ(mrzText);
|
|
357
|
-
|
|
966
|
+
// `parse` THROWS on a wrong MRZ-line count (e.g. a noisy frame with a title
|
|
967
|
+
// line + fragments). That must NOT short-circuit the recovery/reconstruction
|
|
968
|
+
// path below — treat a throw here as "no valid parse yet" and keep going.
|
|
969
|
+
let result: ReturnType<typeof parse> | null = null;
|
|
970
|
+
try {
|
|
971
|
+
result = parse(fixedText, { autocorrect });
|
|
972
|
+
} catch {
|
|
973
|
+
result = null;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// The direct parse can validate (the name field has no check digit) while the
|
|
977
|
+
// name line still carries OCR noise — a leading "O" read as "0", or trailing
|
|
978
|
+
// "<" fillers read as K/C/E/G that leak into the given names. The
|
|
979
|
+
// reconstruction path normalises the name line; when the direct result's name
|
|
980
|
+
// looks noisy, prefer a reconstructed candidate that validates with a cleaner
|
|
981
|
+
// name. This only ever swaps in another fully check-digit-valid reading.
|
|
982
|
+
if (result?.valid && nameLooksNoisy(result)) {
|
|
983
|
+
for (const reLines of reconstructMRZCandidates(mrzText)) {
|
|
984
|
+
let cand: ReturnType<typeof parse>;
|
|
985
|
+
try {
|
|
986
|
+
cand = parse(reLines);
|
|
987
|
+
} catch {
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
if (cand.valid && !nameLooksNoisy(cand)) {
|
|
991
|
+
result = cand;
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Primary recovery: exhaustive, CHECK-DIGIT-DRIVEN correction scoped to the
|
|
998
|
+
// fields whose check digit actually fails. It tries every OCR-ambiguous
|
|
999
|
+
// permutation within those fields — ordered by how likely the OCR error is
|
|
1000
|
+
// (letter→digit in numeric fields is cheapest) — and only accepts a candidate
|
|
1001
|
+
// when the WHOLE MRZ validates (document-number, date and composite check
|
|
1002
|
+
// digits all pass). This both (a) fixes a misread document number before the
|
|
1003
|
+
// caller advances to the NFC/next screen, and (b) prefers the genuinely
|
|
1004
|
+
// correct correction over an arbitrary check-digit-satisfying one.
|
|
1005
|
+
if (!result || !result.valid) {
|
|
1006
|
+
const fixedLines = fixedText.split('\n').filter((l) => l.trim());
|
|
1007
|
+
if (fixedLines.length >= 2) {
|
|
1008
|
+
const recovered = recoverByFailingFields(fixedLines);
|
|
1009
|
+
if (recovered && recovered.valid) {
|
|
1010
|
+
result = recovered;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
358
1014
|
|
|
1015
|
+
// Last-resort fallback: the older whole-string ambiguous sweep, only for
|
|
1016
|
+
// inputs the field-scoped recovery can't address (e.g. an unrecognised
|
|
1017
|
+
// format with no check-digit span map). Still requires a fully-valid parse.
|
|
359
1018
|
if (!result || !result.valid) {
|
|
360
1019
|
const variants = generateAmbiguousVariants(fixedText);
|
|
361
1020
|
for (const variant of variants) {
|
|
1021
|
+
if (recoveryExpired()) break; // bail before blocking the JS thread
|
|
362
1022
|
if (variant === fixedText) continue;
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
1023
|
+
try {
|
|
1024
|
+
const candidate = parse(variant, { autocorrect });
|
|
1025
|
+
if (candidate && candidate.valid) {
|
|
1026
|
+
result = candidate;
|
|
1027
|
+
break;
|
|
1028
|
+
}
|
|
1029
|
+
} catch {
|
|
1030
|
+
// Malformed variant — try the next.
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Last resort: the standard line-splitter couldn't assemble a parseable MRZ
|
|
1036
|
+
// (e.g. a noisy frame where OCR split the band, mixed in front-of-card text,
|
|
1037
|
+
// or broke a field with spaces). Reconstruct fixed-width line-sets for every
|
|
1038
|
+
// supported format (TD1/TD2/TD3) and validate each WITH CHECK DIGITS, keeping
|
|
1039
|
+
// only one that passes — a wrong reconstruction can never leak through.
|
|
1040
|
+
if (!result || !result.valid) {
|
|
1041
|
+
for (const reLines of reconstructMRZCandidates(mrzText)) {
|
|
1042
|
+
// A reconstructed candidate for the wrong format (e.g. a 2-line TD2 guess
|
|
1043
|
+
// built from a TD1 frame) makes the `mrz` parser THROW on line count —
|
|
1044
|
+
// that must not abort the loop before a good candidate is tried.
|
|
1045
|
+
try {
|
|
1046
|
+
const direct = parse(reLines);
|
|
1047
|
+
if (direct && direct.valid) {
|
|
1048
|
+
result = direct;
|
|
1049
|
+
break;
|
|
1050
|
+
}
|
|
1051
|
+
const recovered = recoverByFailingFields(reLines);
|
|
1052
|
+
if (recovered && recovered.valid) {
|
|
1053
|
+
result = recovered;
|
|
1054
|
+
break;
|
|
1055
|
+
}
|
|
1056
|
+
} catch {
|
|
1057
|
+
// Skip this malformed candidate and try the next.
|
|
367
1058
|
}
|
|
368
1059
|
}
|
|
369
1060
|
}
|
|
@@ -407,6 +1098,7 @@ const validateMRZ = (
|
|
|
407
1098
|
valid: true,
|
|
408
1099
|
format,
|
|
409
1100
|
fields,
|
|
1101
|
+
correctedMrz: correctedMrzFromParse(result),
|
|
410
1102
|
};
|
|
411
1103
|
} catch (error) {
|
|
412
1104
|
return {
|
|
@@ -517,6 +1209,7 @@ export default {
|
|
|
517
1209
|
fixMRZ,
|
|
518
1210
|
validateMRZ,
|
|
519
1211
|
validateMRZWithCorrections,
|
|
1212
|
+
reconstructMRZCandidates,
|
|
520
1213
|
calculateMRZQualityScore,
|
|
521
1214
|
assessMRZQuality,
|
|
522
1215
|
isValidOCRBPattern,
|