diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/MRZParser/.gitignore b/MRZParser/.gitignore deleted file mode 100644 index bb460e7..0000000 --- a/MRZParser/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj -xcuserdata/ -DerivedData/ -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata diff --git a/MRZParser/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/MRZParser/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/MRZParser/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/MRZParser/README.md b/MRZParser/README.md deleted file mode 100644 index 6fc5494..0000000 --- a/MRZParser/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# MRZParser - -A description of this package. diff --git a/MRZParser/Sources/MRZParser/MRZParser.swift b/MRZParser/Sources/MRZParser/MRZParser.swift deleted file mode 100644 index 597d771..0000000 --- a/MRZParser/Sources/MRZParser/MRZParser.swift +++ /dev/null @@ -1,3 +0,0 @@ -struct MRZParser { - var text = "Hello, World!" -} diff --git a/MRZParser/Tests/MRZParserTests/MRZParserTests.swift b/MRZParser/Tests/MRZParserTests/MRZParserTests.swift deleted file mode 100644 index bc25ade..0000000 --- a/MRZParser/Tests/MRZParserTests/MRZParserTests.swift +++ /dev/null @@ -1,11 +0,0 @@ - import XCTest - @testable import MRZParser - - final class MRZParserTests: XCTestCase { - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(MRZParser().text, "Hello, World!") - } - } diff --git a/MRZParser/Package.swift b/Package.swift similarity index 100% rename from MRZParser/Package.swift rename to Package.swift diff --git a/README.md b/README.md index fbd3724..6fc5494 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # MRZParser -Library for parsing MRZ code + +A description of this package. diff --git a/Sources/MRZParser/MRZField.swift b/Sources/MRZParser/MRZField.swift new file mode 100644 index 0000000..d813fde --- /dev/null +++ b/Sources/MRZParser/MRZField.swift @@ -0,0 +1,56 @@ +// +// MRZField.swift +// +// +// Created by Roman Mazeev on 15.06.2021. +// + +import Foundation + +struct MRZField { + enum FieldType { + case documentType, countryCode, names, documentNumber, nationality, birthdate, sex, expiryDate, personalNumber, optionalData, hash + } + + let value: Any? + let rawValue: String + let checkDigit: String? + let isValid: Bool? + + init(value: Any?, rawValue: String, checkDigit: String?) { + self.value = value + self.rawValue = rawValue + self.checkDigit = checkDigit + self.isValid = (checkDigit == nil) ? nil : MRZField.isValueValid(rawValue, checkDigit: checkDigit!) + } + + // MARK: Static + static func isValueValid(_ value: String, checkDigit: String) -> Bool { + guard let numericCheckDigit = Int(checkDigit) else { + return checkDigit == "<" ? value.trimmingFillers().isEmpty : false + } + + var total = 0 + + for (index, character) in value.enumerated() { + guard let unicodeScalar = character.unicodeScalars.first else { return false } + let charValue: Int + + if CharacterSet.uppercaseLetters.contains(unicodeScalar) { + charValue = Int(10 + unicodeScalar.value) - 65 + } else if CharacterSet.decimalDigits.contains(unicodeScalar) { + charValue = Int(String(character))! + } else if character == "<" { + charValue = 0 + } else { + return false + } + + total += charValue * [7, 3, 1][index % 3] + } + + return total % 10 == numericCheckDigit + } +} + + diff --git a/Sources/MRZParser/MRZFieldFormatter.swift b/Sources/MRZParser/MRZFieldFormatter.swift new file mode 100644 index 0000000..e80c906 --- /dev/null +++ b/Sources/MRZParser/MRZFieldFormatter.swift @@ -0,0 +1,137 @@ +// +// MRZFieldFormatter.swift +// +// +// Created by Roman Mazeev on 15.06.2021. +// + +import Foundation + +class MRZFieldFormatter { + private let ocrCorrection: Bool + + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(abbreviation: "GMT+0:00") + return formatter + }() + + init(ocrCorrection: Bool) { + self.ocrCorrection = ocrCorrection + } + + func createField( + type: MRZField.FieldType, + from string: String, + at startIndex: Int, + length: Int, + checkDigitFollows: Bool = false + ) -> MRZField { + let endIndex = (startIndex + length) + var rawValue = string.substring(startIndex, to: (endIndex - 1)) + var checkDigit = checkDigitFollows ? string.substring(endIndex, to: endIndex) : nil + + if ocrCorrection { + rawValue = correct(rawValue, fieldType: type) + checkDigit = (checkDigit == nil) ? nil : correct(checkDigit!, fieldType: type) + } + + return MRZField(value: format(rawValue, as: type), rawValue: rawValue, checkDigit: checkDigit) + } + + func format(_ string: String, as fieldType: MRZField.FieldType) -> Any? { + switch fieldType { + case .names: + return names(from: string) + case .birthdate: + return birthdate(from: string) + case .sex: + return sex(from: string) + case .expiryDate: + return expiryDate(from: string) + case .documentType, .documentNumber, .countryCode, .nationality, .personalNumber, .optionalData, .hash: + return text(from: string) + } + } + + func correct(_ string: String, fieldType: MRZField.FieldType) -> String { + switch fieldType { + case .birthdate, .expiryDate, .hash: + // TODO: Check correction of dates (month & day) + return replaceLetters(in: string) + case .names, .documentType, .countryCode, .nationality: + // TODO: Check documentType, countryCode and nationality against possible (allowed) values + return replaceDigits(in: string) + case .sex: + // TODO: Improve correction (take into account "M" & "<" too) + return string.replace("P", with: "F") + default: + return string + } + } + + // MARK: Value Formatters + private func names(from string: String) -> (primary: String, secondary: String) { + let identifiers = string.trimmingFillers().components(separatedBy: "<<").map({ $0.replace("<", with: " ") }) + let secondaryID = identifiers.indices.contains(1) ? identifiers[1] : "" + return (primary: identifiers[0], secondary: secondaryID) + } + + private func sex(from string: String) -> String? { + switch string { + case "M": return "MALE" + case "F": return "FEMALE" + case "<": return "UNSPECIFIED" // X + default: return nil + } + } + + private func birthdate(from string: String) -> Date? { + guard CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string)) else { + return nil + } + + let currentYear = Calendar.current.component(.year, from: Date()) - 2000 + let parsedYear = Int(string.substring(0, to: 1))! + let centennial = (parsedYear > currentYear) ? "19" : "20" + + return dateFormatter.date(from: centennial + string) + } + + private func expiryDate(from string: String) -> Date? { + guard CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string)) else { + return nil + } + + let parsedYear = Int(string.substring(0, to: 1))! + let centennial = (parsedYear >= 70) ? "19" : "20" + + return dateFormatter.date(from: centennial + string) + } + + private func text(from string: String) -> String { + return string.trimmingFillers().replace("<", with: " ") + } + + // MARK: Utils + private func replaceDigits(in string: String) -> String { + return string + .replace("0", with: "O") + .replace("1", with: "I") + .replace("2", with: "Z") + .replace("8", with: "B") + } + + private func replaceLetters(in string: String) -> String { + return string + .replace("O", with: "0") + .replace("Q", with: "0") + .replace("U", with: "0") + .replace("D", with: "0") + .replace("I", with: "1") + .replace("Z", with: "2") + .replace("B", with: "8") + } +} diff --git a/Sources/MRZParser/MRZParser.swift b/Sources/MRZParser/MRZParser.swift new file mode 100644 index 0000000..5908156 --- /dev/null +++ b/Sources/MRZParser/MRZParser.swift @@ -0,0 +1,70 @@ +// +// MRZParser.swift +// +// +// Created by Roman Mazeev on 15.06.2021. +// + +import Foundation + +public class MRZParser { + let formatter: MRZFieldFormatter + + enum MRZFormat: Int { + case td1, td2, td3, invalid + } + + public init(ocrCorrection: Bool = false) { + formatter = MRZFieldFormatter(ocrCorrection: ocrCorrection) + } + + // MARK: Parsing + public func parse(mrzLines: [String]) -> MRZResult? { + switch self.mrzFormat(from: mrzLines) { + case .td1: + return TD1(from: mrzLines, using: formatter).result + case .td2: + return TD2(from: mrzLines, using: formatter).result + case .td3: + return TD3(from: mrzLines, using: formatter).result + case .invalid: + return nil + } + } + + public func parse(mrzString: String) -> MRZResult? { + return parse(mrzLines: mrzString.components(separatedBy: "\n")) + } + + // MARK: MRZ-Format detection + fileprivate func mrzFormat(from mrzLines: [String]) -> MRZFormat { + switch mrzLines.count { + case 2: + let lineLength = uniformedLineLength(for: mrzLines) + let possibleFormats = [MRZFormat.td2: TD2.lineLength, .td3: TD3.lineLength] + + for (format, requiredLineLength) in possibleFormats where lineLength == requiredLineLength { + return format + } + + return .invalid + case 3: + return (uniformedLineLength(for: mrzLines) == TD1.lineLength) ? .td1 : .invalid + default: + return .invalid + } + } + + fileprivate func uniformedLineLength(for mrzLines: [String]) -> Int? { + guard let lineLength = mrzLines.first?.count else { + return nil + } + + if mrzLines.contains(where: { $0.count != lineLength }) { + return nil + } + + return lineLength + } +} + diff --git a/Sources/MRZParser/MRZResult.swift b/Sources/MRZParser/MRZResult.swift new file mode 100644 index 0000000..b35fa5a --- /dev/null +++ b/Sources/MRZParser/MRZResult.swift @@ -0,0 +1,33 @@ +// +// MRZResult.swift +// +// +// Created by Roman Mazeev on 15.06.2021. +// + +import Foundation + +public struct MRZResult { + public let documentType: String + public let countryCode: String + public let surnames: String + public let givenNames: String + public let documentNumber: String + public let nationalityCountryCode: String + /// `nil` if formatting failed + public let birthdate: Date? + /// `nil` if formatting failed + public let sex: String? + /// `nil` if formatting failed + public let expiryDate: Date? + public let personalNumber: String + /// `nil` if not provided + public let personalNumber2: String? + + public let isDocumentNumberValid: Bool + public let isBirthdateValid: Bool + public let isExpiryDateValid: Bool + public let isPersonalNumberValid: Bool? + public let allCheckDigitsValid: Bool +} + diff --git a/Sources/MRZParser/Parsers/TD1.swift b/Sources/MRZParser/Parsers/TD1.swift new file mode 100644 index 0000000..e765990 --- /dev/null +++ b/Sources/MRZParser/Parsers/TD1.swift @@ -0,0 +1,71 @@ +// +// TD1.swift +// QKMRZParser +// +// Created by Matej Dorcak on 14/10/2018. +// + +import Foundation + +class TD1 { + static let lineLength = 30 + fileprivate let finalCheckDigit: String + let documentType: MRZField + let countryCode: MRZField + let documentNumber: MRZField + let optionalData: MRZField + let birthdate: MRZField + let sex: MRZField + let expiryDate: MRZField + let nationality: MRZField + let optionalData2: MRZField + let names: MRZField + + fileprivate lazy var allCheckDigitsValid: Bool = { + let compositedValue = [documentNumber, optionalData, birthdate, expiryDate, optionalData2].reduce("", { ($0 + $1.rawValue + ($1.checkDigit ?? "")) }) + let isCompositedValueValid = MRZField.isValueValid(compositedValue, checkDigit: finalCheckDigit) + return (documentNumber.isValid! && birthdate.isValid! && expiryDate.isValid! && isCompositedValueValid) + }() + + lazy var result: MRZResult = { + let (surnames, givenNames) = names.value as! (String, String) + + return MRZResult( + documentType: documentType.value as! String, + countryCode: countryCode.value as! String, + surnames: surnames, + givenNames: givenNames, + documentNumber: documentNumber.value as! String, + nationalityCountryCode: nationality.value as! String, + birthdate: birthdate.value as! Date?, + sex: sex.value as! String?, + expiryDate: expiryDate.value as! Date?, + personalNumber: optionalData.value as! String, + personalNumber2: (optionalData2.value as! String), + + isDocumentNumberValid: documentNumber.isValid!, + isBirthdateValid: birthdate.isValid!, + isExpiryDateValid: expiryDate.isValid!, + isPersonalNumberValid: nil, + allCheckDigitsValid: allCheckDigitsValid + ) + }() + + init(from mrzLines: [String], using formatter: MRZFieldFormatter) { + let (firstLine, secondLine, thirdLine) = (mrzLines[0], mrzLines[1], mrzLines[2]) + + documentType = formatter.createField(type: .documentType, from: firstLine, at: 0, length: 2) + countryCode = formatter.createField(type: .countryCode, from: firstLine, at: 2, length: 3) + documentNumber = formatter.createField(type: .documentNumber, from: firstLine, at: 5, length: 9, checkDigitFollows: true) + optionalData = formatter.createField(type: .optionalData, from: firstLine, at: 15, length: 15) + + birthdate = formatter.createField(type: .birthdate, from: secondLine, at: 0, length: 6, checkDigitFollows: true) + sex = formatter.createField(type: .sex, from: secondLine, at: 7, length: 1) + expiryDate = formatter.createField(type: .expiryDate, from: secondLine, at: 8, length: 6, checkDigitFollows: true) + nationality = formatter.createField(type: .nationality, from: secondLine, at: 15, length: 3) + optionalData2 = formatter.createField(type: .optionalData, from: secondLine, at: 18, length: 11) + finalCheckDigit = formatter.createField(type: .hash, from: secondLine, at: 29, length: 1).rawValue + + names = formatter.createField(type: .names, from: thirdLine, at: 0, length: 29) + } +} diff --git a/Sources/MRZParser/Parsers/TD2.swift b/Sources/MRZParser/Parsers/TD2.swift new file mode 100644 index 0000000..2fa6d5b --- /dev/null +++ b/Sources/MRZParser/Parsers/TD2.swift @@ -0,0 +1,75 @@ +// +// TD2.swift +// QKMRZParser +// +// Created by Matej Dorcak on 14/10/2018. +// + +import Foundation + +class TD2 { + static let lineLength = 36 + fileprivate let finalCheckDigit: String? + let documentType: MRZField + let countryCode: MRZField + let names: MRZField + let documentNumber: MRZField + let nationality: MRZField + let birthdate: MRZField + let sex: MRZField + let expiryDate: MRZField + let optionalData: MRZField + + fileprivate lazy var allCheckDigitsValid: Bool = { + if let checkDigit = finalCheckDigit { + let compositedValue = [documentNumber, birthdate, expiryDate, optionalData].reduce("", { ($0 + $1.rawValue + ($1.checkDigit ?? "")) }) + let isCompositedValueValid = MRZField.isValueValid(compositedValue, checkDigit: checkDigit) + return (documentNumber.isValid! && birthdate.isValid! && expiryDate.isValid! && isCompositedValueValid) + } + else { + return (documentNumber.isValid! && birthdate.isValid! && expiryDate.isValid!) + } + }() + + lazy var result: MRZResult = { + let (surnames, givenNames) = names.value as! (String, String) + + return MRZResult( + documentType: documentType.value as! String, + countryCode: countryCode.value as! String, + surnames: surnames, + givenNames: givenNames, + documentNumber: documentNumber.value as! String, + nationalityCountryCode: nationality.value as! String, + birthdate: birthdate.value as! Date?, + sex: sex.value as! String?, + expiryDate: expiryDate.value as! Date?, + personalNumber: optionalData.value as! String, + personalNumber2: nil, + + isDocumentNumberValid: documentNumber.isValid!, + isBirthdateValid: birthdate.isValid!, + isExpiryDateValid: expiryDate.isValid!, + isPersonalNumberValid: nil, + allCheckDigitsValid: allCheckDigitsValid + ) + }() + + init(from mrzLines: [String], using formatter: MRZFieldFormatter) { + let (firstLine, secondLine) = (mrzLines[0], mrzLines[1]) + /// MRV-B type + let isVisaDocument = (firstLine.substring(0, to: 0) == "V") + + documentType = formatter.createField(type: .documentType, from: firstLine, at: 0, length: 2) + countryCode = formatter.createField(type: .countryCode, from: firstLine, at: 2, length: 3) + names = formatter.createField(type: .names, from: firstLine, at: 5, length: 31) + + documentNumber = formatter.createField(type: .documentNumber, from: secondLine, at: 0, length: 9, checkDigitFollows: true) + nationality = formatter.createField(type: .nationality, from: secondLine, at: 10, length: 3) + birthdate = formatter.createField(type: .birthdate, from: secondLine, at: 13, length: 6, checkDigitFollows: true) + sex = formatter.createField(type: .sex, from: secondLine, at: 20, length: 1) + expiryDate = formatter.createField(type: .expiryDate, from: secondLine, at: 21, length: 6, checkDigitFollows: true) + optionalData = formatter.createField(type: .optionalData, from: secondLine, at: 28, length: isVisaDocument ? 8 : 7) + finalCheckDigit = isVisaDocument ? nil : formatter.createField(type: .hash, from: secondLine, at: 35, length: 1).rawValue + } +} diff --git a/Sources/MRZParser/Parsers/TD3.swift b/Sources/MRZParser/Parsers/TD3.swift new file mode 100644 index 0000000..2427476 --- /dev/null +++ b/Sources/MRZParser/Parsers/TD3.swift @@ -0,0 +1,83 @@ +// +// TD3.swift +// QKMRZParser +// +// Created by Matej Dorcak on 14/10/2018. +// + +import Foundation + +class TD3 { + static let lineLength = 44 + fileprivate let finalCheckDigit: String? + let documentType: MRZField + let countryCode: MRZField + let names: MRZField + let documentNumber: MRZField + let nationality: MRZField + let birthdate: MRZField + let sex: MRZField + let expiryDate: MRZField + let personalNumber: MRZField + + fileprivate lazy var allCheckDigitsValid: Bool = { + if let checkDigit = finalCheckDigit { + let compositedValue = [documentNumber, birthdate, expiryDate, personalNumber].reduce("", { ($0 + $1.rawValue + $1.checkDigit!) }) + let isCompositedValueValid = MRZField.isValueValid(compositedValue, checkDigit: checkDigit) + return (documentNumber.isValid! && birthdate.isValid! && expiryDate.isValid! && personalNumber.isValid! && isCompositedValueValid) + } + else { + return (documentNumber.isValid! && birthdate.isValid! && expiryDate.isValid!) + } + }() + + lazy var result: MRZResult = { + let (surnames, givenNames) = names.value as! (String, String) + + return MRZResult( + documentType: documentType.value as! String, + countryCode: countryCode.value as! String, + surnames: surnames, + givenNames: givenNames, + documentNumber: documentNumber.value as! String, + nationalityCountryCode: nationality.value as! String, + birthdate: birthdate.value as! Date?, + sex: sex.value as! String?, + expiryDate: expiryDate.value as! Date?, + personalNumber: personalNumber.value as! String, + personalNumber2: nil, + + isDocumentNumberValid: documentNumber.isValid!, + isBirthdateValid: birthdate.isValid!, + isExpiryDateValid: expiryDate.isValid!, + isPersonalNumberValid: personalNumber.isValid, + allCheckDigitsValid: allCheckDigitsValid + ) + }() + + init(from mrzLines: [String], using formatter: MRZFieldFormatter) { + let (firstLine, secondLine) = (mrzLines[0], mrzLines[1]) + + /// MRV-A type + let isVisaDocument = (firstLine.substring(0, to: 0) == "V") + + documentType = formatter.createField(type: .documentType, from: firstLine, at: 0, length: 2) + countryCode = formatter.createField(type: .countryCode, from: firstLine, at: 2, length: 3) + names = formatter.createField(type: .names, from: firstLine, at: 5, length: 39) + + documentNumber = formatter.createField(type: .documentNumber, from: secondLine, at: 0, length: 9, checkDigitFollows: true) + nationality = formatter.createField(type: .nationality, from: secondLine, at: 10, length: 3) + birthdate = formatter.createField(type: .birthdate, from: secondLine, at: 13, length: 6, checkDigitFollows: true) + sex = formatter.createField(type: .sex, from: secondLine, at: 20, length: 1) + expiryDate = formatter.createField(type: .expiryDate, from: secondLine, at: 21, length: 6, checkDigitFollows: true) + + if isVisaDocument { + personalNumber = formatter.createField(type: .optionalData, from: secondLine, at: 28, length: 16) + finalCheckDigit = nil + } + else { + personalNumber = formatter.createField(type: .personalNumber, from: secondLine, at: 28, length: 14, checkDigitFollows: true) + finalCheckDigit = formatter.createField(type: .hash, from: secondLine, at: 43, length: 1).rawValue + } + } +} diff --git a/Sources/MRZParser/String+TrimmingFillers.swift b/Sources/MRZParser/String+TrimmingFillers.swift new file mode 100644 index 0000000..5e9a596 --- /dev/null +++ b/Sources/MRZParser/String+TrimmingFillers.swift @@ -0,0 +1,28 @@ +// +// String+TrimmingFillers.swift +// +// +// Created by Roman Mazeev on 15.06.2021. +// + +import Foundation + +// MARK: Parser related +extension String { + func trimmingFillers() -> String { + return trimmingCharacters(in: CharacterSet(charactersIn: "<")) + } +} + +// MARK: Generic +extension String { + func replace(_ target: String, with: String) -> String { + return replacingOccurrences(of: target, with: with, options: .literal, range: nil) + } + + func substring(_ from: Int, to: Int) -> String { + let fromIndex = index(startIndex, offsetBy: from) + let toIndex = index(startIndex, offsetBy: to + 1) + return String(self[fromIndex..