Rust in Everything
基于 pulldown-cmark 0.13.5

脚注 [^Note] 编号对不上、链接跳不过去

主线位置:跨两条线。块主线的第一遍把脚注定义收进 FootnoteDefs(表格与脚注),行内主线据此决定 [^…] 是不是引用;输出主线的 HtmlWriter 再给引用和定义编号(HTML 写出器)。两边对「同一个脚注」的判断不一样。

flowchart TB
  src["Note 引用 note 定义"] --> fd["FootnoteDefs 折叠大小写"]
  fd --> ev["引用事件 名字 Note"]
  ev --> hw["numbers 按原样"]
  hw --> two["Note 拿 1 note 拿 2"]
  two --> bad["href Note 对不上 id note"]
  ev --> norm["事件流上归一化"]
  norm --> one["都是 note 编号 1"]

实验 crate:exp-pulldown-cmark/pitb-probe 的 footnote-case(0.13.5),pitb-probe134 是同一份代码配 0.13.4,输出逐字节相同。M2 Max、macOS 27、rustc 1.99.0,输出在各自的 out/footnote-case.txt。

现象

开 ENABLE_FOOTNOTES。一处引用首字母大写,一处小写;定义写成小写,后来又补写了一次:

正文[^Note],再看[^note]。

[^note]: 小写的定义。

[^note]: 又写了一遍。

事件流:

0..33 Start(Paragraph)
  0..6 Text(Borrowed("正文"))
  6..13 FootnoteReference(Borrowed("Note"))
  13..22 Text(Borrowed(",再看"))
  22..29 FootnoteReference(Borrowed("note"))
  29..32 Text(Borrowed("。"))
0..33 End(Paragraph)
34..63 Start(FootnoteDefinition(Borrowed("note")))
  43..62 Start(Paragraph)
    43..61 Text(Borrowed("小写的定义。"))
  43..62 End(Paragraph)
34..63 End(FootnoteDefinition)
63..91 Start(FootnoteDefinition(Borrowed("note")))
  72..91 Start(Paragraph)
    72..90 Text(Borrowed("又写了一遍。"))
  72..91 End(Paragraph)
63..91 End(FootnoteDefinition)

push_html 的输出:

<p>正文<sup class="footnote-reference"><a href="#Note">1</a></sup>,再看<sup class="footnote-reference"><a href="#note">2</a></sup>。</p>
<div class="footnote-definition" id="note"><sup class="footnote-definition-label">2</sup>
<p>小写的定义。</p>
</div>
<div class="footnote-definition" id="note"><sup class="footnote-definition-label">2</sup>
<p>又写了一遍。</p>
</div>

三处不对:

  • 正文第一个脚注标 1,可页面上没有标号为 1 的脚注。
  • href="#Note",而定义的 id="note"。HTML 的 id 区分大小写,点了不跳。
  • 两个定义都输出,id 都是 note、标号都是 2。#note 只会跳到第一个,第二段内容永远到不了。

没有报错,也没有任何事件提示这些问题。

原因

解析器不分大小写。 脚注定义在第一遍收进 FootnoteDefs,键是 UniCase,和链接引用定义一样做大小写折叠(表格与脚注)。GFM 风格下,[^Note] 能不能成为引用,取决于折叠后查不查得到定义;查到了,就产生 FootnoteReference,事件里的名字保留原文的 Note。

