Todo pasaporte tiene, al pie de la página de datos, dos líneas de texto de aspecto poco amistoso: mayúsculas, dígitos y un montón de signos <. Es la zona de lectura mecánica, o MRZ (por sus siglas en inglés), y la define el Doc 9303 de la OACI (ICAO en inglés), la norma de los documentos de viaje. Contiene los mismos datos básicos que la página impresa (nombre, número de documento, nacionalidad, fecha de nacimiento, sexo, fecha de caducidad) en una forma que un escáner puede leer sin adivinar tipos de letra ni maquetaciones.

Además lleva su propia detección de errores. Algunos de sus caracteres son dígitos de control: cada uno se calcula a partir de un campo concreto, y un último dígito cubre varios campos a la vez. Si se lee mal un solo carácter, el dígito que lo protege normalmente deja de coincidir. Eso convierte la MRZ en una de las pocas cosas del procesamiento de documentos de identidad que puedes verificar tú mismo, con veinte líneas de código y sin fiarte del OCR de nadie.

Este artículo recorre el algoritmo, dónde se sitúan los dígitos en cada uno de los tres formatos de MRZ y un validador en Python y JavaScript que puedes pegar en un proyecto. Todos los ejemplos usan el espécimen ficticio de la propia OACI, Anna Maria Eriksson de “Utopía” (UTO, un código de país que solo existe en especímenes). No aparece ningún documento real.

Este es el blog de doc.cheap, una API de reconocimiento de documentos que lee la MRZ y vuelve a comprobar estos dígitos en el servidor. Nada de lo que sigue la necesita; el código funciona sin conexión.

El alfabeto

Una MRZ usa exactamente 37 caracteres: 0-9, A-Z y el carácter de relleno <. No hay minúsculas, ni espacios, ni signos de puntuación. Los nombres con tildes o en alfabetos no latinos se transliteran, y los espacios dentro de un campo se convierten en <. El relleno también completa cada campo hasta su ancho fijo, así que ERIKSSON<<ANNA<MARIA<<<<<<< significa “apellido ERIKSSON, nombres ANNA MARIA”, con el doble << separando el apellido de los nombres.

El algoritmo: pesos 7, 3, 1

Un dígito de control se calcula igual para cualquier campo de cualquier formato:

  1. Convierte cada carácter en un número. Un dígito vale su propio valor. Una letra vale su posición en el alfabeto más 9, así que A = 10, B = 11, … Z = 35. El relleno < vale 0.
  2. Multiplica por un peso que se repite, 7, 3, 1, 7, 3, 1, …, empezando por el primer carácter del campo.
  3. Suma los productos y quédate con el resto de dividir entre 10. Ese único dígito es el dígito de control.

Aplicado paso a paso al número de pasaporte del espécimen, L898902C3, cuyo dígito de control impreso es 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 qué 7-3-1? Los pesos están elegidos para que los errores de lectura más comunes cambien la suma: un solo carácter erróneo y muchos intercambios de dos caracteres contiguos. No es una suma de verificación criptográfica. Cualquiera puede calcularla, así que un dígito que coincide solo demuestra que la zona es coherente consigo misma, no que el documento sea auténtico.

Los tres formatos

El Doc 9303 de la OACI define tres disposiciones de MRZ. El número de líneas y de caracteres por línea las distingue:

Formato Líneas × caracteres Dónde lo encuentras
TD1 3 × 30 Tarjetas de identidad, permisos de residencia
TD2 2 × 36 Tarjetas de identidad antiguas y algunos documentos de viaje
TD3 2 × 44 Pasaportes en formato libreta

Los especímenes que se usan a continuación:

TD3  P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<
     L898902C36UTO7408122F1204159ZE184226B<<<<<10

TD2  I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<
     D231458907UTO7408122F1204159<<<<<<<6

TD1  I<UTOD231458907<<<<<<<<<<<<<<<
     7408122F1204159UTO<<<<<<<<<<<6
     ERIKSSON<<ANNA<MARIA<<<<<<<<<<

Lee la segunda línea del TD3 de izquierda a derecha: L898902C3 es el número de documento, 6 su dígito de control, UTO la nacionalidad, 740812 la fecha de nacimiento (AAMMDD), 2 su dígito de control, F el sexo, 120415 la fecha de caducidad, 9 su dígito de control, ZE184226B<<<<< los datos opcionales (a menudo un número personal), 1 su dígito de control y, por último, 0, el dígito de control compuesto.

El analizador de MRZ incluye como tabla de referencia la posición de cada campo y de cada dígito de control en los tres formatos, y la página de formatos MRZ explica cada disposición.

Dónde va cada dígito de control

Las posiciones empiezan en 0, así que encajan directamente en slice. El dígito de control de cada campo va justo después del campo.

Campo TD3 (línea 2) TD2 (línea 2) TD1
Número de documento 0–8, dígito en 9 0–8, dígito en 9 línea 1: 5–13, dígito en 14
Fecha de nacimiento 13–18, dígito en 19 13–18, dígito en 19 línea 2: 0–5, dígito en 6
Fecha de caducidad 21–26, dígito en 27 21–26, dígito en 27 línea 2: 8–13, dígito en 14
Datos opcionales 28–41, dígito en 42 no hay no hay
Compuesto dígito en 43 dígito en 35 línea 2: dígito en 29

