Ogni passaporto ha, in fondo alla pagina con la foto, due righe di testo dall'aria poco amichevole: lettere maiuscole, cifre e tanti segni <. È la zona a lettura ottica, o MRZ (dall'inglese machine-readable zone), ed è definita dal Doc 9303 dell'ICAO, lo standard dei documenti di viaggio. Contiene gli stessi dati essenziali della pagina stampata (nome, numero del documento, cittadinanza, data di nascita, sesso, data di scadenza) in una forma che uno scanner può leggere senza dover indovinare caratteri tipografici o impaginazioni.
Porta con sé anche un proprio meccanismo di rilevamento degli errori. Alcuni caratteri sono cifre di controllo (check digit): ciascuna è calcolata da un campo preciso, e un'ultima cifra copre più campi insieme. Se un singolo carattere viene letto male, la cifra che lo protegge di solito smette di corrispondere. Per questo la MRZ è una delle poche cose, nell'elaborazione dei documenti d'identità, che puoi verificare da solo, con venti righe di codice e senza fidarti dell'OCR di nessuno.
Questo articolo spiega l'algoritmo, dove si trovano le cifre in ciascuno dei tre formati di MRZ e un validatore in Python e JavaScript da incollare in un progetto. Tutti gli esempi usano il facsimile fittizio pubblicato dalla stessa ICAO, Anna Maria Eriksson di "Utopia" (UTO, un codice paese che esiste solo nei facsimili). Nessun documento reale compare da nessuna parte.
Questo è il blog di doc.cheap, un'API di riconoscimento dei documenti che legge la MRZ e ricontrolla queste cifre sul server. Niente di quanto segue ne ha bisogno: il codice funziona offline.
L'alfabeto
Una MRZ usa esattamente 37 caratteri: 0-9, A-Z e il carattere di riempimento <. Niente minuscole, niente spazi, niente punteggiatura. I nomi con accenti o in alfabeti non latini vengono traslitterati, e gli spazi all'interno di un campo diventano <. Il riempimento porta inoltre ogni campo alla sua larghezza fissa, quindi ERIKSSON<<ANNA<MARIA<<<<<<< significa "cognome ERIKSSON, nomi ANNA MARIA", con il doppio << che separa il cognome dai nomi.
L'algoritmo: pesi 7, 3, 1
Una cifra di controllo si calcola sempre allo stesso modo, per ogni campo di ogni formato:
- Trasforma ogni carattere in un numero. Una cifra vale sé stessa. Una lettera vale la sua posizione nell'alfabeto più 9, quindi
A= 10,B= 11, …Z= 35. Il riempimento<vale 0. - Moltiplica per un peso che si ripete, 7, 3, 1, 7, 3, 1, …, a partire dal primo carattere del campo.
- Somma i prodotti e prendi il resto della divisione per 10. Quell'unica cifra è la cifra di controllo.
Ecco il calcolo passo per passo sul numero di passaporto del facsimile, L898902C3, la cui cifra di controllo stampata è 6:
character L 8 9 8 9 0 2 C 3
value 21 8 9 8 9 0 2 12 3
weight 7 3 1 7 3 1 7 3 1
product 147 24 9 56 27 0 14 36 3
sum = 316 316 mod 10 = 6 the zone prints 6
Perché 7-3-1? I pesi sono scelti in modo che gli errori di lettura più comuni cambino la somma: un singolo carattere sbagliato e molti scambi tra due caratteri adiacenti. Non è un checksum crittografico. Chiunque può calcolarlo, quindi una cifra che corrisponde dimostra solo che la zona è coerente al suo interno, non che il documento sia autentico.
I tre formati
L'ICAO 9303 definisce tre layout di MRZ. Si distinguono per il numero di righe e di caratteri per riga:
| Formato | Righe × caratteri | Dove lo trovi |
|---|---|---|
| TD1 | 3 × 30 | Carte d'identità, permessi di soggiorno |
| TD2 | 2 × 36 | Carte d'identità meno recenti e alcuni documenti di viaggio |
| TD3 | 2 × 44 | Passaporti a libretto |
I facsimili usati più avanti:
TD3 P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<
L898902C36UTO7408122F1204159ZE184226B<<<<<10
TD2 I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<
D231458907UTO7408122F1204159<<<<<<<6
TD1 I<UTOD231458907<<<<<<<<<<<<<<<
7408122F1204159UTO<<<<<<<<<<<6
ERIKSSON<<ANNA<MARIA<<<<<<<<<<
Leggi la seconda riga del TD3 da sinistra a destra: L898902C3 numero del documento, 6 la sua cifra di controllo, UTO cittadinanza, 740812 data di nascita (AAMMGG), 2 la sua cifra di controllo, F sesso, 120415 data di scadenza, 9 la sua cifra di controllo, ZE184226B<<<<< dati facoltativi (spesso un numero personale), 1 la sua cifra di controllo e infine 0, la cifra di controllo composita.
Il parser MRZ riporta in una tabella di riferimento la posizione di ogni campo e di ogni cifra di controllo in tutti e tre i formati, e la pagina sui formati MRZ descrive ciascun layout.
Dove si trova ogni cifra di controllo
Le posizioni partono da 0, così puoi passarle direttamente a slice. La cifra di controllo di ogni campo segue immediatamente il campo.
| Campo | TD3 (riga 2) | TD2 (riga 2) | TD1 |
|---|---|---|---|
| Numero del documento | 0–8, cifra in 9 | 0–8, cifra in 9 | riga 1: 5–13, cifra in 14 |
| Data di nascita | 13–18, cifra in 19 | 13–18, cifra in 19 | riga 2: 0–5, cifra in 6 |
| Data di scadenza | 21–26, cifra in 27 | 21–26, cifra in 27 | riga 2: 8–13, cifra in 14 |
| Dati facoltativi | 28–41, cifra in 42 | nessuna | nessuna |
| Composita | cifra in 43 | cifra in 35 | riga 2: cifra in 29 |
La cifra composita è il punto in cui sbaglia la maggior parte dei validatori fatti in casa, perché non copre l'intera riga:
- TD3: posizioni 0–9, 13–19 e 21–42 della riga 2. Salta la cittadinanza (10–12) e il sesso (20).
- TD2: posizioni 0–9, 13–19 e 21–34 della riga 2. Stessi salti.
- TD1: si estende su due righe: riga 1 posizioni 5–29, poi riga 2 posizioni 0–6, 8–14 e 18–28.
Ogni intervallo include le cifre di controllo dei singoli campi che contiene, ed è per questo che la composita rileva gli errori anche nelle cifre stesse.
Un validatore in Python
Nessuna dipendenza. Riconosce il formato dalla forma, controlla ogni cifra di campo e la composita, e restituisce un dizionario con i risultati.
WEIGHTS = (7, 3, 1)
def char_value(c):
if c.isdigit():
return int(c)
if "A" <= c <= "Z":
return ord(c) - ord("A") + 10
if c == "<":
return 0
raise ValueError(f"not an MRZ character: {c!r}")
def check_digit(data):
return sum(char_value(c) * WEIGHTS[i % 3] for i, c in enumerate(data)) % 10
def digit_ok(data, printed):
# Un campo fatto solo di riempimento può stampare "<" come cifra di controllo.
expected = 0 if printed == "<" else int(printed)
return check_digit(data) == expected
# (nome, indice di riga, inizio, fine, posizione della cifra di controllo) per formato
LAYOUTS = {
"TD3": [("document number", 1, 0, 9, 9), ("birth date", 1, 13, 19, 19),
("expiry date", 1, 21, 27, 27), ("personal number", 1, 28, 42, 42)],
"TD2": [("document number", 1, 0, 9, 9), ("birth date", 1, 13, 19, 19),
("expiry date", 1, 21, 27, 27)],
"TD1": [("document number", 0, 5, 14, 14), ("birth date", 1, 0, 6, 6),
("expiry date", 1, 8, 14, 14)],
}
def composite(fmt, lines):
if fmt == "TD3":
l = lines[1]
return l[0:10] + l[13:20] + l[21:43], l[43]
if fmt == "TD2":
l = lines[1]
return l[0:10] + l[13:20] + l[21:35], l[35]
a, b = lines[0], lines[1]
return a[5:30] + b[0:7] + b[8:15] + b[18:29], b[29]
def detect(lines):
shape = (len(lines), len(lines[0]))
fmt = {(2, 44): "TD3", (2, 36): "TD2", (3, 30): "TD1"}.get(shape)
if fmt is None or any(len(l) != shape[1] for l in lines):
raise ValueError(f"unknown MRZ shape: {[len(l) for l in lines]}")
return fmt
def validate(lines):
fmt = detect(lines)
results = {}
for name, li, start, end, pos in LAYOUTS[fmt]:
results[name] = digit_ok(lines[li][start:end], lines[li][pos])
data, printed = composite(fmt, lines)
results["composite"] = digit_ok(data, printed)
return fmt, results
if __name__ == "__main__":
print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C36UTO7408122F1204159ZE184226B<<<<<10"]))
print(*validate(["I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<",
"D231458907UTO7408122F1204159<<<<<<<6"]))
print(*validate(["I<UTOD231458907<<<<<<<<<<<<<<<",
"7408122F1204159UTO<<<<<<<<<<<6",
"ERIKSSON<<ANNA<MARIA<<<<<<<<<<"]))
# Un carattere letto male: il 3 letto come 4 nel numero del documento
print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C46UTO7408122F1204159ZE184226B<<<<<10"]))
Output:
TD3 {'document number': True, 'birth date': True, 'expiry date': True, 'personal number': True, 'composite': True}
TD2 {'document number': True, 'birth date': True, 'expiry date': True, 'composite': True}
TD1 {'document number': True, 'birth date': True, 'expiry date': True, 'composite': True}
TD3 {'document number': False, 'birth date': True, 'expiry date': True, 'personal number': True, 'composite': False}
L'ultima riga è il senso di tutto l'esercizio: un carattere scambiato per quello vicino, e lo segnalano sia la cifra del campo sia la composita.
Lo stesso validatore in JavaScript
Un semplice modulo ES, funziona in Node o nel browser.
const WEIGHTS = [7, 3, 1];
function charValue(c) {
if (c >= "0" && c <= "9") return c.charCodeAt(0) - 48;
if (c >= "A" && c <= "Z") return c.charCodeAt(0) - 55; // A = 10
if (c === "<") return 0;
throw new Error(`not an MRZ character: ${JSON.stringify(c)}`);
}
export function checkDigit(data) {
let sum = 0;
for (let i = 0; i < data.length; i++) sum += charValue(data[i]) * WEIGHTS[i % 3];
return sum % 10;
}
const digitOk = (data, printed) => checkDigit(data) === (printed === "<" ? 0 : Number(printed));
const LAYOUTS = {
TD3: [["document number", 1, 0, 9], ["birth date", 1, 13, 19], ["expiry date", 1, 21, 27], ["personal number", 1, 28, 42]],
TD2: [["document number", 1, 0, 9], ["birth date", 1, 13, 19], ["expiry date", 1, 21, 27]],
TD1: [["document number", 0, 5, 14], ["birth date", 1, 0, 6], ["expiry date", 1, 8, 14]],
};
function composite(fmt, [a, b]) {
if (fmt === "TD3") return [b.slice(0, 10) + b.slice(13, 20) + b.slice(21, 43), b[43]];
if (fmt === "TD2") return [b.slice(0, 10) + b.slice(13, 20) + b.slice(21, 35), b[35]];
return [a.slice(5, 30) + b.slice(0, 7) + b.slice(8, 15) + b.slice(18, 29), b[29]];
}
export function validate(lines) {
const fmt = { "2x44": "TD3", "2x36": "TD2", "3x30": "TD1" }[`${lines.length}x${lines[0].length}`];
if (!fmt || lines.some((l) => l.length !== lines[0].length)) throw new Error("unknown MRZ shape");
const results = {};
// La cifra di controllo segue subito il campo che protegge.
for (const [name, li, start, end] of LAYOUTS[fmt]) {
results[name] = digitOk(lines[li].slice(start, end), lines[li][end]);
}
const [data, printed] = composite(fmt, lines);
results.composite = digitOk(data, printed);
return { format: fmt, results };
}
console.log(validate([
"P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C36UTO7408122F1204159ZE184226B<<<<<10",
]));
node mrz.mjs stampa format: 'TD3' e true per tutti e cinque i controlli.
Le trappole
Non eliminare il riempimento. I caratteri < fanno parte dei dati su cui si calcolano le cifre. Togli i < finali da una riga e la composita fallisce su un documento perfettamente valido.
Non ricostruire la zona dai campi estratti. Se analizzi la MRZ in campi, li normalizzi (date in ISO, nomi con gli spazi) e poi li riserializzi per controllare le cifre, stai controllando il tuo serializzatore. Controlla le righe grezze così come sono state lette.
Normalizza l'output dell'OCR prima di validare, con cautela. I motori OCR tendono a restituire minuscole, spazi o « al posto di <. Convertire in maiuscolo ed eliminare gli spazi bianchi è sicuro. Sostituire O con 0 "perché i numeri dei documenti sono numerici" no: i numeri dei documenti possono contenere lettere, come mostra proprio L898902C3.
Una cifra corretta non è una data reale. 740812 supera il controllo della sua cifra che il 12 agosto 1974 sia plausibile o no, e AAMMGG non indica il secolo. Deducilo dal contesto: una data di nascita è nel passato, una data di scadenza di solito nel futuro.
Numeri di documento lunghi nel TD1. L'ICAO consente che in un TD1 un numero di documento più lungo di nove caratteri prosegua nel campo dei dati facoltativi, con un < nella posizione normale della cifra di controllo e la cifra di controllo dopo l'ultimo carattere del numero. Il validatore qui sopra non gestisce questo caso. Se elabori carte d'identità di emittenti che lo usano, aggiungi un ramo; il parser MRZ lo gestisce, se ti serve qualcosa con cui confrontarti.
Le cifre di controllo non garantiscono l'autenticità. Chiunque sappia modificare un'immagine può calcolare una cifra valida. La MRZ ti dice che la zona è stata letta correttamente ed è coerente al suo interno, non che il documento sia autentico. Confrontare la MRZ con la zona visiva stampata è un segnale più forte, e nemmeno quello è un controllo antifalsificazione.
Dati di test senza passaporti reali
Non dovresti mai aver bisogno del passaporto di una persona reale per testare questo codice. Due possibilità:
- I facsimili ICAO qui sopra, pubblicati proprio a questo scopo.
- Generarne di tuoi: il generatore di MRZ crea nel browser una zona TD3 sintetica con cifre di controllo corrette a partire dai valori che inserisci. Cambia poi un carattere e avrai un caso che fallisce.
Per la direzione opposta, incolla una zona qualsiasi (TD1, TD2 o TD3) nel parser MRZ: riconosce il formato, legge ogni campo e mostra ogni cifra di controllo calcolata accanto a quella stampata, tutto nel browser. Utile quando la tua implementazione e quella di qualcun altro non sono d'accordo.
Dove si colloca in una pipeline reale
Se leggi le MRZ con un tuo OCR, esegui questi controlli su ogni lettura e tratta un errore come "rifai la foto", non come "respingi la persona": un riflesso su un carattere, una plastificazione consumata o una pagina piegata sono molto più comuni di una frode.
Se invece usi un'API di riconoscimento in hosting, ricalcola comunque tu le cifre quando il risultato decide su denaro o accessi. È l'unica parte della risposta che puoi verificare senza fidarti del fornitore. Noi compresi: la risposta di doc.cheap pubblica la zona testuale in mrz.lines e mrz.text (le righe unite senza nulla in mezzo) accanto al proprio verdetto mrz.status, proprio perché tu possa passarla a una funzione come quella qui sopra. La guida Check an MRZ della documentazione (in inglese) descrive questo flusso.
Se trovi un caso in cui il validatore sbaglia, scrivi a admin@doc.cheap.
Entrambi i blocchi di codice sono stati eseguiti e il loro output è riportato così come è stato stampato; ogni affermazione su doc.cheap è stata verificata sul suo codice.