@trustchex/react-native-sdk 1.472.0 → 1.475.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/android/build.gradle +3 -3
  2. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +147 -9
  3. package/ios/Camera/TrustchexCameraView.swift +92 -19
  4. package/lib/module/Screens/Debug/MRZTestScreen.js +121 -147
  5. package/lib/module/Screens/Static/ResultScreen.js +5 -0
  6. package/lib/module/Shared/Components/IdentityDocumentCamera.js +38 -4
  7. package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
  8. package/lib/module/Shared/Libs/mrz.utils.js +639 -16
  9. package/lib/module/Shared/Libs/mrzFrameAggregator.js +301 -0
  10. package/lib/module/Shared/Libs/mrzOcrIntegration.js +109 -0
  11. package/lib/module/version.js +1 -1
  12. package/lib/typescript/src/Screens/Debug/MRZTestScreen.d.ts.map +1 -1
  13. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  14. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  15. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts +8 -0
  16. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  17. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +76 -0
  18. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -0
  19. package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts +73 -0
  20. package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts.map +1 -0
  21. package/lib/typescript/src/version.d.ts +1 -1
  22. package/package.json +15 -10
  23. package/src/Screens/Debug/MRZTestScreen.tsx +137 -166
  24. package/src/Screens/Static/ResultScreen.tsx +5 -0
  25. package/src/Shared/Components/IdentityDocumentCamera.tsx +46 -6
  26. package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
  27. package/src/Shared/Libs/mrz.utils.ts +704 -11
  28. package/src/Shared/Libs/mrzFrameAggregator.ts +370 -0
  29. package/src/Shared/Libs/mrzOcrIntegration.ts +175 -0
  30. package/src/version.ts +1 -1
