File size: 4,867 Bytes
205b3fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bdb685b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205b3fc
 
 
 
 
 
 
 
 
 
bdb685b
205b3fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
/**
 * Normalise LaTeX delimiters so `remark-math` can see them.
 *
 * `remark-math` only understands `$…$` and `$$…$$`, but LLMs routinely emit the
 * other standard LaTeX forms — `\(…\)` for inline and `\[…\]` for display.
 * Without this pass those answers render as literal backslash noise.
 */

/**
 * Code that must never be rewritten: a closed fenced block, an *unclosed*
 * fenced block (mid-stream, the closing fence has not arrived yet), or an
 * inline code span. Split with a capturing group so the segments come back
 * interleaved with the prose.
 *
 * Both fence alternatives are anchored to the start of a line (`m` flag, up to
 * the three leading spaces CommonMark allows). Without that anchor a stray
 * ``` written mid-sentence — an answer explaining markdown, for instance —
 * looks like an unterminated block, and every formula after it silently stops
 * rendering for the rest of the message.
 */
const CODE_SEGMENT = /(^ {0,3}```[\s\S]*?^ {0,3}```[^\n]*|^ {0,3}```[\s\S]*|`[^`\n]*`)/gm;

/**
 * Both patterns are non-greedy and require a closing delimiter, which is what
 * makes them safe during streaming: a half-arrived `\(E = mc` simply does not
 * match and is left alone until its `\)` shows up.
 *
 * No lookbehind is used to exclude an escaped `\\(`. Lookbehind is unsupported
 * on Safari < 16.4, and the tradeoff is not worth it: a `\(…\)` pair in prose
 * is math essentially every time.
 */
const DISPLAY_MATH = /\\\[([\s\S]+?)\\\]/g;
const INLINE_MATH = /\\\(([\s\S]+?)\\\)/g;

/**
 * Characters that only appear in a formula, never in a price. Their presence
 * between two dollars is what distinguishes `$5^2 = 25$` from `$5 and $`.
 */
const LATEX_HINT = /[\\^_{}]/;

/**
 * Escape dollar signs that introduce a monetary amount rather than a formula.
 *
 * `remark-math` treats `$` as a math delimiter, so "costs $5 and $10 total"
 * silently renders "5 and " as a formula. The rule applied here: a `$`
 * followed by a digit is currency *unless* the text up to the next `$` on the
 * same line contains a LaTeX hint — which keeps genuinely digit-initial math
 * such as `$2\pi r$` or `$5^2$` working.
 *
 * Must run before the bracket-delimiter rewrite below, so the dollars this
 * module generates itself are never mistaken for currency.
 */
function escapeCurrency(prose: string): string {
  let out = "";
  let i = 0;

  while (i < prose.length) {
    const ch = prose[i];

    // Copy any escape pair verbatim — notably an already-escaped `\$`, and the
    // `\(` / `\[` openers that the next pass depends on.
    if (ch === "\\") {
      out += prose.slice(i, i + 2);
      i += 2;
      continue;
    }

    // `$$` is a display delimiter, never currency.
    if (ch === "$" && prose[i + 1] === "$") {
      out += "$$";
      i += 2;
      continue;
    }

    if (ch === "$" && /\d/.test(prose[i + 1] ?? "")) {
      const rest = prose.slice(i + 1);
      const newline = rest.indexOf("\n");
      const line = newline === -1 ? rest : rest.slice(0, newline);
      const close = line.indexOf("$");
      const inner = close === -1 ? null : line.slice(0, close);

      // No closing dollar on this line, or nothing formula-like between them.
      if (inner === null || !LATEX_HINT.test(inner)) {
        out += "\\$";
        i += 1;
        continue;
      }
    }

    out += ch;
    i += 1;
  }

  return out;
}

/**
 * Rewrite the LaTeX delimiters in one run of non-code markdown.
 *
 * Display math must end up with its `$$` fences alone on their own lines.
 * `remark-math` only produces a block formula when `$$` opens a line — written
 * inline as `$$ x $$` it degrades to *inline* math, silently losing the
 * centering and the larger operators. A single newline is enough to interrupt
 * the surrounding paragraph, so no blank line is inserted.
 */
function normalizeProse(prose: string): string {
  return escapeCurrency(prose)
    .replace(DISPLAY_MATH, (_match, formula: string) => `\n$$\n${formula.trim()}\n$$\n`)
    .replace(INLINE_MATH, (_match, formula: string) => `$${formula}$`);
}

/**
 * Convert `\(…\)` to `$…$` and `\[…\]` to `$$…$$`, leaving code untouched.
 *
 * Text already using `$` delimiters passes through unchanged, so this is safe
 * to run over every message regardless of which form the model chose.
 *
 * @param markdown Raw markdown, possibly a partial chunk from a stream.
 * @returns The same markdown with math delimiters normalised to dollars.
 */
export function normalizeMath(markdown: string): string {
  if (!markdown) return markdown;

  // split() with a capturing group yields [prose, code, prose, code, …], so
  // the odd indices are the code segments that must be passed through as-is.
  return markdown
    .split(CODE_SEGMENT)
    .map((segment, index) => (index % 2 === 1 ? segment : normalizeProse(segment)))
    .join("");
}