fix(web): read a percent followed straight by the fence as content

A `%` whose comment text is empty (`95%$$`, or a multiline block whose last
line ends that way) no longer suppresses the closing fence, so the whole
formula survives instead of falling back to upstream and dropping its first
line. A `%` with text before the fence still suppresses it, which keeps
`\frac{1}{ % keep $$` and the no-space spelling at base behavior.

The verbatim scan now follows LaTeX's own reader: a letter cannot be the
delimiter, one `*` marks the starred form, the region must close before the
line ending or its characters stay ordinary content, and a comment holds no
commands. The Agent Note states which state belongs to which construct.
This commit is contained in:
Chinesezjc committed 2026-09-18 13:48:29 +08:00
1 parent d9b2e06b46
commit 3e0a5c3af5
5 files changed
+123 -21

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md
2026-09-17-multiline-display-dollar-math.md: df2caeeea3a52ba8f04b2d4c598921a79222d0ce
2026-09-17-multiline-display-dollar-math.zh.md: ffe69e3e4857a44780ec0b7b37cb85fb93d59db2
2026-09-17-multiline-display-dollar-math.md: 015d18eebd114aa17e93cffa786fca93b3361b5c
2026-09-17-multiline-display-dollar-math.zh.md: 78bd4e97cf7c03cdbb9a6bb3b3d66478e8a2f936
@@ -21,7 +21,7 @@ In the session that surfaced this, one chat reply lost lines 22–55 of 55 to a
[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) gains multi-line display-dollar blocks. Its dollar construct keeps the opening line's text in `mathFlowValue` instead of fence meta and closes at a trailing `$$` on any later content line, so `$$a+b` continued by `c+d$$` becomes one math node.
It ends the attempt when a `$$` sequence is not followed by the line's end, on the opening line or any later one (`$$x$$ trailing`), because scanning on from there would consume a later block's closing fence. A solitary `$` is content rather than a failed fence, so a formula that uses one — `$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$` — keeps the block open and closes normally wherever this flow owns the block. A trailing backslash escapes the line ending, not the next line's first character, so the escape run ends at the line ending instead of reaching into the next candidate. An unescaped `%` starts a TeX comment that runs to the line ending, so a `$$` at the end of one closes nothing when it appears on a later line of the block; a block that opens and closes on one line keeps closing at that line's `$$`, as it did before this flow spanned lines. A `\verb<delimiter>…<delimiter>` region is verbatim, so a `%` inside one is content. Both states are tracked for the dollar flow, which is the only flow that reads them.
It ends the attempt when a `$$` sequence is not followed by the line's end, on the opening line or any later one (`$$x$$ trailing`), because scanning on from there would consume a later block's closing fence. A solitary `$` is content rather than a failed fence, so a formula that uses one — `$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$` — keeps the block open and closes normally wherever this flow owns the block. A trailing backslash escapes the line ending, not the next line's first character, so the escape run ends at the line ending instead of reaching into the next candidate. An unescaped `%` starts a TeX comment that runs to the line ending, so a `$$` at the end of a later line closes nothing when the comment holds text before it. A `%` the fence follows immediately (`95%$$`) holds no comment text, and a block that opens and closes on one line keeps closing at that line's `$$`, as it did before this flow spanned lines. A `\verb<delimiter>…<delimiter>` region is verbatim, so a `%` inside one is content; the delimiter cannot be a letter, one optional `*` marks the starred form, and the region must close before the line ending or the characters read so far stay ordinary content. The comment state and its gate on the closing check belong to the dollar flow; the escape run and the verbatim scan are shared with the backslash construct.
Its paragraph-interruption check declines instead of consuming the line ending, for the dollar construct only. Consuming it there keeps the paragraph open across the line boundary, so the document layer keeps the container and counts the next line lazy for every construct tried afterwards; that made upstream's `mathFlow`, the construct tried after this one, end empty inside a list item. Declining also leaves the interruption decision to that upstream flow, whose own check short-circuits without consuming and claims only the fences it accepts, so a candidate neither construct accepts (`**before` followed by `$$a\$b`) leaves its paragraph intact. The backslash construct has no competing upstream construct and keeps consuming there.
@@ -21,7 +21,7 @@ $$f(x)=x
[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) 现在支持跨行的显示美元块。dollar 构造把开围栏同一行的文本留在 `mathFlowValue` 里而不是当作围栏 meta,并在后续任意内容行行尾的 `$$` 处闭合,于是 `$$a+b` 接 `c+d$$` 成为一个 math 节点。
`$$` 序列后面不是该行行尾时(开围栏行或任意后续行,例如 `$$x$$ trailing`)它放弃这次尝试:继续扫描会把更靠后一个块的闭合围栏也吞掉。孤立的一个 `$` 属于公式内容而不是失败的围栏,所以凡是本 flow 拥有该块的场合,用到它的公式——`$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$`——保持块打开并正常闭合。行尾反斜杠转义的是换行本身,不是下一行的第一个字符,因此转义状态在换行处结束,不会延续到下一个候选。未转义的 `%` 会开启延续到行尾的 TeX 注释,因此当这样的 `$$` 出现在块的后续行行尾时不闭合任何东西;而在一行内开闭的块仍在该行的 `$$` 处闭合,与它跨行之前的行为一致。`\verb<分隔符>…<分隔符>` 是原样文本区域,其中的 `%` 属于内容。这两种状态都只为 dollar flow 跟踪,也只有它会读取。
`$$` 序列后面不是该行行尾时(开围栏行或任意后续行,例如 `$$x$$ trailing`)它放弃这次尝试:继续扫描会把更靠后一个块的闭合围栏也吞掉。孤立的一个 `$` 属于公式内容而不是失败的围栏,所以凡是本 flow 拥有该块的场合,用到它的公式——`$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$`——保持块打开并正常闭合。行尾反斜杠转义的是换行本身,不是下一行的第一个字符,因此转义状态在换行处结束,不会延续到下一个候选。未转义的 `%` 会开启延续到行尾的 TeX 注释:当注释在围栏之前还有文本时,块后续行行尾的 `$$` 不闭合任何东西;而紧跟着围栏的 `%`(`95%$$`)不含注释文本,且在一行内开闭的块仍在该行的 `$$` 处闭合,与它跨行之前的行为一致。`\verb<分隔符>…<分隔符>` 是原样文本区域,其中的 `%` 属于内容;分隔符不能是字母,至多一个 `*` 表示星号形式,并且该区域必须在行尾之前闭合,否则已读到的字符仍按普通内容处理。注释状态及其对闭合判定的门控属于 dollar flow;转义状态与原样文本扫描与反斜杠构造共用。
它的段落打断检查改为「不认领」,且只作用于 dollar 构造。在那里消耗换行会让段落跨行边界保持打开,document 层因此保留容器并把下一行对随后尝试的所有构造计为 lazy;这正是上游 `mathFlow`(本构造之后被尝试的构造)在列表项内以空块结束的原因。不认领同时把打断决定交给上游 flow:它自己的检查不消耗换行就短路返回,并且只认领它接受的围栏,因此两个构造都不接受的候选(`**before` 后接 `$$a\$b`)保持原段落。反斜杠构造没有竞争的上游构造,仍然照旧消耗换行。
@@ -18,6 +18,12 @@ import type { Construct, Extension, Previous, State, Tokenizer } from 'micromark
/** Letters that follow a backslash to open TeX's `\verb` verbatim form. */
const VERB_NAME = 'verb'
/** Whether a code unit is an ASCII letter, which continues a control word. */
function isAsciiLetter(code: number): boolean {
return (code >= codes.lowercaseA && code <= codes.lowercaseZ)
|| (code >= codes.uppercaseA && code <= codes.uppercaseZ)
}
const previousBackslash: Previous = function (code) {
if (code !== codes.backslash) return true
const tail = this.events.at(-1)
@@ -135,6 +141,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
const self = this
let oddBackslashRun = false
let inComment = false
let commentHasContent = false
let crossedLineEnding = false
let sawClosingFenceSequence = false
let verbPrefixIndex = 0
@@ -170,7 +177,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
function content(code: number | null): State | undefined {
if (code === codes.eof) return nok(code)
if (code === marker && (marker !== codes.dollarSign || (!oddBackslashRun && !(inComment && crossedLineEnding)))) {
if (code === marker && closingFenceAllowed()) {
return effects.attempt(
{ partial: true, tokenize: tokenizeClosingFence },
closed,
@@ -185,7 +192,8 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
// same-line form keeps closing as it did before this flow spanned lines.
oddBackslashRun = false
inComment = false
crossedLineEnding = true
commentHasContent = false
if (marker === codes.dollarSign) crossedLineEnding = true
// A paragraph-interruption check on the dollar flow must not consume the
// line ending: that keeps the paragraph open across the line boundary,
// and the document layer then counts the next line lazy for every
@@ -199,6 +207,15 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
return valueStart(code)
}
/**
* Whether the marker under the scanner may close the block. The dollar flow
* keeps an escaped `$` and a `%` comment that holds text from closing.
*/
function closingFenceAllowed(): boolean {
if (marker !== codes.dollarSign) return true
return !oddBackslashRun && !(inComment && commentHasContent && crossedLineEnding)
}
function afterClosingFenceAttempt(code: number | null): State | undefined {
if (marker === codes.backslash) {
return effects.check({ partial: true, tokenize: tokenizeOpeningFence }, nok, markerValueStart)(code)
@@ -226,9 +243,14 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
// of a value run, such as a line that starts inside the block. The
// backslash construct reads neither this state nor the escape run.
oddBackslashRun = code === codes.backslash
if (code === codes.percentSign && marker === codes.dollarSign) inComment = true
if (code === codes.percentSign && marker === codes.dollarSign) {
inComment = true
commentHasContent = false
} else if (inComment) {
commentHasContent = true
}
effects.consume(code)
if (code !== codes.backslash) return value
if (code !== codes.backslash || inComment) return value
verbPrefixIndex = 0
return afterBackslash
}
@@ -236,6 +258,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
function markerValueStart(code: number | null): State | undefined {
effects.enter('mathFlowValue')
oddBackslashRun = false
if (inComment) commentHasContent = true
effects.consume(code)
return valueAfterMarker
}
@@ -255,16 +278,23 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
}
const escaped = oddBackslashRun
oddBackslashRun = code === codes.backslash ? !escaped : false
if (code === codes.backslash && !escaped) {
if (code === codes.backslash && !escaped && !inComment) {
// A `\verb<delimiter>…<delimiter>` region is verbatim, so a `%` inside
// it is content rather than a comment.
// it is content rather than a comment. A comment holds no commands.
effects.consume(code)
verbPrefixIndex = 0
return afterBackslash
}
// An unescaped `%` starts a TeX comment that runs to the line ending, so
// a `$$` inside it closes nothing.
if (!escaped && code === codes.percentSign && marker === codes.dollarSign) inComment = true
// a `$$` inside it closes nothing. A `%` the fence follows immediately
// holds no comment text, which is how writers use it mid-formula
// (`95%$$`); only a comment with text suppresses the close.
if (!escaped && code === codes.percentSign && marker === codes.dollarSign) {
inComment = true
commentHasContent = false
} else if (inComment) {
commentHasContent = true
}
effects.consume(code)
return value
}
@@ -274,26 +304,61 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
effects.consume(code)
oddBackslashRun = false
verbPrefixIndex += 1
return verbPrefixIndex === VERB_NAME.length ? verbatimDelimiterStart : afterBackslash
return verbPrefixIndex === VERB_NAME.length ? verbatimStar : afterBackslash
}
return value(code)
}
function verbatimDelimiterStart(code: number | null): State | undefined {
/** `\verb` takes at most one `*`, then the delimiter it repeats. */
function verbatimStar(code: number | null): State | undefined {
verbPrefixIndex = 0
if (code === codes.eof || markdownLineEnding(code)) return value(code)
if (code === codes.asterisk) {
effects.consume(code)
return verbatimDelimiterStart
}
if (code !== codes.asterisk) return verbatimDelimiterStart(code)
effects.consume(code)
return verbatimDelimiterStart
}
function verbatimDelimiterStart(code: number | null): State | undefined {
// A letter continues the control word (`\verbatim`), so it is no
// delimiter; `\verb` at a line ending has no delimiter at all.
if (code === null || markdownLineEnding(code) || isAsciiLetter(code)) return value(code)
verbatimDelimiter = code
oddBackslashRun = false
effects.consume(code)
return verbatimContent
return verbatimCloseCheck
}
/**
* `\verb` closes on its delimiter before the line ends; without one the
* characters read so far are ordinary content, which keeps the error inside
* the formula instead of consuming a closing fence as verbatim text.
*/
function verbatimCloseCheck(code: number | null): State | undefined {
return effects.check(
{ partial: true, tokenize: tokenizeVerbatimClose },
verbatimContent,
verbatimFallback,
)(code)
}
function tokenizeVerbatimClose(
checkEffects: Parameters<Tokenizer>[0],
checkOk: State,
checkNok: State,
): State {
return scan
function scan(code: number | null): State | undefined {
if (code === null || markdownLineEnding(code)) return checkNok(code)
if (code === verbatimDelimiter) return checkOk(code)
checkEffects.consume(code)
return scan
}
}
function verbatimContent(code: number | null): State | undefined {
if (code === codes.eof || markdownLineEnding(code)) {
/* v8 ignore next 4 -- the close check guarantees the delimiter precedes
any line ending, so this state never sees one. */
if (code === null || markdownLineEnding(code)) {
verbatimDelimiter = null
return value(code)
}
@@ -304,6 +369,11 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
return value
}
function verbatimFallback(code: number | null): State | undefined {
verbatimDelimiter = null
return value(code)
}
function closed(code: number | null): State | undefined {
effects.exit('mathFlow')
return ok(code)
@@ -588,6 +588,20 @@ describe('MarkdownText', () => {
}
})
it('closes a multi-line block whose last line ends in a bare percent', () => {
const { container } = render(<MarkdownText text={'$$\\text{acc} =\n95%$$'} />)
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(container.querySelector('.katex-display annotation')?.textContent).toBe('\\text{acc} =\n95%')
})
it('suppresses a close behind a comment that holds text', () => {
const { container } = render(<MarkdownText text={'$$\na = x% $$\n$$'} />)
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(container.querySelector('.katex-display annotation')?.textContent).toContain('a = x% $$')
})
it('closes a same-line block whose percent is formula content', () => {
const { container } = render(<MarkdownText text={'$$\\text{acc} = 95%$$'} />)
@@ -624,6 +638,24 @@ describe('MarkdownText', () => {
}
})
it('keeps a control word and an unterminated verbatim inside their formulas', () => {
const controlWord = render(<MarkdownText text={'$$\\verbatim + y$$'} />)
expect(controlWord.container.querySelector('.katex-display annotation')?.textContent).toBe('\\verbatim + y')
controlWord.unmount()
for (const source of [
'$$\\verb|abc$$\n\nAfter\n\n$$\ny\n$$',
'$$x % \\verb$abc$$\n\nAfter\n\n$$\ny\n$$',
'$$\\verb**abc*$$\n\nAfter\n\n$$\ny\n$$',
]) {
const rendered = render(<MarkdownText text={source} />)
expect(rendered.container.querySelectorAll('.katex-display, .katex-error')).toHaveLength(2)
expect(rendered.container.querySelector('p')?.textContent).toBe('After')
rendered.unmount()
}
})
it('ends the backslash escape run at a line ending', () => {
const { container } = render(<MarkdownText text={'$$a$b\\\n\n$$y$$'} />)