r""" latex2html.py — convertisseur LaTeX → HTML pour le dialecte « uqo-notes.sty ». Couverture : sectionnement, boîtes tcolorbox (definition/formule/exemple/exercice/ solution/attention/terrain/remarque/quebec/aretenir/objectifs/fichesynthese), listes, tabular/tabularx (booktabs), figures/tables numérotées, équations (KaTeX en différé), TikZ (rendu SVG en différé), notes marginales (\repere, \margterme, \margdate), \motcle, \dollars/\num (siunitx FR), citations biblatex, renvois \ref/\eqref. Les formules et figures sont remplacées par des jetons @@M{n}@@ / @@TIKZ{n}@@ et les renvois par @@REF{label}@@ ; le générateur de site les résout ensuite. """ from __future__ import annotations import html import re import sys import unicodedata from dataclasses import dataclass, field # ============================================================================= # Utilitaires de découpage # ============================================================================= def find_matching_brace(s: str, i: int) -> int: """s[i] == '{' → indice de l'accolade fermante correspondante.""" assert s[i] == "{", (i, s[i:i + 20]) depth = 0 j = i n = len(s) while j < n: c = s[j] if c == "\\": j += 2 continue if c == "{": depth += 1 elif c == "}": depth -= 1 if depth == 0: return j j += 1 raise ValueError("Accolade non fermée près de : " + s[i:i + 60]) def read_group(s: str, i: int) -> tuple[str, int]: """Lit un groupe {…} commençant à s[i] (espaces ignorés). Retourne (contenu, index après '}').""" n = len(s) while i < n and s[i] in " \t\n": i += 1 if i >= n or s[i] != "{": return "", i j = find_matching_brace(s, i) return s[i + 1:j], j + 1 def read_opt(s: str, i: int) -> tuple[str | None, int]: """Lit un argument optionnel [...] (espaces ignorés, accolades respectées).""" n = len(s) k = i while k < n and s[k] in " \t": k += 1 if k >= n or s[k] != "[": return None, i depth = 0 j = k while j < n: c = s[j] if c == "\\": j += 2 continue if c == "{": depth += 1 elif c == "}": depth -= 1 elif c == "[" and depth == 0: pass elif c == "]" and depth == 0: return s[k + 1:j], j + 1 j += 1 return None, i def find_env_end(s: str, name: str, start: int) -> tuple[int, int]: """Trouve \\end{name} correspondant à un \\begin{name} déjà consommé. Retourne (début de \\end, fin après \\end{name}).""" pat = re.compile(r"\\(begin|end)\{" + re.escape(name) + r"\}") depth = 1 pos = start while True: m = pat.search(s, pos) if not m: raise ValueError(f"\\end{{{name}}} introuvable") if m.group(1) == "begin": depth += 1 else: depth -= 1 if depth == 0: return m.start(), m.end() pos = m.end() def split_top_level(s: str, sep_re: str) -> list[str]: """Découpe s aux occurrences de sep_re situées hors accolades, hors math $…$ et hors environnements imbriqués.""" parts = [] depth = 0 env_depth = 0 in_math = False buf_start = 0 i = 0 n = len(s) sep = re.compile(sep_re) while i < n: c = s[i] if c == "\\": if s.startswith("\\begin{", i): env_depth += 1 i += 7 continue if s.startswith("\\end{", i): env_depth -= 1 i += 5 continue if depth == 0 and env_depth == 0 and not in_math: m = sep.match(s, i) if m: parts.append(s[buf_start:i]) i = m.end() buf_start = i continue i += 2 continue if c == "$": in_math = not in_math i += 1 continue if c == "{": depth += 1 elif c == "}": depth -= 1 elif depth == 0 and env_depth == 0 and not in_math: m = sep.match(s, i) if m: parts.append(s[buf_start:i]) i = m.end() buf_start = i continue i += 1 parts.append(s[buf_start:]) return parts def strip_comments(s: str) -> str: out = [] i = 0 n = len(s) while i < n: c = s[i] if c == "\\" and i + 1 < n: out.append(s[i:i + 2]) i += 2 continue if c == "%": # commentaire jusqu'à la fin de ligne (la fin de ligne est avalée comme en TeX) j = s.find("\n", i) if j == -1: break i = j + 1 # avaler les espaces de début de ligne suivante while i < n and s[i] in " \t": i += 1 continue out.append(c) i += 1 return "".join(out) def slugify(text: str) -> str: t = unicodedata.normalize("NFKD", text) t = "".join(ch for ch in t if not unicodedata.combining(ch)) t = re.sub(r"[^a-zA-Z0-9]+", "-", t).strip("-").lower() return t or "x" def label_to_id(label: str) -> str: return re.sub(r"[^a-zA-Z0-9_-]+", "-", label) # ============================================================================= # Nombres (siunitx FR) # ============================================================================= NNBSP = "\u202f" # espace fine insécable NBSP = "\u00a0" def group_digits(int_part: str, sep: str) -> str: int_part = int_part.lstrip("0") or "0" if int_part != "0" else "0" if len(int_part) < 5: # group-minimum-digits=4 → 1234 reste 1234 ; 12345 → 12 345 return int_part out = [] while len(int_part) > 3: out.insert(0, int_part[-3:]) int_part = int_part[:-3] out.insert(0, int_part) return sep.join(out) def fmt_num(raw: str, math: bool = False) -> str: """\\num{1234567} → 1 234 567 ; \\num{10.764} → 10,764 (et variante KaTeX).""" s = raw.strip().replace("\\,", "").replace(" ", "").replace("~", "") sign = "" if s[:1] in "+-": sign, s = s[0], s[1:] s = s.replace(",", ".") if s.count(".") > 1: # « 1.234.567 » improbable ; on retire tout sauf le dernier head, _, tail = s.rpartition(".") s = head.replace(".", "") + "." + tail if "." in s: ip, dp = s.split(".", 1) else: ip, dp = s, "" if not ip.isdigit(): return raw # pas un nombre : on rend tel quel sep = "\\," if math else NNBSP out = group_digits(ip, sep) if dp: out += ("{,}" if math else ",") + dp return sign + out def fmt_dollars(raw: str, math: bool = False) -> str: n = fmt_num(raw, math) return n + ("\\,\\$" if math else NBSP + "$") # ============================================================================= # Contexte de conversion # ============================================================================= @dataclass class SearchEntry: id: str kind: str title: str text: str @dataclass class ChapterResult: key: str # ch01, annexe-formulaire… num: str # "1", "A" title_html: str title_text: str html: str toc: list = field(default_factory=list) # (level, num, title_html, id) search: list = field(default_factory=list) # SearchEntry counts: dict = field(default_factory=dict) objectives: list = field(default_factory=list) # html des objectifs synth_html: str = "" class Ctx: """Contexte partagé pour un cours (labels, math, tikz, bib, collectes).""" def __init__(self, course_code: str, bib: dict | None = None): self.course_code = course_code self.labels: dict[str, dict] = {} self.math: list[tuple[str, str]] = [] # (mode, tex) self.tikz: list[str] = [] self.warnings: list[str] = [] self.bib = bib or {} self.cited: dict[str, set] = {} self.formulas: list[dict] = [] self.definitions: list[dict] = [] self.examples: list[dict] = [] self.exercises: list[dict] = [] self.figures: list[dict] = [] # --- math --- def add_math(self, mode: str, tex: str) -> str: self.math.append((mode, tex)) return f"@@M{len(self.math) - 1}@@" def add_tikz(self, tex: str) -> str: self.tikz.append(tex) return f"@@TIKZ{len(self.tikz) - 1}@@" def warn(self, msg: str): self.warnings.append(msg) class ChapterState: def __init__(self, ctx: Ctx, key: str, num: str, url: str): self.ctx = ctx self.key = key self.num = num # "1".."14", "A", "B" self.url = url # /seance/01/ ou /aide-memoire/ self.sec = 0 self.subsec = 0 self.subsubsec = 0 self.c = {"def": 0, "form": 0, "ex": 0, "exo": 0, "tab": 0, "fig": 0, "eq": 0} self.sidenote = 0 self.toc: list = [] self.search: list[SearchEntry] = [] self.current_section_id = "" self.current_section_title = "" self.pending_label_target: str | None = None # id du dernier titre pour \label self.in_objectifs = False self.obj_index = 0 self.objectives: list[str] = [] self.synth_html = "" self.title_html = "" self.title_text = "" self.para_counter = 0 self.starred_section = False self.list_depth = 0 def next_id(self, prefix: str) -> str: self.para_counter += 1 return f"{prefix}-{self.para_counter}" # ============================================================================= # Conversion en ligne # ============================================================================= SIMPLE_MAP = { "textbf": ("", ""), "emph": ("", ""), "textit": ("", ""), "textsl": ("", ""), "texttt": ("", ""), "textsc": ('', ""), "underline": ("", ""), "uqotitle": ('', ""), "uqoalert": ('', ""), "uqogreen": ('', ""), "uqohighlight": ('', ""), "textsuperscript": ("", ""), "textsubscript": ("", ""), "mbox": ("", ""), "hbox": ("", ""), "textnormal": ("", ""), "textrm": ("", ""), "textsf": ("", ""), "textup": ("", ""), "textmd": ("", ""), "MakeUppercase": ('', ""), "makecell": ("", ""), "thead": ("", ""), } ZERO_ARG = { "oe": "œ", "OE": "Œ", "ae": "æ", "AE": "Æ", "ss": "ß", "ldots": "…", "dots": "…", "textellipsis": "…", "textbullet": "•", "checkmark": "✓", "textdegree": "°", "textonehalf": "½", "textonequarter": "¼", "textthreequarters": "¾", "textquoteright": "’", "textquoteleft": "‘", "textquotedblleft": "“", "textquotedblright": "”", "textendash": "–", "textemdash": "—", "textbackslash": "\\", "textasciitilde": "~", "textasciicircum": "^", "textgreater": ">", "textless": "<", "textbar": "|", "textregistered": "®", "texttrademark": "™", "copyright": "©", "textcopyright": "©", "euro": "€", "pounds": "£", "S": "§", "P": "¶", "dag": "†", "ddag": "‡", "ier": "er", "iere": "re", "ieme": "e", "iemes": "es", "no": "no", "No": "No", "og": "«" + NBSP, "fg": NBSP + "»", "quad": " ", "qquad": "  ", "enspace": " ", "newline": "
", "linebreak": "
", "noindent": "", "relax": "", "protect": "", "par": "

