HTML 写出器
主线位置:输出主线的全部。
Parser交出事件,push_html建一个HtmlWriter,run逐个取事件写成标签;文本和属性值交给pulldown-cmark-escape转义(见转义)。写出器只是事件迭代器的一个消费者,在它前面怎么改写事件流见改写事件流。
flowchart TB ev["事件迭代器"] --> run["HtmlWriter run"] run --> st["start_tag"] run --> et["end_tag"] run --> txt["Text 等转义"] run --> raw["Html 原样"] st --> alt["raw_text 吃掉 alt"] st --> sw["StrWrite"] et --> sw txt --> sw raw --> sw alt --> sw sw --> fw["FmtWriter"] sw --> iw["IoWriter"] fw --> s["String 等"] iw --> io["io Write"]
整个写出器在 html.rs 里,631 行,其中一半是 start_tag 和 end_tag 两个大 match。HtmlWriter 是私有类型,对外只有三个函数:push_html、write_html_io、write_html_fmt。
本页的输出来自实验 crate exp-pulldown-cmark/output-probe(M2 Max、macOS 27、rustc 1.99.0,pulldown-cmark 0.13.5,debug 构建),原样保存在它的 out/ 目录;事件按 into_offset_iter 打印,缩进表示嵌套。
一个循环,八个字段
HtmlWriter 的全部状态:
struct HtmlWriter<'a, I, W> {
/// Iterator supplying events.
iter: I,
/// Writer to write to.
writer: W,
/// Whether or not the last write wrote a newline.
end_newline: bool,
/// Whether if inside a metadata block (text should not be written)
in_non_writing_block: bool,
table_state: TableState,
table_alignments: Vec<Alignment>,
table_cell_index: usize,
numbers: HashMap<CowStr<'a>, usize>,
}
没有栈,没有「当前在哪个块里」。写出器不记嵌套,一个 Start 写开标签、对应的 End 写闭标签,配对完全靠事件流本身是平衡的。三个表格字段和一个脚注表是仅有的跨事件状态,下面分别讲。
run 是一个 while let 加一个 match,Start 交给 start_tag,End 交给 end_tag,其余事件就地处理:
fn run(mut self) -> Result<(), W::Error> {
while let Some(event) = self.iter.next() {
match event {
Start(tag) => {
self.start_tag(tag)?;
}
End(tag) => {
self.end_tag(tag)?;
}
Text(text) => {
if !self.in_non_writing_block {
escape_html_body_text(&mut self.writer, &text)?;
self.end_newline = text.ends_with('\n');
}
}
// ...
Html(html) | InlineHtml(html) => {
self.write(&html)?;
}
// ...
}
}
Ok(())
}
非 Start / End 的事件各写成什么:
| 事件 | 写出 | 转义 |
|---|---|---|
Text | 文本本身;元数据块里不写 | escape_html_body_text |
Code | <code>…</code> | escape_html_body_text |
InlineMath / DisplayMath | <span class="math math-inline">…</span>,math-display 同理 | escape_html,正文里的 " 也变成 " |
Html / InlineHtml | 原样 | 不转义 |
SoftBreak | \n | |
HardBreak | <br />\n | |
Rule | <hr />\n | |
FootnoteReference | <sup class="footnote-reference"><a href="#名字">编号</a></sup> | 名字用 escape_html |
TaskListMarker | <input disabled="" type="checkbox"/>,勾选的多一个 checked="" |
数学是唯一一处正文内容用了属性转义:$$"x"$$ 写成 <span class="math math-display">"x"</span>,而 Text 和 Code 里的 " 原样保留。两者在浏览器里显示相同,只是输出长一点。Html 不转义这一行是整个写出器里唯一的信任边界,单独在转义里讲。
end_newline 只管换行
end_newline 记的是「上一次写出的东西是不是以 \n 结尾」。块级开标签都先看它:是就直接写,不是就先补一个 \n。以段落为例:
Tag::Paragraph => {
if self.end_newline {
self.write("<p>")
} else {
self.write("\n<p>")
}
}
这样不用知道嵌套关系,也能让每个块从新的一行开始。松散列表里 <li> 后面紧跟 Start(Paragraph),<li> 不以换行结尾,于是输出成 <li> 换行 <p>a</p>;紧凑列表里 <li> 后面直接是文字,输出 <li>a</li>。这是写出器对列表松紧唯一的「感知」,而且它其实什么都不知道。
紧凑列表不归写出器管
同样两项,中间有没有空行(- a\n- b\n 和 - a\n\n- b\n):
=== tight
0..8 Start(List(None))
0..4 Start(Item)
2..3 Text(Borrowed("a"))
0..4 End(Item)
...
=== loose
0..9 Start(List(None))
0..5 Start(Item)
2..4 Start(Paragraph)
2..3 Text(Borrowed("a"))
2..4 End(Paragraph)
0..5 End(Item)
...
紧凑列表的事件流里根本没有 Paragraph:解析器在列表出栈时把段落改成 TightParagraph,Parser::next 遇到它不产生事件(见容器块)。html.rs 里没有任何与松紧有关的字段。上游 guide/src/dev/html-generation.md 说写出器要「Managing state for special cases like tables and tight lists」,后半句与代码不符;同一页的示例代码写的 End(EndTag::Paragraph) 也不存在,类型叫 TagEnd。
表格:tbody 在表头结束时补上
事件里有 Table、TableHead、TableRow、TableCell,没有表体。写出器用 table_state 区分自己在表头还是表体,<tbody> 是在 End(TableHead) 时和 </thead> 一起写出的:
Tag::Table(alignments) => {
self.table_alignments = alignments;
self.write("<table>")
}
Tag::TableHead => {
self.table_state = TableState::Head;
self.table_cell_index = 0;
self.write("<thead><tr>")
}
// ...
Tag::TableCell => {
match self.table_state {
TableState::Head => {
self.write("<th")?;
}
TableState::Body => {
self.write("<td")?;
}
}
match self.table_alignments.get(self.table_cell_index) {
Some(&Alignment::Left) => self.write(" style=\"text-align: left\">"),
Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"),
Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"),
_ => self.write(">"),
}
}
TagEnd::Table => {
self.write("</tbody></table>\n")?;
}
TagEnd::TableHead => {
self.write("</tr></thead><tbody>\n")?;
self.table_state = TableState::Body;
}
单元格不带对齐,写出器把 Tag::Table 里的 Vec<Alignment> 存下,table_cell_index 在每个 End(TableCell) 加一、每行开头清零,按列号去取对齐。取用 get 而不是下标,多出来的列只是没有 style。用解析器产出的事件走不到这个分支:多余的单元格在第一遍就被丢掉,缺的被补齐(表格与脚注)。| 1 | 2 | 3 | 和 | 4 | 两行的输出:
<tr><td style="text-align: left">1</td><td style="text-align: right">2</td></tr>
<tr><td style="text-align: left">4</td><td style="text-align: right"></td></tr>
因为 <tbody> 在表头结束时就无条件写了,只有表头的表格也有一个空表体:
=== header only
--- src "| a | b |\n| :- | -: |\n"
...
--- html
<table><thead><tr><th style="text-align: left">a</th><th style="text-align: right">b</th></tr></thead><tbody>
</tbody></table>
GFM 规范的原文是「If there are no rows in the body, no <tbody> is generated in HTML output」。
<tbody></tbody>仓库在 third_party/GitHub/gfm_table.txt 里放了一份 GFM 表格规范,build.rs 从它生成测试。上面那句「no <tbody> is generated」下面的例子,在 tag 里的期望输出是 </thead> 后跟 <tbody></tbody>,文字和例子自相矛盾。原因在测试比较 HTML 的方式。2019 年的 15ff550「Drop empty tbody's during html normalization」让测试用 html5ever 把两边的 HTML 都规范化一遍,规范化时删掉空的 tbody,写出器的输出于是能对上规范。2023 年的 916b0dd「fix: html5ever may hide actual errors」(「Fixes: #702」)认为这种规范化会把真错误也抹平,换成了现在 tests/lib.rs 里只替换 <br>、<hr> 和标签间换行的 html_standardize,同时直接改规范文件,给这个例子加了一行 <tbody></tbody>。这个提交经 PR #704 合入,首次发布于 0.10.0。从那以后,写出器的这个行为由一份改过的规范「保证」。
TableState 在 Start(TableHead) 时重置为 Head,不在 End(Table) 时重置;解析器产出的每张表都有表头,所以不会把上一张表的 Body 状态带进下一张。
脚注:编号按首次出现分配
脚注引用和脚注定义共用一张表 numbers,键是脚注名,值是编号。两处代码几乎一样(引用、定义):
FootnoteReference(name) => {
let len = self.numbers.len() + 1;
self.write("<sup class=\"footnote-reference\"><a href=\"#")?;
escape_html(&mut self.writer, &name)?;
self.write("\">")?;
let number = *self.numbers.entry(name).or_insert(len);
write!(&mut self.writer, "{}", number)?;
self.write("</a></sup>")?;
}
Tag::FootnoteDefinition(name) => {
// ...
escape_html(&mut self.writer, &name)?;
self.write("\"><sup class=\"footnote-definition-label\">")?;
let len = self.numbers.len() + 1;
let number = *self.numbers.entry(name).or_insert(len);
write!(&mut self.writer, "{}", number)?;
self.write("</sup>")
}
一个名字第一次出现(不管是引用还是定义)时拿到「表长加一」,之后再出现就沿用。由此有几个结果:
定义写在哪,就输出在哪。 写出器不收集脚注,不排序,也不去掉没被引用的定义;定义在文档开头,就在开头输出
<div class="footnote-definition">。要做成 GitHub 那样「文末列表 + 回链」,得自己在事件流上缓冲,上游的footnote-rewrite例子就是这么做的(改写事件流)。定义在前,定义先拿号。
[^b]: B、[^a]: A两个定义在前,正文x[^a] y[^b] z[^c]在后:<div class="footnote-definition" id="b"><sup class="footnote-definition-label">1</sup> <p>B</p> </div> <div class="footnote-definition" id="a"><sup class="footnote-definition-label">2</sup> <p>A</p> </div> <p>x<sup class="footnote-reference"><a href="#a">2</a></sup> y<sup class="footnote-reference"><a href="#b">1</a></sup> z[^c]</p>正文里的第一个脚注显示成 2。
[^c]没有定义,GFM 风格下解析器不产生引用事件,它是普通文字。重复定义都输出,id 相同、编号相同。
x[^a]之后跟两个[^a]:,两个FootnoteDefinition("a")事件都在,写出两个id="a"的div,标号都是 1:<p>x<sup class="footnote-reference"><a href="#a">1</a></sup></p> <div class="footnote-definition" id="a"><sup class="footnote-definition-label">1</sup> <p>one</p> </div> <div class="footnote-definition" id="a"><sup class="footnote-definition-label">1</sup> <p>two</p> </div>HTML 里
id应当唯一;按 HTML 规范,#a跳到文档顺序里的第一个,第二个定义永远到不了。链接引用定义是先定义的赢(表格与脚注),脚注定义没有这条规则。旧风格下没有定义的引用也拿号。 开
ENABLE_OLD_FOOTNOTES时x[^c]输出<a href="#c">1</a>,页面上没有id="c"。
numbers 的键是 CowStr,比较的是原样的字符串。解析器那边,脚注定义收在 FootnoteDefs 里,键是 UniCase,一个 [^…] 算不算引用要看大小写折叠后能不能查到定义。两边对「同一个脚注」的定义不一样。
x[^A] 后面空一行接 [^a]: def,开 ENABLE_FOOTNOTES,定义那一行的 footnote-definition-label 里是几?引用的 href 能跳到定义吗?答案在文末。图片:alt 是一段被压平的事件
Tag::Image 的子事件是图片描述,可以有强调、代码、行内 HTML,而 HTML 的 alt 是一个属性值,只能放纯文本。写出器在 start_tag 里写完 src 后调 raw_text,自己把子事件一直取到配对的 End 为止:
// run raw text, consuming end tag
fn raw_text(&mut self) -> Result<(), W::Error> {
let mut nest = 0;
while let Some(event) = self.iter.next() {
match event {
Start(_) => nest += 1,
End(_) => {
if nest == 0 {
break;
}
nest -= 1;
}
Html(_) => {}
InlineHtml(text) | Code(text) | Text(text) => {
// Don't use escape_html_body_text here.
// The output of this function is used in the `alt` attribute.
escape_html(&mut self.writer, &text)?;
self.end_newline = text.ends_with('\n');
}
// ...
SoftBreak | HardBreak | Rule => {
self.write(" ")?;
}
FootnoteReference(name) => {
let len = self.numbers.len() + 1;
let number = *self.numbers.entry(name).or_insert(len);
write!(&mut self.writer, "[{}]", number)?;
}
// ...
}
}
Ok(())
}
下面这个图片的描述跨了两行:

