1
1
import type MarkdownIt from 'markdown-it'
2
- // Exported helper for direct testing and reuse
3
2
import type { MathOptions } from '../config'
4
3
5
4
import findMatchingClose from '../findMatchingClose'
@@ -37,6 +36,9 @@ export const ESCAPED_TEX_BRACE_COMMANDS = TEX_BRACE_COMMANDS.map(c => c.replace(
37
36
// Common KaTeX/TeX command names that might lose their leading backslash.
38
37
// Keep this list conservative to avoid false-positives in normal text.
39
38
export const KATEX_COMMANDS = [
39
+ 'ldots' ,
40
+ 'cdots' ,
41
+ 'quad' ,
40
42
'in' ,
41
43
'infty' ,
42
44
'perp' ,
@@ -76,6 +78,10 @@ export const KATEX_COMMANDS = [
76
78
'exp' ,
77
79
'lim' ,
78
80
'frac' ,
81
+ 'text' ,
82
+ 'left' ,
83
+ 'right' ,
84
+ 'times' ,
79
85
]
80
86
81
87
// Precompute escaped KATEX commands and default regex used by
@@ -89,10 +95,6 @@ export const ESCAPED_KATEX_COMMANDS = KATEX_COMMANDS
89
95
. map ( c => c . replace ( / [ . * + ? ^ $ { } ( ) | [ \\ ] \\ \] / g, '\\$&' ) )
90
96
. join ( '|' )
91
97
const CONTROL_CHARS_CLASS = '[\t\r\b\f\v]'
92
- // Match when command words appear at start, after whitespace, or after a
93
- // non-word (but not after a backslash). This avoids matching inside words
94
- // like "sin" (we only want to match " in" -> "\\in" when separated).
95
- const DEFAULT_KATEX_RE = new RegExp ( '(^|\\s|[^\\\\\\w])(' + `(?:${ ESCAPED_KATEX_COMMANDS } )\\b|${ CONTROL_CHARS_CLASS } ` + ')' , 'g' )
96
98
97
99
// Precompiled regexes for isMathLike to avoid reconstructing them per-call
98
100
const TEX_CMD_RE = / \\ [ a - z ] + / i
@@ -166,22 +168,29 @@ export function normalizeStandaloneBackslashT(s: string, opts?: MathOptions) {
166
168
const escapeExclamation = opts ?. escapeExclamation ?? true
167
169
168
170
// Choose a prebuilt regex when using default command set for performance,
169
- // otherwise build one from the provided commands.
170
- const re = ( opts ?. commands == null )
171
- ? DEFAULT_KATEX_RE
172
- : new RegExp ( '(^|\\s|[^\\\\\\w])(' + `(?:${ commands . slice ( ) . sort ( ( a , b ) => b . length - a . length ) . map ( c => c . replace ( / [ . * + ? ^ $ { } ( ) | [ \\ ] \\ \] / g, '\\$&' ) ) . join ( '|' ) } )\\b|${ CONTROL_CHARS_CLASS } ` + ')' , 'g' )
173
-
174
- let out = s . replace ( re , ( _m , p1 , p2 ) => {
175
- // If p2 is a control character, map it to its escaped letter (t, r, ...)
176
- if ( controlMap [ p2 ] !== undefined ) {
177
- return `${ p1 } \\${ controlMap [ p2 ] } `
178
- }
179
-
180
- // Otherwise if it's one of the katex command words, prefix with backslash
181
- if ( commands . includes ( p2 ) )
182
- return `${ p1 } \\${ p2 } `
183
-
184
- return _m
171
+ // otherwise build one from the provided commands. Use a negative
172
+ // lookbehind to ensure the matched command isn't already escaped (i.e.
173
+ // not preceded by a backslash) and not part of a larger word. We also
174
+ // match literal control characters (tab, backspace, etc.). This form
175
+ // avoids capturing the prefix (p1) which previously caused overlapping
176
+ // replacement issues.
177
+ const commandPattern = ( opts ?. commands == null )
178
+ ? `(?:${ ESCAPED_KATEX_COMMANDS } )`
179
+ : `(?:${ commands . slice ( ) . sort ( ( a , b ) => b . length - a . length ) . map ( c => c . replace ( / [ . * + ? ^ $ { } ( ) | [ \\ ] \\ " \] / g, '\\$&' ) ) . join ( '|' ) } )`
180
+
181
+ // Match either a control character or an unescaped command word.
182
+ const re = new RegExp ( `${ CONTROL_CHARS_CLASS } |(?<!\\\\|\\w)(${ commandPattern } )\\b` , 'g' )
183
+
184
+ let out = s . replace ( re , ( m , cmd ) => {
185
+ // If m is a literal control character (e.g. '\t' as actual tab), map it.
186
+ if ( controlMap [ m ] !== undefined )
187
+ return `\\${ controlMap [ m ] } `
188
+
189
+ // Otherwise cmd will be populated with the matched command word.
190
+ if ( cmd && commands . includes ( cmd ) )
191
+ return `\\${ cmd } `
192
+
193
+ return m
185
194
} )
186
195
187
196
// Escape standalone '!' but don't double-escape already escaped ones.
@@ -192,8 +201,12 @@ export function normalizeStandaloneBackslashT(s: string, opts?: MathOptions) {
192
201
// lost their leading backslash, e.g. "operatorname{span}". Ensure we
193
202
// restore a backslash before known brace-taking commands when they are
194
203
// followed by '{' and are not already escaped.
195
- // Use default escaped list when possible.
196
- const braceEscaped = ( opts ?. commands == null ) ? ESCAPED_KATEX_COMMANDS : commands . map ( c => c . replace ( / [ . * + ? ^ $ { } ( ) | [ \\ ] \\ \] / g, '\\$&' ) ) . join ( '|' )
204
+ // Use default escaped list when possible. Include TEX_BRACE_COMMANDS so
205
+ // known brace-taking TeX commands (e.g. `text`, `boldsymbol`) are also
206
+ // restored when their leading backslash was lost.
207
+ const braceEscaped = ( opts ?. commands == null )
208
+ ? [ ESCAPED_TEX_BRACE_COMMANDS , ESCAPED_KATEX_COMMANDS ] . filter ( Boolean ) . join ( '|' )
209
+ : [ commands . map ( c => c . replace ( / [ . * + ? ^ $ { } ( ) | [ \\ ] \\ \] / g, '\\$&' ) ) . join ( '|' ) , ESCAPED_TEX_BRACE_COMMANDS ] . filter ( Boolean ) . join ( '|' )
197
210
if ( braceEscaped ) {
198
211
const braceCmdRe = new RegExp ( `(^|[^\\\\])(${ braceEscaped } )\\s*\\{` , 'g' )
199
212
out = out . replace ( braceCmdRe , ( _m , p1 , p2 ) => `${ p1 } \\${ p2 } {` )
@@ -203,18 +216,20 @@ export function normalizeStandaloneBackslashT(s: string, opts?: MathOptions) {
203
216
export function applyMath ( md : MarkdownIt , mathOpts ?: MathOptions ) {
204
217
// Inline rule for \(...\) and $$...$$ and $...$
205
218
const mathInline = ( state : any , silent : boolean ) => {
219
+ if ( state . src . includes ( '\n' ) ) {
220
+ return false
221
+ }
206
222
const delimiters : [ string , string ] [ ] = [
207
223
[ '$$' , '$$' ] ,
224
+ [ '\(' , '\)' ] ,
208
225
[ '\\(' , '\\)' ] ,
209
226
]
210
227
let searchPos = 0
211
228
// use findMatchingClose from util
212
-
213
229
for ( const [ open , close ] of delimiters ) {
214
230
// We'll scan the entire inline source and tokenize all occurrences
215
231
const src = state . src
216
232
let foundAny = false
217
-
218
233
const pushText = ( text : string ) => {
219
234
if ( ! text )
220
235
return
@@ -243,7 +258,18 @@ export function applyMath(md: MarkdownIt, mathOpts?: MathOptions) {
243
258
const index = src . indexOf ( open , searchPos )
244
259
if ( index === - 1 )
245
260
break
246
-
261
+ // If the delimiter is immediately preceded by a ']' (possibly with
262
+ // intervening spaces), it's likely part of a markdown link like
263
+ // `[text](...)`, so we should not treat this '(' as the start of
264
+ // an inline math span. Also guard the index to avoid OOB access.
265
+ if ( index > 0 ) {
266
+ let i = index - 1
267
+ // skip spaces between ']' and the delimiter
268
+ while ( i >= 0 && src [ i ] === ' ' )
269
+ i --
270
+ if ( i >= 0 && src [ i ] === ']' )
271
+ return false
272
+ }
247
273
// 有可能遇到 \((\operatorname{span}\\{\boldsymbol{\alpha}\\})^\perp\)
248
274
// 这种情况,前面的 \( 是数学公式的开始,后面的 ( 是普通括号
249
275
// endIndex 需要找到与 open 对应的 close
@@ -281,10 +307,10 @@ export function applyMath(md: MarkdownIt, mathOpts?: MathOptions) {
281
307
return c
282
308
}
283
309
284
- let isStrongPrefix = false
285
- const toPushBefore = prevConsumed ? src . slice ( searchPos , index ) : before
286
- if ( countUnescapedStrong ( toPushBefore ) % 2 === 1 ) {
287
- isStrongPrefix = true
310
+ let toPushBefore = prevConsumed ? src . slice ( searchPos , index ) : before
311
+ const isStrongPrefix = countUnescapedStrong ( toPushBefore ) % 2 === 1
312
+ if ( index !== state . pos && isStrongPrefix ) {
313
+ toPushBefore = src . slice ( state . pos , index )
288
314
}
289
315
290
316
// strong prefix handling (preserve previous behavior)
@@ -342,8 +368,6 @@ export function applyMath(md: MarkdownIt, mathOpts?: MathOptions) {
342
368
endLine : number ,
343
369
silent : boolean ,
344
370
) => {
345
- if ( silent )
346
- return true
347
371
const delimiters : [ string , string ] [ ] = [
348
372
[ '\\[' , '\\]' ] ,
349
373
[ '$$' , '$$' ] ,
@@ -367,8 +391,7 @@ export function applyMath(md: MarkdownIt, mathOpts?: MathOptions) {
367
391
nextLineStart ,
368
392
state . eMarks [ startLine + 1 ] ,
369
393
)
370
- const hasMathContent = isMathLike ( nextLineText )
371
- if ( hasMathContent ) {
394
+ if ( isMathLike ( nextLineText . trim ( ) ) ) {
372
395
matched = true
373
396
openDelim = open
374
397
closeDelim = close
@@ -389,6 +412,9 @@ export function applyMath(md: MarkdownIt, mathOpts?: MathOptions) {
389
412
390
413
if ( ! matched )
391
414
return false
415
+ if ( silent )
416
+ return true
417
+
392
418
if (
393
419
lineText . includes ( closeDelim )
394
420
&& lineText . indexOf ( closeDelim ) > openDelim . length
@@ -403,13 +429,8 @@ export function applyMath(md: MarkdownIt, mathOpts?: MathOptions) {
403
429
endDelimIndex ,
404
430
)
405
431
406
- // For the heuristic-only bracket delimiter '[', check content is math-like
407
- if ( openDelim === '[' && ! isMathLike ( content ) )
408
- return false
409
-
410
432
const token : any = state . push ( 'math_block' , 'math' , 0 )
411
-
412
- token . content = normalizeStandaloneBackslashT ( content , mathOpts ) // 规范化 \t -> \\\t
433
+ token . content = normalizeStandaloneBackslashT ( content )
413
434
token . markup
414
435
= openDelim === '$$' ? '$$' : openDelim === '[' ? '[]' : '\\[\\]'
415
436
token . map = [ startLine , startLine + 1 ]
@@ -437,9 +458,9 @@ export function applyMath(md: MarkdownIt, mathOpts?: MathOptions) {
437
458
content = firstLineContent
438
459
439
460
for ( nextLine = startLine + 1 ; nextLine < endLine ; nextLine ++ ) {
440
- const lineStart = state . bMarks [ nextLine ] + state . tShift [ nextLine ] - 1
461
+ const lineStart = state . bMarks [ nextLine ] + state . tShift [ nextLine ]
441
462
const lineEnd = state . eMarks [ nextLine ]
442
- const currentLine = state . src . slice ( lineStart , lineEnd )
463
+ const currentLine = state . src . slice ( lineStart - 1 , lineEnd )
443
464
if ( currentLine . trim ( ) === closeDelim ) {
444
465
found = true
445
466
break
@@ -454,18 +475,13 @@ export function applyMath(md: MarkdownIt, mathOpts?: MathOptions) {
454
475
}
455
476
}
456
477
457
- // For bracket-delimited math, ensure it's math-like before accepting
458
- if ( openDelim === '[' && ! isMathLike ( content ) )
459
- return false
460
-
461
478
const token : any = state . push ( 'math_block' , 'math' , 0 )
462
- token . content = normalizeStandaloneBackslashT ( content , mathOpts ) // 规范化 \t -> \\\t
479
+ token . content = normalizeStandaloneBackslashT ( content )
463
480
token . markup
464
481
= openDelim === '$$' ? '$$' : openDelim === '[' ? '[]' : '\\[\\]'
465
482
token . map = [ startLine , nextLine + 1 ]
466
483
token . block = true
467
484
token . loading = ! found
468
-
469
485
state . line = nextLine + 1
470
486
return true
471
487
}
0 commit comments