記事一覧

アーカイブされた gofpdf から gpdf へ移行する完全ガイド

gofpdf は 2021 年アーカイブ、後継 go-pdf/fpdf も 2025 年停止。CJK 対応・ゼロ依存の純 Go ライブラリ gpdf への移行ガイド。

TL;DR

gpdf は純 Go・外部依存ゼロ・ネイティブ CJK 対応の PDF ライブラリ。AddUTF8Font のお作法も、SetXY で座標を押し回す必要もない。Bootstrap 風の 12 カラムグリッドで宣言的に書ける。ベンチマークでは gofpdf より約 10 倍速い。移行はだいたい「命令型のカーソル操作」を「宣言的なビルダー」に置き換える作業で、5 つの Before/After で全体像を示す。

先週、社内の同僚が新規プロジェクトで go get github.com/jung-kurt/gofpdf を叩いて、10 分後に GitHub のバナーのスクリーンショットを送ってきた。「This repository has been archived by the owner. It is now read-only.」 続けて一言、「フォークもアーカイブされてるんだけど」。

両方ともそうです。

jung-kurt/gofpdf がアーカイブされたのは 2021 年 9 月 8 日。コミュニティフォークの go-pdf/fpdf も最終リリースは 2023 年で、2025 年に正式にアーカイブ された。Stack Overflow や Qiita の Go PDF 記事の 7 割が今も指している先は、4 年以上 read-only。後継として推されていたフォークも消えた。

すでに gofpdf 製のコードが本番にあるなら、この記事は移行マップ。新規に「Go で PDF どうすればいい?」で gofpdf に手が伸びかけているなら、これがそのまま代替案。

なぜ gofpdf は本当に死んだままなのか

OSS は必ずしも死なない。メンテナが手を引いても誰かが拾うことはある。gofpdf もそのパターンになるはずだった。実際 go-pdf/fpdf はコードを再編成し、長年の bug を直し、PR を受け入れていた。普通に「後継」として機能していた。

それが 2025 年に止まった。README には「このプロジェクトは積極的にメンテナンスされていません。別のライブラリを検討してください」という一文。

理由はどうでもいい。問題は結果のほうだ。gofpdf 依存の Go プロジェクトは、今や 2 段重ねの未メンテコードの上に座っている。脆弱性が出ても誰も直さない。PDF 2.0 仕様は 2020 年に出たが、gofpdf はその大半に未対応。Go 1.25 のループ変数セマンティクスは今は gofpdf でも問題なく動くが、明日壊れたらフォークするのはあなたの仕事。

これは「ライブラリにバグがある」問題ではなく、サプライチェーンの問題

日本のチームには特に効く。電子帳簿保存法対応で「PDF/A-3 で保存しろ」と言われたとき、未メンテのライブラリを根拠に出すのはきつい。監査も通らない。

日本のチームが gofpdf で実際にやってきたこと

GitHub Issues や Qiita 投稿を眺めると、gofpdf の主用途はだいたい次の 4 つに集約される:

  1. 請求書・領収書・納品書 — ヘッダ、宛先、明細表、合計、フッタ
  2. 帳票・レポート — ヘッダとページ番号が繰り返される複数ページの文書
  3. 証明書・チケット類 — テンプレ画像の上に固定位置でテキストを乗せる
  4. 日本語・中国語・韓国語の PDF — 明細・配送ラベル・領収書

最初の 3 つは gpdf のビルダー API で素直に書ける。問題は 4 つ目。gofpdf は AddUTF8Font を呼び、TTF のパスを管理し、文字が基本面の外に出ないことを祈る、というお作法だった。gpdf は CJK を最初からファーストクラスとして扱う — TrueType を登録して日本語を書く、それで終わり。

API 対応表

下の表がチートシート。後ろの章で 5 つの具体的な Before/After を見せる。

