HTML 转 Markdown——每一个标签都追得到,每一次拒绝都记在账上

把标记粘进来,把 markdown 拿走——整条路都印在纸上:那台照你浏览器读标签的方式来读你标签的分词器,每个元素变成了什么、为什么,不得不转义的每一个字符,被拒绝解释的每一个构造,底下还有拿你自己的浏览器那台 HTML 解析器跑的一遍对账。你粘进来的东西不会离开这台设备。

标签变成 markdown 的规则

进来的是什么,出去的是什么

把标记粘进来,把 markdown 拿走。难的地方不在转换,在诚实:markdown 拼不出 HTML 的大部分东西,所以一 个转换器要么瞎编,要么把字扔掉,而一个编了、扔了却不吭声的页面,总会在粘贴和提交之间的某个地方赔掉你 一个下午。

留着三本账,另外跑一次对账。

  • 每一个元素,按遇见它们的顺序——每个标签变成了什么,以及解释这个选择的那句话。一个只装着 别的盒子的 <div> 是有意消失的;一个带着 class 的 <span> 不是,因为那个 class 就是它存在的理由。
  • 输出为了自保而不得不护住的每一个字符——那些反斜杠,和每一个反斜杠在这里的原因。散文里的 & 好 好待着;& 挡在 copy; 前面就是一个字符引用,于是它领到一个反斜杠和这张表里的一行。
  • 被拒绝解释的东西——那些条目不是不肯帮忙,而是两句陈述:要么 markdown 里没有这个构造,要么 你的标记需要解析器替作者把一句话说完,而作者没说完。

底下是对账表:二十份小文档,加上你自己粘的那份 markup,每一份都解析两遍——一遍由这台分词器和这棵 树构造器,一遍由 DOMParser,也就是正在解析你此刻这个标签页的同一台引擎。两棵树一致,那一行就照实 说一致。不一致的时候,你看到的是一次真正的分歧,而不是一句"根本没有分歧"的声明。

为什么解析器要摊开写

markdown 是一份带约定的纯文本,HTML 是一棵带文档的树。从后者走到前者,意味着对每一个节点判断一件事: markdown 有没有它的拼法——然后再确保你写下去的那个拼法,读回来还是你心里那棵树。

读的那一半是一台分词器和一棵树构造器,两头都取自 HTML 解析规则本身,而不是谁的库。一个片段转换器真正 需要的部分又短又怪,怪到值得逐个点名:

  • 从不闭合的标签照样在文件尽头结束。 <p>left open 给你一个段落;<p>one<p>two 给你两个,因为第 二个段落起始标签会把第一个关掉。这条规则,加上列表项、表格单元、标题那九条同类规则,就是第二张表不 是空着的原因。
  • 标签末尾那个斜杠对大多数元素什么都不表示。 <div/>after 让那个 div 开着,并把 after 放进去。 只有空元素——br、img、hr、input 这些——可以自己把自己写闭。
  • 格式化标签可以套叠,最多套到三个一模一样的。 标准里那一条自己的名字叫 Noah’s Ark:三个相同的 <b> 还开着的时候,第四个 <b> 会把最早的那个从"活动格式化标签"这份名单里拿出去。而那份名单是结束 标签去找的名单,不是树——被点名的那个元素照样开着,照样收着它后面写下的字,所以 <b>1<b>2<b>3<b>4</b>5</b>6</b>7</b> 是四层套叠,最后那个 7 坐在最外层的 <b> 里面。把这一条读成 “把最早的那个关掉”,那个字就被挪出了它当初写进去的那一段强调,而对账表里那一行摆的就是这两种形状。
  • script 的身体是数据。 里面的引用不解码,里面的 < 不开标签,里面的内容也不是字——所以 script 的 字节永远不会以段落的形式出来。
  • 字符引用是有棱角的。 &ltt; 是 <t;,不是 &ltt;,也不是 <;;&#128; 是一个欧元符号,不是控制 字符,因为浏览器到今天还在对小于 32 的数字套用 Windows-1252 那张表;而在属性里面,&copy=2 一个字 不改地留着,&copy;=2 却变成 ©=2。每一条都是一行对账。
  • 表格永远有一副 tbody,行里永远装着单元格。 <table><td>x</table> 里既没写 tbody 也没写 tr,而 浏览器在安置那个 x 之前会把两者都建出来。解析器也建,并且在节点表里把每一个都标成"为这段标记 开出来的标签",好让一次插入不会看起来像你忘了关的那个。把树建成读者将要重新建成的那副样子,这就是转换 器和字符串替换器的区别。
  • 具名引用的那张表是从你的浏览器读来的,读它的规则不是。 &sect; 代表什么,是一份两千二百来条、由 浏览器出厂并不断更新的数据表,所以每个名字都向你的浏览器问一次、答案存下来,问不到的那些也一并报告。 什么时候必须有分号、不带分号的名字能匹配成什么、同样一段文字在属性里又如何表现——这些是上面摊开写 的规则。规则要是也从库里借,就不再是可核对的了。

有意没做的:完整的树构造算法连同它的 foster parenting(表格行之间的文字被解析器搬到别处去)、frameset 的各个阶段,还有 template 的内容。这些正是标准里会把你写的字挪到树上另一个位置的部分。树构造器不挪, 意思是:碰上触发这条规则的标记,对账表就会打印出一处分歧——而把分歧摆在你面前,比把它藏进一个测试文件 诚实。

建出一副区段,跟搬走一个词,不是一回事,两者分得很清楚:为没有 tbody 的表格开出的那一副 tbody 和那一行 tr,就落在你的标记本来放单元格的位置上,你写的任何东西都不换主人。

