ContactHeuristicParser.swift
345 lignes · 15797 octets
import Foundation /// Deterministic structuring of business-card OCR text. /// Single structuring source in Card2vcf (no LLM). enum ContactHeuristicParser { /// OCR confidence (0-100) under which a field is flagged « à vérifier ». private static let seuilConfiance: Float = 70 private static let jobKeywords = KotlinRegex( #"(?i)\b(directeur|directrice|ceo|cto|cfo|coo|président|presidente|"# + #"manager|responsable|ingénieur|ingenieur|commercial|consultan[te]?|"# + #"associé|associe|fondateur|founder|head of|chef de|avocat|docteur|dr\.?)\b"# ) private static let addressKeywords = KotlinRegex( #"(?i)\b(rue|avenue|av\.?|bd\.?|boulevard|all[ée]e|chemin|place|impasse|"# + #"cedex|cs\b|bp\b|boîte postale|france|siège|siege)\b|\b\d{5}\b"# ) private static let companyKeywords = KotlinRegex( #"(?i)\b(sas|sarl|sa\b|sasu|eurl|sci|ltd|inc|gmbh|software|solutions|"# + #"group|groupe|technologies|tech|consulting)\b"# ) private static let skipLine = KotlinRegex( #"(?i)^(mobile|tél\.?|tel\.?|phone|fax|email|e-mail|mail|www\.|http|"# + #"siège social|siege social|linkedin|twitter)\b"# ) private static let emailOrUrlOrPhone = KotlinRegex( #"@|www\.|https?://|\+?\d[\d\s.\-()]{7,}\d"#, options: [.caseInsensitive] ) private static let nonNameChars = KotlinRegex(#"[^\p{L}\s\-']"#) private static let whitespaceRun = KotlinRegex(#"\s+"#) private static let companySeparators = KotlinRegex(#"[&—–\-_|]+"#) private static let camelBoundary = KotlinRegex(#"(?<=[a-z])(?=[A-Z])"#) static func parse(_ ocr: OcrResult) -> ContactCard { if !ocr.spatialLines.isEmpty { return parseSpatial(ocr) } let lines: [String] if ocr.lines.isEmpty { lines = ocr.rawText.kotlinLines() .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } } else { lines = ocr.lines } var residual = lines func takeMatching(_ pred: (String) -> Bool) -> String? { guard let idx = residual.firstIndex(where: pred) else { return nil } return residual.remove(at: idx) } func removeFirst(_ element: String) { if let idx = residual.firstIndex(of: element) { residual.remove(at: idx) } } // Kotlin maxByOrNull: first element with the maximum selector value. func firstMax(_ candidates: [String], by score: (String) -> Int) -> String? { var best: String? = nil var bestScore = Int.min for c in candidates { let s = score(c) if s > bestScore { bestScore = s best = c } } return best } let jobTitle = takeMatching { jobKeywords.containsMatchIn($0) && !emailOrUrlOrPhone.containsMatchIn($0) } let addressCandidates = residual.filter { addressKeywords.containsMatchIn($0) && !emailOrUrlOrPhone.containsMatchIn($0) } let address = firstMax(addressCandidates) { $0.count } if let address { removeFirst(address) } let personLine = firstMax(residual.filter { looksLikePersonName($0) }) { personScore($0) } if let personLine { removeFirst(personLine) } let (firstName, lastName, fullName) = splitPersonName(personLine) let companyFromEmail = ocr.emails.first.flatMap { companyFromEmailDomain($0) } let companyLine = firstMax(residual.filter { looksLikeCompany($0) }) { companyScore($0) } if let companyLine { removeFirst(companyLine) } let company = cleanCompany(companyLine) ?? companyFromEmail let website = ocr.urls.first let noteParts = residual.filter { line in !skipLine.containsMatchIn(line) && !emailOrUrlOrPhone.containsMatchIn(line) && line.count > 2 } return ContactCard( fullName: fullName, firstName: firstName, lastName: lastName, company: company, jobTitle: cleanJob(jobTitle), phones: ocr.phones, emails: ocr.emails, website: website, address: cleanAddress(address), note: noteParts.prefix(3).joined(separator: " · ").nilIfBlank ) } static func looksLikePersonName(_ line: String) -> Bool { let t = nonNameChars.replace(line, with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) guard (4...60).contains(t.count) else { return false } if jobKeywords.containsMatchIn(t) || companyKeywords.containsMatchIn(t) { return false } if addressKeywords.containsMatchIn(t) { return false } if emailOrUrlOrPhone.containsMatchIn(line) { return false } let parts = whitespaceRun.split(t).filter { $0.isNotBlank } guard (2...4).contains(parts.count) else { return false } // At least two capitalized / uppercase tokens. let caps = parts.filter { $0.first?.isUppercase == true }.count return caps >= 2 } static func personScore(_ line: String) -> Int { let parts = whitespaceRun.split( nonNameChars.replace(line, with: " ").trimmingCharacters(in: .whitespacesAndNewlines) ) var s = parts.count * 10 if parts.contains(where: { $0.count >= 2 && $0 == $0.uppercased() && $0.contains(where: { $0.isLetter }) }) { s += 40 } if (2...3).contains(parts.count) { s += 15 } return s } static func splitPersonName(_ line: String?) -> (firstName: String?, lastName: String?, fullName: String?) { guard let line, line.isNotBlank else { return (nil, nil, nil) } let cleaned = whitespaceRun.replace( nonNameChars.replace(line.replacingOccurrences(of: "!", with: "l"), with: " "), // Pau! → Paul (frequent OCR error) with: " " ).trimmingCharacters(in: .whitespacesAndNewlines) let parts = cleaned.components(separatedBy: " ").filter { $0.isNotBlank } if parts.isEmpty { return (nil, nil, nil) } let lastCaps = parts.lastIndex { $0.count >= 2 && $0 == $0.uppercased() && $0.allSatisfy { c in !c.isLetter || c.isUppercase } } func titleWord(_ w: String) -> String { let lower = w.lowercased() guard let f = lower.first else { return lower } return String(f).uppercased() + lower.dropFirst() } if let lastCaps, lastCaps > 0 { let last = titleWord(parts[lastCaps]) // Keep compound first names without forcing odd casing when already mixed; // but an all-caps token (« SONIA MARTIN ») goes back to Title Case. let firstKeep = parts[0..<lastCaps] .map { w in (w.count >= 2 && w == w.uppercased()) ? titleWord(w) : w } .joined(separator: " ") return (firstKeep, last, "\(firstKeep) \(last)") } else if parts.count >= 2 { let last = titleWord(parts[parts.count - 1]) let first = parts.dropLast().joined(separator: " ") return (first, last, "\(first) \(last)") } else { return (nil, nil, cleaned) } } /// Spatial path: exploits boxes/heights/blocks instead of flat text. Same /// classification regexes as the legacy path, enriched with layout signals /// (font size, grouping, labels). private static func parseSpatial(_ ocr: OcrResult) -> ContactCard { let blocks = BlockGrouper.group(ocr.spatialLines) // « Typical height » = low median: robust to giant logos and 2-line // cards where the true median would be pulled upward. let heights = ocr.spatialLines.map(\.box.height).sorted() let typicalHeight = heights[(heights.count - 1) / 2] func isData(_ t: String) -> Bool { emailOrUrlOrPhone.containsMatchIn(t) } func isLabel(_ t: String) -> Bool { skipLine.containsMatchIn(t) } // Kotlin maxByOrNull: first element with the maximum selector value. func firstMax<T>(_ candidates: [T], by score: (T) -> Int) -> T? { var best: T? = nil var bestScore = Int.min for c in candidates { let s = score(c) if s > bestScore { bestScore = s best = c } } return best } // Lone label (Tél. : / Mobile…) → the value sits on the block's next line. var phones = ocr.phones for block in blocks { for (label, value) in zip(block.lines, block.lines.dropFirst()) { if isLabel(label.text) && !isData(label.text) { for p in OcrPostProcessor.process(value.text).phones where !phones.contains(p) { phones.append(p) } } } } // Address: the most « address-like » block, multi-line join. func addressLineCount(_ b: OcrBlock) -> Int { b.lines.filter { addressKeywords.containsMatchIn($0.text) && !isData($0.text) }.count } let addressBlock = firstMax(blocks.filter { addressLineCount($0) > 0 }, by: addressLineCount) let address = addressBlock?.lines .filter { !isData($0.text) && !isLabel($0.text) } .map { whitespaceRun.replace($0.text, with: " ").trimmingCharacters(in: .whitespacesAndNewlines) } .joined(separator: ", ") .nilIfBlank let addressLines = addressBlock?.lines ?? [] let rest = ocr.spatialLines.filter { !addressLines.contains($0) && !isData($0.text) && !isLabel($0.text) } let jobLine = rest.first { jobKeywords.containsMatchIn($0.text) } let emailDomain: String? = ocr.emails.first.flatMap { email in guard let atIdx = email.firstIndex(of: "@") else { return nil } let domain = String(email[email.index(after: atIdx)...].prefix(while: { $0 != "." })) return domain.count >= 3 ? domain : nil } // NB: « all caps » is NOT a company signal — person names are very often // capitalized on French business cards. func looksCompanySpatial(_ l: OcrLine) -> Bool { companyKeywords.containsMatchIn(l.text) || (emailDomain != nil && l.text.lowercased().replacingOccurrences(of: " ", with: "").contains(emailDomain!)) } let nameLine = firstMax( rest.filter { $0 != jobLine && !looksCompanySpatial($0) && personLikeSpatial($0, typicalHeight: typicalHeight) } ) { personScore($0.text) + $0.box.height * 40 / typicalHeight } let (firstName, lastName, fullName) = splitPersonName(nameLine?.text) let companyLine = firstMax( rest.filter { $0 != jobLine && $0 != nameLine && looksCompanySpatial($0) } ) { $0.box.height * 1000 - $0.box.top } // large and near the top of the card let company = cleanCompany(companyLine?.text) ?? ocr.emails.first.flatMap { companyFromEmailDomain($0) } let noteParts = rest.filter { $0 != jobLine && $0 != nameLine && $0 != companyLine && $0.text.count > 2 } var douteux: Set<String> = [] if let nameLine, nameLine.confidence < seuilConfiance { douteux.insert("fullName") } if let jobLine, jobLine.confidence < seuilConfiance { douteux.insert("jobTitle") } if let companyLine, companyLine.confidence < seuilConfiance { douteux.insert("company") } if let addressBlock, addressBlock.avgConfidence() < seuilConfiance { douteux.insert("address") } return ContactCard( fullName: fullName, firstName: firstName, lastName: lastName, company: company, jobTitle: cleanJob(jobLine?.text), phones: phones, emails: ocr.emails, website: ocr.urls.first, address: address, note: noteParts.prefix(3).map(\.text).joined(separator: " · ").nilIfBlank, champsDouteux: douteux ) } /// Like `looksLikePersonName`, with a capitals waiver for oversized characters. private static func personLikeSpatial(_ line: OcrLine, typicalHeight: Int) -> Bool { let t = nonNameChars.replace(line.text, with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) guard (4...60).contains(t.count) else { return false } if jobKeywords.containsMatchIn(t) || companyKeywords.containsMatchIn(t) { return false } if addressKeywords.containsMatchIn(t) { return false } let parts = whitespaceRun.split(t).filter { $0.isNotBlank } guard (2...4).contains(parts.count) else { return false } let caps = parts.filter { $0.first?.isUppercase == true }.count return caps >= 2 || Double(line.box.height) >= 1.5 * Double(typicalHeight) } private static func looksLikeCompany(_ line: String) -> Bool { guard (2...50).contains(line.count) else { return false } if emailOrUrlOrPhone.containsMatchIn(line) { return false } if jobKeywords.containsMatchIn(line) { return false } if addressKeywords.containsMatchIn(line) { return false } if looksLikePersonName(line) { return false } return companyKeywords.containsMatchIn(line) || line == line.uppercased() || whitespaceRun.split(line).count <= 3 } private static func companyScore(_ line: String) -> Int { var s = 0 if companyKeywords.containsMatchIn(line) { s += 50 } if line == line.uppercased() { s += 20 } if line.range(of: "software", options: .caseInsensitive) != nil { s += 30 } return s + max(40 - line.count, 0) } private static func cleanCompany(_ line: String?) -> String? { guard let line, line.isNotBlank else { return nil } return whitespaceRun.replace(companySeparators.replace(line, with: " "), with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) .nilIfBlank } private static func cleanJob(_ line: String?) -> String? { guard let line else { return nil } return whitespaceRun.replace(line, with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) .nilIfBlank } private static func cleanAddress(_ line: String?) -> String? { guard let line else { return nil } return whitespaceRun.replace(line, with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) .nilIfBlank } static func companyFromEmailDomain(_ email: String) -> String? { let afterAt: Substring if let atIdx = email.firstIndex(of: "@") { afterAt = email[email.index(after: atIdx)...] } else { afterAt = "" } let domain = String(afterAt.prefix(while: { $0 != "." })) if domain.count < 3 { return nil } // dimosoftware → Dimo Software (best-effort) let spaced = camelBoundary.replace(domain, with: " ") .replacingOccurrences(of: "software", with: " Software", options: .caseInsensitive) .replacingOccurrences(of: "solutions", with: " Solutions", options: .caseInsensitive) .trimmingCharacters(in: .whitespacesAndNewlines) guard let f = spaced.first else { return nil } return (String(f).uppercased() + spaced.dropFirst()).nilIfBlank } }
GitRust