Todas las publicaciones

Pensamiento Bootstrap para PDF: la rejilla de 12 columnas de gpdf

gpdf adopta la rejilla de 12 columnas de Bootstrap para componer PDFs. Por qué 12, qué conservamos del modelo web y qué descartamos sin pensar dos veces.

TL;DR

gpdf usa la rejilla de 12 columnas de Bootstrap. Doce, porque divide limpio en 1, 2, 3, 4 y 6 — los repartos que en serio querés. Conservamos el modelo de span entero y tiramos todo lo demás: sin breakpoints, sin gutter, sin order, sin auto-fill. Una página es una pila de filas; cada fila es un único Box horizontal; las columnas dentro son anchos en doceavos.

Esa es toda la rejilla. La implementación son alrededor de 30 líneas de Go. Lo interesante es lo que no portamos.

Por qué existe este artículo

gpdf es una librería de Go para generar PDFs. La API de layout de alto nivel es un builder: page.AutoRow → r.Col(span, fn) → c.Text/Image/Table. Quien la ve por primera vez con r.Col(4, ...) suele preguntar tres cosas:

  1. ¿Por qué 12? ¿Por qué no 16, 24 o «las que quieras»?
  2. ¿Esto es CSS Grid? ¿Bootstrap? ¿Otra cosa?
  3. ¿Qué pasa si los span no suman 12?

Este artículo recorre las decisiones de diseño que llevaron a esa API. Casi todas se reducen a un principio: el renderizado de un PDF necesita ser predecible, no adaptativo. Una página web se reflowea al cambiar el tamaño. Un PDF no. Esa única diferencia elimina la mayor parte de lo que hace difíciles a las rejillas web y nos deja entregar una idea mucho más pequeña.

Si solo querés saber cómo se usa, la receta en /blog/12-column-grid es más directa. Este post es sobre por qué tiene esa forma.

Las tres opciones para diagramar un PDF

Cuando arrancamos con la API de alto nivel, las opciones reales eran tres:

  1. Posicionamiento absoluto. «Dibujá texto en (72, 540) en puntos.» Es lo que ofrecen casi todas las librerías PDF de bajo nivel en Go. Máxima potencia, ergonomía pésima. Calculás cada coordenada vos.
  2. Flow + flexbox. Apilás contenido de arriba abajo; las filas reparten hijos horizontalmente con grow/shrink. Potente, pero la pasada de layout no es trivial — necesitás un solver de restricciones, y los errores de redondeo se acumulan.
  3. Rejilla fija + ratios. Una página es una pila de filas. Una fila se divide en N slots iguales. Cada columna toma un número entero de slots. Ancho = slots / N × ancho_de_fila. Sin solver. Sin grow/shrink.

Elegimos la opción 3. Bootstrap llegó al mismo punto hace más de una década por la misma razón: la mayoría de los layouts que vas a necesitar son layouts de fracciones enteras. Dos columnas iguales. Un 1/3 + 2/3. Cuatro tarjetas en línea. Una fila 25-50-25. Ninguno necesita un solver.

Quedaba la pregunta: ¿cuántos slots?

Por qué 12

12 no es magia, pero tampoco es arbitrario. Pensá qué divisiones enteras realmente querés en un documento:

  • 2 columnas — mitades izquierda/derecha
  • 3 columnas — tercios (galería de tres tarjetas)
  • 4 columnas — cuartos (franja de KPI)
  • 6 columnas — sextos (raros, paneles laterales angostos)
  • 12 columnas — doceavos (raros, separadores finos)

Mirá los divisores de 12: 1, 2, 3, 4, 6, 12. Es decir, toda división entera útil hasta un sexto. 10 no te da tercios. 16 tampoco. 24 te da todo pero duplica la carga cognitiva — escribís r.Col(8, ...) y tenés que recordar si eso es un tercio (24/3) o dos tercios (8/12). 12 es el menor número que cubre los repartos que la gente usa de verdad.

