Cada fecha de una zona de lectura mecánica tiene seis dígitos: dos para el año, dos para el mes y dos para el día. No hay siglo. El pasaporte de muestra que publica la OACI indica 740812 como fecha de nacimiento, y nada en la zona dice si eso es 1974 o 2074. Usted sabe que es 1974 porque nadie tiene pasaporte antes de nacer, y ese único razonamiento es toda la regla. Aplicarla con total exactitud, también el día en que importa, exige algo más de cuidado que un año de corte fijo.
Este artículo da la regla para las fechas de nacimiento y para las de caducidad, los casos límite que rompen los parsers sencillos y una función en Python y en JavaScript con una tabla de vectores de prueba que puede incorporar a su propia batería de tests. Todos los ejemplos usan el espécimen ficticio de la OACI o valores sintéticos; no aparece ningún documento real.
Este es el blog de doc.cheap, una API de OCR para pasaportes y documentos de identidad que lee la zona por usted. Nada de lo que sigue la necesita; el código no tiene dependencias.
Dónde están las fechas
El Doc 9303 de la OACI, la norma para documentos de viaje de lectura mecánica, da a cada formato dos campos de fecha: fecha de nacimiento y fecha de caducidad, cada una escrita como YYMMDD y seguida de su propio dígito de control. Las posiciones empiezan en 0, listas para slice:
| Formato | Fecha de nacimiento | Fecha de caducidad |
|---|---|---|
| TD3 (pasaportes), línea 2 | 13–18, dígito de control en 19 | 21–26, dígito de control en 27 |
| TD2, línea 2 | 13–18, dígito de control en 19 | 21–26, dígito de control en 27 |
| TD1 (documentos de identidad), línea 2 | 0–5, dígito de control en 6 | 8–13, dígito de control en 14 |
En la línea TD3 del espécimen, L898902C36UTO7408122F3404159ZE184226B<<<<<16, eso da 740812 como fecha de nacimiento y 340415 como caducidad.
Por qué falla un año de corte fijo
El atajo habitual es un pivote: los años de dos dígitos por debajo de cierto número van a los 2000 y el resto a los 1900. El propio time.strptime de Python funciona así con %y. Su documentación 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."
Es un valor por defecto razonable para un archivo de log. Para una fecha de nacimiento es erróneo en ambas direcciones. Según esa regla, una persona nacida el 12 de abril de 1968 (680412) nace en 2068. Si mueve el pivote, solo mueve el error: cualquier número fijo es erróneo para alguien, y lo es cada año más mientras el código siga en producción. La respuesta correcta depende de la fecha de hoy, así que la regla tiene que recibir la fecha de hoy como entrada.
Fechas de nacimiento: el siglo más reciente que no esté en el futuro
Lea el año como 2000 + YY. Si la fecha completa resultante es posterior a hoy, reste 100 años. Eso es todo.
Tres detalles deciden si una implementación es realmente correcta:
- Compare la fecha completa, no solo el año. En los ejemplos de abajo, hoy es el 24 de septiembre de 2026.
261224es el 24 de diciembre de 2026 en la lectura de los 2000, dentro de tres meses, así que la persona nació el 24 de diciembre de 1926. Comparar solo26con26la tomaría por un recién nacido. - Hoy cuenta como pasado. Una persona nacida hoy nació en este siglo, no en el anterior.
260924sigue siendo el 24 de septiembre de 2026;260925, un día después, vuelve a 1926. Use "posterior a", no "posterior o igual a". - Tome "hoy" en UTC, o en una zona horaria fija de su elección, y páselo como argumento. Una función que lee el reloj por sí misma no se puede probar, y un servidor en una zona y un navegador en otra pueden discrepar sobre la fecha durante parte de cada día.
Fechas de caducidad: este siglo
Una fecha de caducidad se lee como 2000 + YY y se deja así. Un pasaporte que caducó en 2012 sigue siendo una fecha de 2012, no de 2112, y uno que caduca en 2032 es de 2032. La lectura de los 1900 solo sería correcta para un documento que caducó antes de 2000, y ningún documento así sigue en uso. Esta regla vale hasta la década de 2090, cuando un documento emitido entonces podría caducar en los 2100; el código necesitará una revisión antes de eso, no ahora.
Los casos límite que merecen un test
El 29 de febrero a través del siglo. Elija primero el siglo y luego compruebe que la fecha existe. 280229 como fecha de nacimiento es posterior a hoy en la lectura de los 2000, así que pasa a ser el 29 de febrero de 1928, que existe porque 1928 es bisiesto. 000229 es el 29 de febrero de 2000, que existe porque 2000 es bisiesto; en 1900, que no fue bisiesto, los mismos seis dígitos no serían ninguna fecha. 00 es el único año de dos dígitos en el que el siglo cambia la respuesta a "¿es bisiesto?". Valide la fecha antes de elegir el siglo y uno de estos casos saldrá mal.
Una fecha de nacimiento más adelante en este año. Visto arriba: 261224 es 1926, no 2026, y un test debería fijarlo con un "hoy" fijo.
Caracteres de relleno en la fecha. Una zona puede llevar < en una fecha de nacimiento cuando parte de la fecha se desconoce, por ejemplo 94<<08. No hay ninguna fecha de calendario que devolver. La función de abajo no devuelve nada para cualquier valor que no sean seis dígitos y deja que quien la llama muestre el texto en bruto.
Un dígito de control correcto en una fecha imposible. El dígito de control protege los caracteres, no el calendario. Con los pesos 7, 3, 1, 741312 suma 88, así que su dígito de control es 8, y 7413128 lo supera. El mes 13 no existe. El dígito de control y la fecha son dos comprobaciones separadas y ambas deben superarse; cómo funcionan los dígitos de control de la MRZ explica la primera en detalle.
Personas de más de 100 años. Una persona nacida en 1925 y un bebé nacido en 2025 tienen los mismos seis dígitos. La zona por sí sola no puede distinguirlos, y la regla de arriba elige la lectura más joven. Si entre sus usuarios hay centenarios, compare con la fecha impresa en la página de datos, que es donde aparece un año de cuatro dígitos cuando el documento lo imprime.
La función en Python
Sin dependencias. today es un datetime.date que pasa quien llama.
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())
El patrón es [0-9], no \d ni str.isdigit(), a propósito: ambos aceptan dígitos de otras escrituras, y una MRZ solo contiene dígitos ASCII. La comparación usa una tupla para que un mes imposible se compare sin problemas antes de que date() lo rechace.
La misma función en JavaScript
today es una cadena YYYY-MM-DD. Dos cadenas así se comparan correctamente como texto, lo que reduce la regla del siglo a una línea.
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() siempre está en UTC, y eso lo convierte en una fuente segura para today.
Vectores de prueba
Cada fila de abajo está calculada a mano a partir de la regla, con hoy fijado en el 24 de septiembre de 2026. Ambas funciones deberían devolver el valor de la columna "Esperado" (None en Python, null en JavaScript para "ninguna fecha").
| Entrada | Tipo | Esperado | Motivo |
|---|---|---|---|
740812 |
birth | 1974-08-12 | espécimen de la OACI |
340415 |
expiry | 2034-04-15 | espécimen de la OACI |
120415 |
expiry | 2012-04-15 | caducado, sigue siendo 2012 |
940308 |
birth | 1994-03-08 | 2094 está en el futuro |
150101 |
birth | 2015-01-01 | 2015 está en el pasado |
300101 |
birth | 1930-01-01 | 2030 está en el futuro |
261224 |
birth | 1926-12-24 | más adelante este año, así que siglo anterior |
260925 |
birth | 1926-09-25 | mañana, así que siglo anterior |
260924 |
birth | 2026-09-24 | hoy cuenta como pasado |
260923 |
birth | 2026-09-23 | ayer |
261224 |
expiry | 2026-12-24 | la caducidad se queda en los 2000 |
320310 |
expiry | 2032-03-10 | la caducidad se queda en los 2000 |
280229 |
birth | 1928-02-29 | 1928 es bisiesto |
000229 |
birth | 2000-02-29 | 2000 es bisiesto, 1900 no lo fue |
270229 |
birth | ninguna | 1927 no es bisiesto |
230229 |
expiry | ninguna | 2023 no es bisiesto |
240229 |
expiry | 2024-02-29 | 2024 es bisiesto |
941308 |
birth | ninguna | no existe el mes 13 |
94<<08 |
birth | ninguna | caracteres de relleno, no una fecha |
Un ejecutor de tests para la versión en Python es un bucle sobre estas filas con today = date(2026, 9, 24) que compara result.isoformat() (o None) con la columna de lo esperado. Mantenga "hoy" fijo en los tests: con el reloj real, filas como 261224 cambian de respuesta el 25 de diciembre.
Qué hace nuestro parser de MRZ
El parser de MRZ de este sitio sigue la misma regla: el año empieza como 2000 + YY, una fecha de nacimiento cuya fecha completa es posterior a hoy (en UTC) retrocede 100 años, la comprobación del calendario llega después de elegir el siglo y las fechas de caducidad se quedan en los 2000. Sus propios tests cubren una fecha más adelante en este año, el día de hoy y el 29 de febrero a ambos lados del siglo. Para un valor que no puede convertir en fecha muestra en su lugar el texto en bruto, con un ? donde la zona tiene un carácter de relleno, y cuando seis dígitos superan su dígito de control pero no corresponden a ninguna fecha del calendario, lo indica con un aviso. Una fecha de caducidad se marca como caducada o no caducada respecto a la fecha de hoy en UTC. Se ejecuta en el navegador, así que puede pegar una zona sintética y comparar su lectura con su propia implementación.
Dónde encaja esto en un pipeline
Si usa un servicio de reconocimiento alojado, el siglo ya se ha elegido por usted en sus campos de fecha; la pregunta es si su regla es la que usted quiere. La respuesta de doc.cheap devuelve holder.birth_date y document.expiry_date en ISO YYYY-MM-DD y, junto a ellos, la zona exactamente como se leyó en mrz.lines, para que pueda extraer usted mismo los seis dígitos y aplicar la función de arriba cuando una fecha decide un acceso o dinero. Si todavía está eligiendo un servicio, la comparativa de APIs de OCR de pasaportes pone los precios publicados uno al lado del otro.
Si encuentra una entrada en la que la regla falla, escriba a admin@doc.cheap.