Rust in Everything
基于 pulldown-cmark 0.13.5

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,正文里的 " 也变成 &quot;
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">&quot;x&quot;</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」。

📝NOTE
冷知识:仓库里的 GFM 规范被改成了 <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,一个 [^…] 算不算引用要看大小写折叠后能不能查到定义。两边对「同一个脚注」的定义不一样。

❗IMPORTANT
动脑筋: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(())
    }

下面这个图片的描述跨了两行:

![*a* `b` <i>c</i>
d](/x.png "t")

事件和输出:

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 &lt;i&gt;c&lt;/i&gt; d" title="t" /></p>

强调的开闭被丢掉,代码只留内容,换行变空格。行内 HTML 在正文里原样输出,在 alt 里却当文本转义,因为它出现在属性值里。End(Image) 被 raw_text 吃掉,所以 end_tag 里那一支只有一句注释 // shouldn't happen, handled in start。

raw_text 只认「第一个没有配对的 End」,不看它是不是 End(Image)。事件流不平衡时它会多吃。把 a ![x](/y) 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 逐字节相同。

📝NOTE
冷知识: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。

💬 关联讨论

发起讨论
加载中...