記事一覧

Go で請求書 PDF を 50 行以下で作る

動く請求書 PDF の完全コードを Go で 50 行。依存ゼロ、Chromium も CGO も不要。gpdf だけで header / table / total まで全部入り。

TL;DR

Go で動く請求書 PDF を 50 行 で書く。main.go 1 つ、go get 1 回、Chromium なし、CGO なし、テンプレート言語なし、HTML なし。表もストライプも右寄せ合計もあり。コード全体は下に置く。残りは各ブロックの解説と、このスタイルが破綻する条件の話。

先にコードだけ読みたい人向け:

go get github.com/gpdf-dev/gpdf

あとは次のセクションの main.go を貼るだけ。

なぜ「50 行以下」という閾値にこだわるのか

正直に書くと、「go 請求書 pdf」で検索して出てくる記事の多くは、(a) ヘッドレス Chromium を立てろと言うか、(b) 1 つの表を描くのに PDF の低レベル演算子を 400 行書く例を載せているか、のどちらか。両方とも技術的には間違いではない。でも、やりたいことの形と合っていない。

まともな請求書に必要なのは:

  • 発行元と宛先の情報が載ったヘッダ
  • 請求番号と支払期日
  • 明細行の表
  • 合計金額

4 つ。だからコードも 4 ブロックで済むはず。1 画面に収まらない時点で、ライブラリの選択が間違っている。

50 行 は、普通のエディタで 1 画面に収まる限界値。そしてレビュアーがスクロールせずに全部読む限界でもある。これを切れると、生成結果を Slack に貼るだけで他人がライブラリを理解できる。そのラインを狙う。

下のコードは gofmt 済み、import 完全展開、エラーも全部拾っている。隠されたヘルパーパッケージも小細工もない。見えているものがそのままコンパイルされる。

50 行

package main

import (
    "log"
    "os"

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

func main() {
    doc := gpdf.NewDocument(template.WithPageSize(document.A4))
    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(6, func(c *template.ColBuilder) {
            c.Text("ACME 株式会社", template.FontSize(22), template.Bold())
            c.Text("東京都千代田区丸の内 1-1-1")
        })
        r.Col(6, func(c *template.ColBuilder) {
            c.Text("請求書 #INV-2026-001", template.Bold(), template.AlignRight())
            c.Text("支払期日: 2026-03-31", template.AlignRight())
        })
    })
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Spacer(document.Mm(6))
            c.Table(
                []string{"品目", "数量", "単価", "金額"},
                [][]string{
                    {"フロントエンド開発", "40 時間", "¥18,000", "¥720,000"},
                    {"バックエンド開発", "60 時間", "¥18,000", "¥1,080,000"},
                    {"UI/UX デザイン", "20 時間", "¥15,000", "¥300,000"},
                },
                template.ColumnWidths(40, 15, 20, 25),
                template.TableHeaderStyle(template.Bold(), template.BgColor(pdf.RGBHex(0xF0F0F0))),
                template.TableStripe(pdf.RGBHex(0xFAFAFA)),
            )
            c.Text("合計: ¥2,100,000", template.AlignRight(), template.Bold(), template.FontSize(14))
        })
    })
    b, err := doc.Generate()
    if err != nil {
        log.Fatal(err)
    }
    if err := os.WriteFile("invoice.pdf", b, 0644); err != nil {
        log.Fatal(err)
    }
}

※ 上のコードをそのまま go run . すると日本語は「豆腐」(□) になる。日本語フォントの埋め込みは最後に別途扱う。まず構造を見てほしい。

go run . でカレントディレクトリに invoice.pdf が出力される。M1 の手元では数ミリ秒で終わる。実際の PDF 生成部分は 150 µs 未満。残りはプロセスの起動時間。

各ブロックが何をしているか

import

gpdf からは 4 つのパッケージ:

  • github.com/gpdf-dev/gpdf — ファサード。使うのは gpdf.NewDocument だけで、実体は template.New の薄いラッパ。
  • github.com/gpdf-dev/gpdf/document — 単位 (Mm, Pt, Cm, In, Em, Pct)、ページサイズ (A4, Letter, Legal)、マージン。
  • github.com/gpdf-dev/gpdf/pdf — 色プリミティブ (RGBHex, Gray, pdf.White のような定数)。
  • github.com/gpdf-dev/gpdf/template — Builder API 本体。template. で始まるもの (オプション、レイアウト関数、スタイル修飾) は全部ここから出る。

