Jedes Datum in einer maschinenlesbaren Zone besteht aus sechs Ziffern: zwei für das Jahr, zwei für den Monat, zwei für den Tag. Ein Jahrhundert gibt es nicht. Der Musterpass, den die ICAO veröffentlicht, nennt 740812 als Geburtsdatum, und nichts in der Zone verrät, ob das 1974 oder 2074 ist. Sie wissen, dass es 1974 ist, weil niemand einen Pass besitzt, bevor er geboren ist, und diese eine Überlegung ist schon die ganze Regel. Sie genau richtig umzusetzen, auch an dem Tag, an dem es darauf ankommt, verlangt etwas mehr Sorgfalt als ein festes Stichjahr.
Dieser Beitrag nennt die Regel für Geburts- und für Ablaufdaten, die Grenzfälle, an denen einfache Parser scheitern, und eine Funktion in Python und in JavaScript mit einer Tabelle von Testvektoren, die Sie in Ihre eigene Testsuite übernehmen können. Alle Beispiele verwenden das fiktive Muster der ICAO oder synthetische Werte; ein echtes Dokument kommt nirgends vor.
Dies ist der Blog von doc.cheap, einer OCR-API für Reisepässe und Ausweise, die die Zone für Sie liest. Für alles Folgende brauchen Sie sie nicht; der Code hat keine Abhängigkeiten.
Wo die Daten stehen
ICAO Doc 9303, der Standard für maschinenlesbare Reisedokumente, gibt jedem Format zwei Datumsfelder: Geburtsdatum und Ablaufdatum, jeweils als YYMMDD geschrieben und jeweils gefolgt von einer eigenen Prüfziffer. Die Positionen zählen ab 0, bereit für slice:
| Format | Geburtsdatum | Ablaufdatum |
|---|---|---|
| TD3 (Reisepässe), Zeile 2 | 13–18, Prüfziffer bei 19 | 21–26, Prüfziffer bei 27 |
| TD2, Zeile 2 | 13–18, Prüfziffer bei 19 | 21–26, Prüfziffer bei 27 |
| TD1 (Personalausweise), Zeile 2 | 0–5, Prüfziffer bei 6 | 8–13, Prüfziffer bei 14 |
In der TD3-Zeile des Musters, L898902C36UTO7408122F3404159ZE184226B<<<<<16, ergibt das 740812 als Geburtsdatum und 340415 als Ablaufdatum.
Warum ein festes Stichjahr scheitert
Die übliche Abkürzung ist ein Pivot: Zweistellige Jahre unter einer bestimmten Zahl kommen in die 2000er, der Rest in die 1900er. Pythons eigenes time.strptime arbeitet bei %y genau so. Die Dokumentation sagt: "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."
Für eine Logdatei ist das ein vernünftiger Standard. Für ein Geburtsdatum ist es in beide Richtungen falsch. Nach dieser Regel ist eine Person, die am 12. April 1968 geboren wurde (680412), im Jahr 2068 geboren. Verschieben Sie den Pivot, verschieben Sie nur den Fehler: Jede feste Zahl ist für irgendwen falsch, und sie wird mit jedem Jahr, das der Code in Produktion bleibt, falscher. Die richtige Antwort hängt vom heutigen Datum ab, also muss die Regel das heutige Datum als Eingabe erhalten.
Geburtsdaten: das jüngste Jahrhundert, das nicht in der Zukunft liegt
Lesen Sie das Jahr als 2000 + YY. Liegt das daraus entstehende vollständige Datum nach dem heutigen Tag, ziehen Sie 100 Jahre ab. Das ist alles.
Drei Details entscheiden, ob eine Implementierung wirklich korrekt ist:
- Vergleichen Sie das ganze Datum, nicht nur das Jahr. In den Beispielen unten ist heute der 24. September 2026.
261224ist in der 2000er-Lesart der 24. Dezember 2026, also in drei Monaten, deshalb wurde die Person am 24. Dezember 1926 geboren. Wer nur26mit26vergleicht, hielte sie für ein Neugeborenes. - Heute zählt als Vergangenheit. Wer heute geboren ist, ist in diesem Jahrhundert geboren, nicht im vorigen.
260924bleibt der 24. September 2026;260925, einen Tag später, geht zurück auf 1926. Verwenden Sie „später als“, nicht „später als oder gleich“. - Nehmen Sie „heute“ in UTC, oder in einer festen Zeitzone Ihrer Wahl, und übergeben Sie es als Argument. Eine Funktion, die selbst die Uhr liest, lässt sich nicht testen, und ein Server in einer Zeitzone und ein Browser in einer anderen können sich während eines Teils jedes Tages über das Datum uneinig sein.
Ablaufdaten: dieses Jahrhundert
Ein Ablaufdatum wird als 2000 + YY gelesen und bleibt dabei. Ein Pass, der 2012 abgelaufen ist, trägt weiterhin ein Datum aus 2012, nicht aus 2112, und einer, der 2032 abläuft, ist 2032. Die 1900er-Lesart wäre nur für ein Dokument richtig, das vor 2000 abgelaufen ist, und ein solches Dokument ist nicht mehr in Gebrauch. Diese Regel gilt bis in die 2090er, wenn ein dann ausgestelltes Dokument nach 2100 ablaufen könnte; der Code braucht vorher einen erneuten Blick, aber nicht jetzt.
Die Grenzfälle, die einen Test verdienen
Der 29. Februar über die Jahrhundertgrenze. Wählen Sie zuerst das Jahrhundert und prüfen Sie dann, ob das Datum existiert. 280229 als Geburtsdatum liegt in der 2000er-Lesart nach dem heutigen Tag und wird daher zum 29. Februar 1928, den es gibt, weil 1928 ein Schaltjahr ist. 000229 ist der 29. Februar 2000, den es gibt, weil 2000 ein Schaltjahr ist; im Jahr 1900, das kein Schaltjahr war, wären dieselben sechs Ziffern gar kein Datum. 00 ist das einzige zweistellige Jahr, bei dem das Jahrhundert die Antwort auf „Ist das ein Schaltjahr?“ ändert. Prüfen Sie das Datum, bevor Sie das Jahrhundert wählen, und einer dieser Fälle geht schief.
Ein Geburtsdatum später in diesem Jahr. Oben schon behandelt: 261224 ist 1926, nicht 2026, und ein Test sollte das mit einem festen „heute“ festhalten.
Füllzeichen im Datum. Eine Zone kann < in einem Geburtsdatum enthalten, wenn ein Teil des Datums unbekannt ist, zum Beispiel 94<<08. Es gibt kein Kalenderdatum, das man zurückgeben könnte. Die Funktion unten gibt für jeden Wert, der nicht aus sechs Ziffern besteht, nichts zurück und überlässt es dem Aufrufer, den Rohtext anzuzeigen.
Eine passende Prüfziffer bei einem unmöglichen Datum. Die Prüfziffer schützt die Zeichen, nicht den Kalender. Mit den Gewichten 7, 3, 1 ergibt 741312 die Summe 88, seine Prüfziffer ist also 8, und 7413128 besteht die Prüfung. Einen Monat 13 gibt es nicht. Prüfziffer und Datum sind zwei getrennte Prüfungen, und beide müssen bestehen; So funktionieren die Prüfziffern der MRZ behandelt die erste ausführlich.
Menschen über 100. Eine 1925 geborene Person und ein 2025 geborenes Baby haben dieselben sechs Ziffern. Die Zone allein kann sie nicht unterscheiden, und die Regel oben wählt die jüngere Lesart. Wenn zu Ihren Nutzern Hundertjährige gehören, vergleichen Sie mit dem Datum, das auf der Datenseite gedruckt ist; dort erscheint eine vierstellige Jahreszahl, wenn das Dokument eine druckt.
Die Funktion in Python
Ohne Abhängigkeiten. today ist ein datetime.date, das der Aufrufer übergibt.
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())
Das Muster ist absichtlich [0-9] und nicht \d oder str.isdigit(): Beide akzeptieren Ziffern aus anderen Schriften, und eine MRZ enthält immer nur ASCII-Ziffern. Der Vergleich verwendet ein Tupel, damit sich auch ein unmöglicher Monat sauber vergleichen lässt, bevor date() ihn ablehnt.
Dieselbe Funktion in JavaScript
today ist ein String im Format YYYY-MM-DD. Zwei solche Strings lassen sich als einfacher Text korrekt vergleichen, wodurch die Jahrhundertregel in eine Zeile passt.
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() liefert immer UTC, und genau das macht es zu einer sicheren Quelle für today.
Testvektoren
Jede Zeile unten ist von Hand aus der Regel abgeleitet, mit „heute“ fest auf dem 24. September 2026. Beide Funktionen sollten den Wert in der Spalte „Erwartet“ zurückgeben (None in Python, null in JavaScript für „kein Datum“).
| Eingabe | Art | Erwartet | Warum |
|---|---|---|---|
740812 |
birth | 1974-08-12 | ICAO-Muster |
340415 |
expiry | 2034-04-15 | ICAO-Muster |
120415 |
expiry | 2012-04-15 | abgelaufen, bleibt 2012 |
940308 |
birth | 1994-03-08 | 2094 liegt in der Zukunft |
150101 |
birth | 2015-01-01 | 2015 liegt in der Vergangenheit |
300101 |
birth | 1930-01-01 | 2030 liegt in der Zukunft |
261224 |
birth | 1926-12-24 | später in diesem Jahr, also voriges Jahrhundert |
260925 |
birth | 1926-09-25 | morgen, also voriges Jahrhundert |
260924 |
birth | 2026-09-24 | heute zählt als Vergangenheit |
260923 |
birth | 2026-09-23 | gestern |
261224 |
expiry | 2026-12-24 | Ablaufdaten bleiben in den 2000ern |
320310 |
expiry | 2032-03-10 | Ablaufdaten bleiben in den 2000ern |
280229 |
birth | 1928-02-29 | 1928 ist ein Schaltjahr |
000229 |
birth | 2000-02-29 | 2000 ist ein Schaltjahr, 1900 war keins |
270229 |
birth | keins | 1927 ist kein Schaltjahr |
230229 |
expiry | keins | 2023 ist kein Schaltjahr |
240229 |
expiry | 2024-02-29 | 2024 ist ein Schaltjahr |
941308 |
birth | keins | es gibt keinen Monat 13 |
94<<08 |
birth | keins | Füllzeichen, kein Datum |
Ein Testlauf für die Python-Version ist eine Schleife über diese Zeilen mit today = date(2026, 9, 24), die result.isoformat() (oder None) mit der erwarteten Spalte vergleicht. Halten Sie „heute“ in Tests fest: Mit der echten Uhr ändern Zeilen wie 261224 am 25. Dezember ihr Ergebnis.
Was unser MRZ-Parser macht
Der MRZ-Parser auf dieser Website folgt derselben Regel: Das Jahr beginnt als 2000 + YY, ein Geburtsdatum, dessen vollständiges Datum nach dem heutigen Tag (in UTC) liegt, rückt um 100 Jahre zurück, die Kalenderprüfung kommt nach der Wahl des Jahrhunderts, und Ablaufdaten bleiben in den 2000ern. Seine eigenen Tests decken ein Datum später in diesem Jahr, den heutigen Tag und den 29. Februar auf beiden Seiten der Jahrhundertgrenze ab. Für einen Wert, den er nicht in ein Datum umwandeln kann, zeigt er stattdessen den Rohtext, mit einem ? dort, wo die Zone ein Füllzeichen hat, und wenn sechs Ziffern ihre Prüfziffer bestehen, aber kein Kalenderdatum ergeben, weist er in einer Warnung darauf hin. Ein Ablaufdatum wird gegenüber dem heutigen Datum in UTC als abgelaufen oder nicht abgelaufen markiert. Er läuft im Browser, sodass Sie eine synthetische Zone einfügen und seine Lesart mit Ihrer eigenen Implementierung vergleichen können.
Wo das in eine Pipeline passt
Wenn Sie einen gehosteten Erkennungsdienst nutzen, wurde das Jahrhundert in dessen Datumsfeldern bereits für Sie gewählt; die Frage ist, ob seine Regel die ist, die Sie wollen. Die Antwort von doc.cheap liefert holder.birth_date und document.expiry_date als ISO YYYY-MM-DD und daneben die Zone genau so, wie sie gelesen wurde, in mrz.lines, damit Sie die sechs Ziffern selbst herausschneiden und die Funktion oben anwenden können, wenn ein Datum über Zugang oder Geld entscheidet. Wenn Sie noch einen Dienst auswählen, stellt der Vergleich der Reisepass-OCR-APIs die veröffentlichten Preise nebeneinander.
Wenn Sie eine Eingabe finden, bei der die Regel falsch liegt, schreiben Sie an admin@doc.cheap.