← Back to writing

Building a Pure Go zero-dependency PDF library: gpdf design philosophy

Why I avoided CGO and wrote gpdf from scratch: the decisions behind a Pure Go, zero-dependency PDF library with native CJK font support.

The night I decided to write it from scratch

In January 2026, I spent three hours looking for a Go PDF generation library. The results were not encouraging. gofpdf was archived in 2021. go-pdf/fpdf, the most active fork, was archived in 2025. UniPDF is commercial, which doesn't fit the budget of a solo developer. gopdf is active but low-level enough that practical document generation requires substantial glue code on every use. Maroto is a higher-level layout library built on top of gofpdf — meaning it inherits the same archival dead end.

The Go ecosystem has no shortage of PDF libraries if all you need is Latin text in a standard font. The gap is specifically in maintained, production-ready libraries that handle CJK font embedding (critical for Japanese, Chinese, and Korean document generation), work without external dependencies, and have an API surface that doesn't require reimplementing basic layout primitives from scratch. That's the gap gpdf was written to fill.

After eliminating each option, the conclusion was quiet: write it myself.

Before writing a single line, I set one constraint: no CGO. No external C library dependencies. go.mod ends up with an empty require block. That single constraint determined nearly every design decision that followed in gpdf.

CGO: the path not taken

CGO would have made the font subsetting work much easier. FreeType is the proven C library for TrueType/OpenType font rendering, and HarfBuzz handles text shaping with real-world production coverage. Calling either via CGO would have let me lean on existing implementations for CJK font subsetting.

But the moment you choose CGO, you lose something important: cross-compilation freedom.

# With Pure Go, this is all it takes for any target architecture
GOOS=linux GOARCH=arm64 go build -o app ./cmd/app

CGO requires a C compiler for the target architecture in your CI environment. That means pulling aarch64-linux-gnu-gcc into your Docker images, managing cross-compile toolchain caches, and dealing with C header path configuration. For a solo developer, that overhead compounds quickly. It also propagates outward: any project that imports gpdf would inherit the CGO constraint, requiring their users to set up C toolchains too. A library choice shouldn't impose build system complexity on everyone downstream.

The other consideration is distribution simplicity. A Pure Go single binary has zero runtime dependencies. Users run go get and go build; the result just works. Embedding in a Docker image is a single COPY ./binary /app/binary. With CGO, you're asking users to verify ldd output and manage LD_LIBRARY_PATH — cognitive overhead that belongs to you, not your library's users.

This matters practically in production Go deployment patterns. Alpine-based Docker images (which lack glibc by default) frequently cause runtime errors with CGO binaries that link against glibc. The debugging cycle — figure out which shared library is missing, add the right Alpine package, rebuild the image — is a solved problem that Pure Go eliminates entirely. Library users get to stay in their workflow; they don't inherit your implementation choices as operational overhead.

One of the design principles running through nadai projects is: don't push adoption friction onto the people using your tools. For gpdf, that meant making the build environment requirements as close to zero as possible. That's where the CGO-free constraint comes from.

There's a personal reason too. CGO development experience is significantly rougher than Pure Go development. Header path configuration, cgo build tags, memory management at the C/Go type boundary. Keeping CGO out meant I could focus on the actual problem — generating correct PDF output — rather than managing a mixed-language build system.

Why not fork gofpdf

The decision to write from scratch came after seriously evaluating the fork option.

go-pdf/fpdf was reasonably active until shortly before its 2025 archive. It had name recognition among gofpdf users and served as a migration path for the existing community. Taking over that codebase was a realistic option.

Two things ruled it out.

First: the CGO dependency inheritance problem. gofpdf's font handling had CGO-dependent implementation paths. Making it Pure Go would require rewriting the font processing core entirely. If I was rewriting the core anyway, there was no good reason to keep the rest of the forked structure around — the rewrite scope was nearly equivalent to writing from scratch, with the added confusion of leftover fork code.

Second: API design freedom. A fork inherits its API surface. Existing users have written code against that interface. The chainable API style I wanted to experiment with in gpdf would have been difficult to introduce on top of gofpdf's existing design without breaking changes. Starting from zero meant no inherited design debt.

Worth noting: Maroto's chainable style was useful as a reference for higher-level API design. But since Maroto itself depends on gofpdf, it was never a direct adoption candidate.

The combined effect of these two constraints — CGO inheritance and API lock-in — meant that forking would have cost nearly as much development effort as writing from scratch, while delivering less design freedom. The calculus wasn't close.

