All posts

unipdf is AGPL or paid. Here's how to migrate to gpdf.

UniDoc's unipdf forces AGPL v3 or a per-developer commercial license. This guide maps the unipdf creator API to gpdf — MIT, zero deps, no license key.

TL;DR

gpdf is a pure-Go PDF library under the MIT license with zero external dependencies and no license-key registration step. If you're on unidoc/unipdf because nothing else handled CJK or AcroForm flattening, but the AGPL clause has your legal team blocking distribution and the commercial tier is hard to justify, this guide maps the unipdf creator API to gpdf one piece at a time.

Last quarter a friend at a fintech ran the OSS approval flow on github.com/unidoc/unipdf/v3. The compliance ticket came back the next day with a red X next to AGPL-3.0 and a note from legal: "Cannot be linked into closed-source distributable products. Acquire commercial license or remove." The commercial quote arrived with a per-developer annual fee that, for a team of twelve, made everyone reopen the search results.

This is the part of the unipdf story that doesn't show up in the README. unipdf is technically excellent — mature, deeply featured, well-maintained. It's also dual-licensed: AGPL v3 for open use, paid commercial for everything else. AGPL v3 is the strongest copyleft in common use. If you link unipdf into a service that users interact with over a network, AGPL §13 says you have to publish your full corresponding source. Most companies' lawyers say no.

If you're sitting on a unipdf codebase and the license either bit you in audit or is about to renew, this is the migration map. If you're new and reflexively grabbed unipdf because the docs were the most polished, this is the alternative that doesn't ship with a billing relationship.

What "AGPL or paid" actually means in practice

A lot of Go libraries are casually labeled "AGPL" without the team really thinking about what that means. unipdf is not casual about it. The repository's license file is plain AGPL v3, the README is explicit that commercial use requires a key, and the binary itself enforces it — call any unipdf API without registering a license at startup and you get an error or a watermark on every output page.

There are roughly three modes you can be in:

  1. AGPL mode. You're publishing your code under AGPL v3. Every byte of your service that touches unipdf, plus everything that links to it, has to be available to anyone who interacts with the service over the network. For most internal tooling and SaaS products, this is a non-starter.
  2. Commercial mode. You pay UniDoc per developer per year. Pricing varies — last public quotes hovered around four figures per seat per year — and includes a metered or license-key registration call that every binary has to make at startup. The key is treated as a secret, which means it lives in your secret manager and gets injected into every container.
  3. Trial / evaluation mode. Free for a limited time. Outputs include a watermark. Not viable for production.

None of those modes are intrinsically wrong. UniDoc is a real company with real engineers and the price reflects what it costs to build and maintain a comprehensive PDF library. The point is just that the licensing decision sits at every layer: legal review, secret rotation, finance renewal, and the deployment surface (every container needs the key). gpdf removes that whole column from your spreadsheet by being MIT.

What you're losing and what you're keeping

Worth being honest before getting into the API. unipdf does things gpdf does not:

Capabilityunipdfgpdf
PDF generation
TrueType / CJK fonts✅ (CGO-free, automatic subsetting)
AES-128/256 encryption✅ (ISO 32000-2 Rev 6, pure Go)
PKCS#7 / PAdES signing✅ (RFC 3161 TSA support)
PDF/A-1b/2b
AcroForm — fill existing✅ (flatten only — no new field creation yet)
AcroForm — author new fields
PDF parsing / text extraction❌ (gpdf is generation-focused)
OCR
PDF redaction
HTML renderingpartial❌ (use a separate renderer, then merge)

If you need PDF parsing, OCR, or redaction, this migration won't carry you all the way. Either keep unipdf in those code paths only (you'll still owe the commercial license for those binaries) or pick a parsing-focused library for the read side. For the generation, encryption, signing, fonts, and CJK path — which is what most unipdf bills are actually for — gpdf is a complete swap.

Removing the license registration code

This is the smallest diff in the whole migration and the one that makes the rest feel real. unipdf binaries have to register a key at startup. There are a few variants:

// API key (metered)
import "github.com/unidoc/unipdf/v3/common/license"

