机读区(MRZ)中的每个日期都是六位数字:两位年、两位月、两位日,没有世纪。ICAO 公布的样本护照上,出生日期写作 740812,而机读区里没有任何信息能告诉你这是 1974 年还是 2074 年。你知道是 1974 年,是因为没有人会在出生之前持有护照,而这一条推理就是规则的全部。不过,要把它做得完全正确,包括在它真正起作用的那一天,需要比固定分界年份多花一点心思。

本文给出出生日期和有效期各自的规则、会让简单解析器出错的边界情况,以及一个 Python 和 JavaScript 函数,并附上一张可以直接放进你自己测试套件的测试向量表。所有示例都使用 ICAO 的虚构样本或合成数值,不涉及任何真实证件。

这是 doc.cheap 的博客,doc.cheap 是一个替你读取机读区的护照和身份证 OCR API。下文内容不需要用到它,代码也没有任何依赖。

日期所在的位置

机读旅行证件标准 ICAO Doc 9303 为每种格式规定了两个日期字段:出生日期和有效期,均写作 YYMMDD,且各自紧跟一位校验位。位置从 0 开始,可直接用于 slice:

格式 出生日期 有效期
TD3(护照),第 2 行 13–18,校验位在 19 21–26,校验位在 27
TD2,第 2 行 13–18,校验位在 19 21–26,校验位在 27
TD1(身份证),第 2 行 0–5,校验位在 6 8–13,校验位在 14

在 TD3 样本行 L898902C36UTO7408122F3404159ZE184226B<<<<<16 中,出生日期为 740812,有效期为 340415。

为什么固定分界年份会出错

常见的捷径是设一个分界点:两位年份小于某个数的归入 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 年。仅此而已。

实现是否真正正确,取决于以下三个细节:

  • 比较完整日期,而不只是年份。 下面的示例中,今天是 2026 年 9 月 24 日。261224 按 2000 年代读是 2026 年 12 月 24 日,还有三个月才到,所以此人出生于 1926 年 12 月 24 日。如果只比较 26 和 26,就会把他判断为新生儿。
  • 今天算作过去。 今天出生的人是在本世纪出生的,而不是上个世纪。260924 保持为 2026 年 9 月 24 日;晚一天的 260925 则回到 1926 年。要用"晚于",而不是"晚于或等于"。
  • 用 UTC 或你选定的某个固定时区取"今天",并把它作为参数传入。 自己读取时钟的函数无法测试,而且位于一个时区的服务器和位于另一个时区的浏览器,每天都会有一段时间对日期的判断不一致。

有效期:本世纪

有效期读作 2000 + YY,不再调整。2012 年过期的护照仍然是 2012 年,而不是 2112 年;2032 年到期的就是 2032 年。只有 2000 年以前就已过期的证件才应读作 1900 年代,而这样的证件已不再使用。这条规则在 2090 年代之前都成立,届时签发的证件可能在 2100 年代到期;在那之前需要再看一下代码,但不是现在。

值得测试的边界情况

跨世纪的 2 月 29 日。 先确定世纪,再检查日期是否存在。作为出生日期的 280229 按 2000 年代读晚于今天,因此变为 1928 年 2 月 29 日,由于 1928 年是闰年,这个日期存在。000229 是 2000 年 2 月 29 日,由于 2000 年是闰年,也存在;而在不是闰年的 1900 年,同样的六位数字根本不是一个日期。00 是唯一一个世纪会改变"是否为闰年"答案的两位年份。如果在确定世纪之前就校验日期,其中之一就会出错。

今年稍后的出生日期。 上文已讲过:261224 是 1926 年而不是 2026 年,测试应当用固定的"今天"把这一点锁定。

日期中的填充符。 当日期的一部分未知时,出生日期中可能出现 <,例如 94<<08。此时没有可返回的日历日期。下面的函数对不是六位数字的任何值都不返回结果,并交由调用方显示原始文本。

不可能的日期也能通过校验位。 校验位保护的是字符,而不是日历。按权重 7、3、1 计算,741312 的和为 88,所以其校验位是 8,7413128 能够通过校验。但 13 月并不存在。校验位和日期是两项独立的检查,两项都必须通过;第一项在 MRZ 校验位的计算原理中有完整介绍。

超过 100 岁的人。 1925 年出生的人和 2025 年出生的婴儿有相同的六位数字。仅凭机读区无法区分二者,上述规则会选择更年轻的读法。如果你的用户中有百岁老人,请与资料页上印刷的日期比较:证件若印有四位年份,就在那里。

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())

模式特意使用 [0-9],而不是 \d 或 str.isdigit():后两者都会接受其他文字系统中的数字,而 MRZ 只包含 ASCII 数字。比较使用元组,这样即使月份不可能存在,也能在 date() 拒绝它之前正常完成比较。

同样的函数,JavaScript 版

today 是 YYYY-MM-DD 格式的字符串。两个这样的字符串按普通字符串比较即可得到正确结果,因此世纪规则只需一行。

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 日。对于无法转换为日期的值,它会改为显示原始文本,机读区中有填充符的位置显示为 ?;当六位数字通过了校验位却不对应任何日历日期时,它会给出警告。有效期会以 UTC 的今天为准标记为已过期或未过期。它在浏览器中运行,你可以粘贴一段合成的机读区,把它的读取结果与你自己的实现进行比较。

在处理流程中的位置

如果你使用托管的识别服务,日期字段的世纪已经替你选好了;问题在于它的规则是不是你想要的。doc.cheap 的响应以 ISO YYYY-MM-DD 格式返回 holder.birth_date 和 document.expiry_date,同时在 mrz.lines 中返回原样读取的机读区,因此当日期决定访问权限或涉及资金时,你可以自己截取那六位数字并应用上面的函数。如果你还在挑选服务,护照 OCR API 对比把各家公布的价格并排列出。

如果你发现规则处理出错的输入,请写信至 admin@doc.cheap。