What PDF generation actually involves

After committing to no CGO and no fork, the work that followed was reading the PDF specification (ISO 32000-2:2020) and assembling binary output directly in Go.

PDF structure is simpler than it looks and more exacting than you'd expect. The foundation is an object graph paired with a cross-reference table (xref). The file body is a sequence of objects (1 0 obj ... endobj), followed by the xref table. The xref records the byte offset of each object — PDF viewers use it to jump directly to any object without scanning the entire file.

Pages, fonts, images, and text content are all objects. Text placement happens inside content streams: sequences of PDF operators enclosed in BT...ET blocks. The Tf operator specifies the font and size, Td moves the text position, and Tj draws a string. For Japanese text, Tj doesn't take a Unicode string — it takes CID (character identifier) values in hexadecimal encoding, derived from the font's cmap table mapping.

Stream objects are usually FlateDecode-compressed (zlib), and the Length entry in the stream dictionary must match the compressed byte count exactly. The surface looks clean; the correctness requirements are unforgiving.

Two details that bit me early: page resources and font embedding. Each page object references a resource dictionary listing the fonts, images, and graphics states used on that page. The font objects themselves contain the embedded font data and character encoding information. Building those reference chains correctly — object number assignment, cross-reference registration, resource dictionary population — requires tracking state across the entire document assembly process, not just the current page or element being drawn.

The PDF content model is fundamentally a graph with strict consistency requirements rather than a stream you write to sequentially. That framing shift made the implementation architecture clearer once I had it, but the first few weeks involved a lot of reading spec sections I didn't fully understand until the second or third pass.

The xref integrity problem

The hardest implementation problem was xref table integrity.

Every object's byte offset must be recorded accurately. Writing bytes to an io.Writer while simultaneously tracking "what is the current file offset right now" is straightforward in theory. My first implementation used bufio.Writer. The buffering introduced a gap between bytes written to the Go buffer and bytes actually flushed to the underlying writer — which meant the offsets I was recording didn't match the actual positions in the output file.

The result: Adobe Acrobat either crashed or silently returned "cannot read this file."

I used Didier Stevens' pdf-parser.py for debugging — it scans raw PDF binary and computes each object's actual byte offset, which you can compare against the recorded xref values. Once I found the discrepancy, tracing it back to the bufio flush timing took another day.

The fix was a countWriter wrapper that tracks byte count precisely as data flows through. Drop bufio.Writer, use countWriter for position tracking. Simple in retrospect. But learning Go's io.Writer interface and bufio.Writer flush semantics by debugging PDF binary output was not how I expected to spend that week. I can't say I entirely regret it, but that particular week I wouldn't mind having back.

The trailer dictionary and startxref handling were straightforward from the spec. The xref offset issue had a different quality: one wrong offset causes a total parse failure with no error message, which meant debugging in complete silence.

Implementing CJK font subsetting in Pure Go

The highest-difficulty piece of building a CGO-free PDF library is font subsetting, specifically for CJK (Chinese, Japanese, Korean) fonts.

Embedding a full font in a PDF document is impractical for CJK: Japanese fonts routinely contain 10,000+ glyphs, making font files 10MB or larger. The standard approach is to extract only the glyphs used in the document and embed that subset. gpdf implements this subsetting entirely in Pure Go.

The implementation required writing a binary parser for TrueType and OpenType font tables. The key tables and what they provide:

  • cmap: maps Unicode code points to glyph IDs
  • glyf: glyph outlines (quadratic Bézier control points)
  • loca: maps glyph IDs to offsets within the glyf table
  • hmtx: horizontal advance widths and side bearings

Subsetting means: identify the used glyph IDs via cmap, extract their outlines and metrics, recalculate loca offsets, and assemble a new font binary containing only those glyphs. The assembled subset must be a valid TrueType/OpenType binary — offsets, lengths, and checksums all recalculated — or the PDF viewer either rejects it or renders blank space where text should be.

One non-obvious detail: the font tables have interdependencies. The head table contains a checkSumAdjustment value computed over the entire font file. The loca table format (short vs. long offsets) is specified by a flag in head. Rebuilding a subset font means touching multiple tables in the right order, not just swapping out the glyf content.

The complication that wasn't obvious from the spec at first glance: composite glyphs. The glyf table allows one glyph to reference other glyphs — this is common in CJK characters and emoji, where complex shapes are composed from simpler components. Correct subsetting requires recursively tracing all referenced glyphs, or the composed character renders as garbage. After implementing subsetting without composite glyph resolution, testing against Noto Sans JP produced character corruption in exactly the compound characters I hadn't accounted for.

