改写事件流
主线位置:输出主线的前半段,夹在
Parser和写出器之间。push_html接受任何Iterator<Item = Event>,所以解析器和写出器之间可以插任意多层迭代器适配器(为什么是 pull parser)。写出器本身没有选项(HTML 写出器),想改输出就改这里。
flowchart TB p["Parser"] --> mf["map filter"] mf --> fm["flat_map 一变多"] fm --> tm["TextMergeStream"] tm --> buf["缓冲到 End"] buf --> ph["push_html"] p --> off["into_offset_iter"] off --> tmo["TextMergeWithOffset"] tmo --> strip["丢掉范围"] strip --> ph
本页的程序在 exp-pulldown-cmark/output-probe(各种变换)和 exp-pulldown-cmark/output-examples(上游 examples/ 原样复制)里,平台 M2 Max、macOS 27、rustc 1.99.0,pulldown-cmark 0.13.5,输出原样保存在各自的 out/ 目录。utils.rs(TextMergeStream、TextMergeWithOffset)181 行,examples/ 八个文件共 515 行。
事件流就是迭代器
上游的 event-filter 例子把文字里的 Peter 换成 John,再去掉图片:
let parser = Parser::new_ext(markdown_input, Options::empty())
.map(|event| match event {
Event::Text(text) => Event::Text(text.replace("Peter", "John").into()),
_ => event,
})
.filter(|event| match event {
Event::Start(Tag::Image { .. }) | Event::End(TagEnd::Image) => false,
_ => true,
});
// ...
html::write_html_io(&mut handle, parser).unwrap();
输入是 This is Peter on .。
几点要看清:
- 事件拥有自己的字符串。
Event<'a>里是CowStr<'a>,map可以把借用输入的Borrowed换成新分配的Boxed(String的into()),生命周期不变。text.replace对每个Text都新建一个String,不管里面有没有 Peter;在乎分配就先contains再换。 Start和End一起删。 这里两个都过滤掉了,写出器看到的流仍然平衡。只删一个的后果见 HTML 写出器 的图片一节:raw_text会一直吃到下一个闭标签。- 删标签不删内容。
filter只拿掉了两个标签事件,它们之间的子事件还在。
holiday in Greece 这几个字?如果有,在什么标签里?答案在文末。一个事件变几个:flat_map
map 一进一出,filter_map 一进零或一出,要一进多出用 flat_map。给表格套一层可以横向滚动的 div:
let it = Parser::new_ext(src, Options::ENABLE_TABLES).flat_map(|ev| match ev {
Event::Start(Tag::Table(_)) => vec![Event::Html("<div class=\"scroll\">".into()), ev],
Event::End(TagEnd::Table) => vec![ev, Event::Html("</div>\n".into())],
_ => vec![ev],
});
<div class="scroll"><table><thead><tr><th>a</th></tr></thead><tbody>
<tr><td>1</td></tr>
</tbody></table>
</div>
Event::Html 是往输出里插标记的通用办法,写出器原样写出它(转义)。反过来,凡是拼进 Event::Html 的、来自文档的字符串都要自己转义,否则就把转义这道关拆了;下面上游例子一节有一个实例。每个事件一个 Vec 有分配开销,热路径上可以换成 std::iter::once 和 Option 组合,或者自己实现 Iterator。
被切开的文字:TextMergeStream
一段连续的文字可能是好几个 Text 事件:实体、反斜杠转义、没配上的方括号都会切开它(事件模型)。按事件做替换会漏:
src "Peter and Peter\n"
events [Start(Paragraph), Text(Borrowed("Pe")), Text(Inlined(InlineStr { inner: [116, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], len: 1 })), Text(Borrowed("er and Peter")), End(Paragraph)]
plain <p>Peter and John</p>
merged <p>John and John</p>
第一个 Peter 被 t(t)切成三段,逐事件替换只换到了第二个。先套一层 TextMergeStream::new(parser) 再替换,两个都换了。
TextMergeStream 本身只是一层壳:它把事件包成 (event, 0..0) 交给 TextMergeWithOffset,再把范围扔掉。合并逻辑在后者的 next:手里压一个事件,取下一个,两个都是 Text 就把字符串拼进一个新的 String,一直拼到不是 Text 为止。由此:
- 合并出来的总是
CowStr::Boxed,每次合并一次分配;只有一个Text时原样通过,不分配。 - 只合并紧挨着的
Text。Code、SoftBreak、InlineHtml都会打断,跨行的文字仍是两段。 - 合并后全为空的文本整个丢掉;单独一个空
Text不在合并分支里,原样通过。 - 每个事件都要先进「手里压的那一个」再出来,没合并的事件也多搬一次。在本站语料上这让迭代慢 27%,分配只多了 459 次,见一次解析分配了什么。
TextMergeWithOffset 收的是 (Event, Range<usize>) 的迭代器,正好接 into_offset_iter(),合并后的范围从第一个的 start 到最后一个的 end,中间被跳过的源文字节(反斜杠、实体的源文)也算在里面;具体的例子在事件模型。两者都由 lib.rs 的 pub use crate::utils::* 导出到 crate 根。上游 README 里演示 TextMergeStream 的那段代码没有导入它,原样编译不过(源码地图)。
TextMergeWithOffset 以前拼的是源文TextMergeWithOffset 最早是 7224009「feat(utils): text events merger with offsets」(「Fixes: #708」),随 PR #743 进了 0.10.0。那一版自己建解析器:构造函数是 new_ext(source, options),内部是 Peekable<OffsetIter>,遇到连续的 Text 时不拼事件里的字符串,而是 self.source[start..end],直接切源文。这样拿到的是原始的 Markdown:a\*b & c 合并出来是带反斜杠、带 & 的源文,交给 push_html 还会把 & 再转义一次。它也只能接自己建的解析器,不能接在别的适配器后面。edd532b「Rewrite and refactor TextMergeStream/WithOffset」(「Rewrite TextMergeWithOffset to work similarly to the original TextMergeStream. Implement TextMergeStream in terms of this new implementation (with dummy offsets), so that their behavior is the same.」)改成现在的样子:收任意 (Event, Range) 迭代器,拼事件文本,TextMergeStream 反过来用 DummyOffsets 套在它上面。这次重写经 PR #931 合入,首次发布于 0.12.0。0.10 和 0.11 的旧行为是按 tag 上的代码读出来的,实验环境里没有这两个版本,没有实际运行。
带范围:into_offset_iter
into_offset_iter() 让每个事件带上它在源文里的字节范围,范围的含义(End 和 Start 范围相同、实体和转义的范围比文本长)在事件模型里逐项核对过。最直接的用途是报行号,比如给编辑器做「点 HTML 跳到源码」:
for (ev, r) in Parser::new_ext(&doc, opts).into_offset_iter() {
if let Event::Start(Tag::CodeBlock(_) | Tag::Table(_) | Tag::Heading { .. }) = ev {
let line = doc[..r.start].matches('\n').count() + 1;
println!("line {line:>2} bytes {r:?} {ev:?}");
}
}
例子文档(mainline.md)上的输出:
line 1 bytes 0..34 Start(Heading { level: H1, id: None, classes: [], attrs: [] })
line 12 bytes 282..353 Start(CodeBlock(Fenced(Borrowed("rust"))))
line 16 bytes 355..439 Start(Table([Left, Right]))
每次都从头数换行是 O(n) 的,事件多时先建一张行首偏移表再二分。
问: 我用 into_offset_iter() 做完变换,能直接交给 push_html 吗?
答: 不能,元素类型不同,编译器会直接拒绝:
error[E0271]: expected `OffsetIter<'_>` to be an iterator that yields `Event<'_>`, but it yields `(Event<'_>, Range<usize>)`
--> src/main.rs:5:29
|
5 | html::push_html(&mut out, Parser::new("*a*").into_offset_iter());
| --------------- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Event<'_>`, found `(Event<'_>, Range<usize>)`
| |
| required by a bound introduced by this call
|
= note: expected enum `Event<'_>`
found tuple `(Event<'_>, std::ops::Range<usize>)`
note: required by a bound in `push_html`
--> .../pulldown-cmark-0.13.5/src/html.rs:552:17
...
最后一步加 .map(|(ev, _)| ev) 把范围丢掉。写出器用不上范围,所以它的签名不收。
什么时候必须缓冲
迭代器适配器看得到当前事件和自己攒下的状态,看不到后面的事件。凡是「写这个事件之前需要知道后面的内容」的变换,都要先把后面的事件攒起来。
标题锚点。 Start(Heading) 在标题文字之前到达,id 却要由文字算出来(只有开 ENABLE_HEADING_ATTRIBUTES 并写了 {#id} 时解析器才给 id)。只能把整个标题的事件留到 End(Heading),算好再一起放出去:
use pulldown_cmark::{Event, Tag, TagEnd};
// Buffers each heading until its End, derives an id from its text,
// then releases the heading with the id filled in.
fn heading_ids<'a>(
iter: impl Iterator<Item = Event<'a>>,
) -> impl Iterator<Item = Event<'a>> {
let mut buf: Vec<Event<'a>> = Vec::new();
iter.flat_map(move |ev| match ev {
Event::Start(Tag::Heading { .. }) => {
buf.push(ev);
Vec::new()
}
Event::End(TagEnd::Heading(_)) => {
buf.push(ev);
let text: String = buf
.iter()
.filter_map(|e| match e {
Event::Text(t) | Event::Code(t) => Some(t.as_ref()),
_ => None,
})
.collect();
let slug: String = text
.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty())
.map(|w| w.to_lowercase())
.collect::<Vec<_>>()
.join("-");
if let Event::Start(Tag::Heading { id, .. }) = &mut buf[0] {
if id.is_none() {
*id = Some(slug.into());
}
}
std::mem::take(&mut buf)
}
_ if !buf.is_empty() => {
buf.push(ev);
Vec::new()
}
_ => vec![ev],
})
}
例子文档后面再加两个标题(## 第二遍 {#second} 和 ## A `b` & C),开 ENABLE_TABLES | ENABLE_HEADING_ATTRIBUTES:
<h1 id="拉取式解析-pull-parser">拉取式解析:<em>pull</em> parser</h1>
<h2 id="second">第二遍</h2>
<h2 id="a-b-c">A <code>b</code> & C</h2>
缓冲的范围只是一个标题,内存是标题里的事件数乘以 80 字节(Event 的大小见事件模型)。id 由写出器用 escape_html 写出,中文原样保留。两个标题同名时还要去重,那需要一张贯穿全文的表,是状态,不是缓冲。本站的做法见 08 在本站中。
脚注挪到文末。 写出器把脚注定义原地输出(HTML 写出器)。要 GitHub 那样的文末列表加回链,就得把每个定义的全部事件攒起来,正文写完后按引用顺序排好再写,还要等定义结束才知道最后一段在哪、回链插在哪。上游的 footnote-rewrite 例子 167 行,做的就是这件事。
目录。 目录要在正文前面,内容却来自全部标题,只能先过一遍整份文档。事件流是一次性的,要么解析两遍(第一遍只收标题),要么把全部事件 collect 成 Vec 再写。后者按每个 80 字节算,本站语料平均每篇约 660 个事件(转义用的快照:1137 篇共 747,089 个),约 52 KB。
缓冲时要保证放出去的流仍然平衡:攒了 Start 就要在某个时刻放出对应的 End,否则写出器不会报错,只会写出错的 HTML。
上游的八个例子
pulldown-cmark/examples/ 在 tag 上有八个文件,guide/src/examples/ 每页是一两句说明加上对应文件的 include。crates.io 上的 pulldown-cmark-0.13.5 包里带着同样的八个文件(逐个 diff 相同)。把它们原样复制进一个依赖 pulldown-cmark = "=0.13.5"(加 regex 作开发依赖)的 crate,cargo build --examples 没有错误也没有警告,逐个运行退出码都是 0:
| 例子 | 做什么 | 手法 |
|---|---|---|
string-to-string | 开删除线,push_html,assert_eq! 对比输出 | 最小用法 |
broken-link-callbacks | [my website][] 没有定义,回调给出地址和标题 | Parser::new_with_broken_link_callback |
event-filter | Peter 换成 John,去掉图片标签 | map + filter |
parser-map-event-print | 打印每个事件再原样交给 push_html | 带副作用的 map |
parser-map-tag-print | Options::all() 下打印每个 Start 标签的字段 | 同上 |
events | 从标准输入读,按嵌套缩进打印事件 | 只消费不输出 HTML |
normalize-wikilink | 把 wikilink 的地址规范成 /wiki/Wiki_Walk/ | map 改 Tag::Link |
footnote-rewrite | GitHub 风格的文末脚注加回链 | filter_map 加缓冲,两次 write_html_io |
parser-map-tag-print 用的是 Options::all(),里面包括 ENABLE_OLD_FOOTNOTES,所以它演示的是旧风格脚注。normalize-wikilink 需要 regex,是这几个例子里唯一用到开发依赖的。
footnote-rewrite 把脚注名直接 format! 进 Event::Html:
let html = Event::Html(format!(r##"<sup class="footnote-reference" id="fr-{name}-{nr}"><a href="#fn-{name}">[{n}]</a></sup>"##).into());
name 来自文档,没有转义。把例子的输入换成名字里带引号的脚注(x [^a"onmouseover="alert(1)] 加对应的定义,其余不改):
<p>x <sup class="footnote-reference" id="fr-a"onmouseover="alert(1)-1"><a href="#fn-a"onmouseover="alert(1)">[1]</a></sup></p>
<hr><ol class="footnotes-list">
<li id="fn-a"onmouseover="alert(1)">
<p>c <a href="#fr-a"onmouseover="alert(1)-1">↩</a></p>
</li>
</ol>
onmouseover 成了 <a> 和 <li> 的属性。写出器自己输出脚注时用 escape_html 写名字,不会这样;照抄这个例子处理不可信输入时,要先用 pulldown_cmark_escape::escape_html 转义 name。main 上这个文件只改了 use 的写法,问题仍在。
动脑筋答案
output-examples 运行 event-filter 的输出:
Parsing the following markdown string:
This is Peter on .
HTML output:
<p>This is John on holiday in Greece.</p>
有,而且不在任何标签里,成了段落里的普通文字。图片的描述是 Start(Image) 和 End(Image) 之间的一个 Text 事件,filter 只拿掉了两端的标签。写出器正常情况下用 raw_text 把这段文字吃进 alt;标签没了,它就是一个普通的 Text,按正文写出。源码注释写的是「replace … image by its alt text」,guide 的标题却是「Strip all images and replace text」:图片确实去掉了,描述文字留了下来,这正是注释说的效果。
小结
- 事件流是普通迭代器,
map/filter/flat_map都能直接用;Event::Html用来插标记,插进去的文档内容要自己转义。 - 删标签时
Start和End成对删,子事件不会跟着消失。 - 文字会被实体、转义、方括号切成几段;
TextMergeStream合并相邻的Text,每次合并一次分配,语料上整体慢 27%。TextMergeWithOffset接into_offset_iter(),0.12.0 以前它拼的是源文。 into_offset_iter()的元素是(Event, Range),交给push_html前要.map(|(ev, _)| ev)。- 需要「后面的内容」才能写当前事件的变换要缓冲:标题锚点攒一个标题,文末脚注攒全部定义,目录要整份文档。
- 上游八个例子在 0.13.5 上都能编译运行;
footnote-rewrite把脚注名未转义地拼进 HTML,带引号的名字能注入属性。