在机读区(MRZ)里,姓名是最难预料的字段。日期是六位数字,证件号码是带校验位的九个字符,各种代码来自固定的列表。姓名则是一个人怎么叫就怎么写,被塞进固定数量的大写字母里:没有空格,没有重音符号,没有标点,也没有校验位在某个字符被读错时提醒您。

本文讲解机读区如何书写姓名、如何把它重新拆分为姓和名、转写对姓名有什么影响、如何判断姓名可能被截断,以及如何与页面上印刷的姓名进行比对。下文所有姓名都是虚构的,或者是 ICAO 自己的样本 Anna Maria Eriksson,不出现任何真实人物。

这里是 doc.cheap 的博客。doc.cheap 是一个护照与身份证 OCR API。下文的内容都不依赖它,代码也没有任何依赖。

姓名的位置

机读旅行证件的标准 ICAO Doc 9303 在三种版式中都为姓名分配了固定宽度的字段。这里的位置从 1 开始计数,与标准的计数方式一致:

格式 姓名所在位置 宽度
TD3(护照),2 × 44 第 1 行,第 6–44 位 39 个字符
TD2,2 × 36 第 1 行,第 6–36 位 31 个字符
TD1(身份证),3 × 30 第 3 行,整行 30 个字符

TD3 或 TD2 第 1 行的前五个字符是证件代码和签发国,姓名占据其后的全部位置。TD1 给姓名单独一行,但它仍然是三者中最窄的。MRZ 格式页面逐个字段讲解每种版式。

在 TD3 样本中,第 1 行是 P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<。去掉 P<UTO,姓名字段就是 ERIKSSON<<ANNA<MARIA,后面用填充符补足到 39 个字符。

语法:<<、< 与补位

机读区的字符集是 A–Z、0–9 和填充符 <。姓名在此之上使用三条约定:

  • 主标识符在前。 通常是姓。
  • 两个填充符 << 结束主标识符。 其后是次标识符,通常是名。
  • 一个填充符 < 在任一部分内部分隔单词。 ANNA<MARIA 是两个名;VAN<DER<BERG 是由三个单词组成的姓。

姓名之后剩下的字段用 < 填满。所以把姓名读回来只需三步:去掉末尾的填充符,在第一个 << 处拆分,再把剩下的单个 < 换成空格。

有两种形态看起来奇怪,但并不是错误。只有一个名字的人只有主标识符,所以字段是名字后面直接跟填充符,前面没有 <<:OKONKWO<<<<…。写了分隔符但后面什么都没有的字段 NAKAMURA<<<<…,在去掉末尾填充符之后读起来完全一样。无论哪种情况,次标识符都是空的,而不是缺失的。

无法保留的是标点和空格本身。连字符、撇号或空格都不能写进机读区,所以印刷的 Smith-Jones 会变成 SMITH<JONES,与由两个单词组成的姓无法区分。如果您需要标点,它在印刷页面上,而不在机读区里。

转写:从任何文字到 26 个字母

每个姓名最终都必须落在 A–Z 之内。对于用带变音符号的拉丁字母印刷的姓名,这意味着把每个带重音的字母映射为普通字母。对于希腊文、阿拉伯文、西里尔文、汉字或其他任何文字的姓名,这意味着把整个姓名用拉丁字母写出来。

Doc 9303 中各格式通用的 Part 3 为这两种情况都给出了推荐的转写方式。不过决定权在签发国手中,各国的选择并不相同。对代码来说有三个重要后果:

  1. 一个印刷姓名在机读区中可能有不止一种拼法。 印刷的 MÜLLER 在机读区中可能是 MUELLER,也可能是 MULLER,取决于签发机构的规则。期望固定唯一映射的代码会把正确的机读区判为错误。
  2. 映射无法反推。 机读区中的 MULLER 可能来自页面上的 MULLER、MÜLLER 或 MÚLLER。机读区告诉您的是它所包含的字母,而不是本人书写的姓名。
  3. 页面上的拉丁文姓名可能与机读区不同。 以本国文字印刷的证件通常也会印一个拉丁文版本,而这个印刷的拉丁拼写并不总是与机读区逐字母一致。请把差异当作需要查看的情况,而不是读错的证据。

实用的规则是:比对机读区姓名和印刷姓名时,先把两者都化简为纯字母,允许您所对接的签发机构实际使用的拼法,凡是给人看的地方都使用印刷的拼写。

