gpdf のフッターに「Page X of Y」を 1 行で出すには?
ページ番号と総ページ数のプレースホルダを 1 つの c.Text に埋め込むだけ。v1.0.14 以降は右寄せも崩れない。
質問を言い換えると
フッターに 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.13 | v1.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 ページ目を描いているのかを知らないので、「表紙にはページ番号を出さない」には別のやり方が要る。それはまた別のレシピで。
関連レシピ
- Go の PDF にページ番号とヘッダー・フッターをちゃんと入れる —— ヘッダー/フッターの全体像と、2 パスの置換処理の位置づけ
- テーブルを複数ページにまたがって出力するには? —— この例であふれたページがどう作られるか
- Col を行の右端に寄せるには? —— 番号だけを右側の独立カラムに置きたい場合
- 基本的な使い方ガイド ——
template.Newの全オプション
gpdf を使ってみる
gpdf は Go の PDF 生成ライブラリ。MIT、ゼロ依存、CJK 対応。
go get github.com/gpdf-dev/gpdf