記事一覧

Go で PDF テーブルを描く — 列幅・縞模様・ページ分割

Go の PDF でテーブルを描くのは事故りやすい。gpdf は列幅・縞模様・ヘッダー繰り返しを Table 呼び出し 1 つに圧縮する。API の全貌とトレードオフ。

TL;DR

テーブルは PDF 生成で週末を潰す部分だ。 合計が合わない列幅、2 ページ目で消えるヘッダー、行ループのオフバイワンで描かれる縞模様。gpdf はこれを 1 つの呼び出しに畳む:

c.Table(header, rows,
    template.ColumnWidths(40, 15, 20, 25),
    template.TableHeaderStyle(template.TextColor(pdf.White), template.BgColor(brand)),
    template.TableStripe(pdf.RGBHex(0xF5F5F5)),
)

これで列幅、縞模様、そして ページ分割時のヘッダー自動繰り返し が揃う。行ループは書かない。PageBreak オプションも無い。テーブルが収まらないとレイアウトエンジンが気付き、次のページの先頭で Header スライスを再描画する。ColSpan / RowSpan、毎ページに繰り返すフッターが要るときだけ document.Table へ降りる — 同じ部品を、より細かく組む層だ。

この記事はテーブル設計で本当に効く 3 つの軸 (列幅・縞模様・ページ分割) について、gpdf がそれぞれ何をしていて、抽象化を意図的にどこで止めているかを書いた。

なぜこの記事を書くか

gpdf は Go の PDF 生成ライブラリ。MIT、外部依存ゼロ、1 ページの描画は約 13 µs。ハイレベル API の中でテーブル部分は小さい — TableOption は 8 個しかない — が、ここに掛かっている設計圧力は大きい。Go の PDF プロジェクトはほぼテーブルで詰まるからだ。

テーブルを Go で扱うときに事故るのは決まって以下の 3 点:

  1. 列幅。 Web には CSS の <col>colgroup がある。PDF にはない。点指定で全列を自分で計算するか、ライブラリの均等分割を受け入れるかの二択を迫られる。
  2. 縞模様。 1 行おきに薄いグレーで塗って読みやすくしたい。低レベルライブラリだと自分で行ループを書き、i % 2 を管理することになる — テーブル描画バグの半分はこの分岐から生まれる。
  3. ページ分割。 200 行のレポートは A4 1 ページに入らない。ライブラリは (a) 妥当な位置で本体を分け、(b) 現ページを閉じ、(c) 次のページを開き、(d) 新しいページの先頭でヘッダーを再描画する 必要がある。どれか 1 つでも欠けるとそのテーブルは使い物にならない。

この記事は gpdf が各課題をどう解いているかと、その設計上のトレードオフを順に説明する。コピペレシピだけで良ければ末尾の関連リンクへ。これは「1 万行の月次明細をこの API に預けて良いか」を判断したい人向けの長文版だ。

API の形

ビルダー層のエントリーポイントは 1 つ:

func (c *ColBuilder) Table(header []string, rows [][]string, opts ...TableOption)

ヘッダーは文字列スライス、行は文字列スライスのスライス、可変長 opts がそれ以外の全てを設定する。オプションコンストラクタは 8 個:

オプション何を制御するか
ColumnWidths(...float64)親 Col に対するパーセントで各列幅
TableHeaderStyle(...TextOption)ヘッダーの背景色とテキスト色
TableStripe(pdf.Color)ボディ偶数行の背景色 (縞模様)
TableCellVAlign(document.VerticalAlign)ボディセルの縦方向揃え (top/middle/bottom)
WithTableBorder(BorderSpec)テーブル全体の外枠
WithTableCellBorder(BorderSpec)全セルに同じ罫線 — グリッド表示
WithTableBorderCollapse(bool)CSS の border-collapse: collapse 相当
WithTableBackground(pdf.Color)テーブル全体の背景塗り

これがビルダー API の全表面だ。ビルダーで作れるものはこの 8 個で全部組む。これより外 — ColSpan、RowSpan、フッター、固定 pt 幅 — は document.Table に降りる。後述する。

動くコード: 半年分の請求台帳

完全なサンプル。main.go で保存して go run . を実行すると ledger.pdf が出力される。

