| 1 | // Normalize LaTeX source for KaTeX rendering. Only processes already-identified |
| 2 | // math source — never raw Markdown text. |
| 3 | // |
| 4 | // Handles two things: |
| 5 | // 1. LaTeX text-mode commands (\text{}, \textrm{}, etc.) where KaTeX requires |
| 6 | // escaped literal characters (#, %, &, _, $, ^, ~). |
| 7 | // 2. | → \vert inside math-mode content. |
| 8 | |
| 9 | const TEXT_COMMANDS = new Set([ |
| 10 | "emph", |
| 11 | "mbox", |
| 12 | "text", |
| 13 | "textbf", |
| 14 | "textit", |
| 15 | "textmd", |
| 16 | "textnormal", |
| 17 | "textrm", |
| 18 | "textsf", |
| 19 | "texttt", |
| 20 | "textup", |
| 21 | ]); |
| 22 | |
| 23 | // Environments whose first {...} argument is a column specification (preamble). |
| 24 | // Inside that brace group, `|` means "draw a vertical rule" (not \vert) and |
| 25 | // `@{...}` is an inter-column decoration — both must be copied verbatim, or |
| 26 | // the | → \vert rewrite below corrupts `{c|c}` into `{c\vert c}` and KaTeX |
| 27 | // fails with "Unknown column alignment: \vert". |
| 28 | const COLUMN_SPEC_ENVS = new Set([ |
| 29 | "array", |
| 30 | "tabular", |
| 31 | "tabularx", |
| 32 | "longtable", |
| 33 | "matrix", // pmatrix/bmatrix etc. have no preamble, but harmless to list |
| 34 | "subarray", |
| 35 | ]); |
| 36 | |
| 37 | export function latexNormalizeForKatex(source: string) { |
| 38 | // ── Ket-pipe fix ──────────────────────────────────────────────────────── |
| 39 | // In GFM Markdown tables, `|` is the column delimiter, so an LLM that |
| 40 | // writes a ket like |uud⟩ must escape it as \|uud\rangle to avoid breaking |
| 41 | // the table. But `\|` in LaTeX/KaTeX is the *parallel-to* symbol (double |
| 42 | // bar ‖, U+2225), NOT a ket bar (single bar ∣, U+2223). The result is |
| 43 | // kets rendered with heavy double bars instead of light single bars. |
| 44 | // |
| 45 | // We distinguish kets from norms: |
| 46 | // \|x\| → norm (matched \|...\| pair) → keep ‖ |
| 47 | // \|uud\rangle → ket (unpaired, ends in \rangle) → convert to \vert |
| 48 | // \langle\psi\| → bra (unpaired, starts with \langle) → convert to \vert |
| 49 | // |
| 50 | // Strategy: find every \|...\rangle or \langle...\| span and rewrite the |
| 51 | // lone \| inside it to \vert. Matched \|...\| pairs (norms) are left alone. |
| 52 | source = fixKetPipes(source); |
| 53 | |
| 54 | // Convert \slashed{X} → \not{X} and \slashed X → \not X. KaTeX doesn't |
| 55 | // support \slashed, but \not provides a similar visual effect (slash |
| 56 | // through the character). This is commonly used in physics for Feynman |
| 57 | // slash notation (\slashed{p}, \slashed{\partial}). |
| 58 | // Handles two forms: |
| 59 | // 1. Braced: \slashed{X} → \not{X} |
| 60 | // 2. Unbraced: \slashed X → \not X (single token, no spaces) |
| 61 | source = source.replace(/\\slashed\s*\{((?:[^{}]|\{[^{}]*\})*)\}/g, "\\not{$1}"); |
| 62 | // Also handle unbraced forms: |
| 63 | // \slashed\epsilon → \not{\epsilon} |
| 64 | // \slashed\epsilon(0) → \not{\epsilon(0)} |
| 65 | // \slashed a → \not a |
| 66 | // \slashed x → \not x |
| 67 | // Match a backslash command (optionally followed by (...) for function calls) |
| 68 | // or a single ASCII letter. Use a function so we can add braces around |
| 69 | // function-call forms. |
| 70 | source = source.replace(/\\slashed\s*(\\[A-Za-z]+(?:\([^)]*\))?|[A-Za-z])/g, (_match, inner) => { |
| 71 | return inner.includes("(") ? `\\not{${inner}}` : `\\not ${inner}`; |
| 72 | }); |
| 73 | |
| 74 | // When \tag is present, convert aligned/gathered/alignedat environments |
| 75 | // to align/gather/alignat. KaTeX's aligned/gathered treat the entire |
| 76 | // block as one equation and only permit a single \tag (parse error |
| 77 | // "Multiple \tag" on older versions), while align/gather support \tag |
| 78 | // on every row natively. We only convert when \tag exists so that |
| 79 | // plain aligned blocks keep their un-numbered behaviour. |
| 80 | if (/\\tag\*?\s*\{/.test(source)) { |
| 81 | source = source |
| 82 | .replace(/\\begin\{alignedat\}\{(\d+)\}/g, "\\begin{alignat}{$1}") |
| 83 | .replace(/\\end\{alignedat\}/g, "\\end{alignat}") |
| 84 | .replace(/\\begin\{aligned\}/g, "\\begin{align}") |
| 85 | .replace(/\\end\{aligned\}/g, "\\end{align}") |
| 86 | .replace(/\\begin\{gathered\}/g, "\\begin{gather}") |
| 87 | .replace(/\\end\{gathered\}/g, "\\end{gather}"); |
| 88 | } |
| 89 | |
| 90 | let out = ""; |
| 91 | let i = 0; |
| 92 | |
| 93 | while (i < source.length) { |
| 94 | if (source[i] === "\\") { |
| 95 | const cmd = readCommand(source, i); |
| 96 | if (cmd && TEXT_COMMANDS.has(cmd.name) && source[cmd.end] === "{") { |
| 97 | const rewritten = rewriteTextArg(source, cmd.end); |
| 98 | if (rewritten) { |
| 99 | out += source.slice(i, cmd.end + 1) + rewritten.content + "}"; |
| 100 | i = rewritten.end + 1; |
| 101 | continue; |
| 102 | } |
| 103 | } |
| 104 | if (cmd) { |
| 105 | // \begin{array} / \begin{tabular} / etc.: the next {...} argument is |
| 106 | // a column specification (preamble). Inside it, `|` means "vertical |
| 107 | // rule" and must NOT be rewritten to \vert — that corrupts `{c|c}` |
| 108 | // into `{c\vert c}` which KaTeX rejects ("Unknown column alignment"). |
| 109 | // `%` inside a column spec is rare but also belongs to the spec, not |
| 110 | // the equation, so we copy the whole brace group verbatim. |
| 111 | if (cmd.name === "begin") { |
| 112 | const envName = readBeginEnvName(source, cmd.end); |
| 113 | if (envName && COLUMN_SPEC_ENVS.has(envName)) { |
| 114 | const specEnd = findMatchingBrace(source, cmd.end, envName); |
| 115 | if (specEnd > 0) { |
| 116 | // Copy from the `\` of \begin through the closing `}` of the |
| 117 | // column spec verbatim — no | or % rewriting inside it. |
| 118 | out += source.slice(i, specEnd + 1); |
| 119 | i = specEnd + 1; |
| 120 | continue; |
| 121 | } |
| 122 | } |
| 123 | } |
| 124 | out += source.slice(i, cmd.end); |
| 125 | i = cmd.end; |
| 126 | continue; |
| 127 | } |
| 128 | out += source[i]; |
| 129 | i += 1; |
| 130 | continue; |
| 131 | } |
| 132 | |
| 133 | if (source[i] === "|") { |
| 134 | out += "\\vert"; |
| 135 | if (/[A-Za-z]/.test(source[i + 1] ?? "")) out += " "; |
| 136 | i += 1; |
| 137 | continue; |
| 138 | } |
| 139 | |
| 140 | // KaTeX treats unescaped `%` as a LaTeX comment char and silently |
| 141 | // truncates the formula — e.g. `$x = 50%$` renders as just `x = 50`. |
| 142 | // Escape every top-level `%` to `\%`. Already-escaped `\%` is handled |
| 143 | // above as a 2-char command, so we never reach this branch for it. |
| 144 | if (source[i] === "%") { |
| 145 | out += "\\%"; |
| 146 | i += 1; |
| 147 | continue; |
| 148 | } |
| 149 | |
| 150 | out += source[i]; |
| 151 | i += 1; |
| 152 | } |
| 153 | |
| 154 | return out; |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * Convert `\|` (parallel-to, double bar) to `\vert` (single bar) when it is |
| 159 | * used as a ket/bra delimiter rather than a norm. |
| 160 | * |
| 161 | * In GFM Markdown tables, `|` is the column delimiter. To write a ket |
| 162 | * `|ψ⟩` inside a table, the `|` must be escaped as `\|` — otherwise the |
| 163 | * table breaks. But `\|` in LaTeX/KaTeX renders as the *parallel-to* glyph |
| 164 | * (‖, U+2225), the heavy double bar used for norms, not the light single |
| 165 | * bar (∣, U+2223) that kets use. So `\|uud\rangle` renders as `‖uud⟩` |
| 166 | * instead of `|uud⟩`. |
| 167 | * |
| 168 | * We distinguish kets/bra from norms: |
| 169 | * - A **norm** is a matched `\|...\|` pair: `\|x\|`, `\|\vec{v}\|^2`. |
| 170 | * These correctly need the double bar and are left untouched. |
| 171 | * - A **ket** is an unpaired `\|` followed by content ending in |
| 172 | * `\rangle`: `\|uud\rangle`. The `\|` is converted to `\vert`. |
| 173 | * - A **bra** is `\langle...\|`: `\langle\psi\|`. The trailing `\|` is |
| 174 | * converted to `\vert`. |
| 175 | * |
| 176 | * We process left-to-right. When we find `\|`, we scan ahead for the next |
| 177 | * `\|` or `\rangle`: |
| 178 | * - next delimiter is `\rangle` → this `\|` is a ket opener → `\vert` |
| 179 | * - next delimiter is `\|` → this is a norm opening → keep `\|`, |
| 180 | * and skip the matching closing `\|` so it isn't treated as a ket opener. |
| 181 | */ |
| 182 | function fixKetPipes(source: string): string { |
| 183 | let out = ""; |
| 184 | let i = 0; |
| 185 | const len = source.length; |
| 186 | |
| 187 | while (i < len) { |
| 188 | // Match \| (backslash immediately followed by pipe). |
| 189 | if (source[i] === "\\" && source[i + 1] === "|") { |
| 190 | // Scan ahead to find the next \| or \rangle (at brace depth 0). |
| 191 | let j = i + 2; |
| 192 | let depth = 0; |
| 193 | let nextIs = ""; |
| 194 | while (j < len) { |
| 195 | const ch = source[j]; |
| 196 | if (ch === "\\") { |
| 197 | // Check for \| or \rangle or \langle |
| 198 | if (source[j + 1] === "|") { |
| 199 | nextIs = "pipe"; |
| 200 | break; |
| 201 | } |
| 202 | if (source.startsWith("\\rangle", j)) { |
| 203 | nextIs = "rangle"; |
| 204 | break; |
| 205 | } |
| 206 | if (source.startsWith("\\langle", j)) { |
| 207 | nextIs = "langle"; |
| 208 | break; |
| 209 | } |
| 210 | // Skip the escaped command so braces inside it aren't counted. |
| 211 | const cmd = readCommand(source, j); |
| 212 | j = cmd ? cmd.end : j + 2; |
| 213 | continue; |
| 214 | } |
| 215 | if (ch === "{") depth += 1; |
| 216 | else if (ch === "}") depth -= 1; |
| 217 | j += 1; |
| 218 | } |
| 219 | |
| 220 | if (nextIs === "rangle") { |
| 221 | // Ket opener: \|...\rangle → \vert ...\rangle |
| 222 | out += "\\vert "; |
| 223 | i += 2; |
| 224 | continue; |
| 225 | } |
| 226 | if (nextIs === "pipe") { |
| 227 | // Norm pair: \|...\| — keep the opening \| as a double bar and |
| 228 | // let the content + closing \| process through the main loop |
| 229 | // normally. We only emit the opening \| here; the closing \| will |
| 230 | // be handled when we reach it (it will find no \rangle ahead and |
| 231 | // no unmatched \langle, so it stays \|). |
| 232 | out += "\\|"; |
| 233 | i += 2; |
| 234 | continue; |
| 235 | } |
| 236 | // No \| or \rangle ahead. This could be a bra closer: |
| 237 | // \langle\psi\| — the \| is at the end, preceded by \langle{...}. |
| 238 | // Scan backward through `out` for an unmatched \langle. |
| 239 | if (hasUnmatchedAngleOpen(out)) { |
| 240 | out += "\\vert"; |
| 241 | i += 2; |
| 242 | continue; |
| 243 | } |
| 244 | // Truly unpaired \| with no context: conservative — leave as-is. |
| 245 | out += "\\|"; |
| 246 | i += 2; |
| 247 | continue; |
| 248 | } |
| 249 | |
| 250 | out += source[i]; |
| 251 | i += 1; |
| 252 | } |
| 253 | |
| 254 | return out; |
| 255 | } |
| 256 | |
| 257 | /** |
| 258 | * Check whether `out` (the already-emitted prefix of the output) contains an |
| 259 | * unmatched `\langle` — i.e. a `\langle` not yet closed by a `\rangle`. |
| 260 | * Used to detect bra closers: in `\langle\psi\|`, by the time we reach the |
| 261 | * trailing `\|`, `out` contains `\langle\psi` with no matching `\rangle`, |
| 262 | * so the `\|` is a bra closer (single bar) not a norm (double bar). |
| 263 | */ |
| 264 | function hasUnmatchedAngleOpen(out: string): boolean { |
| 265 | // Count \langle vs \rangle in the emitted output. |
| 266 | let opens = 0; |
| 267 | let k = 0; |
| 268 | while (k < out.length) { |
| 269 | if (out.startsWith("\\langle", k)) { |
| 270 | opens += 1; |
| 271 | k += 7; |
| 272 | continue; |
| 273 | } |
| 274 | if (out.startsWith("\\rangle", k)) { |
| 275 | opens -= 1; |
| 276 | k += 8; |
| 277 | continue; |
| 278 | } |
| 279 | k += 1; |
| 280 | } |
| 281 | return opens > 0; |
| 282 | } |
| 283 | |
| 284 | function rewriteTextArg(s: string, openBrace: number): { content: string; end: number } | null { |
| 285 | let out = ""; |
| 286 | let depth = 1; |
| 287 | for (let i = openBrace + 1; i < s.length; ) { |
| 288 | const ch = s[i]; |
| 289 | if (ch === "\\") { |
| 290 | const cmd = readCommand(s, i); |
| 291 | const end = cmd?.end ?? i + 1; |
| 292 | out += s.slice(i, end); |
| 293 | i = end; |
| 294 | continue; |
| 295 | } |
| 296 | if (ch === "{") { |
| 297 | depth += 1; |
| 298 | out += ch; |
| 299 | i += 1; |
| 300 | continue; |
| 301 | } |
| 302 | if (ch === "}") { |
| 303 | depth -= 1; |
| 304 | if (depth === 0) return { content: out, end: i }; |
| 305 | out += ch; |
| 306 | i += 1; |
| 307 | continue; |
| 308 | } |
| 309 | out += escapeTextChar(ch); |
| 310 | i += 1; |
| 311 | } |
| 312 | return null; |
| 313 | } |
| 314 | |
| 315 | function escapeTextChar(ch: string): string { |
| 316 | if (ch === "$") return "\\textdollar{}"; |
| 317 | if (ch === "#" || ch === "%" || ch === "&" || ch === "_") return `\\${ch}`; |
| 318 | if (ch === "^") return "\\textasciicircum{}"; |
| 319 | if (ch === "~") return "\\textasciitilde{}"; |
| 320 | return ch; |
| 321 | } |
| 322 | |
| 323 | function readCommand(s: string, slash: number): { name: string; end: number } | null { |
| 324 | if (s[slash] !== "\\" || slash + 1 >= s.length) return null; |
| 325 | let end = slash + 1; |
| 326 | while (end < s.length && /[A-Za-z]/.test(s[end])) end += 1; |
| 327 | if (end > slash + 1) return { name: s.slice(slash + 1, end), end }; |
| 328 | return { name: s[slash + 1], end: slash + 2 }; |
| 329 | } |
| 330 | |
| 331 | /** |
| 332 | * After `\begin`, read the environment name in the immediately-following |
| 333 | * `{...}`. Returns the name (e.g. "array") or null if the structure doesn't |
| 334 | * match. `cmdEnd` is the index just past the `n` of `\begin`. |
| 335 | */ |
| 336 | function readBeginEnvName(s: string, cmdEnd: number): string | null { |
| 337 | // Skip whitespace between \begin and { |
| 338 | let j = cmdEnd; |
| 339 | while (j < s.length && (s[j] === " " || s[j] === "\t")) j += 1; |
| 340 | if (s[j] !== "{") return null; |
| 341 | const close = s.indexOf("}", j + 1); |
| 342 | if (close < 0) return null; |
| 343 | const name = s.slice(j + 1, close).trim(); |
| 344 | return name || null; |
| 345 | } |
| 346 | |
| 347 | /** |
| 348 | * Find the matching `}` for the column-spec `{...}` that follows an |
| 349 | * environment name. `cmdEnd` is the index just past `\begin`. We skip |
| 350 | * whitespace, consume the env-name `{...}`, then return the index of the |
| 351 | * closing `}` of the column spec itself. Returns -1 if not found. |
| 352 | */ |
| 353 | function findMatchingBrace(s: string, cmdEnd: number, _envName: string): number { |
| 354 | let j = cmdEnd; |
| 355 | // Skip whitespace before the env-name brace. |
| 356 | while (j < s.length && (s[j] === " " || s[j] === "\t")) j += 1; |
| 357 | if (s[j] !== "{") return -1; |
| 358 | // Skip past the env-name {...} group. |
| 359 | const nameClose = s.indexOf("}", j + 1); |
| 360 | if (nameClose < 0) return -1; |
| 361 | // Skip whitespace before the column-spec brace. |
| 362 | let k = nameClose + 1; |
| 363 | while (k < s.length && (s[k] === " " || s[k] === "\t")) k += 1; |
| 364 | if (s[k] !== "{") return -1; |
| 365 | // Find the matching close brace at depth 0 (column specs like |
| 366 | // {c|c@{\;}c} contain nested braces, so we track depth). |
| 367 | let depth = 1; |
| 368 | let p = k + 1; |
| 369 | while (p < s.length && depth > 0) { |
| 370 | const ch = s[p]; |
| 371 | if (ch === "\\") { |
| 372 | p += 2; // skip escaped char (\{, \}, etc.) |
| 373 | continue; |
| 374 | } |
| 375 | if (ch === "{") depth += 1; |
| 376 | else if (ch === "}") depth -= 1; |
| 377 | if (depth === 0) return p; |
| 378 | p += 1; |
| 379 | } |
| 380 | return -1; |
| 381 | } |
| 382 | |
| 383 | /** Strip outer LaTeX math delimiters from already-identified math content. */ |
| 384 | export function stripMathDelimiters(source: string): string { |
| 385 | const trimmed = source.trim(); |
| 386 | if (trimmed.startsWith("\\[") && trimmed.endsWith("\\]")) { |
| 387 | return trimmed.slice(2, -2).trim(); |
| 388 | } |
| 389 | if (trimmed.startsWith("\\(") && trimmed.endsWith("\\)")) { |
| 390 | return trimmed.slice(2, -2).trim(); |
| 391 | } |
| 392 | if (trimmed.startsWith("$$") && trimmed.endsWith("$$")) { |
| 393 | return trimmed.slice(2, -2).trim(); |
| 394 | } |
| 395 | if (trimmed.startsWith("$") && trimmed.endsWith("$")) { |
| 396 | return trimmed.slice(1, -1).trim(); |
| 397 | } |
| 398 | return trimmed; |
| 399 | } |
| 400 |