How do I show "Page X of Y" in a gpdf footer?
Put document.PageNumberPlaceholder and TotalPagesPlaceholder inside one c.Text string. gpdf v1.0.14+ fills both in and keeps the line aligned.
The question, in other words
You want the footer to read Page 3 of 12 — one line, one string, right-aligned like everything else in the footer. gpdf has c.PageNumber() and c.TotalPages(), but each of those is its own element. How do you get the words and both numbers into a single line without gluing four columns together?
TL;DR
The two placeholders are exported string constants. Put them in the middle of an ordinary string:
c.Text("Page "+document.PageNumberPlaceholder+" of "+document.TotalPagesPlaceholder,
template.AlignRight())
gpdf replaces them after pagination, on every page. On gpdf v1.0.14 or later the line is re-measured after the swap, so right alignment holds when the page number goes from 9 to 10.
The code
A footer on a 400-row table that overflows onto nine A4 pages. Checked against 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 - prints Page 1 of 9 through Page 9 of 9, one per page. Nothing else to configure.
Why this works
c.PageNumber() isn't magic. Its whole implementation is "append a document.Text whose content is document.PageNumberPlaceholder". c.TotalPages() is the same with the other constant. The placeholders are sentinel strings wrapped in NUL bytes ("\x00GPDF_PAGE\x00" and "\x00GPDF_TOTAL\x00"), chosen so no real text will ever collide with them.
The substitution happens after the paginator has decided how many pages exist. It walks every placed text node on every page and runs strings.ReplaceAll for both placeholders. It doesn't care whether the node holds only a placeholder or a whole sentence with one in the middle. So "Page " + PageNumberPlaceholder + " of " + TotalPagesPlaceholder gets the same treatment as the dedicated methods.
Header and footer nodes are laid out once and shared across physical pages, so the resolver works on a per-page copy. Page 3's footer gets 3, page 4's gets 4, and your *template.Document isn't mutated, so rendering it a second time still finds the placeholders.
That last part is easy to miss. The same trick works in body text, which comes in handy for a cover line:
c.Text(fmt.Sprintf("This report has %s pages.", document.TotalPagesPlaceholder))
That prints This report has 9 pages. on page 1 of the same nine-page document.
Why our own post told you to use four columns
In May, Page numbers, headers, and footers that just work called single-line "Page X of Y" a rough edge and showed a four-column workaround. That advice was right for the gpdf of the time, and the reason is the measurement order.
The layout engine wraps and aligns text before the page count exists. Up to v1.0.13 it did that with the raw sentinel in the string. \x00GPDF_TOTAL\x00 is 11 bytes, so the engine thought Page 1 of 9 was much wider than it really was. I reran the program above against v1.0.13 to see how far off it gets:
| Footer layout | v1.0.13 | v1.0.17 |
|---|---|---|
| 12-span column, right-aligned | right edge at x = 424.6 pt — 114 pt short of the margin | right edge at x = 538.6 pt (the margin) on all 9 pages |
| 3-span column, right-aligned | Page 1 of on one line, 9 wrapped to the next | Page 1 of 9 on one line |
| 2-digit pages (32-page run) | — | right edge stays at 538.6 pt from Page 9 of 32 to Page 10 of 32 |
114 points is 40 mm. On a right-aligned footer that doesn't look like a minor kerning issue. It looks like someone pressed Tab. In narrower columns the commit that fixed it describes the worse version: the wrapper split the sentinel itself, and fragments like ?GP and DF were printed verbatim.
v1.0.14 (commit b58626a) changed two things. During layout, each placeholder is measured and wrapped as a single digit ("0"), and the placeholder is restored in each line's content afterwards. Then ResolvePageNumbersWithFonts re-measures every resolved line with the real font and shifts centered and right-aligned lines back to their anchor. That second step is why Page 10 of 32 ends at the same x as Page 9 of 32 even though it's one digit wider.
If you're stuck on v1.0.13 or older, the multi-column layout from the earlier post still works, because each placeholder sits alone in its own column. Otherwise, upgrade:
go get github.com/gpdf-dev/gpdf@latest
Variations
Slash style. document.PageNumberPlaceholder + " / " + document.TotalPagesPlaceholder gives 3 / 12.
fmt.Sprintf. The placeholders are plain strings, so %s works. Don't try %d. You don't have the numbers yet. That's the point.
fmt.Sprintf("Page %s of %s", document.PageNumberPlaceholder, document.TotalPagesPlaceholder)
Japanese or other CJK. Register a font that covers the characters and set it on the text. A format like PageNumberPlaceholder + " / " + TotalPagesPlaceholder + " ページ" resolves the same way. Only the glyphs you actually use end up in the font subset.
JSON and Go templates. Stick with separate {"type": "pageNumber"} and {"type": "totalPages"} elements. Writing \u0000GPDF_PAGE\u0000 into a JSON string does work on v1.0.17. But the constant's name is the API and its value is an implementation detail. I wouldn't bet a production template on the value staying the same.
What you can't do yet is branch on the page. A footer closure doesn't know whether it's rendering page 1, so "no number on the cover" needs a different approach. That's its own recipe.
Related recipes
- Page numbers, headers, and footers that just work — the full header/footer model and how the two-pass resolver fits in
- How do I make a table span multiple pages? — where the overflow pages in this example come from
- How do I align a column to the right edge of a row? — when you want the number in its own right-hand column instead
- Basic usage guide — every
template.Newoption
Try gpdf
gpdf is a Go library for generating PDFs. MIT licensed, zero external dependencies, native CJK support.
go get github.com/gpdf-dev/gpdf