本博客用 KaTeX 排版公式,但 KaTeX 本身不维护编号:renderMathInElement 只识别定界符并把 LaTeX 排出来,编号得自己给。这篇笔记说明我给 Markdown 公式加自动编号的方法——编号与锚点在服务端算好,浏览器里的 KaTeX 只负责排版。效果是 \label\eqref\ref 都能像 LaTeX 一样工作,前向引用也成立。

1. 问题

「自动编号 + 交叉引用」需要三件事:

  1. 每条要编号的公式拿到一个递增的编号;
  2. \label{name} 变成一个可定位的锚点;
  3. \eqref{name} / \ref{name} 变成指向该锚点的链接,分别显示 (n) / n

在 LaTeX 里这三件事由 \tag\label\ref 配合两次编译完成。Markdown 包一遍渲染、没有编译环节,于是我把前两件放到服务端的 Markdown 扩展里做,把排版留给浏览器里的 KaTeX。

2. 分工:服务端编号,客户端排版

关键决定:编号在服务端(一个 Python Markdown 扩展)算好,输出仍是「带 \tag{n} 的 LaTeX 加上一个锚点 <div id="eq:name">」;浏览器里的 KaTeX 只负责把 \[ … \tag{n} \] 排成右对齐的 (n)

为什么不在客户端做?KaTeX 不维护 \label / \ref,要编号就得自己算;既然要算,在服务端算还能顺手在 HTML 里埋下锚点和链接(<a href="#eq:name">),锚点跳转开箱即用。在客户端做就得等 KaTeX 渲染完再回填 DOM,复杂得多。而编号只依赖文档内公式的出现顺序,服务端渲染时天然是有顺序的,不需要额外状态。

3. 三步流程

扩展实现为 Markdown 包(Python 的 markdown 库)的两个扩展点:

前两步在 Preprocessor 里做,第三步在 InlineProcessor 里做。

3.1 保护数学区

Markdown 包本身是不支持数学公式解析的:$a_b$ 里的下划线会被当成强调,\( 会被反斜杠转义。所以第一步把数学区藏起来,用 htmlStash 占位,等 Markdown 包处理完再放回,等到了客户端再由 KaTeX 处理渲染。

同时把代码块(围栏 ``` ``` 和行内 `)也藏起来——代码里的 $ 不能被当成公式。

3.2 编号与标签

$$...$$ 显示公式,按内容分四类:

环境 处理
\begin{equation} 编号
\begin{equation*} 不编号
\begin{align} 编号(内容转成 aligned
\begin{align*} 不编号
其它 不编号

编号用每个文档一个的计数器,从 1 递增。\label{name} 从式子里摘出来,记录 name → n,并生成 id="eq:name" 的锚点;式子末尾补 \tag{n}\notag / \nonumber 被剥掉。

align 要转成 aligned 的原因:\tag 需要一个「单个公式」的外壳 \[ … \],而 align 本身是独立的 display 环境,不能直接嵌进去;aligned 是它的非 display 版本,可以放在 \[ … \] 里。

3.3 引用解析

InlineProcessor 匹配 \eqref{name}\ref{name},查标签表:

因为编号在 Preprocessor 阶段就已全部算完、引用在 InlineProcessor 阶段才解析,前向引用(先引用、后定义)天然成立,LaTeX式的两遍编译中的第一遍已经在 3.2 中完成了。

4. 实现细节

5. 一个例子

输入 Markdown:

$$
\begin{equation}
E = mc^2 \label{einstein}
\end{equation}
$$

质能方程见 \eqref{einstein}。

渲染后:equation 块变成 \[ E = mc^2 \tag{1} \],外面套着 <div id="eq:einstein">\eqref{einstein} 变成 <a href="#eq:einstein">(1)</a>,点击跳回式子。

下面这条是本文里真实存在的编号公式,编号由扩展自动分配:

\[e^{i\pi} + 1 = 0 \tag{1}\]

引用它:(1)

6. 边界与局限

7. 小结

一句话:服务端分配编号与锚点,客户端只排版。三步——藏公式、编号加标签、解析引用——合计不到 160 行,换来 LaTeX 式的 \label / \eqref 体验,且前向引用、代码安全、失败可见都成立。