やりたいことgofpdfgpdf
文書の作成gofpdf.New("P", "mm", "A4", "")gpdf.NewDocument(gpdf.WithPageSize(document.A4))
ページ追加pdf.AddPage()doc.AddPage() (*PageBuilder を返す)
フォント指定pdf.SetFont("Arial", "B", 16)template.FontFamily(...) / template.Bold() / template.FontSize(16)
TTF 登録 (CJK)pdf.AddUTF8Font("noto", "", "NotoSansJP-Regular.ttf")gpdf.WithFont("NotoSansJP", ttfBytes) (構築時に渡す)
1 行テキストpdf.Cell(40, 10, "hi")c.Text("hi")
折り返しテキストpdf.MultiCell(0, 10, body, "", "L", false)c.Text(body) (自動で折り返し)
テキスト色pdf.SetTextColor(255, 0, 0)template.TextColor(pdf.Red) (テキスト単位のオプション)
横線pdf.Line(x1, y1, x2, y2)c.Line(template.LineThickness(document.Pt(1)))
画像埋め込みpdf.ImageOptions("logo.png", x, y, w, h, ...)c.Image(imgBytes, template.FitWidth(document.Mm(50)))
カーソル位置pdf.SetXY(x, y)(なし — 行/列で書く、または page.Absolute(x, y, fn))
全ページ共通のヘッダpdf.SetHeaderFunc(fn)doc.Header(fn)
全ページ共通のフッタpdf.SetFooterFunc(fn)doc.Footer(fn)
ページ番号pdf.PageNo()(手動)c.PageNumber() / c.TotalPages()
ファイル出力pdf.OutputFileAndClose("out.pdf")data, _ := doc.Generate(); os.WriteFile("out.pdf", data, 0o644)
io.Writer に出力pdf.Output(w)doc.Render(w)

一番大きな変化は API の形。gofpdf は 命令型 で、カーソルを動かして書く。gpdf は 宣言的 で、行と列のツリーを記述してレイアウトエンジンに任せる。最初の数本は gpdf のほうが長く感じる。3 本目あたりで SetXY が恋しくなくなる。

単位の話。gofpdf は構築時に基準単位 ("mm" / "pt" / "in") を選ぶ。gpdf は内部はすべて pt で固定し、呼び出し側で document.Mm(20) / document.Pt(12) / document.Cm(1) などのヘルパを使う。CSS に近い感覚で、ヘッダのマージンを document.Mm(15) で切ったあとは単位のことを意識しなくなる。

Before / After 1: 一番シンプルな PDF

「Hello, World」のペア。gofpdf の短さこそが流行った理由なのは間違いない。gpdf 版は数行多い — カーソルを動かしているのではなく、ツリーを組んでいるからだ。

Before — gofpdf:

package main

import "github.com/jung-kurt/gofpdf"

func main() {
    pdf := gofpdf.New("P", "mm", "A4", "")
    pdf.AddPage()
    pdf.SetFont("Arial", "B", 24)
    pdf.Cell(40, 10, "Hello, World!")
    pdf.OutputFileAndClose("hello.pdf")
}

After — gpdf:

package main

import (
    "log"
    "os"

    "github.com/gpdf-dev/gpdf"
    "github.com/gpdf-dev/gpdf/document"
    "github.com/gpdf-dev/gpdf/template"
)

func main() {
    doc := gpdf.NewDocument(
        gpdf.WithPageSize(document.A4),
        gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
    )

    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("Hello, World!", template.FontSize(24), template.Bold())
        })
    })

    data, err := doc.Generate()
    if err != nil {
        log.Fatal(err)
    }
    if err := os.WriteFile("hello.pdf", data, 0o644); err != nil {
        log.Fatal(err)
    }
}

グリッドが仕事をしている。AutoRow は中身の高さで決まる行を追加し、r.Col(12, ...) は「12 グリッド全部を占める列」。Bootstrap と同じ発想を PDF ページに適用しただけ。

Generate() はバイト列を返す。io.Writer に流したいなら Render(w)。「ファイルを閉じる」処理がないのは gpdf がファイルハンドルを所有していないから。

Before / After 2: 明細表

請求書の明細表は gofpdf が一番饒舌になる場所。built-in のテーブルがないので、Cell を二重ループで叩き、列幅を自分で管理し、Ln(-1) で改行する。gofpdf の請求書チュートリアル記事は半分が表のボイラープレートで埋まっている。

Before — gofpdf:

pdf.SetFont("Arial", "B", 11)
pdf.SetFillColor(220, 220, 220)
pdf.CellFormat(80, 8, "品目",   "1", 0, "L", true, 0, "")
pdf.CellFormat(20, 8, "数量",   "1", 0, "C", true, 0, "")
pdf.CellFormat(30, 8, "単価",   "1", 0, "R", true, 0, "")
pdf.CellFormat(30, 8, "金額",   "1", 1, "R", true, 0, "")

