Ogni data in una zona a lettura ottica è composta da sei cifre: due per l'anno, due per il mese e due per il giorno. Non c'è il secolo. Il passaporto di esempio pubblicato dall'ICAO riporta 740812 come data di nascita, e nulla nella zona dice se si tratta del 1974 o del 2074. Sapete che è il 1974 perché nessuno ha un passaporto prima di nascere, e questo unico ragionamento è l'intera regola. Applicarla con esattezza, anche nel giorno in cui conta, richiede un po' più di attenzione di un anno di soglia fisso.

Questo articolo presenta la regola per le date di nascita e per quelle di scadenza, i casi limite che mettono in crisi i parser semplici e una funzione in Python e in JavaScript con una tabella di vettori di test da inserire nella vostra suite. Tutti gli esempi usano il campione fittizio dell'ICAO o valori sintetici; non compare alcun documento reale.

Questo è il blog di doc.cheap, un'API OCR per passaporti e documenti d'identità che legge la zona al posto vostro. Niente di ciò che segue ne ha bisogno; il codice non ha dipendenze.

Dove si trovano le date

Il Doc 9303 dell'ICAO, lo standard per i documenti di viaggio a lettura ottica, assegna a ogni formato due campi data: data di nascita e data di scadenza, ciascuno scritto come YYMMDD e seguito dalla propria cifra di controllo. Le posizioni partono da 0, pronte per slice:

