@parselo/scanner-core 0.1.0 → 0.3.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,261 @@
1
+ # @parselo/scanner-core
2
+
3
+ Platform-agnostic core for the Parselo ID scanning SDK. Parses AAMVA PDF417
4
+ barcodes, magnetic-stripe tracks, ICAO 9303 MRZ from passports and travel
5
+ documents, and Mexican INE (Credencial para Votar) credentials into a unified
6
+ canonical document shape. Includes offline ES256 license enforcement and
7
+ PII-free usage analytics.
8
+
9
+ No Capacitor dependency — fully testable in Node.
10
+
11
+ ## What it parses
12
+
13
+ | Document type | Format | Notes |
14
+ |---|---|---|
15
+ | Canadian / US driver's licences & ID cards | PDF417 (AAMVA) | All provinces; dates normalised to ISO |
16
+ | BC / ON / AB licences (older stock) | Magnetic-stripe tracks encoded in PDF417 | Three-track `%…?` format |
17
+ | Passports, emergency travel docs, PR cards | ICAO 9303 TD3 MRZ (2 × 44 chars) | Five check digits validated; VIZ cross-reference corrects `<` OCR corruption |
18
+ | Mexican INE (Credencial para Votar), models D–J | ICAO 9303 TD1 MRZ (3 × 30 chars) | Extraction + offline integrity only — see "INE offline integrity" below. Validated on 2 real cards on-device; models D onward carry the MRZ, models A–C predate it and are detected and rejected cleanly (`no_ine`) rather than crashing. PDF417 (proprietary/undocumented) is stubbed pending real sample payloads. |
19
+
20
+ All document types produce the same `CanonicalDocument` shape with identical field names and ISO `YYYY-MM-DD` dates.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install @parselo/scanner-core
26
+ ```
27
+
28
+ A native capture plugin is required for on-device use:
29
+
30
+ | Use case | Plugin |
31
+ |---|---|
32
+ | Driver's licence / barcode | [`@parselo/capacitor-pdf417`](https://www.npmjs.com/package/@parselo/capacitor-pdf417) |
33
+ | Passport / travel document | [`@parselo/capacitor-mrz`](https://www.npmjs.com/package/@parselo/capacitor-mrz) |
34
+
35
+ ## Scanning a driver's licence
36
+
37
+ ```ts
38
+ import { Scanner, type BarcodeNative } from "@parselo/scanner-core";
39
+ import { Pdf417 } from "@parselo/capacitor-pdf417";
40
+
41
+ const native: BarcodeNative = {
42
+ captureAndDecodePdf417: async () => {
43
+ const { raw } = await Pdf417.decodePdf417({ image: dataUrl });
44
+ return raw; // null if no barcode found
45
+ },
46
+ };
47
+
48
+ const scanner = new Scanner({ license, analytics, native });
49
+ await scanner.init();
50
+
51
+ const result = await scanner.scan();
52
+ if (result.ok && result.document) {
53
+ const { fields, jurisdiction } = result.document;
54
+ console.log(fields.firstName?.value, fields.lastName?.value);
55
+ console.log(fields.dateOfBirth?.value); // "1985-03-12"
56
+ console.log(jurisdiction); // "CA-QC"
57
+ }
58
+ ```
59
+
60
+ ## Scanning a passport or travel document
61
+
62
+ ```ts
63
+ import { Scanner, type MrzNative } from "@parselo/scanner-core";
64
+ import { Mrz } from "@parselo/capacitor-mrz";
65
+
66
+ const mrzNative: MrzNative = {
67
+ captureAndRecognizeMrz: async () => {
68
+ const { lines } = await Mrz.recognizeText({ image: dataUrl });
69
+ return lines; // string[] of OCR observations
70
+ },
71
+ };
72
+
73
+ const scanner = new Scanner({ license, analytics, native, mrzNative });
74
+ await scanner.init();
75
+
76
+ const result = await scanner.scanPassport();
77
+ if (result.ok && result.document) {
78
+ const { fields, jurisdiction } = result.document;
79
+ console.log(fields.firstName?.value, fields.lastName?.value);
80
+ console.log(fields.dateOfBirth?.value); // "1978-11-28"
81
+ console.log(fields.country?.value); // "MEX" (ICAO 3-char)
82
+ console.log(jurisdiction); // "CA" for Canadian-issued docs
83
+ }
84
+ ```
85
+
86
+ Handles standard passports (`P<`), emergency travel documents (`PU`), permanent
87
+ resident travel documents (`PR`), and foreign passports. The parser cross-references
88
+ Vision's biographical-zone OCR lines to recover names even when the OCR-B `<` fill
89
+ character is misread.
90
+
91
+ ## Scanning a Mexican INE (Credencial para Votar)
92
+
93
+ ```ts
94
+ import { Scanner, type MrzNative } from "@parselo/scanner-core";
95
+ import { Mrz } from "@parselo/capacitor-mrz";
96
+
97
+ const mrzNative: MrzNative = {
98
+ captureAndRecognizeMrz: async () => {
99
+ const { lines } = await Mrz.recognizeText({ image: dataUrl });
100
+ return lines;
101
+ },
102
+ };
103
+
104
+ const scanner = new Scanner({ license, analytics, native, mrzNative });
105
+ await scanner.init();
106
+
107
+ const result = await scanner.scanIne();
108
+ if (result.ok && result.document) {
109
+ const { fields } = result.document;
110
+ console.log(fields.firstName?.value, fields.lastName?.value); // given names, paternal surname
111
+ console.log(fields.dateOfBirth?.value);
112
+ }
113
+ ```
114
+
115
+ Reuses the same `mrzNative` capture as `scanPassport()` — INE cards from model D
116
+ onward carry a 3-line, 30-char ICAO 9303 TD1 MRZ on the back, located and
117
+ validated the same way the TD3 passport MRZ is. `parseIneMrz()` implements the
118
+ generic TD1 structure with no per-model branching, so it should apply uniformly
119
+ across models D–J; validated so far against 2 real cards on-device (see the root
120
+ README's Development notes for what that validation caught — a real MRZ sex-field
121
+ convention neither the original implementation nor any public spec we had access
122
+ to got right on the first try). Models A–C predate the MRZ and have no
123
+ on-device-extractable structured data via this path; those, and any non-INE MRZ
124
+ input, are rejected cleanly (`error: "no_ine"`) rather than producing garbage output.
125
+
126
+ **INE offline integrity — read this before using these fields in a product decision.**
127
+ `buildIneCredential()` (re-exported from this package) produces an `integrity` block:
128
+
129
+ ```ts
130
+ interface IneIntegrity {
131
+ mrzCheckDigitsValid: boolean;
132
+ curpCheckDigitValid: boolean;
133
+ claveElectorStructureValid: boolean;
134
+ crossFieldConsistent: boolean; // DOB/sex agree across every source present
135
+ overall: "consistent" | "inconsistent" | "insufficient_data";
136
+ }
137
+ ```
138
+
139
+ This proves internal consistency only — check digits, structural shape, and
140
+ agreement between the MRZ, CURP, and Clave de Elector when more than one is
141
+ present. **It does not prove the card is genuine, current, or registered with
142
+ INE.** Only INE's own (off-device, consent-gated) Lista Nominal verification
143
+ service can do that, and this SDK deliberately does not call it — see the root
144
+ README for why. Never surface `overall === "consistent"` to an end user as
145
+ "verified" or "valid credential".
146
+
147
+ The MRZ alone does not carry a CURP or Clave de Elector (INE's PDF417 might, but
148
+ its encoding is proprietary/undocumented and is currently stubbed — see `ine.ts`).
149
+ Until that's implemented from real sample payloads, `curp` and `claveElector` are
150
+ only populated if you pass them into `buildIneCredential()` yourself from another
151
+ source, and `integrity.overall` correctly reports `"insufficient_data"` when no
152
+ identifiers besides the MRZ were available to cross-check.
153
+
154
+ ## Canonical document shape
155
+
156
+ ```ts
157
+ interface CanonicalDocument {
158
+ documentType: "drivers_license" | "id_card" | "passport" | "ine" | "unknown";
159
+ jurisdiction: string; // "CA-QC", "CA-BC", "CA", "MEX", "MX", …
160
+ fields: {
161
+ // All document types
162
+ firstName?: Field;
163
+ lastName?: Field;
164
+ middleName?: Field;
165
+ dateOfBirth?: Field; // ISO YYYY-MM-DD
166
+ expiryDate?: Field; // ISO YYYY-MM-DD
167
+ documentNumber?: Field;
168
+ sex?: Field;
169
+ // Driver's licences
170
+ addressStreet?: Field;
171
+ addressCity?: Field;
172
+ addressRegion?: Field;
173
+ addressPostalCode?: Field;
174
+ vehicleClass?: Field;
175
+ // Passports / travel documents / INE
176
+ country?: Field; // ICAO 3-char issuing country, "MEX" for INE
177
+ };
178
+ raw?: Record<string, string>; // INE also carries raw.curp / raw.claveElector when present
179
+ }
180
+ ```
181
+
182
+ Mexican naming has two surnames with no dedicated canonical slot: INE maps
183
+ `lastName` to the paternal surname and `middleName` to the maternal surname
184
+ (same "reuse the generic shape" approach every document type here takes).
185
+
186
+ ```ts
187
+ interface Field {
188
+ value: string;
189
+ confidence: number; // 0–1
190
+ source: "barcode" | "mrz";
191
+ }
192
+ ```
193
+
194
+ ## Scan result
195
+
196
+ ```ts
197
+ interface ScanResult {
198
+ ok: boolean;
199
+ document?: CanonicalDocument;
200
+ error?: ScanError;
201
+ }
202
+
203
+ type ScanError =
204
+ | "no_barcode" | "not_aamva" // barcode path
205
+ | "no_mrz" // passport path
206
+ | "no_ine" // INE path
207
+ | "license_expired" | "license_bundle_mismatch"
208
+ | "license_bad_signature" | "license_unknown_key" | "license_malformed";
209
+ ```
210
+
211
+ ## License enforcement
212
+
213
+ Tokens are ES256 JWTs signed by AWS KMS. `scanner.init()` verifies the
214
+ signature offline against the embedded public key — no network call required.
215
+ Each token encodes an expiry date, an allowed bundle ID list, and a scan quota.
216
+
217
+ ```ts
218
+ const { valid, reason, claims } = await scanner.init();
219
+ if (!valid) {
220
+ // reason: "expired" | "bundle_mismatch" | "bad_signature"
221
+ // | "unknown_key" | "malformed"
222
+ }
223
+ ```
224
+
225
+ ## Analytics
226
+
227
+ One PII-free event per scan (document type, jurisdiction, success/fail,
228
+ confidence bucket, device model — never document field values). Events buffer
229
+ locally and flush in batches. Force a flush with:
230
+
231
+ ```ts
232
+ await scanner.flushAnalytics();
233
+ ```
234
+
235
+ ## Design notes
236
+
237
+ - **Every parser file (`aamva.ts`, `magstripe.ts`, `mrz.ts`, `ine.ts`) is fully
238
+ self-contained** — none of them import from each other at the value level,
239
+ even where the logic genuinely overlaps (`ine.ts` has its own internal copy
240
+ of the ICAO 7-3-1 check-digit algorithm and 2-digit-year resolution that
241
+ `mrz.ts` also implements). This is deliberate, not an oversight: `src/test.ts`
242
+ runs every parser directly through Node's ESM loader
243
+ (`node --experimental-strip-types`), which requires exact-extension relative
244
+ specifiers (`./mrz.ts`); the real `tsc` build targets bundler-style module
245
+ resolution and expects extension-less specifiers (`./mrz`) for downstream
246
+ Vite/Capacitor consumers. A single cross-file import statement can't satisfy
247
+ both resolution modes, so `ine.ts` duplicates the ~20 lines of shared
248
+ algorithm rather than importing them — consistent with how every other
249
+ parser file in this package was already structured before INE existed.
250
+ - **`ine.ts` implements the generic ICAO 9303 TD1 structure, not a per-model
251
+ INE format.** There's no model-letter detection or branching anywhere in it.
252
+ INE cards from model D onward all carry a standard TD1 MRZ; models A–C
253
+ predate it and simply have nothing for this path to extract. This has been
254
+ validated on 2 real cards (see root README's Development notes for a bug
255
+ that validation caught: INE's MRZ sex field is the literal Spanish `H`/`M`
256
+ character, not an ICAO M/F code needing translation — an assumption that
257
+ looked reasonable by analogy to the TD3 passport parser and was wrong).
258
+
259
+ ## License
260
+
261
+ MIT
@@ -1,12 +1,14 @@
1
1
  import type { AamvaResult } from "./aamva";
2
2
  import type { MagstripeResult } from "./magstripe";
3
+ import type { MrzResult } from "./mrz";
4
+ import type { IneCredential } from "./ine";
3
5
  export type FieldSource = "barcode" | "ocr" | "mrz";
4
6
  export interface CanonicalField {
5
7
  value: string;
6
8
  confidence: number;
7
9
  source: FieldSource;
8
10
  }
9
- export type DocumentType = "drivers_license" | "id_card" | "passport" | "unknown";
11
+ export type DocumentType = "drivers_license" | "id_card" | "passport" | "ine" | "unknown";
10
12
  export interface CanonicalDocument {
11
13
  documentType: DocumentType;
12
14
  jurisdiction: string;
@@ -28,5 +30,7 @@ export interface CanonicalDocument {
28
30
  };
29
31
  raw: Record<string, string>;
30
32
  }
33
+ export declare function mrzToCanonical(m: MrzResult): CanonicalDocument;
31
34
  export declare function magstripeToCanonical(m: MagstripeResult, confidence?: number): CanonicalDocument;
32
35
  export declare function toCanonical(a: AamvaResult, confidence?: number): CanonicalDocument;
36
+ export declare function ineToCanonical(i: IneCredential): CanonicalDocument;
package/dist/canonical.js CHANGED
@@ -2,6 +2,38 @@
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
+ }
5
37
  export function magstripeToCanonical(m, confidence = 1.0) {
6
38
  const field = (value) => value !== undefined ? { value, confidence, source: "barcode" } : undefined;
7
39
  const province = m.jurisdiction ?? m.addressRegion ?? "";
@@ -62,3 +94,48 @@ export function toCanonical(a, confidence = 1.0) {
62
94
  raw: e,
63
95
  };
64
96
  }
97
+ // Mexican naming has two surnames; there is no dedicated canonical slot for
98
+ // that, so paternal maps to lastName and maternal to middleName (same
99
+ // reuse-the-generic-shape approach as every other document type here).
100
+ // documentNumber prefers the CIC (from MRZ) since OCR/emissionNumber are not
101
+ // populated by the MRZ-only path this SDK currently implements.
102
+ export function ineToCanonical(i) {
103
+ const source = i.source === "MRZ" ? "mrz" : "barcode";
104
+ const confidence = i.source === "MRZ" ? 1.0 : 0.5; // PDF417 is best-effort/unimplemented today
105
+ const field = (value) => value !== undefined ? { value, confidence, source } : undefined;
106
+ // integrity.* is stuffed into raw (stringified) rather than added as a typed
107
+ // CanonicalDocument field, since raw is already documented as "every parsed
108
+ // element, for debugging/QA" and every other document type keeps
109
+ // format-specific data out of the shared `fields` shape. Never rename these
110
+ // keys to imply "verified"/"valid" — see ine.ts's integrity boundary comment.
111
+ const raw = {
112
+ integrityOverall: i.integrity.overall,
113
+ mrzCheckDigitsValid: String(i.integrity.mrzCheckDigitsValid),
114
+ curpCheckDigitValid: String(i.integrity.curpCheckDigitValid),
115
+ claveElectorStructureValid: String(i.integrity.claveElectorStructureValid),
116
+ crossFieldConsistent: String(i.integrity.crossFieldConsistent),
117
+ };
118
+ if (i.mrzLines) {
119
+ raw.mz1 = i.mrzLines.mz1;
120
+ raw.mz2 = i.mrzLines.mz2;
121
+ raw.mz3 = i.mrzLines.mz3;
122
+ }
123
+ if (i.curp)
124
+ raw.curp = i.curp.value;
125
+ if (i.claveElector)
126
+ raw.claveElector = i.claveElector.value;
127
+ return {
128
+ documentType: "ine",
129
+ jurisdiction: "MX",
130
+ fields: {
131
+ firstName: field(i.givenNames),
132
+ lastName: field(i.surnamePaternal),
133
+ middleName: field(i.surnameMaternal),
134
+ dateOfBirth: field(i.dateOfBirth),
135
+ sex: field(i.sex),
136
+ documentNumber: field(i.cic ?? i.ocr),
137
+ country: field("MEX"),
138
+ },
139
+ raw,
140
+ };
141
+ }
package/dist/index.d.ts CHANGED
@@ -3,12 +3,17 @@ import { type LicenseResult } from "./license";
3
3
  import { type AnalyticsConfig } from "./analytics";
4
4
  export * from "./aamva";
5
5
  export * from "./magstripe";
6
+ export * from "./mrz";
7
+ export * from "./ine";
6
8
  export * from "./canonical";
7
9
  export * from "./license";
8
10
  export * from "./analytics";
9
11
  export interface BarcodeNative {
10
12
  captureAndDecodePdf417(): Promise<string | null>;
11
13
  }
14
+ export interface MrzNative {
15
+ captureAndRecognizeMrz(): Promise<string[] | null>;
16
+ }
12
17
  export interface ScannerConfig {
13
18
  license: {
14
19
  token: string;
@@ -17,9 +22,10 @@ export interface ScannerConfig {
17
22
  };
18
23
  analytics: AnalyticsConfig;
19
24
  native: BarcodeNative;
25
+ mrzNative?: MrzNative;
20
26
  deviceModel: string;
21
27
  }
22
- export type ScanError = "license_malformed" | "license_unknown_key" | "license_bad_signature" | "license_expired" | "license_bundle_mismatch" | "no_barcode" | "not_aamva";
28
+ export type ScanError = "license_malformed" | "license_unknown_key" | "license_bad_signature" | "license_expired" | "license_bundle_mismatch" | "no_barcode" | "not_aamva" | "no_mrz" | "no_ine";
23
29
  export interface ScanResult {
24
30
  ok: boolean;
25
31
  document?: CanonicalDocument;
@@ -32,8 +38,24 @@ export declare class Scanner {
32
38
  constructor(cfg: ScannerConfig);
33
39
  /** Verify the signature once, up front. Call on plugin init. */
34
40
  init(): Promise<LicenseResult>;
41
+ /** Scan a driver's licence or provincial ID card via PDF417 barcode. */
35
42
  scan(): Promise<ScanResult>;
43
+ /** Scan a passport via on-device MRZ text recognition. */
44
+ scanPassport(): Promise<ScanResult>;
45
+ /**
46
+ * Scan a Mexican INE (Credencial para Votar) via on-device MRZ text
47
+ * recognition. Reuses the same mrzNative capture used by scanPassport() —
48
+ * no new native path required, since native code only does OCR and all
49
+ * parsing stays in this package.
50
+ *
51
+ * This produces extraction + offline integrity signals only. It does NOT
52
+ * verify the credential against INE's Lista Nominal registry — that is an
53
+ * off-device, consent-gated service and is explicitly out of scope (see
54
+ * README). Do not present `document`'s presence as proof of authenticity.
55
+ */
56
+ scanIne(): Promise<ScanResult>;
36
57
  /** Flush buffered analytics (e.g. on app foreground/background). */
37
58
  flushAnalytics(): Promise<void>;
59
+ private checkLicense;
38
60
  private emit;
39
61
  }
package/dist/index.js CHANGED
@@ -1,16 +1,21 @@
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
9
  import { parseMagstripe } from "./magstripe";
9
- import { toCanonical, magstripeToCanonical } from "./canonical";
10
+ import { parseMrz } from "./mrz";
11
+ import { parseIneMrz, buildIneCredential } from "./ine";
12
+ import { toCanonical, magstripeToCanonical, mrzToCanonical, ineToCanonical } from "./canonical";
10
13
  import { verifyLicense, isExpired } from "./license";
11
14
  import { ScanAnalytics } from "./analytics";
12
15
  export * from "./aamva";
13
16
  export * from "./magstripe";
17
+ export * from "./mrz";
18
+ export * from "./ine";
14
19
  export * from "./canonical";
15
20
  export * from "./license";
16
21
  export * from "./analytics";
@@ -33,17 +38,12 @@ export class Scanner {
33
38
  this.claims = result.claims;
34
39
  return result;
35
40
  }
41
+ /** Scan a driver's licence or provincial ID card via PDF417 barcode. */
36
42
  async scan() {
37
43
  // (1) LICENSE GATE -----------------------------------------------------
38
- if (!this.claims) {
39
- const result = await this.init();
40
- if (!result.valid)
41
- return { ok: false, error: `license_${result.reason}` };
42
- }
43
- // cheap per-scan expiry re-check (no crypto) so a long session can't outlive the token
44
- if (!this.claims || isExpired(this.claims)) {
45
- return { ok: false, error: "license_expired" };
46
- }
44
+ const licenseError = await this.checkLicense();
45
+ if (licenseError)
46
+ return { ok: false, error: licenseError };
47
47
  // (2) NATIVE DECODE ----------------------------------------------------
48
48
  const raw = await this.cfg.native.captureAndDecodePdf417();
49
49
  if (!raw) {
@@ -73,9 +73,75 @@ export class Scanner {
73
73
  await this.emit("unknown", "", false, "low");
74
74
  return { ok: false, error: "not_aamva" };
75
75
  }
76
- // (4) CANONICAL already set above --------------------------------------
77
- // (5) ANALYTICS (PII-FREE) --------------------------------------------
76
+ // (4) ANALYTICS (PII-FREE) --------------------------------------------
78
77
  await this.emit(document.documentType, document.jurisdiction, true, "high");
78
+ // (5) RETURN -----------------------------------------------------------
79
+ return { ok: true, document };
80
+ }
81
+ /** Scan a passport via on-device MRZ text recognition. */
82
+ async scanPassport() {
83
+ // (1) LICENSE GATE -----------------------------------------------------
84
+ const licenseError = await this.checkLicense();
85
+ if (licenseError)
86
+ return { ok: false, error: licenseError };
87
+ // (2) NATIVE TEXT RECOGNITION ------------------------------------------
88
+ if (!this.cfg.mrzNative)
89
+ return { ok: false, error: "no_mrz" };
90
+ const lines = await this.cfg.mrzNative.captureAndRecognizeMrz();
91
+ if (!lines || lines.length === 0) {
92
+ await this.emit("unknown", "", false, "low", "mrz");
93
+ return { ok: false, error: "no_mrz" };
94
+ }
95
+ // (3) PARSE ------------------------------------------------------------
96
+ const mrz = parseMrz(lines);
97
+ if (!mrz.isMrz || mrz.confidence === 0) {
98
+ await this.emit("unknown", "", false, "low", "mrz");
99
+ return { ok: false, error: "no_mrz" };
100
+ }
101
+ // (4) CANONICAL --------------------------------------------------------
102
+ const document = mrzToCanonical(mrz);
103
+ // (5) ANALYTICS (PII-FREE) --------------------------------------------
104
+ const bucket = mrz.confidence >= 0.8 ? "high" : mrz.confidence >= 0.6 ? "medium" : "low";
105
+ await this.emit(document.documentType, document.jurisdiction, true, bucket, "mrz");
106
+ // (6) RETURN -----------------------------------------------------------
107
+ return { ok: true, document };
108
+ }
109
+ /**
110
+ * Scan a Mexican INE (Credencial para Votar) via on-device MRZ text
111
+ * recognition. Reuses the same mrzNative capture used by scanPassport() —
112
+ * no new native path required, since native code only does OCR and all
113
+ * parsing stays in this package.
114
+ *
115
+ * This produces extraction + offline integrity signals only. It does NOT
116
+ * verify the credential against INE's Lista Nominal registry — that is an
117
+ * off-device, consent-gated service and is explicitly out of scope (see
118
+ * README). Do not present `document`'s presence as proof of authenticity.
119
+ */
120
+ async scanIne() {
121
+ // (1) LICENSE GATE -----------------------------------------------------
122
+ const licenseError = await this.checkLicense();
123
+ if (licenseError)
124
+ return { ok: false, error: licenseError };
125
+ // (2) NATIVE TEXT RECOGNITION ------------------------------------------
126
+ if (!this.cfg.mrzNative)
127
+ return { ok: false, error: "no_ine" };
128
+ const lines = await this.cfg.mrzNative.captureAndRecognizeMrz();
129
+ if (!lines || lines.length === 0) {
130
+ await this.emit("unknown", "", false, "low", "mrz");
131
+ return { ok: false, error: "no_ine" };
132
+ }
133
+ // (3) PARSE — TD1 MRZ only today; PDF417 is stubbed (see ine.ts) -------
134
+ const mrz = parseIneMrz(lines);
135
+ if (!mrz.isIne || mrz.confidence === 0) {
136
+ await this.emit("unknown", "", false, "low", "mrz");
137
+ return { ok: false, error: "no_ine" };
138
+ }
139
+ // (4) CREDENTIAL + CANONICAL ---------------------------------------------
140
+ const credential = buildIneCredential({ mrz, source: "MRZ" });
141
+ const document = ineToCanonical(credential);
142
+ // (5) ANALYTICS (PII-FREE) --------------------------------------------
143
+ const bucket = mrz.confidence >= 0.8 ? "high" : mrz.confidence >= 0.6 ? "medium" : "low";
144
+ await this.emit(document.documentType, document.jurisdiction, true, bucket, "mrz");
79
145
  // (6) RETURN -----------------------------------------------------------
80
146
  return { ok: true, document };
81
147
  }
@@ -83,13 +149,25 @@ export class Scanner {
83
149
  async flushAnalytics() {
84
150
  await this.analytics.flush();
85
151
  }
86
- async emit(documentType, jurisdiction, success, confidenceBucket) {
152
+ // ─── Private helpers ──────────────────────────────────────────────────────
153
+ async checkLicense() {
154
+ if (!this.claims) {
155
+ const result = await this.init();
156
+ if (!result.valid)
157
+ return `license_${result.reason}`;
158
+ }
159
+ // Cheap per-scan expiry re-check (no crypto) so a long session can't outlive the token.
160
+ if (!this.claims || isExpired(this.claims))
161
+ return "license_expired";
162
+ return null;
163
+ }
164
+ async emit(documentType, jurisdiction, success, confidenceBucket, source = "barcode") {
87
165
  await this.analytics.record({
88
166
  jti: this.claims?.jti ?? "unknown",
89
167
  ts: new Date().toISOString(),
90
168
  documentType,
91
169
  jurisdiction,
92
- source: "barcode",
170
+ source,
93
171
  success,
94
172
  confidenceBucket,
95
173
  deviceModel: this.cfg.deviceModel,
package/dist/ine.d.ts ADDED
@@ -0,0 +1,95 @@
1
+ export interface IneMrzResult {
2
+ isIne: boolean;
3
+ documentCode?: string;
4
+ issuingCountry?: string;
5
+ documentNumber?: string;
6
+ optionalData1?: string;
7
+ dateOfBirth?: string;
8
+ sex?: string;
9
+ expiryDate?: string;
10
+ nationality?: string;
11
+ optionalData2?: string;
12
+ surnamePaternal?: string;
13
+ surnameMaternal?: string;
14
+ givenNames?: string;
15
+ confidence: number;
16
+ checksumFailures: string[];
17
+ raw: {
18
+ line1: string;
19
+ line2: string;
20
+ line3: string;
21
+ };
22
+ }
23
+ /**
24
+ * Find and parse an INE TD1 MRZ from an array of OCR-recognised text lines.
25
+ * Mirrors parseMrz()'s tolerant line-location strategy, but for the 3×30-char
26
+ * TD1 shape instead of TD3's 2×44. Requires a Mexico-issued ID-class document
27
+ * code (documentCode starting "I", issuingCountry "MEX") so this never
28
+ * collides with a Canadian/US TD1 or a TD3 passport pair.
29
+ */
30
+ export declare function parseIneMrz(lines: string[]): IneMrzResult;
31
+ /** Compute the expected 18th (check) digit for a CURP, or undefined if malformed. */
32
+ export declare function curpCheckDigit(curp: string): number | undefined;
33
+ /** Validate a CURP's check digit. Does not confirm the CURP exists in RENAPO's registry. */
34
+ export declare function isCurpCheckDigitValid(curp: string): boolean;
35
+ export declare function claveElectorStructureValid(clave: string, cross?: {
36
+ dateOfBirth?: string;
37
+ sex?: string;
38
+ }): boolean;
39
+ export interface IneBarcodeResult {
40
+ supported: false;
41
+ reason: "unsupported_format";
42
+ }
43
+ export declare function parseInePdf417(_raw: string): IneBarcodeResult;
44
+ export interface IneIntegrity {
45
+ mrzCheckDigitsValid: boolean;
46
+ curpCheckDigitValid: boolean;
47
+ claveElectorStructureValid: boolean;
48
+ crossFieldConsistent: boolean;
49
+ overall: "consistent" | "inconsistent" | "insufficient_data";
50
+ }
51
+ export interface IneCredential {
52
+ documentType: "MX_INE";
53
+ model: string;
54
+ surnamePaternal?: string;
55
+ surnameMaternal?: string;
56
+ givenNames?: string;
57
+ fullNameRaw?: string;
58
+ dateOfBirth?: string;
59
+ sex?: string;
60
+ claveElector?: {
61
+ value: string;
62
+ structureValid: boolean;
63
+ };
64
+ curp?: {
65
+ value: string;
66
+ checkDigitValid: boolean;
67
+ };
68
+ seccion?: string;
69
+ expiryYear?: string;
70
+ registrationYear?: string;
71
+ emissionNumber?: string;
72
+ ocr?: string;
73
+ cic?: string;
74
+ mrzLines?: {
75
+ mz1: string;
76
+ mz2: string;
77
+ mz3: string;
78
+ };
79
+ source: "MRZ" | "PDF417";
80
+ integrity: IneIntegrity;
81
+ }
82
+ /**
83
+ * Assemble a typed IneCredential + honest offline-integrity signal from
84
+ * whatever sources decoded successfully. `curp`/`claveElector` are optional
85
+ * because today only the MRZ path is implemented (PDF417 is stubbed above,
86
+ * pending real samples) and the MRZ does not carry either identifier — pass
87
+ * them in once a future source (PDF417 or OCR) can supply them.
88
+ */
89
+ export declare function buildIneCredential(input: {
90
+ mrz?: IneMrzResult;
91
+ curp?: string;
92
+ claveElector?: string;
93
+ model?: string;
94
+ source?: "MRZ" | "PDF417";
95
+ }): IneCredential;
package/dist/ine.js ADDED
@@ -0,0 +1,301 @@
1
+ // ine.ts
2
+ // Mexican INE ("Credencial para Votar") — MRZ (ICAO 9303 TD1) parser, CURP /
3
+ // Clave de Elector offline integrity checks, and a stubbed PDF417 path.
4
+ //
5
+ // INE cards issued 2014+ carry a 3-line, 30-char-per-line TD1 MRZ. The line
6
+ // segmentation and check-digit algorithm below are the same ICAO 7-3-1
7
+ // scheme mrz.ts uses for TD3 passports, duplicated rather than imported:
8
+ // every parser file in this package is self-contained (no cross-parser
9
+ // imports anywhere) because src/test.ts runs each file directly through
10
+ // Node's ESM loader with no bundler, which requires exact-extension
11
+ // specifiers on relative imports — the opposite of the extension-less
12
+ // specifiers the tsc "Bundler" resolution mode expects for the real build.
13
+ // Keeping parser files standalone avoids straddling both requirements.
14
+ //
15
+ // IMPORTANT — what this module does NOT do:
16
+ // It does not verify the card against INE's Lista Nominal registry (that is
17
+ // an off-device service requiring a convenio with INE, explicit cardholder
18
+ // consent, and a paid aggregator; it is out of scope — see root README).
19
+ // Everything here runs on-device, offline, and proves only internal
20
+ // consistency ("does this look like a well-formed, non-garbled, non-typo'd
21
+ // card"), never document authenticity. Do not relabel `integrity.overall` as
22
+ // "valid" or "verified" anywhere downstream — that would misrepresent what
23
+ // an offline check can prove.
24
+ // ─── ICAO 7-3-1 check-digit algorithm (see mrz.ts header comment above) ──────
25
+ const WEIGHT = [7, 3, 1];
26
+ function charValue(c) {
27
+ if (c === "<")
28
+ return 0;
29
+ const code = c.charCodeAt(0);
30
+ if (code >= 48 && code <= 57)
31
+ return code - 48; // '0'–'9'
32
+ if (code >= 65 && code <= 90)
33
+ return code - 55; // 'A'–'Z' → 10–35
34
+ return 0;
35
+ }
36
+ function checkDigit(s) {
37
+ let sum = 0;
38
+ for (let i = 0; i < s.length; i++)
39
+ sum += charValue(s[i]) * WEIGHT[i % 3];
40
+ return sum % 10;
41
+ }
42
+ function resolveYear(yy, mustBePast) {
43
+ const now = new Date().getFullYear();
44
+ const century = Math.floor(now / 100) * 100;
45
+ const candidate = century + yy;
46
+ if (mustBePast && candidate > now)
47
+ return candidate - 100;
48
+ if (!mustBePast && candidate < now - 50)
49
+ return candidate + 100;
50
+ return candidate;
51
+ }
52
+ function parseMrzDate(yymmdd, mustBePast) {
53
+ if (!/^\d{6}$/.test(yymmdd))
54
+ return undefined;
55
+ const yy = parseInt(yymmdd.slice(0, 2), 10);
56
+ const mm = yymmdd.slice(2, 4);
57
+ const dd = yymmdd.slice(4, 6);
58
+ return `${resolveYear(yy, mustBePast)}-${mm}-${dd}`;
59
+ }
60
+ // ─── TD1 line location & normalisation ───────────────────────────────────────
61
+ // Normalise an OCR line to exactly 30 chars for TD1 (mirrors toTd3Line in mrz.ts,
62
+ // tolerant of the same +/-1 char OCR drop/add errors).
63
+ function toTd1Line(raw) {
64
+ const s = raw.replace(/\s/g, "").toUpperCase();
65
+ if (s.length === 30)
66
+ return s;
67
+ if (s.length === 29)
68
+ return s + "<";
69
+ if (s.length === 31)
70
+ return s.slice(0, 30);
71
+ return null;
72
+ }
73
+ // ICAO 9303 reserves this slot for the English M/F sex code, so it would be
74
+ // reasonable to assume INE follows suit and translates to its own H (Hombre)
75
+ // / M (Mujer) convention. It does not: confirmed against a real card, INE
76
+ // writes the Spanish letter directly into this MRZ position with no ICAO
77
+ // translation — raw 'M' here already means Mujer, not "male". Pass it
78
+ // through verbatim; do not "normalise" it against the ICAO convention.
79
+ function readIneSex(c) {
80
+ return c === "H" || c === "M" ? c : undefined;
81
+ }
82
+ function parseIneName(field) {
83
+ const idx = field.indexOf("<<");
84
+ const primary = idx === -1 ? field : field.slice(0, idx);
85
+ const secondary = idx === -1 ? "" : field.slice(idx + 2);
86
+ const [paternal, maternal] = primary.split("<").map(s => s.trim()).filter(Boolean);
87
+ const givenNames = secondary.replace(/</g, " ").trim();
88
+ return {
89
+ surnamePaternal: paternal || undefined,
90
+ surnameMaternal: maternal || undefined,
91
+ givenNames: givenNames || undefined,
92
+ };
93
+ }
94
+ function parseTd1(l1, l2, l3) {
95
+ const failures = [];
96
+ const verify = (label, data, expectedChar) => {
97
+ const expected = parseInt(expectedChar, 10);
98
+ if (isNaN(expected) || checkDigit(data) !== expected)
99
+ failures.push(label);
100
+ };
101
+ // ── Line 1 (document code, issuing country, document number) ──────────
102
+ const documentCode = l1.slice(0, 2);
103
+ const issuingCountry = l1.slice(2, 5).replace(/</g, "");
104
+ const docNumField = l1.slice(5, 14);
105
+ const docNumCheck = l1[14];
106
+ const optionalData1 = l1.slice(15, 30);
107
+ // ── Line 2 (biographic data) ────────────────────────────────────────────
108
+ const dobField = l2.slice(0, 6);
109
+ const dobCheck = l2[6];
110
+ const sexChar = l2[7];
111
+ const expField = l2.slice(8, 14);
112
+ const expCheck = l2[14];
113
+ const nationality = l2.slice(15, 18).replace(/</g, "");
114
+ const optionalData2 = l2.slice(18, 29);
115
+ const composite = l2[29];
116
+ verify("documentNumber", docNumField, docNumCheck);
117
+ verify("dateOfBirth", dobField, dobCheck);
118
+ verify("expiryDate", expField, expCheck);
119
+ // ICAO 9303-5 TD1 composite: docNumber+check, optionalData1, DOB+check,
120
+ // expiry+check, optionalData2 (50 chars total), checked against line2[29].
121
+ verify("composite", docNumField + docNumCheck + optionalData1 + dobField + dobCheck + expField + expCheck + optionalData2, composite);
122
+ const totalChecks = 4;
123
+ const passed = totalChecks - failures.length;
124
+ const confidence = Math.max(0, passed / totalChecks);
125
+ const { surnamePaternal, surnameMaternal, givenNames } = parseIneName(l3);
126
+ return {
127
+ isIne: true,
128
+ documentCode,
129
+ issuingCountry: issuingCountry || undefined,
130
+ documentNumber: docNumField.replace(/</g, "") || undefined,
131
+ optionalData1: optionalData1.replace(/</g, "") || undefined,
132
+ dateOfBirth: parseMrzDate(dobField, true),
133
+ sex: readIneSex(sexChar),
134
+ expiryDate: parseMrzDate(expField, false),
135
+ nationality: nationality || undefined,
136
+ optionalData2: optionalData2.replace(/</g, "") || undefined,
137
+ surnamePaternal,
138
+ surnameMaternal,
139
+ givenNames,
140
+ confidence,
141
+ checksumFailures: failures,
142
+ raw: { line1: l1, line2: l2, line3: l3 },
143
+ };
144
+ }
145
+ /**
146
+ * Find and parse an INE TD1 MRZ from an array of OCR-recognised text lines.
147
+ * Mirrors parseMrz()'s tolerant line-location strategy, but for the 3×30-char
148
+ * TD1 shape instead of TD3's 2×44. Requires a Mexico-issued ID-class document
149
+ * code (documentCode starting "I", issuingCountry "MEX") so this never
150
+ * collides with a Canadian/US TD1 or a TD3 passport pair.
151
+ */
152
+ export function parseIneMrz(lines) {
153
+ const empty = {
154
+ isIne: false, confidence: 0, checksumFailures: [],
155
+ raw: { line1: "", line2: "", line3: "" },
156
+ };
157
+ const flat = lines.flatMap(l => l.split(/\n/));
158
+ for (let i = 0; i < flat.length - 2; i++) {
159
+ const l1 = toTd1Line(flat[i]);
160
+ const l2 = toTd1Line(flat[i + 1]);
161
+ const l3 = toTd1Line(flat[i + 2]);
162
+ if (!l1 || !l2 || !l3)
163
+ continue;
164
+ if (/^I[A-Z<]MEX/.test(l1))
165
+ return parseTd1(l1, l2, l3);
166
+ }
167
+ return empty;
168
+ }
169
+ // ─── CURP check-digit validation ─────────────────────────────────────────────
170
+ // Implements the published SAT/RENAPO CURP check-digit formula: a weighted
171
+ // sum over positions 1-17 against a fixed 37-character alphabet, weight =
172
+ // (18 - position). This is the same algorithm used for the Mexican RFC.
173
+ // Structural positions (letters/digits/H|M) are NOT re-derived here beyond
174
+ // what the check-digit computation itself requires — see claveElectorStructureValid
175
+ // for the fuller positional validation pattern this mirrors.
176
+ const CURP_ALPHABET = "0123456789ABCDEFGHIJKLMNÑOPQRSTUVWXYZ";
177
+ /** Compute the expected 18th (check) digit for a CURP, or undefined if malformed. */
178
+ export function curpCheckDigit(curp) {
179
+ if (!/^[A-ZÑ0-9]{17}/.test(curp))
180
+ return undefined;
181
+ let sum = 0;
182
+ for (let i = 0; i < 17; i++) {
183
+ const value = CURP_ALPHABET.indexOf(curp[i]);
184
+ if (value === -1)
185
+ return undefined;
186
+ sum += value * (18 - i);
187
+ }
188
+ return (10 - (sum % 10)) % 10;
189
+ }
190
+ /** Validate a CURP's check digit. Does not confirm the CURP exists in RENAPO's registry. */
191
+ export function isCurpCheckDigitValid(curp) {
192
+ if (curp.length !== 18)
193
+ return false;
194
+ const expected = curpCheckDigit(curp);
195
+ return expected !== undefined && String(expected) === curp[17];
196
+ }
197
+ // ─── Clave de Elector structural validation ──────────────────────────────────
198
+ // No published checksum exists for the Clave de Elector, so this validates
199
+ // character-class structure, a plausible entidad (state) code, and — when a
200
+ // DOB/sex from another source is supplied — cross-field agreement only.
201
+ // Structure: 6 letters (name-derived) + 6 digits (DOB, YYMMDD) +
202
+ // 2 digits (entidad code, 01-32) + "H"|"M" (sex) + 3 digits (homonymy).
203
+ const CLAVE_ELECTOR_RE = /^[A-Z]{6}(\d{6})(\d{2})([HM])(\d{3})$/;
204
+ export function claveElectorStructureValid(clave, cross) {
205
+ const m = CLAVE_ELECTOR_RE.exec(clave);
206
+ if (!m)
207
+ return false;
208
+ const [, dobYyMmDd, entidad, sex] = m;
209
+ const entidadCode = parseInt(entidad, 10);
210
+ if (entidadCode < 1 || entidadCode > 32)
211
+ return false;
212
+ if (cross?.dateOfBirth) {
213
+ const expected = yyMmDdFromIso(cross.dateOfBirth);
214
+ if (expected && expected !== dobYyMmDd)
215
+ return false;
216
+ }
217
+ if (cross?.sex && cross.sex !== sex)
218
+ return false;
219
+ return true;
220
+ }
221
+ // ─── Cross-field consistency (DOB/sex must agree across every source present) ─
222
+ function yyMmDdFromIso(iso) {
223
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
224
+ return m ? m[1].slice(2) + m[2] + m[3] : undefined;
225
+ }
226
+ function crossFieldConsistent(mrz, curp, claveElector) {
227
+ const dobCandidates = [];
228
+ const sexCandidates = [];
229
+ const mrzDob = mrz?.dateOfBirth ? yyMmDdFromIso(mrz.dateOfBirth) : undefined;
230
+ if (mrzDob)
231
+ dobCandidates.push(mrzDob);
232
+ if (mrz?.sex)
233
+ sexCandidates.push(mrz.sex);
234
+ if (curp && curp.length === 18) {
235
+ dobCandidates.push(curp.slice(4, 10)); // CURP: 4 letters, then DOB YYMMDD
236
+ const curpSex = curp[10]; // "H" | "M"
237
+ if (curpSex === "H" || curpSex === "M")
238
+ sexCandidates.push(curpSex);
239
+ }
240
+ const claveMatch = claveElector ? CLAVE_ELECTOR_RE.exec(claveElector) : null;
241
+ if (claveMatch) {
242
+ dobCandidates.push(claveMatch[1]);
243
+ sexCandidates.push(claveMatch[3]);
244
+ }
245
+ const dobAgree = dobCandidates.every(d => d === dobCandidates[0]);
246
+ const sexAgree = sexCandidates.every(s => s === sexCandidates[0]);
247
+ return dobAgree && sexAgree;
248
+ }
249
+ export function parseInePdf417(_raw) {
250
+ return { supported: false, reason: "unsupported_format" };
251
+ }
252
+ function computeOverall(mrzPresent, curpPresent, clavePresent, mrzOk, curpOk, claveOk, crossOk) {
253
+ if (!mrzPresent && !curpPresent && !clavePresent)
254
+ return "insufficient_data";
255
+ const anyFailure = (mrzPresent && !mrzOk) || (curpPresent && !curpOk) || (clavePresent && !claveOk) || !crossOk;
256
+ return anyFailure ? "inconsistent" : "consistent";
257
+ }
258
+ /**
259
+ * Assemble a typed IneCredential + honest offline-integrity signal from
260
+ * whatever sources decoded successfully. `curp`/`claveElector` are optional
261
+ * because today only the MRZ path is implemented (PDF417 is stubbed above,
262
+ * pending real samples) and the MRZ does not carry either identifier — pass
263
+ * them in once a future source (PDF417 or OCR) can supply them.
264
+ */
265
+ export function buildIneCredential(input) {
266
+ const mrz = input.mrz?.isIne ? input.mrz : undefined;
267
+ const mrzCheckDigitsValid = mrz ? mrz.checksumFailures.length === 0 : false;
268
+ const curpOk = input.curp !== undefined ? isCurpCheckDigitValid(input.curp) : false;
269
+ const claveOk = input.claveElector !== undefined
270
+ ? claveElectorStructureValid(input.claveElector, { dateOfBirth: mrz?.dateOfBirth, sex: mrz?.sex })
271
+ : false;
272
+ const crossOk = crossFieldConsistent(mrz, input.curp, input.claveElector);
273
+ const overall = computeOverall(!!mrz, input.curp !== undefined, input.claveElector !== undefined, mrzCheckDigitsValid, curpOk, claveOk, crossOk);
274
+ return {
275
+ documentType: "MX_INE",
276
+ model: input.model ?? "unknown",
277
+ surnamePaternal: mrz?.surnamePaternal,
278
+ surnameMaternal: mrz?.surnameMaternal,
279
+ givenNames: mrz?.givenNames,
280
+ fullNameRaw: mrz?.raw.line3,
281
+ dateOfBirth: mrz?.dateOfBirth,
282
+ sex: mrz?.sex,
283
+ claveElector: input.claveElector !== undefined
284
+ ? { value: input.claveElector, structureValid: claveOk }
285
+ : undefined,
286
+ curp: input.curp !== undefined
287
+ ? { value: input.curp, checkDigitValid: curpOk }
288
+ : undefined,
289
+ expiryYear: mrz?.expiryDate?.slice(0, 4),
290
+ cic: mrz?.documentNumber,
291
+ mrzLines: mrz ? { mz1: mrz.raw.line1, mz2: mrz.raw.line2, mz3: mrz.raw.line3 } : undefined,
292
+ source: input.source ?? (mrz ? "MRZ" : "PDF417"),
293
+ integrity: {
294
+ mrzCheckDigitsValid,
295
+ curpCheckDigitValid: curpOk,
296
+ claveElectorStructureValid: claveOk,
297
+ crossFieldConsistent: crossOk,
298
+ overall,
299
+ },
300
+ };
301
+ }
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.1.0",
3
+ "version": "0.3.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/parselo-io/parselo-sdk.git",