事件和输出:
0..34 Start(Paragraph)
0..33 Start(Image { link_type: Inline, dest_url: Borrowed("/x.png"), title: Boxed("t"), id: Borrowed("") })
2..5 Start(Emphasis)
3..4 Text(Borrowed("a"))
2..5 End(Emphasis)
5..6 Text(Borrowed(" "))
6..9 Code(Borrowed("b"))
9..10 Text(Borrowed(" "))
10..13 InlineHtml(Borrowed("<i>"))
13..14 Text(Borrowed("c"))
14..18 InlineHtml(Borrowed("</i>"))
18..19 SoftBreak
19..20 Text(Borrowed("d"))
0..33 End(Image)
0..34 End(Paragraph)
--- html
<p><img src="/x.png" alt="a b <i>c</i> d" title="t" /></p>
强调的开闭被丢掉,代码只留内容,换行变空格。行内 HTML 在正文里原样输出,在 alt 里却当文本转义,因为它出现在属性值里。End(Image) 被 raw_text 吃掉,所以 end_tag 里那一支只有一句注释 // shouldn't happen, handled in start。
raw_text 只认「第一个没有配对的 End」,不看它是不是 End(Image)。事件流不平衡时它会多吃。把 a  b 后面接一段 next 的事件流过滤掉 End(Image) 再交给 push_html:
<p>a <img src="/y" alt="x b" />
<p>next</p>
raw_text 一直吃到 End(Paragraph), b 进了 alt,第一段的 </p> 没了。写出器不检查配对,也不报错。自己改写事件流时,Start 和 End 要成对增删,见改写事件流。
元数据块:只是不写 Text
开 ENABLE_YAML_STYLE_METADATA_BLOCKS 时,---\ntitle: x\n--- 产出 Start(MetadataBlock)、一个 Text、End(MetadataBlock)。写出器在 Start 时把 in_non_writing_block 置真,End 时清掉,中间的 Text 不写;run 里只有 Text 那一支检查这个标志。---\ntitle: x\n---\n\nbody\n 的输出只有 <p>body</p>。元数据要用就在事件流上自己取。
三个入口和 StrWrite
三个公开函数都只是建一个 HtmlWriter 再 run,区别在把输出目标包成什么:
pub fn push_html<'a, I>(s: &mut String, iter: I)
where
I: Iterator<Item = Event<'a>>,
{
write_html_fmt(s, iter).unwrap()
}
pub fn write_html_io<'a, I, W>(writer: W, iter: I) -> std::io::Result<()>
where
I: Iterator<Item = Event<'a>>,
W: std::io::Write,
{
HtmlWriter::new(iter, IoWriter(writer)).run()
}
pub fn write_html_fmt<'a, I, W>(writer: W, iter: I) -> std::fmt::Result
where
I: Iterator<Item = Event<'a>>,
W: std::fmt::Write,
{
HtmlWriter::new(iter, FmtWriter(writer)).run()
}
(push_html、write_html_io、write_html_fmt,文档注释已省略。)
HtmlWriter 对 W 的要求是 StrWrite,定义在 escape crate 里,因为转义函数也要往同一个目标写:
pub trait StrWrite {
type Error;
fn write_str(&mut self, s: &str) -> Result<(), Self::Error>;
fn write_fmt(&mut self, args: Arguments) -> Result<(), Self::Error>;
}
FmtWriter 把 fmt::Write 包成它,错误类型是 fmt::Error;IoWriter 包 io::Write,write_str 就是 write_all。另有 impl StrWrite for String 和 impl StrWrite for &mut W。之所以要包一层,escape crate 的注释说得很直接:不能同时有「所有 Write 类型」的通配实现和 &mut W 的实现。
几点由此而来:
push_html的unwrap不会触发。 往String写的fmt::Write永远返回Ok。pulldown_cmark不导出StrWrite。html.rs只是use它,lib.rs没有再导出。想写到自己的目标里,实现fmt::Write或io::Write再调对应的函数,不能直接传一个StrWrite。write_html_io自己不缓冲。 每次write_str都直接落到底层的write_all。用一个计数的io::Write接住例子文档的输出:unbuffered: 72 events, 97 write calls, 917 bytes BufWriter: 1 write calls, 917 bytes fmt == push: true (917 bytes)917 字节的 HTML 分 97 次写;直接传文件或 socket 就是 97 次系统调用。包一层
BufWriter后底层只收到 1 次。函数的文档注释也提醒了这一点。write_html_fmt写进String的结果和push_html逐字节相同。
write_html_io 和 write_html_fmt 是从一个 write_html 拆出来的0.10 只有 push_html 和 write_html(writer: W),W: io::Write,要输出到 fmt::Write 的目标(比如 fmt::Formatter)只能先写进 String。PR #870 的第一个提交 081a4d2「Change write_to_html to allow fmt::Write」把 write_html 改成对 W: StrWrite 泛型,调用方要自己包 IoWriter(…),lib.rs 也为此导出了 FmtWriter、IoWriter、StrWrite。同一个 PR 的最后一个提交 cefabf3「do not reexport writer wrappers」又收了回去:删掉导出,把 write_html 拆成现在的 write_html_io(io::Write)和 write_html_fmt(fmt::Write),包装在函数里面做。所以 StrWrite 从没出现在发布版 pulldown_cmark 的公开接口里。这个 PR 首次发布于 0.11.0,write_html 这个名字也随之消失,从 0.10 升级的代码会在这里编译失败。
HtmlWriter 私有还意味着不能只改某一个标签的写法(比如给标题加锚点、给表格套一层 div),也没有选项。上游 guide 的同一页最后列了「Customization」四种做法,实际都是在写出器之外:改写事件流、换输出目标、对输出的 HTML 做后处理、改解析 Options。
动脑筋答案
output-probe footnote-case 的输出:
=== ref [^A], def [^a]
--- src "x[^A]\n\n[^a]: def\n"
0..6 Start(Paragraph)
0..1 Text(Borrowed("x"))
1..5 FootnoteReference(Borrowed("A"))
0..6 End(Paragraph)
7..17 Start(FootnoteDefinition(Borrowed("a")))
13..17 Start(Paragraph)
13..16 Text(Borrowed("def"))
13..17 End(Paragraph)
7..17 End(FootnoteDefinition)
--- html
<p>x<sup class="footnote-reference"><a href="#A">1</a></sup></p>
<div class="footnote-definition" id="a"><sup class="footnote-definition-label">2</sup>
<p>def</p>
</div>
是 2,而且跳不过去。
- 解析器认为
[^A]是引用:FootnoteDefs按UniCase查到了a的定义,所以产生FootnoteReference("A")。事件里保留的是各自原样的名字,A和a。 - 写出器的
numbers按原样字符串比较,"A"先出现拿到 1,"a"是新名字,拿到 2。读者看到正文标 1、脚注标 2。 href="#A"而id="a"。HTML 的id匹配区分大小写,这个链接指向一个不存在的锚点。
把引用也写成 [^a],两边都是 1,href="#a" 对上 id="a"。这个问题在 tag 和 main 上都一样(main 的 numbers 仍是 HashMap<CowStr, usize>),0.13.4 的 html.rs 与 0.13.5 相同。
小结
HtmlWriter是一个循环加八个字段,不记嵌套,Start/End的配对完全靠事件流平衡;end_newline只用来决定块前要不要补换行。- 紧凑列表没有
<p>是因为事件里没有Paragraph,写出器没有松紧状态。 - 表格:对齐按列号从
Tag::Table里取,<tbody>在End(TableHead)时无条件写出,只有表头的表也有空表体;仓库里的 GFM 规范被改成了这样(PR #704)。 - 脚注:引用和定义共用一张编号表,名字首次出现时编号;定义原地输出,重复定义都输出且 id 相同;编号按原样字符串,解析器按大小写折叠,
[^A]配[^a]:会编成两个号、链接失效。 - 图片的
alt由raw_text把子事件压平写出,行内 HTML 在这里被转义;事件流不平衡时它会吃掉后面的闭标签。 - 三个入口都是包一层
FmtWriter/IoWriter再run;StrWrite不对外导出;write_html_io不缓冲,例子文档 97 次写,要自己包BufWriter。