Formato Data di nascita Data di scadenza
TD3 (passaporti), riga 2 13–18, cifra di controllo in 19 21–26, cifra di controllo in 27
TD2, riga 2 13–18, cifra di controllo in 19 21–26, cifra di controllo in 27
TD1 (carte d'identità), riga 2 0–5, cifra di controllo in 6 8–13, cifra di controllo in 14

Sulla riga TD3 del campione, L898902C36UTO7408122F3404159ZE184226B<<<<<16, si ottiene 740812 per la data di nascita e 340415 per la scadenza.

Perché un anno di soglia fisso non funziona

La scorciatoia abituale è un pivot: gli anni a due cifre sotto un certo numero vanno negli anni 2000, gli altri nei 1900. Lo stesso time.strptime di Python funziona così con %y. La sua documentazione dice: "When 2-digit years are parsed, they are converted according to the POSIX and ISO C standards: values 69–99 are mapped to 1969–1999, and values 0–68 are mapped to 2000–2068."

È un'impostazione predefinita ragionevole per un file di log. Per una data di nascita è sbagliata in entrambe le direzioni. Secondo quella regola, una persona nata il 12 aprile 1968 (680412) nasce nel 2068. Spostate il pivot e spostate solo il bug: qualsiasi numero fisso è sbagliato per qualcuno, e lo diventa sempre di più ogni anno in cui il codice resta in produzione. La risposta giusta dipende dalla data di oggi, quindi la regola deve ricevere la data di oggi come input.

Date di nascita: il secolo più recente che non sia nel futuro

Leggete l'anno come 2000 + YY. Se la data completa risultante è successiva a oggi, sottraete 100 anni. Tutto qui.

Tre dettagli decidono se un'implementazione è davvero corretta:

  • Confrontate la data intera, non solo l'anno. Negli esempi qui sotto oggi è il 24 settembre 2026. 261224 è il 24 dicembre 2026 nella lettura degli anni 2000, fra tre mesi, quindi la persona è nata il 24 dicembre 1926. Confrontare solo 26 con 26 la scambierebbe per un neonato.
  • Oggi conta come passato. Una persona nata oggi è nata in questo secolo, non in quello precedente. 260924 resta il 24 settembre 2026; 260925, un giorno dopo, torna al 1926. Usate "successiva a", non "successiva o uguale a".
  • Prendete "oggi" in UTC, o in un fuso orario fisso a vostra scelta, e passatelo come argomento. Una funzione che legge l'orologio da sola non si può testare, e un server in un fuso e un browser in un altro possono non essere d'accordo sulla data per una parte di ogni giorno.

Date di scadenza: questo secolo

Una data di scadenza si legge come 2000 + YY e resta così. Un passaporto scaduto nel 2012 è ancora una data del 2012, non del 2112, e uno che scade nel 2032 è del 2032. La lettura degli anni 1900 sarebbe corretta solo per un documento scaduto prima del 2000, e nessun documento del genere è ancora in uso. Questa regola vale fino agli anni 2090, quando un documento emesso allora potrebbe scadere dopo il 2100; il codice andrà rivisto prima di allora, non adesso.

I casi limite che meritano un test

Il 29 febbraio a cavallo del secolo. Scegliete prima il secolo, poi verificate che la data esista. 280229 come data di nascita è successiva a oggi nella lettura degli anni 2000, quindi diventa il 29 febbraio 1928, che esiste perché il 1928 è bisestile. 000229 è il 29 febbraio 2000, che esiste perché il 2000 è bisestile; nel 1900, che non era bisestile, le stesse sei cifre non sarebbero una data. 00 è l'unico anno a due cifre in cui il secolo cambia la risposta a "è bisestile?". Validate la data prima di scegliere il secolo e uno di questi casi va storto.

Una data di nascita più avanti in quest'anno. Già visto sopra: 261224 è il 1926, non il 2026, e un test dovrebbe fissarlo con un "oggi" fisso.

Caratteri di riempimento nella data. Una zona può contenere < in una data di nascita quando una parte della data è sconosciuta, per esempio 94<<08. Non c'è alcuna data di calendario da restituire. La funzione qui sotto non restituisce nulla per qualsiasi valore che non sia di sei cifre e lascia al chiamante il compito di mostrare il testo grezzo.

Una cifra di controllo valida su una data impossibile. La cifra di controllo protegge i caratteri, non il calendario. Con i pesi 7, 3, 1, 741312 dà somma 88, quindi la sua cifra di controllo è 8, e 7413128 supera la verifica. Il mese 13 non esiste. La cifra di controllo e la data sono due controlli separati ed entrambi devono essere superati; come funzionano le cifre di controllo della MRZ tratta il primo nel dettaglio.

Persone con più di 100 anni. Una persona nata nel 1925 e un bambino nato nel 2025 hanno le stesse sei cifre. La zona da sola non può distinguerli, e la regola qui sopra sceglie la lettura più giovane. Se tra i vostri utenti ci sono centenari, confrontate con la data stampata sulla pagina dati, dove compare un anno a quattro cifre quando il documento lo stampa.

La funzione in Python

Nessuna dipendenza. today è un datetime.date passato dal chiamante.

import re
from datetime import date, datetime, timezone

SIX_DIGITS = re.compile(r"[0-9]{6}")

def read_mrz_date(raw, kind, today):
    """Turn an MRZ YYMMDD field into a date, or None if it is not one.

    kind is "birth" or "expiry"; today is a datetime.date.
    """
    if kind not in ("birth", "expiry"):
        raise ValueError(f"unknown kind: {kind!r}")
    if not SIX_DIGITS.fullmatch(raw):
        return None  # fillers, letters or the wrong length
    yy, mm, dd = int(raw[0:2]), int(raw[2:4]), int(raw[4:6])
    year = 2000 + yy
    if kind == "birth" and (year, mm, dd) > (today.year, today.month, today.day):
        year -= 100  # the 2000s reading is in the future
    try:
        return date(year, mm, dd)  # checked after the century is chosen
    except ValueError:
        return None  # 13th month, 31 April, 29 February in a common year

# In production:
# read_mrz_date("740812", "birth", datetime.now(timezone.utc).date())

Il pattern è [0-9], non \d né str.isdigit(), di proposito: entrambi accettano cifre di altri sistemi di scrittura, e una MRZ contiene solo cifre ASCII. Il confronto usa una tupla in modo che anche un mese impossibile si confronti senza errori prima che date() lo rifiuti.

La stessa funzione in JavaScript

today è una stringa YYYY-MM-DD. Due stringhe di questo tipo si confrontano correttamente come semplice testo, il che riduce la regola del secolo a una riga.

export function readMrzDate(raw, kind, today /* "YYYY-MM-DD", UTC */) {
  if (kind !== "birth" && kind !== "expiry") throw new Error(`unknown kind: ${kind}`);
  if (!/^[0-9]{6}$/.test(raw)) return null; // fillers, letters or the wrong length
  const month = Number(raw.slice(2, 4));
  const day = Number(raw.slice(4, 6));
  let year = 2000 + Number(raw.slice(0, 2));
  const iso = (y) => `${y}-${raw.slice(2, 4)}-${raw.slice(4, 6)}`;
  if (kind === "birth" && iso(year) > today) year -= 100; // the 2000s reading is in the future
  // Date.UTC rolls 31 April over to 1 May; a changed month or day means no such date.
  const d = new Date(Date.UTC(year, month - 1, day));
  if (month < 1 || month > 12 || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) {
    return null;
  }
  return iso(year);
}

// In production:
// readMrzDate("740812", "birth", new Date().toISOString().slice(0, 10));

toISOString() è sempre in UTC, ed è questo a renderlo una fonte sicura per today.

Vettori di test

Ogni riga qui sotto è ricavata a mano dalla regola, con oggi fissato al 24 settembre 2026. Entrambe le funzioni dovrebbero restituire il valore della colonna "Atteso" (None in Python, null in JavaScript per "nessuna data").

Input Tipo Atteso Perché
740812 birth 1974-08-12 campione ICAO
340415 expiry 2034-04-15 campione ICAO
120415 expiry 2012-04-15 scaduto, resta 2012
940308 birth 1994-03-08 il 2094 è nel futuro
150101 birth 2015-01-01 il 2015 è nel passato
300101 birth 1930-01-01 il 2030 è nel futuro
261224 birth 1926-12-24 più avanti quest'anno, quindi secolo scorso
260925 birth 1926-09-25 domani, quindi secolo scorso
260924 birth 2026-09-24 oggi conta come passato
260923 birth 2026-09-23 ieri
261224 expiry 2026-12-24 la scadenza resta negli anni 2000
320310 expiry 2032-03-10 la scadenza resta negli anni 2000
280229 birth 1928-02-29 il 1928 è bisestile
000229 birth 2000-02-29 il 2000 è bisestile, il 1900 non lo era
270229 birth nessuna il 1927 non è bisestile
230229 expiry nessuna il 2023 non è bisestile
240229 expiry 2024-02-29 il 2024 è bisestile
941308 birth nessuna non esiste il mese 13
94<<08 birth nessuna caratteri di riempimento, non una data

Un test runner per la versione Python è un ciclo su queste righe con today = date(2026, 9, 24), che confronta result.isoformat() (o None) con la colonna dell'atteso. Tenete "oggi" fisso nei test: con l'orologio reale, righe come 261224 cambiano risposta il 25 dicembre.

Cosa fa il nostro parser MRZ

Il parser MRZ di questo sito segue la stessa regola: l'anno parte da 2000 + YY, una data di nascita la cui data completa è successiva a oggi (in UTC) torna indietro di 100 anni, il controllo del calendario arriva dopo la scelta del secolo e le date di scadenza restano negli anni 2000. I suoi test coprono una data più avanti in quest'anno, il giorno stesso e il 29 febbraio su entrambi i lati del secolo. Per un valore che non riesce a trasformare in data mostra invece il testo grezzo, con un ? dove la zona ha un carattere di riempimento, e quando sei cifre superano la cifra di controllo ma non corrispondono a nessuna data del calendario, lo segnala con un avviso. Una data di scadenza è indicata come scaduta o non scaduta rispetto alla data di oggi in UTC. Funziona nel browser, quindi potete incollare una zona sintetica e confrontarne la lettura con la vostra implementazione.

Dove si colloca in una pipeline

Se usate un servizio di riconoscimento in hosting, il secolo è già stato scelto per voi nei suoi campi data; la domanda è se la sua regola è quella che volete. La risposta di doc.cheap restituisce holder.birth_date e document.expiry_date in formato ISO YYYY-MM-DD e, accanto, la zona esattamente come è stata letta in mrz.lines, così potete estrarre da soli le sei cifre e applicare la funzione qui sopra quando una data decide un accesso o del denaro. Se state ancora scegliendo un servizio, il confronto tra API OCR per passaporti mette fianco a fianco i prezzi pubblicati.

Se trovate un input su cui la regola sbaglia, scrivete a admin@doc.cheap.