@trustchex/react-native-sdk 1.512.1 → 1.513.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/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +9 -2
- package/android/src/main/java/com/trustchex/reactnativesdk/opencv/OpenCVModule.kt +54 -0
- package/ios/Camera/TrustchexCameraView.swift +6 -2
- package/ios/OpenCV/OpenCVModule.mm +59 -0
- package/lib/module/Screens/Dynamic/AddressCaptureScreen.js +427 -112
- package/lib/module/Screens/Dynamic/IdentityDocumentEIDScanningScreen.js +9 -4
- package/lib/module/Screens/Static/ResultScreen.js +46 -0
- package/lib/module/Shared/Animations/address-scan.json +1 -0
- package/lib/module/Shared/Components/DebugNavigationPanel.js +10 -0
- package/lib/module/Shared/Components/EIDScanner.js +16 -4
- package/lib/module/Shared/Components/IdentityDocumentCamera.js +100 -19
- package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +47 -0
- package/lib/module/Shared/Libs/addressProofHeuristic.js +130 -0
- package/lib/module/Shared/Libs/demo.utils.js +4 -0
- package/lib/module/Shared/Libs/sendDiagnosticReport.js +47 -1
- package/lib/module/Shared/Libs/vizName.js +138 -16
- package/lib/module/Translation/Resources/en.js +16 -3
- package/lib/module/Translation/Resources/tr.js +16 -3
- package/lib/module/version.js +1 -1
- package/lib/typescript/src/Screens/Dynamic/AddressCaptureScreen.d.ts.map +1 -1
- package/lib/typescript/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.d.ts.map +1 -1
- package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/DebugNavigationPanel.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/EIDScanner.d.ts +8 -3
- package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +25 -0
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/addressProofHeuristic.d.ts +34 -0
- package/lib/typescript/src/Shared/Libs/addressProofHeuristic.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Libs/demo.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/vizName.d.ts +31 -6
- package/lib/typescript/src/Shared/Libs/vizName.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Types/identificationInfo.d.ts +0 -5
- package/lib/typescript/src/Shared/Types/identificationInfo.d.ts.map +1 -1
- package/lib/typescript/src/Translation/Resources/en.d.ts +13 -0
- package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
- package/lib/typescript/src/Translation/Resources/tr.d.ts +13 -0
- package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
- package/lib/typescript/src/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/Screens/Dynamic/AddressCaptureScreen.tsx +434 -75
- package/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.tsx +14 -4
- package/src/Screens/Static/ResultScreen.tsx +59 -0
- package/src/Shared/Animations/address-scan.json +1 -0
- package/src/Shared/Components/DebugNavigationPanel.tsx +13 -0
- package/src/Shared/Components/EIDScanner.tsx +38 -12
- package/src/Shared/Components/IdentityDocumentCamera.tsx +109 -17
- package/src/Shared/Components/IdentityDocumentCamera.utils.ts +53 -0
- package/src/Shared/Libs/addressProofHeuristic.ts +197 -0
- package/src/Shared/Libs/demo.utils.ts +5 -0
- package/src/Shared/Libs/sendDiagnosticReport.ts +65 -7
- package/src/Shared/Libs/vizName.ts +158 -20
- package/src/Shared/Types/identificationInfo.ts +0 -5
- package/src/Translation/Resources/en.ts +28 -3
- package/src/Translation/Resources/tr.ts +28 -3
- package/src/version.ts +1 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-device, best-effort check that a captured address-proof photo actually
|
|
3
|
+
* looks like a document containing the holder's NAME and ADDRESS information.
|
|
4
|
+
*
|
|
5
|
+
* MASAK Tebliğ No. 32 Art. 4/C(1)(d) requires an OPERATOR to manually review the
|
|
6
|
+
* address proof, so this is only a soft quality gate: it nudges the user to
|
|
7
|
+
* retake an obviously-wrong photo (a selfie, a blank wall, the wrong document)
|
|
8
|
+
* before it wastes an operator's time. A negative result NEVER blocks the user.
|
|
9
|
+
*
|
|
10
|
+
* Input is the OCR text recognised on the captured proof photo (full image,
|
|
11
|
+
* via the native still-image recognizer), plus the holder's name and any chip
|
|
12
|
+
* (DG11) address tokens — no extra ML models, no network, works offline.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { foldDiacritics } from './vizName';
|
|
16
|
+
|
|
17
|
+
export interface AddressProofSignals {
|
|
18
|
+
/** True if the holder's name appears (fuzzily) in the recognised text. */
|
|
19
|
+
hasName: boolean;
|
|
20
|
+
/** True if address-like signals (postal code, street/place keywords, or chip
|
|
21
|
+
* address tokens) appear in the recognised text. */
|
|
22
|
+
hasAddress: boolean;
|
|
23
|
+
/** True when both are present — the photo plausibly is an address document. */
|
|
24
|
+
ok: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AddressProofContext {
|
|
28
|
+
/** Holder name parts (first/last, any Unicode form). Used to look for the name. */
|
|
29
|
+
nameParts: Array<string | null | undefined>;
|
|
30
|
+
/** The chip's DG11 address, if any — its tokens are strong address evidence. */
|
|
31
|
+
chipAddress?: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Address keywords across the languages this SDK targets (TR + common EU/EN).
|
|
36
|
+
* Matched as whole words after normalisation. A Set so membership is O(1) and
|
|
37
|
+
* duplicates collapse. Deliberately broad — recall over precision, since the
|
|
38
|
+
* operator makes the real decision — BUT we exclude very short tokens
|
|
39
|
+
* ('NO','IL','ST','SK','RD') that collide with everyday non-address text and
|
|
40
|
+
* would make the address signal fire on almost any document.
|
|
41
|
+
*/
|
|
42
|
+
const ADDRESS_KEYWORDS = new Set([
|
|
43
|
+
// Turkish
|
|
44
|
+
'MAH',
|
|
45
|
+
'MAHALLE',
|
|
46
|
+
'MAHALLESI',
|
|
47
|
+
'CAD',
|
|
48
|
+
'CADDE',
|
|
49
|
+
'CADDESI',
|
|
50
|
+
'SOK',
|
|
51
|
+
'SOKAK',
|
|
52
|
+
'APT',
|
|
53
|
+
'DAIRE',
|
|
54
|
+
'BINA',
|
|
55
|
+
'BLOK',
|
|
56
|
+
'KAPI',
|
|
57
|
+
'ILCE',
|
|
58
|
+
'SEHIR',
|
|
59
|
+
'POSTA',
|
|
60
|
+
'ADRES',
|
|
61
|
+
'BULVAR',
|
|
62
|
+
'BULVARI',
|
|
63
|
+
'SITE',
|
|
64
|
+
// English / generic
|
|
65
|
+
'STREET',
|
|
66
|
+
'AVENUE',
|
|
67
|
+
'AVE',
|
|
68
|
+
'ROAD',
|
|
69
|
+
'LANE',
|
|
70
|
+
'BLVD',
|
|
71
|
+
'BOULEVARD',
|
|
72
|
+
'APARTMENT',
|
|
73
|
+
'SUITE',
|
|
74
|
+
'FLOOR',
|
|
75
|
+
'BUILDING',
|
|
76
|
+
'CITY',
|
|
77
|
+
'TOWN',
|
|
78
|
+
'POSTAL',
|
|
79
|
+
'POSTCODE',
|
|
80
|
+
'ZIP',
|
|
81
|
+
'ADDRESS',
|
|
82
|
+
'DISTRICT',
|
|
83
|
+
'PROVINCE',
|
|
84
|
+
// German (passports/utility bills are common)
|
|
85
|
+
'STRASSE',
|
|
86
|
+
'PLATZ',
|
|
87
|
+
'GASSE',
|
|
88
|
+
'STADT',
|
|
89
|
+
'PLZ',
|
|
90
|
+
'ADRESSE',
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Strip diacritics + uppercase so "Şişli" ~ "SISLI" and OCR variants align.
|
|
95
|
+
* Shares the SDK's canonical diacritic fold ({@link foldDiacritics}) so the
|
|
96
|
+
* address path and the VIZ-name path stay in sync (ß→SS, ø→O, ł→L, …).
|
|
97
|
+
*/
|
|
98
|
+
function normalize(input: string): string {
|
|
99
|
+
return foldDiacritics(input);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Split into alphabetic / numeric tokens, dropping punctuation. */
|
|
103
|
+
function tokenize(input: string): string[] {
|
|
104
|
+
return normalize(input)
|
|
105
|
+
.split(/[^A-Z0-9]+/)
|
|
106
|
+
.filter((t) => t.length > 0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A postal-code-like run, REQUIRED to be flanked by address-ish context to
|
|
111
|
+
* avoid matching invoice/account/order numbers. We accept a 4–6 digit run only
|
|
112
|
+
* when it is not part of a longer digit run (so account numbers like 1234567890
|
|
113
|
+
* don't count) — `\b\d{4,6}\b` already excludes longer runs.
|
|
114
|
+
*/
|
|
115
|
+
const POSTAL_RE = /\b\d{4,6}\b/;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Levenshtein distance ≤ 1 check (single insert/delete/substitute), used to
|
|
119
|
+
* tolerate one OCR slip in a name token (e.g. "SAYGI" read as "SAYGL").
|
|
120
|
+
*/
|
|
121
|
+
function withinEditDistance1(a: string, b: string): boolean {
|
|
122
|
+
if (a === b) return true;
|
|
123
|
+
const la = a.length;
|
|
124
|
+
const lb = b.length;
|
|
125
|
+
if (Math.abs(la - lb) > 1) return false;
|
|
126
|
+
// Substitution (equal length): at most one differing char.
|
|
127
|
+
if (la === lb) {
|
|
128
|
+
let diffs = 0;
|
|
129
|
+
for (let i = 0; i < la; i++) {
|
|
130
|
+
if (a[i] !== b[i] && ++diffs > 1) return false;
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
// Insertion/deletion (length differs by 1): the shorter is a subsequence
|
|
135
|
+
// of the longer with exactly one extra char.
|
|
136
|
+
const [short, long] = la < lb ? [a, b] : [b, a];
|
|
137
|
+
let i = 0;
|
|
138
|
+
let j = 0;
|
|
139
|
+
let skips = 0;
|
|
140
|
+
while (i < short.length && j < long.length) {
|
|
141
|
+
if (short[i] === long[j]) {
|
|
142
|
+
i++;
|
|
143
|
+
j++;
|
|
144
|
+
} else if (++skips > 1) {
|
|
145
|
+
return false;
|
|
146
|
+
} else {
|
|
147
|
+
j++;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Assess whether the OCR text from a captured proof plausibly contains the
|
|
155
|
+
* holder's name and an address. Best-effort and tolerant of OCR noise.
|
|
156
|
+
*/
|
|
157
|
+
export function assessAddressProof(
|
|
158
|
+
recognizedText: string | undefined | null,
|
|
159
|
+
ctx: AddressProofContext
|
|
160
|
+
): AddressProofSignals {
|
|
161
|
+
const text = (recognizedText ?? '').trim();
|
|
162
|
+
if (!text) {
|
|
163
|
+
// No text recognised at all (blank/sideways/too blurry) — fail soft.
|
|
164
|
+
return { hasName: false, hasAddress: false, ok: false };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const normText = normalize(text);
|
|
168
|
+
const tokens = tokenize(text);
|
|
169
|
+
const textTokens = new Set(tokens);
|
|
170
|
+
|
|
171
|
+
// --- Name: a name part (>=3 chars) appears as a token in the OCR text,
|
|
172
|
+
// tolerating a single OCR slip (edit distance ≤1) on tokens of 4+ chars
|
|
173
|
+
// so the holder's own document isn't flagged over one misread letter. ---
|
|
174
|
+
const nameTokens = ctx.nameParts
|
|
175
|
+
.flatMap((p) => (p ? tokenize(p) : []))
|
|
176
|
+
.filter((t) => t.length >= 3 && !/^\d+$/.test(t));
|
|
177
|
+
const hasName = nameTokens.some(
|
|
178
|
+
(n) =>
|
|
179
|
+
textTokens.has(n) ||
|
|
180
|
+
(n.length >= 4 && tokens.some((t) => withinEditDistance1(n, t)))
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// --- Address: postal code OR an address keyword OR overlap with the chip
|
|
184
|
+
// address tokens (a strong, holder-specific signal). ---
|
|
185
|
+
const hasPostal = POSTAL_RE.test(normText);
|
|
186
|
+
const hasKeyword = tokens.some((t) => ADDRESS_KEYWORDS.has(t));
|
|
187
|
+
|
|
188
|
+
const chipTokens = (ctx.chipAddress ? tokenize(ctx.chipAddress) : []).filter(
|
|
189
|
+
(t) => t.length >= 3
|
|
190
|
+
);
|
|
191
|
+
const chipOverlap = chipTokens.filter((t) => textTokens.has(t)).length;
|
|
192
|
+
const hasChipMatch = chipOverlap >= 2; // ≥2 chip-address words present
|
|
193
|
+
|
|
194
|
+
const hasAddress = hasPostal || hasKeyword || hasChipMatch;
|
|
195
|
+
|
|
196
|
+
return { hasName, hasAddress, ok: hasName && hasAddress };
|
|
197
|
+
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* - `scanDataJson` — the scanned identity data (PII),
|
|
18
18
|
* - `images` — the captured document/face images (base64, PII).
|
|
19
19
|
*/
|
|
20
|
+
import { NativeModules } from 'react-native';
|
|
20
21
|
import { diagnostics } from './diagnostics';
|
|
21
22
|
import { gatherDeviceDetails } from './native-device-info.utils';
|
|
22
23
|
import {
|
|
@@ -37,6 +38,56 @@ export interface DiagnosticImage {
|
|
|
37
38
|
mime: 'jpg' | 'png';
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
// Diagnostic images are for a support inbox, not archival — full capture
|
|
42
|
+
// resolution is wasteful and makes the upload (and the on-JS-thread
|
|
43
|
+
// JSON.stringify of the payload) slow. Cap the longest edge and recompress so
|
|
44
|
+
// the whole bundle is a few hundred KB instead of several MB.
|
|
45
|
+
const DIAGNOSTIC_IMAGE_MAX_DIM = 1024;
|
|
46
|
+
const DIAGNOSTIC_IMAGE_JPEG_QUALITY = 70;
|
|
47
|
+
|
|
48
|
+
interface OpenCVNativeModule {
|
|
49
|
+
resizeAndCompress?: (
|
|
50
|
+
base64: string,
|
|
51
|
+
maxDim: number,
|
|
52
|
+
quality: number
|
|
53
|
+
) => Promise<string>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Downscale + recompress the report images off the JS thread (native OpenCV
|
|
58
|
+
* module), in parallel. Best-effort per image: if the native method is missing
|
|
59
|
+
* or throws, that image falls back to its original base64 so the report still
|
|
60
|
+
* sends. Already-small images are returned near-unchanged (native never
|
|
61
|
+
* upscales).
|
|
62
|
+
*/
|
|
63
|
+
async function shrinkDiagnosticImages(
|
|
64
|
+
images: DiagnosticImage[]
|
|
65
|
+
): Promise<DiagnosticImage[]> {
|
|
66
|
+
const opencv = (NativeModules as { OpenCVModule?: OpenCVNativeModule })
|
|
67
|
+
.OpenCVModule;
|
|
68
|
+
if (!opencv?.resizeAndCompress) return images;
|
|
69
|
+
return Promise.all(
|
|
70
|
+
images.map(async (img) => {
|
|
71
|
+
if (!img.base64) return img;
|
|
72
|
+
try {
|
|
73
|
+
const shrunk = await opencv.resizeAndCompress!(
|
|
74
|
+
img.base64,
|
|
75
|
+
DIAGNOSTIC_IMAGE_MAX_DIM,
|
|
76
|
+
DIAGNOSTIC_IMAGE_JPEG_QUALITY
|
|
77
|
+
);
|
|
78
|
+
// On any failure the native side resolves the ORIGINAL base64 unchanged
|
|
79
|
+
// (same string) — in that case keep the original mime, since a PNG that
|
|
80
|
+
// failed to shrink is still PNG. Only when it actually recompressed (a
|
|
81
|
+
// new string) is the output guaranteed JPEG, so relabel then.
|
|
82
|
+
if (!shrunk || shrunk === img.base64) return img;
|
|
83
|
+
return { ...img, base64: shrunk, mime: 'jpg' as const };
|
|
84
|
+
} catch {
|
|
85
|
+
return img;
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
40
91
|
export interface SendDiagnosticParams {
|
|
41
92
|
/** Backend base URL (same value the SDK was initialised with). */
|
|
42
93
|
baseUrl: string;
|
|
@@ -110,6 +161,15 @@ export const sendDiagnosticReport = async (
|
|
|
110
161
|
? `NFC-${(nfc.errorCategory ?? 'reading_error').toUpperCase()}${failingSw ? `-${failingSw}` : ''}`
|
|
111
162
|
: null;
|
|
112
163
|
|
|
164
|
+
// Shrink images natively before they go into the (JS-stringified) payload —
|
|
165
|
+
// this is the dominant cost of the send, not the proof-of-work.
|
|
166
|
+
const shrunkImages = await shrinkDiagnosticImages(
|
|
167
|
+
images.filter((img) => img.base64)
|
|
168
|
+
);
|
|
169
|
+
if (shouldCancel?.()) {
|
|
170
|
+
return { uploaded: false, cancelled: true };
|
|
171
|
+
}
|
|
172
|
+
|
|
113
173
|
const payload = {
|
|
114
174
|
// metadata (stored in plaintext, no PII)
|
|
115
175
|
platform: device.platform,
|
|
@@ -123,13 +183,11 @@ export const sendDiagnosticReport = async (
|
|
|
123
183
|
// full report (PII, encrypted at rest by the backend)
|
|
124
184
|
diagnosticsJson: report.diagnosticsJson,
|
|
125
185
|
scanDataJson: report.scanDataJson,
|
|
126
|
-
images:
|
|
127
|
-
|
|
128
|
-
.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
mimeType: mimeToType(img.mime),
|
|
132
|
-
})),
|
|
186
|
+
images: shrunkImages.map((img) => ({
|
|
187
|
+
name: img.name,
|
|
188
|
+
base64: img.base64,
|
|
189
|
+
mimeType: mimeToType(img.mime),
|
|
190
|
+
})),
|
|
133
191
|
};
|
|
134
192
|
|
|
135
193
|
// Authenticity via proof of work: mint a server-signed challenge, solve it,
|
|
@@ -82,13 +82,14 @@ export interface VizNameCandidate {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
* Fold
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
85
|
+
* Fold diacritics to bare ASCII letters but PRESERVE digits, spaces and other
|
|
86
|
+
* separators (uppercased). This is the shared normalization core: strip
|
|
87
|
+
* combining marks via NFD, then map the handful of letters that have no
|
|
88
|
+
* combining decomposition. Used by both {@link foldAscii} (which then drops
|
|
89
|
+
* non-letters) and the address-proof heuristic (which keeps digits/separators
|
|
90
|
+
* so it can tokenize and detect postal codes).
|
|
90
91
|
*/
|
|
91
|
-
export function
|
|
92
|
+
export function foldDiacritics(value: string | null | undefined): string {
|
|
92
93
|
if (!value) return '';
|
|
93
94
|
return value
|
|
94
95
|
.normalize('NFD')
|
|
@@ -103,17 +104,59 @@ export function foldAscii(value: string | null | undefined): string {
|
|
|
103
104
|
.replace(/ß/g, 'SS')
|
|
104
105
|
.replace(/æ/gi, 'AE')
|
|
105
106
|
.replace(/œ/gi, 'OE')
|
|
106
|
-
.toUpperCase()
|
|
107
|
-
|
|
107
|
+
.toUpperCase();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function foldAscii(value: string | null | undefined): string {
|
|
111
|
+
// Bare letters only — drop digits, spaces and punctuation after folding.
|
|
112
|
+
return foldDiacritics(value).replace(/[^A-Z]/g, '');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* True when a line looks like a machine-readable-zone (MRZ) line rather than a
|
|
117
|
+
* printed field. MRZ lines are long OCR-B runs of `[A-Z0-9<]` padded with `<`
|
|
118
|
+
* filler (e.g. `P<TURSIMSEK<<CAGRI<<<<<…`). They MUST be excluded from the name
|
|
119
|
+
* search: the MRZ literally contains the folded target name, so if an MRZ line
|
|
120
|
+
* leaked into the candidate lines it would "match" and return the ASCII name
|
|
121
|
+
* with no diacritics restored — silently defeating the whole VIZ recovery. This
|
|
122
|
+
* is a content check (not a position guess), so it holds no matter where the OCR
|
|
123
|
+
* places the MRZ block or how many blocks it spans.
|
|
124
|
+
*/
|
|
125
|
+
function isMrzLine(line: string): boolean {
|
|
126
|
+
const compact = line.replace(/\s+/g, '');
|
|
127
|
+
// A run of ≥2 consecutive fillers is unique to the MRZ; printed name lines
|
|
128
|
+
// never contain `<<`. Also treat a long, dense OCR-B run as MRZ.
|
|
129
|
+
return (
|
|
130
|
+
/<{2,}/.test(compact) ||
|
|
131
|
+
(compact.length >= 20 && /^[A-Z0-9<]+$/i.test(compact) && /</.test(compact))
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Upper-case a printed token for display, honouring Turkish casing when the
|
|
137
|
+
* document is Turkish, and normalising to NFC.
|
|
138
|
+
*
|
|
139
|
+
* Two Turkish-specific glitches this fixes (both reported from the field):
|
|
140
|
+
* - "different i": the default (invariant) `toUpperCase()` maps a lower-case
|
|
141
|
+
* dotted `i` to a DOTLESS `I`, so an OCR'd "İsmail" becomes "İSMAIL" instead
|
|
142
|
+
* of the correct "İSMAİL". Turkish-locale uppercasing maps `i`→`İ` and the
|
|
143
|
+
* dotless `ı`→`I`, preserving the dot distinction Turkish depends on.
|
|
144
|
+
* - "dot leaned to the right": some sources/fonts carry the dotted capital as a
|
|
145
|
+
* DECOMPOSED `I`+combining-dot (U+0049 U+0307), which renders with an offset
|
|
146
|
+
* dot. NFC precomposes it to the single glyph `İ` (U+0130).
|
|
147
|
+
*/
|
|
148
|
+
function upperForDisplay(word: string, locale?: string): string {
|
|
149
|
+
const upper = locale ? word.toLocaleUpperCase(locale) : word.toUpperCase();
|
|
150
|
+
return upper.normalize('NFC');
|
|
108
151
|
}
|
|
109
152
|
|
|
110
153
|
/** Split a printed-name line into upper-case word tokens (letters + marks). */
|
|
111
|
-
function tokenize(line: string): string[] {
|
|
154
|
+
function tokenize(line: string, locale?: string): string[] {
|
|
112
155
|
return line
|
|
113
156
|
.split(/[\s,]+/)
|
|
114
157
|
.map((w) => w.trim())
|
|
115
158
|
.filter((w) => w.length > 0 && /\p{L}/u.test(w))
|
|
116
|
-
.map((w) => w
|
|
159
|
+
.map((w) => upperForDisplay(w, locale));
|
|
117
160
|
}
|
|
118
161
|
|
|
119
162
|
/**
|
|
@@ -125,12 +168,16 @@ function tokenize(line: string): string[] {
|
|
|
125
168
|
* Because the comparison is on the FOLDED form, only an exact same-name match
|
|
126
169
|
* succeeds — a different printed name will not fold-match and yields null.
|
|
127
170
|
*/
|
|
128
|
-
function recoverPrintedName(
|
|
171
|
+
function recoverPrintedName(
|
|
172
|
+
lines: string[],
|
|
173
|
+
mrzName: string,
|
|
174
|
+
locale?: string
|
|
175
|
+
): string | null {
|
|
129
176
|
const target = foldAscii(mrzName);
|
|
130
177
|
if (!target) return null;
|
|
131
178
|
|
|
132
179
|
for (const line of lines) {
|
|
133
|
-
const tokens = tokenize(line);
|
|
180
|
+
const tokens = tokenize(line, locale);
|
|
134
181
|
// Slide a window over the tokens; the printed name may sit among other
|
|
135
182
|
// label text on the same block ("Surname / Nom ÖZTÜRK").
|
|
136
183
|
for (let start = 0; start < tokens.length; start++) {
|
|
@@ -160,22 +207,35 @@ function recoverPrintedName(lines: string[], mrzName: string): string | null {
|
|
|
160
207
|
* sit ABOVE the MRZ on the data page; passing this restricts the
|
|
161
208
|
* search to blocks above it, avoiding false matches in the MRZ
|
|
162
209
|
* itself. When undefined, all blocks are searched.
|
|
210
|
+
* @param issuingCountry ICAO 3-letter issuing state, used to pick locale-correct
|
|
211
|
+
* casing for the recovered name (Turkish `i`/`İ` handling). When
|
|
212
|
+
* omitted, invariant uppercasing is used.
|
|
163
213
|
*/
|
|
164
214
|
export function recoverVizName(
|
|
165
215
|
blocks: VizTextBlock[],
|
|
166
216
|
mrzFirst: string,
|
|
167
217
|
mrzLast: string,
|
|
168
|
-
mrzTop?: number
|
|
218
|
+
mrzTop?: number,
|
|
219
|
+
issuingCountry?: string | null
|
|
169
220
|
): VizNameCandidate {
|
|
221
|
+
// Turkish needs locale-aware uppercasing so the dotted/dotless i distinction
|
|
222
|
+
// survives (i→İ, ı→I). Other Latin scripts are unaffected by invariant casing.
|
|
223
|
+
const locale = issuingCountry?.toUpperCase() === 'TUR' ? 'tr' : undefined;
|
|
170
224
|
const above =
|
|
171
|
-
typeof mrzTop === 'number'
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
//
|
|
175
|
-
|
|
225
|
+
typeof mrzTop === 'number' ? blocks.filter((b) => b.top < mrzTop) : blocks;
|
|
226
|
+
// Each block's text may hold several printed lines. Drop any MRZ line: the
|
|
227
|
+
// `mrzTop` position filter above is only a hint (the caller can't always know
|
|
228
|
+
// the true MRZ block, and the MRZ may span several blocks), so we also exclude
|
|
229
|
+
// MRZ lines by content — otherwise the MRZ's own name text would fold-match
|
|
230
|
+
// the target and be returned as the "printed" name with diacritics stripped.
|
|
231
|
+
const lines = above
|
|
232
|
+
.flatMap((b) => b.text.split(/\r?\n/))
|
|
233
|
+
.filter((l) => !isMrzLine(l));
|
|
176
234
|
|
|
177
|
-
const vizFirst = mrzFirst
|
|
178
|
-
|
|
235
|
+
const vizFirst = mrzFirst
|
|
236
|
+
? recoverPrintedName(lines, mrzFirst, locale)
|
|
237
|
+
: null;
|
|
238
|
+
const vizLast = mrzLast ? recoverPrintedName(lines, mrzLast, locale) : null;
|
|
179
239
|
|
|
180
240
|
// Detect a letter-level discrepancy: a printed line clearly carries the name
|
|
181
241
|
// field but its fold does not equal the MRZ name. We approximate this by
|
|
@@ -190,3 +250,81 @@ export function recoverVizName(
|
|
|
190
250
|
|
|
191
251
|
return { vizFirst, vizLast, mismatch };
|
|
192
252
|
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Pick the most frequent non-null value from a list of candidates.
|
|
256
|
+
*
|
|
257
|
+
* OCR is noisy: the same printed name can come back slightly different across
|
|
258
|
+
* passes (a diacritic dropped, an i-variant flipped, a stray combining mark).
|
|
259
|
+
* Rather than trust a single read, we run recovery over several images/frames
|
|
260
|
+
* and take the value that appeared MOST OFTEN as the truth. Ties are broken by
|
|
261
|
+
* first appearance (stable), and null/empty candidates never win — a pass that
|
|
262
|
+
* failed to recover the name doesn't dilute the ones that succeeded.
|
|
263
|
+
*
|
|
264
|
+
* Values are compared by NFC form so two spellings that only differ by
|
|
265
|
+
* composition (precomposed İ vs I+combining-dot) are counted as the same vote.
|
|
266
|
+
*/
|
|
267
|
+
function voteMostFrequent(
|
|
268
|
+
values: Array<string | null>,
|
|
269
|
+
minAgree: number
|
|
270
|
+
): string | null {
|
|
271
|
+
const counts = new Map<string, { count: number; display: string }>();
|
|
272
|
+
let total = 0;
|
|
273
|
+
for (const v of values) {
|
|
274
|
+
if (!v) continue;
|
|
275
|
+
total += 1;
|
|
276
|
+
const key = v.normalize('NFC');
|
|
277
|
+
const entry = counts.get(key);
|
|
278
|
+
if (entry) entry.count += 1;
|
|
279
|
+
else counts.set(key, { count: 1, display: key });
|
|
280
|
+
}
|
|
281
|
+
let winner: { count: number; display: string } | null = null;
|
|
282
|
+
for (const entry of counts.values()) {
|
|
283
|
+
if (!winner || entry.count > winner.count) winner = entry;
|
|
284
|
+
}
|
|
285
|
+
if (!winner) return null;
|
|
286
|
+
// Require the winner to be corroborated by multiple samples — the name is
|
|
287
|
+
// only "decided" when frames AGREE. The exception is when there is only a
|
|
288
|
+
// single sample available at all (total < minAgree): with one OCR pass we
|
|
289
|
+
// can't reach a quorum, so we accept that lone reading rather than discard a
|
|
290
|
+
// legitimately-recovered name. (It is still non-destructive — recoverVizName
|
|
291
|
+
// only ever returns a value that folds to the same ASCII as the MRZ.)
|
|
292
|
+
if (winner.count < minAgree && total >= minAgree) return null;
|
|
293
|
+
return winner.display;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Fuse several VIZ recovery results into one by majority vote per field.
|
|
298
|
+
*
|
|
299
|
+
* Run {@link recoverVizName} over multiple frames/images (e.g. several camera
|
|
300
|
+
* frames of the data page, or the passport page plus ID front/back), then pass
|
|
301
|
+
* the candidates here. Each field (first/last) is decided independently by the
|
|
302
|
+
* value that MULTIPLE samples agree on — a single noisy OCR pass can neither
|
|
303
|
+
* corrupt the name nor decide it alone. When only one sample exists we fall
|
|
304
|
+
* back to it (can't form a quorum from one frame).
|
|
305
|
+
*
|
|
306
|
+
* Non-destructive by construction: this only ever surfaces a diacritic form
|
|
307
|
+
* that {@link recoverVizName} already confirmed folds to the same ASCII as the
|
|
308
|
+
* MRZ name, so the authoritative MRZ letters are never changed — only their
|
|
309
|
+
* diacritics restored. `mismatch` is reported only when NO sample recovered a
|
|
310
|
+
* field yet at least one saw printed name text that disagreed with the MRZ.
|
|
311
|
+
*
|
|
312
|
+
* @param candidates One {@link VizNameCandidate} per frame/image.
|
|
313
|
+
* @param minAgree Minimum agreeing samples to "decide" a field (default 2).
|
|
314
|
+
*/
|
|
315
|
+
export function voteVizName(
|
|
316
|
+
candidates: VizNameCandidate[],
|
|
317
|
+
minAgree = 2
|
|
318
|
+
): VizNameCandidate {
|
|
319
|
+
const vizFirst = voteMostFrequent(
|
|
320
|
+
candidates.map((c) => c.vizFirst),
|
|
321
|
+
minAgree
|
|
322
|
+
);
|
|
323
|
+
const vizLast = voteMostFrequent(
|
|
324
|
+
candidates.map((c) => c.vizLast),
|
|
325
|
+
minAgree
|
|
326
|
+
);
|
|
327
|
+
const anyMismatch = candidates.some((c) => c.mismatch);
|
|
328
|
+
const mismatch = anyMismatch && (vizFirst === null || vizLast === null);
|
|
329
|
+
return { vizFirst, vizLast, mismatch };
|
|
330
|
+
}
|
|
@@ -137,11 +137,6 @@ export interface WorkflowStep {
|
|
|
137
137
|
* verdict and sends it to the backend, which decides. Defaults to off.
|
|
138
138
|
*/
|
|
139
139
|
enforceChipVizMatch?: boolean;
|
|
140
|
-
/**
|
|
141
|
-
* Whether a proof document is required in the ADDRESS_CAPTURE step
|
|
142
|
-
* (MASAK 4/C(1)(d)). Defaults to true; set false to allow address-only.
|
|
143
|
-
*/
|
|
144
|
-
addressProofRequired?: boolean;
|
|
145
140
|
/**
|
|
146
141
|
* Run Passive Authentication on the eID scan (ICAO 9303-11 §5.1 / MASAK
|
|
147
142
|
* 4/C(1)(b),(c)): read EF.SOD, verify on-device DG-hash integrity, and send
|
|
@@ -133,12 +133,37 @@ export default {
|
|
|
133
133
|
'addressCaptureScreen.addressPlaceholder': 'Street, number, city, country',
|
|
134
134
|
'addressCaptureScreen.proofLabel': 'Proof of address',
|
|
135
135
|
'addressCaptureScreen.proofHint':
|
|
136
|
-
'A residence certificate,
|
|
136
|
+
'A residence certificate, a utility bill issued within the last 3 months, or a document from a public authority. Capture the whole document.',
|
|
137
137
|
'addressCaptureScreen.captureProof': 'Capture proof document',
|
|
138
|
-
'addressCaptureScreen.retakeProof': 'Retake
|
|
138
|
+
'addressCaptureScreen.retakeProof': 'Retake',
|
|
139
139
|
'addressCaptureScreen.proofCameraHint':
|
|
140
|
-
'
|
|
140
|
+
'Fit the whole document inside the frame, then capture.',
|
|
141
141
|
'addressCaptureScreen.capture': 'Capture',
|
|
142
|
+
'addressCaptureScreen.previewHint':
|
|
143
|
+
'Make sure the whole document is readable.',
|
|
144
|
+
'addressCaptureScreen.warnNoName':
|
|
145
|
+
"We couldn't find your name on this document. Make sure your name is visible, or continue if it's correct.",
|
|
146
|
+
'addressCaptureScreen.warnNoAddress':
|
|
147
|
+
"We couldn't detect an address on this document. Make sure the address is visible, or continue if it's correct.",
|
|
148
|
+
'addressCaptureScreen.warnNoText':
|
|
149
|
+
"We couldn't read this document. Make sure the whole page is in frame and readable, or continue if it's correct.",
|
|
150
|
+
'addressCaptureScreen.guideHeader': 'Proof of address',
|
|
151
|
+
'addressCaptureScreen.guideText':
|
|
152
|
+
'Please ensure the following for best results:',
|
|
153
|
+
'addressCaptureScreen.guidePointWholeDocument':
|
|
154
|
+
'Fit the entire document inside the frame.',
|
|
155
|
+
'addressCaptureScreen.guidePointLighting':
|
|
156
|
+
'Use good lighting and avoid glare or shadows.',
|
|
157
|
+
'addressCaptureScreen.guidePointSteady':
|
|
158
|
+
'Hold the device steady so the photo is sharp.',
|
|
159
|
+
'addressCaptureScreen.guidePointDocuments':
|
|
160
|
+
'A residence certificate, a utility bill no older than 3 months, or a public-authority document.',
|
|
161
|
+
'addressCaptureScreen.retakeTooDark':
|
|
162
|
+
'Too dark — move to better lighting and retake.',
|
|
163
|
+
'addressCaptureScreen.retakeTooBright':
|
|
164
|
+
'Too bright or glare — adjust the angle and retake.',
|
|
165
|
+
'addressCaptureScreen.retakeBlurry':
|
|
166
|
+
'Photo is blurry — hold steady and retake.',
|
|
142
167
|
'eidScannerScreen.faceImageNotFound': 'Photo data not found on document.',
|
|
143
168
|
'eidScannerScreen.documentCode': 'Document Type',
|
|
144
169
|
'eidScannerScreen.nationality': 'Nationality',
|
|
@@ -133,12 +133,37 @@ export default {
|
|
|
133
133
|
'addressCaptureScreen.addressPlaceholder': 'Cadde, numara, şehir, ülke',
|
|
134
134
|
'addressCaptureScreen.proofLabel': 'Adres kanıtı',
|
|
135
135
|
'addressCaptureScreen.proofHint':
|
|
136
|
-
'Yerleşim yeri belgesi
|
|
136
|
+
'Yerleşim yeri belgesi, son 3 ay içinde düzenlenmiş bir fatura ya da bir kamu kurumu tarafından verilen belge. Belgenin tamamını çekin.',
|
|
137
137
|
'addressCaptureScreen.captureProof': 'Kanıt belgesini çek',
|
|
138
|
-
'addressCaptureScreen.retakeProof': '
|
|
138
|
+
'addressCaptureScreen.retakeProof': 'Yeniden çek',
|
|
139
139
|
'addressCaptureScreen.proofCameraHint':
|
|
140
|
-
'
|
|
140
|
+
'Belgenin tamamını çerçeveye sığdırın ve fotoğraf çekin.',
|
|
141
141
|
'addressCaptureScreen.capture': 'Çek',
|
|
142
|
+
'addressCaptureScreen.previewHint':
|
|
143
|
+
'Belgenin tamamının okunaklı olduğundan emin olun.',
|
|
144
|
+
'addressCaptureScreen.warnNoName':
|
|
145
|
+
'Bu belgede adınızı bulamadık. Adınızın göründüğünden emin olun ya da doğruysa devam edin.',
|
|
146
|
+
'addressCaptureScreen.warnNoAddress':
|
|
147
|
+
'Bu belgede bir adres tespit edemedik. Adresin göründüğünden emin olun ya da doğruysa devam edin.',
|
|
148
|
+
'addressCaptureScreen.warnNoText':
|
|
149
|
+
'Bu belgeyi okuyamadık. Sayfanın tamamının kadraja girdiğinden ve okunaklı olduğundan emin olun ya da doğruysa devam edin.',
|
|
150
|
+
'addressCaptureScreen.guideHeader': 'Adres kanıtı',
|
|
151
|
+
'addressCaptureScreen.guideText':
|
|
152
|
+
'En iyi sonuç için lütfen şunları sağlayın:',
|
|
153
|
+
'addressCaptureScreen.guidePointWholeDocument':
|
|
154
|
+
'Belgenin tamamını çerçeveye sığdırın.',
|
|
155
|
+
'addressCaptureScreen.guidePointLighting':
|
|
156
|
+
'İyi aydınlatma kullanın; yansıma ve gölgeden kaçının.',
|
|
157
|
+
'addressCaptureScreen.guidePointSteady':
|
|
158
|
+
'Fotoğrafın net olması için cihazı sabit tutun.',
|
|
159
|
+
'addressCaptureScreen.guidePointDocuments':
|
|
160
|
+
'Yerleşim yeri belgesi, son 3 aydan eski olmayan bir fatura ya da bir kamu kurumu belgesi.',
|
|
161
|
+
'addressCaptureScreen.retakeTooDark':
|
|
162
|
+
'Çok karanlık — daha aydınlık bir yere geçip tekrar çekin.',
|
|
163
|
+
'addressCaptureScreen.retakeTooBright':
|
|
164
|
+
'Çok parlak veya yansıma var — açıyı değiştirip tekrar çekin.',
|
|
165
|
+
'addressCaptureScreen.retakeBlurry':
|
|
166
|
+
'Fotoğraf bulanık — sabit tutup tekrar çekin.',
|
|
142
167
|
'eidScannerScreen.faceImageNotFound': 'Belgede fotoğraf verisi bulunamadı.',
|
|
143
168
|
'eidScannerScreen.documentCode': 'Belge Türü',
|
|
144
169
|
'eidScannerScreen.nationality': 'Uyruk',
|
package/src/version.ts
CHANGED