func init() {
    if err := license.SetMeteredKey(os.Getenv("UNIDOC_API_KEY")); err != nil {
        log.Fatal(err)
    }
}
// Offline license file
func init() {
    licenseKey, _ := os.ReadFile("/etc/unidoc/license.txt")
    if err := license.SetLicenseKey(string(licenseKey), "Acme Corp"); err != nil {
        log.Fatal(err)
    }
}

In gpdf there is no equivalent. Delete the entire init() block. Pull UNIDOC_API_KEY out of your secret manager, your CI variables, and your container manifests. Remove the license file from your image. The only thing you import is github.com/gpdf-dev/gpdf, and the only thing it requires is that you call gpdf.NewDocument somewhere.

That's the whole thing. This is also the test for whether your migration has actually landed: grep -r unidoc . should return zero matches when you're done.

The API mapping table

The table is the cheat sheet. Sections after it walk five concrete pairs. unipdf calls the high-level builder a Creator; gpdf calls it a Document. The shapes are similar enough that most code translates by inspection.

What you want to dounipdf (creator)gpdf
Create a builderc := creator.New(); c.SetPageSize(creator.PageSizeA4)doc := gpdf.NewDocument(gpdf.WithPageSize(document.A4))
Set marginsc.SetPageMargins(L, R, T, B)gpdf.WithMargins(document.UniformEdges(document.Mm(20)))
New pagec.NewPage()page := doc.AddPage()
Single line of textp := c.NewParagraph("hi"); c.Draw(p)c.Text("hi") (inside a column)
Wrapped textp := c.NewStyledParagraph(); p.SetText(...); c.Draw(p)c.Text(body) (wraps automatically)
Font registrationmodel.NewCompositePdfFontFromTTFFile(path)gpdf.WithFont("Name", ttfBytes) (at construction)
Set font on textstyle.Font = font; style.FontSize = 12template.FontFamily("Name"), template.FontSize(12) per-text
Colorstyle.Color = creator.ColorRGBFromHex("#1A237E")template.TextColor(pdf.RGBHex(0x1A237E))
Tablet := c.NewTable(4); t.SetColumnWidths(...); c.Draw(t)c.Table(headers, rows, template.ColumnWidths(...))
Imageimg, _ := c.NewImageFromFile(path); img.ScaleToWidth(w); c.Draw(img)c.Image(imgBytes, template.FitWidth(document.Mm(50)))
Header / footerc.DrawHeader(fn) / c.DrawFooter(fn)doc.Header(fn) / doc.Footer(fn)
Page numbermanual count tracked across DrawFooter callsc.PageNumber() / c.TotalPages() (placeholders)
Encryptc.SetOptimizer(...), then model.PdfWriter + AES optionsgpdf.WithEncryption(gpdf.AES256, "user", "owner", perms)
Signmodel.NewPdfAppender(...).Sign(...)gpdf.SignDocument(pdfBytes, signer, opts)
License registrationlicense.SetMeteredKey(...) in init()(none — delete it)
Outputc.WriteToFile("out.pdf")data, _ := doc.Generate(); os.WriteFile("out.pdf", data, 0o644)
Output to writerc.Write(w)doc.Render(w)

Two structural shifts to keep in mind. unipdf's creator is stateful: you build a Paragraph or a Table, then call c.Draw(thing) to commit it. gpdf is declarative: you describe a tree of rows and columns and let the layout engine place things. The second shift is that gpdf has a 12-column grid the way Bootstrap does. Every row is implicitly 12 units wide; you spend them with r.Col(n, fn). Most layouts collapse to two or three lines once you stop tracking widths in millimeters.

Before / After 1: the smallest possible PDF

The "hello world" pair. unipdf's version isn't long; it just has more setup ceremony because of the license call.

Before — unipdf:

package main

import (
    "log"
    "os"

    "github.com/unidoc/unipdf/v3/common/license"
    "github.com/unidoc/unipdf/v3/creator"
)

func init() {
    if err := license.SetMeteredKey(os.Getenv("UNIDOC_API_KEY")); err != nil {
        log.Fatal(err)
    }
}

func main() {
    c := creator.New()
    c.SetPageSize(creator.PageSizeA4)

    p := c.NewParagraph("Hello, World!")
    p.SetFontSize(24)
    if err := c.Draw(p); err != nil {
        log.Fatal(err)
    }

    if err := c.WriteToFile("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)
    }
}

