#!/usr/bin/env python3 r"""Traçabilité du glossaire (annexe A). 1. Extrait de tous les chapitres rédigés (`manuscript/chapters/chNN.tex`, sans `\stubchapter`) les termes marqués `\motcle{…}`, `\terme{fr}{en}`, les titres d'environnements `definition` et les sigles définis entre parenthèses ; les normalise (macros retirées, initiale en minuscule sauf sigle ou nom propre) et écrit `verification/glossaire-termes.csv` (terme, type, chapitres d'occurrence, nombre d'occurrences dans la prose de tous les chapitres). 2. Lit `manuscript/appendices/glossaire.tex` et écrit le bilan `verification/chapters/appA.md` (nombre d'entrées, termes sans équivalent anglais établi, chapitres couverts par les renvois, termes extraits absents du glossaire). Usage : python3 tools/glossaire_extraction.py (depuis la racine du dépôt ; aucune dépendance externe) """ from __future__ import annotations import csv, datetime, json, pathlib, re, unicodedata from collections import defaultdict ROOT = pathlib.Path(__file__).resolve().parents[1] CHAP = ROOT / "manuscript/chapters" GLOS = ROOT / "manuscript/appendices/glossaire.tex" OUT_CSV = ROOT / "verification/glossaire-termes.csv" OUT_MD = ROOT / "verification/chapters/appA.md" TITRES = json.load(open(CHAP / "TITRES.json", encoding="utf-8")) RE_MOTCLE = re.compile(r"\\motcle\{((?:[^{}]|\{[^{}]*\})*)\}") RE_TERME = re.compile(r"\\terme\{((?:[^{}]|\{[^{}]*\})*)\}\{((?:[^{}]|\{[^{}]*\})*)\}") RE_DEF = re.compile(r"\\begin\{definition\}\{((?:[^{}]|\{[^{}]*\})*)\}") # sigle défini : « revenu net d'exploitation (RNE, \emph{net operating income}) » ou « (RNE) » ; 2 à 6 capitales RE_SIGLE = re.compile(r"\(([A-ZÉ][A-ZÉ&/]{1,6})(?:[,;)]|\s*\\emph)") SIGLES_EXCLUS = {"II", "III", "IV", "VI", "VII", "VIII", "IX", "XI", "XII", "XX", "HC", "OLS", "ISO", "PDF", "CSV", "URL", "USD", "CAD", "API", "SQL", "ID", "QC", "ON", "BC", "AB", "MB", "SK", "NB", "NS", "PE", "NL", "YT", "NT", "NU", "US", "UK", "EU", "SE", "NE", "SO", "NO", "ES", "EN", "FR", "MA", "AR", "ARMA", "ADF", "KPSS", "AIC", "BIC", "MSE", "RMSE", "MAE", "MAPE", "COD", "PRD", "PRB", "IQR", "TM", "RLRQ", "LRC", "LQ"} PROPRES = ("Québec", "Montréal", "Gatineau", "Ottawa", "Canada", "Laval", "Monte", "Bayes", "Gauss", "Markov", "Moran", "Geary", "Getis", "Pareto", "Nash", "Ricardo", "Alonso", "Muth", "Mills", "Rosen", "Lancaster", "Hotelling", "Duan", "Halvorsen", "Kennedy", "Case", "Shiller", "Bailey", "Tobit", "Heckman", "Hausman", "Durbin", "Watson", "Shapley", "LASSO", "SHAP", "Ellwood", "Akerson", "Inwood", "Hoskold", "Gordon", "Fisher", "Laspeyres", "Paasche", "Törnqvist", "Marshall", "Swift", "RSMeans", "Excel", "Python", "R", "Code", "Loi", "Règlement", "Tribunal", "Ordre", "Régie", "Société", "Statistique", "Banque", "Teranet", "Centris", "JLR", "OEAQ", "SCHL", "TAL", "IVS", "CUSPAP", "USPAP", "IAAO", "RICS", "AIC", "BSIF", "SIG", "ArcGIS", "QGIS", "PostGIS", "OpenStreetMap", "Geocoder", "Lidar", "LiDAR") def strip_tex(s: str) -> str: s = re.sub(r"\\(emph|textit|textbf|texttt|text|mbox)\{([^{}]*)\}", r"\2", s) s = re.sub(r"\\(?:cref|Cref|ref|label|index|parencite|textcite|cite)\{[^{}]*\}", "", s) s = re.sub(r"\\[a-zA-Z]+\*?(\[[^\]]*\])?", "", s) s = s.replace("~", " ").replace("{", " ").replace("}", " ").replace("$", "") s = re.sub(r"\s+", " ", s).strip(" .;:") return s def normalise(s: str) -> str: s = strip_tex(s) if not s: return s first = s.split()[0] if first.isupper() and len(first) <= 6: # sigle return s w = re.split(r"[^\wÀ-ÿ]", first)[0] if w in PROPRES or w.rstrip("s") in PROPRES: # nom propre return s return s[0].lower() + s[1:] def sort_key(s: str) -> str: s = unicodedata.normalize("NFD", s) s = "".join(c for c in s if unicodedata.category(c) != "Mn") return s.lower().replace("’", "'").replace("œ", "oe") def chapters() -> dict[str, str]: res = {} for f in sorted(CHAP.glob("ch[0-9][0-9].tex")): txt = f.read_text(encoding="utf-8") if "\\stubchapter" in txt: continue res[f.stem[2:]] = txt return res def main() -> None: chs = chapters() plain = {n: strip_tex(re.sub(r"%.*", "", t)) for n, t in chs.items()} occ = defaultdict(lambda: {"type": set(), "chapitres": set()}) for n, txt in chs.items(): txt = re.sub(r"(?m)%.*$", "", txt) for m in RE_MOTCLE.finditer(txt): t = normalise(m.group(1)) if t: occ[t]["type"].add("motcle"); occ[t]["chapitres"].add(n) for m in RE_TERME.finditer(txt): t = normalise(m.group(1)) if t: occ[t]["type"].add("terme"); occ[t]["chapitres"].add(n); occ[t].setdefault("en", strip_tex(m.group(2))) for m in RE_DEF.finditer(txt): t = normalise(m.group(1)) if t: occ[t]["type"].add("definition"); occ[t]["chapitres"].add(n) for m in RE_SIGLE.finditer(txt): s = m.group(1) if s in SIGLES_EXCLUS or len(s) < 2: continue occ[s]["type"].add("sigle"); occ[s]["chapitres"].add(n) rows = [] for t, info in occ.items(): pat = re.compile(r"(? {OUT_CSV.relative_to(ROOT)}") # ---- bilan du glossaire rédigé ------------------------------------------------------------ if not GLOS.exists() or "\\stubchapter" in GLOS.read_text(encoding="utf-8"): print("glossaire.tex : stub, pas de bilan"); return g = GLOS.read_text(encoding="utf-8") ARG = r"\{((?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*)\}" # argument avec deux niveaux d'accolades imbriquées entries = re.findall(r"\\gloentree" + ARG * 4, g) sans_en = [strip_tex(e[0]) for e in entries if strip_tex(e[1]) in ("", "—", "---", "--")] refs = defaultdict(int) for e in entries: for m in re.finditer(r"ch:(\d\d)", e[3]): refs[m.group(1)] += 1 non_couverts = sorted(set(f"{int(k):02d}" for k in TITRES) - set(refs)) labels = [sort_key(strip_tex(e[0])) for e in entries] desordre = [entries[i][0] for i in range(1, len(labels)) if labels[i] < labels[i - 1]] gl_plain = sort_key(strip_tex(g)) STOP = {"dans", "pour", "avec", "sans", "sous", "entre", "comme", "cette", "leur", "leurs", "selon", "autre", "autres", "plus", "très", "tres", "moins", "d'un", "d'une", "l'", "art"} def couvert(terme: str) -> bool: k = sort_key(terme) if k in gl_plain: return True mots = [w for w in re.findall(r"[a-z0-9]+", k) if len(w) >= 4 and w not in STOP] return bool(mots) and all((w in gl_plain) or (w.rstrip("sx") in gl_plain) for w in mots) absents = [r["terme"] for r in rows if r["type"] != "sigle" and not couvert(r["terme"])] absents_sigles = [r["terme"] for r in rows if r["type"] == "sigle" and not re.search(r"(? {OUT_MD.relative_to(ROOT)} ({len(entries)} entrées, {len(sans_en)} sans équivalent EN, {len(refs)} chapitres renvoyés)") if __name__ == "__main__": main()