181 lines
5.8 KiB
Python
181 lines
5.8 KiB
Python
"""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.]+)?)\^\]")
|
||
|
|
|
||
|
|
_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"(?<!\\)\|", interne)
|
||
|
|
return [c.replace(r"\|", "|").strip() for c in cellules]
|
||
|
|
|
||
|
|
|
||
|
|
def nettoyer(texte: str, *, garder_marqueurs: bool = False) -> 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:
|
||
|
|
resultat = MOTIF_MARQUEUR.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
|