Merge readable math rendering into desktop main

Bring the desktop markdown LaTeX renderer back into the local main line
without folding unrelated local release edits into the merge commit.

Constraint: Local main had uncommitted release changes, so they were preserved outside the merge and restored afterward
Confidence: high
Scope-risk: narrow
Directive: Keep future markdown rendering changes covered by focused renderer tests before broad desktop gates
Tested: bun run test --run src/components/markdown/MarkdownRenderer.test.tsx
Tested: bun run lint
Tested: bun run build
Tested: WebUI complex formula screenshot and 390px viewport overflow check
Not-tested: Full desktop gate still has unrelated /goal command hint expectation drift
This commit is contained in:
程序员阿江(Relakkes) 2026-05-16 13:29:59 +08:00
commit b6fcd9e422
4 changed files with 294 additions and 8 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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(
<MarkdownRenderer
content={'Inline formula: $E = mc^2$\n\nBlock formula:\n\n$$\\int_0^1 x^2 \\, dx = \\frac{1}{3}$$'}
/>,
)
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(
<MarkdownRenderer
content={'$$\n\\begin{aligned}\na &= b + c \\\\\nd &= e + f\n\\end{aligned}\n$$'}
/>,
)
expect(container.querySelector('.md-math-display .katex')).toBeInTheDocument()
})
it('renders bracket-delimited inline and display formulas', () => {
const { container } = render(
<MarkdownRenderer
content={'圆面积是 \\(A = \\pi r^2\\)。\n\n\\[\nE = mc^2\n\\]'}
/>,
)
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(
<MarkdownRenderer
content={[
'矩阵和分段函数:',
'',
'$$',
'\\begin{bmatrix}1 & 2 \\\\ 3 & 4\\end{bmatrix}',
'\\begin{bmatrix}x \\\\ y\\end{bmatrix}',
'=',
'\\begin{cases}',
'x + 2y, & x > 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(
<MarkdownRenderer
content={'Long display:\n\n$$\\sum_{i=1}^{n} \\left(y_i - \\hat{y}_i\\right)^2 + \\frac{\\alpha}{2}\\|w\\|_2^2 = \\mathcal{L}(w, b)$$'}
/>,
)
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(
<MarkdownRenderer content={'Price is \\$5 and not math. Range is $5 to $10.'} />,
)
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(
<MarkdownRenderer
content={'Keep `$E = mc^2$` as code.\n\n```text\n$$\\int_0^1 x^2 dx$$\n```'}
/>,
)
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(
<MarkdownRenderer

View File

@ -1,6 +1,8 @@
import { useMemo, useCallback } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import DOMPurify from 'dompurify'
import katex from 'katex'
import 'katex/dist/katex.min.css'
import { marked, type Tokens } from 'marked'
import { CodeViewer } from '../chat/CodeViewer'
import { MermaidRenderer } from '../chat/MermaidRenderer'
@ -19,9 +21,16 @@ type CodeBlock = {
language: string | undefined
}
type MathBlock = {
id: string
tex: string
displayMode: boolean
}
const MERMAID_LANGUAGE = 'mermaid'
const PLAINTEXT_LANGUAGES = new Set(['', 'text', 'plaintext', 'plain'])
const MERMAID_DIAGRAM_START = /^(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|journey|gantt|pie|gitGraph|mindmap|timeline|requirementDiagram|quadrantChart|xychart-beta|sankey-beta|block-beta|packet-beta|architecture|kanban)\b/i
const CODE_FENCE_START = /^ {0,3}(`{3,}|~{3,})/
function normalizeCodeLanguage(language: string | undefined): string | undefined {
const normalized = language?.trim().split(/\s+/)[0]?.toLowerCase()
@ -71,7 +80,160 @@ marked.setOptions({
})
marked.use({ renderer })
function enhanceMarkdownHtml(html: string): string {
function findUnescapedDelimiter(text: string, delimiter: string, fromIndex: number): number {
let index = text.indexOf(delimiter, fromIndex)
while (index !== -1) {
let backslashCount = 0
for (let i = index - 1; i >= 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}"></${tag}>${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<HTMLElement>('[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 (
<div
className={proseClasses}
@ -239,7 +419,7 @@ export function MarkdownRenderer({ content, variant = 'default', className, onLi
<div className={proseClasses} onClick={handleClick}>
{parts.map((part, i) =>
part.type === 'html' ? (
<div key={i} dangerouslySetInnerHTML={{ __html: enhanceMarkdownHtml(part.content) }} />
<div key={i} dangerouslySetInnerHTML={{ __html: enhanceMarkdownHtml(part.content, mathBlocks) }} />
) : shouldRenderAsMermaid(part.block) ? (
<MermaidRenderer key={part.block.id} code={part.block.code} />
) : (