如果你曾经尝试写过有大量数学公式的博客文章,那我相信你一定因为数学公式渲染的问题而略微痛过。而如果你尝试在 EPUB 里面排大量数学公式,额……朋友,你现在还好吗……
如果你只是单纯想在浏览器里面把一个公式渲染出来,其实这并不难。一方面我们有 MathML 这样的标准,另外一方面像是 MathJax、KaTeX 这类渲染库都能把事情做好。但如果你稍微有那么一丢丢额外的追求,那么事情就会变得无比麻烦。
比如:Chromium 的 MathML 兼容性其实没你想象当中的好,Firefox 这边如果你把数学公式排到表格里就会发现版面很容易就会变得乱七八糟。如果你想要换个字体,那 KaTeX 就不是一个选项了,因为这个库是用自定义字体实现的部分排版功能,换了字体就只能渲染出来一片白了。如果你的环境没有 JS 这么高级的东西,那 MathJax 可能也不会是一个好的选项。
你问这世间难道还有没有 JS 的 Web 标准渲染环境么?对!EPUB!不仅大多数 EPUB 阅读器不能跑任何 JS 脚本,这些阅读器甚至连 SVG 都渲染不出来哦!事实上你买的绝大多数「改善你阅读体验」的电子墨水屏设备在「优质的阅读体验」这件事情上并没有任何追求,不仅对各种渲染和排版特性实现得一塌糊涂,有的时候还会往你的书里面加料导致渲染出来的东西变成一坨大便。
对!说得就是你!文石!当然,文石只是烂得最骄傲的那个,其他墨水屏阅读器虽然没见他们天天吹嘘自己的阅读软件设计得有多好,但就渲染引擎实现质量而言,同样是不及格的。
基本上每次做基于 Web 标准的排版时,我的血压都会高几天,恰好最近在排一本有大量数学公式的 EPUB 电子书,群友在开发自己的博客模板想要换字体。有两个人同时需要一个东西,那说明这就是一个「可以复用」的需求了(え?),于是我便着手封装了一个轮子。
Gladest
Gladest 是一个基于 Typst 的通用公式渲染引擎,着重处理 Web 标准当中的数学公式渲染问题。我当然知道你在 NPM 上能找到 114513 个渲染轮子,而我这第 114514 个也不是圆的。但我想简单阐释一下我做这个设计的价值取向。
用三个词来概括就是:「通用」、「便利」和「兼容」。
我希望我设计的渲染工具在某种程度上能够做到通用,不要写博客用一个工具渲染、做电子书用另外一个工具渲染,这堆工具各有各的脾气各有各的坑。我的目标是只有一套工具,对应的只有一个 Bug Set。因此我需要一个稳健的基底,并由此派生出来数个面向不同工作流的渲染工具。
比如我的 EPUB 的渲染工具链是先写 Markdown 文件,喂给一个 Bun 脚本来做一系列后处理整理格式,然后传到 pandoc 做转码,输出成 GladTeX 格式的 htex 文件,最后再交给 Gladest 做公式渲染。
再比如我们的博客都是用 markdown-it 做 Markdown 渲染的,那么我们就需要一个 markdown-it 的插件来处理内嵌公式。
我希望这个渲染引擎的开发和部署都尽可能简单,因此 lAtEX 就不是一个好的选项。毕竟那一大堆 TEx 版本和各种奇妙的插件兼容性问题,以及残暴的发行版尺寸都会让使用者感到痛苦。而站在其对立面的,就是「实现清爽」的 Typst 了。整个软件全部基于 Rust、编译工具链配置简单、FFI 到其他语言的轮子都非常圆、(基本)没有历史包袱、包管理机制无比科学、中文渲染配置难度颇低。
当然我不是说 Typst 在方方面面都比 LAtex 好,很多高级排版特性它还是做不到。但就公式渲染这一个话题来讲,Typst 已经是一个很好的选择了。最妙的是,Typst 生态中有一个叫做 mitex 的 lAtEx 兼容层,这意味着只要模板做对,你输入 LaTEX 它就能做正常的渲染了。
这里的「模板」和「引擎胶水」跟以前的搞法是一样的。用 typst_as_lib
把 Typst 封装成一个模板渲染库,把要用的中英文字体和模板全都打包到二进制里。然后,我们只需要写一个简单的 Rust 程序就可以解析 htex
文件,把 lateX 表达式变成嵌入式的图片了。
而 Markdown-it 这边也仅需一个简单的插件实现就能调到基础库的 API 做出渲染。
这里要提一嘴,Rust 做多线程并行运算很方便,而且 typst 的性能很好,因此 Gladest
的 htex
渲染要比 GladTex 快出好几倍,真的是告到不知哪里去了。
一些排版上的问题
上面只是简单的嘚啵一下我写了什么,接下来我想跟你讲讲真实的排版问题:文字和图片公式的「对齐」。而聊到 Inline 的排版问题,就不得不谈 Web 上的字体排版究竟是怎么工作的了,特别是在遇到不同字体混排、图文混排的时候。
四线格
先来帮 JS Boy 补一补基础设计知识:西文字体排印时,在垂直方向上的几条参考线。让我们来一起回忆一下在「四线格」上写字母的时候,都是怎么做的:

