表格里代码段的 | 把单元格切开了
主线位置:块主线。表格是第一遍认出来的,单元格也在第一遍切好,这时行内语法(代码段、强调)还没有解析(表格与脚注)。代码段要等到第二遍才配对(代码段、HTML、实体),那时竖线早已把它切成了两半。
flowchart TB row["一行表格源文"] --> fp["第一遍 parse_line"] fp --> cut["遇到未转义竖线就切"] cut --> cells["单元格比表头多"] cells --> drop["多出的断开 静默丢弃"] cut --> bt["反引号各在一格"] bt --> txt["配不上 当文字"] fix["写成反斜杠竖线"] --> keep["当文字 代码段完整"]
实验 crate:exp-pulldown-cmark/pitb-probe 的 table-pipe(0.13.5),pitb-probe134 是同一份代码配 0.13.4,输出逐字节相同。M2 Max、macOS 27、rustc 1.99.0,输出在 out/table-pipe.txt。
现象
开 ENABLE_TABLES,写一张讲运算符的表:
| 写法 | 说明 |
| --- | --- |
| `a || b` | 逻辑或 |
| `|x| x + 1` | 闭包 |
push_html 的输出:
<table><thead><tr><th>写法</th><th>说明</th></tr></thead><tbody>
<tr><td>`a</td><td></td></tr>
<tr><td>`</td><td>x</td></tr>
</tbody></table>
两行的「说明」列都没了,代码段变成了带反引号的文字。事件流里看得出发生了什么:
34..59 Start(TableRow)
35..39 Start(TableCell)
36..37 Text(Borrowed("`"))
37..38 Text(Borrowed("a"))
35..39 End(TableCell)
40..40 Start(TableCell)
40..40 End(TableCell)
34..59 End(TableRow)
59..84 Start(TableRow)
60..62 Start(TableCell)
61..62 Text(Borrowed("`"))
60..62 End(TableCell)
63..64 Start(TableCell)
63..64 Text(Borrowed("x"))
63..64 End(TableCell)
59..84 End(TableRow)
第三行 `a || b` 被两个竖线切成 `a、空串、b`、逻辑或 四格;第四行同理。表头只有两列,每行只留前两格,b、逻辑或、x + 1、闭包 都不在任何事件里。没有警告。
原因
单元格是第一遍切的。FirstPass::parse_line 扫一行时,遇到 | 走到这一支:
b'|' => {
if ix != 0 && bytes[ix - 1] == b'\\' {
LoopInstruction::ContinueAndSkip(0)
} else if let TableParseMode::Active = mode {
LoopInstruction::BreakAtWith(ix, None)
} else {
last_pipe_ix = ix;
pipes += 1;
LoopInstruction::ContinueAndSkip(0)
}
}
在表格里(TableParseMode::Active),只要前一个字节不是反斜杠,竖线就结束当前单元格。这时反引号只是被记成 MaybeCode 占位,还不知道它是不是代码段的开头,所以挡不住竖线。等第二遍在每个单元格里配反引号,两个反引号分在两格,各自配不上,只能当文字。
parse_table_row_inner 再按表头的列数处理:少了补空单元格,多了照样解析进树,然后把第 N 个单元格的 next 设成 None,后面的单元格从树上断开(表格与脚注)。这就是内容「消失」的地方。
这不是 pulldown-cmark 的 bug,而是 GFM 规定的顺序。仓库里的 third_party/GitHub/gfm_table.txt 写的是「Include a pipe in a cell's content by escaping it, including inside other inline spans」,例子正是 b `\|` az 输出 b <code>|</code> az。pulldown-cmark 自己的 specs/table.txt 也说表格对反斜杠的处理「as-if they were parsed in a completely separate pass」。
修法
在单元格里,所有要当内容的 | 都写成 \|,代码段里也一样:
| 写法 | 说明 |
| --- | --- |
| `a \|\| b` | 逻辑或 |
| `\|x\| x + 1` | 闭包 |
<table><thead><tr><th>写法</th><th>说明</th></tr></thead><tbody>
<tr><td><code>a || b</code></td><td>逻辑或</td></tr>
<tr><td><code>|x| x + 1</code></td><td>闭包</td></tr>
</tbody></table>
代码段里的反斜杠被去掉了,显示的是 a || b。
问: 代码段里不是不处理反斜杠转义吗?`a \|\| b` 为什么显示成 a || b?
答: 只在表格里是这样。去掉 \| 的反斜杠是表格自己的一层规则,比代码段先生效:第一遍切单元格时看到 \| 就跳过、不在那里切,第二遍 make_code_span 在表格里再把 \| 还原成 |。同样一段放在表格外面,反斜杠原样保留:
=== escaped-outside-table: "`a \\|\\| b`\n"
--- html
<p><code>a \|\| b</code></p>
内容是程序生成的(比如把 API 文档转成表格),在写进单元格之前把 | 替换成 \|。也可以干脆不用表格放这类代码,改用列表或 HTML 表格。
怎么检查
丢掉的单元格还在源文里,只是没有事件。用 into_offset_iter() 拿到每一行(TableHead / TableRow)的字节范围和最后一个单元格的结束位置;最后一个单元格之后如果还有竖线和空白以外的东西,这一行就丢了内容。pitb-probe 里的 check_table_rows:
Event::End(TagEnd::TableRow) => {
if let Some(row) = row.take() {
let rest = src[last_cell_end.min(row.end)..row.end]
.trim_matches(|c: char| c == '|' || c.is_whitespace());
if !rest.is_empty() {
let line = src[..row.start].matches('\n').count() + 1;
println!("check: line {line}: kept {cells} of {head_cells} cells, dropped {rest:?}");
problems += 1;
}
}
}
对原来的表:
check: line 3: kept 2 of 2 cells, dropped "b` | 逻辑或"
check: line 4: kept 2 of 2 cells, dropped "x + 1` | 闭包"
check: 2 row(s) lost cells
对改好的表是 check: 0 row(s) lost cells。这条检查同样能抓到「一行写了比表头多的列」这类与代码段无关的情况。
小结
- 表格单元格在第一遍按未转义的
|切开,比代码段配对早;`a || b`这样的代码会把一行切成更多格。 - 超过表头列数的单元格从树上断开,内容不出现在任何事件里,没有警告;留下来的格子里是带反引号的文字。
- 这是 GFM 规定的行为。修法是单元格里一律写
\|,包括代码段里;表格外的代码段不去反斜杠,复制时要改。 - 检查:用偏移量比较每行源文的末尾和最后一个单元格的结束位置,多出来的就是被丢掉的内容。