LuaTeX の普及状況を LuaTeX で調べてみた

2019-12-23   #LuaTeX  #Lua  #PDF 

本稿は TeX & LaTeX Advent Calendar 2019 の23日目の記事です.22日目は puripuri2100 さんでした.24日目は golden_lucky さんです.

2016年に LuaTeX 1.0 がリリースされてからおよそ3年が経ち,体感ではありますが私の周辺では LuaTeX の存在感は徐々に増してきているように思われます.LuaTeX が日本で広まっていく上での障壁として「日本語の文献が少ない」ということがかねてから指摘されていましたが,定期的に開催される技術書典で LuaTeX をテーマとする同人誌が複数頒布されてきたこと,恒例の TeX & LaTeX Advent Calanedar の重点テーマに2年連続で LuaTeX が採択されたことなどがあり,LuaTeX に関する日本語の解説もその量が増えてきました.また LaTeX 文書クラスも ltjs 系クラスbxjs 系クラスに加えて jlreq クラスと LuaTeX に対応しているものの選択肢が増えてきており,活躍の幅も広がっています.本稿を読んでいる皆さんも,一度はお使いになったことがあるでしょうか?

今回はそんな LuaTeX が現在どのぐらいのシェアを誇るのか,調査することを目標にしてみたいと思います.LuaTeX のシェアを調査するといいましたが,実はこれはそんなに簡単なことではありません.世界中で LuaTeX が実行される回数を厳密にカウントすることはもちろん不可能ですし,周囲への聞き取り調査や Twitter アンケートを行ってもデータが偏ってしまうでしょう.一般的なソフトウェアであれば,ダウンロード数からシェアを予測するという方法もありますが,TeX の世界はというと,様々な TeX 処理系が全部入りの TeX Live フルスキームをインストールするのが一般的であり,ダウンロード数からシェアを推測するという手は使えません.

どのような方法を採るにせよバイアスを完全に取り除くのは困難なので,今回は開き直って,敢えて偏った対象を選んでシェアを計算してみたいと思います.具体的には,世界の TeX 系開発者における LuaTeX のシェアに焦点を絞ります.これであれば TeX Live に含まれる処理系や文書クラス,パッケージ等々のドキュメントの組版に用いられている各 TeX 処理系の割合を求めることで,大勢を見て取ることができそうです.TeX Live に成果物を上げている開発者の中での処理系シェアを知ることは,今後の TeX 界の動向を予測する上でも役立つでしょう.

PDF の生成プログラムを推測する

TeX Live には各パッケージのプログラム本体とは別に,付属ドキュメントが含まれています.こうしたドキュメントの中には TeX 組版とは無関係のテキストファイル(例えば README)も多数含まれていますが,今回はそうしたものは対象としないことにし,最終的に PDF となっているドキュメントのみをシェア計測の対象にします.対象を TeX Live に含まれる PDF ドキュメントに絞ると,そのほとんどは TeX 組版されたものということになります.

TeX Live には最終成果物である PDF の他に,それらのソース(主に TeX ファイル)も含まれているのが通常です.そのため,PDF 生成に用いられているプログラム(TeX 処理系など)を推測する方法は,ソースから推測するアプローチと PDF から推測するアプローチの2通りが考えられます.TeX ソースから処理系を推測することについては先行事例もありますが,これは意外と大変です.場合によっては LaTeX のクラスオプションや使用パッケージから推測できる場合もありますが,必ずしも自明ではありません.例えば次の LaTeX 文書はほとんどあらゆる TeX 処理系でコンパイル可能なため,作者でもない限り何で組版すべき TeX ソースなのかはわかりません.

\documentclass{article}
\begin{document}
Hello {\TeX}!
\end{document}

TeX ソースと PDF の双方が入手可能な場合,実は組版結果の PDF を見た方が生成プログラムの推測は簡単です.PDF には,文書本体には印字されない「文書プロパティ」と呼ばれるデータが含まれており1,文書の作成者,タイトル,キーワードなどの情報を埋め込んでおくことができます.LaTeX を用いて PDF を作成する場合,何らかのパッケージ2を用いて明示的に値を指定しない限り,これらのほとんどの値は空のままになるため,実際には設定されていないことがほとんどです.しかし,幸いにも “PDF Producer” という PDF 生成を行ったプログラムの情報を格納する項目は,多くの PDF 生成ツールが規定で値を挿入するため,ほとんどの TeX 製 PDF にこの情報が含まれています.

