news 2026/9/8 21:59:52

Joplin HTML 转 Markdown 转换规则解析:下标、上标、下划线与删除线的处理边界

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Joplin HTML 转 Markdown 转换规则解析:下标、上标、下划线与删除线的处理边界

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 接受两种形态:

  1. <INS>标签本身——Joplin 富文本编辑器 TinyMCE 在用户点击工具栏“下划线”按钮时产生;
  2. 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 同时覆盖delsstrike三种历史标签写法。由于~~是 GFM 删除线的事实标准、无歧义(Joplin 的渲染端 renderer 包支持它),这里选择“真转换”而非保留 HTML。这也印证了第二节提到的取舍:~单波浪线留给(被保留为 HTML 的)下标语境以避免冲突,双波浪线~~则安全地分配给删除线。

五、测试框架如何校验这条期望输出

以上规则最终由 tests/HtmlToMd.ts 中的集成测试批量验证。该测试的执行机制:

  1. 扫描夹具目录(第 10-11 行):
const basePath = `${__dirname}/html_to_md`; const files = await shim.fsDriver().readDirStats(basePath);
  1. 对每个.html文件,按同名约定找到期望的.md文件(第 18-19 行mdPathfilename(htmlFilename) + '.md'推导),本例即sub_sup_insert_strikethrough.html对应sub_sup_insert_strikethrough.md

  2. 调用转换器并做精确字符串比对(第 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>~~的混合)非常关键。

  1. 本夹具未命中任何特殊分支:anchorNamespreserveImageTagsWithSizetightLists等选项(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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 21:59:37

提升科研效率的实用路径与实践方法探析

搞科研的朋友都懂&#xff0c;找英文文献永远是科研路上第一道耗时又磨人的坎。 尤其是 2026 年的当下&#xff0c;顶刊新成果迭代速度翻倍&#xff0c;学校图书馆权限永远覆盖不全&#xff0c;关键词检索翻几十页都找不到匹配研究方向的核心论文&#xff1b;免费 OA 平台要么…

作者头像 李华
网站建设 2026/9/8 21:58:44

C语言读写FreeFem++网格文件:从数据格式解析到有限元联调

简介&#xff1a;面向科学计算与数值模拟开发者&#xff0c;压缩包内提供了用C语言读取和写入FreeFem有限元软件网格文件&#xff08;.msh格式&#xff09;的完整实现&#xff0c;适合具有C语言基础、希望实现跨语言数据交互或自定义网格处理流程的工程师与研究者在实际项目中参…

作者头像 李华
网站建设 2026/9/8 21:57:59

btop GPU 监控 3 步上手:游戏掉帧先看这里

btop GPU 监控 3 步上手&#xff1a;游戏掉帧先看这里 【免费下载链接】btop A monitor of resources 项目地址: https://gitcode.com/GitHub_Trending/bt/btop 你打游戏掉帧&#xff0c;打开任务管理器却只能看到几个数字&#xff0c;分不清是 CPU 喂不饱还是显卡忙不过…

作者头像 李华
网站建设 2026/9/8 21:53:56

Autojs人脸识别脚本拆解:权限、技术路线与避坑指南

简介&#xff1a;这是一份Autojs实现的抖音人脸识别脚本项目&#xff0c;主要面向计算机、数学、电子信息等专业学生&#xff0c;适合用于课程设计、期末大作业或毕业设计&#xff0c;也适合有一定代码基础并愿意动手调试的自动化脚本爱好者。压缩包共46个文件&#xff0c;以36…

作者头像 李华
网站建设 2026/9/8 21:53:20

RSoft光子晶体光滤波器设计与仿真:从带隙原理到WDM应用实操

这几年做光通信方向的器件级仿真&#xff0c;我有一半时间耗在“想在方案里用某个器件&#xff0c;但市面选不到完全匹配的”这种问题上。光通信系统往波分复用&#xff08;WDM&#xff09;和多场景扩展走以后&#xff0c;滤波器这个环节越来越绕不开&#xff0c;而RSoft和光子…

作者头像 李华
网站建设 2026/9/8 21:51:38

Ryujinx 配置指南:如何从首次安装到流畅运行的完整教程

Ryujinx 配置指南&#xff1a;如何从首次安装到流畅运行的完整教程 【免费下载链接】Ryujinx 用 C# 编写的实验性 Nintendo Switch 模拟器 项目地址: https://gitcode.com/GitHub_Trending/ry/Ryujinx Ryujinx 是一款用 C# 编写的免费开源 Nintendo Switch 模拟器&#…

作者头像 李华