Todas las publicaciones

Tablas en PDFs con Go: anchos, rayas zebra, saltos de página

Las tablas son la parte más difícil de un PDF en Go. gpdf colapsa anchos, rayas y la repetición del encabezado en cada página dentro de una sola llamada.

TL;DR

Las tablas son la parte de la generación de PDFs que arruina los fines de semana. Anchos de columna que no suman, headers que desaparecen en la página 2, rayas dibujadas con un bucle de filas que tiene un off-by-one. gpdf colapsa todo eso en una sola llamada:

c.Table(header, rows,
    template.ColumnWidths(40, 15, 20, 25),
    template.TableHeaderStyle(template.TextColor(pdf.White), template.BgColor(brand)),
    template.TableStripe(pdf.RGBHex(0xF5F5F5)),
)

Eso resuelve anchos, rayas y repetición automática del encabezado en cada salto de página. Sin bucle de filas. Sin opción PageBreak. El motor de layout detecta cuando la tabla no entra y reemite el slice Header arriba de la siguiente página. Para colspan, rowspan o un footer que también se repita, bajas un nivel a document.Table — los mismos bloques con más control.

Este artículo explica por qué esos son los tres ejes que importan, qué hace gpdf con cada uno, y dónde la abstracción se detiene a propósito.

Por qué este artículo

gpdf es una librería Go para generar PDFs. MIT, cero dependencias, una página renderiza en ~13 µs. La API de tablas es pequeña — ocho constructores TableOption — pero la presión de diseño sobre ella es enorme: la mayoría de los proyectos PDF en Go se atascan en las tablas.

Los tres puntos donde una tabla se rompe en el mundo de PDF en Go:

  1. Anchos de columna. La web tiene CSS <col> y colgroup. PDF no tiene nada. O calculas cada ancho en puntos a mano, o aceptas lo que la librería te dé — normalmente partes iguales.
  2. Rayas zebra. Quieres que cada fila alterna del cuerpo se tinte de gris para legibilidad. La mayoría de las librerías de bajo nivel te obliga a escribir el bucle y rastrear paridad — la fuente de la mitad de los bugs de renderizado de tablas.
  3. Saltos de página. Un reporte de 200 filas no entra en una hoja A4. La librería tiene que (a) cortar el cuerpo en algún sitio razonable, (b) cerrar la página, (c) abrir una nueva, y (d) reemitir el header en la nueva página para que el lector sepa qué columna está mirando. Olvida cualquiera de estos y la tabla es inutilizable.

Este artículo recorre cómo gpdf resuelve cada uno y qué compromisos tiene el diseño. Si solo quieres recetas copy-paste, los enlaces al final apuntan a las recetas por opción. Esto es la versión larga para quien quiere saber si puede confiar en la API antes de comprometer un estado de cuenta mensual de diez mil filas.

La forma de la API

Hay un único punto de entrada en la capa builder:

func (c *ColBuilder) Table(header []string, rows [][]string, opts ...TableOption)

Header es un slice de strings, rows es un slice de slices de strings, y el variádico opts configura todo lo demás. Existen ocho constructores de opciones:

OpciónQué controla
ColumnWidths(...float64)Anchos por columna como porcentaje del Col padre
TableHeaderStyle(...TextOption)Color de fondo y de texto del header
TableStripe(pdf.Color)Color de fondo para filas alternas del cuerpo
TableCellVAlign(document.VerticalAlign)Alineación vertical de celdas (top/middle/bottom)
WithTableBorder(BorderSpec)Marco exterior de toda la tabla
WithTableCellBorder(BorderSpec)Mismo borde alrededor de cada celda — la cuadrícula
WithTableBorderCollapse(bool)Semántica de CSS border-collapse: collapse
WithTableBackground(pdf.Color)Relleno detrás de toda la tabla

Esa es toda la superficie. Lo que se construye con el builder se construye con estos ocho. Cualquier cosa más allá — colspan, rowspan, footer, anchos fijos en pt — es una llamada a document.Table. Llegamos ahí.

Código que funciona: un libro mayor de seis meses

Programa completo y ejecutable. Guarda como main.go, ejecuta go run ., obtén ledger.pdf.

package main

