Números de página, cabeceras y pies de página en PDFs Go con gpdf
Cómo añadir cabeceras, pies de página y 'Page X of Y' a PDFs en Go con gpdf: dos métodos del builder y un paginador de dos pasadas que resuelve los totales solo.
Un informe financiero de 60 páginas. Alguien abre la página 12 en la cola de impresión y hace una pregunta: ¿qué página es esta y cuántas quedan? Si el pie sólo dice 12, nadie lo sabe. Tiene que decir 12 / 60.
Ese 60 es la parte donde la mayoría de las librerías PDF fallan. O el total de páginas no está disponible cuando escribes el pie, o se esconde detrás de un token tipo AliasNbPages que tienes que llamar después de construir, o terminas renderizando el documento dos veces y descartando la primera pasada.
gpdf lo resuelve limpiamente con dos métodos del builder y un paginador interno de dos pasadas. Este artículo cuenta cómo es la API, cómo funciona por dentro y el único punto áspero del que conviene estar al tanto.
TL;DR
doc.Header(fn)ydoc.Footer(fn)registran una closure que se ejecuta en cada página.- Dentro de la closure se usa el mismo grid de 12 columnas que en el cuerpo.
c.PageNumber()imprime el número de página actual.c.TotalPages()imprime el total.- El total se resuelve en una segunda pasada, después de que termina la paginación. No tienes que escribir una build de dos pasadas tú mismo.
- Un detalle áspero: no existe un helper
c.PageNumberOf(total)que pinte"3 of 12"como una sola cadena en línea. Se compone con tres columnas. Más abajo.
Todo el código aquí viene de gpdf/_examples/builder/26_page_number_test.go, que forma parte de la suite de tests.
Todo en un solo archivo
Programa completo. Guárdalo como main.go, ejecuta go run main.go y obtienes un PDF de 4 páginas con cabecera que muestra el total y pie con el número actual.
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("Informe 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{"Introducción", "Contexto", "Análisis", "Conclusión"} {
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("Cuerpo de la sección " + title + ".")
})
})
}
out, err := doc.Generate()
if err != nil {
panic(err)
}
_ = os.WriteFile("report.pdf", out, 0o644)
}
Se generan 4 páginas, con 4 en la esquina superior derecha de la cabecera y 1〜4 en la esquina inferior derecha del pie. En ningún momento le dijiste a gpdf cuántas páginas tendría el documento — gpdf tampoco lo sabe hasta que termina de paginar.
Por qué "Page X of Y" es la parte difícil
El Y es complicado porque el motor de layout no lo conoce mientras dibuja la página 1. En un informe de 50 páginas, la 47 podría partirse en dos por una fila de tabla que no cabe. El total 50 sólo está disponible cuando el paginador termina. Pero el pie de la página 1 ya se dibujó mucho antes.
Cada librería PDF choca contra este muro. Cómo lo resuelven las más usadas en Go:
| Librería | Cómo hace "Page X of Y" |
|---|---|
| gofpdf | pdf.AliasNbPages("{nb}"). Escribes {nb} como literal en el texto, llamas al método y se reescribe el stream PDF a posteriori. Funciona, pero hay que acordarse de llamarlo, y el placeholder es un string mágico. |
| go-pdf/fpdf | Fork de gofpdf. Mismo mecanismo. |
| signintech/gopdf | Sin soporte de primera clase. Construyes el documento, cuentas páginas, reconstruyes. |
| maroto v2 | Registro Header/Footer parecido al de gpdf. Internamente también dos pasadas. Más lento porque por debajo es gofpdf — unas 10× más lento que gpdf en cargas comunes. |
| gpdf | c.PageNumber() / c.TotalPages(). Llamadas a métodos tipadas, sin strings mágicos, resuelto con la segunda pasada interna. |
gpdf es la única en la que la primitiva de numeración es parte de la API tipada del builder. En gofpdf, si escribes {nB} en lugar de {nb}, aparece literalmente {nB} en el pie. Con c.TotalPages() lo peor que puede pasar es que se te olvide llamarlo — y entonces no aparece nada, no un número equivocado.
Cómo funciona la segunda pasada
Internamente c.PageNumber() se renderiza como un placeholder — un valor centinela que ninguna glifo de fuente real va a coincidir. Cuando el paginador termina de maquetar todas las páginas y conoce el total, recorre las instrucciones de texto renderizadas y sustituye:
- Pasada 1 (paginar): renderiza cada página, incluida cabecera y pie, tratando
PageNumberyTotalPagescomo tokens de ancho fijo. Calcula el total. - Pasada 2 (resolver): recorre el árbol de páginas, encuentra cada centinela y lo sustituye por el número real (actual o total).
El ancho del placeholder se reserva en función del máximo esperado de páginas (heurístico), así que tras la sustitución el layout no se desplaza. Los números alineados a la derecha siguen alineados cuando el conteo pasa de 9 a 10 dígitos.
Tú no escribes la segunda pasada. No renderizas el documento dos veces. Llamas a doc.Generate() y recibes los bytes.
Cabecera y pie son layout normal
A quien viene de gofpdf esto le descoloca. Allí SetHeaderFunc se llama en una Y fija y colocas texto con Cell(...) en coordenadas absolutas. En gpdf, la closure de la cabecera recibe un *template.PageBuilder — el mismo tipo que usa el cuerpo. El grid es el mismo, las filas y columnas son las mismas, los estilos también.
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 a la izquierda, título en el centro, total a la derecha. Las columnas suman 12, igual que en una fila de cuerpo.
La altura de la cabecera se mide automáticamente. gpdf ejecuta una vez la closure antes del cuerpo, mide la altura renderizada y la resta de la altura útil del cuerpo en cada página. El pie igual. No le pasas headerHeight. Si añades una fila a la cabecera, el cuerpo se encoge.
Ambos se repiten en todas las páginas, también en las que se generan por desbordamiento de contenido. Si una tabla larga se derrama hasta la página 12, la página 12 también tiene cabecera y pie. No hay flag de "solo primera página" (ver más abajo).
El detalle áspero: "Page X of Y" en una línea
Aquí, sinceramente, la API podría mejorar. No existe c.PageOf("Page %d of %d"). Para producir el string literal "Page 3 of 12" hay que componerlo en columnas, porque c.Text() y c.PageNumber() son hijos independientes de una columna:
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. Visualmente queda bien. Pero es expandir a cuatro columnas algo que normalmente se escribiría como una cadena de formato. Un raspón. Estamos considerando un helper c.PageOf(format string, opts ...TextOption) al estilo fmt.Sprintf con %d. Si tienes opinión sobre la forma de la API, hay un issue abierto en GitHub.
El atajo pragmático actual es quitar el prefijo y separar con 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 lee perfectamente en un pie. Y si quieres Página 3 de 12, basta con intercalar más c.Text por el medio.
Patrones habituales
Configuraciones que la gente realmente quiere.
Línea bajo el título. Añade un segundo AutoRow con c.Line(). Es lo que hace el ejemplo del principio.
Pie centrado con aviso de confidencialidad. Una fila, una columna, AlignCenter. El caso más simple.
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)))
})
})
})
En empresas españolas y latinoamericanas se suele añadir "Fecha de impresión: 19 de mayo de 2026" o "Documento: DOC-2026-0517". Apila dos o tres c.Text(...). En contextos de factura electrónica (España/LatAm) este pie aparece en casi todos los PDFs adjuntos al XML.
Logo izquierda, número de página derecha. Dos columnas 8/4 o 6/6. Imagen a la izquierda, c.PageNumber() con AlignRight a la derecha.
Pie con "Continúa en la siguiente página". Sin soporte actual. La closure recibe sólo PageBuilder, sin índice de página, así que no puedes ramificar por "¿es la última?". Si quieres ponerlo en el cuerpo, necesitas conocer el total antes — paradoja. Está en la lista.
Cabecera distinta en la primera página. Mismo motivo, sin soporte. El workaround es dejar la cabecera "vacía" en la página 1 metiendo un spacer alto al inicio del cuerpo de la página 1, y dejar que las siguientes hereden el flujo. Feo. Estamos diseñando un doc.HeaderOn(pages, fn).
CJK funciona directamente
Como gpdf hace subsetting de TrueType sin CGO, puedes meter japonés, chino o coreano en cabecera y pie como c.Text(...). Sin ritual AddUTF8Font, sin "tofu" si la fuente cubre los 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))
})
})
})
El subset embebido en el PDF final sólo contiene "los glifos que aparecen". Para un informe de 60 páginas con "社外秘" en el pie son tres glifos de NotoSansJP, no 20.000.
Rendimiento
Esto importa si generas PDFs a gran escala.
La segunda pasada no es gratis, pero es barata. En un documento de 100 páginas en un M1, la segunda pasada se queda por debajo de 50µs — menos del 1% del tiempo total. El benchmark de una sola página de gpdf es 13µs; el de 100 páginas, 683µs. La resolución de números de página es un factor constante independiente de la complejidad de las páginas.
Para comparar, AliasNbPages de gofpdf hace un reemplazo de string sobre el stream completo después de decidir compresión, lo que fuerza recompresión de los streams que contienen el alias. En los propios benchmarks de gofpdf suele rondar el 2–4% del tiempo total en un documento de 100 páginas. En gpdf el reemplazo ocurre antes de codificar el stream.
Si generas un millón de PDFs al día, la diferencia se nota. Si generas diez, no.
Preguntas frecuentes
¿La altura de la cabecera/pie cuenta dentro del margen de página?
Sí. gpdf mide la altura renderizada de cabecera y pie, y calcula la altura útil del cuerpo como pageHeight - top_margin - headerHeight - footerHeight - bottom_margin. Margen superior 20mm y cabecera 15mm: el cuerpo empieza a 35mm del borde superior.
¿Puede cambiar la altura de la cabecera por página? No. La closure se evalúa una vez para la medición y el resultado queda fijo para todo el documento. Si necesitas altura variable, tienes que diseñar con una altura máxima y rellenar con espacio.
¿Qué pasa en una página sin contenido? gpdf no genera páginas vacías. Si tu cuerpo cabe en tres páginas, el PDF tiene tres páginas. Cabecera y pie aparecen en esas tres y en ninguna más.
¿Puedo omitir la cabecera en páginas apaisadas de un documento mixto?
Las orientaciones mixtas se soportan vía WithPageSize(...) por página, pero la closure de cabecera/pie es la misma para todas las páginas independientemente de la orientación. Lo práctico es diseñar algo centrado que funcione en ambas.
¿Funciona con el input JSON?
Sí. El schema JSON tiene header, footer y los tipos {"type": "pageNumber"} y {"type": "totalPages"}. El test gpdf/_examples/json/26_page_number_test.go valida que el JSON produce el mismo PDF golden que el builder.
¿Y con text/template de Go?
Sí. gpdf/_examples/gotemplate/26_page_number_test.go corre el mismo escenario. Sea cual sea la entrada — builder, JSON o template — debajo corre la misma paginación de dos pasadas.
Siguiente paso
Cabeceras, pies y números de página son lo más aburrido de un informe, pero también lo que hace que parezca terminado. Si llevas tiempo escribiendo esto a mano sobre librerías PDF de bajo nivel, las pocas líneas de este artículo son todo lo que necesitas. Coge el ejemplo, cambia los strings, manda a producción.
Los puntos abiertos — c.PageOf(...) para formato de cadena única, cabecera distinta en la primera página, detección de "última página" — están en la lista. Si alguno te bloquea, escribe un issue en GitHub. Los casos de uso concretos definen mejor la API que las peticiones abstractas.
Probar gpdf
gpdf es una librería de generación de PDF para Go. MIT, cero dependencias, soporte CJK.
go get github.com/gpdf-dev/gpdf