記事一覧

Go の PDF にページ番号とヘッダー・フッターをちゃんと入れる

gpdf でヘッダー、フッター、『Page X of Y』を Go PDF に入れる方法。ビルダの 2 メソッドと、ページ総数を自動で埋める 2 段階パスの仕組みを解説。

60 ページの財務レポート。誰かが印刷キューでページ 12 を開いて聞く。「これ今何ページ目で、あと何ページ残ってる?」フッターに 12 とだけあっても分からない。12 / 60 と書いてほしい。

この 60 の側が、ほとんどの PDF ライブラリで詰む部分。フッターを書く時点では総ページ数が確定していないからだ。あるいは AliasNbPages 的なトークンに頼って後から書き換えるか、文書を 2 回レンダリングして 1 回目を捨てるか。

gpdf は 2 つのビルダメソッドと、内部の 2 段階パスでこれを素直に解決する。本記事では API、内部の仕組み、そして 1 つだけ気になるエッジケースを書く。

TL;DR

  • doc.Header(fn)doc.Footer(fn) に渡したクロージャは全ページで実行される。
  • クロージャの中では本文と同じ 12 列グリッドが使える。
  • 現在ページは c.PageNumber()、総ページは c.TotalPages()
  • 総ページは、ページネーション完了後の 2 段階目のパスで自動的に解決される。自分で 2 周ビルドを書く必要はない。
  • 一点だけ気になる箇所: c.PageNumberOf(total) のように "3 of 12" を 1 つの文字列として描画するヘルパーは現状無い。3 列に分割して合成する。後述する。

本記事の全コードは gpdf/_examples/builder/26_page_number_test.go から取ってきた実コード。テストスイートに入っているのでビルドは保証されている。

1 ファイルで完結する全体像

これは完全に動くプログラム。main.go に保存して go run main.go すると、4 ページの PDF が出来て、各ページのヘッダーには総ページ数、フッターには現在のページ番号が出る。

package main

import (
    "os"

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

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

    doc.Header(func(p *template.PageBuilder) {
        p.AutoRow(func(r *template.RowBuilder) {
            r.Col(6, func(c *template.ColBuilder) {
                c.Text("四半期レポート", template.Bold(), template.FontSize(10))
            })
            r.Col(6, func(c *template.ColBuilder) {
                c.TotalPages(template.AlignRight(), template.FontSize(9),
                    template.TextColor(pdf.Gray(0.5)))
            })
        })
        p.AutoRow(func(r *template.RowBuilder) {
            r.Col(12, func(c *template.ColBuilder) {
                c.Line(template.LineColor(pdf.RGBHex(0x1565C0)))
                c.Spacer(document.Mm(3))
            })
        })
    })

    doc.Footer(func(p *template.PageBuilder) {
        p.AutoRow(func(r *template.RowBuilder) {
            r.Col(12, func(c *template.ColBuilder) {
                c.Spacer(document.Mm(3))
                c.Line(template.LineColor(pdf.Gray(0.7)))
                c.Spacer(document.Mm(2))
            })
        })
        p.AutoRow(func(r *template.RowBuilder) {
            r.Col(6, func(c *template.ColBuilder) {
                c.Text("Generated by gpdf", template.FontSize(8),
                    template.TextColor(pdf.Gray(0.5)))
            })
            r.Col(6, func(c *template.ColBuilder) {
                c.PageNumber(template.AlignRight(), template.FontSize(8),
                    template.TextColor(pdf.Gray(0.5)))
            })
        })
    })

    for _, title := range []string{"はじめに", "背景", "分析", "結論"} {
        page := doc.AddPage()
        page.AutoRow(func(r *template.RowBuilder) {
            r.Col(12, func(c *template.ColBuilder) {
                c.Text(title, template.FontSize(18), template.Bold())
                c.Spacer(document.Mm(5))
                c.Text(title + " のセクション本文。")
            })
        })
    }

    out, err := doc.Generate()
    if err != nil {
        panic(err)
    }
    _ = os.WriteFile("report.pdf", out, 0o644)
}