試しに,上記の単純な LaTeX 文書をさまざまな TeX 処理系でコンパイルして,出来上がった PDF を Acrobat Reader で開いて文書プロパティを確認してみましょう3.以下に,手許の TeX Live 2019 で試した結果を載せておきます.

【LuaTeX 製 PDF の文書プロパティ】

LuaTeX 製 PDF の文書プロパティ

LuaTeX で PDF を作成する場合,LuaTeX 自体が PDF の出力まで自力で行うため,“PDF Producer” は LuaTeX-<version> の形で埋め込まれていました.その上の“Application” 項目は単に TeX となっています.

【XeTeX 製 PDF の文書プロパティ】

XeTeX 製 PDF の文書プロパティ

XeTeX で PDF を作成する場合は,直接的には XeTeX 向けに拡張された dvipdfmx である xdvipdfmx が PDF 生成を担うため “PDF Producer” は xdvipdfmx (<version>) の形式になっています.XeTeX の場合は “Application” 項目も XeTeX output <date> という独自の値になっているようです.

【pdfTeX 製 PDF の文書プロパティ】

pdfTeX 製 PDF の文書プロパティ

pdfTeX はもちろん自前で PDF を出力する機能を備えていますので,“PDF Producer” は pdfTeX-<version> の形です.“Application” 項目も LuaTeX 同様単に TeX となっています.つまり,残念ながら “Application” 項目の値を利用して PDF 生成に用いられたプログラムを判定するのは難しそうです.

【pTeX + dvipdfmx 製 PDF の文書プロパティ】

pTeX + dvipdfmx 製 PDF の文書プロパティ

pTeX の場合は,オリジナル TeX と同様,出力は PDF ではなく DVI なので,別のツールを用いて PDF に変換してやる必要があります.DVI → PDF 変換ルートは複数ありますが,近年は(少なくとも日本では)dvipdfmx4 を用いるのが一般的なので,この例でもそのフローを採用しています.

以上から,PDF 文書プロパティの “PDF Producer” を見ることにより,少なくとも標準的な方法で作成された PDF であれば,どの TeX 処理系を用いて作成したものか推定できるということがわかりました.もちろん,このアプローチは完璧というわけではなく,dvipdfmx で変換された PDF の変換元 DVI がどの TeX 処理系で生成されたものかを推定することは困難です5.ただ,今回は LuaTeX のシェアを調べることが目的なので,dvipdfmx 経由で生成された PDF についてはそれ以上の生成ルートを追究しなくてもよいでしょう.古い PDF については dvipdfmx ではなく dvipdfm で生成されたものもありますが,話が複雑になりそうなので dvipdfm と dvipdfmx も区別しないことにします.

また,例えば次のようなケースでも推定に失敗し得ます:

  1. 未知のツールを用いて PDF 生成が行なわれている場合
  2. 一旦 PDF を生成したあとに,他のツール(例えば Acrobat Distiller)で変更を加えてある場合
  3. 何らかの方法で Producer 項目を書き換えてしまっている場合

しかし,TeX Live に含まれる PDF の大半は数種類の TeX 処理系を用いた標準的な生成フローで作られたものなので,上記の 1. や 2. に該当するような PDF は少数派です.そのため,今回はこれらは「その他 (Others)」としてまとめてしまってもさほど問題はないでしょう.また,わざわざ 3. のようなことをして,PDF Producer を偽装(?)するような人もあまりいないでしょうから,特段考慮しなくても大丈夫かと思います.

さて,PDF の文書プロパティを見ると,生成に用いられた TeX 処理系が概ね判定できることがわかりました.ではどうやって TeX Live に含まれる多数の PDF ファイルからそうした情報を収集し,解析するのがよいでしょうか? 実は LuaTeX には PDF を扱うためのライブラリ pdfe が標準で搭載されており,LuaTeX が内蔵する Lua 処理系から利用することが可能です.このライブラリについては別途 Qiita にチュートリアル記事を投稿しましたので,興味のある方は参照してください:

TeX Live ドキュメントにおける LuaTeX のシェア

