Bootstrap thinking for PDF: gpdf's 12-column grid
gpdf borrows the 12-column grid from Bootstrap, but PDFs aren't web pages. Here's why 12 still works, and what we threw out: breakpoints, gutters, order.
TL;DR
gpdf uses the 12-column grid from Bootstrap. Twelve, because it divides cleanly into 1, 2, 3, 4, 6 — the splits you actually want. We kept the integer span model and threw out everything else: no breakpoints, no gutters, no order, no auto-fill. A page is a stack of rows; each row is one horizontal box; columns inside it are widths in twelfths.
That's the whole grid. The implementation is around 30 lines of Go. The interesting part is what we didn't port.
Why this article exists
gpdf is a Go library for generating PDFs. The high-level layout API is a builder: page.AutoRow → r.Col(span, fn) → c.Text/Image/Table. New users see r.Col(4, ...) and ask three questions:
- Why 12? Why not 16, 24, or "as many as you want"?
- Is this CSS Grid? Bootstrap? Something else?
- What happens if my spans don't add up to 12?
This post answers those by walking through the design choices behind the API. They mostly come down to one principle: PDF rendering needs to be predictable, not adaptive. A web page reflows on resize. A PDF doesn't. That single difference removes most of what makes web grid systems hard, and lets us ship a much smaller idea.
If you only want to know how to use the grid, the recipe at /blog/12-column-grid is more direct. This post is about why it looks the way it does.
The three options for laying out a PDF
When we started the high-level API, we had three real choices:
- Absolute positioning. "Draw text at (72, 540) in points." This is what most low-level Go PDF libraries give you. Maximum power, terrible ergonomics. You compute every coordinate yourself.
- Flow + flexbox. Stack content top-to-bottom; rows distribute children horizontally with grow/shrink ratios. Powerful, but the layout pass is non-trivial — you need a constraint solver, and rounding errors compound.
- Fixed-grid + ratios. A page is a stack of rows. A row is divided into N equal slots. Each column claims some integer number of slots. Width =
slots / N * row_width. No constraint solver. No grow/shrink.
We picked option 3. Bootstrap got there over a decade ago for the same reason: most layouts you actually need are integer-fraction layouts. Two columns of equal width. A 1/3 + 2/3 split. Four cards across. A 25-50-25 row. None of those need a constraint solver.
The remaining question was: how many slots?
Why 12
12 isn't magic, but it isn't arbitrary either. Think about which integer divisions you ever actually want in a document:
- 2 columns — left/right halves
- 3 columns — thirds (gallery, three-card row)
- 4 columns — quarters (KPI strip)
- 6 columns — sixths (rare, but useful for narrow side panels)
- 12 columns — twelfths (rare; thin separators)
Notice the divisors of 12: 1, 2, 3, 4, 6, 12. That's every useful integer split up to a sixth. 10 doesn't give you thirds. 16 doesn't give you thirds either. 24 gives you everything but doubles the cognitive load — you'd write r.Col(8, ...) and have to remember whether that means a third (24/3) or two-thirds (8/12). 12 is the smallest number that covers the splits people actually use.
Bootstrap landed on 12 in 2011 for exactly this reason. CSS Grid later went further and let you write 1fr 2fr 1fr directly, removing the magic number. But fractions are not a free lunch — they push more work onto whoever reads your layout. r.Col(4, ...) is concretely "one third of the row." r.Col(2fr, ...) requires you to look at every sibling before you know what it means.
For PDFs, where layouts are stable and inspected by hand, the integer model wins.
What we kept from Bootstrap
Three things, and only three:
- Twelve. The denominator. The only number on the dial.
- Span as an integer 1–12. Not a fraction, not a CSS unit.
r.Col(4, ...)claims four-twelfths of the row. - The mental model. A page is a stack of rows. A row is divided into columns. Same shape as the grid you've been writing in HTML for ten years.
That's it. Now the actually interesting part.
What we threw out
Breakpoints
Bootstrap's col-md-6 col-lg-4 makes a column claim half the row on tablets and a third on desktops. Useful on the web. Meaningless in a PDF. A PDF page is a fixed canvas. There's no viewport to query, no resize event, no media query. We deleted breakpoints entirely.
The savings are larger than they look. Breakpoints are the reason CSS frameworks ship col-xs-*, col-sm-*, col-md-*, col-lg-*, col-xl-* variants — five copies of the same column class. None of them exist in gpdf. The API is r.Col(span int, fn func(*ColBuilder)). One signature. One mental slot.
Gutters
Bootstrap rows have horizontal padding between columns by default. PDFs don't need a default, because the right margin between columns depends entirely on what you're rendering — a tightly packed table has no gutter, a hero section has 24pt of breathing room, an invoice line might want 0.5pt for a separator. We chose to make spacing explicit.
If you want a gutter, you put it in: drop a c.Spacer(...) between columns, or wrap the inner content in a Box with padding. The grid itself never inserts pixels you didn't ask for. No gutter is the right default for a print medium where every point counts.
Order
CSS lets you reorder columns visually with order: 2. Useful for responsive design, where the same DOM should produce a different visual order on small screens. Useless for PDFs. The order columns appear in the file is the order they appear on the page. We never even considered adding it.
Auto-fill / auto-fit
CSS Grid has repeat(auto-fit, minmax(200px, 1fr)) — fill the row with as many 200px-minimum columns as fit. Beautiful for image galleries on the web. For a PDF, you know the page width at build time. You don't need the layout engine to figure it out.
If you want a 4-card row, write r.Col(3, ...) four times. If you want a 6-card row, write r.Col(2, ...) six times. The "auto" version is one for-loop in your own code:
for _, item := range items {
r.Col(3, func(c *template.ColBuilder) {
c.Text(item.Name)
})
}
Three lines. We didn't need to bake them into the framework.
Span-sum enforcement
Here's the surprising one: gpdf does not require column spans to sum to 12. This is on purpose.
page.AutoRow(func(r *template.RowBuilder) {
r.Col(4, func(c *template.ColBuilder) { c.Text("Left third") })
r.Col(4, func(c *template.ColBuilder) { c.Text("Middle third") })
// sum = 8. The right third is just empty.
})
The library treats each column as span/12 * row_width, period. If you put 4 + 4 in a row, the third "slot" of width is empty. If you put 7 + 8, the second column overflows past the row boundary — also intentional, because sometimes you want overflow (e.g., aligning to a layout grid that's wider than the page itself). Spans clamp to 1–12 (so Col(0, ...) becomes Col(1, ...) and Col(99, ...) becomes Col(12, ...), see gpdf/template/grid.go:120), but no auto-wrapping, no auto-balancing.
Bootstrap's old "wrap into next row when columns sum past 12" behaviour solved a real responsive problem. PDFs don't have that problem. We replaced it with a simpler contract: what you wrote is what you get.
Containers, fluid mode, no-gutters mode, offsets, push/pull
None of it. We don't ship container-fluid, col-md-offset-3, col-md-push-2, or any other Bootstrap utility-class equivalent. If you want to push a column right, wrap it: put an r.Col(3, ...) of empty content before it. Eight more characters, zero new concepts.
gpdf vs Bootstrap vs CSS Grid
| Feature | Bootstrap (CSS) | CSS Grid (CSS) | gpdf (Go) |
|---|---|---|---|
| Grid size | 12 columns | Arbitrary (grid-template-columns) | 12 columns |
| Unit | Class names | Fractions (fr), pixels, % | Integer span 1–12 |
| Breakpoints | 5 (xs/sm/md/lg/xl) | Via media queries | None |
| Default gutter | Yes (gx-* controls it) | None | None |
| Visual reorder | order-* | order property | None |
| Auto-fill | No | Yes | No |
| Wrap when sum > 12 | Yes (legacy) / No (flex) | N/A | No (overflow allowed) |
| Implementation size | ~3,000 LoC SCSS | Inside the browser | ~30 LoC Go |
The "30 LoC" number is real. Open gpdf/template/grid.go and count: a constant (gridColumns = 12), a builder method that clamps integers, and a build pass that emits one Box per row with horizontal direction and Pct(span/12*100) widths per child. There's no measurement pass, no flex algorithm, no rebalancing. The width arithmetic is the algorithm.
How gpdf renders this internally
When you call r.Col(4, fn), gpdf appends a colEntry{span: 4, fn: fn} to the row. When the document builds, each entry becomes a document.Box with Width: document.Pct(33.333…) and the column's content nested inside. The row itself is a Box with Direction: DirectionHorizontal. The PDF writer (Layer 1) walks Boxes in document order and emits content streams; the layout engine (Layer 2) does width and height resolution; the grid (Layer 3) does the integer-to-percentage conversion.
The reason this stays at 30 lines is that percentages and integers compose without rounding errors at the layout boundary. A column inside a column inside a column still ends up as a stack of Pct multiplies, all done in float64. The error budget is well below a typographic point even for deeply nested layouts.
If you want to see the chain end-to-end, why gpdf is 10× faster than alternatives covers the rendering pipeline. The grid is one of the cheapest layers in it — single-page render time on an M1 is around 13 µs, and the grid contributes a few hundred nanoseconds of that.
A complete example
Two-column header (4/8 split), then a full-width table row, then a 3/3/3/3 KPI strip:
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) {
// 4/8 split: logo block on the left, address on the right.
p.AutoRow(func(r *template.RowBuilder) {
r.Col(4, func(c *template.ColBuilder) {
c.Text("ACME, Inc.", template.FontSize(18), template.Bold())
})
r.Col(8, func(c *template.ColBuilder) {
c.Text("123 Industrial Way", template.AlignRight())
c.Text("Tokyo, Japan 100-0001", template.AlignRight())
})
})
p.Spacer(document.Mm(10))
// Full-width row (one 12-span column) for a table.
p.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Table([]string{"Item", "Qty", "Price"}, [][]string{
{"Widget A", "2", "¥1,000"},
{"Widget B", "1", "¥2,500"},
})
})
})
p.Spacer(document.Mm(10))
// KPI strip: four equal columns of 3-span each.
kpis := []struct{ label, value string }{
{"Subtotal", "¥4,500"},
{"Tax (10%)", "¥450"},
{"Shipping", "¥0"},
{"Total", "¥4,950"},
}
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)
}
That's a real, working program. go get github.com/gpdf-dev/gpdf and run it; invoice.pdf lands in your working directory. Render time on an M1: about 130 µs.
When the integer model is wrong
The integer-twelfths model is genuinely the wrong choice in two cases. Honest list, since you'll hit at least one eventually:
- You need exact pixel-perfect widths. "This column must be exactly 73.5pt wide."
Pctwon't get you there because73.5/total*12is rarely an integer. Usepage.Absolute(...)for the few elements that need fixed coordinates and let the grid handle everything else. Mixing both is fine; they live on the same page. - You need newspaper-style column flow. A paragraph that fills one column and continues into the next. The grid does not do this. We don't have a column-flow text engine yet. If you need it, file an issue — we know it's missing.
For everything else — invoices, reports, contracts, brochures, decks — the 12-grid is a tighter fit than CSS, not a looser one.
Frequently asked questions
Q: Can I change the 12 to something else, like 24?
No. gridColumns is a constant. Changing it would invalidate every existing template. We picked 12 once and committed.
Q: What if I want to nest a row inside a column?
You can. c.AutoRow(...) creates a sub-row inside the column. Spans inside the sub-row are 1–12 of the parent column's width, not the page width. Nesting composes cleanly because every level is just Pct(span/12 * 100) of its parent.
Q: Does this work for landscape pages?
Yes. The grid is page-size agnostic. r.Col(6, ...) is half the row whether the row is 210mm wide (A4 portrait) or 297mm wide (A4 landscape).
Q: Why is there no r.Col2(span, span, fn1, fn2) shortcut for two-column rows?
Because saving one line by adding API surface is a bad trade. If you find yourself repeating a row pattern, write a Go function that takes a *template.PageBuilder and adds it. The grid stays minimal so user-level patterns can grow without conflict.
Q: What about CSS Grid features like grid-area and named lines?
Not in gpdf, and not on the roadmap. The cost-benefit doesn't pencil out for PDFs.
Recap
The 12-column grid is the smallest layout primitive that handles the splits real documents need. We borrowed the number from Bootstrap, kept the integer model, and dropped breakpoints, gutters, order, auto-fill, span-sum enforcement, and the rest of the responsive-web baggage. What's left is one constant, one builder method, and one width formula — about 30 lines of Go. It composes through nesting, plays nicely with Absolute for the few cases the grid can't express, and never silently rebalances what you wrote.
Try gpdf
gpdf is a Go PDF library — MIT, zero dependencies, CJK out of the box.
go get github.com/gpdf-dev/gpdf
⭐ Star on GitHub · Read the docs
What to read next
- How does the 12-column grid work in gpdf? — the recipe version, with more code patterns
- Why gpdf is 10× faster than alternatives — internals of the rendering pipeline
- Quickstart — generate your first PDF in five minutes