-
Notifications
You must be signed in to change notification settings - Fork 199
feat: TD1 MRZ Overflow Format #1337
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
92e6f02
3e66b97
00eb5cd
c57b120
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) | ||
| } | ||
|
|
||
| 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) ✓") | ||
|
|
||
| // Try the corrected MRZ first | ||
| if isValidMRZResult(belgiumMRZResult) { | ||
| return belgiumMRZResult | ||
| // parsing the original MRZ to get all other fields (name, birthdate, etc.) | ||
|
Comment on lines
+214
to
+230
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. Stop logging full MRZ line/document number.
[suggested fix] - let line1 = lines[0]
- print("[LiveMRZScannerView] Line 1: \(line1)")
+ let line1 = lines[0]
+ #if DEBUG
+ let maskedLine1 = "\(line1.prefix(4))…\(line1.suffix(4))"
+ print("[LiveMRZScannerView] Line 1: \(maskedLine1)")
+ #endif
@@
- print("[LiveMRZScannerView] TD1 document number validated: \(documentNumber) ✓")
+ #if DEBUG
+ let maskedDoc = String(documentNumber.prefix(3)) + String(repeating: "•", count: Swift.max(documentNumber.count - 6, 0)) + String(documentNumber.suffix(3))
+ print("[LiveMRZScannerView] TD1 document number validated ✓ (doc=\(maskedDoc))")
+ #endifAs per coding guidelines. 🤖 Prompt for AI Agents |
||
| // 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)") | ||
ArmanKolozyan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| #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 | ||
| } | ||
|
|
||
| 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) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -186,9 +186,12 @@ function validateTD3CheckDigits(lines: string[]): Omit<MRZValidation, 'format' | | |
| } | ||
|
|
||
| export function checkScannedInfo(passportNumber: string, dateOfBirth: string, dateOfExpiry: string): boolean { | ||
| if (passportNumber.length > 9) { | ||
| return false; | ||
| } | ||
| // TD1 overflow format allows document numbers > 9 characters per ICAO 9303: | ||
| // When document numbers exceed 9 characters, the overflow digits are stored in the optional data | ||
| // field and the full document number is reconstructed during MRZ parsing. | ||
| // if (passportNumber.length > 9) { | ||
| // return false; | ||
| // } | ||
|
Comment on lines
+189
to
+194
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. Critical: Incomplete TD1 overflow implementation. Removing the length check without updating the TD1 extraction and validation logic creates an incomplete implementation. The iOS code (per PR objectives) added This could lead to:
Apply these fixes: 1. Update function extractTD1Info(lines: string[]): Omit<MRZInfo, 'validation'> {
const line1 = lines[0];
const line2 = lines[1];
const concatenatedLines = line1 + line2;
+
+ // Handle TD1 overflow format per ICAO 9303
+ // When document number exceeds 9 chars, overflow is in optional data
+ let documentNumber = concatenatedLines.slice(5, 14).replace(/</g, '').trim();
+ const documentNumberCheckDigitPos = concatenatedLines.slice(14, 15);
+
+ // Detect overflow: if position 15 is '<', overflow exists in optional data
+ if (documentNumberCheckDigitPos === '<') {
+ // Extract overflow from optional data (positions 15-29, which is indices 15-29)
+ const optionalData = concatenatedLines.slice(15, 30);
+ // Find the check digit (last non-< character in overflow)
+ let overflowChars = '';
+ for (let i = 0; i < optionalData.length; i++) {
+ if (optionalData[i] !== '<') {
+ overflowChars += optionalData[i];
+ } else {
+ break;
+ }
+ }
+ // Append overflow to document number (excluding last char which is check digit)
+ if (overflowChars.length > 0) {
+ documentNumber += overflowChars.slice(0, -1);
+ }
+ }
return {
documentType: concatenatedLines.slice(0, 2),
issuingCountry: concatenatedLines.slice(2, 5),
- documentNumber: concatenatedLines.slice(5, 14).replace(/</g, '').trim(),
+ documentNumber,
dateOfBirth: concatenatedLines.slice(30, 36),
dateOfExpiry: concatenatedLines.slice(38, 44),
};
}2. Update function validateTD1CheckDigits(lines: string[]): Omit<MRZValidation, 'format' | 'overall'> {
const line1 = lines[0];
const line2 = lines[1];
const concatenatedLines = line1 + line2;
- const documentNumber = concatenatedLines.slice(5, 14);
- const documentNumberCheckDigit = concatenatedLines.slice(14, 15);
+ // Handle TD1 overflow format
+ let documentNumber = concatenatedLines.slice(5, 14);
+ let documentNumberCheckDigit = concatenatedLines.slice(14, 15);
+
+ // Detect overflow: if position 15 is '<', check digit is in optional data
+ if (documentNumberCheckDigit === '<') {
+ const optionalData = concatenatedLines.slice(15, 30);
+ // Find overflow characters and check digit
+ let overflowChars = '';
+ for (let i = 0; i < optionalData.length; i++) {
+ if (optionalData[i] !== '<') {
+ overflowChars += optionalData[i];
+ } else {
+ break;
+ }
+ }
+ if (overflowChars.length > 0) {
+ // Last char is check digit, rest is overflow
+ documentNumberCheckDigit = overflowChars.slice(-1);
+ documentNumber += overflowChars.slice(0, -1);
+ }
+ }
+
const dateOfBirth = concatenatedLines.slice(30, 36);
const dobCheckDigit = concatenatedLines.slice(36, 37);
const dateOfExpiry = concatenatedLines.slice(38, 44);
const expiryCheckDigit = concatenatedLines.slice(44, 45);
return {
passportNumberChecksum: verifyCheckDigit(documentNumber, documentNumberCheckDigit),
dateOfBirthChecksum: verifyCheckDigit(dateOfBirth, dobCheckDigit),
dateOfExpiryChecksum: verifyCheckDigit(dateOfExpiry, expiryCheckDigit),
compositeChecksum: true, // TD1 doesn't have a composite check digit like TD3
};
}
Collaborator
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. This specific code path is only reached on Android. This PR addresses only the iOS platform.
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. @seshanthS Thank you for the clarification! I understand now—this TypeScript code path is Android-only, and since this PR addresses only iOS, the Android TD1 overflow implementation is appropriately deferred. The change to comment out the length check makes sense as an interim step: it prevents Android from prematurely rejecting document numbers > 9 characters, while the full TD1 overflow extraction and validation logic (similar to what was implemented in the iOS Swift code) would be added when Android support is implemented later—as noted in the PR TODOs. For the iOS flow, the Swift implementation in ✏️ Learnings added
🧠 Learnings used |
||
| if (dateOfBirth.length !== 6) { | ||
| return false; | ||
| } | ||
|
|
||
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.
Strip PII from standard-format logging.
This
printdumps the raw document number and check digit into logs, which violates our “never log PII” rule for MRZ data and creates GDPR/CCPA exposure in release builds. Please remove or properly gate/redact the output.[suggested fix]
As per coding guidelines.
📝 Committable suggestion
🤖 Prompt for AI Agents