pdf.SetFont("Arial", "", 11)
items := [][]string{
    {"フロントエンド開発", "40h", "¥15,000", "¥600,000"},
    {"バックエンド開発",  "60h", "¥15,000", "¥900,000"},
    {"UI デザイン",      "20h", "¥12,000", "¥240,000"},
}
for _, row := range items {
    pdf.CellFormat(80, 8, row[0], "1", 0, "L", false, 0, "")
    pdf.CellFormat(20, 8, row[1], "1", 0, "C", false, 0, "")
    pdf.CellFormat(30, 8, row[2], "1", 0, "R", false, 0, "")
    pdf.CellFormat(30, 8, row[3], "1", 1, "R", false, 0, "")
}

列幅を頭で計算しながら書く。品目名が折り返したら破綻する。

After — gpdf:

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(12, func(c *template.ColBuilder) {
        c.Table(
            []string{"品目", "数量", "単価", "金額"},
            [][]string{
                {"フロントエンド開発", "40h", "¥15,000", "¥600,000"},
                {"バックエンド開発",  "60h", "¥15,000", "¥900,000"},
                {"UI デザイン",      "20h", "¥12,000", "¥240,000"},
            },
            template.ColumnWidths(50, 15, 15, 20),
            template.TableHeaderStyle(
                template.Bold(),
                template.TextColor(pdf.White),
                template.BgColor(pdf.RGBHex(0x1A237E)),
            ),
            template.TableStripe(pdf.RGBHex(0xF5F5F5)),
        )
    })
})

ColumnWidths(50, 15, 15, 20) の数字は 絶対 mm ではなく、表が乗っている列の中での割合。同じ表を r.Col(6, ...) の中に入れても、この割合のままうまく収まる。CellFormat をラップしないと届かなかった抽象。

折り返しは自動。改ページも自動 — 表が下マージンを越えたら、次のページにヘッダ行が再描画される。

Before / After 3: お作法なしで日本語を書く

gofpdf を捨てる決め手はここだった。gofpdf で日本語を出すには AddUTF8Font を呼び、ディスク上の TTF を指し、フォントをセットして、祈る。サブセット化はだいたい動く。一部の TTF はグリッド ID 衝突を起こして文字化けを吐く。エラーメッセージは何の役にも立たない。

Before — gofpdf:

pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddUTF8Font("notosansjp", "", "NotoSansJP-Regular.ttf")
pdf.AddPage()
pdf.SetFont("notosansjp", "", 14)
pdf.Cell(0, 10, "こんにちは、世界。")
pdf.OutputFileAndClose("ja.pdf")

地雷が 2 つある。TTF が 実行時に 指定パスに存在している必要があり、Docker イメージにフォントを同梱する手間が発生する。Cell の幅を 0 にすると「右マージンまで」になるが、CJK では幅推定が全角を正しく数えず、はみ出してクリップされることがある。

After — gpdf:

package main

import (
    "log"
    "os"

    "github.com/gpdf-dev/gpdf"
    "github.com/gpdf-dev/gpdf/document"
    "github.com/gpdf-dev/gpdf/template"
)

func main() {
    fontData, err := os.ReadFile("NotoSansJP-Regular.ttf")
    if err != nil {
        log.Fatal(err)
    }

    doc := gpdf.NewDocument(
        gpdf.WithPageSize(document.A4),
        gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
        gpdf.WithFont("NotoSansJP", fontData),
        gpdf.WithDefaultFont("NotoSansJP", 14),
    )

    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("こんにちは、世界。")
            c.Text("吾輩は猫である。名前はまだ無い。")
            c.Text("東京都渋谷区神宮前1-2-3")
        })
    })

    data, _ := doc.Generate()
    os.WriteFile("ja.pdf", data, 0o644)
}

違いは 2 つ。

ひとつめ。パスではなくバイト列を渡す//go:embed NotoSansJP-Regular.ttf で TTF を埋め込めば、バイナリだけで完結する。本番で「フォントが見つからない」と言われない。Distroless でも Alpine でも動く。

ふたつめ。gpdf の TrueType サブセッタは CJK の cmap フォーマット (4, 6, 12) と Identity-H エンコーディングを理解している。出力 PDF には実際に使ったグリフだけが入る — NotoSansJP を 200 文字の請求書に使うと、約 30 KB のサブセットになる。フル埋め込みの 4 MB ではない。gofpdf で Japanese 1 ページの PDF が 5 MB になったことがある人は、まずこれに気づくはず。