截断:姓名放不下时

长姓名不会换到下一行。它会被缩短以适应字段宽度,完整的姓名保留在印刷页面上。标准规定了缩短姓名的规则,而被缩短的姓名通常会一直写到字段的最后一个字符。

这就给了解析器唯一的信号:**如果姓名字段的最后一个字符是字母而不是 <,姓名可能被截断了。**是“可能”,不是“已经”:长度恰好等于字段宽度的姓名同样以字母结尾。但以填充符结尾的字段肯定没有被截断。

宽度很关键。以一位虚构的持证人 Konstantin Friedrich Hartmann Schoenebeck 为例。在 39 个字符的 TD3 字段中:

HARTMANN<SCHOENEBECK<<KONSTANTIN<FRIEDR

最后一个字符是字母 R,所以姓名可能被截断,而它确实被截断了:FRIEDRICH 少了三个字母。同一个姓名按 TD1 的 30 个字符宽度截断,只剩下 HARTMANN<SCHOENEBECK<<KONSTANT。把机读区姓名当作持证人姓名存储的系统,会把 KONSTANT 存成名字。

由此得出两个习惯。字段写满时,绝不要用机读区姓名作为显示名称,而要用印刷的姓名。用完整的印刷姓名与机读区姓名比对时,如果字段是满的,那么以机读区姓名开头的印刷姓名就算匹配。

签发机构也可能用截掉末尾以外的方式缩短姓名,例如把第二个名缩写为首字母。前缀检查会把这样的姓名报告为不匹配。遇到这种情况,请转交人工处理,而不是拒绝这份证件。

没有校验位覆盖姓名

校验位覆盖证件号码、两个日期、TD3 上可选的个人编号,以及覆盖下方行大部分内容的复合校验位。它们都不覆盖姓名。在 TD3 和 TD2 上,姓名位于第 1 行,没有任何校验位触及这一行;在 TD1 上,姓名位于第 3 行,而复合校验位止于第 2 行。

所以姓名中的识别错误是无声的。把 M 读成 N,或者把填充符读成字母,得到的机读区算术上依然自洽。姓名唯一的交叉核对来源是印刷页面:两者都读出来再比对,下面的比对函数正是为此而写。受校验位保护的字段如何计算,请见MRZ 校验位的计算原理。

用 Python 拆分字段

没有任何依赖。函数只接收已经从所在行中切出的姓名字段(TD3 上是 line1[5:44],TD2 上是 line1[5:36],TD1 上是 line3)。

import re

def words(part):
    return " ".join(word for word in part.split("<") if word)

def read_mrz_name(field):
    """Split an MRZ name field into (primary, secondary, may_be_truncated)."""
    if not re.fullmatch(r"[A-Z<]+", field):
        raise ValueError("a name field holds only A-Z and the filler <")
    may_be_truncated = not field.endswith("<")
    body = field.rstrip("<")
    primary, _separator, secondary = body.partition("<<")
    return words(primary), words(secondary), may_be_truncated

partition 只在第一个 << 处拆分,没有 << 时返回空的次部分,这就是只有一个名字的情况。正则有意拒绝数字:姓名字段里的 0 是把 O 读错了,它应该交给人工处理,而不是作为姓名输出。

用 JavaScript 实现同样的拆分

export function readMrzName(field) {
  if (!/^[A-Z<]+$/.test(field)) throw new Error("a name field holds only A-Z and the filler <");
  const mayBeTruncated = !field.endsWith("<");
  const body = field.replace(/<+$/, "");
  const at = body.indexOf("<<");
  const words = (part) => part.split("<").filter(Boolean).join(" ");
  if (at < 0) return { primary: words(body), secondary: "", mayBeTruncated };
  return { primary: words(body.slice(0, at)), secondary: words(body.slice(at + 2)), mayBeTruncated };
}

比对机读区与印刷姓名

印刷姓名来自视读区,也就是页面上供人阅读的部分,带有重音符号和标点。下面的函数把它化简为纯大写字母,再与机读区姓名的某一部分进行比对。它返回三个词之一:match、mismatch,或者当印刷姓名中含有本身无法化简为 A–Z 的字母(例如 Ø、Æ 或 Ł)时返回 unknown。

import re
import unicodedata

