Validation

Passport MRZ

Machine-readable zones of passports, ID cards and visas (ICAO 9303 TD1, TD2, TD3, MRV) read into names, numbers and dates with every check digit verified, MRZ lines found in OCR text, and MRZs made for test documents.

Version 1.0.0 · 3 functions · 1 crate

#identity#travel#ocr#validation

Functions

What use passport_mrz.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use mrz_parse, mrz_find from passport_mrz (Quillo 0.6).

mrz_parse(mrz: str) → any

An MRZ read: the two or three lines as printed (line breaks between them; spaces and lower case from OCR are tidied). Gives { valid, format, document_type, issuing_state, surname, given_names, document_number, nationality, birth_date, sex, expiry_date, optional, expired, checks: { document_number, birth_date, expiry_date, optional, composite }, errors }. valid is true when every check digit matches; dates are ISO.

mrz_find(text: str) → str?

The MRZ lines found in OCR text of a document's photo (a run of lines of 30, 36 or 44 MRZ characters; « and spaces OCR adds are handled), joined by line breaks, or null when there is none.

mrz_make(fields: map) → stror an error

A passport's MRZ (TD3) made from fields: document_type ("P"), issuing_state and nationality (3 letters), surname, given_names, document_number, birth_date and expiry_date (ISO dates), sex ("M", "F" or "X") and optional. Accents and punctuation are written as ICAO says (é as E, a hyphen as <). For test documents and kiosks' tests.

Get it

$ quillo install passport_mrz               # passport_mrz.qwer into this folder$ quillo install passport_mrz --example     # and its example, passport_mrz.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/passport_mrz.qwer$ curl -O https://quillolang.org/plugins/files/passport_mrz.qwe

Then, in your program, all of it or only what you call:

main.qwe
use passport_mrz.qweruse mrz_parse, mrz_find from passport_mrz      // Quillo 0.6: only these

It uses this crate, which cargo fetches the first time the plugin is built:

[dependencies]
serde_json = { version = "1", features = ["preserve_order"] }

Example

passport_mrz.qwe
// Hotel check-in: the guest's passport or ID card scanned, its MRZ read from the OCR text, every// check digit verified, and the guest's name and dates filled in.////   quillo run passport_mrz.qwe      then open http://localhost:8080/checkin and http://localhost:8080/carduse "passport_mrz.qwer"// What an OCR app read from the photo of ICAO's specimen passport, page noise included.let OCR = "PASSPORT  PASS  PASAPORTE\nUtopia\nEriksson, Anna Maria\nP<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<\nL898902C36UTO7408122F1204159ZE184226B<<<<<10\n"route GET /checkin {    let mrz = mrz_find(OCR) ?? reply 422 "no MRZ in the picture: take it again"    let p = mrz_parse(mrz)    if !p.valid { reply 422 p.errors }    { guest: `${p.given_names} ${p.surname}`, document: p.document_number, nationality: p.nationality, birth_date: p.birth_date, expired: p.expired, checks: p.checks }}// ICAO's specimen ID card (TD1), and the same with one digit misread.route GET /card = {    card: mrz_parse("I<UTOD231458907<<<<<<<<<<<<<<<\n7408122F1204159UTO<<<<<<<<<<<6\nERIKSSON<<ANNA<MARIA<<<<<<<<<<"),    misread: mrz_parse("I<UTOD231458907<<<<<<<<<<<<<<<\n7408122F1204169UTO<<<<<<<<<<<6\nERIKSSON<<ANNA<MARIA<<<<<<<<<<").errors,}// A test passport for the kiosk, read back as the scanner would.route GET /make {    let mrz = mrz_make({ issuing_state: "UTO", surname: "Ñúñez-García", given_names: "José Luis", document_number: "X4RT2219", birth_date: "1990-02-28", expiry_date: "2034-06-30", sex: "M" })?    { mrz, read_back: mrz_parse(mrz).valid }}
$ quillo run passport_mrz.qwe$ curl 'localhost:8080/checkin'$ curl 'localhost:8080/make'$ curl 'localhost:8080/card'

Source