package main

import (
    "fmt"
    "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(
        gpdf.WithPageSize(gpdf.A4),
        gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
    )

    brand := pdf.RGBHex(0x1A237E)
    stripe := pdf.RGBHex(0xF5F5F5)
    hairline := template.Border(
        template.BorderWidth(document.Pt(0.5)),
        template.BorderColor(pdf.Gray(0.85)),
    )

    header := []string{"日付", "請求書番号", "顧客名", "金額"}
    rows := make([][]string, 0, 120)
    for i := 1; i <= 120; i++ {
        rows = append(rows, []string{
            fmt.Sprintf("2026-%02d-%02d", (i%6)+1, (i%28)+1),
            fmt.Sprintf("INV-%05d", 10000+i),
            fmt.Sprintf("顧客 #%d", i),
            fmt.Sprintf("¥%d", (100+i*7)*100),
        })
    }

    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("2026 年上期 請求台帳", template.FontSize(18), template.Bold())
            c.Spacer(document.Mm(4))

            c.Table(header, rows,
                template.ColumnWidths(20, 20, 40, 20),
                template.TableHeaderStyle(
                    template.TextColor(pdf.White),
                    template.BgColor(brand),
                ),
                template.TableStripe(stripe),
                template.WithTableCellBorder(hairline),
            )
        })
    })

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

A4 で 120 行はおよそ 5 ページに渡る。各ページの先頭にダークブルーのヘッダーが再表示され、ボディは続きから始まり、縞模様の交互パターンはページをまたいでも一貫する。何も追加で書かなくていい。

このコードで注目したいのは 無いもの だ。行ループも、ページカウンタも、if i == lastRowOnPage の手動分岐も、PageBreak() 呼び出しも、ヘッダーの再描画も無い。4 行のオプションがテーブルの見た目を宣言し、エンジンが「いつ・どこで分割するか」を担当する。

列幅: パーセンテージは何のパーセンテージか

ColumnWidths(40, 15, 20, 25) は CSS の <col width="40%"> に近い。違いは 3 つ。

親 Col に対するパーセント、ページに対するではない。 r.Col(6, ...) は行のコンテンツ幅の半分を占める。その中に置いたテーブルで ColumnWidths(50, 50) を指定すると、各列は 行幅の 25% であってページ幅の 50% ではない。パーセントはテーブルが置かれている場所に対してローカル。テーブルを 1 行幅から横並びレイアウトに移すとき、オプションの値を変える必要が無い。

正規化はしない。 合計が 90 なら右に 10% 空く。110 なら一番右の列が親をはみ出してページの外へ流れる。gpdf は計算結果をそのまま使う。警告も出さない — 出すべきではない。書いた値を勝手に直す方が、バグより害が大きい。

末尾の不足は自動分配。 列数より少ない数の幅を渡すと、残りの列が等分する:

// 5 列のテーブル、3 つだけ指定
template.ColumnWidths(40, 10, 20)
// → 40% / 10% / 20% / 15% / 15%   (残り 30% を 2 列で分ける)

「ここの列幅だけ気にしたい」というときに使える。0 を渡せばその列も自動扱い:

template.ColumnWidths(0, 30, 30) // 3 列のテーブルで 40% / 30% / 30%

列幅の隅々については 列幅レシピ で深掘りしている。要点だけ言えば: パーセントで 95% のレイアウトは賄える。残り 5% は 1 つ下の層に降りる。後述。

縞模様: 自分では書かない行ループ

template.TableStripe(pdf.RGBHex(0xF5F5F5))

これだけ。gpdf はボディ行を 0 始まりの index i で歩き、i % 2 == 1 の行に色を塗る。ヘッダーは別スライスでカウント対象外なので、最初のボディ行はクリーン、2 行目が陰影付き — Bootstrap の慣習通り。

このオプションがある理由: gofpdfgopdf だと自分でループを書き、行ごとに SetFillColor を呼び、CellFormat に塗りフラグを渡す。8〜10 行のコードで、オフバイワンの発生頻度は StackOverflow に専用の回答セットがあるレベル。これを 1 つのオプションに畳むとバグクラスごと消える。

