-
Notifications
You must be signed in to change notification settings - Fork 221
SELF-1381 (feat): Belgian (Arman) ID overflow format #1450
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
08fbd7f
8d2c5e4
1248587
c701832
762de05
421e8b9
ff97882
566ddac
05b2414
4a91ca7
076ec4f
9ff8bef
af92754
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ struct LiveMRZScannerView: View { | |
| @State private var lastMRZDetection: Date = Date() | ||
| @State private var parsedMRZ: QKMRZResult? = nil | ||
| @State private var scanComplete: Bool = false | ||
| @State private var overrideDocumentNumber: String? = nil // for TD1 overflow format (ID cards) | ||
| var onScanComplete: ((QKMRZResult) -> Void)? = nil | ||
| var onScanResultAsDict: (([String: Any]) -> Void)? = nil | ||
|
|
||
|
|
@@ -60,12 +61,17 @@ struct LiveMRZScannerView: View { | |
| } | ||
|
|
||
| private func mapVisionResultToDictionary(_ result: QKMRZResult) -> [String: Any] { | ||
|
|
||
| // using manually validated document number for TD1 documents with overflow format | ||
| // this is necessary for NFC chip authentication which requires the full document number | ||
| let documentNumber = overrideDocumentNumber ?? result.documentNumber | ||
|
|
||
| return [ | ||
| "documentType": result.documentType, | ||
| "countryCode": result.countryCode, | ||
| "surnames": result.surnames, | ||
| "givenNames": result.givenNames, | ||
| "documentNumber": result.documentNumber, | ||
| "documentNumber": documentNumber, // using the overriden if available | ||
| "nationalityCountryCode": result.nationalityCountryCode, | ||
| "dateOfBirth": result.birthdate?.description ?? "", | ||
| "sex": result.sex ?? "", | ||
|
|
@@ -80,44 +86,106 @@ struct LiveMRZScannerView: View { | |
| ] | ||
| } | ||
|
|
||
| private func correctBelgiumDocumentNumber(result: String) -> String? { | ||
| // Belgium TD1 format: IDBEL000001115<7027 | ||
| let line1RegexPattern = "IDBEL(?<doc9>[A-Z0-9]{9})<(?<doc3>[A-Z0-9<]{3})(?<checkDigit>\\d)" | ||
| guard let line1Regex = try? NSRegularExpression(pattern: line1RegexPattern) else { return nil } | ||
| let line1Matcher = line1Regex.firstMatch(in: result, options: [], range: NSRange(location: 0, length: result.count)) | ||
| /// Calculates the MRZ check digit using the ICAO 9303 standard | ||
| private func calculateMRZCheckDigit(_ input: String) -> Int { | ||
| let weights = [7, 3, 1] | ||
| var sum = 0 | ||
|
|
||
| for (index, char) in input.enumerated() { | ||
| let value: Int | ||
| if char.isNumber { | ||
| value = Int(String(char)) ?? 0 | ||
| } else if char.isLetter { | ||
| // mapping letters to values: A=10, B=11, ..., Z=35 | ||
| value = Int(char.asciiValue ?? 0) - Int(Character("A").asciiValue ?? 0) + 10 | ||
| } else if char == "<" { | ||
| value = 0 | ||
| } else { | ||
| value = 0 | ||
| } | ||
|
|
||
| if let line1Matcher = line1Matcher { | ||
| let doc9Range = line1Matcher.range(withName: "doc9") | ||
| let doc3Range = line1Matcher.range(withName: "doc3") | ||
| let checkDigitRange = line1Matcher.range(withName: "checkDigit") | ||
| let weight = weights[index % 3] | ||
| sum += value * weight | ||
| } | ||
|
|
||
| let doc9 = (result as NSString).substring(with: doc9Range) | ||
| let doc3 = (result as NSString).substring(with: doc3Range) | ||
| let checkDigit = (result as NSString).substring(with: checkDigitRange) | ||
| return sum % 10 | ||
| } | ||
|
|
||
| if let cleanedDoc = cleanBelgiumDocumentNumber(doc9: doc9, doc3: doc3, checkDigit: checkDigit) { | ||
| let correctedMRZLine = "IDBEL\(cleanedDoc)\(checkDigit)" | ||
| return correctedMRZLine | ||
| } | ||
| /// Extracts and validates the document number from TD1 MRZ line 1, handling both standard and overflow formats. | ||
| /// TD1 format uses an overflow mechanism when document numbers exceed 9 digits. | ||
| /// Example overflow format: IDBEL595392450<8039<<<<<<<<<< where positions 6-14 contain the principal part (595392450), | ||
| /// position 15 contains the overflow indicator (<), positions 16-18 contain overflow digits (803), and position 19 contains the check digit (9). | ||
| /// The full document number becomes: 595392450803. | ||
| /// This overflow format can occur for any country using TD1 MRZ (ID cards). | ||
| private func extractAndValidateTD1DocumentNumber(line1: String) -> (documentNumber: String, isValid: Bool)? { | ||
| guard line1.count == 30 else { return nil } | ||
|
|
||
| // extracting positions 6-14 (9 characters - principal part) | ||
| let startIndex6 = line1.index(line1.startIndex, offsetBy: 5) | ||
| let endIndex14 = line1.index(line1.startIndex, offsetBy: 14) | ||
| let principalPart = String(line1[startIndex6..<endIndex14]) | ||
|
|
||
| // checking position 15 for overflow indicator | ||
| let pos15Index = line1.index(line1.startIndex, offsetBy: 14) | ||
| let pos15 = line1[pos15Index] | ||
|
|
||
| if pos15 != "<" { | ||
| // handling standard format where position 15 is the check digit | ||
| let checkDigit = Int(String(pos15)) ?? -1 | ||
| let calculatedCheck = calculateMRZCheckDigit(principalPart) | ||
| let isValid = (checkDigit == calculatedCheck) | ||
| print("[extractAndValidateTD1DocumentNumber] Standard format: \(principalPart), check=\(checkDigit), calculated=\(calculatedCheck), valid=\(isValid)") | ||
| return (principalPart, isValid) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| private func cleanBelgiumDocumentNumber(doc9: String, doc3: String, checkDigit: String) -> String? { | ||
| // For Belgium TD1 format: IDBEL000001115<7027 | ||
| // doc9 = "000001115" (9 digits) | ||
| // doc3 = "702" (3 digits after <) | ||
| // checkDigit = "7" (single check digit) | ||
| // handling overflow format: scanning positions 16+ until we hit < | ||
| let pos16Index = line1.index(line1.startIndex, offsetBy: 15) | ||
| let remainingPart = String(line1[pos16Index...]) | ||
|
|
||
| // finding the overflow digits and the check digit | ||
| var overflowDigits = "" | ||
| var checkDigitChar: Character? | ||
|
|
||
| var cleanDoc9 = doc9 | ||
| // Strip first 3 characters | ||
| let startIndex = cleanDoc9.index(cleanDoc9.startIndex, offsetBy: 3) | ||
| cleanDoc9 = String(cleanDoc9[startIndex...]) | ||
| for char in remainingPart { | ||
| if char == "<" { | ||
| break | ||
| } | ||
| overflowDigits.append(char) | ||
| } | ||
|
|
||
| guard overflowDigits.count > 0 else { | ||
| print("[extractAndValidateTD1DocumentNumber] ERROR: No overflow digits found") | ||
| return nil | ||
| } | ||
|
|
||
| let fullDocumentNumber = cleanDoc9 + doc3 | ||
| // extracting check digit (last character of overflow) | ||
| checkDigitChar = overflowDigits.last | ||
| let overflowWithoutCheck = String(overflowDigits.dropLast()) | ||
|
|
||
| // constructing full document number: principal + overflow (without check digit) | ||
| let fullDocumentNumber = principalPart + overflowWithoutCheck | ||
|
|
||
| return fullDocumentNumber | ||
| // validating check digit against full document number | ||
| guard let checkDigitChar = checkDigitChar, | ||
| let checkDigit = Int(String(checkDigitChar)) else { | ||
| print("[extractAndValidateTD1DocumentNumber] ERROR: Invalid check digit") | ||
| return nil | ||
| } | ||
| let calculatedCheck = calculateMRZCheckDigit(fullDocumentNumber) | ||
| let isValid = (checkDigit == calculatedCheck) | ||
|
|
||
| #if DEBUG | ||
| print("[extractAndValidateTD1DocumentNumber] Overflow format:") | ||
| print(" Principal part (6-14): \(principalPart)") | ||
| print(" Overflow with check: \(overflowDigits)") | ||
| print(" Overflow without check: \(overflowWithoutCheck)") | ||
| print(" Full document number: \(fullDocumentNumber)") | ||
| print(" Check digit: \(checkDigit)") | ||
| print(" Calculated check: \(calculatedCheck)") | ||
| print(" Valid: \(isValid)") | ||
| #endif | ||
|
|
||
| return (fullDocumentNumber, isValid) | ||
| } | ||
|
Comment on lines
+114
to
189
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Solid TD1 overflow extraction logic with one concern: document numbers logged in production. The extraction and validation logic correctly handles both standard TD1 format (9-digit document number with check digit at position 15) and the overflow format per ICAO 9303. The check digit validation against the reconstructed full document number is properly implemented. However, lines 137-138 log the document number in production builds (outside - print("[extractAndValidateTD1DocumentNumber] Standard format: \(principalPart), check=\(checkDigit), calculated=\(calculatedCheck), valid=\(isValid)")
+ #if DEBUG
+ print("[extractAndValidateTD1DocumentNumber] Standard format: [REDACTED], check=\(checkDigit), calculated=\(calculatedCheck), valid=\(isValid)")
+ #endif
return (principalPart, isValid)🤖 Prompt for AI Agents |
||
|
|
||
| private func isValidMRZResult(_ result: QKMRZResult) -> Bool { | ||
|
|
@@ -131,59 +199,69 @@ struct LiveMRZScannerView: View { | |
| onScanResultAsDict?(mapVisionResultToDictionary(result)) | ||
| } | ||
|
|
||
| private func processBelgiumDocument(result: String, parser: QKMRZParser) -> QKMRZResult? { | ||
| print("[LiveMRZScannerView] Processing Belgium document") | ||
| /// Processes TD1 documents (ID cards) by manually extracting and validating the document number using the overflow format handler, | ||
| /// then parses the remaining MRZ fields (name, dates, etc.) using QKMRZParser. This bypasses QKMRZParser's validation for the | ||
| /// document number field since it doesn't handle TD1 overflow format correctly. | ||
| private func processTD1DocumentWithOverflow(result: String, parser: QKMRZParser) -> QKMRZResult? { | ||
| print("[LiveMRZScannerView] Processing TD1 document with manual overflow validation") | ||
|
|
||
| guard let correctedBelgiumLine = correctBelgiumDocumentNumber(result: result) else { | ||
| print("[LiveMRZScannerView] Failed to correct Belgium document number") | ||
| return nil | ||
| } | ||
|
|
||
| // print("[LiveMRZScannerView] Belgium corrected line: \(correctedBelgiumLine)") | ||
|
|
||
| // Split MRZ into lines and replace the first line | ||
| let lines = result.components(separatedBy: "\n") | ||
| guard lines.count >= 3 else { | ||
| print("[LiveMRZScannerView] Invalid MRZ format - not enough lines") | ||
| return nil | ||
| } | ||
|
|
||
| let originalFirstLine = lines[0] | ||
| // print("[LiveMRZScannerView] Original first line: \(originalFirstLine)") | ||
|
|
||
| // Pad the corrected line to 30 characters (TD1 format) | ||
| let paddedCorrectedLine = correctedBelgiumLine.padding(toLength: 30, withPad: "<", startingAt: 0) | ||
| // print("[LiveMRZScannerView] Padded corrected line: \(paddedCorrectedLine)") | ||
| let line1 = lines[0] | ||
| print("[LiveMRZScannerView] Line 1: \(line1)") | ||
|
|
||
| // Reconstruct the MRZ with the corrected first line | ||
| var correctedLines = lines | ||
| correctedLines[0] = paddedCorrectedLine | ||
| let correctedMRZString = correctedLines.joined(separator: "\n") | ||
| // print("[LiveMRZScannerView] Corrected MRZ string: \(correctedMRZString)") | ||
| // extracting and validating document number manually using overflow format handler | ||
| guard let (documentNumber, isDocNumberValid) = extractAndValidateTD1DocumentNumber(line1: line1) else { | ||
| print("[LiveMRZScannerView] Failed to extract TD1 document number") | ||
| return nil | ||
| } | ||
|
|
||
| guard let belgiumMRZResult = parser.parse(mrzString: correctedMRZString) else { | ||
| print("[LiveMRZScannerView] Belgium MRZ result is not valid") | ||
| if !isDocNumberValid { | ||
| print("[LiveMRZScannerView] TD1 document number check digit is INVALID") | ||
| return nil | ||
| } | ||
|
|
||
| // print("[LiveMRZScannerView] Belgium MRZ result: \(belgiumMRZResult)") | ||
| print("[LiveMRZScannerView] TD1 document number validated: \(documentNumber) ✓") | ||
|
Comment on lines
+224
to
+228
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new TD1 overflow flow prints the MRZ line and validated document number with unconditional Useful? React with 👍 / 👎. |
||
|
|
||
| // Try the corrected MRZ first | ||
| if isValidMRZResult(belgiumMRZResult) { | ||
| return belgiumMRZResult | ||
| // parsing the original MRZ to get all other fields (name, birthdate, etc.) | ||
| // using QKMRZParser for non-documentNumber fields | ||
| guard let mrzResult = parser.parse(mrzString: result) else { | ||
| print("[LiveMRZScannerView] Failed to parse MRZ with QKMRZParser") | ||
| return nil | ||
| } | ||
|
|
||
| // If document number is still invalid, try single character correction | ||
| if !belgiumMRZResult.isDocumentNumberValid { | ||
| if let correctedResult = singleCorrectDocumentNumberInMRZ(result: correctedMRZString, docNumber: belgiumMRZResult.documentNumber, parser: parser) { | ||
| // print("[LiveMRZScannerView] Single correction successful: \(correctedResult)") | ||
| if isValidMRZResult(correctedResult) { | ||
| return correctedResult | ||
| } | ||
| } | ||
| // validating that other fields are also correct | ||
| if !mrzResult.isBirthdateValid || !mrzResult.isExpiryDateValid { | ||
| print("[LiveMRZScannerView] TD1 document has invalid birthdate or expiry date") | ||
| return nil | ||
| } | ||
|
|
||
| return nil | ||
| #if DEBUG | ||
| print("[LiveMRZScannerView] QKMRZParser extracted fields:") | ||
| print(" countryCode: \(mrzResult.countryCode)") | ||
| print(" surnames: \(mrzResult.surnames)") | ||
| print(" givenNames: \(mrzResult.givenNames)") | ||
| print(" birthdate: \(mrzResult.birthdate?.description ?? "nil")") | ||
| print(" sex: \(mrzResult.sex ?? "nil")") | ||
| print(" expiryDate: \(mrzResult.expiryDate?.description ?? "nil")") | ||
| print(" personalNumber: \(mrzResult.personalNumber)") | ||
| print(" Parser's documentNumber: \(mrzResult.documentNumber)") | ||
| print(" Our validated documentNumber: \(documentNumber)") | ||
| #endif | ||
|
|
||
| // storing the manually validated full document number | ||
| // this will be used for NFC chip authentication (BAC keys) | ||
| overrideDocumentNumber = documentNumber | ||
| #if DEBUG | ||
| print("[LiveMRZScannerView] Set overrideDocumentNumber to: \(documentNumber)") | ||
| #endif | ||
|
|
||
| // returning MRZ result, the document number will be overridden in mapVisionResultToDictionary | ||
| return mrzResult | ||
| } | ||
|
Comment on lines
+202
to
265
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TD1 processing flow is well-structured, but production logs expose PII. The
However, lines 206, 215, 228 log document numbers and MRZ line data in production builds. These contain PII and should be guarded with private func processTD1DocumentWithOverflow(result: String, parser: QKMRZParser) -> QKMRZResult? {
- print("[LiveMRZScannerView] Processing TD1 document with manual overflow validation")
+ #if DEBUG
+ print("[LiveMRZScannerView] Processing TD1 document with manual overflow validation")
+ #endif
let lines = result.components(separatedBy: "\n")
guard lines.count >= 3 else {
- print("[LiveMRZScannerView] Invalid MRZ format - not enough lines")
+ #if DEBUG
+ print("[LiveMRZScannerView] Invalid MRZ format - not enough lines")
+ #endif
return nil
}
let line1 = lines[0]
- print("[LiveMRZScannerView] Line 1: \(line1)")
+ #if DEBUG
+ print("[LiveMRZScannerView] Line 1: [REDACTED]")
+ #endif
// extracting and validating document number manually using overflow format handler
guard let (documentNumber, isDocNumberValid) = extractAndValidateTD1DocumentNumber(line1: line1) else {
- print("[LiveMRZScannerView] Failed to extract TD1 document number")
+ #if DEBUG
+ print("[LiveMRZScannerView] Failed to extract TD1 document number")
+ #endif
return nil
}
if !isDocNumberValid {
- print("[LiveMRZScannerView] TD1 document number check digit is INVALID")
+ #if DEBUG
+ print("[LiveMRZScannerView] TD1 document number check digit is INVALID")
+ #endif
return nil
}
- print("[LiveMRZScannerView] TD1 document number validated: \(documentNumber) ✓")
+ #if DEBUG
+ print("[LiveMRZScannerView] TD1 document number validated: [REDACTED] ✓")
+ #endif
// parsing the original MRZ to get all other fields (name, birthdate, etc.)
// using QKMRZParser for non-documentNumber fields
guard let mrzResult = parser.parse(mrzString: result) else {
- print("[LiveMRZScannerView] Failed to parse MRZ with QKMRZParser")
+ #if DEBUG
+ print("[LiveMRZScannerView] Failed to parse MRZ with QKMRZParser")
+ #endif
return nil
}
// validating that other fields are also correct
if !mrzResult.isBirthdateValid || !mrzResult.isExpiryDateValid {
- print("[LiveMRZScannerView] TD1 document has invalid birthdate or expiry date")
+ #if DEBUG
+ print("[LiveMRZScannerView] TD1 document has invalid birthdate or expiry date")
+ #endif
return nil
}🤖 Prompt for AI Agents |
||
|
|
||
| var body: some View { | ||
|
|
@@ -208,10 +286,13 @@ struct LiveMRZScannerView: View { | |
| return | ||
| } | ||
|
|
||
| // Handle Belgium documents (only if not already valid) | ||
| if doc.countryCode == "BEL" { | ||
| if let belgiumResult = processBelgiumDocument(result: result, parser: parser) { | ||
| handleValidMRZResult(belgiumResult) | ||
| // handling TD1 documents with potential overflow format (only if not already valid) | ||
| // TD1 format has 3 lines of 30 characters each | ||
| let lines = result.components(separatedBy: "\n") | ||
| if lines.count >= 3 && lines[0].count == 30 { | ||
| // trying overflow validation | ||
| if let td1Result = processTD1DocumentWithOverflow(result: result, parser: parser) { | ||
| handleValidMRZResult(td1Result) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TD1 documents lose OCR correction fallback for document numbersThe old Additional Locations (1) |
||
| } | ||
| return | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Check digit calculation incorrect for lowercase letters
The
calculateMRZCheckDigitfunction useschar.isLetterwhich matches both uppercase and lowercase letters, but the value calculation assumes uppercase ASCII values ('A' = 65). For lowercase letters like'a'(ASCII 97), the calculation produces97 - 65 + 10 = 42instead of the correct value10. This would cause incorrect check digit results if OCR produces lowercase characters, potentially rejecting valid documents. While MRZ data is defined as uppercase per ICAO 9303, the function would silently produce wrong results rather than handling this case explicitly.