記事一覧

gpdf のフッターに「Page X of Y」を 1 行で出すには?

ページ番号と総ページ数のプレースホルダを 1 つの c.Text に埋め込むだけ。v1.0.14 以降は右寄せも崩れない。

2026年11月12日 公開
著者: Taiki Noda

質問を言い換えると

フッターに Page 3 of 12 と出したい。1 行・1 つの文字列で、他のフッター要素と同じく右寄せで。gpdf には c.PageNumber() と c.TotalPages() があるが、どちらも独立した要素になる。4 つのカラムを並べずに、文言と 2 つの数字を 1 行にまとめるにはどうするか。

即答

2 つのプレースホルダは公開された文字列定数。普通の文字列の途中にそのまま入れればいい。

c.Text("Page "+document.PageNumberPlaceholder+" of "+document.TotalPagesPlaceholder,
	template.AlignRight())

gpdf はページ分割が終わったあとで、全ページの置換を行う。gpdf v1.0.14 以降なら置換後に行幅を測り直すので、ページ番号が 9 から 10 に桁上がりしても右寄せは崩れない。

コード

400 行のテーブルが A4 で 9 ページにあふれるドキュメントに、フッターを付ける。gpdf v1.0.17 で動作確認済み。

package main

import (
	"fmt"
	"log"
	"os"

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

func main() {
	pageOf := "Page " + document.PageNumberPlaceholder +
		" of " + document.TotalPagesPlaceholder

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

	doc.Footer(func(p *template.PageBuilder) {
		p.AutoRow(func(r *template.RowBuilder) {
			r.Col(12, func(c *template.ColBuilder) {
				c.Text(pageOf,
					template.AlignRight(),
					template.FontSize(9),
					template.TextColor(pdf.Gray(0.4)),
				)
			})
		})
	})

	rows := make([][]string, 0, 400)
	for i := 1; i <= 400; i++ {
		rows = append(rows, []string{fmt.Sprint(i), fmt.Sprintf("Line item %03d", i)})
	}

	page := doc.AddPage()
	page.AutoRow(func(r *template.RowBuilder) {
		r.Col(12, func(c *template.ColBuilder) {
			c.Table([]string{"#", "Description"}, rows)
		})
	})

	out, err := doc.Generate()
	if err != nil {
		log.Fatal(err)
	}
	if err := os.WriteFile("page-x-of-y.pdf", out, 0o644); err != nil {
		log.Fatal(err)
	}
}

pdftotext -layout page-x-of-y.pdf - で見ると、各ページに Page 1 of 9 〜 Page 9 of 9 が 1 つずつ出ている。他に設定するものはない。

なぜこれで動くのか

c.PageNumber() に特別な仕掛けはない。実装は「中身が document.PageNumberPlaceholder の document.Text を追加する」だけで、c.TotalPages() も定数が違うだけ。プレースホルダは NUL バイトで挟んだセンチネル文字列 ("\x00GPDF_PAGE\x00" と "\x00GPDF_TOTAL\x00") で、実際のテキストと衝突しないように選ばれている。

置換はページ数が確定したあとに走る。全ページの配置済みテキストノードをたどって、2 つのプレースホルダそれぞれに strings.ReplaceAll をかける。ノードの中身がプレースホルダ だけ なのか、文の途中に 1 つ混じっているのかは区別しない。だから "Page " + PageNumberPlaceholder + " of " + TotalPagesPlaceholder も専用メソッドと同じ扱いになる。

ヘッダー・フッターのノードは 1 回だけレイアウトされて全ページで共有されるので、置換はページごとのコピーに対して行われる。3 ページ目のフッターには 3、4 ページ目には 4 が入り、手元の *template.Document は書き換わらない。もう一度レンダリングしても、プレースホルダはちゃんと残っている。

見落としやすいが、同じ手は本文でも使える。表紙に総ページ数を書きたいときに便利だ。

c.Text(fmt.Sprintf("This report has %s pages.", document.TotalPagesPlaceholder))

上と同じ 9 ページのドキュメントなら、1 ページ目に This report has 9 pages. と出る。

以前の記事が「4 カラムで組め」と書いていた理由

5 月の記事 Go の PDF にページ番号とヘッダー・フッターをちゃんと入れる では、1 行の「Page X of Y」を API の弱点として挙げ、4 カラムで組む回避策を紹介した。当時の gpdf ではそれが正解だった。原因は幅を測る順番にある。

レイアウトエンジンは、総ページ数が決まる 前に テキストの折り返しと寄せを計算する。v1.0.13 まではその計算を、文字列に入ったセンチネルのまま行っていた。\x00GPDF_TOTAL\x00 は 11 バイトあるので、エンジンは Page 1 of 9 を実際よりずっと幅の広い文字列だと思い込む。上のプログラムを v1.0.13 で走らせ直して、どれくらいずれるか測ってみた。

フッターの組み方v1.0.13v1.0.17
12 カラム・右寄せ右端が x = 424.6 pt —— マージンより 114 pt 手前9 ページすべてで右端が x = 538.6 pt (マージン位置)
3 カラム・右寄せPage 1 of で改行され、9 が次の行に落ちるPage 1 of 9 が 1 行に収まる
2 桁ページ (32 ページで実行)——Page 9 of 32 から Page 10 of 32 まで右端は 538.6 pt のまま

114 pt は約 40 mm。右寄せのフッターでこれだけずれると、カーニングの問題には見えない。誰かがタブを押したように見える。修正コミットのメッセージには、もっと細いカラムでの症状も書いてある。センチネル自体が折り返しで分断され、?GP や DF といった断片がそのまま印字されていた。請求書の右下に DF と出ていたら、問い合わせが来るのは時間の問題だ。

v1.0.14 (コミット b58626a) で変わったのは 2 点。レイアウト時にはプレースホルダを 1 桁の数字 ("0") として測って折り返し、あとで各行の中身にプレースホルダを戻す。そのうえで ResolvePageNumbersWithFonts が置換後の各行を実際のフォントで測り直し、中央寄せ・右寄せの行を基準位置に戻す。Page 10 of 32 が Page 9 of 32 より 1 桁長いのに右端が揃うのは、この 2 段目のおかげ。

v1.0.13 以前から上げられないなら、以前の記事のマルチカラム構成は今でも動く。プレースホルダがそれぞれ独立したカラムに入っているからだ。そうでなければ上げてしまえばいい。

go get github.com/gpdf-dev/gpdf@latest

バリエーション

スラッシュ区切り。 document.PageNumberPlaceholder + " / " + document.TotalPagesPlaceholder で 3 / 12 になる。

fmt.Sprintf。 プレースホルダはただの文字列なので %s が使える。%d は使えない。その時点ではまだ数字がないのだから当然だ。

fmt.Sprintf("Page %s of %s", document.PageNumberPlaceholder, document.TotalPagesPlaceholder)

日本語の帳票。 日本の帳票だと 3 / 12 ページ や - 3 - が多い。日本語を含めるなら、その文字をカバーするフォント (Noto Sans JP など) を登録してテキストに指定する。PageNumberPlaceholder + " / " + TotalPagesPlaceholder + " ページ" も同じように置換される。フォントのサブセットに入るのは実際に使ったグリフだけなので、フッターに「ページ」の 3 文字を足してもファイルサイズはほとんど増えない。

JSON と Go テンプレート。 こちらは {"type": "pageNumber"} と {"type": "totalPages"} を別要素にするのがおすすめ。JSON の文字列に \u0000GPDF_PAGE\u0000 と書いても v1.0.17 では実は動く。ただ API として約束されているのは定数の 名前 で、値 は実装の詳細だ。本番のテンプレートを値に依存させるのは避けたい。

まだできないのは、ページによって出し分けること。フッターのクロージャは自分が 1 ページ目を描いているのかを知らないので、「表紙にはページ番号を出さない」には別のやり方が要る。それはまた別のレシピで。

関連レシピ

gpdf を使ってみる

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

go get github.com/gpdf-dev/gpdf

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