それではいよいよ TeX Live のドキュメントツリー (TEXMF/doc) にある PDF ファイル群における各 TeX 処理系のシェアを,LuaTeX を用いて調べていきましょう.まずは必要なライブラリを読み込みます.ついでに例外処理で使用するため警告メッセージを標準エラー出力に吐き出す関数も定義しておきます.

local kpse = require 'kpse'
local pdfe = require 'pdfe'
local lfs = require 'lfs'

function warn(msg, ...)
  io.stderr:write('WARNING: ' .. msg:format(...) .. '\n')
end

続いて LuaFileSystem (lfs) の力を借りつつ,指定したディレクトリ以下の PDF ファイルを列挙する配列を返す関数を定義します.ただし TeX Live のドキュメントツリーで man ディレクトリ以下にある PDF はいわゆる manpages 用の roff 文書を機械的に変換して生成されたもので,各パッケージの作者が彼ら自身の手で TeX 組版したものではありませんので,今回の調査では除外します.

function list_pdf(dir, list)
  list = list or {}  -- 引数に与えられたリストを用いるか,新規に作成する
  
  for entry in lfs.dir(dir) do
    if entry ~= '.' and entry ~= '..' and entry ~= 'man' then  -- man ディレクトリは無視
      local ne = dir .. '/' .. entry
      if lfs.attributes(ne).mode == 'directory' then
        list_pdf(ne, list)
      else
        if ne:match('%.pdf$') then  -- PDF ファイルだけを収集
          table.insert(list, ne)
        end
      end
    end
  end
    
  return list
end

文書プロパティの “PDF Producer” エントリにある文字列から使用された TeX 処理系(または DVI ウェア)を推定する関数 judge_producer() を定義します.先述したとおり,今回は LuaTeX のシェアを調べることが主目的なので,DVI を経由して生成された PDF については大雑把にしか分類しません.この関数のロジックは実際に TeX Live に含まれる PDF ドキュメントの “PDF Producer” 値を見ながら泥縄式に考案したもので,汎用的とは言い難いです.ひとまず今回の調査には十分ということで,細かいことは気にしないでください.

function judge_producer(p)
  local res = 'Others'  -- とりあえず「その他」に初期化

  -- 正規化
  p = p:lower()
  if p:match('^miktex') then  -- MikTeX 搭載の TeX 処理系では特殊な値になる
    p = p:sub(8)
  end

  -- PDF 生成ツールの推定
  if p:match('^luatex') then
    res = 'LuaTeX'
  elseif p:match('^xdvipdfmx') or p:match('^xetex') then
    res = 'XeTeX'
  elseif p:find('pdftex') or p:find('pdflatex') or p:match('^pdfetex') then
    res = 'pdfTeX'
  elseif p:match('^dvips') or p:find('ghostscript') then
    res = 'Ghostscript'
  elseif p:match('^dvipdfm') then
    res = 'dvipdfmx'
  end
  return res
end

あとは pdfe ライブラリを用いて list_pdf() 関数で取得した各 PDF について文書プロパティの “PDF Producer” を読み出し,その値を judge_producer() に流し込んで,各 TeX 処理系のシェアを集計すれば OK です.稀に pdfe でうまく開けない PDF ファイルやなぜか文書プロパティが存在しない PDF があるので,適当に例外処理をしておきます.

-- 引数 doc_dir で指定したディレクトリ以下の PDF ファイルについてシェアを計算
function calc_share(doc_dir)
  local share = {
    ['LuaTeX'] = 0,
    ['XeTeX'] = 0,
    ['pdfTeX'] = 0,
    ['Ghostscript'] = 0,
    ['dvipdfmx'] = 0,
    ['Others'] = 0
  }

  -- doc_dir 以下の PDF ファイルのリストを取得し,ループを回す
  for _, fn in ipairs(list_pdf(doc_dir)) do
    -- PDF ファイルを開く
    local doc = pdfe.open(fn)
    
    -- PDF ファイルの open に失敗する場合がある
    if pdfe.getstatus(doc) ~= 0 then
      warn('Cannot open File %s', fn)
    else
      -- PDF の info 辞書を取得
      local info = pdfe.getinfo(doc)
      if info == nil then
        warn('No info in File %s', fn)
      else
        -- Producer エントリの値を読み出す
        local info_producer = pdfe.getstring(info, 'Producer')
        if type(info_producer) ~= 'string' then
          warn('No Producer entry in File %s', fn)
        else
          -- PDF 生成プログラムを推定し,結果を集計
          local producer = judge_producer(info_producer)
          share[producer] = share[producer] + 1
        end
      end
    end
    
    -- PDF ファイルは忘れずに閉じる
    pdfe.close(doc)
  end

  return share
