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

يقدّم هذا المقال القاعدة لتواريخ الميلاد ولتواريخ الانتهاء، والحالات الحدّية التي تُفسد المحللات البسيطة، ودالة بلغتي Python وJavaScript مع جدول لمتجهات الاختبار يمكنك إضافته إلى مجموعة اختباراتك. كل الأمثلة تستخدم العيّنة الوهمية لمنظمة ICAO أو قيمًا اصطناعية؛ لا تظهر أي وثيقة حقيقية.

هذه مدونة doc.cheap، وهي واجهة API للتعرّف على جوازات السفر وبطاقات الهوية تقرأ المنطقة نيابةً عنك. لا شيء مما يلي يحتاج إليها؛ فالشيفرة بلا اعتماديات.

أين تقع التواريخ

يعطي المستند Doc 9303 الصادر عن ICAO، وهو معيار وثائق السفر المقروءة آليًا، كل تنسيق حقلَي تاريخ: تاريخ الميلاد وتاريخ الانتهاء، ويُكتب كل منهما بصيغة 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 وما بعدها). وتعمل الدالة time.strptime في Python نفسها بهذه الطريقة مع %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."

هذا افتراض معقول لملف سجلات. أما لتاريخ الميلاد فهو خاطئ في الاتجاهين. وفق هذه القاعدة، فإن شخصًا وُلد في 12 أبريل 1968 (680412) يكون مولودًا في 2068. حرّك نقطة الارتكاز ولن تفعل سوى نقل الخطأ: أي عدد ثابت خاطئ بالنسبة إلى أحدٍ ما، ويزداد خطؤه كل عام تبقى فيه الشيفرة في بيئة الإنتاج. الإجابة الصحيحة تعتمد على تاريخ اليوم، لذا يجب أن تأخذ القاعدة تاريخ اليوم مُدخلًا.

تواريخ الميلاد: أحدث قرن لا يقع في المستقبل

اقرأ السنة على أنها 2000 + YY. إذا كان التاريخ الكامل الناتج لاحقًا لليوم، فاطرح 100 سنة. هذا كل شيء.

ثلاث تفاصيل تحدد ما إذا كان التطبيق صحيحًا فعلًا:

  • قارن التاريخ كاملًا، لا السنة وحدها. اليوم في الأمثلة أدناه هو 24 سبتمبر 2026. القيمة 261224 في قراءة الألفية الجديدة هي 24 ديسمبر 2026، أي بعد ثلاثة أشهر، لذا وُلد الشخص في 24 ديسمبر 1926. مقارنة 26 بـ26 فقط كانت ستعدّه مولودًا جديدًا.
  • اليوم يُحسب من الماضي. من وُلد اليوم وُلد في هذا القرن، لا في القرن السابق. تبقى 260924 هي 24 سبتمبر 2026؛ أما 260925، بعد يوم واحد، فتعود إلى 1926. استخدم «أكبر من»، لا «أكبر من أو يساوي».
  • خذ «اليوم» بتوقيت UTC، أو بمنطقة زمنية ثابتة واحدة تختارها، ومرّره إلى الدالة. الدالة التي تقرأ الساعة بنفسها لا يمكن اختبارها، وقد يختلف خادم في منطقة زمنية ومتصفح في منطقة أخرى على التاريخ خلال جزء من كل يوم.

تواريخ الانتهاء: هذا القرن

يُقرأ تاريخ الانتهاء على أنه 2000 + YY ويُترك كذلك. جواز السفر الذي انتهت صلاحيته في 2012 يبقى تاريخه 2012 لا 2112، والذي تنتهي صلاحيته في 2032 هو 2032. لن تكون قراءة القرن العشرين صحيحة إلا لوثيقة انتهت صلاحيتها قبل عام 2000، ولا توجد وثيقة كهذه ما زالت قيد الاستخدام. تبقى هذه القاعدة صالحة حتى تسعينيات القرن الحادي والعشرين، حين قد تنتهي صلاحية وثيقة صادرة آنذاك بعد عام 2100؛ ستحتاج الشيفرة إلى مراجعة قبل ذلك، لا الآن.

الحالات الحدّية التي تستحق اختبارًا

29 فبراير عبر القرون. اختر القرن أولًا، ثم تحقق من أن التاريخ موجود. القيمة 280229 بصفتها تاريخ ميلاد لاحقة لليوم في قراءة الألفية الجديدة، فتصبح 29 فبراير 1928، وهو تاريخ موجود لأن 1928 سنة كبيسة. والقيمة 000229 هي 29 فبراير 2000، وهو موجود لأن 2000 سنة كبيسة؛ أما في 1900، التي لم تكن كبيسة، فلن تكون الأرقام الستة نفسها تاريخًا أصلًا. السنة 00 هي السنة الوحيدة ذات الرقمين التي يغيّر فيها القرن الإجابة عن سؤال «هل هذه سنة كبيسة؟». تحقّق من صحة التاريخ قبل اختيار القرن وستخطئ في إحدى هذه الحالات.

