// Parseur LaTeX pragmatique pour le corpus UQO : diapositives beamer (frames titrées, // boîtes sémantiques tcolorbox) et documents article (sections). Travaille sur la SOURCE, // ce qui garantit des numéros de diapositives fiables et des équations intactes. export type ParsedFrame = { slideNumber: number; // numéro visible dans le PDF compilé (noframenumbering exclu) title: string; sectionTitle: string; content: string; // texte détexifié indexable boxTypes: string[]; }; export type ParsedSection = { title: string; path: string; // « Section > Sous-section » content: string; }; const BOX_LABELS: Record = { defbox: "Définition", definitionbox: "Définition", conceptbox: "Concept", importbox: "Important", exbox: "Exemple", notebox: "Note", quizbox: "Question éclair", infobox: "Information", alertbox: "Attention", attentionbox: "Attention", warnbox: "Attention", formbox: "Formule", formulebox: "Formule", calculbox: "Calcul", donneebox: "Données", donneesbox: "Données", enoncebox: "Énoncé", travailbox: "Travail demandé", resultatbox: "Résultat", reconbox: "Réconciliation", rappelbox: "Rappel", astucebox: "Astuce", conseilbox: "Conseil", tipbox: "Astuce", }; /** Retire les commentaires LaTeX (% en fin de ligne, pas \%). */ export function stripComments(tex: string): string { return tex .split("\n") .map((line) => { let out = ""; for (let i = 0; i < line.length; i++) { if (line[i] === "%" && line[i - 1] !== "\\") return out; out += line[i]; } return out; }) .join("\n"); } /** Extrait le contenu d'un environnement balancé à partir d'un index (après \begin{env}). */ function findEnvEnd(tex: string, env: string, from: number): number { const begin = `\\begin{${env}}`; const end = `\\end{${env}}`; let depth = 1; let i = from; while (i < tex.length) { const nb = tex.indexOf(begin, i); const ne = tex.indexOf(end, i); if (ne === -1) return tex.length; if (nb !== -1 && nb < ne) { depth++; i = nb + begin.length; } else { depth--; if (depth === 0) return ne; i = ne + end.length; } } return tex.length; } /** Lit un groupe {…} balancé à partir d'une accolade ouvrante. */ function readGroup(tex: string, openBrace: number): { content: string; end: number } { let depth = 0; for (let i = openBrace; i < tex.length; i++) { if (tex[i] === "{" && tex[i - 1] !== "\\") depth++; else if (tex[i] === "}" && tex[i - 1] !== "\\") { depth--; if (depth === 0) return { content: tex.slice(openBrace + 1, i), end: i }; } } return { content: tex.slice(openBrace + 1), end: tex.length }; } /** Convertit un tabular en lignes « a | b | c ». */ function tabularToText(body: string): string { const noFormat = body .replace(/\\(top|mid|bottom)rule/g, "") .replace(/\\hline/g, "") .replace(/\\cline\{[^}]*\}/g, "") .replace(/\\rowcolor\{[^}]*\}/g, "") .replace(/\\arrayrulecolor\{[^}]*\}/g, "") .replace(/\\multicolumn\{\d+\}\{[^}]*\}/g, "") .replace(/\\multirow\{[^}]*\}\{[^}]*\}/g, ""); return noFormat .split("\\\\") .map((row) => row .split(/(? detexify(c).trim()) .filter(Boolean) .join(" | ") ) .map((r) => r.trim()) .filter((r) => r.length > 1) .join("\n"); } /** Détexification : LaTeX → texte lisible/indexable. Les segments mathématiques * ($…$ et \[…\]) sont protégés tels quels pour un rendu KaTeX fidèle. */ export function detexify(tex: string): string { // Maths affichées → $$…$$, puis mise à l'abri de tous les segments mathématiques let s = tex.replace(/\\\[/g, "$$$$").replace(/\\\]/g, "$$$$"); const mathSegs: string[] = []; s = s.replace(/\$\$[\s\S]{1,800}?\$\$|\$[^$\n]{1,300}\$/g, (m) => { mathSegs.push(m); return `\u0001${mathSegs.length - 1}\u0001`; }); s = detexifyText(s); s = s.replace(/\u0001(\d+)\u0001/g, (_m, i) => ` ${mathSegs[parseInt(i, 10)]} `); return s.replace(/[ \t]+/g, " ").trim(); } function detexifyText(tex: string): string { let s = tex; // Environnements à aplatir spécialement s = s.replace(/\\begin\{(tikzpicture|axis|pgfplots)\}[\s\S]*?\\end\{\1\}/g, " [schéma] "); // tabular(x) → texte tabulaire for (const env of ["tabularx", "tabular", "longtable"]) { let idx = s.indexOf(`\\begin{${env}}`); while (idx !== -1) { // sauter les spécificateurs de colonnes {..}{..} et options [..] let cursor = idx + `\\begin{${env}}`.length; let skipped = 0; while (cursor < s.length && skipped < 2) { while (cursor < s.length && /\s/.test(s[cursor])) cursor++; if (s[cursor] === "[") cursor = s.indexOf("]", cursor) + 1; else if (s[cursor] === "{") { cursor = readGroup(s, cursor).end + 1; skipped++; } else break; } const end = findEnvEnd(s, env, cursor); const table = tabularToText(s.slice(cursor, end)); s = s.slice(0, idx) + "\n" + table + "\n" + s.slice(end + `\\end{${env}}`.length); idx = s.indexOf(`\\begin{${env}}`); } } // Items s = s.replace(/\\item\s*/g, "\n• "); // Environnements structurels transparents s = s.replace(/\\(begin|end)\{(itemize|enumerate|description|center|columns|column|block|flushleft|flushright|minipage|small|footnotesize|scriptsize|frame)\}(\[[^\]]*\])?(\{[^}]*\})*/g, " "); // Espaces et sauts s = s.replace(/\\(vspace|hspace|vskip|hskip)\*?\{[^}]*\}/g, " "); s = s.replace(/\\(par|smallskip|medskip|bigskip|newline|linebreak|pause|centering|raggedright|noindent|footnotesize|scriptsize|small|large|Large|huge|Huge|normalsize|tiny)\b/g, " "); s = s.replace(/\\\\(\[[^\]]*\])?/g, "\n"); // Guillemets français s = s.replace(/\\og\s*/g, "« ").replace(/\\fg\{?\}?/g, " »"); // Commandes à un argument dont on garde le contenu for (let pass = 0; pass < 4; pass++) { s = s.replace( /\\(textbf|textit|emph|underline|texttt|textsc|textcolor\{[^}]*\}|colorbox\{[^}]*\}|mbox|text|textsuperscript|textsubscript|fbox|highlight|alert|structure|hl)\{([^{}]*)\}/g, "$2" ); } // \href{url}{texte} → texte (url) s = s.replace(/\\href\{([^}]*)\}\{([^}]*)\}/g, "$2 ($1)"); s = s.replace(/\\url\{([^}]*)\}/g, "$1"); // Commutateurs de couleur : la commande ET son argument disparaissent s = s.replace(/\\(color|pagecolor|cellcolor|columncolor|arrayrulecolor)\{[^}]*\}/g, " "); // Notes de bas de page → parenthèses s = s.replace(/\\footnote\{([^{}]*)\}/g, " ($1)"); // Citations bibliographiques s = s.replace(/\\(auto|text|paren|foot)?cite[tp]?\*?(\[[^\]]*\])*\{[^}]*\}/g, ""); // Icônes et images s = s.replace(/\\(faIcon|includegraphics)(\[[^\]]*\])?\{[^}]*\}/g, " "); // Tirets TeX et espaces fines (hors mode math) s = s.replace(/(? ${title}`, content, }); } return sections; } /** Redécoupe un texte long en morceaux ~maxLen en respectant les paragraphes. */ export function splitLong(text: string, maxLen = 1800, overlapSentences = 1): string[] { if (text.length <= maxLen) return [text]; const paragraphs = text.split(/\n\n+/); const parts: string[] = []; let buf = ""; for (const p of paragraphs) { if (buf.length + p.length + 2 > maxLen && buf) { parts.push(buf.trim()); const sentences = buf.split(/(?<=[.!?…])\s+/); buf = sentences.slice(-overlapSentences).join(" ") + "\n\n" + p; } else { buf = buf ? buf + "\n\n" + p : p; } } if (buf.trim()) parts.push(buf.trim()); return parts; }