Mọi ngày tháng trong vùng đọc máy (MRZ) đều gồm sáu chữ số: hai cho năm, hai cho tháng, hai cho ngày. Không có thế kỷ. Hộ chiếu mẫu mà ICAO công bố ghi ngày sinh là 740812, và không có gì trong vùng này cho bạn biết đó là năm 1974 hay 2074. Bạn biết đó là 1974 vì không ai có hộ chiếu trước khi được sinh ra, và chính lập luận đó là toàn bộ quy tắc. Để xử lý thật chính xác, kể cả vào đúng ngày mà điều này quan trọng, cần cẩn thận hơn một chút so với một năm mốc cố định.

Bài viết này đưa ra quy tắc cho ngày sinh và ngày hết hạn, các trường hợp biên làm hỏng những trình phân tích đơn giản, và một hàm bằng Python và JavaScript kèm bảng vector kiểm thử mà bạn có thể đưa thẳng vào bộ kiểm thử của mình. Mọi ví dụ đều dùng mẫu hư cấu của ICAO hoặc giá trị tổng hợp; không có giấy tờ thật nào xuất hiện.

Đây là blog của doc.cheap, một API OCR hộ chiếu và giấy tờ tùy thân đọc vùng MRZ thay cho bạn. Nội dung dưới đây không cần đến nó; mã không có phụ thuộc nào.

Vị trí của các ngày

ICAO Doc 9303, tiêu chuẩn cho giấy tờ du lịch đọc được bằng máy, quy định cho mỗi định dạng hai trường ngày: ngày sinh và ngày hết hạn, mỗi trường được viết dưới dạng YYMMDD và theo sau là chữ số kiểm tra riêng của nó. Vị trí đánh số từ 0, dùng được ngay với slice:

Định dạng Ngày sinh Ngày hết hạn
TD3 (hộ chiếu), dòng 2 13–18, chữ số kiểm tra ở 19 21–26, chữ số kiểm tra ở 27
TD2, dòng 2 13–18, chữ số kiểm tra ở 19 21–26, chữ số kiểm tra ở 27
TD1 (thẻ căn cước), dòng 2 0–5, chữ số kiểm tra ở 6 8–13, chữ số kiểm tra ở 14

Trên dòng mẫu TD3 L898902C36UTO7408122F3404159ZE184226B<<<<<16, ta có 740812 là ngày sinh và 340415 là ngày hết hạn.

Vì sao một năm mốc cố định không đúng

Lối tắt thường gặp là một điểm xoay: năm hai chữ số nhỏ hơn một số nào đó thuộc những năm 2000, còn lại thuộc những năm 1900. Chính time.strptime của Python xử lý %y theo cách này. Tài liệu của nó viết: "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."

Với một tệp log, đó là giá trị mặc định hợp lý. Với ngày sinh, nó sai theo cả hai hướng. Theo quy tắc đó, một người sinh ngày 12 tháng 4 năm 1968 (680412) sẽ thành sinh năm 2068. Dời điểm xoay đi chỉ dời lỗi đi: mọi con số cố định đều sai với ai đó, và càng sai nhiều hơn theo mỗi năm mã còn chạy trong môi trường thực tế. Câu trả lời đúng phụ thuộc vào ngày hôm nay, nên quy tắc phải nhận ngày hôm nay làm đầu vào.

Ngày sinh: thế kỷ gần nhất không nằm ở tương lai

Đọc năm là 2000 + YY. Nếu ngày đầy đủ thu được muộn hơn hôm nay, trừ đi 100 năm. Chỉ vậy thôi.

Ba chi tiết quyết định một cách triển khai có thực sự đúng hay không:

  • So sánh cả ngày, không chỉ năm. Trong các ví dụ dưới đây, hôm nay là ngày 24 tháng 9 năm 2026. 261224 khi đọc theo những năm 2000 là ngày 24 tháng 12 năm 2026, tức còn ba tháng nữa, nên người này sinh ngày 24 tháng 12 năm 1926. Nếu chỉ so 26 với 26, bạn sẽ coi đó là một trẻ sơ sinh.
  • Hôm nay được tính là quá khứ. Người sinh hôm nay là sinh ở thế kỷ này, không phải thế kỷ trước. 260924 vẫn là ngày 24 tháng 9 năm 2026; 260925, muộn hơn một ngày, quay về năm 1926. Hãy dùng "muộn hơn", không dùng "muộn hơn hoặc bằng".
  • Lấy "hôm nay" theo UTC, hoặc theo một múi giờ cố định bạn chọn, và truyền nó vào. Một hàm tự đọc đồng hồ thì không kiểm thử được, và một máy chủ ở múi giờ này với một trình duyệt ở múi giờ khác có thể không thống nhất về ngày trong một phần của mỗi ngày.

