Todo passaporte tem, no pé da página de dados, duas linhas de texto com cara de poucos amigos: letras maiúsculas, dígitos e um monte de sinais <. É a zona de leitura mecânica, ou MRZ (sigla em inglês de machine-readable zone), definida pelo Doc 9303 da OACI (ICAO, em inglês), o padrão dos documentos de viagem. Ela traz os mesmos dados básicos da página impressa (nome, número do documento, nacionalidade, data de nascimento, sexo, data de validade) num formato que um leitor consegue ler sem adivinhar fontes nem layouts.
Ela também carrega a própria detecção de erros. Alguns dos caracteres são dígitos verificadores (check digits): cada um é calculado a partir de um campo específico, e um último dígito cobre vários campos de uma vez. Se um único caractere for lido errado, o dígito que o protege normalmente deixa de bater. Isso faz da MRZ uma das poucas coisas no processamento de documentos de identidade que você mesmo pode verificar, com vinte linhas de código e sem confiar no OCR de ninguém.
Este post percorre o algoritmo, mostra onde os dígitos ficam em cada um dos três formatos de MRZ e traz um validador em Python e JavaScript que você pode colar num projeto. Todos os exemplos usam o espécime fictício da própria OACI, Anna Maria Eriksson, de "Utopia" (UTO, um código de país que só existe em espécimes). Nenhum documento real aparece aqui.
Este é o blog do doc.cheap, uma API de reconhecimento de documentos que lê a MRZ e confere esses dígitos de novo no servidor. Nada do que vem a seguir depende dela; o código roda offline.
O alfabeto
Uma MRZ usa exatamente 37 caracteres: 0-9, A-Z e o caractere de preenchimento <. Não há letras minúsculas, espaços nem pontuação. Nomes com acentos ou em alfabetos não latinos são transliterados, e os espaços dentro de um campo viram <. O preenchimento também completa cada campo até sua largura fixa, então ERIKSSON<<ANNA<MARIA<<<<<<< quer dizer "sobrenome ERIKSSON, prenomes ANNA MARIA", com o << duplo separando o sobrenome dos prenomes.
O algoritmo: pesos 7, 3, 1
O dígito verificador é calculado do mesmo jeito para todos os campos de todos os formatos:
- Converta cada caractere em um número. Um dígito vale ele mesmo. Uma letra vale sua posição no alfabeto mais 9, então
A= 10,B= 11, …Z= 35. O preenchimento<vale 0. - Multiplique por um peso que se repete: 7, 3, 1, 7, 3, 1, … a partir do primeiro caractere do campo.
- Some os produtos e pegue o resto da divisão por 10. Esse único dígito é o dígito verificador.
Veja o cálculo com o número de passaporte do espécime, L898902C3, cujo dígito verificador impresso é 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
Por que 7-3-1? Os pesos foram escolhidos para que os erros de leitura mais comuns alterem a soma: um único caractere errado e muitas trocas de dois caracteres vizinhos. Não é um checksum criptográfico. Qualquer um pode calculá-lo, então um dígito que bate prova apenas que a zona é coerente consigo mesma, não que o documento seja autêntico.
Os três formatos
O ICAO 9303 define três layouts de MRZ. O número de linhas e de caracteres por linha diferencia um do outro:
| Formato | Linhas × caracteres | Onde você encontra |
|---|---|---|
| TD1 | 3 × 30 | Carteiras de identidade, autorizações de residência |
| TD2 | 2 × 36 | Carteiras de identidade mais antigas e alguns documentos de viagem |
| TD3 | 2 × 44 | Passaportes em caderneta |
Os espécimes usados a seguir:
TD3 P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<
L898902C36UTO7408122F1204159ZE184226B<<<<<10
TD2 I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<
D231458907UTO7408122F1204159<<<<<<<6
TD1 I<UTOD231458907<<<<<<<<<<<<<<<
7408122F1204159UTO<<<<<<<<<<<6
ERIKSSON<<ANNA<MARIA<<<<<<<<<<
Leia a segunda linha do TD3 da esquerda para a direita: L898902C3 é o número do documento, 6 seu dígito verificador, UTO a nacionalidade, 740812 a data de nascimento (AAMMDD), 2 seu dígito verificador, F o sexo, 120415 a data de validade, 9 seu dígito verificador, ZE184226B<<<<< os dados opcionais (muitas vezes um número pessoal), 1 seu dígito verificador e, por fim, 0, o dígito verificador composto.
O analisador de MRZ traz a posição de cada campo e de cada dígito verificador dos três formatos numa tabela de referência, e a página de formatos de MRZ explica cada layout.
Onde fica cada dígito verificador
As posições começam em 0, então entram direto num slice. O dígito verificador de cada campo vem imediatamente depois do campo.
| Campo | TD3 (linha 2) | TD2 (linha 2) | TD1 |
|---|---|---|---|
| Número do documento | 0–8, dígito em 9 | 0–8, dígito em 9 | linha 1: 5–13, dígito em 14 |
| Data de nascimento | 13–18, dígito em 19 | 13–18, dígito em 19 | linha 2: 0–5, dígito em 6 |
| Data de validade | 21–26, dígito em 27 | 21–26, dígito em 27 | linha 2: 8–13, dígito em 14 |
| Dados opcionais | 28–41, dígito em 42 | não há | não há |
| Composto | dígito em 43 | dígito em 35 | linha 2: dígito em 29 |
O dígito composto é onde a maioria dos validadores caseiros erra, porque ele não cobre a linha inteira:
- TD3: posições 0–9, 13–19 e 21–42 da linha 2. Ele pula a nacionalidade (10–12) e o sexo (20).
- TD2: posições 0–9, 13–19 e 21–34 da linha 2. Os mesmos saltos.
- TD1: ele ocupa duas linhas: posições 5–29 da linha 1 e depois posições 0–6, 8–14 e 18–28 da linha 2.
Cada intervalo inclui os dígitos verificadores dos campos que estão dentro dele, e é isso que faz o composto pegar erros nos próprios dígitos.
Um validador em Python
Sem dependências. Ele detecta o formato pelo tamanho, confere o dígito de cada campo e o composto, e devolve um dict com os resultados.
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):
# Um campo feito só de preenchimento pode imprimir "<" como dígito verificador.
expected = 0 if printed == "<" else int(printed)
return check_digit(data) == expected
# (nome, índice da linha, início, fim, posição do dígito verificador) por 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<<<<<<<<<<"]))
# Um caractere lido errado: o 3 lido como 4 no número do documento
print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C46UTO7408122F1204159ZE184226B<<<<<10"]))
Saída:
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}
A última linha é o objetivo de todo o exercício: um caractere lido como o vizinho, e tanto o dígito do campo quanto o composto acusam o erro.
O mesmo validador em JavaScript
Módulo ES puro, roda no Node ou no navegador.
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 = {};
// O dígito verificador fica logo depois do campo que ele protege.
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 imprime format: 'TD3' e true nas cinco verificações.
As armadilhas
Não corte o preenchimento. Os caracteres < fazem parte dos dados sobre os quais os dígitos são calculados. Tire os < do fim de uma linha e o composto falha num documento perfeitamente válido.
Não reconstrua a zona a partir dos campos extraídos. Se você separa a MRZ em campos, normaliza esses campos (datas em ISO, nomes com espaços) e depois serializa tudo de novo para conferir os dígitos, está conferindo o seu próprio serializador. Confira as linhas brutas, exatamente como foram lidas.
Normalize a saída do OCR antes de validar, com cuidado. Motores de OCR adoram devolver letras minúsculas, espaços ou « no lugar de <. Passar para maiúsculas e remover espaços em branco é seguro. Trocar O por 0 "porque número de documento é numérico" não é: números de documento podem ter letras, e L898902C3 mostra exatamente isso.
Um dígito que bate não é uma data real. 740812 passa no dígito verificador seja 12 de agosto de 1974 plausível ou não, e AAMMDD não diz o século. Decida o século pelo contexto: uma data de nascimento está no passado; uma data de validade, normalmente no futuro.
Números de documento longos no TD1. A OACI permite que um número de documento TD1 com mais de nove caracteres continue no campo de dados opcionais, com um < na posição normal do dígito verificador e o dígito verificador depois do último caractere do número. O validador acima não trata esse caso. Se você processa carteiras de identidade de emissores que usam isso, acrescente um desvio; o analisador de MRZ trata esse caso, se você quiser algo para comparar.
Dígito verificador não é autenticidade. Qualquer pessoa que saiba editar uma imagem consegue calcular um dígito válido. A MRZ diz que a zona foi lida corretamente e é coerente consigo mesma, não que o documento seja autêntico. Comparar a MRZ com a zona visual impressa é um sinal mais forte, e nem isso é uma checagem de falsificação.
Dados de teste sem passaportes reais
Você nunca deveria precisar do passaporte de uma pessoa real para testar este código. Duas opções:
- Os espécimes da OACI acima, publicados exatamente para isso.
- Gere os seus: o gerador de MRZ monta uma zona TD3 sintética com dígitos verificadores corretos a partir dos valores que você digita, no navegador. Troque um caractere depois e você tem um caso que falha.
No sentido inverso, cole qualquer zona (TD1, TD2 ou TD3) no analisador de MRZ: ele detecta o formato, lê cada campo e mostra cada dígito verificador calculado ao lado do impresso, tudo no navegador. É útil quando a sua implementação e a de outra pessoa discordam.
Onde isso entra num pipeline real
Se você lê MRZs com o seu próprio OCR, rode essas verificações em cada leitura e trate uma falha como "tire a foto de novo", não como "rejeite a pessoa": reflexo sobre um caractere, uma plastificação gasta ou uma página amassada são muito mais comuns do que fraude.
Se, em vez disso, você usa uma API de reconhecimento hospedada, recalcule os dígitos mesmo assim quando o resultado decide dinheiro ou acesso. É a única parte da resposta que você consegue conferir sem confiar no fornecedor. Isso vale para nós também: a resposta do doc.cheap publica a zona literalmente em mrz.lines e mrz.text (as linhas unidas sem nada entre elas), ao lado do seu próprio veredito em mrz.status, justamente para você poder passá-la para uma função como a mostrada acima. O guia Check an MRZ da documentação (em inglês) cobre esse fluxo.
Se encontrar um caso em que o validador erra, escreva para admin@doc.cheap.
Os dois blocos de código foram executados e a saída está colada exatamente como foi impressa; cada afirmação sobre o doc.cheap foi conferida com o código dele.