Sigil 内嵌 MathJax 3.22 定制构建解析:从 MathML 到 SVG 的最小化 polyfill 方案
Sigil 内嵌 MathJax 3.22 定制构建解析:从 MathML 到 SVG 的最小化 polyfill 方案
本文深入解读 Sigil 仓库中 polyfills/MathJax_README.md 所记录的 MathJax 定制构建方案:Sigil 如何以 MathJax 3.22 官方版本为基础,通过 webpack 组装出仅含 MathML→SVG 转换所需最小组件的 custom-mathjax.min.js,并在构建期打包进应用、运行期注入 EPUB 预览页。读完本文,你将掌握该定制构建的组件清单、入口脚本与 webpack 配置的完整语义,以及它与 Sigil 构建系统、预览窗口和 URL 安全策略之间的真实调用关系。
一、背景:为什么 EPUB 编辑器需要一份"迷你 MathJax"
EPUB 是一种开放电子书格式,其内容可由 XHTML 承载。数学公式在 EPUB 中通常以 MathML(数学标记语言)形式嵌入 <math> 标签。Sigil 的预览窗口基于 Qt WebEngine 渲染 HTML,而 WebEngine 默认对 MathML 的渲染支持有限,因此需要在预览时注入 JavaScript 数学渲染引擎——MathJax——把 MathML 实时转换为可显示的 SVG 图形。
但 Sigil 不是浏览器,它不能要求用户联网加载完整版 MathJax(约数百个组件模块),也不需要支持 TeX 输入、HTML 输出等与电子书场景无关的能力。于是仓库采用了一份从官方 MathJax 3.22 源码按需定制的最小化构建,只保留:
- 输入:
input/mml(MathML 解析器) - 输出:
output/svg(SVG 渲染器)及其 TeX 字体支持 - 扩展:
mml3(MathML 3 支持扩展)
这份定制产物 custom-mathjax.min.js 与说明文档 MathJax_README.md、许可证 MathJax_License.txt 一起存放在 src/Resource_Files/polyfills/ 目录。README 明确写明:"MathJax Version 3.22 is the required minimum"(3.22 是所需最低版本),整个构建以官方 MathJax-3.22 发布版为基线。
二、定制构建的两个核心输入文件
README 完整保留了定制构建的两个入口文件内容,它们决定了最终产物包含哪些 MathJax 模块。
2.1 custom-mathjax.js:组件入口脚本
该文件是 webpack 打包的入口,完整内容如下(原样继承自 README):
require('../components/src/startup/lib/startup.js');
const {Loader} = require('../js/components/loader.js');
Loader.preLoad(
'loader',
'startup',
'core',
'[mml]/mml3',
'input/mml',
'output/svg',
'output/svg/fonts/tex.js'
);
require('../components/src/input/mml/extensions/mml3/mml3.js');
require('../components/src/core/core.js');
require('../components/src/input/mml/mml.js');
require('../components/src/output/svg/svg.js');
require('../components/src/output/svg/fonts/tex/tex.js');
const {insert} = require('../js/util/Options.js');
insert(MathJax.config, {
mml: {
packages: {'[+]': ['mml3']}
}
});
require('../components/src/startup/startup.js');
逐段解析其作用:
- 启动层先加载:首先
require启动库startup/lib/startup.js,再通过Loader.preLoad(...)预先登记一批组件。Loader是 MathJax 3 的组件加载器,preLoad声明的顺序同时决定了初始化时的加载次序。 - 组件清单(7 项):
loader、startup、core:基础运行框架,任何 MathJax 3 构建都不可缺少;[mml]/mml3:MathML 3 扩展包,[mml]表示该包由input/mml输入处理器提供;input/mml:MathML 输入处理器;output/svg:SVG 输出处理器;output/svg/fonts/tex.js:SVG 输出所需的 TeX 字体数据。
- 再按依赖顺序
require各组件实现:mml3.js(扩展)→core.js→mml.js→svg.js→tex.js,与preLoad声明一一对应,确保 webpack 把对应源码编入 bundle。 - 注入扩展配置:通过
Options.js的insert把mml.packages: {'[+]': ['mml3']}写入MathJax.config。这里的'[+]'是 MathJax 3 的扩展合并语法——表示在默认包列表基础上追加mml3扩展,而不是覆盖默认列表。这样input/mml在解析时就启用了 MathML 3 扩展能力。 - 最后启动:
require('../components/src/startup/startup.js')完成启动组件装配。
README 强调这套定制构建"包含支持 mathml 到 svg 转换的最小集合(包括 mml3 扩展)"("includes the very minimum to fully support mathml to svg conversion (including the mml3 extension)"),上述组件清单正是这句话的直接体现:既没有 TeX/AsciiMath 输入,也没有 CHTML 输出,代码体积被压到最小。
2.2 webpack_config.js:构建配置
const PACKAGE = require('../components/webpack.common.js');
module.exports = PACKAGE(
'custom-mathjax', // the package to build
'../js', // location of the MathJax js library
[], // packages to link to
__dirname, // our directory
'.' // where to put the packaged component
);
参数含义(含注释原样继承):
| 参数 | 值 | 含义 |
|---|---|---|
| 包名 | 'custom-mathjax' |
构建产物的组件名称,最终生成 custom-mathjax.min.js |
| JS 库路径 | '../js' |
MathJax 源码库(js 目录)相对位置 |
| 链接包 | [] |
无需链接其他扩展包(保持最小化) |
| 当前目录 | __dirname |
配置文件所在目录 |
| 输出位置 | '.' |
打包后的组件写到当前目录 |
该配置直接复用 MathJax 官方源码中的 components/webpack.common.js 公共配置工厂,产出的即是以 MathJax 全局对象形式自举的单文件 bundle——也就是仓库中那份体积可控的 custom-mathjax.min.js。README 说明构建过程遵循官方"Making a Custom Build of MathJax"(自定义构建指南,对应官方文档中的 webpack 构建章节),打包输入即为上述两个文件。
三、构建期:如何进入 Sigil 的安装包
定制产物在应用构建时被拷贝进安装目录,相关逻辑集中在 src/qt6sigil.cmake:
-
先定义源路径(L120-L121):
# Location of the MathJax custom build for all platforms set( MATHJAX_CUSTOM "${CMAKE_SOURCE_DIR}/src/Resource_Files/polyfills/custom-mathjax.min.js" )说明该文件是全平台通用的定制构建("for all platforms")。
-
打包阶段(L454-L457)先创建目标目录
${MAIN_PACKAGE_DIR}/polyfills,再通过add_custom_command在 POST_BUILD 阶段把文件复制进去:set( MATHJAX_CUSTOM_DEST ${MAIN_PACKAGE_DIR}/polyfills ) add_custom_command( TARGET ${TARGET_FOR_COPY} PRE_BUILD COMMAND cmake -E make_directory ${MATHJAX_CUSTOM_DEST} ) add_custom_command( TARGET ${TARGET_FOR_COPY} POST_BUILD COMMAND cmake -E copy ${MATHJAX_CUSTOM} ${MATHJAX_CUSTOM_DEST} )因此最终 Sigil.app(macOS)或安装目录(Windows/Linux)中会出现
polyfills/custom-mathjax.min.js,与 README 所述"installed into Sigil.app into a polyfills folder during the build"完全一致。
四、运行期:预览窗口如何定位并注入 MathJax
4.1 路径定位与"系统 MathJax"回退
运行期路径解析位于 src/MainUI/MainWindow.cpp。关键逻辑:
- 默认脚本名固定为
custom-mathjax.min.js,位置随平台变化(macOS 取可执行目录旁的polyfills/,其他平台取QCoreApplication::applicationDirPath() + "/polyfills/"); - 若编译期通过
MATHJAX3_DIR配置了系统 MathJax(见 src/sigil_constants.cpp 与 src/sigil_constants.h 中的mathjax3_dir),则优先使用系统 MathJax,脚本名改为官方启动文件startup.js; - 最终 URL 会被改写为
sigil:自定义 scheme(if (mathjaxurl.startsWith("file:")) mathjaxurl = "sigil:" + ...),交给预览窗口使用; - 该文件夹路径同时保存在
m_mathjaxfolder,通过 MainWindow.h 的GetMathJaxFolder()对外暴露,供 URL 安全拦截使用。
4.2 按需注入:只有页面含 MathML 才加载
注入逻辑在 src/MainUI/PreviewWindow.cpp。Sigil 先扫描当前 XHTML 是否使用 MathML 标签(m_usingMathML),只有命中时才把 <script> 注入到 </head> 之前:
// If this page uses mathml tags, inject a polyfill
// MathJax.js so that the mathml appears in the Preview Window
if (m_usingMathML) {
int endheadpos = text.indexOf("</head>");
if (endheadpos > 1) {
QString inject_mathjax;
if (m_mathjaxurl.endsWith("startup.js")) {
inject_mathjax = MATHJAX3_CONFIG + "<script type=\"text/javascript\" async=\"async\" id=\"MathJax-script\" src=\"" + m_mathjaxurl + "\"></script>\n";
} else {
inject_mathjax = "<script type=\"text/javascript\" async=\"async\" id=\"MathJax-script\" src=\"" + m_mathjaxurl + "\"></script>\n";
}
text.insert(endheadpos, inject_mathjax);
}
}
注意脚本带 async="async" 异步加载,不阻塞页面其余渲染;仅在需要时注入也避免了对普通 EPUB 页面性能的影响。而 m_usingMathML 的判定依据可从 src/ResourceObjects/HTMLResource.cpp 找到佐证:资源属性表中若包含 math,则将其归类为 mathml 属性,预览层据此决定是否需要渲染数学内容。
4.3 系统 MathJax 场景下的额外配置
当使用系统 MathJax(startup.js)时,预览窗口还会额外注入一段 MATHJAX3_CONFIG(src/MainUI/PreviewWindow.cpp),用 loader 声明与定制构建完全一致的组件集合:
MathJax = {
loader: {
load: [ '[mml]/mml3', 'core', 'input/mml', 'output/svg' ]
}
};
这从另一个角度印证了 README 中组件清单的"最小必要集合"定位——无论是内嵌的定制构建还是外部系统 MathJax,Sigil 都只加载 [mml]/mml3、core、input/mml、output/svg 四类能力。
五、安全边界:MathJax 目录被列入白名单
Sigil 对预览页发起的 file: 请求有严格的白名单拦截,实现在 src/Misc/URLInterceptor.cpp:只有位于用户偏好目录、当前书籍目录或 Sigil 自带 MathJax 目录内的文件才被放行,其余一律 block(true) 阻止,防止预览页访问用户磁盘任意路径。
其中对 MathJax 目录的放行逻辑(L139-L143):
// or the path must be inside the Sigil's MathJax folder
if (destpath.startsWith(mathjaxfolder)) {
info.block(false);
return;
}
mathjaxfolder 正是上文 MainWindow::GetMathJaxFolder() 返回的路径。这意味着即便 EPUB 作者在书中嵌入指向 polyfills/custom-mathjax.min.js 的引用,Sigil 也只会把它当作受信任的本地资源处理,不会触发安全拦截——定制构建在此既是渲染能力,也是被严格管控的受信资源。
六、构建与部署要点小结
- 最低版本约束:MathJax 3.22 是必需的最低版本(README 原话 "Version 3.22 is the required minimum"),定制构建基于官方 MathJax-3.22 发布版制作;
- 产物形态:单文件
custom-mathjax.min.js,随构建复制到各平台安装目录的polyfills/子目录(对应 src/qt6sigil.cmake); - 功能范围:仅 MathML 输入 + SVG 输出 + mml3 扩展,通过
Loader.preLoad显式声明,并通过MathJax.config.mml.packages的'[+]'追加语法启用扩展; - 运行行为:预览窗口按需注入(仅含 MathML 的页面)、异步加载、支持编译期切换到系统 MathJax(
startup.js+ loader 配置); - 安全模型:MathJax 目录被
URLInterceptor白名单放行,与书籍目录、用户偏好目录同等受信。
七、给二次开发者的实践建议
若你需要维护或复刻这套定制构建,可直接以本仓库的 MathJax_README.md 为蓝本:下载官方 MathJax 3.22 源码,放置 custom-mathjax.js 与 webpack_config.js 两个文件,按官方"Making a Custom Build of MathJax"流程执行 webpack 构建,产物即为与仓库内 custom-mathjax.min.js 同源的最小化 bundle。若要调整功能范围(例如增加 TeX 输入或 CHTML 输出),只需修改 Loader.preLoad 的组件清单并保持 webpack_config.js 的包名一致即可——但需注意 Sigil 侧的脚本名、注入逻辑与安全白名单均以 custom-mathjax.min.js 为前提,改动产物文件名会牵动 MainWindow.cpp 的默认脚本名逻辑。此外,分发时务必保留 Apache 2.0 许可证文本 MathJax_License.txt,满足 MathJax 项目的再分发条款。