全站数学公式的本地 MathJax

症状、原因与可离线打开的排版方案

记录课程网站公式离线排版的失败症状、MathJax 4 本地方案,以及直接打开 HTML 时必须使用相对路径的原因。
Quarto
MathJax
Reveal.js
开发规范
作者

胡华平

发布于

2026年10月13日

修改于

2026年10月13日

摘要
本文说明为什么 CDN 和 MathJax 2.7.9 整包都不能满足本站的离线阅读,以及现行 MathJax 4.1.3 组合组件如何同时服务普通网页和 Reveal 幻灯片。文中给出路径、模板和验收时必须避开的写法。

本文是课程 Quarto 网站的公式排版指南。交互课件的页面结构见《交互课件开发总指南》。这里只处理数学公式如何在无外网、以及直接打开 HTML 时仍然排版。

1 要达到的结果

生成后的页面要同时满足四件事:

  1. 公式在浏览器里排版,而不是停在 \[...\] 或 \(u_i\)。
  2. 不请求 jsDelivr 或其他 CDN。
  3. 用 http://127.0.0.1/ 打开整站,和用 Chrome、Cursor 直接打开 _site 里的 HTML,结果一致。
  4. 仓库里只保留排版真正需要的文件,而不是几十兆的完整发行包。

2 失败时页面长什么样

还没排版时,Reveal 幻灯片里能直接看到 TeX 分隔符:

<h2><span class="math inline">\(u_i\)</span> 与 <span class="math inline">\(e_i\)</span> 能互换吗?</h2>
<span class="math display">\[ u_i=Y_i-E(Y_i\mid X_i) \]</span>

排版成功后,这些节点内部会出现 mjx-container,可见文字变成带下标的 \(u_i\)、\(e_i\)。网络面板里应能看到本站的 load-mathjax.js、tex-chtml.js 和 woff2,状态都是成功。

下面几种“看起来像加载了”的地址都是失败:

地址 实际去了哪里
https://cdn.jsdelivr.net/npm/mathjax@... 外网,离线即失败
/vendor/mathjax/load-mathjax.js 仅当 HTTP 根目录就是 _site 时成立
file:///d%3A/github/course-em/_site/site_libs/vendor/mathjax/load-mathjax.js 盘符被编码,而且目录多了一层 site_libs

3 本站有两条加载路径

Quarto 对普通 HTML 和 Reveal 幻灯片用的不是同一段加载代码。

普通 HTML 由 Pandoc 写入:

<script defer src="../../../vendor/mathjax/load-mathjax.js"></script>

层数随页面深度变化。exam/test/single-choice/index.html 是三层,topics/某日/index.html 是两层。这条路径相对 HTML 文件,直接打开文件也能找到资源。

Reveal 幻灯片由 vendor/mathjax/reveal-template.html 写入:

mathjax3: {
  mathjax: "../../vendor/mathjax/load-mathjax.js"
}
plugins: [RevealMath.MathJax3]

当前课件都在 _site/slide/slide-reactive/ 或 _site/slide/slide-reveal/,所以上面这个固定相对深度是对的。Quarto 自带模板默认写的是 math: 和 RevealMath,那是 MathJax 2 插件,加载后调用 MathJax.Hub。MathJax 4 没有这个接口。

4 曾经踩过的三条路

4.1 CDN

_quarto.yml 不写 html-math-method 时,Quarto 默认把 MathJax 指到 jsDelivr。课堂网络不稳定或离线打开 HTML 时,脚本不到,公式保持原样。

4.2 MathJax 2.7.9 整包

2.x 必须先加载 MathJax.js,再靠 ?config=TeX-AMS_HTML-full 继续请求配置、输入输出组件和多种字体。官方说明 3 和 4 改为一个组合组件,避免这一串请求:配置、加载。

GitHub 上的 2.7.9 标签解压后约 40MB,里面还有未压缩源码、文档、测试和多套字体格式。把它放进 project.resources 时,Quarto 递归扫描会报 Maximum call stack size exceeded。把复制塞进整站 robocopy 同步脚本,又会让每次渲染都搬运整站,并且一关闭同步,_site/vendor 就丢了。

4.3 用 JavaScript 计算绝对 file 地址

曾以 reveal.js 的地址为基准执行:

new URL("../../vendor/mathjax/load-mathjax.js", revealJsSrc).href

