Todas as publicações

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 fazersignintech/gopdfgpdf
Construirpdf := gopdf.GoPdf{}; pdf.Start(gopdf.Config{...})doc := gpdf.NewDocument(gpdf.WithPageSize(document.A4), ...)
Tamanho de páginaConfig{PageSize: gopdf.PageSizeA4}gpdf.WithPageSize(document.A4)
Adicionar páginapdf.AddPage()page := doc.AddPage()
Mover cursorpdf.SetX(40); pdf.SetY(80) (em todo lugar)(sem cursor)
Texto de uma linhapdf.SetXY(x, y); pdf.Cell(nil, "hi")c.Text("hi") (dentro de uma coluna)
Texto com quebrapdf.MultiCell(&gopdf.Rect{W: 200, H: 100}, body)c.Text(body) (quebra automática)
Quebra de linhapdf.Br(20)(implícito entre linhas; c.Spacer(document.Mm(4)) se preciso)
Registro de fontepdf.AddTTFFont("noto", "fonts/Noto.ttf")gpdf.WithFont("Noto", ttfBytes) (na construção)
Fonte ativapdf.SetFont("noto", "", 14)por texto: template.FontFamily("Noto"), template.FontSize(14)
Corpdf.SetTextColor(26, 35, 126)template.TextColor(pdf.RGBHex(0x1A237E))
Linha horizontalpdf.Line(40, 100, 555, 100)c.Line(template.LineColor(pdf.Gray(0.7)))
Retângulopdf.RectFromUpperLeftWithStyle(x, y, w, h, "FD")c.Box(template.BgColor(...), template.Border(...))
Imagempdf.Image("logo.png", x, y, &gopdf.Rect{W: 100, H: 50})c.Image(imgBytes, template.FitWidth(document.Mm(35)))
Tabela manualdezenas de Cell + Line + SetXYc.Table(headers, rows, template.ColumnWidths(...))
Cabeçalho / rodapépdf.AddHeader(fn) / pdf.AddFooter(fn)doc.Header(fn) / doc.Footer(fn)
Número de páginaformatar "Page %d of %d" de um contador próprioc.PageNumber() / c.TotalPages() (placeholders)
CriptografarConfig{Protection: PDFProtectionConfig{...}}gpdf.WithEncryption(gpdf.AES256, "user", "owner", perms)
Saídapdf.WritePdf("out.pdf")data, _ := doc.Generate(); os.WriteFile("out.pdf", data, 0o644)
Saída para writerpdf.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.

Benchmarkgpdfsignintech/gopdfgofpdfMaroto v2
Página única13 µs423 µs132 µs237 µs
Tabela 4×10108 µs835 µs241 µs8.6 ms
Relatório 100 páginas683 µs8.6 ms11.7 ms19.8 ms
NF CJK complexa133 µs997 µs254 µs10.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 primitiva UseImportedTemplate igual.
  • 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; placeholders PageNumber / TotalPages cobrem 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/Cellr.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

Próximas leituras