@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 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
@@ -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 decode -> parse -> canonical -> emit analytics
3
+ // verify license (gate) -> native capture -> parse -> canonical -> emit analytics
4
4
  //
5
- // The native PDF417 decode is supplied by the plugin's Swift/Kotlin bridge via
6
- // the BarcodeNative interface, so this orchestration is platform-agnostic.
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 { toCanonical } from "./canonical";
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
- if (!this.claims) {
37
- const result = await this.init();
38
- if (!result.valid)
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 aamva = parseAamva(raw);
53
- if (!aamva.isAamva) {
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 = toCanonical(aamva);
100
+ const document = mrzToCanonical(mrz);
59
101
  // (5) ANALYTICS (PII-FREE) --------------------------------------------
60
- await this.emit(document.documentType, document.jurisdiction, true, "high");
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
- async emit(documentType, jurisdiction, success, confidenceBucket) {
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: "barcode",
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parselo/scanner-core",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/parselo-io/parselo-sdk.git",