end

これでほぼ完成です.せっかくなので,シェアの集計結果をグラフにして可視化するところまですべて LuaTeX でやってしまいましょう.Python に matplotlib あれば LuaTeX に pgfplots ありです.しかも TeX 的数式にネイティブ対応.というわけで,次のような LuaLaTeX 文書を作成しました.

%#!lualatex
\documentclass[border=4mm]{standalone}

% lua code
\usepackage{luacode}
\begin{luacode*}
local kpse = require 'kpse'
local pdfe = require 'pdfe'
local lfs = require 'lfs'

function warn(msg, ...)
  io.stderr:write('WARNING: ' .. msg:format(...) .. '\n')
end

function list_pdf(dir, list)
  list = list or {}
  
  for entry in lfs.dir(dir) do
    if entry ~= '.' and entry ~= '..' and entry ~= 'man' then
      local ne = dir .. '/' .. entry
      if lfs.attributes(ne).mode == 'directory' then
        list_pdf(ne, list)
      else
        if ne:match('%.pdf$') then
          table.insert(list, ne)
        end
      end
    end
  end
    
  return list
end

function judge_producer(p)
  local res = 'Others'

  -- nomarize
  p = p:lower()
  if p:match('^miktex') then
    p = p:sub(8)
  end

  -- judge
  if p:match('^luatex') then
    res = 'LuaTeX'
  elseif p:match('^xdvipdfmx') or p:match('^xetex') then
    res = 'XeTeX'
  elseif p:find('pdftex') or p:find('pdflatex') or p:match('^pdfetex') then
    res = 'pdfTeX'
  elseif p:match('^dvips') or p:find('ghostscript') then
    res = 'Ghostscript'
  elseif p:match('^dvipdfm') then
    res = 'dvipdfmx'
  end
  return res
end

function calc_share(doc_dir)
  local share = {
    ['LuaTeX'] = 0,
    ['XeTeX'] = 0,
    ['pdfTeX'] = 0,
    ['Ghostscript'] = 0,
    ['dvipdfmx'] = 0,
    ['Others'] = 0
  }

  for _, fn in ipairs(list_pdf(doc_dir)) do
    local doc = pdfe.open(fn)
    if pdfe.getstatus(doc) ~= 0 then
      warn('Cannot open File %s', fn)
    else
      local info = pdfe.getinfo(doc)
      if info == nil then
        warn('No info in File %s', fn)
      else
        local info_producer = pdfe.getstring(info, 'Producer')
        if type(info_producer) ~= 'string' then
          warn('No Producer entry in File %s', fn)
        else
          local producer = judge_producer(info_producer)
          share[producer] = share[producer] + 1
        end
      end
    end
    pdfe.close(doc)
  end

  return share
end

function print_ymax(share)
  ymax = 1000
  for _, v in pairs(share) do
    while v > ymax do
      ymax = ymax + 1000
    end
  end
  tex.sprint(ymax)
end

