Rust in Everything
基于 pulldown-cmark 0.13.5

一段文字被切成好几个 Text

主线位置:行内主线的第一步。第一遍扫一行文字时,遇到实体、反斜杠转义、方括号就结束当前文本节点,另起一个节点;这些节点到第二遍各自变成一个 Event::Text,解析器不负责把它们拼回去(事件模型)。

flowchart TB
  line["一行文字"] --> amp["实体 SynthesizeText"]
  line --> esc["反斜杠 另起节点"]
  line --> br["方括号 MaybeLink"]
  amp --> many["几个相邻的 Text"]
  esc --> many
  br --> many
  many --> miss["逐事件匹配漏掉"]
  many --> merge["TextMergeStream"]
  merge --> one["一个 Text"]

实验在 exp-pulldown-cmark/pita-lab,bin p04_split(release),输出存在 out-p04.txt。M2 Max、macOS 27、rustc 1.99.0,pulldown-cmark 0.13.5,Options::empty()。

现象

给文档里的术语加高亮,做法是在每个 Event::Text 里找术语。文档只有一段:

返回 Vec<T>,先检查 a[i],再读 max\_len。这是一个 pull
parser。

四个术语 Vec<T>、a[i]、max_len、pull parser,每个都在正文里出现一次,逐事件找一个都没找到。事件流(带字节范围)是这样的:

0..85 Start(Paragraph)
0..10 Text(Borrowed("返回 Vec"))
10..14 Text(Borrowed("<"))
14..15 Text(Borrowed("T"))
15..19 Text(Borrowed(">"))
19..33 Text(Borrowed(",先检查 a"))
33..34 Text(Borrowed("["))
34..35 Text(Borrowed("i"))
35..36 Text(Borrowed("]"))
36..49 Text(Borrowed(",再读 max"))
50..74 Text(Borrowed("_len。这是一个 pull"))
74..75 SoftBreak
75..84 Text(Borrowed("parser。"))
0..85 End(Paragraph)

第一行源文变成了 10 个 Text。HTML 输出完全正常,只有在事件层面做文字处理的代码才会碰上。

原因

切开文字的是第一遍的 parse_line。它沿着一行只在特殊字节上停下,有三种停法会结束当前文本节点(append_text(begin_text, ix, ...)):

  • &(第 1105 行):scan_entity 认出实体,就插一个 SynthesizeText 节点,内容是解码后的字符。&lt; 成了单独的 Text("<"),范围是源文的 4 个字节。
  • \(第 884 行):后面是 ASCII 标点时,反斜杠之前的文字成一个节点,被转义的字符从下一个节点开头算起,反斜杠本身不在任何范围里(49..50 没人要)。
  • [ 和 ](第 1083 行起):先插 MaybeLinkOpen / MaybeLinkClose 占位,等第二遍判断是不是链接。a[i] 不是链接,两个占位退回成文字,各是一个 Text。

换行不切 Text,它本身是一个 SoftBreak 事件,所以跨行的 pull parser 中间隔着一个非文本事件。

解析器不合并,是因为它的 Text 尽量借用输入(Borrowed)。上面每一段都是源文的一个切片或静态表里的字符;拼起来的字符串在源文里不存在,只能新分配。哪些事件分配、哪些不分配,见 CowStr。

修法

需要「连续的文字」的地方,先套一层 TextMergeStream:

Start(Paragraph)
Text(Boxed("返回 Vec<T>,先检查 a[i],再读 max_len。这是一个 pull"))
SoftBreak
Text(Borrowed("parser。"))
End(Paragraph)

它只合并紧挨着的 Text,SoftBreak 仍然隔开两行。要匹配跨行的词,在合并之前把 SoftBreak 换成文字:

let events = TextMergeStream::new(Parser::new(doc).map(|ev| match ev {
  Event::SoftBreak => Event::Text(" ".into()),
  ev => ev,
}));

三种做法对四个术语的命中数:

Vec<T>       per-event 0  merged 1  softbreak-as-space 1
a[i]         per-event 0  merged 1  softbreak-as-space 1
max_len      per-event 0  merged 1  softbreak-as-space 1
pull parser  per-event 0  merged 0  softbreak-as-space 1

几点要注意:

  • 换成空格是英文的习惯。中文两行之间的软换行,渲染时通常不该出空格,替换成什么要按两侧的字符决定;这只影响匹配,不要把换过的事件流直接拿去渲染。
  • 合并出来的是 Boxed,有一次分配;在本站语料上,套一层 TextMergeStream 让迭代慢了 27%(一次解析分配了什么)。只在需要的那一段管线上用。
  • 还要字节范围(报行号、回写源文)就用 TextMergeWithOffset,它的范围从第一段的起点到最后一段的终点,中间被跳过的字节也算在里面,见改写事件流和偏移切不回原文。
  • 代码段是 Event::Code,不在合并之列;要在代码里也找,单独处理。

怎么检查

数一下事件流里相邻的 Text 对。只要大于 0,任何「在一个 Text 里找子串」的逻辑都可能漏:

fn adjacent_text_pairs<'a>(events: impl Iterator<Item = Event<'a>>) -> usize {
  let mut prev_text = false;
  let mut pairs = 0;
  for ev in events {
    let is_text = matches!(ev, Event::Text(_));
    pairs += (prev_text && is_text) as usize;
    prev_text = is_text;
  }
  pairs
}
adjacent Text pairs: raw 9, merged 0

把它放进测试,输入里带上实体、反斜杠转义和不成链接的方括号,对交给文字处理逻辑的那个迭代器断言结果为 0。

小结

  • 实体、反斜杠转义、没配上的方括号都会在第一遍切开文字;换行不切,它是 SoftBreak。
  • 解析器不合并,是为了让 Text 借用输入、不分配。
  • 修法是 TextMergeStream(要范围就用 TextMergeWithOffset);跨行匹配还要先把 SoftBreak 换成文字,但换过的流不要直接渲染。

💬 关联讨论

发起讨论
加载中...