في أسفل صفحة البيانات في كل جواز سفر سطران من نص يبدو منفّرًا: أحرف كبيرة وأرقام وكثير من علامات <. هذه هي منطقة القراءة الآلية (MRZ، اختصار Machine Readable Zone)، ويحددها المستند 9303 الصادر عن منظمة الطيران المدني الدولي (ICAO Doc 9303)، وهو معيار وثائق السفر. تحمل المنطقة البيانات الأساسية نفسها المطبوعة في الصفحة (الاسم، ورقم الوثيقة، والجنسية، وتاريخ الميلاد، والجنس، وتاريخ الانتهاء)، بصيغة يستطيع الماسح الضوئي قراءتها دون تخمين الخطوط أو طريقة التخطيط.

وتحمل المنطقة أيضًا آلية لكشف أخطائها بنفسها. بعض محارفها أرقام تحقق (check digits): يُحسب كل منها من حقل محدد، ويغطي رقم أخير واحد عدة حقول معًا. إذا أُسيئت قراءة محرف واحد، فإن الرقم الذي يحميه يتوقف عادةً عن المطابقة. وهذا يجعل MRZ من الأشياء القليلة في معالجة وثائق الهوية التي يمكنك التحقق منها بنفسك، بعشرين سطرًا من الشيفرة ودون أن تثق في OCR أي جهة.

يشرح هذا المقال الخوارزمية، وموضع الأرقام في كل واحد من تنسيقات MRZ الثلاثة، ويقدّم أداة تحقق بلغتي Python وJavaScript يمكنك لصقها في مشروعك. كل الأمثلة تستخدم العيّنة الوهمية التي نشرتها ICAO نفسها، آنا ماريا إريكسون من «يوتوبيا» (UTO، رمز دولة لا وجود له إلا في العيّنات). لا تظهر أي وثيقة حقيقية في أي مكان.

هذه مدونة doc.cheap، وهي واجهة API للتعرّف على الوثائق تقرأ منطقة MRZ وتعيد فحص هذه الأرقام على الخادم. لا شيء مما يلي يحتاج إليها؛ فالشيفرة تعمل دون اتصال بالإنترنت.

الأبجدية

تستخدم منطقة MRZ 37 محرفًا بالضبط: 0-9 وA-Z ومحرف الحشو <. لا أحرف صغيرة ولا مسافات ولا علامات ترقيم. الأسماء التي تحتوي على أحرف بعلامات تشكيل أو بخط غير لاتيني تُنقل إلى الحروف اللاتينية، وتتحول المسافات داخل الحقل إلى <. ويملأ محرف الحشو أيضًا كل حقل حتى عرضه الثابت، فتعني ERIKSSON<<ANNA<MARIA<<<<<<< «اللقب ERIKSSON، والأسماء الأولى ANNA MARIA»، إذ تفصل << المزدوجة بين اللقب والأسماء الأولى.

الخوارزمية: الأوزان 7 و3 و1

يُحسب رقم التحقق بالطريقة نفسها لكل حقل في كل تنسيق:

  1. حوّل كل محرف إلى عدد. قيمة الرقم هي الرقم نفسه. وقيمة الحرف هي ترتيبه في الأبجدية زائد 9، أي A = 10، وB = 11، … وZ = 35. وقيمة محرف الحشو < هي 0.
  2. اضرب في وزن متكرر هو 7، 3، 1، 7، 3، 1، … بدءًا من أول محرف في الحقل.
  3. اجمع النواتج وخذ باقي القسمة على 10. هذا الرقم الواحد هو رقم التحقق.

مثال محلول على رقم جواز العيّنة، L898902C3، ورقم تحققه المطبوع هو 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

لماذا 7-3-1؟ اختيرت الأوزان بحيث تغيّر أكثرُ أخطاء القراءة شيوعًا المجموعَ: محرف واحد خاطئ، وكثير من حالات تبديل محرفين متجاورين. هذا ليس مجموع تحقق تشفيريًا. يستطيع أي أحد حسابه، لذا فإن تطابق الرقم لا يثبت إلا أن المنطقة متسقة داخليًا، لا أن الوثيقة أصلية.

التنسيقات الثلاثة

يعرّف معيار ICAO 9303 ثلاثة تخطيطات لمنطقة MRZ. يميّز بينها عدد الأسطر وعدد المحارف في كل سطر:

