Rust in Everything
基于 pulldown-cmark 0.13.5

只过滤掉 End 事件,HTML 悄悄坏掉

主线位置:输出主线。解析器产出的事件流总是平衡的,每个 Start 都有同种的 End(事件模型);在它和写出器之间插适配器改事件时(改写事件流),平衡要由你自己维持。写出器只管照着事件写,不记嵌套(HTML 写出器)。

flowchart TB
  p["Parser 平衡的流"] --> f["filter 只删一端"]
  f --> hw["HtmlWriter run"]
  hw --> a["少了 /a"]
  hw --> b["多出 /a"]
  hw --> c["raw_text 吞到下一个 End"]
  hw --> d["不写文字的标志不复位"]
  f --> chk["check_balance 栈校验"]
  chk --> err["测试里报出第几个事件"]

实验 crate:exp-pulldown-cmark/pitb-probe 的 unbalanced(0.13.5),pitb-probe134 配 0.13.4,输出逐字节相同。M2 Max、macOS 27、rustc 1.99.0,输出在 out/unbalanced.txt。

现象

想把链接去掉、只留文字,写了一个只看一种事件的 filter:

Parser::new_ext(src, opts).filter(|ev| !matches!(ev, Event::End(TagEnd::Link)))

输入是:

看[文档](/doc)和![图](/a.png)。

下一段 *强调*。

原样渲染和三种「只删一端」的结果:

--- untouched
<p>看<a href="/doc">文档</a>和<img src="/a.png" alt="图" />。</p>
<p>下一段 <em>强调</em>。</p>
--- drop End(Link) only
<p>看<a href="/doc">文档和<img src="/a.png" alt="图" />。</p>
<p>下一段 <em>强调</em>。</p>
--- drop Start(Link) only
<p>看文档</a>和<img src="/a.png" alt="图" />。</p>
<p>下一段 <em>强调</em>。</p>
--- drop End(Image) only
<p>看<a href="/doc">文档</a>和<img src="/a.png" alt="图。" />
<p>下一段 <em>强调</em>。</p>
  • 删 End(Link):<a> 没有闭合,浏览器会把后面的内容一直当成链接,直到它自己决定在哪里补上 </a>。
  • 删 Start(Link):多出一个孤零零的 </a>。
  • 删 End(Image):句号进了 alt,第一段的 </p> 没了。

最严重的一种是元数据块。开 ENABLE_YAML_STYLE_METADATA_BLOCKS,删掉 End(MetadataBlock):

=== source "---\ntitle: x\n---\n\n正文第一段。\n\n正文第二段。\n"
--- untouched
<p>正文第一段。</p>
<p>正文第二段。</p>
--- drop End(MetadataBlock) only
<p></p>
<p></p>

后面所有正文都不见了,只剩空标签。每一种都没有报错、没有 panic。

原因

HtmlWriter::run 是一个逐事件分派的循环:Start 交给 start_tag 写开标签,End 交给 end_tag 写闭标签。它没有标签栈,不知道当前开着什么,所以:

  • 少一个 End,就少写一个闭标签;多一个 End,就多写一个。写出器不补也不报。
  • 图片例外:start_tag 写完 src 后调 raw_text,自己往后取事件写进 alt,直到碰到第一个没配对的 End。End(Image) 没了,它就一直吃到 End(Paragraph),把段落的闭合也吃掉(HTML 写出器)。
  • 元数据块:Start(MetadataBlock) 把 in_non_writing_block 置真,End(MetadataBlock) 才清掉;run 里 Text 那一支只在这个标志为假时写字:
                Text(text) => {
                    if !self.in_non_writing_block {
                        escape_html_body_text(&mut self.writer, &text)?;
                        self.end_newline = text.ends_with('\n');
                    }
                }

End 被删掉,标志永远不复位,此后的每个 Text 都被跳过,段落标签照写,里面是空的。

写出器的设计假设输入来自解析器,而解析器保证平衡;这是一个约定,没有任何类型或运行时检查替它把关。Event 是普通枚举,什么样的序列都能构造出来。这一点在 0.13.4 和 0.13.5 上相同。

修法

删标签时 Start 和 End 一起删。 两者在同一个 matches! 里:

fn strip_links<'a>(events: impl Iterator<Item = Event<'a>>) -> impl Iterator<Item = Event<'a>> {
  events.filter(|ev| !matches!(ev, Event::Start(Tag::Link { .. }) | Event::End(TagEnd::Link)))
}
--- strip_links (pairs)
<p>看文档和<img src="/a.png" alt="图" />。</p>
<p>下一段 <em>强调</em>。</p>
check: Ok(())

链接没了,文字留下。要连内容一起去掉(比如删掉整个脚注定义),就在 Start 处开始计深度,遇到 Start 加一、End 减一,回到 0 之前的事件全部丢掉;脚注编号那一页去掉重复定义用的就是这个写法。

改标签时用 Tag::to_end。 把一种标签换成另一种(比如 Emphasis 换成 Strong),Start 和 End 两处都要换。拿不准时,用 Tag::to_end 从你产出的 Tag 推出它应当配的 TagEnd。

怎么检查

在测试里给改写过的事件流套一个栈:Start 时压入 tag.to_end(),End 时弹出并比较,流结束时栈必须为空。

fn check_balance<'a>(events: impl Iterator<Item = Event<'a>>) -> Result<(), String> {
  let mut stack: Vec<TagEnd> = Vec::new();
  for (i, ev) in events.enumerate() {
    match ev {
      Event::Start(tag) => stack.push(tag.to_end()),
      Event::End(end) => match stack.pop() {
        Some(open) if open == end => {}
        Some(open) => return Err(format!("event {i}: End({end:?}) but innermost open is {open:?}")),
        None => return Err(format!("event {i}: End({end:?}) with nothing open")),
      },
      _ => {}
    }
  }
  match stack.pop() {
    Some(open) => Err(format!("end of stream: {open:?} still open")),
    None => Ok(()),
  }
}

pitb-probe 对上面每种改写都跑一遍,grep '^check' out/unbalanced.txt 依次是删 End(Link)、删 Start(Link)、删 End(Image)、删 End(MetadataBlock) 和成对删除:

check: Err("event 9: End(Paragraph) but innermost open is Link")
check: Err("event 3: End(Link) but innermost open is Paragraph")
check: Err("event 9: End(Paragraph) but innermost open is Image")
check: Err("end of stream: MetadataBlock(YamlStyle) still open")
check: Ok(())

用一批有代表性的文档(各种标签都出现过)喂给你的适配器链,断言 check_balance 返回 Ok。它只在测试里跑,不必进生产路径。

小结

  • 解析器产出的事件流是平衡的;HtmlWriter 不记嵌套,平衡被你的适配器打破时,它照写不误,不报错。
  • 只删 End 会留下没闭合的标签,只删 Start 会多出闭标签;删 End(Image) 让 raw_text 把后面的正文吞进 alt;删 End(MetadataBlock) 让之后所有文字消失。
  • 修法:Start 和 End 在同一个条件里成对增删;连内容一起删就按深度跳过整棵子树;换标签用 Tag::to_end 配对。
  • 检查:测试里用一个 TagEnd 栈校验改写后的流,报出第一个不配对的位置。

💬 关联讨论

发起讨论
加载中...