Sigil 内嵌 MathJax 3.22 定制构建解析:从 MathML 到 SVG 的最小化 polyfill 方案

原创2026-10-03 13:06:331,407 阅读
文章标签:桌面应用文档

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 项目的再分发条款。

登录后查看全文
Sigil