首页
/ Joplin 行内公式转换深度解析:MathJax HTML 是如何还原成 `$...$` Markdown 的

Joplin 行内公式转换深度解析:MathJax HTML 是如何还原成 `$...$` Markdown 的

2026-09-07 09:13:38作者:廉彬冶Miranda

Joplin 是一款以隐私为核心、内置同步能力的跨平台笔记应用,其 Web Clipper、网页导入与富文本编辑器都会接触到网页中常见的数学公式内容。本文以仓库内 HTML→Markdown 转换测试夹具 mathjax_inline.md 为主线,讲解 Joplin 如何识别 MathJax 渲染后的行内公式并还原为标准的 $...$ 公式语法,从而让公式源文本(LaTeX)在笔记中可被 KaTeX 重新渲染、可跨设备同步、可被搜索。读完本文,你将理解"MathJax 页面 → Joplin Markdown 行内公式"整条转换链路的输入、输出与底层规则实现。

一、从一行测试期望看行内公式约定

packages/app-cli/tests/html_to_md/mathjax_inline.md 是整个测试用例的"期望输出",内容只有一行:

*Inline formulas* are surrounded by single dollar signs. For example, `$f(x) = ax^2 + bx + c$` renders as $f(x)=ax^2+bx+c$.

别看它短,它同时确认了三件事:

  1. 行内公式使用单美元符 $...$ 包裹(区别于块级公式的双美元符 $$...$$);
  2. 普通代码内容(`$f(x) = ax^2 + bx + c$`)在转换时仍作为行内代码保留;
  3. 由 MathJax 渲染出的真实公式 f(x)=ax²+bx+c,被还原成不带多余空格的 TeX 源文本 $f(x)=ax^2+bx+c$原样嵌在自然语句中间,而不是被丢弃或变成不可编辑的图片/乱码。

在 Joplin 的 Markdown 语法体系里,这就是"行内公式"的标准写法:公式不出现在独立行,而是与正文文字在同一行内显示,例如"对于方程 f(x)=ax2+bx+c,我们关心它的根"。与之对应,mathjax_block.md 验证了独立成段的块级公式写法 $$...$$

二、输入侧:MathJax 究竟给转换器留下了什么

数学公式在网页中往往不是以 TeX 文本直接出现,而是经过 MathJax 客户端渲染后变成复杂的 DOM。同名输入文件 mathjax_inline.html 展示了真实场景,其结构可以归纳为几层:

  • 一个空的预览占位 <span class="MathJax_Preview" style="color: inherit;"></span>
  • 渲染成品 <span class="MathJax" id="MathJax-Element-2-Frame" tabindex="0" ...>,内含大量定位用的绝对定位 <span><nobr> 片段、<math>/<mrow> 等可视排版结构;
  • 供无障碍阅读的辅助副本 <span class="MJX_Assistive_MathML">...<math>...</math></span>
  • 最关键的是位于末尾的公式源文本脚本
<script type="math/tex" id="MathJax-Element-2">f(x)=ax^2+bx+c</script>

type="math/tex" 是这个 <script> 元素的意义所在:它携带了页面作者最初书写的 TeX 源码。整段 HTML 的正文是:

<p><em>Inline formulas</em> are surrounded by single dollar signs. For example, <code>$f(x) = ax^2 + bx + c$</code> renders as <span class="MathJax_Preview" ...></span><span class="MathJax" ...>…大量排版节点…</span><script type="math/tex" id="MathJax-Element-2">f(x)=ax^2+bx+c</script>.</p>

可见,对于 HTML→Markdown 转换器而言,核心挑战是:把大段"渲染结果"样式 DOM 丢弃,同时把 <script type="math/tex"> 中唯一有价值的 TeX 文本提取出来,包装成 Joplin 认识的公式语法。

三、转换链路:从 HtmlToMd 到 turndown 的 MathJax 规则

Joplin 的 HTML→Markdown 转换统一收口在 HtmlToMd.tsHtmlToMd 类,它在内部实例化的是 Joplin 自带的 turndown fork(@joplin/turndown)。parse 大致流程为:

