Skip to content

Commit 85df676

Browse files
authored
Hotfix: Belgium ID cards (#1061)
* feat: parse belgium TD1 mrz android * feat: Parse Belgium TD1 MRZ IOS
1 parent 5b02868 commit 85df676

File tree

4 files changed

+382
-51
lines changed

4 files changed

+382
-51
lines changed

app/android/android-passport-reader/app/src/main/java/example/jllarraz/com/passportreader/utils/OcrUtils.kt

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,15 @@ object OcrUtils {
2626
private val REGEX_ID_DOCUMENT_NUMBER = "(ID)(?<country>[A-Z<]{3})(?<documentNumber>[A-Z0-9<]{9})(?<checkDigitDocumentNumber>[0-9]{1})"
2727
private val REGEX_ID_DATE_OF_BIRTH = "(?<dateOfBirth>[0-9]{6})(?<checkDigitDateOfBirth>[0-9]{1})(?<gender>[FM<]{1})"
2828

29+
// Belgium TD1 (ID Card) specific pattern
30+
private val REGEX_BELGIUM_ID_DOCUMENT_NUMBER = "IDBEL(?<doc9>[A-Z0-9]{9})<(?<doc3>[A-Z0-9]{3})(?<checkDigit>\\d)"
31+
private val REGEX_BELGIUM_ID_DATE_OF_BIRTH = "(?<dateOfBirth>[0-9]{6})(?<checkDigitDateOfBirth>[0-9]{1})(?<gender>[FM<]{1})(?<expirationDate>[0-9]{6})(?<checkDigitExpiration>[0-9]{1})"
32+
2933
private val patternDocumentNumber = Pattern.compile(REGEX_ID_DOCUMENT_NUMBER)
3034
private val patternDateOfBirth = Pattern.compile(REGEX_ID_DATE_OF_BIRTH)
3135
private val patternDocumentCode = Pattern.compile(REGEX_ID_DOCUMENT_CODE)
36+
private val patternBelgiumDocumentNumber = Pattern.compile(REGEX_BELGIUM_ID_DOCUMENT_NUMBER)
37+
private val patternBelgiumDateOfBirth = Pattern.compile(REGEX_BELGIUM_ID_DATE_OF_BIRTH)
3238

3339

3440
fun processOcr(
@@ -50,7 +56,7 @@ object OcrUtils {
5056
fullRead += "$temp-"
5157
}
5258
fullRead = fullRead.uppercase()
53-
Log.d(TAG, "Read: $fullRead")
59+
// Log.d(TAG, "Read: $fullRead")
5460

5561
// We try with TD1 format first (ID Card)
5662
val patternTD1Line1 = Pattern.compile(REGEX_TD1_LINE1)
@@ -69,40 +75,63 @@ object OcrUtils {
6975

7076
val matcherDocumentNumber = patternDocumentNumber.matcher(fullRead)
7177
val matcherDateOfBirth = patternDateOfBirth.matcher(fullRead)
72-
7378
val hasDocumentNumber = matcherDocumentNumber.find()
7479
val hasDateOfBirth = matcherDateOfBirth.find()
7580

81+
// Belgium specific matchers
82+
val matcherBelgiumDocumentNumber = patternBelgiumDocumentNumber.matcher(fullRead)
83+
val hasBelgiumDocumentNumber = matcherBelgiumDocumentNumber.find()
84+
7685
val documentNumber = if (hasDocumentNumber) matcherDocumentNumber.group("documentNumber") else null
7786
val checkDigitDocumentNumber = if (hasDocumentNumber) matcherDocumentNumber.group("checkDigitDocumentNumber")?.toIntOrNull() else null
7887
val countryCode = if (hasDocumentNumber) matcherDocumentNumber.group("country") else null
7988
val dateOfBirth = if (hasDateOfBirth) matcherDateOfBirth.group("dateOfBirth") else null
89+
90+
// Belgium specific values
91+
val belgiumCheckDigit = if (hasBelgiumDocumentNumber) matcherBelgiumDocumentNumber.group("checkDigit")?.toIntOrNull() else null
92+
val belgiumDateOfBirth = if (hasBelgiumDocumentNumber) {
93+
val dateOfBirthMatcher = patternBelgiumDateOfBirth.matcher(fullRead)
94+
if (dateOfBirthMatcher.find()) dateOfBirthMatcher.group("dateOfBirth") else null
95+
} else null
96+
97+
// Final values
98+
val finalDocumentNumber = if (hasBelgiumDocumentNumber) {
99+
val doc9 = matcherBelgiumDocumentNumber.group("doc9")
100+
val doc3 = matcherBelgiumDocumentNumber.group("doc3")
101+
val checkDigit = matcherBelgiumDocumentNumber.group("checkDigit")
102+
cleanBelgiumDocumentNumber(doc9, doc3, checkDigit)
103+
} else documentNumber
104+
val finalDateOfBirth = if (hasBelgiumDocumentNumber) belgiumDateOfBirth else dateOfBirth
105+
val finalCountryCode = if (hasBelgiumDocumentNumber) "BEL" else countryCode
106+
val finalCheckDigit = if (hasBelgiumDocumentNumber) belgiumCheckDigit else checkDigitDocumentNumber
107+
80108
val checkDigitDateOfBirth = if (hasDateOfBirth) matcherDateOfBirth.group("checkDigitDateOfBirth")?.toIntOrNull() else null
81109
val gender = if (hasDateOfBirth) matcherDateOfBirth.group("gender") else null
82110

83-
val expirationDate: String? = if (!countryCode.isNullOrEmpty()) {
84-
val expirationDateRegex = "(?<expirationDate>[0-9]{6})(?<checkDigitExpiration>[0-9]{1})" + Pattern.quote(countryCode)
111+
val expirationDate: String? = if (!finalCountryCode.isNullOrEmpty()) {
112+
val expirationDateRegex = "(?<expirationDate>[0-9]{6})(?<checkDigitExpiration>[0-9]{1})" + Pattern.quote(finalCountryCode)
113+
// val expirationDateRegex = "(?<expirationDate>[0-9]{6})(?<checkDigitExpiration>[0-9]{1})UTO"
85114
val patternExpirationDate = Pattern.compile(expirationDateRegex)
86115
val matcherExpirationDate = patternExpirationDate.matcher(fullRead)
87116
if (matcherExpirationDate.find()) matcherExpirationDate.group("expirationDate") else null
88117
} else null
89118

90119
// Only proceed if all required fields are present and non-empty
91-
if (!countryCode.isNullOrEmpty() && !documentNumber.isNullOrEmpty() && !dateOfBirth.isNullOrEmpty() && !expirationDate.isNullOrEmpty() && checkDigitDocumentNumber != null) {
92-
val cleanDocumentNumber = cleanDocumentNumber(documentNumber, checkDigitDocumentNumber)
93-
Log.d(TAG, "cleanDocumentNumber")
120+
if (!finalCountryCode.isNullOrEmpty() && !finalDocumentNumber.isNullOrEmpty() && !finalDateOfBirth.isNullOrEmpty() && !expirationDate.isNullOrEmpty() && finalCheckDigit != null) {
121+
val cleanDocumentNumber = cleanDocumentNumber(finalDocumentNumber, finalCheckDigit)
122+
// Log.d(TAG, "cleanDocumentNumber")
94123
if (cleanDocumentNumber != null) {
95-
val mrzInfo = createDummyMrz("ID", countryCode, cleanDocumentNumber, dateOfBirth, expirationDate)
124+
val mrzInfo = createDummyMrz("ID", finalCountryCode, cleanDocumentNumber, finalDateOfBirth, expirationDate)
96125
// Log.d(TAG, "MRZ-TD1: $mrzInfo")
97126
callback.onMRZRead(mrzInfo, timeRequired)
98127
return
99128
}
100129
} else {
101-
if (countryCode.isNullOrEmpty()) Log.d(TAG, "Missing or invalid countryCode")
102-
if (documentNumber.isNullOrEmpty()) Log.d(TAG, "Missing or invalid documentNumber")
103-
if (dateOfBirth.isNullOrEmpty()) Log.d(TAG, "Missing or invalid dateOfBirth")
130+
if (finalCountryCode.isNullOrEmpty()) Log.d(TAG, "Missing or invalid finalCountryCode")
131+
if (finalDocumentNumber.isNullOrEmpty()) Log.d(TAG, "Missing or invalid finalDocumentNumber")
132+
if (finalDateOfBirth.isNullOrEmpty()) Log.d(TAG, "Missing or invalid dateOfBirth")
104133
if (expirationDate.isNullOrEmpty()) Log.d(TAG, "Missing or invalid expirationDate")
105-
if (checkDigitDocumentNumber == null) Log.d(TAG, "Missing or invalid checkDigitDocumentNumber")
134+
if (finalCheckDigit == null) Log.d(TAG, "Missing or invalid finalCheckDigit")
106135
}
107136
}
108137

@@ -194,6 +223,27 @@ object OcrUtils {
194223
return null
195224
}
196225

226+
private fun cleanBelgiumDocumentNumber(doc9: String, doc3: String, checkDigit: String): String? {
227+
// For Belgium TD1 format: IDBEL000001115<7027
228+
// doc9 = "000001115" (9 digits)
229+
// doc3 = "702" (3 digits after <)
230+
// checkDigit = "7" (single check digit)
231+
232+
var cleanDoc9 = doc9
233+
cleanDoc9 = cleanDoc9.substring(3)
234+
235+
val fullDocumentNumber = cleanDoc9 + doc3
236+
237+
val checkDigitCalculated = MRZInfo.checkDigit(fullDocumentNumber).toString().toInt()
238+
val expectedCheckDigit = checkDigit.toInt()
239+
240+
if (checkDigitCalculated == expectedCheckDigit) {
241+
return fullDocumentNumber
242+
}
243+
244+
return null
245+
}
246+
197247
private fun createDummyMrz(
198248
documentType: String,
199249
issuingState: String = "ESP",

app/ios/LiveMRZScannerView.swift

Lines changed: 130 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,112 @@ struct LiveMRZScannerView: View {
8080
]
8181
}
8282

83+
private func correctBelgiumDocumentNumber(result: String) -> String? {
84+
// Belgium TD1 format: IDBEL000001115<7027
85+
let line1RegexPattern = "IDBEL(?<doc9>[A-Z0-9]{9})<(?<doc3>[A-Z0-9<]{3})(?<checkDigit>\\d)"
86+
guard let line1Regex = try? NSRegularExpression(pattern: line1RegexPattern) else { return nil }
87+
let line1Matcher = line1Regex.firstMatch(in: result, options: [], range: NSRange(location: 0, length: result.count))
88+
89+
if let line1Matcher = line1Matcher {
90+
let doc9Range = line1Matcher.range(withName: "doc9")
91+
let doc3Range = line1Matcher.range(withName: "doc3")
92+
let checkDigitRange = line1Matcher.range(withName: "checkDigit")
93+
94+
let doc9 = (result as NSString).substring(with: doc9Range)
95+
let doc3 = (result as NSString).substring(with: doc3Range)
96+
let checkDigit = (result as NSString).substring(with: checkDigitRange)
97+
98+
if let cleanedDoc = cleanBelgiumDocumentNumber(doc9: doc9, doc3: doc3, checkDigit: checkDigit) {
99+
let correctedMRZLine = "IDBEL\(cleanedDoc)\(checkDigit)"
100+
return correctedMRZLine
101+
}
102+
}
103+
return nil
104+
}
105+
106+
private func cleanBelgiumDocumentNumber(doc9: String, doc3: String, checkDigit: String) -> String? {
107+
// For Belgium TD1 format: IDBEL000001115<7027
108+
// doc9 = "000001115" (9 digits)
109+
// doc3 = "702" (3 digits after <)
110+
// checkDigit = "7" (single check digit)
111+
112+
var cleanDoc9 = doc9
113+
// Strip first 3 characters
114+
let startIndex = cleanDoc9.index(cleanDoc9.startIndex, offsetBy: 3)
115+
cleanDoc9 = String(cleanDoc9[startIndex...])
116+
117+
let fullDocumentNumber = cleanDoc9 + doc3
118+
119+
120+
return fullDocumentNumber
121+
}
122+
123+
private func isValidMRZResult(_ result: QKMRZResult) -> Bool {
124+
return result.isDocumentNumberValid && result.isExpiryDateValid && result.isBirthdateValid
125+
}
126+
127+
private func handleValidMRZResult(_ result: QKMRZResult) {
128+
parsedMRZ = result
129+
scanComplete = true
130+
onScanComplete?(result)
131+
onScanResultAsDict?(mapVisionResultToDictionary(result))
132+
}
133+
134+
private func processBelgiumDocument(result: String, parser: QKMRZParser) -> QKMRZResult? {
135+
print("[LiveMRZScannerView] Processing Belgium document")
136+
137+
guard let correctedBelgiumLine = correctBelgiumDocumentNumber(result: result) else {
138+
print("[LiveMRZScannerView] Failed to correct Belgium document number")
139+
return nil
140+
}
141+
142+
// print("[LiveMRZScannerView] Belgium corrected line: \(correctedBelgiumLine)")
143+
144+
// Split MRZ into lines and replace the first line
145+
let lines = result.components(separatedBy: "\n")
146+
guard lines.count >= 3 else {
147+
print("[LiveMRZScannerView] Invalid MRZ format - not enough lines")
148+
return nil
149+
}
150+
151+
let originalFirstLine = lines[0]
152+
// print("[LiveMRZScannerView] Original first line: \(originalFirstLine)")
153+
154+
// Pad the corrected line to 30 characters (TD1 format)
155+
let paddedCorrectedLine = correctedBelgiumLine.padding(toLength: 30, withPad: "<", startingAt: 0)
156+
// print("[LiveMRZScannerView] Padded corrected line: \(paddedCorrectedLine)")
157+
158+
// Reconstruct the MRZ with the corrected first line
159+
var correctedLines = lines
160+
correctedLines[0] = paddedCorrectedLine
161+
let correctedMRZString = correctedLines.joined(separator: "\n")
162+
// print("[LiveMRZScannerView] Corrected MRZ string: \(correctedMRZString)")
163+
164+
guard let belgiumMRZResult = parser.parse(mrzString: correctedMRZString) else {
165+
print("[LiveMRZScannerView] Belgium MRZ result is not valid")
166+
return nil
167+
}
168+
169+
// print("[LiveMRZScannerView] Belgium MRZ result: \(belgiumMRZResult)")
170+
171+
// Try the corrected MRZ first
172+
if isValidMRZResult(belgiumMRZResult) {
173+
return belgiumMRZResult
174+
}
175+
176+
// If document number is still invalid, try single character correction
177+
if !belgiumMRZResult.isDocumentNumberValid {
178+
if let correctedResult = singleCorrectDocumentNumberInMRZ(result: correctedMRZString, docNumber: belgiumMRZResult.documentNumber, parser: parser) {
179+
// print("[LiveMRZScannerView] Single correction successful: \(correctedResult)")
180+
if isValidMRZResult(correctedResult) {
181+
return correctedResult
182+
}
183+
}
184+
}
185+
186+
return nil
187+
}
188+
83189
var body: some View {
84190
ZStack(alignment: .bottom) {
85191
CameraView(
@@ -91,20 +197,31 @@ struct LiveMRZScannerView: View {
91197
// print("[LiveMRZScannerView] result: \(result)")
92198
let parser = QKMRZParser(ocrCorrection: false)
93199
if let mrzResult = parser.parse(mrzString: result) {
94-
let doc = mrzResult;
95-
if doc.allCheckDigitsValid == true && !scanComplete {
96-
parsedMRZ = mrzResult
97-
scanComplete = true
98-
onScanComplete?(mrzResult)
99-
onScanResultAsDict?(mapVisionResultToDictionary(mrzResult))
100-
} else if doc.isDocumentNumberValid == false && !scanComplete {
200+
let doc = mrzResult
201+
// print("[LiveMRZScannerView] doc: \(doc)")
202+
203+
guard !scanComplete else { return }
204+
205+
// Check if already valid
206+
if doc.allCheckDigitsValid {
207+
handleValidMRZResult(mrzResult)
208+
return
209+
}
210+
211+
// Handle Belgium documents (only if not already valid)
212+
if doc.countryCode == "BEL" {
213+
if let belgiumResult = processBelgiumDocument(result: result, parser: parser) {
214+
handleValidMRZResult(belgiumResult)
215+
}
216+
return
217+
}
218+
219+
// Handle other documents with invalid document numbers
220+
if !doc.isDocumentNumberValid {
101221
if let correctedResult = singleCorrectDocumentNumberInMRZ(result: result, docNumber: doc.documentNumber, parser: parser) {
102-
let correctedDoc = correctedResult
103-
if correctedDoc.allCheckDigitsValid == true {
104-
parsedMRZ = correctedResult
105-
scanComplete = true
106-
onScanComplete?(correctedResult)
107-
onScanResultAsDict?(mapVisionResultToDictionary(correctedResult))
222+
// print("[LiveMRZScannerView] correctedDoc: \(correctedResult)")
223+
if correctedResult.allCheckDigitsValid {
224+
handleValidMRZResult(correctedResult)
108225
}
109226
}
110227
}

0 commit comments

Comments
 (0)