OcrPostProcessor.swift 94 lignes · 3901 octets
import Foundation

enum OcrPostProcessor {
    private static let emailRegex = KotlinRegex(
        #"[\w.+-]+@[\w.-]+\.\w{2,}"#,
        options: [.caseInsensitive]
    )
    private static let urlRegex = KotlinRegex(
        #"(?:https?://[\w./?#&=%+-]+)|(?:www\.[\w.-]+\.\w{2,}(?:/[\w./?#&=%+-]*)?)"#,
        options: [.caseInsensitive]
    )
    private static let phoneRegex = KotlinRegex(#"(?:\+?\d[\d\s.\-()]{6,}\d)"#)
    private static let oBetweenDigits = KotlinRegex(#"(?<=\d)O(?=\d)"#)
    private static let oBeforeDigit = KotlinRegex(#"\bO(?=\d)"#)

    static func process(_ rawText: String) -> OcrResult {
        let lines = rawText.kotlinLines()
            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
            .filter { !$0.isEmpty }
        let joined = lines.joined(separator: "\n")
        // Line-by-line extraction: avoids absorbing the postal code or street
        // number of a neighbouring line (\s also matches \n in phoneRegex).
        let phones = lines
            // Fix O/0 only inside numeric sequences (frequent OCR errors).
            .map { oBeforeDigit.replace(oBetweenDigits.replace($0, with: "0"), with: "0") }
            .flatMap { phoneRegex.findAll($0) }
            .map { normalizePhone($0) }
            .filter { isPlausiblePhone($0) }
            .distinctPreservingOrder()
        let emails = emailRegex.findAll(joined)
            .map { $0.lowercased() }
            .distinctPreservingOrder()
        let urls = urlRegex.findAll(joined)
            .map { normalizeUrl($0) }
            .distinctPreservingOrder()
        return OcrResult(
            rawText: joined,
            lines: lines,
            phones: phones,
            emails: emails,
            urls: urls
        )
    }

    static func enrich(_ base: OcrResult, rawText: String) -> OcrResult {
        let extra = process(rawText)
        return OcrResult(
            rawText: extra.rawText.isBlank ? base.rawText : extra.rawText,
            lines: extra.lines.isEmpty ? base.lines : extra.lines,
            phones: (base.phones + extra.phones).distinctPreservingOrder(),
            emails: (base.emails + extra.emails).distinctPreservingOrder(),
            urls: (base.urls + extra.urls).distinctPreservingOrder(),
            confidence: base.confidence,
            spatialLines: base.spatialLines
        )
    }

    static func normalizePhone(_ raw: String) -> String {
        var s = String(String.UnicodeScalarView(raw.unicodeScalars.filter {
            CharacterSet.decimalDigits.contains($0) || $0 == "+"
        }))
        // +33(0)6... → +336...
        if s.hasPrefix("+33") && s.count > 4 {
            let idx = s.index(s.startIndex, offsetBy: 3)
            if s[idx] == "0" {
                s = "+33" + s[s.index(after: idx)...]
            }
        }
        if s.hasPrefix("0033") {
            var rest = String(s.dropFirst(4))
            if rest.hasPrefix("0") { rest = String(rest.dropFirst()) }
            s = "+33" + rest
        }
        return s
    }

    private static func isPlausiblePhone(_ phone: String) -> Bool {
        let digits = phone.unicodeScalars.filter { CharacterSet.decimalDigits.contains($0) }
        // With an international prefix: 11-15 digits. Without: strict FR national
        // format (10 digits, leading 0) to reject address+phone merges.
        if phone.hasPrefix("+") {
            return (11...15).contains(digits.count)
        }
        return digits.count == 10 && digits.first == "0"
    }

    private static func normalizeUrl(_ raw: String) -> String {
        var t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        while let last = t.last, last == "." || last == "," || last == ";" { t.removeLast() }
        let lower = t.lowercased()
        if lower.hasPrefix("http://") || lower.hasPrefix("https://") { return t }
        return "https://" + t
    }
}