// Copyright ©2020 The Gonum Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. // Package vggio provides a vg.Canvas implementation backed by Gio, // a toolkit that implements portable immediate GUI mode in Go. // // More informations about Gio can be found at https://gioui.org/. package vggio // import "gonum.org/v1/plot/vg/vggio" import ( "bytes" "fmt" "image" "image/color" "strings" "sync" "gioui.org/f32" giofont "gioui.org/font" "gioui.org/font/opentype" "gioui.org/gpu/headless" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/text" "gioui.org/unit" "gioui.org/widget/material" "gioui.org/x/stroke" bstroke "github.com/andybalholm/stroke" "golang.org/x/image/draw" "golang.org/x/image/font/sfnt" "gonum.org/v1/plot/font" "gonum.org/v1/plot/vg" ) var ( _ vg.Canvas = (*Canvas)(nil) _ vg.CanvasSizer = (*Canvas)(nil) ) // Canvas implements the vg.Canvas interface, // drawing to an image.Image using vgimg and painting that image // into a Gioui context. type Canvas struct { gtx layout.Context ctx ctxops bkg color.Color // bkg is the background color. } // DefaultDPI is the default dot resolution for image // drawing in dots per inch. const DefaultDPI = 96 // New returns a new image canvas with the provided dimensions and options. // The currently accepted options are UseDPI and UseBackgroundColor. // If the resolution or background color are not specified, defaults are used. func New(gtx layout.Context, w, h vg.Length, opts ...option) *Canvas { cfg := &config{ dpi: DefaultDPI, bkg: color.White, } for _, opt := range opts { opt(cfg) } c := &Canvas{ gtx: gtx, ctx: ctxops{ ops: gtx.Ops, ctx: []context{ {color: color.Black}, }, w: w, h: h, dpi: cfg.dpi, }, bkg: cfg.bkg, } // flip the Y-axis so that Y grows from bottom to top and // Y=0 is at the bottom of the image. c.ctx.invertY() vg.Initialize(c) return c } type config struct { dpi float64 bkg color.Color } type option func(*config) // UseDPI sets the dots per inch of a canvas. It should only be // used as an option argument when initializing a new canvas. func UseDPI(dpi int) option { if dpi <= 0 { panic("DPI must be > 0.") } return func(c *config) { c.dpi = float64(dpi) } } // UseBackgroundColor specifies the image background color. // Without UseBackgroundColor, the default color is white. func UseBackgroundColor(c color.Color) option { return func(cfg *config) { cfg.bkg = c } } // Size implement vg.CanvasSizer. func (c *Canvas) Size() (w, h vg.Length) { return c.ctx.w, c.ctx.h } // DPI returns the resolution of the receiver in pixels per inch. func (c *Canvas) DPI() float64 { return c.ctx.dpi } // Paint returns the painting operations. func (c *Canvas) Paint() *op.Ops { return c.gtx.Ops } // Screenshot returns a screenshot of the canvas as an image. func (c *Canvas) Screenshot() (image.Image, error) { win, err := headless.NewWindow( int(c.ctx.w.Dots(c.ctx.dpi)), int(c.ctx.h.Dots(c.ctx.dpi)), ) if err != nil { return nil, fmt.Errorf("vggio: could not create headless window: %w", err) } err = win.Frame(c.gtx.Ops) if err != nil { return nil, fmt.Errorf("vggio: could not run headless frame: %w", err) } img := image.NewRGBA(image.Rectangle{Max: win.Size()}) err = win.Screenshot(img) if err != nil { return nil, fmt.Errorf("vggio: could not create screenshot: %w", err) } return img, nil } // SetLineWidth sets the width of stroked paths. // If the width is not positive then stroked lines // are not drawn. // // The initial line width is 1 point. func (c *Canvas) SetLineWidth(w vg.Length) { c.ctx.cur().linew = w } // SetLineDash sets the dash pattern for lines. // The pattern slice specifies the lengths of // alternating dashes and gaps, and the offset // specifies the distance into the dash pattern // to start the dash. // // The initial dash pattern is a solid line. func (c *Canvas) SetLineDash(pattern []vg.Length, offset vg.Length) { cur := c.ctx.cur() cur.pattern = pattern cur.offset = offset } // SetColor sets the current drawing color. // Note that fill color and stroke color are // the same, so if you want different fill // and stroke colors then you must set a color, // draw fills, set a new color and then draw lines. // // The initial color is black. // If SetColor is called with a nil color then black is used. func (c *Canvas) SetColor(clr color.Color) { if clr == nil { clr = color.Black } c.ctx.cur().color = clr } // Rotate applies a rotation transform to the context. // The parameter is specified in radians. func (c *Canvas) Rotate(rad float64) { c.ctx.rotate(rad) } // Translate applies a translational transform // to the context. func (c *Canvas) Translate(pt vg.Point) { c.ctx.translate(pt.X.Dots(c.ctx.dpi), pt.Y.Dots(c.ctx.dpi)) } // Scale applies a scaling transform to the // context. func (c *Canvas) Scale(x, y float64) { c.ctx.scale(x, y) } // Push saves the current line width, the // current dash pattern, the current // transforms, and the current color // onto a stack so that the state can later // be restored by calling Pop(). func (c *Canvas) Push() { c.ctx.push() } // Pop restores the context saved by the // corresponding call to Push(). func (c *Canvas) Pop() { c.ctx.pop() } // Stroke strokes the given path. func (c *Canvas) Stroke(p vg.Path) { if c.ctx.cur().linew <= 0 { return } c.ctx.push() defer c.ctx.pop() var ( cur = c.ctx.cur() dashes stroke.Dashes ) dashes.Phase = float32(cur.offset.Dots(c.ctx.dpi)) dashes.Dashes = make([]float32, len(cur.pattern)) for i, v := range cur.pattern { dashes.Dashes[i] = float32(v.Dots(c.ctx.dpi)) } shape := stroke.Stroke{ Path: c.stroke(p), Width: float32(cur.linew.Dots(c.ctx.dpi)), Cap: stroke.FlatCap, Dashes: dashes, }.Op(c.ctx.ops) clr := c.ctx.cur().color paint.FillShape(c.ctx.ops, rgba(clr), shape) } // Fill fills the given path. func (c *Canvas) Fill(p vg.Path) { c.ctx.push() defer c.ctx.pop() shape := clip.Outline{ Path: c.outline(p), }.Op() clr := c.ctx.cur().color paint.FillShape(c.ctx.ops, rgba(clr), shape) } func rgba(c color.Color) color.NRGBA { r, g, b, a := c.RGBA() return color.NRGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: uint8(a)} } func (c *Canvas) outline(p vg.Path) clip.PathSpec { var path clip.Path path.Begin(c.ctx.ops) for _, comp := range p { switch comp.Type { case vg.MoveComp: pt := c.ctx.pt32(comp.Pos) path.MoveTo(pt) case vg.LineComp: pt := c.ctx.pt32(comp.Pos) path.LineTo(pt) case vg.ArcComp: center := c.ctx.pt32(comp.Pos) path.ArcTo(center, center, float32(comp.Angle)) case vg.CurveComp: switch len(comp.Control) { case 1: ctl := c.ctx.pt32(comp.Control[0]) end := c.ctx.pt32(comp.Pos) path.QuadTo(ctl, end) case 2: ctl0 := c.ctx.pt32(comp.Control[0]) ctl1 := c.ctx.pt32(comp.Control[1]) end := c.ctx.pt32(comp.Pos) path.CubeTo(ctl0, ctl1, end) default: panic("vggio: invalid number of control points") } case vg.CloseComp: path.Close() default: panic(fmt.Sprintf("vggio: unknown path component %d", comp.Type)) } } return path.End() } func (c *Canvas) stroke(p vg.Path) stroke.Path { var ( path stroke.Path add = func(seg stroke.Segment) { path.Segments = append(path.Segments, seg) } pen f32.Point beg f32.Point ) for i, comp := range p { if i == 0 { beg = c.ctx.pt32(comp.Pos) } switch comp.Type { case vg.MoveComp: pt := c.ctx.pt32(comp.Pos) add(stroke.MoveTo(pt)) pen = pt case vg.LineComp: pt := c.ctx.pt32(comp.Pos) add(stroke.LineTo(pt)) pen = pt case vg.ArcComp: center := c.ctx.pt32(comp.Pos) arcs := arcTo(pen, center, center, float32(comp.Angle)) path.Segments = append(path.Segments, xStroke(arcs)...) pen = f32.Point(arcs[len(arcs)-1].End) case vg.CurveComp: switch len(comp.Control) { case 1: var ( ctl = c.ctx.pt32(comp.Control[0]) end = c.ctx.pt32(comp.Pos) ) add(stroke.QuadTo(ctl, end)) pen = end case 2: var ( ctl0 = c.ctx.pt32(comp.Control[0]) ctl1 = c.ctx.pt32(comp.Control[1]) end = c.ctx.pt32(comp.Pos) ) add(stroke.CubeTo(ctl0, ctl1, end)) pen = end default: panic("vggio: invalid number of control points") } case vg.CloseComp: add(stroke.LineTo(beg)) pen = beg default: panic(fmt.Sprintf("vggio: unknown path component %d", comp.Type)) } } return path } // FillString fills in text at the specified // location using the given font. // If the font size is zero, the text is not drawn. func (c *Canvas) FillString(fnt font.Face, pt vg.Point, txt string) { if fnt.Font.Size == 0 { return } c.ctx.push() defer c.ctx.pop() e := fnt.Extents() x := pt.X.Dots(c.ctx.dpi) y := pt.Y.Dots(c.ctx.dpi) - e.Descent.Dots(c.ctx.dpi) h := c.ctx.h.Dots(c.ctx.dpi) c.ctx.invertY() c.ctx.translate(x, h-y-fnt.Font.Size.Dots(c.ctx.dpi)) th := material.NewTheme() th.Shaper = text.NewShaper(text.NoSystemFonts(), text.WithCollection(collectionFor(fnt))) lbl := material.Label( th, unit.Sp(float32(fnt.Font.Size.Dots(c.ctx.dpi))), txt, ) lbl.Color = rgba(c.ctx.cur().color) lbl.Alignment = text.Start lbl.Layout(c.gtx) } // DrawImage draws the image, scaled to fit // the destination rectangle. func (c *Canvas) DrawImage(rect vg.Rectangle, img image.Image) { c.ctx.push() defer c.ctx.pop() var ( ops = c.ctx.ops dpi = c.DPI() min = rect.Min xmin = min.X.Dots(dpi) ymin = min.Y.Dots(dpi) rsz = rect.Size() width = rsz.X.Dots(dpi) height = rsz.Y.Dots(dpi) dst = image.NewRGBA(image.Rect(0, 0, int(width), int(height))) ) draw.NearestNeighbor.Scale(dst, dst.Rect, img, img.Bounds(), draw.Src, nil) c.ctx.scale(1, -1) c.ctx.translate(xmin, -ymin-height) paint.NewImageOp(dst).Add(ops) paint.PaintOp{}.Add(ops) } var dbfonts = &gioFontsCache{ cache: make(map[string][]giofont.FontFace), fonts: make(map[string]struct{}), } type gioFontsCache struct { sync.RWMutex cache map[string][]giofont.FontFace fonts map[string]struct{} buf sfnt.Buffer } func (cache *gioFontsCache) get(fnt font.Face) ([]giofont.FontFace, bool) { cache.RLock() defer cache.RUnlock() _, ok := cache.fonts[fnt.Name()] if !ok { return nil, false } name := collectionName(fnt.Name()) return cache.cache[name], ok } func (cache *gioFontsCache) add(fnt font.Face) []giofont.FontFace { cache.Lock() defer cache.Unlock() name := fnt.Name() if fnt.Face == nil { panic(fmt.Errorf("vggio: nil plot/font.Face %q", name)) } buf := new(bytes.Buffer) _, err := fnt.Face.WriteSourceTo(&cache.buf, buf) if err != nil { panic(fmt.Errorf("vggio: could not load font %q: %+v", name, err)) } gioFace, err := opentype.Parse(buf.Bytes()) if err != nil { panic(fmt.Errorf("vggio: could not parse font %q: %+v", name, err)) } gioFnt := gonumToGioFont(fnt.Font) colName := collectionName(fnt.Name()) cache.cache[colName] = append(cache.cache[colName], giofont.FontFace{ Font: gioFnt, Face: gioFace, }) cache.fonts[name] = struct{}{} return cache.cache[colName] } func gonumToGioFont(fnt font.Font) giofont.Font { o := giofont.Font{ Typeface: giofont.Typeface(fnt.Typeface), Style: giofont.Style(fnt.Style), Weight: giofont.Weight(fnt.Weight), } return o } func collectionFor(fnt font.Face) []giofont.FontFace { coll, ok := dbfonts.get(fnt) if !ok { coll = dbfonts.add(fnt) } return coll } func collectionName(name string) string { // regroup fonts with name "Liberation-Italic", "Liberation-Bold", ... // under the same collection "Liberation". if strings.Contains(name, "-") { i := strings.Index(name, "-") name = name[:i] } return name } func arcTo(start, f1, f2 f32.Point, angle float32) []bstroke.Segment { if f1 == f2 { return bstroke.AppendArc(nil, bstroke.Pt(start.X, start.Y), bstroke.Pt(f1.X, f1.Y), angle) } return bstroke.AppendEllipticalArc(nil, bstroke.Pt(start.X, start.Y), bstroke.Pt(f1.X, f1.Y), bstroke.Pt(f2.X, f2.Y), angle) } func xStroke(bs []bstroke.Segment) []stroke.Segment { vs := make([]stroke.Segment, len(bs)) for i, b := range bs { vs[i] = stroke.CubeTo(f32.Point(b.CP1), f32.Point(b.CP2), f32.Point(b.End)) } return vs }