只过滤掉 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)和。
下一段 *强调*。
原样渲染和三种「只删一端」的结果:
--- 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栈校验改写后的流,报出第一个不配对的位置。