Todas las publicaciones

gofpdf está archivado. Guía para migrar a gpdf.

jung-kurt/gofpdf fue archivado en 2021 y go-pdf/fpdf en 2025. Guía para migrar a gpdf — Go puro, cero dependencias, soporte CJK nativo.

por gpdf team

TL;DR

gpdf es una biblioteca Go pura, sin dependencias externas, con soporte CJK nativo (nada del baile de AddUTF8Font), que usa una cuadrícula de 12 columnas en lugar de empujar píxeles con SetXY y corre aproximadamente 10× más rápido que gofpdf en las mismas cargas. La migración consiste básicamente en reemplazar llamadas imperativas al cursor por constructores declarativos. Esta guía recorre el mapeo con cinco pares antes/después.

La semana pasada un compañero abrió un proyecto Go nuevo, ejecutó go get github.com/jung-kurt/gofpdf y a los diez minutos me mandó una captura del banner de GitHub: "This repository has been archived by the owner. It is now read-only." Seguido de: "Espera, ¿el fork también está archivado?"

Sí. Los dos.

jung-kurt/gofpdf fue archivado el 8 de septiembre de 2021. El fork comunitario go-pdf/fpdf publicó su última versión en 2023 y fue archivado en 2025. La biblioteca Go que dos tercios de las respuestas de Stack Overflow todavía recomiendan lleva cuatro años en solo lectura, y el fork que se suponía que la reemplazaba ya no está.

Si tienes código gofpdf en producción, este post es un mapa de migración. Si estás empezando un proyecto nuevo y fuiste a por gofpdf por reflejo, porque eso es lo que devolvía el buscador, esta es la alternativa.

Por qué gofpdf no vuelve

Las bibliotecas open source no siempre mueren. A veces el mantenedor original se retira y otra persona toma el relevo. Eso es lo que casi todos asumían que pasaría con gofpdf — y durante un tiempo pasó. El fork go-pdf/fpdf reorganizó el código, arregló algunos bugs viejos, aceptó PRs y parecía una continuación legítima.

Entonces, a principios de 2025, el fork también fue archivado. El README dice ahora, en parte: "Este proyecto ya no se mantiene activamente. Considera usar otra biblioteca."

La razón importa menos que la consecuencia: todo proyecto Go que depende de gofpdf está ahora sobre dos capas de código sin mantenimiento. Los problemas de seguridad no se parchean. La especificación PDF 2.0 salió en 2020 y gofpdf no cubre la mayoría de sus cambios. La semántica de variables de bucle de Go 1.25 funciona con gofpdf hoy, pero lo que se rompa mañana lo arreglas tú en tu fork privado.

No es un problema de "la biblioteca tiene bugs". Es un problema de cadena de suministro.

Para qué usa realmente la gente gofpdf

Antes de entrar en el mapeo, ayuda ser específico sobre las cargas que se migran. Mirando los rastreadores de issues y las preguntas de Stack Overflow sobre jung-kurt/gofpdf y go-pdf/fpdf, los usos dominantes son:

  1. Facturas y recibos — cabecera, bloque de cliente, tabla de líneas, totales, pie.
  2. Informes — documentos multipágina con cabeceras repetidas, números de página, gráficos insertados como imágenes.
  3. Formularios y certificados — texto en posiciones fijas superpuesto sobre una plantilla.
  4. Documentos CJK — facturas y etiquetas de envío en japonés, chino, coreano.

Los tres primeros los cubre sin problema la API de builder de gpdf. El cuarto — CJK — es donde gpdf más separa a gofpdf: gofpdf te obligaba a llamar AddUTF8Font, a gestionar una ruta a un TTF y a rezar para que tu texto no saliera del plano básico. gpdf trata CJK como ciudadano de primera clase: registras una fuente TrueType, escribes en japonés, obtienes un PDF.

La tabla de mapeo de API

La siguiente tabla es la chuleta. Las secciones de después recorren cinco pares antes/después concretos.

