De signintech/gopdf para gpdf: menos cálculo de coordenadas
signintech/gopdf funciona, mas cada célula, linha e cabeçalho é um cálculo (x, y). Este guia mapeia a API do gopdf para gpdf — mesmo Go, sem coordenadas.
TL;DR
gpdf é uma biblioteca PDF em Go puro com motor de layout de 12 colunas. signintech/gopdf é um binding de baixo nível sobre o sistema de coordenadas PDF. Se você já usa gopdf há algum tempo e a base de código é hoje em sua maioria SetXY, Cell e aritmética de larguras, este guia mostra em que essas chamadas colapsam quando há um motor de layout abaixo.
Semana passada estava com alguém refatorando um gerador de notas fiscais sobre signintech/gopdf. Cinco anos de acúmulo. A função que desenhava a tabela de itens tinha 280 linhas. Cerca de 40 faziam trabalho real: formatar valor, formatar data, repetir por linha. As outras 240 calculavam posições x, rastreavam y, chamavam SetXY, chamavam Cell, chamavam Br, desenhavam linhas de borda com Line(x1, y1, x2, y2), decidiam se a linha cabia na página ou se era preciso AddPage manual e reimprimir o cabeçalho.
Essa é a experiência gopdf em produção. Não é uma biblioteca ruim. É um binding fino, rápido, sem CGO sobre o modelo de imaging do PDF — exatamente o que o nome promete. Há um cursor, há coordenadas, e o engenheiro faz papel de motor de layout.
Este artigo mapeia a API do gopdf para gpdf, função por função. A tese está no título: a maioria das linhas desaparece porque faziam matemática de layout que o runtime pode fazer por você.
O que signintech/gopdf é — e o que não é
Vale deixar claro antes de qualquer narrativa de "migre já", porque o gopdf tem virtudes reais.
É mantido ativamente. É Go puro (sem CGO), então cross-compilation e imagens Alpine simplesmente funcionam. Suporta fontes TrueType incluindo CJK. A saída é rápida — gopdf está na mesma faixa que gpdf nas primitivas de imaging porque ambos escrevem o wire format PDF diretamente, sem motor pesado na frente. A API mapeia diretamente para o modelo PDF subjacente: há um ponto atual, você o move, você desenha nele. Se você já pensa em coordenadas PDF, gopdf é confortável.
O que não é, é um sistema de layout. Não há noção de linha, coluna, container flex ou grade. Não há quebra de página automática: quando seu conteúdo passa da margem inferior, o conteúdo passa da margem inferior (ou sai da página) até você chamar AddPage por conta própria. Tabelas não existem como primitiva — são um padrão que você reimplementa em cada projeto, com chamadas Cell célula a célula, linhas de borda manuais e sua própria lógica de quebra de página.
Para um certificado de uma página ou um formulário fixo muito controlado, o modelo de cursor está ok. Para notas fiscais, relatórios, extratos, qualquer coisa com conteúdo de tamanho variável — a matemática de coordenadas cresce com a área do documento. Essa é a carga de trabalho para a qual gpdf foi feito.
A mudança de modelo mental
Esta é a parte que muda de fato como o código é lido. gpdf tem duas ideias que gopdf não tem:
Árvore declarativa. Você não diz ao renderer onde colocar as coisas. Você descreve uma árvore de páginas → linhas → colunas → conteúdo, e o motor de layout resolve as posições em uma única passagem. Não há cursor a avançar. Duas chamadas r.Col(...) consecutivas não precisam saber uma da outra.
Grade de 12 colunas. Cada linha tem implicitamente 12 unidades de largura. Você as gasta entre as colunas: r.Col(8, ...) toma dois terços, r.Col(4, ...) toma um terço. A grade é a mesma ideia que Bootstrap e Tailwind usam para HTML, aplicada a PDF. Você para de calcular pageWidth - leftMargin - rightMargin dividido por 4. Você escreve r.Col(3, ...) quatro vezes.
Essas duas ideias removem a maior parte da matemática. Os pares before/after a seguir colapsam todos da mesma forma: um loop imperativo que avança um cursor vira uma pequena árvore declarativa.
A tabela de mapeamento da API
Cola primeiro. As seções depois percorrem cinco pares concretos.
| O que você quer fazer | signintech/gopdf | gpdf |
|---|---|---|
| Construir | pdf := gopdf.GoPdf{}; pdf.Start(gopdf.Config{...}) | doc := gpdf.NewDocument(gpdf.WithPageSize(document.A4), ...) |
| Tamanho de página | Config{PageSize: gopdf.PageSizeA4} | gpdf.WithPageSize(document.A4) |
| Adicionar página | pdf.AddPage() | page := doc.AddPage() |
| Mover cursor | pdf.SetX(40); pdf.SetY(80) (em todo lugar) | (sem cursor) |
| Texto de uma linha | pdf.SetXY(x, y); pdf.Cell(nil, "hi") | c.Text("hi") (dentro de uma coluna) |
| Texto com quebra | pdf.MultiCell(&gopdf.Rect{W: 200, H: 100}, body) | c.Text(body) (quebra automática) |
| Quebra de linha | pdf.Br(20) | (implícito entre linhas; c.Spacer(document.Mm(4)) se preciso) |
| Registro de fonte | pdf.AddTTFFont("noto", "fonts/Noto.ttf") | gpdf.WithFont("Noto", ttfBytes) (na construção) |
| Fonte ativa | pdf.SetFont("noto", "", 14) | por texto: template.FontFamily("Noto"), template.FontSize(14) |
| Cor | pdf.SetTextColor(26, 35, 126) | template.TextColor(pdf.RGBHex(0x1A237E)) |
| Linha horizontal | pdf.Line(40, 100, 555, 100) | c.Line(template.LineColor(pdf.Gray(0.7))) |
| Retângulo | pdf.RectFromUpperLeftWithStyle(x, y, w, h, "FD") | c.Box(template.BgColor(...), template.Border(...)) |
| Imagem | pdf.Image("logo.png", x, y, &gopdf.Rect{W: 100, H: 50}) | c.Image(imgBytes, template.FitWidth(document.Mm(35))) |
| Tabela manual | dezenas de Cell + Line + SetXY | c.Table(headers, rows, template.ColumnWidths(...)) |
| Cabeçalho / rodapé | pdf.AddHeader(fn) / pdf.AddFooter(fn) | doc.Header(fn) / doc.Footer(fn) |
| Número de página | formatar "Page %d of %d" de um contador próprio | c.PageNumber() / c.TotalPages() (placeholders) |
| Criptografar | Config{Protection: PDFProtectionConfig{...}} | gpdf.WithEncryption(gpdf.AES256, "user", "owner", perms) |
| Saída | pdf.WritePdf("out.pdf") | data, _ := doc.Generate(); os.WriteFile("out.pdf", data, 0o644) |
| Saída para writer | pdf.Write(w) / pdf.ToBuffer() | doc.Render(w) |
Duas mudanças estruturais. Primeiro, o cursor desaparece. As linhas marcadas (em todo lugar) na tabela não são exagero — em uma base gopdf real, chamadas SetXY superam em número as Cell. Todas colapsam para nada em gpdf. Segundo, pixels viram porcentagens. Rect{W: 200, H: 100} vira "esta coluna toma 4 das 12 unidades do container em que está". Coloque a mesma coluna dentro de uma linha com metade da largura e ela escala sem mudanças.
Before / After 1: hello world
A diferença mais curta possível. Veja o que falta à direita.
Before — signintech/gopdf:
package main
import (
"log"
"github.com/signintech/gopdf"
)
func main() {
pdf := gopdf.GoPdf{}
pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
pdf.AddPage()
if err := pdf.AddTTFFont("helvetica", "fonts/Helvetica.ttf"); err != nil {
log.Fatal(err)
}
if err := pdf.SetFont("helvetica", "", 24); err != nil {
log.Fatal(err)
}
pdf.SetX(40)
pdf.SetY(80)
if err := pdf.Cell(nil, "Olá, mundo!"); err != nil {
log.Fatal(err)
}
if err := pdf.WritePdf("hello.pdf"); err != nil {
log.Fatal(err)
}
}
After — gpdf:
package main
import (
"log"
"os"
"github.com/gpdf-dev/gpdf"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/template"
)
func main() {
doc := gpdf.NewDocument(
gpdf.WithPageSize(document.A4),
gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
)
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("Olá, mundo!", template.FontSize(24), template.Bold())
})
})
data, err := doc.Generate()
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("hello.pdf", data, 0o644); err != nil {
log.Fatal(err)
}
}
Duas coisas sumiram. O arquivo TTF não é mais exigido em runtime — Helvetica é parte das 14 fontes padrão e gpdf as embute. O SetX(40); SetY(80) foi embora — a linha se acomoda dentro das margens automaticamente. O que foi adicionado: uma linha com uma coluna ocupando todas as 12 unidades. Esse andaime parece pesado para "Olá, mundo!", mas é o mesmo que sustenta um relatório de 100 páginas — esse é o ponto.
Before / After 2: uma linha de cabeçalho de 4 colunas
Aqui é onde a matemática de coordenadas mais aparece. Você quer uma faixa de cabeçalho atravessando a página com quatro células iguais: largura da página menos margens, dividida por 4. No gopdf, você faz essa divisão. No gpdf, você gasta 12 unidades em quatro maneiras.
Before — signintech/gopdf:
const (
pageWidth = 595.28 // A4 (pt)
leftMargin = 40.0
rightMargin = 40.0
rowY = 100.0
rowH = 24.0
)
contentWidth := pageWidth - leftMargin - rightMargin // 515.28
colW := contentWidth / 4 // 128.82
pdf.SetFont("helvetica-bold", "", 11)
pdf.SetFillColor(26, 35, 126)
pdf.SetTextColor(255, 255, 255)
headers := []string{"Descrição", "Qtd", "Unitário", "Valor"}
for i, h := range headers {
x := leftMargin + colW*float64(i)
pdf.RectFromUpperLeftWithStyle(x, rowY, colW, rowH, "F")
pdf.SetXY(x+6, rowY+7)
if err := pdf.Cell(nil, h); err != nil {
log.Fatal(err)
}
}
pdf.SetTextColor(0, 0, 0)
Quatro constantes. Uma subtração de larguras. Uma divisão. Um loop com colW*float64(i) — e aquele cast para float só está ali porque o * de Go não promove int para float64. Nada disso existe na versão gpdf.
After — gpdf:
page.AutoRow(func(r *template.RowBuilder) {
headers := []string{"Descrição", "Qtd", "Unitário", "Valor"}
for _, h := range headers {
r.Col(3, func(c *template.ColBuilder) {
c.Box(
template.BgColor(pdf.RGBHex(0x1A237E)),
template.Padding(document.Mm(2), document.Mm(3)),
)
c.Text(h,
template.Bold(), template.FontSize(11),
template.TextColor(pdf.White),
)
})
}
})
r.Col(3, ...) quatro vezes soma 12. A grade cuida das larguras. Trocar A4 por Letter, ou diminuir margens, e o cabeçalho continua alinhado porque nada aqui depende de pageWidth. Para que a coluna 1 seja o dobro da largura das outras três, mude-a para r.Col(6, ...) e uma das outras para r.Col(2, ...). Sem aritmética.
Before / After 3: uma tabela de NF que atravessa páginas
A grande. No gopdf, desenhar uma tabela que flui por várias páginas é quase totalmente bookkeeping: você rastreia a y atual, desenha cada linha, verifica se a próxima cabe e, se não, chama AddPage e reimprime o cabeçalho. A máquina de estados está no seu código.
Before — signintech/gopdf:
func drawInvoiceTable(pdf *gopdf.GoPdf, items [][4]string) error {
const (
pageH = 841.89 // altura A4
bottomLimit = pageH - 40
rowH = 22.0
leftX = 40.0
)
cols := []float64{260, 80, 80, 95}
drawHeader := func(y float64) float64 {
pdf.SetFont("helvetica-bold", "", 11)
pdf.SetFillColor(26, 35, 126)
pdf.SetTextColor(255, 255, 255)
x := leftX
for i, h := range []string{"Descrição", "Qtd", "Unitário", "Valor"} {
pdf.RectFromUpperLeftWithStyle(x, y, cols[i], rowH, "F")
pdf.SetXY(x+6, y+7)
if err := pdf.Cell(nil, h); err != nil {
log.Println(err)
}
x += cols[i]
}
pdf.SetTextColor(0, 0, 0)
pdf.SetFont("helvetica", "", 11)
return y + rowH
}
y := drawHeader(100)
for _, row := range items {
if y+rowH > bottomLimit {
pdf.AddPage()
y = drawHeader(60)
}
x := leftX
for i, cell := range row {
pdf.RectFromUpperLeftWithStyle(x, y, cols[i], rowH, "D")
pdf.SetXY(x+6, y+7)
if err := pdf.Cell(nil, cell); err != nil {
return err
}
x += cols[i]
}
y += rowH
}
return nil
}
A função de tabela tem 30 linhas, das quais só 5 são sobre os dados. O resto é layout: alturas fixas, limite inferior fixo, uma closure para redesenhar o cabeçalho após quebras, dois for, dois avanços de cursor por célula. Essa é a mediana das tabelas gopdf.
After — gpdf:
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Table(
[]string{"Descrição", "Qtd", "Unitário", "Valor"},
items, // [][]string
template.ColumnWidths(55, 15, 15, 15),
template.TableHeaderStyle(
template.Bold(),
template.TextColor(pdf.White),
template.BgColor(pdf.RGBHex(0x1A237E)),
),
template.TableStripe(pdf.RGBHex(0xF5F5F5)),
)
})
})
É só isso. Quebras de página automáticas. Cabeçalho repetido em toda página onde o corpo continua. Linhas zebradas por uma opção. Larguras de coluna são porcentagens do container, então a mesma tabela dentro de r.Col(6, ...) renderiza em metade da largura com as mesmas proporções, sem reescrita. A função de bookkeeping de 25 linhas do gopdf desaparece.
Um número concreto. A renderização da NF de 100 linhas faz benchmark em 108 µs no gpdf e cerca de 2.4 ms no signintech/gopdf — o número do gopdf depende do padrão célula a célula que você escreveu. O fator não é a manchete; o sumiço da função é.
Before / After 4: imagem ao lado de um parágrafo
Padrão comum: logo à esquerda, bloco de endereço à direita.
Before — signintech/gopdf:
const (
leftX = 40.0
rightX = 380.0
blockY = 50.0
)
if err := pdf.Image("logo.png", leftX, blockY, &gopdf.Rect{W: 100, H: 60}); err != nil {
log.Fatal(err)
}
pdf.SetFont("helvetica-bold", "", 14)
pdf.SetXY(rightX, blockY)
if err := pdf.Cell(nil, "ACME Ltda."); err != nil {
log.Fatal(err)
}
pdf.SetFont("helvetica", "", 10)
pdf.SetXY(rightX, blockY+20)
pdf.Cell(nil, "Av. Paulista 1000")
pdf.SetXY(rightX, blockY+34)
pdf.Cell(nil, "São Paulo, SP — 01310-100")
pdf.SetXY(rightX, blockY+48)
pdf.Cell(nil, "[email protected]")
São seis coordenadas y explícitas, e a coluna direita começa em rightX = 380 porque alguém decidiu que o logo tem 100 e o bloco direito precisa de 240 pixels de gap. Mova o logo para a direita e todos os números mudam.
After — gpdf:
//go:embed logo.png
var logoData []byte
page.AutoRow(func(r *template.RowBuilder) {
r.Col(4, func(c *template.ColBuilder) {
c.Image(logoData, template.FitWidth(document.Mm(35)))
})
r.Col(8, func(c *template.ColBuilder) {
c.Text("ACME Ltda.", template.Bold(), template.FontSize(14))
c.Text("Av. Paulista 1000")
c.Text("São Paulo, SP — 01310-100")
c.Text("[email protected]")
})
})
Duas colunas, 4 + 8 = 12. A imagem se ajusta a uma largura fixa e gpdf calcula a altura pela razão de aspecto. Cada c.Text flui abaixo do anterior — sem Br, sem aritmética y. Inverta a ordem das colunas se quiser o logo à direita.
Before / After 5: numeração de página no rodapé
No gopdf, você mantém a contagem manualmente, porque o render é em uma passagem e o total não é conhecido quando você desenha o primeiro rodapé. A maioria das bases faz um workaround de duas passagens: renderizar uma vez para contar, renderizar de novo com o total já assado.
Before — signintech/gopdf:
totalPages := 0
pdf.AddFooter(func() {
totalPages++
})
buildContent(&pdf)
finalTotal := totalPages
pdf2 := gopdf.GoPdf{}
pdf2.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
pageNum := 0
pdf2.AddFooter(func() {
pageNum++
pdf2.SetFont("helvetica", "", 8)
pdf2.SetXY(40, 800)
pdf2.Cell(nil, fmt.Sprintf("Página %d de %d", pageNum, finalTotal))
})
buildContent(&pdf2)
pdf2.WritePdf("report.pdf")
Se você manteve código gopdf, escreveu isto. Não está em FAQ algum, mas é a única forma de obter um rodapé honesto "Página X de Y" sem fazer parsing da saída.
After — gpdf:
doc.Footer(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(6, func(c *template.ColBuilder) {
c.Text("ACME Ltda.",
template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
})
r.Col(6, func(c *template.ColBuilder) {
c.Stack(template.AlignRight(), func(c *template.ColBuilder) {
c.Text("Página ", template.Inline())
c.PageNumber(template.Inline())
c.Text(" de ", template.Inline())
c.TotalPages(template.Inline())
}, template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
})
})
})
PageNumber e TotalPages são placeholders. O motor de layout pagina primeiro, resolve os totais e depois escreve. Uma passagem, sem contagem manual, sem render duplo.
Texto CJK sem o subset manual
signintech/gopdf suporta CJK, mas o caminho é bookkeeping de conjunto de caracteres feito à mão. Você adiciona o TTF, define o mapa de caracteres e, se o texto contém um glifo fora do subset que registrou, sai tofu. O subseter TrueType do gpdf percorre o cmap (formatos 4, 6, 12) e embute exatamente os glifos que você usou — sem lista manual de subset.
//go:embed NotoSansJP-Regular.ttf
var notoJP []byte
doc := gpdf.NewDocument(
gpdf.WithPageSize(document.A4),
gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
gpdf.WithFont("NotoSansJP", notoJP),
gpdf.WithDefaultFont("NotoSansJP", 14),
)
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("こんにちは、世界。")
c.Text("吾輩は猫である。名前はまだ無い。")
})
})
Uma fatura japonesa de 200 caracteres produz um subset de fonte de ~30 KB em vez de um embed completo de 4 MB.
Benchmarks
Mesmo hardware, mesmas cargas, Apple M1 com Go 1.25.
| Benchmark | gpdf | signintech/gopdf | gofpdf | Maroto v2 |
|---|---|---|---|---|
| Página única | 13 µs | 423 µs | 132 µs | 237 µs |
| Tabela 4×10 | 108 µs | 835 µs | 241 µs | 8.6 ms |
| Relatório 100 páginas | 683 µs | 8.6 ms | 11.7 ms | 19.8 ms |
| NF CJK complexa | 133 µs | 997 µs | 254 µs | 10.4 ms |
Números de gpdf/_benchmark/benchmark_test.go.
A 108 µs por página com tabela em um único core dá ~9.000 NF por segundo. Para a maioria das cargas, geração de PDF pode ficar no caminho da request.
O que gopdf tem e gpdf não
Seção honesta. Se seu uso de gopdf depende disto, a migração não cobre tudo só com este artigo.
ImportPage. Importar uma página de um PDF existente e estampar conteúdo por cima. O overlay do gpdf (gpdf.Overlay) cobre o caso comum, mas não expõe a primitivaUseImportedTemplateigual.- Polígonos e ovais como primitivas. O conjunto primitivo do gpdf é retângulos, linhas, imagens, texto e tabelas; desenho de path arbitrário não é de primeira classe. Para visualização, use uma lib de charts para gerar PNG/SVG e embuta.
- Posicionamento direto de cursor. Para colocação pixel-perfect (um carimbo exatamente em
(420, 240)),page.Absolute(x, y, fn)existe, mas é a saída de emergência. PlaceHolderText/FillInPlaceHoldText. Mecanismo geral de "preencher este slot depois" ainda não existe; placeholdersPageNumber/TotalPagescobrem o caso de numeração.
Para NFs, extratos, relatórios, certificados, contratos, recibos, etiquetas de envio, romaneios e documentos CJK — o que a maior parte das contas gopdf realmente gera — a troca é completa.
FAQ
gpdf é um fork de signintech/gopdf? Não. gpdf é uma reimplementação limpa em Go puro. Sem código compartilhado nem linhagem.
Os dois são Go puro e sem CGO. Qual o ganho real de trocar? O motor de layout. As seções de migração acima são 80% sobre remover matemática de coordenadas, e essa é a diferença do dia a dia. Benchmarks são vitória secundária. Licença MIT é idêntica à MIT do gopdf, então licença não é fator.
Posso migrar incrementalmente?
Posso. As duas libs não conflitam. Cada uma produz []byte independente. Renderize uma seção com gpdf, outra com gopdf e gpdf.Merge(a, b) cola. Na prática, a maioria acha mais fácil migrar um documento por vez.
Meu código existente usa pdf.Image(path, ...) para carregar logos do disco. Preciso embutir?
Não precisa. c.Image(imageBytes, ...) aceita bytes — use os.ReadFile para carregar em runtime. Mas //go:embed é o melhor default.
E gopdf.PageSizeA4 e outras constantes de tamanho?document.A4, document.Letter, document.Legal cobrem o mesmo conjunto. Tamanho custom: document.PageSize(document.Mm(210), document.Mm(297)).
Meu gerador usa pdf.Rotate para carimbos diagonais. Tem equivalente?page.Absolute(x, y, fn) aceita opção de rotação; o típico "marca-d'água diagonal" é uma chamada page.Absolute.
Tem ferramenta que reescreva meu código automaticamente?
Ainda não. Mapeamento das partes simples (SetXY/Cell → r.Col/c.Text) é mecânico, mas a reescrita de tabelas é estrutural — você apaga o bookkeeping em vez de traduzir. Migração manual de um gerador típico leva algumas horas por tipo de documento.
Experimente gpdf
gpdf é uma biblioteca Go para gerar PDFs. Licença MIT, zero dependências externas, suporte CJK nativo, layout em grade de 12 colunas.
go get github.com/gpdf-dev/gpdf
⭐ Star no GitHub · Leia a documentação