TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1// Parseur LaTeX pragmatique pour le corpus UQO : diapositives beamer (frames titrées,2// boîtes sémantiques tcolorbox) et documents article (sections). Travaille sur la SOURCE,3// ce qui garantit des numéros de diapositives fiables et des équations intactes.45export type ParsedFrame = {6 slideNumber: number; // numéro visible dans le PDF compilé (noframenumbering exclu)7 title: string;8 sectionTitle: string;9 content: string; // texte détexifié indexable10 boxTypes: string[];11};1213export type ParsedSection = {14 title: string;15 path: string; // « Section > Sous-section »16 content: string;17};1819const BOX_LABELS: Record<string, string> = {20 defbox: "Définition",21 definitionbox: "Définition",22 conceptbox: "Concept",23 importbox: "Important",24 exbox: "Exemple",25 notebox: "Note",26 quizbox: "Question éclair",27 infobox: "Information",28 alertbox: "Attention",29 attentionbox: "Attention",30 warnbox: "Attention",31 formbox: "Formule",32 formulebox: "Formule",33 calculbox: "Calcul",34 donneebox: "Données",35 donneesbox: "Données",36 enoncebox: "Énoncé",37 travailbox: "Travail demandé",38 resultatbox: "Résultat",39 reconbox: "Réconciliation",40 rappelbox: "Rappel",41 astucebox: "Astuce",42 conseilbox: "Conseil",43 tipbox: "Astuce",44};4546/** Retire les commentaires LaTeX (% en fin de ligne, pas \%). */47export function stripComments(tex: string): string {48 return tex49 .split("\n")50 .map((line) => {51 let out = "";52 for (let i = 0; i < line.length; i++) {53 if (line[i] === "%" && line[i - 1] !== "\\") return out;54 out += line[i];55 }56 return out;57 })58 .join("\n");59}6061/** Extrait le contenu d'un environnement balancé à partir d'un index (après \begin{env}). */62function findEnvEnd(tex: string, env: string, from: number): number {63 const begin = `\\begin{${env}}`;64 const end = `\\end{${env}}`;65 let depth = 1;66 let i = from;67 while (i < tex.length) {68 const nb = tex.indexOf(begin, i);69 const ne = tex.indexOf(end, i);70 if (ne === -1) return tex.length;71 if (nb !== -1 && nb < ne) {72 depth++;73 i = nb + begin.length;74 } else {75 depth--;76 if (depth === 0) return ne;77 i = ne + end.length;78 }79 }80 return tex.length;81}8283/** Lit un groupe {…} balancé à partir d'une accolade ouvrante. */84function readGroup(tex: string, openBrace: number): { content: string; end: number } {85 let depth = 0;86 for (let i = openBrace; i < tex.length; i++) {87 if (tex[i] === "{" && tex[i - 1] !== "\\") depth++;88 else if (tex[i] === "}" && tex[i - 1] !== "\\") {89 depth--;90 if (depth === 0) return { content: tex.slice(openBrace + 1, i), end: i };91 }92 }93 return { content: tex.slice(openBrace + 1), end: tex.length };94}9596/** Convertit un tabular en lignes « a | b | c ». */97function tabularToText(body: string): string {98 const noFormat = body99 .replace(/\\(top|mid|bottom)rule/g, "")100 .replace(/\\hline/g, "")101 .replace(/\\cline\{[^}]*\}/g, "")102 .replace(/\\rowcolor\{[^}]*\}/g, "")103 .replace(/\\arrayrulecolor\{[^}]*\}/g, "")104 .replace(/\\multicolumn\{\d+\}\{[^}]*\}/g, "")105 .replace(/\\multirow\{[^}]*\}\{[^}]*\}/g, "");106 return noFormat107 .split("\\\\")108 .map((row) =>109 row110 .split(/(?<!\\)&/)111 .map((c) => detexify(c).trim())112 .filter(Boolean)113 .join(" | ")114 )115 .map((r) => r.trim())116 .filter((r) => r.length > 1)117 .join("\n");118}119120/** Détexification : LaTeX → texte lisible/indexable. Les segments mathématiques121 * ($…$ et \[…\]) sont protégés tels quels pour un rendu KaTeX fidèle. */122export function detexify(tex: string): string {123 // Maths affichées → $$…$$, puis mise à l'abri de tous les segments mathématiques124 let s = tex.replace(/\\\[/g, "$$$$").replace(/\\\]/g, "$$$$");125 const mathSegs: string[] = [];126 s = s.replace(/\$\$[\s\S]{1,800}?\$\$|\$[^$\n]{1,300}\$/g, (m) => {127 mathSegs.push(m);128 return `\u0001${mathSegs.length - 1}\u0001`;129 });130 s = detexifyText(s);131 s = s.replace(/\u0001(\d+)\u0001/g, (_m, i) => ` ${mathSegs[parseInt(i, 10)]} `);132 return s.replace(/[ \t]+/g, " ").trim();133}134135function detexifyText(tex: string): string {136 let s = tex;137138 // Environnements à aplatir spécialement139 s = s.replace(/\\begin\{(tikzpicture|axis|pgfplots)\}[\s\S]*?\\end\{\1\}/g, " [schéma] ");140 // tabular(x) → texte tabulaire141 for (const env of ["tabularx", "tabular", "longtable"]) {142 let idx = s.indexOf(`\\begin{${env}}`);143 while (idx !== -1) {144 // sauter les spécificateurs de colonnes {..}{..} et options [..]145 let cursor = idx + `\\begin{${env}}`.length;146 let skipped = 0;147 while (cursor < s.length && skipped < 2) {148 while (cursor < s.length && /\s/.test(s[cursor])) cursor++;149 if (s[cursor] === "[") cursor = s.indexOf("]", cursor) + 1;150 else if (s[cursor] === "{") {151 cursor = readGroup(s, cursor).end + 1;152 skipped++;153 } else break;154 }155 const end = findEnvEnd(s, env, cursor);156 const table = tabularToText(s.slice(cursor, end));157 s = s.slice(0, idx) + "\n" + table + "\n" + s.slice(end + `\\end{${env}}`.length);158 idx = s.indexOf(`\\begin{${env}}`);159 }160 }161162 // Items163 s = s.replace(/\\item\s*/g, "\n• ");164 // Environnements structurels transparents165 s = s.replace(/\\(begin|end)\{(itemize|enumerate|description|center|columns|column|block|flushleft|flushright|minipage|small|footnotesize|scriptsize|frame)\}(\[[^\]]*\])?(\{[^}]*\})*/g, " ");166 // Espaces et sauts167 s = s.replace(/\\(vspace|hspace|vskip|hskip)\*?\{[^}]*\}/g, " ");168 s = s.replace(/\\(par|smallskip|medskip|bigskip|newline|linebreak|pause|centering|raggedright|noindent|footnotesize|scriptsize|small|large|Large|huge|Huge|normalsize|tiny)\b/g, " ");169 s = s.replace(/\\\\(\[[^\]]*\])?/g, "\n");170 // Guillemets français171 s = s.replace(/\\og\s*/g, "« ").replace(/\\fg\{?\}?/g, " »");172 // Commandes à un argument dont on garde le contenu173 for (let pass = 0; pass < 4; pass++) {174 s = s.replace(175 /\\(textbf|textit|emph|underline|texttt|textsc|textcolor\{[^}]*\}|colorbox\{[^}]*\}|mbox|text|textsuperscript|textsubscript|fbox|highlight|alert|structure|hl)\{([^{}]*)\}/g,176 "$2"177 );178 }179 // \href{url}{texte} → texte (url)180 s = s.replace(/\\href\{([^}]*)\}\{([^}]*)\}/g, "$2 ($1)");181 s = s.replace(/\\url\{([^}]*)\}/g, "$1");182 // Commutateurs de couleur : la commande ET son argument disparaissent183 s = s.replace(/\\(color|pagecolor|cellcolor|columncolor|arrayrulecolor)\{[^}]*\}/g, " ");184 // Notes de bas de page → parenthèses185 s = s.replace(/\\footnote\{([^{}]*)\}/g, " ($1)");186 // Citations bibliographiques187 s = s.replace(/\\(auto|text|paren|foot)?cite[tp]?\*?(\[[^\]]*\])*\{[^}]*\}/g, "");188 // Icônes et images189 s = s.replace(/\\(faIcon|includegraphics)(\[[^\]]*\])?\{[^}]*\}/g, " ");190 // Tirets TeX et espaces fines (hors mode math)191 s = s.replace(/(?<!\\)---/g, " — ").replace(/(?<![-\\])--(?!-)/g, "–");192 s = s.replace(/\\[,;:!]/g, " ");193 // Symboles usuels194 s = s195 .replace(/\\%/g, "%")196 .replace(/\\\$/g, "$$")197 .replace(/\\&/g, "&")198 .replace(/\\_/g, "_")199 .replace(/\\#/g, "#")200 .replace(/~/g, " ")201 .replace(/\\ldots|\\dots/g, "…")202 .replace(/\\rightarrow|\\to/g, "→")203 .replace(/\\leftarrow/g, "←")204 .replace(/\\Rightarrow/g, "⇒")205 .replace(/\\times/g, "×")206 .replace(/\\approx/g, "≈")207 .replace(/\\neq/g, "≠")208 .replace(/\\leq|\\le\b/g, "≤")209 .replace(/\\geq|\\ge\b/g, "≥");210 // Toute commande restante sans argument → retirer le backslash-nom, garder les args {} éventuels211 s = s.replace(/\\[a-zA-Z@]+\*?(\[[^\]]*\])?/g, " ");212 // Accolades restantes213 s = s.replace(/[{}]/g, " ");214 // Nettoyage espace215 s = s.replace(/[ \t]+/g, " ").replace(/ *\n */g, "\n").replace(/\n{3,}/g, "\n\n");216 return s.trim();217}218219/** Extrait les boîtes sémantiques d'un frame et les remplace par un texte préfixé. */220function flattenBoxes(body: string, found: string[]): string {221 let s = body;222 for (const [env, label] of Object.entries(BOX_LABELS)) {223 let idx = s.indexOf(`\\begin{${env}}`);224 while (idx !== -1) {225 let cursor = idx + `\\begin{${env}}`.length;226 let boxTitle = "";227 if (s[cursor] === "[") {228 const close = s.indexOf("]", cursor);229 boxTitle = s.slice(cursor + 1, close);230 cursor = close + 1;231 }232 const end = findEnvEnd(s, env, cursor);233 const inner = s.slice(cursor, end);234 found.push(env);235 const replacement = `\n${label}${boxTitle ? ` — ${detexify(boxTitle)}` : ""} : ${inner}\n`;236 s = s.slice(0, idx) + replacement + s.slice(end + `\\end{${env}}`.length);237 idx = s.indexOf(`\\begin{${env}}`);238 }239 }240 return s;241}242243/** Parse un document beamer en frames numérotées comme dans le PDF. */244export function parseBeamerFrames(rawTex: string): ParsedFrame[] {245 const tex = stripComments(rawTex);246 const frames: ParsedFrame[] = [];247 let slideNumber = 0;248 let currentSection = "";249 // Parcours linéaire : sections et frames dans l'ordre250 const tokens = [...tex.matchAll(/\\section\*?\{|\\begin\{frame\}/g)];251 for (const tok of tokens) {252 if (tok[0].startsWith("\\section")) {253 const { content } = readGroup(tex, tex.indexOf("{", tok.index));254 currentSection = detexify(content);255 continue;256 }257 const frameStart = tok.index + "\\begin{frame}".length;258 let cursor = frameStart;259 let options = "";260 if (tex[cursor] === "[") {261 const close = tex.indexOf("]", cursor);262 options = tex.slice(cursor + 1, close);263 cursor = close + 1;264 }265 let title = "";266 if (tex[cursor] === "{") {267 const g = readGroup(tex, cursor);268 title = detexify(g.content);269 cursor = g.end + 1;270 }271 // sous-titre optionnel {…}272 if (tex[cursor] === "{") {273 const g = readGroup(tex, cursor);274 cursor = g.end + 1;275 }276 const end = findEnvEnd(tex, "frame", cursor);277 const body = tex.slice(cursor, end);278 const unnumbered = /noframenumbering/.test(options);279 if (!unnumbered) slideNumber++;280 if (/\\titlepage|\\tableofcontents/.test(body) && !title) continue;281 const boxTypes: string[] = [];282 const flattened = flattenBoxes(body, boxTypes);283 const content = detexify(flattened);284 if (!content && !title) continue;285 frames.push({286 slideNumber: unnumbered ? Math.max(1, slideNumber) : slideNumber,287 title: title || "(sans titre)",288 sectionTitle: currentSection,289 content,290 boxTypes: [...new Set(boxTypes)],291 });292 }293 return frames;294}295296/** Parse un document article en sections/sous-sections. */297export function parseArticleSections(rawTex: string): ParsedSection[] {298 const tex = stripComments(rawTex);299 const beginDoc = tex.indexOf("\\begin{document}");300 const body = beginDoc === -1 ? tex : tex.slice(beginDoc);301 const matches = [...body.matchAll(/\\(section|subsection|subsubsection)\*?\{/g)];302 const sections: ParsedSection[] = [];303 let currentSection = "";304 if (matches.length === 0) {305 const content = detexify(flattenBoxes(body.replace(/\\(begin|end)\{document\}/g, ""), []));306 if (content) sections.push({ title: "Document", path: "Document", content });307 return sections;308 }309 for (let i = 0; i < matches.length; i++) {310 const m = matches[i];311 const level = m[1];312 const g = readGroup(body, body.indexOf("{", m.index));313 const title = detexify(g.content);314 if (level === "section") currentSection = title;315 const start = g.end + 1;316 const end = i + 1 < matches.length ? matches[i + 1].index : body.indexOf("\\end{document}", start);317 const raw = body.slice(start, end === -1 ? undefined : end);318 const content = detexify(flattenBoxes(raw, []));319 if (!content) continue;320 sections.push({321 title,322 path: level === "section" ? title : `${currentSection} > ${title}`,323 content,324 });325 }326 return sections;327}328329/** Redécoupe un texte long en morceaux ~maxLen en respectant les paragraphes. */330export function splitLong(text: string, maxLen = 1800, overlapSentences = 1): string[] {331 if (text.length <= maxLen) return [text];332 const paragraphs = text.split(/\n\n+/);333 const parts: string[] = [];334 let buf = "";335 for (const p of paragraphs) {336 if (buf.length + p.length + 2 > maxLen && buf) {337 parts.push(buf.trim());338 const sentences = buf.split(/(?<=[.!?…])\s+/);339 buf = sentences.slice(-overlapSentences).join(" ") + "\n\n" + p;340 } else {341 buf = buf ? buf + "\n\n" + p : p;342 }343 }344 if (buf.trim()) parts.push(buf.trim());345 return parts;346}347