Todas las publicaciones

De signintech/gopdf a gpdf: menos cálculos de coordenadas

signintech/gopdf funciona, pero cada celda, línea y encabezado es un cálculo (x, y). Esta guía mapea la API de gopdf a gpdf — el mismo Go, sin coordenadas.

TL;DR

gpdf es una biblioteca de PDF en Go puro con motor de layout de 12 columnas. signintech/gopdf es un binding de bajo nivel sobre el sistema de coordenadas PDF. Si llevas tiempo usando gopdf y la base de código ya es mayormente SetXY, Cell y aritmética de anchos, esta guía muestra en qué se colapsan esas llamadas cuando hay un motor de layout debajo.

La semana pasada estuve con alguien refactorizando un generador de facturas sobre signintech/gopdf. Cinco años de acumulación. La función que dibujaba la tabla de líneas de detalle tenía 280 líneas. Unas 40 hacían trabajo real: formatear el monto, formatear la fecha, repetir por fila. Las otras 240 calculaban posiciones x, rastreaban y, llamaban SetXY, llamaban Cell, llamaban Br, dibujaban líneas de borde con Line(x1, y1, x2, y2), decidían si la fila cabía en la página o requería un AddPage manual y reimprimir el encabezado.

Esa es la experiencia gopdf en producción. No es una mala biblioteca. Es un binding delgado, rápido y sin CGO sobre el modelo de imaging de PDF — exactamente lo que el nombre promete. Hay un cursor, hay coordenadas, y el ingeniero hace de motor de layout.

Este artículo mapea la API de gopdf a gpdf, función por función. La tesis está en el título: la mayoría de las líneas desaparecen porque hacían matemática de layout que el runtime puede hacer por ti.

Qué es signintech/gopdf — y qué no

Vale la pena dejarlo claro antes de cualquier framing de "migra ya", porque gopdf tiene virtudes reales.

Está mantenido activamente. Es Go puro (sin CGO), así que la cross-compilación e imágenes Alpine simplemente funcionan. Soporta fuentes TrueType incluyendo CJK. La salida es rápida — gopdf está en la misma liga que gpdf en las primitivas de imaging porque ambos escriben el wire format PDF directamente sin un motor pesado delante. La API mapea limpiamente al modelo PDF subyacente: hay un punto actual, lo mueves, dibujas en él. Si ya piensas en coordenadas PDF, gopdf es cómodo.

Lo que no es, es un sistema de layout. No hay noción de fila, columna, contenedor flex o grilla. No hay salto de página automático: cuando tu contenido pasa el margen inferior, obtienes contenido pasando el margen inferior (o fuera de la página) hasta que llamas AddPage tú mismo. Las tablas no existen como primitiva — son un patrón que reimplementas en cada proyecto, con llamadas Cell celda a celda, líneas de borde manuales y tu propia lógica de salto de página.

Para un certificado de una página o un formulario de plantilla fija muy controlado, el modelo de cursor está bien. Para facturas, reportes, estados de cuenta, cualquier cosa con contenido de longitud variable — la matemática de coordenadas crece con la superficie del documento. Esa es la carga de trabajo para la que gpdf está construido.

El cambio de modelo mental

Esta es la parte que cambia realmente cómo se lee el código. gpdf tiene dos ideas que gopdf no:

Árbol declarativo. No le dices al renderer dónde poner las cosas. Describes un árbol de páginas → filas → columnas → contenido, y el motor de layout resuelve posiciones en una sola pasada. No hay cursor que avanzar. Dos r.Col(...) consecutivos no necesitan saber el uno del otro.

Grilla de 12 columnas. Cada fila es implícitamente de 12 unidades de ancho. Las gastas entre columnas: r.Col(8, ...) toma dos tercios, r.Col(4, ...) toma un tercio. La grilla es la misma idea que Bootstrap y Tailwind usan para HTML, aplicada a PDF. Dejas de calcular pageWidth - leftMargin - rightMargin dividido entre 4. Escribes r.Col(3, ...) cuatro veces.

Estas dos ideas eliminan la mayor parte de la matemática. Los pares before/after que siguen colapsan todos de la misma manera: un bucle imperativo que avanza un cursor se convierte en un pequeño árbol declarativo.

La tabla de mapeo de API

Hoja de referencia primero. Las secciones después recorren cinco pares concretos.