Bootstrap aterrizó en 12 en 2011 exactamente por esa razón. Después CSS Grid subió un nivel y permitió escribir 1fr 2fr 1fr directamente, eliminando el número mágico. Pero las fracciones no salen gratis — descargan trabajo en quien lee tu layout. r.Col(4, ...) es concretamente «un tercio de la fila». r.Col(2fr, ...) te obliga a mirar a todos los hermanos antes de saber qué significa.

En PDFs, donde los layouts son estables y se inspeccionan a ojo, el modelo entero gana.

Qué conservamos de Bootstrap

Tres cosas, y solo tres:

  1. El doce. El denominador. El único número en el dial.
  2. Span como entero 1–12. Ni fracción, ni unidad CSS. r.Col(4, ...) reclama cuatro doceavos de la fila.
  3. El modelo mental. Una página es una pila de filas. Una fila se divide en columnas. La misma forma que la rejilla que escribís en HTML hace diez años.

Hasta acá idéntico a Bootstrap. Lo realmente interesante viene ahora.

Qué tiramos

Breakpoints

col-md-6 col-lg-4 de Bootstrap hace que una columna ocupe la mitad en tablet y un tercio en desktop. Útil en la web. Sin sentido en un PDF. La página de un PDF es un canvas fijo. No hay viewport que consultar, no hay evento de resize, no hay media query. Borramos los breakpoints por completo.

El ahorro es mayor de lo que parece. Los breakpoints son la razón por la que los frameworks CSS publican variantes col-xs-*, col-sm-*, col-md-*, col-lg-*, col-xl-* — cinco copias de la misma clase. Ninguna existe en gpdf. La API es r.Col(span int, fn func(*ColBuilder)). Una sola firma. Una sola ranura mental.

Gutter

Las filas de Bootstrap traen padding horizontal entre columnas por defecto. Los PDFs no necesitan un default, porque el margen entre columnas depende totalmente de lo que rendericen — una tabla compacta no lleva gutter, una sección hero quiere 24pt de aire, una línea de factura puede querer 0.5pt para un separador. Decidimos que el espaciado fuera explícito.

¿Querés gutter? Lo ponés: tirás un c.Spacer(...) entre columnas, o envolvés el contenido interno en un Box con padding. La rejilla nunca inserta píxeles que no pediste. Sin gutter es el default correcto en un medio impreso donde cada punto cuenta.

Order

CSS te deja reordenar columnas visualmente con order: 2. Útil para diseño responsivo, donde el mismo DOM produce un orden visual distinto en pantallas chicas. Inútil en PDFs. El orden en que las columnas aparecen en el archivo es el orden en que aparecen en la página. Ni siquiera lo consideramos.

Auto-fill / auto-fit

CSS Grid tiene repeat(auto-fit, minmax(200px, 1fr)) — llená la fila con cuantas columnas de mínimo 200px entren. Hermoso para galerías web. En un PDF conocés el ancho de página al compilar. No hace falta que el motor de layout lo deduzca.

¿Querés una fila de 4 tarjetas? r.Col(3, ...) cuatro veces. ¿De 6? r.Col(2, ...) seis veces. La versión «auto» es un for en tu propio código:

for _, item := range items {
    r.Col(3, func(c *template.ColBuilder) {
        c.Text(item.Name)
    })
}

Tres líneas. No hacía falta meterlas en el framework.

Forzar la suma de spans

Acá viene la sorpresa: gpdf no exige que los span sumen 12. Es a propósito.

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(4, func(c *template.ColBuilder) { c.Text("Tercio izq.") })
    r.Col(4, func(c *template.ColBuilder) { c.Text("Tercio centro") })
    // suma = 8. El tercio derecho queda vacío.
})

La librería trata cada columna como span/12 × ancho_de_fila, punto. Si ponés 4 + 4 en una fila, el tercer slot queda vacío. Si ponés 7 + 8, la segunda columna se desborda fuera de la fila — también intencional, porque a veces querés desbordar (por ejemplo, alineando con una rejilla de layout más ancha que la página). Los span se clampan a 1–12 (Col(0, ...) queda como Col(1, ...), Col(99, ...) como Col(12, ...), mirá gpdf/template/grid.go:120), pero no hay auto-wrap ni auto-rebalanceo.

