From 46ae60f8455dca6c3a55e7e2d2669aae6ecb055a Mon Sep 17 00:00:00 2001 From: Div627 Date: Wed, 29 Jul 2026 16:20:14 +0800 Subject: [PATCH] fix(markdown): avoid parsing currency as LaTeX --- .../plugins/Latex/__tests__/index.test.tsx | 37 +++++++++++++++++++ .../x-markdown/src/plugins/Latex/index.ts | 22 ++++++++--- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/x-markdown/src/plugins/Latex/__tests__/index.test.tsx b/packages/x-markdown/src/plugins/Latex/__tests__/index.test.tsx index f2d686429b..ef39b0f030 100644 --- a/packages/x-markdown/src/plugins/Latex/__tests__/index.test.tsx +++ b/packages/x-markdown/src/plugins/Latex/__tests__/index.test.tsx @@ -17,6 +17,43 @@ describe('LaTeX Plugin', () => { expect(container).toMatchSnapshot(); }); + it.each([ + 'Downgrade Max ($100) to Pro ($20)', + '把 Max 5x($100)降级到 Pro($20)', + 'The items cost $10, $20, and $30, respectively.', + ])('should not interpret currency amounts as LaTeX: %s', (content) => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { container } = render( + {`**${content}**`}, + ); + + expect(container.querySelector('.katex')).not.toBeInTheDocument(); + expect(container.querySelector('strong')).toHaveTextContent(content); + expect(warnSpy.mock.calls.flat().join(' ')).not.toContain('unicodeTextInMathMode'); + warnSpy.mockRestore(); + }); + + it('should still render formulas that start with a number', () => { + const { container } = render( + {'$2x + 1$'}, + ); + + expect(container.querySelector('.katex')).toBeInTheDocument(); + }); + + it.each([ + '$ x$', + '$x $', + '$x$2', + ])('should follow single-dollar delimiter rules: %s', (content) => { + const { container } = render( + {content}, + ); + + expect(container.querySelector('.katex')).not.toBeInTheDocument(); + expect(container).toHaveTextContent(content); + }); + it('should render inline LaTeX with $$\n..\n$$ syntax', () => { const { container } = render( diff --git a/packages/x-markdown/src/plugins/Latex/index.ts b/packages/x-markdown/src/plugins/Latex/index.ts index 33cd2fe9f6..d978a73dbb 100644 --- a/packages/x-markdown/src/plugins/Latex/index.ts +++ b/packages/x-markdown/src/plugins/Latex/index.ts @@ -3,8 +3,8 @@ import type { TokenizerAndRendererExtension } from 'marked'; import 'katex/dist/katex.min.css'; -const inlineRuleNonStandard = - /^(?:\${1,2}([^$]{1,10000}?)\${1,2}|\\\(([\s\S]{1,10000}?)\\\)|\\\[((?:\\.|[^\\]){1,10000}?)\\\])/; +const inlineDollarRule = /^(\${1,2})([^$]{1,10000}?)\1/; +const inlineRuleNonStandard = /^(?:\\\(([\s\S]{1,10000}?)\\\)|\\\[((?:\\.|[^\\]){1,10000}?)\\\])/; const blockRule = /^(\${1,2})\n([\s\S]{1,10000}?)\n\1(?:\s*(?:\n|$))|^\\\[((?:\\.|[^\\]){1,10000}?)\\\]/; @@ -28,6 +28,14 @@ function replaceAlign(text: string) { return text ? text.replace(/\{align\*\}/g, '{aligned}') : text; } +// Follow Pandoc's single-dollar delimiter rules to avoid parsing currency as math. +function isValidInlineDollarMatch(match: RegExpMatchArray, src: string) { + const [, delimiter, text] = match; + if (delimiter === '$$') return true; + + return !/^\s|\s$/.test(text) && !/^\d/.test(src.slice(match[0].length)); +} + function createRenderer(options: KatexOptions, newlineAfter: boolean) { return (token: Token) => katex.renderToString(token.text, { @@ -49,15 +57,19 @@ function inlineKatex(renderer: Render, replaceAlignStart: boolean) { return indices.length > 0 ? Math.min(...indices) : undefined; }, tokenizer(src: string) { - const match = src.match(inlineRuleNonStandard); + const dollarMatch = src.match(inlineDollarRule); + if (dollarMatch && !isValidInlineDollarMatch(dollarMatch, src)) return; + + const nonStandardMatch = src.match(inlineRuleNonStandard); + const match = dollarMatch || nonStandardMatch; if (!match) return; - const rawText = match[1] || match[2] || match[3] || ''; + const rawText = dollarMatch?.[2] || nonStandardMatch?.[1] || nonStandardMatch?.[2] || ''; const text = replaceAlignStart ? replaceAlign(rawText.trim()) : rawText.trim(); // 对于 \[...\] 语法,如果内容包含换行,标记为块级公式 // 注意:换行检测必须在 trim 之前进行 - const isBracketSyntax = match[3] !== undefined; + const isBracketSyntax = nonStandardMatch?.[2] !== undefined; const hasNewline = rawText.includes('\n'); return {