機械読取領域(MRZ)の中で、最も予測しにくいフィールドが氏名です。日付は6桁、旅券番号はチェックディジット付きの9文字、コードは決められたリストから選ばれます。氏名はその人の呼び名そのもので、それが決まった数の大文字に押し込まれます。スペースも、アクセント記号も、句読点もなく、文字が誤読されたときに知らせてくれるチェックディジットもありません。

この記事では、領域が氏名をどう書くか、それを姓と名にどう分け直すか、翻字によって氏名に何が起きるか、氏名が途中で切られた可能性をどう見分けるか、そしてページに印字された氏名とどう比較するかを説明します。以下の氏名はすべて架空のものか、ICAO自身の見本であるAnna Maria Erikssonで、実在の人物は登場しません。

これはパスポート・身分証OCR API、doc.cheapのブログです。以下の内容にdoc.cheapは必要ありません。コードには依存ライブラリもありません。

氏名の位置

機械読取式旅行文書の標準であるICAO Doc 9303は、3つのレイアウトそれぞれで氏名に固定幅のフィールドを割り当てています。ここでの位置は標準の数え方に合わせて1始まりです。

フォーマット 氏名の位置 幅
TD3(パスポート)、2 × 44 1行目、6–44桁 39文字
TD2、2 × 36 1行目、6–36桁 31文字
TD1(IDカード)、3 × 30 3行目全体 30文字

TD3とTD2の1行目の最初の5文字は文書コードと発行国で、氏名はその後ろをすべて使います。TD1は氏名に専用の1行を与えていますが、それでも3つの中で最も狭いフィールドです。MRZフォーマットのページでは、各レイアウトをフィールドごとに説明しています。

TD3の見本では、1行目は P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<< です。P<UTO を除くと、氏名フィールドは ERIKSSON<<ANNA<MARIA で、その後ろが39文字までフィラーで埋められています。

文法:<<、<、そして埋め草

領域で使える文字は A–Z、0–9 とフィラー < です。氏名はその上に3つの約束事を重ねています。

  • 第1識別子が先に来ます。 通常は姓です。
  • 2つのフィラー << が第1識別子の終わりを示します。 その後ろが第2識別子で、通常は名です。
  • 1つのフィラー < は、どちらの部分でも単語を区切ります。 ANNA<MARIA は2つの名で、VAN<DER<BERG は3語の姓です。

氏名の後に残ったフィールドは < で埋められます。したがって氏名の読み戻しは3ステップです。末尾のフィラーを取り除き、最初の << で分割し、残った1つだけの < をスペースに置き換えます。

奇妙に見えてもエラーではない形が2つあります。名前が1つしかない人は第1識別子だけを持つので、フィールドは名前の直後にフィラーが続き、その前に << はありません:OKONKWO<<<<…。区切りを書いてその後に何もないフィールド NAKAMURA<<<<… も、末尾のフィラーを取り除けばまったく同じに読めます。どちらの場合も第2識別子は空であって、欠けているわけではありません。

残らないのは句読点とスペースそのものです。ハイフン、アポストロフィ、スペースは領域に書けないので、印字された Smith-Jones は SMITH<JONES となり、2語の姓と区別がつきません。句読点が必要なら、それは領域ではなく印字されたページにあります。

翻字:あらゆる文字体系から26文字へ

すべての氏名は最終的に A–Z に収まる必要があります。ダイアクリティカルマーク付きのラテン文字で印字された氏名なら、アクセント付きの各文字を普通の文字に置き換えることになります。ギリシャ文字、アラビア文字、キリル文字、漢字など、ほかの文字体系の氏名なら、氏名全体をラテン文字で書くことになります。

すべてのフォーマットに共通する部分であるDoc 9303のPart 3には、両方のケースについて推奨される翻字が載っています。ただし決めるのは発行国で、すべての国が同じ選択をするわけではありません。コードにとって重要な結果が3つあります。

  1. 印字された1つの氏名に、領域での綴りが複数ありえます。 印字された MÜLLER は、発行者のルール次第で領域に MUELLER とも MULLER とも書かれます。固定の1つの対応を前提とするコードは、正しい領域を誤りと判定してしまいます。
  2. 対応は逆にたどれません。 領域の MULLER は、ページ上の MULLER、MÜLLER、MÚLLER のどれからでも来えます。領域が教えてくれるのはそこにある文字であって、本人が書く氏名ではありません。
  3. ページ上のラテン文字の氏名が領域と異なることがあります。 自国の文字で印字された書類は、多くの場合ラテン文字版も印字しますが、その印字されたラテン文字の綴りが領域の綴りと一字一句同じとは限りません。違いは確認すべき点として扱い、誤読の証拠とはみなさないでください。