تاريخ ميلاد يقع لاحقًا في هذا العام. سبق شرحه أعلاه: 261224 هي 1926 لا 2026، وينبغي أن يثبّت اختبارٌ ذلك باستخدام «يوم» ثابت.

محارف الحشو في التاريخ. قد تحمل المنطقة < في تاريخ الميلاد حين يكون جزء من التاريخ مجهولًا، مثل 94<<08. لا يوجد تاريخ تقويمي يمكن إرجاعه. الدالة أدناه لا تُرجع شيئًا لأي قيمة ليست ستة أرقام، وتترك للمستدعي عرض النص الخام.

رقم تحقق صحيح على تاريخ مستحيل. رقم التحقق يحمي المحارف، لا التقويم. بالأوزان 7 و3 و1، مجموع 741312 هو 88، فيكون رقم تحققه 8، وتجتاز 7413128 الفحص. الشهر الثالث عشر غير موجود. رقم التحقق والتاريخ فحصان منفصلان ويجب أن ينجح كلاهما؛ ويشرح مقال كيف تعمل أرقام التحقق في MRZ الفحص الأول بالكامل.

أشخاص تجاوزوا المئة. شخص وُلد في 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. وتستخدم المقارنة صفًّا (tuple) كي يُقارَن حتى الشهر المستحيل دون مشكلة قبل أن ترفضه 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.

متجهات الاختبار

كل صف أدناه محسوب يدويًا من القاعدة، مع تثبيت اليوم على 24 سبتمبر 2026. يجب أن تُرجع الدالتان كلتاهما القيمة الواردة في العمود الأخير (None في Python وnull في JavaScript بمعنى «لا تاريخ»).

المُدخل النوع المتوقع السبب
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 تاريخ الانتهاء يبقى في الألفية الجديدة
320310 expiry 2032-03-10 تاريخ الانتهاء يبقى في الألفية الجديدة
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 لا شيء لا يوجد شهر ثالث عشر
94<<08 birth لا شيء محارف حشو، لا تاريخ

مشغّل الاختبارات لنسخة Python هو حلقة تمرّ على هذه الصفوف مع today = date(2026, 9, 24)، وتقارن result.isoformat() (أو None) بالعمود المتوقع. أبقِ «اليوم» ثابتًا في الاختبارات: مع الساعة الحقيقية، تتغير إجابة صفوف مثل 261224 في 25 ديسمبر.

ما يفعله محلل MRZ لدينا

يتبع محلل MRZ في هذا الموقع القاعدة نفسها: تبدأ السنة بوصفها 2000 + YY، وتاريخ الميلاد الذي يكون تاريخه الكامل لاحقًا لليوم (بتوقيت UTC) يعود 100 سنة، ويأتي فحص التقويم بعد اختيار القرن، وتبقى تواريخ الانتهاء في الألفية الجديدة. تغطي اختباراته الخاصة تاريخًا لاحقًا في هذا العام، واليوم، و29 فبراير على جانبي القرن. وللقيمة التي لا يستطيع تحويلها إلى تاريخ يعرض النص الخام بدلًا من ذلك، مع ? حيث تحمل المنطقة محرف حشو، وحين تجتاز ستة أرقام رقم تحققها دون أن تشير إلى تاريخ تقويمي، ينبّه إلى ذلك في تحذير. ويُعلَّم تاريخ الانتهاء بأنه منتهٍ أو غير منتهٍ مقارنةً بتاريخ اليوم بتوقيت UTC. يعمل المحلل في المتصفح، فيمكنك لصق منطقة اصطناعية ومقارنة قراءته بتطبيقك الخاص.

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

إذا كنت تستخدم خدمة تعرّف مستضافة، فقد اختيرَ القرن نيابةً عنك في حقول التاريخ؛ والسؤال هو هل قاعدتها هي القاعدة التي تريدها. تُرجع استجابة doc.cheap الحقلين holder.birth_date وdocument.expiry_date بصيغة ISO YYYY-MM-DD، وبجانبهما المنطقة كما قُرئت تمامًا في mrz.lines، لكي تقتطع الأرقام الستة بنفسك وتطبّق الدالة أعلاه حين يتوقف على التاريخ وصولٌ أو مال. وإن كنت لا تزال تختار خدمة، فإن مقارنة واجهات API للتعرّف على جوازات السفر تضع الأسعار المنشورة جنبًا إلى جنب.

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