2016-07-08 06:16:34 +02:00
|
|
|
package chart
|
2016-07-08 07:18:53 +02:00
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
2016-07-28 22:22:18 +02:00
|
|
|
"math"
|
2016-07-08 07:18:53 +02:00
|
|
|
"strings"
|
|
|
|
|
2023-09-21 21:33:55 +02:00
|
|
|
"git.fireandbrimst.one/aw/golang-image/font"
|
2016-07-08 07:18:53 +02:00
|
|
|
|
2023-09-21 21:33:55 +02:00
|
|
|
"git.fireandbrimst.one/aw/go-chart/drawing"
|
|
|
|
"git.fireandbrimst.one/aw/go-chart/util"
|
|
|
|
"git.fireandbrimst.one/aw/golang-freetype/truetype"
|
2016-07-08 07:18:53 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
// SVG returns a new png/raster renderer.
|
2016-07-09 02:57:14 +02:00
|
|
|
func SVG(width, height int) (Renderer, error) {
|
2016-07-08 07:18:53 +02:00
|
|
|
buffer := bytes.NewBuffer([]byte{})
|
2016-07-09 02:57:14 +02:00
|
|
|
canvas := newCanvas(buffer)
|
2016-07-08 07:18:53 +02:00
|
|
|
canvas.Start(width, height)
|
|
|
|
return &vectorRenderer{
|
2017-03-27 01:27:15 +02:00
|
|
|
b: buffer,
|
|
|
|
c: canvas,
|
|
|
|
s: &Style{},
|
|
|
|
p: []string{},
|
|
|
|
dpi: DefaultDPI,
|
2016-07-09 02:57:14 +02:00
|
|
|
}, nil
|
2016-07-08 07:18:53 +02:00
|
|
|
}
|
|
|
|
|
2018-10-12 18:43:30 +02:00
|
|
|
// SVGWithCSS returns a new png/raster renderer with attached custom CSS
|
|
|
|
// The optional nonce argument sets a CSP nonce.
|
2023-09-21 21:33:55 +02:00
|
|
|
func SVGWithCSS(css string, nonce string) func(width, height int) (Renderer, error) {
|
2018-10-12 18:43:30 +02:00
|
|
|
return func(width, height int) (Renderer, error) {
|
|
|
|
buffer := bytes.NewBuffer([]byte{})
|
|
|
|
canvas := newCanvas(buffer)
|
|
|
|
canvas.css = css
|
|
|
|
canvas.nonce = nonce
|
|
|
|
canvas.Start(width, height)
|
|
|
|
return &vectorRenderer{
|
|
|
|
b: buffer,
|
|
|
|
c: canvas,
|
|
|
|
s: &Style{},
|
|
|
|
p: []string{},
|
|
|
|
dpi: DefaultDPI,
|
|
|
|
}, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2016-07-08 07:18:53 +02:00
|
|
|
// vectorRenderer renders chart commands to a bitmap.
|
|
|
|
type vectorRenderer struct {
|
2016-07-09 02:57:14 +02:00
|
|
|
dpi float64
|
|
|
|
b *bytes.Buffer
|
|
|
|
c *canvas
|
|
|
|
s *Style
|
|
|
|
p []string
|
|
|
|
fc *font.Drawer
|
|
|
|
}
|
|
|
|
|
2016-10-21 21:44:37 +02:00
|
|
|
func (vr *vectorRenderer) ResetStyle() {
|
|
|
|
vr.s = &Style{Font: vr.s.Font}
|
|
|
|
vr.fc = nil
|
|
|
|
}
|
|
|
|
|
2016-07-10 10:11:47 +02:00
|
|
|
// GetDPI returns the dpi.
|
|
|
|
func (vr *vectorRenderer) GetDPI() float64 {
|
|
|
|
return vr.dpi
|
|
|
|
}
|
|
|
|
|
2016-07-09 02:57:14 +02:00
|
|
|
// SetDPI implements the interface method.
|
|
|
|
func (vr *vectorRenderer) SetDPI(dpi float64) {
|
|
|
|
vr.dpi = dpi
|
2016-07-16 03:19:29 +02:00
|
|
|
vr.c.dpi = dpi
|
2016-07-08 07:18:53 +02:00
|
|
|
}
|
|
|
|
|
2018-10-12 02:21:46 +02:00
|
|
|
// SetClassName implements the interface method.
|
|
|
|
func (vr *vectorRenderer) SetClassName(classname string) {
|
|
|
|
vr.s.ClassName = classname
|
|
|
|
}
|
|
|
|
|
2016-07-09 02:57:14 +02:00
|
|
|
// SetStrokeColor implements the interface method.
|
2016-07-09 20:23:35 +02:00
|
|
|
func (vr *vectorRenderer) SetStrokeColor(c drawing.Color) {
|
2016-07-08 07:18:53 +02:00
|
|
|
vr.s.StrokeColor = c
|
|
|
|
}
|
|
|
|
|
|
|
|
// SetFillColor implements the interface method.
|
2016-07-09 20:23:35 +02:00
|
|
|
func (vr *vectorRenderer) SetFillColor(c drawing.Color) {
|
2016-07-08 07:18:53 +02:00
|
|
|
vr.s.FillColor = c
|
|
|
|
}
|
|
|
|
|
|
|
|
// SetLineWidth implements the interface method.
|
|
|
|
func (vr *vectorRenderer) SetStrokeWidth(width float64) {
|
|
|
|
vr.s.StrokeWidth = width
|
|
|
|
}
|
|
|
|
|
2016-07-12 03:48:51 +02:00
|
|
|
// StrokeDashArray sets the stroke dash array.
|
|
|
|
func (vr *vectorRenderer) SetStrokeDashArray(dashArray []float64) {
|
|
|
|
vr.s.StrokeDashArray = dashArray
|
|
|
|
}
|
|
|
|
|
2016-07-08 07:18:53 +02:00
|
|
|
// MoveTo implements the interface method.
|
|
|
|
func (vr *vectorRenderer) MoveTo(x, y int) {
|
|
|
|
vr.p = append(vr.p, fmt.Sprintf("M %d %d", x, y))
|
|
|
|
}
|
|
|
|
|
|
|
|
// LineTo implements the interface method.
|
|
|
|
func (vr *vectorRenderer) LineTo(x, y int) {
|
|
|
|
vr.p = append(vr.p, fmt.Sprintf("L %d %d", x, y))
|
|
|
|
}
|
|
|
|
|
2016-07-28 11:34:44 +02:00
|
|
|
// QuadCurveTo draws a quad curve.
|
|
|
|
func (vr *vectorRenderer) QuadCurveTo(cx, cy, x, y int) {
|
|
|
|
vr.p = append(vr.p, fmt.Sprintf("Q%d,%d %d,%d", cx, cy, x, y))
|
|
|
|
}
|
|
|
|
|
|
|
|
func (vr *vectorRenderer) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) {
|
2017-05-13 02:12:23 +02:00
|
|
|
startAngle = util.Math.RadianAdd(startAngle, _pi2)
|
|
|
|
endAngle := util.Math.RadianAdd(startAngle, delta)
|
2016-07-28 22:22:18 +02:00
|
|
|
|
|
|
|
startx := cx + int(rx*math.Sin(startAngle))
|
|
|
|
starty := cy - int(ry*math.Cos(startAngle))
|
|
|
|
|
|
|
|
if len(vr.p) > 0 {
|
|
|
|
vr.p = append(vr.p, fmt.Sprintf("L %d %d", startx, starty))
|
|
|
|
} else {
|
|
|
|
vr.p = append(vr.p, fmt.Sprintf("M %d %d", startx, starty))
|
|
|
|
}
|
|
|
|
|
|
|
|
endx := cx + int(rx*math.Sin(endAngle))
|
|
|
|
endy := cy - int(ry*math.Cos(endAngle))
|
|
|
|
|
2017-05-13 02:12:23 +02:00
|
|
|
dd := util.Math.RadiansToDegrees(delta)
|
2016-07-28 22:22:18 +02:00
|
|
|
|
2017-10-12 22:29:55 +02:00
|
|
|
largeArcFlag := 0
|
|
|
|
if delta > _pi {
|
|
|
|
largeArcFlag = 1
|
|
|
|
}
|
|
|
|
|
|
|
|
vr.p = append(vr.p, fmt.Sprintf("A %d %d %0.2f %d 1 %d %d", int(rx), int(ry), dd, largeArcFlag, endx, endy))
|
2016-07-28 11:34:44 +02:00
|
|
|
}
|
|
|
|
|
2016-07-12 03:48:51 +02:00
|
|
|
// Close closes a shape.
|
2016-07-08 07:18:53 +02:00
|
|
|
func (vr *vectorRenderer) Close() {
|
|
|
|
vr.p = append(vr.p, fmt.Sprintf("Z"))
|
|
|
|
}
|
|
|
|
|
|
|
|
// Stroke draws the path with no fill.
|
|
|
|
func (vr *vectorRenderer) Stroke() {
|
2016-07-30 01:36:29 +02:00
|
|
|
vr.drawPath(vr.s.GetStrokeOptions())
|
2016-07-08 07:18:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// Fill draws the path with no stroke.
|
|
|
|
func (vr *vectorRenderer) Fill() {
|
2016-07-30 01:36:29 +02:00
|
|
|
vr.drawPath(vr.s.GetFillOptions())
|
2016-07-08 07:18:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// FillStroke draws the path with both fill and stroke.
|
|
|
|
func (vr *vectorRenderer) FillStroke() {
|
2016-07-30 01:36:29 +02:00
|
|
|
vr.drawPath(vr.s.GetFillAndStrokeOptions())
|
2016-07-08 07:18:53 +02:00
|
|
|
}
|
|
|
|
|
2016-07-12 03:48:51 +02:00
|
|
|
// drawPath draws a path.
|
2016-07-11 03:09:41 +02:00
|
|
|
func (vr *vectorRenderer) drawPath(s Style) {
|
2016-07-30 01:36:29 +02:00
|
|
|
vr.c.Path(strings.Join(vr.p, "\n"), vr.s.GetFillAndStrokeOptions())
|
2016-07-11 03:09:41 +02:00
|
|
|
vr.p = []string{} // clear the path
|
2016-07-08 07:18:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// Circle implements the interface method.
|
|
|
|
func (vr *vectorRenderer) Circle(radius float64, x, y int) {
|
2016-07-30 01:36:29 +02:00
|
|
|
vr.c.Circle(x, y, int(radius), vr.s.GetFillAndStrokeOptions())
|
2016-07-08 07:18:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// SetFont implements the interface method.
|
|
|
|
func (vr *vectorRenderer) SetFont(f *truetype.Font) {
|
2016-07-16 03:19:29 +02:00
|
|
|
vr.s.Font = f
|
2016-07-08 07:18:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// SetFontColor implements the interface method.
|
2016-07-09 20:23:35 +02:00
|
|
|
func (vr *vectorRenderer) SetFontColor(c drawing.Color) {
|
2016-07-08 07:18:53 +02:00
|
|
|
vr.s.FontColor = c
|
|
|
|
}
|
|
|
|
|
|
|
|
// SetFontSize implements the interface method.
|
|
|
|
func (vr *vectorRenderer) SetFontSize(size float64) {
|
|
|
|
vr.s.FontSize = size
|
|
|
|
}
|
|
|
|
|
|
|
|
// Text draws a text blob.
|
|
|
|
func (vr *vectorRenderer) Text(body string, x, y int) {
|
2016-07-30 01:36:29 +02:00
|
|
|
vr.c.Text(x, y, body, vr.s.GetTextOptions())
|
2016-07-08 07:18:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// MeasureText uses the truetype font drawer to measure the width of text.
|
2016-07-12 03:48:51 +02:00
|
|
|
func (vr *vectorRenderer) MeasureText(body string) (box Box) {
|
2016-07-16 03:19:29 +02:00
|
|
|
if vr.s.GetFont() != nil {
|
2016-07-08 07:18:53 +02:00
|
|
|
vr.fc = &font.Drawer{
|
2016-07-16 03:19:29 +02:00
|
|
|
Face: truetype.NewFace(vr.s.GetFont(), &truetype.Options{
|
2016-07-09 02:57:14 +02:00
|
|
|
DPI: vr.dpi,
|
2016-07-08 07:18:53 +02:00
|
|
|
Size: vr.s.FontSize,
|
|
|
|
}),
|
|
|
|
}
|
2016-07-09 19:27:47 +02:00
|
|
|
w := vr.fc.MeasureString(body).Ceil()
|
|
|
|
|
2016-07-12 03:48:51 +02:00
|
|
|
box.Right = w
|
|
|
|
box.Bottom = int(drawing.PointsToPixels(vr.dpi, vr.s.FontSize))
|
2016-09-05 22:26:12 +02:00
|
|
|
if vr.c.textTheta == nil {
|
|
|
|
return
|
|
|
|
}
|
2017-05-13 02:12:23 +02:00
|
|
|
box = box.Corners().Rotate(util.Math.RadiansToDegrees(*vr.c.textTheta)).Box()
|
2016-07-08 07:18:53 +02:00
|
|
|
}
|
2016-07-09 02:57:14 +02:00
|
|
|
return
|
2016-07-08 07:18:53 +02:00
|
|
|
}
|
|
|
|
|
2016-08-07 06:59:46 +02:00
|
|
|
// SetTextRotation sets the text rotation.
|
|
|
|
func (vr *vectorRenderer) SetTextRotation(radians float64) {
|
2016-09-05 22:26:12 +02:00
|
|
|
vr.c.textTheta = &radians
|
2016-08-07 06:59:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// ClearTextRotation clears the text rotation.
|
|
|
|
func (vr *vectorRenderer) ClearTextRotation() {
|
2016-09-05 22:26:12 +02:00
|
|
|
vr.c.textTheta = nil
|
2016-08-07 06:59:46 +02:00
|
|
|
}
|
|
|
|
|
2016-07-12 03:48:51 +02:00
|
|
|
// Save saves the renderer's contents to a writer.
|
2016-07-08 07:18:53 +02:00
|
|
|
func (vr *vectorRenderer) Save(w io.Writer) error {
|
|
|
|
vr.c.End()
|
|
|
|
_, err := w.Write(vr.b.Bytes())
|
|
|
|
return err
|
|
|
|
}
|
2016-07-09 02:57:14 +02:00
|
|
|
|
|
|
|
func newCanvas(w io.Writer) *canvas {
|
|
|
|
return &canvas{
|
2017-03-27 01:29:24 +02:00
|
|
|
w: w,
|
|
|
|
dpi: DefaultDPI,
|
2016-07-09 02:57:14 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
type canvas struct {
|
2016-09-05 22:26:12 +02:00
|
|
|
w io.Writer
|
|
|
|
dpi float64
|
|
|
|
textTheta *float64
|
|
|
|
width int
|
|
|
|
height int
|
2018-10-12 18:43:30 +02:00
|
|
|
css string
|
|
|
|
nonce string
|
2016-07-09 02:57:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
func (c *canvas) Start(width, height int) {
|
|
|
|
c.width = width
|
|
|
|
c.height = height
|
|
|
|
c.w.Write([]byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%d" height="%d">\n`, c.width, c.height)))
|
2018-10-12 18:43:30 +02:00
|
|
|
if c.css != "" {
|
|
|
|
c.w.Write([]byte(`<style type="text/css"`))
|
|
|
|
if c.nonce != "" {
|
|
|
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
|
|
|
|
c.w.Write([]byte(fmt.Sprintf(` nonce="%s"`, c.nonce)))
|
|
|
|
}
|
|
|
|
// To avoid compatibility issues between XML and CSS (f.e. with child selectors) we should encapsulate the CSS with CDATA.
|
|
|
|
c.w.Write([]byte(fmt.Sprintf(`><![CDATA[%s]]></style>`, c.css)))
|
|
|
|
}
|
2016-07-09 02:57:14 +02:00
|
|
|
}
|
|
|
|
|
2016-07-30 01:36:29 +02:00
|
|
|
func (c *canvas) Path(d string, style Style) {
|
2016-07-16 03:19:29 +02:00
|
|
|
var strokeDashArrayProperty string
|
|
|
|
if len(style.StrokeDashArray) > 0 {
|
2016-07-30 01:36:29 +02:00
|
|
|
strokeDashArrayProperty = c.getStrokeDashArray(style)
|
2016-07-09 02:57:14 +02:00
|
|
|
}
|
2018-10-12 02:21:46 +02:00
|
|
|
c.w.Write([]byte(fmt.Sprintf(`<path %s d="%s" %s/>`, strokeDashArrayProperty, d, c.styleAsSVG(style))))
|
2016-07-09 02:57:14 +02:00
|
|
|
}
|
|
|
|
|
2016-07-30 01:36:29 +02:00
|
|
|
func (c *canvas) Text(x, y int, body string, style Style) {
|
2016-09-05 22:26:12 +02:00
|
|
|
if c.textTheta == nil {
|
2018-10-12 02:21:46 +02:00
|
|
|
c.w.Write([]byte(fmt.Sprintf(`<text x="%d" y="%d" %s>%s</text>`, x, y, c.styleAsSVG(style), body)))
|
2016-08-07 06:59:46 +02:00
|
|
|
} else {
|
2017-05-13 02:12:23 +02:00
|
|
|
transform := fmt.Sprintf(` transform="rotate(%0.2f,%d,%d)"`, util.Math.RadiansToDegrees(*c.textTheta), x, y)
|
2018-10-12 02:21:46 +02:00
|
|
|
c.w.Write([]byte(fmt.Sprintf(`<text x="%d" y="%d" %s%s>%s</text>`, x, y, c.styleAsSVG(style), transform, body)))
|
2016-08-07 06:59:46 +02:00
|
|
|
}
|
2016-07-09 02:57:14 +02:00
|
|
|
}
|
|
|
|
|
2016-07-30 01:36:29 +02:00
|
|
|
func (c *canvas) Circle(x, y, r int, style Style) {
|
2018-10-12 02:21:46 +02:00
|
|
|
c.w.Write([]byte(fmt.Sprintf(`<circle cx="%d" cy="%d" r="%d" %s/>`, x, y, r, c.styleAsSVG(style))))
|
2016-07-09 02:57:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
func (c *canvas) End() {
|
|
|
|
c.w.Write([]byte("</svg>"))
|
|
|
|
}
|
2016-07-30 01:36:29 +02:00
|
|
|
|
|
|
|
// getStrokeDashArray returns the stroke-dasharray property of a style.
|
|
|
|
func (c *canvas) getStrokeDashArray(s Style) string {
|
|
|
|
if len(s.StrokeDashArray) > 0 {
|
|
|
|
var values []string
|
|
|
|
for _, v := range s.StrokeDashArray {
|
|
|
|
values = append(values, fmt.Sprintf("%0.1f", v))
|
|
|
|
}
|
|
|
|
return "stroke-dasharray=\"" + strings.Join(values, ", ") + "\""
|
|
|
|
}
|
|
|
|
return ""
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetFontFace returns the font face for the style.
|
|
|
|
func (c *canvas) getFontFace(s Style) string {
|
|
|
|
family := "sans-serif"
|
|
|
|
if s.GetFont() != nil {
|
|
|
|
name := s.GetFont().Name(truetype.NameIDFontFamily)
|
|
|
|
if len(name) != 0 {
|
|
|
|
family = fmt.Sprintf(`'%s',%s`, name, family)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return fmt.Sprintf("font-family:%s", family)
|
|
|
|
}
|
|
|
|
|
2018-10-12 02:21:46 +02:00
|
|
|
// styleAsSVG returns the style as a svg style or class string.
|
2016-07-30 01:36:29 +02:00
|
|
|
func (c *canvas) styleAsSVG(s Style) string {
|
2018-10-12 02:21:46 +02:00
|
|
|
if s.ClassName != "" {
|
|
|
|
return fmt.Sprintf("class=\"%s\"", s.ClassName)
|
|
|
|
}
|
2016-07-30 01:36:29 +02:00
|
|
|
sw := s.StrokeWidth
|
|
|
|
sc := s.StrokeColor
|
|
|
|
fc := s.FillColor
|
|
|
|
fs := s.FontSize
|
|
|
|
fnc := s.FontColor
|
|
|
|
|
2017-03-06 08:52:13 +01:00
|
|
|
var pieces []string
|
|
|
|
|
2016-07-30 01:36:29 +02:00
|
|
|
if sw != 0 {
|
2017-03-06 08:52:13 +01:00
|
|
|
pieces = append(pieces, "stroke-width:"+fmt.Sprintf("%d", int(sw)))
|
|
|
|
} else {
|
|
|
|
pieces = append(pieces, "stroke-width:0")
|
2016-07-30 01:36:29 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if !sc.IsZero() {
|
2017-03-06 08:52:13 +01:00
|
|
|
pieces = append(pieces, "stroke:"+sc.String())
|
|
|
|
} else {
|
|
|
|
pieces = append(pieces, "stroke:none")
|
2016-07-30 01:36:29 +02:00
|
|
|
}
|
|
|
|
|
2017-03-27 01:27:15 +02:00
|
|
|
if !fnc.IsZero() {
|
|
|
|
pieces = append(pieces, "fill:"+fnc.String())
|
|
|
|
} else if !fc.IsZero() {
|
2017-03-06 08:52:13 +01:00
|
|
|
pieces = append(pieces, "fill:"+fc.String())
|
|
|
|
} else {
|
|
|
|
pieces = append(pieces, "fill:none")
|
2016-07-30 01:36:29 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if fs != 0 {
|
2017-03-06 08:52:13 +01:00
|
|
|
pieces = append(pieces, "font-size:"+fmt.Sprintf("%.1fpx", drawing.PointsToPixels(c.dpi, fs)))
|
2016-07-30 01:36:29 +02:00
|
|
|
}
|
|
|
|
|
2017-03-06 08:52:13 +01:00
|
|
|
if s.Font != nil {
|
|
|
|
pieces = append(pieces, c.getFontFace(s))
|
|
|
|
}
|
2018-10-12 02:21:46 +02:00
|
|
|
return fmt.Sprintf("style=\"%s\"", strings.Join(pieces, ";"))
|
2016-07-30 01:36:29 +02:00
|
|
|
}
|