parse static method
Implementation
static Map<String, dynamic> parse(String text, {DateTime? now}) {
final lines = normalize(text);
if (lines.length != 2 || lines.any((line) => line.length != 44)) {
throw const FormatException('需要两行机读码,每行 44 个字符。');
}
final a = lines[0], b = lines[1];
if (!RegExp(r'^P[A-Z<][A-Z<]{3}[A-Z<]{39}$').hasMatch(a) ||
!RegExp(r'^[A-Z0-9<]{44}$').hasMatch(b)) {
throw const FormatException('不是有效的 TD3 护照机读码字符格式。');
}
Map<String, dynamic> check(
String label,
String value,
String actual, {
bool optional = false,
}) => {
'label': label,
'expected': checkDigit(value),
'actual': actual,
'valid':
checkDigit(value) == actual ||
(optional && RegExp(r'^<+$').hasMatch(value) && actual == '<'),
};
final checks = [
check('护照号码', b.substring(0, 9), b[9]),
check('出生日期', b.substring(13, 19), b[19]),
check('有效期', b.substring(21, 27), b[27]),
check('可选数据', b.substring(28, 42), b[42], optional: true),
check(
'综合校验',
b.substring(0, 10) + b.substring(13, 20) + b.substring(21, 43),
b[43],
),
];
String clean(String value) =>
value.replaceAll('<', ' ').trim().replaceAll(RegExp(' +'), ' ');
final nameField = a.substring(5);
// Single fillers separate components; repeated fillers after the surname
// delimiter only pad the end. Reject later letters instead of removing them.
final nameValid = RegExp(
r'^[A-Z]+(?:<[A-Z]+)*(?:<<[A-Z]+(?:<[A-Z]+)*)?<*$',
).hasMatch(nameField);
final separator = nameField.indexOf('<<');
final surname = separator < 0
? nameField
: nameField.substring(0, separator);
final givenNames = separator < 0 ? '' : nameField.substring(separator + 2);
final current = (now ?? DateTime.now()).toUtc();
final birthDate = _date(b.substring(13, 19), true, current);
final expiryDate = _date(b.substring(21, 27), false, current);
final structureValid =
nameValid &&
RegExp(r'^[A-Z<]{3}$').hasMatch(b.substring(10, 13)) &&
RegExp(r'^[MF<]$').hasMatch(b[20]) &&
birthDate != null &&
expiryDate != null &&
clean(surname).isNotEmpty &&
clean(b.substring(0, 9)).isNotEmpty;
final formatValid =
structureValid &&
knownMrzCountryCodes.contains(a.substring(2, 5)) &&
knownMrzCountryCodes.contains(b.substring(10, 13));
final identityChecked =
structureValid && checks.every((c) => c['valid'] == true);
return {
'documentType': a.substring(0, 2).replaceAll('<', ''),
'issuingCountry': a.substring(2, 5),
'surname': clean(surname),
'givenNames': clean(givenNames),
'passportNumber': b.substring(0, 9).replaceAll('<', ''),
'nationality': b.substring(10, 13),
'birthDate': birthDate,
'sex': b[20] == '<' ? 'X' : b[20],
'expiryDate': expiryDate,
'rawBirthDate': b.substring(13, 19),
'rawExpiryDate': b.substring(21, 27),
'checks': checks,
'formatValid': formatValid,
'identityChecked': identityChecked,
'valid': formatValid && checks.every((c) => c['valid'] == true),
'dateCenturyInferred': true,
'rawMrz': lines,
'warnings': [
'MRZ 日期只有两位年份,世纪按当前日期推断;请与护照版面核对。',
'校验位不覆盖姓名和全部字段,也不验证证件真伪。',
],
};
}