一段文字被切成好几个 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节点,内容是解码后的字符。<成了单独的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换成文字,但换过的流不要直接渲染。