西文字体排印的时候,遵循的也是差不多的原理。比如字母「p、g」的圈圈、「h、A」的两只脚都踩在了「四线格」的第三条线上、而这第三条线就是「基线」。大部分字母(无论大小写)的底部都坐落在这条线上。它是字母垂直对齐的基准,字母主体部分的底部基本都落在这条线上。
字母「e、x、m」都被写在了第二条和第三条线之间,我们可以将这「第二条线」视作小写字母主体部分的顶端,这条先被称作「主线」或是「字高线」,而第二条线和第三条线之间的距离被称作「x 字高」(x-height)。x 字高对字体的易认性影响很大,尤其是在小字号时。较高的 x 字高通常让字体看起来更清晰、更现代。
几乎所有大写字母都会写在第一条和第三条线之间,而第一条和第三条线之间的距离被称作「大写高度」(Cap Height)。不过,有些大写字母(如 ‘O’, ‘Q’)为了视觉平衡,顶部或底部可能会稍微超出一点点大写高度线或基线。
接下来,是升部线和降部线。升部线的概念跟手写实作可能有些距离,在印刷设计时为了追求视觉平衡,有些字母可能会比「四线格」高出一些,而定义「最多超出多少」的基准就是升部线。与之对应,向下「最多超出多少」的基准就是降部线。
最后,我们谈谈数字字体的基础单位:UPM(Units Per eM)。可以理解为「每 Em 里的单位数」。在数字字体设计中,每个字符都在一个名为 Em Square 的虚拟方形空间内,通过坐标点精确绘制。UPM 值定义了这个 Em Square 被细分成了多少个基本单位(常见值为 1000、1024 或 2048 等)。更高的 UPM 意味着更精细的绘图网格,允许设计师创作出更平滑的曲线和更精确的细节。
那么,前面提到的基线、主线、大写高度线、升部线、降部线在字体文件内部是如何定义的呢?它们的位置正是通过 UPM 单位来精确指定的。这与我们手写时可能遇到的等分四线格不同,字体设计师会根据字体的风格和视觉需求,自由设定这些线条之间的相对距离(如 x 字高与大写高度的比例、升部和降部的大小等),从而赋予字体独特的个性和外观。
字号与对齐
在图文混排的时候,图片元素的底部是跟文本基线对齐的。这意味着,你渲染出来的图片公式的「降部线」跟主体文本的「基线」被对到了一起。And, that’s how things getting fucked.
你或许会说我们可以用 CSS 里面的 Vertical Align 属性来调整对齐方式吧?但你翻遍了文档也没找到一个属性能把没有基线的图片跟有基线的字体拉到一个水平面上。
另外,图片的尺寸如何调整也是一个很抽象的事情。在大多数情况下,你用的正文字体跟公式字体并不是同一种字体,可能那几根线就没有能一个能对到一块的。这个时候通行的实践是基线对齐后、调整字体的大小使大写高度一致。
这意味着你需要对字体有相当程度的了解,你必须得知道当下每款字体的参数分别是多少,做对齐的时候才能有所依据。
这就延伸出了一个问题,你设置的 CSS 属性当中有数个 Fallback 字体,每款字体的字体参考线配置统统不一样,这还怎么玩。
对,大多数情况下这事情就没得玩了。这也是为什么哪怕是少数派这种「头部互联网媒体」也在「文本垂直对齐」这件事上做得漓漓拉拉,只要你在用的不是苹果设备,那就没有一个东西能对得齐。
可耻。
针对这个问题,我曾提出过一个主张:「在 2025 年,无论是 CJK 字体还是西文字体,为设计品质负责的开发者都有义务自己提供 Web Font」。你可能会抱怨「可是中文字体文件很大捏~」,但一方面 WOFF2 字体格式的压缩效率已经很高,另外一方面你完全可以通过把文件拆分得很细来减少用户的冗余下载量。另外,通过合理的 CSS设置可以做到「当用户电脑上已经安装了这款字体就不再自行下载」的效果,而 HTTP2 的存在也提升了大量细碎字体文件的下载速度。
因此,在 2025 年,你没有理由不提供 Web Font,在这里我们也呼吁所有字体厂商,特别是开源字体厂商,请着手提供切分过的字体文件,拯救残破的 CJK 互联网阅读体验。
IBM Plex Sans 的 CJK 字体在这方面做得很好,如果你下载了它的字体包,会发现里面除了完整字体外还有一个「Splitted」目录,里面陈列了大量零碎的字体文件,还有一个汇总的 CSS 文件。你只需引入这个 CSS 就能获得很一致的视觉效果了。
Typst 与 Web 的对齐
公式排版是一个更让人上火的事情,因为公式这东西它不一定只有一行,像是根号、分数、求和、积分等排版都会撑开版面。所以你不太能统一把每一个图片都固定到某一特定高度,以达成对齐的效果。
另一方面,你的用户用着各种分辨率的设备,而你的图片自输出的那一刻起变固定了分辨率,倘若你不把输出文件尺寸开大一点,就会遇到高分屏下图片变糊的问题,而输出高分辨率图片之后,又会遇到不知如何设定图片尺寸的问题。
最后,Typst 跟 Web 引擎是两个系统,两边的一像素并不一定「一样大」,你的一像素不一定是我的一像素,所以怎么让两个系统「互通有无」就是一个很棘手的问题了。
我们的思路是:既然二者渲染的都是「字体」,那不如就都来用「em」为单位拉齐座标系统。你或许熟悉「em」这个单位,但不一定知道得那么清楚:
「em」的名称源自字母「M」。在传统排版中,大写字母「M」通常是最宽的字符,因此被用作测量单位的基准。
在数字字体设计中,em 是一个相对度量单位,表示字体设计的整体高度空间。从历史上看, em 源自金属活字时代大写字母「M」的宽度,现在则表示字体设计的方形空间。而先前我们讲的 UPM (Units Per eM) 是定义这个 em 方形空间内有多少个基本单位的参数。
在 Web 环境中,em是相对单位,会随上下文环境改变,其大小取决于当前的字体大小。在排版中,1em 等于当前上下文的字体大小。例如,如果字体大小设置为 16px,那么 1em 就是 16px。em 的相对性使其非常适合用于创建可缩放的布局和设计。
在 Gladest 的渲染引擎中,我们在输出 img 标签的同时,也会通过 style
标签告知渲染环境自己在渲染上下文当中究竟应该占多大尺寸,然后再由浏览器引擎通过计算决定最终的输出图片的长宽。
Web 上的实装问题
不过,这里还有一丢丢坑,如果你读过源码会发现在 typst 模板里,我给输出图片加了一个固定的页边距 0.455em。这是因为 Typst 在公式渲染这块硬编码了一个向内塌陷边距以确保大公式不会把行距挤变形[1]。如果我不加这个边距输出的公式就会被裁掉一块,加上了之后 Web 上又会出现上下留大空白的情况,为了处理这件事你需要给所有公式加一个 margin: -0.455em 0
的 CSS 设置。
不过为了拉齐基线,你可能需要让 margin-bottom
再大一些,为了让大写高度一致,你可能需要调整 Gladest 输出图片的 font-size
属性。都是简单的数学问题,在这里就不多做赘述了。
后续工作
目前版本的 Gladest 还没做自定义字体的功能,我想再多花一点时间确保基础功能稳定之后再去研究字体元信息解析这个问题。目前我的博客正在重构,估计在博客重构做完的时候 Gladest 的自定义功能会(被迫地)变得更加完备。
以上就是今天的简要分享,莉莉爱你 ♥(´∀` )人。
注:本文的 LATEx 没有一个按照官方推荐的大小写规则撰写,此乃刻意为之。
Loading comments...