Pure Go で zero-dependency の PDF ライブラリを作った話: gpdf 設計思想
なぜ CGO 依存を避け、なぜ既存 OSS のフォークでなくゼロから書いたか。設計判断の連鎖と、それが結果として何を可能にしたか。
ゼロから書く、と決めた夜
2026 年 1 月のある夜、私は Go で PDF を生成するライブラリを探して 3 時間を費やした。gofpdf は 2021 年にアーカイブ済み。その後継として派生した go-pdf/fpdf は 2025 年に同じ末路をたどった。UniPDF は商用ライセンスで、個人開発の費用感とは合わない。gopdf はアクティブだが API が低レベルすぎて、実用的なドキュメント生成には毎回かなりのグルーコードを書かせる。Maroto は gofpdf 依存のレイアウトライブラリで、依存元がアーカイブになった今は積極的に選べない。
選択肢を一つひとつ外したとき、「書くしかないか」という判断は静かに出た。大げさな決断ではなく、消去法の終着点だった。
書き始める前に、一つだけ自分に制約を課した。CGO を使わない。外部 C ライブラリに依存しない。go.mod の require を最終的に空にする。 この一行の制約が、gpdf のその後の設計判断のほぼ全部を決めることになった。
CGO という選択肢
CGO を使えば PDF のフォント処理は楽になる。FreeType は TrueType/OpenType フォントの描画ライブラリとして実績があり、HarfBuzz はテキストシェーピングのデファクトスタンダードだ。この二つを CGO 経由で呼べば、CJK フォントのサブセット化も既存実装の力を借りて早く実装できる。それは事実だ。
しかし CGO を選んだ瞬間に、一つ大きなものを失う。クロスコンパイルの自由だ。
# Pure Go なら、これだけでどのアーキテクチャ向けバイナリでも出る
GOOS=linux GOARCH=arm64 go build -o app ./cmd/app
CGO が入ると、ターゲットアーキテクチャ向けの C コンパイラを CI に揃えなければならない。aarch64-linux-gnu-gcc を Docker イメージに積む手間が発生し、ビルドキャッシュの管理が複雑になる。個人開発者として、このオーバーヘッドは払う気になれなかった。
もう一つは 配布の単純さ。Pure Go の single binary は依存ランタイムがゼロだ。利用者は go get して go build すれば動く。Docker イメージへの組み込みも COPY ./binary /app/binary の一行で完結する。CGO ありの場合、ldd で共有ライブラリの依存を確認したり LD_LIBRARY_PATH を調整したりする手間が利用者に移る。
使う側に認知コストを押し付けない、というのが nadai が設計全体に通している軸の一つだ。gpdf の場合、ライブラリを組み込む側のビルド環境への要求を最小化することが摩擦削減の筆頭に来た。CGO なしという制約はそこから来ている。
さらに個人的な動機もある。CGO の開発体験は、Pure Go の開発体験と比べて摩擦が多い。C のヘッダパス設定、cgo ビルドタグの管理、C と Go の型境界でのメモリ管理。これを継続して触りたいかと正直に問われたら、答えは「そうでもない」だった。メインの問題 (PDF を生成すること) に集中できる環境を選んだ。
フォークという選択肢を外した理由
ゼロから書くという判断は、フォークを真剣に検討したあとに出た。
gofpdf のフォークである go-pdf/fpdf は、2025 年のアーカイブ直前まである程度活発だった。API の知名度もあり、既存 gofpdf ユーザーへの移行パスとしても機能していた。このコードベースを引き継ぐ選択肢はあった。
外した理由は二つある。
一つ目は CGO 依存の継承問題。gofpdf のフォントハンドリングには CGO に依存した実装が含まれている。Pure Go 化しようとすると、フォント処理のコアを丸ごと書き直す必要があった。どうせコアを書き直すなら、残りの構造も自分がコントロールできる設計で始めたほうがいい。書き直す量は変わらないのに、フォークのコードが周囲に残るだけ余計な混乱を生む。
二つ目は API 設計の自由。フォークは既存 API を実質的に継承する。既存ユーザーがそのインターフェースに依存したコードを書いているからだ。gpdf で試したかった chainable なインターフェース設計は、フォークの文脈では後から導入が難しい。設計上の負債を持ち込まずに始められる、というのがゼロスタートの最大の利点だった。
ちなみに Maroto は chainable に近いスタイルで書かれていて、高レベル API の参考にはなった。ただし gofpdf 依存のままなので、そのまま採用する選択肢は最初から外れていた。
PDF を生成するとはどういうことか
CGO なし、フォークなし、と決めたあとに待っていたのは、PDF 仕様 (ISO 32000-2:2020) を読みながら Go でバイナリを直接組み立てる作業だった。
PDF の構造は思ったより単純で、思ったより厄介だ。基本はオブジェクトグラフとクロスリファレンステーブル (xref) の組み合わせだ。ファイルの本体はオブジェクト (1 0 obj ... endobj) の列で、その後ろに xref テーブルが続く。xref テーブルには各オブジェクトのバイトオフセットが記録されていて、PDF ビューアはここを読んで目的のオブジェクトにジャンプする。
ページ、フォント、画像、テキストストリームはすべてオブジェクトで表現される。テキストを配置するコンテンツストリームは、BT〜ET ブロックの中に Tf (フォント指定)、Td (位置移動)、Tj (文字列描画) などの PDF オペレータを並べた形式だ。日本語テキストの場合、Tj に渡すのは Unicode 文字列ではなく CID (文字識別子) を 16 進エンコードした形式で、フォントの cmap テーブルから得たマッピングを使って変換する必要がある。
ストリームオブジェクトは FlateDecode (zlib) で圧縮されることが多く、圧縮後のバイト数と実際のデータが完全に一致していなければならない。表面はシンプルに見えて、実装すると整合性の要求が細かい。
xref の整合性と最初の壁
実装中に最も手間取ったのは xref テーブルの整合性だった。
xref には各オブジェクトが始まるバイトオフセットを正確に記録しなければならない。Go の io.Writer でバイト列を流し込みながら、同時に「今この瞬間のオフセットはいくつか」を追跡する必要がある。最初の実装では bufio.Writer を使っていた。フラッシュのタイミングによって、書き出したと思っていたバイト数と実際に io.Writer に渡ったバイト数にズレが出た。
結果: Adobe Acrobat がクラッシュするか、無言で「このファイルは読めません」と返すファイルを生成していた。
デバッグには Didier Stevens の pdf-parser.py を使い、生成したバイナリの xref を直接確認した。Python でバイナリを走査して各オブジェクトの実際のオフセットを計算し、記録されているオフセットと比較する。ズレを見つけたら、Go コードのどこでフラッシュタイミングがずれているかを追った。
最終的には書き込みと同時にオフセットをカウントする countWriter ラッパーを実装して解決した。bufio.Writer をやめて countWriter で直接バイト数を追跡する形だ。結果はシンプルだったが、Go の io.Writer インターフェースと bufio.Writer のフラッシュ挙動を PDF バイナリを読みながら学ぶとは思っていなかった。あの 1 週間を返してほしいとは今でも少し思っている。
trailer ディクショナリや startxref の扱いは仕様書を読めばすぐ分かったが、xref のオフセット管理は「一つのバグが全面的なパース失敗に化ける」という性質があり、エラーメッセージが一切出ない分だけ手間取った。
Pure Go でフォントサブセット化を書く
CGO なしで PDF を生成する上で最も難易度が高いのは、フォント処理、とりわけ CJK フォントのサブセット化だ。
PDF にフォントを埋め込む際、全グリフをそのまま埋め込むとファイルサイズが爆発する。日本語フォントは 1 万グリフ超えが普通で、フォントファイルが 10MB を超えることも珍しくない。実用上は、文書中で実際に使われたグリフだけを抽出してサブセットを作り、それを埋め込む。この処理を Pure Go で実装した。
TrueType と OpenType の仕様を読んで、バイナリパーサを書いた。主要テーブルは以下の順に解析する:
cmap: Unicode コードポイントから glyph ID へのマッピングglyf: 各グリフのアウトライン (ベジエ曲線の制御点)loca: glyph ID からglyfテーブル内のオフセットhmtx: 水平アドバンス幅とサイドベアリング
使用グリフを特定したら、対応するアウトラインと幅情報を抽出し、新しいフォントバイナリを組み立てる。このとき loca の再計算と、新しいフォントバイト列の整合性確認が必要だ。
glyf テーブルには composite glyph という概念があり、一つのグリフが別のグリフへの参照を持つことがある。漢字や絵文字では複合グリフが多い。使用グリフを正しくサブセット化するには、参照先グリフを再帰的にトレースする必要があった。これは仕様書を読んで初めて知った挙動で、実装後に Noto Sans JP でテストを走らせたら最初は文字化けが出た。再帰トレースを実装して直したが、あのデバッグは今でも記憶に残っていると感じている。
2 週間かかった。途中で「FreeType を呼んでいいですか」と自分への言い訳が頭をよぎった瞬間もあった。しかし最終的に Pure Go で動いたとき、ビルドパイプラインから CGO の制約が完全に消えていた。この選択が正しかったという判断は今も変わっていない。
CJK フォントサブセット化が Pure Go で動くことで、gpdf は日本語・中国語・韓国語ドキュメント生成をゼロ依存で実現できる。これが gpdf のコア selling point として「CJK サポート」を置ける根拠だ。
gpdf v1.0.0 でできること
2026 年 3 月、gpdf v1.0.0 を MIT License で公開した。
現時点で対応しているのは:
- テキスト配置 (TrueType/OpenType フォント埋め込み)
- CJK フォントサブセット化 (Pure Go、ゼロ依存)
- テーブル、画像、リスト、レイアウト API
- PDF/A 準拠出力
- 電子署名
- バーコード / QR コード描画
- FlateDecode ストリーム圧縮
使い方はシンプルだ:
doc := gpdf.New()
doc.AddPage()
if err := doc.SetFont("NotoSansJP-Regular.ttf", 14); err != nil {
return err
}
doc.Text(20, 20, "Pure Go で生成した PDF")
f, err := os.Create("output.pdf")
if err != nil {
return err
}
defer f.Close()
return doc.Output(f)
CGO なし、外部依存なし。go get github.com/gpdf-dev/gpdf で導入して、go build で single binary に含まれる。GOOS=linux GOARCH=arm64 go build でクロスコンパイルも完結する。私が gpdf を作ったのは、この体験を実現するためでもあった。
「gofpdf 互換がないと採用されない」という反論
gpdf を公開してから、「gofpdf 互換の API を提供しないと既存ユーザーが移行しない」という意見をいくつか受けた。
これは正しい観察だ。ただし前提が違う。
gpdf は gofpdf のユーザーの移行先として設計していない。gpdf のターゲットは、これから Go で PDF 生成を始める開発者だ。gofpdf が 2021 年にアーカイブになった今、新規採用者はサポートが続く代替を探している。その層に対して、よりよい API 設計と Pure Go の単純さを提供するのが gpdf の立ち位置だ。
互換レイヤーを実装すると設計の制約が増え、内部構造の改善が難しくなる。それをやるよりも、gofpdf から gpdf への移行ガイドを別に書くほうが筋がいい。互換 API は「既存ユーザーを引き込む」策のようで、実際には「新しい API 設計のチャンスを潰す」手になりやすい。
gpdf の API 設計判断は、gofpdf との互換性よりも「Go らしい使い心地」と「ゼロ依存による配布の単純さ」を優先した結果だ。
次のステップ
gpdf の API 設計 — chainable vs builder パターンをどう選んだか — については別の記事で詳しく書く予定だ。
Pure Go の micro SaaS を兼業で並走させている全体像は Pure Go の micro SaaS を兼業で出している現在地 で整理している。
gpdf のコードは GitHub (gpdf-dev/gpdf) にある。v1.0.0 以降も継続してメンテする予定だ。