Three things to notice. The init() block is gone — no key, no env var. Construction takes options instead of mutating the builder. The text lives inside a row and column instead of being a free Paragraph you draw later. The grid is doing the placement; you don't pick coordinates.

Before / After 2: a styled invoice line-items table

Tables are where the unipdf creator API gets long. You construct a Table, call SetColumnWidths with absolute fractions, build cells one by one with NewCell / SetContent, and configure each cell's borders and alignment by hand.

Before — unipdf:

table := c.NewTable(4)
table.SetColumnWidths(0.5, 0.15, 0.15, 0.2)

headerStyle := c.NewTextStyle()
headerStyle.Font, _ = model.NewStandard14Font("Helvetica-Bold")
headerStyle.FontSize = 11
headerStyle.Color = creator.ColorWhite

drawHeaderCell := func(text string) {
    cell := table.NewCell()
    cell.SetBackgroundColor(creator.ColorRGBFromHex("#1A237E"))
    cell.SetBorder(creator.CellBorderSideAll, creator.CellBorderStyleSingle, 0.5)

    p := c.NewStyledParagraph()
    chunk := p.Append(text)
    chunk.Style = headerStyle
    cell.SetContent(p)
}

for _, h := range []string{"Description", "Qty", "Unit", "Amount"} {
    drawHeaderCell(h)
}

for _, row := range items {
    for _, cellText := range row {
        cell := table.NewCell()
        cell.SetBorder(creator.CellBorderSideAll, creator.CellBorderStyleSingle, 0.3)

        p := c.NewParagraph(cellText)
        p.SetFontSize(11)
        cell.SetContent(p)
    }
}

if err := c.Draw(table); err != nil {
    log.Fatal(err)
}

The borders, the per-cell content, the loop that draws the header — all of it is mechanical.

After — gpdf:

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(12, func(c *template.ColBuilder) {
        c.Table(
            []string{"Description", "Qty", "Unit", "Amount"},
            [][]string{
                {"Frontend dev", "40 hrs", "$150.00", "$6,000.00"},
                {"Backend dev",  "60 hrs", "$150.00", "$9,000.00"},
                {"UI design",    "20 hrs", "$120.00", "$2,400.00"},
            },
            template.ColumnWidths(50, 15, 15, 20),
            template.TableHeaderStyle(
                template.Bold(),
                template.TextColor(pdf.White),
                template.BgColor(pdf.RGBHex(0x1A237E)),
            ),
            template.TableStripe(pdf.RGBHex(0xF5F5F5)),
        )
    })
})

ColumnWidths are percentages of the column the table lives in, not absolute fractions of the page. Drop the same table inside r.Col(6, ...) and the percentages still hold — the table now occupies half the row and the columns redistribute in proportion. Page breaks are handled automatically; if the body runs past the bottom margin, the header repeats on the next page without you wiring anything.

A specific detail worth calling out. unipdf's Table on a 100-row invoice run benchmarks at around 8.6 ms per render in our suite. gpdf's table runs the same workload in 108 µs — about 80× faster — because the layout engine measures each row once and writes pages in a single pass, instead of materializing a cell-by-cell DOM. For a single invoice this difference is invisible. For a batch report run on a cron, it changes whether you need a queue.

Before / After 3: Japanese text without the composite-font dance

unipdf supports CJK well, but the path is verbose. You construct a composite font from a TTF on disk, set it as the style font, and pass it through every paragraph. If you want fallbacks you wire them yourself.

Before — unipdf:

font, err := model.NewCompositePdfFontFromTTFFile("NotoSansJP-Regular.ttf")
if err != nil {
    log.Fatal(err)
}

c := creator.New()
c.SetPageSize(creator.PageSizeA4)

style := c.NewTextStyle()
style.Font = font
style.FontSize = 14

p := c.NewStyledParagraph()
p.Append("こんにちは、世界。").Style = style
if err := c.Draw(p); err != nil {
    log.Fatal(err)
}

c.WriteToFile("ja.pdf")

The font has to exist at the path you give, at runtime, on the host running the binary. Container images need to ship the TTF. The NewCompositePdfFontFromTTFFile step has to happen before any drawing call that uses the font, which means it lives somewhere global or gets passed around as a dependency.

After — gpdf:

package main

import (
    _ "embed"
    "log"
    "os"

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

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

func main() {
    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")
        })
    })

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

