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に書き添えることでしょうか。
とはいえ、うまくいったのは手元のいくつかのフォントとビューアでの話で、これで直ったと言い切れるわけではありません。メンテナがリリースをためらう気持ちが、少し分かった気がします。言い切れないなら、確かめた範囲を添えて返す。それくらいの距離感がちょうどよいのかもしれません。
maxp騒動(1.6系)