function print_order(share)
  local tab = {}
  local res = ''
  
  local function spairs(t, order)
    local keys = {}
    for k in pairs(t) do keys[#keys+1] = k end

    if order then
      table.sort(keys, function(a,b) return order(t, a, b) end)
    else
      table.sort(keys)
    end

    local i = 0
    return function()
      i = i + 1
      if keys[i] then
        return keys[i], t[keys[i]]
      end
    end
  end
  
  for k, v in pairs(share) do
    if k ~= 'Others' then
      tab[k] = v
    end
  end
  
  for k, _ in spairs(tab, function(t, a, b) return t[b] < t[a] end) do
    res = res .. string.format('\\pname{%s}, ', k)
  end

  tex.sprint(res .. '\\pname{Others}')
end

function print_values(share)
  local res = ''
  for k, v in pairs(share) do
    res = res .. string.format('(\\pname{%s}, %s) ', k, v)
  end
  tex.sprint(res)
end

local doc_dir = kpse.var_value('TEXMFDIST') .. '/doc'
share = calc_share(doc_dir)
\end{luacode*}

% pgfplots
\usepackage{pgfplots}
\pgfplotsset{compat=1.16}

% logos
\usepackage{bxtexlogo}
\bxtexlogoimport{*}
\makeatletter
\newcommand{\dvipdfmx}{dvipdfm(x)}
\newcommand{\pname}[1]{%
  \@ifundefined{#1}{#1}{\@nameuse{#1}}}
\makeatother

\begin{document}

\begin{tikzpicture}
\begin{axis}[
  ybar,
  ylabel={\#PDF},
  xlabel={PDF Producer},
  ymin=0,
  ymax={\directlua{print_ymax(share)}},
  xticklabel style={rotate=90},
  symbolic x coords/.expand once={%
    \directlua{print_order(share)}
  },
  nodes near coords, 
  nodes near coords align={vertical},
  axis lines*=left]
\addplot coordinates {
  \directlua{print_values(share)}
};
\end{axis}
\end{tikzpicture}

\end{document}

念のため何点か補足しておきます.まず LuaLaTeX 文書内に Lua コードを書く場合,直接 \directlua プリミティブを使用すると文字のエスケープが厄介です6luacode パッケージが定義する luacode* 環境を用いると,自然な Lua コードを書けるので,利用しました.第二に kpse ライブラリは texlua で利用する際には kpse.set_program_name() による初期化が必要ですが,LuaTeX 文書から呼び出す場合は不要のため省略しています.第三に print_*() という名前の関数をいくつか定義して,calc_share() 関数がテーブルの形で返す集計結果を pgfplots の入力と各種設定に使える形式の文字列に変換して,最終的に tex.sprint() で TeX 処理系側に渡しています.最後に,出力結果のグラフで各種 TeX 処理系のロゴを使うために,ちょっとだけアレな TeX on LaTeX をやっていますが,見なかったことにしてください.

さて,上記の LuaLaTeX 文書を処理すると,目的のグラフが得られます.TeX Live ドキュメントツリーに含まれるほとんどすべての PDF 文書を走査するので,実行にはさすがに少し時間がかかります.手許での処理時間は約30秒でした.

というわけで,手許の最新版 TeX Live 2019 についての,お待ちかねの集計結果は次の通りです.

【TeX Live 2019収録 PDF ドキュメントにおける PDF 生成プログラムのシェア】

TeX Live に含まれるドキュメントの PDF 生成プログラムシェア

一見して pdfTeX が圧倒的多数派です.続いて多かったのは Ghostscript (dvips) 経由で生成された PDF でした.LuaTeX は第3位で,シェアを計算すると 9.7% という結果になりました.個人的には,予想よりちょっと低いシェアだったかなという気がします.日本人にとっては未だに馴染みの深い dvipdfmx 経由の PDF 生成フローは世界的にはかなりマイナーだということもわかり面白い結果です.

しかし,TeX Live ドキュメント全体の中で LuaTeX 製 PDF が占める割合をもって「現在の LuaTeX のシェア」と言うのは無理があります.TeX Live にはかなり古いパッケージも多く含まれており,長いことメンテナンスされていない,いわゆる “枯れた” パッケージも少なくありません.そうした古いパッケージの,大昔に生成された PDF は LuaTeX で生成されているはずもなく,歴史の長い TeX 処理系ほど有利になってしまうという不公平が生じています.

ちょうど PDF の文書プロパティには PDF の生成日時の情報が “CreationDate” 項目に格納されているので,これを用いて直近5年に生成された PDF に絞って再度集計を行ってみましょう.そのために calc_share() 関数を少し改変して,次のように calc_share2() 関数を定義します.

function calc_share2(doc_dir, start, stop)
  local share = {
    ['LuaTeX'] = 0,
    ['XeTeX'] = 0,
    ['pdfTeX'] = 0,
    ['Ghostscript'] = 0,
    ['dvipdfmx'] = 0,
    ['Others'] = 0
  }

  for _, fn in ipairs(list_pdf(doc_dir)) do
    local doc = pdfe.open(fn)
    if pdfe.getstatus(doc) ~= 0 then
      warn('Cannot open File %s', fn)
    else
      local info = pdfe.getinfo(doc)
      if info == nil then
        warn('No info in File %s', fn)
      else
        local info_producer = pdfe.getstring(info, 'Producer')
        if type(info_producer) ~= 'string' then
          warn('No Producer entry in File %s', fn)
        else
          local info_creation = pdfe.getstring(info, 'CreationDate')
          if type(info_creation) ~= 'string' then
            warn('No CreationDate entry in File %s', fn)
          else
            local year = tonumber(info_creation:sub(3, 6))
            if type(year) ~= 'number' then
              warn('Failed to parse CreationDate entry in File %s', fn)
            else
              if start <= year and year <= stop then
                local producer = judge_producer(info_producer)
                share[producer] = share[producer] + 1
              end
            end
          end
        end
      end
    end
    pdfe.close(doc)
  end

  return share
end

share = calc_share2(doc_dir, 2015, 2019)

【直近5年 (2015-2019) に絞った PDF 生成プログラムのシェア】

直近5年間 (2015-2019) に絞ったシェア

首位は相変わらず pdfTeX でしたが,LuaTeX が2位に浮上していました.LuaTeX のシェアは約 14.4% です.当然といえば当然ですが,近年 TeX 系開発者の中での LuaTeX の採用率は上がってきていると言うことはできそうです.

とはいえ,今回明らかになった LuaTeX のシェアは私が想像していたよりも小さかったというのが正直なところです.本稿の冒頭でも注意した通り,今回は TeX Live に含まれる PDF ドキュメントを対象に計測を行ったので,ほとんどの PDF 作者が TeX 系の開発者,つまり TeX を使いこなしているエキスパートと呼べる人々です.これは仮説に過ぎませんが,エキスパート間での LuaTeX シェアがこの程度ということは,初心者を含む一般の TeX ユーザにおける LuaTeX のシェアはこれよりさらに小さな値になるのではないでしょうか.

もっと細かくどのドキュメントがどの PDF 生成プログラムで制作されているのか見てみたいという方のために,今回グラフ化するにあたって取得した生データは gist に置いておきます.

今回作成したシェア計算を行う LuaLaTeX 文書では,calc_share() 関数に与えるディレクトリを変えるだけで,任意のディレクトリ配下の PDF ファイルについて TeX 処理系のシェアを計算することができます.生憎私の手許には TeX Live のドキュメントツリーよりも良い TeX 製 PDF ファイル群の持ち合わせがありませんが,幸運にも多様な著者による多様な TeX 組の PDF をお持ちの方がもしいらっしゃいましたら,ぜひ LuaTeX のシェアを算出してみて欲しいと思います.

Happy LuaTeXing!


*「素人質問で恐縮なのですが,今回の知見は何の役に立つのでしょうか?」
W「えー,例えば Texdoc も texlua で動いていて pdfe ライブラリが使えるはずなので,LuaTeX の選択圧を強めるために非 LuaTeX 製の PDF は表示しない,などということができますね」
*「いや,それはどうなのよw」
W「ちなみに Texdoc のマニュアルも XeTeX 製なんですが🙃」


  1. 詳細は Adobe 社のヘルプページを参照してください. ↩︎

  2. 具体的には hyperref, hyperxmp, pdfx パッケージなどが利用できます. ↩︎

  3. PDF の文書プロパティを表示できるビューアなら別に Acrobat Reader でなくても構いません.PDF を目でパースできる方ならテキストエディタでも OK. ↩︎

  4. TeX Live 2019 など,最近の TeX 環境では xdvipdfmx と dvipdfmx プログラムの実体は同じもののようです.ただ,起動モードが異なるため,それぞれ xdvipdfmx, dvipdfmx のように振る舞います.今回はそうした細かいことは気にしないことにします. ↩︎

  5. もしかすると文書プロパティ以外の部分で,各 TeX 処理系が生成する PDF に特徴的な箇所があるかもしれませんが,やはり今回は LuaTeX のシェアを調べることが目的なので,dvipdfmx による変換の前がどうであったかまでは深追いしません. ↩︎

  6. \directlua の引数が内蔵の Lua 処理系に渡される前に,まず TeX の字句解析が適用されてしまうためです(参考). ↩︎