本文由 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
2
3
4
5
<mjx-container>
<svg>...</svg>
<mjx-break ...></mjx-break>
<svg>...</svg>
</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
2
3
4
5
linebreaks: {
inline: true,
width: '100%',
lineleading: 0.2
}

inline: true 表示允许行内公式在运算符处断行。MathJax 会在公式里标记所有可断点,比如 =、<、+、-,然后为每一段生成一个独立的 <svg>,并在中间插入 <mjx-break>。

相关逻辑可以简化成:

1
2
const makeBreaks = this.options.linebreaks.inline && !math.display;
const forcebreak = this.forceInlineBreaks;

当 linebreaks.inline 为真时,forceInlineBreaks 也为真,MathJax 会给每个可断点设置 forcebreak,SVG 输出再根据这个标记切分。

这里有一个容易误判的点:它不是等公式宽到装不下才断行,而是每个可断点都被无条件标记。 所以哪怕 $Re(Z_A) \lt 0$ 很短,也会被拆成两段。我一开始怀疑是服务端 LiteAdaptor 测不到容器宽度,后来看代码才发现这个猜测不成立,真正的原因是 forcebreak 本身。

根因二:主题把每个 SVG 当成一个网格项

如果只是多个 <svg>,浏览器默认会把它们当作行内元素排在一起,视觉上仍然是一行。真正让它们“变成多行”的,是 butterfly 主题的 CSS:

1
2
3
4
5
6
7
8
9
10
11
12
mjx-container
overflow-x: auto
overflow-y: hidden
padding-bottom: 4px
max-width: 100%

&[display]
display: block !important
min-width: auto !important

&:not([display])
display: inline-grid !important

inline-grid 会把每个直接子元素当成一个网格项。MathJax 输出了两个 <svg>,它们就成了两行;输出了三个,就成了三行。

所以这是两个合理设计叠加后的意外结果:

  • MathJax 4 为了支持行内公式断行,默认输出多个 SVG 片段;
  • 主题为了处理公式的溢出和布局,把行内容器设成了 inline-grid。

单独看,前者是 MathJax 的新特性,后者是主题的排版选择;合在一起,行内公式就被拆成了多行。

修复:在 MathJax 输出之前关掉行内断行

修复方式没有动主题 CSS,也没有再改文章源码,而是在项目里加了一个 Hexo 脚本:

scripts/fix-mathjax-inline-breaks.js

1
2
3
4
5
6
7
'use strict';

const { SVG } = require('@mathjax/src/cjs/output/svg.js');

if (SVG && SVG.OPTIONS && SVG.OPTIONS.linebreaks) {
SVG.OPTIONS.linebreaks.inline = false;
}

它的作用很单一:在 MathJax 创建 SVG 输出器之前,把 linebreaks.inline 改成 false。

改完之后:

1
2
forceInlineBreaks = false
makeBreaks = false

MathJax 不再给运算符设置 forcebreak,每个行内公式只输出一个 <svg>。主题的 inline-grid 里只有一个网格项,公式自然回到单行。

块级公式不受影响。$$...$$ 对应的 math.display 为真,本来就会被 !math.display 排除在行内断行之外,仍然是一个块级公式。

为什么脚本能赶在渲染之前执行

Hexo 的 scripts/*.js 是本地插件脚本,不是浏览器脚本,也不是每篇文章渲染时才执行的。

它会在每次 Hexo 进程启动并执行 hexo.init() 时加载一次,顺序大致是:

1
2
3
4
5
6
启动 Hexo
→ 加载 node_modules 插件(包括 hexo-filter-mathjax)
→ 加载 scripts/ 下的本地脚本
→ fix-mathjax-inline-breaks.js 执行
→ 开始 generate,逐篇渲染文章
→ 此时才 new SVG(...),读取被改过的配置

hexo-filter-mathjax 虽然是在模块加载时注册过滤器,但它是在每次渲染文章时才创建 new SVG(...):

1
2
3
4
5
const svg = new SVG({
fontCache: 'none',
fontData: MathJaxNewcmFont,
dynamicPrefix: '[mathjax-newcm]/svg/dynamic'
});

而 SVG 构造时会把静态的 SVG.OPTIONS 复制进实例。脚本改的正是这个静态对象,所以只要它先执行,后面每次渲染都会读到 linebreaks.inline = false。

这里有两个前提:

  1. package.json 里要有 hexo 字段,Hexo 才会把 env.init 设为真并加载本地脚本;
  2. 不能用 hexo --safe,安全模式会跳过 scripts/。

另外,脚本只在进程启动时执行一次。如果 hexo server 正在运行,改完脚本必须重启服务器;只刷新浏览器没有用。

验证

修复后重新生成:

1
2
npx hexo clean
npx hexo generate

这次生成了 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 摆成了几行。