import (
    "fmt"
    "log"
    "os"

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

func main() {
    doc := gpdf.NewDocument(
        gpdf.WithPageSize(gpdf.A4),
        gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
    )

    brand := pdf.RGBHex(0x1A237E)
    stripe := pdf.RGBHex(0xF5F5F5)
    hairline := template.Border(
        template.BorderWidth(document.Pt(0.5)),
        template.BorderColor(pdf.Gray(0.85)),
    )

    header := []string{"Fecha", "Factura #", "Cliente", "Importe"}
    rows := make([][]string, 0, 120)
    for i := 1; i <= 120; i++ {
        rows = append(rows, []string{
            fmt.Sprintf("2026-%02d-%02d", (i%6)+1, (i%28)+1),
            fmt.Sprintf("INV-%05d", 10000+i),
            fmt.Sprintf("Cliente #%d", i),
            fmt.Sprintf("%d.00", 100+i*7),
        })
    }

    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("Libro mayor S1 2026", template.FontSize(18), template.Bold())
            c.Spacer(document.Mm(4))

            c.Table(header, rows,
                template.ColumnWidths(20, 20, 40, 20),
                template.TableHeaderStyle(
                    template.TextColor(pdf.White),
                    template.BgColor(brand),
                ),
                template.TableStripe(stripe),
                template.WithTableCellBorder(hairline),
            )
        })
    })

    data, err := doc.Generate()
    if err != nil {
        log.Fatal(err)
    }
    if err := os.WriteFile("ledger.pdf", data, 0o644); err != nil {
        log.Fatal(err)
    }
}

120 filas en A4 ocupan unas cinco páginas. En cada página el header azul oscuro reaparece arriba, el cuerpo continúa donde se cortó, las rayas grises alternas mantienen la consistencia entre saltos. No tienes que tocar nada de eso.

Lo que hay que mirar de este snippet es lo que no tiene: no hay bucle de filas, ni contador de página, ni if i == lastRowOnPage manual, ni PageBreak(), ni reemisión de header. Las cuatro líneas de opciones declaran cómo se ve la tabla; el motor decide cuándo y dónde cortar.

Anchos de columna: de qué son porcentajes

ColumnWidths(40, 15, 20, 25) se parece a CSS <col width="40%">. Casi. Con tres bordes filosos.

El porcentaje es del Col padre, no de la página. Un r.Col(6, ...) ocupa la mitad del ancho de contenido de la fila. Una tabla dentro con ColumnWidths(50, 50) produce dos columnas al 25% del ancho de la fila, no 50%. Los porcentajes son locales al sitio donde vive la tabla. Si mueves una tabla de una fila a ancho completo a un layout en paralelo, la llamada a la opción no cambia.

Sin normalización. Si tus anchos suman 90, queda 10% de espacio vacío a la derecha. Si suman 110, la columna más derecha desborda al padre y se mete en la página. gpdf confía en tu aritmética. Sin warning — y no debería: corregir automáticamente lo que escribiste es peor que el bug.

Los faltantes al final se autodistribuyen. Pasar menos anchos que columnas hace que las columnas restantes compartan el resto en partes iguales:

// Tabla de cinco columnas, tres anchos dados.
template.ColumnWidths(40, 10, 20)
// → 40% / 10% / 20% / 15% / 15%   (30% repartido entre las dos finales)

Truco útil para "me importan estos anchos específicos; el resto que se acomode". Pasar 0 explícito también marca la columna como auto:

template.ColumnWidths(0, 30, 30) // → 40% / 30% / 30% en una tabla de 3 columnas

Para los detalles de las esquinas, ver la receta de anchos de columna. Resumen: los porcentajes cubren el 95% de los layouts, y cuando no, bajas una capa. Descrito más adelante.

Rayas: el bucle de filas que no escribes

template.TableStripe(pdf.RGBHex(0xF5F5F5))

Eso es todo. gpdf recorre las filas del cuerpo con un índice i desde 0 y tinta las que cumplen i % 2 == 1. El header es su propio slice y no cuenta, así que la primera fila del cuerpo queda limpia y la segunda con sombra — convención Bootstrap.

Por qué existe la opción: en gofpdf y gopdf escribes el bucle, llamas SetFillColor por fila e invocas CellFormat con el flag de relleno. Son ocho o diez líneas y la tasa de off-by-one es lo bastante alta para tener un set propio de respuestas en StackOverflow. Meterlo en una sola opción hace desaparecer la clase de bug.

