SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
12.3 KB · 347 lines typescript
Raw Blame History
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