NGW開発BLOG
← 記事一覧へ
·#026

外字をSVGで差し込む純PHPでTrueTypeの輪郭を抽出する

BMP外の一文字だけを検出し、そのグリフ輪郭を純PHPでSVGに起こしてPDFへ差し込む。豆腐問題の解決編。

豆腐の外側だけを別経路に流す

別稿で触れた「豆腐問題」——PDFライブラリのTCPDFがBMP(基本多言語面)の外にある文字、たとえばCJK拡張Bの 𠮷 を出力できず、□(豆腐)になってしまう話——の解決編である。

前記事の結論は身も蓋もなかった。サロゲートペアで表現される符号は、TCPDFの内部で正しくグリフIDへ解決されず、描画段で潰れる。ライブラリ本体の限界だ。

ここで方針が二つに分かれる。ひとつはライブラリを改造してBMP外に対応させる道。もうひとつは、ライブラリはそのまま使い、限界の外側にある文字だけを別経路で処理する道である。後者を選んだ。理由は単純で、本体を改造すると以後のバージョン追従が地獄になるからだ。フォールバックなら、限界にぶつかった一文字だけを迂回させればよい。

迂回の中身はこうだ。BMP外の文字を、その一文字ぶんのベクター画像(SVG)に変換し、<img> としてテキストの流れに差し込む。TCPDFはHTMLモードでSVGを描ける。ラスタのPNGと違い、拡大しても輪郭が滲まない。帳票の文字としては高精細なほうが望ましい。

なぜ純PHPでグリフを取り出すのか

「一文字を画像に」と言えば、普通はImageMagickやフォントレンダラを呼び出す。exec でcoreに投げる、という発想だ。だがここに配布上の制約があった。

このシステムは環境の異なる多数の配布先で動く。共有ホスティングで exec が無効化されている、ImageMagickが入っていない、追加ライブラリを勝手に入れられない——そういう環境が現実に混じる。外部バイナリに依存した瞬間、その環境では豆腐に逆戻りする。つまり依存を増やすことが、そのまま動かない環境を増やすことになる。

だから、グリフ輪郭の抽出を純PHPで完結させた。やることは日本語フォント(IPAmj明朝)のファイル(TrueType)をバイナリとして読み、必要なテーブルを自前で解釈することだ。骨格だけ擬似コードで示す。

関数 glyphToSvgPath(font, codepoint):
    gid  = font.cmap.lookup(codepoint)   # 符号 → グリフID
    glyf = font.readGlyph(gid)           # 輪郭データ
    if glyf.isComposite:                 # 複合グリフ
        return mergeComponents(glyf)     #  → 部品を再帰展開して合成
    path = ""
    for 輪郭 in glyf.contours:
        # on/off点の並びから2次ベジェを復元
        path += contourToQuadraticBezier(輪郭)
    return path   # SVGの d 属性へ

肝は三つある。まず cmap。符号からグリフIDへの対応表で、BMP外を正しく引くには32bit符号を扱うformat 12に対応する必要がある(BMPだけならformat 4で足りるが、それでは今回の対象を引けない)。次に glyf の輪郭。TrueTypeの輪郭は2次ベジェ曲線で、制御点(off-curve点)が連続する箇所は中点を補って復元する。最後に複合グリフ。多くの漢字は部品(偏や旁)を平行移動・変形して組み立てた合成体なので、部品を再帰的に展開してから合成する。

これらはすべてフォントバイナリの中に構造として存在する。外部プロセスは要らない。読み解くだけだ。

周囲の文字と揃えるための較正

輪郭が取れても、そのまま貼ると浮く。実装で効いた工夫を挙げる。

  • tight-bbox: グリフの実インク領域でviewBoxを詰める。字面の余白まで含めると、隣の文字より不自然に小さく見えるからだ。実際に塗られる範囲でトリミングして、見た目の大きさを揃える。
  • キャッシュ: 同じ符号のSVGを毎回生成するのは無駄なので、生成物を鍵付きで蓄える。同一文字が何度も出る帳票では効きが大きい。
  • 較正定数: 高さとベースラインを定数で微調整し、前後の文字と天地を合わせる。ここは理屈より現物合わせで、帳票を見ながら詰めた。

これで 𠮷 は、周囲の文字と同じ顔をして行に収まる。

トレードオフもある。差し込んだ一文字は画像なので、PDF上で検索・コピーの対象から外れる。また初回はフォントを展開するぶんメモリを食う(だからこそキャッシュが効く)。行数測定・縦揃え・色追従といったPDF特有の落とし穴もこの先に続くが、それは別稿に譲る。

AIとの協働作業での学び

ライブラリの構造的な限界にぶつかったとき、本体を改造して内側を作り替えるのは、たいてい割に合わない。限界の外側にある対象だけを検出し、別経路(ここでは画像化)で処理するフォールバックが、しばしば現実解になる。全体を賢くするのではなく、例外だけを迂回させるのだ。

もうひとつ。今回いちばん効いたのは「純言語で完結させた」ことだった。ImageMagickを呼べば実装は数行短くなったはずだが、そのぶん動かない配布先が増えた。依存を減らすことが、動く環境を増やす——配布可能性を決めるのは、賢いコードより、余計なものを持ち込まない設計のほうだったりする。