制約は意図的:

  • 縞色は 1 つだけ、2 色交互は無い。 「青とグレーの交互」みたいな指定はできない。ページが既に白いから、塗らない行は自動的に白になる。3 色サイクルを足すのは読み手に余計な認知負荷を掛ける — 縞模様は逆のためにある。
  • 奇偶を反転する手段は無い。 最初のボディ行は常にクリーン、2 行目は常に陰影付き。本当に逆にしたければデータの先頭に空行を足す。普通そうしたい人はいない。
  • ページをまたいでも縞模様は崩れない。 ボディ 14 行目はページ 2 に流れても 14 行目のままでパリティが保たれる。エンジンが分割を越えて index を引き継ぐ。

色選びとダークテーマ向けの値については 縞模様レシピ を参照。この記事の主張は: テーブルの 性質 (交互パターン) はテーブル呼び出し側で宣言するもので、行レベルで指示するものではない、ということだ。

ページ分割: ここが本当に難しい部分

Go の PDF ライブラリの大半はここで瓦解する。そして gpdf の設計が一番効いてくるのもここだ。

簡単に言うと: 1 ページに入りきらない量の行をテーブルに渡すと、gpdf が自動でページ分割する。Header スライスは継続ページの先頭で毎回再描画される。 有効化するオプションも、呼ぶメソッドも要らない。レイアウトエンジンの既定挙動だ。

詳しくはもう少し面白い話になる。ブロックレイアウトエンジン (document/layout/block.go) は利用可能な高さでテーブルをレイアウトする。ボディが収まらないと結果に Overflow フィールドが付く — 同じ Header、同じ Footer残りのボディ行 を持つ新しい *document.Table だ。ページシステムは収まった分を現ページに書き出し、次のページを開き、新しいページの利用可能高さでオーバーフローテーブルをもう一度レイアウトに掛ける。空になるまで繰り返す。

この設計から 2 つの帰結が出る:

  1. ヘッダーは行ループではなく tbl.Header に住んでいる。 オーバーフローテーブルは同じ Header スライスを再利用するため、継続ページの先頭でヘッダーが自動的に繰り返される。スタイルも列幅も全て同じ。
  2. 「ヘッダーがこのページに収まらない」エッジケースを考えなくていい。 レイアウトエンジンはボディ行を測る前にヘッダー分の領域を確保する。ヘッダー + 少なくとも 1 ボディ行が入らないなら、テーブル全体を次ページに送る。

フッター — ドキュメント層で使ったとき — も同じ仕組み。継続ページの末尾で自動的に毎回描かれる。

無い機能: 「この行グループは分けないで」アノテーション、特定行でのページ分割抑制、「このテーブルは新しいページから始めて」指示。前 2 つは TODO。3 つ目はページ層でやる — テーブルを含む行の前で doc.AddPage() を呼ぶ。

ビルダー API を超えるとき

ビルダーは「よくあるケース」に強い。セルスパンや固定 pt 幅、毎ページに繰り返すフッター、セルごとに異なるコンテンツ型を混ぜるとき、document.Table に降りる。

import (
    "github.com/gpdf-dev/gpdf/document"
)

footer := document.TableRow{
    Cells: []document.TableCell{
        {
            Content: []document.DocumentNode{
                &document.Text{Content: "合計", TextStyle: document.DefaultStyle()},
            },
            ColSpan: 3, // ← 最初の 3 列にまたがる
            RowSpan: 1,
        },
        {
            Content: []document.DocumentNode{
                &document.Text{Content: "¥4,872,000", TextStyle: document.DefaultStyle()},
            },
            ColSpan: 1,
            RowSpan: 1,
        },
    },
}

tbl := &document.Table{
    Columns: []document.TableColumn{
        {Width: document.Pct(20)},
        {Width: document.Pct(20)},
        {Width: document.Auto},
        {Width: document.Pt(80)}, // ページ幅に関わらず固定 80pt
    },
    Header: /* ... */,
    Body:   /* ... */,
    Footer: []document.TableRow{footer},
}

注目したい点がいくつか。TableColumn.Widthdocument.Value 型で、Pt / Mm / Cm / In / Em / Pct、そして特殊な Auto を取れる。1 つのテーブル内で混ぜられる。Auto 列は固定列とパーセント列を引いた残りを共有する。CSS の <col> 要素に近い。