4 ページ生成され、ヘッダー右端に 4 が、フッター右端に 14 が出る。一度も「4 ページの文書だ」とは書いていない。gpdf 自身もページネーションが終わるまでは知らない。

「Page X of Y」の Y が難しい理由

Y は、ページ 1 を描画する時点ではまだ確定していない。50 ページのレポートで、ページ 47 がテーブル行の都合でページ境界に挟まれて 2 ページに割れる可能性もある。総数 50 はページネーション完了後にしか出ない。ページ 1 のフッターはそれよりずっと前に描画される。

PDF ライブラリはどれもこの壁にぶつかる。Go の主要ライブラリの回避策を並べる。

ライブラリ「Page X of Y」の実装
gofpdfpdf.AliasNbPages("{nb}") を呼び、本文中に {nb} を文字列として書く。出力ストリームに対して後から置換が走る。動くが、置換し忘れると {nb} がそのまま印字される。
go-pdf/fpdfgofpdf のフォーク。同じ仕組み。
signintech/gopdf公式サポートなし。自前で文書を 1 回ビルドしてページ数を数え、もう 1 回ビルドする。
maroto v2gpdf に近い Header/Footer 登録。内部では似た 2 段階パスを取るが、土台が gofpdf ベースなので、共通ワークロードで gpdf より 10 倍遅い。
gpdfc.PageNumber() / c.TotalPages() 。型付きメソッド、マジック文字列なし、内部の 2 段階パスで解決。

gpdf のアプローチだけが、ページ番号プリミティブを型付きビルダ API の一部にしている。gofpdf で {nb}{nB} と打ち間違えるとそのまま {nB} が PDF に出る。c.TotalPages() で起きうる最悪は「呼び忘れ」で、数字が出ないだけ。間違った数字は出ない。

2 段階パスの仕組み

内部では c.PageNumber() がプレースホルダ文字列としてレンダリングされる。実フォントのグリフには一致しないセンチネルだ。ページネーションが全ページのレイアウトを終え総数が確定した時点で、レンダー済みのテキスト命令列を走査し置換する:

  1. 1 段階目 (ページネーション): ヘッダー・フッター含む全ページを描画。PageNumberTotalPages は固定幅トークンとして扱う。総ページ数を確定。
  2. 2 段階目 (解決): ページツリーを再走査し、各センチネルを実数値で置換する。

プレースホルダの幅は想定される最大ページ数に基づくヒューリスティクスで確保されているので、置換後にレイアウトがズレない。9 ページ → 10 ページの桁数増加でも右揃えのページ番号は揃ったまま。

2 段階目のコードは書かなくていい。文書を 2 回レンダリングする必要もない。doc.Generate() を呼ぶと bytes が返ってくる。

ヘッダーとフッターは普通のレイアウト

gofpdf から来た人がここで戸惑う。あちらでは SetHeaderFunc が固定 Y 座標でコールバックされて、絶対座標の Cell(...) で文字を置く。gpdf ではヘッダーのクロージャは *template.PageBuilder を受け取る。本文と同じ型だ。グリッドも同じ。行と列も同じ。スタイル指定も同じ。

doc.Header(func(p *template.PageBuilder) {
    p.AutoRow(func(r *template.RowBuilder) {
        r.Col(2, func(c *template.ColBuilder) {
            c.Image("logo.png", template.ImageHeight(document.Mm(12)))
        })
        r.Col(8, func(c *template.ColBuilder) {
            c.Text("Annual Report 2026", template.Bold(), template.FontSize(14))
        })
        r.Col(2, func(c *template.ColBuilder) {
            c.TotalPages(template.AlignRight())
        })
    })
})

ロゴを左、タイトルを中央、総ページを右に配置したヘッダー。列幅の合計は 12 で、本文の行と同じルール。

ヘッダーの高さは自動測定される。gpdf は本文レイアウトの前にヘッダークロージャを 1 回実行して描画結果の高さを測り、各ページの本文有効高から引く。フッターも同じ。headerHeight を渡す必要はない。ヘッダーに行を 1 つ足せば、本文がその分だけ縮む。