IPAex ゴシック・源ノ角ゴシック・フォントフォールバックなど、もっと深い CJK 周りは別記事で扱う予定。

Before / After 4: 全ページ共通ヘッダ + フッタにページ番号

gofpdf の繰り返しクロムは SetHeaderFunc / SetFooterFunc で、それぞれカーソルに対して走る func() を渡す。ページ番号は pdf.PageNo()pdf.AliasNbPages() を組み合わせる。

Before — gofpdf:

pdf := gofpdf.New("P", "mm", "A4", "")
pdf.SetHeaderFunc(func() {
    pdf.SetFont("Arial", "B", 12)
    pdf.Cell(0, 10, "ACME 株式会社")
    pdf.Ln(15)
})
pdf.SetFooterFunc(func() {
    pdf.SetY(-15)
    pdf.SetFont("Arial", "I", 8)
    pdf.CellFormat(0, 10,
        fmt.Sprintf("Page %d/{nb}", pdf.PageNo()),
        "", 0, "C", false, 0, "")
})
pdf.AliasNbPages("")
pdf.AddPage()
// ... 本文 ...

{nb} は gofpdf が出力時に総ページ数で書き換えるセンチネル。動くんだけど「知っているかどうか」の世界。

After — gpdf:

doc := gpdf.NewDocument(
    gpdf.WithPageSize(document.A4),
    gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
)

doc.Header(func(p *template.PageBuilder) {
    p.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("ACME 株式会社", template.Bold(), template.FontSize(12))
            c.Line(template.LineColor(pdf.Gray(0.7)))
            c.Spacer(document.Mm(4))
        })
    })
})

doc.Footer(func(p *template.PageBuilder) {
    p.AutoRow(func(r *template.RowBuilder) {
        r.Col(6, func(c *template.ColBuilder) {
            c.Text("ACME 株式会社",
                template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
        })
        r.Col(6, func(c *template.ColBuilder) {
            // "ページ X / Y" — どちらもプレースホルダで、
            // ページネーション完了後にレイアウトエンジンが解決する。
            c.PageNumber(template.AlignRight(),
                template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
        })
    })
})

for i := 0; i < 10; i++ {
    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text(fmt.Sprintf("ページ %d の本文。", i+1))
        })
    })
}

PageNumberTotalPages はプレースホルダ。レイアウトエンジンがページ数を確定したあとに展開される。{nb} センチネルも SetY(-15) でフッタを下端に打ち付ける必要もない — フッタは単なるツリーで、毎ページ自動的に空間が確保される。

Before / After 5: HTTP ハンドラからバイト列を返す

実運用の gofpdf コードはたいていファイルではなく io.Writer に書く — 多くは application/pdf を返す http.ResponseWriter。このペアは gpdf の API が gofpdf に一番近い場所。

Before — gofpdf:

func handler(w http.ResponseWriter, r *http.Request) {
    pdf := gofpdf.New("P", "mm", "A4", "")
    pdf.AddPage()
    pdf.SetFont("Arial", "", 12)
    pdf.Cell(0, 10, "生成日時: "+time.Now().Format(time.RFC3339))

    w.Header().Set("Content-Type", "application/pdf")
    if err := pdf.Output(w); err != nil {
        http.Error(w, err.Error(), 500)
    }
}

After — gpdf:

func handler(w http.ResponseWriter, r *http.Request) {
    doc := gpdf.NewDocument(
        gpdf.WithPageSize(document.A4),
        gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
    )

    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("生成日時: " + time.Now().Format(time.RFC3339))
        })
    })

    w.Header().Set("Content-Type", "application/pdf")
    if err := doc.Render(w); err != nil {
        http.Error(w, err.Error(), 500)
    }
}

形は同じ。doc.Render(w) がそのままレスポンスに流す。Content-Length を立てたいなら、先に Generate() でバイト列を取って len() を入れる。

どれだけ速ければ「十分」か

gpdf は実用ワークロードで gofpdf より約 10 倍速い。下の数値は _benchmark/benchmark_test.go を Apple M1 / Go 1.25 で回したもの。

