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": '
",
}
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'
{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('(.*)
', 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'{html.escape(body)}"
return ""
if env == "tcolorbox":
inner = self.convert(body)
return f'