feat: render Markdown formulas with KaTeX

This commit is contained in:
lofyer
2026-08-12 23:48:44 +08:00
parent 2e489d5bc3
commit d33df979da
6 changed files with 550 additions and 2 deletions
@@ -73,6 +73,98 @@ const ready = true
expect(container.querySelector('pre')).not.toBeInTheDocument()
})
it('renders inline and display LaTeX formulas with KaTeX', () => {
const { container } = render(
<MarkdownRenderer>{`质能方程为 $E = mc^2$
$$
\\int_0^1 x^2\\,dx = \\frac{1}{3}
$$`}</MarkdownRenderer>
)
expect(container.querySelectorAll('.katex')).toHaveLength(2)
expect(container.querySelector('.katex-display')).toBeInTheDocument()
expect(
container.querySelector(
'annotation[encoding="application/x-tex"]'
)
).toHaveTextContent('E = mc^2')
expect(
[...container.querySelectorAll(
'annotation[encoding="application/x-tex"]'
)].at(-1)
).toHaveTextContent('\\int_0^1 x^2\\,dx = \\frac{1}{3}')
})
it('supports common model-style LaTeX delimiters outside code', () => {
const { container } = render(
<MarkdownRenderer>{`行内公式 \\(a^2 + b^2 = c^2\\)。
\\[
\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}
\\]
\\[ \\sqrt{x^2} = |x| \\]
\`\\(not rendered\\)\`
\`\`\`tex
\\[
also not rendered
\\]
\`\`\``}</MarkdownRenderer>
)
expect(container.querySelectorAll('.katex')).toHaveLength(3)
expect(container.querySelectorAll('.katex-display')).toHaveLength(2)
expect(screen.getByText('\\(not rendered\\)')).toBeInTheDocument()
expect(screen.getByText(/also not rendered/u)).toBeInTheDocument()
expect(container.querySelectorAll('code .katex')).toHaveLength(0)
})
it('promotes formulas that occupy a whole line to centered display math', () => {
const { container } = render(
<MarkdownRenderer>{`段落内仍是 $E = mc^2$ 行内公式。
$S(A) = \\frac{\\operatorname{Area}(\\gamma_A)}{4G_N}$
\\(S \\leq \\frac{A}{4G_N}\\)`}</MarkdownRenderer>
)
expect(container.querySelectorAll('.katex')).toHaveLength(3)
expect(container.querySelectorAll('.katex-display')).toHaveLength(2)
expect(
container.querySelector(
'p:first-child .katex-display'
)
).not.toBeInTheDocument()
for (const display of container.querySelectorAll('.katex-display')) {
expect(display.querySelector(':scope > .katex')).toBeInTheDocument()
}
})
it('shows invalid formulas without crashing or rendering raw HTML', () => {
const { container } = render(
<MarkdownRenderer>{`$\\notARealCommand{value}$
$\\href{javascript:alert(1)}{unsafe}$`}</MarkdownRenderer>
)
expect(
container.querySelector(
'annotation[encoding="application/x-tex"]'
)
).toHaveTextContent('\\notARealCommand{value}')
expect(container.querySelectorAll('.katex')).toHaveLength(2)
expect(container).toHaveTextContent(
'\\notARealCommand'
)
expect(container.querySelector('script')).not.toBeInTheDocument()
expect(
container.querySelector('a[href^="javascript:"]')
).not.toBeInTheDocument()
})
it('updates table accessibility copy when the locale changes', async () => {
render(
<MarkdownRenderer>{`| Name |
+111 -2
View File
@@ -1,6 +1,8 @@
import { memo, useMemo } from 'react'
import rehypeKatex from 'rehype-katex'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import type { Components } from 'react-markdown'
import { useTranslation } from 'react-i18next'
@@ -43,6 +45,100 @@ type MarkdownRendererProps = {
const wholeMarkdownFence =
/^```(?:markdown|md)\s*\r?\n([\s\S]*?)\r?\n```$/iu
function replaceLatexDelimiters(line: string): string {
let output = ''
let index = 0
let codeDelimiterLength = 0
while (index < line.length) {
if (line[index] === '`') {
let runLength = 1
while (line[index + runLength] === '`') {
runLength += 1
}
if (
codeDelimiterLength === 0 ||
codeDelimiterLength === runLength
) {
codeDelimiterLength =
codeDelimiterLength === 0 ? runLength : 0
}
output += line.slice(index, index + runLength)
index += runLength
continue
}
if (
codeDelimiterLength === 0 &&
line[index] === '\\' &&
line[index - 1] !== '\\' &&
(line[index + 1] === '(' || line[index + 1] === ')')
) {
output += '$'
index += 2
continue
}
output += line[index]
index += 1
}
return output
}
function normalizeLatexDelimiters(content: string): string {
let fence:
| {
character: '`' | '~'
length: number
}
| undefined
return content
.split(/\r?\n/u)
.map((line) => {
if (fence) {
const closingFence = new RegExp(
`^ {0,3}${fence.character}{${fence.length},}\\s*$`,
'u'
)
if (closingFence.test(line)) {
fence = undefined
}
return line
}
const openingFence = /^ {0,3}(`{3,}|~{3,})/u.exec(line)
if (openingFence) {
const marker = openingFence[1]!
fence = {
character: marker[0] as '`' | '~',
length: marker.length
}
return line
}
const standaloneDollar =
/^ {0,3}\$(?!\$)(.+?)(?<!\\)\$\s*$/u.exec(line)
if (standaloneDollar) {
return `$$\n${standaloneDollar[1]}\n$$`
}
const standaloneParentheses =
/^ {0,3}\\\(\s*(.*?)\s*\\\)\s*$/u.exec(line)
if (standaloneParentheses) {
return `$$\n${standaloneParentheses[1]}\n$$`
}
const singleLineDisplay =
/^ {0,3}\\\[\s*(.*?)\s*\\\]\s*$/u.exec(line)
if (singleLineDisplay) {
return `$$\n${singleLineDisplay[1]}\n$$`
}
if (/^ {0,3}\\\[\s*$/u.test(line)) {
return '$$'
}
if (/^ {0,3}\\\]\s*$/u.test(line)) {
return '$$'
}
return replaceLatexDelimiters(line)
})
.join('\n')
}
function unwrapMarkdownFence(content: string): string {
const fencedMarkdown = wholeMarkdownFence.exec(content.trim())
return fencedMarkdown?.[1] ?? content
@@ -60,10 +156,23 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
return (
<ReactMarkdown
components={components}
remarkPlugins={[remarkGfm]}
rehypePlugins={[
[
rehypeKatex,
{
output: 'htmlAndMathml',
strict: 'warn',
trust: false
}
]
]}
remarkPlugins={[
remarkGfm,
[remarkMath, { singleDollarTextMath: true }]
]}
skipHtml
>
{unwrapMarkdownFence(children)}
{normalizeLatexDelimiters(unwrapMarkdownFence(children))}
</ReactMarkdown>
)
})
+2
View File
@@ -1,6 +1,8 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@fontsource-variable/noto-sans-sc/wght.css'
import 'katex/dist/katex.min.css'
import './markdown-math.css'
import App from './App'
import { installBundledUiFonts } from './fonts'
import { changeUiLocale } from './i18n'
+27
View File
@@ -0,0 +1,27 @@
.markdown-content .katex {
color: inherit;
font-size: 1.05em;
}
.markdown-content .katex-display {
max-width: 100%;
margin: 0.85em 0;
padding: var(--space-1) 0;
overflow-x: auto;
overflow-y: hidden;
text-align: center;
}
.markdown-content .katex-display > .katex {
width: max-content;
min-width: max-content;
margin-inline: auto;
text-align: center;
}
.markdown-content .katex-error {
color: var(--danger);
font-family: var(--font-family-mono);
font-size: 0.9em;
overflow-wrap: anywhere;
}