Las restricciones son deliberadas:

  • Un solo color de raya, no dos. Sin "alternar entre azul y gris". La página ya es blanca, así que la fila sin raya es automáticamente blanca. Pedir un ciclo de tres colores es pedirle al lector pensar más, y las rayas zebra son para lo opuesto.
  • No hay forma de invertir la paridad. La primera fila del cuerpo siempre limpia, la segunda siempre sombreada. Si realmente lo quieres invertido, pon una fila vacía al inicio. Pero nadie quiere eso de verdad.
  • Las rayas cruzan saltos de página correctamente. La fila 14 sigue siendo paridad-14 cuando aterriza en la página 2. El motor lleva el índice a través del corte.

Para elección de color y variantes en tema oscuro, la receta de rayas zebra tiene la discusión de paleta. Para este post, el punto es que una propiedad de la tabla (la alternancia) se configura al nivel de la llamada de tabla, no por fila.

Saltos de página: la parte que de verdad cuesta

Aquí es donde la mayoría de las historias de PDF en Go se caen, y donde el diseño de gpdf rinde más.

Versión simple: escribe una tabla con más filas de las que entran en una página y gpdf la pagina por ti. El slice Header se repite arriba de cada página de continuación. Ninguna opción para activarlo. Ningún método que llamar. Es el comportamiento por defecto del motor.

Versión real, más interesante. El motor de layout (document/layout/block.go) maquina la tabla con la altura disponible. Cuando el cuerpo no entra, el resultado incluye un campo Overflow — un nuevo *document.Table con el mismo Header, el mismo Footer y las filas restantes. El sistema de página vuelca lo que entró a la página actual, abre la siguiente y mete la tabla overflow de vuelta al motor con la nueva altura disponible. Repite hasta que el overflow esté vacío.

Dos consecuencias del diseño:

  1. El header vive en tbl.Header, no en el bucle. Como la tabla overflow reusa el mismo slice Header, el header se repite automáticamente en cada página de continuación. Mismo estilo, mismos anchos, todo igual.
  2. No hay caso límite "el header no entra en esta página". El motor reserva el espacio del header antes de medir cuántas filas del cuerpo entran. Si la página no aguanta el header más al menos una fila, la tabla entera pasa a la siguiente página.

Footers — cuando los usas en la capa documento — funcionan igual: se llevan en cada página de continuación automáticamente.

Lo que no obtienes: una anotación "mantén este grupo de filas junto", supresión de salto en una fila específica o "empieza esta tabla en una página nueva". Las primeras dos son TODO. La tercera la haces a nivel página — doc.AddPage() antes de la fila que contiene la tabla.

Cuando se te queda chica la API builder

El builder es bueno para los casos comunes. Cuando necesitas combinación de celdas, anchos en pt fijos, un footer que se repita o cualquier cosa que mezcle tipos de contenido por celda, bajas a document.Table.

import (
    "github.com/gpdf-dev/gpdf/document"
)

footer := document.TableRow{
    Cells: []document.TableCell{
        {
            Content: []document.DocumentNode{
                &document.Text{Content: "Total", TextStyle: document.DefaultStyle()},
            },
            ColSpan: 3, // ← combina las tres primeras columnas
            RowSpan: 1,
        },
        {
            Content: []document.DocumentNode{
                &document.Text{Content: "€48,720.00", TextStyle: document.DefaultStyle()},
            },
            ColSpan: 1,
            RowSpan: 1,
        },
    },
}

tbl := &document.Table{
    Columns: []document.TableColumn{
        {Width: document.Pct(20)},
        {Width: document.Pct(20)},
        {Width: document.Auto},
        {Width: document.Pt(80)}, // 80pt fijos sin importar el ancho de página
    },
    Header: /* ... */,
    Body:   /* ... */,
    Footer: []document.TableRow{footer},
}

Notar varias cosas. TableColumn.Width es un document.ValuePt, Mm, Cm, In, Em, Pct o el especial Auto. Los puedes mezclar en una tabla. Las columnas Auto comparten lo que queda después de restar las fijas y las en porcentaje. Más cerca del elemento <col> de CSS que del modelo solo-porcentaje del builder.

TableCell.ColSpan y RowSpan son enteros, por defecto 1. El ejemplo es el footer clásico de factura: tres columnas del header se combinan para "Total" y la cuarta lleva la suma.

