"""Utilitaires de normalisation et de traitement du texte. Ce module centralise les transformations de texte partagées par plusieurs couches du pipeline ``pronote-sync`` (sources, synchronisation) afin que les modules de logique de domaine ne dépendent pas d'adaptateurs concrets. """ from __future__ import annotations import re import unicodedata from bs4 import BeautifulSoup __all__ = ["normalize_subject", "sanitize_plaintext"] def normalize_subject(subject: str) -> str: """Normalise une matière pour le matching déterministe. Applique la normalisation Unicode NFKC, unifie les espaces (y compris tabulations et espaces insécables), supprime la ponctuation et met la chaîne en minuscules. Deux représentations visuellement identiques d'une même matière produisent ainsi la même forme normalisée. :param subject: La matière brute. :return: La forme normalisée (NFKC, espaces unifiés, sans ponctuation, minuscule). :rtype: str """ normalized = unicodedata.normalize("NFKC", subject) normalized = re.sub(r"\s+", " ", normalized).strip() normalized = re.sub(r"[^\w\s]", "", normalized) normalized = re.sub(r"\s+", " ", normalized).strip() return normalized.lower() # Pattern des caractères de contrôle ASCII non imprimables (à l'exception # des tabulations ``\\t``, des sauts de ligne ``\\n`` et des retours chariot ``\\r``). _CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]") def sanitize_plaintext(text: str) -> str: """Prépare un texte pour le corps de message XMPP en texte brut. Supprime les balises HTML (via ``BeautifulSoup`` avec le parseur ``html.parser``) puis les caractères de contrôle ASCII non imprimables, à l'exception des tabulations (``\\t``), des sauts de ligne (``\\n``) et des retours chariot (``\\r``). Les caractères Unicode au-delà de ``\\x1f``, notamment les emojis, sont conservés. La transformation est idempotente : appliquée deux fois, elle produit le même résultat qu'appliquée une seule fois. Une chaîne vide donne une chaîne vide. :param text: Le texte brut ou HTML à assainir. :return: Le texte assaini, sans balises HTML ni caractères de contrôle. :rtype: str """ # Étape 1 : suppression des balises HTML. plain = BeautifulSoup(text, "html.parser").get_text() # Étape 2 : suppression des caractères de contrôle. return _CONTROL_CHARS_RE.sub("", plain)