返回 DeepSeek-Reasonix
latexNormalize.ts
根目录 / desktop / frontend / src / components / latexNormalize.ts
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
400 lines TYPESCRIPT