機械読取領域(MRZ)の日付はすべて6桁です。年が2桁、月が2桁、日が2桁で、世紀はありません。ICAOが公開している見本のパスポートでは生年月日が 740812 となっていますが、それが1974年なのか2074年なのかを示すものは領域の中にありません。1974年だとわかるのは、生まれる前にパスポートを持つ人はいないからで、この1つの推論がルールのすべてです。ただし、それが問題になるまさにその日も含めて正確に扱うには、固定の境界年を使うよりも少し注意が必要です。
この記事では、生年月日と有効期限それぞれのルール、単純なパーサーが壊れるエッジケース、そしてPythonとJavaScriptの関数と、自分のテストスイートにそのまま組み込めるテストベクターの表を紹介します。例はすべてICAOの架空の見本か合成した値を使っており、実在の書類は登場しません。
これは、MRZを代わりに読み取るパスポート・身分証OCR API、doc.cheapのブログです。以下の内容にdoc.cheapは必要ありません。コードには依存ライブラリもありません。
日付の位置
機械読取式旅行文書の標準であるICAO Doc 9303は、各フォーマットに2つの日付フィールドを定めています。生年月日と有効期限で、どちらも YYMMDD で書かれ、それぞれ直後に専用のチェックディジットが続きます。位置は0始まりなので、そのまま slice に渡せます。
| フォーマット | 生年月日 | 有効期限 |
|---|---|---|
| TD3(パスポート)、2行目 | 13–18、チェックディジットは19 | 21–26、チェックディジットは27 |
| TD2、2行目 | 13–18、チェックディジットは19 | 21–26、チェックディジットは27 |
| TD1(IDカード)、2行目 | 0–5、チェックディジットは6 | 8–13、チェックディジットは14 |
TD3見本の行 L898902C36UTO7408122F3404159ZE184226B<<<<<16 では、生年月日が 740812、有効期限が 340415 になります。
固定の境界年が壊れる理由
よくある近道はピボットです。2桁の年がある数より小さければ2000年代、それ以外は1900年代とみなします。Python自身の time.strptime も %y をこの方法で扱います。ドキュメントには次のように書かれています: "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."
ログファイルならこれは妥当な既定値です。しかし生年月日には、どちらの方向にも誤りになります。このルールでは、1968年4月12日(680412)生まれの人は2068年生まれになります。ピボットを動かしてもバグの場所が移るだけです。どんな固定値も誰かにとっては誤りで、コードが本番で動き続けるほど年々誤りが増えます。正しい答えは今日の日付によって決まるので、ルールは今日の日付を入力として受け取る必要があります。
生年月日:未来にならない最も新しい世紀
年を 2000 + YY として読みます。その結果の日付全体が今日より後なら、100年を引きます。それだけです。
実装が本当に正しいかどうかは、次の3つの点で決まります。
- 年だけでなく日付全体を比較すること。 以下の例では今日を2026年9月24日とします。
261224を2000年代として読むと2026年12月24日で、3か月先です。したがってその人は1926年12月24日生まれです。年の26と26だけを比べると、新生児と判定してしまいます。 - 今日は過去として扱うこと。 今日生まれた人は前の世紀ではなく今の世紀に生まれています。
260924は2026年9月24日のままで、1日後の260925は1926年に戻ります。「以上」ではなく「より後」を使ってください。 - 「今日」はUTC、または自分で決めた1つの固定タイムゾーンで取得し、引数として渡すこと。 自分で時計を読む関数はテストできません。また、あるタイムゾーンのサーバーと別のタイムゾーンのブラウザでは、毎日ある時間帯に日付が食い違います。
有効期限:今の世紀
有効期限は 2000 + YY として読み、そのままにします。2012年に期限が切れたパスポートは2112年ではなく2012年のままで、2032年に切れるものは2032年です。1900年代として読むのが正しいのは2000年より前に期限切れになった書類だけで、そのような書類はもう使われていません。このルールは2090年代まで有効です。その頃に発行された書類は2100年代に期限を迎える可能性があるので、その前にコードを見直す必要がありますが、今ではありません。
テストすべきエッジケース
世紀をまたぐ2月29日。 先に世紀を決め、その後で日付が存在するかを確認します。生年月日としての 280229 は2000年代として読むと今日より後なので、1928年2月29日になります。1928年はうるう年なので、この日付は存在します。000229 は2000年2月29日で、2000年はうるう年なので存在します。うるう年でなかった1900年なら、同じ6桁はまったく日付になりません。00 は、世紀によって「うるう年かどうか」の答えが変わる唯一の2桁の年です。世紀を決める前に日付を検証すると、どちらかが誤りになります。
今年これから来る生年月日。 上で説明したとおり、261224 は2026年ではなく1926年です。テストでは固定の「今日」を使ってこれを確かめてください。
日付の中のフィラー文字。 日付の一部が不明な場合、生年月日に < が入ることがあります。たとえば 94<<08 です。返すべき暦上の日付はありません。以下の関数は6桁の数字でない値には何も返さず、生のテキストを表示するかどうかは呼び出し側に任せます。
存在しない日付でもチェックディジットは通る。 チェックディジットが守るのは文字であって、暦ではありません。重み7、3、1で計算すると 741312 の合計は88なので、チェックディジットは 8 になり、7413128 は検証を通過します。13月は存在しません。チェックディジットと日付は別々のチェックで、両方が通る必要があります。前者についてはMRZチェックディジットの仕組みで詳しく説明しています。
100歳を超える人。 1925年生まれの人と2025年生まれの赤ちゃんは同じ6桁になります。領域だけでは区別できず、上のルールは若いほうの読み方を選びます。利用者に100歳以上の人が含まれるなら、データページに印字された日付と比較してください。書類が4桁の年を印字している場合、それはそこにあります。
Pythonの関数
依存ライブラリはありません。today は呼び出し側が渡す datetime.date です。
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())
パターンを \d や str.isdigit() ではなく [0-9] にしているのは意図的です。この2つは他の文字体系の数字も受け付けますが、MRZに含まれるのはASCIIの数字だけです。比較にタプルを使っているので、存在しない月でも date() が拒否する前に問題なく比較できます。
JavaScriptの同じ関数
today は YYYY-MM-DD 形式の文字列です。この形式の文字列同士は普通の文字列として正しく比較できるので、世紀のルールは1行で済みます。
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() は常にUTCなので、today の取得元として安全です。
テストベクター
以下の各行は、今日を2026年9月24日に固定して、ルールから手で導いたものです。どちらの関数も最後の列の値を返すはずです(「日付なし」はPythonでは None、JavaScriptでは null)。
| 入力 | 種類 | 期待値 | 理由 |
|---|---|---|---|
740812 |
birth | 1974-08-12 | ICAOの見本 |
340415 |
expiry | 2034-04-15 | ICAOの見本 |
120415 |
expiry | 2012-04-15 | 期限切れでも2012年のまま |
940308 |
birth | 1994-03-08 | 2094年は未来 |
150101 |
birth | 2015-01-01 | 2015年は過去 |
300101 |
birth | 1930-01-01 | 2030年は未来 |
261224 |
birth | 1926-12-24 | 今年これから来るので前の世紀 |
260925 |
birth | 1926-09-25 | 明日なので前の世紀 |
260924 |
birth | 2026-09-24 | 今日は過去として扱う |
260923 |
birth | 2026-09-23 | 昨日 |
261224 |
expiry | 2026-12-24 | 有効期限は2000年代のまま |
320310 |
expiry | 2032-03-10 | 有効期限は2000年代のまま |
280229 |
birth | 1928-02-29 | 1928年はうるう年 |
000229 |
birth | 2000-02-29 | 2000年はうるう年、1900年は違った |
270229 |
birth | なし | 1927年はうるう年ではない |
230229 |
expiry | なし | 2023年はうるう年ではない |
240229 |
expiry | 2024-02-29 | 2024年はうるう年 |
941308 |
birth | なし | 13月は存在しない |
94<<08 |
birth | なし | フィラー文字で、日付ではない |
Python版のテストランナーは、today = date(2026, 9, 24) としてこれらの行をループし、result.isoformat()(または None)を期待値の列と比べるだけです。テストでは「今日」を固定してください。実際の時計を使うと、261224 のような行は12月25日に答えが変わります。
当サイトのMRZパーサーの動作
このサイトのMRZパーサーも同じルールに従います。年は 2000 + YY から始め、日付全体が今日(UTC)より後の生年月日は100年戻し、暦のチェックは世紀を決めた後に行い、有効期限は2000年代のままにします。独自のテストは、今年これから来る日付、今日、そして世紀の前後の2月29日をカバーしています。日付に変換できない値には代わりに生のテキストを表示し、領域にフィラー文字がある箇所は ? にします。6桁がチェックディジットを通過しても暦上の日付にならない場合は、警告でそれを伝えます。有効期限は、UTCでの今日の日付に対して期限切れかどうかが表示されます。ブラウザ内で動くので、合成した領域を貼り付けて、その読み取り結果を自分の実装と比べることができます。
パイプラインでの位置づけ
ホスティング型の認識サービスを使う場合、日付フィールドの世紀はすでに決められています。問題は、そのルールが自分の望むものかどうかです。doc.cheapの応答は holder.birth_date と document.expiry_date をISO形式の YYYY-MM-DD で返し、その横に読み取ったままの領域を mrz.lines として返します。そのため、日付がアクセス権やお金を左右する場合は、自分で6桁を切り出して上の関数を適用できます。まだサービスを選んでいる段階なら、パスポートOCR APIの比較で公開されている料金を並べて確認できます。
ルールが誤る入力を見つけたら、admin@doc.cheap までお知らせください。