Todas las publicaciones

Genera una factura PDF en Go en menos de 50 líneas

Código completo y ejecutable para generar una factura PDF en Go — 50 líneas con gpdf, cero dependencias, sin Chromium, sin CGO. Incluye cabecera, tabla y total.

TL;DR

Una factura PDF que funciona en Go, de principio a fin, en 50 líneas. Un main.go, un go get, sin Chromium, sin CGO, sin lenguaje de plantillas, sin HTML. Tabla, filas con cebrado, totales alineados a la derecha. Corre. El código está abajo, y el resto del post explica qué hace cada bloque y dónde deja de escalar el patrón.

Si solo quieres leer el código primero:

go get github.com/gpdf-dev/gpdf

Luego pega el main.go de la siguiente sección.

Por qué "menos de 50 líneas" es el umbral que nos importa

La razón honesta por la que existe este post: la mayoría de la gente que busca "generar factura pdf en go" encuentra artículos que o bien (a) recomiendan lanzar Chromium headless, o bien (b) muestran 400 líneas de operadores PDF de bajo nivel para renderizar una sola tabla. Ambas respuestas son técnicamente correctas. Ninguna es la forma que la tarea tiene.

Una factura razonable tiene:

  • Cabecera con tu empresa y la del cliente
  • Número de factura y fecha de vencimiento
  • Tabla de conceptos
  • Un total

Cuatro cosas. Deberían ser cuatro bloques de código. Si no cabe en una pantalla, la librería está mal elegida.

50 líneas es aproximadamente el límite donde el código todavía cabe en una pantalla de un editor normal. También es el umbral donde un revisor leerá todo de principio a fin en lugar de saltar a los tests. Llegar ahí significa que puedes pegar el resultado en un mensaje de Slack y alguien puede aprender la librería solo desde ese mensaje.

El código de abajo está gofmt-eado, con todos los imports expandidos y todas las rutas de error respetadas. Sin trucos, sin paquete helper escondido. Lo que ves es lo que compila.

Las 50 líneas

package main

import (
    "log"
    "os"

    "github.com/gpdf-dev/gpdf"
    "github.com/gpdf-dev/gpdf/document"
    "github.com/gpdf-dev/gpdf/pdf"
    "github.com/gpdf-dev/gpdf/template"
)

func main() {
    doc := gpdf.NewDocument(template.WithPageSize(document.A4))
    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(6, func(c *template.ColBuilder) {
            c.Text("ACME S.L.", template.FontSize(22), template.Bold())
            c.Text("Calle Gran Vía 1, Madrid 28013")
        })
        r.Col(6, func(c *template.ColBuilder) {
            c.Text("FACTURA #INV-2026-001", template.Bold(), template.AlignRight())
            c.Text("Vencimiento: 2026-03-31", template.AlignRight())
        })
    })
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Spacer(document.Mm(6))
            c.Table(
                []string{"Concepto", "Cantidad", "Precio unit.", "Importe"},
                [][]string{
                    {"Desarrollo frontend", "40 h", "150,00 €", "6.000,00 €"},
                    {"Desarrollo backend", "60 h", "150,00 €", "9.000,00 €"},
                    {"Diseño UI/UX", "20 h", "120,00 €", "2.400,00 €"},
                },
                template.ColumnWidths(40, 15, 20, 25),
                template.TableHeaderStyle(template.Bold(), template.BgColor(pdf.RGBHex(0xF0F0F0))),
                template.TableStripe(pdf.RGBHex(0xFAFAFA)),
            )
            c.Text("Total: 17.400,00 €", template.AlignRight(), template.Bold(), template.FontSize(14))
        })
    })
    b, err := doc.Generate()
    if err != nil {
        log.Fatal(err)
    }
    if err := os.WriteFile("invoice.pdf", b, 0644); err != nil {
        log.Fatal(err)
    }
}

