| 1 | // Golden-case verification for the math rendering pipeline. |
| 2 | // |
| 3 | // Run: tsx src/__tests__/math-golden.test.ts |
| 4 | // |
| 5 | // We import the *production* modules (mathNormalize, latexNormalize, |
| 6 | // mathClassify) rather than reimplementing them inline, so this file |
| 7 | // catches regressions in the actual code path that runs inside <Markdown>. |
| 8 | |
| 9 | import { createElement } from "react"; |
| 10 | import { renderToStaticMarkup } from "react-dom/server"; |
| 11 | import ReactMarkdown from "react-markdown"; |
| 12 | import katex from "katex"; |
| 13 | import { latexNormalizeForKatex, stripMathDelimiters } from "../components/latexNormalize"; |
| 14 | import { classifyInlineMath, isLikelyInlineMath } from "../components/mathClassify"; |
| 15 | import { reasonixRehypePlugins, reasonixRemarkPlugins } from "../components/markdownRemarkPlugins"; |
| 16 | import { |
| 17 | normalizeMath, |
| 18 | resolveProtectedInlineMathSource, |
| 19 | restoreProtectedInlineMathSource, |
| 20 | } from "../components/mathNormalize"; |
| 21 | import { expandYoungDiagrams } from "../components/youngDiagrams"; |
| 22 | |
| 23 | let passed = 0; |
| 24 | let failed = 0; |
| 25 | |
| 26 | function check(label: string, fn: () => boolean) { |
| 27 | try { |
| 28 | if (fn()) { process.stdout.write(` PASS ${label}\n`); passed += 1; } |
| 29 | else { process.stdout.write(` FAIL ${label}\n`); failed += 1; } |
| 30 | } catch (e) { |
| 31 | process.stdout.write(` ERROR ${label}: ${(e as Error).message}\n`); failed += 1; |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | function eq(a: unknown, b: unknown, label: string) { |
| 36 | if (a === b) { |
| 37 | process.stdout.write(` PASS ${label}\n`); |
| 38 | passed += 1; |
| 39 | } else { |
| 40 | process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); |
| 41 | failed += 1; |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | // ── stripMathDelimiters ──────────────────────────────────────────────────────── |
| 46 | |
| 47 | console.log("\nstripMathDelimiters"); |
| 48 | eq(stripMathDelimiters("\\(x+1\\)"), "x+1", "\\(...\\)"); |
| 49 | eq(stripMathDelimiters("\\[E=mc^2\\]"), "E=mc^2", "\\[...\\]"); |
| 50 | eq(stripMathDelimiters("$$\\frac{a}{b}$$"), "\\frac{a}{b}", "$$...$$"); |
| 51 | eq(stripMathDelimiters("$x_i^2$"), "x_i^2", "$...$"); |
| 52 | eq(stripMathDelimiters("plain text"), "plain text", "no delimiters"); |
| 53 | eq(stripMathDelimiters("$a|b$"), "a|b", "inline with pipe"); |
| 54 | |
| 55 | // ── latexNormalizeForKatex ───────────────────────────────────────────────────── |
| 56 | |
| 57 | console.log("\nlatexNormalizeForKatex"); |
| 58 | eq(latexNormalizeForKatex("x+1"), "x+1", "plain unchanged"); |
| 59 | eq(latexNormalizeForKatex("\\text{baryon #}"), "\\text{baryon \\#}", "escapes # in \\text"); |
| 60 | eq(latexNormalizeForKatex("\\text{cost is $5}"), "\\text{cost is \\textdollar{}5}", "escapes $ in \\text"); |
| 61 | eq(latexNormalizeForKatex("\\text{a & b % c_d ^ e ~ f}"), |
| 62 | "\\text{a \\& b \\% c\\_d \\textasciicircum{} e \\textasciitilde{} f}", |
| 63 | "escapes & % _ ^ ~ in \\text"); |
| 64 | eq(latexNormalizeForKatex("\\text{already\\_escaped}"), "\\text{already\\_escaped}", "no double-escape"); |
| 65 | eq(latexNormalizeForKatex("\\alpha + \\beta"), "\\alpha + \\beta", "non-text commands"); |
| 66 | eq(latexNormalizeForKatex("a | b"), "a \\vert b", "| to \\vert without doubled space"); |
| 67 | eq(latexNormalizeForKatex("|x|"), "\\vert x\\vert", "|x| keeps command boundary"); |
| 68 | eq(latexNormalizeForKatex("\\text{foo \\$ bar}"), "\\text{foo \\$ bar}", "already escaped $"); |
| 69 | eq(latexNormalizeForKatex("100%"), "100\\%", "raw % escaped to \\% (KaTeX comment-char fix)"); |
| 70 | eq(latexNormalizeForKatex("x = 50%"), "x = 50\\%", "% at end of math escaped"); |
| 71 | eq(latexNormalizeForKatex("a%b"), "a\\%b", "% between letters escaped"); |
| 72 | eq(latexNormalizeForKatex("a\\%b"), "a\\%b", "already-escaped \\% not double-escaped"); |
| 73 | eq(latexNormalizeForKatex("\\textrm{test #}"), "\\textrm{test \\#}", "\\textrm also handled"); |
| 74 | eq(latexNormalizeForKatex("\\textbf{hello world}"), "\\textbf{hello world}", "\\textbf no special chars"); |
| 75 | eq(latexNormalizeForKatex("\\tfrac{a}{b}"), "\\tfrac{a}{b}", "nested braces in command"); |
| 76 | eq(latexNormalizeForKatex("\\|x\\|"), "\\|x\\|", "\\| is left alone (readCommand handles \\|, not | branch)"); |
| 77 | eq(latexNormalizeForKatex("\\\\|x|"), "\\\\\\vert x\\vert", "\\\\| line break + pipe: both | → \\vert"); |
| 78 | |
| 79 | // ── latexNormalizeForKatex — array column-spec pipes (regression) ────────────── |
| 80 | // Inside \begin{array}{c|c} the | means "draw a vertical rule" — it must |
| 81 | // NOT be rewritten to \vert, or KaTeX fails with "Unknown column alignment: |
| 82 | // \vert". The whole {...} preamble is copied verbatim. |
| 83 | eq(latexNormalizeForKatex("\\begin{array}{c|c} a & b \\\\ c & d \\end{array}"), |
| 84 | "\\begin{array}{c|c} a & b \\\\ c & d \\end{array}", "array column-spec | preserved (c|c)"); |
| 85 | eq(latexNormalizeForKatex("\\begin{array}{|c|c|} a & b \\end{array}"), |
| 86 | "\\begin{array}{|c|c|} a & b \\end{array}", "array column-spec ||| preserved"); |
| 87 | eq(latexNormalizeForKatex("\\begin{array}{cc|c} a & b & c \\end{array}"), |
| 88 | "\\begin{array}{cc|c} a & b & c \\end{array}", "array column-spec cc|c preserved"); |
| 89 | eq(latexNormalizeForKatex("\\begin{array}{c|c} a & b \\end{array} |x|"), |
| 90 | "\\begin{array}{c|c} a & b \\end{array} \\vert x\\vert", "pipe OUTSIDE array still → \\vert"); |
| 91 | eq(latexNormalizeForKatex("\\begin{tabular}{c|c} a & b \\end{tabular}"), |
| 92 | "\\begin{tabular}{c|c} a & b \\end{tabular}", "tabular column-spec | preserved"); |
| 93 | |
| 94 | // ── latexNormalizeForKatex — ket-pipe disambiguation (regression) ───────────── |
| 95 | // In GFM Markdown tables, | is the column delimiter, so kets are written as |
| 96 | // \|uud\rangle. But \| is the "parallel-to" double bar ‖ in LaTeX, not a ket |
| 97 | // bar. We convert \| to \vert when it's a ket opener (\|...\rangle) or bra |
| 98 | // closer (\langle...\|), but leave matched \|...\| norms alone. |
| 99 | eq(latexNormalizeForKatex("\\|uud\\rangle"), "\\vert uud\\rangle", "ket \\|uud\\rangle → \\vert"); |
| 100 | eq(latexNormalizeForKatex("\\|\\alpha\\rangle"), "\\vert \\alpha\\rangle", "ket \\|\\alpha\\rangle → \\vert"); |
| 101 | eq(latexNormalizeForKatex("\\|u\\uparrow d\\rangle"), "\\vert u\\uparrow d\\rangle", "ket with content → \\vert"); |
| 102 | eq(latexNormalizeForKatex("\\frac{1}{\\sqrt{2}}\\|\\psi\\rangle"), "\\frac{1}{\\sqrt{2}}\\vert \\psi\\rangle", "ket in fraction → \\vert"); |
| 103 | eq(latexNormalizeForKatex("\\|a\\rangle + \\|b\\rangle"), "\\vert a\\rangle + \\vert b\\rangle", "two kets both → \\vert"); |
| 104 | // Norms (matched \|...\| pair) must KEEP the double bar |
| 105 | eq(latexNormalizeForKatex("\\|x\\|"), "\\|x\\|", "norm \\|x\\| preserved (double bar)"); |
| 106 | eq(latexNormalizeForKatex("\\|v\\|^2"), "\\|v\\|^2", "norm \\|v\\|^2 preserved"); |
| 107 | eq(latexNormalizeForKatex("\\|\\vec{v}\\|"), "\\|\\vec{v}\\|", "norm with content preserved"); |
| 108 | // Bra closers (\langle...\|) |
| 109 | eq(latexNormalizeForKatex("\\langle\\psi\\|"), "\\langle\\psi\\vert", "bra \\langle\\psi\\| → \\vert"); |
| 110 | // Inner product: \langle x \| y \rangle — the \| between bra and ket content |
| 111 | eq(latexNormalizeForKatex("\\langle x \\| y \\rangle"), "\\langle x \\vert y \\rangle", "inner product \\| → \\vert"); |
| 112 | |
| 113 | // ── latexNormalizeForKatex — \tag → align conversion (regression for KaTeX "Multiple \tag") ── |
| 114 | eq(latexNormalizeForKatex("a = b \\tag{10}"), "a = b \\tag{10}", "\\tag without aligned passes through"); |
| 115 | eq(latexNormalizeForKatex("\\begin{aligned} a &= b \\\\ \\end{aligned}"), |
| 116 | "\\begin{aligned} a &= b \\\\ \\end{aligned}", "aligned without \\tag unchanged"); |
| 117 | eq(latexNormalizeForKatex("\\begin{aligned} a &= b \\tag{10}\\\\ c &= d \\end{aligned}"), |
| 118 | "\\begin{align} a &= b \\tag{10}\\\\ c &= d \\end{align}", "aligned with \\tag → align"); |
| 119 | eq(latexNormalizeForKatex("\\begin{aligned} a &= b \\tag{10}\\\\ c &= d \\tag{11} \\end{aligned}"), |
| 120 | "\\begin{align} a &= b \\tag{10}\\\\ c &= d \\tag{11} \\end{align}", "aligned with multiple \\tag → align"); |
| 121 | eq(latexNormalizeForKatex("\\boxed{\\begin{aligned} a &= b \\tag{10}\\\\ c &= d \\end{aligned}}"), |
| 122 | "\\boxed{\\begin{align} a &= b \\tag{10}\\\\ c &= d \\end{align}}", "boxed aligned with \\tag → boxed align"); |
| 123 | eq(latexNormalizeForKatex("\\begin{gathered} a = b \\tag{10}\\\\ c = d \\end{gathered}"), |
| 124 | "\\begin{gather} a = b \\tag{10}\\\\ c = d \\end{gather}", "gathered with \\tag → gather"); |
| 125 | |
| 126 | // ── isLikelyInlineMath (mathClassify) ────────────────────────────────────────── |
| 127 | |
| 128 | console.log("\nisLikelyInlineMath — math"); |
| 129 | check("$x$ (single var)", () => isLikelyInlineMath("x") === true); |
| 130 | check("$E=mc^2$", () => isLikelyInlineMath("E=mc^2") === true); |
| 131 | check("$x_i^2$", () => isLikelyInlineMath("x_i^2") === true); |
| 132 | check("$\\alpha$", () => isLikelyInlineMath("\\alpha") === true); |
| 133 | check("$a \\le b$", () => isLikelyInlineMath("a \\le b") === true); |
| 134 | check("$\\frac{a}{b}$", () => isLikelyInlineMath("\\frac{a}{b}") === true); |
| 135 | check("$f(x)$", () => isLikelyInlineMath("f(x)") === true); |
| 136 | check("$x+1$", () => isLikelyInlineMath("x+1") === true); |
| 137 | |
| 138 | console.log("\nisLikelyInlineMath — classifier gaps from PR #4543"); |
| 139 | check("$\\tfrac12$", () => isLikelyInlineMath("\\tfrac12") === true); |
| 140 | check("$\\sqrt2$", () => isLikelyInlineMath("\\sqrt2") === true); |
| 141 | check("$SO(3,1)$", () => isLikelyInlineMath("SO(3,1)") === true); |
| 142 | check("$SU(2)$", () => isLikelyInlineMath("SU(2)") === true); |
| 143 | check("$GL(n)$", () => isLikelyInlineMath("GL(n)") === true); |
| 144 | check("$K = -iJ$", () => isLikelyInlineMath("K = -iJ") === true); |
| 145 | check("$p = +\\alpha$", () => isLikelyInlineMath("p = +\\alpha") === true); |
| 146 | check("$+$", () => isLikelyInlineMath("+") === true); |
| 147 | check("$=$", () => isLikelyInlineMath("=") === true); |
| 148 | |
| 149 | console.log("\nisLikelyInlineMath — numeric syntax and contextual currency"); |
| 150 | check("$5 defaults to literal without math context", () => isLikelyInlineMath("5") === false); |
| 151 | check("$10 defaults to literal without math context", () => isLikelyInlineMath("10") === false); |
| 152 | check("$10.50 defaults to literal without math context", () => isLikelyInlineMath("10.50") === false); |
| 153 | check("$100% defaults to math", () => isLikelyInlineMath("100%") === true); |
| 154 | check("costs $5$ is contextual currency", () => |
| 155 | classifyInlineMath("5", { before: "it costs ", after: " today" }) === "currency"); |
| 156 | check("price is $10.50$ each is contextual currency", () => |
| 157 | classifyInlineMath("10.50", { before: "price is ", after: " each" }) === "currency"); |
| 158 | check("10–$20$ MeV remains math", () => |
| 159 | classifyInlineMath("20", { before: "10–", after: " MeV" }) === "math"); |
| 160 | check("$20$ MeV uses a scientific unit as positive math context", () => |
| 161 | classifyInlineMath("20", { after: " MeV" }) === "math"); |
| 162 | check("$5$ cm uses an SI-prefixed unit as positive math context", () => |
| 163 | classifyInlineMath("5", { after: " cm" }) === "math"); |
| 164 | check("$2$ L uses a scientific unit symbol as positive math context", () => |
| 165 | classifyInlineMath("2", { after: " L" }) === "math"); |
| 166 | check("$3$ dB uses a common scientific unit as positive math context", () => |
| 167 | classifyInlineMath("3", { after: " dB" }) === "math"); |
| 168 | check("x = $2$ uses an operator as positive math context", () => |
| 169 | classifyInlineMath("2", { before: "x = " }) === "math"); |
| 170 | check("URL", () => isLikelyInlineMath("https://example.com") === false); |
| 171 | check("prose text", () => isLikelyInlineMath("hello world today") === false); |
| 172 | check("prose $x y z$ (spaces)", () => isLikelyInlineMath("x y z") === false); |
| 173 | check("$PATH$ env token", () => isLikelyInlineMath("PATH") === false); |
| 174 | check("$TODO$ word token", () => isLikelyInlineMath("TODO") === false); |
| 175 | check("$OK$ word token", () => isLikelyInlineMath("OK") === false); |
| 176 | check("$v1$ version token", () => isLikelyInlineMath("v1") === false); |
| 177 | check("$foo$ plain word", () => isLikelyInlineMath("foo") === false); |
| 178 | |
| 179 | console.log("\nisLikelyInlineMath — single-letter regression"); |
| 180 | check("lowercase $x$ → math", () => isLikelyInlineMath("x") === true); |
| 181 | check("uppercase $I$ → math (math name in non-English prose)", () => isLikelyInlineMath("I") === true); |
| 182 | check("uppercase $A$ → math", () => isLikelyInlineMath("A") === true); |
| 183 | check("uppercase $V$ → math", () => isLikelyInlineMath("V") === true); |
| 184 | |
| 185 | console.log("\nisLikelyInlineMath — primed letters and bracketed labels"); |
| 186 | check("$S'$ → math (primed letter)", () => isLikelyInlineMath("S'") === true); |
| 187 | check("$y''$ → math (double prime)", () => isLikelyInlineMath("y''") === true); |
| 188 | check("$f'(x)$ → math (primed function)", () => isLikelyInlineMath("f'(x)") === true); |
| 189 | check("$\\psi'$ → math (Greek with prime)", () => isLikelyInlineMath("\\psi'") === true); |
| 190 | check("$[56]$ → math (irrep label)", () => isLikelyInlineMath("[56]") === true); |
| 191 | check("$[56,0^+]$ → math", () => isLikelyInlineMath("[56,0^+]") === true); |
| 192 | check("$[\\mathbf{56}]$ → math", () => isLikelyInlineMath("[\\mathbf{56}]") === true); |
| 193 | |
| 194 | console.log("\nisLikelyInlineMath — minimal LaTeX patterns (regression)"); |
| 195 | // LLMs frequently emit minimal LaTeX in math contexts that the older |
| 196 | // classifier rejected as currency / word tokens. These tests pin down the |
| 197 | // deliberately-permissive rules for common math patterns while keeping |
| 198 | // context-free pure numbers literal until the AST policy sees a math signal. |
| 199 | check("single-digit $1$, $2$, $5$ → literal without context", () => isLikelyInlineMath("1") === false); |
| 200 | check("multi-digit $42$ → literal without context", () => isLikelyInlineMath("42") === false); |
| 201 | check("$2.5x$ is math (number with variable)", () => isLikelyInlineMath("2.5x") === true); |
| 202 | check("$10\%$ is math (percentage with LaTeX)", () => isLikelyInlineMath("10\\%") === true); |
| 203 | check("$2.5x dollars$ → NOT math (prefix-only numeric variable)", () => isLikelyInlineMath("2.5x dollars") === false); |
| 204 | check("$10\\% off$ → NOT math (prefix-only escaped percentage)", () => isLikelyInlineMath("10\\% off") === false); |
| 205 | check("$5\\cdot3$ is math (number with LaTeX command)", () => isLikelyInlineMath("5\\cdot3") === true); |
| 206 | |
| 207 | check("comma-separated $A, B$ → math (ordered pair)", () => isLikelyInlineMath("A, B") === true); |
| 208 | check("comma-separated $1, 2, 3$ → math (sequence)", () => isLikelyInlineMath("1, 2, 3") === true); |
| 209 | check("comma-separated $\\alpha, \\beta$ → math (Greek pair)", () => isLikelyInlineMath("\\alpha, \\beta") === true); |
| 210 | check("parens-wrapped $(A, B)$ inner → math", () => isLikelyInlineMath("(A, B)") === true); |
| 211 | check("cycle notation $(12)$ → math", () => isLikelyInlineMath("(12)") === true); |
| 212 | check("cycle notation $(12)(34)$ → math", () => isLikelyInlineMath("(12)(34)") === true); |
| 213 | check("$S$ (set name) → math", () => isLikelyInlineMath("S") === true); |
| 214 | check("$S$ with surrounding prose (regression)", () => { |
| 215 | return normalizeMath("$S$ 非空\n$S$ 有上界") === "$S$ 非空\n$S$ 有上界"; |
| 216 | }); |
| 217 | check("one-sided comparison $< B$ → math", () => isLikelyInlineMath("< B") === true); |
| 218 | check("one-sided comparison $<= 0$ → math", () => isLikelyInlineMath("<= 0") === true); |
| 219 | check("one-sided comparison $> 5$ → math", () => isLikelyInlineMath("> 5") === true); |
| 220 | check("one-sided comparison $A <$ → math", () => isLikelyInlineMath("A <") === true); |
| 221 | check("one-sided equality $=1$ → math", () => isLikelyInlineMath("=1") === true); |
| 222 | check("one-sided signed equality $=-1$ → math", () => isLikelyInlineMath("=-1") === true); |
| 223 | check("one-sided equality is fully anchored", () => isLikelyInlineMath("=1 dollar") === false); |
| 224 | check("$< B$ with surrounding prose", () => { |
| 225 | return normalizeMath("A 的每个元素 $< B$ 的每个元素") === "A 的每个元素 $< B$ 的每个元素"; |
| 226 | }); |
| 227 | |
| 228 | // ── KaTeX end-to-end rendering ──────────────────────────────────────────────── |
| 229 | |
| 230 | const chiralSource = String.raw` |
| 231 | \underbrace{N}_{\text{baryon #}} |
| 232 | = |
| 233 | \underbrace{\frac{1+\tau_3}{2}}_{\text{isospin}} |
| 234 | + |
| 235 | \underbrace{g_A \gamma^\mu \gamma_5}_{\text{axial}} |
| 236 | + |
| 237 | \underbrace{SU(2)_L \times SU(2)_R}_{\text{chiral}} |
| 238 | `; |
| 239 | |
| 240 | function renderDisplay(source: string): string { |
| 241 | return katex.renderToString(latexNormalizeForKatex(source), { |
| 242 | throwOnError: true, |
| 243 | displayMode: true, |
| 244 | }); |
| 245 | } |
| 246 | |
| 247 | console.log("\nKaTeX renderToString — end to end"); |
| 248 | check("chiral decomposition renders", () => { |
| 249 | const html = renderDisplay(chiralSource); |
| 250 | return !html.includes("katex-error") |
| 251 | && ["baryon", "isospin", "axial", "chiral"].every((label) => html.includes(label)); |
| 252 | }); |
| 253 | check("\\|x\\| renders as double bars", () => { |
| 254 | const html = renderDisplay(String.raw`\|x\|`); |
| 255 | return !html.includes("katex-error") && html.includes("∥"); |
| 256 | }); |
| 257 | |
| 258 | // ── normalizeMath pre-pass (LLM delimiters + classifier) ─────────────────────── |
| 259 | // These exercise the *production* normalizeMath, not a copy of it. |
| 260 | |
| 261 | console.log("\nnormalizeMath — LLM delimiter conversion"); |
| 262 | eq(normalizeMath("\\(x^2\\)"), "$x^2$", "\\(…\\) → $…$"); |
| 263 | eq(normalizeMath("\\[E=mc^2\\]"), "$$\nE=mc^2\n$$", "\\[…\\] → $$…$$"); |
| 264 | eq(normalizeMath("\\\\[4pt]"), "\\\\[4pt]", "\\\\[ line-break spacing protected"); |
| 265 | |
| 266 | console.log("\nnormalizeMath — \\slashed conversion (regression)"); |
| 267 | // KaTeX has no \slashed (Feynman slash notation). The pre-pass preserves it |
| 268 | // verbatim; the AST policy rewrites it to \not only for rendering. |
| 269 | eq(normalizeMath("$\\slashed{p}$"), "$\\slashed{p}$", "\\slashed{p} deferred to AST policy"); |
| 270 | eq(normalizeMath("$\\slashed{\\partial}$"), "$\\slashed{\\partial}$", "\\slashed{\\partial} deferred to AST policy"); |
| 271 | eq( |
| 272 | normalizeMath("The momentum $\\slashed{p}$ is conserved"), |
| 273 | "The momentum $\\slashed{p}$ is conserved", |
| 274 | "\\slashed in prose deferred to AST policy", |
| 275 | ); |
| 276 | eq(normalizeMath("$\\slashed\\epsilon(0)$"), "$\\slashed\\epsilon(0)$", "unbraced \\slashed normalisation deferred to AST policy"); |
| 277 | eq(normalizeMath("$\\slashed a$"), "$\\slashed a$", "unbraced \\slashed letter normalisation deferred to AST policy"); |
| 278 | |
| 279 | console.log("\nnormalizeMath — inline $$ glued to prose (regression)"); |
| 280 | // User-reported: "…decomposes as$$\n\mathbf{6}…" — block math glued to prose. |
| 281 | // Without a blank line, remark-math parses the opening $$ as an empty math node |
| 282 | // and the formula leaks out as literal text. normalizeMath must insert a blank |
| 283 | // line before any $$ preceded by a letter/closing bracket/etc. |
| 284 | check("inline $$ after prose", () => { |
| 285 | const out = normalizeMath("decomposes as$$\n\\mathbf{6}.$$"); |
| 286 | return /^decomposes as\n\$\$/.test(out) && out.includes("\\mathbf{6}"); |
| 287 | }); |
| 288 | check("inline $$ after closing bracket", () => { |
| 289 | const out = normalizeMath("(octet)$$ \\mathbf{56}.$$"); |
| 290 | return out.startsWith("(octet)\n$$"); |
| 291 | }); |
| 292 | check("inline $$ after closing brace (\\end{...}$$)", () => { |
| 293 | // A display equation ending with }$$ must be extracted as a unit. |
| 294 | // The closing $$ must not be split off, or the equation body is emptied. |
| 295 | const out = normalizeMath("$$\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$$"); |
| 296 | return out.includes("\\end{pmatrix},\n$$") || out.includes("\\end{pmatrix}\n$$"); |
| 297 | }); |
| 298 | check("inline $$ after comma on same line as content", () => { |
| 299 | // User-reported (2026-06-12, soft-pion chat): the model wrote the |
| 300 | // closing $$ of a display block on the same line as the trailing |
| 301 | // comma of the equation content, like |
| 302 | // …D(q^2),$$ |
| 303 | // with $P=…$ |
| 304 | // Without a blank line before the closing $$, micromark-extension-math |
| 305 | // does not recognise the closing fence (it only checks for $$ at |
| 306 | // the start of a new line) and consumes the rest of the document |
| 307 | // as math, which then fails to render with "Can't use function '$' |
| 308 | // in math mode" on the stray $ inside the equation body. |
| 309 | const out = normalizeMath("…D(q^2),$$\nwith $P=…$"); |
| 310 | return out.includes("D(q^2),\n$$"); |
| 311 | }); |
| 312 | check("well-formed $$ already on own line is normalised consistently", () => { |
| 313 | // Whether the model writes `decomposes as$$\n\mathbf{6}.$$` or |
| 314 | // `decomposes as\n\n$$\n\mathbf{6}.$$`, both must produce valid |
| 315 | // remark-math-parseable form: opening $$ on its own line, body, closing |
| 316 | // $$ on its own line. |
| 317 | const inline = normalizeMath("decomposes as$$\n\\mathbf{6}.$$"); |
| 318 | const block = normalizeMath("decomposes as\n\n$$\n\\mathbf{6}.$$"); |
| 319 | const valid = (s: string) => /\n\$\$\n/.test(s) && /\n\$\$/.test(s) && s.includes("\\mathbf{6}"); |
| 320 | return valid(inline) && valid(block); |
| 321 | }); |
| 322 | check("\\[…\\] → $$…$$ still works (no spurious blank line)", () => { |
| 323 | return normalizeMath("\\[E=mc^2\\]") === "$$\nE=mc^2\n$$"; |
| 324 | }); |
| 325 | check("digit before $$ is NOT a prose boundary (preserves c^2$$)", () => { |
| 326 | const out = normalizeMath("c^2$$ x $$"); |
| 327 | return out === "c^2$$ x $$"; |
| 328 | }); |
| 329 | eq(normalizeMath("intro$$x+1"), "intro\n$$\nx+1", "orphan opening $$ is not duplicated"); |
| 330 | eq( |
| 331 | normalizeMath("first$$a$$ middle $$b$$ end"), |
| 332 | "first\n$$\na\n$$\n middle \n$$\nb\n$$\n end", |
| 333 | "multiple display blocks on one line are all normalised", |
| 334 | ); |
| 335 | |
| 336 | console.log("\nnormalizeMath — semantic dollar decisions deferred to AST policy"); |
| 337 | eq(normalizeMath("costs $1$ today"), "costs $1$ today", "$1$ preserved for contextual classification"); |
| 338 | eq(normalizeMath("env $PATH$ here"), "env $PATH$ here", "$PATH$ preserved for AST literal restoration"); |
| 339 | eq(normalizeMath("solve $x^2 + y^2 = z^2$ please"), "solve $x^2 + y^2 = z^2$ please", "$x^2+y^2$ is math"); |
| 340 | eq(normalizeMath("$\\alpha + \\beta$"), "$\\alpha + \\beta$", "$\\alpha+\\beta$ is math"); |
| 341 | eq(normalizeMath("price is $10.50$ each"), "price is $10.50$ each", "$10.50$ preserved for contextual classification"); |
| 342 | eq(normalizeMath("$I$ think"), "$I$ think", "$I$ is math (uppercase single letter)"); |
| 343 | eq(normalizeMath("it costs $5 and $10 total"), "it costs $5 and $10 total", "multiple prose dollars preserved for parser-aware policy"); |
| 344 | |
| 345 | console.log("\nnormalizeMath — Markdown code regions stay literal"); |
| 346 | eq(normalizeMath("`$PATH$`"), "`$PATH$`", "inline code with env token"); |
| 347 | eq(normalizeMath("Use `$HOME` and `$PATH$`."), "Use `$HOME` and `$PATH$`.", "multiple inline code spans"); |
| 348 | eq(normalizeMath("```sh\necho $PATH$\n```"), "```sh\necho $PATH$\n```", "fenced code with env token"); |
| 349 | eq(normalizeMath("```\necho $PATH$\n```\n\nsolve $x^2$"), "```\necho $PATH$\n```\n\nsolve $x^2$", "fenced code protected while prose math renders"); |
| 350 | eq(normalizeMath("Code: `r.replace(/\\$\\$/, ...)`"), "Code: `r.replace(/\\$\\$/, ...)`", "escaped $ in inline code stays literal"); |
| 351 | eq(normalizeMath("```javascript\nr = r.replace(/\\$\\$([\\s\\S]*?)\\$\\$/g, ...);\n```"), "```javascript\nr = r.replace(/\\$\\$([\\s\\S]*?)\\$\\$/g, ...);\n```", "regex patterns with $ in code blocks stay literal"); |
| 352 | eq(normalizeMath("Code: `` `${DOLLAR}${m}${DOLLAR}` ``"), "Code: `` `${DOLLAR}${m}${DOLLAR}` ``", "template literals with $ in inline code stay literal"); |
| 353 | |
| 354 | // ── normalizeMath — text-mode source protection (regression for PR #3287) ───── |
| 355 | // A stray inner $ must be hidden until remark-math establishes the outer |
| 356 | // boundary, while the AST policy retains the exact source for copying. |
| 357 | |
| 358 | console.log("\nnormalizeMath — text-mode escapes (regression)"); |
| 359 | check("$\\text{cost is $5}$ inner $ is parser-safe and reversible", () => { |
| 360 | const out = normalizeMath("$\\text{cost is $5}$"); |
| 361 | return !out.slice(1, -1).includes("$") |
| 362 | && restoreProtectedInlineMathSource(out.slice(1, -1)) === "\\text{cost is $5}"; |
| 363 | }); |
| 364 | check("$\\text{baryon #}$ # escape is deferred to AST policy", () => { |
| 365 | return normalizeMath("$\\text{baryon #}$") === "$\\text{baryon #}$"; |
| 366 | }); |
| 367 | check("$\\text{a & b}$ & escape is deferred to AST policy", () => { |
| 368 | return normalizeMath("$\\text{a & b}$") === "$\\text{a & b}$"; |
| 369 | }); |
| 370 | check("$\\text{cost is \\$5}$ escaped dollar stays literal", () => { |
| 371 | return normalizeMath("$\\text{cost is \\$5}$") === "$\\text{cost is \\$5}$"; |
| 372 | }); |
| 373 | check("$\\textrm{cost is \\$5}$ escaped dollar stays literal", () => { |
| 374 | return normalizeMath("$\\textrm{cost is \\$5}$") === "$\\textrm{cost is \\$5}$"; |
| 375 | }); |
| 376 | check("$\\sqrt{x}$ non-text command preserved", () => { |
| 377 | return normalizeMath("$\\sqrt{x}$") === "$\\sqrt{x}$"; |
| 378 | }); |
| 379 | |
| 380 | // ── normalizeMath — TEXT_MODE_PAIR trailing content ────────────────────────────── |
| 381 | // $\cmd{...} + extra$ should be handled as a whole, not split at inner $. |
| 382 | |
| 383 | console.log("\nnormalizeMath — TEXT_MODE_PAIR trailing content"); |
| 384 | check("$\\text{cost is $5} + x^2$ inner $ escaped with trailing", () => { |
| 385 | const out = normalizeMath("$\\text{cost is $5} + x^2$"); |
| 386 | return restoreProtectedInlineMathSource(out.slice(1, -1)) |
| 387 | === "\\text{cost is $5} + x^2"; |
| 388 | }); |
| 389 | check("$\\text{a} | b$ pipe after text command", () => { |
| 390 | const out = normalizeMath("$\\text{a} | b$"); |
| 391 | return restoreProtectedInlineMathSource(out.slice(1, -1)) === "\\text{a} | b"; |
| 392 | }); |
| 393 | check("$\\text{abc}$ simple text-mode (no trailing)", () => { |
| 394 | return normalizeMath("$\\text{abc}$") === "$\\text{abc}$"; |
| 395 | }); |
| 396 | |
| 397 | // ── normalizeMath — GFM pipe protection (raw | marked, \\| preserved) ────────── |
| 398 | |
| 399 | console.log("\nnormalizeMath — pipe handling"); |
| 400 | check("$|x+1|$ absolute value", () => { |
| 401 | const out = normalizeMath("$|x+1|$"); |
| 402 | return restoreProtectedInlineMathSource(out.slice(1, -1)) === "|x+1|"; |
| 403 | }); |
| 404 | check("$\\|x\\|$ norm preserved (no \\vert mangling)", () => { |
| 405 | return normalizeMath("$\\|x\\|$") === "$\\|x\\|$"; |
| 406 | }); |
| 407 | |
| 408 | // ── normalizeMath — % in math (KaTeX comment-char) ───────────────────────────── |
| 409 | // KaTeX treats unescaped % as a LaTeX comment to end-of-line, silently |
| 410 | // truncating `$x = 50%$` to `$x = 50$`. Top-level % must be escaped. |
| 411 | |
| 412 | console.log("\nnormalizeMath — % in math"); |
| 413 | eq(normalizeMath("$x = 50%$"), "$x = 50%$", "trailing % escape deferred to AST policy"); |
| 414 | eq(normalizeMath("$100%$"), "$100%$", "pure percentage preserved for AST math policy"); |
| 415 | eq(normalizeMath("$10\\%$"), "$10\\%$", "already-escaped \\% left alone"); |
| 416 | |
| 417 | // ── normalizer + AST policy — end-to-end KaTeX render of common LLM outputs ─── |
| 418 | |
| 419 | console.log("\nnormalizeMath → KaTeX end-to-end"); |
| 420 | function katexOf(normalized: string, display: boolean): boolean { |
| 421 | let inner: string; |
| 422 | if (normalized.startsWith("$$") && normalized.endsWith("$$")) { |
| 423 | inner = normalized.slice(2, -2); |
| 424 | display = true; |
| 425 | } else if (normalized.startsWith("$") && normalized.endsWith("$")) { |
| 426 | inner = normalized.slice(1, -1); |
| 427 | } else { |
| 428 | return false; // no math delimiters — nothing for KaTeX to render |
| 429 | } |
| 430 | try { |
| 431 | const source = restoreProtectedInlineMathSource(inner); |
| 432 | katex.renderToString(latexNormalizeForKatex(source), { |
| 433 | throwOnError: true, |
| 434 | displayMode: display, |
| 435 | }); |
| 436 | return true; |
| 437 | } catch { |
| 438 | return false; |
| 439 | } |
| 440 | } |
| 441 | |
| 442 | const e2e: Array<[string, string]> = [ |
| 443 | ["$\\text{cost is $5}$", "text mode with literal $"], |
| 444 | ["$\\text{baryon #}$", "text mode with #"], |
| 445 | ["$\\text{a & b}$", "text mode with &"], |
| 446 | ["$\\|x\\|$", "norm"], |
| 447 | ["$|x+1|$", "abs value"], |
| 448 | ["$x=1$", "simple equation"], |
| 449 | ["$\\frac{a}{b}$", "fraction"], |
| 450 | ["$\\alpha + \\beta$", "greek letters"], |
| 451 | ["$ \\sqrt{x} $", "sqrt with surrounding spaces"], |
| 452 | ["$$E=mc^2$$", "display equation"], |
| 453 | ["\\(\\alpha\\)", "LLM-native inline delimiter"], |
| 454 | ["\\[\\sum_{i=1}^n i\\]", "LLM-native display delimiter"], |
| 455 | ["$$ |a| = |b| $$", "display with absolute values"], |
| 456 | ["$$\\boxed{\\begin{aligned}\nr_A E_\\pi(k;0) &= B(k^2) \\\\\nF_R(k;0) + 2r_A F_\\pi(k;0) &= A(k^2)\n\\end{aligned}}$$", "boxed aligned (no \\tag)"], |
| 457 | ["$$\\boxed{\\begin{aligned}\nr_A E_\\pi(k;0) &= B(k^2) \\tag{10}\\\\\nF_R(k;0) + 2r_A F_\\pi(k;0) &= A(k^2) \\tag{11}\n\\end{aligned}}$$", "boxed aligned with \\tag → align (no error)"], |
| 458 | ["\\[\\boxed{\\begin{aligned}\nx &= 1 \\\\\ny &= 2\n\\end{aligned}}\\]", "LLM-native boxed aligned"], |
| 459 | // Array with column-spec pipe — regression: |→\vert used to corrupt {c|c} |
| 460 | // into {c\vert c} (KaTeX: "Unknown column alignment"). Must render cleanly. |
| 461 | ["$$\\begin{array}{c|c} a & b \\\\ c & d \\end{array}$$", "array with c|c column spec"], |
| 462 | ["$$\\begin{array}{cc|c} a & b & c \\\\ d & e & f \\end{array}$$", "array with cc|c column spec"], |
| 463 | ["$$\\begin{array}{|c|c|} a & b \\\\ c & d \\end{array}$$", "array with |c|c| column spec"], |
| 464 | // Ket with \| delimiter (common in GFM tables where | must be escaped) |
| 465 | ["$\\|\\psi\\rangle$", "ket with \\| → single bar (regression)"], |
| 466 | ["$\\frac{1}{\\sqrt{2}}\\|uud\\rangle$", "ket in fraction with \\|"], |
| 467 | ["$\\|x\\|$", "norm \\|x\\| → double bar (regression)"], |
| 468 | ["$\\langle\\psi\\|$", "bra closer \\| → single bar (regression)"], |
| 469 | ["$S'$", "primed letter S'"], |
| 470 | ["$f'(x)$", "primed function call"], |
| 471 | ["$[56]$", "bracketed irrep label"], |
| 472 | ["$[56,0^+]$", "bracketed irrep label with charge"], |
| 473 | ]; |
| 474 | for (const [src, label] of e2e) { |
| 475 | check(`${label}: ${src}`, () => katexOf(normalizeMath(src), false)); |
| 476 | } |
| 477 | |
| 478 | // Inputs that contain no math delimiters must survive normalizeMath |
| 479 | // unchanged — KaTeX isn't involved here. |
| 480 | console.log("\nnormalizeMath — non-math inputs pass through"); |
| 481 | type Passthrough = { src: string; expected: string; label: string }; |
| 482 | const passthrough: Passthrough[] = [ |
| 483 | { src: "costs $100$ today", expected: "costs $100$ today", label: "multi-digit currency preserved for AST policy" }, |
| 484 | { src: "line break \\\\[4pt] here", expected: "line break \\\\[4pt] here", label: "LaTeX line-break spacing" }, |
| 485 | { src: "hello world", expected: "hello world", label: "plain text" }, |
| 486 | ]; |
| 487 | for (const { src, expected, label } of passthrough) { |
| 488 | check(`${label}: ${src}`, () => normalizeMath(src) === expected); |
| 489 | } |
| 490 | |
| 491 | // ── remark-math render boundary ──────────────────────────────────────────────── |
| 492 | // These cases cross the real react-markdown → remark-math → Reasonix AST |
| 493 | // policy → rehype-katex boundary. The policy restores literal nodes after |
| 494 | // parsing, so rejected content cannot be reparsed as math. |
| 495 | |
| 496 | console.log("\nnormalizeMath → remark-math render boundary"); |
| 497 | |
| 498 | function renderHtml(src: string): string { |
| 499 | return renderToStaticMarkup( |
| 500 | createElement(ReactMarkdown, { |
| 501 | remarkPlugins: reasonixRemarkPlugins, |
| 502 | rehypePlugins: reasonixRehypePlugins, |
| 503 | children: normalizeMath(src), |
| 504 | }), |
| 505 | ); |
| 506 | } |
| 507 | |
| 508 | check("currency '$5 and $6' renders as literal dollars, not math", () => { |
| 509 | const html = renderHtml("These two apples cost $5 and $6"); |
| 510 | return !html.includes("katex") && html.includes("$5") && html.includes("$6"); |
| 511 | }); |
| 512 | check("paired currency 'costs $1$ today' drops the spurious closing delimiter", () => { |
| 513 | const html = renderHtml("costs $1$ today"); |
| 514 | return !html.includes("katex") && html.includes("$1 today") && !html.includes("$1$"); |
| 515 | }); |
| 516 | check("paired decimal currency uses surrounding price context", () => { |
| 517 | const html = renderHtml("price is $10.50$ each"); |
| 518 | return !html.includes("katex") && html.includes("$10.50 each") && !html.includes("$10.50$"); |
| 519 | }); |
| 520 | check("Chinese paired currency uses localized price context", () => { |
| 521 | const html = renderHtml("价格是$5$"); |
| 522 | return !html.includes("katex") && html.includes("价格是$5") && !html.includes("$5$"); |
| 523 | }); |
| 524 | check("full-width punctuation keeps paired currency literal", () => { |
| 525 | const html = renderHtml("价格:$5$"); |
| 526 | return !html.includes("katex") && html.includes("价格:$5") && !html.includes("$5$"); |
| 527 | }); |
| 528 | check("parentheses do not hide preceding currency context", () => { |
| 529 | const html = renderHtml("The price ($5$) includes tax."); |
| 530 | return !html.includes("katex") && html.includes("The price ($5) includes tax."); |
| 531 | }); |
| 532 | check("braces do not hide preceding currency context", () => { |
| 533 | const html = renderHtml("The price {$5$} includes tax."); |
| 534 | return !html.includes("katex") && html.includes("The price {$5} includes tax."); |
| 535 | }); |
| 536 | check("curly quotes do not hide preceding currency context", () => { |
| 537 | const html = renderHtml("The price ‘$5$’ includes tax."); |
| 538 | return !html.includes("katex") && html.includes("The price ‘$5’ includes tax."); |
| 539 | }); |
| 540 | check("full-width parentheses do not hide Chinese currency context", () => { |
| 541 | const html = renderHtml("价格($5$)含税。"); |
| 542 | return !html.includes("katex") && html.includes("价格($5)含税。"); |
| 543 | }); |
| 544 | check("parenthesized suffix currency unit repairs paired dollars", () => { |
| 545 | const html = renderHtml("It is $5$ (USD)."); |
| 546 | return !html.includes("katex") && html.includes("It is $5 (USD)."); |
| 547 | }); |
| 548 | check("dash-separated cash suffix repairs paired dollars", () => { |
| 549 | const html = renderHtml("It is $5$—cash."); |
| 550 | return !html.includes("katex") && html.includes("It is $5—cash."); |
| 551 | }); |
| 552 | check("cash context keeps paired currency literal", () => { |
| 553 | const html = renderHtml("I have $5$ in cash"); |
| 554 | return !html.includes("katex") && html.includes("$5 in cash") && !html.includes("$5$"); |
| 555 | }); |
| 556 | check("bold markup does not hide preceding currency context", () => { |
| 557 | const html = renderHtml("costs **$5$** today"); |
| 558 | return !html.includes("katex") && html.includes("<strong>$5</strong>"); |
| 559 | }); |
| 560 | check("emphasis does not hide surrounding currency context", () => { |
| 561 | const html = renderHtml("price is *$10.50$* each"); |
| 562 | return !html.includes("katex") && html.includes("<em>$10.50</em>"); |
| 563 | }); |
| 564 | check("ambiguous paired numbers remain literal without positive math context", () => { |
| 565 | const html = renderHtml("from $5$ to $10$"); |
| 566 | return !html.includes("katex") && html.includes("$5$") && html.includes("$10$"); |
| 567 | }); |
| 568 | check("env var $PATH$ renders as literal, not math", () => { |
| 569 | const html = renderHtml("env $PATH$ here"); |
| 570 | return !html.includes("katex") && html.includes("$PATH$"); |
| 571 | }); |
| 572 | check("range endpoint 10–$20$ MeV renders numeric math", () => { |
| 573 | const html = renderHtml("10–$20$ MeV"); |
| 574 | return html.includes("katex") && html.includes("<mn>20</mn>"); |
| 575 | }); |
| 576 | check("standalone $42$ remains literal without positive math context", () => { |
| 577 | const html = renderHtml("$42$ elements"); |
| 578 | return !html.includes("katex") && html.includes("$42$ elements"); |
| 579 | }); |
| 580 | check("scientific unit makes a paired number mathematical", () => { |
| 581 | const html = renderHtml("$20$ MeV"); |
| 582 | return html.includes("katex") && html.includes("<mn>20</mn>"); |
| 583 | }); |
| 584 | check("centimetres make a paired number mathematical", () => { |
| 585 | const html = renderHtml("$5$ cm"); |
| 586 | return html.includes("katex") && html.includes("<mn>5</mn>"); |
| 587 | }); |
| 588 | check("litres make a paired number mathematical", () => { |
| 589 | const html = renderHtml("$2$ L"); |
| 590 | return html.includes("katex") && html.includes("<mn>2</mn>"); |
| 591 | }); |
| 592 | check("decibels make a paired number mathematical", () => { |
| 593 | const html = renderHtml("$3$ dB"); |
| 594 | return html.includes("katex") && html.includes("<mn>3</mn>"); |
| 595 | }); |
| 596 | check("parenthesized scientific quantity remains mathematical", () => { |
| 597 | const html = renderHtml("A vector ($5$ m) long."); |
| 598 | return html.includes("katex") && html.includes("<mn>5</mn>"); |
| 599 | }); |
| 600 | check("parenthesized ambiguous value retains mathematical wrapper context", () => { |
| 601 | const html = renderHtml("The value ($5$) is exact."); |
| 602 | return html.includes("katex") && html.includes("<mn>5</mn>"); |
| 603 | }); |
| 604 | check("one-sided equality $=1$ renders as math", () => { |
| 605 | const html = renderHtml("set $=1$ here"); |
| 606 | return html.includes("katex") && html.includes("<mo>=</mo>"); |
| 607 | }); |
| 608 | check("one-sided equality does not prefix-match prose", () => { |
| 609 | const html = renderHtml("set $=1 dollar$ here"); |
| 610 | return !html.includes("katex") && html.includes("$=1 dollar$"); |
| 611 | }); |
| 612 | check("inline code remains outside math policy", () => { |
| 613 | const html = renderHtml("code `$42$` and env `$PATH$`"); |
| 614 | return !html.includes("katex") && html.includes("<code>$42$</code>") && html.includes("<code>$PATH$</code>"); |
| 615 | }); |
| 616 | check("AST policy applies KaTeX percent normalisation", () => { |
| 617 | const html = renderHtml("$x = 50%$"); |
| 618 | return html.includes("katex") |
| 619 | && !html.includes("katex-error") |
| 620 | && html.includes('data-latex-source="x = 50%"') |
| 621 | && html.includes('encoding="application/x-tex">x = 50%</annotation>'); |
| 622 | }); |
| 623 | check("AST policy applies unbraced slashed normalisation", () => { |
| 624 | const html = renderHtml("$\\slashed a$"); |
| 625 | return html.includes("katex") |
| 626 | && !html.includes("katex-error") |
| 627 | && html.includes('data-latex-source="\\slashed a"') |
| 628 | && html.includes('encoding="application/x-tex">\\slashed a</annotation>'); |
| 629 | }); |
| 630 | check("AST policy preserves braced slashed source after rendering", () => { |
| 631 | const html = renderHtml("$\\slashed{p}$"); |
| 632 | return html.includes("katex") |
| 633 | && !html.includes("katex-error") |
| 634 | && html.includes('data-latex-source="\\slashed{p}"') |
| 635 | && html.includes('encoding="application/x-tex">\\slashed{p}</annotation>'); |
| 636 | }); |
| 637 | check("parser-safe text-mode math restores the exact copy source", () => { |
| 638 | const html = renderHtml("$\\text{cost is $5}$"); |
| 639 | return html.includes("katex") |
| 640 | && !html.includes("katex-error") |
| 641 | && html.includes('data-latex-source="\\text{cost is $5}"') |
| 642 | && html.includes('encoding="application/x-tex">\\text{cost is $5}</annotation>'); |
| 643 | }); |
| 644 | check("real inline math $x^2$ still renders as KaTeX", () => { |
| 645 | const html = renderHtml("the value $x^2$ here"); |
| 646 | return html.includes("katex"); |
| 647 | }); |
| 648 | check("inline math with asymmetric delimiter padding still renders", () => { |
| 649 | const html = renderHtml("before $\\alpha $ after"); |
| 650 | return html.includes("katex") |
| 651 | && html.includes('data-latex-source="\\alpha "') |
| 652 | && html.includes('encoding="application/x-tex">\\alpha </annotation>'); |
| 653 | }); |
| 654 | check("inline math with multiple delimiter spaces still renders", () => { |
| 655 | const html = renderHtml("before $ x $ after"); |
| 656 | return html.includes("katex") |
| 657 | && html.includes('data-latex-source=" x "') |
| 658 | && html.includes('encoding="application/x-tex"> x </annotation>'); |
| 659 | }); |
| 660 | check("GFM table preserves inline absolute-value math and every cell", () => { |
| 661 | const html = renderHtml("| Expr | Value |\n| --- | --- |\n| $|x|$ | abs |"); |
| 662 | return html.includes("<table>") |
| 663 | && html.includes("katex") |
| 664 | && html.includes('data-latex-source="|x|"') |
| 665 | && html.includes('encoding="application/x-tex">|x|</annotation>') |
| 666 | && html.includes("<td>abs</td>") |
| 667 | && (html.match(/<td>/g) ?? []).length === 2; |
| 668 | }); |
| 669 | check("display math preserves original TeX in the KaTeX root and annotation", () => { |
| 670 | const html = renderHtml("$$|x|$$"); |
| 671 | return html.includes('class="katex-display" data-latex-source="|x|"') |
| 672 | && html.includes('encoding="application/x-tex">|x|</annotation>'); |
| 673 | }); |
| 674 | check("inline Young diagrams preserve their authored macro source", () => { |
| 675 | const html = renderHtml("before $V=\\yng(2,1)$ after"); |
| 676 | return html.includes("katex") |
| 677 | && !html.includes("katex-error") |
| 678 | && !html.includes("reasonixInternal") |
| 679 | && html.includes('data-latex-source="V=\\yng(2,1)"') |
| 680 | && html.includes('encoding="application/x-tex">V=\\yng(2,1)</annotation>'); |
| 681 | }); |
| 682 | check("display Young tableaux preserve their authored macro source", () => { |
| 683 | const html = renderHtml("$$\\young(ab,c)$$"); |
| 684 | return html.includes("katex-display") |
| 685 | && !html.includes("katex-error") |
| 686 | && !html.includes("reasonixInternal") |
| 687 | && html.includes('data-latex-source="\\young(ab,c)"') |
| 688 | && html.includes('encoding="application/x-tex">\\young(ab,c)</annotation>'); |
| 689 | }); |
| 690 | check("Young source survives nested pipe protection without cross-assignment", () => { |
| 691 | const html = renderHtml("$V=\\yng(2,1) | x + \\young(ab,c)$"); |
| 692 | const source = "V=\\yng(2,1) | x + \\young(ab,c)"; |
| 693 | return html.includes("katex") |
| 694 | && !html.includes("katex-error") |
| 695 | && !html.includes("reasonixInternal") |
| 696 | && html.includes(`data-latex-source="${source}"`) |
| 697 | && html.includes(`encoding="application/x-tex">${source}</annotation>`); |
| 698 | }); |
| 699 | check("multiple formulas restore their own source without cross-assignment", () => { |
| 700 | const html = renderHtml("$|x|$ then $x = 50%$"); |
| 701 | const annotations = html.match(/<annotation encoding="application\/x-tex">.*?<\/annotation>/g) ?? []; |
| 702 | return annotations.length === 2 |
| 703 | && annotations[0].includes(">|x|</annotation>") |
| 704 | && annotations[1].includes(">x = 50%</annotation>"); |
| 705 | }); |
| 706 | check("blockquote display math does not swallow following inline math", () => { |
| 707 | const html = renderHtml("> theorem\n> $$E=mc^2$$\n> after $x$"); |
| 708 | return html.includes("katex-display") |
| 709 | && !html.includes("katex-error") |
| 710 | && html.includes(">x</mi>"); |
| 711 | }); |
| 712 | check("multi-line blockquote display math strips quote markers from the formula", () => { |
| 713 | const html = renderHtml("> theorem\n> $$\n> E=mc^2\n> $$\n> after $x$"); |
| 714 | return html.includes("katex-display") |
| 715 | && !html.includes("katex-error") |
| 716 | && !html.includes("> E") |
| 717 | && html.includes(">x</mi>"); |
| 718 | }); |
| 719 | |
| 720 | // ── Young diagram / tableau macros ───────────────────────────────────────────── |
| 721 | // `\yng` (ytableau) and `\young` (youngtab) are common in physics — |
| 722 | // SU(N) irreps, tensor decompositions, character tables — but KaTeX |
| 723 | // doesn't bundle either macro package. The pre-pass translates them |
| 724 | // to KaTeX-compatible `\boxed{array}` forms inside the math body so |
| 725 | // the diagram renders as a grid of boxes. |
| 726 | |
| 727 | console.log("\nnormalizeMath — Young diagram macros"); |
| 728 | |
| 729 | check("\\yng(2,1) renders as (2,1) Young diagram", () => { |
| 730 | const html = renderHtml("$$\\yng(2,1)$$"); |
| 731 | return html.includes("katex-display") && !html.includes("katex-error"); |
| 732 | }); |
| 733 | check("\\yng(2,1) in prose (no $ delimiters) gets wrapped and rendered", () => { |
| 734 | // A model that writes "the partition \\yng(2,1) corresponds to the |
| 735 | // (2,1) irrep" doesn't usually put $$ around the macro. The |
| 736 | // translator wraps bare \\yng in `$…$` so remark-math sees it as |
| 737 | // inline math and katex renders the diagram. |
| 738 | const html = renderHtml("The partition \\yng(2,1) is symmetric."); |
| 739 | return html.includes("katex") |
| 740 | && !html.includes("katex-error") |
| 741 | && !html.includes("reasonixInternal") |
| 742 | && html.includes('data-latex-source="\\yng(2,1)"'); |
| 743 | }); |
| 744 | check("\\yng inside \\(...\\) does not get double-wrapped", () => { |
| 745 | const out = resolveProtectedInlineMathSource(normalizeMath("\\(\\yng(2,1)\\)")); |
| 746 | return out.source === "$\\yng(2,1)$" |
| 747 | && out.rendered === "$\\begin{array}{l}\\square \\! \\square \\\\[-0.525em] \\square\\end{array}$"; |
| 748 | }); |
| 749 | check("\\yng inside \\[...\\] stays display math without triple dollars", () => { |
| 750 | const out = resolveProtectedInlineMathSource(normalizeMath("\\[\\yng(2,1)\\]")); |
| 751 | return out.source === "$$\n\\yng(2,1)\n$$" |
| 752 | && out.rendered.startsWith("$$\n\\begin{array}{l}") |
| 753 | && out.rendered.endsWith("$$") |
| 754 | && !out.rendered.includes("$$$"); |
| 755 | }); |
| 756 | check("escaped dollar before bare \\yng does not suppress wrapping", () => { |
| 757 | const src = String.raw`Price is \$5; shape \yng(2,1)`; |
| 758 | const expected = String.raw`Price is \$5; shape $\begin{array}{l}\square \! \square \\[-0.525em] \square\end{array}$`; |
| 759 | const out = resolveProtectedInlineMathSource(normalizeMath(src)); |
| 760 | return out.source === String.raw`Price is \$5; shape $\yng(2,1)$` |
| 761 | && out.rendered === expected; |
| 762 | }); |
| 763 | check("digit-starting inline math with \\yng does not get nested wrappers", () => { |
| 764 | const out = resolveProtectedInlineMathSource(normalizeMath("$3\\,\\yng(2,1)$")); |
| 765 | return out.source === "$3\\,\\yng(2,1)$" |
| 766 | && out.rendered === "$3\\,\\begin{array}{l}\\square \\! \\square \\\\[-0.525em] \\square\\end{array}$"; |
| 767 | }); |
| 768 | check("digit-starting inline math with \\young does not get nested wrappers", () => { |
| 769 | const out = resolveProtectedInlineMathSource(normalizeMath("$2 + \\young(ab,c)$")); |
| 770 | return out.source === "$2 + \\young(ab,c)$" |
| 771 | && out.rendered === "$2 + \\begin{array}{l}\\boxed{a} \\! \\boxed{b} \\\\[-0.525em] \\boxed{c}\\end{array}$"; |
| 772 | }); |
| 773 | check("display math ending in digit closes before following bare \\yng", () => { |
| 774 | const out = resolveProtectedInlineMathSource(normalizeMath("$$x^2$$ \\yng(1)")); |
| 775 | return out.source === "$$\nx^2\n$$\n $\\yng(1)$" |
| 776 | && out.rendered === "$$\nx^2\n$$\n $\\begin{array}{l}\\square\\end{array}$"; |
| 777 | }); |
| 778 | check("bare \\yng after inline math is separated from adjacent dollars", () => { |
| 779 | const out = resolveProtectedInlineMathSource(normalizeMath("$x$\\yng(1)")); |
| 780 | return out.source === "$x$ $\\yng(1)$" |
| 781 | && out.rendered === "$x$ $\\begin{array}{l}\\square\\end{array}$"; |
| 782 | }); |
| 783 | check("bare \\yng before inline math is separated from adjacent dollars", () => { |
| 784 | const out = resolveProtectedInlineMathSource(normalizeMath("\\yng(1)$x$")); |
| 785 | return out.source === "$\\yng(1)$ $x$" |
| 786 | && out.rendered === "$\\begin{array}{l}\\square\\end{array}$ $x$"; |
| 787 | }); |
| 788 | check("\\yng (2,1) with a space before parens gets wrapped and rendered", () => { |
| 789 | const html = renderHtml("The partition \\yng (2,1) is symmetric."); |
| 790 | return html.includes("katex") |
| 791 | && !html.includes("katex-error") |
| 792 | && !html.includes("reasonixInternal") |
| 793 | && html.includes('data-latex-source="\\yng (2,1)"'); |
| 794 | }); |
| 795 | check("\\yng(3,2,1) renders as (3,2,1) Young diagram", () => { |
| 796 | const html = renderHtml("$$\\yng(3,2,1)$$"); |
| 797 | return html.includes("katex-display") && !html.includes("katex-error"); |
| 798 | }); |
| 799 | check("\\yng(2,1){a&b\\\\c\\\\d&e} renders filled Young tableau", () => { |
| 800 | const html = renderHtml("$$\\yng(2,1){a&b\\\\c\\\\d&e}$$"); |
| 801 | return html.includes("katex-display") && !html.includes("katex-error"); |
| 802 | }); |
| 803 | check("\\young(2 1) compatibility shorthand renders as (2,1) diagram", () => { |
| 804 | const html = renderHtml("$$\\young(2 1)$$"); |
| 805 | return html.includes("katex-display") && !html.includes("katex-error"); |
| 806 | }); |
| 807 | check("\\young(ab,c) labelled youngtab syntax renders labels", () => { |
| 808 | const html = renderHtml("$$\\young(ab,c)$$"); |
| 809 | return html.includes("katex-display") |
| 810 | && !html.includes("katex-error") |
| 811 | && !html.includes("reasonixInternal") |
| 812 | && html.includes('data-latex-source="\\young(ab,c)"') |
| 813 | && ["a", "b", "c"].every((label) => html.includes(label)); |
| 814 | }); |
| 815 | check("\\young(ab,c) labelled cells keep boxes", () => { |
| 816 | const out = expandYoungDiagrams("\\young(ab,c)"); |
| 817 | return out.includes("\\boxed{a}") |
| 818 | && out.includes("\\boxed{b}") |
| 819 | && out.includes("\\boxed{c}"); |
| 820 | }); |
| 821 | check("\\young(abcd,:cd,:c) skew placeholders are invisible offsets", () => { |
| 822 | const out = expandYoungDiagrams("\\young(abcd,:cd,:c)"); |
| 823 | return out.includes("\\hphantom{\\boxed{x}}") |
| 824 | && !out.includes("\\boxed{:}"); |
| 825 | }); |
| 826 | check("\\yng(4,3,2,1) renders as (4,3,2,1) Young diagram", () => { |
| 827 | const html = renderHtml("$$\\yng(4,3,2,1)$$"); |
| 828 | return html.includes("katex-display") && !html.includes("katex-error"); |
| 829 | }); |
| 830 | check("\\yng(3,2,1) uses left-aligned array (rows start at same x)", () => { |
| 831 | // A Young diagram's shorter rows must start at the same x-position |
| 832 | // as the longest row's first cell — `{l}` (left) instead of `{c}` |
| 833 | // (centered) gives that layout. Without this, the diagram looks |
| 834 | // like each row is independently centred, which isn't a Young |
| 835 | // diagram. |
| 836 | const out = expandYoungDiagrams("\\yng(3,2,1)"); |
| 837 | return out.includes("\\begin{array}{l}") |
| 838 | && !out.includes("\\begin{array}{c}"); |
| 839 | }); |
| 840 | check("expandYoungDiagrams uses flush cells (\\! cancels \\,) ", () => { |
| 841 | // Adjacent \square boxes should be flush — the convention for Young |
| 842 | // diagrams. The translator uses `\!` (negative thin space, -0.1667em) |
| 843 | // which exactly cancels `\,` so cells touch without visible gap. |
| 844 | // `\,` (positive thin space) would leave a gap. |
| 845 | const out = expandYoungDiagrams("\\yng(3)"); |
| 846 | return out.includes("\\!") && !out.includes("\\, "); |
| 847 | }); |
| 848 | check("expandYoungDiagrams uses flush rows (\\[-0.525em] closes the math-axis gap)", () => { |
| 849 | // The math axis positions a \square glyph centred on the row |
| 850 | // baseline, which leaves a visible ~0.4em gap between the bottom of |
| 851 | // one row's box and the top of the next row's box when the default |
| 852 | // 1.2em baseline-to-baseline spacing is used. Using `\\[-0.4em]` |
| 853 | // between rows pulls each subsequent row up by the math-axis offset, |
| 854 | // so consecutive rows touch. (Earlier versions tried wrapping each |
| 855 | // cell in `\raisebox{-0.35em}` which does NOT close the gap — |
| 856 | // uniform translation can't change the relative distance between |
| 857 | // row baselines.) |
| 858 | const out = expandYoungDiagrams("\\yng(2,1)"); |
| 859 | return out.includes("\\\\[-0.525em]"); |
| 860 | }); |
| 861 | check("expandYoungDiagrams substitutes correct array form", () => { |
| 862 | // Direct unit test on the translator — no need to go through the |
| 863 | // full pipeline for this assertion. |
| 864 | const out = expandYoungDiagrams("\\yng(2,1)"); |
| 865 | return out.includes("\\begin{array}{l}") |
| 866 | && out.includes("\\square") |
| 867 | && out.includes(" \\\\[-0.525em] "); |
| 868 | }); |
| 869 | check("expandYoungDiagrams handles \\yng with content", () => { |
| 870 | // Bare \yng in prose gets wrapped in `$…$` so remark-math sees it as |
| 871 | // math; macros already inside a `$…$` block just substitute the inner |
| 872 | // form (the surrounding delimiters are preserved). |
| 873 | // Cells are joined with `\!` (negative thin space) so adjacent |
| 874 | // boxes are flush. Row separators use `\\[-0.525em]` (per-row |
| 875 | // negative spacing) so consecutive rows touch — the visible |
| 876 | // glyph height of `\square` is 0.675em (measured from katex's |
| 877 | // single-glyph strut), and the default 1.2em baseline spacing |
| 878 | // leaves 0.525em of gap. `\\[-0.525em]` subtracts exactly that. |
| 879 | const out = expandYoungDiagrams("\\yng(2,1){a&b\\\\c}"); |
| 880 | return out === "$\\begin{array}{l}\\boxed{a} \\! \\boxed{b} \\\\[-0.525em] \\boxed{c}\\end{array}$"; |
| 881 | }); |
| 882 | check("expandYoungDiagrams handles labelled \\young rows", () => { |
| 883 | const out = expandYoungDiagrams("\\young(ab,c)"); |
| 884 | return out === "$\\begin{array}{l}\\boxed{a} \\! \\boxed{b} \\\\[-0.525em] \\boxed{c}\\end{array}$"; |
| 885 | }); |
| 886 | check("expandYoungDiagrams treats comma-separated numeric \\young as labels, not a 12-cell row", () => { |
| 887 | const out = expandYoungDiagrams("\\young(12,3)"); |
| 888 | return out === "$\\begin{array}{l}\\boxed{1} \\! \\boxed{2} \\\\[-0.525em] \\boxed{3}\\end{array}$"; |
| 889 | }); |
| 890 | check("expandYoungDiagrams leaves invalid negative \\yng shape alone", () => { |
| 891 | const out = expandYoungDiagrams("\\yng(-1)"); |
| 892 | return out === "\\yng(-1)"; |
| 893 | }); |
| 894 | check("expandYoungDiagrams leaves oversized \\yng shape alone", () => { |
| 895 | const out = expandYoungDiagrams("\\yng(513)"); |
| 896 | return out === "\\yng(513)"; |
| 897 | }); |
| 898 | check("expandYoungDiagrams leaves non-Young macros alone", () => { |
| 899 | const out = expandYoungDiagrams("\\frac{a}{b}"); |
| 900 | return out === "\\frac{a}{b}"; |
| 901 | }); |
| 902 | |
| 903 | // ── Summary ─────────────────────────────────────────────────────────────────── |
| 904 | |
| 905 | console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 906 | if (failed > 0) process.exit(1); |
| 907 |