Setiap paspor punya dua baris teks yang tampak galak di bagian bawah halaman fotonya: huruf kapital, angka, dan banyak tanda <. Itulah zona baca mesin (machine-readable zone, MRZ), yang didefinisikan oleh ICAO Doc 9303, standar untuk dokumen perjalanan. Isinya sama dengan data inti di halaman cetak (nama, nomor dokumen, kewarganegaraan, tanggal lahir, jenis kelamin, tanggal kedaluwarsa), dalam bentuk yang bisa dibaca pemindai tanpa menebak-nebak jenis huruf atau tata letak.

MRZ juga membawa deteksi galatnya sendiri. Beberapa karakternya adalah digit pemeriksa (check digit): masing-masing dihitung dari satu field tertentu, dan satu digit terakhir mencakup beberapa field sekaligus. Jika satu karakter salah terbaca, digit yang melindunginya biasanya tidak lagi cocok. Karena itu MRZ adalah salah satu dari sedikit hal dalam pemrosesan dokumen identitas yang bisa Anda verifikasi sendiri, dengan dua puluh baris kode dan tanpa harus memercayai OCR milik siapa pun.

Artikel ini membahas algoritmanya, letak digit-digit tersebut di masing-masing dari tiga format MRZ, dan validator di Python serta JavaScript yang bisa Anda tempel ke proyek. Semua contoh memakai spesimen fiktif milik ICAO sendiri, Anna Maria Eriksson dari "Utopia" (UTO, kode negara yang hanya ada di spesimen). Tidak ada dokumen asli di mana pun.

Ini adalah blog doc.cheap, API pengenalan dokumen yang membaca MRZ dan memeriksa ulang digit-digit ini di server. Tidak ada bagian di bawah yang membutuhkannya; kodenya berjalan offline.

Alfabetnya

MRZ memakai tepat 37 karakter: 0-9, A-Z dan pengisi <. Tidak ada huruf kecil, spasi, maupun tanda baca. Nama dengan aksen atau aksara non-Latin ditransliterasi, dan spasi di dalam field menjadi <. Pengisi juga dipakai untuk menggenapi setiap field hingga lebar tetapnya, sehingga ERIKSSON<<ANNA<MARIA<<<<<<< berarti "nama keluarga ERIKSSON, nama depan ANNA MARIA", dengan << ganda yang memisahkan nama keluarga dari nama depan.

Algoritmanya: bobot 7, 3, 1

Digit pemeriksa dihitung dengan cara yang sama untuk setiap field di setiap format:

  1. Ubah setiap karakter menjadi angka. Digit bernilai dirinya sendiri. Huruf bernilai posisinya dalam alfabet ditambah 9, jadi A = 10, B = 11, … Z = 35. Pengisi < bernilai 0.
  2. Kalikan dengan bobot yang berulang 7, 3, 1, 7, 3, 1, … mulai dari karakter pertama field.
  3. Jumlahkan hasil kalinya dan ambil sisa bagi 10. Satu digit itulah digit pemeriksanya.

Contoh perhitungan pada nomor paspor spesimen, L898902C3, yang digit pemeriksa tercetaknya 6:

character   L    8    9    8    9    0    2    C    3
value      21    8    9    8    9    0    2   12    3
weight      7    3    1    7    3    1    7    3    1
product   147   24    9   56   27    0   14   36    3

sum = 316        316 mod 10 = 6        the zone prints 6

Mengapa 7-3-1? Bobotnya dipilih agar galat pembacaan yang paling umum mengubah jumlahnya: satu karakter yang salah, dan banyak kasus pertukaran dua karakter yang bersebelahan. Ini bukan checksum kriptografis. Siapa pun bisa menghitungnya, jadi digit yang cocok hanya membuktikan bahwa zona itu konsisten secara internal, bukan bahwa dokumennya asli.

Tiga format

ICAO 9303 mendefinisikan tiga tata letak MRZ. Jumlah baris dan jumlah karakter per baris membedakan ketiganya:

Format Baris × karakter Di mana Anda menjumpainya
TD1 3 × 30 Kartu identitas, izin tinggal
TD2 2 × 36 Kartu identitas lama dan sebagian dokumen perjalanan
TD3 2 × 44 Buku paspor

Spesimen yang dipakai di bawah ini:

TD3  P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<
     L898902C36UTO7408122F1204159ZE184226B<<<<<10

TD2  I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<
     D231458907UTO7408122F1204159<<<<<<<6

