@trustchex/react-native-sdk 1.478.7 → 1.483.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +57 -3
  2. package/ios/Camera/TrustchexCameraView.swift +20 -15
  3. package/lib/module/Screens/Dynamic/IdentityDocumentEIDScanningScreen.js +0 -3
  4. package/lib/module/Screens/Dynamic/IdentityDocumentScanningScreen.js +21 -0
  5. package/lib/module/Screens/Dynamic/VideoCallScreen.js +1 -1
  6. package/lib/module/Screens/Static/ResultScreen.js +75 -16
  7. package/lib/module/Shared/Components/DiagnosticReportButton.js +64 -0
  8. package/lib/module/Shared/Components/EIDScanner.js +52 -19
  9. package/lib/module/Shared/Components/IdentityDocumentCamera.js +132 -8
  10. package/lib/module/Shared/Components/NavigationManager.js +7 -9
  11. package/lib/module/Shared/Contexts/AppContext.js +3 -0
  12. package/lib/module/Shared/EIDReader/eidReader.js +167 -30
  13. package/lib/module/Shared/EIDReader/lds/icao/dg11File.js +77 -0
  14. package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +7 -0
  15. package/lib/module/Shared/Libs/diagnosticReport.js +133 -0
  16. package/lib/module/Shared/Libs/diagnostics.js +171 -0
  17. package/lib/module/Shared/Libs/documentDataNormalizer.js +104 -0
  18. package/lib/module/Shared/Libs/mrz.utils.js +0 -5
  19. package/lib/module/Shared/Libs/mrzFrameAggregator.js +32 -5
  20. package/lib/module/Shared/Libs/mrzTransliteration.js +80 -0
  21. package/lib/module/Shared/Libs/native-device-info.utils.js +67 -0
  22. package/lib/module/Shared/Libs/sendDiagnosticReport.js +130 -0
  23. package/lib/module/Shared/Services/DataUploadService.js +6 -3
  24. package/lib/module/Shared/Types/documentData.js +1 -0
  25. package/lib/module/Shared/Types/documentReadResult.js +3 -0
  26. package/lib/module/Translation/Resources/en.js +5 -0
  27. package/lib/module/Translation/Resources/tr.js +5 -0
  28. package/lib/module/Trustchex.js +31 -4
  29. package/lib/module/version.js +1 -1
  30. package/lib/typescript/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.d.ts.map +1 -1
  31. package/lib/typescript/src/Screens/Dynamic/IdentityDocumentScanningScreen.d.ts.map +1 -1
  32. package/lib/typescript/src/Screens/Dynamic/VideoCallScreen.d.ts.map +1 -1
  33. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  34. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts +20 -0
  35. package/lib/typescript/src/Shared/Components/DiagnosticReportButton.d.ts.map +1 -0
  36. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
  37. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  38. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts +10 -0
  39. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts.map +1 -1
  40. package/lib/typescript/src/Shared/Components/NavigationManager.d.ts.map +1 -1
  41. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts +10 -0
  42. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts.map +1 -1
  43. package/lib/typescript/src/Shared/Contexts/AppContext.d.ts +5 -0
  44. package/lib/typescript/src/Shared/Contexts/AppContext.d.ts.map +1 -1
  45. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts +2 -0
  46. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
  47. package/lib/typescript/src/Shared/EIDReader/lds/icao/dg11File.d.ts +35 -0
  48. package/lib/typescript/src/Shared/EIDReader/lds/icao/dg11File.d.ts.map +1 -0
  49. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts +48 -0
  50. package/lib/typescript/src/Shared/Libs/diagnosticReport.d.ts.map +1 -0
  51. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts +145 -0
  52. package/lib/typescript/src/Shared/Libs/diagnostics.d.ts.map +1 -0
  53. package/lib/typescript/src/Shared/Libs/documentDataNormalizer.d.ts +12 -0
  54. package/lib/typescript/src/Shared/Libs/documentDataNormalizer.d.ts.map +1 -0
  55. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  56. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +33 -0
  57. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -1
  58. package/lib/typescript/src/Shared/Libs/mrzTransliteration.d.ts +3 -0
  59. package/lib/typescript/src/Shared/Libs/mrzTransliteration.d.ts.map +1 -0
  60. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts +33 -0
  61. package/lib/typescript/src/Shared/Libs/native-device-info.utils.d.ts.map +1 -1
  62. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts +30 -0
  63. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts.map +1 -0
  64. package/lib/typescript/src/Shared/Services/DataUploadService.d.ts +3 -2
  65. package/lib/typescript/src/Shared/Services/DataUploadService.d.ts.map +1 -1
  66. package/lib/typescript/src/Shared/Types/documentData.d.ts +44 -0
  67. package/lib/typescript/src/Shared/Types/documentData.d.ts.map +1 -0
  68. package/lib/typescript/src/Shared/Types/documentReadResult.d.ts +44 -0
  69. package/lib/typescript/src/Shared/Types/documentReadResult.d.ts.map +1 -0
  70. package/lib/typescript/src/Shared/Types/mrzFields.d.ts +1 -1
  71. package/lib/typescript/src/Shared/Types/mrzFields.d.ts.map +1 -1
  72. package/lib/typescript/src/Translation/Resources/en.d.ts +5 -0
  73. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  74. package/lib/typescript/src/Translation/Resources/tr.d.ts +5 -0
  75. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  76. package/lib/typescript/src/Trustchex.d.ts +9 -0
  77. package/lib/typescript/src/Trustchex.d.ts.map +1 -1
  78. package/lib/typescript/src/index.d.ts +2 -0
  79. package/lib/typescript/src/index.d.ts.map +1 -1
  80. package/lib/typescript/src/version.d.ts +1 -1
  81. package/package.json +5 -1
  82. package/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.tsx +0 -4
  83. package/src/Screens/Dynamic/IdentityDocumentScanningScreen.tsx +23 -0
  84. package/src/Screens/Dynamic/VideoCallScreen.tsx +5 -1
  85. package/src/Screens/Static/ResultScreen.tsx +85 -16
  86. package/src/Shared/Components/DiagnosticReportButton.tsx +77 -0
  87. package/src/Shared/Components/EIDScanner.tsx +67 -18
  88. package/src/Shared/Components/IdentityDocumentCamera.tsx +145 -7
  89. package/src/Shared/Components/IdentityDocumentCamera.types.ts +6 -0
  90. package/src/Shared/Components/NavigationManager.tsx +7 -7
  91. package/src/Shared/Components/TrustchexCamera.tsx +6 -0
  92. package/src/Shared/Contexts/AppContext.ts +8 -0
  93. package/src/Shared/EIDReader/eidReader.ts +193 -47
  94. package/src/Shared/EIDReader/lds/icao/dg11File.ts +94 -0
  95. package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +7 -0
  96. package/src/Shared/Libs/diagnosticReport.ts +206 -0
  97. package/src/Shared/Libs/diagnostics.ts +251 -0
  98. package/src/Shared/Libs/documentDataNormalizer.ts +108 -0
  99. package/src/Shared/Libs/mrz.utils.ts +0 -5
  100. package/src/Shared/Libs/mrzFrameAggregator.ts +61 -3
  101. package/src/Shared/Libs/mrzTransliteration.ts +131 -0
  102. package/src/Shared/Libs/native-device-info.utils.ts +116 -0
  103. package/src/Shared/Libs/sendDiagnosticReport.ts +165 -0
  104. package/src/Shared/Services/DataUploadService.ts +9 -3
  105. package/src/Shared/Types/documentData.ts +52 -0
  106. package/src/Shared/Types/documentReadResult.ts +47 -0
  107. package/src/Shared/Types/mrzFields.ts +0 -5
  108. package/src/Translation/Resources/en.ts +6 -0
  109. package/src/Translation/Resources/tr.ts +6 -0
  110. package/src/Trustchex.tsx +48 -3
  111. package/src/index.tsx +7 -0
  112. package/src/version.ts +1 -1
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+
3
+ import { Buffer } from 'buffer';
4
+ import { TLVInputStream } from "../../tlv/tlvInputStream.js";
5
+ import { DataGroup } from "../dataGroup.js";
6
+
7
+ /**
8
+ * DG11 — Additional Personal Data (ICAO 9303-10 §4.6.6).
9
+ *
10
+ * We only extract tag 0x5F0E (Name of Holder), which contains the full name
11
+ * exactly as printed on the document, in UTF-8, using the same `<<` primary/
12
+ * secondary delimiter as the MRZ (but with native Unicode characters).
13
+ *
14
+ * Example: "ÖZTÜRK<<GÖKHAN MEHMET" → lastName="ÖZTÜRK", firstName="GÖKHAN MEHMET"
15
+ *
16
+ * The file is optional on the chip — if absent or unreadable we return null
17
+ * so the caller can fall back to reverse transliteration.
18
+ */
19
+ export class DG11File extends DataGroup {
20
+ static DG11_TAG = 0x6b;
21
+ static TAG_LIST_TAG = 0x5c;
22
+ static NAME_OF_HOLDER_TAG = 0x5f0e;
23
+ firstName = null;
24
+ lastName = null;
25
+ constructor(is) {
26
+ super(DG11File.DG11_TAG, is);
27
+ }
28
+
29
+ /** Returns the first name from DG11, or null if not present. */
30
+ getFirstName() {
31
+ return this.firstName;
32
+ }
33
+
34
+ /** Returns the last name from DG11, or null if not present. */
35
+ getLastName() {
36
+ return this.lastName;
37
+ }
38
+ async read() {
39
+ await super.readObject();
40
+ }
41
+ async readContent(inputStream) {
42
+ const tlvIn = inputStream instanceof TLVInputStream ? inputStream : new TLVInputStream(inputStream);
43
+
44
+ // Skip the tag list (0x5C) — we only care about 0x5F0E
45
+ // The tag list tells us which optional tags are present; we probe directly.
46
+ try {
47
+ await tlvIn.skipToTag(DG11File.NAME_OF_HOLDER_TAG);
48
+ } catch {
49
+ // Tag not present in this chip's DG11
50
+ return;
51
+ }
52
+ const length = await tlvIn.readLength();
53
+ if (length <= 0) return;
54
+ const bytes = new Uint8Array(length);
55
+ await inputStream.readBytesWithOffset(bytes, 0, length);
56
+ const nameString = Buffer.from(bytes).toString('utf8').trim();
57
+ this.parseName(nameString);
58
+ }
59
+ writeContent() {
60
+ // Write not needed for reading
61
+ }
62
+
63
+ /**
64
+ * Split "PRIMARY<<SECONDARY" into last/first name components.
65
+ * Strips trailing filler `<` within each segment.
66
+ */
67
+ parseName(nameString) {
68
+ const delimIndex = nameString.indexOf('<<');
69
+ if (delimIndex < 0) {
70
+ this.lastName = nameString.replace(/<+$/, '').trim();
71
+ this.firstName = '';
72
+ return;
73
+ }
74
+ this.lastName = nameString.substring(0, delimIndex).replace(/<+$/, '').trim();
75
+ this.firstName = nameString.substring(delimIndex + 2).replace(/<$/, ' ').replace(/<+$/, '').trim();
76
+ }
77
+ }
@@ -118,6 +118,13 @@ Covered by the "real letters in names are not eaten" tests in `mrz.utils.test.ts
118
118
  30–44-char MRZ line below that floor, so the native pipeline **crops to the
119
119
  on-screen scan frame and upscales + grayscale/contrast-enhances** before OCR.
120
120
  The MRZ band must be reasonably filled within the on-screen guide.
121
+ - Two distinct regions exist in the native code (don't confuse them):
122
+ the **MRZ-OCR crop** (`buildScanFrameRoiImage`: top 25% / bottom 12% / sides
123
+ 4%, upscaled ≤2.5× to ≤2200px, grayscale, contrast 1.6×) and the
124
+ **brightness-sampling region** (the on-screen guide: 36% / 36% / 5%). Both are
125
+ the same on iOS and Android. The diagnostic report records the measured MRZ
126
+ band pixel height so you can tell when a device leaves the band below the
127
+ ~16 px/char floor.
121
128
  - **Glare / lamination.** Heavy glare on a glossy laminated card still degrades
122
129
  OCR; the enhancement helps but does not eliminate it. Steadying the card and
123
130
  reducing direct glare improves convergence.
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Builds the support diagnostic report from a scan session.
5
+ *
6
+ * Produces three things the result screen turns into a reviewable email:
7
+ * - `diagnostics.json` — technical signals (device, camera, MRZ, NFC steps).
8
+ * - `scan-data.json` — the scanned identity data + raw MRZ (PII; the user
9
+ * reviews it in the draft and chooses to send).
10
+ * - a short, size-bounded plain-text BODY summary that always fits an email.
11
+ *
12
+ * The full detail lives in the JSON attachments; the email body is intentionally
13
+ * compact so the draft opens reliably on every mail client.
14
+ */
15
+
16
+ /** ICAO 9303 7-3-1 weighted check digit (local copy; mirrors mrz.utils). */
17
+ const icaoCheckDigit = str => {
18
+ const weights = [7, 3, 1];
19
+ let sum = 0;
20
+ for (let i = 0; i < str.length; i++) {
21
+ const c = str[i];
22
+ const v = c === '<' ? 0 : c >= '0' && c <= '9' ? c.charCodeAt(0) - 48 : c.charCodeAt(0) - 55;
23
+ sum = (sum + weights[i % 3] * v) % 10;
24
+ }
25
+ return String(sum);
26
+ };
27
+ /** Compute per-field check-digit pass/fail for a TD1 MRZ (best-effort). */
28
+ const computeTd1CheckDigits = mrzText => {
29
+ if (!mrzText) return undefined;
30
+ const lines = mrzText.split('\n').map(l => l.trim()).filter(Boolean);
31
+ if (lines.length < 2 || lines[0].length < 30 || lines[1].length < 30) {
32
+ return undefined; // only TD1 handled here; other formats omitted from summary
33
+ }
34
+ const l1 = lines[0];
35
+ const l2 = lines[1];
36
+ const result = {};
37
+ const check = (data, printed) => icaoCheckDigit(data) === printed ? 'pass' : 'fail';
38
+
39
+ // Turkish convention: a doc number may carry a leading serial LETTER, and the
40
+ // doc-number check digit is computed over the DIGITS ONLY (letter dropped).
41
+ // The TD1 composite then also diverges from strict ICAO for such cards (no
42
+ // interpretation of the letter reproduces it). To avoid reporting a misleading
43
+ // "fail" on a card the SDK legitimately accepted, detect the letter-prefix and
44
+ // treat both the doc-number and composite as validated under that convention.
45
+ const docData = l1.slice(5, 14);
46
+ const docPrinted = l1[14];
47
+ const hasLetterPrefix = /^[A-Z]/.test(docData);
48
+ const strictDocOk = icaoCheckDigit(docData) === docPrinted;
49
+ const turkishDocOk = hasLetterPrefix && icaoCheckDigit(docData.slice(1)) === docPrinted;
50
+ const docOk = strictDocOk || turkishDocOk;
51
+ result.documentNumber = docOk ? 'pass' : 'fail';
52
+ result.birthDate = check(l2.slice(0, 6), l2[6]);
53
+ result.expiry = check(l2.slice(8, 14), l2[14]);
54
+ const composite = l1.slice(5, 30) + l2.slice(0, 7) + l2.slice(8, 15) + l2.slice(18, 29);
55
+ const compStrict = check(composite, l2[29]);
56
+ // If the strict composite fails ONLY because of the Turkish letter-prefix
57
+ // doc number (which itself passed under the digits-only rule), don't surface a
58
+ // misleading failure — the SDK accepts these cards.
59
+ result.composite = compStrict === 'fail' && hasLetterPrefix && docOk ? 'pass' : compStrict;
60
+ // The Turkish convention was the deciding factor only when strict ICAO would
61
+ // have failed the doc number but the digits-only rule passes it.
62
+ const turkishLetterPrefix = !strictDocOk && turkishDocOk;
63
+ return {
64
+ checkDigits: result,
65
+ turkishLetterPrefix
66
+ };
67
+ };
68
+ const MAX_BODY_BYTES = 4096;
69
+ const yesNo = b => b ? 'yes' : 'no';
70
+
71
+ /** Assemble the full report bundle. Pure — no I/O, no native calls. */
72
+ export const buildDiagnosticReport = params => {
73
+ const {
74
+ diag,
75
+ device,
76
+ scan,
77
+ sdkVersion,
78
+ sessionId
79
+ } = params;
80
+ const computed = computeTd1CheckDigits(scan.mrzText);
81
+ const checkDigits = diag.mrz.checkDigits ?? computed?.checkDigits;
82
+ // Set the Turkish-letter-prefix flag from the final accepted MRZ (the collector
83
+ // can't know it during scanning; the report does, from the MRZ text).
84
+ const turkishLetterPrefixApplied = diag.mrz.turkishLetterPrefixApplied || computed?.turkishLetterPrefix === true;
85
+
86
+ // --- diagnostics.json (technical, low-PII) ---
87
+ const diagnosticsObj = {
88
+ schemaVersion: diag.schemaVersion,
89
+ generatedAt: diag.generatedAt,
90
+ sessionId: sessionId ?? null,
91
+ sdk: {
92
+ platform: device.platform,
93
+ sdkVersion,
94
+ buildNumber: device.buildNumber
95
+ },
96
+ device,
97
+ scan: {
98
+ documentType: scan.documentType ?? diag.documentType ?? null,
99
+ dataSource: scan.dataSource ?? diag.dataSource ?? null,
100
+ totalDurationMs: diag.totalDurationMs,
101
+ camera: diag.camera
102
+ },
103
+ mrz: {
104
+ ...diag.mrz,
105
+ checkDigits: checkDigits ?? null,
106
+ turkishLetterPrefixApplied
107
+ },
108
+ nfc: diag.nfc,
109
+ errors: diag.errors
110
+ };
111
+
112
+ // --- scan-data.json (PII; user reviews before sending) ---
113
+ const scanDataObj = {
114
+ documentType: scan.documentType ?? diag.documentType ?? null,
115
+ dataSource: scan.dataSource ?? diag.dataSource ?? null,
116
+ mrzText: scan.mrzText ?? null,
117
+ fields: scan.mrzFields ?? null,
118
+ barcodeValue: scan.barcodeValue ?? null
119
+ };
120
+
121
+ // --- compact email body (always under MAX_BODY_BYTES) ---
122
+ const nfc = diag.nfc;
123
+ const nfcLine = nfc.attempted ? `${nfc.authProtocol}${nfc.paceFellBackToBac ? '→BAC' : ''} · ${nfc.succeeded ? 'success' : `failed at ${nfc.failureStep ?? '?'} (${nfc.errorCategory ?? 'error'})`}` : 'not attempted';
124
+ const checksLine = checkDigits ? Object.entries(checkDigits).map(([k, v]) => `${k}:${v}`).join(' ') : 'n/a';
125
+ const body = ['TRUSTCHEX SCAN REPORT', `Device: ${device.brand} ${device.model} · ${device.systemName} ${device.systemVersion}${device.apiLevel ? ` (API ${device.apiLevel})` : ''}`, `SDK: ${sdkVersion} (build ${device.buildNumber}) · ${device.manufacturer} · ${device.deviceId}`, `Document: ${scanDataObj.documentType ?? 'unknown'} · source: ${scanDataObj.dataSource ?? 'unknown'}`, `MRZ: ${diag.mrz.reachedStable ? 'stable' : 'NOT stable'} after ${diag.mrz.framesProcessed} frames · checks: ${checksLine}`, `Camera: ${diag.camera.frames} frames · brightness ${diag.camera.avgBrightness} (low:${yesNo(diag.camera.hadLowBrightness)} blur:${yesNo(diag.camera.hadBlurryFrames)})`, `NFC: ${nfcLine}`, sessionId ? `Session: ${sessionId}` : '', '', 'Describe the issue here:', '', '', '— Full diagnostics and document images are attached. Please review before sending; do not edit the attachments. —'].filter(Boolean).join('\n');
126
+ const subject = `Trustchex scan report — ${scanDataObj.documentType ?? 'doc'} — ${device.brand} ${device.model} — v${sdkVersion}${sessionId ? ` — ${sessionId}` : ''}`;
127
+ return {
128
+ diagnosticsJson: JSON.stringify(diagnosticsObj, null, 2),
129
+ scanDataJson: JSON.stringify(scanDataObj, null, 2),
130
+ body: body.length > MAX_BODY_BYTES ? body.slice(0, MAX_BODY_BYTES - 1) + '…' : body,
131
+ subject
132
+ };
133
+ };
@@ -0,0 +1,171 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Diagnostics collector for the "Report a scanning issue" feature.
5
+ *
6
+ * Accumulates a PII-light technical snapshot of a single scan session — camera
7
+ * quality, MRZ consensus, and (the highest-value, most device-specific part) a
8
+ * structured per-step NFC/eID read log. The result screen serialises this into
9
+ * the diagnostic email the user reviews and sends to support.
10
+ *
11
+ * Design notes:
12
+ * - This is a MODULE-LEVEL SINGLETON so the deep camera/EID call stack can record
13
+ * into it without threading a prop/context through every layer. Call `reset()`
14
+ * at the start of a scan flow.
15
+ * - It stores only TECHNICAL signals (counts, timings, brightness, APDU status
16
+ * words, pass/fail). It deliberately holds NO identity data, images, MRZ text,
17
+ * keys, or nonces — the scanned identity/images shown to the user come from the
18
+ * result screen's own state, not from here, and are attached separately with
19
+ * the user's explicit review in the email draft.
20
+ */
21
+
22
+ /** A single step of the NFC/eID read, with its outcome and timing. */
23
+
24
+ const emptyCamera = () => ({
25
+ frames: 0,
26
+ frameW: 0,
27
+ frameH: 0,
28
+ avgBrightness: 0,
29
+ minBrightness: 0,
30
+ maxBrightness: 0,
31
+ hadLowBrightness: false,
32
+ hadBlurryFrames: false
33
+ });
34
+ const emptyMrz = () => ({
35
+ framesProcessed: 0,
36
+ reachedStable: false,
37
+ stabilityFrames: 0,
38
+ minContestedMargin: 0,
39
+ turkishLetterPrefixApplied: false
40
+ });
41
+ const emptyNfc = () => ({
42
+ attempted: false,
43
+ chipDetected: false,
44
+ authProtocol: 'none',
45
+ paceFellBackToBac: false,
46
+ steps: [],
47
+ retryCount: 0,
48
+ succeeded: false
49
+ });
50
+
51
+ /**
52
+ * Accumulates one scan session's diagnostics. A single shared instance is
53
+ * exported below; tests can construct their own.
54
+ */
55
+ export class DiagnosticsCollector {
56
+ startedAtMs = 0;
57
+ camera = emptyCamera();
58
+ mrz = emptyMrz();
59
+ nfc = emptyNfc();
60
+ errors = [];
61
+
62
+ /** Begin a fresh session — call when a scan flow starts. */
63
+ reset(nowMs) {
64
+ this.startedAtMs = nowMs;
65
+ this.documentType = undefined;
66
+ this.dataSource = undefined;
67
+ this.camera = emptyCamera();
68
+ this.mrz = emptyMrz();
69
+ this.nfc = emptyNfc();
70
+ this.errors = [];
71
+ }
72
+ setDocument(documentType, dataSource) {
73
+ if (documentType) this.documentType = documentType;
74
+ if (dataSource) this.dataSource = dataSource;
75
+ }
76
+
77
+ /** Merge a camera snapshot (called periodically as frames arrive). */
78
+ setCamera(c) {
79
+ this.camera = {
80
+ ...this.camera,
81
+ ...c
82
+ };
83
+ }
84
+
85
+ /** Merge MRZ consensus metrics. `reachedStable` is STICKY (once true, stays
86
+ * true) so a later frame's flicker can't unset that the scan did stabilise;
87
+ * `maxMrzBandHeightPx` keeps the largest seen. */
88
+ setMrz(m) {
89
+ const reachedStable = this.mrz.reachedStable || m.reachedStable === true;
90
+ const maxMrzBandHeightPx = Math.max(this.mrz.maxMrzBandHeightPx ?? 0, m.maxMrzBandHeightPx ?? 0) || undefined;
91
+ this.mrz = {
92
+ ...this.mrz,
93
+ ...m,
94
+ reachedStable,
95
+ maxMrzBandHeightPx
96
+ };
97
+ }
98
+
99
+ // ---- NFC: the structured per-step log ----
100
+
101
+ nfcAttemptStarted() {
102
+ // A fresh attempt: bump retry count if a previous attempt left steps.
103
+ if (this.nfc.attempted) this.nfc.retryCount += 1;
104
+ this.nfc.attempted = true;
105
+ }
106
+ nfcChipDetected() {
107
+ this.nfc.chipDetected = true;
108
+ }
109
+ nfcSetAuth(protocol, oid) {
110
+ this.nfc.authProtocol = protocol;
111
+ if (oid) this.nfc.paceOid = oid;
112
+ }
113
+ nfcPaceFellBackToBac(reason) {
114
+ this.nfc.paceFellBackToBac = true;
115
+ if (reason && !this.nfc.paceFallbackReason) {
116
+ this.nfc.paceFallbackReason = reason;
117
+ }
118
+ }
119
+
120
+ /** Record one completed step. Keep messages short and free of PII. */
121
+ nfcStep(step) {
122
+ this.nfc.steps.push(step);
123
+ if (!step.ok && !this.nfc.failureStep) {
124
+ this.nfc.failureStep = step.step;
125
+ }
126
+ }
127
+ nfcResult(succeeded, errorCategory) {
128
+ this.nfc.succeeded = succeeded;
129
+ if (succeeded) {
130
+ // The read ultimately succeeded — transient per-attempt failures (e.g. a
131
+ // recovered DG2 retry, or the expected EF.CardAccess 6982 on a BAC-only
132
+ // card) must NOT be reported as the outcome. Clear the failure markers.
133
+ this.nfc.failureStep = undefined;
134
+ this.nfc.errorCategory = undefined;
135
+ } else if (errorCategory) {
136
+ this.nfc.errorCategory = errorCategory;
137
+ }
138
+ }
139
+ addError(e) {
140
+ this.errors.push(e);
141
+ }
142
+
143
+ /** Build the serialisable snapshot. */
144
+ snapshot(nowMs, isoNow) {
145
+ return {
146
+ schemaVersion: 1,
147
+ generatedAt: isoNow,
148
+ documentType: this.documentType,
149
+ dataSource: this.dataSource,
150
+ totalDurationMs: this.startedAtMs ? Math.max(0, nowMs - this.startedAtMs) : 0,
151
+ camera: {
152
+ ...this.camera
153
+ },
154
+ mrz: {
155
+ ...this.mrz
156
+ },
157
+ nfc: {
158
+ ...this.nfc,
159
+ steps: this.nfc.steps.map(s => ({
160
+ ...s
161
+ }))
162
+ },
163
+ errors: this.errors.map(e => ({
164
+ ...e
165
+ }))
166
+ };
167
+ }
168
+ }
169
+
170
+ /** Shared session-scoped collector used across the scan flow. */
171
+ export const diagnostics = new DiagnosticsCollector();
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Expand a 2-digit MRZ year to a 4-digit calendar year.
5
+ * ICAO 9303: YY >= 30 → 19YY, YY < 30 → 20YY.
6
+ * (Threshold 30 keeps all current valid documents in range.)
7
+ */
8
+ function expandYear(yy) {
9
+ const n = parseInt(yy, 10);
10
+ return n >= 30 ? `19${yy}` : `20${yy}`;
11
+ }
12
+
13
+ /**
14
+ * Convert a YYMMDD MRZ date string to ISO 8601 YYYY-MM-DD.
15
+ * Returns null for empty, null, or malformed input.
16
+ */
17
+ function mrzDateToISO(raw) {
18
+ if (!raw || raw.length !== 6) return null;
19
+ const yy = raw.slice(0, 2);
20
+ const mm = raw.slice(2, 4);
21
+ const dd = raw.slice(4, 6);
22
+ if (!/^\d{6}$/.test(raw)) return null;
23
+ return `${expandYear(yy)}-${mm}-${dd}`;
24
+ }
25
+
26
+ /**
27
+ * Normalize the MRZ document-code field to a single-letter form.
28
+ * The `mrz` npm package returns "ID" for TD1, while the chip returns "I".
29
+ */
30
+ function normalizeDocumentType(raw) {
31
+ if (!raw) return 'I';
32
+ const upper = raw.trim().toUpperCase();
33
+ if (upper === 'ID') return 'I';
34
+ // TD2 can be "IR", "AC", etc — take only the first character
35
+ return upper.charAt(0);
36
+ }
37
+
38
+ /**
39
+ * Normalize the sex field to a single uppercase letter.
40
+ * NFC chip: "M" | "F" | "U" | "X"
41
+ * mrz package: "male" | "female" | "" | null
42
+ */
43
+ function normalizeSex(raw) {
44
+ if (!raw) return 'U';
45
+ switch (raw.trim().toLowerCase()) {
46
+ case 'm':
47
+ case 'male':
48
+ return 'M';
49
+ case 'f':
50
+ case 'female':
51
+ return 'F';
52
+ case 'x':
53
+ case 'unspecified':
54
+ return 'X';
55
+ default:
56
+ return 'U';
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Trim a string and return null if empty or only filler chars.
62
+ */
63
+ function clean(val) {
64
+ if (!val) return null;
65
+ const t = val.replace(/</g, '').trim();
66
+ return t.length > 0 ? t : null;
67
+ }
68
+
69
+ /**
70
+ * Build a DocumentData from a chip-read MRZInfo object (NFC path).
71
+ */
72
+ export function normalizeFromMRZInfo(mrz) {
73
+ const rawPersonal = mrz.getPersonalNumber() ?? clean(mrz.getOptionalData1());
74
+ return {
75
+ documentType: normalizeDocumentType(mrz.getDocumentCode()),
76
+ issuingCountry: mrz.getIssuingState().replace(/<$/, '').trim(),
77
+ nationality: mrz.getNationality().replace(/<$/, '').trim(),
78
+ documentNumber: mrz.getDocumentNumber().trim(),
79
+ personalNumber: clean(rawPersonal),
80
+ lastName: mrz.getPrimaryIdentifier().trim(),
81
+ firstName: mrz.getSecondaryIdentifier().trim(),
82
+ sex: normalizeSex(mrz.getGender().getStrCode()),
83
+ dateOfBirth: mrzDateToISO(mrz.getDateOfBirth()),
84
+ dateOfExpiry: mrzDateToISO(mrz.getDateOfExpiry())
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Build a DocumentData from an MRZFields object (camera OCR path).
90
+ */
91
+ export function normalizeFromMRZFields(fields) {
92
+ return {
93
+ documentType: normalizeDocumentType(fields.documentCode),
94
+ issuingCountry: clean(fields.issuingState) ?? '',
95
+ nationality: clean(fields.nationality) ?? '',
96
+ documentNumber: clean(fields.documentNumber) ?? '',
97
+ personalNumber: clean(fields.personalNumber),
98
+ lastName: clean(fields.lastName) ?? '',
99
+ firstName: clean(fields.firstName) ?? '',
100
+ sex: normalizeSex(fields.sex),
101
+ dateOfBirth: mrzDateToISO(fields.birthDate),
102
+ dateOfExpiry: mrzDateToISO(fields.expirationDate)
103
+ };
104
+ }
@@ -1134,18 +1134,13 @@ const validateMRZ = (mrzText, autocorrect = true) => {
1134
1134
  documentCode: result.fields.documentCode || null,
1135
1135
  issuingState: result.fields.issuingState || null,
1136
1136
  documentNumber: result.fields.documentNumber || null,
1137
- documentNumberCheckDigit: result.fields.documentNumberCheckDigit || null,
1138
1137
  nationality: result.fields.nationality || null,
1139
1138
  lastName: result.fields.lastName || null,
1140
1139
  firstName: result.fields.firstName || null,
1141
1140
  sex: result.fields.sex || null,
1142
1141
  birthDate: result.fields.birthDate || null,
1143
- birthDateCheckDigit: result.fields.birthDateCheckDigit || null,
1144
1142
  expirationDate: result.fields.expirationDate || null,
1145
- expirationDateCheckDigit: result.fields.expirationDateCheckDigit || null,
1146
1143
  personalNumber: result.fields.personalNumber || null,
1147
- personalNumberCheckDigit: result.fields.personalNumberCheckDigit || null,
1148
- compositeCheckDigit: result.fields.compositeCheckDigit || null,
1149
1144
  optional1: result.fields.optional1 || null,
1150
1145
  optional2: result.fields.optional2 || null
1151
1146
  };
@@ -40,6 +40,12 @@ const FORMAT_GEOMETRY = {
40
40
  }
41
41
  };
42
42
  const MRZ_CHAR = /^[A-Z0-9<]$/;
43
+ /**
44
+ * Floor applied to a frame's confidence multiplier. A frame ML Kit was very
45
+ * unsure about still contributes (it may carry the only reading of a cell), but
46
+ * at a fraction of a confident frame's weight.
47
+ */
48
+ const MIN_CONFIDENCE_WEIGHT = 0.25;
43
49
  /**
44
50
  * Reconstructs fixed-width MRZ candidate line-sets for a frame's raw OCR text.
45
51
  * Reuses the same reconstruction the single-frame validator uses, so the
@@ -181,7 +187,12 @@ export class MRZFrameAggregator {
181
187
  * consensus is returned.
182
188
  */
183
189
  addFrame(input) {
184
- const weight = input.weight && input.weight > 0 ? input.weight : 1;
190
+ const baseWeight = input.weight && input.weight > 0 ? input.weight : 1;
191
+ // Fold OCR confidence into the weight (clamped to [floor, 1]). Absent
192
+ // confidence leaves the weight unchanged, so callers without per-symbol
193
+ // confidence (e.g. iOS ML Kit) keep the prior sharpness-only behaviour.
194
+ const confidenceFactor = typeof input.confidence === 'number' && Number.isFinite(input.confidence) ? Math.min(1, Math.max(MIN_CONFIDENCE_WEIGHT, input.confidence)) : 1;
195
+ const weight = baseWeight * confidenceFactor;
185
196
  const lines = linesForFrame(input.text);
186
197
  if (lines) {
187
198
  // Determine the format this frame's lines fit (by line count + width).
@@ -244,18 +255,30 @@ export class MRZFrameAggregator {
244
255
  * is what turns "looks valid this frame" into "the camera agrees across frames".
245
256
  */
246
257
  consensusIsConfident(format) {
258
+ return this.minContestedMargin(format) >= this.minMargin;
259
+ }
260
+
261
+ /**
262
+ * Smallest winning vote margin across all CONTESTED cells (where >1 distinct
263
+ * char was voted). Returns Infinity when no cell is contested. Used both to
264
+ * gate confidence and to surface a diagnostic of how narrowly a look-alike was
265
+ * resolved at acceptance.
266
+ */
267
+ minContestedMargin(format) {
247
268
  const geom = FORMAT_GEOMETRY[format];
248
269
  const tally = this.tallies[format];
270
+ let min = Infinity;
249
271
  for (let li = 0; li < geom.lines; li++) {
250
272
  for (let ci = 0; ci < geom.width; ci++) {
251
273
  const cell = tally[li][ci];
252
274
  const weights = Object.values(cell);
253
275
  if (weights.length < 2) continue; // uncontested cell — no ambiguity
254
276
  weights.sort((a, b) => b - a);
255
- if (weights[0] - weights[1] < this.minMargin) return false;
277
+ const margin = weights[0] - weights[1];
278
+ if (margin < min) min = margin;
256
279
  }
257
280
  }
258
- return true;
281
+ return min;
259
282
  }
260
283
 
261
284
  /** Read out the winning character per cell for a format. */
@@ -293,7 +316,9 @@ export class MRZFrameAggregator {
293
316
  mrz: null,
294
317
  validation: null,
295
318
  frames: this.frameCount,
296
- stable: false
319
+ stable: false,
320
+ stableStreak: 0,
321
+ minContestedMargin: Infinity
297
322
  };
298
323
  }
299
324
  let bestMrz = null;
@@ -338,7 +363,9 @@ export class MRZFrameAggregator {
338
363
  mrz: bestMrz,
339
364
  validation: bestValidation,
340
365
  frames: this.frameCount,
341
- stable: bestValid && confident && this.stableStreak >= this.stabilityTarget
366
+ stable: bestValid && confident && this.stableStreak >= this.stabilityTarget,
367
+ stableStreak: this.stableStreak,
368
+ minContestedMargin: bestFormat != null ? this.minContestedMargin(bestFormat) : Infinity
342
369
  };
343
370
  }
344
371
  }
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+
3
+ // ICAO 9303 digraphs safe to apply universally — these appear across many
4
+ // national variants and are unlikely to collide with real letter sequences.
5
+ // SS→ß, TH→Þ etc. are excluded: too ambiguous (HASSAN, THOMAS).
6
+ const UNIVERSAL = [['OE', 'Ö'], ['UE', 'Ü']];
7
+ const GERMAN = [['AE', 'Ä'], ['OE', 'Ö'], ['UE', 'Ü'], ['SS', 'ß']];
8
+ const SWEDISH = [['AA', 'Å'], ['AE', 'Æ'], ['OE', 'Ø']];
9
+ const DANISH = [['AA', 'Å'], ['AE', 'Æ'], ['OE', 'Ø']];
10
+ const NORWEGIAN = [['AA', 'Å'], ['AE', 'Æ'], ['OE', 'Ø']];
11
+ const FRENCH = [['OE', 'Œ'], ['AE', 'Æ']];
12
+ const ICELANDIC = [['AE', 'Æ'], ['OE', 'Ö'], ['TH', 'Þ'], ['D', 'Ð']];
13
+ const COUNTRY_MAP = {
14
+ DEU: GERMAN,
15
+ AUT: GERMAN,
16
+ CHE: GERMAN,
17
+ SWE: SWEDISH,
18
+ DNK: DANISH,
19
+ NOR: NORWEGIAN,
20
+ FRA: FRENCH,
21
+ LUX: FRENCH,
22
+ ISL: ICELANDIC
23
+ };
24
+ function applyDigraphMap(token, map) {
25
+ let result = '';
26
+ let i = 0;
27
+ while (i < token.length) {
28
+ let matched = false;
29
+ for (const [digraph, unicode] of map) {
30
+ if (token.startsWith(digraph, i)) {
31
+ result += unicode;
32
+ i += digraph.length;
33
+ matched = true;
34
+ break;
35
+ }
36
+ }
37
+ if (!matched) {
38
+ result += token[i];
39
+ i++;
40
+ }
41
+ }
42
+ return result;
43
+ }
44
+ function convertName(rawName, map) {
45
+ return rawName.split(' ').map(word => applyDigraphMap(word, map)).join(' ');
46
+ }
47
+ export function buildDocumentName(rawFirst, rawLast, issuingState, dg11First, dg11Last) {
48
+ if (dg11First != null && dg11Last != null && (dg11First || dg11Last)) {
49
+ return {
50
+ rawFirst,
51
+ rawLast,
52
+ displayFirst: dg11First,
53
+ displayLast: dg11Last,
54
+ source: 'dg11'
55
+ };
56
+ }
57
+ const country = (issuingState ?? '').toUpperCase().trim();
58
+ const map = COUNTRY_MAP[country];
59
+ if (map) {
60
+ return {
61
+ rawFirst,
62
+ rawLast,
63
+ displayFirst: convertName(rawFirst, map),
64
+ displayLast: convertName(rawLast, map),
65
+ source: 'reverse_table'
66
+ };
67
+ }
68
+
69
+ // No country-specific table: apply OE→Ö and UE→Ü universally.
70
+ const displayFirst = convertName(rawFirst, UNIVERSAL);
71
+ const displayLast = convertName(rawLast, UNIVERSAL);
72
+ const changed = displayFirst !== rawFirst || displayLast !== rawLast;
73
+ return {
74
+ rawFirst,
75
+ rawLast,
76
+ displayFirst,
77
+ displayLast,
78
+ source: changed ? 'reverse_table' : 'raw'
79
+ };
80
+ }