const turndown = new TurndownService(turndownOpts);
turndown.use(turndownPluginGfm);
turndown.remove('script');
turndown.remove('style');
...
let md = turndown.turndown(html);

注意这里虽然默认 remove('script'),即一般意义上的脚本会被整体剔除,但 MathJax 的 math/tex 脚本并不走这条移除路径——因为 turndown fork 的内置规则表里为它注册了专门规则,转换时优先命中。真正处理 MathJax 的逻辑位于 fork 包的 commonmark-rules.js 中,代码顶部用注释写明了设计意图(见 commonmark-rules.js#L750-L760):

遇到 MathJax 元素时,首先是"已渲染的 MathJax",这部分需要跳过,因为它无法被可靠地转换为 Markdown;紧随其后的是真正要导出的、位于 <script> 标签里的 MathJax 源文本。把它用 $$$ 包裹后,Joplin 内即可由 KaTeX 正确显示。

3.1 判断脚本类型

function majaxScriptBlockType(node) {
  if (node.nodeName !== 'SCRIPT') return null;
  const a = node.getAttribute('type');
  if (!a || a.indexOf('math/tex') < 0) return null;
  return a.indexOf('display') >= 0 ? 'block' : 'inline';
}

函数依据两点判定:节点必须是 <script>,且其 type 属性包含 math/textype 中还含 display 关键字(即 MathJax 块级公式的 type="math/tex; mode=display")则归类为块级,否则为行内。

3.2 跳过已渲染的排版 DOM

rules.mathjaxRendered = {
  filter: function (node) {
    return node.nodeName === 'SPAN' && node.getAttribute('class') === 'MathJax';
  },
  replacement: function (content, node, options) {
    return '';
  }
}

凡是 class 精确等于 MathJax 的渲染成品 <span>,直接替换为空字符串——它包含的绝对定位排版节点、SVG/HTML-CSS 输出没有任何迁移到 Markdown 的价值,这一点从输入 HTML 中的嵌套 <span> 海洋可以得到直观印证。

3.3 行内公式规则

rules.mathjaxScriptInline = {
  filter: function (node) {
    return majaxScriptBlockType(node) === 'inline';
  },
  escapeContent: function() {
    return false;
  },
  replacement: function (content, node, options) {
    return '$' + content + '$';
  }
}

对行内公式脚本,取出其中 TeX 文本并用单个 $ 包裹。特别值得注意的是 escapeContent 返回 false:注释说明"我们想要原始的、未经转义的内容,因为这是 KaTeX 渲染所必需的;一旦转义,反斜杠 \\ 会被加倍"。这正是 TeX 源文本(例如含 \frac\sqrt\pm 的公式)能够被无损还原到笔记中的关键保证。

3.4 块级公式规则

rules.mathjaxScriptBlock = {
  filter: function (node) {
    return majaxScriptBlockType(node) === 'block';
  },
  escapeContent: function() {
    return false;
  },
  replacement: function (content, node, options) {
    return '$$\n' + content + '\n$$';
  }
}

块级公式脚本则被包裹成独立成段的 $$\n...\n$$。对照 mathjax_block.md 的期望输出即可看到同一约定:

$$
x = \frac{-b \pm \sqrt{b^2 - 4ac} }{2a}.
$$

于是整个转换结果就是一行可读、可编辑、可同步的 Markdown:渲染 DOM 被静默丢弃,而公式源码被提取为 Joplin 的标准行内语法,与正文中的 *Inline formulas*`$f(x) = ax^2 + bx + c$` 等元素和谐共存。

四、渲染回环:KaTeX 让 $...$ 重新变成数学

转换是"HTML → Markdown"方向的单向能力,但要让 $f(x)=ax^2+bx+c$ 在笔记阅读时重新显示成漂亮的数学排版,Joplin 依赖的是 KaTeX 渲染插件。渲染侧实现在 packages/renderer/MdToHtml/rules/katex.ts:它基于 markdown-it 的行内/块级状态机,识别 $...$$$...$$ 分隔符并调用 KaTeX 输出 HTML,同时还注入 katex_mhchem.js 以支持化学方程式宏(katex.ts#L7-L9)。

为保证安全,该规则对 KaTeX 的 \href\url 宏启用了受限的 trust 判定,仅放行 https://http://mailto:joplin://#锚点 等白名单协议(katex.ts#L43-L52),避免通过公式注入外部跳转。

由此形成的完整闭环是:

  1. 采集/导入:Web Clipper 或导入流程拿到带 MathJax 的 HTML;
  2. 还原:turndown fork 的 MathJax 规则把 math/tex 脚本提取为 $...$ / $$...$$ 存入笔记(本测试夹具验证的环节);
  3. 再渲染:阅读时 markdown-it + KaTeX 把 $...$ 重新渲染成公式;
  4. 由于笔记中保存的是文本而非位图/排版 DOM,公式可编辑、可全文搜索、可跨端同步,这与 Joplin 以 Markdown 为核心数据模型的定位是一致的。

五、扩展:MathML 与"脚本家族"的统一处理

MathJax 并不是页面上数学公式的唯一来源。在 turndown fork 的同一段代码中,紧邻 MathJax 规则之后还有 MathML 规则(针对维基百科与 KaTeX 输出),其实现是从 <math> 中寻找 <semantics><annotation>...</annotation></semantics> 结构并取出注解文本(见 commonmark-rules.js#L815-L842):

rules.mathMlScriptBlock = {
  filter: function (node) {
    return node.nodeName === 'math' && !!getSourceText(node);
  },
  escapeContent: function() {
    return false;
  },
  replacement: function (_content, node, _options) {
    return '$' + getSourceText(node) + '$';
  }
};

对应测试夹具为 wikipedia_math_inline.md,其期望输出演示了带 {\displaystyle ...}\begin{cases} 的复杂行内公式如何从维基百科风格 HTML 中还原。可见"渲染结果丢弃、取源文本并以 $ 包裹"是 Joplin 处理各派公式标记的统一策略。

六、测试运行方式与回归保护

该夹具由 packages/app-cli/tests/HtmlToMd.ts 驱动:测试扫描 html_to_md 目录下全部 .html 文件,对每个文件用 new HtmlToMd() 转换并与其同名 .md 文件做逐字节比对(HtmlToMd.ts#L9-L94):

const html = await readFile(htmlPath, 'utf8');
let expectedMd = await readFile(mdPath, 'utf8');
let actualMd = await htmlToMd.parse(`<div>${html}</div>`, htmlToMdOptions);
...
if (actualMd !== expectedMd) { ... expect(false).toBe(true); }

也就是说,mathjax_inline.md 中这一行文本既是文档,也是断言基准——任何对 MathJax 规则或转义逻辑的改动,只要让输出格式与这里不一致(多一个空格、多一个转义符、少一个美元符),测试就会失败。这样就把"行内公式必须还原成 $...$"这条行为规范固化成了持续回归保护的契约。

七、在应用中的实际接入点

HtmlToMd 转换器在仓库中的真实调用方包括:

  • CLI 命令packages/lib/commands/convertHtmlToMarkdown.ts 暴露 convertHtmlToMarkdown 命令,对传入 HTML 调用 new HtmlToMd().parse(...) 并返回 Markdown,供用户在命令行中对网页内容做 HTML→Markdown 转换;
  • REST 服务packages/lib/services/rest/routes/notes.ts 中缓存了 HtmlToMd 实例,用于 API 侧的正文清洗/转换;
  • OneNote 导入packages/lib/services/interop/InteropService_Importer_OneNote.test.ts 在导入互操作测试中同样构造 HtmlToMd 实例来断言导入结果。

无论从哪个入口进入,行内公式都最终汇聚到本文剖析的同一套 MathJax 还原规则。结合 HtmlToMd.tsParseOptions(如 preserveImageTagsWithSizepreserveTableStylestightListscollapseMultipleBlankLines 等选项)可以看出,HTML→Markdown 转换是 Joplin 导入链路的"中央处理器",而数学公式还原只是它众多精细规则中的一条——单美元符与双美元符的分工、escapeContent: false 的防转义设计,保证了来自复杂数学排版页面的公式在 Joplin 中既不丢失也不失真。

登录后查看全文
热门项目推荐
相关项目推荐