4 パッケージ分割が多いと思うかもしれないが、これは意図的。pdf は低レベル Writer なのでほぼ直接触らないが、色は Text / Table / Line で共有するため外に出してある。残り 3 つはどの gpdf コードでも必ず import する。

外部依存ゼロ。go get github.com/gpdf-dev/gpdf 後の go.mod:

require github.com/gpdf-dev/gpdf v1.x.x

require ブロックはこれだけ。indirect が雪崩れ込むことはない。

Document の構築

doc := gpdf.NewDocument(template.WithPageSize(document.A4))

gpdf.NewDocument...template.Option 可変引数。ページサイズ、マージン、デフォルトフォント、メタデータ、カスタムフォント、すべて WithXxx オプション。US レター用紙なら document.A4document.Letter に差し替える。マージンのデフォルトは 20 mm。もっと狭くしたければ template.WithMargins(document.UniformEdges(document.Mm(15)))

上のコードはライブラリ同梱の Latin フォントで描画される。日本語を出すには TTF を template.WithFont で登録する必要がある。それが gpdf に来る人の一番の用件だが、ここでは別トピック。日本語対応の節で扱う。

ヘッダ行

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(6, func(c *template.ColBuilder) { ... })
    r.Col(6, func(c *template.ColBuilder) { ... })
})

gofpdf や gopdf から来た人が驚く部分: gpdf は 12 カラムグリッドを採用している。Bootstrap と同じメンタルモデル。1 行は水平方向に 12 単位分。r.Col(6, ...) はその半分。Col(6) が 2 つで合計 12、行をちょうど埋める。

AutoRow は「いちばん背の高いカラムに合わせて行の高さを決める」方式。カード状の固定高レイアウトなら FixedRow(height, fn) もある。請求書なら AutoRow で正解。

各カラムの中では c.Text(...) を縦に積んでいくだけ。明示的な座標指定はない。ビルダーが内部でカーソルを持っていて、各要素の描画高さぶんだけカーソルが進む。

右側カラムは template.AlignRight() で右寄せ。テキストオプションは合成可能で、c.Text("請求書", template.Bold(), template.AlignRight(), template.FontSize(20)) のように 1 回の呼び出しで 3 つの修飾子を重ねられる。順番は関係ない。

明細テーブル

c.Table(
    []string{"品目", "数量", "単価", "金額"},
    [][]string{
        {"フロントエンド開発", "40 時間", "¥18,000", "¥720,000"},
        ...
    },
    template.ColumnWidths(40, 15, 20, 25),
    template.TableHeaderStyle(template.Bold(), template.BgColor(pdf.RGBHex(0xF0F0F0))),
    template.TableStripe(pdf.RGBHex(0xFAFAFA)),
)

位置引数 3 つ、形状を決めるオプション 3 つ。これだけ。

ColumnWidths(40, 15, 20, 25)カラム幅のパーセンテージ。絶対値の pt ではない。4 つの数字の合計は 100。40, 20, 20, 20 (合計 100) なら問題なし。40, 15, 20, 30 (合計 105) を渡しても描画はされるが、最後のカラムがはみ出す。これが唯一のハマりどころ。合計 100 にするか、はみ出しを受け入れるか。合計不一致を強制エラーにするかは検討したが、あえて 90% 合計で右に 10% 余白を欲しいレイアウトもあるので、ゆるくしてある。

TableHeaderStyle は通常のテキストオプション (Bold, TextColor, BgColor, AlignCenter) をそのまま受け取る。暗色ヘッダが欲しければ 1 行で済む。TableStripe(color) は縞模様 (ゼブラ) の背景色。省略すれば行の背景は透明。

ここで書かないこと:

  • 行の高さは指定しない。テーブルが各セルを計測して、一番高いセルに合わせる。
  • フォントは指定しない。テーブルはカラムのデフォルトを継承し、カラムはドキュメントのデフォルトを継承する。
  • ページ分割も書かない。テーブルがページを超えると gpdf が自動で分割し、継続ページの先頭にヘッダを再描画する。上の 3 行なら 1 ページに確実に収まるが、100 行でも同じコードで動く。

