All posts

From signintech/gopdf to gpdf: less coordinate math

signintech/gopdf works, but every cell, line, and header is an (x, y) calculation. This guide maps the gopdf API to gpdf — same Go, no coordinate math.

TL;DR

gpdf is a pure-Go PDF library with a 12-column layout engine. signintech/gopdf is a low-level binding around the PDF coordinate system. If you've been running gopdf for a while and the codebase is now mostly SetXY, Cell, and width arithmetic, this guide shows what those calls collapse into when there's a layout engine underneath.

Last week I sat with someone refactoring an invoice generator on signintech/gopdf. Five years of accretion. The function rendering the line-items table was 280 lines. About 40 of those did real work: format the amount, format the date, repeat for each row. The other 240 were calculating x-positions, tracking y, calling SetXY, calling Cell, calling Br, drawing border lines with Line(x1, y1, x2, y2), deciding whether the row fit on the page or needed a manual AddPage and a manual reprint of the header.

That's the gopdf experience in production. It's not a bad library. It's a thin, fast, CGO-free binding around the PDF imaging model — and that's exactly what you get. There's a cursor, there are coordinates, and the engineer is the layout engine.

This article maps the gopdf API to gpdf, function by function. The thesis is in the title: most of the lines disappear because they were doing layout math the runtime can do for you.

What signintech/gopdf is — and what it isn't

Worth getting this straight before any "migrate away" framing, because gopdf has real virtues.

It's actively maintained. It's pure Go (no CGO), so cross-compilation and Alpine images just work. It supports TrueType fonts including CJK. Output is fast — gopdf is in the same ballpark as gpdf for the imaging primitives because they're both writing PDF wire format directly without a heavyweight engine in front. The API maps cleanly onto the underlying PDF model: there's a current point, you move it, you draw at it. If you already think in PDF coordinates, gopdf is comfortable.

What it isn't, is a layout system. There's no notion of a row, a column, a flex container, or a grid. There's no automatic page break: when your content runs past the bottom margin, you get content past the bottom margin (or off the page entirely) until you call AddPage yourself. Tables don't exist as a primitive — they're a pattern you re-implement every project, with cell-by-cell Cell calls, manual border lines, and your own page-break logic.

For a one-page certificate or a very controlled fixed-template form, the cursor model is fine. For invoices, reports, statements, anything with variable-length content — the coordinate math grows with the surface area of the document. That's the workload gpdf is built for.

The mental model shift

This is the part that actually changes how the code reads. gpdf has two ideas that gopdf doesn't:

Declarative tree. You don't tell the renderer where to put things. You describe a tree of pages → rows → columns → content, and the layout engine resolves positions in a single pass. There's no cursor to advance. Two consecutive r.Col(...) calls don't need to know about each other.

12-column grid. Every row is implicitly 12 units wide. You spend those 12 units across columns: r.Col(8, ...) takes two-thirds, r.Col(4, ...) takes one-third. The grid is the same idea Bootstrap and Tailwind use for HTML, applied to PDF. You stop calculating pageWidth - leftMargin - rightMargin and dividing by 4. You write r.Col(3, ...) four times.

These two ideas remove most of the math. The before/after pairs below all collapse the same way: a cursor-advancing imperative loop becomes a small declarative tree.

The API mapping table

Cheat sheet first. Sections after walk through five concrete pairs.

