Pensamento Bootstrap para PDF: a grade de 12 colunas do gpdf
gpdf adota a grade de 12 colunas do Bootstrap para diagramar PDFs. Por que 12, o que mantemos do modelo web e o que descartamos sem dó.
TL;DR
gpdf usa a grade de 12 colunas do Bootstrap. Doze, porque divide certinho em 1, 2, 3, 4 e 6 — as repartições que você de fato quer. Mantivemos o modelo de span inteiro e jogamos fora todo o resto: sem breakpoints, sem gutter, sem order, sem auto-fill. Uma página é uma pilha de linhas; cada linha é um Box horizontal; as colunas dentro são larguras em doze avos.
É a grade inteira. A implementação tem cerca de 30 linhas de Go. O interessante é o que não portamos.
Por que este artigo existe
gpdf é uma biblioteca Go para gerar PDFs. A API de layout de alto nível é um builder: page.AutoRow → r.Col(span, fn) → c.Text/Image/Table. Quem encontra r.Col(4, ...) pela primeira vez costuma fazer três perguntas:
- Por que 12? Por que não 16, 24 ou «quantas eu quiser»?
- Isso é CSS Grid? Bootstrap? Outra coisa?
- O que acontece se meus span não somarem 12?
Este post percorre as decisões de design por trás dessa API. Quase todas convergem para um princípio: renderização de PDF precisa ser previsível, não adaptativa. Uma página web sofre reflow ao redimensionar. Um PDF não. Essa diferença sozinha elimina a maior parte do que torna grades web complicadas e nos permite entregar uma ideia bem menor.
Se você só quer saber como usar, a receita em /blog/12-column-grid é mais direta. Este post é sobre por que tem essa cara.
Três opções para diagramar um PDF
Quando começamos a API de alto nível, as opções reais eram três:
- Posicionamento absoluto. «Desenhe texto em (72, 540) em pontos.» É o que a maioria das libs PDF de baixo nível em Go oferece. Poder máximo, ergonomia péssima. Você calcula cada coordenada na mão.
- Flow + flexbox. Empilha o conteúdo de cima para baixo; linhas distribuem filhos horizontalmente com grow/shrink. Poderoso, mas a passada de layout não é trivial — precisa de um solver de restrições, e os erros de arredondamento se acumulam.
- Grade fixa + razões. Página é pilha de linhas. Linha é dividida em N slots iguais. Cada coluna pega um número inteiro de slots. Largura =
slots / N × largura_da_linha. Sem solver. Sem grow/shrink.
Escolhemos a opção 3. O Bootstrap chegou ao mesmo ponto há mais de uma década pelo mesmo motivo: a maioria dos layouts que você precisa são layouts de fração inteira. Duas colunas iguais. 1/3 + 2/3. Quatro cards na linha. Linha 25-50-25. Nada disso pede solver.
Restou a pergunta: quantos slots?
Por que 12
12 não é mágica, mas também não é arbitrário. Pense em quais divisões inteiras você de fato quer num documento:
- 2 colunas — metades esquerda/direita
- 3 colunas — terços (galeria de três cards)
- 4 colunas — quartos (faixa de KPI)
- 6 colunas — sextos (raros, painéis laterais estreitos)
- 12 colunas — doze avos (raros, separadores finos)
Olhe os divisores de 12: 1, 2, 3, 4, 6, 12. Ou seja, toda divisão inteira útil até um sexto. 10 não te dá terços. 16 também não. 24 dá tudo, mas dobra a carga cognitiva — você escreve r.Col(8, ...) e tem que lembrar se isso é um terço (24/3) ou dois terços (8/12). 12 é o menor número que cobre as repartições que as pessoas usam de verdade.
O Bootstrap pousou em 12 em 2011 exatamente por isso. Depois o CSS Grid subiu mais um nível e deixou você escrever 1fr 2fr 1fr direto, eliminando o número mágico. Mas frações não saem de graça — empurram trabalho para quem lê o seu layout. r.Col(4, ...) é concretamente «um terço da linha». r.Col(2fr, ...) exige olhar todos os irmãos antes de saber o que significa.
Em PDFs, onde o layout é estável e a inspeção é a olho, o modelo inteiro vence.
O que mantivemos do Bootstrap
Três coisas, e só:
- O doze. O denominador. O único número no mostrador.
- Span como inteiro 1–12. Nem fração, nem unidade CSS.
r.Col(4, ...)reivindica quatro doze avos da linha. - O modelo mental. Página é pilha de linhas. Linha é dividida em colunas. A mesma forma da grade que você escreve em HTML há dez anos.
Até aqui igual ao Bootstrap. A parte realmente interessante vem agora.
O que jogamos fora
Breakpoints
col-md-6 col-lg-4 do Bootstrap faz uma coluna ocupar metade no tablet e um terço no desktop. Útil na web. Inútil em PDF. A página de PDF é canvas fixo. Não há viewport para consultar, não há evento de resize, não há media query. Apagamos os breakpoints inteiros.
A economia é maior do que parece. Os breakpoints são a razão de frameworks CSS publicarem variantes col-xs-*, col-sm-*, col-md-*, col-lg-*, col-xl-* — cinco cópias da mesma classe. Nenhuma delas existe no gpdf. A API é r.Col(span int, fn func(*ColBuilder)). Uma assinatura. Um único compartimento mental.
Gutter
As linhas do Bootstrap vêm com padding horizontal entre colunas por padrão. PDFs não precisam de default, porque o espaço entre colunas depende totalmente do que se renderiza — uma tabela compacta vai com 0, uma seção hero quer 24pt de respiro, uma linha de fatura pode querer 0.5pt de separador. Decidimos deixar o espaçamento explícito.
Quer gutter? Põe: solte um c.Spacer(...) entre colunas, ou embrulhe o conteúdo num Box com padding. A grade nunca insere pixels que você não pediu. Sem gutter é o default certo num meio impresso onde cada ponto importa.
Order
CSS deixa reordenar colunas visualmente com order: 2. Útil em design responsivo, onde o mesmo DOM gera ordem visual diferente em telas pequenas. Inútil em PDFs. A ordem em que as colunas aparecem no arquivo é a ordem em que aparecem na página. Nem cogitamos.
Auto-fill / auto-fit
CSS Grid tem repeat(auto-fit, minmax(200px, 1fr)) — preencha a linha com quantas colunas de pelo menos 200px couberem. Lindo para galerias web. Em PDF, você sabe a largura da página em build time. Não precisa que o motor de layout descubra.
Quer 4 cards numa linha? r.Col(3, ...) quatro vezes. 6? r.Col(2, ...) seis vezes. A versão «auto» é um for no seu próprio código:
for _, item := range items {
r.Col(3, func(c *template.ColBuilder) {
c.Text(item.Name)
})
}
Três linhas. Não precisava entrar no framework.
Forçar soma dos spans
Surpresa: gpdf não exige que os span de coluna somem 12. É proposital.
page.AutoRow(func(r *template.RowBuilder) {
r.Col(4, func(c *template.ColBuilder) { c.Text("Terço esq.") })
r.Col(4, func(c *template.ColBuilder) { c.Text("Terço meio") })
// soma = 8. O terço direito fica em branco.
})
A lib trata cada coluna como span/12 × largura_da_linha, ponto. Se você bota 4 + 4 numa linha, o slot direito fica vazio. Se bota 7 + 8, a segunda coluna estoura para fora da linha — também é intencional, porque às vezes você quer overflow (alinhar com uma grade de layout mais larga que a página, por exemplo). O span é clampeado em 1–12 (Col(0, ...) vira Col(1, ...), Col(99, ...) vira Col(12, ...), ver gpdf/template/grid.go:120), mas sem auto-wrap nem auto-balanceamento.
O comportamento antigo do Bootstrap «se a soma passar de 12, quebra para a próxima linha» resolvia um problema responsivo real. PDFs não têm esse problema. Trocamos por um contrato mais simples: o que você escreveu é o que sai.
Container, fluid, no-gutters, offset, push/pull
Nada disso. Não tem container-fluid, col-md-offset-3, col-md-push-2 ou qualquer equivalente das classes utilitárias do Bootstrap. Quer empurrar uma coluna pra direita? Embrulhe: ponha um r.Col(3, ...) vazio antes. Oito caracteres a mais, zero conceitos novos.
gpdf vs Bootstrap vs CSS Grid
| Recurso | Bootstrap (CSS) | CSS Grid (CSS) | gpdf (Go) |
|---|---|---|---|
| Tamanho da grade | 12 colunas | Arbitrário (grid-template-columns) | 12 colunas |
| Unidade | Nomes de classe | Frações (fr), px, % | Span inteiro 1–12 |
| Breakpoints | 5 (xs/sm/md/lg/xl) | Via media queries | Nenhum |
| Gutter padrão | Sim (gx-* controla) | Não | Não |
| Reordenação visual | order-* | Propriedade order | Não |
| Auto-fill | Não | Sim | Não |
| Wrap se soma > 12 | Sim (legacy) / Não (flex) | N/A | Não (overflow permitido) |
| Tamanho da implementação | ~3.000 LoC SCSS | Dentro do navegador | ~30 LoC Go |
«30 LoC» é número de verdade. Abre gpdf/template/grid.go e conta: uma constante (gridColumns = 12), um método de builder que clampeia inteiros, e uma passada de build que emite um Box por linha com direção horizontal e largura Pct(span/12*100) por filho. Sem passada de medição, sem algoritmo flex, sem rebalanceamento. A aritmética da largura é o algoritmo.
Como gpdf renderiza isso por dentro
Quando você chama r.Col(4, fn), gpdf adiciona um colEntry{span: 4, fn: fn} à linha. Quando o documento é construído, cada entrada vira um document.Box com Width: document.Pct(33,333…) e o conteúdo da coluna aninhado dentro. A linha em si é um Box com Direction: DirectionHorizontal. O PDF Writer (Layer 1) percorre os Box em ordem do documento e emite content streams; o motor de layout (Layer 2) resolve largura e altura; a grade (Layer 3) faz a conversão inteiro→percentual.
A razão de isso parar em 30 linhas é que percentuais e inteiros compõem sem erro de arredondamento na fronteira de layout. Coluna dentro de coluna dentro de coluna ainda é só uma pilha de multiplicações Pct em float64. O orçamento de erro fica bem abaixo de um ponto tipográfico mesmo em layouts profundos.
Se quiser ver toda a cadeia, por que gpdf é 10× mais rápido que as alternativas cobre o pipeline de render. A grade é uma das camadas mais baratas — em M1, uma página leva uns 13 µs e a grade aporta apenas algumas centenas de nanossegundos.
Um exemplo completo e funcional
Cabeçalho 4/8, depois uma linha 12 com tabela, depois uma faixa de KPI 3/3/3/3:
package main
import (
"os"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/template"
)
func main() {
doc := template.NewDocument(document.PageSize(document.A4))
doc.Page(func(p *template.PageBuilder) {
// Divisão 4/8: logo à esquerda, endereço à direita.
p.AutoRow(func(r *template.RowBuilder) {
r.Col(4, func(c *template.ColBuilder) {
c.Text("ACME, Ltda.", template.FontSize(18), template.Bold())
})
r.Col(8, func(c *template.ColBuilder) {
c.Text("Av. Industrial, 123", template.AlignRight())
c.Text("São Paulo - SP, 01000-000", template.AlignRight())
})
})
p.Spacer(document.Mm(10))
// Linha 12 (uma coluna span 12) para uma tabela.
p.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Table([]string{"Item", "Qtd.", "Preço"}, [][]string{
{"Widget A", "2", "R$ 10,00"},
{"Widget B", "1", "R$ 25,00"},
})
})
})
p.Spacer(document.Mm(10))
// Faixa de KPI: 3 span × 4 colunas
kpis := []struct{ label, value string }{
{"Subtotal", "R$ 45,00"},
{"ICMS (18%)", "R$ 8,10"},
{"Frete", "R$ 0,00"},
{"Total", "R$ 53,10"},
}
p.AutoRow(func(r *template.RowBuilder) {
for _, k := range kpis {
k := k
r.Col(3, func(c *template.ColBuilder) {
c.Text(k.label, template.FontSize(8))
c.Text(k.value, template.FontSize(14), template.Bold())
})
}
})
})
f, _ := os.Create("invoice.pdf")
defer f.Close()
doc.Render(f)
}
É um programa que roda. go get github.com/gpdf-dev/gpdf e executa; o arquivo invoice.pdf aparece na pasta. Tempo de render em M1: cerca de 130 µs.
Quando o modelo inteiro está errado
O modelo de doze avos inteiros é genuinamente a escolha errada em dois casos. Lista honesta, porque você vai esbarrar em pelo menos um:
- Você precisa de larguras com precisão de pixel. «Esta coluna tem que medir exatamente 73,5pt.»
Pctnão te entrega isso, porque73,5 / total × 12raramente é inteiro. Usepage.Absolute(...)para os poucos elementos com coordenada fixa e deixe o resto para a grade. Misturar os dois na mesma página é tranquilo. - Você precisa de fluxo de colunas estilo jornal. Um parágrafo enche uma coluna e continua na próxima. A grade não faz isso. Ainda não temos motor de fluxo de texto entre colunas. Se precisar, abra um issue — sabemos que falta.
Para o resto — faturas, relatórios, contratos, brochuras, decks — a grade de 12 encaixa mais justa que CSS, não mais frouxa.
Perguntas frequentes
P: Posso trocar o 12 por outro valor, tipo 24?
Não. gridColumns é constante. Trocar invalidaria todos os templates existentes. Decidimos 12 uma vez e ficamos.
P: E se eu quiser aninhar uma linha dentro de uma coluna?
Pode. c.AutoRow(...) cria uma sub-linha dentro da coluna. Os spans dentro da sub-linha são 1–12 da largura da coluna pai, não da página. A aninhação compõe limpa porque cada nível é só Pct(span/12 × 100) do pai.
P: Funciona em páginas em paisagem?
Sim. A grade é agnóstica a tamanho de página. r.Col(6, ...) é metade da linha tendo ela 210mm (A4 retrato) ou 297mm (A4 paisagem).
P: Por que não tem um atalho r.Col2(span, span, fn1, fn2) para duas colunas?
Porque trocar uma linha por mais superfície de API é troca ruim. Se você se pega repetindo um padrão de linha, escreva uma função Go que receba *template.PageBuilder e o adicione. A grade fica mínima para que padrões do usuário cresçam sem conflito.
P: E grid-area e linhas nomeadas do CSS Grid?
Não estão no gpdf nem no roadmap. O custo-benefício não fecha para PDFs.
Resumo
A grade de 12 colunas é a primitiva de layout mais enxuta que cobre as divisões de que documentos reais precisam. Pegamos o número emprestado do Bootstrap, mantivemos o modelo inteiro e descartamos breakpoints, gutter, order, auto-fill, soma de span forçada e o resto da bagagem do responsivo web. Sobrou uma constante, um método de builder e uma fórmula de largura — cerca de 30 linhas de Go. Compõe por aninhamento, convive com Absolute para os poucos casos em que a grade não dá conta, e nunca rebalanceia em silêncio o que você escreveu.
Experimente o gpdf
gpdf é uma biblioteca Go para PDF: MIT, sem dependências, suporte CJK pronto.
go get github.com/gpdf-dev/gpdf
⭐ Star no GitHub · Ler a documentação
O que ler em seguida
- Como funciona a grade de 12 colunas no gpdf? — versão receita, com mais padrões de código
- Por que gpdf é 10× mais rápido que as alternativas — internals do pipeline de render
- Quickstart — gere seu primeiro PDF em cinco minutos