Joplin 行内公式转换深度解析:MathJax HTML 是如何还原成 `$...$` Markdown 的
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$.
别看它短,它同时确认了三件事:
- 行内公式使用单美元符
$...$包裹(区别于块级公式的双美元符$$...$$); - 普通代码内容(
`$f(x) = ax^2 + bx + c$`)在转换时仍作为行内代码保留; - 由 MathJax 渲染出的真实公式
f(x)=ax²+bx+c,被还原成不带多余空格的 TeX 源文本$f(x)=ax^2+bx+c$,原样嵌在自然语句中间,而不是被丢弃或变成不可编辑的图片/乱码。
在 Joplin 的 Markdown 语法体系里,这就是"行内公式"的标准写法:公式不出现在独立行,而是与正文文字在同一行内显示,例如"对于方程 ,我们关心它的根"。与之对应,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.ts 的 HtmlToMd 类,它在内部实例化的是 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/tex;type 中还含 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),避免通过公式注入外部跳转。
由此形成的完整闭环是:
- 采集/导入:Web Clipper 或导入流程拿到带 MathJax 的 HTML;
- 还原:turndown fork 的 MathJax 规则把
math/tex脚本提取为$...$/$$...$$存入笔记(本测试夹具验证的环节); - 再渲染:阅读时 markdown-it + KaTeX 把
$...$重新渲染成公式; - 由于笔记中保存的是文本而非位图/排版 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.ts 中 ParseOptions(如 preserveImageTagsWithSize、preserveTableStyles、tightLists、collapseMultipleBlankLines 等选项)可以看出,HTML→Markdown 转换是 Joplin 导入链路的"中央处理器",而数学公式还原只是它众多精细规则中的一条——单美元符与双美元符的分工、escapeContent: false 的防转义设计,保证了来自复杂数学排版页面的公式在 Joplin 中既不丢失也不失真。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0626
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00