Rust in Everything
基于 pulldown-cmark 0.13.5

表格里代码段的 | 把单元格切开了

主线位置:块主线。表格是第一遍认出来的,单元格也在第一遍切好,这时行内语法(代码段、强调)还没有解析(表格与脚注)。代码段要等到第二遍才配对(代码段、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。

📝NOTE
没有蠢问题

问: 代码段里不是不处理反斜杠转义吗?`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 规定的行为。修法是单元格里一律写 \|,包括代码段里;表格外的代码段不去反斜杠,复制时要改。
  • 检查:用偏移量比较每行源文的末尾和最后一个单元格的结束位置,多出来的就是被丢掉的内容。

💬 关联讨论

发起讨论
加载中...