Joplin HTML 转 Markdown 转换规则解析:下标、上标、下划线与删除线的处理边界
【免费下载链接】joplinJoplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS.项目地址: https://gitcode.com/GitHub_Trending/jo/joplin
本文以 Joplin 测试夹具 sub_sup_insert_strikethrough.md 及其配套输入 sub_sup_insert_strikethrough.html 为核心,剖析 Joplin 官方 HTML 到 Markdown 转换器(HtmlToMd,底层为 turndown 的 Joplin fork)对<sub>、<sup>、<ins>、下划线<span>和<s>这几类行内标签的转换规则:哪些格式被有意保留为 HTML,哪些被转换成了 GFM 扩展语法,以及测试框架如何逐字节校验这些行为。读完后你能理解 Joplin 富文本笔记在导入、剪藏(clipper)场景下格式保真背后的取舍逻辑。
一、测试夹具:一行期望输出定义了一组转换契约
该文档本身是 Joplin HTML 转 Markdown 测试目录下的一个“期望输出”文件。与它同名同目录的.html文件是输入,二者共同构成一组“输入 → 期望输出”契约。
输入 HTML(sub_sup_insert_strikethrough.html):
X<sub>1</sub> X<sup>1</sup> <ins>Insert</ins> <span style="text-decoration: underline;">Insert alt</span> <s>Strike</s>期望 Markdown 输出(即本文档 sub_sup_insert_strikethrough.md 的唯一一行内容):
X<sub>1</sub> X<sup>1</sup> <ins>Insert</ins> <ins>Insert alt</ins> ~~Strike~~把这组输入输出放在一起,五种标签的归宿一目了然:
| 输入标签 | 期望输出 | 处理方式 |
|---|---|---|
<sub>1</sub> | <sub>1</sub> | 原样保留为 HTML |
<sup>1</sup> | <sup>1</sup> | 原样保留为 HTML |
<ins>Insert</ins> | <ins>Insert</ins> | 原样保留为 HTML |
<span style="text-decoration: underline;"> | <ins>Insert alt</ins> | 归一化为<ins> |
<s>Strike</s> | ~~Strike~~ | 转换为 GFM 删除线 |
前四项保留为 HTML,最后一项转成 Markdown 扩展语法——这个不对称正是 Joplin 转换器的设计意图所在。
二、<sub>/<sup>:有意保留为 HTML 的原因
在 turndown fork 的 commonmark-rules.js 中,Joplin 为下标和上标显式注册了两条规则:
rules.superscript = { filter: 'sup', replacement: function (content, node, options) { return '<sup>' + content + '</sup>' } } rules.subscript = { filter: 'sub', replacement: function (content, node, options) { return '<sub>' + content + '</sub>' } }replacement 函数不做任何语法转换,只是把内容重新包回原标签,效果等价于“透传”。为什么不用~x~之类的下标语法?同文件上方 第 104-108 行 的注释给出了明确解释:下划线/下标语法并不普及,而且~在 GitHub 上恰恰是删除线的语法,若把<sub>转成~...~会造成歧义,因此“best to keep it as HTML to avoid any ambiguity”。
这与本文档期望输出完全对应:输入中的X<sub>1</sub> X<sup>1</sup>在输出中原封不动。由于 Joplin 的笔记正文支持行内 HTML,这种保留是无损的,往返编辑(round-trip)也不会丢失格式。
三、<ins>与下划线<span>:两条入口,同一个归一化出口
期望输出中<ins>Insert alt</ins>这一段的输入其实是一个带内联样式的<span>,而不是<ins>标签。这正是 commonmark-rules.js 第 110-129 行rules.insert规则要解决的问题:
rules.insert = { filter: function (node, options) { // TinyMCE represents this either with an <INS> tag (when pressing the // toolbar button) or using style "text-decoration" (when using shortcut // Cmd+U) // // https://github.com/laurent22/joplin/issues/5480 if (node.nodeName === 'INS') return true; if (node.nodeName === 'A' && ( node.getAttribute('href') || node.getAttribute('name') || node.getAttribute('id') )) return false; return getStyleProp(node, 'text-decoration') === 'underline'; }, replacement: function (content, node, options) { return '<ins>' + content + '</ins>' } }从源码结构看,这条规则的 filter 接受两种形态:
<INS>标签本身——Joplin 富文本编辑器 TinyMCE 在用户点击工具栏“下划线”按钮时产生;text-decoration: underline样式的节点——用户用 Cmd+U 快捷键时 TinyMCE 产生的形态。
规则还专门排除了带href/name/id属性的<A>标签,避免把真正的超链接误判为下划线文本。两种入口最终都归一化为<ins>内容</ins>,这也是为什么输入里写的是<span style="text-decoration: underline;">,期望输出却是<ins>Insert alt</ins>。
值得注意的是,Markdown 原生语法中没有“下划线强调”(_text_表示斜体),所以这里同样选择保留 HTML 而非发明私有语法——这与 Joplin 为高亮注册的mark→==内容==规则(第 96-102 行)形成对比:mark有自定义语法可用,而 insert/sub/sup 没有,故保留 HTML。
四、<s>→~~删除线~~:GFM 插件接管
与前三者不同,<s>Strike</s>被转换成了~~Strike~~。该行为来自 GFM(GitHub Flavored Markdown)插件:gfm.js 将 strikethrough 规则 一并启用:
turndownService.addRule('strikethrough', { filter: ['del', 's', 'strike'], replacement: function (content) { return '~~' + content + '~~' } })filter 同时覆盖del、s、strike三种历史标签写法。由于~~是 GFM 删除线的事实标准、无歧义(Joplin 的渲染端 renderer 包支持它),这里选择“真转换”而非保留 HTML。这也印证了第二节提到的取舍:~单波浪线留给(被保留为 HTML 的)下标语境以避免冲突,双波浪线~~则安全地分配给删除线。
五、测试框架如何校验这条期望输出
以上规则最终由 tests/HtmlToMd.ts 中的集成测试批量验证。该测试的执行机制:
- 扫描夹具目录(第 10-11 行):
const basePath = `${__dirname}/html_to_md`; const files = await shim.fsDriver().readDirStats(basePath);对每个
.html文件,按同名约定找到期望的.md文件(第 18-19 行mdPath由filename(htmlFilename) + '.md'推导),本例即sub_sup_insert_strikethrough.html对应sub_sup_insert_strikethrough.md。调用转换器并做精确字符串比对(第 51-61 行):
const html = await readFile(htmlPath, 'utf8'); let expectedMd = await readFile(mdPath, 'utf8'); let actualMd = await htmlToMd.parse(`<div>${html}</div>`, htmlToMdOptions);输入被包在一层<div>中模拟真实文档片段;比对前会对 Windows 的\r\n做归一化。不一致时,测试会把“Got / Expected”两栏逐行加引号打印出来(第 62-75 行),便于定位差异出现在哪一行、哪个字符——对这种逐字节敏感的夹具(如本例中<ins>与~~的混合)非常关键。
- 本夹具未命中任何特殊分支:
anchorNames、preserveImageTagsWithSize、tightLists等选项(tests/HtmlToMd.ts 第 25-49 行)均按默认值运行,因此它验证的就是转换器默认行为下 sub/sup/ins/删除线的契约。
转换器本体位于 packages/lib/HtmlToMd.ts。在parse()中可以看到默认参数设置(第 23-44 行):ATX 标题、fenced 代码块、-项目符号、*/**强调分隔符,以及br: ' '(行尾两个空格,因为启用软换行时<br/>需要尾部空格才能在 Markdown 中渲染)。随后turndown.use(turndownPluginGfm)注入 GFM 规则(即删除线、表格、任务列表),再移除script/style节点。本文档期望输出中的~~Strike~~正是这一链路的产物。
六、复现与扩展验证
该测试随app-cli包的 Jest 套件运行。在 packages/app-cli 目录下执行:
cd packages/app-cli && npx jest tests/HtmlToMd.ts若想手动验证本文的规则结论,也可以复用同一入口:HtmlToMd是公共导出类(@joplin/lib/HtmlToMd,lib 包入口),parse(html, options)接受字符串或 DOM 节点,返回 Markdown 字符串。
如果将来要为转换器新增标签行为,参考本夹具的组织方式即可:在 html_to_md 目录 下放一对同名.html/.md文件,在 commonmark-rules.js(Joplin 私有格式)或 turndown-plugin-gfm(GFM 扩展)中实现规则,集成测试会自动将其纳入全量比对。
七、小结
这个仅一行的期望输出文件,浓缩了 Joplin HTML 转 Markdown 的核心设计哲学:
- 无标准 Markdown 语法承载的格式(
<sub>、<sup>、<ins>及下划线样式)→ 归一化后保留为 HTML,利用 Joplin 笔记的行内 HTML 支持实现无损往返,且刻意规避~与删除线的歧义; - GFM 已有标准语法的格式(
<s>/<del>/<strike>删除线)→ 转换为~~...~~,获得跨平台渲染兼容; - 所有行为由“输入-期望输出”夹具对 + 精确字符串比对的集成测试锁定,任何规则回归都会以逐行差异的形式在测试输出中暴露。
理解这条契约,也就理解了 Joplin 富文本编辑器(TinyMCE 产生的多种等价写法)、网页剪藏和 Markdown 导入路径能够格式互通的底层机制。
【免费下载链接】joplinJoplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS.项目地址: https://gitcode.com/GitHub_Trending/jo/joplin
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考