All posts

How does the 12-column grid work in gpdf?

gpdf's 12-column grid uses r.Col(span, fn) where span is 1–12. Column width is (span/12) of the row. No breakpoints, no gutters, predictable by design.

by gpdf team

The question, in other words

You've seen the gpdf API — page builder, row builder, column builder — and the column constructor takes a number: r.Col(4, fn), r.Col(8, fn). What's the number, what happens if the spans don't add up to 12, and how does this compare to the grid you already know from CSS?

The short version

r.Col(span, fn) takes an integer from 1 to 12. That integer is the column's share of the row — span / 12 of the available width. Spans under 1 are clamped to 1, spans over 12 are clamped to 12, and the library does not force spans to sum to 12 per row. The grid is fixed at 12 divisions. Everything else is you choosing how to carve up a row.

A working example

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(15))),
    )

    page := doc.AddPage()

    // Full width
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("Invoice #2026-0416", template.FontSize(18), template.Bold())
        })
    })

    // 2-column header (6 + 6)
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(6, func(c *template.ColBuilder) {
            c.Text("Billed to")
            c.Text("Acme GmbH")
        })
        r.Col(6, func(c *template.ColBuilder) {
            c.Text("Issue date")
            c.Text("2026-04-16")
        })
    })

    // 3-column summary (4 + 4 + 4)
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(4, func(c *template.ColBuilder) {
            c.Text("Subtotal")
        })
        r.Col(4, func(c *template.ColBuilder) {
            c.Text("Tax")
        })
        r.Col(4, func(c *template.ColBuilder) {
            c.Text("Total")
        })
    })

    // Asymmetric (8 + 4) — article area + side panel
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(8, func(c *template.ColBuilder) {
            c.Text("Line items appear here.")
        })
        r.Col(4, func(c *template.ColBuilder) {
            c.Text("Notes")
        })
    })

    data, err := doc.Generate()
    if err != nil {
        log.Fatal(err)
    }
    if err := os.WriteFile("layout.pdf", data, 0o644); err != nil {
        log.Fatal(err)
    }
}

Run go run main.go. You get one page with four rows, each divided differently.

Why 12

Twelve factors cleanly into 2, 3, 4, and 6. That covers almost every real layout: halves (6+6), thirds (4+4+4), quarters (3+3+3+3), a sidebar + body (3+9 or 4+8), and a body + rail (8+4). Pick a grid with a smaller factor count and you lose one of those cheaply. Bootstrap settled on twelve back in 2011, and by now "12-column grid" is the lingua franca that every designer and frontend engineer already speaks. gpdf borrows the idiom on purpose — a PDF layout isn't a different mental model than a web layout, even though the rendering target happens to be fixed-width paper.

The math, spelled out

With A4 portrait and 15 mm uniform margins, the usable width is 180 mm. A Col(4) inside a row takes 4/12 of that — 60 mm. Col(8) takes 120 mm. There is no gutter between columns by default. If you want breathing room, either add a c.Spacer inside the shorter column or leave a one-unit span empty.

The width is computed as a percentage at build time (the relevant line is in gpdf/template/grid.go), and the layout engine resolves it to points using the current page width minus margins. That means the same r.Col(6, fn) means different physical widths on A4 vs Letter, but the same proportion of the row.

Sums that don't hit 12

gpdf does not validate that your spans add up. This is deliberate.

  • Sum < 12: the right side of the row is blank. Useful when you want a column anchored to the left edge and the rest of the line to stay empty.
  • Sum > 12: the last column overflows past the right margin. Usually a bug. The generated PDF looks wrong; nothing crashes.

Most layouts land on exactly 12 per row because that's what fills the page. But when you want a 6-width block centered on a line, the cheap move is Col(3) empty, Col(6) content, Col(3) empty — the grid was designed for this kind of shorthand.

AutoRow vs Row

page.AutoRow(fn) grows vertically to fit the tallest column. Most rows should use this.

page.Row(height, fn) pins the height. Content past that height gets clipped. Use it for invoice headers that must stay exactly 30 mm tall so downstream stapling lines up, and for anything else where visual consistency beats content freedom.

page.Row(document.Mm(30), func(r *template.RowBuilder) {
    r.Col(8, func(c *template.ColBuilder) {
        c.Text("Logo")
    })
    r.Col(4, func(c *template.ColBuilder) {
        c.Text("Invoice #")
    })
})

What the grid does not do

No nesting. You can't put a sub-row inside a column — ColBuilder accepts content (Text, Image, Table, List, Spacer), not another row. Layouts that need that pattern are usually better expressed as two sibling rows at the page level.

No offset columns. Bootstrap has .offset-2; gpdf does not. Leave an empty Col(n) to push content right.

No breakpoints. PDF pages don't resize. The grid produces the same layout on every device because the output is a raster of fixed coordinates, not a DOM that re-flows.

These omissions are the point. Every feature the grid doesn't have is a class of ambiguity the PDF output doesn't have to reason about.

Try gpdf

gpdf is a Go library for generating PDFs. MIT licensed, zero external dependencies, native CJK support.

go get github.com/gpdf-dev/gpdf

⭐ Star on GitHub · Read the docs