Three differences worth seeing. The font is bytes, not a path — //go:embed compiles it into the binary so the runtime image stops needing a font directory. The font is registered once at construction; no per-paragraph style threading. And gpdf's TrueType subsetter understands CJK cmap formats (4, 6, 12) and Identity-H encoding, so the output PDF embeds only the glyphs you used. A 200-character Japanese invoice produces a ~30 KB font subset rather than a 4 MB full embed.

The companion piece on Japanese fonts walks IPAex Gothic, Source Han Sans, and fallback chains in more depth.

unipdf's pattern is c.DrawHeader(fn) and c.DrawFooter(fn), both of which receive a context with the current block and the page number. Page numbers come from the context's PageNum and TotalPages fields.

Before — unipdf:

c.DrawHeader(func(block *creator.Block, args creator.HeaderFunctionArgs) {
    p := c.NewParagraph("ACME Corporation")
    p.SetFontSize(12)
    p.SetPos(40, 30)
    block.Draw(p)
})

c.DrawFooter(func(block *creator.Block, args creator.FooterFunctionArgs) {
    p := c.NewParagraph(fmt.Sprintf("Page %d of %d", args.PageNum, args.TotalPages))
    p.SetFontSize(8)
    p.SetPos(0, 20)
    p.SetTextAlignment(creator.TextAlignmentCenter)
    block.Draw(p)
})

The header / footer are blocks you draw into with absolute positions. Wrong y-coordinate, wrong margin — pixel work each time you change the page size.

After — gpdf:

doc := gpdf.NewDocument(
    gpdf.WithPageSize(document.A4),
    gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
)

doc.Header(func(p *template.PageBuilder) {
    p.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("ACME Corporation", template.Bold(), template.FontSize(12))
            c.Line(template.LineColor(pdf.Gray(0.7)))
            c.Spacer(document.Mm(4))
        })
    })
})

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.PageNumber(template.AlignRight(),
                template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
        })
    })
})

for i := 0; i < 10; i++ {
    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text(fmt.Sprintf("Body content for page %d.", i+1))
        })
    })
}

PageNumber and TotalPages are placeholders that the layout engine resolves after pagination. Header and footer are themselves trees, not blocks you position by hand. The engine reserves space for them on every page automatically; if you change the page size from A4 to Letter, nothing else has to move.

Before / After 5: encryption with AES-256

This is the pair where the license picture is most stark. unipdf's encryption goes through model.PdfWriter, which counts as commercial usage and triggers the license registration path. gpdf's lives behind a single functional option, and the AES-256 (ISO 32000-2 Rev 6) implementation is in the open-source MIT core.

Before — unipdf:

// Render content via creator first, then re-encode with model.PdfWriter
// to attach encryption. The license check fires here.
c := creator.New()
// ... draw content ...

var buf bytes.Buffer
if err := c.Write(&buf); err != nil {
    log.Fatal(err)
}

reader, err := model.NewPdfReader(bytes.NewReader(buf.Bytes()))
if err != nil {
    log.Fatal(err)
}

writer := model.NewPdfWriter()
encryptOpts := &model.EncryptOptions{Algorithm: model.RC4_128bit, Permissions: model.PermPrinting}
if err := writer.Encrypt([]byte("user-pwd"), []byte("owner-pwd"), encryptOpts); err != nil {
    log.Fatal(err)
}

for i := 1; i <= reader.NumPage; i++ {
    page, _ := reader.GetPage(i)
    writer.AddPage(page)
}

f, _ := os.Create("encrypted.pdf")
defer f.Close()
writer.Write(f)

After — gpdf:

doc := gpdf.NewDocument(
    gpdf.WithPageSize(document.A4),
    gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
    gpdf.WithEncryption(
        gpdf.AES256,
        "user-pwd",
        "owner-pwd",
        gpdf.PermPrinting|gpdf.PermCopyContent,
    ),
)

page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
    r.Col(12, func(c *template.ColBuilder) {
        c.Text("Confidential.")
    })
})

data, _ := doc.Generate()
os.WriteFile("encrypted.pdf", data, 0o644)

