@parselo/scanner-core 0.0.1 → 0.2.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/README.md +165 -0
- package/dist/canonical.d.ts +4 -0
- package/dist/canonical.js +58 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +75 -20
- package/dist/magstripe.d.ts +21 -0
- package/dist/magstripe.js +133 -0
- package/dist/mrz.d.ts +30 -0
- package/dist/mrz.js +240 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# @parselo/scanner-core
|
|
2
|
+
|
|
3
|
+
Platform-agnostic core for the Parselo Canadian ID scanning SDK. Parses AAMVA
|
|
4
|
+
PDF417 barcodes, magnetic-stripe tracks, and ICAO 9303 MRZ from passports and
|
|
5
|
+
travel documents into a unified canonical document shape. Includes offline
|
|
6
|
+
ES256 license enforcement and PII-free usage analytics.
|
|
7
|
+
|
|
8
|
+
No Capacitor dependency — fully testable in Node.
|
|
9
|
+
|
|
10
|
+
## What it parses
|
|
11
|
+
|
|
12
|
+
| Document type | Format | Notes |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| Canadian / US driver's licences & ID cards | PDF417 (AAMVA) | All provinces; dates normalised to ISO |
|
|
15
|
+
| BC / ON / AB licences (older stock) | Magnetic-stripe tracks encoded in PDF417 | Three-track `%…?` format |
|
|
16
|
+
| Passports, emergency travel docs, PR cards | ICAO 9303 TD3 MRZ (2 × 44 chars) | Five check digits validated; VIZ cross-reference corrects `<` OCR corruption |
|
|
17
|
+
|
|
18
|
+
All document types produce the same `CanonicalDocument` shape with identical field names and ISO `YYYY-MM-DD` dates.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install @parselo/scanner-core
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
A native capture plugin is required for on-device use:
|
|
27
|
+
|
|
28
|
+
| Use case | Plugin |
|
|
29
|
+
|---|---|
|
|
30
|
+
| Driver's licence / barcode | [`@parselo/capacitor-pdf417`](https://www.npmjs.com/package/@parselo/capacitor-pdf417) |
|
|
31
|
+
| Passport / travel document | [`@parselo/capacitor-mrz`](https://www.npmjs.com/package/@parselo/capacitor-mrz) |
|
|
32
|
+
|
|
33
|
+
## Scanning a driver's licence
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { Scanner, type BarcodeNative } from "@parselo/scanner-core";
|
|
37
|
+
import { Pdf417 } from "@parselo/capacitor-pdf417";
|
|
38
|
+
|
|
39
|
+
const native: BarcodeNative = {
|
|
40
|
+
captureAndDecodePdf417: async () => {
|
|
41
|
+
const { raw } = await Pdf417.decodePdf417({ image: dataUrl });
|
|
42
|
+
return raw; // null if no barcode found
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const scanner = new Scanner({ license, analytics, native });
|
|
47
|
+
await scanner.init();
|
|
48
|
+
|
|
49
|
+
const result = await scanner.scan();
|
|
50
|
+
if (result.ok && result.document) {
|
|
51
|
+
const { fields, jurisdiction } = result.document;
|
|
52
|
+
console.log(fields.firstName?.value, fields.lastName?.value);
|
|
53
|
+
console.log(fields.dateOfBirth?.value); // "1985-03-12"
|
|
54
|
+
console.log(jurisdiction); // "CA-QC"
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Scanning a passport or travel document
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { Scanner, type MrzNative } from "@parselo/scanner-core";
|
|
62
|
+
import { Mrz } from "@parselo/capacitor-mrz";
|
|
63
|
+
|
|
64
|
+
const mrzNative: MrzNative = {
|
|
65
|
+
captureAndRecognizeMrz: async () => {
|
|
66
|
+
const { lines } = await Mrz.recognizeText({ image: dataUrl });
|
|
67
|
+
return lines; // string[] of OCR observations
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const scanner = new Scanner({ license, analytics, native, mrzNative });
|
|
72
|
+
await scanner.init();
|
|
73
|
+
|
|
74
|
+
const result = await scanner.scanPassport();
|
|
75
|
+
if (result.ok && result.document) {
|
|
76
|
+
const { fields, jurisdiction } = result.document;
|
|
77
|
+
console.log(fields.firstName?.value, fields.lastName?.value);
|
|
78
|
+
console.log(fields.dateOfBirth?.value); // "1978-11-28"
|
|
79
|
+
console.log(fields.country?.value); // "MEX" (ICAO 3-char)
|
|
80
|
+
console.log(jurisdiction); // "CA" for Canadian-issued docs
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Handles standard passports (`P<`), emergency travel documents (`PU`), permanent
|
|
85
|
+
resident travel documents (`PR`), and foreign passports. The parser cross-references
|
|
86
|
+
Vision's biographical-zone OCR lines to recover names even when the OCR-B `<` fill
|
|
87
|
+
character is misread.
|
|
88
|
+
|
|
89
|
+
## Canonical document shape
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
interface CanonicalDocument {
|
|
93
|
+
documentType: "drivers_license" | "id_card" | "passport" | "unknown";
|
|
94
|
+
jurisdiction: string; // "CA-QC", "CA-BC", "CA", "MEX", …
|
|
95
|
+
fields: {
|
|
96
|
+
// All document types
|
|
97
|
+
firstName?: Field;
|
|
98
|
+
lastName?: Field;
|
|
99
|
+
middleName?: Field;
|
|
100
|
+
dateOfBirth?: Field; // ISO YYYY-MM-DD
|
|
101
|
+
expiryDate?: Field; // ISO YYYY-MM-DD
|
|
102
|
+
documentNumber?: Field;
|
|
103
|
+
sex?: Field;
|
|
104
|
+
// Driver's licences
|
|
105
|
+
addressStreet?: Field;
|
|
106
|
+
addressCity?: Field;
|
|
107
|
+
addressRegion?: Field;
|
|
108
|
+
addressPostalCode?: Field;
|
|
109
|
+
vehicleClass?: Field;
|
|
110
|
+
// Passports / travel documents
|
|
111
|
+
country?: Field; // ICAO 3-char issuing country
|
|
112
|
+
};
|
|
113
|
+
raw?: Record<string, string>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface Field {
|
|
117
|
+
value: string;
|
|
118
|
+
confidence: number; // 0–1
|
|
119
|
+
source: "barcode" | "mrz";
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Scan result
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
interface ScanResult {
|
|
127
|
+
ok: boolean;
|
|
128
|
+
document?: CanonicalDocument;
|
|
129
|
+
error?: ScanError;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type ScanError =
|
|
133
|
+
| "no_barcode" | "not_aamva" // barcode path
|
|
134
|
+
| "no_mrz" // passport path
|
|
135
|
+
| "license_expired" | "license_bundle_mismatch"
|
|
136
|
+
| "license_bad_signature" | "license_unknown_key" | "license_malformed";
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## License enforcement
|
|
140
|
+
|
|
141
|
+
Tokens are ES256 JWTs signed by AWS KMS. `scanner.init()` verifies the
|
|
142
|
+
signature offline against the embedded public key — no network call required.
|
|
143
|
+
Each token encodes an expiry date, an allowed bundle ID list, and a scan quota.
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
const { valid, reason, claims } = await scanner.init();
|
|
147
|
+
if (!valid) {
|
|
148
|
+
// reason: "expired" | "bundle_mismatch" | "bad_signature"
|
|
149
|
+
// | "unknown_key" | "malformed"
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Analytics
|
|
154
|
+
|
|
155
|
+
One PII-free event per scan (document type, jurisdiction, success/fail,
|
|
156
|
+
confidence bucket, device model — never document field values). Events buffer
|
|
157
|
+
locally and flush in batches. Force a flush with:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
await scanner.flushAnalytics();
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT
|
package/dist/canonical.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { AamvaResult } from "./aamva";
|
|
2
|
+
import type { MagstripeResult } from "./magstripe";
|
|
3
|
+
import type { MrzResult } from "./mrz";
|
|
2
4
|
export type FieldSource = "barcode" | "ocr" | "mrz";
|
|
3
5
|
export interface CanonicalField {
|
|
4
6
|
value: string;
|
|
@@ -27,4 +29,6 @@ export interface CanonicalDocument {
|
|
|
27
29
|
};
|
|
28
30
|
raw: Record<string, string>;
|
|
29
31
|
}
|
|
32
|
+
export declare function mrzToCanonical(m: MrzResult): CanonicalDocument;
|
|
33
|
+
export declare function magstripeToCanonical(m: MagstripeResult, confidence?: number): CanonicalDocument;
|
|
30
34
|
export declare function toCanonical(a: AamvaResult, confidence?: number): CanonicalDocument;
|
package/dist/canonical.js
CHANGED
|
@@ -2,6 +2,64 @@
|
|
|
2
2
|
// One stable output shape every document maps into, regardless of source
|
|
3
3
|
// (barcode now; OCR/MRZ later). Each field carries its value, a confidence,
|
|
4
4
|
// and which path produced it, so the future merge step can prefer ground truth.
|
|
5
|
+
// Minimal ICAO 3-char → ISO 3166-1 alpha-2 map for jurisdiction derivation.
|
|
6
|
+
const ICAO_ISO2 = {
|
|
7
|
+
CAN: "CA", USA: "US", GBR: "GB", FRA: "FR", DEU: "DE", AUS: "AU",
|
|
8
|
+
NZL: "NZ", JPN: "JP", CHN: "CN", IND: "IN", BRA: "BR", MEX: "MX",
|
|
9
|
+
ITA: "IT", ESP: "ES", PRT: "PT", NLD: "NL", BEL: "BE", CHE: "CH",
|
|
10
|
+
AUT: "AT", SWE: "SE", NOR: "NO", DNK: "DK", FIN: "FI", IRL: "IE",
|
|
11
|
+
POL: "PL", CZE: "CZ", HUN: "HU", HRV: "HR", ROU: "RO", UKR: "UA",
|
|
12
|
+
RUS: "RU", TUR: "TR", ISR: "IL", ZAF: "ZA", KOR: "KR", SGP: "SG",
|
|
13
|
+
MYS: "MY", THA: "TH", IDN: "ID", PHL: "PH", PAK: "PK", EGY: "EG",
|
|
14
|
+
};
|
|
15
|
+
export function mrzToCanonical(m) {
|
|
16
|
+
const c = m.confidence;
|
|
17
|
+
const field = (value) => value !== undefined ? { value, confidence: c, source: "mrz" } : undefined;
|
|
18
|
+
const iso2 = m.issuingCountry ? (ICAO_ISO2[m.issuingCountry] ?? m.issuingCountry) : "unknown";
|
|
19
|
+
return {
|
|
20
|
+
documentType: "passport",
|
|
21
|
+
jurisdiction: iso2,
|
|
22
|
+
fields: {
|
|
23
|
+
firstName: field(m.firstName),
|
|
24
|
+
lastName: field(m.lastName),
|
|
25
|
+
documentNumber: field(m.documentNumber),
|
|
26
|
+
dateOfBirth: field(m.dateOfBirth),
|
|
27
|
+
expiryDate: field(m.expiryDate),
|
|
28
|
+
sex: field(m.sex),
|
|
29
|
+
country: field(m.issuingCountry),
|
|
30
|
+
},
|
|
31
|
+
raw: {
|
|
32
|
+
line1: m.raw.line1,
|
|
33
|
+
line2: m.raw.line2,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function magstripeToCanonical(m, confidence = 1.0) {
|
|
38
|
+
const field = (value) => value !== undefined ? { value, confidence, source: "barcode" } : undefined;
|
|
39
|
+
const province = m.jurisdiction ?? m.addressRegion ?? "";
|
|
40
|
+
const jurisdiction = province ? `CA-${province}` : "CA";
|
|
41
|
+
return {
|
|
42
|
+
documentType: "drivers_license",
|
|
43
|
+
jurisdiction,
|
|
44
|
+
fields: {
|
|
45
|
+
firstName: field(m.firstName),
|
|
46
|
+
lastName: field(m.lastName),
|
|
47
|
+
dateOfBirth: field(m.dateOfBirth),
|
|
48
|
+
expiryDate: field(m.expiryDate),
|
|
49
|
+
documentNumber: field(m.documentNumber),
|
|
50
|
+
sex: field(m.sex),
|
|
51
|
+
addressStreet: field(m.addressStreet),
|
|
52
|
+
addressCity: field(m.addressCity),
|
|
53
|
+
addressRegion: field(m.addressRegion),
|
|
54
|
+
addressPostalCode: field(m.addressPostalCode),
|
|
55
|
+
},
|
|
56
|
+
raw: {
|
|
57
|
+
track1: m.raw.track1 ?? "",
|
|
58
|
+
track2: m.raw.track2 ?? "",
|
|
59
|
+
track3: m.raw.track3 ?? "",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
5
63
|
export function toCanonical(a, confidence = 1.0) {
|
|
6
64
|
const e = a.elements;
|
|
7
65
|
const field = (code) => e[code] !== undefined ? { value: e[code], confidence, source: "barcode" } : undefined;
|
package/dist/index.d.ts
CHANGED
|
@@ -2,12 +2,17 @@ import { type CanonicalDocument } from "./canonical";
|
|
|
2
2
|
import { type LicenseResult } from "./license";
|
|
3
3
|
import { type AnalyticsConfig } from "./analytics";
|
|
4
4
|
export * from "./aamva";
|
|
5
|
+
export * from "./magstripe";
|
|
6
|
+
export * from "./mrz";
|
|
5
7
|
export * from "./canonical";
|
|
6
8
|
export * from "./license";
|
|
7
9
|
export * from "./analytics";
|
|
8
10
|
export interface BarcodeNative {
|
|
9
11
|
captureAndDecodePdf417(): Promise<string | null>;
|
|
10
12
|
}
|
|
13
|
+
export interface MrzNative {
|
|
14
|
+
captureAndRecognizeMrz(): Promise<string[] | null>;
|
|
15
|
+
}
|
|
11
16
|
export interface ScannerConfig {
|
|
12
17
|
license: {
|
|
13
18
|
token: string;
|
|
@@ -16,9 +21,10 @@ export interface ScannerConfig {
|
|
|
16
21
|
};
|
|
17
22
|
analytics: AnalyticsConfig;
|
|
18
23
|
native: BarcodeNative;
|
|
24
|
+
mrzNative?: MrzNative;
|
|
19
25
|
deviceModel: string;
|
|
20
26
|
}
|
|
21
|
-
export type ScanError = "license_malformed" | "license_unknown_key" | "license_bad_signature" | "license_expired" | "license_bundle_mismatch" | "no_barcode" | "not_aamva";
|
|
27
|
+
export type ScanError = "license_malformed" | "license_unknown_key" | "license_bad_signature" | "license_expired" | "license_bundle_mismatch" | "no_barcode" | "not_aamva" | "no_mrz";
|
|
22
28
|
export interface ScanResult {
|
|
23
29
|
ok: boolean;
|
|
24
30
|
document?: CanonicalDocument;
|
|
@@ -31,8 +37,12 @@ export declare class Scanner {
|
|
|
31
37
|
constructor(cfg: ScannerConfig);
|
|
32
38
|
/** Verify the signature once, up front. Call on plugin init. */
|
|
33
39
|
init(): Promise<LicenseResult>;
|
|
40
|
+
/** Scan a driver's licence or provincial ID card via PDF417 barcode. */
|
|
34
41
|
scan(): Promise<ScanResult>;
|
|
42
|
+
/** Scan a passport via on-device MRZ text recognition. */
|
|
43
|
+
scanPassport(): Promise<ScanResult>;
|
|
35
44
|
/** Flush buffered analytics (e.g. on app foreground/background). */
|
|
36
45
|
flushAnalytics(): Promise<void>;
|
|
46
|
+
private checkLicense;
|
|
37
47
|
private emit;
|
|
38
48
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
// index.ts
|
|
2
2
|
// Ties the core together and shows the wrap points explicitly:
|
|
3
|
-
// verify license (gate) -> native
|
|
3
|
+
// verify license (gate) -> native capture -> parse -> canonical -> emit analytics
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// Supports two scan paths:
|
|
6
|
+
// scan() — PDF417 barcode (driver's licences)
|
|
7
|
+
// scanPassport() — MRZ text recognition (passports)
|
|
7
8
|
import { parseAamva } from "./aamva";
|
|
8
|
-
import {
|
|
9
|
+
import { parseMagstripe } from "./magstripe";
|
|
10
|
+
import { parseMrz } from "./mrz";
|
|
11
|
+
import { toCanonical, magstripeToCanonical, mrzToCanonical } from "./canonical";
|
|
9
12
|
import { verifyLicense, isExpired } from "./license";
|
|
10
13
|
import { ScanAnalytics } from "./analytics";
|
|
11
14
|
export * from "./aamva";
|
|
15
|
+
export * from "./magstripe";
|
|
16
|
+
export * from "./mrz";
|
|
12
17
|
export * from "./canonical";
|
|
13
18
|
export * from "./license";
|
|
14
19
|
export * from "./analytics";
|
|
@@ -31,33 +36,71 @@ export class Scanner {
|
|
|
31
36
|
this.claims = result.claims;
|
|
32
37
|
return result;
|
|
33
38
|
}
|
|
39
|
+
/** Scan a driver's licence or provincial ID card via PDF417 barcode. */
|
|
34
40
|
async scan() {
|
|
35
41
|
// (1) LICENSE GATE -----------------------------------------------------
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return { ok: false, error: `license_${result.reason}` };
|
|
40
|
-
}
|
|
41
|
-
// cheap per-scan expiry re-check (no crypto) so a long session can't outlive the token
|
|
42
|
-
if (!this.claims || isExpired(this.claims)) {
|
|
43
|
-
return { ok: false, error: "license_expired" };
|
|
44
|
-
}
|
|
42
|
+
const licenseError = await this.checkLicense();
|
|
43
|
+
if (licenseError)
|
|
44
|
+
return { ok: false, error: licenseError };
|
|
45
45
|
// (2) NATIVE DECODE ----------------------------------------------------
|
|
46
46
|
const raw = await this.cfg.native.captureAndDecodePdf417();
|
|
47
47
|
if (!raw) {
|
|
48
48
|
await this.emit("unknown", "", false, "low");
|
|
49
49
|
return { ok: false, error: "no_barcode" };
|
|
50
50
|
}
|
|
51
|
-
// (3) PARSE
|
|
52
|
-
const
|
|
53
|
-
|
|
51
|
+
// (3) PARSE — route to the correct parser based on format signature ------
|
|
52
|
+
const trimmedRaw = raw.replace(/^[\x00-\x1f\s]+/, "");
|
|
53
|
+
let document;
|
|
54
|
+
if (trimmedRaw.startsWith("@") || raw.includes("ANSI ")) {
|
|
55
|
+
const aamva = parseAamva(raw);
|
|
56
|
+
if (!aamva.isAamva) {
|
|
57
|
+
await this.emit("unknown", "", false, "low");
|
|
58
|
+
return { ok: false, error: "not_aamva" };
|
|
59
|
+
}
|
|
60
|
+
document = toCanonical(aamva);
|
|
61
|
+
}
|
|
62
|
+
else if (trimmedRaw.startsWith("%")) {
|
|
63
|
+
const ms = parseMagstripe(raw);
|
|
64
|
+
if (!ms.isMagstripe) {
|
|
65
|
+
await this.emit("unknown", "", false, "low");
|
|
66
|
+
return { ok: false, error: "not_aamva" };
|
|
67
|
+
}
|
|
68
|
+
document = magstripeToCanonical(ms);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
54
71
|
await this.emit("unknown", "", false, "low");
|
|
55
72
|
return { ok: false, error: "not_aamva" };
|
|
56
73
|
}
|
|
74
|
+
// (4) ANALYTICS (PII-FREE) --------------------------------------------
|
|
75
|
+
await this.emit(document.documentType, document.jurisdiction, true, "high");
|
|
76
|
+
// (5) RETURN -----------------------------------------------------------
|
|
77
|
+
return { ok: true, document };
|
|
78
|
+
}
|
|
79
|
+
/** Scan a passport via on-device MRZ text recognition. */
|
|
80
|
+
async scanPassport() {
|
|
81
|
+
// (1) LICENSE GATE -----------------------------------------------------
|
|
82
|
+
const licenseError = await this.checkLicense();
|
|
83
|
+
if (licenseError)
|
|
84
|
+
return { ok: false, error: licenseError };
|
|
85
|
+
// (2) NATIVE TEXT RECOGNITION ------------------------------------------
|
|
86
|
+
if (!this.cfg.mrzNative)
|
|
87
|
+
return { ok: false, error: "no_mrz" };
|
|
88
|
+
const lines = await this.cfg.mrzNative.captureAndRecognizeMrz();
|
|
89
|
+
if (!lines || lines.length === 0) {
|
|
90
|
+
await this.emit("unknown", "", false, "low", "mrz");
|
|
91
|
+
return { ok: false, error: "no_mrz" };
|
|
92
|
+
}
|
|
93
|
+
// (3) PARSE ------------------------------------------------------------
|
|
94
|
+
const mrz = parseMrz(lines);
|
|
95
|
+
if (!mrz.isMrz || mrz.confidence === 0) {
|
|
96
|
+
await this.emit("unknown", "", false, "low", "mrz");
|
|
97
|
+
return { ok: false, error: "no_mrz" };
|
|
98
|
+
}
|
|
57
99
|
// (4) CANONICAL --------------------------------------------------------
|
|
58
|
-
const document =
|
|
100
|
+
const document = mrzToCanonical(mrz);
|
|
59
101
|
// (5) ANALYTICS (PII-FREE) --------------------------------------------
|
|
60
|
-
|
|
102
|
+
const bucket = mrz.confidence >= 0.8 ? "high" : mrz.confidence >= 0.6 ? "medium" : "low";
|
|
103
|
+
await this.emit(document.documentType, document.jurisdiction, true, bucket, "mrz");
|
|
61
104
|
// (6) RETURN -----------------------------------------------------------
|
|
62
105
|
return { ok: true, document };
|
|
63
106
|
}
|
|
@@ -65,13 +108,25 @@ export class Scanner {
|
|
|
65
108
|
async flushAnalytics() {
|
|
66
109
|
await this.analytics.flush();
|
|
67
110
|
}
|
|
68
|
-
|
|
111
|
+
// ─── Private helpers ──────────────────────────────────────────────────────
|
|
112
|
+
async checkLicense() {
|
|
113
|
+
if (!this.claims) {
|
|
114
|
+
const result = await this.init();
|
|
115
|
+
if (!result.valid)
|
|
116
|
+
return `license_${result.reason}`;
|
|
117
|
+
}
|
|
118
|
+
// Cheap per-scan expiry re-check (no crypto) so a long session can't outlive the token.
|
|
119
|
+
if (!this.claims || isExpired(this.claims))
|
|
120
|
+
return "license_expired";
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
async emit(documentType, jurisdiction, success, confidenceBucket, source = "barcode") {
|
|
69
124
|
await this.analytics.record({
|
|
70
125
|
jti: this.claims?.jti ?? "unknown",
|
|
71
126
|
ts: new Date().toISOString(),
|
|
72
127
|
documentType,
|
|
73
128
|
jurisdiction,
|
|
74
|
-
source
|
|
129
|
+
source,
|
|
75
130
|
success,
|
|
76
131
|
confidenceBucket,
|
|
77
132
|
deviceModel: this.cfg.deviceModel,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface MagstripeResult {
|
|
2
|
+
isMagstripe: boolean;
|
|
3
|
+
jurisdiction?: string;
|
|
4
|
+
iin?: string;
|
|
5
|
+
lastName?: string;
|
|
6
|
+
firstName?: string;
|
|
7
|
+
dateOfBirth?: string;
|
|
8
|
+
expiryDate?: string;
|
|
9
|
+
documentNumber?: string;
|
|
10
|
+
sex?: string;
|
|
11
|
+
addressStreet?: string;
|
|
12
|
+
addressCity?: string;
|
|
13
|
+
addressRegion?: string;
|
|
14
|
+
addressPostalCode?: string;
|
|
15
|
+
raw: {
|
|
16
|
+
track1?: string;
|
|
17
|
+
track2?: string;
|
|
18
|
+
track3?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export declare function parseMagstripe(raw: string): MagstripeResult;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// magstripe.ts
|
|
2
|
+
// Parser for the AAMVA magnetic-stripe track format used by some Canadian provinces
|
|
3
|
+
// (confirmed BC; likely ON/AB). Three tracks concatenated in one PDF417 payload:
|
|
4
|
+
// Track 1 %...? jurisdiction + name + address
|
|
5
|
+
// Track 2 ;...? IIN + document number + expiry (YYMM) + DOB (CCYYMMDD)
|
|
6
|
+
// Track 3 ...? postal code + physical description (layout varies by jurisdiction)
|
|
7
|
+
//
|
|
8
|
+
// Reference: AAMVA DL/ID Card Design Standard, magnetic-stripe annex.
|
|
9
|
+
// Returns the last calendar day of a 1-based month in the given year.
|
|
10
|
+
function lastDayOfMonth(year, month) {
|
|
11
|
+
// Date(y, m, 0) with 0-indexed month m = last day of month (m-1); passing 1-based month directly works.
|
|
12
|
+
return new Date(year, month, 0).getDate();
|
|
13
|
+
}
|
|
14
|
+
function applyTrack1(out, t1) {
|
|
15
|
+
// Format: jurisdiction(2) + city + ^ + SURNAME[,]$GIVEN NAMES + ^ + STREET$CITY PROVINCE
|
|
16
|
+
const parts = t1.split("^");
|
|
17
|
+
if (parts.length < 2)
|
|
18
|
+
return;
|
|
19
|
+
out.jurisdiction = parts[0].slice(0, 2);
|
|
20
|
+
const namePart = parts[1];
|
|
21
|
+
const dollar = namePart.indexOf("$");
|
|
22
|
+
if (dollar !== -1) {
|
|
23
|
+
out.lastName = namePart.slice(0, dollar).replace(/,+$/, "").trim();
|
|
24
|
+
out.firstName = namePart.slice(dollar + 1).trim();
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
out.lastName = namePart.replace(/,+$/, "").trim();
|
|
28
|
+
}
|
|
29
|
+
if (parts.length < 3)
|
|
30
|
+
return;
|
|
31
|
+
const addrPart = parts[2].trim();
|
|
32
|
+
const addrDollar = addrPart.indexOf("$");
|
|
33
|
+
if (addrDollar === -1) {
|
|
34
|
+
out.addressStreet = addrPart;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
out.addressStreet = addrPart.slice(0, addrDollar).trim();
|
|
38
|
+
const cityProv = addrPart.slice(addrDollar + 1).trim();
|
|
39
|
+
// Province is the trailing space-separated token (always 2 chars); city is the rest.
|
|
40
|
+
const lastSpace = cityProv.lastIndexOf(" ");
|
|
41
|
+
if (lastSpace !== -1) {
|
|
42
|
+
out.addressCity = cityProv.slice(0, lastSpace).trim();
|
|
43
|
+
out.addressRegion = cityProv.slice(lastSpace + 1).trim();
|
|
44
|
+
}
|
|
45
|
+
else if (cityProv.length >= 2) {
|
|
46
|
+
out.addressRegion = cityProv.slice(-2);
|
|
47
|
+
out.addressCity = cityProv.slice(0, -2).trim();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function applyTrack2(out, t2) {
|
|
51
|
+
// Format: IIN(6) + document-number + = + expiry-YYMM(4) + DOB-CCYYMMDD(8) + = + [discretionary]
|
|
52
|
+
if (t2.length < 7)
|
|
53
|
+
return;
|
|
54
|
+
out.iin = t2.slice(0, 6);
|
|
55
|
+
const eq1 = t2.indexOf("=");
|
|
56
|
+
if (eq1 === -1)
|
|
57
|
+
return;
|
|
58
|
+
out.documentNumber = t2.slice(6, eq1);
|
|
59
|
+
const after = t2.slice(eq1 + 1);
|
|
60
|
+
if (after.length < 12)
|
|
61
|
+
return;
|
|
62
|
+
const expiryYYMM = after.slice(0, 4);
|
|
63
|
+
const dobCCYYMMDD = after.slice(4, 12);
|
|
64
|
+
if (/^\d{4}$/.test(expiryYYMM)) {
|
|
65
|
+
const yy = parseInt(expiryYYMM.slice(0, 2), 10);
|
|
66
|
+
const mm = parseInt(expiryYYMM.slice(2, 4), 10);
|
|
67
|
+
const year = yy < 70 ? 2000 + yy : 1900 + yy;
|
|
68
|
+
const day = lastDayOfMonth(year, mm);
|
|
69
|
+
out.expiryDate = `${year}-${String(mm).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
70
|
+
}
|
|
71
|
+
if (/^\d{8}$/.test(dobCCYYMMDD)) {
|
|
72
|
+
out.dateOfBirth =
|
|
73
|
+
`${dobCCYYMMDD.slice(0, 4)}-${dobCCYYMMDD.slice(4, 6)}-${dobCCYYMMDD.slice(6, 8)}`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function applyTrack3(out, t3) {
|
|
77
|
+
// Track 3 layout varies by jurisdiction — extract only what we can identify reliably.
|
|
78
|
+
// Canadian postal code: letter-digit-letter-digit-letter-digit (no space in raw)
|
|
79
|
+
const postalMatch = t3.match(/([A-Z]\d[A-Z]\d[A-Z]\d)/);
|
|
80
|
+
if (postalMatch) {
|
|
81
|
+
const p = postalMatch[1];
|
|
82
|
+
out.addressPostalCode = `${p.slice(0, 3)} ${p.slice(3)}`;
|
|
83
|
+
}
|
|
84
|
+
// Sex: M or F immediately followed by 3 digits (height in cm)
|
|
85
|
+
const physMatch = t3.match(/([MF])(\d{3})/);
|
|
86
|
+
if (physMatch)
|
|
87
|
+
out.sex = physMatch[1];
|
|
88
|
+
}
|
|
89
|
+
export function parseMagstripe(raw) {
|
|
90
|
+
const out = { isMagstripe: false, raw: {} };
|
|
91
|
+
// Strip leading control/whitespace for format detection; keep raw intact for track extraction.
|
|
92
|
+
const s = raw.replace(/^[\x00-\x1f\s]+/, "");
|
|
93
|
+
if (!s.startsWith("%"))
|
|
94
|
+
return out;
|
|
95
|
+
// Track 1: % sentinel to first ?
|
|
96
|
+
const t1End = s.indexOf("?");
|
|
97
|
+
if (t1End === -1)
|
|
98
|
+
return out;
|
|
99
|
+
const t1 = s.slice(1, t1End);
|
|
100
|
+
if (!t1.includes("^"))
|
|
101
|
+
return out; // must have the field-separator to be a DL track 1
|
|
102
|
+
out.isMagstripe = true;
|
|
103
|
+
out.raw.track1 = t1;
|
|
104
|
+
applyTrack1(out, t1);
|
|
105
|
+
// Track 2: ; sentinel somewhere after track 1
|
|
106
|
+
const after1 = s.slice(t1End + 1);
|
|
107
|
+
const t2SentinelPos = after1.indexOf(";");
|
|
108
|
+
if (t2SentinelPos === -1)
|
|
109
|
+
return out;
|
|
110
|
+
const t2ContentStart = t2SentinelPos + 1;
|
|
111
|
+
const t2End = after1.indexOf("?", t2ContentStart);
|
|
112
|
+
if (t2End === -1)
|
|
113
|
+
return out;
|
|
114
|
+
const t2 = after1.slice(t2ContentStart, t2End);
|
|
115
|
+
out.raw.track2 = t2;
|
|
116
|
+
applyTrack2(out, t2);
|
|
117
|
+
// Track 3: everything after track 2's ? up to the last ? in the string.
|
|
118
|
+
// Inter-track bytes (e.g. "_%0A" — an underscore + percent-encoded LF) are stripped.
|
|
119
|
+
const afterT2 = after1.slice(t2End + 1);
|
|
120
|
+
const t3LastQ = afterT2.lastIndexOf("?");
|
|
121
|
+
if (t3LastQ > 0) {
|
|
122
|
+
const t3Section = afterT2.slice(0, t3LastQ);
|
|
123
|
+
// Decode any percent-encoded sequences in the inter-track gap, then strip
|
|
124
|
+
// leading non-uppercase chars (underscores, decoded control chars, etc.).
|
|
125
|
+
const t3Decoded = t3Section.replace(/%([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
126
|
+
const t3 = t3Decoded.replace(/^[^A-Z]+/, "");
|
|
127
|
+
if (t3.length > 0) {
|
|
128
|
+
out.raw.track3 = t3;
|
|
129
|
+
applyTrack3(out, t3);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
package/dist/mrz.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface MrzResult {
|
|
2
|
+
isMrz: boolean;
|
|
3
|
+
documentType?: string;
|
|
4
|
+
issuingCountry?: string;
|
|
5
|
+
lastName?: string;
|
|
6
|
+
firstName?: string;
|
|
7
|
+
documentNumber?: string;
|
|
8
|
+
nationality?: string;
|
|
9
|
+
dateOfBirth?: string;
|
|
10
|
+
sex?: string;
|
|
11
|
+
expiryDate?: string;
|
|
12
|
+
personalNumber?: string;
|
|
13
|
+
confidence: number;
|
|
14
|
+
checksumFailures: string[];
|
|
15
|
+
raw: {
|
|
16
|
+
line1: string;
|
|
17
|
+
line2: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Find and parse a TD3 MRZ from an array of OCR-recognised text lines.
|
|
22
|
+
* Handles passports (P<), emergency travel documents (PU), permanent resident
|
|
23
|
+
* travel documents (PR), and any other ICAO TD3 document type starting with P.
|
|
24
|
+
*
|
|
25
|
+
* Lines are normalised (spaces stripped, uppercased) before matching; lines
|
|
26
|
+
* within ±1 char of the required 44 are accepted with padding/trimming.
|
|
27
|
+
* Each element may also contain newline-separated sub-lines (some OCR engines
|
|
28
|
+
* return both MRZ rows as one observation).
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseMrz(lines: string[]): MrzResult;
|
package/dist/mrz.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// mrz.ts
|
|
2
|
+
// ICAO 9303 TD3 two-line × 44-char MRZ parser with full check-digit validation.
|
|
3
|
+
// TD1/TD2 (ID-card) formats are out of scope and left for future work.
|
|
4
|
+
//
|
|
5
|
+
// Usage from scanner-core:
|
|
6
|
+
// parseMrz(lines) — pass all OCR lines; function locates the TD3 pair.
|
|
7
|
+
// ─── ICAO 7-3-1 check-digit algorithm ────────────────────────────────────────
|
|
8
|
+
const WEIGHT = [7, 3, 1];
|
|
9
|
+
function charValue(c) {
|
|
10
|
+
if (c === "<")
|
|
11
|
+
return 0;
|
|
12
|
+
const code = c.charCodeAt(0);
|
|
13
|
+
if (code >= 48 && code <= 57)
|
|
14
|
+
return code - 48; // '0'–'9'
|
|
15
|
+
if (code >= 65 && code <= 90)
|
|
16
|
+
return code - 55; // 'A'–'Z' → 10–35
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
function checkDigit(s) {
|
|
20
|
+
let sum = 0;
|
|
21
|
+
for (let i = 0; i < s.length; i++)
|
|
22
|
+
sum += charValue(s[i]) * WEIGHT[i % 3];
|
|
23
|
+
return sum % 10;
|
|
24
|
+
}
|
|
25
|
+
// ─── 2-digit year resolution ─────────────────────────────────────────────────
|
|
26
|
+
function resolveYear(yy, mustBePast) {
|
|
27
|
+
const now = new Date().getFullYear();
|
|
28
|
+
const century = Math.floor(now / 100) * 100;
|
|
29
|
+
const candidate = century + yy;
|
|
30
|
+
// DOB must be in the past; if the candidate is a future year, step back a century.
|
|
31
|
+
if (mustBePast && candidate > now)
|
|
32
|
+
return candidate - 100;
|
|
33
|
+
// Expiry/issue: if candidate is implausibly far in the past (> 50 years ago),
|
|
34
|
+
// step forward a century — handles the century rollover for long-lived documents.
|
|
35
|
+
if (!mustBePast && candidate < now - 50)
|
|
36
|
+
return candidate + 100;
|
|
37
|
+
return candidate;
|
|
38
|
+
}
|
|
39
|
+
function parseMrzDate(yymmdd, mustBePast) {
|
|
40
|
+
if (!/^\d{6}$/.test(yymmdd))
|
|
41
|
+
return undefined;
|
|
42
|
+
const yy = parseInt(yymmdd.slice(0, 2), 10);
|
|
43
|
+
const mm = yymmdd.slice(2, 4);
|
|
44
|
+
const dd = yymmdd.slice(4, 6);
|
|
45
|
+
return `${resolveYear(yy, mustBePast)}-${mm}-${dd}`;
|
|
46
|
+
}
|
|
47
|
+
// ─── Name parsing ─────────────────────────────────────────────────────────────
|
|
48
|
+
function parseName(field) {
|
|
49
|
+
// SURNAME<<GIVEN<NAMES<<… — primary and secondary identifiers split by <<
|
|
50
|
+
const idx = field.indexOf("<<");
|
|
51
|
+
if (idx === -1)
|
|
52
|
+
return { lastName: field.replace(/</g, " ").trim(), firstName: "" };
|
|
53
|
+
const lastName = field.slice(0, idx).replace(/</g, " ").trim();
|
|
54
|
+
const firstName = field.slice(idx + 2).replace(/</g, " ").trim();
|
|
55
|
+
return { lastName, firstName };
|
|
56
|
+
}
|
|
57
|
+
// ─── Core TD3 parser (expects exactly two 44-char lines) ──────────────────────
|
|
58
|
+
function parseTd3(l1, l2) {
|
|
59
|
+
const failures = [];
|
|
60
|
+
const verify = (label, data, expectedChar) => {
|
|
61
|
+
const expected = parseInt(expectedChar, 10);
|
|
62
|
+
if (isNaN(expected) || checkDigit(data) !== expected)
|
|
63
|
+
failures.push(label);
|
|
64
|
+
};
|
|
65
|
+
// ── Line 1 ──────────────────────────────────────────
|
|
66
|
+
const documentType = l1[0];
|
|
67
|
+
const issuingCountry = l1.slice(2, 5).replace(/</g, "");
|
|
68
|
+
const { lastName, firstName } = parseName(l1.slice(5, 44));
|
|
69
|
+
// ── Line 2 ──────────────────────────────────────────
|
|
70
|
+
const docNumField = l2.slice(0, 9);
|
|
71
|
+
const docNumCheck = l2[9];
|
|
72
|
+
const nationality = l2.slice(10, 13).replace(/</g, "");
|
|
73
|
+
const dobField = l2.slice(13, 19);
|
|
74
|
+
const dobCheck = l2[19];
|
|
75
|
+
const sexChar = l2[20];
|
|
76
|
+
const expField = l2.slice(21, 27);
|
|
77
|
+
const expCheck = l2[27];
|
|
78
|
+
const persField = l2.slice(28, 42);
|
|
79
|
+
const persCheck = l2[42];
|
|
80
|
+
const composite = l2[43];
|
|
81
|
+
// ── Check digits ─────────────────────────────────────
|
|
82
|
+
verify("documentNumber", docNumField, docNumCheck);
|
|
83
|
+
verify("dateOfBirth", dobField, dobCheck);
|
|
84
|
+
verify("expiryDate", expField, expCheck);
|
|
85
|
+
// Personal-number check: ICAO allows "<" when the field is unused/absent.
|
|
86
|
+
let totalChecks = 4; // docNum + DOB + expiry + composite
|
|
87
|
+
if (persCheck !== "<") {
|
|
88
|
+
verify("personalNumber", persField, persCheck);
|
|
89
|
+
totalChecks = 5;
|
|
90
|
+
}
|
|
91
|
+
// Composite covers positions 1–10, 14–20, 22–43 of line 2 (0-based: [0:10],[13:20],[21:43])
|
|
92
|
+
verify("composite", l2.slice(0, 10) + l2.slice(13, 20) + l2.slice(21, 43), composite);
|
|
93
|
+
const passed = totalChecks - failures.length;
|
|
94
|
+
const confidence = Math.max(0, passed / totalChecks);
|
|
95
|
+
return {
|
|
96
|
+
isMrz: true,
|
|
97
|
+
documentType,
|
|
98
|
+
issuingCountry: issuingCountry || undefined,
|
|
99
|
+
lastName: lastName || undefined,
|
|
100
|
+
firstName: firstName || undefined,
|
|
101
|
+
documentNumber: docNumField.replace(/</g, "") || undefined,
|
|
102
|
+
nationality: nationality || undefined,
|
|
103
|
+
dateOfBirth: parseMrzDate(dobField, true),
|
|
104
|
+
sex: (sexChar && sexChar !== "<") ? sexChar : undefined,
|
|
105
|
+
expiryDate: parseMrzDate(expField, false),
|
|
106
|
+
personalNumber: persField.replace(/</g, "") || undefined,
|
|
107
|
+
confidence,
|
|
108
|
+
checksumFailures: failures,
|
|
109
|
+
raw: { line1: l1, line2: l2 },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
113
|
+
// ─── VIZ (biographical zone) name cross-reference ────────────────────────────
|
|
114
|
+
//
|
|
115
|
+
// The OCR-B MRZ font encodes word separators as "<". Vision and ML Kit
|
|
116
|
+
// frequently misread "<" as "C", "S", "K", "@", "&", "/" etc., making the
|
|
117
|
+
// MRZ name field unreliable. However, Vision also reads the standard-font
|
|
118
|
+
// biographical zone above the MRZ (surname row, given-names row) with high
|
|
119
|
+
// accuracy because that zone uses a regular typeface with no fill characters.
|
|
120
|
+
//
|
|
121
|
+
// Strategy:
|
|
122
|
+
// 1. After parseTd3 produces a (possibly garbled) name, scan all non-MRZ
|
|
123
|
+
// Vision lines for "name candidates" – short all-Latin-letter lines
|
|
124
|
+
// whose uppercase tokens ALL appear verbatim in the raw 39-char MRZ
|
|
125
|
+
// name field.
|
|
126
|
+
// 2. Sort candidates by their earliest token position in the name field:
|
|
127
|
+
// the surname always starts before the given names in TD3.
|
|
128
|
+
// 3. Override lastName / firstName with the clean biographical-zone values.
|
|
129
|
+
// Words that commonly appear in Vision results as standalone lines but are
|
|
130
|
+
// NOT personal name components.
|
|
131
|
+
const VIZ_SKIP_TOKENS = new Set([
|
|
132
|
+
"PASSPORT", "PASAPORTE", "TRAVEL", "DOCUMENT", "EMERGENCY", "CANADA", "MEXICO",
|
|
133
|
+
"MEXICANA", "MEXICANO", "CANADIAN", "CANADIENNE", "NATIONALITY", "NATIONAL",
|
|
134
|
+
"RESIDENCE", "RESIDENT", "PERMANENT", "SURNAME", "GIVEN", "NAMES", "DATE", "BIRTH",
|
|
135
|
+
"EXPIRY", "ISSUED", "AUTHORITY", "SIGNATURE", "SEX", "SEXE", "TYPE", "ISSUING",
|
|
136
|
+
"FEDERAL", "REPUBLIC", "OFFICIAL", "HOLDER", "OTTAWA", "GATINEAU", "GOVERNMENT",
|
|
137
|
+
"UNITED", "STATES", "KINGDOM", "COMMONWEALTH", "FOREIGN", "AFFAIRS", "INTERIOR",
|
|
138
|
+
]);
|
|
139
|
+
function isVizNameCandidate(line) {
|
|
140
|
+
if (/\d/.test(line))
|
|
141
|
+
return false; // rejects dates, IDs, etc.
|
|
142
|
+
const up = line.replace(/\s/g, "").toUpperCase();
|
|
143
|
+
if (!/^[A-Z]+$/.test(up))
|
|
144
|
+
return false; // must be pure ASCII uppercase after removing spaces
|
|
145
|
+
const words = line.trim().toUpperCase().split(/\s+/).filter(w => w.length >= 2);
|
|
146
|
+
if (words.length === 0 || words.length > 4)
|
|
147
|
+
return false;
|
|
148
|
+
return !words.some(w => VIZ_SKIP_TOKENS.has(w));
|
|
149
|
+
}
|
|
150
|
+
function vizNameTokens(s) {
|
|
151
|
+
return (s.toUpperCase().match(/[A-Z]{3,}/g) ?? []);
|
|
152
|
+
}
|
|
153
|
+
function refineNamesFromViz(result, flat, mrzIdx) {
|
|
154
|
+
if (!result.isMrz)
|
|
155
|
+
return;
|
|
156
|
+
// The raw 39-char name field from line 1 may contain OCR-corrupted separators.
|
|
157
|
+
const rawName = result.raw.line1.slice(5, 44).toUpperCase();
|
|
158
|
+
const nonMrz = flat.filter((_, i) => i !== mrzIdx && i !== mrzIdx + 1);
|
|
159
|
+
const candidates = [];
|
|
160
|
+
for (const line of nonMrz) {
|
|
161
|
+
if (!isVizNameCandidate(line))
|
|
162
|
+
continue;
|
|
163
|
+
const clean = line.trim().toUpperCase().replace(/\s+/g, " ");
|
|
164
|
+
const tokens = vizNameTokens(clean).filter(t => t.length >= 3);
|
|
165
|
+
if (tokens.length === 0)
|
|
166
|
+
continue;
|
|
167
|
+
// Every token of this Vision line must appear somewhere in the raw MRZ name field.
|
|
168
|
+
const hits = tokens.filter(t => rawName.includes(t));
|
|
169
|
+
if (hits.length < tokens.length)
|
|
170
|
+
continue;
|
|
171
|
+
const firstPos = Math.min(...tokens.map(t => rawName.indexOf(t)).filter(p => p >= 0));
|
|
172
|
+
if (!candidates.some(c => c.clean === clean)) {
|
|
173
|
+
candidates.push({ clean, firstPos });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (candidates.length === 0)
|
|
177
|
+
return;
|
|
178
|
+
// Surname always starts at or near position 0 in the name field;
|
|
179
|
+
// given names follow the "<<" separator (position ≥ ~10).
|
|
180
|
+
candidates.sort((a, b) => a.firstPos - b.firstPos);
|
|
181
|
+
const first = candidates[0];
|
|
182
|
+
const second = candidates[1];
|
|
183
|
+
if (first.firstPos <= 5) {
|
|
184
|
+
result.lastName = first.clean;
|
|
185
|
+
if (second)
|
|
186
|
+
result.firstName = second.clean;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
// Only given names were recoverable from VIZ (surname was too corrupted).
|
|
190
|
+
result.firstName = first.clean;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Normalise an OCR line to exactly 44 chars for TD3:
|
|
194
|
+
// - Strip whitespace (Vision / ML Kit insert spaces in OCR-B)
|
|
195
|
+
// - Uppercase
|
|
196
|
+
// - If 43 chars: pad with "<" (OCR commonly drops a trailing filler char)
|
|
197
|
+
// - If 45 chars: trim to 44 (OCR occasionally adds one)
|
|
198
|
+
// - Otherwise return null (too far off to be a TD3 line)
|
|
199
|
+
function toTd3Line(raw) {
|
|
200
|
+
const s = raw.replace(/\s/g, "").toUpperCase();
|
|
201
|
+
if (s.length === 44)
|
|
202
|
+
return s;
|
|
203
|
+
if (s.length === 43)
|
|
204
|
+
return s + "<";
|
|
205
|
+
if (s.length === 45)
|
|
206
|
+
return s.slice(0, 44);
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Find and parse a TD3 MRZ from an array of OCR-recognised text lines.
|
|
211
|
+
* Handles passports (P<), emergency travel documents (PU), permanent resident
|
|
212
|
+
* travel documents (PR), and any other ICAO TD3 document type starting with P.
|
|
213
|
+
*
|
|
214
|
+
* Lines are normalised (spaces stripped, uppercased) before matching; lines
|
|
215
|
+
* within ±1 char of the required 44 are accepted with padding/trimming.
|
|
216
|
+
* Each element may also contain newline-separated sub-lines (some OCR engines
|
|
217
|
+
* return both MRZ rows as one observation).
|
|
218
|
+
*/
|
|
219
|
+
export function parseMrz(lines) {
|
|
220
|
+
const empty = {
|
|
221
|
+
isMrz: false, confidence: 0, checksumFailures: [], raw: { line1: "", line2: "" },
|
|
222
|
+
};
|
|
223
|
+
// Flatten: split on newlines in case the OCR engine joined both MRZ rows.
|
|
224
|
+
const flat = lines.flatMap(l => l.split(/\n/));
|
|
225
|
+
console.log("[parseMrz] input lines:", flat.length, flat.map(l => `"${l.replace(/\s/g, "").slice(0, 10)}…(${l.replace(/\s/g, "").length})"`));
|
|
226
|
+
for (let i = 0; i < flat.length - 1; i++) {
|
|
227
|
+
const l1 = toTd3Line(flat[i]);
|
|
228
|
+
const l2 = toTd3Line(flat[i + 1]);
|
|
229
|
+
if (!l1 || !l2)
|
|
230
|
+
continue;
|
|
231
|
+
if (/^P[A-Z<]/.test(l1)) {
|
|
232
|
+
console.log("[parseMrz] TD3 pair found at index", i, "→ parsing");
|
|
233
|
+
const result = parseTd3(l1, l2);
|
|
234
|
+
refineNamesFromViz(result, flat, i);
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
console.log("[parseMrz] no TD3 pair found in", flat.length, "lines");
|
|
239
|
+
return empty;
|
|
240
|
+
}
|