Todas as publicações

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:

  1. Por que 12? Por que não 16, 24 ou «quantas eu quiser»?
  2. Isso é CSS Grid? Bootstrap? Outra coisa?
  3. 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:

  1. 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.
  2. 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.
  3. 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ó:

  1. O doze. O denominador. O único número no mostrador.
  2. Span como inteiro 1–12. Nem fração, nem unidade CSS. r.Col(4, ...) reivindica quatro doze avos da linha.
  3. 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

RecursoBootstrap (CSS)CSS Grid (CSS)gpdf (Go)
Tamanho da grade12 colunasArbitrário (grid-template-columns)12 colunas
UnidadeNomes de classeFrações (fr), px, %Span inteiro 1–12
Breakpoints5 (xs/sm/md/lg/xl)Via media queriesNenhum
Gutter padrãoSim (gx-* controla)NãoNão
Reordenação visualorder-*Propriedade orderNão
Auto-fillNãoSimNão
Wrap se soma > 12Sim (legacy) / Não (flex)N/ANão (overflow permitido)
Tamanho da implementação~3.000 LoC SCSSDentro 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:

  1. Você precisa de larguras com precisão de pixel. «Esta coluna tem que medir exatamente 73,5pt.» Pct não te entrega isso, porque 73,5 / total × 12 raramente é inteiro. Use page.Absolute(...) para os poucos elementos com coordenada fixa e deixe o resto para a grade. Misturar os dois na mesma página é tranquilo.
  2. 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