Chaque date d'une zone de lecture automatique tient en six chiffres : deux pour l'année, deux pour le mois, deux pour le jour. Il n'y a pas de siècle. Le passeport spécimen publié par l'OACI indique 740812 comme date de naissance, et rien dans la zone ne dit s'il s'agit de 1974 ou de 2074. Vous savez que c'est 1974 parce que personne ne détient de passeport avant sa naissance, et ce seul raisonnement constitue toute la règle. L'appliquer exactement, y compris le jour où cela compte, demande un peu plus de soin qu'une année de bascule fixe.
Cet article donne la règle pour les dates de naissance et pour les dates d'expiration, les cas limites qui font échouer les parsers simples, ainsi qu'une fonction en Python et en JavaScript avec un tableau de vecteurs de test à intégrer à votre propre suite. Tous les exemples utilisent le spécimen fictif de l'OACI ou des valeurs synthétiques ; aucun document réel n'apparaît.
Ceci est le blog de doc.cheap, une API OCR pour passeports et pièces d'identité qui lit la zone à votre place. Rien de ce qui suit n'en a besoin ; le code n'a aucune dépendance.
Où se trouvent les dates
Le Doc 9303 de l'OACI, la norme des documents de voyage lisibles à la machine, donne à chaque format deux champs de date : date de naissance et date d'expiration, chacune écrite sous la forme YYMMDD et suivie de son propre chiffre de contrôle. Les positions commencent à 0, prêtes pour slice :
| Format | Date de naissance | Date d'expiration |
|---|---|---|
| TD3 (passeports), ligne 2 | 13–18, chiffre de contrôle en 19 | 21–26, chiffre de contrôle en 27 |
| TD2, ligne 2 | 13–18, chiffre de contrôle en 19 | 21–26, chiffre de contrôle en 27 |
| TD1 (cartes d'identité), ligne 2 | 0–5, chiffre de contrôle en 6 | 8–13, chiffre de contrôle en 14 |
Sur la ligne TD3 du spécimen, L898902C36UTO7408122F3404159ZE184226B<<<<<16, cela donne 740812 pour la date de naissance et 340415 pour l'expiration.
Pourquoi une année de bascule fixe échoue
Le raccourci habituel est un pivot : les années à deux chiffres inférieures à un certain nombre vont dans les années 2000, les autres dans les années 1900. Le time.strptime de Python lui-même fonctionne ainsi pour %y. Sa documentation dit : "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."
C'est une valeur par défaut raisonnable pour un fichier de log. Pour une date de naissance, elle est fausse dans les deux sens. Selon cette règle, une personne née le 12 avril 1968 (680412) est née en 2068. Déplacez le pivot et vous ne faites que déplacer le bug : tout nombre fixe est faux pour quelqu'un, et il l'est un peu plus chaque année où le code reste en production. La bonne réponse dépend de la date du jour, donc la règle doit recevoir la date du jour en entrée.
Dates de naissance : le siècle le plus récent qui ne soit pas dans le futur
Lisez l'année comme 2000 + YY. Si la date complète obtenue est postérieure à aujourd'hui, retranchez 100 ans. C'est tout.
Trois détails décident si une implémentation est vraiment correcte :
- Comparez la date entière, pas seulement l'année. Dans les exemples ci-dessous, nous sommes le 24 septembre 2026.
261224est le 24 décembre 2026 dans la lecture des années 2000, dans trois mois, donc la personne est née le 24 décembre 1926. Comparer seulement26à26la prendrait pour un nouveau-né. - Aujourd'hui compte comme le passé. Une personne née aujourd'hui est née dans ce siècle, pas dans le précédent.
260924reste le 24 septembre 2026 ;260925, un jour plus tard, revient à 1926. Utilisez « postérieur à », pas « postérieur ou égal à ». - Prenez « aujourd'hui » en UTC, ou dans un fuseau fixe de votre choix, et passez-le en argument. Une fonction qui lit elle-même l'horloge ne peut pas être testée, et un serveur dans un fuseau et un navigateur dans un autre peuvent être en désaccord sur la date pendant une partie de chaque journée.
Dates d'expiration : ce siècle
Une date d'expiration se lit comme 2000 + YY et reste telle quelle. Un passeport expiré en 2012 reste une date de 2012, pas de 2112, et un passeport qui expire en 2032 est de 2032. La lecture en 1900 ne serait juste que pour un document expiré avant 2000, et aucun document de ce type n'est encore en usage. Cette règle tient jusqu'aux années 2090, quand un document délivré à ce moment-là pourrait expirer après 2100 ; le code devra être revu avant cela, pas maintenant.
Les cas limites qui méritent un test
Le 29 février de part et d'autre du siècle. Choisissez d'abord le siècle, puis vérifiez que la date existe. 280229 comme date de naissance est postérieur à aujourd'hui dans la lecture des années 2000, il devient donc le 29 février 1928, qui existe parce que 1928 est bissextile. 000229 est le 29 février 2000, qui existe parce que 2000 est bissextile ; en 1900, qui ne l'était pas, les mêmes six chiffres ne formeraient aucune date. 00 est la seule année à deux chiffres pour laquelle le siècle change la réponse à « est-ce une année bissextile ? ». Validez la date avant de choisir le siècle et l'un de ces cas tournera mal.
Une date de naissance plus tard dans l'année. Vu plus haut : 261224 est 1926, pas 2026, et un test doit le fixer avec un « aujourd'hui » figé.
Des caractères de remplissage dans la date. Une zone peut contenir < dans une date de naissance lorsqu'une partie de la date est inconnue, par exemple 94<<08. Il n'y a aucune date du calendrier à renvoyer. La fonction ci-dessous ne renvoie rien pour toute valeur qui n'est pas composée de six chiffres, et laisse l'appelant afficher le texte brut.
Un chiffre de contrôle valide sur une date impossible. Le chiffre de contrôle protège les caractères, pas le calendrier. Avec les poids 7, 3, 1, 741312 donne une somme de 88, son chiffre de contrôle est donc 8, et 7413128 passe la vérification. Le mois 13 n'existe pas. Le chiffre de contrôle et la date sont deux vérifications distinctes, et les deux doivent réussir ; le fonctionnement des chiffres de contrôle de la MRZ détaille la première.
Les personnes de plus de 100 ans. Une personne née en 1925 et un bébé né en 2025 ont les mêmes six chiffres. La zone seule ne peut pas les distinguer, et la règle ci-dessus choisit la lecture la plus jeune. Si vos utilisateurs comptent des centenaires, comparez avec la date imprimée sur la page de données, là où une année à quatre chiffres apparaît quand le document en imprime une.
La fonction en Python
Aucune dépendance. today est un datetime.date passé par l'appelant.
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())
Le motif est [0-9], et non \d ou str.isdigit(), à dessein : ces deux-là acceptent des chiffres d'autres écritures, alors qu'une MRZ ne contient que des chiffres ASCII. La comparaison utilise un tuple afin qu'un mois impossible se compare sans erreur avant que date() ne le rejette.
La même fonction en JavaScript
today est une chaîne YYYY-MM-DD. Deux chaînes de ce type se comparent correctement comme du simple texte, ce qui tient la règle du siècle en une ligne.
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() est toujours en UTC, et c'est ce qui en fait une source sûre pour today.
Vecteurs de test
Chaque ligne ci-dessous est calculée à la main à partir de la règle, avec aujourd'hui fixé au 24 septembre 2026. Les deux fonctions doivent renvoyer la valeur de la colonne « Attendu » (None en Python, null en JavaScript pour « aucune date »).
| Entrée | Type | Attendu | Pourquoi |
|---|---|---|---|
740812 |
birth | 1974-08-12 | spécimen OACI |
340415 |
expiry | 2034-04-15 | spécimen OACI |
120415 |
expiry | 2012-04-15 | expiré, reste 2012 |
940308 |
birth | 1994-03-08 | 2094 est dans le futur |
150101 |
birth | 2015-01-01 | 2015 est dans le passé |
300101 |
birth | 1930-01-01 | 2030 est dans le futur |
261224 |
birth | 1926-12-24 | plus tard cette année, donc siècle précédent |
260925 |
birth | 1926-09-25 | demain, donc siècle précédent |
260924 |
birth | 2026-09-24 | aujourd'hui compte comme le passé |
260923 |
birth | 2026-09-23 | hier |
261224 |
expiry | 2026-12-24 | l'expiration reste dans les années 2000 |
320310 |
expiry | 2032-03-10 | l'expiration reste dans les années 2000 |
280229 |
birth | 1928-02-29 | 1928 est bissextile |
000229 |
birth | 2000-02-29 | 2000 est bissextile, 1900 ne l'était pas |
270229 |
birth | aucune | 1927 n'est pas bissextile |
230229 |
expiry | aucune | 2023 n'est pas bissextile |
240229 |
expiry | 2024-02-29 | 2024 est bissextile |
941308 |
birth | aucune | il n'y a pas de mois 13 |
94<<08 |
birth | aucune | caractères de remplissage, pas une date |
Un exécuteur de tests pour la version Python est une boucle sur ces lignes avec today = date(2026, 9, 24), qui compare result.isoformat() (ou None) à la colonne attendue. Gardez « aujourd'hui » figé dans les tests : avec l'horloge réelle, des lignes comme 261224 changent de réponse le 25 décembre.
Ce que fait notre parser MRZ
Le parser MRZ de ce site suit la même règle : l'année part de 2000 + YY, une date de naissance dont la date complète est postérieure à aujourd'hui (en UTC) recule de 100 ans, la vérification du calendrier vient après le choix du siècle, et les dates d'expiration restent dans les années 2000. Ses propres tests couvrent une date plus tard dans l'année, le jour même et le 29 février des deux côtés du siècle. Pour une valeur qu'il ne peut pas transformer en date, il affiche le texte brut à la place, avec un ? là où la zone contient un caractère de remplissage, et lorsque six chiffres passent leur chiffre de contrôle sans correspondre à aucune date du calendrier, il le signale par un avertissement. Une date d'expiration est marquée expirée ou non expirée par rapport à la date du jour en UTC. Il fonctionne dans le navigateur : vous pouvez coller une zone synthétique et comparer sa lecture avec votre propre implémentation.
Où cela s'insère dans un pipeline
Si vous utilisez un service de reconnaissance hébergé, le siècle a déjà été choisi pour vous dans ses champs de date ; la question est de savoir si sa règle est celle que vous voulez. La réponse de doc.cheap renvoie holder.birth_date et document.expiry_date au format ISO YYYY-MM-DD et, à côté, la zone exactement telle qu'elle a été lue dans mrz.lines, pour que vous puissiez extraire vous-même les six chiffres et appliquer la fonction ci-dessus lorsqu'une date décide d'un accès ou d'argent. Si vous choisissez encore un service, le comparatif des API OCR de passeport met les prix publiés côte à côte.
Si vous trouvez une entrée sur laquelle la règle se trompe, écrivez à admin@doc.cheap.