What you want to dosignintech/gopdfgpdf
Constructpdf := gopdf.GoPdf{}; pdf.Start(gopdf.Config{...})doc := gpdf.NewDocument(gpdf.WithPageSize(document.A4), ...)
Set page sizeConfig{PageSize: gopdf.PageSizeA4}gpdf.WithPageSize(document.A4)
Add pagepdf.AddPage()page := doc.AddPage()
Move cursorpdf.SetX(40); pdf.SetY(80) (everywhere)(no cursor)
Single line of textpdf.SetXY(x, y); pdf.Cell(nil, "hi")c.Text("hi") (inside a column)
Wrapped textpdf.MultiCell(&gopdf.Rect{W: 200, H: 100}, body)c.Text(body) (wraps automatically)
Line breakpdf.Br(20)(implicit between rows; c.Spacer(document.Mm(4)) if you need one)
Font registrationpdf.AddTTFFont("noto", "fonts/Noto.ttf")gpdf.WithFont("Noto", ttfBytes) (at construction)
Set active fontpdf.SetFont("noto", "", 14)template.FontFamily("Noto"), template.FontSize(14) per-text
Colorpdf.SetTextColor(26, 35, 126)template.TextColor(pdf.RGBHex(0x1A237E))
Horizontal rulepdf.Line(40, 100, 555, 100)c.Line(template.LineColor(pdf.Gray(0.7)))
Rectanglepdf.RectFromUpperLeftWithStyle(x, y, w, h, "FD")c.Box(template.BgColor(...), template.Border(...))
Imagepdf.Image("logo.png", x, y, &gopdf.Rect{W: 100, H: 50})c.Image(imgBytes, template.FitWidth(document.Mm(35)))
Manual table (cell-by-cell)dozens of Cell + Line + SetXY callsc.Table(headers, rows, template.ColumnWidths(...))
Header / footerpdf.AddHeader(fn) / pdf.AddFooter(fn)doc.Header(fn) / doc.Footer(fn)
Page numberformat "Page %d of %d" from a counter you maintainc.PageNumber() / c.TotalPages() (placeholders)
EncryptConfig{Protection: PDFProtectionConfig{...}}gpdf.WithEncryption(gpdf.AES256, "user", "owner", perms)
Outputpdf.WritePdf("out.pdf")data, _ := doc.Generate(); os.WriteFile("out.pdf", data, 0o644)
Output to writerpdf.Write(w) / pdf.ToBuffer()doc.Render(w)

Two structural shifts. First, the cursor goes away. Lines marked (everywhere) in the table aren't an exaggeration — in a real gopdf codebase, SetXY calls outnumber Cell calls. They all collapse to nothing in gpdf. Second, percentages replace pixels. Rect{W: 200, H: 100} becomes "this column takes 4 of 12 units of whatever container it's in." Drop the same column inside a half-width row and it scales without changes.

Before / After 1: hello world

The shortest possible diff. Notice what's missing on the right.

Before — signintech/gopdf:

package main

import (
    "log"

    "github.com/signintech/gopdf"
)

func main() {
    pdf := gopdf.GoPdf{}
    pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
    pdf.AddPage()

    if err := pdf.AddTTFFont("helvetica", "fonts/Helvetica.ttf"); err != nil {
        log.Fatal(err)
    }
    if err := pdf.SetFont("helvetica", "", 24); err != nil {
        log.Fatal(err)
    }

    pdf.SetX(40)
    pdf.SetY(80)
    if err := pdf.Cell(nil, "Hello, World!"); err != nil {
        log.Fatal(err)
    }

    if err := pdf.WritePdf("hello.pdf"); err != nil {
        log.Fatal(err)
    }
}

After — 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)
    }
}

Two things gone. The TTF file is no longer required at runtime — Helvetica is part of the standard 14 fonts, and gpdf bundles them. The SetX(40); SetY(80) is gone — the row sits inside the page margins automatically. What's added: a row with one column spanning all 12 units. That row scaffolding looks heavy for "Hello, World!", but it's the same scaffolding that handles a 100-page report, which is the whole point.

Before / After 2: a 4-column header row

This is the place coordinate math is most visible. You want a header strip across the page with four equal cells: page width minus margins, divided by four. In gopdf you do that division. In gpdf you spend 12 units four ways.

Before — signintech/gopdf:

const (
    pageWidth   = 595.28 // A4 in points
    leftMargin  = 40.0
    rightMargin = 40.0
    rowY        = 100.0
    rowH        = 24.0
)

contentWidth := pageWidth - leftMargin - rightMargin // 515.28
colW := contentWidth / 4                              // 128.82

pdf.SetFont("helvetica-bold", "", 11)
pdf.SetFillColor(26, 35, 126)
pdf.SetTextColor(255, 255, 255)

headers := []string{"Description", "Qty", "Unit", "Amount"}
for i, h := range headers {
    x := leftMargin + colW*float64(i)
    pdf.RectFromUpperLeftWithStyle(x, rowY, colW, rowH, "F")

    pdf.SetXY(x+6, rowY+7)
    if err := pdf.Cell(nil, h); err != nil {
        log.Fatal(err)
    }
}

pdf.SetTextColor(0, 0, 0)

There are four constants. There's a width subtraction. There's a division. There's a loop with colW*float64(i) — and that float cast was only there because Go's * doesn't promote int to float64. None of those exist in the gpdf version.

