Número de página, cabeçalho e rodapé em PDFs Go com gpdf
Como adicionar cabeçalho, rodapé e 'Page X of Y' a PDFs em Go com gpdf: dois métodos do builder e um paginador de duas passagens que resolve os totais sozinho.
Um relatório financeiro de 60 páginas. Alguém abre a página 12 na fila de impressão e faz uma pergunta: que página é essa e quantas faltam? Se o rodapé só diz 12, ninguém sabe. Precisa dizer 12 / 60.
Esse 60 é o lado onde a maioria das bibliotecas PDF falha. Ou o total não está disponível quando você escreve o rodapé, ou está atrás de um token tipo AliasNbPages que precisa ser chamado depois do build, ou você renderiza o documento duas vezes e descarta a primeira passagem.
gpdf resolve isso de maneira limpa com dois métodos do builder e um paginador interno de duas passagens. Este post é sobre como a API se parece, como funciona por dentro, e o único ponto áspero que vale a pena conhecer.
TL;DR
doc.Header(fn)edoc.Footer(fn)registram uma closure executada em cada página.- Dentro da closure usa-se o mesmo grid de 12 colunas do corpo.
c.PageNumber()imprime o número da página atual.c.TotalPages()imprime o total.- O total é resolvido em uma segunda passagem, depois que a paginação termina. Você não precisa escrever um build de duas passagens.
- Uma aspereza: não existe um helper
c.PageNumberOf(total)que imprima"3 of 12"como uma única string inline. Compõe-se com três colunas. Detalhe abaixo.
Todo o código do post vem de gpdf/_examples/builder/26_page_number_test.go, que faz parte do conjunto de testes.
Tudo em um arquivo
Programa completo. Salve como main.go, rode go run main.go e obtenha um PDF de 4 páginas com cabeçalho mostrando o total e rodapé mostrando o número atual.
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("Relatório trimestral", 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{"Introdução", "Contexto", "Análise", "Conclusão"} {
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("Corpo da seção " + title + ".")
})
})
}
out, err := doc.Generate()
if err != nil {
panic(err)
}
_ = os.WriteFile("report.pdf", out, 0o644)
}
Quatro páginas, 4 no canto superior direito do cabeçalho e 1〜4 no canto inferior direito do rodapé. Em nenhum momento você disse ao gpdf que o documento tem 4 páginas — o próprio gpdf não sabe até a paginação terminar.
Por que "Page X of Y" é a parte difícil
O Y é chato porque o motor de layout não o conhece enquanto desenha a página 1. Num relatório de 50 páginas, a página 47 pode quebrar em duas porque uma linha de tabela não coube. O total 50 só fica disponível depois que o paginador termina. Mas o rodapé da página 1 foi desenhado muito antes disso.
Toda biblioteca PDF bate nesse muro. Como as principais do Go contornam:
| Biblioteca | Estratégia para "Page X of Y" |
|---|---|
| gofpdf | pdf.AliasNbPages("{nb}"). Você escreve {nb} como literal no texto, chama o método e o stream PDF é reescrito depois. Funciona, mas você tem que lembrar de chamar, e o placeholder é uma string mágica. |
| go-pdf/fpdf | Fork de gofpdf. Mesmo mecanismo. |
| signintech/gopdf | Sem suporte de primeira classe. Você constrói o documento, conta as páginas, reconstrói. |
| maroto v2 | Registro Header/Footer parecido com o do gpdf. Internamente também duas passagens. Mais lento porque por baixo é gofpdf — cerca de 10× mais lento que gpdf em cargas comuns. |
| gpdf | c.PageNumber() / c.TotalPages(). Chamadas de método tipadas, sem strings mágicas, resolvido pela segunda passagem interna. |
gpdf é a única em que a primitiva de numeração faz parte da API tipada do builder. No gofpdf, se você digitar {nB} em vez de {nb}, sai literalmente {nB} no rodapé. Com c.TotalPages(), o pior cenário é esquecer de chamar — aí não aparece número, não um errado.
Como a segunda passagem funciona
Internamente, c.PageNumber() é renderizado como uma string placeholder — um sentinel que nenhum glifo de fonte real vai casar. Quando o paginador termina de fazer o layout de todas as páginas e sabe o total, ele percorre as instruções de texto renderizadas e substitui:
- Passagem 1 (paginar): renderiza cada página, incluindo cabeçalho e rodapé, tratando
PageNumbereTotalPagescomo tokens de largura fixa. Calcula o total. - Passagem 2 (resolver): percorre a árvore de páginas, encontra cada sentinel e substitui pelo número real (atual ou total).
A largura do placeholder é reservada com base no máximo esperado de páginas (heurística), então o layout não se desloca depois da substituição. Números alinhados à direita continuam alinhados quando a contagem passa de 9 para 10 dígitos.
Você não escreve a segunda passagem. Não renderiza o documento duas vezes. Chama doc.Generate() e recebe os bytes.
Cabeçalho e rodapé são layout normal
Quem vem do gofpdf se confunde aqui. Lá, SetHeaderFunc é chamado em uma Y fixa e você posiciona texto com Cell(...) em coordenadas absolutas. No gpdf, a closure do cabeçalho recebe um *template.PageBuilder — o mesmo tipo do corpo. Mesmo grid, mesmas linhas e colunas, mesmas opções de estilo.
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())
})
})
})
Logo à esquerda, título no centro, total à direita. As colunas somam 12, mesma regra de uma linha de corpo.
A altura do cabeçalho é medida automaticamente. gpdf executa a closure uma vez antes do corpo, mede a altura renderizada e subtrai da altura útil do corpo em cada página. Rodapé igual. Não há headerHeight para passar. Adicione uma linha ao cabeçalho e o corpo encolhe sozinho.
Ambos se repetem em todas as páginas, incluindo as geradas por overflow. Se uma tabela longa vaza para a página 12, a página 12 também ganha cabeçalho e rodapé. Não há flag "somente primeira página" (ver abaixo).
O ponto áspero: "Page X of Y" em uma linha
Aqui, honestamente, a API podia ser melhor. Não existe c.PageOf("Page %d of %d"). Para produzir a string literal "Page 3 of 12" é preciso compor por colunas, porque c.Text() e c.PageNumber() são filhos independentes de uma coluna:
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) {})
})
})
Funciona. Fica visualmente ok. Mas é expandir para quatro colunas algo que a maioria escreveria como uma única string de formato. Uma farpa. Estamos pensando em adicionar c.PageOf(format string, opts ...TextOption) no estilo fmt.Sprintf com %d. Se você tem opinião sobre a forma da API, abra uma issue no GitHub.
O atalho pragmático hoje é tirar o "Page" e usar barra:
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 se lê tranquilamente num rodapé. Se você quiser Página 3 de 12, intercale c.Text adicionais.
Padrões que aparecem
Configurações que aparecem de verdade no dia a dia.
Linha sob o título. Adicione um segundo AutoRow com c.Line(). É o que o exemplo no topo faz.
Rodapé centralizado com nota de confidencialidade. Uma linha, uma coluna, AlignCenter. O caso mais simples.
doc.Footer(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("Confidencial — Uso interno",
template.AlignCenter(),
template.FontSize(8),
template.TextColor(pdf.Gray(0.5)))
})
})
})
No Brasil é comum adicionar "Data de impressão: 19 de maio de 2026" e "Documento: DOC-2026-0517" no rodapé. Empilhe dois ou três c.Text(...). Em PDFs anexados a NFe (DANFE auxiliar, contratos eletrônicos) esse rodapé aparece em quase todo documento.
Logo à esquerda, número de página à direita. Duas colunas 8/4 ou 6/6. Imagem à esquerda, c.PageNumber() com AlignRight à direita.
Rodapé "Continua na próxima página". Sem suporte atual. A closure recebe só PageBuilder, sem índice de página, então não dá para ramificar em "é a última?". Colocar isso no corpo exigiria saber o total antes — paradoxo. Está na lista.
Cabeçalho diferente na primeira página. Mesmo motivo, sem suporte. O workaround é deixar o cabeçalho efetivamente vazio na página 1 colocando um spacer alto no início do corpo, e deixar as demais seguirem o fluxo normal. Deselegante. Um doc.HeaderOn(pages, fn) está em design.
CJK funciona direto
Como o gpdf faz subset de TrueType sem CGO, dá para colocar japonês, chinês ou coreano em cabeçalho e rodapé como c.Text(...). Sem ritual AddUTF8Font, sem "tofu" se a fonte cobre os caracteres.
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))
})
})
})
O subset embutido no PDF final contém só "os glifos que aparecem". Em um relatório de 60 páginas com "社外秘" no rodapé, são três glifos da NotoSansJP, não 20.000. Para PDFs que precisam atender PDF/A com restrições de tamanho (anexos a sistemas fiscais brasileiros, por exemplo) isso pesa.
Performance
Esta parte importa se você gera PDFs em escala.
A segunda passagem não é grátis, mas é barata. Em um documento de 100 páginas em um M1, a segunda passagem fica abaixo de 50µs — menos de 1% do tempo total de geração. Benchmark de página única do gpdf: 13µs. Cem páginas: 683µs. A resolução de números de página é um fator constante independente da complexidade da página.
Para comparar, o AliasNbPages do gofpdf faz substituição de string sobre o stream inteiro depois das decisões de compressão, o que força recompressão dos streams que contêm o alias. Nos benchmarks do próprio gofpdf, isso fica em cerca de 2–4% do tempo total em um documento de 100 páginas. No gpdf, a substituição ocorre antes da codificação do stream.
Se você gera um milhão de PDFs por dia, a diferença pesa. Se gera dez, não.
FAQ
A altura do cabeçalho/rodapé conta na margem da página?
Sim. gpdf mede a altura renderizada de cabeçalho e rodapé e calcula a altura útil do corpo como pageHeight - top_margin - headerHeight - footerHeight - bottom_margin. Margem superior 20mm + cabeçalho 15mm: o corpo começa a 35mm do topo.
Dá para variar a altura do cabeçalho por página? Não. A closure é avaliada uma vez para medição, e o resultado fica fixo para o documento inteiro. Se precisar de altura variável, projete uma altura máxima fixa e ajuste com espaço em branco.
O que acontece com uma página sem conteúdo? gpdf não gera páginas vazias. Se o corpo cabe em três páginas, o PDF tem três páginas. Cabeçalho e rodapé aparecem nessas três, e em nenhuma outra.
Posso omitir o cabeçalho em páginas paisagem num documento misto?
Orientações mistas funcionam via WithPageSize(...) por página, mas a closure de cabeçalho/rodapé é a mesma para todas independentemente da orientação. O caminho prático é desenhar algo centralizado que funcione nas duas orientações.
Funciona com input JSON?
Sim. O schema JSON tem header, footer e os tipos {"type": "pageNumber"} e {"type": "totalPages"}. O teste gpdf/_examples/json/26_page_number_test.go valida que o input JSON produz o mesmo PDF golden que o builder.
E com text/template do Go?
Sim. gpdf/_examples/gotemplate/26_page_number_test.go roda o mesmo cenário. Seja qual for a entrada — builder, JSON ou Go template — por baixo roda a mesma paginação de duas passagens.
Próximos passos
Cabeçalho, rodapé e número de página são a parte mais sem graça de um relatório — e também o que faz parecer pronto. Se você vinha escrevendo isso à mão sobre bibliotecas PDF de baixo nível, as poucas linhas deste post são tudo. Pegue o exemplo, troque as strings, suba.
Os pontos em aberto — c.PageOf(...) para formatação em string única, cabeçalho diferente na primeira página, detecção de "última página" — estão na lista. Se algum deles te bloqueia, registre uma issue no GitHub. Casos de uso concretos moldam a API melhor do que pedidos abstratos.
Experimentar o gpdf
gpdf é uma biblioteca de geração de PDF para Go. MIT, zero dependências, suporte CJK.
go get github.com/gpdf-dev/gpdf