TableCell.ColSpanRowSpan は整数で既定 1。例の通り、ヘッダー側 3 列を結合して「合計」と書き、4 列目で金額を見せる — 古典的な請求書フッターだ。

document.Table.Footer[]TableRow で、ヘッダーと同じく毎ページ繰り返される。ビルダー API がこれを公開していないのは、短いテーブルの大半で要らないから。要るときには既に「よくあるケース」を抜けている。

これは gpdf 全般のパターン。ハイレベルビルダーが 90% の場面を快適にカバーし、ドキュメント層が残り 10% のために隣に座っている。別ライブラリではない。同じドキュメント内でビルダー製の行と手組みの行を混在できる。ビルダーは同じ document.Table ノードのコンストラクタにすぎない。

罫線とボックスモデル

罫線オプションは 3 つ、それぞれ役割が違う:

template.WithTableBorder(spec)         // テーブル全体の外枠
template.WithTableCellBorder(spec)     // 全セルに同じ罫線
template.WithTableBorderCollapse(true) // 隣接セルの罫線を結合

既定では罫線は無い。外枠だけ欲しいなら WithTableBorder。グリッド表示にするなら WithTableCellBorder。両方付ければ「枠付きグリッド」。BorderSpectemplate.Border(template.BorderWidth(...), template.BorderColor(...)) で組む。

WithTableBorderCollapse(true) は CSS の同名プロパティと同じ意味: 隣接するセル罫線を 1 本に結合する (それぞれの辺で 2 重に描かない)。ヘアラインのグリッドではこちらの方が綺麗、太い罫線で 2 重描画を意図的に使いたいときは外す。既定は分離。

実用的な組み合わせはヘアライン + 薄い縞模様:

c.Table(header, rows,
    template.ColumnWidths(40, 20, 15, 25),
    template.TableHeaderStyle(template.TextColor(pdf.White), template.BgColor(brand)),
    template.TableStripe(pdf.RGBHex(0xF5F5F5)),
    template.WithTableCellBorder(template.Border(
        template.BorderWidth(document.Pt(0.5)),
        template.BorderColor(pdf.Gray(0.85)),
    )),
    template.WithTableBorderCollapse(true),
)

会計士の Excel 印刷プレビューのあの見た目だ。請求書、明細、台帳、経費精算 — 経理隣接ドキュメントの既定として正解。

他ライブラリとの比較

参考までに、同じ「複数ページ + 縞模様」のテーブルを他ライブラリで書くとどうなるか:

ライブラリテーブル本体の行数ページ分割時のヘッダー繰り返し縞模様備考
gpdf約 10 行自動TableStripe(...)ビルダーと低レベルの両方が使える
jung-kurt/gofpdf (2021 archived)40〜60 行手動: Y を追跡し AddPage を呼びヘッダーを再描画SetFillColor を行ループで草分けだが保守終了
go-pdf/fpdf (2025 archived)40〜60 行同上同上gofpdf フォーク。同じモデル
signintech/gopdf50〜80 行手動手動より低レベル
johnfercher/maroto v2約 15 行自動行ごとに WithBackgroundColor を手動gofpdf 上に構築。API は綺麗だが依存関係を引きずる
unidoc/unipdf約 12 行自動行スタイルヘルパーあり商用ライセンス必須

ビルダーの行数で比べると差は縮まる。本当の差は使い始めて 6 ヶ月後に出る。要件がドリフトしたとき — 列の整列が変わる、レポートが日本語版を必要とする、顧客がフッターに行数表示を要求する — gofpdfgopdf では毎回行ループを触る。gpdf ではオプションリストが伸びて、ボディは触らない。

ベンチマーク (µs/テーブル) は gpdf が速い理由 を参照。より広い軸での比較は 2026 ライブラリ総覧

CJK とテーブル

上の比較表からは見えない事実: gpdf は CJK グリフをネイティブに描く。日本語向けの「テーブルモード」は無い。フォントを 1 度登録すれば、テーブルもそのまま使う。

ttf, _ := os.ReadFile("NotoSansJP-Regular.ttf")
doc := gpdf.NewDocument(
    gpdf.WithPageSize(gpdf.A4),
    gpdf.WithFont("NotoSansJP", ttf),
    gpdf.WithDefaultFont("NotoSansJP"),
)

