@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 CHANGED
@@ -1,9 +1,10 @@
1
1
  # @parselo/scanner-core
2
2
 
3
- Platform-agnostic core for the Parselo Canadian ID scanning SDK. Parses AAMVA
4
- PDF417 barcodes, magnetic-stripe tracks, and ICAO 9303 MRZ from passports and
5
- travel documents into a unified canonical document shape. Includes offline
6
- ES256 license enforcement and PII-free usage analytics.
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
@@ -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 { toCanonical, magstripeToCanonical, mrzToCanonical } from "./canonical";
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parselo/scanner-core",
3
- "version": "0.2.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",