All posts

How do I nest a Row inside a Col in gpdf?

You can't — ColBuilder has no Row method in gpdf. The 12-column grid is flat by design. Here are the three idioms that replace nested rows.

The question, in other words

You've used Bootstrap or Tailwind, where .row and .col nest freely. You can put a .row inside a .col inside another .row and the grid keeps cascading. You sit down with gpdf, see the same r.Col(span, fn) idiom, and reach for c.Row(...) inside the column callback. It's not there. Was that an oversight?

TL;DR

No. gpdf's 12-column grid is flat on purpose. ColBuilder only accepts content — Text, Image, Table, Box, List, Spacer — and Row/AutoRow live on PageBuilder, not on ColBuilder. If you came here looking for the syntax, there isn't one. Read on for the three things that replace it.

The shape of the API

Here's what ColBuilder's method set actually contains (from gpdf/template/grid.go):

func (c *ColBuilder) Text(text string, opts ...TextOption)
func (c *ColBuilder) Image(src []byte, opts ...ImageOption)
func (c *ColBuilder) Box(fn func(c *ColBuilder), opts ...BoxOption)
func (c *ColBuilder) Table(header []string, rows [][]string, opts ...TableOption)
func (c *ColBuilder) Line(opts ...LineOption)
func (c *ColBuilder) List(items []string, opts ...ListOption)
func (c *ColBuilder) Spacer(height document.Value)
// …PageNumber, TotalPages, RichText, QRCode, Barcode

No Row. No AutoRow. No Col. The Col → Row path doesn't exist as a method, and c.Box(fn, ...) is the closest thing — but Box accepts another *ColBuilder, not a row. You can nest columns inside columns (sort of, via Box), but you can't open a new horizontal row inside a column. That's the constraint.

Idiom 1 — Sibling rows at the page level

This is what 90% of "nested row" patterns actually want.

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

    // What you wanted to write (but can't):
    //
    //   page.AutoRow(func(r *template.RowBuilder) {
    //       r.Col(8, func(c *template.ColBuilder) {
    //           c.Row(...) ❌ doesn't exist
    //       })
    //   })

    // What you write instead:
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(8, func(c *template.ColBuilder) {
            c.Text("Article title", template.FontSize(18), template.Bold())
        })
        r.Col(4, func(c *template.ColBuilder) {
            c.Text("2026-05-16")
        })
    })
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(8, func(c *template.ColBuilder) {
            c.Text("Lead paragraph spans the same 8-wide column.")
        })
        r.Col(4, func(c *template.ColBuilder) {
            c.Text("by Taiki Noda")
        })
    })

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

The two AutoRows share the same 8+4 spans, so the columns line up visually. There's no sub-grid; there's a flat sequence of rows that happen to use the same carve. The output is identical to what you'd get from a CSS layout that nested .row inside .col-8 — because the only thing the nested form bought you was syntactic locality, and gpdf prefers you spend that on width consistency instead.

Idiom 2 — c.Box for visual grouping

If the real motivation was "I want a bordered card with two stacked elements inside this column," you wanted Box, not a sub-row:

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(6, func(c *template.ColBuilder) {
        c.Box(func(c *template.ColBuilder) {
            c.Text("Bill to", template.Bold())
            c.Text("Acme GmbH")
            c.Text("Hamburg, Germany")
        },
            template.WithBoxBorder(template.Border(
                template.BorderWidth(document.Pt(1)),
                template.BorderColor(pdf.RGBHex(0xBDBDBD)),
            )),
            template.WithBoxPadding(document.UniformEdges(document.Mm(4))),
        )
    })
    r.Col(6, func(c *template.ColBuilder) {
        c.Box(func(c *template.ColBuilder) {
            c.Text("Ship to", template.Bold())
            c.Text("Same as billing")
        },
            template.WithBoxPadding(document.UniformEdges(document.Mm(4))),
        )
    })
})

Box accepts another *ColBuilder, which means everything stacked inside is vertical. You can't split a Box horizontally either — for that, you go back to Idiom 1. But for the "card" pattern that nested-row syntax often gets reached for, this is the right tool. The c.Box line in gpdf/template/grid.go:246 is the only nesting the grid does, and it's deliberately one-dimensional.

Idiom 3 — Plan the sub-grid into 12 columns directly

Sometimes you genuinely want a 2-column layout inside what feels like a half-page section: a thumbnail and a caption inside the left half of the page, a paragraph on the right. The instinct is Col(6) > Row > Col(6) + Col(6). The flat equivalent is just Col(3) + Col(3) + Col(6):

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(3, func(c *template.ColBuilder) {
        c.Image(thumbBytes)
    })
    r.Col(3, func(c *template.ColBuilder) {
        c.Text("Photo by Ansel Adams", template.Italic())
        c.Text("1942")
    })
    r.Col(6, func(c *template.ColBuilder) {
        c.Text("The body paragraph fills the right half of the page.")
    })
})

3 + 3 together equal 6, so the thumbnail/caption pair occupies the left half exactly. Twelve factors into 2, 3, 4, and 6, so a nested grid almost always collapses cleanly. If your nested grid was Col(8) > Row > Col(7) + Col(5), that doesn't collapse — but those numbers don't mean anything in a real document either. Pick the flat version that does.

Why no nesting

A flat grid resolves widths in one pass. The row is a percentage of the page width minus margins. Each Col(span) is span / 12 of that. Done. There is no recursion, no width-of-a-width-of-a-width, no parent context to thread through the layout engine. The line in grid.go that computes column width is exactly one line:

Width: document.Pct(float64(col.span) / float64(gridColumns) * 100),

Add nesting and that line becomes a tree walk. Suddenly you need to decide what Col(6) inside Col(8) inside Col(12) means — is 6 50% of the parent column, or 50% of the row, or 50% of the page? Bootstrap chose "50% of the parent" and added breakpoints and gutters to make that bearable. PDFs don't have breakpoints. PDFs don't have a fluid container. Borrowing the nesting idiom would import three problems we don't have, in exchange for syntactic shorthand we don't need.

"But I really want syntactic locality"

Fair. The downside of flattening is that two AutoRow calls that conceptually belong together can drift apart in the source as you edit. A small helper closes that gap:

func card(page *template.PageBuilder, title, body string) {
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text(title, template.Bold())
        })
    })
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text(body)
        })
    })
}

The locality lives in your function, not in the API. gpdf doesn't ship card because it's three lines and your version will fit your document better than ours would.

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