链接与图片
主线位置:行内主线第一趟。
handle_inline_pass1遇到[、![就压进LinkStack,遇到]就弹出栈顶,依次试内联链接(...)、完整引用[label]、折叠引用[]、快捷引用;引用类的去第一遍收好的refdefs里查,查不到再问BrokenLinkCallback。例子文档里的[CommonMark 规范][spec]就在这里变成Link { link_type: Reference, .. }。
flowchart TB
open["左方括号"] --> push["压进 LinkStack"]
close["右方括号"] --> pop["弹出栈顶"]
pop --> dis{"已被禁用"}
dis -->|是| txt["留作文本"]
dis -->|否| inl{"后面是圆括号"}
inl -->|是| mk["Link 或 Image"]
inl -->|否| ref{"引用 折叠 快捷"}
ref --> look["查 refdefs"]
look -->|找到| mk
look -->|没找到| cb["BrokenLinkCallback"]
cb -->|给了地址| mk
cb -->|None| txt
mk --> off["禁用外层链接"]
实验平台同按需解析:inline-probe(带计数器的本地拷贝)打印事件;inline-run135 / inline-run134 用 crates.io 上的原版做分配计数和版本对照;inline-cmp 把同一份输入交给 pulldown-cmark 0.13.5、comrak 0.59、markdown-rs 1.0(默认设置)。
链接栈
第一遍把 [ 记成 MaybeLinkOpen,![ 记成 MaybeImage,] 记成 MaybeLinkClose(true)。第一趟走到它们时(handle_inline_pass1 的 MaybeLinkOpen 一支从第 603 行起),先把节点改成 Text,再把下标压进 LinkStack。先改成文本是为了省事:配不上时什么都不用再做,[ 本来就是文本;配上了再把开括号节点改写成 Link 或 Image。
] 弹出栈顶,对应规范附录里的「look for link or image」:「Starting at the top of the delimiter stack, we look backwards through the stack for an opening [ or ![ delimiter」。然后:
- 栈顶是
Disabled(规范叫 inactive):留作文本。CommonMark 规定链接不能嵌套链接,一个链接成立之后,LinkStack::disable_all_links把栈里所有类型为Link的开括号改成Disabled,Image不动。它记着disabled_ix,下一次只改新压进来的部分,整条链上的禁用总共是线性的。 - 否则依次试内联、完整引用、折叠、快捷,任何一种成立就改写节点:开括号节点变成
Link(LinkIndex)或Image(LinkIndex),child指向括号里的第一个节点,next跳到整个链接之后;地址、标题、标签存进Allocations的links表,节点只存下标。规范的算法在这一步还要对链接里的内容跑一遍强调处理;pulldown-cmark 推迟到next走进链接时再做(按需解析的动脑筋)。
里层的链接先闭合,所以里层赢:
0..17 Start(Paragraph)
0..1 Text(Borrowed("["))
1..3 Text(Borrowed("a "))
3..10 Start(Link { link_type: Inline, dest_url: Borrowed("/u"), title: Borrowed(""), id: Borrowed("") })
4..5 Text(Borrowed("b"))
3..10 End(Link)
10..12 Text(Borrowed(" c"))
12..13 Text(Borrowed("]"))
13..17 Text(Borrowed("(/v)"))
0..17 End(Paragraph)
这是 [a [b](/u) c](/v)。换成  c](/v),外层是图片,不会被禁用,得到一个 Image 包着一个 Link。
四种写法
[a](/u "t") [b][R] [r][] [r]
[r]: /x
0..11 Start(Link { link_type: Inline, dest_url: Borrowed("/u"), title: Boxed("t"), id: Borrowed("") })
12..18 Start(Link { link_type: Reference, dest_url: Borrowed("/x"), title: Borrowed(""), id: Borrowed("R") })
19..22 Start(Link { link_type: Collapsed, dest_url: Borrowed("/x"), title: Borrowed(""), id: Borrowed("r") })
25..28 Start(Link { link_type: Shortcut, dest_url: Borrowed("/x"), title: Borrowed(""), id: Borrowed("r") })
(只留 Start(Link)。)id 是使用处写的标签原文,不是规范化之后的,[b][R] 的 id 是 R。
内联:地址、标题和括号上限
scan_inline_link 从 ] 后面一个字节开始,要求是 (,然后:空白(最多跨一个换行,换行后跳过容器前缀,比如引用块的 > ),地址,空白,可选的标题,空白,)。地址由 scan_link_dest 扫:<...> 形式里可以有空格,裸地址遇到空白或控制字符就停,圆括号必须配平。配平要计数,计数就有上限:
// Allowing arbitrary depth nested parentheses inside link destinations
// can create denial of service vulnerabilities if we're not careful.
// The simplest countermeasure is to limit their depth, which is
// explicitly allowed by the spec as long as the limit is at least 3:
// https://spec.commonmark.org/0.29/#link-destination
pub(crate) const LINK_MAX_NESTED_PARENS: usize = 32;
(LINK_MAX_NESTED_PARENS,2019 年的 cbd3b73「Guard against unbounded paren nesting inside links」加入,#237,首次发布于 0.4.1;链接定义从 8d598a8 起用同一个上限。)scan_link_dest 里的判断是 if nest > max_next { return None; },写在 nest += 1 之前,所以实际允许的层数比常量多一层。[a]( 加 n 个 (、n 个 ) 再加 ),三个库对比(inline-cmp parens n,每行截断):
== parens 32
pulldown: <p><a href="((((((((
comrak: <p><a href="((((((((
mdrs: <p><a href="((((((((
== parens 33
pulldown: <p><a href="((((((((
comrak: <p>[a]((((((((((((((
mdrs: <p>[a]((((((((((((((
== parens 34
pulldown: <p>[a]((((((((((((((
comrak: <p>[a]((((((((((((((
mdrs: <p>[a]((((((((((((((
规范只要求「at least three levels」,33 层不违反规范,只是和常量名差一。
地址交给 unescape 去掉反斜杠转义、解出实体,有改动时分配。标题由 scan_link_title 扫,mark == 1 那一行让借用的分支永远走不到,CowStr 一页讲过原因。这里用计数分配器把第二遍的分配单独数出来(inline-run135 alloc,Parser::new 之后才开始计数):
"[a](/u)\n": second-pass allocs=1 bytes=64
title = Borrowed("")
"[a](/u \"\")\n": second-pass allocs=1 bytes=64
title = Boxed("")
"[a](/u \"t\")\n": second-pass allocs=3 bytes=73
title = Boxed("t")
"[a](/u \"a longer title here\")\n": second-pass allocs=2 bytes=83
title = Boxed("a longer title here")
"[a][r]\n\n[r]: /u \"t\"\n": second-pass allocs=1 bytes=64
title = Borrowed("t")
...
每个链接例子都有的那 1 次 64 字节和标题无关,没有标题的 [a](/u)、引用链接 [a][r] 也有。去掉它,结论比「总是分配」更细一点:
- 内联链接的标题总是
Boxed;非空时分配 1 到 2 次(String第一次push_str,into_boxed_str容量不等于长度时再缩一次,"t"就是 8 字节缩成 1 字节)。 - 空标题
""也是Boxed(""),但空的String和空的Box<str>都不分配。 - 引用链接的标题来自
refdefs,是Borrowed;没有标题是Borrowed("")。
这一行 if mark == 1 从 2019 年 acb5fb6「Get rid of InlineScanner and unescape for link title scanning」(#279,首次发布于 0.5.1)把标题扫描改成按绝对下标工作时就是这样,到 main 也没有变。
引用:完整、折叠、快捷
内联不成立时,scan_reference 看 ] 后面:紧跟 [] 是折叠引用,紧跟 [label] 是完整引用,都不是就当快捷引用,标签取两个方括号之间的原文。完整引用的第二个 ] 在扫描时被改成 MaybeLinkClose(false):
self.tree[reference_close_node].item.body =
ItemBody::MaybeLinkClose(false);
注释说这样做是为了「we won't call the broken link callback twice on one reference」:[foo][bar] 查不到 bar 时,第二个 ] 走到时还会再弹一次栈,false 让它不再被当成快捷引用 [bar] 去查一遍。
标签:在使用的一侧
使用处的标签和定义处的标签走同一个函数 scan_link_label_rest:连续空白(含至多一个换行)折成一个空格,去掉首尾空白,没有改动时借用原文;大小写交给 UniCase。定义一侧的折叠规则和实测见表格与脚注,这里只补一个上限。规范说「A link label can have at most 999 characters inside the square brackets」,代码里的计数器是这样加的:
b => {
only_white_space = false;
ix += 1;
if b & 0b1000_0000 != 0 {
codepoints += 1;
}
}
b & 0b1000_0000 != 0 对 UTF-8 的每个非 ASCII 字节都成立,包括后续字节;ASCII 字母一个都不计。结果是中文按字节算、英文不限长。定义和使用都写同一个标签 n 个字符,三个库对比(inline-cmp ascii_label n / cjk_label n,每行截断):
== ascii_label 999
pulldown: <p><a href="/u">aaaaaaaaaaaaaa
comrak: <p><a href="/u">aaaaaaaaaaaaaa
mdrs: <p><a href="/u">aaaaaaaaaaaaaa
== ascii_label 1000
pulldown: <p><a href="/u">aaaaaaaaaaaaaa
comrak: <p><a href="/u">aaaaaaaaaaaaaa
mdrs: <p>[aaaaaaaaaaaaaaaaaaaaaaaaaa
== ascii_label 1001
pulldown: <p><a href="/u">aaaaaaaaaaaaaa
comrak: <p>[aaaaaaaaaaaaaaaaaaaaaaaaaa
mdrs: <p>[aaaaaaaaaaaaaaaaaaaaaaaaaa
== ascii_label 100000
pulldown: <p><a href="/u">aaaaaaaaaaaaaa
comrak: <p>[aaaaaaaaaaaaaaaaaaaaaaaaaa
mdrs: <p>[aaaaaaaaaaaaaaaaaaaaaaaaaa
== cjk_label 333
pulldown: <p><a href="/u">中中中中
comrak: <p><a href="/u">中中中中
mdrs: <p><a href="/u">中中中中
== cjk_label 334
pulldown: <p>[中中中中中中中中中中中中中中中中
comrak: <p>[中中中中中中中中中中中中中中中中
mdrs: <p>[中中中中中中中中中中中中中中中中
10 万个 ASCII 字母的标签在 pulldown-cmark 里仍然是链接;中文标签 334 个字(1002 字节)就不是了,这一点三个库一致,都按字节算。这个计数写法来自 2019 年的 fb7f57f「Only accept ascii whitespace in link labels as per spec」(首次发布于 0.5.2),之前的版本按 char 逐个计数;main 上的 linklabel.rs 这几行没有改。扫描本身是线性的,没有上限也不会变成平方,影响的只是「什么算链接」。
查定义与 BrokenLinkCallback
引用类的链接都经过 fetch_link_type_url_title:
if self.link_ref_expansion_limit == 0 {
return None;
}
let (link_type, url, title) = self
.allocs
.refdefs
.get(link_label.as_ref())
.map(|matching_def| {
// found a matching definition!
let title = matching_def
.title
.as_ref()
.cloned()
.unwrap_or_else(|| "".into());
let url = matching_def.dest.clone();
(link_type, url, title)
})
.or_else(|| {
match self.broken_link_callback.as_mut() {
Some(callback) => {
// Construct a BrokenLink struct, which will be passed to the callback
let broken_link = BrokenLink {
span,
link_type,
reference: link_label,
};
callback
.handle_broken_link(broken_link)
.map(|(url, title)| (link_type.to_unknown(), url, title))
}
None => None,
}
})?;
// Limit expansion from link references.
// ...
self.link_ref_expansion_limit = self
.link_ref_expansion_limit
.saturating_sub(url.len() + title.len());
- 找到定义就
clone地址和标题。refdefs里大多是Borrowed,克隆不分配。 - 找不到就构造
BrokenLink(源码范围、链接类型、标签原文)交给回调。回调返回Some((url, title))时链接成立,类型换成...Unknown(LinkType::to_unknown),消费方能分辨这个地址是回调给的;返回None时方括号留作文本。 - 回调通过
Parser::new_with_broken_link_callback传入,类型参数F: BrokenLinkCallback<'input>;BrokenLinkCallback对所有FnMut(BrokenLink<'input>) -> Option<(CowStr<'input>, CowStr<'input>)>自动实现,默认的DefaultBrokenLinkCallback永远返回None。
一个把所有未定义的引用指向 /wiki/<小写标签>、但对 baz 返回 None 的回调(inline-run135 broken,输入是 [Foo] [bar][Missing Label] [baz][] [x](/u) [[w]],没开 wikilink):
callback: 0..5 Shortcut Borrowed("Foo")
callback: 6..26 Reference Borrowed("Missing Label")
callback: 27..32 Collapsed Borrowed("baz")
callback: 44..47 Shortcut Borrowed("w")
link: ShortcutUnknown Boxed("/wiki/foo") id=Borrowed("Foo")
link: ReferenceUnknown Boxed("/wiki/missing label") id=Borrowed("Missing Label")
link: Inline Borrowed("/u") id=Borrowed("")
link: ShortcutUnknown Boxed("/wiki/w") id=Borrowed("w")
每个引用只问一次;内联链接 [x](/u) 不问;没开 wikilink 时 [[w]] 里的 [w] 是一个快捷引用,回调照样会被问到。回调在 next 里同步执行,走到哪个块才问哪个块里的引用;消费方提前停下,后面的引用就不会被问到。
第一行是一个链接定义,地址是 / 加 199 个 u(200 字节);空一行后是一个段落,[x] 重复 1000 次。没有开任何选项。答案在文末。
link_ref_expansion_limit 在构造 Parser 时设为 text.len().max(100_000),每次查到定义就减去地址和标题的长度,减到 0 之后 fetch_link_type_url_title 直接返回 None,连回调都不调。
预算是 2024 年的 d30d4ef「Limit link def expansion」加的,提交说明只有一行「Fixes #844」。这个提交同时在 Parser 结构体上加了注释,举的例子是一个很长的地址定义一次、引用很多次,输出的 HTML 里每个 <a href> 都带一份完整地址:「This is quadratic growth, because it's filling in the area of a square. To prevent this, track how much it's expanded and limit it.」之前展开量没有限制。同一个 PR 里的 6a37830「Check link expansions against the limit before cloning anything」把检查挪到克隆之前,就是上面函数开头那个 == 0 的判断。两个提交都在 #845,首次发布于 0.10.2。
脚注引用
开了 ENABLE_FOOTNOTES 时,scan_link_label 看到 [^ 就按脚注标签扫,得到 ReferenceLabel::Footnote。] 闭合时如果标签是脚注,节点变成 FootnoteReference(CowIndex),产出 Event::FootnoteReference。GFM 风格的脚注(默认)要求定义存在,否则留作文本;旧风格(ENABLE_OLD_FOOTNOTES)不查。![^1] 里的 ! 不属于脚注,开括号节点被改成 SynthesizeChar('!')。脚注定义的两种风格见表格与脚注。
0.13.5 摘进来的 #1136 修的是脚注的块一侧:段落里每一行开头的 [^ 都要试一下是不是脚注定义(能不能打断段落),0.13.4 的这个探测对剩余的整个输入做 UTF-8 校验,「making UTF-8 validation O(n) per line and O(n^2) overall」。改法是把 &[u8] 参数换成 &str,直接用文档本身。[^x]\n 重复 n 行、开 ENABLE_FOOTNOTES,原版 crate 各 3 次取最小:
== 0.13.4
footdef n= 2000 bytes= 10000 events= 8001 min_ms= 0.579
footdef n= 8000 bytes= 40000 events= 32001 min_ms= 5.975
footdef n= 32000 bytes= 160000 events= 128001 min_ms= 82.495
footdef n= 64000 bytes= 320000 events= 256001 min_ms= 330.577
== 0.13.5
footdef n= 2000 bytes= 10000 events= 8001 min_ms= 0.244
footdef n= 8000 bytes= 40000 events= 32001 min_ms= 0.928
footdef n= 32000 bytes= 160000 events= 128001 min_ms= 3.641
footdef n= 64000 bytes= 320000 events= 256001 min_ms= 7.788
Wikilink
开了 ENABLE_WIKILINKS 时,[[ 除了照常压 link_stack,还压一份进 wikilink_stack;]] 先交给 handle_wikilink。两个方括号之间的原文就是地址,有 | 时前半是地址、后半是显示文本(has_pothole: true)。wikilink 优先于引用链接和内联链接(specs/wikilinks.txt),成立后同样禁用外层的普通链接:
0..7 Start(Link { link_type: WikiLink { has_pothole: false }, dest_url: Borrowed("foo"), title: Borrowed(""), id: Borrowed("") })
2..5 Text(Borrowed("foo"))
0..7 End(Link)
7..8 Text(Borrowed(" "))
8..19 Start(Link { link_type: WikiLink { has_pothole: true }, dest_url: Borrowed("bar"), title: Borrowed(""), id: Borrowed("") })
14..17 Text(Borrowed("baz"))
8..19 End(Link)
19..20 Text(Borrowed(" "))
20..32 Start(Image { link_type: WikiLink { has_pothole: false }, dest_url: Borrowed("img.png"), title: Borrowed(""), id: Borrowed("") })
23..30 Text(Borrowed("img.png"))
20..32 End(Image)
([[foo]] [[bar|baz]] ![[img.png]],略去段落事件。)没有 | 的 wikilink 要新建一个文本节点当孩子(create_node),这是第二遍少数几处追加节点的地方之一。
0.13.5 的 8 个摘取里有两个落在这个函数上。#1102「Fix: wikilink with pipe separator leaks child node's next chain into sibling content」:带 | 的 wikilink 复用原有节点当孩子,却没有截断那个节点的 next,链一直接到 ]] 之后的内容,next 走树时同一段内容既当孩子又当兄弟被访问一遍。PR 里加的测试 wikilink_no_event_blowup 的注释说「Adversarial repeated [[|]] patterns compounded exponentially」。用那个测试的输入重复 n 行,数事件个数:
== 0.13.4
wikiblowup n= 1 bytes= 22 events= 30 ms= 0.017
wikiblowup n= 2 bytes= 44 events= 88 ms= 0.004
wikiblowup n= 4 bytes= 88 events= 436 ms= 0.008
wikiblowup n= 8 bytes= 176 events= 7396 ms= 0.061
wikiblowup n= 16 bytes= 352 events= 1900516 ms= 14.464
== 0.13.5
wikiblowup n= 1 bytes= 22 events= 18 ms= 0.019
wikiblowup n= 2 bytes= 44 events= 35 ms= 0.004
wikiblowup n= 4 bytes= 88 events= 69 ms= 0.002
wikiblowup n= 8 bytes= 176 events= 137 ms= 0.006
wikiblowup n= 16 bytes= 352 events= 273 ms= 0.012
352 字节在 0.13.4 上产出 190 万个事件,0.13.5 上是 273 个,事件数和输入成正比。#1111「Fix subtract-overflow panic in handle_wikilink on malformed input」修的是另一种畸形输入:链接体的起点落在闭合 ]] 之后,end_ix - start_ix 下溢。现在函数开头先判断 if end_ix <= start_ix { return None; }。回归用例(specs/regression.txt 里的 issue 1108)是 ![[] ]()]],在 0.13.4 的 release 构建上:
thread 'main' (7476249) panicked at .../pulldown-cmark-0.13.4/src/parse.rs:911:47:
byte range starts at 9 but ends at 8
release 构建关了溢出检查,减法本身不报错,崩在随后的切片上;0.13.5 正常产出 9 个事件。
动脑筋答案
500 个。inline-probe events 的输出里数 Start(Link:
$ grep -c 'Start(Link' expansion.events.txt
500
最后一个链接和它后面的事件:
2203..2206 Start(Link { link_type: Shortcut, dest_url: Borrowed("/uuuuuuuuuuuuuu...
2204..2205 Text(Borrowed("x"))
2203..2206 End(Link)
2206..2207 Text(Borrowed(" "))
2207..2208 Text(Borrowed("["))
2208..2209 Text(Borrowed("x"))
2209..2210 Text(Borrowed("]"))
文档只有 4208 字节,预算取下限 100 000;每次展开扣 200(地址 200 字节,标题为空),500 次正好扣完。第 501 个 [x] 起,fetch_link_type_url_title 一进来就返回 None,方括号退成文本,没有任何警告,也不会调用 BrokenLinkCallback。
小结
[、![压进LinkStack,]弹出栈顶;链接成立后外层的普通链接全部禁用,图片不禁用,所以里层链接赢、图片里可以有链接。- 依次试内联、完整引用、折叠、快捷;
id是使用处的标签原文,MaybeLinkClose(false)保证一个引用只查一次。 - 三个上限:括号嵌套常量 32 实际放行 33 层;标签的 999 字符只按非 ASCII 字节计,ASCII 标签不限长;引用展开预算是输入长度和 100 000 取大,用完后的引用静默变成文本。
- 内联链接的标题总是
Boxed,非空才分配;引用链接的标题借用refdefs。 - 查不到定义时
BrokenLinkCallback每个引用只被问一次,给出的链接类型带Unknown。 - 0.13.5 摘进来的 #1136 让每行一个
[^的脚注探测变回线性,#1102 和 #1111 修了 wikilink 的事件爆炸和 panic。