reveal.js 位于 _site/site_libs/revealjs/dist/reveal.js。从 dist/ 向上两级只到 site_libs/,于是请求变成 _site/site_libs/vendor/mathjax/load-mathjax.js。文件实际在 _site/vendor/mathjax/。

new URL().href 还会把 Windows 盘符冒号编成 %3A。Chrome 不会把 file:///d%3A/... 当作 D:\...,脚本直接 404。相关记录见 百分号编码的盘符 和 file URL 再解析。

5 现行方案

资产固定为 MathJax 4.1.3,放在项目根目录:

vendor/mathjax/
├── load-mathjax.js
├── tex-chtml.js
├── reveal-template.html
├── woff2/
└── dynamic/

tex-chtml.js 约 1MB,是 TeX 输入加 CommonHTML 输出的组合组件。woff2/ 与 dynamic/ 只取 @mathjax/mathjax-newcm-font 里 CHTML 需要的文件。load-mathjax.js 用当前脚本地址设置 fontURL 和 dynamicPrefix,再加载 tex-chtml.js。Reveal 的 MathJax 3 插件可能已经写过 window.MathJax,加载器保留其中的 startup,避免盖掉翻页后的重新排版。

_quarto.yml 有两项:

project:
  resources:
    - vendor/mathjax
html-math-method:
  method: mathjax
  url: /vendor/mathjax/load-mathjax.js

/vendor/... 只作为 Quarto 的源配置。普通 HTML 渲染时会改写成相对于该页面的路径。Reveal 模板不使用这个绝对地址,而是写死 ../../vendor/mathjax/load-mathjax.js。

接入 _reveal.yml、slide/slide-reveal/_metadata.yml 和 chpt00-revealjs-basic.qmd 的幻灯片,都要引用 vendor/mathjax/reveal-template.html。模板里的插件是 RevealMath.MathJax3。该插件先设置 window.MathJax,再加载一个组件文件,这与 MathJax 4 的官方加载方式一致;3 的配置对象可以继续用在 4 上。

6 验收

渲染后先看源码,再看页面。

源码里应出现本站相对路径,不应出现 cdn.jsdelivr.net、MathJax.Hub 或 file:///。幻灯片配置类似:

mathjax3: {
  mathjax: "../../vendor/mathjax/load-mathjax.js"
}

然后用两种方式打开同一份 _site/slide/slide-reactive/chpt02-population-sample-regression.html:

  1. 站点根目录是 _site 的 HTTP 服务。
  2. Chrome 或 Cursor 直接打开该 HTML 文件。

两种方式下,标题中的 \(u_i\) 和正文中的 \[...\] 都应排版。开发者工具里 MathJax-script 的地址应落在 _site/vendor/mathjax/load-mathjax.js,盘符保持 D: 这种未编码形式。

7 以后修改时要守住的做法

  • 新增或移动 Reveal 课件时,先数它相对于 _site 的目录深度。深度变了,就要改模板里的 ../../vendor/mathjax/load-mathjax.js,不能只改普通 HTML 的自动相对路径。
  • 幻灯片脚本地址写成相对路径。不要用 new URL(...).href 生成 file:// 绝对地址。
  • 不要把 MathJax 整包放进 project.resources,也不要靠整站镜像脚本搬运它。
  • Pandoc 模板里的 $ 是变量符号。模板 JavaScript 如果要输出 $ 分隔符,需写成 $$;现行模板只保留 \( 和 \[,避免这个问题。
  • 升级 Quarto 后,对照 share/formats/revealjs/pandoc/template.html 检查本仓库的 reveal-template.html。数学插件段落必须仍是 RevealMath.MathJax3。
  • tutorial/book-em/ 和 lab/lab-em/ 中已生成的 HTML 仍可能指向 MathJax 3 CDN。它们不是当前 qmd 渲染产物,改 _quarto.yml 不会更新它们。
  • .renvignore 忽略 vendor/,避免 R 依赖扫描把这些 JavaScript 当成包代码。

8 和交互课件规范的关系

公式资产属于全站公共资源,放在项目根目录 vendor/mathjax/。它不属于某一份 slide/slide-reactive/chptXX/ 课件。课件专用的 CSS、脚本和 R 代码仍按交互课件开发总指南放在同名目录。