Files
deepseek-harness/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md
T

11 KiB
Raw Blame History

Agent Note: Multi-line display-dollar math

Status: implemented

English | 中文

Problem

作者和模型写 $$ 行间公式时有三种写法。定稿语法只渲染其中两种:一行内开闭的 $$math$$,以及 $$ 独占一行、由独占一行的 $$ 闭合。第三种——开围栏同一行就带公式内容,闭合 $$ 落在最后一行公式行行尾——不渲染:

$$f(x)=x
\text{continued}$$

micromark-extension-math 把开围栏 $$ 之后的同一行文本当作围栏 meta,并且只接受内容恰为 $$(后接可选空白)的行作为闭合围栏。markdown 兼容层只认同一行开闭这一种形状,而定稿渲染器把 node.value 交给 KaTeX,不读 node.meta。于是该块始终不闭合:它吞掉后续正文,value 里残留字面 $$,KaTeX 抛出 Can't use function '$' in math mode,katex.tsx 的错误分支把 TeX 原文连同被吞掉的正文一起显示为红色。

在暴露该问题的会话中,一条聊天回复 55 行里有 34 行(第 22–55 行)变成同一个错误块,六个笔记文件从出错点起的所有行(最多约 500 行)同样变成一个错误块。同一机制还带出第二个缺陷:当更靠后的独占行 $$ 恰好闭合了这个流时,开围栏那一行已经归给 meta,渲染器于是静默丢掉公式的第一行。

Decision

mathCompatibility.ts 现在支持跨行的显示美元块。dollar 构造把开围栏同一行的文本留在 mathFlowValue 里而不是当作围栏 meta,并在后续任意内容行行尾的 $$ 处闭合,于是 $$a+b 接 c+d$$ 成为一个 math 节点。

闭合失败的 $$ 序列会结束该候选:出现在后续行时立即结束;出现在开围栏行时在该行行尾结束($$x$$ trailing 即在此放弃)。在开围栏行内扫描继续,因此同一行更靠后的 $$ 仍能闭合($$\verb*a$$a$$)。越过这两点继续扫描都会吞掉更靠后一个块的闭合围栏。孤立的一个 $ 属于公式内容而不是失败的围栏,所以凡是本 flow 拥有该块的场合,用到它的公式——$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$——保持块打开并正常闭合。行尾反斜杠转义的是换行本身,不是下一行的第一个字符,因此转义状态在换行处结束,不会延续到下一个候选。未转义的 % 会开启延续到行尾的 TeX 注释:当注释在围栏之前还有文本时,块后续行行尾的 $$ 不闭合任何东西;而围栏之前只有空白或什么都没有的 %(95%$$、95 %$$)不含注释文本,且在一行内开闭的块仍在该行的 $$ 处闭合,与它跨行之前的行为一致。该状态及其对闭合判定的门控属于 dollar flow。\verb 或 \verb* 之后的那个字符是该命令的分隔符而不是围栏,因此该分隔符、以及区域为空时紧随其后的那个 $ 都属于内容;这段尾部状态在换行处、以及在被转义的反斜杠之后清空(\\verb 是换行加字母,不是该命令),注释里也不识别命令。除此之外不解析原样文本区域,使含 \verb 的公式与其它输入走同一条 value 路径。

它的段落打断检查改为「不认领」,且只作用于 dollar 构造。在那里消耗换行会让段落跨行边界保持打开,document 层因此保留容器并把下一行对随后尝试的所有构造计为 lazy;这正是上游 mathFlow(本构造之后被尝试的构造)在列表项内以空块结束的原因。不认领同时把打断决定交给上游 flow:它自己的检查不消耗换行就短路返回,并且只认领它接受的围栏,因此两个构造都不接受的候选(**before 后接 $$a\$b)保持原段落。反斜杠构造没有竞争的上游构造,仍然照旧消耗换行。

parse.ts 把 mathCompatibility() 注册在 math() 之后:micromark 先派发最后注册的扩展,上面的 dollar flow 因此优先于上游 mathFlow,而行内 $…$ 仍由 math() 负责。

Alternatives considered

兼容层维持只处理同一行 $$…$$。 多行写法是 GitHub、KaTeX auto-render、VS Code、Typora 都接受的形状,也是模型写多分支公式时实际采用的形状;不解析它意味着公式位置显示为红色错误块,并连带吞掉后文。

解析后修补语法树。 被吞掉的正文已经进入 mathFlowValue,与数学源码无法区分;meta 和 value 无法还原成原意的块,而且后面的块根本没有被解析。

解析前用文本替换改写 $$ 围栏。 兼容层的设计就是语法扩展;文本替换需要自己定位代码 span、围栏和转义,而且改的是作者原文而不是解析它。

闭合失败后继续扫描。 这正是单纯打开 multiline 会得到的行为:$$x$$ trailing\n\n$$y$$ 会一路扫到第二个块的 $$,渲染成一个同时含着两行文本的 math 节点;而上游行内数学会把 x、正文和 y 分开渲染。$$x$$ trailing 的既有行为已经固定了这条回退,因此该构造放弃这个候选。对任意一个 $(而不是完整的 $$ 序列)就放弃,还会破坏在 \text{} 内使用单个 $ 的公式。

