表格与脚注
主线位置:块主线里三处不太像「块」的地方。表格藏在段落里:段落的第一行扫完、看到下一行是分隔行,段落节点就地改成表格。脚注定义是
parse_block容器循环里的第一种容器(循环见容器块)。链接引用定义在开段落之前被吃掉,存进Allocations里的refdefs,不进树;第二遍解析行内链接时才去查它(见链接与图片)。
flowchart TB
pb["parse_block"] --> fnq{"中括号脱字符 冒号"}
fnq -->|是| fdef["FootnoteDefinition"]
fnq -->|否| leafq["叶子块判断"]
leafq --> rd{"像引用定义"}
rd -->|是| refdefs["refdefs 先到先得"]
refdefs --> rd
rd -->|否| para["parse_paragraph"]
para --> scan{"首行有竖线"}
scan -->|否| p2["普通段落"]
scan -->|是| head{"下一行是分隔行"}
head -->|列数相等| table["段落改成 Table"]
head -->|否| p2
table --> rows["parse_table 逐行"]
实验环境和打印格式同容器块:exp-pulldown-cmark/blockb-probe,M2 Max、macOS 27、rustc 1.99.0,pulldown-cmark 0.13.5,按 into_offset_iter 打印事件,缩进表示嵌套。
GFM 表格
怎么认出来
表格的规则在 GFM「Tables (extension)」一节(third_party/GitHub/gfm_table.txt):一行表头、一行分隔行(delimiter row)、零或多行数据;表头的单元格数必须和分隔行相同,否则不是表格。
pulldown-cmark 没有单独的「表格开头」判断。parse_paragraph 照常开一个段落,只是扫第一行时把 TableParseMode 设成 Scan(要开 ENABLE_TABLES,而且只有 ix == start_ix 那一行)。parse_line 一边扫一边数没被反斜杠转义的 |,到行尾时:
if mode == TableParseMode::Scan && pipes > 0 {
// check if we may be parsing a table
let next_line_ix = ix + eol_bytes;
let mut line_start = LineStart::new(&bytes[next_line_ix..]);
if scan_containers(&self.tree, &mut line_start, self.options, false)
== self.tree.spine_len()
{
let table_head_ix = next_line_ix + line_start.bytes_scanned();
let (table_head_bytes, alignment) =
scan_table_head(&bytes[table_head_ix..]);
if table_head_bytes > 0 {
// computing header count from number of pipes
let header_count =
count_header_cols(bytes, pipes, start, last_pipe_ix);
// make sure they match the number of columns we find in separator line
if alignment.len() == header_count {
let alignment_ix = self.allocs.allocate_alignment(alignment);
let end_ix = table_head_ix + table_head_bytes;
return LoopInstruction::BreakAtWith(
end_ix,
Some(Item {
start: i,
end: end_ix, // must update later
body: ItemBody::Table(alignment_ix),
}),
);
}
}
}
}
三个条件:下一行在同一组容器里(引用块里的表格,分隔行也要有 >);scan_table_head 认为它是分隔行;列数相等。count_header_cols 从竖线数推列数:行首的 | 不算分隔,行尾 | 后面只剩空白也不算。
scan_table_head 对分隔行的要求:缩进不超过 3 个空格;只能有空格、:、-、|;每一列至少一个 -(连着两个 | 不行);整行至少一个 | 和一个 -。最后这条把 --- 留给分隔线和 Setext 标题。
条件都满足时,parse_line 带着一个 ItemBody::Table 返回,parse_paragraph 把刚开的段落节点改成 Table、清掉已经挂上的文字子节点,交给 parse_table 从表头那一行重新解析。
| a | b | -> 段落,两行文字 没有分隔行
| c | d |
| a | b | -> 段落,两行文字 2 列对 1 列
| --- |
打断段落
GFM 的例子说表格可以打断段落。pulldown-cmark 加了一个限制,写在 scan_paragraph_interrupt 的注释里:只有表头行以 | 开头的「heavy table」才能打断段落。段落的第二行起不再用 Scan 模式扫,所以这是表格出现在段落中间的唯一途径。
Hello / | a | b | / |---|---| -> <p>Hello</p><table>…
Hello / a | b / --|-- -> <p>Hello\na | b\n--|--</p>
GFM 规范里这个例子的表头正好以 | 开头,两种做法在它上面结果相同。
行与单元格
parse_table 先用 parse_table_row_inner 解析表头,把那一行的节点改成 TableHead;然后循环 parse_table_row。每个单元格是一次 parse_line(ix, None, TableParseMode::Active),Active 模式下没转义的 | 和换行都让它停下。
一行的单元格数和表头不一样时,按 GFM 的规则处理:少了就补 start == end 的空 TableCell;多了的照样解析进树,再把第 N 个单元格的 next 设成 None,后面的就从树上断开了。
表格一直延续到下一行不在同一组容器里,或者下一行能打断段落(空行、>、列表、标题等,scan_paragraph_interrupt_no_table)。一行不含 | 的普通文字也会被收成一行,只有第一个单元格有内容,这和 GFM 的例子一致。
例子文档里的表格:
355..439 Start(Table([Left, Right]))
355..372 Start(TableHead)
356..361 Start(TableCell)
357..360 Text(Borrowed("遍"))
356..361 End(TableCell)
...
355..372 End(TableHead)
386..415 Start(TableRow)
387..398 Start(TableCell)
388..397 Text(Borrowed("第一遍"))
387..398 End(TableCell)
399..413 Start(TableCell)
400..412 Code(Borrowed("Tree<Item>"))
399..413 End(TableCell)
386..415 End(TableRow)
...
355..439 End(Table)
TableHead 下面直接是单元格,没有 TableRow;push_html 在 Start(TableHead) 时一并写出 <thead><tr>。分隔行 372..386 只落在 Table 的范围里,不属于任何行或单元格。
转义的竖线与代码段
单元格的切分发生在第一遍,比行内解析早,所以反引号挡不住 |:
} else if bytes[ix + 1] == b'|' && TableParseMode::Active == mode {
// Yeah, it's super weird that backslash escaped pipes in tables aren't "real"
// backslash escapes.
//
// This tree structure is intended for the benefit of inline analysis, and it
// is supposed to operate as-if backslash escaped pipes were stripped out in a
// separate pass.
begin_text = ix + 1;
backslash_escaped = false;
LoopInstruction::ContinueAndSkip(1)
\| 里的反斜杠被丢掉,| 当普通文字;\\| 也是去掉一个反斜杠后再当转义处理。pulldown-cmark 的 specs/table.txt 把这条规则写成:表格对反斜杠的处理「as-if they were parsed in a completely separate pass」,并说应当与 GitHub 相同。GFM「Tables (extension)」也说要在单元格里放 | 就转义它,「including inside other inline spans」。
| 写法 | 结果 |
|:-|--:|
| `a\|b` | r \| s |
| \\| y |
29..49 Start(TableRow)
30..38 Start(TableCell)
31..37 Code(Boxed("a|b"))
30..38 End(TableCell)
39..47 Start(TableCell)
40..42 Text(Borrowed("r "))
43..46 Text(Borrowed("| s"))
39..47 End(TableCell)
29..49 End(TableRow)
49..59 Start(TableRow)
50..57 Start(TableCell)
53..56 Text(Borrowed("| y"))
50..57 End(TableCell)
59..59 Start(TableCell)
59..59 End(TableCell)
49..59 End(TableRow)
0..59 End(Table)
代码段里的 \| 变成了 Code("a|b"),而且是 Boxed:去掉反斜杠后不再是原文的一个切片,只能分配(行内代码段怎么处理表格里的转义见代码段、HTML、实体)。\\| 那一行只得到一个单元格,第二列是补上的空单元格。
| `p|q` | x |,这一行有几个单元格事件、x 去了哪里?ENABLE_TABLES,看事件流。答案在文末。
对齐存在哪
scan_table_head 边扫边记每一列的对齐:: 在开头是 Left,开头结尾都有是 Center,只在结尾是 Right,没有是 None。这个 Vec<Alignment> 不放进树节点,而是由 Allocations::allocate_alignment 推进 alignments: Vec<Vec<Alignment>>,节点里只存下标 ItemBody::Table(AlignmentIndex)。列数也从这里来:parse_paragraph 用 self.allocs[alignment_ix].len() 作为每行该有的单元格数。
第二遍遇到表格节点时,Allocations::take_alignment 用 mem::replace 把整个 Vec 搬出来,变成 Tag::Table(Vec<Alignment>)。单元格事件 Tag::TableCell 不带对齐,消费者要自己记住表格的对齐、自己数第几列,push_html 就是这么做的(table_alignments 加 table_cell_index,见 HTML 写出器)。本站的 mdx.rs 匹配的是 Tag::Table(_),对齐被丢掉了。
补齐单元格的上限
parse_table_row_inner 补空单元格时有一个计数器 missing_empty_cells,整张表累计超过 MAX_AUTOCOMPLETED_CELLS = 1 << 18(262144)就返回 None。
一个 N 列的表头加 N 行只有一个单元格的数据,要补 N × (N - 1) 个空单元格,输入只有 O(N) 字节。7f0ff33「Implement missing cell cutoff to stop quadratic results」加上了这个累计上限,注释指向 issue 832,同一提交在基准里加了 pathological_missing_table_cells,正是 "|x".repeat(i * 100) 的表头配 "|x\n".repeat(i * 100) 的数据。它经 PR #837 合并,首次进入 0.10.0。限的是整张表的累计数,不是每行。
超过上限后的结果不太好看。1000 列的表头配 300 行 |x0| 到 |x299|,再跟一行 after(实验目录 in/bigtable.md)。每行要补 999 个空单元格,前 262 行用掉 261738 个,第 263 行 |x262| 补到 406 个时撞上上限:
5721..5728 Start(TableRow)
...
5721..5728 End(TableRow)
5728..5728 Start(TableRow)
...
5728..5728 End(TableRow)
5728..6000 Start(Paragraph)
5728..5734 Text(Borrowed("|x262|"))
5734..5735 SoftBreak
5735..5741 Text(Borrowed("|x263|"))
...
5728..6000 End(Paragraph)
0..6000 End(Table)
parse_table_row_inner 在 return None 之前没有把已经 push 的行出栈,接着 parse_table 的 self.pop(ix) 弹出的是这一行而不是表格。表格还留在 spine 上,parse_block 从这一行的开头重新解析,开出的段落成了表格的子节点,一直吃到文件末尾。结果是 |x262| 出现两次(那个 407 格的行里一次,段落开头一次),x262 到 x299 的 38 行加上 after 变成表格里的一个段落,push_html 输出 <p>|x262|……after</p></tbody></table>。只有恶意构造的输入会走到这里,但这时 Table 下面直接出现了 Paragraph,按「表格里只有表头和行」写的消费者要当心。
脚注定义
两种风格是一个位
const ENABLE_FOOTNOTES = 1 << 2;
// ...
const ENABLE_OLD_FOOTNOTES = (1 << 9) | (1 << 2);
Options::ENABLE_OLD_FOOTNOTES 同时置了两位,开它就隐含开了 ENABLE_FOOTNOTES。代码里于是有两种判断:contains(ENABLE_FOOTNOTES) 表示「有脚注」,has_gfm_footnotes(有脚注且不是旧风格)表示「新风格」。默认的新风格按 GitHub 来,specs/footnotes.txt 说它的解析「nearly identical」于 GFM;旧风格是 0.10 之前唯一的行为,规则在 specs/old_footnotes.txt。
parse_footnote 在容器循环里第一个被试:
fn parse_footnote(&mut self, start: usize) -> Option<usize> {
let bytes = &self.text.as_bytes()[start..];
if !bytes.starts_with(b"[^") {
return None;
}
let (mut i, label) = if self.options.has_gfm_footnotes() {
// GitHub doesn't allow footnote definition labels to contain line breaks.
// It actually does allow this for link definitions under certain circumstances,
// but for this it's simpler to avoid it.
scan_link_label_rest(&self.text[start + 2..], &|_| None, self.tree.is_in_table())?
} else {
self.parse_refdef_label(start + 2)?
};
// ...
i += 2;
if bytes.get(i) != Some(&b':') {
return None;
}
i += 1;
self.finish_list(start);
if let Some(node_ix) = self.tree.peek_up() {
if let ItemBody::FootnoteDefinition(..) = self.tree[node_ix].item.body {
// finish previous footnote if it's still open
self.pop(start);
}
}
if self.options.has_gfm_footnotes() {
i += scan_whitespace_no_nl(&bytes[i..]);
}
self.allocs
.footdefs
.0
.insert(UniCase::new(label.clone()), FootnoteDef { use_count: 0 });
// ...
}
两种风格的差别散在四处:
新风格(ENABLE_FOOTNOTES) | 旧风格(ENABLE_OLD_FOOTNOTES) | |
|---|---|---|
| 标签 | 不能跨行(&|_| None) | 用引用定义的标签规则,可以跨行 |
[^x]: 之后 | 跳过同一行的空白 | parse_block 跳过整个空行,下一行成为内容 |
| 后续行 | scan_containers 要求 4 列缩进或空行 | 不检查;parse_block 开头遇到「上一行是空行且 spine 顶上是脚注」才关掉 |
没有定义的引用 [^4] | 原样文字 | 照样产生 FootnoteReference |
最后一行发生在第二遍:解析 [^…] 时查 allocs.footdefs,新风格查不到就不产生引用事件(parse.rs 中 !self.options.has_gfm_footnotes() || self.allocs.footdefs.contains(…) 那个条件)。footdefs 的键是 UniCase,和引用定义一样大小写不敏感,因为它也在第一遍收齐,所以引用写在定义前面没有关系。
同一个输入,两种事件流
[^1]: 一
[^2]: 二
[^3]:
第一段
第二段
引用 [^1] [^3] [^4]
新风格(ENABLE_FOOTNOTES):
...
21..58 Start(FootnoteDefinition(Borrowed("3")))
32..42 Start(Paragraph)
32..41 Text(Borrowed("第一段"))
32..42 End(Paragraph)
47..57 Start(Paragraph)
47..56 Text(Borrowed("第二段"))
47..57 End(Paragraph)
21..58 End(FootnoteDefinition)
58..80 Start(Paragraph)
58..65 Text(Borrowed("引用 "))
65..69 FootnoteReference(Borrowed("1"))
69..70 Text(Borrowed(" "))
70..74 FootnoteReference(Borrowed("3"))
74..75 Text(Borrowed(" "))
75..76 Text(Borrowed("["))
76..78 Text(Borrowed("^4"))
78..79 Text(Borrowed("]"))
58..80 End(Paragraph)
旧风格(ENABLE_OLD_FOOTNOTES):
...
21..28 Start(FootnoteDefinition(Borrowed("3")))
21..28 End(FootnoteDefinition)
32..57 Start(CodeBlock(Indented))
32..43 Text(Borrowed("第一段\n\n"))
47..57 Text(Borrowed("第二段\n"))
32..57 End(CodeBlock)
58..80 Start(Paragraph)
58..65 Text(Borrowed("引用 "))
65..69 FootnoteReference(Borrowed("1"))
69..70 Text(Borrowed(" "))
70..74 FootnoteReference(Borrowed("3"))
74..75 Text(Borrowed(" "))
75..79 FootnoteReference(Borrowed("4"))
58..80 End(Paragraph)
前两个定义两种风格完全相同(0..10 和 10..21 两个 FootnoteDefinition),省略了;完整输出在实验目录的 out/foot_new.txt 和 out/foot_old.txt。[^3] 在新风格里是一个两段的脚注,在旧风格里是一个空脚注,后面跟着一个缩进代码块;[^4] 没有定义,新风格是字面文字,旧风格是一个指向不存在的定义的引用。[^1] 和 [^2] 在两种风格里都是两个定义。
ENABLE_OLD_FOOTNOTES 的文档第一句说反了lib.rs 里这个常量的文档举了三个例子,第一个是:「[^1]: In new syntax, this is two footnote definitions.」「[^2]: In old syntax, this is a single footnote definition with two lines.」把这两行原样用旧风格解析,得到的是 FootnoteDefinition("1") 和 FootnoteDefinition("2") 两个定义。这段文档来自 27db86e「Add more docs to ENABLE(_OLD)_FOOTNOTES」,随 PR #654(新增 GFM 风格脚注)进入 0.10.0,描述的是当时旧风格的行为:specs/old_footnotes.txt 那时写着「They do need one line between each other」。八个月后,52ba7d5「Backport footnote handling without needing blank lines between them for old footnote format」让旧风格也在遇到新的 [^x]: 时关掉前一个脚注,经 PR #836 也进了 0.10.0。两次改动在同一个版本里发布,文档没有跟着改,所以这句话在任何一个发布版本里都不成立。另外两个例子(缩进的第二段、没有定义的引用)和上面的实测一致。
旧风格的「空行才关」只看 spine 顶上是不是脚注。脚注里开了列表,空行之后列表先由 finish_list 关掉,那时检查已经过了,脚注就一直开着:
[^1]: a
- 列表
正文
旧风格下 正文 在脚注里面(push_html 输出 <p>正文</p></div>),新风格下 - 列表 没有 4 列缩进,在脚注外面。
链接引用定义
第一遍就收齐
FirstPass::parse_block 把所有叶子块都试过、轮到段落之前,先试引用定义:
// parse refdef
while let Some((bytecount, label, link_def)) =
self.parse_refdef_total(start_ix + line_start.bytes_scanned())
{
self.allocs.refdefs.0.entry(label).or_insert(link_def);
let container_start = start_ix + line_start.bytes_scanned();
let mut ix = container_start + bytecount;
// ...
if let Some(nl) = scan_blank_line(&bytes[ix..]) {
ix += nl;
} else {
self.finish_list(start_ix);
return ix;
}
if let Some(lazy_line_start) = self.scan_next_line_or_lazy_continuation(&bytes[ix..]) {
line_start = lazy_line_start;
start_ix = ix;
} else {
self.finish_list(start_ix);
return ix;
}
}
读出来的几条规则:
- 定义不进树,只进
allocs.refdefs,事件流里不留痕迹。在引用块或列表项里写的定义也进同一张全局表。 entry(label).or_insert(link_def):同名的第二个定义被丢弃,先定义的赢。这对应 CommonMark「Link reference definitions」一节「If there are several matching definitions, the first one takes precedence」。- 只在块的开头试。段落中间的
[x]: /url是段落的一部分:「A link reference definition cannot interrupt a paragraph」(同一节)。 - 一个块开头可以连着好几个定义(
while);定义之后的行按惰性续行处理,源码注释的例子是> [foo]: http://example.com下一行bar,bar留在引用块里,和定义写成段落时一样。
键的类型是 LinkLabel<'a> = UniCase<CowStr<'a>>(linklabel.rs)。parse_refdef_total 交给 scan_link_label_rest 的标签已经把连续空白折叠成一个空格、去掉首尾空白,UniCase 再负责大小写:纯 ASCII 时按 ASCII 比较,否则做 Unicode 大小写折叠。CommonMark「Links」一节规定标签匹配要「perform the Unicode case fold」并折叠空白。公开的 RefDefs::get 也包一层 UniCase,用户查表时同样大小写不敏感。
实测:谁赢、怎么折叠
[Foo] [ẞ] [a b] [q]
[foo]: /first
[FOO]: /second
[SS]: /eszett
[A b]: /ws
段落
[x]: /not-a-def
> [q]: /in-quote
Options::empty(),先打印 parser.reference_definitions()(按键排序),再打印事件(略去文字):
refdef "A b" -> Borrowed("/ws") title=None span=68..78
refdef "SS" -> Borrowed("/eszett") title=None span=54..67
refdef "foo" -> Borrowed("/first") title=None span=25..38
refdef "q" -> Borrowed("/in-quote") title=None span=105..119
0..24 Start(Paragraph)
0..5 Start(Link { link_type: Shortcut, dest_url: Borrowed("/first"), title: Borrowed(""), id: Borrowed("Foo") })
0..5 End(Link)
6..11 Start(Link { link_type: Shortcut, dest_url: Borrowed("/eszett"), title: Borrowed(""), id: Borrowed("ẞ") })
6..11 End(Link)
12..19 Start(Link { link_type: Shortcut, dest_url: Borrowed("/ws"), ...
12..19 End(Link)
20..23 Start(Link { link_type: Shortcut, dest_url: Borrowed("/in-quote"), title: Borrowed(""), id: Borrowed("q") })
20..23 End(Link)
0..24 End(Paragraph)
79..102 Start(Paragraph)
85..86 SoftBreak
79..102 End(Paragraph)
103..120 Start(BlockQuote(None))
103..120 End(BlockQuote(None))
[foo]和[FOO]是同一个键,表里只有/first,[FOO]: /second被丢掉;键保留的是第一次的写法foo。[ẞ](大写 sharp s)找到了[SS]: /eszett:Unicode 完全折叠把ẞ折成ss。这正是 CommonMark「Links」一节「Unicode case fold is used」那条例子。[a b]找到了[A b],空白和大小写都被归一。[x]: /not-a-def跟在段落后面,是段落的第二行,不是定义。- 引用块里的
[q]: /in-quote被收进全局表,引用块本身是空的;用它的[q]写在文档最前面。
commonmark.js 对这份输入输出相同的 HTML。
最后一条决定了 pulldown-cmark 的形状:定义可以写在使用之后、写在任意容器里,第一个段落里的 [q] 要到读完整份文档才知道是不是链接。所以 Parser::new_ext 在返回之前就跑完整个第一遍(一份文档),行内解析才能边走边查。
动脑筋答案
59..73 Start(TableRow)
60..63 Start(TableCell)
61..62 Text(Borrowed("`"))
62..63 Text(Borrowed("p"))
60..63 End(TableCell)
64..67 Start(TableCell)
64..65 Text(Borrowed("q"))
65..66 Text(Borrowed("`"))
64..67 End(TableCell)
59..73 End(TableRow)
0..73 End(Table)
两个单元格,x 没有了。第一遍切单元格时还不知道反引号是代码段,`p 和 q` 各成一格,两个反引号在各自的格子里配不上对,按文字输出。这一行于是有三个单元格,超过表头的两列,第三个 x 已经解析进树,又被 next = None 从树上断开,事件里不出现。HTML 是 <td>`p</td><td>q`</td>。要在代码段里放竖线,写成 `p\|q`。
小结
- 表格是段落的第一行加上一行合格的分隔行:只在段落首行用
TableParseMode::Scan扫竖线,列数要和分隔行相等;只有以|开头的表头能打断段落。 - 单元格在第一遍按未转义的
|切开,比行内解析早,代码段挡不住竖线;\|和\\|都去掉一个反斜杠后当文字。少的单元格补空,多的从树上断开;整张表补齐的空单元格上限 262144(PR #837,0.10.0),超过后剩余内容成了表格里的段落。 - 对齐存在
Allocations::alignments,节点里只存下标,出事件时整个Vec搬进Tag::Table;单元格事件不带对齐。 - 脚注:
ENABLE_OLD_FOOTNOTES包含ENABLE_FOOTNOTES那一位。新风格要求 4 列缩进、未定义的引用是文字;旧风格靠空行关闭、未定义也产生引用。lib.rs文档里「旧风格是一个两行的定义」那句与代码不符。 - 链接引用定义在段落之前被吃掉,进全局的
RefDefs,先定义的赢,键是做过空白折叠和 Unicode 大小写折叠的UniCase;所以整份文档要先过完第一遍。