lualatex 并不是 LuaTeX。在 TeX Live 2024 上顺着符号链接看下去,它指向的不是 luatex,而是 luahbtex——一个内建 HarfBuzz 的另一个二进制。fmtutil.cnf 里也是同样的说法:lualatex luahbtex ... lualatex.ini,连格式本身都是由 LuaHBTeX 生成的。运行时横幅自报家门为 This is LuaHBTeX, Version 1.18.0 (TeX Live 2024),只是很容易被一眼扫过去。本页从这个 LaTeX 引擎的真身讲起,说明 Lua 究竟嵌进了排版的哪一处(\directlua 与回调),LuaJITTeX 如今变成了什么,以及「LuaLaTeX 慢」这个名声在实测中究竟有几分为真。
lualatex 的真身是 LuaHBTeX:HarfBuzz 加了什么
它把整个 HarfBuzz 装进了二进制里。 LuaHBTeX 是 2020 年进入 TeX Live 的,lualatex 背后的那个程序就是那时被换掉的;当年的发行说明写得很清楚:LuaTeX 本身仍是普通的 LuaTeX,改变的只有 LaTeX 的格式。一行代码即可确认:把 \directlua{tex.print(status.luatex_engine)} 用 lualatex 编译,页面上印出的是 luahbtex;用 luatex 编译,则印出 luatex。再试 require("luaharfbuzz"):在 luahbtex 下成功,报出 HarfBuzz 8.3.0;在原始 luatex 下则失败于 module 'luaharfbuzz' not found。HarfBuzz 并不是磁盘上某处的一个文件,而是静态链接进可执行程序里的,所以事后没有任何宏包能补上它——这也正是它要作为独立二进制发行的原因。
$ readlink $(which lualatex)
luahbtex
$ grep -E '^lualatex ' $(kpsewhich fmtutil.cnf)
lualatex luahbtex language.dat,language.dat.lua lualatex.ini
$ lualatex --version | head -1
This is LuaHBTeX, Version 1.18.0 (TeX Live 2024)
# Ask the engine what it is, from inside a document:
% \directlua{tex.print(status.luatex_engine)} -> luahbtex
% \directlua{tex.print(_VERSION)} -> Lua 5.3
% \directlua{local hb = require("luaharfbuzz")
% tex.print(hb.version())} -> 8.3.0实务上的含义是这样的:字形整形有两条路径,默认走的是 luaotfload 自己用 Lua 写的整形器。在 fontspec 里指定 Renderer=Harfbuzz,就会切到 HarfBuzz 一侧——日志里能看到 luaotfload.harf.finalize_hlist 之类被插进 hpack_filter 与 post_linebreak_filter。对整形复杂的文字体系,例如藏文或孟加拉文,HarfBuzz 更准确,这本来就是造出 LuaHBTeX 的动机。若文档全是拉丁字母,基本看不出差别。
LuaTeX 是谁、为了什么造的
敲 luatex --credits,开发团队列出的是 Hans Hagen、Hartmut Henkel、Taco Hoekwater、Luigi Scarso,接着是它吸收的项目清单:TeX(Knuth)、e-TeX(Peter Breitenlohner 等)、Omega(John Plaice、Yannis Haralambous)、Aleph(Giuseppe Bilotta)、pdfTeX(Hàn Thế Thành)、MetaPost、Lua,以及标注着「用于 LuajitTeX」的 LuaJIT(Mike Pall)。Omega 与 Aleph 这一支多语言引擎的血脉,是很少有人知道的部分。开发起点在 ConTeXt 圈子,另一处则是 Oriental TeX 项目——一项面向阿拉伯语学术排版、尤其是校勘本的工作,由科罗拉多州立大学哲学系的 Idris Samawi Hamid 提出的经费资助了核心部分的重写;荷兰的 NTG、TUG 与德国的 DANTE 也提供了资金。「让 TeX 可编程」这个想法,源自一个非常具体的愿望:把阿拉伯文排得漂亮。
由于底座是 pdfTeX,LuaTeX 直接写出 PDF,并继承了 e-TeX 扩展与 pdfTeX 的大部分排版机制。不过 \pdf... 命名空间被整理过:其中许多如今经由 \pdfextension、\pdfvariable、\pdffeedback 三个入口接收关键字与参数,而 \pdfoutput 改名为 \outputmode,\pdfximage 改名为 \saveimageresource。日常写作中几乎不会手写这些,LaTeX 宏包会吸收差异。输入从一开始就是 UTF-8,字体和 XeTeX 一样可以用 fontspec 按系统名字指定——但负责载入的是 LuaTeX 自己用 Lua 写的加载器 luaotfload,它由 ConTeXt 的字体加载器改造而来,面向 Plain TeX 与 LaTeX,现由 LaTeX 团队的 latex3 维护。
\directlua:在文档中途运行 Lua
\directlua{...} 会当场立即执行其中的 Lua,而 tex.print(...) 送回来的字符串会回到 TeX 的输入流,被当作普通文本排版。这里有一件事,凡是第一次动手的人都会撞上:\directlua{tex.print(2^10)} 印出来的不是 1024,而是 1024.0。因为 LuaTeX 用的 Lua 5.3 里,^ 永远返回浮点数。想要整数就写 math.tointeger(2^10)。同样的性质在把 Lua 的计算结果赋给长度或计数器时也会咬人,LuaTeX 手册明确提醒 tostring 与 string.format 可能返回科学计数法,从而把 TeX 那一侧搞糊涂。
\documentclass{article}
\begin{document}
% careful: 2^10 is a float in Lua 5.3, so this prints 1024.0
Two to the tenth is \directlua{tex.print(2^10)}.
% and this prints 1024
Two to the tenth is \directlua{tex.print(math.tointeger(2^10))}.
\end{document}它的兄弟 \latelua{...} 则在它所在的那一页被输出(shipout)时才运行。那些必须等页码与最终坐标定下来才能做的事——直接往 PDF 上绘制、贴上注释——归它管。把两者并排写进一份文档编译,日志会清楚显示这个差别:\directlua 的输出出现在 [1 之前,\latelua 的输出出现在其后。另有一条实务提示:原始的 \directlua 对花括号与特殊字符很挑剔,写长一点的 Lua 时请改用 luacode 宏包的 luacode 环境,可以原样粘贴且安全。
回调:把自己的函数插进排版过程
LuaTeX 真正的力量在回调。 TeX 把文本装配成一条由节点——字符、盒子、以及可伸缩的粘连(glue)——构成的链表;只要注册一个回调,你自己的 Lua 函数就会在每个这样的关节被调用,可以自由地检视并改写那条节点链表。TeX Live 2024 的 LuaTeX 公开了 74 个回调,手册把它们归成六类:文件查找、数据处理、节点链表处理、信息报告、PDF 相关、字体相关。下面的例子是真能跑的:它数出一个段落被断成了几行,并在运行结束时报出总数。
\documentclass{article}
\usepackage{luacode}
\begin{luacode*}
local count = 0
luatexbase.add_to_callback("post_linebreak_filter",
function(head)
for line in node.traverse_id(node.id("hlist"), head) do
count = count + 1
end
return head
end, "count lines")
luatexbase.add_to_callback("stop_run",
function() texio.write_nl("LINES TYPESET: " .. count) end, "report")
\end{luacode*}
\begin{document}
This paragraph is broken into lines by TeX, and the Lua function
registered on post\_linebreak\_filter counts them as they go past.
\end{document}例子里用 luatexbase.add_to_callback 而不是原始的 callback.register,是因为后者每个回调只允许挂一个函数。在 LaTeX 的世界里,好几个宏包都想在同一个位置介入,于是 luatexbase 把它们收拢成一份有序列表。操作节点的工具是一组从 Lua 可见的表:tex 是通向 TeX 内部状态(寄存器与长度)的窗口,node 负责创建、遍历与释放节点,token 处理记号(TeX 意义上的最小单位),font 处理字体数据,status 提供运行时信息。像 luaotfload 这样高级的字体机制,也正是用 Lua 建在这套底座之上的。
| 回调 | 触发时机 | 典型用途 |
|---|---|---|
process_input_buffer | 每读入一行输入时 | 输入的预处理(接收字符串,返回字符串) |
pre_linebreak_filter | 段落被断成行之前 | 在断行前改写节点链表 |
post_linebreak_filter | 断行完成之后 | 对已排好的每一行动手 |
hpack_filter | 每次组成一个水平盒子时 | 事后调整盒子里的内容 |
ligaturing / kerning | 构建连字与紧排的阶段 | 替换字体自带的字距处理 |
stop_run | 整趟运行的最后 | 汇总报告与收尾 |
LuaJITTeX 现在怎么样了
它并没有消失。 TeX Live 2024 里装着 luajittex 与 luajithbtex 两个可执行文件,版本都是 1.18.0,fmtutil.cnf 中也有对应的格式定义。里面装的不是参考实现的 Lua,而是 LuaJIT——一种在运行时编译成机器码的即时编译实现。直接问它,jit.version 回答 LuaJIT 2.1.0-beta3,而 _VERSION 回答 Lua 5.1。也就是说,LuaJITTeX 的语言级别停在 5.1,与主线 LuaTeX(Lua 5.3)跑在不同的语言规范上。LuaTeX 手册也明说了这一点:LuaJIT 未与常规 Lua 的开发同步,因此 LuaJITTeX 会落后。
这个差别不是纸上谈兵。用四个二进制分别编译完全相同的一行 \directlua{tex.print(2^10)},luatex 与 luahbtex 印出 1024.0,而 luajittex 与 luajithbtex 印出 1024——因为 Lua 5.3 区分整数与浮点数,5.1 不区分。仅仅换个引擎,文档的内容就变了,这正是不宜把 LuaJITTeX 当作 LuaLaTeX 的直接替代来推荐的原因之一。还有第二件、更具决定性的事实:fmtutil.cnf 为 LuaJIT 系引擎定义的只有 Plain 形式的格式,并不存在与 luajitlatex 对应的东西。想让 LaTeX 跑在 LuaJIT 之上,就得自己造格式;因此准确的说法是:它实际上并不在 LaTeX 用户的选项之列。
另一条继承线是 LuaMetaTeX:把 LuaTeX 削减后重写的精简版,是现代 ConTeXt(LMTX / MkXL)的底座。它重做了数学排版,加入大量新原语,语言用的是 Lua 5.4 一系。TeX Live 2024 随附 luametatex 2.11.02,而 context 命令本身就是指向它的符号链接。分工很清楚:写 LaTeX 用 LuaTeX,用最新的 ConTeXt 就用 LuaMetaTeX。
LuaLaTeX 真的慢吗——实测
确实更慢,但人们常把慢的位置搞错。 启动本身几乎没有区别:一份近乎空白的单页文档,pdfLaTeX 用 0.20 秒,XeLaTeX 0.30 秒,LuaLaTeX 0.32 秒(同一台机器,多次运行取最快值)。差距拉开的是随排版量增长的那一部分。同一份 417 页的数学文档,pdfLaTeX 用 0.40 秒,XeLaTeX 0.53 秒,LuaLaTeX 1.41 秒。扣掉启动、只看排版本身:XeLaTeX 约为 pdfLaTeX 的 1.2 倍,LuaLaTeX 则超过五倍。反过来说,十来页的论文,三者的差别根本感觉不到。
| 测量项 | pdfLaTeX | XeLaTeX | LuaLaTeX |
|---|---|---|---|
1-page document | 0.20 秒 | 0.30 秒 | 0.32 秒 |
417-page document | 0.40 秒 | 0.53 秒 | 1.41 秒 |
typesetting only | 基准 | 约 1.2 倍 | 约 5.6 倍 |
实务上有三条对策。其一,写作期间要减少往返次数,而不是追求单次速度——用 latexmk 之类只重编改动部分的自动构建,比换引擎更管用。其二,别把它和字体缓存的一次性成本混为一谈。 luaotfload 遇到没见过的字体会扫描并建立索引,那一趟会异常缓慢。所谓「LuaLaTeX 要跑几十秒」的说法多半就是这个,从第二次起就回到上表的数值。其三,在 CI 里要数往返与重跑的次数,而不是单次编译的开销。 如果构建为了交叉引用与目录要跑两三遍,每遍的差距就会被放大三倍。
内建 MetaPost 与日文(LuaTeX-ja)
LuaTeX 把绘图引擎 MetaPost 以库的形式内建为 MPlib,因此可以在同一个进程里生成图形,完全不必调用外部程序。从 LaTeX 一侧经由 luamplib 宏包,在 mplibcode 环境里直接书写 MetaPost 代码即可。实际编译一遍再查日志,找不到任何启动外部 mpost 的痕迹。这一点看似不起眼却很实用:在关闭 shell escape 的环境和 CI 里,图形照样能生成。
日文排版由 LuaTeX-ja(宏包 luatexja)负责。它在 Lua 一侧用回调重新实现了 pTeX 积累的日文排版知识——竖排、基于 JFM(Japanese Font Metrics) 的字距与标点处理、和文与西文之间的间隔——因此可说是本页所述回调机制最大的实例。配合 luatexja-fontspec,也能用 fontspec 的写法选择日文字体。但要注意:LuaHBTeX 的 HarfBuzz 整形与 LuaTeX-ja 的竖排、CID 机制的组合需要谨慎。 LuaTeX-ja 手册提醒,经由 HarfBuzz 定义的日文字体可能产生意料之外的结果。正式稿件请先用 TeX Live 附带的原之味字体与标准设置跑通 PDF,OpenType 特性或 HarfBuzz 选项则先在小规模试验中验证再引入。
\documentclass{ltjsarticle}
\usepackage{luatexja-fontspec}
% Harano Aji ships with TeX Live, so this builds anywhere
\setmainjfont{HaranoAjiMincho-Regular}
\setsansjfont{HaranoAjiGothic-Medium}
\begin{document}
\section{日本語}
Text and mathematics $E=mc^2$ go through the same engine.
\end{document}用 LuaLaTeX 开始一份新文档
- 从 Unicode 源文件开始。 不要
inputenc,也不要fontenc。带着 pdfLaTeX 时代的声明迁移,换来的只是警告和重复的字体设置。 - 让
fontspec成为选字体的唯一入口。 对于共享的稿件,先做到用 TeX Live 附带字体能构建,再逐个替换。 - 日文用
luatexja。 竖排、JFM,以及和文与西文之间的间隔都由它照管。 - 较长的 Lua 放进
luacode环境。 原始的\directlua对花括号与特殊字符很挑剔。 - 回调用
luatexbase.add_to_callback注册。 原始的callback.register每个挂点只允许一个函数。 - 先做一份小样。 用一页把正文字体、日文字体、数学与参考文献都跑一遍,然后拿它当模板。
一句话的判断标准是:想伸手进排版本身就用 LuaLaTeX;只想按名字使用系统字体就用 XeLaTeX;以英文为主、看重速度与兼容性就用 pdfLaTeX。 三者的正面比较另有专页。由于 LaTeX 团队把新开发的目标定在 LuaTeX 上,如今新起的 Unicode/OpenType 工作流——尤其是需要写点处理逻辑的场合——LuaLaTeX 正在逐步成为默认选择。