Lo que quieres hacersignintech/gopdfgpdf
Construirpdf := gopdf.GoPdf{}; pdf.Start(gopdf.Config{...})doc := gpdf.NewDocument(gpdf.WithPageSize(document.A4), ...)
Tamaño de páginaConfig{PageSize: gopdf.PageSizeA4}gpdf.WithPageSize(document.A4)
Añadir páginapdf.AddPage()page := doc.AddPage()
Mover cursorpdf.SetX(40); pdf.SetY(80) (en todas partes)(sin cursor)
Una línea de textopdf.SetXY(x, y); pdf.Cell(nil, "hi")c.Text("hi") (dentro de una columna)
Texto con ajustepdf.MultiCell(&gopdf.Rect{W: 200, H: 100}, body)c.Text(body) (ajusta solo)
Salto de líneapdf.Br(20)(implícito entre filas; c.Spacer(document.Mm(4)) si hace falta)
Registro de fuentepdf.AddTTFFont("noto", "fonts/Noto.ttf")gpdf.WithFont("Noto", ttfBytes) (en construcción)
Fuente activapdf.SetFont("noto", "", 14)por texto: template.FontFamily("Noto"), template.FontSize(14)
Colorpdf.SetTextColor(26, 35, 126)template.TextColor(pdf.RGBHex(0x1A237E))
Línea horizontalpdf.Line(40, 100, 555, 100)c.Line(template.LineColor(pdf.Gray(0.7)))
Rectángulopdf.RectFromUpperLeftWithStyle(x, y, w, h, "FD")c.Box(template.BgColor(...), template.Border(...))
Imagenpdf.Image("logo.png", x, y, &gopdf.Rect{W: 100, H: 50})c.Image(imgBytes, template.FitWidth(document.Mm(35)))
Tabla manualdocenas de Cell + Line + SetXYc.Table(headers, rows, template.ColumnWidths(...))
Encabezado / piepdf.AddHeader(fn) / pdf.AddFooter(fn)doc.Header(fn) / doc.Footer(fn)
Número de páginaformateas "Page %d of %d" desde un contador propioc.PageNumber() / c.TotalPages() (placeholders)
CifrarConfig{Protection: PDFProtectionConfig{...}}gpdf.WithEncryption(gpdf.AES256, "user", "owner", perms)
Salidapdf.WritePdf("out.pdf")data, _ := doc.Generate(); os.WriteFile("out.pdf", data, 0o644)
Salida a writerpdf.Write(w) / pdf.ToBuffer()doc.Render(w)

Dos cambios estructurales. Primero, el cursor desaparece. Las filas marcadas (en todas partes) en la tabla no son exageración — en una base de código gopdf real, las llamadas a SetXY superan en número a las de Cell. Todas colapsan a nada en gpdf. Segundo, los píxeles se vuelven porcentajes. Rect{W: 200, H: 100} se vuelve "esta columna toma 4 de 12 unidades de cualquier contenedor en el que esté". Pon la misma columna dentro de una fila a media anchura y escala sin cambios.

Before / After 1: hello world

La diferencia más corta posible. Mira lo que falta a la derecha.

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, "¡Hola, 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("¡Hola, 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)
    }
}

Dos cosas que faltan. El archivo TTF ya no es requerido en runtime — Helvetica es parte de las 14 fuentes estándar y gpdf las trae incluidas. El SetX(40); SetY(80) desapareció — la fila se sienta dentro de los márgenes de la página automáticamente. Lo añadido: una fila con una columna que abarca las 12 unidades. Ese andamiaje se ve pesado para "¡Hola, mundo!", pero es el mismo andamiaje que sostiene un reporte de 100 páginas, que es justamente el punto.

Before / After 2: una fila de encabezado de 4 columnas

Aquí es donde la matemática de coordenadas se ve más. Quieres una franja de encabezado a través de la página con cuatro celdas iguales: ancho de página menos márgenes, dividido entre cuatro. En gopdf haces esa división. En gpdf gastas 12 unidades en cuatro maneras.

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{"Concepto", "Cant.", "Unitario", "Importe"}
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)

Hay cuatro constantes. Hay una resta de anchos. Hay una división. Hay un bucle con colW*float64(i) — y ese cast a float estaba ahí solo porque el * de Go no promueve int a float64. Ninguno de estos existe en la versión gpdf.

After — gpdf:

page.AutoRow(func(r *template.RowBuilder) {
    headers := []string{"Concepto", "Cant.", "Unitario", "Importe"}
    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, ...) cuatro veces suma 12. La grilla maneja los anchos. Si cambias A4 por Letter, o reduces los márgenes, el encabezado sigue alineado correctamente porque nada en este código depende de pageWidth. Si decides que la columna 1 debe ser el doble de ancha que las otras tres, cámbiala a r.Col(6, ...) y a una de las otras a r.Col(2, ...). Sin aritmética.

Before / After 3: una tabla de factura que cruza páginas

La grande. En gopdf, dibujar una tabla que fluye sobre múltiples páginas es mayormente contabilidad: rastreas la y actual, dibujas cada fila, verificas si la siguiente cabe, y si no, llamas AddPage y reimprimes el encabezado. La máquina de estados está en tu código.

Before — signintech/gopdf:

func drawInvoiceTable(pdf *gopdf.GoPdf, items [][4]string) error {
    const (
        pageH       = 841.89 // alto 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{"Concepto", "Cant.", "Unitario", "Importe"} {
            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
}

La función de tabla tiene 30 líneas y solo 5 son sobre los datos. El resto es layout: alturas hardcodeadas, límite inferior hardcodeado, una closure para redibujar el encabezado tras saltos, dos for, dos avances de cursor por celda. Esta es la mediana de las tablas gopdf.

After — gpdf:

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(12, func(c *template.ColBuilder) {
        c.Table(
            []string{"Concepto", "Cant.", "Unitario", "Importe"},
            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)),
        )
    })
})

Eso es todo. Saltos de página automáticos. El encabezado se repite en cada página donde el cuerpo continúa. Filas en zebra con una sola opción. Anchos de columna en porcentajes del contenedor, así que esta misma tabla dentro de r.Col(6, ...) se renderiza a media anchura con las mismas proporciones, sin reescritura. La función gopdf de 25 líneas desaparece.

Un número concreto. La factura de 100 filas mide en gpdf alrededor de 108 µs y en signintech/gopdf alrededor de 2.4 ms — y el número de gopdf depende del patrón celda a celda que escribiste. El factor no es el titular; la desaparición de la función sí.

Before / After 4: una imagen junto a un párrafo

Patrón común: logo a la izquierda, bloque de dirección a la derecha.

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 S.A."); err != nil {
    log.Fatal(err)
}

pdf.SetFont("helvetica", "", 10)
pdf.SetXY(rightX, blockY+20)
pdf.Cell(nil, "Calle Gran Vía 28")
pdf.SetXY(rightX, blockY+34)
pdf.Cell(nil, "28013 Madrid")
pdf.SetXY(rightX, blockY+48)
pdf.Cell(nil, "[email protected]")

Hay seis coordenadas y explícitas, y la columna derecha empieza en rightX = 380 porque alguien decidió que el logo medía 100 y el bloque derecho necesitaba un hueco de 240. Mueve el logo a la derecha y todos los números cambian.

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 S.A.", template.Bold(), template.FontSize(14))
        c.Text("Calle Gran Vía 28")
        c.Text("28013 Madrid")
        c.Text("[email protected]")
    })
})

Dos columnas, 4 + 8 = 12. La imagen se ajusta a un ancho fijo y deja que gpdf calcule la altura por la relación de aspecto. Cada c.Text fluye debajo del anterior — sin Br, sin aritmética y. Cambia el orden de columnas si quieres el logo a la derecha.

Before / After 5: números de página en el pie

En gopdf mantienes la cuenta tú mismo, porque el render es de una sola pasada y el total no se conoce cuando dibujas el primer pie. La mayoría de bases de código hacen un workaround de dos pasadas: renderizar una vez para contar, renderizar de nuevo con el total ya conocido.

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")

Si has mantenido código gopdf, has escrito esto. No está en ningún FAQ, pero es la única forma de obtener un pie honesto "Página X de Y" sin parsear la salida.

After — gpdf:

