@parselo/scanner-core 0.2.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 +105 -9
- package/dist/canonical.d.ts +3 -1
- package/dist/canonical.js +45 -0
- package/dist/index.d.ts +14 -1
- package/dist/index.js +42 -1
- package/dist/ine.d.ts +95 -0
- package/dist/ine.js +301 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# @parselo/scanner-core
|
|
2
2
|
|
|
3
|
-
Platform-agnostic core for the Parselo
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
ES256 license enforcement and
|
|
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.
|
|
7
8
|
|
|
8
9
|
No Capacitor dependency — fully testable in Node.
|
|
9
10
|
|
|
@@ -14,6 +15,7 @@ No Capacitor dependency — fully testable in Node.
|
|
|
14
15
|
| Canadian / US driver's licences & ID cards | PDF417 (AAMVA) | All provinces; dates normalised to ISO |
|
|
15
16
|
| BC / ON / AB licences (older stock) | Magnetic-stripe tracks encoded in PDF417 | Three-track `%…?` format |
|
|
16
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. |
|
|
17
19
|
|
|
18
20
|
All document types produce the same `CanonicalDocument` shape with identical field names and ISO `YYYY-MM-DD` dates.
|
|
19
21
|
|
|
@@ -86,12 +88,75 @@ resident travel documents (`PR`), and foreign passports. The parser cross-refere
|
|
|
86
88
|
Vision's biographical-zone OCR lines to recover names even when the OCR-B `<` fill
|
|
87
89
|
character is misread.
|
|
88
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
|
+
|
|
89
154
|
## Canonical document shape
|
|
90
155
|
|
|
91
156
|
```ts
|
|
92
157
|
interface CanonicalDocument {
|
|
93
|
-
documentType: "drivers_license" | "id_card" | "passport" | "unknown";
|
|
94
|
-
jurisdiction: string; // "CA-QC", "CA-BC", "CA", "MEX", …
|
|
158
|
+
documentType: "drivers_license" | "id_card" | "passport" | "ine" | "unknown";
|
|
159
|
+
jurisdiction: string; // "CA-QC", "CA-BC", "CA", "MEX", "MX", …
|
|
95
160
|
fields: {
|
|
96
161
|
// All document types
|
|
97
162
|
firstName?: Field;
|
|
@@ -107,12 +172,18 @@ interface CanonicalDocument {
|
|
|
107
172
|
addressRegion?: Field;
|
|
108
173
|
addressPostalCode?: Field;
|
|
109
174
|
vehicleClass?: Field;
|
|
110
|
-
// Passports / travel documents
|
|
111
|
-
country?: Field; // ICAO 3-char issuing country
|
|
175
|
+
// Passports / travel documents / INE
|
|
176
|
+
country?: Field; // ICAO 3-char issuing country, "MEX" for INE
|
|
112
177
|
};
|
|
113
|
-
raw?: Record<string, string>;
|
|
178
|
+
raw?: Record<string, string>; // INE also carries raw.curp / raw.claveElector when present
|
|
114
179
|
}
|
|
180
|
+
```
|
|
115
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
|
|
116
187
|
interface Field {
|
|
117
188
|
value: string;
|
|
118
189
|
confidence: number; // 0–1
|
|
@@ -132,6 +203,7 @@ interface ScanResult {
|
|
|
132
203
|
type ScanError =
|
|
133
204
|
| "no_barcode" | "not_aamva" // barcode path
|
|
134
205
|
| "no_mrz" // passport path
|
|
206
|
+
| "no_ine" // INE path
|
|
135
207
|
| "license_expired" | "license_bundle_mismatch"
|
|
136
208
|
| "license_bad_signature" | "license_unknown_key" | "license_malformed";
|
|
137
209
|
```
|
|
@@ -160,6 +232,30 @@ locally and flush in batches. Force a flush with:
|
|
|
160
232
|
await scanner.flushAnalytics();
|
|
161
233
|
```
|
|
162
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
|
+
|
|
163
259
|
## License
|
|
164
260
|
|
|
165
261
|
MIT
|
package/dist/canonical.d.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { AamvaResult } from "./aamva";
|
|
2
2
|
import type { MagstripeResult } from "./magstripe";
|
|
3
3
|
import type { MrzResult } from "./mrz";
|
|
4
|
+
import type { IneCredential } from "./ine";
|
|
4
5
|
export type FieldSource = "barcode" | "ocr" | "mrz";
|
|
5
6
|
export interface CanonicalField {
|
|
6
7
|
value: string;
|
|
7
8
|
confidence: number;
|
|
8
9
|
source: FieldSource;
|
|
9
10
|
}
|
|
10
|
-
export type DocumentType = "drivers_license" | "id_card" | "passport" | "unknown";
|
|
11
|
+
export type DocumentType = "drivers_license" | "id_card" | "passport" | "ine" | "unknown";
|
|
11
12
|
export interface CanonicalDocument {
|
|
12
13
|
documentType: DocumentType;
|
|
13
14
|
jurisdiction: string;
|
|
@@ -32,3 +33,4 @@ export interface CanonicalDocument {
|
|
|
32
33
|
export declare function mrzToCanonical(m: MrzResult): CanonicalDocument;
|
|
33
34
|
export declare function magstripeToCanonical(m: MagstripeResult, confidence?: number): CanonicalDocument;
|
|
34
35
|
export declare function toCanonical(a: AamvaResult, confidence?: number): CanonicalDocument;
|
|
36
|
+
export declare function ineToCanonical(i: IneCredential): CanonicalDocument;
|
package/dist/canonical.js
CHANGED
|
@@ -94,3 +94,48 @@ export function toCanonical(a, confidence = 1.0) {
|
|
|
94
94
|
raw: e,
|
|
95
95
|
};
|
|
96
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
|
@@ -4,6 +4,7 @@ import { type AnalyticsConfig } from "./analytics";
|
|
|
4
4
|
export * from "./aamva";
|
|
5
5
|
export * from "./magstripe";
|
|
6
6
|
export * from "./mrz";
|
|
7
|
+
export * from "./ine";
|
|
7
8
|
export * from "./canonical";
|
|
8
9
|
export * from "./license";
|
|
9
10
|
export * from "./analytics";
|
|
@@ -24,7 +25,7 @@ export interface ScannerConfig {
|
|
|
24
25
|
mrzNative?: MrzNative;
|
|
25
26
|
deviceModel: string;
|
|
26
27
|
}
|
|
27
|
-
export type ScanError = "license_malformed" | "license_unknown_key" | "license_bad_signature" | "license_expired" | "license_bundle_mismatch" | "no_barcode" | "not_aamva" | "no_mrz";
|
|
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";
|
|
28
29
|
export interface ScanResult {
|
|
29
30
|
ok: boolean;
|
|
30
31
|
document?: CanonicalDocument;
|
|
@@ -41,6 +42,18 @@ export declare class Scanner {
|
|
|
41
42
|
scan(): Promise<ScanResult>;
|
|
42
43
|
/** Scan a passport via on-device MRZ text recognition. */
|
|
43
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>;
|
|
44
57
|
/** Flush buffered analytics (e.g. on app foreground/background). */
|
|
45
58
|
flushAnalytics(): Promise<void>;
|
|
46
59
|
private checkLicense;
|
package/dist/index.js
CHANGED
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
import { parseAamva } from "./aamva";
|
|
9
9
|
import { parseMagstripe } from "./magstripe";
|
|
10
10
|
import { parseMrz } from "./mrz";
|
|
11
|
-
import {
|
|
11
|
+
import { parseIneMrz, buildIneCredential } from "./ine";
|
|
12
|
+
import { toCanonical, magstripeToCanonical, mrzToCanonical, ineToCanonical } from "./canonical";
|
|
12
13
|
import { verifyLicense, isExpired } from "./license";
|
|
13
14
|
import { ScanAnalytics } from "./analytics";
|
|
14
15
|
export * from "./aamva";
|
|
15
16
|
export * from "./magstripe";
|
|
16
17
|
export * from "./mrz";
|
|
18
|
+
export * from "./ine";
|
|
17
19
|
export * from "./canonical";
|
|
18
20
|
export * from "./license";
|
|
19
21
|
export * from "./analytics";
|
|
@@ -104,6 +106,45 @@ export class Scanner {
|
|
|
104
106
|
// (6) RETURN -----------------------------------------------------------
|
|
105
107
|
return { ok: true, document };
|
|
106
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");
|
|
145
|
+
// (6) RETURN -----------------------------------------------------------
|
|
146
|
+
return { ok: true, document };
|
|
147
|
+
}
|
|
107
148
|
/** Flush buffered analytics (e.g. on app foreground/background). */
|
|
108
149
|
async flushAnalytics() {
|
|
109
150
|
await this.analytics.flush();
|
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
|
+
}
|