ttfunk 1.8.0のmaxpバグを、手元のパッチで凌ぐ

2026-11-06 with Claude Code ruby rails prawn

PrawnでPDFを作っているRailsアプリで、ttfunk 1.8.0のバグに当たりました。2年半前から知られていて、修正もマージ済みなのに、まだリリースされていないバグです。

症状

prawn 2.5.0+ttfunk 1.8.0で、TrueTypeフォントをサブセット埋め込み(Prawnの既定)にすると、Adobe Acrobatやプリンタで日本語が表示・印刷できなくなります。

require "prawn"

pdf = Prawn::Document.new
pdf.font_families.update("IPAexGothic" => { normal: "ipaexg.ttf" })
pdf.font "IPAexGothic"
pdf.text "株式会社テスト請求書"
pdf.render_file "test.pdf"

実は1年前にも同じ症状で記事を書いていました。そのときは、ttfunkを1.7.0に戻せば直ることまでは分かったものの、原因が分からず「Adobeの方に要望するところ?」と書いていました。今回、原因はttfunkの側にあると分かりました。

原因

ttfunkがサブセットを作るとき、maxpテーブル(グリフの最大点数・輪郭数など)を計算し直すのですが、グリフ番号の対応表を逆向きに渡していました。修正は1行です。

     def maxp_table
-      @maxp_table ||= TTFunk::Table::Maxp.encode(original.maximum_profile, old_to_new_glyph)
+      @maxp_table ||= TTFunk::Table::Maxp.encode(original.maximum_profile, new_to_old_glyph)
     end

その結果、max_pointsやmax_contoursが実際より小さい値になり、厳密なビューアやプリンタがフォントを捨ててしまいます。Acrobatでは次のように出ます。

Cannot extract the embedded font 'XXXXXX+IPAexGothic'. Some characters may not display or print correctly.

上流の状況

maxpはこれまでにも何度も直しては戻されています。

日付 出来事
2020-01 1.6.0でmaxpを計算し直すようにしたら、Acrobatで壊れる報告が続出(#72)→ #75で差し戻し
2020-02 計算し直す方式で再修正(#78)。マージは2023-12
2024-03-05 1.8.0リリース(#78入り)。prawn 2.5.0も同日リリースでttfunk ~> 1.8に
2024-03 再発の報告(prawn#1346、ttfunk#102)
2024-04 日本語フォント(源真ゴシック)でも再現、1.7.0相当に戻すPR(#104)。マージされず
2025-03-26 原因を特定した1行の修正PR(#106)
2026-03-06 #106がマージ(6bc9950)。CHANGELOGは[Unreleased]のまま
2026-08 「マージ済みの修正をリリースして」(#123)。BigDecimal 4対応も同じく未リリース(#115)

リリースは「フォントのサブセット化をちゃんと直してから」というのがメンテナの方針のようです(#115)。#102でメンテナは、フォントは半世紀分の積み重ねで、あるフォントで動いても他で動く保証はない、Acrobatだけでなく Preview、PDF.js、poppler、プリンタでも確かめる必要がある、と書いています。

凌ぎ方

いくつか選択肢があります。

# 1. ttfunkを1.7に戻す(prawnも2.4に戻すことになる)
gem "prawn", "~> 2.4.0"
gem "ttfunk", "< 1.8"

# 2. 未リリースの修正をGitから取る
gem "ttfunk", github: "prawnpdf/ttfunk", ref: "6bc9950"

# 3. サブセットをやめる(フォント全体が入るので、日本語だとPDFが数MBになる)
font_families.update("IPAGothic" => { normal: { file: path, subset: false } })

今回は、gemはそのままにして、修正と同じ1行をinitializerで当てました。バージョンが変わったら当てずに、ログで知らせるようにしています。

# config/initializers/ttfunk_maxp_fix.rb
require "ttfunk"

ttfunk_version = Gem.loaded_specs["ttfunk"]&.version
if ttfunk_version == Gem::Version.new("1.8.0")
  TTFunk::TTFEncoder.class_eval do
    def maxp_table
      @maxp_table ||= TTFunk::Table::Maxp.encode(original.maximum_profile, new_to_old_glyph)
    end
  end
else
  Rails.logger&.warn "ttfunk #{ttfunk_version} が使われています。config/initializers/ttfunk_maxp_fix.rb が不要になっていないか確認してください"
end

回帰テストは、サブセットのmaxpに書かれた最大点数が、実際のグリフの点数以上になっていることを確かめます。

it "サブセットフォントの maxp テーブルに正しい最大点数が入る" do
  original = TTFunk::File.open("ipaexg.ttf")
  subset = TTFunk::Subset::Unicode.new(original)
  "株式会社テスト請求書".each_char { |c| subset.use(c.ord) }
  encoded = TTFunk::File.new(subset.encode)

  actual_max_points = (1...encoded.maximum_profile.num_glyphs).map { |gid|
    glyph = encoded.glyph_outlines.for(gid)
    glyph.respond_to?(:end_point_of_last_contour) ? glyph.end_point_of_last_contour : 0
  }.max

  expect(encoded.maximum_profile.max_points).to be >= actual_max_points
end

思ったこと

原因の特定から、上流の修正コミットを見つけて、パッチとテストを書くまで、AIの助けで30分ほどでした。以前なら半日は溶けていたと思います。

一方で、こうして誰もが手元で簡単に凌げるようになると、OSSのメンテナンスが疎かになっていくのではないか、という気もしました。ただ、ttfunkの経緯を見る限り、足りないのはパッチではありません。原因を特定した修正は1年半以上前からあり、マージもされています。詰まっているのは「いろいろなフォントとビューアで確かめて、責任を持ってリリースする」ところで、そこを担える人がほぼ1人しかいない。これはAI以前からある問題です。

AIは、パッチを書くコストを大きく下げましたが、確かめてリリースするコストはほとんど下げていません。むしろ、丁寧に書かれたissueや「+1」が増えて、メンテナが読む量は増えているかもしれません。そして、利用者がそれぞれ手元で黙って直してしまうと、上流には「困っている人がいる」という声も、「この環境で直った」という確認結果も届かなくなります。弊害があるとすれば、そこだと思います。

なので、AIで浮いた時間は、上流がいちばん欲しがっている「確かめた結果」を返すことに使うのがよさそうです。今回なら、IPAexゴシックとAcrobat・プリンタで修正版を確かめた結果を、#123に書き添えることでしょうか。

とはいえ、うまくいったのは手元のいくつかのフォントとビューアでの話で、これで直ったと言い切れるわけではありません。メンテナがリリースをためらう気持ちが、少し分かった気がします。言い切れないなら、確かめた範囲を添えて返す。それくらいの距離感がちょうどよいのかもしれません。

参考