SPB Git forge

spb/uqo-imm1003

Public
7commits 1branches 0releases
189.5 MBsize
maindefault branch
6 h agolast push
JavaScript 68% CSS 32%
57.7 KB · 1,420 lines python
Raw Blame History
1r"""2latex2html.py — convertisseur LaTeX → HTML pour le dialecte « uqo-notes.sty ».34Couverture : sectionnement, boîtes tcolorbox (definition/formule/exemple/exercice/5solution/attention/terrain/remarque/quebec/aretenir/objectifs/fichesynthese),6listes, tabular/tabularx (booktabs), figures/tables numérotées, équations7(KaTeX en différé), TikZ (rendu SVG en différé), notes marginales (\repere,8\margterme, \margdate), \motcle, \dollars/\num (siunitx FR), citations biblatex,9renvois \ref/\eqref.1011Les formules et figures sont remplacées par des jetons @@M{n}@@ / @@TIKZ{n}@@ et12les renvois par @@REF{label}@@ ; le générateur de site les résout ensuite.13"""14from __future__ import annotations1516import html17import re18import sys19import unicodedata20from dataclasses import dataclass, field212223# =============================================================================24# Utilitaires de découpage25# =============================================================================2627def find_matching_brace(s: str, i: int) -> int:28    """s[i] == '{' → indice de l'accolade fermante correspondante."""29    assert s[i] == "{", (i, s[i:i + 20])30    depth = 031    j = i32    n = len(s)33    while j < n:34        c = s[j]35        if c == "\\":36            j += 237            continue38        if c == "{":39            depth += 140        elif c == "}":41            depth -= 142            if depth == 0:43                return j44        j += 145    raise ValueError("Accolade non fermée près de : " + s[i:i + 60])464748def read_group(s: str, i: int) -> tuple[str, int]:49    """Lit un groupe {…} commençant à s[i] (espaces ignorés). Retourne (contenu, index après '}')."""50    n = len(s)51    while i < n and s[i] in " \t\n":52        i += 153    if i >= n or s[i] != "{":54        return "", i55    j = find_matching_brace(s, i)56    return s[i + 1:j], j + 1575859def read_opt(s: str, i: int) -> tuple[str | None, int]:60    """Lit un argument optionnel [...] (espaces ignorés, accolades respectées)."""61    n = len(s)62    k = i63    while k < n and s[k] in " \t":64        k += 165    if k >= n or s[k] != "[":66        return None, i67    depth = 068    j = k69    while j < n:70        c = s[j]71        if c == "\\":72            j += 273            continue74        if c == "{":75            depth += 176        elif c == "}":77            depth -= 178        elif c == "[" and depth == 0:79            pass80        elif c == "]" and depth == 0:81            return s[k + 1:j], j + 182        j += 183    return None, i848586def find_env_end(s: str, name: str, start: int) -> tuple[int, int]:87    """Trouve \\end{name} correspondant à un \\begin{name} déjà consommé.88    Retourne (début de \\end, fin après \\end{name})."""89    pat = re.compile(r"\\(begin|end)\{" + re.escape(name) + r"\}")90    depth = 191    pos = start92    while True:93        m = pat.search(s, pos)94        if not m:95            raise ValueError(f"\\end{{{name}}} introuvable")96        if m.group(1) == "begin":97            depth += 198        else:99            depth -= 1100            if depth == 0:101                return m.start(), m.end()102        pos = m.end()103104105def split_top_level(s: str, sep_re: str) -> list[str]:106    """Découpe s aux occurrences de sep_re situées hors accolades, hors math $…$107    et hors environnements imbriqués."""108    parts = []109    depth = 0110    env_depth = 0111    in_math = False112    buf_start = 0113    i = 0114    n = len(s)115    sep = re.compile(sep_re)116    while i < n:117        c = s[i]118        if c == "\\":119            if s.startswith("\\begin{", i):120                env_depth += 1121                i += 7122                continue123            if s.startswith("\\end{", i):124                env_depth -= 1125                i += 5126                continue127            if depth == 0 and env_depth == 0 and not in_math:128                m = sep.match(s, i)129                if m:130                    parts.append(s[buf_start:i])131                    i = m.end()132                    buf_start = i133                    continue134            i += 2135            continue136        if c == "$":137            in_math = not in_math138            i += 1139            continue140        if c == "{":141            depth += 1142        elif c == "}":143            depth -= 1144        elif depth == 0 and env_depth == 0 and not in_math:145            m = sep.match(s, i)146            if m:147                parts.append(s[buf_start:i])148                i = m.end()149                buf_start = i150                continue151        i += 1152    parts.append(s[buf_start:])153    return parts154155156def strip_comments(s: str) -> str:157    out = []158    i = 0159    n = len(s)160    while i < n:161        c = s[i]162        if c == "\\" and i + 1 < n:163            out.append(s[i:i + 2])164            i += 2165            continue166        if c == "%":167            # commentaire jusqu'à la fin de ligne (la fin de ligne est avalée comme en TeX)168            j = s.find("\n", i)169            if j == -1:170                break171            i = j + 1172            # avaler les espaces de début de ligne suivante173            while i < n and s[i] in " \t":174                i += 1175            continue176        out.append(c)177        i += 1178    return "".join(out)179180181def slugify(text: str) -> str:182    t = unicodedata.normalize("NFKD", text)183    t = "".join(ch for ch in t if not unicodedata.combining(ch))184    t = re.sub(r"[^a-zA-Z0-9]+", "-", t).strip("-").lower()185    return t or "x"186187188def label_to_id(label: str) -> str:189    return re.sub(r"[^a-zA-Z0-9_-]+", "-", label)190191192# =============================================================================193# Nombres (siunitx FR)194# =============================================================================195196NNBSP = "\u202f"  # espace fine insécable197NBSP = "\u00a0"198199200def group_digits(int_part: str, sep: str) -> str:201    int_part = int_part.lstrip("0") or "0" if int_part != "0" else "0"202    if len(int_part) < 5:  # group-minimum-digits=4 → 1234 reste 1234 ; 12345 → 12 345203        return int_part204    out = []205    while len(int_part) > 3:206        out.insert(0, int_part[-3:])207        int_part = int_part[:-3]208    out.insert(0, int_part)209    return sep.join(out)210211212def fmt_num(raw: str, math: bool = False) -> str:213    """\\num{1234567} → 1 234 567 ; \\num{10.764} → 10,764 (et variante KaTeX)."""214    s = raw.strip().replace("\\,", "").replace(" ", "").replace("~", "")215    sign = ""216    if s[:1] in "+-":217        sign, s = s[0], s[1:]218    s = s.replace(",", ".")219    if s.count(".") > 1:  # « 1.234.567 » improbable ; on retire tout sauf le dernier220        head, _, tail = s.rpartition(".")221        s = head.replace(".", "") + "." + tail222    if "." in s:223        ip, dp = s.split(".", 1)224    else:225        ip, dp = s, ""226    if not ip.isdigit():227        return raw  # pas un nombre : on rend tel quel228    sep = "\\," if math else NNBSP229    out = group_digits(ip, sep)230    if dp:231        out += ("{,}" if math else ",") + dp232    return sign + out233234235def fmt_dollars(raw: str, math: bool = False) -> str:236    n = fmt_num(raw, math)237    return n + ("\\,\\$" if math else NBSP + "$")238239240# =============================================================================241# Contexte de conversion242# =============================================================================243244@dataclass245class SearchEntry:246    id: str247    kind: str248    title: str249    text: str250251252@dataclass253class ChapterResult:254    key: str                 # ch01, annexe-formulaire…255    num: str                 # "1", "A"256    title_html: str257    title_text: str258    html: str259    toc: list = field(default_factory=list)      # (level, num, title_html, id)260    search: list = field(default_factory=list)   # SearchEntry261    counts: dict = field(default_factory=dict)262    objectives: list = field(default_factory=list)  # html des objectifs263    synth_html: str = ""264265266class Ctx:267    """Contexte partagé pour un cours (labels, math, tikz, bib, collectes)."""268269    def __init__(self, course_code: str, bib: dict | None = None):270        self.course_code = course_code271        self.labels: dict[str, dict] = {}272        self.math: list[tuple[str, str]] = []      # (mode, tex)273        self.tikz: list[str] = []274        self.warnings: list[str] = []275        self.bib = bib or {}276        self.cited: dict[str, set] = {}277        self.formulas: list[dict] = []278        self.definitions: list[dict] = []279        self.examples: list[dict] = []280        self.exercises: list[dict] = []281        self.figures: list[dict] = []282283    # --- math ---284    def add_math(self, mode: str, tex: str) -> str:285        self.math.append((mode, tex))286        return f"@@M{len(self.math) - 1}@@"287288    def add_tikz(self, tex: str) -> str:289        self.tikz.append(tex)290        return f"@@TIKZ{len(self.tikz) - 1}@@"291292    def warn(self, msg: str):293        self.warnings.append(msg)294295296class ChapterState:297    def __init__(self, ctx: Ctx, key: str, num: str, url: str):298        self.ctx = ctx299        self.key = key300        self.num = num            # "1".."14", "A", "B"301        self.url = url            # /seance/01/ ou /aide-memoire/302        self.sec = 0303        self.subsec = 0304        self.subsubsec = 0305        self.c = {"def": 0, "form": 0, "ex": 0, "exo": 0, "tab": 0, "fig": 0, "eq": 0}306        self.sidenote = 0307        self.toc: list = []308        self.search: list[SearchEntry] = []309        self.current_section_id = ""310        self.current_section_title = ""311        self.pending_label_target: str | None = None  # id du dernier titre pour \label312        self.in_objectifs = False313        self.obj_index = 0314        self.objectives: list[str] = []315        self.synth_html = ""316        self.title_html = ""317        self.title_text = ""318        self.para_counter = 0319        self.starred_section = False320        self.list_depth = 0321322    def next_id(self, prefix: str) -> str:323        self.para_counter += 1324        return f"{prefix}-{self.para_counter}"325326327# =============================================================================328# Conversion en ligne329# =============================================================================330331SIMPLE_MAP = {332    "textbf": ("<strong>", "</strong>"),333    "emph": ("<em>", "</em>"),334    "textit": ("<em>", "</em>"),335    "textsl": ("<em>", "</em>"),336    "texttt": ("<code>", "</code>"),337    "textsc": ('<span class="sc">', "</span>"),338    "underline": ("<u>", "</u>"),339    "uqotitle": ('<strong class="c-bleu">', "</strong>"),340    "uqoalert": ('<strong class="c-rouge">', "</strong>"),341    "uqogreen": ('<strong class="c-vert">', "</strong>"),342    "uqohighlight": ('<strong class="c-or">', "</strong>"),343    "textsuperscript": ("<sup>", "</sup>"),344    "textsubscript": ("<sub>", "</sub>"),345    "mbox": ("", ""),346    "hbox": ("", ""),347    "textnormal": ("", ""),348    "textrm": ("", ""),349    "textsf": ("", ""),350    "textup": ("", ""),351    "textmd": ("", ""),352    "MakeUppercase": ('<span class="upper">', "</span>"),353    "makecell": ("", ""),354    "thead": ("<strong>", "</strong>"),355}356357ZERO_ARG = {358    "oe": "œ", "OE": "Œ", "ae": "æ", "AE": "Æ", "ss": "ß",359    "ldots": "…", "dots": "…", "textellipsis": "…",360    "textbullet": "•", "checkmark": "✓", "textdegree": "°",361    "textonehalf": "½", "textonequarter": "¼", "textthreequarters": "¾",362    "textquoteright": "’", "textquoteleft": "‘", "textquotedblleft": "“",363    "textquotedblright": "”", "textendash": "–", "textemdash": "—",364    "textbackslash": "\\", "textasciitilde": "~", "textasciicircum": "^",365    "textgreater": "&gt;", "textless": "&lt;", "textbar": "|",366    "textregistered": "®", "texttrademark": "™", "copyright": "©", "textcopyright": "©",367    "euro": "€", "pounds": "£", "S": "§", "P": "¶", "dag": "†", "ddag": "‡",368    "ier": "<sup>er</sup>", "iere": "<sup>re</sup>", "ieme": "<sup>e</sup>",369    "iemes": "<sup>es</sup>", "no": "n<sup>o</sup>", "No": "N<sup>o</sup>",370    "og": "«" + NBSP, "fg": NBSP + "»",371    "quad": "&emsp;", "qquad": "&emsp;&emsp;", "enspace": "&ensp;",372    "newline": "<br>", "linebreak": "<br>",373    "noindent": "", "relax": "", "protect": "", "par": "</p><p>",374    "smallskip": "", "medskip": "", "bigskip": "", "hfill": "", "hfil": "", "strut": "",375    "centering": "", "raggedright": "", "raggedleft": "", "arraybackslash": "",376    "small": "", "footnotesize": "", "scriptsize": "", "tiny": "", "large": "",377    "Large": "", "LARGE": "", "huge": "", "Huge": "", "normalsize": "", "normalfont": "",378    "selectfont": "", "toprule": "", "midrule": "", "bottomrule": "", "hline": "",379    "tableofcontents": "", "cleardoublepage": "", "clearpage": "", "newpage": "",380    "frontmatter": "", "mainmatter": "", "backmatter": "", "appendix": "",381    "uqosep": '<hr class="sep">',382    "rightarrow": "→", "Rightarrow": "⇒", "leftarrow": "←", "leftrightarrow": "↔",383    "times": "×", "approx": "≈", "leq": "≤", "geq": "≥", "le": "≤", "ge": "≥", "neq": "≠",384    "pm": "±", "div": "÷", "infty": "∞", "cdot": "·",385    "textperiodcentered": "·", "textminus": "−",386    "indent": "", "vfill": "", "null": "", "ignorespaces": "", "unskip": "",387    "faIcon": "",  # icône FontAwesome : argument avalé plus bas388    "phantomsection": "", "listoffigures": "", "listoftables": "", "singlespacing": "", "onehalfspacing": "", "sffamily": "", "rmfamily": "",389    "textregistered": "®", "clearpage": "", "par": "</p><p>",390}391392ONE_ARG_DROP = {393    "index", "label_inline", "vspace", "hspace", "vspace*", "hspace*", "phantom", "hphantom",394    "vphantom", "pagestyle", "thispagestyle", "markright", "setcounter", "addtocounter",395    "stepcounter", "refstepcounter", "nocite", "faIcon", "pgfplotsset", "tcbset",396    "hyphenation", "input", "include", "bibliography", "printbibliography", "printindex",397    "addbibresource", "makeindex", "usepackage", "documentclass", "enlargethispage",398    "captionsetup", "rule",  # \rule{w}{h} : 2 args → géré ci-dessous399}400401TWO_ARG_DROP = {"renewcommand", "newcommand", "setlength", "addtolength", "markboth",402                "providecommand", "rule", "settowidth", "definecolor"}403404COLOR_CLASS = {405    "uqoBleu": "c-bleu", "uqoBleuClair": "c-bleu-clair", "uqoOr": "c-or", "uqoGris": "c-gris",406    "uqoGrisClair": "c-gris-clair", "uqoVert": "c-vert", "uqoRouge": "c-rouge",407    "uqoNavy": "c-navy", "uqoInk": "c-ink", "white": "c-white", "black": "c-ink",408    "red": "c-rouge", "blue": "c-bleu", "gray": "c-gris", "grey": "c-gris", "green": "c-vert",409}410411412def color_class(name: str) -> str:413    base = name.split("!")[0].strip()414    return COLOR_CLASS.get(base, "c-gris")415416417INLINE_RE = re.compile(r"\\([a-zA-Z@]+)\*?|\\(.)|(\$\$?)|(\{)|(\})|(~)|(---)|(--)|(\n)|([<>&\"])|(``)|('')|(\\\()")418419420class InlineConverter:421    def __init__(self, st: ChapterState):422        self.st = st423        self.ctx = st.ctx424425    # ------------------------------------------------------------------426    def convert(self, s: str) -> str:427        return self._run(s)428429    def _run(self, s: str) -> str:430        out: list[str] = []431        i = 0432        n = len(s)433        while i < n:434            m = INLINE_RE.search(s, i)435            if not m:436                out.append(html.escape(s[i:], quote=False).replace('"', "&quot;") if False else html.escape(s[i:], quote=False))437                break438            if m.start() > i:439                out.append(html.escape(s[i:m.start()], quote=False))440            i = m.end()441            if m.group(1):  # \commande442                name = m.group(1)443                piece, i = self.command(name, s, i, m)444                out.append(piece)445            elif m.group(2) is not None:  # \x caractère échappé446                c = m.group(2)447                if c == "\\":448                    # \\ → saut de ligne ; avaler [2pt]449                    opt, i2 = read_opt(s, i)450                    i = i2451                    out.append("<br>")452                elif c == ",":453                    out.append(NNBSP)454                elif c == ";":455                    out.append("&thinsp;")456                elif c == ":":457                    out.append("&thinsp;")458                elif c == "!":459                    out.append("")460                elif c == " ":461                    out.append(" ")462                elif c == "/":463                    out.append("")464                elif c == "-":465                    out.append("")466                elif c == "@":467                    out.append("")468                elif c == "'":469                    # accent aigu : \'e ou \'{e}470                    grp, i2 = self._accent_target(s, i)471                    i = i2472                    out.append(self._accent(grp, "\u0301"))473                elif c == "`":474                    grp, i2 = self._accent_target(s, i)475                    i = i2476                    out.append(self._accent(grp, "\u0300"))477                elif c == "^":478                    grp, i2 = self._accent_target(s, i)479                    i = i2480                    out.append(self._accent(grp, "\u0302"))481                elif c == '"':482                    grp, i2 = self._accent_target(s, i)483                    i = i2484                    out.append(self._accent(grp, "\u0308"))485                elif c in "$%&#_{}":486                    out.append(html.escape(c, quote=False))487                elif c == "[":488                    # \[ … \] math hors-texte489                    j = s.find("\\]", i)490                    if j == -1:491                        j = n492                    tex = s[i:j]493                    i = j + 2494                    out.append(self.display_math(tex, numbered=False))495                elif c == "(":496                    j = s.find("\\)", i)497                    if j == -1:498                        j = n499                    tex = s[i:j]500                    i = j + 2501                    out.append(self.inline_math(tex))502                elif c == "]" or c == ")":503                    out.append("")504                else:505                    out.append(html.escape(c, quote=False))506            elif m.group(3):  # $ ou $$507                dollars = m.group(3)508                if dollars == "$$":509                    j = s.find("$$", i)510                    if j == -1:511                        j = n512                    out.append(self.display_math(s[i:j], numbered=False))513                    i = j + 2514                else:515                    j = self._find_math_end(s, i)516                    out.append(self.inline_math(s[i:j]))517                    i = j + 1518            elif m.group(4):  # {519                j = find_matching_brace(s, m.start())520                inner = s[m.start() + 1:j]521                out.append(self._group(inner))522                i = j + 1523            elif m.group(5):  # } orphelin524                pass525            elif m.group(6):  # ~526                out.append(NBSP)527            elif m.group(7):528                out.append("—")529            elif m.group(8):530                out.append("–")531            elif m.group(9):  # \n → espace532                out.append(" ")533            elif m.group(10):534                out.append(html.escape(m.group(10), quote=False))535            elif m.group(11):536                out.append("“")537            elif m.group(12):538                out.append("”")539            elif m.group(13):540                j = s.find("\\)", i)541                if j == -1:542                    j = n543                out.append(self.inline_math(s[i:j]))544                i = j + 2545        return "".join(out)546547    # ------------------------------------------------------------------548    def _accent_target(self, s: str, i: int) -> tuple[str, int]:549        if i < len(s) and s[i] == "{":550            return read_group(s, i)551        if i < len(s):552            return s[i], i + 1553        return "", i554555    @staticmethod556    def _accent(base: str, comb: str) -> str:557        if not base:558            return ""559        return unicodedata.normalize("NFC", base[0] + comb) + base[1:]560561    @staticmethod562    def _find_math_end(s: str, i: int) -> int:563        n = len(s)564        j = i565        while j < n:566            if s[j] == "\\":567                j += 2568                continue569            if s[j] == "$":570                return j571            j += 1572        return n573574    def _group(self, inner: str) -> str:575        """Groupe {…} : gère les déclarations en tête (\\bfseries, \\itshape, \\color…)."""576        t = inner.lstrip()577        m = re.match(r"\\(bfseries|itshape|em|slshape|scshape|ttfamily|color|small|footnotesize|scriptsize|tiny|large|Large|LARGE|huge|Huge|normalsize|raggedright|centering|sffamily|rmfamily|mdseries|upshape)\b\*?", t)578        if m:579            name = m.group(1)580            rest = t[m.end():]581            if name == "color":582                col, k = read_group(rest, 0)583                return f'<span class="{color_class(col)}">{self._run(rest[k:])}</span>'584            if name in ("bfseries",):585                return f"<strong>{self._run(rest)}</strong>"586            if name in ("itshape", "em", "slshape"):587                return f"<em>{self._run(rest)}</em>"588            if name == "scshape":589                return f'<span class="sc">{self._run(rest)}</span>'590            if name == "ttfamily":591                return f"<code>{self._run(rest)}</code>"592            return self._run(rest)593        return self._run(inner)594595    # ------------------------------------------------------------------596    def inline_math(self, tex: str) -> str:597        tex = tex.strip()598        if not tex:599            return ""600        return self.ctx.add_math("inline", prep_math(tex))601602    def display_math(self, tex: str, numbered: bool, env: str = "", label: str | None = None) -> str:603        tex = tex.strip()604        # labels internes605        labels = re.findall(r"\\label\{([^}]*)\}", tex)606        tex = re.sub(r"\\label\{[^}]*\}", "", tex)607        tag = ""608        eid = ""609        if numbered:610            self.st.c["eq"] += 1611            num = f"{self.st.num}.{self.st.c['eq']}"612            tag = num613            eid = f"eq-{label_to_id(labels[0])}" if labels else f"eq-{self.st.num}-{self.st.c['eq']}"614            for lb in labels:615                self.ctx.labels[lb] = {"kind": "eq", "num": num, "url": f"{self.st.url}#{eid}"}616        elif labels:617            eid = f"eq-{label_to_id(labels[0])}"618            for lb in labels:619                self.ctx.labels[lb] = {"kind": "eq", "num": "", "url": f"{self.st.url}#{eid}"}620        body = prep_math(tex, env=env)621        if tag:622            body = body + f"\\tag{{{tag}}}"623        token = self.ctx.add_math("display", body)624        idattr = f' id="{eid}"' if eid else ""625        return f'<div class="eq"{idattr}>{token}</div>'626627    # ------------------------------------------------------------------628    def command(self, name: str, s: str, i: int, m) -> tuple[str, int]:629        st = self.st630        ctx = self.ctx631        if name in SIMPLE_MAP:632            arg, i = read_group(s, i)633            op, cl = SIMPLE_MAP[name]634            return op + self._run(arg) + cl, i635        if name in ZERO_ARG:636            val = ZERO_ARG[name]637            if name == "faIcon":638                _, i = read_group(s, i)639                return "", i640            # avaler un {} vide éventuel (\oe{}, \ier{})641            if i < len(s) and s[i] == "{" and i + 1 < len(s) and s[i + 1] == "}":642                i += 2643            elif name in ("oe", "OE", "ae", "AE", "ss", "ier", "iere", "ieme", "iemes", "no", "No", "og", "fg"):644                # sémantique TeX : l'espace qui suit un mot de contrôle est avalée (main-d'\oe uvre → main-d'œuvre)645                while i < len(s) and s[i] in " \t":646                    i += 1647                if i < len(s) and s[i] == "\n" and not (i + 1 < len(s) and s[i + 1:].lstrip(" \t").startswith("\n")):648                    i += 1649                    while i < len(s) and s[i] in " \t":650                        i += 1651            return val, i652        if name == "textcolor":653            col, i = read_group(s, i)654            arg, i = read_group(s, i)655            if col.strip() == "white":656                return self._run(arg), i657            return f'<span class="{color_class(col)}">{self._run(arg)}</span>', i658        if name == "color":659            col, i = read_group(s, i)660            # déclaration : s'applique à la suite (déjà géré dans _group) ; ici on ignore661            return "", i662        if name in ("bfseries", "itshape", "em", "scshape", "ttfamily", "sffamily", "rmfamily",663                    "mdseries", "upshape", "slshape"):664            return "", i665        if name == "motcle":666            arg, i = read_group(s, i)667            inner = self._run(arg)668            plain = plain_text(inner)669            return f'<span class="motcle" data-term="{html.escape(plain, quote=True)}">{inner}</span>', i670        if name == "dollars":671            arg, i = read_group(s, i)672            return f'<span class="num">{fmt_dollars(arg)}</span>', i673        if name == "num":674            arg, i = read_group(s, i)675            return f'<span class="num">{fmt_num(arg)}</span>', i676        if name == "SI":677            v, i = read_group(s, i)678            u, i = read_group(s, i)679            return f'<span class="num">{fmt_num(v)}{NBSP}{self._run(u)}</span>', i680        if name == "up":681            arg, i = read_group(s, i)682            return f"<sup>{self._run(arg)}</sup>", i683        if name == "repere":684            arg, i = read_group(s, i)685            return self.sidenote(self._run(arg), kind="repere"), i686        if name == "margterme":687            t, i = read_group(s, i)688            d, i = read_group(s, i)689            return self.sidenote(f'<strong class="sn-term">{self._run(t)}</strong> {self._run(d)}', kind="terme"), i690        if name == "margdate":691            y, i = read_group(s, i)692            d, i = read_group(s, i)693            return self.sidenote(f'<strong class="sn-date">{self._run(y)}</strong> {self._run(d)}', kind="date"), i694        if name == "footnote":695            arg, i = read_group(s, i)696            return self.sidenote(self._run(arg), kind="note", numbered=True), i697        if name in ("parencite", "autocite", "textcite", "cite", "citep", "citet", "citeauthor", "citeyear"):698            opt1, i = read_opt(s, i)699            opt2, i = read_opt(s, i)700            keys, i = read_group(s, i)701            return self.citation(name, keys, opt1, opt2), i702        if name in ("ref", "eqref", "autoref", "nameref", "pageref", "cref", "Cref"):703            key, i = read_group(s, i)704            key = key.strip()705            if name == "eqref":706                return f"(@@REF{{{key}}}@@)", i707            if name == "pageref":708                return "", i709            return f"@@REF{{{key}}}@@", i710        if name == "label":711            key, i = read_group(s, i)712            key = key.strip()713            target = st.pending_label_target or st.current_section_id714            if target:715                ctx.labels[key] = {"kind": "sec", "num": st.current_section_num(), "url": f"{st.url}#{target}"}716            return "", i717        if name == "href":718            url, i = read_group(s, i)719            txt, i = read_group(s, i)720            return f'<a href="{html.escape(url.strip(), quote=True)}" target="_blank" rel="noopener">{self._run(txt)}</a>', i721        if name == "url":722            url, i = read_group(s, i)723            u = url.strip()724            return f'<a href="{html.escape(u, quote=True)}" target="_blank" rel="noopener">{html.escape(u)}</a>', i725        if name == "includegraphics":726            _, i = read_opt(s, i)727            path, i = read_group(s, i)728            path = path.strip()729            if not hasattr(ctx, "images"):730                ctx.images = []731            ctx.images.append(path)732            return f"@@IMG{len(ctx.images) - 1}@@", i733        if name == "addcontentsline":734            _, i = read_group(s, i)735            _, i = read_group(s, i)736            _, i = read_group(s, i)737            return "", i738        if name == "multicolumn":  # hors table (ne devrait pas arriver)739            _, i = read_group(s, i)740            _, i = read_group(s, i)741            arg, i = read_group(s, i)742            return self._run(arg), i743        if name == "rowcolor" or name == "cellcolor" or name == "arrayrulecolor":744            _, i = read_opt(s, i)745            _, i = read_group(s, i)746            return "", i747        if name in TWO_ARG_DROP:748            _, i = read_group(s, i)749            _, i = read_opt(s, i)750            _, i = read_group(s, i)751            return "", i752        if name in ONE_ARG_DROP:753            _, i = read_opt(s, i)754            _, i = read_group(s, i)755            return "", i756        if name in ("item",):757            # \item hors liste (ne devrait pas arriver)758            _, i = read_opt(s, i)759            return "<br>• ", i760        if name in ("caption", "captionof"):761            _, i = read_opt(s, i)762            arg, i = read_group(s, i)763            return f'<span class="caption-inline">{self._run(arg)}</span>', i764        if name == "paragraph":765            arg, i = read_group(s, i)766            return f'<span class="para-title">{self._run(arg)}</span> ', i767        if name in ("section", "subsection", "subsubsection", "chapter"):768            # sectionnement en ligne (dans une cellule ?) : on garde le texte en gras769            _, i = read_opt(s, i)770            arg, i = read_group(s, i)771            return f"<strong>{self._run(arg)}</strong>", i772        if name == "verb":773            # \verb|...|774            if i < len(s):775                delim = s[i]776                j = s.find(delim, i + 1)777                if j != -1:778                    return f"<code>{html.escape(s[i + 1:j])}</code>", j + 1779            return "", i780        if name == "textsuperscript":781            arg, i = read_group(s, i)782            return f"<sup>{self._run(arg)}</sup>", i783        if name in ("mathrm", "mathbf", "text"):784            arg, i = read_group(s, i)785            return self._run(arg), i786        if name in ("frac", "tfrac", "dfrac"):787            a, i = read_group(s, i)788            b, i = read_group(s, i)789            return f"{self._run(a)}/{self._run(b)}", i790        if name == "enteterang":791            arg, i = read_group(s, i)792            return f"<strong>{self._run(arg)}</strong>", i793        # Inconnue : avertir, avaler un éventuel argument entre accolades794        ctx.warn(f"[{st.key}] commande inconnue : \\{name}")795        if i < len(s) and s[i] == "{":796            arg, i = read_group(s, i)797            return self._run(arg), i798        return "", i799800    # ------------------------------------------------------------------801    def sidenote(self, inner_html: str, kind: str, numbered: bool = False) -> str:802        st = self.st803        st.sidenote += 1804        sid = f"sn-{st.num}-{st.sidenote}"805        cls = f"sidenote sn-{kind}"806        mark = f'<sup class="sn-num">{st.sidenote}</sup>' if numbered else '<span class="sn-mark" aria-hidden="true">◆</span>'807        return (f'<label for="{sid}" class="sn-toggle" title="Note marginale">{mark}</label>'808                f'<input type="checkbox" id="{sid}" class="sn-cb" aria-label="Afficher la note">'809                f'<span class="{cls}">{("<span class=\"sn-num\">" + str(st.sidenote) + "</span> ") if numbered else ""}{inner_html}</span>')810811    # ------------------------------------------------------------------812    def citation(self, cmd: str, keys: str, pre: str | None, post: str | None) -> str:813        ctx = self.ctx814        parts = []815        for k in keys.split(","):816            k = k.strip()817            if not k:818                continue819            ctx.cited.setdefault(k, set()).add(self.st.key)820            e = ctx.bib.get(k)821            if not e:822                ctx.warn(f"[{self.st.key}] clé bibliographique inconnue : {k}")823                parts.append(f'<a class="cite" href="/bibliographie/#{k}">{html.escape(k)}</a>')824                continue825            label = e["short"]826            year = e.get("year", "s.d.")827            if cmd in ("textcite", "citet"):828                txt = f"{label} ({year})"829            elif cmd == "citeauthor":830                txt = label831            elif cmd == "citeyear":832                txt = year833            else:834                txt = f"{label}, {year}"835            parts.append(f'<a class="cite" href="/bibliographie/#{k}" data-cite="{k}">{html.escape(txt)}</a>')836        inner = "; ".join(parts)837        if post:838            inner += ", " + self._run(post)839        if pre:840            inner = self._run(pre) + " " + inner841        if cmd in ("textcite", "citet", "citeauthor", "citeyear"):842            return inner843        return f'<span class="citation">({inner})</span>'844845846def plain_text(h: str) -> str:847    t = re.sub(r"<[^>]+>", "", h)848    t = html.unescape(t)849    t = re.sub(r"@@M\d+@@", "", t)850    return re.sub(r"\s+", " ", t).strip()851852853# =============================================================================854# Préparation des formules pour KaTeX855# =============================================================================856857def prep_math(tex: str, env: str = "") -> str:858    t = tex859    # \dollars{...} et \num{...}860    t = re.sub(r"\\dollars\{([^{}]*)\}", lambda m: fmt_dollars(m.group(1), math=True), t)861    t = re.sub(r"\\num\{([^{}]*)\}", lambda m: fmt_num(m.group(1), math=True), t)862    t = re.sub(r"\\SI\{([^{}]*)\}\{([^{}]*)\}", lambda m: fmt_num(m.group(1), math=True) + r"\,\text{" + m.group(2) + "}", t)863    t = t.replace("\\notag", "").replace("\\nonumber", "")864    # exposants en mode texte (pi\up{2}, \textsuperscript{2} dans \text{}) → caractères Unicode865    sup_map = {"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹",866               "er": "ᵉʳ", "e": "ᵉ", "re": "ʳᵉ", "o": "ᵒ"}867    t = re.sub(r"\\(?:up|textsuperscript)\{([0-9]|er|re|e|o)\}", lambda m: sup_map[m.group(1)], t)868    t = re.sub(r"\\ier\{\}|\\ier\b", "ᵉʳ", t)869    t = re.sub(r"\\ieme\{\}|\\ieme\b", "ᵉ", t)870    t = re.sub(r"\\intertext\{([^{}]*)\}", r"\\text{\1}\\\\", t)871    # environnements align → aligned872    if env in ("align", "align*", "flalign", "flalign*", "eqnarray", "eqnarray*"):873        t = "\\begin{aligned}" + t + "\\end{aligned}"874    elif env in ("gather", "gather*"):875        t = "\\begin{gathered}" + t + "\\end{gathered}"876    elif env in ("multline", "multline*"):877        t = "\\begin{gathered}" + t + "\\end{gathered}"878    # \\[2pt] → \\879    t = re.sub(r"\\\\\[[^\]]*\]", r"\\\\", t)880    # \textonehalf, \ier… gérés par macros KaTeX (voir render_math.mjs)881    return t.strip()882883884# =============================================================================885# Conversion par blocs886# =============================================================================887888BOX_ENVS = {889    # env : (classe css, libellé, numéroté?, compteur, icône, titre par défaut)890    "definition": ("box-def", "Définition", True, "def", "book", ""),891    "formule": ("box-form", "Formule", True, "form", "formula", ""),892    "exemple": ("box-ex", "Exemple", True, "ex", "bulb", ""),893    "exercice": ("box-exo", "Exercice", True, "exo", "pencil", ""),894    "attention": ("box-att", "", False, "", "warning", "Attention"),895    "terrain": ("box-terrain", "", False, "", "hardhat", "Sur le terrain"),896    "remarque": ("box-rem", "", False, "", "pen", "Remarque"),897    "quebec": ("box-qc", "", False, "", "map", "Contexte québécois"),898    "aretenir": ("box-ret", "", False, "", "star", "À retenir"),899    "pointscles": ("box-pts", "", False, "", "star", "Points clés"),900}901902BLOCK_RE = re.compile(903    r"\\begin\{([a-zA-Z*]+)\}"904    r"|\\(chapter|section|subsection|subsubsection|paragraph)(\*?)\s*(?=\{|\[)"905    r"|\\\["906    r"|\\uqosep\b"907    r"|\\label\{"908    r"|\\(cleardoublepage|clearpage|newpage|tableofcontents|frontmatter|mainmatter|backmatter|appendix|centering|noindent|medskip|bigskip|smallskip|vfill)\b"909    r"|\\vspace\*?\{"910    r"|\\(item)\b"911)912913HEADING_TAGS = {"chapter": "h1", "section": "h2", "subsection": "h3", "subsubsection": "h4", "paragraph": "h5"}914915916class BlockConverter:917    def __init__(self, st: ChapterState):918        self.st = st919        self.ctx = st.ctx920        self.inline = InlineConverter(st)921922    # ------------------------------------------------------------------923    def convert(self, s: str) -> str:924        out: list[str] = []925        para: list[str] = []926927        def flush():928            txt = "".join(para).strip()929            para.clear()930            if txt:931                out.append(self.paragraph(txt))932933        i = 0934        n = len(s)935        while i < n:936            m = BLOCK_RE.search(s, i)937            if not m:938                para.append(s[i:])939                break940            # texte avant le bloc : découper par lignes vides941            chunk = s[i:m.start()]942            if chunk:943                pieces = re.split(r"\n[ \t]*\n", chunk)944                for k, p in enumerate(pieces):945                    if k > 0:946                        flush()947                    para.append(p)948            i = m.end()949            if m.group(1):  # \begin{env}950                env = m.group(1)951                if env in ("tikzpicture",):952                    pass953                opt, i2 = read_opt(s, i)954                # certains envs ont un argument obligatoire (tabularx{\textwidth}{spec}, tabular{spec}, minipage{w})955                end_start, end_end = find_env_end(s, env, i2)956                body = s[i2:end_start]957                i = end_end958                if env in ("equation", "equation*", "align", "align*", "gather", "gather*", "multline",959                           "multline*", "flalign", "flalign*", "eqnarray", "eqnarray*", "displaymath"):960                    # math hors-texte : reste dans le paragraphe (comme en LaTeX) → on flush avant961                    flush()962                    out.append(self.inline.display_math(body, numbered=not env.endswith("*") and env not in ("displaymath",), env=env))963                    continue964                if env in ("itemize", "enumerate", "description"):965                    flush()966                    out.append(self.list_env(env, opt, body))967                    continue968                flush()969                out.append(self.environment(env, opt, body))970            elif m.group(2):  # sectionnement971                flush()972                kind = m.group(2)973                star = bool(m.group(3))974                _, i = read_opt(s, i)975                title, i = read_group(s, i)976                # \label éventuel sur la ligne suivante977                out.append(self.heading(kind, title, star))978            elif m.group(0) == "\\[":979                flush()980                j = s.find("\\]", i)981                if j == -1:982                    j = n983                out.append(self.inline.display_math(s[i:j], numbered=False))984                i = j + 2985            elif m.group(0).startswith("\\uqosep"):986                flush()987                out.append('<hr class="sep">')988            elif m.group(0) == "\\label{":989                key_end = find_matching_brace(s, m.end() - 1)990                key = s[m.end():key_end].strip()991                i = key_end + 1992                if para and "".join(para).strip():993                    # label au milieu d'un paragraphe : pointe vers le paragraphe courant (id ajouté au flush ? simplifié)994                    self.ctx.labels[key] = {"kind": "sec", "num": self.st.current_section_num(), "url": f"{self.st.url}#{self.st.current_section_id}"}995                else:996                    target = self.st.pending_label_target or self.st.current_section_id997                    self.ctx.labels[key] = {"kind": "sec", "num": self.st.current_section_num(), "url": f"{self.st.url}#{target}"}998            elif m.group(4):999                if m.group(4) in ("medskip", "bigskip", "smallskip"):1000                    flush()1001                # autres : ignorés1002            elif m.group(0).startswith("\\vspace"):1003                _, i = read_group(s, m.end() - 1)1004            elif m.group(5):  # \item hors liste1005                _, i = read_opt(s, i)1006                para.append(" • ")1007        flush()1008        return "\n".join(x for x in out if x)10091010    # ------------------------------------------------------------------1011    def paragraph(self, txt: str) -> str:1012        st = self.st1013        # \paragraph{Titre.} en tête de paragraphe → titre courant1014        h = self.inline.convert(txt)1015        h = h.strip()1016        if not h:1017            return ""1018        # nettoyer <p></p> issus de \par1019        pid = st.next_id(f"p{st.num}")1020        if h.startswith("<div class=\"eq\"") and h.endswith("</div>") and h.count("<div") == 1:1021            return h1022        st.search.append(SearchEntry(pid, "p", st.current_section_title, plain_text(h)))1023        return f'<p id="{pid}">{h}</p>'10241025    # ------------------------------------------------------------------1026    def heading(self, kind: str, title: str, star: bool) -> str:1027        st = self.st1028        title_html = self.inline.convert(title)1029        title_text = plain_text(title_html)1030        if kind == "chapter":1031            st.title_html = title_html1032            st.title_text = title_text1033            st.pending_label_target = "top"1034            st.current_section_id = "top"1035            st.current_section_title = title_text1036            return ""  # le titre de chapitre est rendu par le gabarit de page1037        if kind == "paragraph":1038            return f'<h5 class="para-title">{title_html}</h5>'1039        if kind == "section":1040            if not star:1041                st.sec += 11042                st.subsec = 01043                st.subsubsec = 01044                num = f"{st.num}.{st.sec}"1045            else:1046                num = ""1047            sid = f"s-{st.num}-{st.sec}" if not star else f"s-{st.num}-{slugify(title_text)}"1048        elif kind == "subsection":1049            if not star:1050                st.subsec += 11051                st.subsubsec = 01052                num = f"{st.num}.{st.sec}.{st.subsec}"1053            else:1054                num = ""1055            sid = f"s-{st.num}-{st.sec}-{st.subsec}" if not star else f"s-{st.num}-{st.sec}-{slugify(title_text)}"1056        else:1057            if not star:1058                st.subsubsec += 11059                num = f"{st.num}.{st.sec}.{st.subsec}.{st.subsubsec}"1060            else:1061                num = ""1062            sid = f"s-{st.num}-{st.sec}-{st.subsec}-{st.subsubsec}" if not star else f"s-{st.num}-{slugify(title_text)}"1063        st.current_section_id = sid1064        st.current_section_title = title_text1065        st.pending_label_target = sid1066        st._last_num = num1067        level = {"section": 2, "subsection": 3, "subsubsection": 4}[kind]1068        st.toc.append((level, num, title_html, sid))1069        tag = HEADING_TAGS[kind]1070        numspan = f'<span class="h-num">{num}</span>' if num else ""1071        return f'<{tag} id="{sid}" class="h-{kind}">{numspan}<span class="h-text">{title_html}</span><a class="h-anchor" href="#{sid}" aria-label="Lien vers cette section">#</a></{tag}>'10721073    # ------------------------------------------------------------------1074    def list_env(self, env: str, opt: str | None, body: str) -> str:1075        st = self.st1076        items = split_top_level(body, r"\\item\b")1077        # items[0] = texte avant le premier \item (vide)1078        lis = []1079        st.list_depth += 11080        for raw in items[1:]:1081            lab, k = read_opt(raw, 0)1082            content = raw[k:].strip()1083            inner = self.convert(content)1084            # déballer un paragraphe unique1085            mm = re.fullmatch(r'<p id="[^"]*">(.*)</p>', inner, flags=re.S)1086            if mm:1087                inner = mm.group(1)1088            if st.in_objectifs and st.list_depth == 1:1089                st.obj_index += 11090                oid = f"obj-{st.num}-{st.obj_index}"1091                st.objectives.append(plain_text(inner))1092                lis.append(f'<li class="obj"><label><input type="checkbox" class="obj-cb" id="{oid}" data-obj="{oid}"><span class="obj-text">{inner}</span></label></li>')1093            elif lab is not None:1094                lis.append(f'<li class="labeled"><span class="li-label">{self.inline.convert(lab)}</span><div class="li-body">{inner}</div></li>')1095            else:1096                lis.append(f"<li>{inner}</li>")1097        st.list_depth -= 11098        if env == "enumerate":1099            return '<ol class="enum">' + "".join(lis) + "</ol>"1100        if env == "description":1101            return '<ul class="desc">' + "".join(lis) + "</ul>"1102        return "<ul>" + "".join(lis) + "</ul>"11031104    # ------------------------------------------------------------------1105    def environment(self, env: str, opt: str | None, body: str) -> str:1106        st = self.st1107        ctx = self.ctx1108        if env in BOX_ENVS:1109            return self.box(env, opt, body)1110        if env == "solution":1111            inner = self.convert(body)1112            return ('<details class="box box-sol"><summary><span class="box-icon" data-icon="check"></span>'1113                    '<span class="box-label">Solution</span><span class="sol-hint">Cliquer pour révéler</span></summary>'1114                    f'<div class="box-body">{inner}</div></details>')1115        if env == "objectifs":1116            st.in_objectifs = True1117            inner = self.convert(body)1118            st.in_objectifs = False1119            return ('<section class="box box-obj" id="objectifs"><div class="box-head"><span class="box-icon" data-icon="target"></span>'1120                    '<span class="box-label">Objectifs d’apprentissage</span><span class="obj-progress" aria-live="polite"></span></div>'1121                    f'<div class="box-body">{inner}</div></section>')1122        if env == "fichesynthese":1123            inner = self.convert(body)1124            h = ('<section class="box box-synth" id="synthese"><div class="box-head"><span class="box-icon" data-icon="clipboard"></span>'1125                 '<span class="box-label">L’essentiel de la séance</span></div>'1126                 f'<div class="box-body">{inner}</div></section>')1127            st.synth_html = inner1128            st.search.append(SearchEntry("synthese", "synth", "L’essentiel de la séance", plain_text(inner)))1129            return h1130        if env == "center":1131            inner = self.convert(body)1132            return f'<div class="center">{inner}</div>'1133        if env in ("flushleft", "flushright", "quote", "quotation", "small", "footnotesize", "minipage", "adjustbox"):1134            if env == "minipage":1135                _, k = read_group(body, 0)1136                body = body[k:]1137            inner = self.convert(body)1138            cls = {"quote": "quote", "quotation": "quote"}.get(env, env)1139            return f'<div class="{cls}">{inner}</div>'1140        if env in ("table", "table*"):1141            return self.float_env("tab", body)1142        if env in ("figure", "figure*"):1143            return self.float_env("fig", body)1144        if env in ("tabular", "tabularx", "tabular*", "longtable", "tabulary"):1145            return self.tabular(env, body)1146        if env == "tikzpicture":1147            tex = f"\\begin{{tikzpicture}}{('[' + opt + ']') if opt is not None else ''}{body}\\end{{tikzpicture}}"1148            token = ctx.add_tikz(tex)1149            return f'<div class="tikz">{token}</div>'1150        if env in ("titlepage", "abstract", "verbatim", "comment", "lstlisting"):1151            if env == "verbatim" or env == "lstlisting":1152                return f"<pre><code>{html.escape(body)}</code></pre>"1153            return ""1154        if env == "tcolorbox":1155            inner = self.convert(body)1156            return f'<div class="box box-rem"><div class="box-body">{inner}</div></div>'1157        ctx.warn(f"[{st.key}] environnement inconnu : {env}")1158        return f'<div class="env-{env}">{self.convert(body)}</div>'11591160    # ------------------------------------------------------------------1161    def box(self, env: str, opt: str | None, body: str) -> str:1162        st = self.st1163        ctx = self.ctx1164        cls, label, numbered, counter, icon, default_title = BOX_ENVS[env]1165        # label interne au début de la boîte1166        labels = re.findall(r"\\label\{([^}]*)\}", body)1167        body_wo = re.sub(r"\\label\{[^}]*\}", "", body)1168        num = ""1169        if numbered:1170            st.c[counter] += 11171            num = f"{st.num}.{st.c[counter]}"1172            bid = f"{counter}-{st.num}-{st.c[counter]}"1173        else:1174            bid = st.next_id(f"{counter or env}-{st.num}")1175        title_html = self.inline.convert(opt) if opt else (default_title if not numbered else "")1176        for lb in labels:1177            ctx.labels[lb] = {"kind": env, "num": num, "url": f"{st.url}#{bid}"}1178        inner = self.convert(body_wo)1179        if numbered:1180            head_label = f"{label} {num}"1181            title_part = f'<span class="box-title">— {title_html}</span>' if title_html else ""1182        else:1183            head_label = title_html or default_title1184            title_part = ""1185        h = (f'<div class="box {cls}" id="{bid}"><div class="box-head"><span class="box-icon" data-icon="{icon}"></span>'1186             f'<span class="box-label">{head_label}</span>{title_part}'1187             f'<a class="box-anchor" href="#{bid}" aria-label="Lien">#</a></div>'1188             f'<div class="box-body">{inner}</div></div>')1189        entry = {"chapter": st.num, "chapter_key": st.key, "num": num, "title": plain_text(title_html) if title_html else "",1190                 "title_html": title_html, "html": inner, "id": bid, "url": f"{st.url}#{bid}", "text": plain_text(inner)}1191        if env == "formule":1192            ctx.formulas.append(entry)1193        elif env == "definition":1194            ctx.definitions.append(entry)1195        elif env == "exemple":1196            ctx.examples.append(entry)1197        elif env == "exercice":1198            ctx.exercises.append(entry)1199        st.search.append(SearchEntry(bid, env, f"{head_label}{(' — ' + entry['title']) if entry['title'] and numbered else ''}", entry["text"]))1200        return h12011202    # ------------------------------------------------------------------1203    def float_env(self, kind: str, body: str) -> str:1204        st = self.st1205        ctx = self.ctx1206        # extraire caption et label1207        caption = ""1208        labels = []1209        m = re.search(r"\\caption\b", body)1210        if m:1211            _, k = read_opt(body, m.end())1212            cap, k2 = read_group(body, k)1213            caption = cap1214            body = body[:m.start()] + body[k2:]1215        for lm in re.finditer(r"\\label\{([^}]*)\}", body):1216            labels.append(lm.group(1))1217        body = re.sub(r"\\label\{[^}]*\}", "", body)1218        body = re.sub(r"\\centering|\\small|\\footnotesize|\\scriptsize", "", body)1219        st.c[kind] += 11220        num = f"{st.num}.{st.c[kind]}"1221        fid = f"{kind}-{label_to_id(labels[0])}" if labels else f"{kind}-{st.num}-{st.c[kind]}"1222        for lb in labels:1223            ctx.labels[lb] = {"kind": kind, "num": num, "url": f"{st.url}#{fid}"}1224        inner = self.convert(body)1225        cap_html = self.inline.convert(caption) if caption else ""1226        word = "Tableau" if kind == "tab" else "Figure"1227        figcap = f'<figcaption><span class="fig-label">{word} {num}</span> {cap_html}</figcaption>' if caption else ""1228        cls = "float-table" if kind == "tab" else "float-figure"1229        if kind == "fig":1230            ctx.figures.append({"chapter": st.num, "num": num, "caption": plain_text(cap_html), "id": fid, "url": f"{st.url}#{fid}"})1231        st.search.append(SearchEntry(fid, kind, f"{word} {num}", plain_text(cap_html) + " " + plain_text(inner)[:400]))1232        return f'<figure class="{cls}" id="{fid}">{figcap if kind == "tab" else ""}{inner}{figcap if kind == "fig" else ""}</figure>'12331234    # ------------------------------------------------------------------1235    def tabular(self, env: str, body: str) -> str:1236        # arguments : tabularx{\textwidth}{spec} ; tabular{spec} ; tabular*{w}{spec}1237        k = 01238        if env in ("tabularx", "tabular*", "tabulary"):1239            _, k = read_group(body, 0)1240        spec, k = read_group(body, k)1241        rows_raw = body[k:]1242        cols = parse_colspec(spec)1243        rows = split_top_level(rows_raw, r"\\\\(\[[^\]]*\])?")1244        parsed = []  # (cells, flags)1245        header_end = None1246        pending_rule = False1247        for ridx, raw in enumerate(rows):1248            r = raw1249            flags = {"rule_above": False, "header_color": False}1250            # règles et couleurs en tête de ligne1251            while True:1252                r2 = r.lstrip()1253                mm = re.match(r"\\(toprule|midrule|bottomrule|hline|addlinespace|specialrule|cmidrule|cline)(\[[^\]]*\])?(\{[^}]*\})*(\([^)]*\))?(\{[^}]*\})*", r2)1254                if mm:1255                    if mm.group(1) == "midrule" or mm.group(1) == "hline":1256                        if header_end is None and parsed:1257                            header_end = len(parsed)1258                        elif parsed:1259                            flags["rule_above"] = True1260                    r = r2[mm.end():]1261                    continue1262                mm = re.match(r"\\rowcolor(\[[^\]]*\])?\{([^}]*)\}", r2)1263                if mm:1264                    if mm.group(2).startswith("uqoBleu") and not mm.group(2).startswith("uqoBleuClair"):1265                        flags["header_color"] = True1266                    r = r2[mm.end():]1267                    continue1268                mm = re.match(r"\\(renewcommand|setlength)\{[^}]*\}\{[^}]*\}", r2)1269                if mm:1270                    r = r2[mm.end():]1271                    continue1272                mm = re.match(r"\\(small|footnotesize|scriptsize|centering|arraybackslash|raggedright|noalign\{[^}]*\})", r2)1273                if mm:1274                    r = r2[mm.end():]1275                    continue1276                break1277            if not r.strip():1278                continue1279            cells = split_top_level(r, r"&")1280            parsed.append((cells, flags))1281        if header_end is None:1282            header_end = 1 if parsed and parsed[0][1]["header_color"] else 01283        # rendu1284        thead = []1285        tbody = []1286        for idx, (cells, flags) in enumerate(parsed):1287            is_head = idx < header_end1288            tds = []1289            col = 01290            for c in cells:1291                c = c.strip()1292                span = 11293                align = None1294                mm = re.match(r"\\multicolumn\{(\d+)\}", c)1295                if mm:1296                    span = int(mm.group(1))1297                    sp, kk = read_group(c, mm.end())1298                    align = parse_colspec(sp)[0] if parse_colspec(sp) else "l"1299                    c, kk2 = read_group(c, kk)1300                if align is None:1301                    align = cols[col] if col < len(cols) else "l"1302                col += span1303                inner = self.inline.convert(c)1304                # cellule d'en-tête : retirer les <strong> superflus (déjà en gras via CSS)1305                tag = "th" if is_head or flags["header_color"] else "td"1306                if tag == "th":1307                    inner = re.sub(r"^<strong>(.*)</strong>$", r"\1", inner, flags=re.S)1308                cls = {"r": "al-r", "c": "al-c"}.get(align, "")1309                attrs = ""1310                if cls:1311                    attrs += f' class="{cls}"'1312                if span > 1:1313                    attrs += f' colspan="{span}"'1314                tds.append(f"<{tag}{attrs}>{inner}</{tag}>")1315            rcls = []1316            if flags["rule_above"]:1317                rcls.append("rule-above")1318            tr = f'<tr{(" class=\"" + " ".join(rcls) + "\"") if rcls else ""}>' + "".join(tds) + "</tr>"1319            if is_head:1320                thead.append(tr)1321            else:1322                tbody.append(tr)1323        ncols = len(cols)1324        wide = " table-wide" if ncols >= 5 else ""1325        h = f'<div class="table-wrap"><table class="tbl{wide}">'1326        if thead:1327            h += "<thead>" + "".join(thead) + "</thead>"1328        h += "<tbody>" + "".join(tbody) + "</tbody></table></div>"1329        return h133013311332def parse_colspec(spec: str) -> list[str]:1333    s = spec1334    # retirer @{...}, >{...}, <{...}, !{...}1335    out = []1336    i = 01337    n = len(s)1338    while i < n:1339        c = s[i]1340        if c in "@><!":1341            if i + 1 < n and s[i + 1] == "{":1342                j = find_matching_brace(s, i + 1)1343                i = j + 11344                continue1345            i += 11346            continue1347        if c in "lcrXLCRJ":1348            out.append({"L": "l", "C": "c", "R": "r", "J": "l", "X": "l"}.get(c, c))1349            i += 11350            continue1351        if c in "pmb" and i + 1 < n and s[i + 1] == "{":1352            j = find_matching_brace(s, i + 1)1353            out.append("l")1354            i = j + 11355            continue1356        if c == "*" and i + 1 < n and s[i + 1] == "{":1357            j = find_matching_brace(s, i + 1)1358            cnt = int(s[i + 2:j].strip() or "1")1359            sub, k = read_group(s, j + 1)1360            out.extend(parse_colspec(sub) * cnt)1361            i = k1362            continue1363        i += 11364    return out136513661367# =============================================================================1368# API1369# =============================================================================13701371def add_section_num_method():1372    def current_section_num(self):1373        return getattr(self, "_last_num", "") or self.num1374    ChapterState.current_section_num = current_section_num137513761377add_section_num_method()137813791380def convert_chapter(ctx: Ctx, key: str, num: str, url: str, tex: str) -> ChapterResult:1381    st = ChapterState(ctx, key, num, url)1382    tex = strip_comments(tex)1383    bc = BlockConverter(st)1384    body = bc.convert(tex)1385    # labels de chapitre1386    m = re.search(r"\\label\{(ch:[^}]*|ann[A-Z][^}]*)\}", tex)1387    if m:1388        ctx.labels[m.group(1)] = {"kind": "chapter", "num": num, "url": url}1389    res = ChapterResult(key=key, num=num, title_html=st.title_html, title_text=st.title_text, html=body,1390                        toc=st.toc, search=st.search,1391                        counts={"def": st.c["def"], "form": st.c["form"], "ex": st.c["ex"], "exo": st.c["exo"],1392                                "tab": st.c["tab"], "fig": st.c["fig"], "eq": st.c["eq"], "sidenotes": st.sidenote},1393                        objectives=st.objectives, synth_html=st.synth_html)1394    return res139513961397def resolve_refs(h: str, ctx: Ctx, current_url: str = "") -> str:1398    def rep(m):1399        key = m.group(1)1400        e = ctx.labels.get(key)1401        if not e:1402            ctx.warn(f"renvoi non résolu : {key}")1403            return '<span class="xref-missing">?</span>'1404        url = e["url"]1405        if current_url and url.startswith(current_url) and "#" in url:1406            url = url[url.index("#"):]1407        num = e["num"] or "↗"1408        return f'<a class="xref" href="{url}">{num}</a>'1409    return re.sub(r"@@REF\{([^}]*)\}@@", rep, h)141014111412if __name__ == "__main__":1413    # test rapide : python3 latex2html.py fichier.tex1414    ctx = Ctx("TEST")1415    src = open(sys.argv[1], encoding="utf-8").read()1416    r = convert_chapter(ctx, "ch01", "1", "/seance/01/", src)1417    print(r.html[:5000])1418    print("WARN:", "\n".join(ctx.warnings[:40]))1419    print("math:", len(ctx.math), "tikz:", len(ctx.tikz), "labels:", len(ctx.labels))1420