11import type MarkdownIt from 'markdown-it'
2- // Exported helper for direct testing and reuse
32import type { MathOptions } from '../config'
43
54import findMatchingClose from '../findMatchingClose'
@@ -37,6 +36,9 @@ export const ESCAPED_TEX_BRACE_COMMANDS = TEX_BRACE_COMMANDS.map(c => c.replace(
3736// Common KaTeX/TeX command names that might lose their leading backslash.
3837// Keep this list conservative to avoid false-positives in normal text.
3938export const KATEX_COMMANDS = [
39+ 'ldots' ,
40+ 'cdots' ,
41+ 'quad' ,
4042 'in' ,
4143 'infty' ,
4244 'perp' ,
@@ -76,6 +78,10 @@ export const KATEX_COMMANDS = [
7678 'exp' ,
7779 'lim' ,
7880 'frac' ,
81+ 'text' ,
82+ 'left' ,
83+ 'right' ,
84+ 'times' ,
7985]
8086
8187// Precompute escaped KATEX commands and default regex used by
@@ -89,10 +95,6 @@ export const ESCAPED_KATEX_COMMANDS = KATEX_COMMANDS
8995 . map ( c => c . replace ( / [ . * + ? ^ $ { } ( ) | [ \\ ] \\ \] / g, '\\$&' ) )
9096 . join ( '|' )
9197const 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' )
9698
9799// Precompiled regexes for isMathLike to avoid reconstructing them per-call
98100const TEX_CMD_RE = / \\ [ a - z ] + / i
@@ -166,22 +168,29 @@ export function normalizeStandaloneBackslashT(s: string, opts?: MathOptions) {
166168 const escapeExclamation = opts ?. escapeExclamation ?? true
167169
168170 // 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
185194 } )
186195
187196 // Escape standalone '!' but don't double-escape already escaped ones.
@@ -192,8 +201,12 @@ export function normalizeStandaloneBackslashT(s: string, opts?: MathOptions) {
192201 // lost their leading backslash, e.g. "operatorname{span}". Ensure we
193202 // restore a backslash before known brace-taking commands when they are
194203 // 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 ( '|' )
197210 if ( braceEscaped ) {
198211 const braceCmdRe = new RegExp ( `(^|[^\\\\])(${ braceEscaped } )\\s*\\{` , 'g' )
199212 out = out . replace ( braceCmdRe , ( _m , p1 , p2 ) => `${ p1 } \\${ p2 } {` )
@@ -203,18 +216,20 @@ export function normalizeStandaloneBackslashT(s: string, opts?: MathOptions) {
203216export function applyMath ( md : MarkdownIt , mathOpts ?: MathOptions ) {
204217 // Inline rule for \(...\) and $$...$$ and $...$
205218 const mathInline = ( state : any , silent : boolean ) => {
219+ if ( state . src . includes ( '\n' ) ) {
220+ return false
221+ }
206222 const delimiters : [ string , string ] [ ] = [
207223 [ '$$' , '$$' ] ,
224+ [ '\(' , '\)' ] ,
208225 [ '\\(' , '\\)' ] ,
209226 ]
210227 let searchPos = 0
211228 // use findMatchingClose from util
212-
213229 for ( const [ open , close ] of delimiters ) {
214230 // We'll scan the entire inline source and tokenize all occurrences
215231 const src = state . src
216232 let foundAny = false
217-
218233 const pushText = ( text : string ) => {
219234 if ( ! text )
220235 return
@@ -243,7 +258,18 @@ export function applyMath(md: MarkdownIt, mathOpts?: MathOptions) {
243258 const index = src . indexOf ( open , searchPos )
244259 if ( index === - 1 )
245260 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+ }
247273 // 有可能遇到 \((\operatorname{span}\\{\boldsymbol{\alpha}\\})^\perp\)
248274 // 这种情况,前面的 \( 是数学公式的开始,后面的 ( 是普通括号
249275 // endIndex 需要找到与 open 对应的 close
@@ -281,10 +307,10 @@ export function applyMath(md: MarkdownIt, mathOpts?: MathOptions) {
281307 return c
282308 }
283309
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 )
288314 }
289315
290316 // strong prefix handling (preserve previous behavior)
@@ -342,8 +368,6 @@ export function applyMath(md: MarkdownIt, mathOpts?: MathOptions) {
342368 endLine : number ,
343369 silent : boolean ,
344370 ) => {
345- if ( silent )
346- return true
347371 const delimiters : [ string , string ] [ ] = [
348372 [ '\\[' , '\\]' ] ,
349373 [ '$$' , '$$' ] ,
@@ -367,8 +391,7 @@ export function applyMath(md: MarkdownIt, mathOpts?: MathOptions) {
367391 nextLineStart ,
368392 state . eMarks [ startLine + 1 ] ,
369393 )
370- const hasMathContent = isMathLike ( nextLineText )
371- if ( hasMathContent ) {
394+ if ( isMathLike ( nextLineText . trim ( ) ) ) {
372395 matched = true
373396 openDelim = open
374397 closeDelim = close
@@ -389,6 +412,9 @@ export function applyMath(md: MarkdownIt, mathOpts?: MathOptions) {
389412
390413 if ( ! matched )
391414 return false
415+ if ( silent )
416+ return true
417+
392418 if (
393419 lineText . includes ( closeDelim )
394420 && lineText . indexOf ( closeDelim ) > openDelim . length
@@ -403,13 +429,8 @@ export function applyMath(md: MarkdownIt, mathOpts?: MathOptions) {
403429 endDelimIndex ,
404430 )
405431
406- // For the heuristic-only bracket delimiter '[', check content is math-like
407- if ( openDelim === '[' && ! isMathLike ( content ) )
408- return false
409-
410432 const token : any = state . push ( 'math_block' , 'math' , 0 )
411-
412- token . content = normalizeStandaloneBackslashT ( content , mathOpts ) // 规范化 \t -> \\\t
433+ token . content = normalizeStandaloneBackslashT ( content )
413434 token . markup
414435 = openDelim === '$$' ? '$$' : openDelim === '[' ? '[]' : '\\[\\]'
415436 token . map = [ startLine , startLine + 1 ]
@@ -437,9 +458,9 @@ export function applyMath(md: MarkdownIt, mathOpts?: MathOptions) {
437458 content = firstLineContent
438459
439460 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 ]
441462 const lineEnd = state . eMarks [ nextLine ]
442- const currentLine = state . src . slice ( lineStart , lineEnd )
463+ const currentLine = state . src . slice ( lineStart - 1 , lineEnd )
443464 if ( currentLine . trim ( ) === closeDelim ) {
444465 found = true
445466 break
@@ -454,18 +475,13 @@ export function applyMath(md: MarkdownIt, mathOpts?: MathOptions) {
454475 }
455476 }
456477
457- // For bracket-delimited math, ensure it's math-like before accepting
458- if ( openDelim === '[' && ! isMathLike ( content ) )
459- return false
460-
461478 const token : any = state . push ( 'math_block' , 'math' , 0 )
462- token . content = normalizeStandaloneBackslashT ( content , mathOpts ) // 规范化 \t -> \\\t
479+ token . content = normalizeStandaloneBackslashT ( content )
463480 token . markup
464481 = openDelim === '$$' ? '$$' : openDelim === '[' ? '[]' : '\\[\\]'
465482 token . map = [ startLine , nextLine + 1 ]
466483 token . block = true
467484 token . loading = ! found
468-
469485 state . line = nextLine + 1
470486 return true
471487 }
0 commit comments