# The spellings to try besides plain removal of the accent. These three are
# an example; put in the ones your issuers use.
EXPANSIONS = {"Ä": "AE", "Ö": "OE", "Ü": "UE"}

def compare_name(zone_part, printed_part, truncated=False, expansions=EXPANSIONS):
    """'match', 'mismatch' or 'unknown' for one name part: zone against printed."""
    zone = zone_part.replace(" ", "")
    upper = unicodedata.normalize("NFC", printed_part).upper()
    candidates = {upper, "".join(expansions.get(c, c) for c in upper)}
    verdict = "unknown"
    for candidate in candidates:
        letters = "".join(
            c for c in unicodedata.normalize("NFKD", candidate)
            if c.isalpha() and not unicodedata.combining(c)
        )
        if not re.fullmatch(r"[A-Z]*", letters):
            continue  # a letter with no plain A-Z form: leave it to a person
        if letters == zone or (truncated and letters.startswith(zone)):
            return "match"
        verdict = "mismatch"
    return verdict

NFKD 把带重音的字母拆分为普通字母和重音符号,重音符号随后被丢弃。空格、连字符和撇号也被丢弃,因为比对只看字母:Smith-Jones 和 SMITH JONES 都会变成 SMITHJONES。对于只用非拉丁文字印刷的姓名,请用页面印刷的拉丁文版本与机读区比对,而不是用本国文字版本;用本国文字比对,这个函数每次都会返回 unknown。

测试向量

除 ICAO 样本外,所有姓名都是虚构的。在第一张表中,每个字段都用 < 补足到 TD3 的宽度 39(在 Python 中是 name.ljust(39, "<")),最后一行除外,它本身就恰好是 39 个字符。

补位前的字段 主标识符 次标识符 可能被截断
ERIKSSON<<ANNA<MARIA ERIKSSON ANNA MARIA 否
VAN<DER<BERG<<PIETER VAN DER BERG PIETER 否
SMITH<JONES<<MARY SMITH JONES MARY 否
OKONKWO OKONKWO (空) 否
NAKAMURA<< NAKAMURA (空) 否
HARTMANN<SCHOENEBECK<<KONSTANTIN<FRIEDR HARTMANN SCHOENEBECK KONSTANTIN FRIEDR 是

compare_name 的测试向量:

机读区部分 印刷部分 截断 预期结果
ANNA MARIA Anna Maria 否 match
SMITH JONES Smith-Jones 否 match
MULLER Müller 否 match
MUELLER Müller 否 match(通过展开)
MILLER Müller 否 mismatch
KONSTANTIN FRIEDR Konstantin Friedrich 是 match
KONSTANTIN FRIEDR Konstantin Friedrich 否 mismatch
SORENSEN Sørensen 否 unknown

最后一行是有意为之。Ø 变成 O 还是 OE,是签发机构的选择,靠猜测的函数总会对某些人出错;请把您所对接的签发机构使用的形式加入展开表。

本站 MRZ 解析器的做法

本站的 MRZ 解析器以同样的方式读取姓名:去掉末尾的填充符,在第一个 << 处拆分,把单个填充符换成空格;没有 << 的字段被读作只有姓。当姓名一直写到字段的最后一个字符时,它会显示一条警告,提示姓名可能为适应字段而被截断,并标出它所占的位置。它在浏览器中运行,所以您可以粘贴一个合成的机读区,把它的读取结果与您自己的代码进行比较。

在实际流程中的位置

托管的识别服务会同时读取机读区和印刷页面,所以问题在于它交给您的是哪一个姓名。在 doc.cheap 的响应中,holder.surname、holder.given_names 和 holder.full_name 承载合并后的拉丁文读取结果。以本国文字印刷的姓名是 fields 中的单独条目:每种读取结果各有自己的条目,默认的拉丁文条目的 id 以 @0 结尾,本国文字条目会注明其 language,例如 Greek。机读区本身按读取时的原样放在 mrz.lines 中,所以您可以切出姓名字段,对它运行上面的两个函数。关于非拉丁文字的指南(英文)介绍了如何对读取结果分组并同时保存两种拼写。如果您还在挑选服务,护照 OCR API 对比把各家公布的价格并排列出。

如果您发现上面的代码读错了某个姓名,请写信至 admin@doc.cheap。

**最后想问您一个问题:**在比对机读区与印刷姓名时,哪一种转写最让您意外?欢迎在下方评论区告诉我们。