Ngày hết hạn: thế kỷ này

Ngày hết hạn được đọc là 2000 + YY và giữ nguyên. Hộ chiếu hết hạn năm 2012 vẫn là ngày năm 2012, không phải 2112, và hộ chiếu hết hạn năm 2032 là 2032. Cách đọc theo những năm 1900 chỉ đúng với giấy tờ đã hết hạn trước năm 2000, và không còn giấy tờ nào như vậy đang được sử dụng. Quy tắc này đúng cho đến những năm 2090, khi một giấy tờ được cấp lúc đó có thể hết hạn vào những năm 2100; mã sẽ cần được xem lại trước thời điểm đó, chứ không phải bây giờ.

Các trường hợp biên đáng kiểm thử

Ngày 29 tháng 2 qua các thế kỷ. Chọn thế kỷ trước, rồi mới kiểm tra ngày đó có tồn tại không. 280229 là ngày sinh, khi đọc theo những năm 2000 thì muộn hơn hôm nay, nên trở thành ngày 29 tháng 2 năm 1928; ngày này tồn tại vì 1928 là năm nhuận. 000229 là ngày 29 tháng 2 năm 2000, tồn tại vì 2000 là năm nhuận; vào năm 1900, vốn không phải năm nhuận, cùng sáu chữ số đó sẽ hoàn toàn không phải là một ngày. 00 là năm hai chữ số duy nhất mà thế kỷ làm thay đổi câu trả lời cho câu hỏi "đây có phải năm nhuận không?". Nếu kiểm tra ngày trước khi chọn thế kỷ, một trong hai trường hợp này sẽ sai.

Ngày sinh rơi vào thời điểm sau trong năm nay. Đã nói ở trên: 261224 là 1926, không phải 2026, và một bài kiểm thử nên cố định điều này với một "hôm nay" cố định.

Ký tự đệm trong ngày. Vùng MRZ có thể chứa < trong ngày sinh khi một phần của ngày không được biết, ví dụ 94<<08. Không có ngày lịch nào để trả về. Hàm dưới đây không trả về gì cho bất kỳ giá trị nào không phải sáu chữ số, và để bên gọi hiển thị văn bản gốc.

Chữ số kiểm tra hợp lệ trên một ngày không thể có. Chữ số kiểm tra bảo vệ các ký tự, không bảo vệ lịch. Với trọng số 7, 3, 1, tổng của 741312 là 88, nên chữ số kiểm tra của nó là 8, và 7413128 vượt qua kiểm tra. Tháng 13 không tồn tại. Chữ số kiểm tra và ngày là hai phép kiểm tra riêng biệt và cả hai đều phải đạt; bài chữ số kiểm tra MRZ hoạt động thế nào trình bày đầy đủ phép kiểm tra thứ nhất.

Người trên 100 tuổi. Một người sinh năm 1925 và một em bé sinh năm 2025 có cùng sáu chữ số. Riêng vùng MRZ không thể phân biệt họ, và quy tắc trên chọn cách đọc trẻ hơn. Nếu người dùng của bạn có người trên trăm tuổi, hãy so sánh với ngày in trên trang dữ liệu, nơi năm bốn chữ số xuất hiện nếu giấy tờ có in.

Hàm bằng Python

Không có phụ thuộc. today là một datetime.date do bên gọi truyền vào.

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())

Mẫu dùng [0-9], không dùng \d hay str.isdigit(), là có chủ ý: cả hai cách đó đều chấp nhận chữ số của các hệ chữ viết khác, trong khi MRZ chỉ chứa chữ số ASCII. Phép so sánh dùng tuple để một tháng không thể có vẫn được so sánh bình thường trước khi date() từ chối nó.

Cùng hàm đó bằng JavaScript

