本博客用 KaTeX 排版公式,但 KaTeX 本身不维护编号:renderMathInElement 只识别定界符并把 LaTeX 排出来,编号得自己给。这篇笔记说明我给 Markdown 公式加自动编号的方法——编号与锚点在服务端算好,浏览器里的 KaTeX 只负责排版。效果是 \label、\eqref、\ref 都能像 LaTeX 一样工作,前向引用也成立。
1. 问题
「自动编号 + 交叉引用」需要三件事:
- 每条要编号的公式拿到一个递增的编号;
\label{name}变成一个可定位的锚点;\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(预处理器):在 Markdown 包做块级解析之前,对整个源文本先做一次变换再交回。这里用它把数学区和代码块藏进占位符,让后面的解析器看不到它们。
- InlineProcessor(行内处理器):块级结构确定之后、逐段解析行内内容时生效,用正则匹配片段、命中就返回一个替换元素。这里用它把
\eqref/\ref替换成<a>链接。
前两步在 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},查标签表:
- 命中:
\eqref显示(n),\ref显示n,都包成<a href="#eq:name" class="eqref">; - 未命中:显示
(???)/???——失败要可见,与 LaTeX 的??一致。
因为编号在 Preprocessor 阶段就已全部算完、引用在 InlineProcessor 阶段才解析,前向引用(先引用、后定义)天然成立,LaTeX式的两遍编译中的第一遍已经在 3.2 中完成了。
4. 实现细节
- 转义:
<、>要转义,否则会被当成 HTML 标签;&必须原样保留——它是aligned环境里的列对齐标记。 - 代码安全:围栏和行内代码先被占位,代码里的
$、\eqref不会被当成公式或引用处理。 - 每文档计数:扩展实例随每篇文章新建,计数器从 1 开始、按文档内公式出现顺序递增,编号不跨文章。
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>,点击跳回式子。
下面这条是本文里真实存在的编号公式,编号由扩展自动分配:
引用它:(1)。
6. 边界与局限
- 只识别
equation/align及星号变体,其它编号环境(如gather)落回不编号。 - 编号按文档从 1 开始,不跨文章。
align整块只给一个编号,不逐行编号(\notag仅被移除,不实现行级取消)。- 引用未定义时显示
(???),需要作者自查——这与 LaTeX 的行为一致。
7. 小结
一句话:服务端分配编号与锚点,客户端只排版。三步——藏公式、编号加标签、解析引用——合计不到 160 行,换来 LaTeX 式的 \label / \eqref 体验,且前向引用、代码安全、失败可见都成立。