diff --git a/desktop/bun.lock b/desktop/bun.lock index ba1833f1..bc30800c 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -10,6 +10,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "dompurify": "^3.3.3", + "katex": "^0.16.45", "lucide-react": "^0.469.0", "marked": "^15.0.7", "mermaid": "^11.14.0", diff --git a/desktop/package.json b/desktop/package.json index 079a7459..9ce7d338 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -21,6 +21,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "dompurify": "^3.3.3", + "katex": "^0.16.45", "lucide-react": "^0.469.0", "marked": "^15.0.7", "mermaid": "^11.14.0", diff --git a/desktop/src/components/markdown/MarkdownRenderer.test.tsx b/desktop/src/components/markdown/MarkdownRenderer.test.tsx index 832e188a..5daca8a1 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.test.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.test.tsx @@ -18,6 +18,12 @@ vi.mock('../chat/MermaidRenderer', () => ({ import { MarkdownRenderer } from './MarkdownRenderer' +function visibleMathText(container: HTMLElement): string { + const clone = container.cloneNode(true) as HTMLElement + clone.querySelectorAll('annotation').forEach((node) => node.remove()) + return clone.textContent ?? '' +} + describe('MarkdownRenderer', () => { it('applies document prose classes and custom width classes', () => { const { container } = render( @@ -108,6 +114,104 @@ describe('MarkdownRenderer', () => { expect(screen.queryByTestId('mermaid-renderer')).not.toBeInTheDocument() }) + it('renders inline and block LaTeX formulas with KaTeX', () => { + const { container } = render( + , + ) + + expect(container.querySelectorAll('.katex')).toHaveLength(2) + expect(container.querySelector('.md-math-inline')).toBeInTheDocument() + expect(container.querySelector('.md-math-display')).toBeInTheDocument() + expect(container.textContent).not.toContain('$E = mc^2$') + expect(container.textContent).not.toContain('$$') + }) + + it('renders multi-line display LaTeX formulas', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.md-math-display .katex')).toBeInTheDocument() + }) + + it('renders bracket-delimited inline and display formulas', () => { + const { container } = render( + , + ) + + expect(container.querySelectorAll('.katex')).toHaveLength(2) + expect(container.querySelector('.md-math-inline .katex')).toBeInTheDocument() + expect(container.querySelector('.md-math-display .katex-display')).toBeInTheDocument() + expect(container.textContent).not.toContain('\\(A = \\pi r^2\\)') + expect(container.textContent).not.toContain('\\[') + }) + + it('renders complex display formulas without exposing TeX source', () => { + const { container } = render( + 0 \\\\', + '3x + 4y, & x \\le 0', + '\\end{cases}', + '$$', + ].join('\n')} + />, + ) + + expect(container.querySelector('.md-math-display .katex')).toBeInTheDocument() + expect(visibleMathText(container)).not.toContain('\\begin{bmatrix}') + expect(visibleMathText(container)).not.toContain('\\begin{cases}') + }) + + it('keeps math layout protected from markdown forced wrapping rules', () => { + const { container } = render( + , + ) + + const root = container.firstChild as HTMLDivElement + expect(root.className).toContain('[&_.katex]:[white-space:nowrap]') + expect(root.className).toContain('[&_.katex]:[overflow-wrap:normal]') + expect(root.className).toContain('[&_.md-math-display]:justify-center') + expect(container.querySelector('.md-math-display .katex')).toBeInTheDocument() + }) + + it('does not treat escaped dollars or currency text as formulas', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.katex')).not.toBeInTheDocument() + expect(container.textContent).toContain('$5') + expect(container.textContent).toContain('$10') + }) + + it('does not render LaTeX inside inline code or code fences', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.katex')).not.toBeInTheDocument() + expect(screen.getByText('$E = mc^2$')).toBeInTheDocument() + expect(screen.getByTestId('code-viewer')).toHaveTextContent('$$\\int_0^1 x^2 dx$$') + }) + it('wraps markdown tables for horizontal overflow handling', () => { const { container } = render( = 0 && text[i] === '\\'; i--) { + backslashCount += 1 + } + if (backslashCount % 2 === 0) return index + index = text.indexOf(delimiter, index + delimiter.length) + } + return -1 +} + +function consumeMath( + content: string, + mathBlocks: MathBlock[], + start: number, + open: string, + close: string, + displayMode: boolean, +): { replacement: string; end: number } | null { + const contentStart = start + open.length + const end = findUnescapedDelimiter(content, close, contentStart) + if (end === -1) return null + + const tex = content.slice(contentStart, end) + if (!tex.trim()) return null + if (!displayMode && /[\n\r]/.test(tex)) return null + if (open === '$' && (/\s/.test(content[contentStart] ?? '') || /\s/.test(content[end - 1] ?? ''))) { + return null + } + + const id = `math-${mathBlocks.length}` + mathBlocks.push({ id, tex, displayMode }) + const tag = displayMode ? 'div' : 'span' + const spacing = displayMode ? '\n\n' : '' + return { + replacement: `${spacing}<${tag} data-math-id="${id}">${spacing}`, + end: end + close.length, + } +} + +function extractMathFromSegment(segment: string, mathBlocks: MathBlock[]): string { + let output = '' + let index = 0 + + while (index < segment.length) { + if (segment[index] === '`') { + const match = /^`+/.exec(segment.slice(index)) + const ticks = match?.[0] ?? '`' + const end = segment.indexOf(ticks, index + ticks.length) + if (end !== -1) { + output += segment.slice(index, end + ticks.length) + index = end + ticks.length + continue + } + } + + const displayDollar = segment.startsWith('$$', index) + ? consumeMath(segment, mathBlocks, index, '$$', '$$', true) + : null + if (displayDollar) { + output += displayDollar.replacement + index = displayDollar.end + continue + } + + const displayBracket = segment.startsWith('\\[', index) + ? consumeMath(segment, mathBlocks, index, '\\[', '\\]', true) + : null + if (displayBracket) { + output += displayBracket.replacement + index = displayBracket.end + continue + } + + const inlineParen = segment.startsWith('\\(', index) + ? consumeMath(segment, mathBlocks, index, '\\(', '\\)', false) + : null + if (inlineParen) { + output += inlineParen.replacement + index = inlineParen.end + continue + } + + const inlineDollar = segment[index] === '$' && segment[index + 1] !== '$' + ? consumeMath(segment, mathBlocks, index, '$', '$', false) + : null + if (inlineDollar) { + output += inlineDollar.replacement + index = inlineDollar.end + continue + } + + output += segment[index] + index += 1 + } + + return output +} + +function extractMath(content: string): { markdown: string; mathBlocks: MathBlock[] } { + const mathBlocks: MathBlock[] = [] + const lines = content.match(/[^\n]*\n|[^\n]+/g) ?? [''] + let output = '' + let pendingMarkdown = '' + let inFence: string | null = null + + const flushPendingMarkdown = () => { + if (!pendingMarkdown) return + output += extractMathFromSegment(pendingMarkdown, mathBlocks) + pendingMarkdown = '' + } + + for (const line of lines) { + const fenceMatch = CODE_FENCE_START.exec(line) + if (fenceMatch) { + const marker = fenceMatch[1]!.charAt(0) + if (!inFence) { + flushPendingMarkdown() + inFence = marker + } else if (inFence === marker) { + inFence = null + } + output += line + continue + } + + if (inFence) { + output += line + } else { + pendingMarkdown += line + } + } + + flushPendingMarkdown() + + return { markdown: output, mathBlocks } +} + +function renderMath(block: MathBlock): string { + try { + return katex.renderToString(block.tex, { + displayMode: block.displayMode, + throwOnError: false, + strict: false, + trust: false, + }) + } catch { + return DOMPurify.sanitize(block.tex) + } +} + +function enhanceMarkdownHtml(html: string, mathBlocks: MathBlock[]): string { const cleanHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['use'], ADD_ATTR: ['xlink:href'], @@ -83,6 +245,17 @@ function enhanceMarkdownHtml(html: string): string { const container = document.createElement('div') container.innerHTML = cleanHtml + const mathById = new Map(mathBlocks.map((block) => [block.id, block])) + + container.querySelectorAll('[data-math-id]').forEach((placeholder) => { + const block = mathById.get(placeholder.dataset.mathId ?? '') + if (!block) return + + const rendered = document.createElement(block.displayMode ? 'div' : 'span') + rendered.className = block.displayMode ? 'md-math-display' : 'md-math-inline' + rendered.innerHTML = renderMath(block) + placeholder.replaceWith(rendered) + }) container.querySelectorAll('table').forEach((table) => { if (table.parentElement?.classList.contains('md-table-wrap')) return @@ -100,12 +273,13 @@ function enhanceMarkdownHtml(html: string): string { return container.innerHTML } -function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[] } { +function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[]; mathBlocks: MathBlock[] } { pendingCodeBlocks = [] - const html = marked.parse(content) as string + const { markdown, mathBlocks } = extractMath(content) + const html = marked.parse(markdown) as string const codeBlocks = [...pendingCodeBlocks] pendingCodeBlocks = [] - return { html, codeBlocks } + return { html, codeBlocks, mathBlocks } } const BASE_PROSE_CLASSES = `markdown-prose prose prose-sm min-w-0 max-w-none break-words [overflow-wrap:anywhere] text-[var(--color-text-primary)] @@ -121,6 +295,10 @@ const BASE_PROSE_CLASSES = `markdown-prose prose prose-sm min-w-0 max-w-none bre prose-table:my-0 prose-table:w-full prose-table:table-auto prose-table:text-sm prose-th:bg-[var(--color-surface-info)] prose-th:px-3 prose-th:py-2 prose-th:text-left prose-th:whitespace-normal prose-th:break-words prose-th:align-top prose-th:border-b prose-th:border-[var(--color-border)] prose-td:px-3 prose-td:py-2 prose-td:border-b prose-td:border-[var(--color-border)] prose-td:whitespace-normal prose-td:break-words prose-td:align-top prose-td:bg-[var(--color-surface)] + [&_.katex]:[white-space:nowrap] [&_.katex]:[overflow-wrap:normal] [&_.katex]:[word-break:normal] + [&_.md-math-inline]:inline-flex [&_.md-math-inline]:max-w-full [&_.md-math-inline]:overflow-x-auto [&_.md-math-inline]:[vertical-align:-0.08em] [&_.md-math-inline_.katex]:text-[1.02em] + [&_.md-math-display]:my-5 [&_.md-math-display]:flex [&_.md-math-display]:max-w-full [&_.md-math-display]:justify-center [&_.md-math-display]:overflow-x-auto [&_.md-math-display]:px-1 [&_.md-math-display]:py-2 [&_.md-math-display]:[scrollbar-width:thin] + [&_.md-math-display_.katex-display]:m-0 [&_.md-math-display_.katex]:text-[1.14em] [&_.md-math-display_.katex-html]:min-w-max [&_.md-table-wrap]:my-5 [&_.md-table-wrap]:overflow-x-auto [&_.md-table-wrap]:rounded-xl [&_.md-table-wrap]:border [&_.md-table-wrap]:border-[var(--color-border)] [&_.md-table-wrap]:bg-[var(--color-surface-container-lowest)]` const DOCUMENT_PROSE_CLASSES = ` @@ -137,7 +315,8 @@ const DOCUMENT_PROSE_CLASSES = ` prose-ul:pl-5 prose-ul:[&>li]:marker:text-[var(--color-text-tertiary)] prose-ol:pl-5 prose-ol:[&>li]:marker:text-[var(--color-text-tertiary)] prose-li:my-1.5 - prose-table:my-0` + prose-table:my-0 + [&_.md-math-display]:my-6 [&_.md-math-display_.katex]:text-[1.18em]` const COMPACT_PROSE_CLASSES = ` prose-p:my-1 prose-p:text-xs prose-p:leading-5 prose-p:text-[var(--color-text-secondary)] @@ -148,6 +327,7 @@ const COMPACT_PROSE_CLASSES = ` prose-ul:my-1 prose-ol:my-1 prose-ul:pl-4 prose-ol:pl-4 prose-li:my-0.5 prose-li:text-xs prose-li:leading-5 prose-li:text-[var(--color-text-secondary)] prose-table:text-xs + [&_.md-math-display]:my-2 [&_.md-math-display]:py-1 [&_.md-math-display_.katex]:text-[1.04em] [&_.md-table-wrap]:my-2` function getProseClasses(variant: 'default' | 'document' | 'compact', className?: string) { @@ -162,7 +342,7 @@ function getProseClasses(variant: 'default' | 'document' | 'compact', className? } export function MarkdownRenderer({ content, variant = 'default', className, onLinkClick }: Props) { - const { html, codeBlocks } = useMemo(() => parseMarkdown(content), [content]) + const { html, codeBlocks, mathBlocks } = useMemo(() => parseMarkdown(content), [content]) const proseClasses = useMemo( () => getProseClasses(variant, className), [variant, className], @@ -225,7 +405,7 @@ export function MarkdownRenderer({ content, variant = 'default', className, onLi }, [onLinkClick]) if (codeBlocks.length === 0) { - const cleanHtml = enhanceMarkdownHtml(html) + const cleanHtml = enhanceMarkdownHtml(html, mathBlocks) return (
{parts.map((part, i) => part.type === 'html' ? ( -
+
) : shouldRenderAsMermaid(part.block) ? ( ) : (