実務上のルールはこうです。領域の氏名と印字された氏名は、両方を素の文字に落としてから比較し、自分が扱う発行者が実際に使う綴りを許容し、人が読むものには印字された綴りを使います。

切り詰め:氏名が収まらないとき

長い氏名は次の行に折り返されません。フィールドに収まるように短くされ、完全な氏名は印字されたページに残ります。標準は氏名の短くし方のルールを定めており、短くされた氏名は通常、フィールドの最後の文字まで埋めます。

そこからパーサーが得られる唯一の手がかりが生まれます。氏名フィールドの最後の文字が < ではなく文字なら、氏名は切り詰められている可能性があります。「可能性がある」のであって「切り詰められた」のではありません。フィールドとちょうど同じ長さの氏名も文字で終わるからです。しかし、フィラーで終わるフィールドが切られていないことは確実です。

幅が重要です。架空の所持者Konstantin Friedrich Hartmann Schoenebeckを例にとります。39文字のTD3フィールドでは次のようになります。

HARTMANN<SCHOENEBECK<<KONSTANTIN<FRIEDR

最後の文字は R で文字なので、氏名は切られている可能性があり、実際に切られています。FRIEDRICH は3文字を失いました。同じ氏名をTD1の30文字幅で切ると、HARTMANN<SCHOENEBECK<<KONSTANT しか残りません。領域の氏名を所持者の氏名として保存するシステムは、KONSTANT を名として保存してしまいます。

ここから2つの習慣が導かれます。フィールドが埋まっているときは、領域の氏名を表示名に使わず、印字された氏名を使うこと。そして完全な印字氏名を領域の氏名と照合するとき、フィールドが埋まっていたなら、領域の氏名で始まる印字氏名は一致とみなすことです。

発行者は、末尾を切る以外の方法で氏名を短くすることもあります。たとえば2つ目の名を頭文字にする方法です。前方一致のテストは、そのような氏名を不一致と報告します。その場合は書類を却下せず、人の確認に回してください。

氏名を守るチェックディジットはない

チェックディジットが守るのは、旅券番号、2つの日付、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 は最初の << だけで分割し、それがなければ空の第2部分を返します。これが名前が1つの場合です。パターンが数字を拒否するのは意図的です。氏名フィールドの 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 };
}

領域と印字された氏名を比較する

印字された氏名は、人が読むページの部分である視覚読取領域から、アクセント記号や句読点付きで取得します。下の関数はそれを素の大文字に落とし、領域の氏名の一方の部分と比較します。返すのは3つの語のいずれかです: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文字です。

埋める前のフィールド 第1識別子 第2識別子 切り詰めの可能性
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パーサーも同じ方法で氏名を読みます。末尾のフィラーを取り除き、最初の << で分割し、1つだけのフィラーをスペースに置き換えます。<< のないフィールドは姓だけとして読みます。氏名がフィールドの最後の文字まで埋まっているときは、氏名が収まるように切り詰められている可能性があるという警告を、埋めている位置とともに表示します。ブラウザ内で動くので、合成した領域を貼り付けて、その読み取り結果を自分のコードと比べることができます。

パイプラインでの位置づけ

ホスティング型の認識サービスは領域と印字されたページの両方を読むので、問題はどちらの氏名を渡してくれるかです。doc.cheapの応答では、holder.surname、holder.given_names、holder.full_name が統合されたラテン文字の読み取り結果を持ちます。自国の文字で印字された氏名は fields の別のエントリです。読み取りごとに独自のエントリがあり、既定のラテン文字のものは id が @0 で終わり、自国の文字のものは language(たとえば Greek)を示します。領域そのものは読み取ったままの形で mrz.lines にあるので、氏名フィールドを切り出して上の2つの関数を適用できます。ラテン文字以外の文字体系のガイド(英語)では、読み取り結果をまとめて両方の綴りを保存する方法を紹介しています。まだサービスを選んでいる段階なら、パスポートOCR APIの比較で公開されている料金を並べて確認できます。

上のコードが誤って読む氏名を見つけたら、admin@doc.cheap までお知らせください。

**最後に質問です。**領域と印字された氏名を比べたとき、最も驚いた翻字は何でしたか。下のコメント欄で教えてください。