Tabelas em PDFs com Go: larguras, listras zebra, quebra de página
Tabelas são a parte mais difícil de um PDF em Go. gpdf reúne larguras, listras e a repetição de cabeçalho a cada página em uma única chamada Table.
Resumo
Tabelas são a parte da geração de PDF que destrói fim de semana. Larguras de coluna que não somam, cabeçalhos que somem na página 2, listras desenhadas com um loop de linhas que tem um off-by-one. O gpdf comprime tudo isso em uma chamada:
c.Table(header, rows,
template.ColumnWidths(40, 15, 20, 25),
template.TableHeaderStyle(template.TextColor(pdf.White), template.BgColor(brand)),
template.TableStripe(pdf.RGBHex(0xF5F5F5)),
)
Isso resolve larguras, listras e repetição automática do cabeçalho a cada quebra de página. Sem loop. Sem opção PageBreak. O motor de layout percebe quando a tabela não cabe e reemite o slice Header no topo da próxima página. Para colspan, rowspan ou um rodapé que também repita, você desce uma camada para document.Table — mesmos blocos, mais controle.
Este post é sobre por que esses são os três eixos que importam, o que o gpdf faz em cada um e onde a abstração para de propósito.
Por que este artigo existe
gpdf é uma biblioteca Go para gerar PDFs. MIT, dependências zero, renderiza uma página em ~13 µs. A API de tabela é pequena — oito construtores TableOption — mas a pressão de design sobre ela é enorme: é nas tabelas que a maioria dos projetos PDF em Go encalha.
Os três pontos onde uma tabela quebra no mundo Go PDF:
- Larguras de coluna. A web tem CSS
<col>ecolgroup. PDF não tem nada. Ou você calcula cada largura em pontos manualmente, ou aceita o que a biblioteca dá — geralmente divisões iguais. - Listras zebra. Você quer cada linha alternada do corpo tingida de cinza para legibilidade. A maioria das bibliotecas de baixo nível obriga você a escrever o loop e rastrear paridade — fonte de metade dos bugs de renderização de tabela.
- Quebras de página. Um relatório de 200 linhas não cabe em uma A4. A biblioteca precisa (a) cortar o corpo em algum lugar razoável, (b) fechar a página, (c) abrir a próxima e (d) reemitir o cabeçalho na nova página para que o leitor saiba qual coluna está olhando. Esqueça qualquer uma e a tabela é inutilizável.
Este post percorre como o gpdf resolve cada um e quais trade-offs o design assume. Se você só quer receitas para colar, os links no final apontam para receitas por opção. Esta é a versão longa para quem quer saber se pode confiar na API antes de comprometer um extrato mensal de dez mil linhas.
A forma da API
Há um único ponto de entrada na camada builder:
func (c *ColBuilder) Table(header []string, rows [][]string, opts ...TableOption)
Cabeçalho é um slice de strings, rows é um slice de slices de strings, e o variádico opts configura todo o resto. Existem oito construtores de opção:
| Opção | O que controla |
|---|---|
ColumnWidths(...float64) | Larguras por coluna como porcentagem do Col pai |
TableHeaderStyle(...TextOption) | Cor de fundo e de texto do cabeçalho |
TableStripe(pdf.Color) | Cor de fundo das linhas alternadas do corpo |
TableCellVAlign(document.VerticalAlign) | Alinhamento vertical das células do corpo |
WithTableBorder(BorderSpec) | Moldura externa de toda a tabela |
WithTableCellBorder(BorderSpec) | Mesma borda em volta de cada célula — visual de grade |
WithTableBorderCollapse(bool) | Semântica do CSS border-collapse: collapse |
WithTableBackground(pdf.Color) | Preenchimento de fundo da tabela inteira |
Essa é toda a superfície. O que dá para construir no builder se constrói com esses oito. Qualquer coisa além — colspan, rowspan, rodapé, larguras fixas em pt — vira chamada document.Table. Já chegamos lá.
Código que roda: livro razão de seis meses
Programa completo e executável. Salve como main.go, rode go run ., obtenha 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{"Data", "Nº NF", "Cliente", "Valor"}
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("NF-%05d", 10000+i),
fmt.Sprintf("Cliente #%d", i),
fmt.Sprintf("R$%d,00", 100+i*7),
})
}
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("Razão 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 linhas em A4 ocupam cerca de cinco páginas. Em cada uma o cabeçalho azul-escuro reaparece no topo, o corpo continua de onde parou, as listras alternadas seguem coerentes através das quebras. Você não precisa tocar em nada disso.
O que vale notar nesse trecho é o que não tem: nenhum loop de linhas, nenhum contador de página, nenhum if i == lastRowOnPage manual, nenhum PageBreak(), nenhum reemitir de cabeçalho. As quatro linhas de opções declaram como a tabela parece; o motor decide quando e onde quebrar.
Larguras de coluna: porcentagens de quê
ColumnWidths(40, 15, 20, 25) parece o CSS <col width="40%">. Quase. Com três arestas afiadas.
A porcentagem é do Col pai, não da página. Um r.Col(6, ...) ocupa metade da largura de conteúdo da linha. Uma tabela dentro com ColumnWidths(50, 50) produz duas colunas com 25% da largura da linha, não 50%. As porcentagens são locais ao lugar onde a tabela mora. Quando você move uma tabela de uma linha de largura inteira para um layout lado a lado, a chamada da opção não precisa mudar.
Sem normalização. Se suas larguras somam 90, sobra 10% vazio à direita. Se somam 110, a coluna mais à direita transborda o pai e vaza para a página. O gpdf confia na sua aritmética. Sem aviso — e nem deveria: corrigir automaticamente o valor que você escreveu é pior que o bug.
Faltantes ao final auto-distribuem. Passe menos larguras do que colunas e as restantes dividem o que sobra em partes iguais:
// Tabela de cinco colunas, três larguras dadas.
template.ColumnWidths(40, 10, 20)
// → 40% / 10% / 20% / 15% / 15% (30% dividido entre as duas finais)
Truque útil para "me importam essas larguras específicas; o resto se vire". Passar 0 explícito também marca a coluna como auto:
template.ColumnWidths(0, 30, 30) // → 40% / 30% / 30% numa tabela de 3 colunas
Para os cantos finos das larguras, veja a receita de larguras de coluna. Resumo: porcentagens cobrem 95% dos layouts; quando não, você desce uma camada. Descrito adiante.
Listras: o loop de linhas que você não escreve
template.TableStripe(pdf.RGBHex(0xF5F5F5))
É só isso. O gpdf percorre as linhas do corpo com um índice i desde 0 e tinge as linhas com i % 2 == 1. O cabeçalho é seu próprio slice e não conta, então a primeira linha do corpo fica limpa e a segunda sombreada — convenção Bootstrap.
Por que essa opção existe: em gofpdf e gopdf você escreve o loop, chama SetFillColor por linha e invoca CellFormat com a flag de preenchimento. São oito ou dez linhas e a taxa de off-by-one é alta o suficiente para o StackOverflow ter um conjunto de respostas dedicado. Empacotar isso em uma opção faz a classe inteira de bug desaparecer.
Restrições deliberadas:
- Uma cor de listra, não duas. Sem "alternar entre azul e cinza". A página já é branca, então a linha sem listra é automaticamente branca. Pedir um ciclo de três cores é pedir ao leitor para pensar mais — listras zebra existem para o oposto.
- Sem inverter a paridade. A primeira linha do corpo é sempre limpa, a segunda sempre sombreada. Se você quer mesmo invertido, ponha uma linha em branco no início. Mas ninguém quer isso de verdade.
- Listras atravessam quebras de página corretamente. A linha 14 do corpo continua sendo paridade-14 quando aterrissa na página 2. O motor leva o índice através do corte.
Para escolha de cor e variantes para tema escuro, a receita de listras zebra tem a discussão de paleta. O ponto deste post: uma propriedade da tabela (a alternância) é configurada na chamada da tabela, não por linha.
Quebras de página: a parte que é difícil de verdade
É aqui que a maioria das histórias de PDF em Go desmorona, e onde o design do gpdf rende mais.
Versão simples: escreva uma tabela com mais linhas do que cabem em uma página e o gpdf pagina para você. O slice Header é repetido no topo de cada página de continuação. Nenhuma opção para ligar. Nenhum método para chamar. É o comportamento padrão do motor.
Versão real, mais interessante. O motor de layout (document/layout/block.go) maquina a tabela com a altura disponível. Quando o corpo não cabe, o resultado inclui um campo Overflow — um novo *document.Table com o mesmo Header, o mesmo Footer e as linhas restantes. O sistema de páginas despeja a parte ajustada na página atual, abre a próxima e devolve a tabela overflow ao motor com a nova altura disponível. Repete até overflow estar vazio.
Duas consequências do design:
- O cabeçalho mora em
tbl.Header, não no loop. Como a tabela overflow reusa o mesmo sliceHeader, o cabeçalho é repetido automaticamente em cada página de continuação. Mesmo estilo, mesmas larguras, tudo igual. - Não há caso-limite "o cabeçalho não cabe nessa página". O motor reserva espaço para o cabeçalho antes de medir quantas linhas do corpo cabem. Se a página não aguenta cabeçalho mais ao menos uma linha do corpo, a tabela inteira vai para a próxima.
Rodapés — quando você os usa na camada documento — funcionam igual: levados em cada página de continuação automaticamente.
O que você não tem: uma anotação "mantenha esse grupo de linhas junto", supressão de quebra em uma linha específica ou "comece esta tabela em uma página nova". As duas primeiras são TODO. A terceira você faz na camada de página — doc.AddPage() antes da linha que contém a tabela.
Quando você superou a API builder
O builder é bom para casos comuns. Quando você precisa de células mescladas, larguras fixas em pt, rodapé repetido ou misturar tipos de conteúdo por célula, desce para 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, // ← cobre as três primeiras colunas
RowSpan: 1,
},
{
Content: []document.DocumentNode{
&document.Text{Content: "R$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 fixos independente da largura da página
},
Header: /* ... */,
Body: /* ... */,
Footer: []document.TableRow{footer},
}
Algumas coisas para notar. TableColumn.Width é um document.Value — Pt, Mm, Cm, In, Em, Pct ou o especial Auto. Você mistura na mesma tabela. Colunas Auto compartilham o que sobra após as fixas e em porcentagem. Mais perto do elemento <col> do CSS do que do modelo só-porcentagem do builder.
TableCell.ColSpan e RowSpan são inteiros, padrão 1. O exemplo é o rodapé clássico de NF: três colunas do cabeçalho mescladas em "Total" e a quarta com a soma.
document.Table.Footer é []TableRow repetido em cada página, igual ao cabeçalho. A API builder não expõe porque tabelas curtas em geral não precisam — quando você precisa, já saiu da zona "caso comum".
Esse é o padrão geral do gpdf: o builder de alto nível cobre 90% com ergonomia, e a camada documento está bem ao lado para os outros 10%. Não são bibliotecas separadas. Você pode misturar linhas de builder e linhas montadas a mão no mesmo documento. O builder é só um construtor do mesmo nó document.Table.
Bordas e o modelo de caixa
Três opções de borda, três trabalhos diferentes:
template.WithTableBorder(spec) // moldura externa de toda a tabela
template.WithTableCellBorder(spec) // mesma borda em volta de cada célula
template.WithTableBorderCollapse(true) // mescla bordas adjacentes
Por padrão, sem bordas. Adicione WithTableBorder para uma moldura. Adicione WithTableCellBorder para o visual de grade. Os dois juntos = moldura em volta da grade. O BorderSpec é construído com template.Border(template.BorderWidth(...), template.BorderColor(...)).
WithTableBorderCollapse(true) é o análogo CSS: bordas adjacentes mesclam em uma única linha (em vez de desenhar duas vezes, uma por borda de célula). Em grades hairline onde a borda importa visualmente, collapse fica mais limpo. Em bordas grossas onde você quer o efeito duplicado de propósito, deixe desligado. O padrão é separado.
Combinação útil: bordas hairline nas células + listras claras:
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),
)
O visual em que toda prévia de impressão de planilha de contador acaba caindo, deliberadamente. É o padrão certo para qualquer documento financeiro adjacente — NFs, extratos, livros razão, relatórios de despesa.
Como isso compara com as alternativas
Para contexto, o que a mesma tabela multipágina com listras custa nas bibliotecas que o gpdf costuma substituir:
| Biblioteca | Linhas para a tabela | Cabeçalho repetido em quebra | Listras | Notas |
|---|---|---|---|---|
| gpdf | ~10 | automático | TableStripe(...) | Builder e baixo nível disponíveis |
| jung-kurt/gofpdf (arquivado em 2021) | 40–60 | manual: rastrear Y, chamar AddPage, reemitir cabeçalho | loop manual com SetFillColor | Fundacional, sem manutenção |
| go-pdf/fpdf (arquivado em 2025) | 40–60 | igual | igual | Era fork de gofpdf, mesmo modelo |
| signintech/gopdf | 50–80 | manual | manual | Ainda mais baixo nível |
| johnfercher/maroto v2 | ~15 | automático | WithBackgroundColor por linha à mão | Sobre gofpdf; API agradável mas herda dependências |
| unidoc/unipdf | ~12 | automático | helper de estilo de linha | Licença comercial obrigatória |
Comparando só linhas no builder, a diferença aperta. A diferença real aparece no mês 6 de uso, quando os requisitos derivam — uma nova coluna precisa de outro alinhamento, o relatório precisa sair em japonês, o cliente quer a contagem de linhas no rodapé. Com gofpdf ou gopdf, cada deriva exige tocar no loop de linhas. Com gpdf, a lista de opções cresce e o corpo do código não muda.
Para benchmarks — os números µs por tabela — veja por que o gpdf é mais rápido. Para o showdown mais amplo, o showdown de 2026 vai coluna a coluna.
CJK em tabelas
Algo invisível na tabela comparativa acima: o gpdf renderiza glifos CJK nativamente. Não há "modo japonês" para tabelas — você registra a fonte uma vez e a tabela usa para tudo.
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),
)
Cabeçalho em japonês, corpo em japonês, larguras seguem em porcentagem, repetição de cabeçalho em quebras continua funcionando. A fonte é subseteada apenas para os glifos que o documento usa, então o PDF resultante é pequeno mesmo com a Noto Sans JP completa disponível — cerca de 50 KB para uma página versus os 6 MB do arquivo completo.
Para a configuração da fonte, embutir uma fonte TrueType japonesa é a receita. O ponto aqui é que nada na API de tabela muda quando os dados são CJK.
Perguntas frequentes
P: O gpdf suporta estilo por linha?
Não na API builder. O builder recebe [][]string para o corpo, o que significa que toda célula compartilha o mesmo Style derivado da coluna. Para estilizar linhas individuais, construa a tabela na camada document.Table onde cada TableCell carrega seu próprio CellStyle. O padrão é direto; só custa a conveniência da forma [][]string.
P: Posso colocar imagens ou outras tabelas dentro de uma célula?
Sim, na camada document.Table. TableCell.Content é []DocumentNode, que aceita qualquer nó — *Text, *Image, até *Table aninhado. A API builder baseada em strings não expõe porque é uma aresta mais afiada do que a maioria dos usuários quer, mas o modelo subjacente suporta.
P: Como o gpdf decide onde partir o corpo entre páginas?
Linha por linha. O motor mede cada linha do corpo em ordem e adiciona à página atual até que a próxima ultrapassaria a altura disponível. Aquela linha vira a primeira linha da tabela overflow. Ainda não há anotação "mantenha essas linhas juntas" — toda linha é divisível. Para itens de NF onde você realmente precisa de um grupo lógico em uma página, abra a página manualmente antes do grupo ou caia para a camada documento para inserir dicas de quebra.
P: Qual a maior tabela que o gpdf consegue renderizar?
Testamos com 10.000 linhas em A4. Pagina corretamente, o cabeçalho repete em todas as páginas, o PDF resultante tem ~150 páginas e algumas centenas de KB. O gargalo não é o layout da tabela — é o shaping de texto do conteúdo da célula, que é O(linhas × colunas). Se você precisa de 100.000+ linhas, escreva em disco em chunks (várias chamadas a Generate por ~10k linhas) ou alimente runs pré-shaped na camada document.Table.
P: Posso fazer o rodapé aparecer só na última página?
Não nativamente. document.Table.Footer repete em cada página por design — é o caso comum (totais por página). Se você precisa de um resumo único no fim do documento, adicione como bloco de linhas separado depois da tabela, não dentro.
P: WithTableCellBorder afeta o cabeçalho também?
Sim. Bordas de célula aplicam uniformemente a cabeçalho e corpo. Se você quer borda diferente no cabeçalho (por exemplo, borda inferior mais grossa abaixo da linha de cabeçalho), construa o cabeçalho na camada documento e aplique CellStyle.Border por célula lá.
A forma do design
Se há uma coisa para levar: a API de tabelas do gpdf é pequena porque a maioria dos problemas de tabela é sempre os mesmos três problemas. Larguras, listras, quebras de página. O resto é long tail. Pôr os casos comuns no builder e o long tail na camada documento é o trade — você ganha tabelas de cinco linhas para o que aparece todo dia, e não paga a abstração quando precisa fazer algo que o builder não expressa.
O custo é honesto: não há atalho setRowStyle(i, ...) e não vai ter. Se você quer estilizar a linha 4 diferente da 5, cruzou uma linha de complexidade que o builder não tenta lidar. Desça uma camada. A fronteira é clara e estável.
É o artigo todo. Vinte minutos de leitura para uma parte da API que vale a pena entender bem uma vez e depois não pensar mais.
Experimente o gpdf
gpdf é uma biblioteca Go para gerar PDFs. Licença MIT, dependências externas zero, suporte CJK nativo.
go get github.com/gpdf-dev/gpdf
⭐ Star no GitHub · Leia a documentação
Leitura relacionada
- Como defino larguras de coluna numa tabela gpdf? — os detalhes de
ColumnWidths - Como crio linhas com listras zebra? — escolha de cor e tema escuro
- Pensamento Bootstrap para PDF: a grade de 12 colunas do gpdf — qual Col pai resolve as porcentagens
- Gere uma NF em PDF em Go com menos de 50 linhas — uma tabela do mundo real dentro de um documento completo
- Por que o gpdf é mais rápido que gofpdf, gopdf e Maroto — os µs por trás da tabela comparativa