合計

c.Text("合計: ¥2,100,000", template.AlignRight(), template.Bold(), template.FontSize(14))

特別なことはしていない。テーブルの外に Text を置いて、右寄せ、少し大きく。テーブルとの間隔は行のカーソルが自然に進むぶんで、明示的な Spacer はない。余白が欲しければ c.Table(...)c.Text(...) の間に c.Spacer(document.Mm(3)) を挟む。

生成と書き出し

b, err := doc.Generate()
if err != nil { log.Fatal(err) }
if err := os.WriteFile("invoice.pdf", b, 0644); err != nil { log.Fatal(err) }

doc.Generate()([]byte, error) を返す。ファイルシステムには触れない。返ってきたバイト列はそのまま完全な PDF。ディスクに書くもよし、S3 にアップするもよし、HTTP レスポンスに w.Write(b) で流すもよし、メールに添付するもよし。一時ファイルもクリーンアップも不要。

バッファを経由せずにストリーム書き込みしたいなら doc.Render(w io.Writer) が用意されている。請求書のサイズ (数 KB) なら差は誤差。1 万ページの帳票なら Render を使う。

日本語対応

上のコードは Latin-1 フォントしか持っていないので、日本語は豆腐 (□) になる。TTF を 1 行加えれば直る。Noto Sans JP を使う例:

ttf, err := os.ReadFile("NotoSansJP-Regular.ttf")
if err != nil { log.Fatal(err) }
doc := gpdf.NewDocument(
    template.WithPageSize(document.A4),
    template.WithFont("NotoSansJP", ttf),
    template.WithDefaultFont("NotoSansJP", 10),
)

WithDefaultFont を使うとこのドキュメント全体のデフォルトが NotoSansJP になるので、以降の c.Text(...) はそのまま日本語で描画される。フォントサブセット化は gpdf が自動でやるので、NotoSansJP の 3 MB TTF が PDF に全部埋め込まれる心配はない。実際に使った字だけが埋め込まれる。

詳しくは gpdf で日本語フォントを埋め込むにはPDF で日本語が豆腐になる原因 を参照。

50 行を崩さずに見栄えを整える

上のバージョンは機能はしているが地味。1 行の追加でかなり印象が変わる。

ブランドカラー。 16 進値 (例えば紺色の 0x1A237E) を会社名とテーブルヘッダに流す:

brand := pdf.RGBHex(0x1A237E)
c.Text("ACME 株式会社", template.FontSize(22), template.Bold(), template.TextColor(brand))
// ...
template.TableHeaderStyle(template.Bold(), template.TextColor(pdf.White), template.BgColor(brand)),

追加 2 行、変更 1 行。まだ 50 行以下。

小計と消費税。 合計の上に小計と税額を分けて出すなら、c.Text を 3 つ積む:

c.Text("小計: ¥2,100,000", template.AlignRight())
c.Text("消費税 (10%): ¥210,000",  template.AlignRight())
c.Text("合計:    ¥2,310,000",      template.AlignRight(), template.Bold(), template.FontSize(14))

ここで 50 行の予算を 2 行オーバーする。「50」という数字を売るか「消費税行を足してもレイアウトが崩れない」を売るか。我々は後者を採る。

合計の上に罫線。 小計ブロックと合計の間に c.Line() を挟む:

c.Spacer(document.Mm(2))
c.Line(template.LineThickness(document.Pt(0.5)))
c.Spacer(document.Mm(2))

適格請求書 (インボイス制度) 対応。 登録番号を発行元セクションに 1 行追加するだけ。レイアウトの変更なし:

c.Text("登録番号: T1234567890123")

税率ごとの区分 (8%/10%) を明細表に入れる場合は、カラムを 5 つに増やして ColumnWidths(30, 10, 15, 15, 10, 20) みたいに調整する。ここまで来るとレイアウトの範疇で、ライブラリ機能というより設計の話。

実行

mkdir invoice-demo
cd invoice-demo
go mod init example.com/invoice-demo
go get github.com/gpdf-dev/gpdf
# main.go を貼る
go run .
open invoice.pdf    # macOS; Linux は xdg-open, Windows は start

