///|
fn scan_illegal(lines : Array[String], diags : Array[Diagnostic]) -> Unit {
let mut line_no = 0
while line_no < lines.length() {
let line = lines[line_no]
let mut col = 0
while col < line.length() {
if !is_mrz_char(line[col]) {
diags.push(
error_diag(
"MRZ003",
"character is not in the ICAO 9303 MRZ alphabet",
line_no + 1,
col + 1,
col,
col + 1,
),
)
}
col += 1
}
line_no += 1
}
}
///|
fn check_field(
field : String,
actual : String,
code : String,
message : String,
line : Int,
column : Int,
start : Int,
diags : Array[Diagnostic],
) -> Unit {
if !field_matches_check(field, actual) {
diags.push(
error_diag(code, message, line, column, start, start + actual.length()),
)
}
}
///|
fn parse_td3(lines : Array[String], diags : Array[Diagnostic]) -> Document? {
let l1 = lines[0]
let l2 = lines[1]
let doc_raw = l2[0:9].to_owned()
let birth = l2[13:19].to_owned()
let expiry = l2[21:27].to_owned()
let optional = l2[28:42].to_owned()
check_field(
doc_raw,
unit_at(l2, 9),
"MRZ005",
"document number check digit is wrong",
2,
10,
9,
diags,
)
check_field(
birth,
unit_at(l2, 19),
"MRZ006",
"date of birth check digit is wrong",
2,
20,
19,
diags,
)
check_field(
expiry,
unit_at(l2, 27),
"MRZ007",
"date of expiry check digit is wrong",
2,
28,
27,
diags,
)
check_field(
optional,
unit_at(l2, 42),
"MRZ008",
"optional data check digit is wrong",
2,
43,
42,
diags,
)
let composite = l2[0:10].to_owned() +
l2[13:20].to_owned() +
l2[21:43].to_owned()
check_field(
composite,
unit_at(l2, 43),
"MRZ009",
"composite check digit is wrong",
2,
44,
43,
diags,
)
if !is_valid_yymmdd(birth) {
diags.push(
error_diag(
"MRZ010", "date of birth is not a valid YYMMDD value", 2, 14, 13, 19,
),
)
}
if !is_valid_yymmdd(expiry) {
diags.push(
error_diag(
"MRZ010", "date of expiry is not a valid YYMMDD value", 2, 22, 21, 27,
),
)
}
let sex = unit_at(l2, 20)
if !is_valid_sex(sex) {
diags.push(error_diag("MRZ011", "sex must be M, F, or <", 2, 21, 20, 21))
}
let issuer = l1[2:5].to_owned()
let nationality = l2[10:13].to_owned()
if !is_valid_alpha3(issuer) {
diags.push(
error_diag("MRZ012", "issuing state is not three MRZ letters", 1, 3, 2, 5),
)
}
if !is_valid_alpha3(nationality) {
diags.push(
error_diag(
"MRZ012", "nationality is not three MRZ letters", 2, 11, 10, 13,
),
)
}
let names = split_names(l1[5:44].to_owned())
if names.primary.length() == 0 {
diags.push(error_diag("MRZ013", "primary name is missing", 1, 6, 5, 44))
}
if rtrim_fillers(doc_raw).length() == 0 {
diags.push(error_diag("MRZ014", "document number is empty", 2, 1, 0, 9))
}
Some({
layout: TD3,
document_code: rtrim_fillers(l1[0:2].to_owned()),
issuer: rtrim_fillers(issuer),
names,
document_number: rtrim_fillers(doc_raw),
nationality: rtrim_fillers(nationality),
birth,
sex,
expiry,
optional: rtrim_fillers(optional),
optional_secondary: "",
raw_lines: [l1, l2],
})
}
///|
fn parse_td2(lines : Array[String], diags : Array[Diagnostic]) -> Document? {
let l1 = lines[0]
let l2 = lines[1]
let doc_raw = l2[0:9].to_owned()
let birth = l2[13:19].to_owned()
let expiry = l2[21:27].to_owned()
let optional = l2[28:35].to_owned()
check_field(
doc_raw,
unit_at(l2, 9),
"MRZ005",
"document number check digit is wrong",
2,
10,
9,
diags,
)
check_field(
birth,
unit_at(l2, 19),
"MRZ006",
"date of birth check digit is wrong",
2,
20,
19,
diags,
)
check_field(
expiry,
unit_at(l2, 27),
"MRZ007",
"date of expiry check digit is wrong",
2,
28,
27,
diags,
)
let composite = l2[0:10].to_owned() +
l2[13:20].to_owned() +
l2[21:35].to_owned()
check_field(
composite,
unit_at(l2, 35),
"MRZ009",
"composite check digit is wrong",
2,
36,
35,
diags,
)
if !is_valid_yymmdd(birth) {
diags.push(
error_diag(
"MRZ010", "date of birth is not a valid YYMMDD value", 2, 14, 13, 19,
),
)
}
if !is_valid_yymmdd(expiry) {
diags.push(
error_diag(
"MRZ010", "date of expiry is not a valid YYMMDD value", 2, 22, 21, 27,
),
)
}
let sex = unit_at(l2, 20)
if !is_valid_sex(sex) {
diags.push(error_diag("MRZ011", "sex must be M, F, or <", 2, 21, 20, 21))
}
let names = split_names(l1[5:36].to_owned())
Some({
layout: TD2,
document_code: rtrim_fillers(l1[0:2].to_owned()),
issuer: rtrim_fillers(l1[2:5].to_owned()),
names,
document_number: rtrim_fillers(doc_raw),
nationality: rtrim_fillers(l2[10:13].to_owned()),
birth,
sex,
expiry,
optional: rtrim_fillers(optional),
optional_secondary: "",
raw_lines: [l1, l2],
})
}
///|
fn parse_td1(lines : Array[String], diags : Array[Diagnostic]) -> Document? {
let l1 = lines[0]
let l2 = lines[1]
let l3 = lines[2]
let doc_raw = l1[5:14].to_owned()
let opt1 = l1[15:30].to_owned()
let birth = l2[0:6].to_owned()
let expiry = l2[8:14].to_owned()
let opt2 = l2[18:29].to_owned()
check_field(
doc_raw,
unit_at(l1, 14),
"MRZ005",
"document number check digit is wrong",
1,
15,
14,
diags,
)
check_field(
birth,
unit_at(l2, 6),
"MRZ006",
"date of birth check digit is wrong",
2,
7,
6,
diags,
)
check_field(
expiry,
unit_at(l2, 14),
"MRZ007",
"date of expiry check digit is wrong",
2,
15,
14,
diags,
)
let composite = l1[5:30].to_owned() +
l2[0:7].to_owned() +
l2[8:15].to_owned() +
l2[18:29].to_owned()
check_field(
composite,
unit_at(l2, 29),
"MRZ009",
"composite check digit is wrong",
2,
30,
29,
diags,
)
if !is_valid_yymmdd(birth) {
diags.push(
error_diag(
"MRZ010", "date of birth is not a valid YYMMDD value", 2, 1, 0, 6,
),
)
}
if !is_valid_yymmdd(expiry) {
diags.push(
error_diag(
"MRZ010", "date of expiry is not a valid YYMMDD value", 2, 9, 8, 14,
),
)
}
let sex = unit_at(l2, 7)
if !is_valid_sex(sex) {
diags.push(error_diag("MRZ011", "sex must be M, F, or <", 2, 8, 7, 8))
}
let names = split_names(l3)
Some({
layout: TD1,
document_code: rtrim_fillers(l1[0:2].to_owned()),
issuer: rtrim_fillers(l1[2:5].to_owned()),
names,
document_number: rtrim_fillers(doc_raw),
nationality: rtrim_fillers(l2[15:18].to_owned()),
birth,
sex,
expiry,
optional: rtrim_fillers(opt1),
optional_secondary: rtrim_fillers(opt2),
raw_lines: [l1, l2, l3],
})
}
///|
/// Parse MRZ text and always return diagnostics. A document is present even
/// when check-digit errors were recorded, so callers can inspect extracted fields.
pub fn parse_with_diagnostics(input : String) -> (Document?, Array[Diagnostic]) {
let diags : Array[Diagnostic] = []
if trim_ascii(input).length() == 0 {
diags.push(error_diag("MRZ001", "MRZ input is empty", 1, 1, 0, 0))
return (None, diags)
}
let mut lines = split_raw_lines(input)
if lines.length() == 1 {
match expand_single_line(lines[0]) {
None => ()
Some(expanded) => lines = expanded
}
}
scan_illegal(lines, diags)
match detect_layout(lines) {
None => {
diags.push(
error_diag(
"MRZ002", "MRZ layout is not TD1 (3x30), TD2 (2x36), or TD3 (2x44)", 1,
1, 0, 0,
),
)
(None, diags)
}
Some(layout) => {
let doc = match layout {
TD3 => parse_td3(lines, diags)
TD2 => parse_td2(lines, diags)
TD1 => parse_td1(lines, diags)
}
(doc, diags)
}
}
}
///|
pub fn parse(input : String) -> Document? {
let (doc, diags) = parse_with_diagnostics(input)
if has_error(diags) {
None
} else {
doc
}
}
///|
pub fn is_valid(input : String) -> Bool {
match parse(input) {
None => false
Some(_) => true
}
}
///|
/// Detect TD1/TD2/TD3 from line lengths after CRLF splitting and 88/72/90 unfolding.
pub fn layout_of(input : String) -> Layout? {
let mut lines = split_raw_lines(input)
if lines.length() == 1 {
match expand_single_line(lines[0]) {
None => ()
Some(expanded) => lines = expanded
}
}
detect_layout(lines)
}