passport_mrz.qwer
//! Machine-readable zones of passports, ID cards and visas (ICAO 9303: TD1, TD2, TD3, MRV-A and MRV-B) read into names, document number, nationality, birth and expiry dates and sex, every check digit verified; MRZ lines found in OCR text; and MRZs made for test documents.//! version: 1.0.0//!//! [dependencies]//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Map, Value};fn value(c: char) -> Option<u32> {    match c {        '<' => Some(0),        '0'..='9' => c.to_digit(10),        'A'..='Z' => Some(c as u32 - 'A' as u32 + 10),        _ => None,    }}/// The ICAO check digit of `field` (weights 7, 3, 1; `<` counts 0, letters 10 to 35).fn check(field: &str) -> Option<u32> {    let mut t = 0;    for (i, c) in field.chars().enumerate() {        t += value(c)? * [7, 3, 1][i % 3];    }    Some(t % 10)}fn this_year() -> i32 {    let days = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() / 86400) as i64;    (1970.0 + days as f64 / 365.2425) as i32}fn today() -> String {    let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()) as i64;    let z = secs.div_euclid(86400) + 719468;    let era = z.div_euclid(146097);    let doe = z - era * 146097;    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);    let mp = (5 * doy + 2) / 153;    let d = doy - (153 * mp + 2) / 5 + 1;    let m = if mp < 10 { mp + 3 } else { mp - 9 };    format!("{:04}-{:02}-{:02}", yoe + era * 400 + if m <= 2 { 1 } else { 0 }, m, d)}/// YYMMDD as an ISO date; birth dates in the future are taken a century back, expiry dates are this century.fn date(s: &str, birth: bool) -> Option<String> {    if s.len() != 6 || !s.chars().all(|c| c.is_ascii_digit()) {        return None;    }    let (yy, m, d): (i32, u32, u32) = (s[..2].parse().ok()?, s[2..4].parse().ok()?, s[4..6].parse().ok()?);    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {        return None;    }    let now = this_year();    let y = if birth { if 2000 + yy > now { 1900 + yy } else { 2000 + yy } } else if 2000 + yy > now + 50 { 1900 + yy } else { 2000 + yy };    Some(format!("{:04}-{:02}-{:02}", y, m, d))}fn names(field: &str) -> (String, String) {    let f = field.trim_end_matches('<');    let (sur, given) = f.split_once("<<").unwrap_or((f, ""));    let tidy = |s: &str| s.split('<').filter(|p| !p.is_empty()).collect::<Vec<_>>().join(" ");    (tidy(sur), tidy(given))}fn text(field: &str) -> String {    field.trim_end_matches('<').replace('<', " ").trim().to_string()}struct Checks(Map<String, Value>, Vec<String>);impl Checks {    fn add(&mut self, name: &str, field: &str, digit: char) {        let ok = match (check(field), digit) {            (Some(c), d) => d.to_digit(10) == Some(c) || (d == '<' && c == 0 && field.chars().all(|x| x == '<')),            (None, _) => false,        };        if !ok {            self.1.push(format!("the {} check digit does not match", name.replace('_', " ")));        }        self.0.insert(name.into(), json!(ok));    }}/// An MRZ read: the two or three lines as printed (line breaks between them; spaces and lower case from OCR are tidied)./// Gives `{ valid, format, document_type, issuing_state, surname, given_names, document_number, nationality, birth_date, sex,/// expiry_date, optional, expired, checks: { document_number, birth_date, expiry_date, optional, composite }, errors }`./// `valid` is true when every check digit matches; dates are ISO.pub fn mrz_parse(mrz: &str) -> Value {    let lines: Vec<String> = mrz.lines().map(|l| l.chars().filter(|c| !c.is_whitespace()).map(|c| c.to_ascii_uppercase()).collect::<String>()).filter(|l| !l.is_empty()).collect();    let shape: Vec<usize> = lines.iter().map(|l| l.len()).collect();    let mut c = Checks(Map::new(), vec![]);    let mut out = Map::new();    let visa = lines.first().is_some_and(|l| l.starts_with('V'));    let format = match (shape.as_slice(), visa) {        ([30, 30, 30], _) => "TD1",        ([36, 36], false) => "TD2",        ([44, 44], false) => "TD3",        ([44, 44], true) => "MRV-A",        ([36, 36], true) => "MRV-B",        _ => return json!({ "valid": false, "errors": [format!("an MRZ is 3 lines of 30, or 2 of 36 or 44 characters; these are {:?}", shape)] }),    };    if let Some(bad) = lines.iter().flat_map(|l| l.chars()).find(|ch| value(*ch).is_none()) {        return json!({ "valid": false, "format": format, "errors": [format!("`{}` is not an MRZ character (A-Z, 0-9 and <)", bad)] });    }    let (l1, l2) = (&lines[0], &lines[1]);    let (doc, doc_check, nat, dob, dob_check, sex, exp, exp_check, optional, name_field);    match format {        "TD1" => {            let l3 = &lines[2];            doc = &l1[5..14]; doc_check = l1.chars().nth(14).unwrap();            dob = &l2[0..6]; dob_check = l2.chars().nth(6).unwrap();            sex = l2.chars().nth(7).unwrap();            exp = &l2[8..14]; exp_check = l2.chars().nth(14).unwrap();            nat = &l2[15..18];            optional = format!("{}{}", &l1[15..30], &l2[18..29]);            name_field = l3.as_str();            // A long document number continues in the optional field, ended by its check digit.            c.add("document_number", doc, doc_check);            c.add("composite", &format!("{}{}{}{}", &l1[5..30], &l2[0..7], &l2[8..15], &l2[18..29]), l2.chars().nth(29).unwrap());        }        "TD2" | "MRV-B" => {            doc = &l2[0..9]; doc_check = l2.chars().nth(9).unwrap();            nat = &l2[10..13];            dob = &l2[13..19]; dob_check = l2.chars().nth(19).unwrap();            sex = l2.chars().nth(20).unwrap();            exp = &l2[21..27]; exp_check = l2.chars().nth(27).unwrap();            name_field = &l1[5..36];            c.add("document_number", doc, doc_check);            if format == "TD2" {                optional = l2[28..35].to_string();                c.add("composite", &format!("{}{}{}", &l2[0..10], &l2[13..20], &l2[21..35]), l2.chars().nth(35).unwrap());            } else {                optional = l2[28..36].to_string();            }        }        _ => {            doc = &l2[0..9]; doc_check = l2.chars().nth(9).unwrap();            nat = &l2[10..13];            dob = &l2[13..19]; dob_check = l2.chars().nth(19).unwrap();            sex = l2.chars().nth(20).unwrap();            exp = &l2[21..27]; exp_check = l2.chars().nth(27).unwrap();            name_field = &l1[5..44];            c.add("document_number", doc, doc_check);            if format == "TD3" {                optional = l2[28..42].to_string();                c.add("optional", &l2[28..42], l2.chars().nth(42).unwrap());                c.add("composite", &format!("{}{}{}", &l2[0..10], &l2[13..20], &l2[21..43]), l2.chars().nth(43).unwrap());            } else {                optional = l2[28..44].to_string();            }        }    }    c.add("birth_date", dob, dob_check);    c.add("expiry_date", exp, exp_check);    let (surname, given) = names(name_field);    let birth = date(dob, true);    let expiry = date(exp, false);    if birth.is_none() && dob.chars().all(|x| x.is_ascii_digit()) {        c.1.push(format!("{} is not a birth date", dob));    }    out.insert("valid".into(), json!(c.1.is_empty()));    out.insert("format".into(), json!(format));    out.insert("document_type".into(), json!(text(&l1[..2])));    out.insert("issuing_state".into(), json!(text(&l1[2..5])));    out.insert("surname".into(), json!(surname));    out.insert("given_names".into(), json!(given));    out.insert("document_number".into(), json!(text(doc)));    out.insert("nationality".into(), json!(text(nat)));    out.insert("birth_date".into(), json!(birth));    out.insert("sex".into(), json!(match sex { 'M' => "M", 'F' => "F", _ => "X" }));    out.insert("expiry_date".into(), json!(expiry));    out.insert("optional".into(), json!(text(&optional)));    out.insert("expired".into(), json!(expiry.as_ref().map(|e| *e < today())));    out.insert("checks".into(), Value::Object(c.0));    out.insert("errors".into(), json!(c.1));    Value::Object(out)}/// The MRZ lines found in OCR text of a document's photo (a run of lines of 30, 36 or 44 MRZ characters; `«` and spaces OCR adds/// are handled), joined by line breaks, or null when there is none.pub fn mrz_find(text: &str) -> Option<String> {    let lines: Vec<String> = text.lines().map(|l| l.replace('«', "<").chars().filter(|c| !c.is_whitespace()).map(|c| c.to_ascii_uppercase()).collect::<String>()).collect();    let is_mrz = |l: &str, n: usize| l.len() == n && l.contains('<') && l.chars().all(|c| value(c).is_some());    for i in 0..lines.len() {        for (n, count) in [(30usize, 3usize), (44, 2), (36, 2)] {            if i + count <= lines.len() && (0..count).all(|k| is_mrz(&lines[i + k], n)) {                return Some(lines[i..i + count].join("\n"));            }        }    }    None}/// A passport's MRZ (TD3) made from `fields`: `document_type` ("P"), `issuing_state` and `nationality` (3 letters), `surname`,/// `given_names`, `document_number`, `birth_date` and `expiry_date` (ISO dates), `sex` ("M", "F" or "X") and `optional`. Accents/// and punctuation are written as ICAO says (é as E, a hyphen as <). For test documents and kiosks' tests.pub fn mrz_make(fields: std::collections::HashMap<String, serde_json::Value>) -> Result<String, String> {    let f = |k: &str| fields.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();    let translit = |s: &str| -> String {        s.to_uppercase().chars().map(|c| match c {            'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => 'A', 'Ç' => 'C', 'È' | 'É' | 'Ê' | 'Ë' => 'E', 'Ì' | 'Í' | 'Î' | 'Ï' => 'I', 'Ñ' => 'N',            'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'Ø' => 'O', 'Ù' | 'Ú' | 'Û' | 'Ü' => 'U', 'Ý' => 'Y', c if c.is_ascii_alphanumeric() => c, _ => '<',        }).collect()    };    let pad = |s: String, n: usize| -> String { let mut s: String = s.chars().take(n).collect(); while s.len() < n { s.push('<'); } s };    let ymd = |iso: &str| -> Result<String, String> {        if iso.len() != 10 { return Err(format!("`{}` is not an ISO date", iso)); }        Ok(format!("{}{}{}", &iso[2..4], &iso[5..7], &iso[8..10]))    };    let state = translit(&f("issuing_state"));    let nat = if f("nationality").is_empty() { state.clone() } else { translit(&f("nationality")) };    if state.len() != 3 || nat.len() != 3 {        return Err("issuing_state and nationality are 3-letter codes".into());    }    let dtype = if f("document_type").is_empty() { "P".to_string() } else { translit(&f("document_type")) };    let name = format!("{}<<{}", translit(&f("surname")).trim_matches('<'), translit(&f("given_names")).trim_matches('<'));    let l1 = pad(format!("{}{}{}", pad(dtype, 2), state, name), 44);    let doc = pad(translit(&f("document_number")), 9);    let dob = ymd(&f("birth_date"))?;    let exp = ymd(&f("expiry_date"))?;    let sex = match f("sex").as_str() { "M" => 'M', "F" => 'F', _ => '<' };    let opt = pad(translit(&f("optional")), 14);    let cd = |s: &str| check(s).map(|d| char::from_digit(d, 10).unwrap()).ok_or_else(|| "not MRZ characters".to_string());    let opt_check = if opt.chars().all(|c| c == '<') { '<' } else { cd(&opt)? };    let mut l2 = format!("{}{}{}{}{}{}{}{}{}{}", doc, cd(&doc)?, nat, dob, cd(&dob)?, sex, exp, cd(&exp)?, opt, opt_check);    let composite = format!("{}{}{}", &l2[0..10], &l2[13..20], &l2[21..43]);    l2.push(cd(&composite)?);    Ok(format!("{}\n{}", l1, l2))}
National ID numbers national_ids.qwer · 1.0.0National identity numbers of 29 countries checked by their own rules (US SSN, UK NINO, CPF, Aadhaar, PH TIN, DNI and NIE, codice fiscale, PESEL...), with the birth date and sex read out where the number holds them.validationidentitycomplianceforms7 views · 7 downloadsCard numbers cards.qwer · 1.0.0Payment card numbers checked before they reach a payment provider: Luhn check digit, brand and length, expiry, and the number formatted or masked for receipts.paymentsformscheckoutvalidation7 views · 7 downloadsCheck digits check_digits.qwer · 1.0.0Check digits made and checked: ISBN-10 and ISBN-13 with conversion, EAN-13, EAN-8 and UPC-A barcodes, IMEI, Luhn, ISO 7064 mod 97-10 references and Verhoeff.validationbarcodesbooksimport6 views · 7 downloadsISBN isbn.qwer · 1.0.0ISBNs checked, converted between 10 and 13 digits, hyphenated by the ISBN Agency's ranges into group, publisher and title parts with the group's language or country named, and every ISBN in a text found.booksvalidationcodesebooks6 views · 6 downloadsPhone and SIM numbers imei.qwer · 1.0.0The numbers that identify phones and SIM cards: IMEI and IMEISV read and made, CDMA MEIDs converted to decimal with their pseudo-ESN, SIM ICCIDs checked with their country, and IMSIs split into country and operator codes.phonedevicesmobilevalidation7 views · 11 downloadsPostal codes postal_codes.qwer · 1.0.0Postal codes checked for 45 countries and written the post office's way: SW1A 1AA, 1012 JS, K1A 0B1, 100-0001, 20500-0001.addressesformsvalidationshipping6 views · 7 downloads
Thread
69Useful? Like it, or add to its thread below.

Thread 0 posts

On Passport MRZ: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.