Go draw2d
last modified October 30, 2026
In this article we show how to work with graphics in Go using draw2d. The library draws vector shapes, curves and text with a Cairo-like API and writes the result to an image, a PDF document or an OpenGL surface.
draw2d uses the same coordinate conventions as the HTML canvas: the origin is
in the top-left corner, the x axis grows to the right and the y axis grows
downwards. Angles are in radians, and a path is built with
MoveTo, LineTo and the curve methods before it is
filled or stroked.
The examples draw on an image.RGBA canvas from the
draw2dimg package. All were compiled and run with Go 1.27.0 and
draw2d v0.0.0-20260422081035-c4331ac66734.
Installation
draw2d is a module dependency. The image backend is imported from
github.com/llgcode/draw2d/draw2dimg, and the shape helpers from
draw2dkit.
$ mkdir draw2d-demo $ cd draw2d-demo $ go mod init com.zetcode/draw2d go: creating new go.mod: module com.zetcode/draw2d $ go get github.com/llgcode/draw2d@latest go: added github.com/llgcode/draw2d v0.0.0-20260422081035-c4331ac66734
Canvas and rectangle
A canvas is created with image.NewRGBA. The graphic context wraps
the canvas and keeps the current colors, line width and transformation.
draw2dkit.Rectangle adds a rectangle to the path, and
FillStroke fills it and then draws its outline.
package main
import (
"fmt"
"image"
"image/color"
"github.com/llgcode/draw2d/draw2dimg"
"github.com/llgcode/draw2d/draw2dkit"
)
func main() {
dest := image.NewRGBA(image.Rect(0, 0, 300, 200))
gc := draw2dimg.NewGraphicContext(dest)
gc.SetFillColor(color.White)
gc.SetStrokeColor(color.Black)
gc.SetLineWidth(3)
draw2dkit.Rectangle(gc, 40, 40, 260, 160)
gc.FillStroke()
draw2dimg.SaveToPngFile("rect.png", dest)
for _, p := range [][2]int{{150, 100}, {40, 100}, {10, 10}} {
fmt.Printf("%v: %v\n", p, dest.At(p[0], p[1]))
}
}
The rectangle arguments are the top-left and bottom-right corners. The pixels confirm the result: the center is white, the edge at x=40 is black and a point outside the rectangle is still transparent. The saved PNG keeps an alpha channel because the canvas was never filled completely.
$ go run main.go
[150 100]: {255 255 255 255}
[40 100]: {0 0 0 255}
[10 10]: {0 0 0 0}
$ file rect.png
rect.png: PNG image data, 300 x 200, 8-bit/color RGBA, non-interlaced
Lines and dashes
A line is a path with two points. SetLineWidth changes the
thickness, SetLineDash splits the stroke into dashes, and
SetLineCap controls the ends. Passing nil to
SetLineDash restores a solid line.
package main
import (
"fmt"
"image"
"image/color"
"github.com/llgcode/draw2d"
"github.com/llgcode/draw2d/draw2dimg"
"github.com/llgcode/draw2d/draw2dkit"
)
func main() {
dest := image.NewRGBA(image.Rect(0, 0, 300, 200))
gc := draw2dimg.NewGraphicContext(dest)
gc.SetFillColor(color.White)
draw2dkit.Rectangle(gc, 0, 0, 300, 200)
gc.Fill()
gc.SetStrokeColor(color.RGBA{0x00, 0x00, 0xcc, 0xff})
gc.SetLineWidth(4)
gc.MoveTo(20, 50)
gc.LineTo(280, 50)
gc.Stroke()
gc.SetLineDash([]float64{15, 10}, 0)
gc.MoveTo(20, 100)
gc.LineTo(280, 100)
gc.Stroke()
gc.SetLineDash(nil, 0)
gc.SetLineWidth(12)
gc.SetLineCap(draw2d.RoundCap)
gc.MoveTo(30, 160)
gc.LineTo(270, 160)
gc.Stroke()
draw2dimg.SaveToPngFile("lines.png", dest)
for _, p := range [][2]int{{150, 50}, {20, 100}, {40, 100}, {150, 160}} {
fmt.Printf("%v: %v\n", p, dest.At(p[0], p[1]))
}
}
The dash pattern alternates 15 pixels of ink and 10 pixels of gap, so the point at x=20 is drawn and the point at x=40 falls into a gap. The last line uses a round cap, which extends the stroke beyond its end points.
$ go run main.go
[150 50]: {0 0 204 255}
[20 100]: {0 0 204 255}
[40 100]: {255 255 255 255}
[150 160]: {0 0 204 255}
$ file lines.png
lines.png: PNG image data, 300 x 200, 8-bit/color RGB, non-interlaced
Circles and ellipses
draw2dkit.Circle takes a center and a radius.
draw2dkit.Ellipse takes a center and two radii; equal radii make
a circle.
package main
import (
"fmt"
"image"
"image/color"
"github.com/llgcode/draw2d/draw2dimg"
"github.com/llgcode/draw2d/draw2dkit"
)
func main() {
dest := image.NewRGBA(image.Rect(0, 0, 300, 200))
gc := draw2dimg.NewGraphicContext(dest)
gc.SetFillColor(color.White)
draw2dkit.Rectangle(gc, 0, 0, 300, 200)
gc.Fill()
gc.SetFillColor(color.RGBA{0xcc, 0x00, 0x00, 0xff})
draw2dkit.Circle(gc, 80, 100, 50)
gc.Fill()
gc.SetFillColor(color.RGBA{0x00, 0x99, 0x00, 0xff})
draw2dkit.Ellipse(gc, 220, 100, 60, 35)
gc.Fill()
draw2dimg.SaveToPngFile("circles.png", dest)
for _, p := range [][2]int{{80, 100}, {80, 40}, {220, 100}, {220, 60}, {150, 100}} {
fmt.Printf("%v: %v\n", p, dest.At(p[0], p[1]))
}
}
The centers are filled, a point above the circle and a point above the ellipse are outside, and the middle of the canvas stays white. The ellipse is 120 pixels wide and 70 pixels high.
$ go run main.go
[80 100]: {204 0 0 255}
[80 40]: {255 255 255 255}
[220 100]: {0 153 0 255}
[220 60]: {255 255 255 255}
[150 100]: {255 255 255 255}
$ file circles.png
circles.png: PNG image data, 300 x 200, 8-bit/color RGB, non-interlaced
Curves
QuadCurveTo uses one control point and
CubicCurveTo uses two. draw2dkit.RoundedRectangle
combines straight edges with elliptical corners.
package main
import (
"fmt"
"image"
"image/color"
"github.com/llgcode/draw2d/draw2dimg"
"github.com/llgcode/draw2d/draw2dkit"
)
func main() {
dest := image.NewRGBA(image.Rect(0, 0, 300, 300))
gc := draw2dimg.NewGraphicContext(dest)
gc.SetFillColor(color.White)
draw2dkit.Rectangle(gc, 0, 0, 300, 300)
gc.Fill()
gc.SetStrokeColor(color.RGBA{0x00, 0x00, 0xcc, 0xff})
gc.SetLineWidth(3)
gc.MoveTo(20, 80)
gc.CubicCurveTo(100, 20, 200, 140, 280, 80)
gc.Stroke()
gc.SetStrokeColor(color.RGBA{0xcc, 0x00, 0x00, 0xff})
gc.MoveTo(20, 250)
gc.QuadCurveTo(150, 150, 280, 250)
gc.Stroke()
gc.SetStrokeColor(color.RGBA{0x00, 0x99, 0x00, 0xff})
draw2dkit.RoundedRectangle(gc, 40, 120, 260, 180, 20, 20)
gc.Stroke()
draw2dimg.SaveToPngFile("curves.png", dest)
for _, p := range [][2]int{{150, 80}, {150, 200}, {40, 150}} {
fmt.Printf("%v: %v\n", p, dest.At(p[0], p[1]))
}
}
The midpoint of a cubic curve lies halfway between the end points on both axes when the control points are symmetric, so the pixel at (150, 80) is on the blue curve. The quadratic curve reaches its apex at (150, 200), and (40, 150) is on the left edge of the rounded rectangle.
$ go run main.go
[150 80]: {0 0 204 255}
[150 200]: {204 0 0 255}
[40 150]: {0 153 0 255}
$ file curves.png
curves.png: PNG image data, 300 x 300, 8-bit/color RGB, non-interlaced
Text
Text needs a TrueType font. The library registers fonts by name with
draw2d.RegisterFont; the Go font from
golang.org/x/image/font/gofont/goregular is embedded in the
program, so no font file has to be shipped next to the binary.
package main
import (
"fmt"
"image"
"image/color"
"log"
"github.com/golang/freetype/truetype"
"github.com/llgcode/draw2d"
"github.com/llgcode/draw2d/draw2dimg"
"github.com/llgcode/draw2d/draw2dkit"
"golang.org/x/image/font/gofont/goregular"
)
func main() {
font, err := truetype.Parse(goregular.TTF)
if err != nil {
log.Fatal(err)
}
draw2d.RegisterFont(draw2d.FontData{
Name: "Go",
Family: draw2d.FontFamilySans,
Style: draw2d.FontStyleNormal,
}, font)
dest := image.NewRGBA(image.Rect(0, 0, 400, 200))
gc := draw2dimg.NewGraphicContext(dest)
gc.SetFillColor(color.White)
draw2dkit.Rectangle(gc, 0, 0, 400, 200)
gc.Fill()
gc.SetFontData(draw2d.FontData{Name: "Go", Family: draw2d.FontFamilySans})
gc.SetFontSize(32)
gc.SetFillColor(color.RGBA{0x22, 0x22, 0x22, 0xff})
left, top, right, bottom := gc.GetStringBounds("Hello draw2d")
fmt.Printf("bounds: %.2f %.2f %.2f %.2f\n", left, top, right, bottom)
fmt.Printf("width: %.2f height: %.2f\n", right-left, bottom-top)
gc.FillStringAt("Hello draw2d", 20, 100)
draw2dimg.SaveToPngFile("text.png", dest)
}
The font data name must match the name used at registration. The baseline of
the text is at y=100, and GetStringBounds returns the bounding box
relative to that point; the negative top value means that most of the text
lies above the baseline.
$ go run main.go bounds: 3.30 -31.52 239.19 0.50 width: 235.89 height: 32.02 $ file text.png text.png: PNG image data, 400 x 200, 8-bit/color RGB, non-interlaced
Transformations
Translate, Rotate and Scale modify the
current transformation matrix. Save and Restore
isolate a transformation so that later drawing is not affected.
package main
import (
"fmt"
"image"
"image/color"
"math"
"github.com/llgcode/draw2d/draw2dimg"
"github.com/llgcode/draw2d/draw2dkit"
)
func main() {
dest := image.NewRGBA(image.Rect(0, 0, 300, 300))
gc := draw2dimg.NewGraphicContext(dest)
gc.SetFillColor(color.White)
draw2dkit.Rectangle(gc, 0, 0, 300, 300)
gc.Fill()
gc.Save()
gc.Translate(150, 150)
gc.Rotate(math.Pi / 4)
gc.SetFillColor(color.RGBA{0x00, 0x00, 0xcc, 0xff})
draw2dkit.Rectangle(gc, -60, -30, 60, 30)
gc.Fill()
gc.Restore()
gc.SetFillColor(color.RGBA{0xcc, 0x00, 0x00, 0xff})
draw2dkit.Rectangle(gc, 0, 0, 60, 60)
gc.Fill()
draw2dimg.SaveToPngFile("transform.png", dest)
for _, p := range [][2]int{{150, 150}, {120, 120}, {150, 60}, {30, 30}} {
fmt.Printf("%v: %v\n", p, dest.At(p[0], p[1]))
}
}
The blue rectangle is moved to the center and rotated by 45 degrees, written
as math.Pi / 4 because angles are in radians. After
Restore the red square is drawn in the original coordinates at the
top-left corner.
$ go run main.go
[150 150]: {0 0 204 255}
[120 120]: {0 0 204 255}
[150 60]: {255 255 255 255}
[30 30]: {204 0 0 255}
$ file transform.png
transform.png: PNG image data, 300 x 300, 8-bit/color RGB, non-interlaced
Transparency
A color with an alpha channel blends with what is already on the canvas. Use
color.NRGBA to give straight, non-premultiplied components;
color.RGBA expects the components to be premultiplied by alpha.
package main
import (
"fmt"
"image"
"image/color"
"github.com/llgcode/draw2d/draw2dimg"
"github.com/llgcode/draw2d/draw2dkit"
)
func main() {
dest := image.NewRGBA(image.Rect(0, 0, 300, 200))
gc := draw2dimg.NewGraphicContext(dest)
gc.SetFillColor(color.White)
draw2dkit.Rectangle(gc, 0, 0, 300, 200)
gc.Fill()
gc.SetFillColor(color.NRGBA{0xff, 0x00, 0x00, 0x80})
draw2dkit.Rectangle(gc, 20, 40, 160, 160)
gc.Fill()
gc.SetFillColor(color.NRGBA{0x00, 0x00, 0xff, 0x80})
draw2dkit.Rectangle(gc, 120, 40, 260, 160)
gc.Fill()
draw2dimg.SaveToPngFile("alpha.png", dest)
for _, p := range [][2]int{{60, 80}, {200, 80}, {140, 80}} {
fmt.Printf("%v: %v\n", p, dest.At(p[0], p[1]))
}
}
Half-transparent red over white gives {255 127 127} and blue
over white gives {127 127 255}. In the overlap the blue rectangle
covers the red one, which produces {127 63 191}.
$ go run main.go
[60 80]: {255 127 127 255}
[200 80]: {127 127 255 255}
[140 80]: {127 63 191 255}
$ file alpha.png
alpha.png: PNG image data, 300 x 200, 8-bit/color RGB, non-interlaced
Saving PNG and JPEG
SaveToPngFile is a convenience wrapper around the PNG encoder.
Other formats are written with the standard image packages, for
example image/jpeg.
package main
import (
"fmt"
"image"
"image/color"
"image/jpeg"
"os"
"github.com/llgcode/draw2d/draw2dimg"
"github.com/llgcode/draw2d/draw2dkit"
)
func main() {
dest := image.NewRGBA(image.Rect(0, 0, 300, 200))
gc := draw2dimg.NewGraphicContext(dest)
gc.SetFillColor(color.White)
draw2dkit.Rectangle(gc, 0, 0, 300, 200)
gc.Fill()
gc.SetFillColor(color.RGBA{0x22, 0x66, 0xcc, 0xff})
draw2dkit.Circle(gc, 150, 100, 70)
gc.Fill()
if err := draw2dimg.SaveToPngFile("drawing.png", dest); err != nil {
fmt.Println(err)
return
}
f, err := os.Create("drawing.jpg")
if err != nil {
fmt.Println(err)
return
}
defer f.Close()
if err := jpeg.Encode(f, dest, &jpeg.Options{Quality: 90}); err != nil {
fmt.Println(err)
}
}
The PNG file keeps the exact colors, while JPEG is lossy and has no alpha channel. The quality option ranges from 1 to 100.
$ go run main.go $ file drawing.png drawing.jpg drawing.png: PNG image data, 300 x 200, 8-bit/color RGB, non-interlaced drawing.jpg: JPEG image data, baseline, precision 8, 300x200, components 3
PDF output
The draw2dpdf backend draws the same paths into a PDF document.
The page size is given in the constructor, and SaveToPdfFile
writes the file.
package main
import (
"image/color"
"github.com/llgcode/draw2d/draw2dpdf"
"github.com/llgcode/draw2d/draw2dkit"
)
func main() {
pdf := draw2dpdf.NewPdf("L", "mm", "A5")
gc := draw2dpdf.NewGraphicContext(pdf)
gc.SetFillColor(color.RGBA{0x44, 0xff, 0x44, 0xff})
gc.SetStrokeColor(color.RGBA{0x44, 0x44, 0x44, 0xff})
gc.SetLineWidth(2)
draw2dkit.Rectangle(gc, 10, 10, 100, 60)
gc.FillStroke()
draw2dpdf.SaveToPdfFile("rect.pdf", pdf)
}
The first argument selects landscape orientation, the second the unit and the third the page size. Shapes and text use the same calls as on an image canvas.
$ go get github.com/llgcode/draw2d/draw2dpdf $ go run main.go $ file rect.pdf rect.pdf: PDF document, version 1.3, 1 pages
Source
Go draw2d - Github page
Go draw2d - package documentation
In this article we have worked with graphics in Go using draw2d. We drew rectangles, lines and dashes, circles and ellipses, curves and rounded rectangles, text with an embedded font, transformed shapes, blended transparent colors, and saved the canvas as PNG, JPEG and PDF.
Author
List all Go tutorials.