ContactHeuristicParser.kt
285 lignes · 13099 octets
package fr.ebii.card2vcf.contact import fr.ebii.card2vcf.ocr.BlockGrouper import fr.ebii.card2vcf.ocr.OcrLine import fr.ebii.card2vcf.ocr.OcrPostProcessor import fr.ebii.card2vcf.ocr.OcrResult /** * Structuration déterministe d'un texte OCR de carte de visite. * Source unique de structuration dans Card2vcf (pas de LLM). */ object ContactHeuristicParser { /** Confiance Tesseract (0-100) sous laquelle un champ est marqué « à vérifier ». */ private const val SEUIL_CONFIANCE = 70f private val jobKeywords = Regex( """(?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 val addressKeywords = Regex( """(?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 val companyKeywords = Regex( """(?i)\b(sas|sarl|sa\b|sasu|eurl|sci|ltd|inc|gmbh|software|solutions|""" + """group|groupe|technologies|tech|consulting)\b""", ) private val skipLine = Regex( """(?i)^(mobile|tél\.?|tel\.?|phone|fax|email|e-mail|mail|www\.|http|""" + """siège social|siege social|linkedin|twitter)\b""", ) private val emailOrUrlOrPhone = Regex( """@|www\.|https?://|\+?\d[\d\s.\-()]{7,}\d""", RegexOption.IGNORE_CASE, ) fun parse(ocr: OcrResult): ContactCard { if (ocr.spatialLines.isNotEmpty()) return parseSpatial(ocr) val lines = ocr.lines.ifEmpty { ocr.rawText.lines().map { it.trim() }.filter { it.isNotEmpty() } } val residual = lines.toMutableList() fun takeMatching(pred: (String) -> Boolean): String? { val idx = residual.indexOfFirst(pred) if (idx < 0) return null return residual.removeAt(idx) } val jobTitle = takeMatching { jobKeywords.containsMatchIn(it) && !emailOrUrlOrPhone.containsMatchIn(it) } val address = residual .filter { addressKeywords.containsMatchIn(it) && !emailOrUrlOrPhone.containsMatchIn(it) } .maxByOrNull { it.length } ?.also { residual.remove(it) } val personLine = residual .asSequence() .filter { looksLikePersonName(it) } .maxByOrNull { personScore(it) } ?.also { residual.remove(it) } val (firstName, lastName, fullName) = splitPersonName(personLine) val companyFromEmail = ocr.emails.firstOrNull()?.let { companyFromEmailDomain(it) } val companyLine = residual .asSequence() .filter { looksLikeCompany(it) } .maxByOrNull { companyScore(it) } ?.also { residual.remove(it) } val company = cleanCompany(companyLine) ?: companyFromEmail val website = ocr.urls.firstOrNull() val noteParts = residual.filter { line -> !skipLine.containsMatchIn(line) && !emailOrUrlOrPhone.containsMatchIn(line) && line.length > 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.take(3).joinToString(" · ").ifBlank { null }, ) } internal fun looksLikePersonName(line: String): Boolean { val t = line.replace(Regex("""[^\p{L}\s\-']"""), " ").trim() if (t.length !in 4..60) return false if (jobKeywords.containsMatchIn(t) || companyKeywords.containsMatchIn(t)) return false if (addressKeywords.containsMatchIn(t)) return false if (emailOrUrlOrPhone.containsMatchIn(line)) return false val parts = t.split(Regex("""\s+""")).filter { it.isNotBlank() } if (parts.size !in 2..4) return false // Au moins un token capitalisé / majuscules val caps = parts.count { it.first().isUpperCase() } return caps >= 2 } internal fun personScore(line: String): Int { val parts = line.replace(Regex("""[^\p{L}\s\-']"""), " ").trim().split(Regex("""\s+""")) var s = parts.size * 10 if (parts.any { it.length >= 2 && it == it.uppercase() && it.any { c -> c.isLetter() } }) s += 40 if (parts.size in 2..3) s += 15 return s } internal fun splitPersonName(line: String?): Triple<String?, String?, String?> { if (line.isNullOrBlank()) return Triple(null, null, null) val cleaned = line .replace('!', 'l') // Pau! → Paul (erreur OCR fréquente) .replace(Regex("""[^\p{L}\s\-']"""), " ") .replace(Regex("""\s+"""), " ") .trim() val parts = cleaned.split(' ').filter { it.isNotBlank() } if (parts.isEmpty()) return Triple(null, null, null) val lastCaps = parts.indexOfLast { it.length >= 2 && it == it.uppercase() && it.all { c -> !c.isLetter() || c.isUpperCase() } } fun titleWord(w: String) = w.lowercase().replaceFirstChar { it.titlecase() } return if (lastCaps > 0) { val last = titleWord(parts[lastCaps]) val first = parts.take(lastCaps).joinToString(" ") { titleWord(it) } // Conserve les prénoms composés sans forcer une casse bizarre si déjà mixtes ; // mais un token tout en capitales (« SONIA MARTIN ») est remis en Title Case. val firstKeep = parts.take(lastCaps).joinToString(" ") { w -> if (w.length >= 2 && w == w.uppercase()) titleWord(w) else w } Triple(firstKeep, last, "$firstKeep $last") } else if (parts.size >= 2) { val last = titleWord(parts.last()) val first = parts.dropLast(1).joinToString(" ") Triple(first, last, "$first $last") } else { Triple(null, null, cleaned) } } private fun looksLikeCompany(line: String): Boolean { if (line.length !in 2..50) 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.uppercase() || line.split(Regex("""\s+""")).size <= 3 } private fun companyScore(line: String): Int { var s = 0 if (companyKeywords.containsMatchIn(line)) s += 50 if (line == line.uppercase()) s += 20 if (line.contains("software", ignoreCase = true)) s += 30 return s + (40 - line.length).coerceAtLeast(0) } private fun cleanCompany(line: String?): String? { if (line.isNullOrBlank()) return null return line .replace(Regex("""[&—–\-_|]+"""), " ") .replace(Regex("""\s+"""), " ") .trim() .ifBlank { null } } private fun cleanJob(line: String?): String? = line?.replace(Regex("""\s+"""), " ")?.trim()?.ifBlank { null } private fun cleanAddress(line: String?): String? = line?.replace(Regex("""\s+"""), " ")?.trim()?.ifBlank { null } /** * Chemin spatial : exploite boîtes/hauteurs/blocs (ResultIterator) au lieu du * texte plat. Mêmes regex de classification que le chemin legacy, enrichies * des signaux de mise en page (taille de police, regroupement, libellés). */ private fun parseSpatial(ocr: OcrResult): ContactCard { val blocks = BlockGrouper.group(ocr.spatialLines) // « Hauteur typique » = médiane basse : robuste aux logos géants et aux // cartes à 2 lignes où la médiane vraie serait tirée vers le haut. val typicalHeight = ocr.spatialLines.map { it.box.height }.sorted() .let { it[(it.size - 1) / 2] } fun isData(t: String) = emailOrUrlOrPhone.containsMatchIn(t) fun isLabel(t: String) = skipLine.containsMatchIn(t) // Libellé seul (Tél. : / Mobile…) → la valeur est sur la ligne suivante du bloc. val phones = ocr.phones.toMutableList() for (block in blocks) { block.lines.zipWithNext { label, value -> if (isLabel(label.text) && !isData(label.text)) { OcrPostProcessor.process(value.text).phones.forEach { p -> if (p !in phones) phones += p } } } } // Adresse : le bloc le plus « adresse », jointure multi-lignes. val addressBlock = blocks .filter { b -> b.lines.any { addressKeywords.containsMatchIn(it.text) && !isData(it.text) } } .maxByOrNull { b -> b.lines.count { addressKeywords.containsMatchIn(it.text) && !isData(it.text) } } val address = addressBlock?.lines ?.filter { !isData(it.text) && !isLabel(it.text) } ?.joinToString(", ") { it.text.replace(Regex("""\s+"""), " ").trim() } ?.ifBlank { null } val addressLines = addressBlock?.lines?.toSet().orEmpty() val rest = ocr.spatialLines.filter { it !in addressLines && !isData(it.text) && !isLabel(it.text) } val jobLine = rest.firstOrNull { jobKeywords.containsMatchIn(it.text) } val emailDomain = ocr.emails.firstOrNull() ?.substringAfter('@', "")?.substringBefore('.')?.takeIf { it.length >= 3 } // NB : « tout en majuscules » n'est PAS un critère société — les noms de // personnes sont très souvent en capitales sur les cartes françaises. fun looksCompanySpatial(l: OcrLine) = companyKeywords.containsMatchIn(l.text) || (emailDomain != null && l.text.lowercase().replace(" ", "").contains(emailDomain)) val nameLine = rest .filter { it !== jobLine && !looksCompanySpatial(it) && personLikeSpatial(it, typicalHeight) } .maxByOrNull { personScore(it.text) + it.box.height * 40 / typicalHeight } val (firstName, lastName, fullName) = splitPersonName(nameLine?.text) val companyLine = rest .filter { it !== jobLine && it !== nameLine && looksCompanySpatial(it) } .maxByOrNull { it.box.height * 1000 - it.box.top } // grand et en haut de carte val company = cleanCompany(companyLine?.text) ?: ocr.emails.firstOrNull()?.let { companyFromEmailDomain(it) } val noteParts = rest .filter { it !== jobLine && it !== nameLine && it !== companyLine && it.text.length > 2 } val douteux = buildSet { if (nameLine != null && nameLine.confidence < SEUIL_CONFIANCE) add("fullName") if (jobLine != null && jobLine.confidence < SEUIL_CONFIANCE) add("jobTitle") if (companyLine != null && companyLine.confidence < SEUIL_CONFIANCE) add("company") if (addressBlock != null && addressBlock.avgConfidence() < SEUIL_CONFIANCE) add("address") } return ContactCard( fullName = fullName, firstName = firstName, lastName = lastName, company = company, jobTitle = cleanJob(jobLine?.text), phones = phones, emails = ocr.emails, website = ocr.urls.firstOrNull(), address = address, note = noteParts.take(3).joinToString(" · ") { it.text }.ifBlank { null }, champsDouteux = douteux, ) } /** Comme [looksLikePersonName], avec dispense de majuscules pour les gros caractères. */ private fun personLikeSpatial(line: OcrLine, typicalHeight: Int): Boolean { val t = line.text.replace(Regex("""[^\p{L}\s\-']"""), " ").trim() if (t.length !in 4..60) return false if (jobKeywords.containsMatchIn(t) || companyKeywords.containsMatchIn(t)) return false if (addressKeywords.containsMatchIn(t)) return false val parts = t.split(Regex("""\s+""")).filter { it.isNotBlank() } if (parts.size !in 2..4) return false val caps = parts.count { it.first().isUpperCase() } return caps >= 2 || line.box.height >= 1.5 * typicalHeight } internal fun companyFromEmailDomain(email: String): String? { val domain = email.substringAfter('@', "").substringBefore('.') if (domain.length < 3) return null // dimosoftware → Dimo Software (best-effort) val spaced = domain .replace(Regex("""(?<=[a-z])(?=[A-Z])"""), " ") .replace("software", " Software", ignoreCase = true) .replace("solutions", " Solutions", ignoreCase = true) .trim() return spaced.replaceFirstChar { it.titlecase() }.ifBlank { null } } }
GitRust