Rust in Everything
基于 pulldown-cmark 0.13.5

链接定义写在后面,分段渲染就断了

主线位置:块主线的第一遍。Parser::new_ext 在返回之前跑完整个第一遍,所有链接引用定义都在这时收进 refdefs;第二遍遇到 [文字][标签] 才去查(表格与脚注)。查不到的引用留作文字。

sequenceDiagram
  participant App as 调用方
  participant P as Parser
  participant FP as 第一遍
  App->>P: new_ext 整份文本
  P->>FP: run_first_pass
  FP-->>P: 树和 refdefs
  App->>P: next
  P-->>App: 第一个事件
  Note over App,P: 只给前半段时定义还没收到

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

现象

一份定义写在文末的文档:

按[规范][spec]的定义,先读[第二节]。

(中间还有很多段落。)

[spec]: https://spec.commonmark.org/0.31.2/
[第二节]: #part-2

边收边渲染。 像聊天界面显示模型输出那样,每收到一段就把已有的前缀整个重新解析、渲染一遍。第一段的 HTML 随着前缀变长而变化(左边是前缀的字节数,unresolved 是回调收到的未解析引用):

 51 B  <p>按[规范][spec]的定义,先读[第二节]。</p>  unresolved=["spec", "第二节"]
 86 B  <p>按[规范][spec]的定义,先读[第二节]。</p>  unresolved=["spec", "第二节"]
130 B  <p>按<a href="https://spec.commonmark.org/0.31.2/">规范</a>的定义,先读[第二节]。</p>  unresolved=["第二节"]
151 B  <p>按<a href="https://spec.commonmark.org/0.31.2/">规范</a>的定义,先读<a href="#part-2">第二节</a>。</p>  unresolved=[]

已经显示出去的方括号文字,在定义到达后变成了链接。

分块解析。 为了先显示第一屏,把文档在第二段开头切开,前半块单独交给解析器:

<p>按[规范][spec]的定义,先读[第二节]。</p>  <- chunk 1 alone

这一块里的引用永远不会变成链接。

原因

CommonMark 允许定义写在文档任何位置、任何容器里,一个引用是不是链接,要看完整份文档才知道。pulldown-cmark 的做法是把「看完整份文档」放进构造函数。Parser::new_with_broken_link_callback(new 和 new_ext 都转到这里)的第一行:

        let (mut tree, allocs) = run_first_pass(text, options);

第一遍收齐的定义在 allocs.refdefs 里,之后每个 next 只做当前块的行内解析(按需解析)。两个后果:

  • 输入必须一次给全。 参数是 &'input str,不能喂 Read,也没有「追加文本」的接口。给它一个前缀,它就把前缀当成完整的文档,前缀里没有的定义就是不存在。

  • 第一个事件要等第一遍。 spec.txt(204 815 字节)重复 1、10、100 次,各取 10 次中最快的一次(KB 按 1024 字节算,uptime 负载 4.25):

       200 KB  first event    0.269 ms  all events    0.449 ms
      2000 KB  first event    2.965 ms  all events    4.751 ms
     20001 KB  first event   28.018 ms  all events   46.326 ms
    

    拿到第一个事件的时间和文档长度成正比,约占全部事件的六成。这是设计,不是缺陷:两遍为什么这样分,见为什么是 pull parser。

找不到定义的引用会交给 BrokenLinkCallback(链接与图片),上面 unresolved 就是用它收集的。

修法

能拿到整份文档,就整份解析。 这是唯一保证和规范一致的做法;20 MB 的文档第一个事件也只要 28 ms。

必须分块(先渲染第一屏、按章节懒加载),就先用整份文档建一个解析器拿定义,再把它交给每一块的回调:

let whole = Parser::new(doc);
let defs = whole.reference_definitions();
let cb = |link: BrokenLink<'_>| {
  defs
    .get(&link.reference)
    .map(|d| (d.dest.clone(), d.title.clone().unwrap_or_else(|| "".into())))
};
let mut out = String::new();
html::push_html(&mut out, Parser::new_with_broken_link_callback(head, Options::empty(), Some(cb)));
<p>按<a href="https://spec.commonmark.org/0.31.2/">规范</a>的定义,先读<a href="#part-2">第二节</a>。</p>

RefDefs::get 按 Unicode 大小写折叠查找,和解析器自己查的规则一样。两点代价:Parser::new(doc) 本身就是一次完整的第一遍,分块省下的只是行内解析和渲染;回调给出的链接类型是 ReferenceUnknown、ShortcutUnknown 这类,按 LinkType::Reference 匹配的代码要一并改。

边收边渲染,没有整份文档可用。每次重新解析整个前缀,结果和最终一致,但总代价随长度平方增长;上表的数字可以估算每次重新解析要多久。界面上要接受「方括号文字过一会儿变成链接」,或者用回调收集未解析的引用,在它们所在的段落标记为「未定稿」。

怎么检查

  • 测试输入把定义放在文末、放在引用块和列表项里,断言引用都成了链接。
  • 分块渲染的程序,把每一块的未解析引用(用回调收集)和整份解析时的比一比,多出来的就是被分块弄断的。
  • 流式显示的界面,在最终结果到达后对比一次最后显示的 HTML 和整份渲染的结果,看是否需要整体刷新。

小结

  • 链接引用定义可以写在使用之后、写在任意容器里;Parser::new_ext 在返回前跑完第一遍收齐定义,第一个事件的延迟和文档长度成正比。
  • 只给前缀或分块解析时,定义不在这一块的引用就留作文字,回调会收到它们。
  • 修法:整份解析;必须分块就用整份文档的 reference_definitions() 喂每一块的 BrokenLinkCallback;流式显示要接受早先的输出会变。

💬 关联讨论

发起讨论
加载中...