LuaTeX / LuaJITTeX

lualatex は LuaTeX ではありません。TeX Live 2024 で lualatex の実体をたどると、リンク先は 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 が TeX Live に入ったのは 2020 年で、そのとき 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 はファイルとしてどこかにあるのではなく、バイナリに静的に組み込まれている ので、後からパッケージで足すことができません。だから別の実行ファイルとして配られているわけです。

shell
$ 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 で書いたシェイパーを使います。fontspecRenderer=Harfbuzz を指定すると HarfBuzz 側に切り替わり、ログを見ると luaotfload.harf.finalize_hlist などが hpack_filterpost_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\setmainfont\setsansfont\setmonofont で OS のものを名前指定できますが、読み込むのは 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 のマニュアルも tostringstring.format が科学記法を返して TeX 側を混乱させうる、と注意しています。

latex
\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 の出力が [1 の後ろに現れて、順序の違いがそのまま見えます。なお生の \directlua は波括弧や特殊文字の扱いが神経質なので、長い Lua を書くときは luacode パッケージの luacode 環境を使うと、そのまま貼り付けられて安全です。

コールバック — 組版の途中に自分の関数を差し込む

LuaTeX の本当の力はコールバックです。 TeX は文章を ノード——文字・ボックス・グルー(伸縮する空き)——の連結リストとして組み立てていきますが、コールバックを登録しておくと、その節目ごとに自分の Lua 関数が呼ばれ、ノードリストを覗き、書き換える ことができます。TeX Live 2024 の LuaTeX が公開しているコールバックは 74 個で、マニュアルはこれをファイル探索・データ処理・ノードリスト処理・情報報告・PDF 関連・フォント関連の 6 群に整理しています。下の例は本当に動くもので、段落が何行に組まれたかを数えて実行の最後に報告します。

latex
\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 には luajittexluajithbtex の二つの実行ファイルが入っており、どちらもバージョン 1.18.0、fmtutil.cnf にフォーマットの定義もあります。中身は本家の Lua ではなく LuaJIT——実行時に機械語へコンパイルするジャストインタイム方式の処理系です。走らせて確かめると jit.versionLuaJIT 2.1.0-beta3 を返し、_VERSIONLua 5.1。つまり LuaJITTeX は言語のレベルが 5.1 に据え置かれており、本流の LuaTeX(Lua 5.3)とは別の言語仕様で動いています。LuaTeX のマニュアルもこの点を明記していて、LuaJIT が通常の Lua の開発と歩調を合わせていないため LuaJITTeX は遅れる、と述べています。

この違いは机上の話ではありません。まったく同じ一行 \directlua{tex.print(2^10)} を四つのバイナリで組むと、luatexluahbtex1024.0 を、luajittexluajithbtex1024 を刷ります。Lua 5.3 が整数と浮動小数点を区別するのに対し、5.1 は区別しないからです。エンジンを替えただけで文書の中身が変わるわけで、これが「LuaJITTeX を LuaLaTeX の代わりに使う」ことを勧めにくい理由のひとつです。そしてもう一つ、より決定的な事情があります——fmtutil.cnf が LuaJITTeX 系に定義しているのは 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 は 5 倍以上 かかっています。逆に言えば、10 ページ程度の論文なら三者の差は体感できません。

測定pdfLaTeXXeLaTeXLuaLaTeX
1-page document0.20 秒0.30 秒0.32 秒
417-page document0.40 秒0.53 秒1.41 秒
typesetting only基準約 1.2 倍約 5.6 倍

実務での対処は三つあります。ひとつ、執筆中は速さより往復回数を減らすこと——latexmk のような自動ビルドで差分だけ回すほうが、エンジンの選択より効きます。ふたつ、フォントキャッシュの初回コストを混同しないことluaotfload は新しいフォントを見つけるとスキャンして索引を作るので、その一回だけ極端に遅くなります。「LuaLaTeX が数十秒かかる」という話の大半はこれで、二回目からは上の表の値に戻ります。みっつ、CI では PDF 生成そのものより往復と再実行の回数を数えること。相互参照や目次のために 2〜3 回まわす設計になっていれば、1 回あたりの差が 3 倍に増幅されます。

MetaPost 内蔵と日本語(LuaTeX-ja)

LuaTeX には作図エンジン MetaPost がライブラリ MPlib として組み込まれており、外部プログラムを呼ばずに 同じプロセスの中で図を生成できます。LaTeX からは luamplib パッケージ経由で、mplibcode 環境の中に MetaPost のコードをそのまま書きます。実際にコンパイルしてログを確認すると、外部の mpost を起動した形跡はどこにもありません。shell escape を切った環境や CI でも図が作れるということで、これは地味に効きます。

日本語組版は LuaTeX-ja(パッケージ luatexja)が担当します。pTeX が持っていた日本語組版の知見——縦組み、JFM(Japanese Font Metrics) に基づく字間や約物の処理、和文と欧文のあいだの空き——を、Lua 側でコールバックを使って実装し直したもので、このページで見てきたコールバック機構の最大の実例と言えます。luatexja-fontspec を使えば fontspec 流の書き方で和文フォントも選べます。ただし LuaHBTeX の HarfBuzz シェイピングと LuaTeX-ja の縦組み・CID 機構の組み合わせには注意が必要 で、LuaTeX-ja のマニュアルは HarfBuzz 経由で定義した和文フォントが意図しない結果を生みうると警告しています。原稿では TeX Live 同梱の原ノ味フォントと標準設定でまず PDF を通し、OpenType 機能や HarfBuzz オプションは小さな試験で確かめてから入れるのが安全です。

latex
\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 の原稿から始める。 inputencfontenc も入れません。pdfLaTeX 時代の指定を残したまま移行すると、警告とフォント設定の二重化を抱え込みます。
  • フォントは fontspec に一本化する。 共有する原稿では、まず TeX Live 同梱のフォントでビルドが通る状態を作ってから差し替えます。
  • 日本語は luatexja 縦組み・JFM・和欧文間の空きまで面倒を見ます。
  • 長い Lua は luacode 環境に入れる。 生の \directlua は波括弧と特殊文字に神経質です。
  • コールバックは luatexbase.add_to_callback で登録する。 生の callback.register は一箇所に一つしか関数を許しません。
  • 最初に小さな見本を作る。 本文フォント・和文フォント・数式・文献リストを一枚で通す原稿を用意し、それをテンプレートにします。

ひとことでの判断基準は「組版そのものに手を入れたいなら LuaLaTeX、OS のフォントを名前で使いたいだけなら XeLaTeX、欧文中心で速さと互換性が要るなら pdfLaTeX」です。三者を正面から比べたページは別にあります。LaTeX チームが新しい開発の的にしているのは LuaTeX なので、いま新規に始める Unicode/OpenType のワークフローでは、とくに処理を書きたい場面で LuaLaTeX が既定に近づきつつあります。