After — gpdf:

page.AutoRow(func(r *template.RowBuilder) {
    headers := []string{"Description", "Qty", "Unit", "Amount"}
    for _, h := range headers {
        r.Col(3, func(c *template.ColBuilder) {
            c.Box(
                template.BgColor(pdf.RGBHex(0x1A237E)),
                template.Padding(document.Mm(2), document.Mm(3)),
            )
            c.Text(h,
                template.Bold(), template.FontSize(11),
                template.TextColor(pdf.White),
            )
        })
    }
})

r.Col(3, ...) four times sums to 12. The grid handles widths. If you change A4 to Letter, or shrink margins, the header still tiles correctly because nothing in this code depends on pageWidth at all. If you decide column 1 should be twice as wide as the other three, change r.Col(3, ...) to r.Col(6, ...) for that one and one of the others to r.Col(2, ...). No arithmetic.

Before / After 3: an invoice table that breaks across pages

The big one. In gopdf, drawing a table that flows over multiple pages is mostly bookkeeping: you track the current y, draw each row, check if the next row will fit, and if not, call AddPage and reprint the header. The state machine is in your code.

Before — signintech/gopdf:

func drawInvoiceTable(pdf *gopdf.GoPdf, items [][4]string) error {
    const (
        pageH       = 841.89 // A4 height
        bottomLimit = pageH - 40
        rowH        = 22.0
        leftX       = 40.0
        widths      = 4
    )
    cols := []float64{260, 80, 80, 95} // Description, Qty, Unit, Amount

    // Header function we'll call on first page and after page breaks.
    drawHeader := func(y float64) float64 {
        pdf.SetFont("helvetica-bold", "", 11)
        pdf.SetFillColor(26, 35, 126)
        pdf.SetTextColor(255, 255, 255)
        x := leftX
        for i, h := range []string{"Description", "Qty", "Unit", "Amount"} {
            pdf.RectFromUpperLeftWithStyle(x, y, cols[i], rowH, "F")
            pdf.SetXY(x+6, y+7)
            if err := pdf.Cell(nil, h); err != nil {
                log.Println(err)
            }
            x += cols[i]
        }
        pdf.SetTextColor(0, 0, 0)
        pdf.SetFont("helvetica", "", 11)
        return y + rowH
    }

    y := drawHeader(100)
    for _, row := range items {
        if y+rowH > bottomLimit {
            pdf.AddPage()
            y = drawHeader(60)
        }

        x := leftX
        for i, cell := range row {
            pdf.RectFromUpperLeftWithStyle(x, y, cols[i], rowH, "D") // border only
            pdf.SetXY(x+6, y+7)
            if err := pdf.Cell(nil, cell); err != nil {
                return err
            }
            x += cols[i]
        }
        y += rowH
    }
    return nil
}

The table-drawing function is 30 lines, and only 5 of them are about the data. The rest is layout: hard-coded heights, a hard-coded bottom limit, a closure to redraw the header after page breaks, two for loops, two cursor advances per cell. This is the median gopdf table.

After — gpdf:

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(12, func(c *template.ColBuilder) {
        c.Table(
            []string{"Description", "Qty", "Unit", "Amount"},
            items, // [][]string
            template.ColumnWidths(55, 15, 15, 15),
            template.TableHeaderStyle(
                template.Bold(),
                template.TextColor(pdf.White),
                template.BgColor(pdf.RGBHex(0x1A237E)),
            ),
            template.TableStripe(pdf.RGBHex(0xF5F5F5)),
        )
    })
})

That's it. Page breaks are automatic. The header repeats on every page where the body continues. Striped rows take one option. Column widths are percentages of the container, so this same table inside r.Col(6, ...) would render at half size with the same proportions, no rewrites. The 25-line gopdf bookkeeping function disappears.

A specific number worth seeing. The 100-row invoice render benchmarks at 108 µs in gpdf and roughly 2.4 ms in signintech/gopdf — and the gopdf number depends on the cell-by-cell pattern you wrote, so it varies. The factor isn't the headline; the disappearance of the function is.

Before / After 4: an image positioned next to a paragraph

Common pattern: company logo on the left, address block on the right.

Before — signintech/gopdf:

const (
    leftX  = 40.0
    rightX = 380.0
    blockY = 50.0
)

if err := pdf.Image("logo.png", leftX, blockY, &gopdf.Rect{W: 100, H: 60}); err != nil {
    log.Fatal(err)
}