go run . produce invoice.pdf en el directorio actual. En un M1 el programa entero termina en unos milisegundos — la generación del PDF en sí está por debajo de 150 µs, el resto es arranque del proceso.

Qué hace cada bloque

Imports

Cuatro paquetes de gpdf:

  • github.com/gpdf-dev/gpdf — la fachada. Solo usamos gpdf.NewDocument, que es un envoltorio fino sobre template.New.
  • github.com/gpdf-dev/gpdf/document — unidades (Mm, Pt, Cm, In, Em, Pct), tamaños de página (A4, Letter, Legal), márgenes.
  • github.com/gpdf-dev/gpdf/pdf — primitivas de color (RGBHex, Gray, constantes como pdf.White).
  • github.com/gpdf-dev/gpdf/template — el Builder API.

Sin dependencias externas. Tras go get github.com/gpdf-dev/gpdf, el require de go.mod tiene una sola línea.

Construcción del documento

doc := gpdf.NewDocument(template.WithPageSize(document.A4))

gpdf.NewDocument acepta ...template.Option. Tamaño de página, márgenes, fuente por defecto, metadatos, fuentes custom: todo son opciones WithXxx. Margen por defecto 20 mm.

La fila de cabecera

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(6, func(c *template.ColBuilder) { ... })
    r.Col(6, func(c *template.ColBuilder) { ... })
})

gpdf usa una rejilla de 12 columnas, mismo modelo mental que Bootstrap. Una fila tiene 12 unidades de espacio horizontal, r.Col(6, ...) ocupa la mitad, dos Col(6) llenan la fila.

AutoRow significa que la altura de la fila es la del contenido más alto. Dentro de cada columna apilas c.Text(...) de arriba a abajo. Sin posicionamiento explícito — el builder lleva un cursor.

La columna derecha usa template.AlignRight(). Las opciones de texto son componibles: c.Text("FACTURA", template.Bold(), template.AlignRight(), template.FontSize(20)) compone tres modificadores en una sola llamada. El orden no importa.

La tabla de conceptos

c.Table(
    []string{"Concepto", "Cantidad", "Precio unit.", "Importe"},
    [][]string{ /* filas */ },
    template.ColumnWidths(40, 15, 20, 25),
    template.TableHeaderStyle(template.Bold(), template.BgColor(pdf.RGBHex(0xF0F0F0))),
    template.TableStripe(pdf.RGBHex(0xFAFAFA)),
)

ColumnWidths son porcentajes del ancho de la columna contenedora, no puntos absolutos. Los cuatro valores deben sumar 100. Si no suman, no hay error pero la última columna desborda — el único pie forzoso.

TableHeaderStyle acepta las opciones de texto habituales. TableStripe(color) alterna el fondo de las filas. La tabla mide cada celda, elige la altura por la más alta, y si desborda la página, gpdf parte la tabla y redibuja la cabecera en la continuación.

El total

c.Text("Total: 17.400,00 €", template.AlignRight(), template.Bold(), template.FontSize(14))

Otro Text fuera de la tabla, alineado a la derecha, un poco más grande. Si quieres más aire, mete un c.Spacer(document.Mm(3)) antes.

Generar y escribir

b, err := doc.Generate()
if err != nil { log.Fatal(err) }
if err := os.WriteFile("invoice.pdf", b, 0644); err != nil { log.Fatal(err) }

doc.Generate() devuelve ([]byte, error). No toca el sistema de archivos. El slice es un PDF completo — escríbelo a disco, súbelo a S3, devuélvelo como respuesta HTTP con w.Write(b), adjúntalo en un email. Si prefieres streaming, existe doc.Render(w io.Writer).

Hacerla más bonita sin pasarse de 50 líneas

Color corporativo. Elige un hex (por ejemplo un azul marino 0x1A237E) y úsalo en el nombre de la empresa y en la cabecera de la tabla:

