@parselo/scanner-core 0.0.1 → 0.1.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/dist/canonical.d.ts +2 -0
- package/dist/canonical.js +26 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +24 -6
- package/dist/magstripe.d.ts +21 -0
- package/dist/magstripe.js +133 -0
- package/package.json +1 -1
package/dist/canonical.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AamvaResult } from "./aamva";
|
|
2
|
+
import type { MagstripeResult } from "./magstripe";
|
|
2
3
|
export type FieldSource = "barcode" | "ocr" | "mrz";
|
|
3
4
|
export interface CanonicalField {
|
|
4
5
|
value: string;
|
|
@@ -27,4 +28,5 @@ export interface CanonicalDocument {
|
|
|
27
28
|
};
|
|
28
29
|
raw: Record<string, string>;
|
|
29
30
|
}
|
|
31
|
+
export declare function magstripeToCanonical(m: MagstripeResult, confidence?: number): CanonicalDocument;
|
|
30
32
|
export declare function toCanonical(a: AamvaResult, confidence?: number): CanonicalDocument;
|
package/dist/canonical.js
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
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
|
+
export function magstripeToCanonical(m, confidence = 1.0) {
|
|
6
|
+
const field = (value) => value !== undefined ? { value, confidence, source: "barcode" } : undefined;
|
|
7
|
+
const province = m.jurisdiction ?? m.addressRegion ?? "";
|
|
8
|
+
const jurisdiction = province ? `CA-${province}` : "CA";
|
|
9
|
+
return {
|
|
10
|
+
documentType: "drivers_license",
|
|
11
|
+
jurisdiction,
|
|
12
|
+
fields: {
|
|
13
|
+
firstName: field(m.firstName),
|
|
14
|
+
lastName: field(m.lastName),
|
|
15
|
+
dateOfBirth: field(m.dateOfBirth),
|
|
16
|
+
expiryDate: field(m.expiryDate),
|
|
17
|
+
documentNumber: field(m.documentNumber),
|
|
18
|
+
sex: field(m.sex),
|
|
19
|
+
addressStreet: field(m.addressStreet),
|
|
20
|
+
addressCity: field(m.addressCity),
|
|
21
|
+
addressRegion: field(m.addressRegion),
|
|
22
|
+
addressPostalCode: field(m.addressPostalCode),
|
|
23
|
+
},
|
|
24
|
+
raw: {
|
|
25
|
+
track1: m.raw.track1 ?? "",
|
|
26
|
+
track2: m.raw.track2 ?? "",
|
|
27
|
+
track3: m.raw.track3 ?? "",
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
5
31
|
export function toCanonical(a, confidence = 1.0) {
|
|
6
32
|
const e = a.elements;
|
|
7
33
|
const field = (code) => e[code] !== undefined ? { value: e[code], confidence, source: "barcode" } : undefined;
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ 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";
|
|
5
6
|
export * from "./canonical";
|
|
6
7
|
export * from "./license";
|
|
7
8
|
export * from "./analytics";
|
package/dist/index.js
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
// The native PDF417 decode is supplied by the plugin's Swift/Kotlin bridge via
|
|
6
6
|
// the BarcodeNative interface, so this orchestration is platform-agnostic.
|
|
7
7
|
import { parseAamva } from "./aamva";
|
|
8
|
-
import {
|
|
8
|
+
import { parseMagstripe } from "./magstripe";
|
|
9
|
+
import { toCanonical, magstripeToCanonical } from "./canonical";
|
|
9
10
|
import { verifyLicense, isExpired } from "./license";
|
|
10
11
|
import { ScanAnalytics } from "./analytics";
|
|
11
12
|
export * from "./aamva";
|
|
13
|
+
export * from "./magstripe";
|
|
12
14
|
export * from "./canonical";
|
|
13
15
|
export * from "./license";
|
|
14
16
|
export * from "./analytics";
|
|
@@ -48,14 +50,30 @@ export class Scanner {
|
|
|
48
50
|
await this.emit("unknown", "", false, "low");
|
|
49
51
|
return { ok: false, error: "no_barcode" };
|
|
50
52
|
}
|
|
51
|
-
// (3) PARSE
|
|
52
|
-
const
|
|
53
|
-
|
|
53
|
+
// (3) PARSE — route to the correct parser based on format signature ------
|
|
54
|
+
const trimmedRaw = raw.replace(/^[\x00-\x1f\s]+/, "");
|
|
55
|
+
let document;
|
|
56
|
+
if (trimmedRaw.startsWith("@") || raw.includes("ANSI ")) {
|
|
57
|
+
const aamva = parseAamva(raw);
|
|
58
|
+
if (!aamva.isAamva) {
|
|
59
|
+
await this.emit("unknown", "", false, "low");
|
|
60
|
+
return { ok: false, error: "not_aamva" };
|
|
61
|
+
}
|
|
62
|
+
document = toCanonical(aamva);
|
|
63
|
+
}
|
|
64
|
+
else if (trimmedRaw.startsWith("%")) {
|
|
65
|
+
const ms = parseMagstripe(raw);
|
|
66
|
+
if (!ms.isMagstripe) {
|
|
67
|
+
await this.emit("unknown", "", false, "low");
|
|
68
|
+
return { ok: false, error: "not_aamva" };
|
|
69
|
+
}
|
|
70
|
+
document = magstripeToCanonical(ms);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
54
73
|
await this.emit("unknown", "", false, "low");
|
|
55
74
|
return { ok: false, error: "not_aamva" };
|
|
56
75
|
}
|
|
57
|
-
// (4) CANONICAL
|
|
58
|
-
const document = toCanonical(aamva);
|
|
76
|
+
// (4) CANONICAL already set above --------------------------------------
|
|
59
77
|
// (5) ANALYTICS (PII-FREE) --------------------------------------------
|
|
60
78
|
await this.emit(document.documentType, document.jurisdiction, true, "high");
|
|
61
79
|
// (6) RETURN -----------------------------------------------------------
|
|
@@ -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
|
+
}
|