TD1  I<UTOD231458907<<<<<<<<<<<<<<<
     7408122F1204159UTO<<<<<<<<<<<6
     ERIKSSON<<ANNA<MARIA<<<<<<<<<<

Baca baris kedua TD3 dari kiri ke kanan: L898902C3 nomor dokumen, 6 digit pemeriksanya, UTO kewarganegaraan, 740812 tanggal lahir (YYMMDD), 2 digit pemeriksanya, F jenis kelamin, 120415 tanggal kedaluwarsa, 9 digit pemeriksanya, ZE184226B<<<<< data opsional (sering berupa nomor pribadi), 1 digit pemeriksanya, dan terakhir 0, digit pemeriksa gabungan (composite).

Parser MRZ memuat posisi setiap field dan digit pemeriksa di ketiga format sebagai tabel referensi, dan halaman format MRZ membahas setiap tata letak.

Letak setiap digit pemeriksa

Posisi dihitung dari 0, jadi bisa langsung dipakai di slice. Digit pemeriksa setiap field berada tepat setelah field tersebut.

Field TD3 (baris 2) TD2 (baris 2) TD1
Nomor dokumen 0–8, digit di 9 0–8, digit di 9 baris 1: 5–13, digit di 14
Tanggal lahir 13–18, digit di 19 13–18, digit di 19 baris 2: 0–5, digit di 6
Tanggal kedaluwarsa 21–26, digit di 27 21–26, digit di 27 baris 2: 8–13, digit di 14
Data opsional 28–41, digit di 42 tidak ada tidak ada
Gabungan digit di 43 digit di 35 baris 2: digit di 29

Digit gabungan adalah tempat sebagian besar validator buatan sendiri keliru, karena digit ini tidak mencakup seluruh baris:

  • TD3: posisi 0–9, 13–19 dan 21–42 dari baris 2. Kewarganegaraan (10–12) dan jenis kelamin (20) dilewati.
  • TD2: posisi 0–9, 13–19 dan 21–34 dari baris 2. Yang dilewati sama.
  • TD1: mencakup dua baris: baris 1 posisi 5–29, lalu baris 2 posisi 0–6, 8–14 dan 18–28.

Setiap rentang menyertakan digit pemeriksa per field yang ada di dalamnya, dan itulah yang membuat digit gabungan juga menangkap galat pada digit-digit itu sendiri.

Validator di Python

Tanpa dependensi. Validator ini mendeteksi format dari bentuknya, memeriksa setiap digit field dan digit gabungan, lalu mengembalikan dict berisi hasilnya.

WEIGHTS = (7, 3, 1)

def char_value(c):
    if c.isdigit():
        return int(c)
    if "A" <= c <= "Z":
        return ord(c) - ord("A") + 10
    if c == "<":
        return 0
    raise ValueError(f"not an MRZ character: {c!r}")

def check_digit(data):
    return sum(char_value(c) * WEIGHTS[i % 3] for i, c in enumerate(data)) % 10

def digit_ok(data, printed):
    # Field yang hanya berisi pengisi boleh mencetak "<" sebagai digit pemeriksanya.
    expected = 0 if printed == "<" else int(printed)
    return check_digit(data) == expected

# (nama, indeks baris, awal, akhir, posisi digit pemeriksa) per format
LAYOUTS = {
    "TD3": [("document number", 1, 0, 9, 9), ("birth date", 1, 13, 19, 19),
            ("expiry date", 1, 21, 27, 27), ("personal number", 1, 28, 42, 42)],
    "TD2": [("document number", 1, 0, 9, 9), ("birth date", 1, 13, 19, 19),
            ("expiry date", 1, 21, 27, 27)],
    "TD1": [("document number", 0, 5, 14, 14), ("birth date", 1, 0, 6, 6),
            ("expiry date", 1, 8, 14, 14)],
}

def composite(fmt, lines):
    if fmt == "TD3":
        l = lines[1]
        return l[0:10] + l[13:20] + l[21:43], l[43]
    if fmt == "TD2":
        l = lines[1]
        return l[0:10] + l[13:20] + l[21:35], l[35]
    a, b = lines[0], lines[1]
    return a[5:30] + b[0:7] + b[8:15] + b[18:29], b[29]

