@trustchex/react-native-sdk 1.512.0 → 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.
Files changed (58) hide show
  1. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +9 -2
  2. package/android/src/main/java/com/trustchex/reactnativesdk/opencv/OpenCVModule.kt +54 -0
  3. package/ios/Camera/TrustchexCameraView.swift +6 -2
  4. package/ios/OpenCV/OpenCVModule.mm +59 -0
  5. package/lib/module/Screens/Dynamic/AddressCaptureScreen.js +427 -112
  6. package/lib/module/Screens/Dynamic/IdentityDocumentEIDScanningScreen.js +9 -4
  7. package/lib/module/Screens/Static/ResultScreen.js +46 -0
  8. package/lib/module/Shared/Animations/address-scan.json +1 -0
  9. package/lib/module/Shared/Components/DebugNavigationPanel.js +10 -0
  10. package/lib/module/Shared/Components/EIDScanner.js +16 -4
  11. package/lib/module/Shared/Components/IdentityDocumentCamera.js +100 -19
  12. package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +47 -0
  13. package/lib/module/Shared/Libs/addressProofHeuristic.js +130 -0
  14. package/lib/module/Shared/Libs/demo.utils.js +4 -0
  15. package/lib/module/Shared/Libs/sendDiagnosticReport.js +47 -1
  16. package/lib/module/Shared/Libs/vizName.js +138 -16
  17. package/lib/module/Translation/Resources/en.js +16 -3
  18. package/lib/module/Translation/Resources/tr.js +16 -3
  19. package/lib/module/version.js +1 -1
  20. package/lib/typescript/src/Screens/Dynamic/AddressCaptureScreen.d.ts.map +1 -1
  21. package/lib/typescript/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.d.ts.map +1 -1
  22. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  23. package/lib/typescript/src/Shared/Components/DebugNavigationPanel.d.ts.map +1 -1
  24. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts +8 -3
  25. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
  26. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  27. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +25 -0
  28. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -1
  29. package/lib/typescript/src/Shared/Libs/addressProofHeuristic.d.ts +34 -0
  30. package/lib/typescript/src/Shared/Libs/addressProofHeuristic.d.ts.map +1 -0
  31. package/lib/typescript/src/Shared/Libs/demo.utils.d.ts.map +1 -1
  32. package/lib/typescript/src/Shared/Libs/sendDiagnosticReport.d.ts.map +1 -1
  33. package/lib/typescript/src/Shared/Libs/vizName.d.ts +31 -6
  34. package/lib/typescript/src/Shared/Libs/vizName.d.ts.map +1 -1
  35. package/lib/typescript/src/Shared/Types/identificationInfo.d.ts +0 -5
  36. package/lib/typescript/src/Shared/Types/identificationInfo.d.ts.map +1 -1
  37. package/lib/typescript/src/Translation/Resources/en.d.ts +13 -0
  38. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  39. package/lib/typescript/src/Translation/Resources/tr.d.ts +13 -0
  40. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  41. package/lib/typescript/src/version.d.ts +1 -1
  42. package/package.json +1 -1
  43. package/src/Screens/Dynamic/AddressCaptureScreen.tsx +434 -75
  44. package/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.tsx +14 -4
  45. package/src/Screens/Static/ResultScreen.tsx +59 -0
  46. package/src/Shared/Animations/address-scan.json +1 -0
  47. package/src/Shared/Components/DebugNavigationPanel.tsx +13 -0
  48. package/src/Shared/Components/EIDScanner.tsx +38 -12
  49. package/src/Shared/Components/IdentityDocumentCamera.tsx +109 -17
  50. package/src/Shared/Components/IdentityDocumentCamera.utils.ts +53 -0
  51. package/src/Shared/Libs/addressProofHeuristic.ts +197 -0
  52. package/src/Shared/Libs/demo.utils.ts +5 -0
  53. package/src/Shared/Libs/sendDiagnosticReport.ts +65 -7
  54. package/src/Shared/Libs/vizName.ts +158 -20
  55. package/src/Shared/Types/identificationInfo.ts +0 -5
  56. package/src/Translation/Resources/en.ts +28 -3
  57. package/src/Translation/Resources/tr.ts +28 -3
  58. 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
+ }
@@ -56,6 +56,11 @@ const demoSession = {
56
56
  required: false,
57
57
  data: { voiceGuidanceActive: true },
58
58
  },
59
+ {
60
+ type: 'ADDRESS_CAPTURE',
61
+ required: false,
62
+ data: {},
63
+ },
59
64
  {
60
65
  type: 'LIVENESS_CHECK',
61
66
  required: false,
@@ -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: images
127
- .filter((img) => img.base64)
128
- .map((img) => ({
129
- name: img.name,
130
- base64: img.base64,
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 a string to bare ASCII upper-case letters for comparison: strip
86
- * combining marks via NFD, map the handful of letters that have no combining
87
- * decomposition (ı, ł, ø, đ, ð, ß, æ, œ, þ), drop everything that is not A–Z.
88
- * This is the canonical form in which an MRZ name and its printed counterpart
89
- * become directly comparable.
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 foldAscii(value: string | null | undefined): string {
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
- .replace(/[^A-Z]/g, '');
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.toUpperCase());
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(lines: string[], mrzName: string): string | null {
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
- ? blocks.filter((b) => b.top < mrzTop)
173
- : blocks;
174
- // Each block's text may hold several printed lines.
175
- const lines = above.flatMap((b) => b.text.split(/\r?\n/));
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 ? recoverPrintedName(lines, mrzFirst) : null;
178
- const vizLast = mrzLast ? recoverPrintedName(lines, mrzLast) : null;
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, or a utility bill issued within the last 3 months, or a document from a public authority.',
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 proof document',
138
+ 'addressCaptureScreen.retakeProof': 'Retake',
139
139
  'addressCaptureScreen.proofCameraHint':
140
- 'Position the document within the frame and capture.',
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 veya son 3 ay içinde düzenlenmiş bir fatura ya da bir kamu kurumu tarafından verilen belge.',
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': 'Kanıt belgesini yeniden çek',
138
+ 'addressCaptureScreen.retakeProof': 'Yeniden çek',
139
139
  'addressCaptureScreen.proofCameraHint':
140
- 'Belgeyi çerçeveye yerleştirin ve fotoğraf çekin.',
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
@@ -1,3 +1,3 @@
1
1
  // This file is auto-generated. Do not edit manually.
2
2
  // Version is synced from package.json during build.
3
- export const SDK_VERSION = '1.512.0';
3
+ export const SDK_VERSION = '1.513.0';