Skip to content

Commit

Permalink
vg/vgsvg: bump DPI to 96, introduce functional options
Browse files Browse the repository at this point in the history
Fixes #491.
  • Loading branch information
sbinet committed Jan 7, 2019
1 parent e14e8c2 commit c882b8d
Show file tree
Hide file tree
Showing 3 changed files with 223 additions and 41 deletions.
56 changes: 56 additions & 0 deletions vg/vgsvg/testdata/scatter_golden.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
127 changes: 86 additions & 41 deletions vg/vgsvg/vgsvg.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,25 @@ import (
"gonum.org/v1/plot/vg"
)

// DPI is the resolution of drawing in SVG. This value was determined by
// a survey of Inkscape, Chrome, FireFox and gpicview, but does not appear
// to be specified.
const DPI = 90

// pr is the precision to use when outputting float64s.
const pr = 5

const (
// DefaultDPI is the default dot resolution for image
// drawing in dots per inch.
DefaultDPI = 96

// DefaultWidth and DefaultHeight are the default canvas
// dimensions.
DefaultWidth = 4 * vg.Inch
DefaultHeight = 4 * vg.Inch
)

type Canvas struct {
svg *svgo.SVG
w, h vg.Length
svg *svgo.SVG
w, h vg.Length
dpi float64

buf *bytes.Buffer
ht float64
stack []context
Expand All @@ -46,32 +54,69 @@ type context struct {
gEnds int
}

type option func(*Canvas)

// UseWH specifies the width and height of the canvas.
func UseWH(w, h vg.Length) option {
return func(c *Canvas) {
if w <= 0 || h <= 0 {
panic("vgsvg: w and h must both be > 0")
}
c.w = w
c.h = h
}
}

// 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("vgsvg: DPI must be > 0")
}
return func(c *Canvas) {
c.dpi = float64(dpi)
}
}

// New returns a new image canvas.
func New(w, h vg.Length) *Canvas {
return NewWith(UseWH(w, h), UseDPI(DefaultDPI))
}

// NewWith returns a new image canvas created according to the specified
// options. The currently accepted options are UseWH and UseDPI.
// If size or resolution are not specified, defaults are used.
func NewWith(opts ...option) *Canvas {
buf := new(bytes.Buffer)
c := &Canvas{
svg: svgo.New(buf),
w: w,
h: h,
w: DefaultWidth,
h: DefaultHeight,
dpi: DefaultDPI,
buf: buf,
ht: w.Points(),
stack: []context{{}},
}

for _, opt := range opts {
opt(c)
}
c.ht = c.w.Points()

// This is like svg.Start, except it uses floats
// and specifies the units.
fmt.Fprintf(buf, `<?xml version="1.0"?>
fmt.Fprintf(c.buf, `<?xml version="1.0"?>
<!-- Generated by SVGo and Plotinum VG -->
<svg width="%.*gin" height="%.*gin"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">`+"\n",
pr, w/vg.Inch,
pr, h/vg.Inch,
pr, c.w/vg.Inch,
pr, c.h/vg.Inch,
)

// Swap the origin to the bottom left.
// This must be matched with a </g> when saving,
// before the closing </svg>.
c.svg.Gtransform(fmt.Sprintf("scale(1, -1) translate(0, -%.*g)", pr, h.Dots(DPI)))
c.svg.Gtransform(fmt.Sprintf("scale(1, -1) translate(0, -%.*g)", pr, c.h.Dots(c.dpi)))

vg.Initialize(c)
return c
Expand Down Expand Up @@ -105,7 +150,7 @@ func (c *Canvas) Rotate(rot float64) {
}

func (c *Canvas) Translate(pt vg.Point) {
c.svg.Gtransform(fmt.Sprintf("translate(%.*g, %.*g)", pr, pt.X.Dots(DPI), pr, pt.Y.Dots(DPI)))
c.svg.Gtransform(fmt.Sprintf("translate(%.*g, %.*g)", pr, pt.X.Dots(c.dpi), pr, pt.Y.Dots(c.dpi)))
c.context().gEnds++
}

Expand All @@ -128,16 +173,16 @@ func (c *Canvas) Pop() {
}

func (c *Canvas) Stroke(path vg.Path) {
if c.context().lineWidth.Dots(DPI) <= 0 {
if c.context().lineWidth.Dots(c.dpi) <= 0 {
return
}
c.svg.Path(c.pathData(path),
style(elm("fill", "#000000", "none"),
elm("stroke", "none", colorString(c.context().color)),
elm("stroke-opacity", "1", opacityString(c.context().color)),
elm("stroke-width", "1", "%.*g", pr, c.context().lineWidth.Dots(DPI)),
elm("stroke-width", "1", "%.*g", pr, c.context().lineWidth.Dots(c.dpi)),
elm("stroke-dasharray", "none", dashArrayString(c)),
elm("stroke-dashoffset", "0", "%.*g", pr, c.context().dashOffset.Dots(DPI))))
elm("stroke-dashoffset", "0", "%.*g", pr, c.context().dashOffset.Dots(c.dpi))))
}