التنسيق الأسطر × المحارف أين تصادفه
TD1 3 × 30 بطاقات الهوية، وتصاريح الإقامة
TD2 2 × 36 بطاقات الهوية الأقدم وبعض وثائق السفر
TD3 2 × 44 دفاتر جوازات السفر

العيّنات المستخدمة أدناه:

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

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

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

اقرأ السطر الثاني من TD3 من اليسار إلى اليمين: L898902C3 رقم الوثيقة، و6 رقم تحققه، وUTO الجنسية، و740812 تاريخ الميلاد (YYMMDD)، و2 رقم تحققه، وF الجنس، و120415 تاريخ الانتهاء، و9 رقم تحققه، وZE184226B<<<<< بيانات اختيارية (غالبًا رقم شخصي)، و1 رقم تحققها، وأخيرًا 0، وهو رقم التحقق المركّب.

يعرض محلل MRZ موضع كل حقل وكل رقم تحقق في التنسيقات الثلاثة في جدول مرجعي، وتشرح صفحة تنسيقات MRZ كل تخطيط على حدة.

أين يقع كل رقم تحقق

الترقيم يبدأ من 0، فتصلح المواضع للاستخدام مع slice مباشرة. يقع رقم تحقق كل حقل بعد الحقل مباشرة.

الحقل TD3 (السطر 2) TD2 (السطر 2) TD1
رقم الوثيقة 0–8، والرقم في 9 0–8، والرقم في 9 السطر 1: 5–13، والرقم في 14
تاريخ الميلاد 13–18، والرقم في 19 13–18، والرقم في 19 السطر 2: 0–5، والرقم في 6
تاريخ الانتهاء 21–26، والرقم في 27 21–26، والرقم في 27 السطر 2: 8–13، والرقم في 14
البيانات الاختيارية 28–41، والرقم في 42 لا يوجد لا يوجد
المركّب الرقم في 43 الرقم في 35 السطر 2: الرقم في 29

رقم التحقق المركّب هو الموضع الذي تخطئ فيه معظم أدوات التحقق المكتوبة يدويًا، لأنه لا يغطي السطر كله:

  • TD3: المواضع 0–9 و13–19 و21–42 من السطر 2. يتخطى الجنسية (10–12) والجنس (20).
  • TD2: المواضع 0–9 و13–19 و21–34 من السطر 2. بالتخطيات نفسها.
  • TD1: يمتد على سطرين: المواضع 5–29 من السطر 1، ثم المواضع 0–6 و8–14 و18–28 من السطر 2.

يشمل كل نطاق أرقام التحقق الخاصة بالحقول الواقعة داخله، وهذا ما يجعل الرقم المركّب يلتقط الأخطاء في أرقام التحقق نفسها.

أداة تحقق بلغة Python

دون أي اعتماديات. تكتشف التنسيق من شكل المنطقة، وتفحص رقم تحقق كل حقل والرقم المركّب، وتعيد قاموسًا (dict) بالنتائج.

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):
    # الحقل المكوّن من محارف الحشو وحدها قد يطبع "<" في موضع رقم التحقق.
    expected = 0 if printed == "<" else int(printed)
    return check_digit(data) == expected

# (الاسم، رقم السطر، البداية، النهاية، موضع رقم التحقق) لكل تنسيق
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<<<<<<<<<<"]))
    # محرف واحد قُرئ خطأً: 3 قُرئ 4 في رقم الوثيقة
    print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
                     "L898902C46UTO7408122F1204159ZE184226B<<<<<10"]))

المخرجات:

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}

السطر الأخير هو بيت القصيد: محرف واحد قُرئ على أنه المحرف المجاور له، فيكشفه كلٌّ من رقم تحقق الحقل والرقم المركّب.

أداة التحقق نفسها بلغة JavaScript

وحدة ES عادية، تعمل في Node أو في المتصفح.

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 = {};
  // يقع رقم التحقق مباشرة بعد الحقل الذي يحميه.
  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 القيمة format: 'TD3' والقيمة true للفحوص الخمسة كلها.

الفخاخ

لا تحذف محارف الحشو. محارف < جزء من البيانات التي تُحسب عليها الأرقام. احذف علامات < من نهاية السطر وسيفشل الرقم المركّب على وثيقة سليمة تمامًا.

لا تُعِد بناء المنطقة من الحقول بعد تحليلها. إذا حلّلت منطقة MRZ إلى حقول، ثم وحّدت صيغتها (التواريخ إلى ISO، والأسماء بمسافات)، ثم أعدت تجميعها في أسطر لفحص الأرقام، فأنت تفحص أداة التجميع التي كتبتها. افحص الأسطر الخام كما قُرئت.