Lo que quieres hacergofpdfgpdf
Crear un documentogofpdf.New("P", "mm", "A4", "")gpdf.NewDocument(gpdf.WithPageSize(document.A4))
Añadir una páginapdf.AddPage()doc.AddPage() (devuelve un *PageBuilder)
Fijar una fuentepdf.SetFont("Arial", "B", 16)template.FontFamily(...), template.Bold(), template.FontSize(16) como opciones de texto
Registrar un TTF (CJK)pdf.AddUTF8Font("noto", "", "NotoSansJP-Regular.ttf")gpdf.WithFont("NotoSansJP", ttfBytes) (al construir)
Escribir una líneapdf.Cell(40, 10, "hi")c.Text("hi")
Escribir texto con saltopdf.MultiCell(0, 10, body, "", "L", false)c.Text(body) (hace el salto automáticamente)
Color del textopdf.SetTextColor(255, 0, 0)template.TextColor(pdf.Red) (opción por texto)
Dibujar una línea horizontalpdf.Line(x1, y1, x2, y2)c.Line(template.LineThickness(document.Pt(1)))
Insertar una imagenpdf.ImageOptions("logo.png", x, y, w, h, ...)c.Image(imgBytes, template.FitWidth(document.Mm(50)))
Fijar cursor XYpdf.SetXY(x, y)(no existe equivalente — usa filas/columnas o page.Absolute(x, y, fn))
Cabecera repetidapdf.SetHeaderFunc(fn)doc.Header(fn)
Pie repetidopdf.SetFooterFunc(fn)doc.Footer(fn)
Número de páginamanual: pdf.PageNo()c.PageNumber() / c.TotalPages()
Salida a archivopdf.OutputFileAndClose("out.pdf")data, _ := doc.Generate(); os.WriteFile("out.pdf", data, 0o644)
Salida a writerpdf.Output(w)doc.Render(w)

El cambio de forma es lo más fuerte: gofpdf es imperativo, gpdf es declarativo. En gofpdf empujas un cursor por la página y escribes donde cae. En gpdf describes un árbol de filas y columnas y dejas que el motor de layout coloque las cosas. Los primeros fragmentos parecen más largos en gpdf. Para el tercero dejas de echar de menos SetXY.

Una nota sobre unidades. gofpdf te hace elegir una unidad base al construir ("mm", "pt", "in"). gpdf usa puntos internamente y te da helpers — document.Mm(20), document.Pt(12), document.Cm(1), document.In(0.5) — para la unidad que prefieras en el punto de llamada. Es más parecido a CSS que a gofpdf, y una vez que pones una cabecera en cada página con márgenes de document.Mm(15), dejas de pensar en la unidad.

Antes / Después 1: el PDF más simple posible

El par "hello world". La brevedad de gofpdf es lo que lo hizo tan citable. La versión gpdf tiene unas líneas más porque construye un árbol, no conduce un cursor.

Antes — gofpdf:

package main

import "github.com/jung-kurt/gofpdf"

func main() {
    pdf := gofpdf.New("P", "mm", "A4", "")
    pdf.AddPage()
    pdf.SetFont("Arial", "B", 24)
    pdf.Cell(40, 10, "Hello, World!")
    pdf.OutputFileAndClose("hello.pdf")
}

Después — 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("Hello, World!", 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)
    }
}

La cuadrícula hace el trabajo. AutoRow añade una fila cuya altura viene de su contenido; r.Col(12, ...) dice "esta columna ocupa las 12 columnas de la cuadrícula". La misma idea de Bootstrap, aplicada a una página PDF.

Generate() devuelve los bytes; Render(w) los transmite a un io.Writer si prefieres no asignar. No hay paso de "cerrar el archivo" porque gpdf no es dueño de ningún file handle.

Antes / Después 2: una tabla de líneas de factura

Donde gofpdf se vuelve verboso es en las tablas. No tiene tabla integrada; llamas a Cell en bucles anidados, gestionas el ancho de columnas a mano y haces Ln(-1) para ir a la siguiente fila. La mitad de los tutoriales de facturas con gofpdf en internet son, literalmente, plantilla de tabla.

Antes — gofpdf:

pdf.SetFont("Arial", "B", 11)
pdf.SetFillColor(220, 220, 220)
pdf.CellFormat(80, 8, "Descripción", "1", 0, "L", true, 0, "")
pdf.CellFormat(20, 8, "Uds.",        "1", 0, "C", true, 0, "")
pdf.CellFormat(30, 8, "Precio",      "1", 0, "R", true, 0, "")
pdf.CellFormat(30, 8, "Importe",     "1", 1, "R", true, 0, "")