", "smallskip": "", "medskip": "", "bigskip": "", "hfill": "", "hfil": "", "strut": "", "centering": "", "raggedright": "", "raggedleft": "", "arraybackslash": "", "small": "", "footnotesize": "", "scriptsize": "", "tiny": "", "large": "", "Large": "", "LARGE": "", "huge": "", "Huge": "", "normalsize": "", "normalfont": "", "selectfont": "", "toprule": "", "midrule": "", "bottomrule": "", "hline": "", "tableofcontents": "", "cleardoublepage": "", "clearpage": "", "newpage": "", "frontmatter": "", "mainmatter": "", "backmatter": "", "appendix": "", "uqosep": '


', "rightarrow": "→", "Rightarrow": "⇒", "leftarrow": "←", "leftrightarrow": "↔", "times": "×", "approx": "≈", "leq": "≤", "geq": "≥", "le": "≤", "ge": "≥", "neq": "≠", "pm": "±", "div": "÷", "infty": "∞", "cdot": "·", "textperiodcentered": "·", "textminus": "−", "indent": "", "vfill": "", "null": "", "ignorespaces": "", "unskip": "", "faIcon": "", # icône FontAwesome : argument avalé plus bas "phantomsection": "", "listoffigures": "", "listoftables": "", "singlespacing": "", "onehalfspacing": "", "sffamily": "", "rmfamily": "", "textregistered": "®", "clearpage": "", "par": "