وحّد مخرجات OCR قبل التحقق، ولكن بحذر. تميل محركات OCR إلى إعادة أحرف صغيرة أو مسافات أو « بدلًا من <. تحويل الأحرف إلى كبيرة وحذف المسافات البيضاء آمن. أما استبدال O بـ0 «لأن أرقام الوثائق رقمية» فليس آمنًا: قد تحتوي أرقام الوثائق على أحرف، وهذا بالضبط ما يُظهره L898902C3.

الرقم الصحيح لا يعني تاريخًا حقيقيًا. يجتاز 740812 رقم تحققه سواء كان الثاني عشر من أغسطس 1974 تاريخًا معقولًا أم لا، وصيغة YYMMDD لا تتضمن القرن. حدّد القرن من السياق: تاريخ الميلاد في الماضي، وتاريخ الانتهاء في المستقبل عادةً.

أرقام الوثائق الطويلة في TD1. تسمح ICAO لرقم وثيقة TD1 الأطول من تسعة محارف بأن يمتد إلى حقل البيانات الاختيارية، مع < في الموضع المعتاد لرقم التحقق، ويأتي رقم التحقق بعد آخر محرف من الرقم. أداة التحقق أعلاه لا تعالج هذه الحالة. إذا كنت تعالج بطاقات هوية من جهات إصدار تستخدمها، فأضف فرعًا لها؛ ومحلل MRZ يعالجها، إن أردت شيئًا تقارن به.

أرقام التحقق ليست دليلًا على الأصالة. أي شخص يستطيع تعديل صورة يستطيع حساب رقم صحيح. تخبرك منطقة MRZ بأنها قُرئت بشكل صحيح وأنها متسقة داخليًا، لا بأن الوثيقة أصلية. مقارنة MRZ بالمنطقة المرئية المطبوعة إشارة أقوى، وحتى هذه ليست فحصًا للتزوير.

بيانات اختبار دون جوازات سفر حقيقية

لا ينبغي أن تحتاج أبدًا إلى جواز سفر شخص حقيقي لاختبار هذه الشيفرة. أمامك خياران:

  • عيّنات ICAO أعلاه، وهي منشورة لهذا الغرض تحديدًا.
  • أنشئ عيّناتك بنفسك: يبني مولّد MRZ منطقة TD3 اصطناعية بأرقام تحقق صحيحة من القيم التي تكتبها، داخل المتصفح. غيّر محرفًا واحدًا بعد ذلك وستحصل على حالة فاشلة.

وللاتجاه المعاكس، الصق أي منطقة (TD1 أو TD2 أو TD3) في محلل MRZ: فهو يكتشف التنسيق، ويقرأ كل حقل، ويعرض كل رقم تحقق محسوب بجانب الرقم المطبوع، وكل ذلك في المتصفح. وهذا مفيد حين تختلف نتيجة تطبيقك عن نتيجة تطبيق شخص آخر.

أين يندرج هذا في مسار عمل حقيقي

إذا كنت تقرأ مناطق MRZ باستخدام OCR خاص بك، فشغّل هذه الفحوص على كل قراءة، وتعامل مع الفشل على أنه «أعد التصوير» لا «ارفض الشخص»: فالوهج فوق محرف واحد، أو الطبقة البلاستيكية البالية، أو الصفحة المجعّدة أكثر شيوعًا بكثير من الاحتيال.

وإذا كنت تستخدم بدلًا من ذلك واجهة API مستضافة للتعرّف، فأعد حساب الأرقام بنفسك على أي حال حين تتوقف على النتيجة أموال أو صلاحيات وصول. فهذا هو الجزء الوحيد من الإجابة الذي يمكنك فحصه دون الثقة بالمزوّد. وهذا يشملنا نحن أيضًا: تنشر استجابة doc.cheap المنطقة كما هي حرفيًا في mrz.lines وmrz.text (الأسطر موصولة دون أي فاصل بينها) بجانب حكمها الخاص mrz.status، تحديدًا لكي تمررها إلى دالة كالتي أعلاه. ويشرح دليل فحص MRZ في التوثيق هذا المسار.

إذا وجدت حالة تخطئ فيها أداة التحقق، فاكتب إلى admin@doc.cheap.

شُغّلت كتلتا الشيفرة كلتاهما ونُسخت مخرجاتهما كما طُبعت؛ وكل عبارة عن doc.cheap جرى التحقق منها مقابل شيفرتها.