El viejo comportamiento de Bootstrap «si la suma pasa de 12, baja a la siguiente fila» resolvía un problema responsivo real. Los PDFs no tienen ese problema. Lo reemplazamos con un contrato más simple: lo que escribiste es lo que sale.

Container, modo fluid, sin gutter, offsets, push/pull

Nada. No mandamos container-fluid, col-md-offset-3, col-md-push-2 ni equivalentes a las clases utilitarias de Bootstrap. ¿Querés empujar una columna a la derecha? Envolvela: poné un r.Col(3, ...) vacío antes. Ocho caracteres más, cero conceptos nuevos.

gpdf vs Bootstrap vs CSS Grid

CaracterísticaBootstrap (CSS)CSS Grid (CSS)gpdf (Go)
Tamaño de rejilla12 columnasArbitrario (grid-template-columns)12 columnas
UnidadNombres de claseFracciones (fr), px, %Span entero 1–12
Breakpoints5 (xs/sm/md/lg/xl)Vía media queriesNinguno
Gutter por defectoSí (gx-* controla)NoNo
Reordenamiento visualorder-*Propiedad orderNo
Auto-fillNoNo
Wrap si suma > 12Sí (legacy) / No (flex)N/ANo (overflow permitido)
Tamaño de implementación~3.000 LoC SCSSDentro del navegador~30 LoC Go

«30 LoC» es real. Abrí gpdf/template/grid.go y contá: una constante (gridColumns = 12), un método de builder que clampa enteros, y una pasada de build que emite un Box por fila con dirección horizontal y Pct(span/12*100) por hijo. No hay pasada de medición, no hay algoritmo flex, no hay rebalanceo. La aritmética del ancho es el algoritmo.

Cómo se renderiza por dentro

Cuando llamás r.Col(4, fn), gpdf agrega un colEntry{span: 4, fn: fn} a la fila. Al construir el documento, cada entrada se vuelve un document.Box con Width: document.Pct(33,333…) y el contenido de la columna anidado adentro. La fila es un Box con Direction: DirectionHorizontal. El PDF Writer (Layer 1) recorre los Box en orden y emite content streams; el motor de layout (Layer 2) resuelve ancho y alto; la rejilla (Layer 3) hace la conversión entero→porcentaje.

La razón por la que esto se queda en 30 líneas es que porcentajes y enteros componen sin error de redondeo en la frontera de layout. Una columna dentro de una columna dentro de una columna sigue siendo una pila de multiplicaciones Pct en float64. El presupuesto de error queda muy por debajo de un punto tipográfico incluso con anidamiento profundo.

Si querés ver toda la cadena, por qué gpdf es 10× más rápido que las alternativas explica el pipeline. La rejilla es una de las capas más baratas — en M1, una página tarda unos 13 µs y la rejilla aporta apenas unos cientos de nanosegundos.

Un ejemplo completo y funcional

Cabecera con división 4/8, después una fila a 12 con una tabla, después una franja de KPI 3/3/3/3:

package main

