Rust in Everything
基于 pulldown-cmark 0.13.5

改写事件流

主线位置:输出主线的前半段,夹在 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 ![holiday in Greece](pearl_beach.jpg).。

几点要看清:

  • 事件拥有自己的字符串。 Event<'a> 里是 CowStr<'a>,map 可以把借用输入的 Borrowed 换成新分配的 Boxed(String 的 into()),生命周期不变。text.replace 对每个 Text 都新建一个 String,不管里面有没有 Peter;在乎分配就先 contains 再换。
  • Start 和 End 一起删。 这里两个都过滤掉了,写出器看到的流仍然平衡。只删一个的后果见 HTML 写出器 的图片一节:raw_text 会一直吃到下一个闭标签。
  • 删标签不删内容。 filter 只拿掉了两个标签事件,它们之间的子事件还在。
❗IMPORTANT
动脑筋:这个例子输出的 HTML 里,还有没有 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    "Pe&#116;er 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 被 &#116;(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 的那段代码没有导入它,原样编译不过(源码地图)。

📝NOTE
冷知识: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 &amp; c 合并出来是带反斜杠、带 &amp; 的源文,交给 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) 的,事件多时先建一张行首偏移表再二分。

📝NOTE
没有蠢问题

问: 我用 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> &amp; 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-filterPeter 换成 John,去掉图片标签map + filter
parser-map-event-print打印每个事件再原样交给 push_html带副作用的 map
parser-map-tag-printOptions::all() 下打印每个 Start 标签的字段同上
events从标准输入读,按嵌套缩进打印事件只消费不输出 HTML
normalize-wikilink把 wikilink 的地址规范成 /wiki/Wiki_Walk/map 改 Tag::Link
footnote-rewriteGitHub 风格的文末脚注加回链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 ![holiday in Greece](pearl_beach.jpg).

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,带引号的名字能注入属性。

💬 关联讨论

发起讨论
加载中...