pdf.SetFont("Arial", "", 11)
items := [][]string{
    {"Desarrollo frontend", "40 h", "150,00 €", "6.000,00 €"},
    {"Desarrollo backend",  "60 h", "150,00 €", "9.000,00 €"},
    {"Diseño UI",           "20 h", "120,00 €", "2.400,00 €"},
}
for _, row := range items {
    pdf.CellFormat(80, 8, row[0], "1", 0, "L", false, 0, "")
    pdf.CellFormat(20, 8, row[1], "1", 0, "C", false, 0, "")
    pdf.CellFormat(30, 8, row[2], "1", 0, "R", false, 0, "")
    pdf.CellFormat(30, 8, row[3], "1", 1, "R", false, 0, "")
}

Calculas anchos mentalmente, y mejor que ninguna descripción tenga que envolver.

Después — gpdf:

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(12, func(c *template.ColBuilder) {
        c.Table(
            []string{"Descripción", "Uds.", "Precio", "Importe"},
            [][]string{
                {"Desarrollo frontend", "40 h", "150,00 €", "6.000,00 €"},
                {"Desarrollo backend",  "60 h", "150,00 €", "9.000,00 €"},
                {"Diseño UI",           "20 h", "120,00 €", "2.400,00 €"},
            },
            template.ColumnWidths(50, 15, 15, 20),
            template.TableHeaderStyle(
                template.Bold(),
                template.TextColor(pdf.White),
                template.BgColor(pdf.RGBHex(0x1A237E)),
            ),
            template.TableStripe(pdf.RGBHex(0xF5F5F5)),
        )
    })
})

ColumnWidths(50, 15, 15, 20) son porcentajes de la columna en la que vive la tabla, no milímetros absolutos. Mete la tabla dentro de un r.Col(6, ...) y los mismos porcentajes siguen funcionando. Ese tipo de cosa no sale gratis con CellFormat sin envolverlo.

Los saltos de línea son automáticos. Los saltos de página también — si la tabla se pasa del margen inferior, la cabecera se repite en la página siguiente.

Antes / Después 3: texto CJK sin el baile ritual

Este fue el que me hizo dejar gofpdf. Para renderizar japonés en gofpdf llamas AddUTF8Font, le apuntas a un TTF en disco, fijas la fuente y rezas. El subsetting funciona la mayoría de las veces. Algunos TTF provocan colisiones de glyph-id y salen caracteres corruptos. Los mensajes de error no ayudan.

Antes — gofpdf:

pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddUTF8Font("notosansjp", "", "NotoSansJP-Regular.ttf")
pdf.AddPage()
pdf.SetFont("notosansjp", "", 14)
pdf.Cell(0, 10, "こんにちは、世界。")
pdf.OutputFileAndClose("ja.pdf")

Dos minas. El TTF tiene que existir en la ruta indicada en tiempo de ejecución (así que tu imagen Docker debe llevar la fuente). Y Cell con ancho 0 significa "hasta el margen derecho", lo que con CJK suele recortar porque el estimador de anchura no maneja bien los glifos de ancho completo.

Después — 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() {
    fontData, err := os.ReadFile("NotoSansJP-Regular.ttf")
    if err != nil {
        log.Fatal(err)
    }

    doc := gpdf.NewDocument(
        gpdf.WithPageSize(document.A4),
        gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
        gpdf.WithFont("NotoSansJP", fontData),
        gpdf.WithDefaultFont("NotoSansJP", 14),
    )

    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("こんにちは、世界。")
            c.Text("吾輩は猫である。名前はまだ無い。")
            c.Text("東京都渋谷区神宮前1-2-3")
        })
    })

    data, _ := doc.Generate()
    os.WriteFile("ja.pdf", data, 0o644)
}

Dos diferencias.

Primero, pasas bytes, no una ruta. Embebe el TTF con //go:embed y el binario queda autocontenido. Adiós al "font not found" en producción porque alguien olvidó montar un volumen.

Segundo, el subsetter TrueType de gpdf entiende los formatos cmap CJK (4, 6, 12) y la codificación Identity-H. El PDF de salida solo lleva los glifos que usas realmente — embebiendo NotoSansJP en una factura de 200 caracteres sale un subset de ~30 KB, no un embed completo de 4 MB. Si alguna vez viste a gofpdf escribir un PDF de 5 MB por una página en japonés, esto es lo primero que notas.

Para un recorrido más profundo de opciones CJK específicas — IPAex Gothic, Source Han Sans, cadenas de fallback — se publicará un post complementario.