document.Table.Footer es []TableRow que se repite en cada página, igual que el header. La API builder no la expone porque la mayoría de tablas cortas no la necesitan — cuando la necesitas, ya saliste de la zona "caso común".

Este es el patrón general de gpdf: el builder de alto nivel cubre el 90% con ergonomía, y la capa documento está justo al lado para el otro 10%. No son librerías separadas. Puedes mezclar filas hechas con builder y a mano en el mismo documento. El builder es solo un constructor del mismo nodo document.Table.

Bordes y el modelo de caja

Tres opciones de borde, tres trabajos diferentes:

template.WithTableBorder(spec)         // marco exterior alrededor de toda la tabla
template.WithTableCellBorder(spec)     // mismo borde alrededor de cada celda
template.WithTableBorderCollapse(true) // fusionar bordes adyacentes

Por defecto, sin bordes. Añade WithTableBorder para un marco. Añade WithTableCellBorder para dibujar el mismo borde en cada celda — el look de cuadrícula. Ambos juntos = marco alrededor de cuadrícula. El BorderSpec se construye con template.Border(template.BorderWidth(...), template.BorderColor(...)).

WithTableBorderCollapse(true) es el equivalente CSS: bordes adyacentes se fusionan en una sola línea (en vez de dibujarse dos veces, una por cada celda). En cuadrículas hairline donde el borde importa visualmente, collapse se ve más limpio. En bordes gruesos donde quieres el doble grosor a propósito, déjalo apagado. Por defecto, separados.

Combinación útil: bordes hairline en celdas + rayas suaves:

c.Table(header, rows,
    template.ColumnWidths(40, 20, 15, 25),
    template.TableHeaderStyle(template.TextColor(pdf.White), template.BgColor(brand)),
    template.TableStripe(pdf.RGBHex(0xF5F5F5)),
    template.WithTableCellBorder(template.Border(
        template.BorderWidth(document.Pt(0.5)),
        template.BorderColor(pdf.Gray(0.85)),
    )),
    template.WithTableBorderCollapse(true),
)

El look al que aterriza la vista previa de impresión de cualquier hoja de cálculo de un contable, deliberadamente. Es el default correcto para cualquier documento financiero — facturas, estados de cuenta, libros, reportes de gastos.

Comparación con las alternativas

Para contexto, esto cuesta la misma tabla multipágina con rayas en las librerías que gpdf suele reemplazar:

LibreríaLíneas para la tablaRepetición de header al saltar páginaRayasNotas
gpdf~10automáticoTableStripe(...)Builder y bajo nivel disponibles
jung-kurt/gofpdf (archivado 2021)40–60manual: rastrear Y, llamar AddPage, reemitir headerbucle manual con SetFillColorFundacional, sin mantenimiento
go-pdf/fpdf (archivado 2025)40–60igualigualEra fork de gofpdf, mismo modelo
signintech/gopdf50–80manualmanualAún más bajo nivel
johnfercher/maroto v2~15automáticoWithBackgroundColor por fila a manoSobre gofpdf; API agradable pero hereda dependencias
unidoc/unipdf~12automáticohelper de estilo de filaLicencia comercial requerida

Comparando solo líneas de builder, la diferencia se aprieta. La diferencia real aparece al mes 6 de uso, cuando los requisitos derivan — una nueva columna necesita otra alineación, el reporte tiene que ir en japonés, el cliente quiere el conteo de filas en el footer. Con gofpdf o gopdf, cada deriva pide tocar el bucle de filas. Con gpdf, la lista de opciones crece y el cuerpo del código no cambia.

Para benchmarks — las cifras µs por tabla — ver por qué gpdf es más rápido. Para el showdown más amplio entre librerías, el showdown de 2026 va columna por columna.

CJK en tablas

Algo invisible en la tabla comparativa de arriba: gpdf renderiza glifos CJK nativamente. No hay un "modo japonés" para tablas — registras la fuente una vez y la tabla la usa para todo.

ttf, _ := os.ReadFile("NotoSansJP-Regular.ttf")
doc := gpdf.NewDocument(
    gpdf.WithPageSize(gpdf.A4),
    gpdf.WithFont("NotoSansJP", ttf),
    gpdf.WithDefaultFont("NotoSansJP"),
)