def detect(lines):
    shape = (len(lines), len(lines[0]))
    fmt = {(2, 44): "TD3", (2, 36): "TD2", (3, 30): "TD1"}.get(shape)
    if fmt is None or any(len(l) != shape[1] for l in lines):
        raise ValueError(f"unknown MRZ shape: {[len(l) for l in lines]}")
    return fmt

def validate(lines):
    fmt = detect(lines)
    results = {}
    for name, li, start, end, pos in LAYOUTS[fmt]:
        results[name] = digit_ok(lines[li][start:end], lines[li][pos])
    data, printed = composite(fmt, lines)
    results["composite"] = digit_ok(data, printed)
    return fmt, results

if __name__ == "__main__":
    print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
                     "L898902C36UTO7408122F1204159ZE184226B<<<<<10"]))
    print(*validate(["I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<",
                     "D231458907UTO7408122F1204159<<<<<<<6"]))
    print(*validate(["I<UTOD231458907<<<<<<<<<<<<<<<",
                     "7408122F1204159UTO<<<<<<<<<<<6",
                     "ERIKSSON<<ANNA<MARIA<<<<<<<<<<"]))
    # Satu karakter salah terbaca: 3 terbaca sebagai 4 di nomor dokumen
    print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
                     "L898902C46UTO7408122F1204159ZE184226B<<<<<10"]))

Keluaran:

TD3 {'document number': True, 'birth date': True, 'expiry date': True, 'personal number': True, 'composite': True}
TD2 {'document number': True, 'birth date': True, 'expiry date': True, 'composite': True}
TD1 {'document number': True, 'birth date': True, 'expiry date': True, 'composite': True}
TD3 {'document number': False, 'birth date': True, 'expiry date': True, 'personal number': True, 'composite': False}

Baris terakhir adalah inti dari seluruh latihan ini: satu karakter salah terbaca menjadi karakter tetangganya, dan digit field maupun digit gabungan sama-sama menandainya.

Validator yang sama di JavaScript

Modul ES biasa, berjalan di Node maupun di browser.

const WEIGHTS = [7, 3, 1];

function charValue(c) {
  if (c >= "0" && c <= "9") return c.charCodeAt(0) - 48;
  if (c >= "A" && c <= "Z") return c.charCodeAt(0) - 55; // A = 10
  if (c === "<") return 0;
  throw new Error(`not an MRZ character: ${JSON.stringify(c)}`);
}

export function checkDigit(data) {
  let sum = 0;
  for (let i = 0; i < data.length; i++) sum += charValue(data[i]) * WEIGHTS[i % 3];
  return sum % 10;
}

const digitOk = (data, printed) => checkDigit(data) === (printed === "<" ? 0 : Number(printed));

const LAYOUTS = {
  TD3: [["document number", 1, 0, 9], ["birth date", 1, 13, 19], ["expiry date", 1, 21, 27], ["personal number", 1, 28, 42]],
  TD2: [["document number", 1, 0, 9], ["birth date", 1, 13, 19], ["expiry date", 1, 21, 27]],
  TD1: [["document number", 0, 5, 14], ["birth date", 1, 0, 6], ["expiry date", 1, 8, 14]],
};

function composite(fmt, [a, b]) {
  if (fmt === "TD3") return [b.slice(0, 10) + b.slice(13, 20) + b.slice(21, 43), b[43]];
  if (fmt === "TD2") return [b.slice(0, 10) + b.slice(13, 20) + b.slice(21, 35), b[35]];
  return [a.slice(5, 30) + b.slice(0, 7) + b.slice(8, 15) + b.slice(18, 29), b[29]];
}

export function validate(lines) {
  const fmt = { "2x44": "TD3", "2x36": "TD2", "3x30": "TD1" }[`${lines.length}x${lines[0].length}`];
  if (!fmt || lines.some((l) => l.length !== lines[0].length)) throw new Error("unknown MRZ shape");
  const results = {};
  // Digit pemeriksa berada tepat setelah field yang dilindunginya.
  for (const [name, li, start, end] of LAYOUTS[fmt]) {
    results[name] = digitOk(lines[li].slice(start, end), lines[li][end]);
  }
  const [data, printed] = composite(fmt, lines);
  results.composite = digitOk(data, printed);
  return { format: fmt, results };
}

console.log(validate([
  "P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
  "L898902C36UTO7408122F1204159ZE184226B<<<<<10",
]));

node mrz.mjs mencetak format: 'TD3' dan true untuk kelima pemeriksaan.

Jebakan-jebakannya