写出器按原样字符串编号。 HtmlWriter 的 numbers 是 HashMap<CowStr<'a>, usize>,引用和定义都用 self.numbers.entry(name).or_insert(len) 取号,href 和 id 都直接写名字:

                FootnoteReference(name) => {
                    let len = self.numbers.len() + 1;
                    self.write("<sup class=\"footnote-reference\"><a href=\"#")?;
                    escape_html(&mut self.writer, &name)?;
                    self.write("\">")?;
                    let number = *self.numbers.entry(name).or_insert(len);

"Note" 先出现拿到 1,"note" 是另一个键,拿到 2。两个 FootnoteDefinition("note") 都查到 2;写出器不收集、不去重,事件来一个就写一个。

重复定义没有「先到先得」。 链接引用定义重复时第一个赢,后面的被吞掉(表格与脚注);脚注定义重复时两个 FootnoteDefinition 事件都在。这条行为在 0.13.4 和 main 上相同:main 的 html.rs 改了导入和新的标签,numbers 仍是 HashMap<CowStr, usize>,脚注两支没有改。

修法

改源文最直接: 引用和定义写成同一种大小写,删掉重复的定义。

处理别人写的 Markdown:在事件流上归一化。 把引用和定义的名字都变成小写;同一个名字的定义只留第一个,后面的连同子事件一起跳过:

fn normalize_footnotes<'a>(events: impl Iterator<Item = Event<'a>>) -> impl Iterator<Item = Event<'a>> {
  let mut seen: HashSet<String> = HashSet::new();
  let mut skip_depth = 0usize;
  events.filter_map(move |ev| {
    if skip_depth > 0 {
      match ev {
        Event::Start(_) => skip_depth += 1,
        Event::End(_) => skip_depth -= 1,
        _ => {}
      }
      return None;
    }
    match ev {
      Event::FootnoteReference(name) => Some(Event::FootnoteReference(name.to_lowercase().into())),
      Event::Start(Tag::FootnoteDefinition(name)) => {
        let key = name.to_lowercase();
        if !seen.insert(key.clone()) {
          skip_depth = 1;
          return None;
        }
        Some(Event::Start(Tag::FootnoteDefinition(key.into())))
      }
      other => Some(other),
    }
  })
}

同一份输入:

<p>正文<sup class="footnote-reference"><a href="#note">1</a></sup>,再看<sup class="footnote-reference"><a href="#note">1</a></sup>。</p>
<div class="footnote-definition" id="note"><sup class="footnote-definition-label">1</sup>
<p>小写的定义。</p>
</div>

两处引用都是 1,指向唯一的 id="note"。跳过重复定义时是整棵子树一起跳,Start 和 End 成对消失,事件流仍然平衡(只删一端的后果见只过滤掉 End 事件,HTML 悄悄坏掉)。

两点限制:

  • to_lowercase 和解析器用的 Unicode 大小写折叠不完全相同,对 ASCII 名字没有差别。要和解析器严格一致,可以用 unicase crate(pulldown-cmark 自己的依赖)的 UniCase 当 HashMap 的键,把第一次见到的写法当规范名。
  • 这里「重复定义留第一个」是本页的选择,与链接引用定义的规则一致;pulldown-cmark 自己没有规定。

怎么检查

在事件流上数一遍:引用的原样名字有没有对应的原样定义,同一个定义名出现了几次。pitb-probe 里的 check_footnotes 对上面的输入报:

check: reference [^Note] has no definition with the same spelling
check: [^note] defined 2 times
check: 2 problem(s)

GFM 风格下,没有任何定义的 [^x] 不会成为引用事件,它本来就是文字;所以只要出现「引用的原样名字找不到同名定义」,就一定是大小写不一致。旧风格(ENABLE_OLD_FOOTNOTES)下没有定义的引用也会产生事件(HTML 写出器),这条检查会把它们一起报出来。

输出端也可以查:同一页 HTML 里 id="…" 是否重复、每个 href="#…" 有没有对应的 id。

小结

  • 解析器按 UniCase 判断脚注引用,事件里保留原样名字;push_html 的 numbers 和 href / id 按原样字符串。[^Note] 配 [^note]: 编成两个号,链接指向不存在的 #Note。
  • 重复的脚注定义全部输出,id 相同、标号相同,第二个永远跳不到;链接引用定义那条「先到先得」不适用于脚注。
  • 修法:源文统一大小写;处理外来内容时在事件流上把名字转小写、只留第一个定义(整棵子树一起跳过)。
  • 检查:遍历事件,找原样名字对不上的引用和出现多次的定义。0.13.4、0.13.5 和 main 的行为相同。

💬 关联讨论

发起讨论
加载中...