Antes / Después 4: cabecera en cada página, número de página en el pie

El patrón de gofpdf para el cromado repetido es SetHeaderFunc y SetFooterFunc — ambos reciben un func() que corre contra el cursor actual. Los números de página vienen de pdf.PageNo() y pdf.AliasNbPages().

Antes — gofpdf:

pdf := gofpdf.New("P", "mm", "A4", "")
pdf.SetHeaderFunc(func() {
    pdf.SetFont("Arial", "B", 12)
    pdf.Cell(0, 10, "ACME Corporación")
    pdf.Ln(15)
})
pdf.SetFooterFunc(func() {
    pdf.SetY(-15)
    pdf.SetFont("Arial", "I", 8)
    pdf.CellFormat(0, 10,
        fmt.Sprintf("Página %d/{nb}", pdf.PageNo()),
        "", 0, "C", false, 0, "")
})
pdf.AliasNbPages("")
pdf.AddPage()
// ... cuerpo ...

{nb} es un centinela que gofpdf sustituye al escribir con el número total de páginas. Funciona, pero es de esas cosas que "hay que saber".

Después — gpdf:

doc := gpdf.NewDocument(
    gpdf.WithPageSize(document.A4),
    gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
)

doc.Header(func(p *template.PageBuilder) {
    p.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("ACME Corporación", template.Bold(), template.FontSize(12))
            c.Line(template.LineColor(pdf.Gray(0.7)))
            c.Spacer(document.Mm(4))
        })
    })
})

doc.Footer(func(p *template.PageBuilder) {
    p.AutoRow(func(r *template.RowBuilder) {
        r.Col(6, func(c *template.ColBuilder) {
            c.Text("ACME Corporación",
                template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
        })
        r.Col(6, func(c *template.ColBuilder) {
            // "Página X / Y" — ambas partes son placeholders
            // que el motor de layout resuelve tras la paginación.
            c.PageNumber(template.AlignRight(),
                template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
        })
    })
})

for i := 0; i < 10; i++ {
    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text(fmt.Sprintf("Contenido de la página %d.", i+1))
        })
    })
}

PageNumber y TotalPages son placeholders. Se expanden tras la paginación, cuando el motor de layout ya sabe cuántas páginas hay. Sin centinela {nb}, sin SetY(-15) manual para clavar el pie abajo — el pie es solo un árbol, y el motor le reserva espacio automáticamente en cada página.

Antes / Después 5: producir bytes para un handler HTTP

La mayoría del código gofpdf en producción no escribe a un archivo. Escribe a un io.Writer — normalmente un http.ResponseWriter devolviendo application/pdf al navegador. En este par la API de gpdf es la que más se parece a la de gofpdf.

Antes — gofpdf:

func handler(w http.ResponseWriter, r *http.Request) {
    pdf := gofpdf.New("P", "mm", "A4", "")
    pdf.AddPage()
    pdf.SetFont("Arial", "", 12)
    pdf.Cell(0, 10, "Generado a las "+time.Now().Format(time.RFC3339))

    w.Header().Set("Content-Type", "application/pdf")
    if err := pdf.Output(w); err != nil {
        http.Error(w, err.Error(), 500)
    }
}

Después — gpdf:

func handler(w http.ResponseWriter, r *http.Request) {
    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("Generado a las " + time.Now().Format(time.RFC3339))
        })
    })

    w.Header().Set("Content-Type", "application/pdf")
    if err := doc.Render(w); err != nil {
        http.Error(w, err.Error(), 500)
    }
}

Misma forma. doc.Render(w) transmite el PDF directamente a la respuesta. Si quieres fijar Content-Length, llama primero a Generate() para obtener el slice de bytes y aplícale len().

¿Cómo de rápido es "suficientemente rápido"?

gpdf es aproximadamente 10× más rápido que gofpdf en las cargas que la gente ejecuta de verdad. Los números siguientes vienen de _benchmark/benchmark_test.go en un Apple M1 con Go 1.25.

BenchmarkgpdfgofpdfgopdfMaroto v2
Página única13 µs132 µs423 µs237 µs
Tabla 4×10108 µs241 µs835 µs8,6 ms
Documento de 100 pág.683 µs11,7 ms8,6 ms19,8 ms
Factura CJK compleja133 µs254 µs997 µs10,4 ms