today là một chuỗi YYYY-MM-DD. Hai chuỗi như vậy so sánh đúng như chuỗi thông thường, nhờ đó quy tắc thế kỷ chỉ cần một dòng.

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() luôn theo UTC, đó là lý do nó là nguồn an toàn cho today.

Vector kiểm thử

Mỗi dòng dưới đây được tính tay theo quy tắc, với hôm nay cố định là ngày 24 tháng 9 năm 2026. Cả hai hàm đều phải trả về giá trị ở cột cuối (None trong Python, null trong JavaScript cho "không có ngày").

Đầu vào Loại Kỳ vọng Lý do
740812 birth 1974-08-12 mẫu ICAO
340415 expiry 2034-04-15 mẫu ICAO
120415 expiry 2012-04-15 đã hết hạn, vẫn là 2012
940308 birth 1994-03-08 2094 nằm ở tương lai
150101 birth 2015-01-01 2015 nằm ở quá khứ
300101 birth 1930-01-01 2030 nằm ở tương lai
261224 birth 1926-12-24 sau trong năm nay, nên thuộc thế kỷ trước
260925 birth 1926-09-25 ngày mai, nên thuộc thế kỷ trước
260924 birth 2026-09-24 hôm nay được tính là quá khứ
260923 birth 2026-09-23 hôm qua
261224 expiry 2026-12-24 ngày hết hạn giữ ở những năm 2000
320310 expiry 2032-03-10 ngày hết hạn giữ ở những năm 2000
280229 birth 1928-02-29 1928 là năm nhuận
000229 birth 2000-02-29 2000 là năm nhuận, 1900 thì không
270229 birth không có 1927 không phải năm nhuận
230229 expiry không có 2023 không phải năm nhuận
240229 expiry 2024-02-29 2024 là năm nhuận
941308 birth không có không có tháng 13
94<<08 birth không có ký tự đệm, không phải ngày

Trình chạy kiểm thử cho phiên bản Python là một vòng lặp qua các dòng này với today = date(2026, 9, 24), so sánh result.isoformat() (hoặc None) với cột kỳ vọng. Hãy giữ "hôm nay" cố định trong kiểm thử: với đồng hồ thật, những dòng như 261224 sẽ đổi kết quả vào ngày 25 tháng 12.

Trình phân tích MRZ của chúng tôi làm gì

Trình phân tích MRZ trên trang này theo cùng quy tắc: năm bắt đầu là 2000 + YY, ngày sinh có ngày đầy đủ muộn hơn hôm nay (theo UTC) được lùi lại 100 năm, kiểm tra lịch diễn ra sau khi chọn thế kỷ, và ngày hết hạn giữ ở những năm 2000. Các bài kiểm thử của nó bao gồm một ngày sau trong năm nay, hôm nay, và ngày 29 tháng 2 ở cả hai phía của thế kỷ. Với giá trị không chuyển được thành ngày, nó hiển thị văn bản gốc, với ? ở chỗ vùng MRZ có ký tự đệm; và khi sáu chữ số vượt qua chữ số kiểm tra nhưng không ứng với ngày lịch nào, nó báo điều đó bằng một cảnh báo. Ngày hết hạn được đánh dấu là đã hết hạn hoặc chưa hết hạn so với ngày hôm nay theo UTC. Công cụ chạy trong trình duyệt, nên bạn có thể dán một vùng MRZ tổng hợp và so sánh cách nó đọc với cách triển khai của chính bạn.

Vị trí trong một quy trình xử lý

Nếu bạn dùng một dịch vụ nhận dạng được lưu trữ sẵn, thế kỷ trong các trường ngày đã được chọn thay bạn; câu hỏi là quy tắc của dịch vụ có phải là quy tắc bạn muốn hay không. Phản hồi của doc.cheap trả về holder.birth_date và document.expiry_date dưới dạng ISO YYYY-MM-DD, và bên cạnh đó là vùng MRZ đúng như đã đọc trong mrz.lines, để bạn có thể tự cắt sáu chữ số ra và áp dụng hàm ở trên khi một ngày quyết định quyền truy cập hoặc tiền bạc. Nếu bạn vẫn đang chọn dịch vụ, bảng so sánh API OCR hộ chiếu đặt các mức giá đã công bố cạnh nhau.

Nếu bạn tìm thấy một đầu vào mà quy tắc xử lý sai, hãy viết cho admin@doc.cheap.