强调为什么用它拿到的那个分隔符

foo<em>bar</em>baz 是 markdown 转换器一定会写错的其中一句话,而修法是一条读一遍就能记住的规则。下划线 只有在不是夹在两个文字字符之间时才能开启强调,所以一个词里面的 _bar_ 就是字面下划线,强调没了:

foo_bar_baz        →  foo_bar_baz
foo*bar*baz        →  foo<em>bar</em>baz

星号没有这个限制,所以只要这一对碰到词,写表器就用 *。同一套算术还会把一个多余的空格从标记底下挪出 去——<em> done </em> 变成 *done*,因为内侧带着空格的分隔符谁也没 flank,什么也开不了。而同一种标 记套在自己身上时,里面那个改用这一对的另一个字符,所以你会在输出里看到 _foo *bar* baz_,而不是 _foo _bar_ baz_。

还有第三种情形,它两种分隔符都拿不到。a<em>b<em>c</em>d</em>e 要的是一层强调套在同一种强调里面,而把里面 那个换成这一对的另一个字符,只在那个字符能够 flank 的地方才成立——夹在两个文字字符之间恰恰不行,而嵌套的那 个通常就坐在这种位置上。常见的答复是写四个星号听天由命,可它读回来是两个空的强调;改为把里面那个标 签按标记原样放下:

a<em>b<em>c</em>d</em>e   →   a*b<em>c</em>d*e

这跟元素策略对带着 class 的 <span> 早就做的决定是同一个,并且拒绝表里会记一行,写明放弃的是哪个元素。你 若在那张表里看到 nested-same-mark 或 mark-unflankable,它旁边的 markdown 就是诚实的形状,不是写错了。

如果你的文档里满是 snake_case 标识符,去读那张转义表:护住的每一个下划线,都是一处不加反斜杠就 会吃掉一个词的地方。

列表,转换器栽在这里

一副列表靠两个决定撑着,而这两个在这几张表里都看得见。

松还是紧。 条目之间隔着空行的列表把空行留着;条目只是光秃秃一行的列表不留。在 HTML 里,这个差别就 是每个条目里有没有一个段落,所以转换器就找这个。反过来搞错,某个条目底下那个嵌套列表就不再属于它了。

缩进。 每一层把自己的条目写在第零列,让上面那一层把它们推右,于是一个缩进正好被数一次。一边往下 递归时缩进、一边往回返时又缩进的转换器,产出的就是四、十二、二十空格那截楼梯——很多粘出来的 markdown 正好长这样——而读者看到的是一段代码块,不是一副列表。

相邻的两副列表是第三个麻烦:markdown 没有"列表结束"的记号,所以 - a 接 - b 无论标记里怎么写的, 读回来都是一副列表。中间插一个空注释,在任何真正的解析器里都会关掉前面那副,写的就是这个,并在 转义表里留一行说明此事。

三个选项

markdown 没有拼法的那个标记——keep 把标签原样放进输出,对一个 class 才是要紧的 <div class="note"> 这是对的答案,对一份满是修订记录的页则是错的答案。escape 把标签变成看得见的文字——恰好只有一种场合 有用,就是你要把标记展示给某人看。drop 把标签拿走、把字留下,这是当 markup 来自文字处理器、里面每个 元素都是噪音时你想要的。

链接——行内 [words](address) 待在原地;引用式 [words][label] 把所有地址送到文件末尾,那是一份 引用同一个页面十一遍的文档该有的读法。标签是从链接自己的文字生成的,而撞了名的标签是改名,不是合并, 因为两个标签指向一个地址、和两个地址共用一个标签,两种都是 bug。

项目符号——只有 - 和 *:一个符号加一个空格才是一条列表项,而一份第一个符号是 + 的文档,它的 第一行马上就要不再是列表了。

有两处输出跟你的编辑器对不上

第一处是空白。HTML 在渲染时把文字里任何一串空格、制表符、换行都收成单个空格,所以字写下来的时候 也照同一个规矩来:句子中间的那个换行,不是 markdown 里的换行。只有 <br> 被当作作者要求的一次断行, 而它出来是行末的一个反斜杠。

第二处是表格。管道表只有一行表头、没有 colspan,所以一个单元跨列、或者各列对齐方式不同的表格,写不成 一副管道表。拒了哪张表、为什么拒,都会被说出来,而不是吐出一个读回来是别的东西的形状。

你粘进来的东西,在这里读完就扔

分词器、树构造器和写表器都在你打开的那个标签页里跑起来。你粘进来的东西由这个标签页里长出 的一棵树读,你关掉标签页它就没了:这次转换不上传什么,不发请求,也没有活儿派给服务器。对账之所以能成立, 也是这个道理——那是你自己的浏览器里那台解析器,在你自己的浏览器里,被同一个问题问了两次。

这第二次问,问在一个专为它造出来、也从不显示出来的文档里。这个选择是承诺的一部分,不是实现的细节。 把你的标记喂给当前打开那份文档里的一个元素,两种解析器会给出同样的二十种结果——表里每一条探针在两边都一致——可 那里面的一张图片会开始加载,而一段被你粘进来只是为了转换的标记,没有理由替你去发一个请求。那个一次性 的文档到不了任何服务器,同时它好好留着这场对账要用的注释节点和 script 身体。

相关

如果你要走反方向,本站的 markdown 转 HTML 那一页挂着同样四张表,只是指向 另一头。一份文档从两个方向各读一遍,比你只用其中任一张更快地看出你的标记到底说的是什么。

微信
微信
支付宝
支付宝