93f92929f2
Fixes #2. Allows passing in an io.Writer to save a PNG.
606 lines
16 KiB
Go
606 lines
16 KiB
Go
// Package gg provides a simple API for rendering 2D graphics in pure Go.
|
|
package gg
|
|
|
|
import (
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/png"
|
|
"io"
|
|
"math"
|
|
|
|
"github.com/golang/freetype/raster"
|
|
"golang.org/x/image/font"
|
|
"golang.org/x/image/font/basicfont"
|
|
)
|
|
|
|
type LineCap int
|
|
|
|
const (
|
|
LineCapRound LineCap = iota
|
|
LineCapButt
|
|
LineCapSquare
|
|
)
|
|
|
|
type LineJoin int
|
|
|
|
const (
|
|
LineJoinRound LineJoin = iota
|
|
LineJoinBevel
|
|
)
|
|
|
|
type FillRule int
|
|
|
|
const (
|
|
FillRuleWinding FillRule = iota
|
|
FillRuleEvenOdd
|
|
)
|
|
|
|
type Context struct {
|
|
width int
|
|
height int
|
|
im *image.RGBA
|
|
color color.Color
|
|
strokePath raster.Path
|
|
fillPath raster.Path
|
|
start Point
|
|
current Point
|
|
hasCurrent bool
|
|
dashes []float64
|
|
lineWidth float64
|
|
lineCap LineCap
|
|
lineJoin LineJoin
|
|
fillRule FillRule
|
|
fontFace font.Face
|
|
fontHeight float64
|
|
matrix Matrix
|
|
stack []*Context
|
|
}
|
|
|
|
// NewContext creates a new image.RGBA with the specified width and height
|
|
// and prepares a context for rendering onto that image.
|
|
func NewContext(width, height int) *Context {
|
|
return NewContextForRGBA(image.NewRGBA(image.Rect(0, 0, width, height)))
|
|
}
|
|
|
|
// NewContextForImage copies the specified image into a new image.RGBA
|
|
// and prepares a context for rendering onto that image.
|
|
func NewContextForImage(im image.Image) *Context {
|
|
return NewContextForRGBA(imageToRGBA(im))
|
|
}
|
|
|
|
// NewContextForRGBA prepares a context for rendering onto the specified image.
|
|
// No copy is made.
|
|
func NewContextForRGBA(im *image.RGBA) *Context {
|
|
return &Context{
|
|
width: im.Bounds().Size().X,
|
|
height: im.Bounds().Size().Y,
|
|
im: im,
|
|
color: color.Transparent,
|
|
lineWidth: 1,
|
|
fillRule: FillRuleWinding,
|
|
fontFace: basicfont.Face7x13,
|
|
fontHeight: 13,
|
|
matrix: Identity(),
|
|
}
|
|
}
|
|
|
|
// Image returns the image that has been drawn by this context.
|
|
func (dc *Context) Image() image.Image {
|
|
return dc.im
|
|
}
|
|
|
|
// Width returns the width of the image in pixels.
|
|
func (dc *Context) Width() int {
|
|
return dc.width
|
|
}
|
|
|
|
// Height returns the height of the image in pixels.
|
|
func (dc *Context) Height() int {
|
|
return dc.height
|
|
}
|
|
|
|
// SavePNG encodes the image as a PNG and writes it to disk.
|
|
func (dc *Context) SavePNG(path string) error {
|
|
return SavePNG(path, dc.im)
|
|
}
|
|
|
|
// EncodePNG encodes the image as a PNG and writes it to the provided io.Writer.
|
|
func (dc *Context) EncodePNG(w io.Writer) error {
|
|
return png.Encode(w, dc.im)
|
|
}
|
|
|
|
// SetDash sets the current dash pattern to use. Call with zero arguments to
|
|
// disable dashes. The values specify the lengths of each dash, with
|
|
// alternating on and off lengths.
|
|
func (dc *Context) SetDash(dashes ...float64) {
|
|
dc.dashes = dashes
|
|
}
|
|
|
|
func (dc *Context) SetLineWidth(lineWidth float64) {
|
|
dc.lineWidth = lineWidth
|
|
}
|
|
|
|
func (dc *Context) SetLineCap(lineCap LineCap) {
|
|
dc.lineCap = lineCap
|
|
}
|
|
|
|
func (dc *Context) SetLineCapRound() {
|
|
dc.lineCap = LineCapRound
|
|
}
|
|
|
|
func (dc *Context) SetLineCapButt() {
|
|
dc.lineCap = LineCapButt
|
|
}
|
|
|
|
func (dc *Context) SetLineCapSquare() {
|
|
dc.lineCap = LineCapSquare
|
|
}
|
|
|
|
func (dc *Context) SetLineJoin(lineJoin LineJoin) {
|
|
dc.lineJoin = lineJoin
|
|
}
|
|
|
|
func (dc *Context) SetLineJoinRound() {
|
|
dc.lineJoin = LineJoinRound
|
|
}
|
|
|
|
func (dc *Context) SetLineJoinBevel() {
|
|
dc.lineJoin = LineJoinBevel
|
|
}
|
|
|
|
func (dc *Context) SetFillRule(fillRule FillRule) {
|
|
dc.fillRule = fillRule
|
|
}
|
|
|
|
func (dc *Context) SetFillRuleWinding() {
|
|
dc.fillRule = FillRuleWinding
|
|
}
|
|
|
|
func (dc *Context) SetFillRuleEvenOdd() {
|
|
dc.fillRule = FillRuleEvenOdd
|
|
}
|
|
|
|
// Color Setters
|
|
|
|
// SetColor sets the current color.
|
|
func (dc *Context) SetColor(c color.Color) {
|
|
dc.color = c
|
|
}
|
|
|
|
// SetHexColor sets the current color using a hex string. The leading pound
|
|
// sign (#) is optional. Both 3- and 6-digit variations are supported. 8 digits
|
|
// may be provided to set the alpha value as well.
|
|
func (dc *Context) SetHexColor(x string) {
|
|
r, g, b, a := parseHexColor(x)
|
|
dc.SetRGBA255(r, g, b, a)
|
|
}
|
|
|
|
// SetRGBA255 sets the current color. r, g, b, a values should be between 0 and
|
|
// 255, inclusive.
|
|
func (dc *Context) SetRGBA255(r, g, b, a int) {
|
|
dc.color = color.NRGBA{uint8(r), uint8(g), uint8(b), uint8(a)}
|
|
}
|
|
|
|
// SetRGB255 sets the current color. r, g, b values should be between 0 and 255,
|
|
// inclusive. Alpha will be set to 255 (fully opaque).
|
|
func (dc *Context) SetRGB255(r, g, b int) {
|
|
dc.SetRGBA255(r, g, b, 255)
|
|
}
|
|
|
|
// SetRGBA sets the current color. r, g, b, a values should be between 0 and 1,
|
|
// inclusive.
|
|
func (dc *Context) SetRGBA(r, g, b, a float64) {
|
|
dc.color = color.NRGBA{
|
|
uint8(r * 255),
|
|
uint8(g * 255),
|
|
uint8(b * 255),
|
|
uint8(a * 255),
|
|
}
|
|
}
|
|
|
|
// SetRGB sets the current color. r, g, b values should be between 0 and 1,
|
|
// inclusive. Alpha will be set to 1 (fully opaque).
|
|
func (dc *Context) SetRGB(r, g, b float64) {
|
|
dc.SetRGBA(r, g, b, 1)
|
|
}
|
|
|
|
// Path Manipulation
|
|
|
|
// MoveTo starts a new subpath within the current path starting at the
|
|
// specified point.
|
|
func (dc *Context) MoveTo(x, y float64) {
|
|
if dc.hasCurrent {
|
|
dc.fillPath.Add1(dc.start.Fixed())
|
|
}
|
|
x, y = dc.TransformPoint(x, y)
|
|
p := Point{x, y}
|
|
dc.strokePath.Start(p.Fixed())
|
|
dc.fillPath.Start(p.Fixed())
|
|
dc.start = p
|
|
dc.current = p
|
|
dc.hasCurrent = true
|
|
}
|
|
|
|
// LineTo adds a line segment to the current path starting at the current
|
|
// point. If there is no current point, it is equivalent to MoveTo(x, y)
|
|
func (dc *Context) LineTo(x, y float64) {
|
|
if !dc.hasCurrent {
|
|
dc.MoveTo(x, y)
|
|
} else {
|
|
x, y = dc.TransformPoint(x, y)
|
|
p := Point{x, y}
|
|
dc.strokePath.Add1(p.Fixed())
|
|
dc.fillPath.Add1(p.Fixed())
|
|
dc.current = p
|
|
}
|
|
}
|
|
|
|
// QuadraticTo adds a quadratic bezier curve to the current path starting at
|
|
// the current point. If there is no current point, it first performs
|
|
// MoveTo(x1, y1)
|
|
func (dc *Context) QuadraticTo(x1, y1, x2, y2 float64) {
|
|
if !dc.hasCurrent {
|
|
dc.MoveTo(x1, y1)
|
|
}
|
|
x1, y1 = dc.TransformPoint(x1, y1)
|
|
x2, y2 = dc.TransformPoint(x2, y2)
|
|
p1 := Point{x1, y1}
|
|
p2 := Point{x2, y2}
|
|
dc.strokePath.Add2(p1.Fixed(), p2.Fixed())
|
|
dc.fillPath.Add2(p1.Fixed(), p2.Fixed())
|
|
dc.current = p2
|
|
}
|
|
|
|
// CubicTo adds a cubic bezier curve to the current path starting at the
|
|
// current point. If there is no current point, it first performs
|
|
// MoveTo(x1, y1). Because freetype/raster does not support cubic beziers,
|
|
// this is emulated with many small line segments.
|
|
func (dc *Context) CubicTo(x1, y1, x2, y2, x3, y3 float64) {
|
|
if !dc.hasCurrent {
|
|
dc.MoveTo(x1, y1)
|
|
}
|
|
x0, y0 := dc.current.X, dc.current.Y
|
|
x1, y1 = dc.TransformPoint(x1, y1)
|
|
x2, y2 = dc.TransformPoint(x2, y2)
|
|
x3, y3 = dc.TransformPoint(x3, y3)
|
|
points := CubicBezier(x0, y0, x1, y1, x2, y2, x3, y3)
|
|
previous := dc.current.Fixed()
|
|
for _, p := range points[1:] {
|
|
f := p.Fixed()
|
|
if f == previous {
|
|
// TODO: this fixes some rendering issues but not all
|
|
continue
|
|
}
|
|
previous = f
|
|
dc.strokePath.Add1(f)
|
|
dc.fillPath.Add1(f)
|
|
dc.current = p
|
|
}
|
|
}
|
|
|
|
// ClosePath adds a line segment from the current point to the beginning
|
|
// of the current subpath. If there is no current point, this is a no-op.
|
|
func (dc *Context) ClosePath() {
|
|
if dc.hasCurrent {
|
|
dc.strokePath.Add1(dc.start.Fixed())
|
|
dc.fillPath.Add1(dc.start.Fixed())
|
|
dc.current = dc.start
|
|
}
|
|
}
|
|
|
|
// ClearPath clears the current path. There is no current point after this
|
|
// operation.
|
|
func (dc *Context) ClearPath() {
|
|
dc.strokePath.Clear()
|
|
dc.fillPath.Clear()
|
|
dc.hasCurrent = false
|
|
}
|
|
|
|
// NewSubPath starts a new subpath within the current path. There is no current
|
|
// point after this operation.
|
|
func (dc *Context) NewSubPath() {
|
|
if dc.hasCurrent {
|
|
dc.fillPath.Add1(dc.start.Fixed())
|
|
}
|
|
dc.hasCurrent = false
|
|
}
|
|
|
|
// Path Drawing
|
|
|
|
func (dc *Context) capper() raster.Capper {
|
|
switch dc.lineCap {
|
|
case LineCapButt:
|
|
return raster.ButtCapper
|
|
case LineCapRound:
|
|
return raster.RoundCapper
|
|
case LineCapSquare:
|
|
return raster.SquareCapper
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (dc *Context) joiner() raster.Joiner {
|
|
switch dc.lineJoin {
|
|
case LineJoinBevel:
|
|
return raster.BevelJoiner
|
|
case LineJoinRound:
|
|
return raster.RoundJoiner
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StrokePreserve strokes the current path with the current color, line width,
|
|
// line cap, line join and dash settings. The path is preserved after this
|
|
// operation.
|
|
func (dc *Context) StrokePreserve() {
|
|
path := dc.strokePath
|
|
if len(dc.dashes) > 0 {
|
|
path = dashed(path, dc.dashes)
|
|
}
|
|
painter := raster.NewRGBAPainter(dc.im)
|
|
painter.SetColor(dc.color)
|
|
r := raster.NewRasterizer(dc.width, dc.height)
|
|
r.UseNonZeroWinding = true
|
|
r.AddStroke(path, fix(dc.lineWidth), dc.capper(), dc.joiner())
|
|
r.Rasterize(painter)
|
|
}
|
|
|
|
// Stroke strokes the current path with the current color, line width,
|
|
// line cap, line join and dash settings. The path is cleared after this
|
|
// operation.
|
|
func (dc *Context) Stroke() {
|
|
dc.StrokePreserve()
|
|
dc.ClearPath()
|
|
}
|
|
|
|
// FillPreserve fills the current path with the current color. Open subpaths
|
|
// are implicity closed. The path is preserved after this operation.
|
|
func (dc *Context) FillPreserve() {
|
|
path := dc.fillPath
|
|
if dc.hasCurrent {
|
|
path = make(raster.Path, len(dc.fillPath))
|
|
copy(path, dc.fillPath)
|
|
path.Add1(dc.start.Fixed())
|
|
}
|
|
painter := raster.NewRGBAPainter(dc.im)
|
|
painter.SetColor(dc.color)
|
|
r := raster.NewRasterizer(dc.width, dc.height)
|
|
r.UseNonZeroWinding = dc.fillRule == FillRuleWinding
|
|
r.AddPath(path)
|
|
r.Rasterize(painter)
|
|
}
|
|
|
|
// Fill fills the current path with the current color. Open subpaths
|
|
// are implicity closed. The path is cleared after this operation.
|
|
func (dc *Context) Fill() {
|
|
dc.FillPreserve()
|
|
dc.ClearPath()
|
|
}
|
|
|
|
// Convenient Drawing Functions
|
|
|
|
// Clear fills the entire image with the current color.
|
|
func (dc *Context) Clear() {
|
|
src := image.NewUniform(dc.color)
|
|
draw.Draw(dc.im, dc.im.Bounds(), src, image.ZP, draw.Src)
|
|
}
|
|
|
|
func (dc *Context) DrawLine(x1, y1, x2, y2 float64) {
|
|
dc.MoveTo(x1, y1)
|
|
dc.LineTo(x2, y2)
|
|
}
|
|
|
|
func (dc *Context) DrawRectangle(x, y, w, h float64) {
|
|
dc.MoveTo(x, y)
|
|
dc.LineTo(x+w, y)
|
|
dc.LineTo(x+w, y+h)
|
|
dc.LineTo(x, y+h)
|
|
dc.LineTo(x, y)
|
|
}
|
|
|
|
func (dc *Context) DrawRoundedRectangle(x, y, w, h, r float64) {
|
|
x0, x1, x2, x3 := x, x+r, x+w-r, x+w
|
|
y0, y1, y2, y3 := y, y+r, y+h-r, y+h
|
|
dc.MoveTo(x1, y0)
|
|
dc.LineTo(x2, y0)
|
|
dc.DrawArc(x2, y1, r, Radians(270), Radians(360))
|
|
dc.LineTo(x3, y2)
|
|
dc.DrawArc(x2, y2, r, Radians(0), Radians(90))
|
|
dc.LineTo(x1, y3)
|
|
dc.DrawArc(x1, y2, r, Radians(90), Radians(180))
|
|
dc.LineTo(x0, y1)
|
|
dc.DrawArc(x1, y1, r, Radians(180), Radians(270))
|
|
}
|
|
|
|
func (dc *Context) DrawEllipticalArc(x, y, rx, ry, angle1, angle2 float64) {
|
|
const n = 16
|
|
for i := 0; i < n; i++ {
|
|
p1 := float64(i+0) / n
|
|
p2 := float64(i+1) / n
|
|
a1 := angle1 + (angle2-angle1)*p1
|
|
a2 := angle1 + (angle2-angle1)*p2
|
|
x0 := x + rx*math.Cos(a1)
|
|
y0 := y + ry*math.Sin(a1)
|
|
x1 := x + rx*math.Cos(a1+(a2-a1)/2)
|
|
y1 := y + ry*math.Sin(a1+(a2-a1)/2)
|
|
x2 := x + rx*math.Cos(a2)
|
|
y2 := y + ry*math.Sin(a2)
|
|
cx := 2*x1 - x0/2 - x2/2
|
|
cy := 2*y1 - y0/2 - y2/2
|
|
if i == 0 {
|
|
dc.MoveTo(x0, y0)
|
|
}
|
|
dc.QuadraticTo(cx, cy, x2, y2)
|
|
}
|
|
}
|
|
|
|
func (dc *Context) DrawEllipse(x, y, rx, ry float64) {
|
|
dc.DrawEllipticalArc(x, y, rx, ry, 0, 2*math.Pi)
|
|
}
|
|
|
|
func (dc *Context) DrawArc(x, y, r, angle1, angle2 float64) {
|
|
dc.DrawEllipticalArc(x, y, r, r, angle1, angle2)
|
|
}
|
|
|
|
func (dc *Context) DrawCircle(x, y, r float64) {
|
|
dc.DrawEllipticalArc(x, y, r, r, 0, 2*math.Pi)
|
|
}
|
|
|
|
// DrawImage draws the specified image at the specified point.
|
|
// Currently, rotation and scaling transforms are not supported.
|
|
func (dc *Context) DrawImage(im image.Image, x, y int) {
|
|
dc.DrawImageAnchored(im, x, y, 0, 0)
|
|
}
|
|
|
|
// DrawImageAnchored draws the specified image at the specified anchor point.
|
|
// The anchor point is x - w * ax, y - h * ay, where w, h is the size of the
|
|
// image. Use ax=0.5, ay=0.5 to center the image at the specified point.
|
|
func (dc *Context) DrawImageAnchored(im image.Image, x, y int, ax, ay float64) {
|
|
s := im.Bounds().Size()
|
|
x -= int(ax * float64(s.X))
|
|
y -= int(ay * float64(s.Y))
|
|
p := image.Pt(x, y)
|
|
r := image.Rectangle{p, p.Add(s)}
|
|
draw.Draw(dc.im, r, im, image.ZP, draw.Over)
|
|
}
|
|
|
|
// Text Functions
|
|
|
|
func (dc *Context) SetFontFace(fontFace font.Face) {
|
|
dc.fontFace = fontFace
|
|
}
|
|
|
|
func (dc *Context) LoadFontFace(path string, points float64) {
|
|
if face, err := loadFontFace(path, points); err == nil {
|
|
dc.fontFace = face
|
|
dc.fontHeight = points * 72 / 96
|
|
} else {
|
|
dc.fontFace = basicfont.Face7x13
|
|
dc.fontHeight = 13
|
|
}
|
|
}
|
|
|
|
// DrawString draws the specified text at the specified point.
|
|
// Currently, rotation and scaling transforms are not supported.
|
|
func (dc *Context) DrawString(s string, x, y float64) {
|
|
dc.DrawStringAnchored(s, x, y, 0, 0)
|
|
}
|
|
|
|
// DrawStringAnchored draws the specified text at the specified anchor point.
|
|
// The anchor point is x - w * ax, y - h * ay, where w, h is the size of the
|
|
// text. Use ax=0.5, ay=0.5 to center the text at the specified point.
|
|
func (dc *Context) DrawStringAnchored(s string, x, y, ax, ay float64) {
|
|
w, h := dc.MeasureString(s)
|
|
x -= ax * w
|
|
y += ay * h
|
|
x, y = dc.TransformPoint(x, y)
|
|
d := &font.Drawer{
|
|
Dst: dc.im,
|
|
Src: image.NewUniform(dc.color),
|
|
Face: dc.fontFace,
|
|
Dot: fixp(x, y),
|
|
}
|
|
d.DrawString(s)
|
|
}
|
|
|
|
// MeasureString returns the rendered width and height of the specified text
|
|
// given the current font face.
|
|
func (dc *Context) MeasureString(s string) (w, h float64) {
|
|
d := &font.Drawer{
|
|
Face: dc.fontFace,
|
|
}
|
|
a := d.MeasureString(s)
|
|
return float64(a >> 6), dc.fontHeight
|
|
}
|
|
|
|
// Transformation Matrix Operations
|
|
|
|
// Identity resets the current transformation matrix to the identity matrix.
|
|
// This results in no translating, scaling, rotating, or shearing.
|
|
func (dc *Context) Identity() {
|
|
dc.matrix = Identity()
|
|
}
|
|
|
|
// Translate updates the current matrix with a translation.
|
|
func (dc *Context) Translate(x, y float64) {
|
|
dc.matrix = dc.matrix.Translate(x, y)
|
|
}
|
|
|
|
// Scale updates the current matrix with a scaling factor.
|
|
// Scaling occurs about the origin.
|
|
func (dc *Context) Scale(x, y float64) {
|
|
dc.matrix = dc.matrix.Scale(x, y)
|
|
}
|
|
|
|
// ScaleAbout updates the current matrix with a scaling factor.
|
|
// Scaling occurs about the specified point.
|
|
func (dc *Context) ScaleAbout(sx, sy, x, y float64) {
|
|
dc.Translate(x, y)
|
|
dc.Scale(sx, sy)
|
|
dc.Translate(-x, -y)
|
|
}
|
|
|
|
// Rotate updates the current matrix with a clockwise rotation.
|
|
// Rotation occurs about the origin. Angle is specified in radians.
|
|
func (dc *Context) Rotate(angle float64) {
|
|
dc.matrix = dc.matrix.Rotate(angle)
|
|
}
|
|
|
|
// RotateAbout updates the current matrix with a clockwise rotation.
|
|
// Rotation occurs about the specified point. Angle is specified in radians.
|
|
func (dc *Context) RotateAbout(angle, x, y float64) {
|
|
dc.Translate(x, y)
|
|
dc.Rotate(angle)
|
|
dc.Translate(-x, -y)
|
|
}
|
|
|
|
// Shear updates the current matrix with a shearing angle.
|
|
// Shearing occurs about the origin.
|
|
func (dc *Context) Shear(x, y float64) {
|
|
dc.matrix = dc.matrix.Shear(x, y)
|
|
}
|
|
|
|
// ShearAbout updates the current matrix with a shearing angle.
|
|
// Shearing occurs about the specified point.
|
|
func (dc *Context) ShearAbout(sx, sy, x, y float64) {
|
|
dc.Translate(x, y)
|
|
dc.Shear(sx, sy)
|
|
dc.Translate(-x, -y)
|
|
}
|
|
|
|
// TransformPoint multiplies the specified point by the current matrix,
|
|
// returning a transformed position.
|
|
func (dc *Context) TransformPoint(x, y float64) (tx, ty float64) {
|
|
return dc.matrix.TransformPoint(x, y)
|
|
}
|
|
|
|
// InvertY flips the Y axis so that Y grows from bottom to top and Y=0 is at
|
|
// the bottom of the image.
|
|
func (dc *Context) InvertY() {
|
|
dc.Translate(0, float64(dc.height))
|
|
dc.Scale(1, -1)
|
|
}
|
|
|
|
// Stack
|
|
|
|
// Push saves the current state of the context for later retrieval. These
|
|
// can be nested.
|
|
func (dc *Context) Push() {
|
|
x := *dc
|
|
dc.stack = append(dc.stack, &x)
|
|
}
|
|
|
|
// Pop restores the last saved context state from the stack.
|
|
func (dc *Context) Pop() {
|
|
before := *dc
|
|
s := dc.stack
|
|
x, s := s[len(s)-1], s[:len(s)-1]
|
|
*dc = *x
|
|
dc.strokePath = before.strokePath
|
|
dc.fillPath = before.fillPath
|
|
dc.start = before.start
|
|
dc.current = before.current
|
|
dc.hasCurrent = before.hasCurrent
|
|
}
|