只要有一行解析不下去就结束尝试。 对每次同一行闭合失败都放弃虽然不再吞文,却会让 $$a$b 这类行变成平方级扫描,并丢掉上面那些含单个 $ 的公式。判据是完整的 $$ 序列,不是那个字符。

在检查阶段认领段落打断。 像上游 flow 那样返回成功可以避免破坏列表用例的 lazy 记账,但也会让最终失败的候选提前结束段落(**before\n$$a\$b\nafter** 被拆成两段并暴露 **)。改为不认领,把决定交给上游自己的检查。

把打断守卫也用在反斜杠构造上。 这个守卫是为了保护随后被尝试的构造;\[/\( 没有第二个构造,无条件套用会丢掉它们的跨行块(Prose line 后接 \[)。

Consequences

三种写法现在产生同一个 math 节点,公式不再在开围栏那一行被截断,也不再吞掉其后的正文。$$ 围栏 meta 不再生效:$$asciimath$$ 会解析成 value 为 asciimath 的公式,而上游 flow 会把该段当作 fence meta 丢弃。这种写法在本仓库没有使用者,且渲染器本来就从不读 meta。

有六种输入仍会走到上游 flow,或留在所属段落里,因为兼容 flow 主动放弃它们。闭合 $$ 之后还有文本时($$x$$ trailing,或用中文引号收尾的散文行)走上游行内数学,这一点由 markdown.client.spec.tsx 中已有用例固定,后面还有别的 $$ 块时同样如此。「后面不是行尾」的 $$ 序列会让块放弃:出现在后续行时立即放弃,出现在开围栏行时在该行行尾放弃。未闭合的 $$ 块仍像本次改动之前那样延伸到下一个独占行 $$ 或文件结尾,因为上游 flow 在 EOF 处闭合。

第四种是「开围栏行含孤立 $、且需要打断段落、前面又没有空行」的多行块。两个 flow 都不认领这次打断——本 flow 出于设计,上游则因为它的围栏 meta 在 $ 处即停止——该块因此留在段落里,渲染成行内数学或根本不渲染。用空行把块与段落分开,本 flow 就会拥有它并渲染 display。

第五种来自注释门控:当块唯一的闭合围栏位于「带文本的注释」之后时,块继续扫描而不是在那里闭合,若再没有别的围栏闭合它,就会像上面几种一样走到上游 flow。若围栏之前只有空白,则块在该处闭合,此后单独一行的 $$ 会成为它自己的空块——而被替换掉的语法那时只给出一个节点。

第六种是容器内续行退回容器缩进的多行块。- Define: 之后以列表项缩进写 $$f(x)=x,再接一行不缩进的续行,上游 flow 会得到有 meta、无 value 的结果:读者看到一个空的 display 块加一行散文,公式首行无声消失。续行保持同样缩进,或把整块移到列表之外,即可正常渲染。这两种 flow 行为都与本次替换掉的语法一致;真正新增的只是「本 flow 拥有的多行块」。

另有一项不属于回退的限制:flow 只读取围栏之前的六个字符、不解析原样文本区域,因此未闭合区域内部的围栏(\verb|$$)会在那里闭合块,而内容以文本 \verb 结尾的区域(\verb**\verb*$$)其围栏会被读成该文本的分隔符。两种情况下围栏前的六个字符在局部完全相同,仅凭这段尾部无法区分。

重复的未闭合候选保持线性解析开销:未闭合的候选会在下一行遇到无法闭合的完整 $$ 时放弃,而不是把每个候选都扫到 EOF——否则 9.6 KB 的 $$a$b 行会花约 4 秒而不是约 16 毫秒。公式自身源码层面的 KaTeX 失败,例如 \arccot,仍按设计报错。

Testing

packages/client/ui-primitives/tests/markdown.client.spec.tsx 新增多行用例——开围栏行内容由内容行行尾 $$ 闭合、由独占行 $$ 闭合、出现在段落之后、出现在列表项内,以及列表项之后的零缩进围栏——该用例去掉改动即失败。同一文件还固定了「使用单个 $ 的多行公式」,它与上面的多行用例一样只有本次改动才会渲染成 display。另有一组用例各自对应一个条件:后面还有块时的尾随文本候选(flow 必须放弃「后面不是行尾」的完整 $$)、单行显示公式内的单个 $(不得因为一个 $ 就放弃)、保持段落完整性的转义美元候选(打断检查不得认领段落)、保持段落完整性的未闭合 \[ 候选(守卫不得作用于反斜杠构造)、以奇数个反斜杠结尾的候选(转义状态必须在换行处结束)、TeX 注释内的闭合围栏、紧邻围栏或只隔空白的 %、已开启注释内的第二个 %、\verb|…| 内的 %、\verb 与 \verb* 之后的分隔符及其空区域、verb 之前的转义反斜杠、不跨行的 verb 尾部、以 verb 结尾的注释、后续行尾随文本围栏(其后正文由上游接管)、注释内失败的围栏,以及带该反斜杠、不带该反斜杠、以 verb 尾部结尾三种重复候选的有界解析开销。

DOM 兼容语料新增 math-multiline-display 及其 .settled.txt、.streaming.txt,同时固定了「列表项之后的零缩进 $$ 不进入列表项内」。apps/web/tests/math-rendering.e2e.ts 在浏览器 lane 渲染多行写法。