"""Briques de lecture du markdown du corpus. Trois besoins récurrents : découper les tableaux, retirer le balisage d'emphase sans perdre le texte, et récolter les marqueurs de citation `[^dim07-24^]` qui relient un fait de chapitre à sa source dans un rapport de dimension. """ from __future__ import annotations import re from dataclasses import dataclass, field # Marqueurs du corpus : [^dim07-24^], [^phase5-3.1^], [^insight-5^], [^481^] MOTIF_MARQUEUR = re.compile(r"\[\^([a-zA-Z0-9]+(?:-[0-9.]+)?)\^\]") # Le marqueur avec l'espace qui le précède, pour un retrait sans cicatrice. _MARQUEUR_ET_ESPACE = re.compile(r"[ \t]*(?:\[\^[a-zA-Z0-9]+(?:-[0-9.]+)?\^\])+") _EMPHASE = re.compile(r"\*{1,3}(.+?)\*{1,3}", re.DOTALL) _LIEN_MD = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") _CODE = re.compile(r"`([^`]+)`") _SEPARATEUR_TABLEAU = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$") @dataclass(slots=True) class Tableau: """Un tableau markdown, avec le titre de section qui le précède.""" entetes: list[str] lignes: list[dict[str, str]] section: str = "" ligne_debut: int = 0 def __len__(self) -> int: return len(self.lignes) def colonne(self, prefixe: str) -> str | None: """Retrouve un nom d'en-tête par son début, insensible à la casse.""" prefixe = prefixe.lower() for entete in self.entetes: if entete.lower().startswith(prefixe): return entete return None @dataclass(slots=True) class Marqueur: """Une référence `[^dimXX-N^]` rencontrée dans un texte.""" brut: str # « dim07-24 » dimension: str # « dim07 », « phase5 », « insight », ou « ref » numero: str # « 24 », « 3.1 » @property def cle(self) -> str: return f"{self.dimension}-{self.numero}" if self.numero else self.dimension def extraire_tableaux(markdown: str) -> list[Tableau]: """Découpe tous les tableaux d'un document markdown. Une ligne d'en-tête suivie d'une ligne de séparation ouvre un tableau ; la première ligne qui ne commence pas par `|` le referme. """ lignes = markdown.splitlines() tableaux: list[Tableau] = [] section = "" i = 0 while i < len(lignes): ligne = lignes[i] if ligne.lstrip().startswith("#"): section = ligne.lstrip("#").strip() i += 1 continue est_ligne_tableau = ligne.strip().startswith("|") separateur_suivant = i + 1 < len(lignes) and _SEPARATEUR_TABLEAU.match(lignes[i + 1]) if est_ligne_tableau and separateur_suivant: entetes = _decouper_ligne(ligne) corps: list[dict[str, str]] = [] j = i + 2 while j < len(lignes) and lignes[j].strip().startswith("|"): cellules = _decouper_ligne(lignes[j]) if any(c for c in cellules): # Tolère les lignes plus courtes ou plus longues que l'en-tête. cellules += [""] * (len(entetes) - len(cellules)) corps.append(dict(zip(entetes, cellules[: len(entetes)], strict=True))) j += 1 tableaux.append( Tableau(entetes=entetes, lignes=corps, section=section, ligne_debut=i + 1) ) i = j continue i += 1 return tableaux def _decouper_ligne(ligne: str) -> list[str]: """Découpe une ligne de tableau en cellules, en respectant les `\\|` échappés.""" interne = ligne.strip() if interne.startswith("|"): interne = interne[1:] if interne.endswith("|"): interne = interne[:-1] cellules = re.split(r"(? str: """Retire le balisage d'emphase, les liens et les marqueurs de citation.""" resultat = _CODE.sub(r"\1", texte) resultat = _LIEN_MD.sub(r"\1", resultat) resultat = _EMPHASE.sub(r"\1", resultat) if not garder_marqueurs: # L'espace qui précède le marqueur est consommée avec lui, sinon le # retrait laisse « … dans les DROM . ». On ne touche surtout pas aux # autres espaces : la typographie française en veut une avant « ; », # « : », « ! », « ? » et à l'intérieur des guillemets « … ». resultat = _MARQUEUR_ET_ESPACE.sub("", resultat) return " ".join(resultat.split()).strip(" ;,") def extraire_marqueurs(texte: str) -> list[Marqueur]: """Récolte les marqueurs de citation d'un fragment, sans doublon.""" marqueurs: list[Marqueur] = [] vus: set[str] = set() for brut in MOTIF_MARQUEUR.findall(texte): if brut in vus: continue vus.add(brut) if "-" in brut: dimension, numero = brut.split("-", 1) elif brut.isdigit(): # Marqueur local à un fichier de dimension : [^24^] dimension, numero = "", brut else: dimension, numero = brut, "" marqueurs.append(Marqueur(brut=brut, dimension=dimension, numero=numero)) return marqueurs @dataclass(slots=True) class Section: """Un bloc de texte délimité par un titre markdown.""" niveau: int titre: str corps: str ligne_debut: int sous_sections: list[Section] = field(default_factory=list) def decouper_sections(markdown: str, *, niveau_max: int = 3) -> list[Section]: """Découpe un document en sections à plat, jusqu'au niveau de titre demandé.""" lignes = markdown.splitlines() sections: list[Section] = [] courante: Section | None = None tampon: list[str] = [] for numero, ligne in enumerate(lignes, start=1): entete = re.match(r"^(#{1,6})\s+(.*)$", ligne) if entete and len(entete.group(1)) <= niveau_max: if courante: courante.corps = "\n".join(tampon).strip() sections.append(courante) courante = Section( niveau=len(entete.group(1)), titre=entete.group(2).strip(), corps="", ligne_debut=numero, ) tampon = [] else: tampon.append(ligne) if courante: courante.corps = "\n".join(tampon).strip() sections.append(courante) return sections