c.Table(
    []string{"日付", "請求書番号", "顧客名", "金額"},
    [][]string{
        {"2026-04-01", "INV-10001", "株式会社サンプル", "¥120,000"},
        {"2026-04-02", "INV-10002", "山田商店", "¥38,500"},
    },
    template.ColumnWidths(20, 20, 40, 20),
)

Header en japonés, cuerpo en japonés, anchos siguen siendo porcentajes, repetición de header en saltos sigue funcionando. La fuente se subseta a solo los glifos que el documento usa, así que el PDF resultante es pequeño aun con Noto Sans JP completa disponible — unos 50 KB para una página versus los 6 MB del archivo de fuente sin subsetear.

Para el setup de la fuente, embedir una fuente TrueType japonesa es la receta. El punto aquí es que nada en la API de tabla cambia cuando los datos son CJK.

Preguntas frecuentes

P: ¿gpdf soporta estilo por fila?

No en la API builder. El builder toma [][]string para el cuerpo, lo que significa que cada celda comparte el mismo Style derivado de la columna. Para estilizar filas individuales, construye la tabla en la capa document.Table donde cada TableCell lleva su propio CellStyle. El patrón es directo; solo te cuesta la conveniencia de la forma [][]string.

P: ¿Puedo poner imágenes u otras tablas dentro de una celda?

Sí, en la capa document.Table. TableCell.Content es []DocumentNode, que acepta cualquier nodo — *Text, *Image, incluso otra *Table. La API builder basada en strings no lo expone por ser un borde más afilado de lo que la mayoría quiere, pero el modelo subyacente lo soporta.

P: ¿Cómo decide gpdf dónde partir el cuerpo entre páginas?

Fila por fila. El motor mide cada fila del cuerpo en orden y la añade a la página actual hasta que la siguiente excedería la altura disponible. Esa fila se vuelve la primera de la tabla overflow. Aún no hay anotación "mantén estas filas juntas" — toda fila es divisible. Para líneas de factura donde necesitas un grupo lógico en una página, deberías abrir página manualmente antes del grupo o caer a la capa documento para insertar pistas de salto.

P: ¿Cuál es la tabla más grande que gpdf puede renderizar?

Probamos con 10,000 filas en A4. Pagina correctamente, el header se repite en cada página, el PDF resultante tiene ~150 páginas y unos cientos de KB. El cuello de botella no es el layout de tabla — es el shaping de texto del contenido de la celda, que es O(filas × columnas). Si necesitas 100,000+ filas, escribe a disco en chunks (varias llamadas a Generate por ~10k filas) o alimenta runs pre-shaped en la capa document.Table.

P: ¿Puedo hacer que el footer aparezca solo en la última página?

No de forma nativa. document.Table.Footer se repite en cada página por diseño — es el caso común (totales por página). Si necesitas un resumen único al final del documento, añádelo como bloque de filas separado tras la tabla, no dentro.

P: ¿WithTableCellBorder afecta también al header?

Sí. Los bordes de celda aplican uniformemente a header y cuerpo. Si quieres un borde distinto en el header (por ejemplo, borde inferior más grueso bajo la fila del header), constrúyelo en la capa documento y aplica CellStyle.Border por celda allí.

La forma del diseño

Si hay una sola cosa que llevarse: la API de tablas de gpdf es pequeña porque la mayoría de los problemas de tabla son los mismos tres problemas. Anchos, rayas, saltos de página. Lo demás es long tail. Poner los casos comunes en el builder y el long tail en la capa documento es el trato — tienes tablas de cinco líneas para lo cotidiano y no pagas la abstracción cuando necesitas algo que el builder no expresa.

El costo es honesto: no hay atajo setRowStyle(i, ...) y no lo habrá. Si quieres estilizar la fila 4 distinto de la 5, cruzaste una línea de complejidad que el builder no intenta manejar. Baja una capa. La frontera es clara y estable.

Eso es el artículo entero. Veinte minutos de lectura para una parte de la API que vale la pena entender bien una vez y luego no pensar más.

Prueba gpdf

gpdf es una librería Go para generar PDFs. Licencia MIT, cero dependencias externas, soporte CJK nativo.

go get github.com/gpdf-dev/gpdf

⭐ Star en GitHub · Lee la documentación

Lectura relacionada