@@ -0,0 +1,370 @@
1
+ import mrzUtils from './mrz.utils';
2
+ import type { MRZValidationResult } from './mrz.utils';
3
+
4
+ /**
5
+ * Multi-frame MRZ aggregator: per-character voting across video frames.
6
+ *
7
+ * A single OCR frame of a glossy ID card is noisy — different frames misread
8
+ * different characters (B↔8, S↔5, O↔0, <↔K, dropped/inserted digits). Instead of
9
+ * trusting one frame (or requiring N identical frames, which a single misread
10
+ * resets), this accumulates a per-position character vote across frames and reads
11
+ * out the majority consensus. Frames vote in proportion to a quality weight, so a
12
+ * sharper/closer frame counts more than a blurry one.
13
+ *
14
+ * This is a JS-only, ROVER-inspired post-OCR fusion (Recognizer Output Voting
15
+ * Error Reduction): each frame is first normalised into fixed-width MRZ lines via
16
+ * the existing reconstruction, lines are aligned by index and column, votes are
17
+ * tallied per (line, column), and the winning character per cell forms the
18
+ * consensus MRZ — which is then validated with check digits. Research shows
19
+ * post-OCR result integration beats single-frame OCR and image super-resolution
20
+ * for MRZ in a video stream.
21
+ *
22
+ * Why per-character voting over "N identical reads": a frame that misreads ONE
23
+ * character still contributes correct votes for the other 89 positions, so the
24
+ * consensus converges far faster and is robust to sporadic OCR errors.
25
+ */
26
+
27
+ /** Fixed line geometry per ICAO 9303 format. */
28
+ const FORMAT_GEOMETRY: Record<string, { lines: number; width: number }> = {
29
+ TD1: { lines: 3, width: 30 },
30
+ TD2: { lines: 2, width: 36 },
31
+ TD3: { lines: 2, width: 44 },
32
+ };
33
+
34
+ const MRZ_CHAR = /^[A-Z0-9<]$/;
35
+
36
+ export interface MRZFrameInput {
37
+ /** Raw OCR text for this frame (assembled MRZ blocks, newline-separated). */
38
+ text: string;
39
+ /**
40
+ * Optional per-frame quality weight (higher = sharper/closer). Defaults to 1.
41
+ * A caller can pass e.g. a sharpness/focus estimate or the MRZ block's pixel
42
+ * height so crisper frames dominate the vote, per the research finding that
43
+ * focus-weighted voting beats unweighted.
44
+ */
45
+ weight?: number;
46
+ }
47
+
48
+ export interface MRZConsensus {
49
+ /** Best consensus MRZ (newline-joined fixed-width lines), or null if none yet. */
50
+ mrz: string | null;
51
+ /** Validation of the current consensus (valid only when check digits pass). */
52
+ validation: MRZValidationResult | null;
53
+ /** Number of frames that contributed a usable line-set so far. */
54
+ frames: number;
55
+ /**
56
+ * True when the consensus is valid AND stable — it has not changed across the
57
+ * last `stabilityTarget` contributing frames. Callers should advance only then.
58
+ */
59
+ stable: boolean;
60
+ }
61
+
62
+ export interface MRZFrameAggregatorOptions {
63
+ /**
64
+ * How many consecutive contributing frames the valid consensus must remain
65
+ * unchanged before it is considered stable. Default 2.
66
+ */
67
+ stabilityTarget?: number;
68
+ /** Drop the running tallies after this many frames to bound memory. Default 60. */
69
+ maxFrames?: number;
70
+ }
71
+
72
+ interface CellVotes {
73
+ // char -> accumulated weight
74
+ [ch: string]: number;
75
+ }
76
+
77
+ /**
78
+ * Reconstructs fixed-width MRZ candidate line-sets for a frame's raw OCR text.
79
+ * Reuses the same reconstruction the single-frame validator uses, so the
80
+ * aggregator sees the SAME normalised lines (filler/doc-code fixes applied),
81
+ * just fused across frames. Returns the best (longest, most filled) candidate.
82
+ */
83
+ /** Does this exact line-set already match a known fixed-width MRZ format? */
84
+ const exactFormatLines = (lines: string[]): string[] | null => {
85
+ for (const g of Object.values(FORMAT_GEOMETRY)) {
86
+ if (lines.length === g.lines && lines.every((l) => l.length === g.width)) {
87
+ return lines;
88
+ }
89
+ }
90
+ return null;
91
+ };
92
+
93
+ const linesForFrame = (text: string): string[] | null => {
94
+ // Fast path: if the raw OCR already splits into clean fixed-width MRZ lines of
95
+ // a known format, use them directly. Reconstruction is tuned for noisy TD1 and
96
+ // can mis-shape an already-clean TD2/TD3 (e.g. force a 2×44 passport into a
97
+ // 3×30 layout), so prefer the as-is lines when they're exact.
98
+ const rawLines = text
99
+ .split('\n')
100
+ .map((l) =>
101
+ l
102
+ .trim()
103
+ .replace(/[^A-Z0-9<]/gi, '')
104
+ .toUpperCase()
105
+ )
106
+ .filter(Boolean);
107
+ const exact = exactFormatLines(rawLines);
108
+ if (exact) return exact;
109
+
110
+ const candidates = mrzUtils.reconstructMRZCandidates(text);
111
+ if (!candidates.length) {
112
+ // Fall back to the lightweight fixMRZ split for already-clean text.
113
+ const fixed = mrzUtils
114
+ .fixMRZ(text)
115
+ .split('\n')
116
+ .map((l) => l.trim())
117
+ .filter(Boolean);
118
+ return fixed.length >= 2 ? fixed : null;
119
+ }
120
+ // Pick the best candidate and coerce it to an exact format geometry so it can
121
+ // be voted. A frame whose reconstruction is ALMOST a format (right line count,
122
+ // lines within a few chars of the width) still carries useful per-character
123
+ // votes — pad/truncate each line to the format width rather than discarding the
124
+ // whole frame. This keeps the vote accumulating every frame during continuous
125
+ // scanning instead of stalling on the occasional perfectly-shaped frame.
126
+ let best: string[] | null = null;
127
+ let bestScore = -1;
128
+ for (const cand of candidates) {
129
+ const geom = Object.values(FORMAT_GEOMETRY).find(
130
+ (g) => g.lines === cand.length
131
+ );
132
+ if (!geom) continue;
133
+ // Reject if any line is wildly off the expected width (likely not the band).
134
+ if (cand.some((l) => Math.abs(l.length - geom.width) > 6)) continue;
135
+ const coerced = cand.map((l) =>
136
+ l.length >= geom.width
137
+ ? l.slice(0, geom.width)
138
+ : l.padEnd(geom.width, '<')
139
+ );
140
+ const isExactWidth = cand.every((l) => l.length === geom.width);
141
+ const filled = coerced.join('').replace(/</g, '').length;
142
+ // Exact-shaped frames score a little higher, but near-shaped still qualify.
143
+ const score = (isExactWidth ? 10000 : 0) + filled;
144
+ if (score > bestScore) {
145
+ bestScore = score;
146
+ best = coerced;
147
+ }
148
+ }
149
+ return best;
150
+ };
151
+
152
+ /**
153
+ * Stateful aggregator. Feed it one frame's OCR text at a time via `addFrame`;
154
+ * after each it returns the current per-character-voted consensus and whether it
155
+ * is valid + stable. Reset between documents with `reset`.
156
+ */
157
+ export class MRZFrameAggregator {
158
+ private readonly stabilityTarget: number;
159
+ private readonly maxFrames: number;
160
+ // format -> per-line, per-column vote tallies
161
+ private tallies: Record<string, CellVotes[][]> = {};
162
+ private frameCount = 0;
163
+ private lastConsensusMrz: string | null = null;
164
+ private stableStreak = 0;
165
+ // Consecutive frames whose identifying region disagrees with the consensus.
166
+ private mismatchStreak = 0;
167
+
168
+ constructor(options: MRZFrameAggregatorOptions = {}) {
169
+ this.stabilityTarget = Math.max(1, options.stabilityTarget ?? 2);
170
+ this.maxFrames = Math.max(1, options.maxFrames ?? 60);
171
+ }
172
+
173
+ reset(): void {
174
+ this.tallies = {};
175
+ this.frameCount = 0;
176
+ this.lastConsensusMrz = null;
177
+ this.stableStreak = 0;
178
+ this.mismatchStreak = 0;
179
+ }
180
+
181
+ /**
182
+ * Returns true when the incoming frame's identifying region (the line-1 data
183
+ * span that holds the document number) has disagreed with the current
184
+ * consensus for enough consecutive frames to conclude a NEW document is in
185
+ * view — so the caller should drop the stale votes. A few disagreeing frames
186
+ * are tolerated as OCR noise; a sustained disagreement is a document swap.
187
+ */
188
+ private detectDocumentChange(format: string, lines: string[]): boolean {
189
+ const tally = this.tallies[format];
190
+ if (!tally || this.frameCount < 3) {
191
+ this.mismatchStreak = 0;
192
+ return false;
193
+ }
194
+ // Compare the document-number-bearing region of line 1 (positions 5..14 in
195
+ // TD1; positions 0..9 in TD2/TD3) against the current per-cell winners.
196
+ const [start, end] = format === 'TD1' ? [5, 14] : [0, 9];
197
+ const consensusL1 = this.consensusLines(format)[format === 'TD1' ? 0 : 1];
198
+ const frameL1 = lines[format === 'TD1' ? 0 : 1] ?? '';
199
+ let diffs = 0;
200
+ let compared = 0;
201
+ for (let i = start; i < end; i++) {
202
+ const a = consensusL1[i];
203
+ const b = frameL1[i];
204
+ if (a === '<' || b === '<' || b === undefined) continue; // skip fillers/gaps
205
+ compared++;
206
+ if (a !== b) diffs++;
207
+ }
208
+ // Majority of the doc-number characters differ → this frame is a different doc.
209
+ const isMismatch = compared >= 4 && diffs >= Math.ceil(compared / 2);
210
+ this.mismatchStreak = isMismatch ? this.mismatchStreak + 1 : 0;
211
+ return this.mismatchStreak >= 3;
212
+ }
213
+
214
+ /** Frames contributed so far. */
215
+ get frames(): number {
216
+ return this.frameCount;
217
+ }
218
+
219
+ private ensureTally(format: string): CellVotes[][] {
220
+ if (!this.tallies[format]) {
221
+ const geom = FORMAT_GEOMETRY[format];
222
+ this.tallies[format] = Array.from({ length: geom.lines }, () =>
223
+ Array.from({ length: geom.width }, () => ({}) as CellVotes)
224
+ );
225
+ }
226
+ return this.tallies[format];
227
+ }
228
+
229
+ /**
230
+ * Ingest one frame and return the current consensus. The frame's lines are
231
+ * voted into the tally for whichever format they match; the consensus is then
232
+ * read out per format and validated, and the best valid (or best-effort)
233
+ * consensus is returned.
234
+ */
235
+ addFrame(input: MRZFrameInput): MRZConsensus {
236
+ const weight = input.weight && input.weight > 0 ? input.weight : 1;
237
+ const lines = linesForFrame(input.text);
238
+
239
+ if (lines) {
240
+ // Determine the format this frame's lines fit (by line count + width).
241
+ const format = Object.keys(FORMAT_GEOMETRY).find((f) => {
242
+ const g = FORMAT_GEOMETRY[f];
243
+ return (
244
+ lines.length === g.lines && lines.every((l) => l.length === g.width)
245
+ );
246
+ });
247
+ if (format) {
248
+ // Auto-reset when a DIFFERENT document is shown: if this frame's
249
+ // identifying region (line 1 data, where the doc number lives) disagrees
250
+ // strongly with the accumulated consensus for several consecutive frames,
251
+ // the votes are stale — clear them so the new document can converge
252
+ // instead of staying "stable" on the previous one.
253
+ if (this.detectDocumentChange(format, lines)) {
254
+ this.reset();
255
+ }
256
+ const tally = this.ensureTally(format);
257
+ for (let li = 0; li < lines.length; li++) {
258
+ const line = lines[li];
259
+ for (let ci = 0; ci < line.length; ci++) {
260
+ const ch = line[ci];
261
+ if (!MRZ_CHAR.test(ch)) continue;
262
+ const cell = tally[li][ci];
263
+ cell[ch] = (cell[ch] ?? 0) + weight;
264
+ }
265
+ }
266
+ this.frameCount++;
267
+ if (this.frameCount > this.maxFrames) this.decay();
268
+ }
269
+ }
270
+
271
+ return this.currentConsensus();
272
+ }
273
+
274
+ /** Halve all tallies to bound memory while preserving relative vote ratios. */
275
+ private decay(): void {
276
+ for (const format of Object.keys(this.tallies)) {
277
+ for (const lineCells of this.tallies[format]) {
278
+ for (const cell of lineCells) {
279
+ for (const ch of Object.keys(cell)) {
280
+ cell[ch] /= 2;
281
+ }
282
+ }
283
+ }
284
+ }
285
+ this.frameCount = Math.ceil(this.frameCount / 2);
286
+ }
287
+
288
+ /** Read out the winning character per cell for a format. */
289
+ private consensusLines(format: string): string[] {
290
+ const geom = FORMAT_GEOMETRY[format];
291
+ const tally = this.tallies[format];
292
+ const lines: string[] = [];
293
+ for (let li = 0; li < geom.lines; li++) {
294
+ let line = '';
295
+ for (let ci = 0; ci < geom.width; ci++) {
296
+ const cell = tally[li][ci];
297
+ let bestCh = '<';
298
+ let bestW = -1;
299
+ for (const ch of Object.keys(cell)) {
300
+ // Tie-break deterministically: higher weight wins; on a tie a non-filler
301
+ // beats a filler (fillers are the "no information" default), else lexical.
302
+ const w = cell[ch];
303
+ if (
304
+ w > bestW ||
305
+ (w === bestW && bestCh === '<' && ch !== '<') ||
306
+ (w === bestW && bestCh !== '<' && ch !== '<' && ch < bestCh)
307
+ ) {
308
+ bestW = w;
309
+ bestCh = ch;
310
+ }
311
+ }
312
+ line += bestW < 0 ? '<' : bestCh;
313
+ }
314
+ lines.push(line);
315
+ }
316
+ return lines;
317
+ }
318
+
319
+ /** Best consensus across all formats seen, validated with check digits. */
320
+ currentConsensus(): MRZConsensus {
321
+ const formats = Object.keys(this.tallies);
322
+ if (!formats.length) {
323
+ return {
324
+ mrz: null,
325
+ validation: null,
326
+ frames: this.frameCount,
327
+ stable: false,
328
+ };
329
+ }
330
+
331
+ let bestMrz: string | null = null;
332
+ let bestValidation: MRZValidationResult | null = null;
333
+ let bestValid = false;
334
+
335
+ for (const format of formats) {
336
+ const lines = this.consensusLines(format);
337
+ const mrz = lines.join('\n');
338
+ const validation = mrzUtils.validateMRZWithCorrections(mrz);
339
+ // Prefer a valid consensus; among valid, prefer the one the validator could
340
+ // fully correct (it sets correctedMrz). Among invalid, keep the first.
341
+ if (validation.valid && !bestValid) {
342
+ bestValid = true;
343
+ bestMrz = validation.correctedMrz ?? mrz;
344
+ bestValidation = validation;
345
+ } else if (!bestValid && bestMrz === null) {
346
+ bestMrz = mrz;
347
+ bestValidation = validation;
348
+ }
349
+ }
350
+
351
+ // Stability: the (valid) consensus must be unchanged across consecutive
352
+ // contributing frames. Track the streak on the corrected/consensus string.
353
+ const stableKey = bestValid ? bestMrz : null;
354
+ if (stableKey && stableKey === this.lastConsensusMrz) {
355
+ this.stableStreak++;
356
+ } else {
357
+ this.stableStreak = stableKey ? 1 : 0;
358
+ }
359
+ this.lastConsensusMrz = stableKey;
360
+
361
+ return {
362
+ mrz: bestMrz,
363
+ validation: bestValidation,
364
+ frames: this.frameCount,
365
+ stable: bestValid && this.stableStreak >= this.stabilityTarget,
366
+ };
367
+ }
368
+ }
369
+
370
+ export default MRZFrameAggregator;
@@ -0,0 +1,175 @@
1
+ import { NativeModules } from 'react-native';
2
+ import mrzUtils from './mrz.utils';
3
+ import type { MRZValidationResult } from './mrz.utils';
4
+
5
+ /**
6
+ * On-device MRZ ↔ OCR integration test harness.
7
+ *
8
+ * Runs the REAL ML Kit v2 text recognizer (via the native `MLKitModule`) on a
9
+ * still test image, assembles the recognized text exactly like the camera path
10
+ * does, feeds it through `validateMRZWithCorrections`, and reports whether the
11
+ * OCR-misread MRZ was corrected to a check-digit-valid result.
12
+ *
13
+ * This must run on a device/emulator (ML Kit is native) — it is not a jest test.
14
+ * Feed it the synthetic MRZ images under
15
+ * `src/Screens/Debug/__fixtures__/mrz-images/` (generated by
16
+ * `scripts/gen-mrz-test-images.py`), e.g. read with react-native-fs and pass the
17
+ * base64 to `runMrzOcrSuite`. The check-digit-driven correction in
18
+ * `mrz.utils.ts` is what these images exercise end-to-end against real OCR.
19
+ *
20
+ * ML Kit Text Recognition v2:
21
+ * https://developers.google.com/ml-kit/vision/text-recognition/v2
22
+ */
23
+
24
+ interface NativeTextBlock {
25
+ text?: string;
26
+ boundingBox?: { left: number; top: number; right: number; bottom: number };
27
+ }
28
+
29
+ interface MLKitNativeModule {
30
+ recognizeText: (base64Image: string) => Promise<NativeTextBlock[]>;
31
+ }
32
+
33
+ const MLKit = (NativeModules as { MLKitModule?: MLKitNativeModule })
34
+ .MLKitModule;
35
+
36
+ /** A single image-based test case. */
37
+ export interface MrzOcrTestCase {
38
+ /** Human label, e.g. "TD1 specimen — straight-on". */
39
+ name: string;
40
+ /** Base64-encoded JPEG/PNG of the (synthetic) MRZ document. */
41
+ base64: string;
42
+ /**
43
+ * Optional expectations. When omitted, the test passes if the MRZ validates.
44
+ * Provide these for stronger assertions against the known synthetic values.
45
+ */
46
+ expect?: {
47
+ valid?: boolean; // default true
48
+ documentNumber?: string;
49
+ birthDate?: string;
50
+ expirationDate?: string;
51
+ };
52
+ }
53
+
54
+ export interface MrzOcrTestResult {
55
+ name: string;
56
+ passed: boolean;
57
+ /** Raw text ML Kit produced (joined blocks). Useful for debugging failures. */
58
+ rawOcr: string;
59
+ /** Validation result after correction. */
60
+ validation: MRZValidationResult;
61
+ /** Why it failed (empty when passed). */
62
+ reason?: string;
63
+ /** Time spent in OCR + correction, ms. */
64
+ durationMs: number;
65
+ }
66
+
67
+ /** Assemble the OCR blocks into newline-separated text, mirroring the camera path. */
68
+ export const assembleOcrText = (blocks: NativeTextBlock[]): string =>
69
+ blocks
70
+ .map((b) => (b.text ?? '').trim())
71
+ .filter((t) => t.length > 0)
72
+ .join('\n');
73
+
74
+ /**
75
+ * Runs one image through real ML Kit OCR + MRZ correction and checks expectations.
76
+ */
77
+ export const runMrzOcrCase = async (
78
+ testCase: MrzOcrTestCase,
79
+ now: () => number = Date.now
80
+ ): Promise<MrzOcrTestResult> => {
81
+ const start = now();
82
+
83
+ if (!MLKit?.recognizeText) {
84
+ return {
85
+ name: testCase.name,
86
+ passed: false,
87
+ rawOcr: '',
88
+ validation: { valid: false, format: 'UNKNOWN', error: 'native module' },
89
+ reason:
90
+ 'MLKitModule.recognizeText is unavailable (native module not linked)',
91
+ durationMs: now() - start,
92
+ };
93
+ }
94
+
95
+ let blocks: NativeTextBlock[] = [];
96
+ try {
97
+ blocks = await MLKit.recognizeText(testCase.base64);
98
+ } catch (err) {
99
+ return {
100
+ name: testCase.name,
101
+ passed: false,
102
+ rawOcr: '',
103
+ validation: { valid: false, format: 'UNKNOWN', error: 'ocr error' },
104
+ reason: `OCR threw: ${err instanceof Error ? err.message : String(err)}`,
105
+ durationMs: now() - start,
106
+ };
107
+ }
108
+
109
+ const rawOcr = assembleOcrText(blocks);
110
+ const validation = mrzUtils.validateMRZWithCorrections(rawOcr);
111
+
112
+ const exp = testCase.expect ?? {};
113
+ const wantValid = exp.valid ?? true;
114
+ const checks: string[] = [];
115
+
116
+ if (validation.valid !== wantValid) {
117
+ checks.push(`valid=${validation.valid}, expected ${wantValid}`);
118
+ }
119
+ if (wantValid && validation.valid) {
120
+ if (
121
+ exp.documentNumber &&
122
+ validation.fields?.documentNumber !== exp.documentNumber
123
+ ) {
124
+ checks.push(
125
+ `documentNumber=${validation.fields?.documentNumber}, expected ${exp.documentNumber}`
126
+ );
127
+ }
128
+ if (exp.birthDate && validation.fields?.birthDate !== exp.birthDate) {
129
+ checks.push(
130
+ `birthDate=${validation.fields?.birthDate}, expected ${exp.birthDate}`
131
+ );
132
+ }
133
+ if (
134
+ exp.expirationDate &&
135
+ validation.fields?.expirationDate !== exp.expirationDate
136
+ ) {
137
+ checks.push(
138
+ `expirationDate=${validation.fields?.expirationDate}, expected ${exp.expirationDate}`
139
+ );
140
+ }
141
+ }
142
+
143
+ return {
144
+ name: testCase.name,
145
+ passed: checks.length === 0,
146
+ rawOcr,
147
+ validation,
148
+ reason: checks.join('; '),
149
+ durationMs: now() - start,
150
+ };
151
+ };
152
+
153
+ export interface MrzOcrSuiteResult {
154
+ total: number;
155
+ passed: number;
156
+ failed: number;
157
+ results: MrzOcrTestResult[];
158
+ }
159
+
160
+ /** Runs every case sequentially and aggregates pass/fail. */
161
+ export const runMrzOcrSuite = async (
162
+ cases: MrzOcrTestCase[]
163
+ ): Promise<MrzOcrSuiteResult> => {
164
+ const results: MrzOcrTestResult[] = [];
165
+ for (const c of cases) {
166
+ results.push(await runMrzOcrCase(c));
167
+ }
168
+ const passed = results.filter((r) => r.passed).length;
169
+ return {
170
+ total: results.length,
171
+ passed,
172
+ failed: results.length - passed,
173
+ results,
174
+ };
175
+ };
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.472.0';
3
+ export const SDK_VERSION = '1.475.1';