No son sintéticos — el benchmark de tabla es una de líneas de factura de 4 columnas por 10 filas; el de 100 páginas es un informe paginado con cabecera repetida y números de página. La forma coincide con lo que hace el código real.

Una nota sobre lo que significan. A 13 µs por página única, un solo núcleo produce ~75.000 PDFs "hello world" por segundo. A 108 µs por página con tabla, ~9.000 facturas por segundo. El punto no es presumir — es que puedes dejar de pensar si hay que cachear o meter la generación de PDFs en una cola asíncrona. Para la mayoría de las cargas, generar en la misma vía de la petición está bien.

Lo que pierdes al migrar

Nada de esta guía vale si oculta huecos reales. Esto es lo que gpdf no hace y gofpdf sí:

  • Líneas en ángulos arbitrarios, curvas Bézier y caminos complejos. c.Line() dibuja una línea horizontal que cruza una columna. Si estás produciendo dibujos técnicos o gráficos con geometría propia, gpdf aún no llega. (Gráficos prerrenderizados como imágenes: sin problema.)
  • SetXY y trabajo con cursor absoluto. Puedes posicionar absoluto con page.Absolute(x, y, fn), pero si tu código existente son 2.000 líneas de SetXY seguidas de Cell, la migración es más bien una reescritura. La contrapartida es que el código reescrito suele tener la mitad de tamaño.
  • Campos de formulario (AcroForm). gpdf todavía no genera campos rellenables. Si tus PDFs son plantillas que los usuarios rellenan en un visor, quédate — por ahora — con una biblioteca que soporte AcroForm.
  • Anotaciones y marcadores. Soporte básico de outline sí, anotaciones ricas no.

Si nada de esto te afecta, la migración es directa. Si te afecta, abre un issue — la hoja de ruta se mueve por lo que pide la gente.

FAQ

¿gpdf es un fork de gofpdf? No. gpdf es una reimplementación limpia. El trabajo de formato de cable PDF, el motor de layout, el subsetter de TrueType — todo escrito desde cero en Go puro. No comparte linaje con gofpdf ni sus forks. Tiene que ser reescritura limpia porque la arquitectura de gofpdf se construyó en torno a un único cursor mutable; no puedes sacar una cuadrícula declarativa de ahí sin romper todos los sitios de llamada.

¿gpdf tiene dependencias externas? La biblioteca core tiene cero. Ejecuta go mod graph | grep gpdf tras go get github.com/gpdf-dev/gpdf y verás una sola línea. El complemento gpdf-pro (HTML→PDF, cifrado AES, firmas, PDF/A) sí trae golang.org/x/net para parsear HTML, pero es opt-in y no hace falta para migrar.

¿Y CGO? gofpdf era CGO-free, ¿qué pasa con gpdf? Igual. Go puro, sin CGO. Haz GOOS=linux GOARCH=arm64 go build y despliega un binario estático. Eso importa para imágenes distroless y Alpine, donde arrastrar una toolchain CGO te duplica el tamaño del contenedor.

Mi código gofpdf existente usa SetXY para posicionamiento absoluto en todas partes. ¿Puedo migrar sin reescribir? Puedes envolver page.Absolute(x, y, fn) y tener algo que se le parezca. Pero si tu código está estructurado alrededor de la manipulación de cursor, el modelo del motor de layout es un cambio mental, no sintáctico. La mayoría de los equipos descubre que la reescritura es más corta que el original.

¿Y si go-pdf/fpdf se desarchiva? Entonces tienes una opción más. La apuesta detrás de gpdf no es que gofpdf vaya a quedarse archivado para siempre — es que la arquitectura (basada en cursor, fuentes de un byte, sin CJK nativo) es un callejón sin salida independientemente de quién la mantenga. Generar PDFs en 2026 se parece más a montar una página web que a conducir un plotter, y la API debería reflejarlo.

Probar gpdf

gpdf es una biblioteca Go para generar PDFs. Licencia MIT, cero dependencias externas, soporte CJK nativo.

go get github.com/gpdf-dev/gpdf

⭐ Star en GitHub · Leer la documentación

Lecturas siguientes

  • ¿Cómo funciona la cuadrícula de 12 columnas en gpdf? (próximamente)
  • ¿Cómo embebo una fuente japonesa en gpdf? (próximamente)
  • Quickstart — setup en cinco minutos, incluyendo go.mod