一行公式,三行显示:一次 MathJax 行内公式排障
本文由 DeepSeek-V4.1-Flash 生成
一行公式,三行显示
事情的开头很简单:有读者(其实也是我自己)发现 article-note-6 里的行内公式不对劲。
本来应该是一句话中间夹着一个公式,比如“其中
这篇记录这次排障的完整过程。结论先说:问题不在 $...$ 的写法,而在 MathJax 4 默认的行内断行策略,以及 butterfly 主题给 mjx-container 设置的 inline-grid。两者单独看都合理,叠在一起就把一行公式拆成了多行。
第一个假设:是不是 $$ 用错了
第一反应是内容写错了。
Markdown 里 $...$ 是行内公式,$$...$$ 是块级公式。如果在一句话中间用了 $$...$$,渲染成块级公式,确实会把句子截断。于是我在几篇文章里搜索 $$...$$,把夹在中文句子中间的公式改成了 $...$,重新生成,看起来“好像好了”。
但这个修复是错的。
用户随后指出:那些 $$...$$ 本来就是故意用于独立显示公式的,真正有问题的是 $...$。也就是说,我改动了不该改的地方,却没有解决原始现象。于是先把四个源文件全部 git checkout 还原,再重新看生成结果。
这个弯路留下一个很直接的教训:在改源码之前,先确认渲染链路里到底是哪一层出了问题。 如果只看 Markdown 的 $ 数量,很容易把“块级公式出现在句子中间”和“行内公式被拆成多行”混为一谈。
去看生成后的 HTML
源码还原之后,我重新生成站点,然后直接检查 public/article-note-6/index.html。
MathJax 的输出结构大致是这样的:
1 | <mjx-container> |
一个行内 mjx-container 里,出现了两个甚至三个 <svg>,中间夹着 <mjx-break>。而真正的块级公式,也就是带 display="true" 的容器,只有一个 <svg>。
这就解释了眼见的现象:
$Z_0$这种没有可断点的公式,只有一个<svg>,看起来正常;$Re(Z_A) \lt 0$有一个关系符,被拆成两个<svg>;$R_A = -2Z_0$有等号和负号,可能被拆成三个<svg>。
公式不是“排版变宽了”,而是在 HTML 层面就已经被切成了多段。主题 CSS 只是把这些段一段一段地摆了出来。
根因一:MathJax 4 默认拆分行内公式
继续往下查,问题落在 hexo-filter-mathjax 使用的 MathJax 4.1.2 上。
MathJax 的 SVG 输出默认配置里有这样一项:
1 | linebreaks: { |
inline: true 表示允许行内公式在运算符处断行。MathJax 会在公式里标记所有可断点,比如 =、<、+、-,然后为每一段生成一个独立的 <svg>,并在中间插入 <mjx-break>。
相关逻辑可以简化成:
1 | const makeBreaks = this.options.linebreaks.inline && !math.display; |
当 linebreaks.inline 为真时,forceInlineBreaks 也为真,MathJax 会给每个可断点设置 forcebreak,SVG 输出再根据这个标记切分。
这里有一个容易误判的点:它不是等公式宽到装不下才断行,而是每个可断点都被无条件标记。 所以哪怕 $Re(Z_A) \lt 0$ 很短,也会被拆成两段。我一开始怀疑是服务端 LiteAdaptor 测不到容器宽度,后来看代码才发现这个猜测不成立,真正的原因是 forcebreak 本身。
根因二:主题把每个 SVG 当成一个网格项
如果只是多个 <svg>,浏览器默认会把它们当作行内元素排在一起,视觉上仍然是一行。真正让它们“变成多行”的,是 butterfly 主题的 CSS:
1 | mjx-container |
inline-grid 会把每个直接子元素当成一个网格项。MathJax 输出了两个 <svg>,它们就成了两行;输出了三个,就成了三行。
所以这是两个合理设计叠加后的意外结果:
- MathJax 4 为了支持行内公式断行,默认输出多个 SVG 片段;
- 主题为了处理公式的溢出和布局,把行内容器设成了
inline-grid。
单独看,前者是 MathJax 的新特性,后者是主题的排版选择;合在一起,行内公式就被拆成了多行。
修复:在 MathJax 输出之前关掉行内断行
修复方式没有动主题 CSS,也没有再改文章源码,而是在项目里加了一个 Hexo 脚本:
scripts/fix-mathjax-inline-breaks.js
1 | ; |
它的作用很单一:在 MathJax 创建 SVG 输出器之前,把 linebreaks.inline 改成 false。
改完之后:
1 | forceInlineBreaks = false |
MathJax 不再给运算符设置 forcebreak,每个行内公式只输出一个 <svg>。主题的 inline-grid 里只有一个网格项,公式自然回到单行。
块级公式不受影响。$$...$$ 对应的 math.display 为真,本来就会被 !math.display 排除在行内断行之外,仍然是一个块级公式。
为什么脚本能赶在渲染之前执行
Hexo 的 scripts/*.js 是本地插件脚本,不是浏览器脚本,也不是每篇文章渲染时才执行的。
它会在每次 Hexo 进程启动并执行 hexo.init() 时加载一次,顺序大致是:
1 | 启动 Hexo |
hexo-filter-mathjax 虽然是在模块加载时注册过滤器,但它是在每次渲染文章时才创建 new SVG(...):
1 | const svg = new SVG({ |
而 SVG 构造时会把静态的 SVG.OPTIONS 复制进实例。脚本改的正是这个静态对象,所以只要它先执行,后面每次渲染都会读到 linebreaks.inline = false。
这里有两个前提:
package.json里要有hexo字段,Hexo 才会把env.init设为真并加载本地脚本;- 不能用
hexo --safe,安全模式会跳过scripts/。
另外,脚本只在进程启动时执行一次。如果 hexo server 正在运行,改完脚本必须重启服务器;只刷新浏览器没有用。
验证
修复后重新生成:
1 | npx hexo clean |
这次生成了 621 个文件。随后扫描 public/**/*.html,检查每个行内 mjx-container 里的 <svg> 数量,结果是没有一个行内公式包含多个 <svg>。
再回头看 article-note-6 里的几个例子:
$Z_0$:一个<svg>;$G_0=1+\frac{R_2}{R_1}$:一个<svg>;$Re(Z_A) \lt 0$:一个<svg>;$R_A = -2Z_0$:一个<svg>。
git status 里也只多了一个新文件:
1 | ?? scripts/fix-mathjax-inline-breaks.js |
文章源码没有被改动。
踩过的坑
1. 把“块级公式出现在句子中间”当成了“行内公式被拆成多行”。
这两个现象看起来都像“公式占了多行”,但原因完全不同。前者是 $$...$$ 的语义问题,后者是 MathJax 输出结构的问题。如果不是先还原源码、再去看生成后的 HTML,很容易在错误的层面上修很久。
2. “容器宽度不够”是一个很顺手的错误解释。
公式被拆行,直觉上会认为是宽度不够。但 MathJax 4 的 forcebreak 是在每个可断点无条件设置的,短公式同样会被拆。看代码之前,不要把“听起来合理”当成“已经验证”。
3. 生成物可能是旧的。
调试时 public/ 里的 HTML 可能比源码旧。有一次看到的还是上一版内容,差点以为修复没生效。后来养成习惯:改完先 hexo clean,再 hexo generate,然后检查生成文件的修改时间。
4. Hexo 脚本不是热加载的。
scripts/ 只在进程启动时执行。hexo server 开着的时候改脚本,不重启就不会生效;--safe 模式下则完全不会加载。
最后
这次问题最后只落在一个三行的脚本上,但排查过程跨了三层:
- Markdown 源码:
$...$与$$...$$的语义; - 服务端渲染:MathJax 4 如何决定行内公式的断点;
- 前端布局:主题 CSS 如何排列 MathJax 生成的多个
<svg>。
如果只盯着第一层,就会得出“公式写法不对”的结论;如果只盯着第三层,可能会去改主题 CSS,却没有意识到 MathJax 本来可以不输出多个片段。
最后的修复选择在 MathJax 输出层,而不是主题层,原因是它只改一个明确的开关,不依赖主题内部的网格布局,也不会影响块级公式。代价是极长的行内公式失去了自动换行点,窄屏下会按主题现有的 overflow-x: auto 横向滚动;对博客里的行内公式来说,这比“每个运算符换一行”更符合预期。
还有一个需要记住的边界:这个补丁依赖 MathJax 4 的内部静态选项 SVG.OPTIONS。如果以后升级到不兼容的大版本,应该重新检查这个路径和选项名,而不是假设它永远有效。
排障最花时间的部分,往往不是修,而是确认“到底哪一层在制造这个现象”。这一次,答案藏在生成后的 HTML 里:不是公式写错了,是它被切成了几段,又被 CSS 摆成了几行。