import (
    "os"

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

func main() {
    doc := template.NewDocument(document.PageSize(document.A4))

    doc.Page(func(p *template.PageBuilder) {
        // División 4/8: logo a la izquierda, dirección a la derecha.
        p.AutoRow(func(r *template.RowBuilder) {
            r.Col(4, func(c *template.ColBuilder) {
                c.Text("ACME, S.A.", template.FontSize(18), template.Bold())
            })
            r.Col(8, func(c *template.ColBuilder) {
                c.Text("Av. Industria 123", template.AlignRight())
                c.Text("Madrid 28001", template.AlignRight())
            })
        })

        p.Spacer(document.Mm(10))

        // Fila a 12 (una columna span 12) para una tabla.
        p.AutoRow(func(r *template.RowBuilder) {
            r.Col(12, func(c *template.ColBuilder) {
                c.Table([]string{"Artículo", "Cant.", "Precio"}, [][]string{
                    {"Widget A", "2", "10,00 €"},
                    {"Widget B", "1", "25,00 €"},
                })
            })
        })

        p.Spacer(document.Mm(10))

        // Franja de KPI: 3 span × 4 columnas
        kpis := []struct{ label, value string }{
            {"Subtotal", "45,00 €"},
            {"IVA (21%)", "9,45 €"},
            {"Envío", "0,00 €"},
            {"Total", "54,45 €"},
        }
        p.AutoRow(func(r *template.RowBuilder) {
            for _, k := range kpis {
                k := k
                r.Col(3, func(c *template.ColBuilder) {
                    c.Text(k.label, template.FontSize(8))
                    c.Text(k.value, template.FontSize(14), template.Bold())
                })
            }
        })
    })

    f, _ := os.Create("invoice.pdf")
    defer f.Close()
    doc.Render(f)
}

Es un programa real. go get github.com/gpdf-dev/gpdf, lo ejecutás y aparece invoice.pdf en el directorio. Tiempo de render en M1: cerca de 130 µs.

Cuándo el modelo entero es la elección equivocada

El modelo de doceavos enteros es genuinamente la elección equivocada en dos casos. Lista honesta, porque vas a chocar con al menos uno tarde o temprano:

  1. Necesitás anchos exactos al punto. «Esta columna tiene que medir exactamente 73,5pt.» Pct no te lo da, porque 73,5 / total × 12 rara vez es entero. Usá page.Absolute(...) para los pocos elementos con coordenadas fijas y dejá lo demás a la rejilla. Mezclar ambos en la misma página está bien.
  2. Necesitás flujo de columnas estilo periódico. Un párrafo que llena una columna y continúa en la siguiente. La rejilla no hace eso. Todavía no tenemos motor de flujo de texto entre columnas. Si te hace falta, abrí un issue — sabemos que falta.

Para todo lo demás — facturas, reportes, contratos, folletos, decks — la rejilla de 12 calza más ajustada que CSS, no más floja.

Preguntas frecuentes

P: ¿Puedo cambiar el 12 por otra cosa, por ejemplo 24? No. gridColumns es constante. Cambiarlo invalidaría todas las plantillas existentes. Decidimos 12 una vez y nos quedamos ahí.

P: ¿Qué pasa si quiero anidar una fila dentro de una columna? Se puede. c.AutoRow(...) crea una sub-fila dentro de la columna. Los span dentro de la sub-fila son 1–12 del ancho de la columna padre, no de la página. La anidación compone bien porque cada nivel es solo Pct(span/12 × 100) de su padre.

P: ¿Funciona en páginas apaisadas? Sí. La rejilla es agnóstica al tamaño de página. r.Col(6, ...) es siempre la mitad, ya sea que la fila mida 210mm (A4 vertical) o 297mm (A4 apaisado).

P: ¿Por qué no hay un atajo r.Col2(span, span, fn1, fn2) para dos columnas? Porque ahorrar una línea agregando superficie de API es mal trato. Si te repetís un patrón de fila, escribí una función Go que reciba *template.PageBuilder y la agregue. La rejilla se mantiene mínima para que los patrones de usuario crezcan sin chocar.

P: ¿Y grid-area y líneas con nombre de CSS Grid? No están en gpdf y no están en el roadmap. Para PDFs, el costo-beneficio no cierra.

Resumen

La rejilla de 12 columnas es la primitiva de layout más chica que cubre los repartos que los documentos reales necesitan. Tomamos prestado el número de Bootstrap, mantuvimos el modelo entero y descartamos breakpoints, gutter, order, auto-fill, suma de spans forzada y todo el resto del equipaje del responsive web. Lo que queda es una constante, un método de builder y una fórmula de ancho — unas 30 líneas de Go. Compone por anidamiento, convive con Absolute para los pocos casos que la rejilla no expresa, y nunca rebalancea en silencio lo que escribiste.

Probá gpdf

gpdf es una librería de PDF para Go: MIT, sin dependencias, soporte CJK por defecto.

go get github.com/gpdf-dev/gpdf

⭐ Star en GitHub · Leer la documentación

Qué leer después