├── .github ├── dependabot.yaml └── workflows │ └── test_cli.yaml ├── .gitignore ├── .metadata ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── example └── main.dart ├── lib ├── mrz_parser.dart └── src │ ├── mrz_checkdigit_calculator.dart │ ├── mrz_exceptions.dart │ ├── mrz_field_parser.dart │ ├── mrz_field_recognition_defects_fixer.dart │ ├── mrz_parser.dart │ ├── mrz_result.dart │ ├── mrz_string_extensions.dart │ ├── td1_format_mrz_parser.dart │ ├── td2_format_mrz_parser.dart │ └── td3_format_mrz_parser.dart ├── pubspec.yaml └── test ├── mrz_checkdigit_calculator_test.dart ├── mrz_field_recognition_defects_fixer_test.dart ├── mrz_fields_parser_test.dart └── mrz_parser_test.dart /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | enable-beta-ecosystems: true 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | - package-ecosystem: "pub" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/test_cli.yaml: -------------------------------------------------------------------------------- 1 | name: test_cli 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | pull_request: 9 | paths: 10 | - ".github/workflows/test_cli.yaml" 11 | - "lib/**" 12 | - "test/**" 13 | - "pubspec.yaml" 14 | push: 15 | branches: 16 | - master 17 | paths: 18 | - ".github/workflows/test_cli.yaml" 19 | - "lib/**" 20 | - "test/**" 21 | - "pubspec.yaml" 22 | 23 | jobs: 24 | 25 | build: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: 📚 Git Checkout 29 | uses: actions/checkout@v4 30 | 31 | - name: 🎯 Setup Dart 32 | uses: dart-lang/setup-dart@v1 33 | with: 34 | sdk: "stable" 35 | 36 | - name: 📦 Install Dependencies 37 | run: | 38 | dart pub get 39 | 40 | - name: 💅 Check Formatting 41 | run: dart format --set-exit-if-changed . 42 | 43 | - name: 🕵️ Analyze 44 | run: dart analyze --fatal-infos --fatal-warnings . 45 | 46 | - name: 🧪 Run Tests 47 | run: | 48 | dart pub global activate coverage 49 | dart test --coverage coverage 50 | dart pub global run coverage:format_coverage --lcov --in coverage --out=coverage/lcov.info --report-on=lib 51 | 52 | - name: 📊 Code Coverage 53 | uses: coverallsapp/github-action@v2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | .dart_tool/ 26 | .flutter-plugins 27 | .flutter-plugins-dependencies 28 | .packages 29 | .pub-cache/ 30 | .pub/ 31 | build/ 32 | pubspec.lock 33 | coverage/ 34 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 27321ebbad34b0a3fafe99fac037102196d655ff 8 | channel: stable 9 | 10 | project_type: package 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.0.1] 2 | 3 | * Implements new ICAO9303 part 5 long document numbers for TD1 (by @nicoinn) 4 | * Support Dart 3, while maintaining backward compatibility with Dart 2 (by @tomasaquiles-ca) 5 | 6 | ## [2.0.0] 7 | 8 | * Support null-safety 9 | 10 | ## [1.2.0] 11 | 12 | * French Id format support 13 | 14 | ## [1.1.0] 15 | 16 | Improvements: 17 | 18 | * Make `MRZParser.parse()` throw meaningful instances of `MRZException` 19 | * Support documents with document number shorted than 9 characters 20 | ([#2](https://github.com/olexale/mrz_parser/issues/2)) 21 | 22 | New features: 23 | 24 | * Provide `MRZParser.tryParse()` method which returns `null` if parsing 25 | was unsuccessful 26 | 27 | ## [1.0.0] - First release 28 | 29 | First release with following formats: 30 | * TD1 31 | * TD2 32 | * TD3 33 | * MRV-A 34 | * MRV-B 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Oleksandr Leuschenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mrz_parser (Dart/Flutter) 2 | [![Coverage Status](https://coveralls.io/repos/github/foxanna/mrz_parser/badge.svg?branch=master)](https://coveralls.io/github/foxanna/mrz_parser?branch=master) 3 | 4 | Parse MRZ (Machine Readable Zone) from identity documents. Heavily 5 | inspired by [QKMRZParser](https://github.com/Mattijah/QKMRZParser). 6 | 7 | ### Supported formats: 8 | * TD1 9 | * TD2 10 | * TD3 11 | * MRV-A 12 | * MRV-B 13 | 14 | ## Usage 15 | 16 | ### Import the package 17 | Add to `pubspec.yaml` 18 | ```yaml 19 | dependencies: 20 | mrz_parser: ^2.0.0 21 | ``` 22 | 23 | ### Parse MRZ 24 | ```dart 25 | final mrz = [ 26 | 'P MapEntry(i, v * _weights[i % _weights.length])) 24 | .values 25 | .reduce((value, element) => value + element); 26 | 27 | return checkSum % 10; 28 | } 29 | 30 | static bool _isCapitalLetter(int c) => c >= _A && c <= _Z; 31 | 32 | static bool _isDigit(int c) => c >= _0 && c <= _9; 33 | 34 | static const int _A = 65; 35 | static const int _Z = 90; 36 | static const int _0 = 48; 37 | static const int _9 = 57; 38 | } 39 | -------------------------------------------------------------------------------- /lib/src/mrz_exceptions.dart: -------------------------------------------------------------------------------- 1 | abstract class MRZException implements Exception { 2 | const MRZException(this.message); 3 | 4 | final String message; 5 | 6 | @override 7 | String toString() => 8 | '$message. If you think this is a mistake, please file an issue ' 9 | 'https://github.com/olexale/mrz_parser/issues'; 10 | } 11 | 12 | class InvalidMRZInputException extends MRZException { 13 | const InvalidMRZInputException() : super('Invalid MRZ parser input'); 14 | } 15 | 16 | class InvalidDocumentNumberException extends MRZException { 17 | const InvalidDocumentNumberException() 18 | : super('Document number hash mismatch'); 19 | } 20 | 21 | class InvalidBirthDateException extends MRZException { 22 | const InvalidBirthDateException() : super('Birth date hash mismatch'); 23 | } 24 | 25 | class InvalidExpiryDateException extends MRZException { 26 | const InvalidExpiryDateException() : super('Expiry date hash mismatch'); 27 | } 28 | 29 | class InvalidOptionalDataException extends MRZException { 30 | const InvalidOptionalDataException() : super('Optional data hash mismatch'); 31 | } 32 | 33 | class InvalidMRZValueException extends MRZException { 34 | const InvalidMRZValueException() : super('Final hash mismatch'); 35 | } 36 | -------------------------------------------------------------------------------- /lib/src/mrz_field_parser.dart: -------------------------------------------------------------------------------- 1 | part of 'mrz_parser.dart'; 2 | 3 | class MRZFieldParser { 4 | MRZFieldParser._(); 5 | 6 | static String parseDocumentNumber(String input) => _trim(input); 7 | 8 | static String parseDocumentType(String input) => _trim(input); 9 | 10 | static String parseCountryCode(String input) => _trim(input); 11 | 12 | static String parseNationality(String input) => _trim(input); 13 | 14 | static String parseOptionalData(String input) => _trim(input); 15 | 16 | static List parseNames(String input) { 17 | final words = input.trimChar('<').split('<<'); 18 | final result = [ 19 | if (words.isNotEmpty) _trim(words[0]) else '', 20 | if (words.length > 1) _trim(words[1]) else '', 21 | ]; 22 | return result; 23 | } 24 | 25 | static DateTime parseBirthDate(String input) { 26 | final formattedInput = _formatDate(input); 27 | return _parseDate(formattedInput, DateTime.now().year - 2000); 28 | } 29 | 30 | static DateTime parseExpiryDate(String input) { 31 | final formattedInput = _formatDate(input); 32 | return _parseDate(formattedInput, 70); 33 | } 34 | 35 | static Sex parseSex(String input) { 36 | switch (input) { 37 | case 'M': 38 | return Sex.male; 39 | case 'F': 40 | return Sex.female; 41 | default: 42 | return Sex.none; 43 | } 44 | } 45 | 46 | static String _formatDate(String input) => _trim(input); 47 | 48 | static DateTime _parseDate(String input, int milestoneYear) { 49 | final parsedYear = int.parse(input.substring(0, 2)); 50 | final centennial = (parsedYear > milestoneYear) ? '19' : '20'; 51 | return DateTime.parse(centennial + input); 52 | } 53 | 54 | static String _trim(String input) => 55 | input.replaceAngleBracketsWithSpaces().trim(); 56 | } 57 | -------------------------------------------------------------------------------- /lib/src/mrz_field_recognition_defects_fixer.dart: -------------------------------------------------------------------------------- 1 | part of 'mrz_parser.dart'; 2 | 3 | class MRZFieldRecognitionDefectsFixer { 4 | MRZFieldRecognitionDefectsFixer._(); 5 | 6 | static String fixDocumentType(String input) => 7 | input.replaceSimilarDigitsWithLetters(); 8 | 9 | static String fixCheckDigit(String input) => 10 | input.replaceSimilarLettersWithDigits(); 11 | 12 | static String fixDate(String input) => 13 | input.replaceSimilarLettersWithDigits(); 14 | 15 | static String fixSex(String input) => input.replaceAll('P', 'F'); 16 | 17 | static String fixCountryCode(String input) => 18 | input.replaceSimilarDigitsWithLetters(); 19 | 20 | static String fixNames(String input) => 21 | input.replaceSimilarDigitsWithLetters(); 22 | 23 | static String fixNationality(String input) => 24 | input.replaceSimilarDigitsWithLetters(); 25 | } 26 | -------------------------------------------------------------------------------- /lib/src/mrz_parser.dart: -------------------------------------------------------------------------------- 1 | library mrz_parser; 2 | 3 | import 'package:mrz_parser/src/mrz_exceptions.dart'; 4 | import 'package:mrz_parser/src/mrz_result.dart'; 5 | 6 | part 'mrz_checkdigit_calculator.dart'; 7 | part 'mrz_field_parser.dart'; 8 | part 'mrz_field_recognition_defects_fixer.dart'; 9 | part 'mrz_string_extensions.dart'; 10 | part 'td1_format_mrz_parser.dart'; 11 | part 'td2_format_mrz_parser.dart'; 12 | part 'td3_format_mrz_parser.dart'; 13 | 14 | class MRZParser { 15 | MRZParser._(); 16 | 17 | /// Parse [input] and return [MRZResult] instance. 18 | /// 19 | /// Like [parse] except that this function returns `null` where a 20 | /// similar call to [parse] would throw a [MRZException] 21 | /// in case of invalid input or unsuccessful parsing 22 | static MRZResult? tryParse(List? input) { 23 | try { 24 | return parse(input); 25 | } on Exception { 26 | return null; 27 | } 28 | } 29 | 30 | /// Parse [input] and return [MRZResult] instance. 31 | /// 32 | /// The [input] must be a non-null non-empty List of lines 33 | /// from a documents machine-readable zone. 34 | /// 35 | /// If [input] format is invalid or parsing was unsuccessful, 36 | /// an instance of [MRZException] is thrown 37 | static MRZResult parse(List? input) { 38 | final polishedInput = _polishInput(input); 39 | if (polishedInput == null) { 40 | throw const InvalidMRZInputException(); 41 | } 42 | 43 | if (_TD1MRZFormatParser.isValidInput(polishedInput)) { 44 | return _TD1MRZFormatParser.parse(polishedInput); 45 | } 46 | if (_TD2MRZFormatParser.isValidInput(polishedInput)) { 47 | return _TD2MRZFormatParser.parse(polishedInput); 48 | } 49 | if (_TD3MRZFormatParser.isValidInput(polishedInput)) { 50 | return _TD3MRZFormatParser.parse(polishedInput); 51 | } 52 | 53 | throw const InvalidMRZInputException(); 54 | } 55 | 56 | static List? _polishInput(List? input) { 57 | if (input == null) { 58 | return null; 59 | } 60 | 61 | final polishedInput = 62 | input.where((s) => s != null).map((s) => s!.toUpperCase()).toList(); 63 | 64 | return polishedInput.any((s) => !s.isValidMRZInput) ? null : polishedInput; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/src/mrz_result.dart: -------------------------------------------------------------------------------- 1 | enum Sex { none, male, female } 2 | 3 | class MRZResult { 4 | const MRZResult({ 5 | required this.documentType, 6 | required this.countryCode, 7 | required this.surnames, 8 | required this.givenNames, 9 | required this.documentNumber, 10 | required this.nationalityCountryCode, 11 | required this.birthDate, 12 | required this.sex, 13 | required this.expiryDate, 14 | required this.personalNumber, 15 | this.personalNumber2, 16 | }); 17 | 18 | final String documentType; 19 | final String countryCode; 20 | final String surnames; 21 | final String givenNames; 22 | final String documentNumber; 23 | final String nationalityCountryCode; 24 | final DateTime birthDate; 25 | final Sex sex; 26 | final DateTime expiryDate; 27 | final String personalNumber; 28 | final String? personalNumber2; 29 | 30 | @override 31 | bool operator ==(Object other) => 32 | identical(this, other) || 33 | other is MRZResult && 34 | runtimeType == other.runtimeType && 35 | documentType == other.documentType && 36 | countryCode == other.countryCode && 37 | surnames == other.surnames && 38 | givenNames == other.givenNames && 39 | documentNumber == other.documentNumber && 40 | nationalityCountryCode == other.nationalityCountryCode && 41 | birthDate == other.birthDate && 42 | sex == other.sex && 43 | expiryDate == other.expiryDate && 44 | personalNumber == other.personalNumber && 45 | personalNumber2 == other.personalNumber2; 46 | 47 | @override 48 | int get hashCode => 49 | documentType.hashCode ^ 50 | countryCode.hashCode ^ 51 | surnames.hashCode ^ 52 | givenNames.hashCode ^ 53 | documentNumber.hashCode ^ 54 | nationalityCountryCode.hashCode ^ 55 | birthDate.hashCode ^ 56 | sex.hashCode ^ 57 | expiryDate.hashCode ^ 58 | personalNumber.hashCode ^ 59 | personalNumber2.hashCode; 60 | } 61 | -------------------------------------------------------------------------------- /lib/src/mrz_string_extensions.dart: -------------------------------------------------------------------------------- 1 | part of 'mrz_parser.dart'; 2 | 3 | extension _MRZStringExtensions on String { 4 | static final _validInput = RegExp(r'^[A-Z|0-9|<]+$'); 5 | 6 | bool get isValidMRZInput => _validInput.hasMatch(this); 7 | 8 | String trimChar(String char) { 9 | if (isEmpty || char.isEmpty) { 10 | return this; 11 | } 12 | 13 | var start = 0; 14 | var end = length - 1; 15 | while (start < length && this[start] == char) { 16 | start++; 17 | } 18 | while (end >= 0 && this[end] == char) { 19 | end--; 20 | } 21 | return start < end ? substring(start, end + 1) : ''; 22 | } 23 | 24 | String replaceSimilarDigitsWithLetters() => replaceAll('0', 'O') 25 | .replaceAll('1', 'I') 26 | .replaceAll('2', 'Z') 27 | .replaceAll('8', 'B'); 28 | 29 | String replaceSimilarLettersWithDigits() => replaceAll('O', '0') 30 | .replaceAll('Q', '0') 31 | .replaceAll('U', '0') 32 | .replaceAll('D', '0') 33 | .replaceAll('I', '1') 34 | .replaceAll('Z', '2') 35 | .replaceAll('B', '8'); 36 | 37 | String replaceAngleBracketsWithSpaces() => replaceAll('<', ' '); 38 | } 39 | -------------------------------------------------------------------------------- /lib/src/td1_format_mrz_parser.dart: -------------------------------------------------------------------------------- 1 | part of 'mrz_parser.dart'; 2 | 3 | class _TD1MRZFormatParser { 4 | _TD1MRZFormatParser._(); 5 | 6 | static const _linesLength = 30; 7 | static const _linesCount = 3; 8 | 9 | static bool isValidInput(List input) => 10 | input.length == _linesCount && 11 | input.every((s) => s.length == _linesLength); 12 | 13 | static MRZResult parse(List input) { 14 | if (!isValidInput(input)) { 15 | throw const InvalidMRZInputException(); 16 | } 17 | 18 | final firstLine = input[0]; 19 | final secondLine = input[1]; 20 | final thirdLine = input[2]; 21 | 22 | final documentTypeRaw = firstLine.substring(0, 2); 23 | final countryCodeRaw = firstLine.substring(2, 5); 24 | 25 | final String documentNumberRaw; 26 | final String documentNumberCheckDigitRaw; 27 | final String optionalDataRaw; 28 | final bool isLongDocumentNumber; 29 | 30 | if (firstLine[14] == '<') { 31 | // Implementation for ICAO 9303 Part 5, section 4.2.4 32 | // TD1 check digit for long document numbers 33 | // https://www.icao.int/publications/Documents/9303_p5_cons_en.pdf 34 | 35 | final tmpString = 36 | firstLine.substring(15, 28).replaceAll(RegExp(r'<+$'), ''); 37 | 38 | documentNumberCheckDigitRaw = tmpString[tmpString.length - 1]; 39 | 40 | documentNumberRaw = firstLine.substring(5, 14) + 41 | tmpString.substring(0, tmpString.length - 1); 42 | 43 | //Unclear if optionalData1 is even allowed in this case. 44 | //The ICAO doc is not so clear about it. 45 | //Revise when a sample is availble... 46 | optionalDataRaw = firstLine.substring(15 + tmpString.length, 30); 47 | isLongDocumentNumber = true; 48 | } else { 49 | // Normal TD1 case 50 | documentNumberRaw = firstLine.substring(5, 14); 51 | documentNumberCheckDigitRaw = firstLine[14]; 52 | optionalDataRaw = firstLine.substring(15, 30); 53 | isLongDocumentNumber = false; 54 | } 55 | 56 | final birthDateRaw = secondLine.substring(0, 6); 57 | final birthDateCheckDigitRaw = secondLine[6]; 58 | final sexRaw = secondLine.substring(7, 8); 59 | final expiryDateRaw = secondLine.substring(8, 14); 60 | final expiryDateCheckDigitRaw = secondLine[14]; 61 | final nationalityRaw = secondLine.substring(15, 18); 62 | final optionalData2Raw = secondLine.substring(18, 29); 63 | final finalCheckDigitRaw = secondLine[29]; 64 | final namesRaw = thirdLine.substring(0, 30); 65 | 66 | final documentTypeFixed = 67 | MRZFieldRecognitionDefectsFixer.fixDocumentType(documentTypeRaw); 68 | final countryCodeFixed = 69 | MRZFieldRecognitionDefectsFixer.fixCountryCode(countryCodeRaw); 70 | final documentNumberFixed = documentNumberRaw; 71 | 72 | final documentNumberCheckDigitFixed = 73 | MRZFieldRecognitionDefectsFixer.fixCheckDigit( 74 | documentNumberCheckDigitRaw, 75 | ); 76 | 77 | final optionalDataFixed = optionalDataRaw; 78 | 79 | final birthDateFixed = 80 | MRZFieldRecognitionDefectsFixer.fixDate(birthDateRaw); 81 | 82 | final birthDateCheckDigitFixed = 83 | MRZFieldRecognitionDefectsFixer.fixCheckDigit(birthDateCheckDigitRaw); 84 | 85 | final sexFixed = MRZFieldRecognitionDefectsFixer.fixSex(sexRaw); 86 | final expiryDateFixed = 87 | MRZFieldRecognitionDefectsFixer.fixDate(expiryDateRaw); 88 | final expiryDateCheckDigitFixed = 89 | MRZFieldRecognitionDefectsFixer.fixCheckDigit(expiryDateCheckDigitRaw); 90 | final nationalityFixed = 91 | MRZFieldRecognitionDefectsFixer.fixNationality(nationalityRaw); 92 | final optionalData2Fixed = optionalData2Raw; 93 | final finalCheckDigitFixed = 94 | MRZFieldRecognitionDefectsFixer.fixCheckDigit(finalCheckDigitRaw); 95 | final namesFixed = MRZFieldRecognitionDefectsFixer.fixNames(namesRaw); 96 | 97 | final documentNumberIsValid = int.tryParse(documentNumberCheckDigitFixed) == 98 | MRZCheckDigitCalculator.getCheckDigit(documentNumberFixed); 99 | if (!documentNumberIsValid) { 100 | throw const InvalidDocumentNumberException(); 101 | } 102 | 103 | final birthDateIsValid = int.tryParse(birthDateCheckDigitFixed) == 104 | MRZCheckDigitCalculator.getCheckDigit(birthDateFixed); 105 | 106 | if (!birthDateIsValid) { 107 | throw const InvalidBirthDateException(); 108 | } 109 | 110 | final expiryDateIsValid = int.tryParse(expiryDateCheckDigitFixed) == 111 | MRZCheckDigitCalculator.getCheckDigit(expiryDateFixed); 112 | 113 | if (!expiryDateIsValid) { 114 | throw const InvalidExpiryDateException(); 115 | } 116 | 117 | final String documentNumberFixedForCheckString; 118 | if (isLongDocumentNumber) { 119 | // Long document number requires to re-introduce the < at position 15 120 | documentNumberFixedForCheckString = 121 | '${documentNumberFixed.substring(0, 9)}<${documentNumberFixed.substring(9, documentNumberFixed.length)}'; 122 | } else { 123 | documentNumberFixedForCheckString = documentNumberFixed; 124 | } 125 | 126 | final finalCheckStringFixed = 127 | '$documentNumberFixedForCheckString$documentNumberCheckDigitFixed' 128 | '$optionalDataFixed' 129 | '$birthDateFixed$birthDateCheckDigitFixed' 130 | '$expiryDateFixed$expiryDateCheckDigitFixed' 131 | '$optionalData2Fixed'; 132 | 133 | final finalCheckStringIsValid = int.tryParse(finalCheckDigitFixed) == 134 | MRZCheckDigitCalculator.getCheckDigit(finalCheckStringFixed); 135 | 136 | if (!finalCheckStringIsValid) { 137 | throw const InvalidMRZValueException(); 138 | } 139 | 140 | final documentType = MRZFieldParser.parseDocumentType(documentTypeFixed); 141 | final countryCode = MRZFieldParser.parseCountryCode(countryCodeFixed); 142 | final documentNumber = 143 | MRZFieldParser.parseDocumentNumber(documentNumberFixed); 144 | final optionalData = MRZFieldParser.parseOptionalData(optionalDataFixed); 145 | final birthDate = MRZFieldParser.parseBirthDate(birthDateFixed); 146 | final sex = MRZFieldParser.parseSex(sexFixed); 147 | final expiryDate = MRZFieldParser.parseExpiryDate(expiryDateFixed); 148 | final nationality = MRZFieldParser.parseNationality(nationalityFixed); 149 | final optionalData2 = MRZFieldParser.parseOptionalData(optionalData2Fixed); 150 | final names = MRZFieldParser.parseNames(namesFixed); 151 | 152 | return MRZResult( 153 | documentType: documentType, 154 | countryCode: countryCode, 155 | surnames: names[0], 156 | givenNames: names[1], 157 | documentNumber: documentNumber, 158 | nationalityCountryCode: nationality, 159 | birthDate: birthDate, 160 | sex: sex, 161 | expiryDate: expiryDate, 162 | personalNumber: optionalData, 163 | personalNumber2: optionalData2, 164 | ); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /lib/src/td2_format_mrz_parser.dart: -------------------------------------------------------------------------------- 1 | part of 'mrz_parser.dart'; 2 | 3 | class _TD2MRZFormatParser { 4 | _TD2MRZFormatParser._(); 5 | 6 | static const _linesLength = 36; 7 | static const _linesCount = 2; 8 | 9 | static bool isValidInput(List input) => 10 | input.length == _linesCount && 11 | input.every((s) => s.length == _linesLength); 12 | 13 | static MRZResult parse(List input) { 14 | if (!isValidInput(input)) { 15 | throw const InvalidMRZInputException(); 16 | } 17 | 18 | if (_isFrenchId(input)) { 19 | return _parseFrenchId(input); 20 | } 21 | 22 | final firstLine = input[0]; 23 | final secondLine = input[1]; 24 | 25 | final isVisaDocument = firstLine[0] == 'V'; 26 | final documentTypeRaw = firstLine.substring(0, 2); 27 | final countryCodeRaw = firstLine.substring(2, 5); 28 | final namesRaw = firstLine.substring(5); 29 | final documentNumberRaw = secondLine.substring(0, 9); 30 | final documentNumberCheckDigitRaw = secondLine[9]; 31 | final nationalityRaw = secondLine.substring(10, 13); 32 | final birthDateRaw = secondLine.substring(13, 19); 33 | final birthDateCheckDigitRaw = secondLine[19]; 34 | final sexRaw = secondLine.substring(20, 21); 35 | final expiryDateRaw = secondLine.substring(21, 27); 36 | final expiryDateCheckDigitRaw = secondLine[27]; 37 | final optionalDataRaw = secondLine.substring(28, isVisaDocument ? 36 : 35); 38 | final finalCheckDigitRaw = isVisaDocument ? null : secondLine.substring(35); 39 | 40 | final documentTypeFixed = 41 | MRZFieldRecognitionDefectsFixer.fixDocumentType(documentTypeRaw); 42 | final countryCodeFixed = 43 | MRZFieldRecognitionDefectsFixer.fixCountryCode(countryCodeRaw); 44 | final namesFixed = MRZFieldRecognitionDefectsFixer.fixNames(namesRaw); 45 | final documentNumberFixed = documentNumberRaw; 46 | final documentNumberCheckDigitFixed = 47 | MRZFieldRecognitionDefectsFixer.fixCheckDigit( 48 | documentNumberCheckDigitRaw, 49 | ); 50 | final nationalityFixed = 51 | MRZFieldRecognitionDefectsFixer.fixNationality(nationalityRaw); 52 | final birthDateFixed = 53 | MRZFieldRecognitionDefectsFixer.fixDate(birthDateRaw); 54 | final birthDateCheckDigitFixed = 55 | MRZFieldRecognitionDefectsFixer.fixCheckDigit(birthDateCheckDigitRaw); 56 | final sexFixed = MRZFieldRecognitionDefectsFixer.fixSex(sexRaw); 57 | final expiryDateFixed = 58 | MRZFieldRecognitionDefectsFixer.fixDate(expiryDateRaw); 59 | final expiryDateCheckDigitFixed = 60 | MRZFieldRecognitionDefectsFixer.fixCheckDigit(expiryDateCheckDigitRaw); 61 | final optionalDataFixed = optionalDataRaw; 62 | final finalCheckDigitFixed = finalCheckDigitRaw != null 63 | ? MRZFieldRecognitionDefectsFixer.fixCheckDigit(finalCheckDigitRaw) 64 | : null; 65 | 66 | final documentNumberIsValid = int.tryParse(documentNumberCheckDigitFixed) == 67 | MRZCheckDigitCalculator.getCheckDigit(documentNumberFixed); 68 | 69 | if (!documentNumberIsValid) { 70 | throw const InvalidDocumentNumberException(); 71 | } 72 | 73 | final birthDateIsValid = int.tryParse(birthDateCheckDigitFixed) == 74 | MRZCheckDigitCalculator.getCheckDigit(birthDateFixed); 75 | 76 | if (!birthDateIsValid) { 77 | throw const InvalidBirthDateException(); 78 | } 79 | 80 | final expiryDateIsValid = int.tryParse(expiryDateCheckDigitFixed) == 81 | MRZCheckDigitCalculator.getCheckDigit(expiryDateFixed); 82 | 83 | if (!expiryDateIsValid) { 84 | throw const InvalidExpiryDateException(); 85 | } 86 | 87 | if (finalCheckDigitFixed != null) { 88 | final finalCheckStringFixed = 89 | '$documentNumberFixed$documentNumberCheckDigitFixed' 90 | '$birthDateFixed$birthDateCheckDigitFixed' 91 | '$expiryDateFixed$expiryDateCheckDigitFixed' 92 | '$optionalDataFixed'; 93 | 94 | final finalCheckStringIsValid = int.tryParse(finalCheckDigitFixed) == 95 | MRZCheckDigitCalculator.getCheckDigit(finalCheckStringFixed); 96 | 97 | if (!finalCheckStringIsValid) { 98 | throw const InvalidMRZValueException(); 99 | } 100 | } 101 | 102 | final documentType = MRZFieldParser.parseDocumentType(documentTypeFixed); 103 | final countryCode = MRZFieldParser.parseCountryCode(countryCodeFixed); 104 | final names = MRZFieldParser.parseNames(namesFixed); 105 | final documentNumber = 106 | MRZFieldParser.parseDocumentNumber(documentNumberFixed); 107 | final nationality = MRZFieldParser.parseNationality(nationalityFixed); 108 | final birthDate = MRZFieldParser.parseBirthDate(birthDateFixed); 109 | final sex = MRZFieldParser.parseSex(sexFixed); 110 | final expiryDate = MRZFieldParser.parseExpiryDate(expiryDateFixed); 111 | final optionalData = MRZFieldParser.parseOptionalData(optionalDataFixed); 112 | 113 | return MRZResult( 114 | documentType: documentType, 115 | countryCode: countryCode, 116 | surnames: names[0], 117 | givenNames: names[1], 118 | documentNumber: documentNumber, 119 | nationalityCountryCode: nationality, 120 | birthDate: birthDate, 121 | sex: sex, 122 | expiryDate: expiryDate, 123 | personalNumber: optionalData, 124 | ); 125 | } 126 | 127 | static bool _isFrenchId(List input) => 128 | input[0][0] == 'I' && input[0].substring(2, 5) == 'FRA'; 129 | 130 | static MRZResult _parseFrenchId(List input) { 131 | final firstLine = input[0]; 132 | final secondLine = input[1]; 133 | 134 | final documentTypeRaw = firstLine.substring(0, 2); 135 | final countryCodeRaw = firstLine.substring(2, 5); 136 | final lastNamesRaw = firstLine.substring(5, 30); 137 | final departmentAndOfficeRaw = firstLine.substring(30, 36); 138 | 139 | final issueDateRaw = secondLine.substring(0, 4); 140 | final departmentRaw = secondLine.substring(4, 7); 141 | final documentNumberRaw = secondLine.substring(0, 12); 142 | final documentNumberCheckDigitRaw = secondLine[12]; 143 | final givenNamesRaw = secondLine.substring(13, 27); 144 | final birthDateRaw = secondLine.substring(27, 33); 145 | final birthDateCheckDigitRaw = secondLine[33]; 146 | final sexRaw = secondLine.substring(34, 35); 147 | final finalCheckDigitRaw = secondLine.substring(35); 148 | 149 | final documentTypeFixed = 150 | MRZFieldRecognitionDefectsFixer.fixDocumentType(documentTypeRaw); 151 | final countryCodeFixed = 152 | MRZFieldRecognitionDefectsFixer.fixCountryCode(countryCodeRaw); 153 | final lastNamesFixed = 154 | MRZFieldRecognitionDefectsFixer.fixNames(lastNamesRaw); 155 | final departmentAndOfficeFixed = departmentAndOfficeRaw; 156 | final issueDateFixed = 157 | MRZFieldRecognitionDefectsFixer.fixDate(issueDateRaw); 158 | final departmentFixed = departmentRaw; 159 | final documentNumberFixed = documentNumberRaw; 160 | final documentNumberCheckDigitFixed = 161 | MRZFieldRecognitionDefectsFixer.fixCheckDigit( 162 | documentNumberCheckDigitRaw, 163 | ); 164 | final givenNamesFixed = 165 | MRZFieldRecognitionDefectsFixer.fixNames(givenNamesRaw); 166 | final birthDateFixed = 167 | MRZFieldRecognitionDefectsFixer.fixDate(birthDateRaw); 168 | final birthDateCheckDigitFixed = 169 | MRZFieldRecognitionDefectsFixer.fixCheckDigit(birthDateCheckDigitRaw); 170 | final sexFixed = MRZFieldRecognitionDefectsFixer.fixSex(sexRaw); 171 | final finalCheckDigitFixed = 172 | MRZFieldRecognitionDefectsFixer.fixCheckDigit(finalCheckDigitRaw); 173 | 174 | final documentNumberIsValid = int.tryParse(documentNumberCheckDigitFixed) == 175 | MRZCheckDigitCalculator.getCheckDigit(documentNumberFixed); 176 | 177 | if (!documentNumberIsValid) { 178 | throw const InvalidDocumentNumberException(); 179 | } 180 | 181 | final birthDateIsValid = int.tryParse(birthDateCheckDigitFixed) == 182 | MRZCheckDigitCalculator.getCheckDigit(birthDateFixed); 183 | 184 | if (!birthDateIsValid) { 185 | throw const InvalidBirthDateException(); 186 | } 187 | 188 | final finalCheckStringFixed = 189 | '$documentTypeFixed$countryCodeFixed$lastNamesFixed' 190 | '$departmentAndOfficeFixed' 191 | '$documentNumberFixed$documentNumberCheckDigitFixed' 192 | '$givenNamesFixed$birthDateFixed$birthDateCheckDigitFixed$sexFixed'; 193 | 194 | final finalCheckStringIsValid = int.tryParse(finalCheckDigitFixed) == 195 | MRZCheckDigitCalculator.getCheckDigit(finalCheckStringFixed); 196 | 197 | if (!finalCheckStringIsValid) { 198 | throw const InvalidMRZValueException(); 199 | } 200 | 201 | final documentType = MRZFieldParser.parseDocumentType(documentTypeFixed); 202 | final countryCode = MRZFieldParser.parseCountryCode(countryCodeFixed); 203 | final givenNames = MRZFieldParser.parseNames(givenNamesFixed) 204 | .where((element) => element.isNotEmpty) 205 | .toList() 206 | .join(' '); 207 | final lastNames = MRZFieldParser.parseNames(lastNamesFixed) 208 | .where((element) => element.isNotEmpty) 209 | .toList() 210 | .join(' '); 211 | final documentNumber = 212 | MRZFieldParser.parseDocumentNumber(documentNumberFixed); 213 | final nationality = MRZFieldParser.parseNationality(countryCodeFixed); 214 | final birthDate = MRZFieldParser.parseBirthDate(birthDateFixed); 215 | final sex = MRZFieldParser.parseSex(sexFixed); 216 | final issueDate = MRZFieldParser.parseExpiryDate('${issueDateFixed}01'); 217 | final yearsValid = issueDate.isBefore(DateTime(2014)) 218 | ? 10 219 | : birthDate.isBefore( 220 | DateTime(issueDate.year - 18, issueDate.month, issueDate.day), 221 | ) 222 | ? 15 223 | : 10; 224 | final expiryDate = 225 | DateTime(issueDate.year + yearsValid, issueDate.month, issueDate.day); 226 | final optionalData = 227 | MRZFieldParser.parseOptionalData(departmentAndOfficeFixed); 228 | final optionalData2 = MRZFieldParser.parseOptionalData(departmentFixed); 229 | 230 | return MRZResult( 231 | documentType: documentType, 232 | countryCode: countryCode, 233 | surnames: lastNames, 234 | givenNames: givenNames, 235 | documentNumber: documentNumber, 236 | nationalityCountryCode: nationality, 237 | birthDate: birthDate, 238 | sex: sex, 239 | expiryDate: expiryDate, 240 | personalNumber: optionalData, 241 | personalNumber2: optionalData2, 242 | ); 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /lib/src/td3_format_mrz_parser.dart: -------------------------------------------------------------------------------- 1 | part of 'mrz_parser.dart'; 2 | 3 | class _TD3MRZFormatParser { 4 | _TD3MRZFormatParser._(); 5 | 6 | static const _linesLength = 44; 7 | static const _linesCount = 2; 8 | 9 | static bool isValidInput(List input) => 10 | input.length == _linesCount && 11 | input.every((s) => s.length == _linesLength); 12 | 13 | static MRZResult parse(List input) { 14 | if (!isValidInput(input)) { 15 | throw const InvalidMRZInputException(); 16 | } 17 | 18 | final firstLine = input[0]; 19 | final secondLine = input[1]; 20 | 21 | final isVisaDocument = firstLine[0] == 'V'; 22 | final documentTypeRaw = firstLine.substring(0, 2); 23 | final countryCodeRaw = firstLine.substring(2, 5); 24 | final namesRaw = firstLine.substring(5); 25 | final documentNumberRaw = secondLine.substring(0, 9); 26 | final documentNumberCheckDigitRaw = secondLine[9]; 27 | final nationalityRaw = secondLine.substring(10, 13); 28 | final birthDateRaw = secondLine.substring(13, 19); 29 | final birthDateCheckDigitRaw = secondLine[19]; 30 | final sexRaw = secondLine.substring(20, 21); 31 | final expiryDateRaw = secondLine.substring(21, 27); 32 | final expiryDateCheckDigitRaw = secondLine[27]; 33 | final optionalDataRaw = secondLine.substring(28, isVisaDocument ? 44 : 42); 34 | final optionalDataCheckDigitRaw = isVisaDocument ? null : secondLine[42]; 35 | final finalCheckDigitRaw = isVisaDocument ? null : secondLine.substring(43); 36 | 37 | final documentTypeFixed = 38 | MRZFieldRecognitionDefectsFixer.fixDocumentType(documentTypeRaw); 39 | final countryCodeFixed = 40 | MRZFieldRecognitionDefectsFixer.fixCountryCode(countryCodeRaw); 41 | final namesFixed = MRZFieldRecognitionDefectsFixer.fixNames(namesRaw); 42 | final documentNumberFixed = documentNumberRaw; 43 | final documentNumberCheckDigitFixed = 44 | MRZFieldRecognitionDefectsFixer.fixCheckDigit( 45 | documentNumberCheckDigitRaw, 46 | ); 47 | final nationalityFixed = 48 | MRZFieldRecognitionDefectsFixer.fixNationality(nationalityRaw); 49 | final birthDateFixed = 50 | MRZFieldRecognitionDefectsFixer.fixDate(birthDateRaw); 51 | final birthDateCheckDigitFixed = 52 | MRZFieldRecognitionDefectsFixer.fixCheckDigit(birthDateCheckDigitRaw); 53 | final sexFixed = MRZFieldRecognitionDefectsFixer.fixSex(sexRaw); 54 | final expiryDateFixed = 55 | MRZFieldRecognitionDefectsFixer.fixDate(expiryDateRaw); 56 | final expiryDateCheckDigitFixed = 57 | MRZFieldRecognitionDefectsFixer.fixCheckDigit(expiryDateCheckDigitRaw); 58 | final optionalDataFixed = optionalDataRaw; 59 | final optionalDataCheckDigitFixed = optionalDataCheckDigitRaw != null 60 | ? MRZFieldRecognitionDefectsFixer.fixCheckDigit( 61 | optionalDataCheckDigitRaw, 62 | ) 63 | : null; 64 | final finalCheckDigitFixed = finalCheckDigitRaw != null 65 | ? MRZFieldRecognitionDefectsFixer.fixCheckDigit(finalCheckDigitRaw) 66 | : null; 67 | 68 | final documentNumberIsValid = int.tryParse(documentNumberCheckDigitFixed) == 69 | MRZCheckDigitCalculator.getCheckDigit(documentNumberFixed); 70 | 71 | if (!documentNumberIsValid) { 72 | throw const InvalidDocumentNumberException(); 73 | } 74 | 75 | final birthDateIsValid = int.tryParse(birthDateCheckDigitFixed) == 76 | MRZCheckDigitCalculator.getCheckDigit(birthDateFixed); 77 | 78 | if (!birthDateIsValid) { 79 | throw const InvalidBirthDateException(); 80 | } 81 | 82 | final expiryDateIsValid = int.tryParse(expiryDateCheckDigitFixed) == 83 | MRZCheckDigitCalculator.getCheckDigit(expiryDateFixed); 84 | 85 | if (!expiryDateIsValid) { 86 | throw const InvalidExpiryDateException(); 87 | } 88 | 89 | if (optionalDataCheckDigitFixed != null) { 90 | final optionalDataIsValid = (int.tryParse(optionalDataCheckDigitFixed) == 91 | MRZCheckDigitCalculator.getCheckDigit(optionalDataFixed)) || 92 | ((optionalDataCheckDigitFixed == '<') && 93 | MRZFieldParser.parseOptionalData(optionalDataFixed).isEmpty); 94 | 95 | if (!optionalDataIsValid) { 96 | throw const InvalidOptionalDataException(); 97 | } 98 | } 99 | 100 | if (finalCheckDigitFixed != null) { 101 | final finalCheckStringFixed = 102 | '$documentNumberFixed$documentNumberCheckDigitFixed' 103 | '$birthDateFixed$birthDateCheckDigitFixed' 104 | '$expiryDateFixed$expiryDateCheckDigitFixed' 105 | '$optionalDataFixed${optionalDataCheckDigitFixed ?? ''}'; 106 | 107 | final finalCheckStringIsValid = int.tryParse(finalCheckDigitFixed) == 108 | MRZCheckDigitCalculator.getCheckDigit(finalCheckStringFixed); 109 | 110 | if (!finalCheckStringIsValid) { 111 | throw const InvalidMRZValueException(); 112 | } 113 | } 114 | 115 | final documentType = MRZFieldParser.parseDocumentType(documentTypeFixed); 116 | final countryCode = MRZFieldParser.parseCountryCode(countryCodeFixed); 117 | final names = MRZFieldParser.parseNames(namesFixed); 118 | final documentNumber = 119 | MRZFieldParser.parseDocumentNumber(documentNumberFixed); 120 | final nationality = MRZFieldParser.parseNationality(nationalityFixed); 121 | final birthDate = MRZFieldParser.parseBirthDate(birthDateFixed); 122 | final sex = MRZFieldParser.parseSex(sexFixed); 123 | final expiryDate = MRZFieldParser.parseExpiryDate(expiryDateFixed); 124 | final optionalData = MRZFieldParser.parseOptionalData(optionalDataFixed); 125 | 126 | return MRZResult( 127 | documentType: documentType, 128 | countryCode: countryCode, 129 | surnames: names[0], 130 | givenNames: names[1], 131 | documentNumber: documentNumber, 132 | nationalityCountryCode: nationality, 133 | birthDate: birthDate, 134 | sex: sex, 135 | expiryDate: expiryDate, 136 | personalNumber: optionalData, 137 | ); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: mrz_parser 2 | description: Parse MRZ (Machine Readable Zone) from identity documents. 3 | version: 2.0.1 4 | homepage: https://github.com/foxanna/mrz_parser 5 | 6 | environment: 7 | sdk: ">=2.12.0 <4.0.0" 8 | 9 | dev_dependencies: 10 | collection: ^1.19.0 11 | test: ">=1.16.5 <2.0.0" 12 | very_good_analysis: ^6.0.0 13 | -------------------------------------------------------------------------------- /test/mrz_checkdigit_calculator_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:mrz_parser/mrz_parser.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | test('check empty input', () { 6 | expect(MRZCheckDigitCalculator.getCheckDigit('<<<'), 0); 7 | }); 8 | 9 | test('check document number', () { 10 | expect(MRZCheckDigitCalculator.getCheckDigit('L898902C3'), 6); 11 | expect(MRZCheckDigitCalculator.getCheckDigit('C01X00T47'), 8); 12 | expect(MRZCheckDigitCalculator.getCheckDigit('990000516'), 4); 13 | expect(MRZCheckDigitCalculator.getCheckDigit('M5127939<'), 2); 14 | expect(MRZCheckDigitCalculator.getCheckDigit('L4041765<'), 4); 15 | }); 16 | 17 | test('check birth date', () { 18 | expect(MRZCheckDigitCalculator.getCheckDigit('680229'), 5); 19 | expect(MRZCheckDigitCalculator.getCheckDigit('640812'), 5); 20 | expect(MRZCheckDigitCalculator.getCheckDigit('740812'), 2); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /test/mrz_field_recognition_defects_fixer_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:mrz_parser/mrz_parser.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | test('fixes document type', () { 6 | expect(MRZFieldRecognitionDefectsFixer.fixDocumentType('V'), 'V'); 7 | expect(MRZFieldRecognitionDefectsFixer.fixDocumentType('P<<'), 'P<<'); 8 | expect(MRZFieldRecognitionDefectsFixer.fixDocumentType('0128'), 'OIZB'); 9 | expect(MRZFieldRecognitionDefectsFixer.fixDocumentType('<'), '<'); 10 | }); 11 | 12 | test('fixes check digit', () { 13 | expect(MRZFieldRecognitionDefectsFixer.fixCheckDigit('8'), '8'); 14 | expect(MRZFieldRecognitionDefectsFixer.fixCheckDigit('<6<'), '<6<'); 15 | expect(MRZFieldRecognitionDefectsFixer.fixCheckDigit('0QUDIZB'), '0000128'); 16 | expect(MRZFieldRecognitionDefectsFixer.fixCheckDigit('<'), '<'); 17 | }); 18 | 19 | test('fixes date', () { 20 | expect(MRZFieldRecognitionDefectsFixer.fixDate('190213'), '190213'); 21 | expect(MRZFieldRecognitionDefectsFixer.fixDate('19021<'), '19021<'); 22 | expect(MRZFieldRecognitionDefectsFixer.fixDate('0QUDIZB'), '0000128'); 23 | expect(MRZFieldRecognitionDefectsFixer.fixDate('<'), '<'); 24 | }); 25 | 26 | test('fixes sex', () { 27 | expect(MRZFieldRecognitionDefectsFixer.fixSex('M'), 'M'); 28 | expect(MRZFieldRecognitionDefectsFixer.fixSex('F'), 'F'); 29 | expect(MRZFieldRecognitionDefectsFixer.fixSex('P'), 'F'); 30 | expect(MRZFieldRecognitionDefectsFixer.fixSex('<'), '<'); 31 | }); 32 | 33 | test('fixes country code', () { 34 | expect(MRZFieldRecognitionDefectsFixer.fixCountryCode('UA'), 'UA'); 35 | expect(MRZFieldRecognitionDefectsFixer.fixCountryCode('D<<'), 'D<<'); 36 | expect(MRZFieldRecognitionDefectsFixer.fixCountryCode('0128'), 'OIZB'); 37 | expect(MRZFieldRecognitionDefectsFixer.fixCountryCode('<'), '<'); 38 | }); 39 | 40 | test('fixes names', () { 41 | expect( 42 | MRZFieldRecognitionDefectsFixer.fixNames('? input, 7 | MRZResult? expectedOutput, 8 | }) => 9 | expect(MRZParser.parse(input), expectedOutput); 10 | 11 | void expectException({List? input}) => 12 | expect(() => MRZParser.parse(input), throwsA(isA())); 13 | 14 | group('invalid input throws $InvalidMRZInputException', () { 15 | test( 16 | 'null input', 17 | () => expectException(), 18 | ); 19 | 20 | test( 21 | '1-line null input', 22 | () => expectException(input: [null]), 23 | ); 24 | 25 | test( 26 | '1-line input', 27 | () => expectException(input: ['0123456789']), 28 | ); 29 | 30 | test( 31 | '4-lines input', 32 | () => expectException( 33 | input: [ 34 | '0123456789', 35 | '0123456789', 36 | '0123456789', 37 | '0123456789', 38 | ], 39 | ), 40 | ); 41 | test( 42 | '3-lines input with 10 symbols', 43 | () => expectException( 44 | input: [ 45 | '0123456789', 46 | '0123456789', 47 | '0123456789', 48 | ], 49 | ), 50 | ); 51 | 52 | test( 53 | '3-lines input with 40 symbols', 54 | () => expectException( 55 | input: [ 56 | '0123456789012345678901234567890123456789', 57 | '0123456789012345678901234567890123456789', 58 | '0123456789012345678901234567890123456789', 59 | ], 60 | ), 61 | ); 62 | 63 | test( 64 | '2-lines input with 10 symbols', 65 | () => expectException( 66 | input: [ 67 | '0123456789', 68 | '0123456789', 69 | ], 70 | ), 71 | ); 72 | 73 | test( 74 | '2-lines input with 40 symbols', 75 | () => expectException( 76 | input: [ 77 | '0123456789012345678901234567890123456789', 78 | '0123456789012345678901234567890123456789', 79 | ], 80 | ), 81 | ); 82 | 83 | test( 84 | '2-lines input with 50 symbols', 85 | () => expectException( 86 | input: [ 87 | '01234567890123456789012345678901234567890123456789', 88 | '01234567890123456789012345678901234567890123456789', 89 | ], 90 | ), 91 | ); 92 | 93 | test( 94 | '2-lines input with 36 invalid symbols', 95 | () => expectException( 96 | input: [ 97 | '012345678901234567890123456789!asdfg', 98 | '012345678901234567890123456789{}>,.?', 99 | ], 100 | ), 101 | ); 102 | 103 | test( 104 | '2-lines input with 44 invalid symbols', 105 | () => expectException( 106 | input: [ 107 | '01234567890123456789012345678901234567!asdfg', 108 | '01234567890123456789012345678901234567{}>,.?', 109 | ], 110 | ), 111 | ); 112 | 113 | test( 114 | '3-lines input with 30 invalid symbols', 115 | () => expectException( 116 | input: [ 117 | '012345678901234567890123!asdfg', 118 | '012345678901234567890123{}>,.?', 119 | ], 120 | ), 121 | ); 122 | }); 123 | 124 | group('TD1 passport', () { 125 | test( 126 | 'correct input parses', 127 | () => expectResult( 128 | input: [ 129 | 'I expectResult( 151 | input: [ 152 | 'IDBEL600001476<9355<<<<<<<<<<<', 153 | '1301014F2311207UT0130101987390', 154 | 'SPECIMEN< expectException( 175 | input: [ 176 | 'I expectException( 186 | input: [ 187 | 'I expectException( 197 | input: [ 198 | 'I expectException( 208 | input: [ 209 | 'I expectResult( 221 | input: [ 222 | 'P expectResult( 243 | input: [ 244 | 'P expectException( 265 | input: [ 266 | 'P expectException( 275 | input: [ 276 | 'P expectException( 285 | input: [ 286 | 'P expectException( 295 | input: [ 296 | 'P expectResult( 307 | input: [ 308 | 'VCFINMEIKAELAEINEN< expectException( 329 | input: [ 330 | 'VCFINMEIKAELAEINEN< expectException( 339 | input: [ 340 | 'VCFINMEIKAELAEINEN< expectException( 349 | input: [ 350 | 'VCFINMEIKAELAEINEN< expectResult( 361 | input: [ 362 | 'P expectResult( 383 | input: [ 384 | 'P expectResult( 405 | input: [ 406 | 'I expectException( 427 | input: [ 428 | 'P expectException( 437 | input: [ 438 | 'P expectException( 447 | input: [ 448 | 'P expectException( 457 | input: [ 458 | 'P expectException( 467 | input: [ 468 | 'P expectResult( 479 | input: [ 480 | 'VNUSATRAVELER< expectException( 501 | input: [ 502 | 'VNUSATRAVELER< expectException( 511 | input: [ 512 | 'VNUSATRAVELER< expectException( 521 | input: [ 522 | 'VNUSATRAVELER< expectResult( 533 | input: [ 534 | 'IDFRABERTHIER<<<<<<<<<<<<<<<<<<<<<<<', 535 | '8806923102858CORINNE<<<<<<<6512068F6', 536 | ], 537 | expectedOutput: MRZResult( 538 | documentType: 'ID', 539 | countryCode: 'FRA', 540 | surnames: 'BERTHIER', 541 | givenNames: 'CORINNE', 542 | documentNumber: '880692310285', 543 | nationalityCountryCode: 'FRA', 544 | birthDate: DateTime(1965, 12, 06), 545 | sex: Sex.female, 546 | expiryDate: DateTime(1998, 06), 547 | personalNumber: '', 548 | personalNumber2: '923', 549 | ), 550 | ), 551 | ); 552 | 553 | test( 554 | 'correct input with department and office in first line parses', 555 | () => expectResult( 556 | input: [ 557 | 'IDFRABERTHIER<<<<<<<<<<<<<<<<<923255', 558 | '8806923102858CORINNE<<<<<<<6512068F2', 559 | ], 560 | expectedOutput: MRZResult( 561 | documentType: 'ID', 562 | countryCode: 'FRA', 563 | surnames: 'BERTHIER', 564 | givenNames: 'CORINNE', 565 | documentNumber: '880692310285', 566 | nationalityCountryCode: 'FRA', 567 | birthDate: DateTime(1965, 12, 06), 568 | sex: Sex.female, 569 | expiryDate: DateTime(1998, 06), 570 | personalNumber: '923255', 571 | personalNumber2: '923', 572 | ), 573 | ), 574 | ); 575 | 576 | test( 577 | 'correct input with multiple names parses', 578 | () => expectResult( 579 | input: [ 580 | 'IDFRALOISEAU<<<<<<<<<<<<<<<<<<<<<<<<', 581 | '970675K002774HERVE< expectResult( 602 | input: [ 603 | 'IDFRABERTHIER<<<<<<<<<<<<<<<<<<<<<<<', 604 | '8806923102858CORINNE<<<<<<<6512068F6', 605 | ], 606 | expectedOutput: MRZResult( 607 | documentType: 'ID', 608 | countryCode: 'FRA', 609 | surnames: 'BERTHIER', 610 | givenNames: 'CORINNE', 611 | documentNumber: '880692310285', 612 | nationalityCountryCode: 'FRA', 613 | birthDate: DateTime(1965, 12, 06), 614 | sex: Sex.female, 615 | expiryDate: DateTime(1998, 06), 616 | personalNumber: '', 617 | personalNumber2: '923', 618 | ), 619 | ), 620 | ); 621 | 622 | test( 623 | 'issued after Jan 2014 for adult valid for 15 years', 624 | () => expectResult( 625 | input: [ 626 | 'IDFRABERTHIER<<<<<<<<<<<<<<<<<<<<<<<', 627 | '1506923102850CORINNE<<<<<<<6512068F2', 628 | ], 629 | expectedOutput: MRZResult( 630 | documentType: 'ID', 631 | countryCode: 'FRA', 632 | surnames: 'BERTHIER', 633 | givenNames: 'CORINNE', 634 | documentNumber: '150692310285', 635 | nationalityCountryCode: 'FRA', 636 | birthDate: DateTime(1965, 12, 06), 637 | sex: Sex.female, 638 | expiryDate: DateTime(2030, 06), 639 | personalNumber: '', 640 | personalNumber2: '923', 641 | ), 642 | ), 643 | ); 644 | 645 | test( 646 | 'issued after Jan 2014 for minor valid for 10 years', 647 | () => expectResult( 648 | input: [ 649 | 'IDFRABERTHIER<<<<<<<<<<<<<<<<<<<<<<<', 650 | '1506923102850CORINNE<<<<<<<0012061F6', 651 | ], 652 | expectedOutput: MRZResult( 653 | documentType: 'ID', 654 | countryCode: 'FRA', 655 | surnames: 'BERTHIER', 656 | givenNames: 'CORINNE', 657 | documentNumber: '150692310285', 658 | nationalityCountryCode: 'FRA', 659 | birthDate: DateTime(2000, 12, 06), 660 | sex: Sex.female, 661 | expiryDate: DateTime(2025, 06), 662 | personalNumber: '', 663 | personalNumber2: '923', 664 | ), 665 | ), 666 | ); 667 | 668 | test( 669 | 'document number check digit does not match throws $InvalidDocumentNumberException', 670 | () => expectException( 671 | input: [ 672 | 'IDFRABERTHIER<<<<<<<<<<<<<<<<<<<<<<<', 673 | '8806923102850CORINNE<<<<<<<6512068F6', 674 | ], 675 | ), 676 | ); 677 | 678 | test( 679 | 'birth date check digit does not match throws $InvalidBirthDateException', 680 | () => expectException( 681 | input: [ 682 | 'IDFRABERTHIER<<<<<<<<<<<<<<<<<<<<<<<', 683 | '8806923102858CORINNE<<<<<<<6512060F6', 684 | ], 685 | ), 686 | ); 687 | 688 | test( 689 | 'final check digit does not match throws $InvalidMRZValueException', 690 | () => expectException( 691 | input: [ 692 | 'IDFRABERTHIER<<<<<<<<<<<<<<<<<<<<<<<', 693 | '8806923102858CORINNE<<<<<<<6512068F0', 694 | ], 695 | ), 696 | ); 697 | }); 698 | 699 | group('tryParse', () { 700 | test( 701 | 'invalid input returns null', 702 | () => expect(MRZParser.tryParse(null), null), 703 | ); 704 | 705 | test( 706 | 'correct input parses', 707 | () => expect( 708 | MRZParser.tryParse([ 709 | 'VNUSATRAVELER<