c.Table(
    []string{"日付", "請求書番号", "顧客名", "金額"},
    [][]string{
        {"2026-04-01", "INV-10001", "株式会社サンプル", "¥120,000"},
        {"2026-04-02", "INV-10002", "山田商店", "¥38,500"},
    },
    template.ColumnWidths(20, 20, 40, 20),
)

ヘッダーは日本語、ボディも日本語、列幅指定はパーセントのまま、ページ分割時のヘッダー繰り返しもそのまま動く。フォントはドキュメントが使うグリフだけにサブセット化されるので、Noto Sans JP の全部 (約 6 MB) ではなく、1 ページなら 50 KB 程度で収まる。

フォント登録自体については 日本語 TrueType フォントを埋め込む のレシピへ。この記事の論点は、データが CJK でもテーブル API は何も変わらない、ということだ。

よくある質問

Q: 行ごとのスタイル指定はできるか?

ビルダー API ではできない。ビルダーはボディに [][]string を取るので、ボディ全セルが列由来の同じ Style を共有する。行ごとに違うスタイルを当てたいときは document.Table 層で組む — 各 TableCell が個別の CellStyle を持つ。手順自体は単純、[][]string の手軽さだけ失う。

Q: セル内に画像や別のテーブルを置けるか?

document.Table 層なら可能。TableCell.Content[]DocumentNode で、*Text*Image もネストした *Table も入る。ビルダーの文字列ベース API はこれを公開していない — ほとんどの利用者にとって鋭いエッジになるので意図的に隠している — がモデル自体は対応している。

Q: gpdf はボディをページ間でどう分割しているか?

行単位。レイアウトエンジンはボディ行を順に測り、現ページに足し続けて、次の行を入れると高さが超過する直前で打ち切る。その行が overflow テーブルの先頭行になる。「この行群は離さない」アノテーションはまだ無い — 全ての行が分割可能だ。請求書の論理グループを 1 ページに収めたいときは、グループの前で手動でページを開くか、ドキュメント層で分割ヒントを差し込む。

Q: gpdf が描けるテーブルの最大サイズは?

A4 で 1 万行のボディは検証済み。正しくページ分割され、ヘッダーは毎ページ繰り返され、出力 PDF は約 150 ページ・数百 KB に収まる。ボトルネックはテーブルレイアウトではなくセル内テキストのシェイピングで、O(行数 × 列数)。10 万行以上が必要なら、ディスクに分割書き出し (1 万行ごとに Generate を複数回) するか、document.Table 層で事前シェイプ済みの run を渡す。

Q: フッターを最終ページだけに表示できるか?

組み込みでは不可。document.Table.Footer は設計上毎ページ繰り返される — 列合計をページ別に出すのが普通の用途だから。ドキュメント末尾に 1 度だけ出すサマリーが欲しいなら、テーブルの中ではなく、テーブルの後に別の行ブロックとして追加する。

Q: WithTableCellBorder はヘッダーにも効くか?

効く。セル罫線はヘッダーとボディに一律に適用される。ヘッダーだけ違う罫線 (例えば下辺だけ太く) にしたければ、ヘッダーをドキュメント層で組み、CellStyle.Border をセル単位で指定する。

設計の全体像

1 つだけ持ち帰るなら: gpdf のテーブル API が小さいのは、テーブル問題のほぼ全てが結局 3 つの問題に帰着するから。 列幅、縞模様、ページ分割。それ以外は long tail だ。よくあるケースをビルダーに、long tail をドキュメント層に置く — このトレードがあるから、毎日出てくる用途は 5 行で書け、ビルダーで表現できないものを書くときに抽象化のコストを払わなくていい。

代償ははっきりしている: setRowStyle(i, ...) ショートカットは無い、これからも入らない。4 行目と 5 行目を別スタイルにしたいなら、ビルダーが意図しない複雑さラインを越えている。1 つ降りる。境界は明確で安定している。

これで全部だ。1 度読めばあとは考えなくていい部分の API について、20 分の読み物。

gpdf を試す

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

go get github.com/gpdf-dev/gpdf

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

関連リンク