ベンチマークgpdfgofpdfgopdfMaroto v2
単一ページ13 µs132 µs423 µs237 µs
4×10 表108 µs241 µs835 µs8.6 ms
100 ページ683 µs11.7 ms8.6 ms19.8 ms
複雑な CJK 請求書133 µs254 µs997 µs10.4 ms

合成ベンチではない。表ベンチは 4 列 10 行の請求書明細、100 ページベンチはヘッダとページ番号付きのレポート。本番コードが実際にやる形に揃えてある。

意味のほうも軽く。13 µs/単一ページなら 1 コアで毎秒 75,000 枚、108 µs/表ありなら毎秒 9,000 枚。「PDF 生成はキャッシュすべきか? 非同期キューに逃すべきか?」を考えなくてよくなる。多くのワークロードはリクエスト同期で生成して問題ない。

移行で諦めるもの

ガイドが現実のギャップを隠していたら意味がない。gpdf がまだ gofpdf より弱いところを正直に挙げておく:

  • 任意角度の線、ベジェ、複雑なパスc.Line() は列を横切る水平線を引く。CAD 図面や独自グラフ描画には届いていない (チャートを画像化して埋めるのは普通に動く)。
  • SetXY 中心の絶対座標コードpage.Absolute(x, y, fn) で似たことはできるが、既存コードが 2,000 行の SetXY + Cell だと、移行は事実上書き直しに近い。書き直すと 1/2 になることが多いので、それが救い。
  • AcroForm (入力可能フォーム)。gpdf はまだ生成しない。閲覧側でユーザー入力を受けるテンプレ PDF を作っているなら、当面は AcroForm 対応のライブラリに残る選択肢。
  • 注釈・ブックマーク。基本的なアウトラインは出るが、リッチな注釈は未対応。

このどれにも刺さらないなら、移行はスルッと終わる。刺さるなら GitHub Issue を立ててほしい — ロードマップは要望ベース。

FAQ

gpdf は gofpdf のフォーク? 違う。gpdf は純 Go でゼロからの再実装。PDF ワイヤフォーマット、レイアウトエンジン、TrueType サブセッタ、すべて新規。gofpdf やそのフォークと共有しているコードはない。なぜフォークではないかというと、gofpdf のアーキテクチャは「単一のミュータブルなカーソル」を前提に作られていて、宣言的グリッドを後付けすると既存の呼び出しが全部壊れるから。

外部依存はある? コアはゼロ。go get github.com/gpdf-dev/gpdf のあとに go mod graph | grep gpdf を叩くと 1 行しか返らない。gpdf-pro 拡張 (HTML→PDF、AES 暗号化、署名、PDF/A) は HTML パーサのために golang.org/x/net を引くが、これはオプトインで、移行に必須ではない。

CGO は? gofpdf は CGO フリーだったけど、gpdf は? 同じく純 Go・CGO なし。GOOS=linux GOARCH=arm64 go build でクロスコンパイルして静的バイナリで配布できる。Distroless や Alpine では CGO ツールチェーンがないだけでイメージが半分になるので、ここは大事。

既存 gofpdf コードが SetXY だらけ。書き直しなしで移行できる?page.Absolute(x, y, fn) をラップして似た感触は出せる。ただ、コード全体がカーソル操作中心の構造だと、レイアウトエンジン的モデルへの移行は構文ではなく頭の切り替え。多くのチームでは「書き直したほうが元より短い」になる。

電子帳簿保存法・適格請求書の対応は? タイムスタンプとデジタル署名は gpdf-pro で対応中。要求があるなら Issue を立てて優先度を上げてほしい。

go-pdf/fpdf がアーカイブ解除されたら? 選択肢が 1 つ増えるだけ。gpdf を作った賭けは「gofpdf が永久にアーカイブ」のほうではなく、「カーソルベース・1 バイトフォント・CJK 非対応というアーキテクチャ自体が、誰がメンテしても袋小路」のほう。2026 年の PDF 生成はプロッタを動かすより Web ページを組むのに近く、API もそれを反映すべき。

gpdf を使ってみる

gpdf は Go の PDF 生成ライブラリ。MIT、ゼロ依存、CJK 対応。

go get github.com/gpdf-dev/gpdf

⭐ Star on GitHub · ドキュメント

次に読む

  • 12 カラムグリッドの仕組み (近日公開)
  • gpdf で日本語フォントを埋め込むには? (近日公開)
  • Quickstart — 5 分セットアップ、go.mod 含む