両方ともすべてのページで繰り返される。オーバーフローで生成されたページも含む。長いテーブルがページ 12 まではみ出したら、ページ 12 にもヘッダーとフッターが出る。「最初のページだけ」フラグは現時点で無い (後述)。

引っかかる点: 「Page X of Y」を 1 行で

API としてここは正直なところもう少し良くしたい部分。c.PageOf("Page %d of %d") のようなヘルパーは存在しない。"Page 3 of 12" という 1 つの文字列を作るには、c.Text()c.PageNumber() が独立した子要素なので、列に分けて合成する:

r.Col(12, func(c *template.ColBuilder) {
    c.AutoRow(func(r *template.RowBuilder) {
        r.Col(3, func(c *template.ColBuilder) {
            c.Text("Page", template.AlignRight())
        })
        r.Col(2, func(c *template.ColBuilder) {
            c.PageNumber(template.AlignCenter())
        })
        r.Col(2, func(c *template.ColBuilder) {
            c.Text("of", template.AlignCenter())
        })
        r.Col(3, func(c *template.ColBuilder) {
            c.TotalPages(template.AlignLeft())
        })
        r.Col(2, func(c *template.ColBuilder) {})
    })
})

これで動く。見た目も悪くない。ただ、普通なら 1 行のフォーマット文字列で済む内容を 4 列に展開していて、紙のささくれみたいな違和感はある。c.PageOf(format string, opts ...TextOption) のようなヘルパーを fmt.Sprintf 風の %d プレースホルダで実装することを検討中。API の形に意見があったら GitHub issue で教えてほしい。

現状の実用的なショートカットは「Page」を省いてスラッシュ区切りにすること:

r.Col(6, func(c *template.ColBuilder) {
    c.PageNumber(template.AlignRight())
})
r.Col(1, func(c *template.ColBuilder) {
    c.Text("/", template.AlignCenter())
})
r.Col(5, func(c *template.ColBuilder) {
    c.TotalPages(template.AlignLeft())
})

3 / 12 はフッターとして十分読める。"3 ページ目 / 全 12 ページ" のように接辞付きで出したい場合も、同じ要領で c.Text("ページ目") を挟む。

よくある配置

実務で使うパターンをいくつか。

タイトル下に区切り線。 もう 1 行 AutoRow を足して c.Line() を入れる。記事冒頭の例はこの形。

「社外秘」を中央に出すフッター。 1 行 1 列、AlignCenter だけ。

doc.Footer(func(p *template.PageBuilder) {
    p.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("社外秘 — 取扱注意",
                template.AlignCenter(),
                template.FontSize(8),
                template.TextColor(pdf.Gray(0.5)))
        })
    })
})

日本企業の文書だと、これに「印刷日: 2026年5月19日」「文書 ID: DOC-2026-0517」のような行を足すパターンが多い。普通に c.Text(...) で 2〜3 行積めばいい。

左ロゴ、右ページ番号。 8/4 か 6/6 で 2 列に割る。左列に c.Image(...)、右列に c.PageNumber()AlignRight で。

「次ページに続く」をフッターに。 現在未対応。ヘッダー/フッターのクロージャは PageBuilder だけを受け取り、現在のページインデックスは渡らないので、「最後のページかどうか」で分岐できない。本文側に末尾以外のページで「続く」行を足したいなら、結局総ページ数を事前に知る必要があり矛盾する。要望リストには載っている。

1 ページ目だけヘッダーを変える。 同じ理由で現状未対応。回避策はページ 1 の本文先頭にスペーサーを入れてヘッダーを実質空にし、ページ 2 以降は通常の流れに任せる、という不格好なもの。doc.HeaderOn(pages, fn) バリアントを設計中。

CJK もそのまま動く

gpdf は CGO なしで TrueType フォントをサブセット化する。日本語・中国語・韓国語をヘッダーやフッターにそのまま書ける。AddUTF8Font 的な儀式も不要、フォントが対応グリフを持っていれば豆腐は出ない。

