有时候芝麻大点事我会琢磨好几个月甚至几年(当然不会一直琢磨),解决起来可能会花一整天或者好几天。这回说说 MathJax 与 Markdown 的故事。注意我要说的是纯 Markdown,不是 R Markdown。
Markdown,简单。MathJax,简单。两个搁一块儿,说简单也可以简单,说复杂可以逼死我这样的强迫症。问题是什么呢?问题就在于,现今除了伟大的 Pandoc,多数 Markdown 引擎都并不支持数学公式,所以 $X_i + Y_i$
在这些引擎眼中并没有什么特殊含义,只是普通的 Markdown 文本。这就麻烦大了,因为数学公式里的某些字符对 Markdown 来说可能是特殊语法,例如下划线可能是表示斜体,那前面这个公式可能会被翻译为 $X<em>i + Y</em>i$
。垮了噻。
为了保护公式的内容不被 Markdown 引擎翻译掉,我们只有一条出路,就是把公式保护在代码标签中,也就是前后加两个反引号,例如 `$X_i + Y_i$`
,这样公式内部的字符不会被当做 Markdown 文本,而是会被原样输出,如生成 <code>$X_i + Y_i$</code>
。那么问题接踵而至:
-
MathJax 默认不检查
<code>
标签内部的数学公式。它在查找公式时会跳过一些 HTML 标签,如<pre>
、<code>
、<script>
等,因为这些标签里一般不会有人需要渲染数学公式。当然,这些需要跳过的标签是可以自定义的,你可以把<code>
排除在需要跳过的标签之外。<script type="text/x-mathjax-config"> MathJax.Hub.Config({ tex2jax: { skipTags: ['script', 'noscript', 'style', 'textarea', 'pre'] } }); </script>
-
MathJax 默认也不会把单美元符号识别为行内公式,因为单美元符号可能表示货币。例如一句话中要是包含
$50, $100
,那这里的50,
算不算数学公式?从上下文看可能不算。为了避免误判,MathJax 默认采取保险策略,只把这个奇怪的语法包含的内容当做数学公式:\( \)
。其实也不能算奇怪,因为它就是合法的 LaTeX 语法,在 LaTeX 中跟一对单美元符号基本上等价,只不过多数人嫌麻烦,都敲一对单美元符号而已。如果你想大胆启用单美元符号作为数学公式的标识,当然也不是不行:<script type="text/x-mathjax-config"> MathJax.Hub.Config({ tex2jax: { inlineMath: [['$','$'], ['\\(','\\)']] } }); </script>
问题是如果你真的想写一对美元符号,而不想让 MathJax 把它们当做数学公式标识的话,你得再用一招给自己擦屁股,就是配置选项
processEscapes: true
。它意味着你可以用反斜杠引导一个美元符号,好让它变成真正的美元符号。Mathjax 的文档我过去看过很多遍了,但不知道为什么直到今天才注意到这一点。于是:<script type="text/x-mathjax-config"> MathJax.Hub.Config({ tex2jax: { inlineMath: [['$','$'], ['\\(','\\)']], processEscapes: true } }); </script>
-
至此,非强迫症应该觉得任务已经完成了。我还有什么不满呢?我的不满来自于数学公式在代码标签
<code>
中,它让我设置起代码标签的 CSS 样式时很为难。一般来说,我喜欢行内代码标签背后稍微带一点背景色,以凸显它跟别的元素不一样(码农眼中代码总是要受到特殊待遇)。要是我给代码标签加了背景色,那数学公式也带背景色了!这怎么能忍?于是我想了个办法,把所有MathJax 识别的数学公式找出来,然后把它们头上的<code>
标签移除。以下用了 jQuery:<script src="https://cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-AMS-MML_HTMLorMML"></script> <script> MathJax.Hub.Queue(function() { var jaxs = MathJax.Hub.getAllJax(); for (var i = 0; i < jaxs.length; i++) { var $jax = $(jaxs[i].SourceElement()); if ($jax.parent().is('code')) $jax.unwrap(); } }); </script>
这个解决方案凑合能用,但又有两个逼死强迫症的问题:
-
加载 MathJax.js 的那个
<script>
标签不能用async
属性,也就是说不能异步加载,因为我下一段<script>
里马上就要用MathJax.Hub
对象。如果 MathJax.js 异步加载的话,就无法保证下面的代码能运行(运行下一段时上一段不一定加载完成了)。不能异步加载意味着页面初始加载速度会变慢,因为要活生生等着 MathJax.js 被完全下载下来。 -
初始情况下所有数学公式仍然都在
<code>
标签中,所以第一眼看去的时候其实还是能看见背景色的,等下面那段代码运行完了那些公式的背景色才会去掉。就这少说几十毫秒多则几秒的显示背景色的时间,也不能忍。
肿么破?必须先去掉数学公式的代码标签,后渲染数学公式,因为后者显著地慢,得后做。于是今天再想了个办法,在加载 MathJax 之前就自行识别数学公式,把代码标签去掉:
(function() { var i, text, code, codes = document.getElementsByTagName('code'); for (i = 0; i < codes.length;) { code = codes[i]; if (code.parentNode.tagName !== 'PRE' && code.childElementCount === 0) { text = code.textContent; if (/^\\\((.|\s)+\\\)$/.test(text) || /^\$(.|\s)+\$$/.test(text) || /^\\begin\{([^}]+)\}(.|\s)+\\end\{[^}]+\}$/.test(text)) { code.outerHTML = code.innerHTML; // remove <code></code> continue; } } i++; } })();
大意就是找出所有代码标签,一个个检查:如果里面的文本内容看起来像数学公式,就把代码标签去掉。当然还有一些其它限制条件,如不要处理
<pre>
标签里的代码,也不要处理含有别的标签的代码标签。code.outerHTML = code.innerHTML
是我从 Stack Overflow 上找来的神来之笔,简直太聪明。然而写这段代码的时候我掉进了一个大坑,琢磨了好半天才明白怎么回事,被 JavaScript 的强力活性坑了不止一次了但还是没记住。上面的
for
循环看起来有点不同寻常,普通青年也许会跟我刚开始一样这样写:for (i = 0; i < codes.length; i++) {}
然后我发现有些数学公式没被处理掉。数来数去,页面里的
<code>
标签数量比上面代码处理的标签数量少一些,最终发现,codes = document.getElementsByTagName('code')
这对象竟然是个活的!也就是说,我每删掉一个<code>
标签,它里面都会自动少一个元素(尽管我在循环之前就已经对codes
赋值了)。所以,我每删一个<code>
标签,都不能把i
增加 1 。比如删掉了第 9 个标签之后,原本的第 10 个标签就变成第 9 个标签了。只有一个标签没处理的时候,才需要i++
。这上哪儿说理去…… -
- 2023-09-12 更新
-
上面的坑可以如此这般更加优雅地填平,也就是用
[...]
生成数组然后用.forEach()
方法遍历其中的元素,这样就不需要管下标了。
总而言之,这段代码运行完之后数学公式脱离了代码标签,然后就可以异步加载 MathJax 渲染公式了,为自己和广大人民群众省下了好几毫秒的时间!
这一则故事告诉我们,尽管 Markdown 有很多种实现,但多数 Markdown 引擎的作者并不在乎科技论文写作,他们都没穿裤子在 Markdown 海中游泳。只有 Pandoc 对 Markdown 是真爱。如果你用基于 Pandoc 的 R Markdown,那么我上面说的所有一切都毫无意义:你可以正大光明用单个美元符号写公式,不必担心什么代码标签。对 blogdown 而言,用 R Markdown 的唯一缺点就是每个 Rmd 会生成一个 HTML 文件,这样会使得源代码库不那么干净(副产品也出现在源代码库中了),但就 Markdown 语法来说,Pandoc 绝对是无人能及。各路诸侯的野路子 Markdown 引擎,互相不那么兼容,也不那么严谨,真叫人无奈。
这就是一个给人擦屁股的强迫症的自白。以上。
哦,还有,之所以今天又捣鼓起 MathJax 来,是因为 MathJax CDN 要跑路了。感觉简直世界末日。我们有辣莫多的产品都用了这个 CDN 地址,却只有不到一个月的时间去调整。别介啊。