最初の go get でソース約 3 MB が落ちてくる (コンパイル済みバイナリはない)。モジュールはキャッシュされるので 2 回目以降は一瞬。

go run . が何も言わず終わるのに PDF が見つからない場合は、別のディレクトリに書かれていないか確認する。プログラムはカレントディレクトリをそのまま出力先として使う。読み取り専用ファイルシステムの Docker 内で走らせる場合は os.WriteFileio.Copy(w, bytes.NewReader(b)) に差し替えて HTTP レスポンスに流す。

このパターンが崩れるとき

50 行バージョンは、以下の 4 つのどれかが起きるまでは穏やかにスケールする。

明細がデータになる。 ハードコードされたスライスではなく DB クエリや JSON ペイロードから明細が来るようになった場合、テーブル部分のコードは変わらない。[][]string を構築する処理が加わるだけ。これは破綻ではなく想定通りの形。

レイアウトを使い回したくなる。 ループで複数の請求書を生成し始めた瞬間に、main に全部書くのは止める。func renderInvoice(doc *template.Document, inv Invoice) に切り出す。50 行テンプレの骨格は残ったまま、doc とデータを引数で回すだけ。

レイアウトに分岐が入る。 発注番号列がある請求書・ない請求書、消費税行がある客・ない客。条件分岐が増えてくると、Builder API は冗長に感じ始める。そのときは JSON スキーマ入口 (gpdf.NewDocumentFromJSON) か Go テンプレート入口 が合う。構造をテンプレートファイルで宣言して、データだけ流す形になる。

CJK テキストが必要になる。 上のコードで日本語・中国語・韓国語を書くと豆腐になる。ドキュメント構築時に TTF を 1 つ登録するだけで直る (上の「日本語対応」節を参照)。

どれも「ゼロから書き直し」にはならない。全部インクリメンタルな追加。50 行バージョンはそのまま進化させていける出発地点。

FAQ

この形式で商用請求書を作っても大丈夫ですか? 大丈夫。gpdf は MIT ライセンス。商用クローズドソース製品に組み込むのも自由。表記義務もなし。GitHub で Star をくれると嬉しい。

バイトスライスを経由せず io.Writer に直接書けますか? 書ける。doc.Render(w io.Writer) error。上の doc.Generate() は「[]byte がほしい」というよくあるケースのための便利メソッド。

実際どのくらい速いですか? 上の 50 行は M1 で約 100 µs で PDF を生成する (3 行テーブルとテキストレイアウトが大半)。単一ページの hello world は 13 µs。夜間バッチで一斉発行するような大量処理では、gpdf 自体がボトルネックにはならず、データを供給する側が先に詰まる。

gpdf を使わずに Go で請求書 PDF を作ることもできますよね? できる。jung-kurt/gofpdf (アーカイブ済みだが動く)、signintech/gopdf (低レベル)、johnfercher/maroto (別のレイアウト抽象)。どれで書いても、上の 50 行と同等のものを作るには冗長になる。詳細は 2026 年版 Go PDF ライブラリ比較

なぜ gpdf.Invoice のようなヘルパーがないのですか? 請求書のフォーマットが国ごとに違って、どう簡略化しても誰かを切り捨てるから。日本の適格請求書、米国の W-9 付き、ブラジルの NFe。それぞれ要件が違う。コンストラクタ 1 つで全部は受けきれない。50 行の出発点を渡して、そこから派生させてもらう方が現実的。Builder API がそのヘルパー。

PDF/A などの規格適合はできますか? デフォルトは標準 PDF 1.7。PDF/A-2b (長期保存用規格) が必要なら gpdf.WithPDFA(pdfa.Level2B) をドキュメント構築時に渡す。これは別記事の 純 Go で PDF/A-2b を作る で扱う。

gpdf を使ってみる

gpdf は Go の PDF 生成ライブラリ。MIT ライセンス、依存ゼロ、CJK ネイティブ対応、ベンチマーク対象のワークロードで他ライブラリの 10〜30 倍速い。

go get github.com/gpdf-dev/gpdf

⭐ GitHub で Star · ドキュメントを読む

次に読む