Formules LaTeX rendues dans les réponses (KaTeX, repris d'AJEAN #123)

00-math.js : $…$, $$…$$, \(…\) et \[…\] mis de côté avant marked (qui
abîmerait \, _ et *), remplacés par un repère puis par le rendu KaTeX.
KaTeX n'est chargé qu'à la première formule ; d'ici là la source
s'affiche, puis est rendue sur place. Le code (blocs et en ligne) et les
montants (« 5 $ et 10 $ », « $5 et $10 », \$) restent du texte.

Logique de détection d'AJEAN, avec deux garde-fous en plus : un $ ouvrant
précédé d'un chiffre et d'une espace est un montant (« 5 $, à 10$ »), et
$…$ ne contient pas de backtick — sans quoi « 5 $, code `$x$` »
s'appariait par-dessus le code en ligne (le cas passe aussi chez AJEAN).

Fonctions pures dans un fichier à part : web_katex_math_test.go les
exécute sous node (ignoré sans node) sur les cas limites, et de bout en
bout avec marked. index.html régénéré par tools/assemble-ui ; NOTICE.md
mentionne la reprise et la licence MIT de KaTeX.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-19 11:23:16 +02:00
1 parent 99c96a6c33
commit 965a4d6247
6 files changed
+320 -2

No files matched your search

+15
View File
@@ -52,6 +52,21 @@ et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
expose à la place un export/import de paquet chiffré en fichier local.
Le **rendu des formules LaTeX** dans les réponses (`ui/src/js/00-math.js`,
`web_katex.go`) est repris d'AJEAN (#123) : $…$, $$…$$, \\(…\\) et \\[…\\] rendus
par KaTeX, chargé à la première formule seulement, sans toucher au code ni aux
montants en $. Loki y ajoute deux garde-fous de détection (« 5 $, » et un $
voisin d'un code en ligne restent du texte) et sert la route `/katex/` aussi
depuis la façade Rust (`rust/loki-rs/src/ui.rs`).
## KaTeX
**[KaTeX](https://katex.org/)** 0.16.11 (licence MIT, © Khan Academy et
contributeurs) est embarqué tel que publié — script, feuille de style et
polices woff2 — sous `internal/loki/ui/katex/`, avec sa licence
(`internal/loki/ui/katex/LICENSE`). Il est servi par Loki sous `/katex/`, sans
appel à un CDN.
## Moteur Strata
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
+81 -1
View File
@@ -1418,6 +1418,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
border:1px solid var(--border);border-radius:999px;background:var(--panel);
color:var(--dim);font-size:12px;line-height:1.5}
.stopnote::before{content:"";flex:none;width:6px;height:6px;border-radius:50%;background:var(--dim)}
/* Formules LaTeX (KaTeX, 00-math.js) : une longue formule défile au lieu de déborder. */
.katex{font-size:1.08em}
.katex-display{margin:.6em 0;overflow-x:auto;overflow-y:hidden;padding:2px 0}
.math-pending{font-family:var(--mono);font-size:.92em;color:var(--dim)}
/* ===== Fenêtres et sélecteurs =====================================
Coins arrondis et ombre douce plutôt qu'un cadre marqué. */
@@ -3820,6 +3824,76 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div>
<script src="marked.min.js"></script>
<script>
// --- Formules LaTeX (KaTeX) ---------------------------------------------------
// Repris d'AJEAN (#123). md() (00-md.js) met les formules de côté AVANT marked,
// qui abîmerait \\, _ et * : un repère (caractères à usage privé U+E000/U+E001
// autour du numéro) prend leur place, puis reçoit le rendu. Le code (blocs et
// `en ligne`) n'est jamais touché. Formes reconnues : $$…$$ et \[…\] (bloc),
// \(…\) et $…$ (en ligne). $…$ n'est une formule que s'il ne commence ni ne finit
// par une espace, n'est pas collé à un mot ni échappé (\$) avant, et n'est pas
// suivi d'un chiffre : « 5 $ et 10 $ » ou « $5 et $10 » restent du texte.
// Ajouts de Loki : un $ ouvrant précédé d'un chiffre et d'une espace (« 5 $, »)
// est un montant, et $…$ ne contient pas de ` — sans quoi « 5 $, code `$x$` »
// s'apparierait par-dessus le code en ligne.
// KaTeX (embarqué, servi sous /katex/ par web_katex.go et la façade) n'est chargé
// qu'à la première formule ; d'ici là la source s'affiche, puis est rendue sur
// place. (Pas de lookbehind dans l'expression : les iOS d'avant 16.4 refusent de
// charger le script entier. Ces contrôles-là sont faits dans le remplacement.)
//
// MATH_RE et mathExtract sont purs (ni DOM ni réseau) : web_katex_math_test.go les
// exécute sous node sur les cas limites (montants, code, échappements).
const MATH_RE = /(```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`\n]*`)|\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]|\\\(([\s\S]+?)\\\)|\$(?![\s$])((?:\\.|[^$\\\n`])+?)\$(?![\d$])/g;
const MATH_MARK_RE = /\uE000(\d+)\uE001/g;
function mathExtract(s){
const list = [];
const text = s.replace(MATH_RE, (all, code, dd, br, pa, d, at)=>{
if(code !== undefined) return all;
// $…$ : pas collé à un mot ni échappé (\$) avant, pas un montant (« 5 $ »),
// pas d'espace avant le $ final.
if(d !== undefined && (/[\\$\w]/.test(s[at-1] || '') || /\d\s$/.test(s.slice(Math.max(0, at-2), at)) || /\s$/.test(d))) return all;
const display = dd !== undefined || br !== undefined;
list.push({tex: (dd ?? br ?? pa ?? d).trim(), display});
return '\uE000' + (list.length-1) + '\uE001';
});
return {text, list};
}
// mathRestore remplace chaque repère du HTML produit par marked par le rendu de
// sa formule (render : formule → HTML).
function mathRestore(html, list, render){
return list.length ? html.replace(MATH_MARK_RE, (all, i)=>list[+i] ? render(list[+i]) : all) : html;
}
const mathEsc = (s)=>s.replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const MATH_OPTS = {throwOnError: false, output: 'html'};
function mathHtml(f){
if(window.katex){
try{ return katex.renderToString(f.tex, {...MATH_OPTS, displayMode: f.display}); }
catch(_){ return '<code>'+mathEsc(f.tex)+'</code>'; }
}
mathLoad();
return '<span class="math-pending" data-d="'+(f.display?1:0)+'">'+mathEsc(f.tex)+'</span>';
}
let mathLoading = null;
function mathLoad(){
if(mathLoading) return mathLoading;
if(!document.querySelector('link[data-katex]')){
const css = document.createElement('link');
css.rel = 'stylesheet'; css.href = 'katex/katex.min.css'; css.dataset.katex = '1';
document.head.appendChild(css);
}
mathLoading = new Promise((ok, ko)=>{
const s = document.createElement('script');
s.src = 'katex/katex.min.js'; s.onload = ok;
// Échec (hors ligne, 404) : script retiré, nouvel essai à la prochaine formule.
s.onerror = ()=>{ s.remove(); ko(); };
document.head.appendChild(s);
}).then(()=>{
document.querySelectorAll('.math-pending').forEach(el=>{
try{ katex.render(el.textContent, el, {...MATH_OPTS, displayMode: el.dataset.d === '1'}); }catch(_){}
el.classList.remove('math-pending');
});
}).catch(()=>{ mathLoading = null; });
return mathLoading;
}
// marked: GFM on, no auto-breaks (single newlines stay inline).
marked.setOptions({ gfm: true, breaks: false });
// Pre-process to fix common LLM markdown mistakes before handing to marked:
@@ -3836,7 +3910,13 @@ function fixMd(s){
s = s.replace(/\n{3,}/g, '\n\n');
return s;
}
function md(src){ return src ? marked.parse(fixMd(src)) : ''; }
// Les formules LaTeX sont mises de côté avant marked puis rendues par KaTeX
// (00-math.js) ; le code et les montants en $ ne sont pas touchés.
function md(src){
if(!src) return '';
const m = mathExtract(fixMd(src));
return mathRestore(marked.parse(m.text), m.list, mathHtml);
}
// Les garde-fous du serveur ("[stop: trop d'appels d'outils]") sont concaténés
// au texte de la réponse : ils arrivent donc en clair, au milieu du markdown.
// markNotices les sort du fil pour qu'on ne les prenne pas pour une phrase du
+70
View File
@@ -0,0 +1,70 @@
// --- Formules LaTeX (KaTeX) ---------------------------------------------------
// Repris d'AJEAN (#123). md() (00-md.js) met les formules de côté AVANT marked,
// qui abîmerait \\, _ et * : un repère (caractères à usage privé U+E000/U+E001
// autour du numéro) prend leur place, puis reçoit le rendu. Le code (blocs et
// `en ligne`) n'est jamais touché. Formes reconnues : $$…$$ et \[…\] (bloc),
// \(…\) et $…$ (en ligne). $…$ n'est une formule que s'il ne commence ni ne finit
// par une espace, n'est pas collé à un mot ni échappé (\$) avant, et n'est pas
// suivi d'un chiffre : « 5 $ et 10 $ » ou « $5 et $10 » restent du texte.
// Ajouts de Loki : un $ ouvrant précédé d'un chiffre et d'une espace (« 5 $, »)
// est un montant, et $…$ ne contient pas de ` — sans quoi « 5 $, code `$x$` »
// s'apparierait par-dessus le code en ligne.
// KaTeX (embarqué, servi sous /katex/ par web_katex.go et la façade) n'est chargé
// qu'à la première formule ; d'ici là la source s'affiche, puis est rendue sur
// place. (Pas de lookbehind dans l'expression : les iOS d'avant 16.4 refusent de
// charger le script entier. Ces contrôles-là sont faits dans le remplacement.)
//
// MATH_RE et mathExtract sont purs (ni DOM ni réseau) : web_katex_math_test.go les
// exécute sous node sur les cas limites (montants, code, échappements).
const MATH_RE = /(```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`\n]*`)|\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]|\\\(([\s\S]+?)\\\)|\$(?![\s$])((?:\\.|[^$\\\n`])+?)\$(?![\d$])/g;
const MATH_MARK_RE = /\uE000(\d+)\uE001/g;
function mathExtract(s){
const list = [];
const text = s.replace(MATH_RE, (all, code, dd, br, pa, d, at)=>{
if(code !== undefined) return all;
// $…$ : pas collé à un mot ni échappé (\$) avant, pas un montant (« 5 $ »),
// pas d'espace avant le $ final.
if(d !== undefined && (/[\\$\w]/.test(s[at-1] || '') || /\d\s$/.test(s.slice(Math.max(0, at-2), at)) || /\s$/.test(d))) return all;
const display = dd !== undefined || br !== undefined;
list.push({tex: (dd ?? br ?? pa ?? d).trim(), display});
return '\uE000' + (list.length-1) + '\uE001';
});
return {text, list};
}
// mathRestore remplace chaque repère du HTML produit par marked par le rendu de
// sa formule (render : formule → HTML).
function mathRestore(html, list, render){
return list.length ? html.replace(MATH_MARK_RE, (all, i)=>list[+i] ? render(list[+i]) : all) : html;
}
const mathEsc = (s)=>s.replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const MATH_OPTS = {throwOnError: false, output: 'html'};
function mathHtml(f){
if(window.katex){
try{ return katex.renderToString(f.tex, {...MATH_OPTS, displayMode: f.display}); }
catch(_){ return '<code>'+mathEsc(f.tex)+'</code>'; }
}
mathLoad();
return '<span class="math-pending" data-d="'+(f.display?1:0)+'">'+mathEsc(f.tex)+'</span>';
}
let mathLoading = null;
function mathLoad(){
if(mathLoading) return mathLoading;
if(!document.querySelector('link[data-katex]')){
const css = document.createElement('link');
css.rel = 'stylesheet'; css.href = 'katex/katex.min.css'; css.dataset.katex = '1';
document.head.appendChild(css);
}
mathLoading = new Promise((ok, ko)=>{
const s = document.createElement('script');
s.src = 'katex/katex.min.js'; s.onload = ok;
// Échec (hors ligne, 404) : script retiré, nouvel essai à la prochaine formule.
s.onerror = ()=>{ s.remove(); ko(); };
document.head.appendChild(s);
}).then(()=>{
document.querySelectorAll('.math-pending').forEach(el=>{
try{ katex.render(el.textContent, el, {...MATH_OPTS, displayMode: el.dataset.d === '1'}); }catch(_){}
el.classList.remove('math-pending');
});
}).catch(()=>{ mathLoading = null; });
return mathLoading;
}
+7 -1
View File
@@ -14,7 +14,13 @@ function fixMd(s){
s = s.replace(/\n{3,}/g, '\n\n');
return s;
}
function md(src){ return src ? marked.parse(fixMd(src)) : ''; }
// Les formules LaTeX sont mises de côté avant marked puis rendues par KaTeX
// (00-math.js) ; le code et les montants en $ ne sont pas touchés.
function md(src){
if(!src) return '';
const m = mathExtract(fixMd(src));
return mathRestore(marked.parse(m.text), m.list, mathHtml);
}
// Les garde-fous du serveur ("[stop: trop d'appels d'outils]") sont concaténés
// au texte de la réponse : ils arrivent donc en clair, au milieu du markdown.
// markNotices les sort du fil pour qu'on ne les prenne pas pour une phrase du
+4
View File
@@ -1383,6 +1383,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
border:1px solid var(--border);border-radius:999px;background:var(--panel);
color:var(--dim);font-size:12px;line-height:1.5}
.stopnote::before{content:"";flex:none;width:6px;height:6px;border-radius:50%;background:var(--dim)}
/* Formules LaTeX (KaTeX, 00-math.js) : une longue formule défile au lieu de déborder. */
.katex{font-size:1.08em}
.katex-display{margin:.6em 0;overflow-x:auto;overflow-y:hidden;padding:2px 0}
.math-pending{font-family:var(--mono);font-size:.92em;color:var(--dim)}
/* ===== Fenêtres et sélecteurs =====================================
Coins arrondis et ombre douce plutôt qu'un cadre marqué. */
+143
View File
@@ -0,0 +1,143 @@
package loki
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
)
// Détection des formules (ui/src/js/00-math.js), exécutée sous node : le dépôt
// n'a pas de banc de tests JS, et MATH_RE/mathExtract sont purs. Ignoré si node
// n'est pas installé (il l'est sur les runners de la CI).
type formule struct {
Tex string `json:"tex"`
Display bool `json:"display"`
}
// harnaisMath : charge 00-math.js et marked.min.js, puis pour chaque entrée
// (JSON sur stdin) rend {text, list} de mathExtract et, pour le bout en bout, le
// HTML de marked après mathRestore (chaque formule rendue « [tex] »).
const harnaisMath = `
const fs = require('fs');
const [mathPath, markedPath] = process.argv.slice(2);
const m = new Function(fs.readFileSync(mathPath, 'utf8') + '; return {mathExtract, mathRestore};')();
const {marked} = require(markedPath);
marked.setOptions({ gfm: true, breaks: false });
const cases = JSON.parse(fs.readFileSync(0, 'utf8'));
console.log(JSON.stringify(cases.map(s => {
const r = m.mathExtract(s);
const html = m.mathRestore(marked.parse(r.text), r.list, f => '[' + (f.display ? 'D:' : '') + f.tex + ']');
return {text: r.text, list: r.list, html};
})));
`
type résultatMath struct {
Text string `json:"text"`
List []formule `json:"list"`
HTML string `json:"html"`
}
func lanceMath(t *testing.T, cas []string) []résultatMath {
t.Helper()
node, err := exec.LookPath("node")
if err != nil {
t.Skip("node absent : détection des formules non testée")
}
dir := t.TempDir()
h := filepath.Join(dir, "harnais.js")
if err := os.WriteFile(h, []byte(harnaisMath), 0o644); err != nil {
t.Fatal(err)
}
math, _ := filepath.Abs("ui/src/js/00-math.js")
marked, _ := filepath.Abs("ui/marked.min.js")
in, _ := json.Marshal(cas)
cmd := exec.Command(node, h, math, marked)
cmd.Stdin = strings.NewReader(string(in))
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("node : %v\n%s", err, out)
}
var res []résultatMath
if err := json.Unmarshal(out, &res); err != nil {
t.Fatalf("sortie illisible : %v\n%s", err, out)
}
if len(res) != len(cas) {
t.Fatalf("%d résultats pour %d cas", len(res), len(cas))
}
return res
}
func TestMathDetection(t *testing.T) {
in := func(tex string) formule { return formule{Tex: tex} }
bloc := func(tex string) formule { return formule{Tex: tex, Display: true} }
cas := []struct {
src string
want []formule
}{
// Montants : jamais des formules.
{"Ça coûte 5 $ et 10 $.", nil},
{"Entre $5 et $10 par mois.", nil},
{"Prix : 5$ ou 6$, voire 7 $.", nil},
{`Échappés : \$5 et \$6.`, nil},
{"Collé à un mot : x$y$z.", nil},
{"Espace finale : $x $.", nil},
{"De 5 $, à 10$.", nil},
{"Montant : 5 $, code `$x_1$`.", nil},
// Code : jamais touché.
{"En ligne `$x$` et `$$y$$`.", nil},
{"```\n$x$ et $$y$$\n```", nil},
{"~~~tex\n\\[a\\]\n~~~", nil},
{"```\nbloc non fermé $x$", nil},
// Formules.
{"Soit $x^2 + y^2 = z^2$ ici.", []formule{in("x^2 + y^2 = z^2")}},
{"Deux : $a$, $b$.", []formule{in("a"), in("b")}},
{"$$\n\\int_0^1 x\\,dx\n$$", []formule{bloc(`\int_0^1 x\,dx`)}},
{`En ligne \(a+b\) et en bloc \[c\].`, []formule{in("a+b"), bloc("c")}},
{"Code `$x$` puis $y$.", []formule{in("y")}},
{`Échappement dedans : $a\$b$.`, []formule{in(`a\$b`)}},
}
srcs := make([]string, len(cas))
for i, c := range cas {
srcs[i] = c.src
}
res := lanceMath(t, srcs)
for i, c := range cas {
got := res[i].List
if len(got) == 0 {
got = nil
}
if !reflect.DeepEqual(got, c.want) {
t.Errorf("%q : %+v, attendu %+v", c.src, got, c.want)
}
if c.want == nil && res[i].Text != c.src {
t.Errorf("%q : texte modifié en %q", c.src, res[i].Text)
}
}
}
// Bout en bout avec marked : \\, _ et * d'une formule arrivent intacts au rendu,
// le markdown autour reste du markdown, et aucun repère ne survit.
func TestMathAvecMarked(t *testing.T) {
res := lanceMath(t, []string{
"*Gras* : $a_1 * b_2$ et $$\\begin{matrix}a & b \\\\ c & d\\end{matrix}$$",
"Montant : 5 $, code `$x_1$`.",
})
for _, want := range []string{"<em>Gras</em>", "[a_1 * b_2]", `[D:\begin{matrix}a & b \\ c & d\end{matrix}]`} {
if !strings.Contains(res[0].HTML, want) {
t.Errorf("HTML sans %q :\n%s", want, res[0].HTML)
}
}
for _, r := range res {
if strings.ContainsAny(r.HTML, "\uE000\uE001") {
t.Errorf("repère resté dans le HTML : %q", r.HTML)
}
}
if !strings.Contains(res[1].HTML, "5 $") || !strings.Contains(res[1].HTML, "<code>$x_1$</code>") {
t.Errorf("montant ou code abîmé : %s", res[1].HTML)
}
}