Jangan buang karakter pengisi. Karakter < adalah bagian dari data yang dipakai untuk menghitung digit. Hapus < di ujung baris, dan digit gabungan akan gagal pada dokumen yang sebenarnya sempurna.

Jangan susun ulang zona dari field hasil parsing. Jika Anda mem-parsing MRZ menjadi field, menormalkannya (tanggal ke ISO, nama dengan spasi), lalu menyerialisasikannya kembali untuk memeriksa digit, yang Anda periksa adalah serializer Anda sendiri. Periksa baris mentah persis seperti saat dibaca.

Normalkan keluaran OCR sebelum validasi, dengan hati-hati. Mesin OCR gemar mengembalikan huruf kecil, spasi, atau « sebagai ganti <. Mengubah ke huruf kapital dan membuang spasi itu aman. Mengganti O dengan 0 "karena nomor dokumen berupa angka" tidak aman: nomor dokumen bisa mengandung huruf, persis seperti yang ditunjukkan L898902C3.

Digit yang lolos bukan berarti tanggalnya nyata. 740812 lolos pemeriksaan digitnya terlepas dari masuk akal atau tidaknya 12 Agustus 1974, dan YYMMDD tidak menyimpan abad. Tentukan abadnya dari konteks: tanggal lahir ada di masa lalu, tanggal kedaluwarsa biasanya di masa depan.

Nomor dokumen panjang pada TD1. ICAO mengizinkan nomor dokumen TD1 yang lebih dari sembilan karakter meluap ke field data opsional, dengan < di posisi digit pemeriksa yang biasa dan digit pemeriksanya ditempatkan setelah karakter terakhir nomor tersebut. Validator di atas tidak menangani kasus ini. Jika Anda memproses kartu identitas dari penerbit yang memakainya, tambahkan satu cabang; parser MRZ menanganinya, jika Anda butuh pembanding.

Digit pemeriksa bukan bukti keaslian. Siapa pun yang bisa menyunting gambar bisa menghitung digit yang valid. MRZ memberi tahu Anda bahwa zona itu terbaca dengan benar dan konsisten secara internal, bukan bahwa dokumennya asli. Membandingkan MRZ dengan zona visual yang tercetak adalah sinyal yang lebih kuat, dan itu pun bukan pemeriksaan pemalsuan.

Data uji tanpa paspor asli

Anda tidak semestinya membutuhkan paspor milik orang sungguhan untuk menguji kode ini. Ada dua pilihan:

  • Spesimen ICAO di atas, yang memang diterbitkan untuk tujuan ini.
  • Buat sendiri: generator MRZ menyusun zona TD3 sintetis dengan digit pemeriksa yang benar dari nilai yang Anda ketik, di browser. Ubah satu karakter sesudahnya, dan Anda punya kasus yang gagal.

Untuk arah sebaliknya, tempel zona apa pun (TD1, TD2 atau TD3) ke parser MRZ: parser mendeteksi formatnya, membaca setiap field, dan menampilkan setiap digit pemeriksa hasil hitungan di samping yang tercetak, semuanya di browser. Ini berguna ketika implementasi Anda dan implementasi orang lain tidak sepakat.

Tempatnya dalam pipeline sungguhan

Jika Anda membaca MRZ dengan OCR sendiri, jalankan pemeriksaan ini pada setiap pembacaan dan perlakukan kegagalan sebagai "foto ulang", bukan "tolak orangnya": pantulan cahaya di satu karakter, laminasi yang aus, atau halaman yang terlipat jauh lebih sering terjadi daripada penipuan.

Jika Anda memakai API pengenalan yang di-hosting, tetap jalankan ulang digitnya sendiri ketika hasilnya menentukan uang atau akses. Hanya bagian jawaban inilah yang bisa Anda periksa tanpa harus memercayai vendornya. Itu termasuk kami: respons doc.cheap menyertakan zona apa adanya sebagai mrz.lines dan mrz.text (baris-baris yang digabung tanpa pemisah) di samping verdiknya sendiri, mrz.status, justru agar Anda bisa memasukkannya ke fungsi seperti di atas. Memeriksa MRZ di dokumentasi membahas alur tersebut.

Jika Anda menemukan kasus yang salah ditangani validator ini, tulis ke admin@doc.cheap.

Kedua blok kode telah dijalankan dan keluarannya ditempel persis seperti yang tercetak; setiap pernyataan tentang doc.cheap telah diperiksa terhadap kodenya.