El dígito compuesto es donde fallan la mayoría de los validadores caseros, porque no cubre la línea entera:

  • TD3: posiciones 0–9, 13–19 y 21–42 de la línea 2. Se salta la nacionalidad (10–12) y el sexo (20).
  • TD2: posiciones 0–9, 13–19 y 21–34 de la línea 2. Los mismos saltos.
  • TD1: abarca dos líneas: las posiciones 5–29 de la línea 1 y, después, las posiciones 0–6, 8–14 y 18–28 de la línea 2.

Cada rango incluye los dígitos de control de los campos que contiene, y eso es lo que permite al compuesto detectar errores en los propios dígitos.

Un validador en Python

Sin dependencias. Detecta el formato por su forma, comprueba cada dígito de campo y el compuesto, y devuelve un dict con los 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):
    # Un campo formado solo por relleno puede imprimir "<" como dígito de control.
    expected = 0 if printed == "<" else int(printed)
    return check_digit(data) == expected

# (nombre, índice de línea, inicio, fin, posición del dígito de control) 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<<<<<<<<<<"]))
    # Un carácter mal leído: el 3 leído como 4 en el número de documento
    print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
                     "L898902C46UTO7408122F1204159ZE184226B<<<<<10"]))

Salida:

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 última línea es el sentido de todo el ejercicio: un carácter confundido con su vecino, y tanto el dígito del campo como el compuesto lo señalan.

El mismo validador en JavaScript

Un módulo ES sin más, que funciona en Node o en un 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 = {};
  // El dígito de control va justo después del campo que 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' y true en las cinco comprobaciones.

Las trampas

No recortes el relleno. Los caracteres < forman parte de los datos sobre los que se calculan los dígitos. Quita los < finales de una línea y el compuesto fallará con un documento perfectamente válido.

No reconstruyas la zona a partir de los campos analizados. Si separas la MRZ en campos, los normalizas (fechas a ISO, nombres con espacios) y luego los vuelves a serializar para comprobar los dígitos, estás comprobando tu propio serializador. Comprueba las líneas en bruto tal como se leyeron.

Normaliza la salida del OCR antes de validar, con cuidado. Los motores de OCR tienden a devolver minúsculas, espacios o « en lugar de <. Pasar a mayúsculas y quitar los espacios en blanco es seguro. Sustituir O por 0 “porque los números de documento son numéricos” no lo es: los números de documento pueden contener letras, y L898902C3 es precisamente un ejemplo.

Un dígito correcto no es una fecha real. 740812 pasa su dígito de control tanto si el 12 de agosto de 1974 es verosímil como si no, y AAMMDD no indica el siglo. Decide el siglo por el contexto: una fecha de nacimiento está en el pasado; una fecha de caducidad, normalmente en el futuro.

Números de documento largos en TD1. La OACI permite que un número de documento de TD1 de más de nueve caracteres continúe en el campo de datos opcionales, con un < en la posición normal del dígito de control y el dígito de control después del último carácter del número. El validador de arriba no cubre ese caso. Si procesas tarjetas de identidad de emisores que lo usan, añade una rama; el analizador de MRZ sí lo contempla, por si quieres algo con lo que comparar.

Los dígitos de control no son autenticidad. Cualquiera que sepa editar una imagen puede calcular un dígito válido. La MRZ te dice que la zona se leyó correctamente y que es coherente consigo misma, no que el documento sea auténtico. Comparar la MRZ con la zona visual impresa es una señal más fuerte, y ni siquiera eso es una comprobación de falsificación.

Datos de prueba sin pasaportes reales

Nunca deberías necesitar el pasaporte de una persona real para probar este código. Dos opciones:

  • Los especímenes de la OACI de arriba, publicados precisamente para esto.
  • Generar los tuyos: el generador de MRZ construye en el navegador una zona TD3 sintética con dígitos de control correctos a partir de los valores que escribas. Cambia después un carácter y tendrás un caso que falla.

Para la dirección contraria, pega cualquier zona (TD1, TD2 o TD3) en el analizador de MRZ: detecta el formato, lee cada campo y muestra cada dígito de control calculado junto al impreso, todo en el navegador. Resulta útil cuando tu implementación y la de otra persona no coinciden.

Dónde encaja en un flujo real

Si lees MRZ con tu propio OCR, ejecuta estas comprobaciones en cada lectura y trata un fallo como “vuelve a hacer la foto”, no como “rechaza a la persona”: un reflejo sobre un carácter, un laminado desgastado o una página doblada son mucho más frecuentes que un fraude.

Si en cambio usas una API de reconocimiento alojada, vuelve a calcular tú mismo los dígitos cuando el resultado decida sobre dinero o acceso. Es la única parte de la respuesta que puedes comprobar sin fiarte del proveedor. Eso nos incluye a nosotros: la respuesta de doc.cheap publica la zona literal en mrz.lines y mrz.text (las líneas unidas sin nada entre ellas) junto a su propio veredicto mrz.status, precisamente para que puedas pasarla a una función como la de arriba. La guía Check an MRZ de la documentación (en inglés) describe ese flujo.

Si encuentras un caso en el que el validador se equivoca, escribe a admin@doc.cheap.

Los dos bloques de código se ejecutaron y su salida se reproduce tal como se imprimió; cada afirmación sobre doc.cheap se comprobó contra su código.