数学与智能标点
主线位置:行内主线上两个可选的分支。
ENABLE_MATH打开后,第一遍把$记成带花括号上下文的MaybeMath,第一趟和代码段一起按出现顺序配对,产出InlineMath/DisplayMath。ENABLE_SMART_PUNCTUATION打开后,连字符和省略号在第一遍直接换成字符,引号记成MaybeSmartQuote,在强调那一趟里配对。例子文档两样都没用到。
flowchart TB dollar["美元符"] --> mm["MaybeMath 带上下文"] brace["花括号"] --> ctx["上下文栈"] ctx --> mm mm --> p1["pass1 同上下文配对"] p1 --> math["InlineMath 或 DisplayMath"] dash["连字符 省略号"] --> syn["第一遍直接换字符"] quote["引号"] --> sq["MaybeSmartQuote"] sq --> p2["强调趟配对"] p2 --> curly["弯引号"]
实验平台同按需解析。inline-probe 的选项字母 M 是 ENABLE_MATH,Q 是 ENABLE_SMART_PUNCTUATION;inline-cmp 在 SMART=1 时给 pulldown-cmark 和 comrak 都开智能标点(markdown-rs 没有这个选项)。
数学:$ 能开还是能闭
扩展的规则写在 specs/math.txt,开头说语法参照 commonmark-hs 的 math 扩展。第一遍 parse_line 的 $ 分支(第 958 行起)只看前后各一个字节:
b'$' => {
let byte_suffix = &bytes[ix..];
let can_open = !byte_suffix[1..]
.first()
.copied()
.map_or(true, is_ascii_whitespace);
let can_close = ix > start
&& !bytes[..ix]
.last()
.copied()
.map_or(true, is_ascii_whitespace);
后面不是空白就能开,前面不是空白就能闭,没有标点、没有词内的讲究。$$ 是两个相邻的 MaybeMath 节点,第一趟看到开头后面紧跟着另一个 MaybeMath 就当作行间公式。内容原样交出,不解析转义、强调和链接;行内公式不能为空,行间公式可以;跨行时内容要拼(跳过容器前缀),是 Boxed。
这套规则对货币写法很敏感:
--- =$5 and $10
0..1 Text(Borrowed("$"))
1..7 Text(Borrowed("5 and "))
7..8 Text(Borrowed("$"))
8..10 Text(Borrowed("10"))
--- =$5/$10
0..4 InlineMath(Borrowed("5/"))
4..6 Text(Borrowed("10"))
--- =原价$5,现价$3
0..6 Text(Borrowed("原价"))
6..18 InlineMath(Borrowed("5,现价"))
18..19 Text(Borrowed("3"))
--- =原价 $5,现价 $3
0..7 Text(Borrowed("原价 "))
7..8 Text(Borrowed("$"))
8..19 Text(Borrowed("5,现价 "))
19..20 Text(Borrowed("$"))
20..21 Text(Borrowed("3"))
(inline-probe events "=..." M,略去段落事件。)$5/$10 的第二个 $ 前面是 /,能闭。英文习惯在 $ 前面留空格,第二个 $ 前是空白就闭不上;中文不加空格,现价$3 的 $ 前面是汉字,能闭,于是两个价格之间的文字成了公式。规避办法是转义成 \$,或者在 $ 前面加空格。本站开着 ENABLE_MATH;用本站的选项跑内容仓库全部 1139 个文件的正文,InlineMath 和 DisplayMath 一个也没有(inline-probe corpus_math)。
花括号上下文
$\text{a $b$ c}$ 里有四个 $,中间两个也满足能开能闭的条件。按「从左到右、第一个能闭的就闭」,会得到两段错误的公式。规范的要求是公式里的花括号必须配平(「Math expressions must be nested within balanced curly braces」),所以 $ 只能和同一层花括号里的 $ 配对。
第一遍为此维护一个花括号上下文栈 brace_context_stack: Vec<u8>,每个块开始时清空。第一个 $ 把根上下文 !0(255)压进去;之后每个 { 压一个新编号,} 弹出;$ 记下栈顶的编号,存进 MaybeMath 的第三个字段。第一趟只配同一编号的 $:
--- =$\text{a $b$ c}$
Item { start: 0, end: 16, body: Paragraph }
Item { start: 0, end: 1, body: MaybeMath(true, false, 255) }
Item { start: 1, end: 9, body: Text { backslash_escaped: false } }
Item { start: 9, end: 10, body: MaybeMath(true, false, 0) }
Item { start: 10, end: 11, body: Text { backslash_escaped: false } }
Item { start: 11, end: 12, body: MaybeMath(false, true, 0) }
Item { start: 12, end: 15, body: Text { backslash_escaped: false } }
Item { start: 15, end: 16, body: MaybeMath(false, true, 255) }
0..16 InlineMath(Borrowed("\\text{a $b$ c}"))
外层两个 $ 在上下文 255,里面两个在上下文 0,结果是一整段公式。在根上下文遇到多余的 } 时,根的编号减一(wrapping_sub(1)):右括号两边从此是不同的上下文,跨过它的 $ 配不上。规范的例子 $}$2+2$ 里,第一个 $ 在 255,后两个在 254,所以公式是 2+2,前面的 $} 留作文本。反斜杠转义的花括号 \{ 不计。
编号是 u8,层数也有上限:
// Each level of brace nesting adds another entry to a hash table.
// To limit the amount of memory consumed, do not create a new brace
// context beyond some amount deep.
// ...
const MATH_BRACE_CONTEXT_MAX_NESTING: usize = 25;
(MATH_BRACE_CONTEXT_MAX_NESTING。)超过 25 层后不再压栈,只用 brace_context_next 计数,} 时减回来;不同的花括号组超过 255 个时编号回绕。这两处会把本来配不上的 $ 认成公式,specs/math.txt 的「Implementation limits」一节把两种情况都写成了测试用例。
第一趟的 MaybeMath 分支(第 471 行起)和代码段一样,第一次没找到配对时把沿途所有 $ 记进 MathDelims,之后查表。这张表的键是花括号上下文,值是(位置、能否闭、是否行间)的队列;find 遇到一个暂时用不上的位置时,把它塞回队头当「墓碑」留给下一个开头,不往后多找。make_math_span 再判断是不是行间公式、去掉 $、拼出内容。
代码段和公式在同一趟、按出现顺序处理,所以谁先开始谁赢:$`x`$ 是内容为 `x` 的公式,`$x$` 是内容为 $x$ 的代码。
这些上限来自 2023 年的 047fdb8「Set "reasonable" limits for brace nesting」,在数学扩展的 #734 里,首次发布于 0.11.0(这个 PR 就是数学扩展本身,95b6a29「Yet another Math implementation」)。提交说明:「The essential problem is: every time you write $$x$}, you get another entry added to a hash table. Even if it's not [theoretically] quadratic, it's still slow. Hard limiting it to 255 entries makes this not a problem.」每个 $$x$} 里的 } 都让根上下文换一个编号,MathDelims 就多一个键。提交里用命令行工具解析 $$x$} 重复 5000 次的输入,改前总耗时 2.673 秒,改后 0.109 秒。
同一段说明里还有一句关于代码段的观察:「when I tried to write an analogous torture test for code spans, I couldn't find a way to do it because code spans are keyed by their length instead of their position」,要让 CodeDelims 有 N 个键,就得写 N 种不同长度的反引号串,输入本身就是平方大小(代码段、HTML、实体)。
0.13.5 上同一个输入的耗时(inline-probe patho math,各 3 次取最小):
math pcm n= 10000 bytes= 50000 events= 30002 min_ms= 2.029 find_match_scanned=0 code_scan_steps=0 math_scan_steps=49998
math pcm n= 40000 bytes= 200000 events= 120002 min_ms= 8.389 find_match_scanned=0 code_scan_steps=0 math_scan_steps=199998
math pcm n= 160000 bytes= 800000 events= 480002 min_ms= 33.284 find_match_scanned=0 code_scan_steps=0 math_scan_steps=799998
智能标点:第一遍能定的
ENABLE_SMART_PUNCTUATION(首次出现在 0.8.0)把 .、-、"、' 加进第一遍要看的字节。规则来自 CommonMark 仓库的 smart_punct.txt(third_party/CommonMark/)。
...换成SynthesizeChar('…')(第 1130 行起)。- 连字符(第 1145 行起):两个是 en dash
–,三个是 em dash—,更多时按长度模 6 拆成若干 em dash 加若干 en dash,拼成一个String(这是第一遍里会分配的少数几处之一):
let (ems, ens) = match count % 6 {
0 | 3 => (count / 3, 0),
2 | 4 => (0, count / 2),
1 => (count / 3 - 1, 2),
_ => (count / 3, 1),
};
这两样不需要配对,只看连续的几个字节,所以在第一遍就定了。
------- 输出几个 em dash、几个 en dash?按上面的 match 算一下,再想想规范的文字说明会怎么说。答案在文末。
智能标点:引号
引号要配对,第一遍只记成 MaybeSmartQuote(c, can_open, can_close),能开能闭用的是强调同一套 delim_run_can_open / delim_run_can_close(长度按 1 算,强调与分隔符栈),外加一条:' 前面是 ] 或 ) 时不能开,所以 [a]'s 里是撇号。
配对在 handle_emphasis_and_hard_break 里(第 1113 行起),但没有用分隔符栈:
ItemBody::MaybeSmartQuote(c, can_open, can_close) => {
self.tree[cur_ix].item.body = match c {
b'\'' => {
if let (Some(open_ix), true) = (single_quote_open, can_close) {
self.tree[open_ix].item.body = ItemBody::SynthesizeChar('‘');
single_quote_open = None;
} else if can_open {
single_quote_open = Some(cur_ix);
}
ItemBody::SynthesizeChar('’')
}
_ /* double quote */ => {
if can_close && double_quote_open {
double_quote_open = false;
ItemBody::SynthesizeChar('”')
} else {
if can_open && !double_quote_open {
double_quote_open = true;
}
ItemBody::SynthesizeChar('“')
}
}
};
- 单引号先一律写成
’(撇号和右单引号是同一个字符),只记住最近一个能开的位置;之后有能闭的'时,回头把那个位置改成‘。没配上的'就是撇号,这正是规范说的「A single quote that isn't an open quote matched with a close quote will be treated as an apostrophe」。 - 双引号只有一个
bool:没打开时一律写“,打开了且能闭时写”。规范的「An unmatched double quote will be interpreted as a left double quote」就是这样来的。
smart_punct.txt 开头说引号的配对「The same method is used for matching openers and closers as is used in emphasis parsing」,而这里的状态只有一个位置和一个 bool,嵌套的同种引号认不出来。和 comrak 对照(SMART=1 inline-cmp):
pulldown: <p>“a “b” c“</p>
comrak: <p>“a “b” c”</p>
mdrs: <p>"a "b" c"</p>
pulldown: <p>“a ‘b’ c”</p>
comrak: <p>“a ‘b’ c”</p>
mdrs: <p>"a 'b' c"</p>
pulldown: <p>“已有”和“中文“引号</p>
comrak: <p>“已有”和”中文”引号</p>
mdrs: <p>“已有”和"中文"引号</p>
(输入依次是 "a "b" c"、"a 'b' c"、“已有”和"中文"引号。)双引号套双引号时,pulldown-cmark 最后一个引号是左引号;单双交替的常见写法两边一致。第三行是中文的情形:"中文" 两边都是汉字,按左右侧规则这两个 " 都能开、都不能闭,pulldown-cmark 把两个都写成 “,comrak 把两个都写成 ”,都不对。中文正文里直接写全角弯引号最稳妥。本站没有开智能标点。
动脑筋答案
1 个 em dash 加 2 个 en dash。inline-probe html "=a-------b" Q(循环 4 到 12 个连字符):
4 dashes: <p>a––b</p>
5 dashes: <p>a—–b</p>
6 dashes: <p>a——b</p>
7 dashes: <p>a—––b</p>
8 dashes: <p>a––––b</p>
9 dashes: <p>a———b</p>
10 dashes: <p>a–––––b</p>
11 dashes: <p>a———–b</p>
12 dashes: <p>a————b</p>
7 模 6 是 1,走 1 => (count / 3 - 1, 2),即 (1, 2),3 + 2 × 2 = 7 个连字符。smart_punct.txt 的文字说明写的是「so, 7 hyphens = 2 em dashes an 1 en dash」,可 2 × 3 + 1 × 2 = 8,算不出 7;紧跟着的例子里 seven------- 的期望输出是 seven—––,和 pulldown-cmark 一致。规则的原意是「能全用一种就全用一种,否则 em dash 在前、en dash 尽量少」:7 不是 2 和 3 的倍数,en dash 至少要 2 个。
小结
$能开只看后一个字节、能闭只看前一个字节是不是 ASCII 空白;中文里不留空格的两个价格会被认成公式。本站开着ENABLE_MATH,目前正文里没有任何公式事件。- 花括号上下文保证
$只和同一层花括号里的$配对;25 层以上改为计数,编号是u8会回绕。MathDelims按上下文分队列,线性。 - 公式和代码段在同一趟按出现顺序处理,先开始的赢。
- 智能标点的
...和连字符在第一遍直接替换,4 个以上的连字符要分配一个String;引号在强调那一趟用一个位置和一个bool配对,嵌套的同种引号和夹在汉字中间的引号处理不了。 smart_punct.txt关于 7 个连字符的文字说明和它自己的例子矛盾,代码跟的是例子。