doc.Footer(func(p *template.PageBuilder) {
    p.AutoRow(func(r *template.RowBuilder) {
        r.Col(6, func(c *template.ColBuilder) {
            c.Text("ACME S.A.",
                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 y TotalPages son placeholders. El motor de layout pagina primero, resuelve los totales y luego los escribe. Una pasada, sin contador manual, sin doble render.

Texto CJK sin la danza del subset manual

signintech/gopdf soporta CJK bien, pero el camino es bookkeeping de conjunto de caracteres a mano. Añades el TTF, fijas el mapa de caracteres, y si tu texto contiene un glifo fuera del subset que registraste, sale tofu. El subseter TrueType de gpdf recorre el cmap (formatos 4, 6, 12) e incrusta exactamente los glifos que usaste — sin 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("吾輩は猫である。名前はまだ無い。")
    })
})

Una factura japonesa de 200 caracteres produce un subset de fuente de ~30 KB en lugar de un embed completo de 4 MB.

Benchmarks

Mismo hardware, mismas cargas, Apple M1 con Go 1.25.

Benchmarkgpdfsignintech/gopdfgofpdfMaroto v2
Página única13 µs423 µs132 µs237 µs
Tabla 4×10108 µs835 µs241 µs8.6 ms
Reporte 100 páginas683 µs8.6 ms11.7 ms19.8 ms
Factura CJK compleja133 µs997 µs254 µs10.4 ms

Números de gpdf/_benchmark/benchmark_test.go.

A 108 µs por página con tabla en un solo core son ~9.000 facturas/segundo. Para la mayoría de cargas, la generación PDF puede vivir en la ruta de la request.

Lo que gopdf hace y gpdf no

Sección honesta. Si tu uso de gopdf depende de esto, la migración no te llevará todo el camino con este artículo.

  • ImportPage. Importar una página de un PDF existente y estampar contenido encima. El overlay de gpdf (gpdf.Overlay) cubre el caso común, pero no expone la primitiva UseImportedTemplate exacta.
  • Polígonos y óvalos como primitivas. El conjunto primitivo de gpdf es rectángulos, líneas, imágenes, texto y tablas; el dibujo de paths arbitrarios no es de primera clase. Para visualización, renderiza con una librería de charts a PNG/SVG y embebe.
  • Posicionamiento directo de cursor. Si necesitas colocación pixel-perfect (un sello exactamente en (420, 240)), page.Absolute(x, y, fn) existe, pero es la salida de emergencia.
  • PlaceHolderText / FillInPlaceHoldText. Un mecanismo general "rellena este hueco después" no existe aún en gpdf; los placeholders PageNumber / TotalPages cubren el caso de numeración.

Para facturas, estados de cuenta, reportes, certificados, contratos, recibos, etiquetas de envío, albaranes y documentos CJK — lo que la mayoría de presupuestos gopdf realmente generan — el cambio es completo.

FAQ

¿Es gpdf un fork de signintech/gopdf? No. gpdf es una reimplementación limpia en Go puro. Sin código compartido ni linaje compartido.

Ambas son Go puro y sin CGO. ¿Cuál es el valor real del cambio? El motor de layout. Las secciones de migración arriba son 80% sobre eliminar matemática de coordenadas, y esa es la diferencia día a día. Los benchmarks son una victoria secundaria. La licencia MIT es idéntica a la MIT de gopdf, así que la licencia no es un factor.

¿Puedo migrar incrementalmente? Sí — las dos librerías no entran en conflicto. Producen []byte independientes. Renderiza una sección con gpdf, otra con gopdf, y gpdf.Merge(a, b) las pega. En la práctica, la mayoría encuentra más fácil migrar un documento entero a la vez.

Mi código existente usa pdf.Image(path, ...) para cargar logos. ¿Tengo que embeber? No tienes que. c.Image(imageBytes, ...) toma bytes — usa os.ReadFile si quieres carga en runtime. Pero //go:embed es el mejor por defecto.

¿Y gopdf.PageSizeA4 y otras constantes de tamaño?document.A4, document.Letter, document.Legal cubren el mismo conjunto. Para tamaños custom: document.PageSize(document.Mm(210), document.Mm(297)).

Mi generador usa pdf.Rotate para sellos en diagonal. ¿Hay equivalente?page.Absolute(x, y, fn) acepta opción de rotación; el patrón típico de "marca de agua diagonal" es una llamada page.Absolute.

¿Hay una herramienta que reescriba mi código automáticamente? Aún no. El mapeo de partes simples (SetXY/Cellr.Col/c.Text) es mecánico, pero la reescritura de tablas es estructural — borras el bookkeeping en lugar de traducirlo. La migración manual de un generador típico lleva unas horas por tipo de documento.

Probar gpdf

gpdf es una biblioteca Go para generar PDFs. Licencia MIT, cero dependencias externas, soporte CJK nativo, layout en grilla de 12 columnas.

go get github.com/gpdf-dev/gpdf

⭐ Star en GitHub · Lee la documentación

Lecturas siguientes