perf: keep Markdown segmentation for single-line display math and ship KaTeX fonts as files

This commit is contained in:
mesalogo
2026-10-02 14:41:56 +08:00
parent 80579ee421
commit 3d4e9577b5
5 changed files with 139 additions and 21 deletions
+52 -1
View File
@@ -757,7 +757,16 @@ describe('segmented Markdown rendering', () => {
' indented by three\n\n\tafter tab\n\n - nested\n\ntrailing',
'```a`b\n\ntext\n\n```\n\ncode?\n\n```\n\nend',
'- item\n\n ```\n x\n\n y\n ```\n\nafter\n\n> ```\n\nq',
'1. one\n\n $$\n a\n\n $$\n\nb\n\n10. ten\n\nc'
'1. one\n\n $$\n a\n\n $$\n\nb\n\n10. ten\n\nc',
// Single-line display math is a paragraph with inline math, not a block.
'Display math:\n\n$$\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}$$\n\nafter\n\n$$x^2$$ trailing text\n\n$$a$ odd\n\nend',
'$$$\na\n\n$$\nstill math\n$$$\n\nafter\n\n$$ meta\nx\n\n$$$$\n\nnext\n\n$$\nunclosed\n\nstill unclosed',
'- item with $$x$$\n\n $$\n y\n $$\n\n> $$\n> q\n\n$$z$$\n\nend',
// Document-scoped syntax only inside unindented code fences.
'# Notes\n\n```md\n[ref]: https://example.com\nsee [^1]\n\n[^1]: body\n```\n\nafter [ref]\n\n~~~\n]:\n~~~\n\nend',
'- list\n\n ```\n [ref]: https://example.com\n\n ```\n\n[ref]\n\nend',
'text `a]: b` inline\n\nmore\n\n```\ncode\n```\n\n[x]\n\n[x]: https://example.com',
'````\n```\n[r]: https://example.com\n```\n````\n\n[r]\n\nend'
]
function canonicalHtml(element: HTMLElement): string {
@@ -796,6 +805,37 @@ describe('segmented Markdown rendering', () => {
expect(multiSegment).toBeGreaterThan(500)
}, 15000)
it('renders random block combinations and their prefixes identically to whole-document parsing', () => {
// Deterministic generator over lines that stress segment boundaries.
const lines = [
'para', 'para $x$', '# h', '- a', '1. one', ' indented', ' code4', '> q',
'$$', '$$x$$', '$$$', ' $$', '$$ meta', '$$x$$ tail', '```', ' ```', '~~~', '````', '```md',
'[r]: https://e.com', ' [r]: https://e.com', 'see [r]', '[^1]', '[^1]: fn',
'| a |', '| - |', '***', '---', '===', '<div>', 'x'
]
let seed = 20261002
const random = () => {
seed = (seed * 1103515245 + 12345) % 2147483648
return seed / 2147483648
}
let multiSegment = 0
for (let index = 0; index < 1200; index++) {
const full = Array.from(
{ length: 3 + Math.floor(random() * 14) },
() => random() < 0.4 ? '' : lines[Math.floor(random() * lines.length)]!
).join('\n')
const content = index % 2 ? full.slice(0, Math.floor(random() * (full.length + 1))) : full
const segmented = render(<div><MarkdownRenderer>{content}</MarkdownRenderer></div>)
const reference = render(<div><UnsegmentedMarkdownRenderer>{content}</UnsegmentedMarkdownRenderer></div>)
expect(canonicalHtml(segmented.container), JSON.stringify(content))
.toBe(canonicalHtml(reference.container))
segmented.unmount()
reference.unmount()
if (splitMarkdownSegments(normalizeLatexDelimiters(content)).length > 1) multiSegment++
}
expect(multiSegment).toBeGreaterThan(100)
}, 60000)
it('keeps completed segments intact and falls back for document-scoped syntax', () => {
// List markers never start a segment: the list may continue or turn loose.
expect(splitMarkdownSegments('a\n\nb\n\n- c\n\n- d\n\ne')).toEqual(['a\n\n', 'b\n\n- c\n\n- d\n\n', 'e'])
@@ -805,6 +845,17 @@ describe('segmented Markdown rendering', () => {
expect(splitMarkdownSegments('a\r\n\r\nb')).toHaveLength(1)
})
it('splits around single-line display math and literal code fences', () => {
expect(splitMarkdownSegments('a\n\n$$x^2$$\n\nb')).toEqual(['a\n\n', '$$x^2$$\n\n', 'b'])
expect(splitMarkdownSegments('$$\nx\n\ny\n$$\n\nb')).toEqual(['$$\nx\n\ny\n$$\n\n', 'b'])
// A longer opening run needs an equally long closing run.
expect(splitMarkdownSegments('$$$\nx\n$$\n\ny\n$$$\n\nb')).toEqual(['$$$\nx\n$$\n\ny\n$$$\n\n', 'b'])
expect(splitMarkdownSegments('```md\n[r]: https://example.com\n```\n\nb')).toEqual(['```md\n[r]: https://example.com\n```\n\n', 'b'])
// Indented fences may belong to a container that ends earlier.
expect(splitMarkdownSegments('- a\n\n ```\n [r]: https://example.com\n ```\n\nb')).toHaveLength(1)
expect(splitMarkdownSegments('a `[r]: x`\n\nb')).toHaveLength(1)
})
it('re-parses only the growing tail when completed segments are unchanged', () => {
const base = Array.from({ length: 30 }, (_, i) => `段落 ${i} **粗体** $x_${i}$`).join('\n\n')
const { rerender, container } = render(<MarkdownRenderer>{base}</MarkdownRenderer>)
+37 -18
View File
@@ -257,13 +257,17 @@ function standaloneHtmlSource(content: string): string | undefined {
// Link reference and footnote definitions resolve across the whole document,
// even from inside lists or block quotes, so their presence disables splitting.
// Lines inside an unindented code fence are literal and are not checked.
const documentScopedMarkdown = /\]:|\[\^/u
// Constructs that may legally span blank lines in ways a line scanner cannot
// track: raw HTML blocks and `$$` lines carrying other text.
const unsafeSegmentLine = /^ {0,3}(?:<|\$\$.*\S)/u
// Raw HTML blocks may legally span blank lines in ways a line scanner cannot
// track.
const unsafeSegmentLine = /^ {0,3}</u
// CommonMark: a backtick fence's info string cannot contain backticks.
const segmentFenceOpening = /^ {0,3}(?:(`{3,})(?!.*`)|(~{3,}))/u
const segmentMathFence = /^ {0,3}\$\$\s*$/u
// micromark-extension-math: a run of two or more `$` opens a math block only
// when the rest of the line (the optional meta) has no `$`. Otherwise, as in
// `$$x^2$$`, the line is an ordinary paragraph containing inline math.
const segmentMathFenceOpening = /^ {0,3}(\${2,})(.*)$/u
// A block after a blank line belongs to the previous top-level block when it
// is indented (list item or indented code continuation), a list marker (the
// list may continue and its looseness may change), or a block quote marker.
@@ -277,12 +281,12 @@ const continuationLine = /^(?:\s|[-*+](?:\s|$)|\d{1,9}[.)](?:\s|$)|>)/u
* could make that unsafe.
*/
export function splitMarkdownSegments(content: string): string[] {
if (documentScopedMarkdown.test(content) || content.includes('\r')) {
if (content.includes('\r')) {
return [content]
}
const segments: string[] = []
let fence: { character: '`' | '~'; length: number } | undefined
let inMath = false
let fence: { closing: RegExp; literal: boolean } | undefined
let math: { closing: RegExp } | undefined
let segmentStart = 0
let segmentHasBlock = false
let previousBlank = false
@@ -293,18 +297,21 @@ export function splitMarkdownSegments(content: string): string[] {
const lineEnd = newline === -1 ? content.length : newline
const line = content.slice(lineStart, lineEnd)
// An unindented fence line always opens a top-level code block, so its
// content is literal until the matching close. Indented fences may sit in
// a container that ends earlier, so their lines are still checked.
if (!fence?.literal && documentScopedMarkdown.test(line)) {
return [content]
}
if (fence) {
const closing = new RegExp(
`^ {0,3}${fence.character}{${fence.length},}\\s*$`,
'u'
)
if (closing.test(line)) {
if (fence.closing.test(line)) {
fence = undefined
}
previousBlank = false
} else if (inMath) {
if (segmentMathFence.test(line)) {
inMath = false
} else if (math) {
if (math.closing.test(line)) {
math = undefined
}
previousBlank = false
} else if (line.trim() === '') {
@@ -325,15 +332,27 @@ export function splitMarkdownSegments(content: string): string[] {
}
segmentHasBlock = true
const opening = segmentFenceOpening.exec(line)
const mathOpening = segmentMathFenceOpening.exec(line)
if (opening) {
const marker = (opening[1] ?? opening[2])!
fence = { character: marker[0] as '`' | '~', length: marker.length }
fence = {
closing: new RegExp(
`^ {0,3}${marker[0]}{${marker.length},}[ \\t]*$`,
'u'
),
literal: line.startsWith(marker)
}
} else if (/^ {0,3}`{3,}/u.test(line)) {
// Backtick run with a backtick in its info string: inline code that
// the line scanner cannot model, so keep the document whole.
return [content]
} else if (segmentMathFence.test(line)) {
inMath = true
} else if (mathOpening && !mathOpening[2]!.includes('$')) {
math = {
closing: new RegExp(
`^ {0,3}\\\${${mathOpening[1]!.length},}[ \\t]*$`,
'u'
)
}
}
previousBlank = false
}