This took two weeks. There were moments where I almost talked myself into calling FreeType via CGO and being done with it. The specific low point was around day 10, when the subsetting code was producing valid-looking font binaries that PDF viewers consistently rejected — but only for certain characters, and only in specific font files. The failure mode was composite glyph references pointing to glyph IDs that didn't exist in the subset I'd assembled.

Adding the recursive glyph dependency resolution fixed it. The logic is: before finalizing the subset, walk every selected glyph's outline data; if it's a composite glyph, add all referenced component glyph IDs to the selection set and repeat until no new IDs appear. Simple breadth-first traversal once you see it, but the spec's description of composite glyph format is dense enough that I missed the implication on first read.

When it finally worked in Pure Go, the CGO constraint had fully paid off: the build pipeline was clean, cross-compilation worked without any toolchain setup, and the single binary story held together. That outcome still feels like the right call.

The practical implication: gpdf can generate Japanese, Chinese, and Korean documents with zero external dependencies. That's the foundation behind listing CJK support as a core selling point in gpdf.

What gpdf v1.0.0 supports

gpdf v1.0.0 was released under the MIT License in March 2026. The release represents roughly four months of part-time development — the period from initial design decision through stable CJK font subsetting, PDF/A compliance, and digital signature support. Current capabilities:

  • Text placement with TrueType/OpenType font embedding
  • CJK font subsetting (Pure Go, zero external dependencies)
  • Table, image, list, and layout API
  • PDF/A compliant output
  • Digital signatures
  • Barcode and QR code rendering
  • FlateDecode stream compression

Usage looks like this:

doc := gpdf.New()
doc.AddPage()
if err := doc.SetFont("NotoSansJP-Regular.ttf", 14); err != nil {
    return err
}
doc.Text(20, 20, "Generated with Pure Go")

f, err := os.Create("output.pdf")
if err != nil {
    return err
}
defer f.Close()
return doc.Output(f)

No CGO, no external dependencies. go get github.com/gpdf-dev/gpdf and go build includes it in a single binary. Cross-compilation with GOOS=linux GOARCH=arm64 go build works without any C toolchain configuration.

That last point is worth sitting with. Writing a Japanese-language PDF in a Go service deployed to ARM64 Kubernetes nodes — the entire pipeline works with standard go build flags. No Dockerfile changes, no gcc installs, no shared library pinning. For a solo developer running infrastructure alone, every piece of accidental complexity that disappears is compounding time saved.

Responding to "no gofpdf compatibility means no adoption"

After publishing gpdf, I heard several times: "without gofpdf API compatibility, existing users won't migrate."

That observation is accurate. The premise it rests on is wrong.

gpdf is not designed as a migration target for gofpdf users. gpdf targets developers starting fresh with Go PDF generation. With gofpdf archived since 2021, new projects searching for a maintained Go PDF library aren't committed to any existing API. That population is growing as legacy gofpdf users look for exits. Offering a well-designed API and a genuinely zero-dependency build story is the right play for that audience.

Implementing a compatibility layer would add design constraints and make internal evolution harder — trading future API quality for a migration shortcut that mainly benefits a population already invested in the old API surface. Writing a gofpdf-to-gpdf migration guide would have been a better use of the same effort.

The API choices in gpdf — the chainable interface style, the error handling patterns, the configuration model — were optimized for "Go-idiomatic and easy to embed", not "familiar to gofpdf users." That trade-off was intentional.

There's also a longer-term view here. gofpdf accumulated significant maintenance debt precisely because its API surface was fixed by early adoption. Subsequent contributors couldn't change signatures without breaking thousands of existing projects. gpdf starts clean, which means API evolution is possible as real usage feedback accumulates. That flexibility has value that a compatibility shim would have eliminated before gpdf shipped its first release.

What comes next

The gpdf API design choices — specifically how I chose between chainable and builder patterns — are worth a separate post. That decision involved different tradeoffs from the ones covered here, and it shaped the day-to-day ergonomics of the library more than any of the low-level implementation choices. That's coming in a follow-up article.

For the wider picture of running Pure Go OSS alongside a day job, Pure Go micro SaaS on the side: where I stand covers how gpdf, gsql, and the other nadai projects fit together.

The code is at github.com/gpdf-dev/gpdf. Issues and PRs are open.

© 2026 nadai · JapanSet in Instrument Serif · Inter