pdf.SetFont("helvetica-bold", "", 14)
pdf.SetXY(rightX, blockY)
if err := pdf.Cell(nil, "ACME Corporation"); err != nil {
    log.Fatal(err)
}

pdf.SetFont("helvetica", "", 10)
pdf.SetXY(rightX, blockY+20)
pdf.Cell(nil, "1 Market Street, Suite 400")
pdf.SetXY(rightX, blockY+34)
pdf.Cell(nil, "San Francisco, CA 94103")
pdf.SetXY(rightX, blockY+48)
pdf.Cell(nil, "[email protected]")

There are six explicit y-coordinates, and the right column starts at rightX = 380 because someone decided the logo was 100 wide and the right block needed a 240-pixel gap. Move the logo to the right side and every number changes.

After — gpdf:

//go:embed logo.png
var logoData []byte

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(4, func(c *template.ColBuilder) {
        c.Image(logoData, template.FitWidth(document.Mm(35)))
    })
    r.Col(8, func(c *template.ColBuilder) {
        c.Text("ACME Corporation", template.Bold(), template.FontSize(14))
        c.Text("1 Market Street, Suite 400")
        c.Text("San Francisco, CA 94103")
        c.Text("[email protected]")
    })
})

Two columns, 4 + 8 = 12. The image fits a fixed width and lets gpdf compute the height from the aspect ratio. Each c.Text flows below the previous one — no Br, no y arithmetic. Swap the column order if you want the logo on the right.

In gopdf you maintain the count yourself, because the render is single-pass and the total isn't known when you draw the first footer. Most codebases do a two-pass workaround: render once to count pages, render again with the count baked in.

Before — signintech/gopdf:

totalPages := 0
pdf.AddFooter(func() {
    totalPages++
})

// First pass: count pages by rendering the whole document.
buildContent(&pdf)
finalTotal := totalPages

// Second pass: re-render with the known total.
pdf2 := gopdf.GoPdf{}
pdf2.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
pageNum := 0
pdf2.AddFooter(func() {
    pageNum++
    pdf2.SetFont("helvetica", "", 8)
    pdf2.SetXY(40, 800)
    pdf2.Cell(nil, fmt.Sprintf("Page %d of %d", pageNum, finalTotal))
})
buildContent(&pdf2)
pdf2.WritePdf("report.pdf")

If you've maintained gopdf code, you've written this. It's not in any FAQ, but it's the only way to get an honest "Page X of Y" footer without parsing the output.

After — gpdf:

doc.Footer(func(p *template.PageBuilder) {
    p.AutoRow(func(r *template.RowBuilder) {
        r.Col(6, func(c *template.ColBuilder) {
            c.Text("ACME Corporation",
                template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
        })
        r.Col(6, func(c *template.ColBuilder) {
            c.Stack(template.AlignRight(), func(c *template.ColBuilder) {
                c.Text("Page ", template.Inline())
                c.PageNumber(template.Inline())
                c.Text(" of ", template.Inline())
                c.TotalPages(template.Inline())
            }, template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
        })
    })
})

PageNumber and TotalPages are placeholders. The layout engine paginates first, resolves the totals, then writes them in. One pass, no manual counting, no double render. The placeholders also work mid-paragraph if you want phrasing like "5 of 12" rather than the bare numbers.

Japanese text without the manual subset

signintech/gopdf supports CJK well, but the path is character-set bookkeeping you do by hand. You add the TTF, you set the character map, and if your text contains a glyph outside the subset you registered, you get tofu. gpdf's TrueType subsetter walks the cmap (formats 4, 6, 12) and embeds exactly the glyphs you used — no manual subset list.

//go:embed NotoSansJP-Regular.ttf
var notoJP []byte

doc := gpdf.NewDocument(
    gpdf.WithPageSize(document.A4),
    gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
    gpdf.WithFont("NotoSansJP", notoJP),
    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")
    })
})

A 200-character Japanese invoice produces a ~30 KB font subset rather than a 4 MB full embed. The companion piece on embedding Japanese fonts covers IPAex Gothic and Source Han Sans fallbacks.

Benchmarks

Same hardware, same workloads, Apple M1 and Go 1.25.