doc := template.New(
    template.WithPageSize(document.A4),
    template.WithFont("NotoSansJP", notoSansJPRegular),
)

doc.Footer(func(p *template.PageBuilder) {
    p.AutoRow(func(r *template.RowBuilder) {
        r.Col(6, func(c *template.ColBuilder) {
            c.Text("社外秘", template.FontFamily("NotoSansJP"), template.FontSize(8))
        })
        r.Col(6, func(c *template.ColBuilder) {
            c.PageNumber(template.AlignRight(), template.FontSize(8))
        })
    })
})

最終 PDF に埋め込まれるサブセットには「実際に使ったグリフだけ」入る。60 ページのレポートのフッターに "社外秘" しか書かなければ、NotoSansJP からは 3 グリフだけ埋め込まれる。2 万グリフではない。電子帳簿保存法対応の文書で容量制限がある場合にも効く。

パフォーマンス

スケール時に効く話。

2 段階目のパスはタダではないが、安い。M1 で 100 ページの文書だと 2 段階目に 50µs 未満。生成時間全体の 1% 以下。gpdf の単一ページベンチは 13µs、100 ページベンチは 683µs。ページ番号解決は内容の複雑度に依らない定数倍。

参考までに gofpdf の AliasNbPages は圧縮判断後のコンテンツストリーム全体に文字列置換をかけて、エイリアスを含むストリームの再圧縮が走る。gofpdf 自身のベンチで 100 ページ文書の総時間の 2〜4% に乗っていた。gpdf 側の置換はストリームエンコード前に走るので速い。

1 日 100 万 PDF を生成するなら効く差。1 日 10 件なら関係ない。

FAQ

ヘッダー/フッターの高さはページマージンに食い込む? 食い込まない。gpdf はヘッダーとフッターの実高さを測ってから、本文の有効高を pageHeight - top_margin - headerHeight - footerHeight - bottom_margin として計算する。上マージン 20mm でヘッダー 15mm なら、本文はページ上端から 35mm の位置から始まる。

ページごとにヘッダー高を変えられる? できない。ヘッダークロージャは測定のために 1 回だけ評価され、その結果が文書全体で固定される。ページごとに可変高さが欲しいなら、最大高さを固定して中身を空白で調整するしかない。

本文が空のページにもヘッダー/フッターは出る? gpdf は空ページを生成しない。本文が 3 ページに収まれば 3 ページの PDF になる。ヘッダーとフッターはその 3 ページに出るだけ。

縦/横混在ドキュメントで、横向きページだけヘッダーを変えたい。 ページ単位の WithPageSize(...) で向きを変えるのはサポートされている。ただしヘッダー/フッターのクロージャは向きに関係なく同じものが使われる。実務上は両向きでそれなりに見える中央揃えのデザインにしておくのが落としどころ。

JSON テンプレート入力でも動く? 動く。JSON スキーマには header, footer{"type": "pageNumber"}, {"type": "totalPages"} がある。gpdf/_examples/json/26_page_number_test.go がビルダ版と同じ golden PDF と一致することをテストしている。

Go の text/template 入力では? 動く。gpdf/_examples/gotemplate/26_page_number_test.go も同じシナリオを通す。入口がビルダ・JSON・Go テンプレートのどれであっても、同じ 2 段階パスが下で走る。

次に

ヘッダー、フッター、ページ番号はレポートの中で一番地味な部分だ。同時に、レポートを「ちゃんと出来たもの」に見せるのもこの部分。低レベル PDF ライブラリの上にこれを毎回自前で組んでいたなら、本記事の数行で済む。例をコピーして文字列を変えて、本番に出す。

未解決の部分 — c.PageOf(...) の単一文字列フォーマット、1 ページ目だけ別ヘッダー、「最後のページか」の検知 — はリストに載っている。どれかでブロックされたら GitHub issue に書いてほしい。具体的なユースケースがある方が API は決まりやすい。

gpdf を使ってみる

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

go get github.com/gpdf-dev/gpdf

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