", } ONE_ARG_DROP = { "index", "label_inline", "vspace", "hspace", "vspace*", "hspace*", "phantom", "hphantom", "vphantom", "pagestyle", "thispagestyle", "markright", "setcounter", "addtocounter", "stepcounter", "refstepcounter", "nocite", "faIcon", "pgfplotsset", "tcbset", "hyphenation", "input", "include", "bibliography", "printbibliography", "printindex", "addbibresource", "makeindex", "usepackage", "documentclass", "enlargethispage", "captionsetup", "rule", # \rule{w}{h} : 2 args → géré ci-dessous } TWO_ARG_DROP = {"renewcommand", "newcommand", "setlength", "addtolength", "markboth", "providecommand", "rule", "settowidth", "definecolor"} COLOR_CLASS = { "uqoBleu": "c-bleu", "uqoBleuClair": "c-bleu-clair", "uqoOr": "c-or", "uqoGris": "c-gris", "uqoGrisClair": "c-gris-clair", "uqoVert": "c-vert", "uqoRouge": "c-rouge", "uqoNavy": "c-navy", "uqoInk": "c-ink", "white": "c-white", "black": "c-ink", "red": "c-rouge", "blue": "c-bleu", "gray": "c-gris", "grey": "c-gris", "green": "c-vert", } def color_class(name: str) -> str: base = name.split("!")[0].strip() return COLOR_CLASS.get(base, "c-gris") INLINE_RE = re.compile(r"\\([a-zA-Z@]+)\*?|\\(.)|(\$\$?)|(\{)|(\})|(~)|(---)|(--)|(\n)|([<>&\"])|(``)|('')|(\\\()") class InlineConverter: def __init__(self, st: ChapterState): self.st = st self.ctx = st.ctx # ------------------------------------------------------------------ def convert(self, s: str) -> str: return self._run(s) def _run(self, s: str) -> str: out: list[str] = [] i = 0 n = len(s) while i < n: m = INLINE_RE.search(s, i) if not m: out.append(html.escape(s[i:], quote=False).replace('"', """) if False else html.escape(s[i:], quote=False)) break if m.start() > i: out.append(html.escape(s[i:m.start()], quote=False)) i = m.end() if m.group(1): # \commande name = m.group(1) piece, i = self.command(name, s, i, m) out.append(piece) elif m.group(2) is not None: # \x caractère échappé c = m.group(2) if c == "\\": # \\ → saut de ligne ; avaler [2pt] opt, i2 = read_opt(s, i) i = i2 out.append("
") elif c == ",": out.append(NNBSP) elif c == ";": out.append(" ") elif c == ":": out.append(" ") elif c == "!": out.append("") elif c == " ": out.append(" ") elif c == "/": out.append("") elif c == "-": out.append("") elif c == "@": out.append("") elif c == "'": # accent aigu : \'e ou \'{e} grp, i2 = self._accent_target(s, i) i = i2 out.append(self._accent(grp, "\u0301")) elif c == "`": grp, i2 = self._accent_target(s, i) i = i2 out.append(self._accent(grp, "\u0300")) elif c == "^": grp, i2 = self._accent_target(s, i) i = i2 out.append(self._accent(grp, "\u0302")) elif c == '"': grp, i2 = self._accent_target(s, i) i = i2 out.append(self._accent(grp, "\u0308")) elif c in "$%&#_{}": out.append(html.escape(c, quote=False)) elif c == "[": # \[ … \] math hors-texte j = s.find("\\]", i) if j == -1: j = n tex = s[i:j] i = j + 2 out.append(self.display_math(tex, numbered=False)) elif c == "(": j = s.find("\\)", i) if j == -1: j = n tex = s[i:j] i = j + 2 out.append(self.inline_math(tex)) elif c == "]" or c == ")": out.append("") else: out.append(html.escape(c, quote=False)) elif m.group(3): # $ ou $$ dollars = m.group(3) if dollars == "$$": j = s.find("$$", i) if j == -1: j = n out.append(self.display_math(s[i:j], numbered=False)) i = j + 2 else: j = self._find_math_end(s, i) out.append(self.inline_math(s[i:j])) i = j + 1 elif m.group(4): # { j = find_matching_brace(s, m.start()) inner = s[m.start() + 1:j] out.append(self._group(inner)) i = j + 1 elif m.group(5): # } orphelin pass elif m.group(6): # ~ out.append(NBSP) elif m.group(7): out.append("—") elif m.group(8): out.append("–") elif m.group(9): # \n → espace out.append(" ") elif m.group(10): out.append(html.escape(m.group(10), quote=False)) elif m.group(11): out.append("“") elif m.group(12): out.append("”") elif m.group(13): j = s.find("\\)", i) if j == -1: j = n out.append(self.inline_math(s[i:j])) i = j + 2 return "".join(out) # ------------------------------------------------------------------ def _accent_target(self, s: str, i: int) -> tuple[str, int]: if i < len(s) and s[i] == "{": return read_group(s, i) if i < len(s): return s[i], i + 1 return "", i @staticmethod def _accent(base: str, comb: str) -> str: if not base: return "" return unicodedata.normalize("NFC", base[0] + comb) + base[1:] @staticmethod def _find_math_end(s: str, i: int) -> int: n = len(s) j = i while j < n: if s[j] == "\\": j += 2 continue if s[j] == "$": return j j += 1 return n def _group(self, inner: str) -> str: """Groupe {…} : gère les déclarations en tête (\\bfseries, \\itshape, \\color…).""" t = inner.lstrip() 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) if m: name = m.group(1) rest = t[m.end():] if name == "color": col, k = read_group(rest, 0) return f'{self._run(rest[k:])}' if name in ("bfseries",): return f"{self._run(rest)}" if name in ("itshape", "em", "slshape"): return f"{self._run(rest)}" if name == "scshape": return f'{self._run(rest)}' if name == "ttfamily": return f"{self._run(rest)}" return self._run(rest) return self._run(inner) # ------------------------------------------------------------------ def inline_math(self, tex: str) -> str: tex = tex.strip() if not tex: return "" return self.ctx.add_math("inline", prep_math(tex)) def display_math(self, tex: str, numbered: bool, env: str = "", label: str | None = None) -> str: tex = tex.strip() # labels internes labels = re.findall(r"\\label\{([^}]*)\}", tex) tex = re.sub(r"\\label\{[^}]*\}", "", tex) tag = "" eid = "" if numbered: self.st.c["eq"] += 1 num = f"{self.st.num}.{self.st.c['eq']}" tag = num eid = f"eq-{label_to_id(labels[0])}" if labels else f"eq-{self.st.num}-{self.st.c['eq']}" for lb in labels: self.ctx.labels[lb] = {"kind": "eq", "num": num, "url": f"{self.st.url}#{eid}"} elif labels: eid = f"eq-{label_to_id(labels[0])}" for lb in labels: self.ctx.labels[lb] = {"kind": "eq", "num": "", "url": f"{self.st.url}#{eid}"} body = prep_math(tex, env=env) if tag: body = body + f"\\tag{{{tag}}}" token = self.ctx.add_math("display", body) idattr = f' id="{eid}"' if eid else "" return f'

{token}
' # ------------------------------------------------------------------ def command(self, name: str, s: str, i: int, m) -> tuple[str, int]: st = self.st ctx = self.ctx if name in SIMPLE_MAP: arg, i = read_group(s, i) op, cl = SIMPLE_MAP[name] return op + self._run(arg) + cl, i if name in ZERO_ARG: val = ZERO_ARG[name] if name == "faIcon": _, i = read_group(s, i) return "", i # avaler un {} vide éventuel (\oe{}, \ier{}) if i < len(s) and s[i] == "{" and i + 1 < len(s) and s[i + 1] == "}": i += 2 elif name in ("oe", "OE", "ae", "AE", "ss", "ier", "iere", "ieme", "iemes", "no", "No", "og", "fg"): # sémantique TeX : l'espace qui suit un mot de contrôle est avalée (main-d'\oe uvre → main-d'œuvre) while i < len(s) and s[i] in " \t": i += 1 if i < len(s) and s[i] == "\n" and not (i + 1 < len(s) and s[i + 1:].lstrip(" \t").startswith("\n")): i += 1 while i < len(s) and s[i] in " \t": i += 1 return val, i if name == "textcolor": col, i = read_group(s, i) arg, i = read_group(s, i) if col.strip() == "white": return self._run(arg), i return f'{self._run(arg)}', i if name == "color": col, i = read_group(s, i) # déclaration : s'applique à la suite (déjà géré dans _group) ; ici on ignore return "", i if name in ("bfseries", "itshape", "em", "scshape", "ttfamily", "sffamily", "rmfamily", "mdseries", "upshape", "slshape"): return "", i if name == "motcle": arg, i = read_group(s, i) inner = self._run(arg) plain = plain_text(inner) return f'{inner}', i if name == "dollars": arg, i = read_group(s, i) return f'{fmt_dollars(arg)}', i if name == "num": arg, i = read_group(s, i) return f'{fmt_num(arg)}', i if name == "SI": v, i = read_group(s, i) u, i = read_group(s, i) return f'{fmt_num(v)}{NBSP}{self._run(u)}', i if name == "up": arg, i = read_group(s, i) return f"{self._run(arg)}", i if name == "repere": arg, i = read_group(s, i) return self.sidenote(self._run(arg), kind="repere"), i if name == "margterme": t, i = read_group(s, i) d, i = read_group(s, i) return self.sidenote(f'{self._run(t)} {self._run(d)}', kind="terme"), i if name == "margdate": y, i = read_group(s, i) d, i = read_group(s, i) return self.sidenote(f'{self._run(y)} {self._run(d)}', kind="date"), i if name == "footnote": arg, i = read_group(s, i) return self.sidenote(self._run(arg), kind="note", numbered=True), i if name in ("parencite", "autocite", "textcite", "cite", "citep", "citet", "citeauthor", "citeyear"): opt1, i = read_opt(s, i) opt2, i = read_opt(s, i) keys, i = read_group(s, i) return self.citation(name, keys, opt1, opt2), i if name in ("ref", "eqref", "autoref", "nameref", "pageref", "cref", "Cref"): key, i = read_group(s, i) key = key.strip() if name == "eqref": return f"(@@REF{{{key}}}@@)", i if name == "pageref": return "", i return f"@@REF{{{key}}}@@", i if name == "label": key, i = read_group(s, i) key = key.strip() target = st.pending_label_target or st.current_section_id if target: ctx.labels[key] = {"kind": "sec", "num": st.current_section_num(), "url": f"{st.url}#{target}"} return "", i if name == "href": url, i = read_group(s, i) txt, i = read_group(s, i) return f'{self._run(txt)}', i if name == "url": url, i = read_group(s, i) u = url.strip() return f'{html.escape(u)}', i if name == "includegraphics": _, i = read_opt(s, i) path, i = read_group(s, i) path = path.strip() if not hasattr(ctx, "images"): ctx.images = [] ctx.images.append(path) return f"@@IMG{len(ctx.images) - 1}@@", i if name == "addcontentsline": _, i = read_group(s, i) _, i = read_group(s, i) _, i = read_group(s, i) return "", i if name == "multicolumn": # hors table (ne devrait pas arriver) _, i = read_group(s, i) _, i = read_group(s, i) arg, i = read_group(s, i) return self._run(arg), i if name == "rowcolor" or name == "cellcolor" or name == "arrayrulecolor": _, i = read_opt(s, i) _, i = read_group(s, i) return "", i if name in TWO_ARG_DROP: _, i = read_group(s, i) _, i = read_opt(s, i) _, i = read_group(s, i) return "", i if name in ONE_ARG_DROP: _, i = read_opt(s, i) _, i = read_group(s, i) return "", i if name in ("item",): # \item hors liste (ne devrait pas arriver) _, i = read_opt(s, i) return "
• ", i if name in ("caption", "captionof"): _, i = read_opt(s, i) arg, i = read_group(s, i) return f'{self._run(arg)}', i if name == "paragraph": arg, i = read_group(s, i) return f'{self._run(arg)} ', i if name in ("section", "subsection", "subsubsection", "chapter"): # sectionnement en ligne (dans une cellule ?) : on garde le texte en gras _, i = read_opt(s, i) arg, i = read_group(s, i) return f"{self._run(arg)}", i if name == "verb": # \verb|...| if i < len(s): delim = s[i] j = s.find(delim, i + 1) if j != -1: return f"{html.escape(s[i + 1:j])}", j + 1 return "", i if name == "textsuperscript": arg, i = read_group(s, i) return f"{self._run(arg)}", i if name in ("mathrm", "mathbf", "text"): arg, i = read_group(s, i) return self._run(arg), i if name in ("frac", "tfrac", "dfrac"): a, i = read_group(s, i) b, i = read_group(s, i) return f"{self._run(a)}/{self._run(b)}", i if name == "enteterang": arg, i = read_group(s, i) return f"{self._run(arg)}", i # Inconnue : avertir, avaler un éventuel argument entre accolades ctx.warn(f"[{st.key}] commande inconnue : \\{name}") if i < len(s) and s[i] == "{": arg, i = read_group(s, i) return self._run(arg), i return "", i # ------------------------------------------------------------------ def sidenote(self, inner_html: str, kind: str, numbered: bool = False) -> str: st = self.st st.sidenote += 1 sid = f"sn-{st.num}-{st.sidenote}" cls = f"sidenote sn-{kind}" mark = f'{st.sidenote}' if numbered else '' return (f'' f'' f'{("" + str(st.sidenote) + " ") if numbered else ""}{inner_html}') # ------------------------------------------------------------------ def citation(self, cmd: str, keys: str, pre: str | None, post: str | None) -> str: ctx = self.ctx parts = [] for k in keys.split(","): k = k.strip() if not k: continue ctx.cited.setdefault(k, set()).add(self.st.key) e = ctx.bib.get(k) if not e: ctx.warn(f"[{self.st.key}] clé bibliographique inconnue : {k}") parts.append(f'{html.escape(k)}') continue label = e["short"] year = e.get("year", "s.d.") if cmd in ("textcite", "citet"): txt = f"{label} ({year})" elif cmd == "citeauthor": txt = label elif cmd == "citeyear": txt = year else: txt = f"{label}, {year}" parts.append(f'{html.escape(txt)}') inner = "; ".join(parts) if post: inner += ", " + self._run(post) if pre: inner = self._run(pre) + " " + inner if cmd in ("textcite", "citet", "citeauthor", "citeyear"): return inner return f'({inner})' def plain_text(h: str) -> str: t = re.sub(r"<[^>]+>", "", h) t = html.unescape(t) t = re.sub(r"@@M\d+@@", "", t) return re.sub(r"\s+", " ", t).strip() # ============================================================================= # Préparation des formules pour KaTeX # ============================================================================= def prep_math(tex: str, env: str = "") -> str: t = tex # \dollars{...} et \num{...} t = re.sub(r"\\dollars\{([^{}]*)\}", lambda m: fmt_dollars(m.group(1), math=True), t) t = re.sub(r"\\num\{([^{}]*)\}", lambda m: fmt_num(m.group(1), math=True), t) t = re.sub(r"\\SI\{([^{}]*)\}\{([^{}]*)\}", lambda m: fmt_num(m.group(1), math=True) + r"\,\text{" + m.group(2) + "}", t) t = t.replace("\\notag", "").replace("\\nonumber", "") # exposants en mode texte (pi\up{2}, \textsuperscript{2} dans \text{}) → caractères Unicode sup_map = {"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", "er": "ᵉʳ", "e": "ᵉ", "re": "ʳᵉ", "o": "ᵒ"} t = re.sub(r"\\(?:up|textsuperscript)\{([0-9]|er|re|e|o)\}", lambda m: sup_map[m.group(1)], t) t = re.sub(r"\\ier\{\}|\\ier\b", "ᵉʳ", t) t = re.sub(r"\\ieme\{\}|\\ieme\b", "ᵉ", t) t = re.sub(r"\\intertext\{([^{}]*)\}", r"\\text{\1}\\\\", t) # environnements align → aligned if env in ("align", "align*", "flalign", "flalign*", "eqnarray", "eqnarray*"): t = "\\begin{aligned}" + t + "\\end{aligned}" elif env in ("gather", "gather*"): t = "\\begin{gathered}" + t + "\\end{gathered}" elif env in ("multline", "multline*"): t = "\\begin{gathered}" + t + "\\end{gathered}" # \\[2pt] → \\ t = re.sub(r"\\\\\[[^\]]*\]", r"\\\\", t) # \textonehalf, \ier… gérés par macros KaTeX (voir render_math.mjs) return t.strip() # ============================================================================= # Conversion par blocs # ============================================================================= BOX_ENVS = { # env : (classe css, libellé, numéroté?, compteur, icône, titre par défaut) "definition": ("box-def", "Définition", True, "def", "book", ""), "formule": ("box-form", "Formule", True, "form", "formula", ""), "exemple": ("box-ex", "Exemple", True, "ex", "bulb", ""), "exercice": ("box-exo", "Exercice", True, "exo", "pencil", ""), "attention": ("box-att", "", False, "", "warning", "Attention"), "terrain": ("box-terrain", "", False, "", "hardhat", "Sur le terrain"), "remarque": ("box-rem", "", False, "", "pen", "Remarque"), "quebec": ("box-qc", "", False, "", "map", "Contexte québécois"), "aretenir": ("box-ret", "", False, "", "star", "À retenir"), "pointscles": ("box-pts", "", False, "", "star", "Points clés"), } BLOCK_RE = re.compile( r"\\begin\{([a-zA-Z*]+)\}" r"|\\(chapter|section|subsection|subsubsection|paragraph)(\*?)\s*(?=\{|\[)" r"|\\\[" r"|\\uqosep\b" r"|\\label\{" r"|\\(cleardoublepage|clearpage|newpage|tableofcontents|frontmatter|mainmatter|backmatter|appendix|centering|noindent|medskip|bigskip|smallskip|vfill)\b" r"|\\vspace\*?\{" r"|\\(item)\b" ) HEADING_TAGS = {"chapter": "h1", "section": "h2", "subsection": "h3", "subsubsection": "h4", "paragraph": "h5"} class BlockConverter: def __init__(self, st: ChapterState): self.st = st self.ctx = st.ctx self.inline = InlineConverter(st) # ------------------------------------------------------------------ def convert(self, s: str) -> str: out: list[str] = [] para: list[str] = [] def flush(): txt = "".join(para).strip() para.clear() if txt: out.append(self.paragraph(txt)) i = 0 n = len(s) while i < n: m = BLOCK_RE.search(s, i) if not m: para.append(s[i:]) break # texte avant le bloc : découper par lignes vides chunk = s[i:m.start()] if chunk: pieces = re.split(r"\n[ \t]*\n", chunk) for k, p in enumerate(pieces): if k > 0: flush() para.append(p) i = m.end() if m.group(1): # \begin{env} env = m.group(1) if env in ("tikzpicture",): pass opt, i2 = read_opt(s, i) # certains envs ont un argument obligatoire (tabularx{\textwidth}{spec}, tabular{spec}, minipage{w}) end_start, end_end = find_env_end(s, env, i2) body = s[i2:end_start] i = end_end if env in ("equation", "equation*", "align", "align*", "gather", "gather*", "multline", "multline*", "flalign", "flalign*", "eqnarray", "eqnarray*", "displaymath"): # math hors-texte : reste dans le paragraphe (comme en LaTeX) → on flush avant flush() out.append(self.inline.display_math(body, numbered=not env.endswith("*") and env not in ("displaymath",), env=env)) continue if env in ("itemize", "enumerate", "description"): flush() out.append(self.list_env(env, opt, body)) continue flush() out.append(self.environment(env, opt, body)) elif m.group(2): # sectionnement flush() kind = m.group(2) star = bool(m.group(3)) _, i = read_opt(s, i) title, i = read_group(s, i) # \label éventuel sur la ligne suivante out.append(self.heading(kind, title, star)) elif m.group(0) == "\\[": flush() j = s.find("\\]", i) if j == -1: j = n out.append(self.inline.display_math(s[i:j], numbered=False)) i = j + 2 elif m.group(0).startswith("\\uqosep"): flush() out.append('
') elif m.group(0) == "\\label{": key_end = find_matching_brace(s, m.end() - 1) key = s[m.end():key_end].strip() i = key_end + 1 if para and "".join(para).strip(): # label au milieu d'un paragraphe : pointe vers le paragraphe courant (id ajouté au flush ? simplifié) self.ctx.labels[key] = {"kind": "sec", "num": self.st.current_section_num(), "url": f"{self.st.url}#{self.st.current_section_id}"} else: target = self.st.pending_label_target or self.st.current_section_id self.ctx.labels[key] = {"kind": "sec", "num": self.st.current_section_num(), "url": f"{self.st.url}#{target}"} elif m.group(4): if m.group(4) in ("medskip", "bigskip", "smallskip"): flush() # autres : ignorés elif m.group(0).startswith("\\vspace"): _, i = read_group(s, m.end() - 1) elif m.group(5): # \item hors liste _, i = read_opt(s, i) para.append(" • ") flush() return "\n".join(x for x in out if x) # ------------------------------------------------------------------ def paragraph(self, txt: str) -> str: st = self.st # \paragraph{Titre.} en tête de paragraphe → titre courant h = self.inline.convert(txt) h = h.strip() if not h: return "" # nettoyer

issus de \par pid = st.next_id(f"p{st.num}") if h.startswith("
") and h.count("{h}

' # ------------------------------------------------------------------ def heading(self, kind: str, title: str, star: bool) -> str: st = self.st title_html = self.inline.convert(title) title_text = plain_text(title_html) if kind == "chapter": st.title_html = title_html st.title_text = title_text st.pending_label_target = "top" st.current_section_id = "top" st.current_section_title = title_text return "" # le titre de chapitre est rendu par le gabarit de page if kind == "paragraph": return f'
{title_html}
' if kind == "section": if not star: st.sec += 1 st.subsec = 0 st.subsubsec = 0 num = f"{st.num}.{st.sec}" else: num = "" sid = f"s-{st.num}-{st.sec}" if not star else f"s-{st.num}-{slugify(title_text)}" elif kind == "subsection": if not star: st.subsec += 1 st.subsubsec = 0 num = f"{st.num}.{st.sec}.{st.subsec}" else: num = "" sid = f"s-{st.num}-{st.sec}-{st.subsec}" if not star else f"s-{st.num}-{st.sec}-{slugify(title_text)}" else: if not star: st.subsubsec += 1 num = f"{st.num}.{st.sec}.{st.subsec}.{st.subsubsec}" else: num = "" sid = f"s-{st.num}-{st.sec}-{st.subsec}-{st.subsubsec}" if not star else f"s-{st.num}-{slugify(title_text)}" st.current_section_id = sid st.current_section_title = title_text st.pending_label_target = sid st._last_num = num level = {"section": 2, "subsection": 3, "subsubsection": 4}[kind] st.toc.append((level, num, title_html, sid)) tag = HEADING_TAGS[kind] numspan = f'{num}' if num else "" return f'<{tag} id="{sid}" class="h-{kind}">{numspan}{title_html}#' # ------------------------------------------------------------------ def list_env(self, env: str, opt: str | None, body: str) -> str: st = self.st items = split_top_level(body, r"\\item\b") # items[0] = texte avant le premier \item (vide) lis = [] st.list_depth += 1 for raw in items[1:]: lab, k = read_opt(raw, 0) content = raw[k:].strip() inner = self.convert(content) # déballer un paragraphe unique mm = re.fullmatch(r'

(.*)

', inner, flags=re.S) if mm: inner = mm.group(1) if st.in_objectifs and st.list_depth == 1: st.obj_index += 1 oid = f"obj-{st.num}-{st.obj_index}" st.objectives.append(plain_text(inner)) lis.append(f'
  • ') elif lab is not None: lis.append(f'
  • {self.inline.convert(lab)}
    {inner}
  • ') else: lis.append(f"
  • {inner}
  • ") st.list_depth -= 1 if env == "enumerate": return '
      ' + "".join(lis) + "
    " if env == "description": return '" return "" # ------------------------------------------------------------------ def environment(self, env: str, opt: str | None, body: str) -> str: st = self.st ctx = self.ctx if env in BOX_ENVS: return self.box(env, opt, body) if env == "solution": inner = self.convert(body) return ('
    ' 'SolutionCliquer pour révéler' f'
    {inner}
    ') if env == "objectifs": st.in_objectifs = True inner = self.convert(body) st.in_objectifs = False return ('
    ' 'Objectifs d’apprentissage
    ' f'
    {inner}
    ') if env == "fichesynthese": inner = self.convert(body) h = ('
    ' 'L’essentiel de la séance
    ' f'
    {inner}
    ') st.synth_html = inner st.search.append(SearchEntry("synthese", "synth", "L’essentiel de la séance", plain_text(inner))) return h if env == "center": inner = self.convert(body) return f'
    {inner}
    ' if env in ("flushleft", "flushright", "quote", "quotation", "small", "footnotesize", "minipage", "adjustbox"): if env == "minipage": _, k = read_group(body, 0) body = body[k:] inner = self.convert(body) cls = {"quote": "quote", "quotation": "quote"}.get(env, env) return f'
    {inner}
    ' if env in ("table", "table*"): return self.float_env("tab", body) if env in ("figure", "figure*"): return self.float_env("fig", body) if env in ("tabular", "tabularx", "tabular*", "longtable", "tabulary"): return self.tabular(env, body) if env == "tikzpicture": tex = f"\\begin{{tikzpicture}}{('[' + opt + ']') if opt is not None else ''}{body}\\end{{tikzpicture}}" token = ctx.add_tikz(tex) return f'
    {token}
    ' if env in ("titlepage", "abstract", "verbatim", "comment", "lstlisting"): if env == "verbatim" or env == "lstlisting": return f"
    {html.escape(body)}
    " return "" if env == "tcolorbox": inner = self.convert(body) return f'
    {inner}
    ' ctx.warn(f"[{st.key}] environnement inconnu : {env}") return f'
    {self.convert(body)}
    ' # ------------------------------------------------------------------ def box(self, env: str, opt: str | None, body: str) -> str: st = self.st ctx = self.ctx cls, label, numbered, counter, icon, default_title = BOX_ENVS[env] # label interne au début de la boîte labels = re.findall(r"\\label\{([^}]*)\}", body) body_wo = re.sub(r"\\label\{[^}]*\}", "", body) num = "" if numbered: st.c[counter] += 1 num = f"{st.num}.{st.c[counter]}" bid = f"{counter}-{st.num}-{st.c[counter]}" else: bid = st.next_id(f"{counter or env}-{st.num}") title_html = self.inline.convert(opt) if opt else (default_title if not numbered else "") for lb in labels: ctx.labels[lb] = {"kind": env, "num": num, "url": f"{st.url}#{bid}"} inner = self.convert(body_wo) if numbered: head_label = f"{label} {num}" title_part = f'— {title_html}' if title_html else "" else: head_label = title_html or default_title title_part = "" h = (f'
    ' f'{head_label}{title_part}' f'#
    ' f'
    {inner}
    ') entry = {"chapter": st.num, "chapter_key": st.key, "num": num, "title": plain_text(title_html) if title_html else "", "title_html": title_html, "html": inner, "id": bid, "url": f"{st.url}#{bid}", "text": plain_text(inner)} if env == "formule": ctx.formulas.append(entry) elif env == "definition": ctx.definitions.append(entry) elif env == "exemple": ctx.examples.append(entry) elif env == "exercice": ctx.exercises.append(entry) st.search.append(SearchEntry(bid, env, f"{head_label}{(' — ' + entry['title']) if entry['title'] and numbered else ''}", entry["text"])) return h # ------------------------------------------------------------------ def float_env(self, kind: str, body: str) -> str: st = self.st ctx = self.ctx # extraire caption et label caption = "" labels = [] m = re.search(r"\\caption\b", body) if m: _, k = read_opt(body, m.end()) cap, k2 = read_group(body, k) caption = cap body = body[:m.start()] + body[k2:] for lm in re.finditer(r"\\label\{([^}]*)\}", body): labels.append(lm.group(1)) body = re.sub(r"\\label\{[^}]*\}", "", body) body = re.sub(r"\\centering|\\small|\\footnotesize|\\scriptsize", "", body) st.c[kind] += 1 num = f"{st.num}.{st.c[kind]}" fid = f"{kind}-{label_to_id(labels[0])}" if labels else f"{kind}-{st.num}-{st.c[kind]}" for lb in labels: ctx.labels[lb] = {"kind": kind, "num": num, "url": f"{st.url}#{fid}"} inner = self.convert(body) cap_html = self.inline.convert(caption) if caption else "" word = "Tableau" if kind == "tab" else "Figure" figcap = f'
    {word} {num} {cap_html}
    ' if caption else "" cls = "float-table" if kind == "tab" else "float-figure" if kind == "fig": ctx.figures.append({"chapter": st.num, "num": num, "caption": plain_text(cap_html), "id": fid, "url": f"{st.url}#{fid}"}) st.search.append(SearchEntry(fid, kind, f"{word} {num}", plain_text(cap_html) + " " + plain_text(inner)[:400])) return f'
    {figcap if kind == "tab" else ""}{inner}{figcap if kind == "fig" else ""}
    ' # ------------------------------------------------------------------ def tabular(self, env: str, body: str) -> str: # arguments : tabularx{\textwidth}{spec} ; tabular{spec} ; tabular*{w}{spec} k = 0 if env in ("tabularx", "tabular*", "tabulary"): _, k = read_group(body, 0) spec, k = read_group(body, k) rows_raw = body[k:] cols = parse_colspec(spec) rows = split_top_level(rows_raw, r"\\\\(\[[^\]]*\])?") parsed = [] # (cells, flags) header_end = None pending_rule = False for ridx, raw in enumerate(rows): r = raw flags = {"rule_above": False, "header_color": False} # règles et couleurs en tête de ligne while True: r2 = r.lstrip() mm = re.match(r"\\(toprule|midrule|bottomrule|hline|addlinespace|specialrule|cmidrule|cline)(\[[^\]]*\])?(\{[^}]*\})*(\([^)]*\))?(\{[^}]*\})*", r2) if mm: if mm.group(1) == "midrule" or mm.group(1) == "hline": if header_end is None and parsed: header_end = len(parsed) elif parsed: flags["rule_above"] = True r = r2[mm.end():] continue mm = re.match(r"\\rowcolor(\[[^\]]*\])?\{([^}]*)\}", r2) if mm: if mm.group(2).startswith("uqoBleu") and not mm.group(2).startswith("uqoBleuClair"): flags["header_color"] = True r = r2[mm.end():] continue mm = re.match(r"\\(renewcommand|setlength)\{[^}]*\}\{[^}]*\}", r2) if mm: r = r2[mm.end():] continue mm = re.match(r"\\(small|footnotesize|scriptsize|centering|arraybackslash|raggedright|noalign\{[^}]*\})", r2) if mm: r = r2[mm.end():] continue break if not r.strip(): continue cells = split_top_level(r, r"&") parsed.append((cells, flags)) if header_end is None: header_end = 1 if parsed and parsed[0][1]["header_color"] else 0 # rendu thead = [] tbody = [] for idx, (cells, flags) in enumerate(parsed): is_head = idx < header_end tds = [] col = 0 for c in cells: c = c.strip() span = 1 align = None mm = re.match(r"\\multicolumn\{(\d+)\}", c) if mm: span = int(mm.group(1)) sp, kk = read_group(c, mm.end()) align = parse_colspec(sp)[0] if parse_colspec(sp) else "l" c, kk2 = read_group(c, kk) if align is None: align = cols[col] if col < len(cols) else "l" col += span inner = self.inline.convert(c) # cellule d'en-tête : retirer les superflus (déjà en gras via CSS) tag = "th" if is_head or flags["header_color"] else "td" if tag == "th": inner = re.sub(r"^(.*)$", r"\1", inner, flags=re.S) cls = {"r": "al-r", "c": "al-c"}.get(align, "") attrs = "" if cls: attrs += f' class="{cls}"' if span > 1: attrs += f' colspan="{span}"' tds.append(f"<{tag}{attrs}>{inner}") rcls = [] if flags["rule_above"]: rcls.append("rule-above") tr = f'' + "".join(tds) + "" if is_head: thead.append(tr) else: tbody.append(tr) ncols = len(cols) wide = " table-wide" if ncols >= 5 else "" h = f'
    ' if thead: h += "" + "".join(thead) + "" h += "" + "".join(tbody) + "
    " return h def parse_colspec(spec: str) -> list[str]: s = spec # retirer @{...}, >{...}, <{...}, !{...} out = [] i = 0 n = len(s) while i < n: c = s[i] if c in "@> ChapterResult: st = ChapterState(ctx, key, num, url) tex = strip_comments(tex) bc = BlockConverter(st) body = bc.convert(tex) # labels de chapitre m = re.search(r"\\label\{(ch:[^}]*|ann[A-Z][^}]*)\}", tex) if m: ctx.labels[m.group(1)] = {"kind": "chapter", "num": num, "url": url} res = ChapterResult(key=key, num=num, title_html=st.title_html, title_text=st.title_text, html=body, toc=st.toc, search=st.search, counts={"def": st.c["def"], "form": st.c["form"], "ex": st.c["ex"], "exo": st.c["exo"], "tab": st.c["tab"], "fig": st.c["fig"], "eq": st.c["eq"], "sidenotes": st.sidenote}, objectives=st.objectives, synth_html=st.synth_html) return res def resolve_refs(h: str, ctx: Ctx, current_url: str = "") -> str: def rep(m): key = m.group(1) e = ctx.labels.get(key) if not e: ctx.warn(f"renvoi non résolu : {key}") return '?' url = e["url"] if current_url and url.startswith(current_url) and "#" in url: url = url[url.index("#"):] num = e["num"] or "↗" return f'{num}' return re.sub(r"@@REF\{([^}]*)\}@@", rep, h) if __name__ == "__main__": # test rapide : python3 latex2html.py fichier.tex ctx = Ctx("TEST") src = open(sys.argv[1], encoding="utf-8").read() r = convert_chapter(ctx, "ch01", "1", "/seance/01/", src) print(r.html[:5000]) print("WARN:", "\n".join(ctx.warnings[:40])) print("math:", len(ctx.math), "tikz:", len(ctx.tikz), "labels:", len(ctx.labels))