Tout passeport porte, en bas de sa page de données, deux lignes de texte à l'air peu engageant : des majuscules, des chiffres et une foule de signes <. C'est la zone de lecture automatique, ou MRZ (de l'anglais machine-readable zone), définie par le Doc 9303 de l'OACI (ICAO en anglais), la norme des documents de voyage. Elle contient les mêmes informations essentielles que la page imprimée (nom, numéro du document, nationalité, date de naissance, sexe, date d'expiration), sous une forme qu'un scanner peut lire sans deviner les polices ni la mise en page.
Elle embarque aussi sa propre détection d'erreurs. Certains de ses caractères sont des chiffres de contrôle (check digits) : chacun est calculé à partir d'un champ précis, et un dernier chiffre couvre plusieurs champs à la fois. Si un seul caractère est mal lu, le chiffre qui le protège cesse en général de correspondre. La MRZ est ainsi l'une des rares choses du traitement des documents d'identité que vous pouvez vérifier vous-même, avec vingt lignes de code et sans faire confiance à l'OCR de qui que ce soit.
Cet article détaille l'algorithme, la position des chiffres dans chacun des trois formats de MRZ, et un validateur en Python et JavaScript à coller dans un projet. Tous les exemples utilisent le spécimen fictif de l'OACI elle-même, Anna Maria Eriksson d'« Utopia » (UTO, un code pays qui n'existe que sur les spécimens). Aucun document réel n'apparaît nulle part.
Ceci est le blog de doc.cheap, une API de reconnaissance de documents qui lit la MRZ et revérifie ces chiffres côté serveur. Rien de ce qui suit n'en a besoin : le code tourne hors ligne.
L'alphabet
Une MRZ utilise exactement 37 caractères : 0-9, A-Z et le caractère de remplissage <. Pas de minuscules, pas d'espaces, pas de ponctuation. Les noms avec accents ou en écriture non latine sont translittérés, et les espaces à l'intérieur d'un champ deviennent <. Le remplissage complète aussi chaque champ jusqu'à sa largeur fixe : ERIKSSON<<ANNA<MARIA<<<<<<< se lit donc « nom ERIKSSON, prénoms ANNA MARIA », le double << séparant le nom des prénoms.
L'algorithme : pondérations 7, 3, 1
Un chiffre de contrôle se calcule de la même façon pour chaque champ de chaque format :
- Convertir chaque caractère en nombre. Un chiffre vaut sa propre valeur. Une lettre vaut son rang dans l'alphabet plus 9, donc
A= 10,B= 11, …Z= 35. Le remplissage<vaut 0. - Multiplier par une pondération répétée de 7, 3, 1, 7, 3, 1, … à partir du premier caractère du champ.
- Additionner les produits et prendre le reste modulo 10. Ce chiffre unique est le chiffre de contrôle.
Appliqué au numéro de passeport du spécimen, L898902C3, dont le chiffre de contrôle imprimé est 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
Pourquoi 7-3-1 ? Les pondérations sont choisies pour que les erreurs de lecture les plus courantes modifient la somme : un seul caractère faux, et de nombreuses inversions de deux caractères voisins. Ce n'est pas une somme de contrôle cryptographique. N'importe qui peut la calculer, donc un chiffre qui correspond prouve seulement que la zone est cohérente en elle-même, pas que le document est authentique.
Les trois formats
Le Doc 9303 de l'OACI définit trois dispositions de MRZ. Le nombre de lignes et de caractères par ligne permet de les distinguer :
| Format | Lignes × caractères | Où on le rencontre |
|---|---|---|
| TD1 | 3 × 30 | Cartes d'identité, titres de séjour |
| TD2 | 2 × 36 | Anciennes cartes d'identité et certains documents de voyage |
| TD3 | 2 × 44 | Passeports (livrets) |
Les spécimens utilisés plus bas :
TD3 P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<
L898902C36UTO7408122F1204159ZE184226B<<<<<10
TD2 I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<
D231458907UTO7408122F1204159<<<<<<<6
TD1 I<UTOD231458907<<<<<<<<<<<<<<<
7408122F1204159UTO<<<<<<<<<<<6
ERIKSSON<<ANNA<MARIA<<<<<<<<<<
Lisez la deuxième ligne du TD3 de gauche à droite : L898902C3 numéro du document, 6 son chiffre de contrôle, UTO nationalité, 740812 date de naissance (AAMMJJ), 2 son chiffre de contrôle, F sexe, 120415 date d'expiration, 9 son chiffre de contrôle, ZE184226B<<<<< données facultatives (souvent un numéro personnel), 1 son chiffre de contrôle, et enfin 0, le chiffre de contrôle composite.
Le parseur MRZ donne, sous forme de tableau de référence, la position de chaque champ et de chaque chiffre de contrôle dans les trois formats, et la page des formats MRZ présente chaque disposition en détail.
Où se trouve chaque chiffre de contrôle
Les positions commencent à 0 et s'utilisent donc telles quelles avec slice. Le chiffre de contrôle de chaque champ suit immédiatement le champ.
| Champ | TD3 (ligne 2) | TD2 (ligne 2) | TD1 |
|---|---|---|---|
| Numéro du document | 0–8, chiffre en 9 | 0–8, chiffre en 9 | ligne 1 : 5–13, chiffre en 14 |
| Date de naissance | 13–18, chiffre en 19 | 13–18, chiffre en 19 | ligne 2 : 0–5, chiffre en 6 |
| Date d'expiration | 21–26, chiffre en 27 | 21–26, chiffre en 27 | ligne 2 : 8–13, chiffre en 14 |
| Données facultatives | 28–41, chiffre en 42 | aucun | aucun |
| Composite | chiffre en 43 | chiffre en 35 | ligne 2 : chiffre en 29 |
C'est sur le chiffre composite que se trompent la plupart des validateurs faits maison, car il ne couvre pas toute la ligne :
- TD3 : positions 0–9, 13–19 et 21–42 de la ligne 2. Il saute la nationalité (10–12) et le sexe (20).
- TD2 : positions 0–9, 13–19 et 21–34 de la ligne 2. Mêmes sauts.
- TD1 : il s'étend sur deux lignes : ligne 1 positions 5–29, puis ligne 2 positions 0–6, 8–14 et 18–28.
Chaque plage inclut les chiffres de contrôle des champs qu'elle contient, ce qui permet au composite de détecter aussi les erreurs dans ces chiffres eux-mêmes.
Un validateur en Python
Sans dépendance. Il détecte le format d'après la forme, vérifie le chiffre de chaque champ et le composite, et renvoie un dictionnaire de résultats.
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 champ fait uniquement de remplissage peut imprimer "<" comme chiffre de contrôle.
expected = 0 if printed == "<" else int(printed)
return check_digit(data) == expected
# (nom, indice de ligne, début, fin, position du chiffre de contrôle) par format
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 caractère mal lu : le 3 lu comme un 4 dans le numéro du document
print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C46UTO7408122F1204159ZE184226B<<<<<10"]))
Sortie :
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}
La dernière ligne est tout l'intérêt de l'exercice : un seul caractère confondu avec un voisin, et le chiffre du champ comme le composite le signalent.
Le même validateur en JavaScript
Un simple module ES, qui tourne dans Node ou dans un navigateur.
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 = {};
// Le chiffre de contrôle suit directement le champ qu'il protège.
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 affiche format: 'TD3' et true pour les cinq vérifications.
Les pièges
Ne supprimez pas le remplissage. Les caractères < font partie des données sur lesquelles les chiffres sont calculés. Retirez les < en fin de ligne et le composite échoue sur un document parfaitement valide.
Ne reconstruisez pas la zone à partir des champs analysés. Si vous découpez la MRZ en champs, les normalisez (dates en ISO, noms avec espaces) puis les re-sérialisez pour vérifier les chiffres, c'est votre propre sérialiseur que vous vérifiez. Vérifiez les lignes brutes, telles qu'elles ont été lues.
Normalisez la sortie de l'OCR avant de valider, avec prudence. Les moteurs d'OCR renvoient volontiers des minuscules, des espaces, ou « à la place de <. Passer en majuscules et supprimer les blancs ne pose pas de problème. Remplacer O par 0 « parce que les numéros de document sont numériques », en revanche, en pose un : un numéro de document peut contenir des lettres, et L898902C3 en est justement la preuve.
Un chiffre correct ne fait pas une date réelle. 740812 passe son chiffre de contrôle que le 12 août 1974 soit plausible ou non, et le format AAMMJJ n'a pas de siècle. Déduisez le siècle du contexte : une date de naissance est dans le passé, une date d'expiration généralement dans le futur.
Numéros de document longs en TD1. L'OACI autorise un numéro de document TD1 de plus de neuf caractères à déborder dans le champ des données facultatives, avec un < à la place habituelle du chiffre de contrôle et le chiffre de contrôle après le dernier caractère du numéro. Le validateur ci-dessus ne gère pas ce cas. Si vous traitez des cartes d'identité d'émetteurs qui l'utilisent, ajoutez une branche ; le parseur MRZ le gère, si vous voulez un point de comparaison.
Les chiffres de contrôle ne prouvent pas l'authenticité. Toute personne capable de modifier une image peut calculer un chiffre valide. La MRZ vous dit que la zone a été correctement lue et qu'elle est cohérente en elle-même, pas que le document est authentique. Comparer la MRZ à la zone visuelle imprimée est un signal plus fort, et même cela n'est pas une détection de faux.
Des données de test sans vrais passeports
Vous ne devriez jamais avoir besoin du passeport d'une personne réelle pour tester ce code. Deux options :
- Les spécimens de l'OACI ci-dessus, publiés précisément dans ce but.
- Générer les vôtres : le générateur de MRZ construit dans le navigateur une zone TD3 synthétique, avec des chiffres de contrôle corrects, à partir des valeurs que vous saisissez. Modifiez ensuite un caractère et vous obtenez un cas d'échec.
Dans l'autre sens, collez n'importe quelle zone (TD1, TD2 ou TD3) dans le parseur MRZ : il détecte le format, lit chaque champ et affiche chaque chiffre de contrôle calculé à côté du chiffre imprimé, le tout dans le navigateur. Pratique quand votre implémentation et celle de quelqu'un d'autre ne sont pas d'accord.
Où cela s'insère dans une vraie chaîne de traitement
Si vous lisez les MRZ avec votre propre OCR, lancez ces vérifications à chaque lecture et traitez un échec comme « à reprendre en photo », pas comme « personne à refuser » : un reflet sur un caractère, un plastique usé ou une page pliée sont bien plus fréquents qu'une fraude.
Si vous utilisez plutôt une API de reconnaissance hébergée, recalculez quand même les chiffres vous-même lorsque le résultat engage de l'argent ou un accès. C'est la seule partie de la réponse que vous pouvez vérifier sans faire confiance au fournisseur. Nous compris : la réponse de doc.cheap publie la zone telle quelle dans mrz.lines et mrz.text (les lignes accolées sans rien entre elles), à côté de son propre verdict mrz.status, précisément pour que vous puissiez la passer à une fonction comme celle ci-dessus. Le guide Check an MRZ de la documentation (en anglais) décrit ce parcours.
Si vous trouvez un cas où le validateur se trompe, écrivez à admin@doc.cheap.
Les deux blocs de code ont été exécutés et leur sortie est reproduite telle qu'elle s'est affichée ; chaque affirmation sur doc.cheap a été vérifiée dans son code.