func (c *Canvas) Fill(path vg.Path) {
Expand All @@ -152,17 +197,17 @@ func (c *Canvas) pathData(path vg.Path) string {
for _, comp := range path {
switch comp.Type {
case vg.MoveComp:
fmt.Fprintf(buf, "M%.*g,%.*g", pr, comp.Pos.X.Dots(DPI), pr, comp.Pos.Y.Dots(DPI))
x = comp.Pos.X.Dots(DPI)
y = comp.Pos.Y.Dots(DPI)
fmt.Fprintf(buf, "M%.*g,%.*g", pr, comp.Pos.X.Dots(c.dpi), pr, comp.Pos.Y.Dots(c.dpi))
x = comp.Pos.X.Dots(c.dpi)
y = comp.Pos.Y.Dots(c.dpi)
case vg.LineComp:
fmt.Fprintf(buf, "L%.*g,%.*g", pr, comp.Pos.X.Dots(DPI), pr, comp.Pos.Y.Dots(DPI))
x = comp.Pos.X.Dots(DPI)
y = comp.Pos.Y.Dots(DPI)
fmt.Fprintf(buf, "L%.*g,%.*g", pr, comp.Pos.X.Dots(c.dpi), pr, comp.Pos.Y.Dots(c.dpi))
x = comp.Pos.X.Dots(c.dpi)
y = comp.Pos.Y.Dots(c.dpi)
case vg.ArcComp:
r := comp.Radius.Dots(DPI)
x0 := comp.Pos.X.Dots(DPI) + r*math.Cos(comp.Start)
y0 := comp.Pos.Y.Dots(DPI) + r*math.Sin(comp.Start)
r := comp.Radius.Dots(c.dpi)
x0 := comp.Pos.X.Dots(c.dpi) + r*math.Cos(comp.Start)
y0 := comp.Pos.Y.Dots(c.dpi) + r*math.Sin(comp.Start)
if x0 != x || y0 != y {
fmt.Fprintf(buf, "L%.*g,%.*g", pr, x0, pr, y0)
}
Expand Down Expand Up @@ -194,11 +239,11 @@ func circle(w io.Writer, c *Canvas, comp *vg.PathComp) (x, y float64) {
panic("Impossible angle")
}

r := comp.Radius.Dots(DPI)
x0 := comp.Pos.X.Dots(DPI) + r*math.Cos(comp.Start+angle/2)
y0 := comp.Pos.Y.Dots(DPI) + r*math.Sin(comp.Start+angle/2)
x = comp.Pos.X.Dots(DPI) + r*math.Cos(comp.Start+angle)
y = comp.Pos.Y.Dots(DPI) + r*math.Sin(comp.Start+angle)
r := comp.Radius.Dots(c.dpi)
x0 := comp.Pos.X.Dots(c.dpi) + r*math.Cos(comp.Start+angle/2)
y0 := comp.Pos.Y.Dots(c.dpi) + r*math.Sin(comp.Start+angle/2)
x = comp.Pos.X.Dots(c.dpi) + r*math.Cos(comp.Start+angle)
y = comp.Pos.Y.Dots(c.dpi) + r*math.Sin(comp.Start+angle)

fmt.Fprintf(w, "A%.*g,%.*g 0 %d %d %.*g,%.*g", pr, r, pr, r,
large(angle/2), sweep(angle/2), pr, x0, pr, y0) //
Expand All @@ -220,9 +265,9 @@ func remainder(x, y float64) float64 {
// less than a full circle, if it is greater then
// circle should be used instead.
func arc(w io.Writer, c *Canvas, comp *vg.PathComp) (x, y float64) {
r := comp.Radius.Dots(DPI)
x = comp.Pos.X.Dots(DPI) + r*math.Cos(comp.Start+comp.Angle)
y = comp.Pos.Y.Dots(DPI) + r*math.Sin(comp.Start+comp.Angle)
r := comp.Radius.Dots(c.dpi)
x = comp.Pos.X.Dots(c.dpi) + r*math.Cos(comp.Start+comp.Angle)
y = comp.Pos.Y.Dots(c.dpi) + r*math.Sin(comp.Start+comp.Angle)
fmt.Fprintf(w, "A%.*g,%.*g 0 %d %d %.*g,%.*g", pr, r, pr, r,
large(comp.Angle), sweep(comp.Angle), pr, x, pr, y)
return
Expand Down Expand Up @@ -258,7 +303,7 @@ func (c *Canvas) FillString(font vg.Font, pt vg.Point, str string) {
sty = "\n\t" + sty
}
fmt.Fprintf(c.buf, `<text x="%.*g" y="%.*g" transform="scale(1, -1)"%s>%s</text>`+"\n",
pr, pt.X.Dots(DPI), pr, -pt.Y.Dots(DPI), sty, str)
pr, pt.X.Dots(c.dpi), pr, -pt.Y.Dots(c.dpi), sty, str)
}

// DrawImage implements the vg.Canvas.DrawImage method.
Expand All @@ -272,10 +317,10 @@ func (c *Canvas) DrawImage(rect vg.Rectangle, img image.Image) {
rsz := rect.Size()
min := rect.Min
var (
width = rsz.X.Dots(DPI)
height = rsz.Y.Dots(DPI)
xmin = min.X.Dots(DPI)
ymin = min.Y.Dots(DPI)
width = rsz.X.Dots(c.dpi)
height = rsz.Y.Dots(c.dpi)
xmin = min.X.Dots(c.dpi)
ymin = min.Y.Dots(c.dpi)
)
fmt.Fprintf(
c.buf,
Expand Down Expand Up @@ -384,7 +429,7 @@ func elm(key, def, f string, vls ...interface{}) string {
func dashArrayString(c *Canvas) string {
str := ""
for i, d := range c.context().dashArray {
str += fmt.Sprintf("%.*g", pr, d.Dots(DPI))
str += fmt.Sprintf("%.*g", pr, d.Dots(c.dpi))
if i < len(c.context().dashArray)-1 {
str += ","
}
Expand Down
81 changes: 81 additions & 0 deletions vg/vgsvg/vgsvg_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright ©2019 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 vgsvg_test

import (
"bytes"
"io/ioutil"
"log"
"testing"

"gonum.org/v1/plot"
"gonum.org/v1/plot/internal/cmpimg"
"gonum.org/v1/plot/plotter"
"gonum.org/v1/plot/vg"
"gonum.org/v1/plot/vg/draw"
"gonum.org/v1/plot/vg/vgsvg"
)

func Example() {
p, err := plot.New()
if err != nil {
log.Fatalf("could not create plot: %v", err)
}
p.Title.Text = "Scatter plot"
p.X.Label.Text = "X"
p.Y.Label.Text = "Y"

scatter, err := plotter.NewScatter(plotter.XYs{{1, 1}, {0, 1}, {0, 0}})
if err != nil {
log.Fatalf("could not create scatter: %v", err)
}
p.Add(scatter)

err = p.Save(5*vg.Centimeter, 5*vg.Centimeter, "testdata/scatter.svg")
if err != nil {
log.Fatalf("could not save SVG plot: %v", err)
}
}

func TestSVG(t *testing.T) {
cmpimg.CheckPlot(Example, t, "scatter.svg")
}

func TestNewWith(t *testing.T) {
p, err := plot.New()
if err != nil {
t.Fatalf("could not create plot: %v", err)
}
p.Title.Text = "Scatter plot"
p.X.Label.Text = "X"
p.Y.Label.Text = "Y"

scatter, err := plotter.NewScatter(plotter.XYs{{1, 1}, {0, 1}, {0, 0}})
if err != nil {
t.Fatalf("could not create scatter: %v", err)
}
p.Add(scatter)

c := vgsvg.NewWith(vgsvg.UseDPI(96), vgsvg.UseWH(5*vg.Centimeter, 5*vg.Centimeter))
p.Draw(draw.New(c))

b := new(bytes.Buffer)
if _, err = c.WriteTo(b); err != nil {
t.Fatal(err)
}

want, err := ioutil.ReadFile("testdata/scatter_golden.svg")
if err != nil {
t.Fatal(err)
}

ok, err := cmpimg.Equal("svg", b.Bytes(), want)
if err != nil {
t.Fatalf("could not compare images: %v", err)
}
if !ok {
t.Fatalf("images differ:\ngot:\n%s\nwant:\n%s\n", b.Bytes(), want)
}
}

0 comments on commit c882b8d

Please sign in to comment.