A pure-Go, zero-C PDF 1.7 writer with a Go-idiomatic API. It embeds TrueType and OpenType/CFF fonts as subsetted composite fonts, draws vector graphics and text, and places JPEG and raster images. Fonts are parsed and shaped with go-opentype; nothing outside the Go standard library and our own pure-Go libraries is required.
pdfkit is the Go-native counterpart to our Ruby Prawn port
(go-ruby-prawn); here the API is
Go-idiomatic rather than a gem port.
- Documents — catalog, page tree, cross-reference table and trailer; write
to any
io.Writer; optional Flate stream compression. - Graphics — paths (move/line/cubic/rect/close), fill & stroke (nonzero and
even-odd), DeviceGray/RGB/CMYK colour, line width/cap/join/miter/dash, CTM
transforms (translate/scale/rotate/skew), clipping,
q/Qstate, and constant-alpha transparency via ExtGState. - Text — embeds fonts as Type0 (Identity-H) composite fonts with glyph
subsetting, a per-glyph
/Wwidth array and a/ToUnicodeCMap for copy/paste. TrueTypeglyf→ FontFile2 / CIDFontType2 (compact subset with a/CIDToGIDMapstream); CFF/OpenType → FontFile3 / CIDFontType0 with CFF charstring subsetting (only the used glyphs' charstrings are embedded). Char/word spacing, leading, render modes, a simple wrapping helper, and an optional shaped-text API (GSUB/GPOS via go-opentype) for Arabic/Indic/CJK. A layout engine that places its own glyphs draws them withPage.ShowGlyphs— glyph ids at the positions it computed, each with the text it stands for — and hands over the fonts it already parsed withNewFont(*opentype.Font). - Gradients — axial (type 2) and radial (type 3) shadings with any
number of stops, painted through the clip (
Page.Shade). - Images — JPEG embedded directly (DCTDecode); PNG and any
image.Imagerasterised as XObjects (FlateDecode) with an/SMaskfor alpha, the colour samples written straight (not premultiplied), as a soft mask without/Matterequires. The JPEG marker walk reads frame geometry the way libjpeg andimage/jpegdo — skipping fill bytes,X'FF00'stuffing and extraneous data between segments — becauseDrawJPEGembeds the bytes untouched, and a writer that refuses what every reader of its own output accepts is the one in the wrong. Pixel-identical bitmaps are shared: each image is content-addressed by its uncompressed samples, so a repeat is embedded — and compressed — once per document and every placement, on any page, references the one XObject.DrawPNGreads IHDR before it decodes and refuses a picture declaring more thanOptions.MaxPixels— see below. - Form XObjects (ISO 32000-1 §8.10) —
Document.NewForm(bbox)returns aForm, drawn with everyPagemethod (paths, colours, alpha, shadings, images, text, other forms) in its own form space.Page.DrawForm(f, rect)maps its box onto a rectangle, andPage.PaintForm(f, a, b, c, d, e, f)places it under any matrix, andPage.Document()reaches the document from a page to make forms. A form painted any number of times, on any page, is one stream: a logo, an emoji or a formula used thirty times costs thirtyDooperators. Forms may paint forms made before or after them. A cycle is refused (ErrFormCycle). A form's content belongs to the tagged sequence it is painted in (§14.7.4.2), soBeginTagand tagged links inside a form write nothing. - Pages — standard sizes (A3/A4/A5/Letter/Legal/Tabloid), portrait/landscape,
custom sizes;
Pt/Mm/Inunit helpers. - Widget bridge, in the subpackage
pdfkit/widget:widget.Addandwidget.AddVector"print" a go-widgets/toolkit widget tree onto a page.Addrasterises the tree and places it as an image XObject, pixel-identical to the screen.AddVectorinstead emits PDF vector operators, so fills and strokes stay crisp and text stays selectable. A TrueType-font widget label is embedded in its own face as real Type0 text.- Importing
pdfkitalone does not link the toolkit: 196 packages instead of 403, and a test keeps it that way.
- Deterministic — with the zero
Options, output has no timestamps and a content-derived/ID, so identical inputs produce byte-identical PDFs.
A PNG's header decides the allocation. IHDR's width and height are
four-byte unsigned integers (W3C PNG 3rd ed. §11.2.1), and nothing in the file
has to back them up: seventy-five bytes claiming 20000×20000 cost
1.5 GiB before png.Decode looks at a single sample. Measured on the
identical shape in go-pdfkit/convert, a 10000×10000 claim held 381.6 MiB.
DrawPNG now reads IHDR with png.DecodeConfig and refuses past
Options.MaxPixels — ⛔ before decoding, because a ceiling applied to what a
decoder returns is not a ceiling, it is a post-mortem. The spec asks for the
same reading order: "PNG decoders should detect errors as early as possible"
(§4.10).
Options.MaxPixels defaults to DefaultMaxPixels = 40 << 20, 41 943 040
pixels — exactly 160 MiB at the four bytes a pixel image/png hands back,
and a little over A4 at 600 dpi. A program embedding its own logo never meets
it; a program embedding pictures it did not produce should set it to what its
own documents need.
⛔ That number is go-pdfkit/render's defaultMaxPixels (render.go:79),
read there rather than taken on trust. go-gfx/gfx's svg.DefaultMaxPixels is
40_000_000 and its documentation calls that "the same ceiling
go-pdfkit/render uses" — it is not, and the two differ by 1 943 040 pixels.
That sentence had been travelling between repositories unchecked, this README
included, until someone opened render.go
(go-gfx/gfx#92).
Two neighbours are deliberately not bounded, and the reasons are worth stating because "it has a limit" is otherwise read as "everything has a limit":
DrawJPEGdecodes nothing. The bytes are embedded as a DCTDecode stream and the geometry is read from the SOF marker, so a hundred-megapixel scan costs pdfkit its own length and no more. Its dimensions are not a lie a consumer will discover later, either: the consumer decodes the same JPEG.DrawImageis handed animage.Imagethe caller has already allocated, so the pixels exist before pdfkit sees them and refusing them saves nothing. It copies them once more, a constant factor, not an amplification. (On a 32-bit buildw*h*3would overflowint— but only for an image past 715 megapixels, which is 2.8 GB of pixel buffer the caller could not have addressed to get here.)
go get github.com/go-pdfkit/pdfkitpackage main
import (
"os"
"github.com/go-pdfkit/pdfkit"
)
func main() {
ttf, _ := os.ReadFile("font.ttf")
font, _ := pdfkit.LoadFont(ttf)
doc := pdfkit.New(pdfkit.Options{Title: "Hello"})
p := doc.AddPage(pdfkit.A4)
p.SetFont(font, 24)
p.Text(pdfkit.Mm(20), p.Height()-pdfkit.Mm(20), "Hello, pdfkit")
p.SetStrokeColor(pdfkit.RGB8(0x0d, 0x94, 0x88))
p.SetLineWidth(2)
p.MoveTo(pdfkit.Mm(20), pdfkit.Mm(20))
p.LineTo(pdfkit.Mm(190), pdfkit.Mm(20))
p.Stroke()
f, _ := os.Create("out.pdf")
defer f.Close()
doc.Write(f)
}Shaped (complex-script) text uses p.TextShaped(x, y, s, features...), which
runs the go-opentype shaper so Arabic, Indic and CJK position correctly.
The run is written as one TJ array per baseline segment — a numeric
correction only where shaping departs from the font's own advances (kerning,
marks), none for plain text — and unchanged font/colour state is not
rewritten, so a page of prose is a few tens of KB compressed, not hundreds. The
default Text path stays a simple left-to-right cmap mapping.
Text a layout engine has already shaped and placed goes through
p.ShowGlyphs(x, y, glyphs): each pdfkit.Glyph is a glyph id, its origin's
offset from the run origin and the text it stands for (the /ToUnicode
entry). The same TJ encoding applies — a correction only where a position
departs from the font's own advance by a thousandth of an em or more, computed
from absolute positions so that the pen never drifts. go-webengine's
pdfcanvas draws a laid-out page this way.
import (
"github.com/go-pdfkit/pdfkit/widget"
"github.com/go-widgets/toolkit"
)
root := toolkit.NewContainer(toolkit.NewBoxLayout())
btn := toolkit.NewButton("Submit", nil)
btn.Style = toolkit.ButtonProminent
root.AddWidget(btn)
root.AddWidget(toolkit.NewLabel("Status: ready"))
rect := pdfkit.Rect{X: pdfkit.Mm(20), Y: pdfkit.Mm(200), Width: pdfkit.Mm(80), Height: pdfkit.Mm(30)}
// Raster: pixel-identical to the screen, not selectable.
_ = widget.Add(p, root, rect, nil)
// Vector: crisp fills/strokes, selectable text (needs an embedded Font).
rect.Y -= pdfkit.Mm(40)
_ = widget.AddVector(p, root, rect, &widget.Options{Font: font})Options.Scale sets the layout-pixels-per-point ratio (default
widget.DefaultScale, 2). Options.Theme selects the toolkit theme (default
toolkit.DefaultLight()).
The widget bridge moved out of the root package. Make these replacements:
v0.19 (pdfkit) |
v0.20 (pdfkit/widget) |
|---|---|
p.AddWidget(root, rect, opts) |
widget.Add(p, root, rect, opts) |
p.AddWidgetVector(root, rect, opts) |
widget.AddVector(p, root, rect, opts) |
pdfkit.WidgetOptions |
widget.Options |
pdfkit.DefaultWidgetScale |
widget.DefaultScale |
Nothing else changed. A program that never used the bridge only gets smaller.
GOWORK=off CGO_ENABLED=0 go test ./... runs the suite at exact 100%
statement coverage. Correctness is checked against an independent parser:
generated documents are re-opened with rsc.io/pdf
and their structure verified. The embedded TrueType subset is re-parsed with
go-opentype and each drawn glyph, resolved through the /CIDToGIDMap, is
confirmed contour-identical to the original; the embedded CFF subset is asserted
smaller than the whole CFF table and re-parsed so each kept glyph still renders
intact. Tests are deterministic and network-free: they use a synthesised
TrueType font, a synthesised CFF2 font and a bundled OFL OpenType/CFF font.
Fuzzing. jpegInfo is the one parser here that reads caller-supplied bytes
by hand — markers, segment lengths and offsets, every one a number out of the
file — so FuzzJPEGInfo drives it against image/jpeg as a differential
oracle, and FuzzDrawImageSamples drives the sample splitter's premultiply
arithmetic. The seed corpus is committed under testdata/fuzz/; CI runs the
seeds on every pull request and fuzzes each target for a minute.
⛔ The oracle is not "the specification" but what every consumer of our output
will have used to read the same bytes. The property is therefore not that
jpegInfo accepts what image/jpeg accepts — it accepts more, deliberately,
since DrawJPEG embeds without decoding — but that the two never agree to
disagree: a file both read must have the same dimensions in both, or the PDF
carries a /Width the reader's own decoder contradicts. That found two real
divergences in the first second of fuzzing, both of them files
jpegInfo refused and every other decoder accepts. See
Scope and limitations.
- Both outline flavours are subsetted: TrueType
glyffonts viago-opentype'sSubsetTrueType(compact renumbering + a/CIDToGIDMapstream) and CFF/OpenType fonts viaSubsetCFF(charstring subsetting, glyph numbering preserved). All subsetting and the font-descriptor metrics come straight fromgo-opentype;pdfkitkeeps no private sfnt re-parse. - A CID-keyed CFF or a CFF2 (variable) font cannot be charstring-subsetted
by the preserve-numbering path, so it gracefully falls back to embedding the
whole
CFF/CFF2table. - Link annotations are written here:
AddLink,AddNamedDestandAddNamedLinkput a clickable/Linkwith a URI or a named destination over a rectangle. Text (sticky note) annotations too, since v0.16.0:Page.AddTextAnnotationputs a note with its contents, author, subject, icon, colour and opacity on a page — go-slides carries a slide's speaker notes in them. No other annotation type is written, and nothing here draws an appearance stream —go-pdfkit/renderdoes that when reading. - Page transitions (
Page.SetTransition,Page.SetDisplayDuration): the/Transdictionary of PDF 32000-1 12.4.4.1 (Table 162) — the style (Split, Blinds, Box, Wipe, Dissolve, Glitter, R, Push, Cover, Uncover, Fade),/D,/Dm,/Mand/Di, refused where the table says the style does not take the entry or the value — and the page's/Dur. Fly is not written. - Bookmarks (
AddOutlineItem) nest by level; since v0.16.0AddOutlineItemWithstyles a title bold or italic and points it at a place on the page ([page /XYZ x y null]) instead of fitting the whole page. - Replacement text (after v0.16.0):
Page.BeginActualText(s)…EndMarkedContent()wrap what is drawn in a/Spanwith/ActualText, the text extraction, copying and search use instead of the glyphs. An empty string hides decorations from the text (numbers in a margin, a numbered disc after a line of code): Poppler'spdftotextthen leaves them out. Spans left open are closed when the page is written. - Encryption is not written here. It is not missing from the organisation:
go-pdfkit/readerreads RC4-40 through AES-256 and writes AES-128 and AES-256 with permissions and both passwords. - Forms are not written here —
go-pdfkit/formsreads, fills and flattens an AcroForm, andgo-pdfkit/xfareads the other kind. - Tagged PDF and PDF/UA (after v0.17.0):
Document.Structure()returns the root of the logical structure (ISO 32000-1, 14.7), andAddbuilds the tree in reading order with the standard types (StructSect,StructH1,StructP,StructL/StructLI/StructLbl/StructLBody,StructTable,StructFigureandStructFormulawithAlt,StructCode,StructLink…).Page.BeginTag(e)…EndMarkedContent()ties what is drawn to an element (a marked-content sequence with an MCID, mapped back through the parent tree).Page.BeginArtifactmarks backgrounds, page numbers and ornaments, andPage.AddTaggedLinkputs a link annotation under its Link element with its/Contents. Notes get anAnnotelement of their own, and elements with no content are left out. After v0.18.0,StructElem.Attributescarries the standard structure attributes (§14.8.5): aTH'sScope, which PDF/UA asks for, a cell'sRowSpan…Options.Lang,DisplayDocTitleandPDFUA(the XMP metadata withdc:titleandpdfuaid:part) complete what PDF/UA-1 asks of the file; a small tagged document passes veraPDF's PDF/UA-1 profile. Tagging every drawing is the caller's part. An untagged document is written as before. - PDF/A is not written here, and is not implemented anywhere in the
organisation.
go-pdfkit/opspreserves a structure tree it is given around the pages that survive.
BSD-3-Clause — see LICENSE. Copyright (c) 2026 the go-pdfkit/pdfkit authors.