brand := pdf.RGBHex(0x1A237E)
c.Text("ACME S.L.", template.FontSize(22), template.Bold(), template.TextColor(brand))
template.TableHeaderStyle(template.Bold(), template.TextColor(pdf.White), template.BgColor(brand)),

Subtotal e IVA. Sobre el total, tres c.Text apilados:

c.Text("Subtotal: 17.400,00 €", template.AlignRight())
c.Text("IVA (21%): 3.654,00 €", template.AlignRight())
c.Text("Total: 21.054,00 €", template.AlignRight(), template.Bold(), template.FontSize(14))

Requisitos de facturación electrónica (España / LatAm). Para Facturae (España) o CFDI (México), los campos obligatorios (NIF del emisor, número de serie, fecha de expedición) son c.Text adicionales. El layout no cambia — la diferencia está en los datos y la firma digital posterior, no en la maquetación.

Una línea sobre el total. Entre el bloque de subtotal y el total, un c.Line():

c.Spacer(document.Mm(2))
c.Line(template.LineThickness(document.Pt(0.5)))
c.Spacer(document.Mm(2))

Ejecutarlo

mkdir invoice-demo
cd invoice-demo
go mod init example.com/invoice-demo
go get github.com/gpdf-dev/gpdf
# pega main.go
go run .
open invoice.pdf    # macOS; xdg-open en Linux, start en Windows

Cuándo este patrón deja de servir

  • Los conceptos se vuelven datos. Cuando vienen de una query o un JSON, la tabla no cambia — solo construyes [][]string a partir de tus datos.
  • Quieres reusar el layout. En cuanto generas facturas en bucle, saca el cuerpo a func renderInvoice(doc *template.Document, inv Invoice).
  • El layout tiene ramas. Con condicionales el Builder API se vuelve verboso — el entrypoint JSON o el entrypoint Go templates encaja mejor.
  • Necesitas CJK. Japonés, chino, coreano se ven como cuadros vacíos con la fuente por defecto. Registra un TTF con template.WithFont. Ver ¿Cómo incrusto una fuente japonesa en gpdf?.

Ninguna de las cuatro es "reescribir desde cero" — son extensiones incrementales.

FAQ

¿Puedo usarlo en facturas comerciales sin atribución? Sí. gpdf tiene licencia MIT. Puedes construir encima lo que quieras, incluidos productos comerciales cerrados. La atribución no es obligatoria (una estrella en GitHub se agradece).

¿Admite escribir a io.Writer sin el slice? Sí — doc.Render(w io.Writer) error. La versión con doc.Generate() es una comodidad para el caso común de querer []byte.

¿Qué tan rápido es en la práctica? Las 50 líneas de arriba generan el PDF en unos 100 µs en un M1. Un hello world de una página ronda los 13 µs. En cargas batch — generación nocturna de facturas — gpdf rara vez es el cuello de botella.

¿Puedo generar una factura PDF en Go sin gpdf? Claro. jung-kurt/gofpdf funciona (archivado pero estable), signintech/gopdf a nivel más bajo, y johnfercher/maroto con otra abstracción de layout. Todos terminan siendo más verbosos que las 50 líneas de arriba para la misma factura.

¿Por qué no hay un helper gpdf.Invoice? Porque "factura" significa cosas distintas en distintos países y cualquier simplificación deja fuera un caso. Preferimos darte un punto de partida de 50 líneas adaptable que un NewInvoice(...) que se rompe al pedir un 適格請求書 japonés o una NFe brasileña.

¿El PDF valida contra PDF/A? Por defecto sale PDF 1.7 estándar. Para PDF/A-2b pasa gpdf.WithPDFA(pdfa.Level2B) al construir el documento. Ver Construyendo PDF/A-2b en Go puro.

Prueba gpdf

gpdf es una librería Go para generar PDFs. MIT, cero dependencias, CJK nativo, 10–30× más rápida que las alternativas en los workloads que medimos.

go get github.com/gpdf-dev/gpdf

⭐ Star en GitHub · Leer los docs

Siguientes lecturas