Benchmarkgpdfsignintech/gopdfgofpdfMaroto v2
Single page13 µs423 µs132 µs237 µs
4×10 invoice table108 µs835 µs241 µs8.6 ms
100-page report683 µs8.6 ms11.7 ms19.8 ms
Complex CJK invoice133 µs997 µs254 µs10.4 ms

Numbers come from gpdf/_benchmark/benchmark_test.go. signintech/gopdf is faster than gofpdf on simple paths because it does less per call, but the gap widens on tables because the layout work in gpdf is internal C-style imperative code with no allocations in the hot path, while a hand-rolled gopdf table allocates strings and font metrics per cell.

The single-core throughput at 108 µs per table-rich page is roughly 9,000 invoices per second. For most workloads that means PDF generation can stay on the request path. Cron, queue, and pre-render workarounds you wrote because gopdf was 10× slower can usually be removed.

What gopdf does that gpdf doesn't

Honest section. If your gopdf usage relies on these, the migration won't carry you all the way.

  • ImportPage. gopdf can import a single page from an existing PDF and stamp content over it. This is the "PDF template" workflow — load a vendor-provided invoice template, write your data on top. gpdf's overlay support handles the common case (gpdf.Overlay), but it doesn't expose the same UseImportedTemplate primitive. If your codebase is built around drawing on top of vendor PDFs, evaluate the overlay API first.
  • Polygons and ovals as primitives. gopdf has Oval and Polygon calls. gpdf's primitive set is rectangles, lines, images, text, and tables; arbitrary path drawing is not first-class. For data visualization, render with a charting library to PNG/SVG and embed that.
  • Direct cursor positioning. If your codebase legitimately wants pixel-perfect placement (a stamp at exactly (420, 240)), page.Absolute(x, y, fn) exists, but it's the escape hatch — most code shouldn't reach for it.
  • PlaceHolderText / FillInPlaceHoldText. gopdf has a two-pass placeholder pattern for filling in text after the layout is done. gpdf's PageNumber / TotalPages placeholders handle the common case (page numbering); a general "fill this slot later" mechanism doesn't exist yet.

For invoices, statements, reports, certificates, contracts, receipts, shipping labels, packing slips, and CJK documents — what most gopdf bills actually generate — the swap is complete.

FAQ

Is gpdf a fork of signintech/gopdf? No. gpdf is a clean reimplementation in pure Go. The PDF wire writer, layout engine, TrueType subsetter, AES encryption, and PKCS#7 signing are all written from scratch. There is no shared code or shared lineage.

Both are pure Go and CGO-free. What's the actual value of switching? The layout engine. The migration sections above are 80% about removing coordinate math, and that's the lived-day-to-day difference. The benchmarks are a secondary win. The MIT license is identical to gopdf's MIT, so licensing is not a factor.

Can I migrate incrementally? Yes — the two libraries don't conflict. They produce independent []byte outputs. Render one section with gpdf, another with gopdf, and gpdf.Merge(a, b) glues them. In practice most teams find it easier to migrate a whole document at a time, because the layout shift is conceptual and partial migrations end up with both mental models in the same file.

My existing code uses pdf.Image(path, ...) to load logos from disk. Do I have to embed them? You don't have to. c.Image(imageBytes, ...) takes bytes — read the file with os.ReadFile if you want runtime loading. But //go:embed is the better default: the binary stops needing a writable filesystem at the path it expects, container images shrink to one binary, and the asset can't go missing in production.

What about gopdf.PageSizeA4 and the other page size constants? gpdf's document.A4, document.Letter, document.Legal, etc., cover the same set. For a custom size, document.PageSize(document.Mm(210), document.Mm(297)) works.

My invoice generator uses pdf.Rotate for portrait-on-landscape stamping. Is there an equivalent?page.Absolute(x, y, fn) accepts a rotation option; the typical "watermark across the page diagonally" pattern is one page.Absolute call. Per-element rotation on inline content isn't a primitive — if you need rotated cells inside a table, render that cell to an image and embed.

Is there a tool that auto-rewrites my code? Not yet. The mapping is mechanical for the simple parts (SetXY/Cellr.Col/c.Text) but the table rewrite is structural — you delete the bookkeeping rather than translating it. Hand-migration of a typical generator takes a few hours per document type.

Try gpdf

gpdf is a Go library for generating PDFs. MIT licensed, zero external dependencies, native CJK support, 12-column grid layout.

go get github.com/gpdf-dev/gpdf

⭐ Star on GitHub · Read the docs

Next reads