feat: render Markdown formulas with KaTeX
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user