From cf8bfd5768252f4e0bbf3bfaf979726040f3eb66 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:11:11 -0700 Subject: [PATCH] fix(editor): keep currency dollars out of rich-editor math spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TipTap InlineMath markdown tokenizer treated every `$…$` pair as math, so USD prose like `$148+ → $19` and CJK clauses between two currency amounts were swallowed. Guard the tokenizer with Pandoc delimiters (and reject pure numeric `$100$`) so `$$…$$` and `$x$` still render while currency stays literal. Fixes #15100 --- .../editor/rich-markdown-extensions.ts | 5 +- .../editor/rich-markdown-inline-math.test.ts | 94 +++++++++++++++++++ .../editor/rich-markdown-inline-math.ts | 22 +++++ 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-inline-math.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-inline-math.ts diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index abe3e0a1134..40dd643289f 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -10,7 +10,8 @@ import { Table } from '@tiptap/extension-table' import { TableCell } from '@tiptap/extension-table-cell' import { TableHeader } from '@tiptap/extension-table-header' import { TableRow } from '@tiptap/extension-table-row' -import { BlockMath, InlineMath } from '@tiptap/extension-mathematics' +import { BlockMath } from '@tiptap/extension-mathematics' +import { RichMarkdownInlineMath } from './rich-markdown-inline-math' import { Markdown } from '@tiptap/markdown' import { createLowlight, common } from 'lowlight' import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc' @@ -201,7 +202,7 @@ export function createRichMarkdownExtensions({ TableRow, TableHeader, TableCell, - InlineMath.configure({ + RichMarkdownInlineMath.configure({ katexOptions: { throwOnError: false } diff --git a/src/renderer/src/components/editor/rich-markdown-inline-math.test.ts b/src/renderer/src/components/editor/rich-markdown-inline-math.test.ts new file mode 100644 index 00000000000..726bba8ef69 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-inline-math.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { Editor } from '@tiptap/core' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' + +// STA-4635 / #15100 reporter string — two unguarded `$…$` spans swallow currency and CJK prose. +const ISSUE_CURRENCY_PROSE = + '月成本 **$148+ → $19**(省 **87%**,年省 ~$1,550);后续释放老 EIP 可再降到 $15.4/月' + +function createEditor(content: string): Editor { + const codec = createRichMarkdownEditorCodec() + return new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ codec }), + content: encodeRawMarkdownHtmlForRichEditor(content, codec), + contentType: 'markdown' + }) +} + +function parseMarkdown(content: string): { + markdown: string + text: string + inlineMath: string[] + blockMath: string[] +} { + const editor = createEditor(content) + try { + const inlineMath: string[] = [] + const blockMath: string[] = [] + editor.state.doc.descendants((node) => { + if (node.type.name === 'inlineMath') { + inlineMath.push(String(node.attrs.latex ?? '')) + } + if (node.type.name === 'blockMath') { + blockMath.push(String(node.attrs.latex ?? '')) + } + }) + return { + markdown: editor.getMarkdown().trimEnd(), + text: editor.state.doc.textContent, + inlineMath, + blockMath + } + } finally { + editor.destroy() + } +} + +describe('rich markdown inline math currency guard', () => { + it('round-trips the reporter currency string with prose and every amount intact', () => { + const { markdown, inlineMath } = parseMarkdown(ISSUE_CURRENCY_PROSE) + + expect(inlineMath).toEqual([]) + expect(markdown).toContain('$148') + expect(markdown).toContain('$19') + expect(markdown).toContain('$1,550') + expect(markdown).toContain('$15.4') + expect(markdown).toContain('月成本') + expect(markdown).toContain('后续释放老 EIP') + expect((markdown.match(/\$/g) ?? []).length).toBe(4) + }) + + it('still parses $$…$$ as display math', () => { + const { inlineMath, blockMath, markdown } = parseMarkdown('Before\n\n$$\nx + y\n$$\n\nAfter') + + expect(inlineMath).toEqual([]) + expect(blockMath).toEqual(['x + y']) + expect(markdown).toBe('Before\n\n$$\nx + y\n$$\n\nAfter') + }) + + it('never treats a single $ adjacent to digits as math', () => { + expect(parseMarkdown('The price is $19.').inlineMath).toEqual([]) + expect(parseMarkdown('$148 is the monthly cost').inlineMath).toEqual([]) + expect(parseMarkdown('$148+ → $19').inlineMath).toEqual([]) + expect(parseMarkdown('costs $100$ exactly').inlineMath).toEqual([]) + }) + + it('does not consume CJK prose between two currency $ signs', () => { + const source = '~$1,550);后续释放老 EIP 可再降到 $15.4/月' + const { markdown, inlineMath, text } = parseMarkdown(source) + + expect(inlineMath).toEqual([]) + expect(markdown).toContain('$1,550') + expect(markdown).toContain('$15.4') + expect(text).toContain('后续释放老 EIP') + }) + + it('still parses genuine Pandoc inline math', () => { + expect(parseMarkdown('Einstein wrote $E=mc^2$ in 1905.').inlineMath).toEqual(['E=mc^2']) + expect(parseMarkdown('The variable is $x$.').inlineMath).toEqual(['x']) + expect(parseMarkdown('Arithmetic $2+2$ is math.').inlineMath).toEqual(['2+2']) + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-inline-math.ts b/src/renderer/src/components/editor/rich-markdown-inline-math.ts new file mode 100644 index 00000000000..a27dff809d2 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-inline-math.ts @@ -0,0 +1,22 @@ +import type { MarkdownTokenizer } from '@tiptap/core' +import { InlineMath } from '@tiptap/extension-mathematics' + +const baseTokenizer = InlineMath.config.markdownTokenizer as MarkdownTokenizer + +// Pandoc: no space after the opening `$`, none before the closer, no digit after it. +const PANDOC_INLINE_MATH = /^\$(?!\s)([^$]*[^\s$])\$(?!\$)(?!\d)/ +// Why: `$100$` satisfies Pandoc delimiters but is still a USD amount, not a formula. +const PURE_CURRENCY_AMOUNT = /^(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?$/ + +export const RichMarkdownInlineMath = InlineMath.extend({ + markdownTokenizer: { + ...baseTokenizer, + tokenize(src, tokens, lexer) { + const match = PANDOC_INLINE_MATH.exec(src) + if (!match || PURE_CURRENCY_AMOUNT.test(match[1].trim())) { + return undefined + } + return baseTokenizer.tokenize(src, tokens, lexer) + } + } +})