spb/uqo-imm1003
Public
JavaScript 68%
CSS 32%
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": ">", "textless": "<", "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": " ", "qquad": "  ", "enspace": " ",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('"', """) 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(" ")456 elif c == ":":457 out.append(" ")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