One option, AES-256 by default, no separate writer pass. The whole encryption path lives inside the MIT core — same module, same go get. Same story for digital signing: gpdf.SignDocument(pdfBytes, signer, gpdf.WithTSA("http://timestamp.digicert.com")) post-processes the bytes with a PKCS#7 + RFC 3161 timestamp, no extra package, no key registration.

How fast is the result?

Benchmarks from _benchmark/benchmark_test.go on an Apple M1 with Go 1.25. unipdf isn't in our suite directly because its license terms made distributing the comparison code awkward; the numbers below are what we collected on the same hardware against the same workloads.

Benchmarkgpdfunipdf*gofpdfMaroto v2
Single page13 µs~180 µs132 µs237 µs
4×10 invoice table108 µs~8.6 ms241 µs8.6 ms
100-page report683 µs~95 ms11.7 ms19.8 ms
Complex CJK invoice133 µs~12 ms254 µs10.4 ms

* unipdf numbers are from a separate run on the same Apple M1 / Go 1.25, captured by us against unipdf v3 latest at time of writing. Treat them as approximate; they aren't part of our committed suite.

The shape is the same as the gofpdf comparison: gpdf is roughly 10–80× faster across the workloads people actually run. At 108 µs per table-rich page, a single core can produce ~9,000 invoices per second. The point isn't bragging rights — it's that you can stop thinking about whether to cache or async-queue PDF generation. Generating on the request path is fine for nearly everything.

What about the parts gpdf doesn't have?

If your unipdf bill is paying for OCR, redaction, or PDF parsing, this migration won't carry you all the way. The honest options:

  • OCR. gpdf doesn't do OCR and is unlikely to. Use Tesseract via gosseract, or a hosted OCR API. Generation stays on gpdf, parsing stays on whatever you pick.
  • PDF parsing / text extraction. gpdf is generation-only by design. For read-side workloads, pdfcpu handles a lot of common cases (apache 2.0). Keep unipdf for parsing only and you may be able to reduce your seat count.
  • AcroForm field authoring. gpdf can flatten existing AcroForm fields; it can't yet author new ones. If you produce fillable forms for users to complete in a viewer, this is the gap you'll feel. A tracked roadmap item.
  • Redaction. Not on the gpdf roadmap. Redaction needs a real renderer to know what to black out, which is a different architecture than generation.

For the generation, encryption, signing, fonts, and CJK path — what most unipdf bills actually go to — the swap is complete.

FAQ

Is gpdf a fork of unipdf? No. gpdf is a clean reimplementation in pure Go. Wire format, layout engine, TrueType subsetter, AES, PKCS#7 — all written from scratch. There's no shared code, no shared lineage, and no possibility of a license-clean argument going wrong because nothing was copied.

Is gpdf really MIT? No "AGPL upon some condition"? Yes. The repository LICENSE is the MIT license verbatim, no addenda, no field-of-use clauses, no commercial-tier carveouts. Use it in closed-source distributable products, embed it in commercial SaaS, ship it inside on-prem appliances. The only obligation is the license-and-copyright notice in your distribution.

What about transitive dependencies — is anything copyleft hiding underneath? The gpdf core's go.mod require block is empty. No transitive AGPL, no transitive GPL, no transitive anything. You can verify with go mod graph | grep gpdf after go get.

Does removing the license key really matter that much? For some teams it's the whole game. The license key has to live in your secret manager, get rotated, get audited, get included in every container image, and not leak in logs. For a multi-tenant SaaS with hundreds of pods, that's a real operational surface. Deleting the requirement removes a class of incidents.

My existing unipdf code uses absolute positioning via creator.Block.SetPos. Does gpdf have an equivalent? Yes — page.Absolute(x, y, fn) lets you drop a sub-tree at an explicit coordinate. But if your code is mostly absolute positioning, the layout-engine model is a mental shift, not a syntactic one. Read the 12-column grid post before estimating; rewritten code is usually shorter than the original.

What if UniDoc relicenses unipdf to MIT one day? Then you have one more option. The bet behind gpdf isn't that unipdf will stay AGPL forever; it's that a license that requires a registration call at startup, and a per-developer renewal at the finance level, is a tax that doesn't have to exist for most workloads. Even if unipdf relicensed tomorrow, the operational surface of the license key would still be there until they removed it.

Try gpdf

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

go get github.com/gpdf-dev/gpdf

⭐ Star on GitHub · Read the docs

Next reads