server.garden privileged automation agent (mirror of https://git.sequentialread.com/forest/rootsystem)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

764 lines
23 KiB

package automation
import (
"encoding/xml"
"fmt"
"html"
"regexp"
"strconv"
"strings"
errors "git.sequentialread.com/forest/pkg-errors"
)
type XMLAttr struct {
Name string
Value string
Namespace string
Local string
}
type XMLNode struct {
XMLName xml.Name
Attrs []*XMLAttr `xml:"-"`
Content []byte `xml:",innerxml"`
Children []*XMLNode `xml:",any"`
Parent *XMLNode `xml:"-"`
}
type XMLQuery struct {
DirectChild bool
NodeType string
Class string
Attr string
AttrValue string
}
type Rect2D struct {
NonEmpty bool
Top float64
Left float64
Bottom float64
Right float64
}
const terraformSvgFontFamily = "-apple-system,system-ui,BlinkMacSystemFont,Ubuntu,Roboto,Segoe UI,sans-serif"
const terraformActionRedClass = "red"
const terraformActionYellowClass = "yellow"
const terraformActionGreenClass = "green"
const terraformActionCircleRadius = 14
const terraformActionFontSize = 18
const terraformActionTextOffsetY = 5
const terraformActionOffsetY = 4
const terraformActionOffsetX = -4
const terraformActionTextSpacingX = 7
const dotStringCharactersMinPadding = 12
const dotStringLinearPadding = float32(0)
func (node *XMLNode) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
attrPointers := []*XMLAttr{}
for _, attr := range start.Attr {
// I dont really give a shit about XML namespaces but I do have to search for xlink:title which is separate from normal title
// hardcoded that this is happening inside the svg namespace so all non svg namespaces attrs will be specified like ns:attr
name := attr.Name.Local
if attr.Name.Space != "" {
// attr.Name.Space is a url like http://www.w3.org/1999/xlink or ttp://www.w3.org/2000/svg so we are just grabbing the last part
split := strings.Split(attr.Name.Space, "/")
ns := split[len(split)-1]
if ns != "svg" {
name = fmt.Sprintf("%s:%s", ns, name)
}
}
myAttr := XMLAttr{
Name: name,
Value: attr.Value,
Namespace: attr.Name.Space,
Local: attr.Name.Local,
}
attrPointers = append(attrPointers, &myAttr)
}
node.Attrs = attrPointers
// this little derived type wont directly have this UnmarshalXML method,
// thus prevent the decoder from thinking it has to invoke our custom UnmarshalXML again...
type dontCauseInfiniteLoop XMLNode
return d.DecodeElement((*dontCauseInfiniteLoop)(node), &start)
}
func (node XMLNode) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
attrsToEncode := []xml.Attr{}
for _, attr := range node.Attrs {
// this encoder will print some stupid shit if you give it an xmlns attr:
// for example: <svg ... xmlns:_ _xmlns:xlink="http://www.w3.org/1999/xlink">
if attr.Namespace != "xmlns" {
attrsToEncode = append(attrsToEncode, xml.Attr{
Name: xml.Name{
Local: attr.Local,
Space: attr.Namespace,
},
Value: attr.Value,
})
}
}
start.Attr = attrsToEncode
start.Name = node.XMLName
// this little derived type wont directly have this UnmarshalXML method,
// thus prevent the decoder from thinking it has to invoke our custom UnmarshalXML again...
type dontCauseInfiniteLoop XMLNode
return e.EncodeElement((dontCauseInfiniteLoop)(node), start)
}
func makeSVGFromSimpleStatus(simpleStatus *SimplifiedTerraformStatus) ([]byte, error) {
moduleDots := []string{}
for moduleName, module := range simpleStatus.Modules {
resourceDots := []string{}
for _, resource := range module.Resources {
id := fmt.Sprintf(`%s_%s`, moduleName, resource.DisplayName)
id = strings.ReplaceAll(strings.ReplaceAll(id, ".", "_"), "-", "_")
resourceStyle := `, margin = 0.1, shape = "box3d"`
if resource.DisplayName == "none" {
resourceStyle = ""
}
tooltip := ""
if !strings.HasPrefix(moduleName, "module.ansible-") && resource.DisplayName != "none" {
tooltip = fmt.Sprintf(`, tooltip = "%s.%s"`, moduleName, resource.DisplayName)
}
resourceDot := fmt.Sprintf(`
"%s" [label = "%s"%s%s]`,
id, padStringForDot(resource.DisplayName), tooltip, resourceStyle,
)
resourceDots = append(resourceDots, resourceDot)
}
i := 0
for _, resource := range module.Resources {
if i != 0 {
id0 := fmt.Sprintf(`%s_%s`, moduleName, module.Resources[i-1].DisplayName)
id0 = strings.ReplaceAll(strings.ReplaceAll(id0, ".", "_"), "-", "_")
id1 := fmt.Sprintf(`%s_%s`, moduleName, resource.DisplayName)
id1 = strings.ReplaceAll(strings.ReplaceAll(id1, ".", "_"), "-", "_")
resourceDot := fmt.Sprintf(`
"%s" -> "%s" [style=invis]`, id0, id1,
)
resourceDots = append(resourceDots, resourceDot)
}
i++
}
moduleDot := fmt.Sprintf(`
subgraph cluster_%s {
bgcolor = lightgrey;
tooltip = "%s";
label = "%s";
%s
}`,
strings.ReplaceAll(strings.ReplaceAll(moduleName, ".", "_"), "-", "_"),
moduleName,
padStringForDot(moduleName),
strings.Join(resourceDots, ""),
)
//fmt.Println(moduleName)
moduleDots = append(moduleDots, moduleDot)
}
variableDots := []string{}
connectedVariables := map[string]bool{}
for _, connection := range simpleStatus.Connections {
if connection.From == "data.terraform_remote_state" {
variableDot := fmt.Sprintf(`
"%s" [label = "%s", tooltip = "%s", margin = 0.1, shape = "note"];`,
strings.ReplaceAll(strings.ReplaceAll(connection.From, ".", "_"), "-", "_"),
padStringForDot(remote_state_placeholder_text),
connection.From,
)
variableDots = append(variableDots, variableDot)
} else {
split := strings.Split(connection.From, ".")
connectedVariables[split[len(split)-1]] = true
}
}
for variableName, value := range simpleStatus.Variables {
if connectedVariables[variableName] {
variableDot := fmt.Sprintf(`
"var_%s" [label = "%s", tooltip = "%s", margin = 0.1, shape = "note"];`,
strings.ReplaceAll(variableName, "-", "_"), padStringForDot(value), fmt.Sprintf("var.%s", variableName),
)
variableDots = append(variableDots, variableDot)
}
}
edgeMap := map[string]bool{}
connectionDots := []string{}
for _, connection := range simpleStatus.Connections {
tailLocation := fmt.Sprintf(
"ltail=\"cluster_%s\", ",
strings.ReplaceAll(strings.ReplaceAll(connection.From, ".", "_"), "-", "_"),
)
headLocation := fmt.Sprintf(
"lhead=\"cluster_%s\", ",
strings.ReplaceAll(strings.ReplaceAll(connection.To, ".", "_"), "-", "_"),
)
from := connection.From
if strings.HasPrefix(connection.From, "var") || connection.From == "data.terraform_remote_state" {
tailLocation = ""
} else {
fromResources := simpleStatus.Modules[connection.From].Resources
from = fmt.Sprintf("%s_%s", connection.From, fromResources[len(fromResources)-1].DisplayName)
}
toResources := simpleStatus.Modules[connection.To].Resources
to := fmt.Sprintf("%s_%s", connection.To, toResources[0].DisplayName)
from = strings.ReplaceAll(strings.ReplaceAll(from, ".", "_"), "-", "_")
to = strings.ReplaceAll(strings.ReplaceAll(to, ".", "_"), "-", "_")
paramName := strings.ReplaceAll(strings.ReplaceAll(connection.DisplayName, ".", "_"), "-", "_")
sourceEdge := fmt.Sprintf(`
"%s" -> "param_%s" [%sarrowhead=none];`,
from, paramName, tailLocation,
)
if !edgeMap[sourceEdge] {
edgeMap[sourceEdge] = true
} else {
sourceEdge = ""
}
destEdge := fmt.Sprintf(`
"param_%s" -> "%s" [%s];`,
paramName, to, headLocation,
)
if !edgeMap[destEdge] {
edgeMap[destEdge] = true
} else {
destEdge = ""
}
connectionDot := fmt.Sprintf(`
"param_%s" [label = "%s", shape = "box", style=filled, color=lightgrey]; %s %s`,
paramName, connection.DisplayName, sourceEdge, destEdge,
)
connectionDots = append(connectionDots, connectionDot)
}
dot := fmt.Sprintf(`
digraph {
compound = "true"
newrank = "true"
ranksep = 0.1;
%s
%s
%s
}`,
strings.Join(moduleDots, ""),
strings.Join(variableDots, ""),
strings.Join(connectionDots, ""),
)
exitCode, dotStdout, dotStderr, err := shellExecInputPipe(".", &dot, "dot", "-Tsvg")
err = errorFromShellExecResult("dot -Tsvg", exitCode, dotStdout, dotStderr, err)
if err != nil {
return []byte{}, err
}
svgString := string(dotStdout)
// the svg output from dot has html comments that contain metadata preceeding each entity.
// for example:
// <!-- param_ssh_private_key_filepath -->
// <g id="node16" class="node">
// We will attach that metadata to the svg elements as a data property
// Note that this will not work perfectly for the "cluster"s (aka modules)
// they are handled separately after the SVG has been inserted.
// Yes parsing XML with regex is "evil" but keep in mind, this XML was generated by a program, not written by hand.
// So it is a lot more predictable. Also, I couldn't figure out how to parse the comments with the go std lib XML parser.
commentRegex := regexp.MustCompile(`<!--\s*(.+)\s*-->\s*\n\s*(<[a-z]+\s)`)
svgString = commentRegex.ReplaceAllStringFunc(svgString, func(matched string) string {
matches := commentRegex.FindStringSubmatch(matched)
return fmt.Sprintf("%s dot=\"%s\" ", matches[2], strings.Trim(html.UnescapeString(matches[1]), " "))
})
//ioutil.WriteFile("./test-gen.svg", []byte(svgString), 0777)
var svgDoc XMLNode
err = xml.Unmarshal(dotStdout, &svgDoc)
if err != nil {
panic(err)
}
var setParentRecurse func(*XMLNode)
setParentRecurse = func(node *XMLNode) {
for _, child := range node.Children {
child.Parent = node
setParentRecurse(child)
}
}
setParentRecurse(&svgDoc)
// remove the white background from the SVG
svgDoc.WithQuerySelector(
// 'g.graph > polygon'
[]XMLQuery{XMLQuery{NodeType: "g", Class: "graph"}, XMLQuery{NodeType: "polygon", DirectChild: true}},
func(node *XMLNode) {
node.SetAttr("fill", "none")
},
)
// correctly set the dot property for resources, variables, and parameters
svgDoc.WithQuerySelector(
// 'g.node > title'
[]XMLQuery{XMLQuery{NodeType: "g", Class: "node"}, XMLQuery{NodeType: "title", DirectChild: true}},
func(node *XMLNode) {
if node.Parent != nil {
title := html.UnescapeString(string(node.Content))
node.Parent.SetAttr("data-dot", title)
if strings.HasPrefix(title, "var_") || strings.HasPrefix(title, "data_") {
node.Parent.SetAttr("class", "variable")
}
if strings.HasPrefix(title, "param_") {
node.Parent.SetAttr("class", "parameter")
// the parameters start out as boxes
//but lets squeeze them into keystone shape to differentiate them a bit
node.Parent.WithQuerySelector([]XMLQuery{XMLQuery{NodeType: "polygon"}}, func(polygonNode *XMLNode) {
vertices, err := polygonNode.GetSVGPolygonVertices()
if err == nil {
center := []float64{0, 0}
for _, vertex := range vertices {
center[0] += vertex[0]
center[1] += vertex[1]
}
center[0] = center[0] / float64(len(vertices))
center[1] = center[1] / float64(len(vertices))
newVertices := [][]float64{}
for _, vertex := range vertices {
x := vertex[0]
y := vertex[1]
if y > center[1] {
if x > center[0] {
x -= 10
} else {
x += 10
}
}
newVertices = append(newVertices, []float64{x, y})
}
polygonNode.SetSVGPolygonVertices(newVertices)
}
})
}
if strings.HasPrefix(title, "module_") {
node.Parent.SetAttr("class", "resource")
}
}
},
)
// correctly set the dot property for the "cluster"s (aka modules)
moduleNames := []string{}
svgDoc.WithQuerySelector(
// 'a[xlink:title]'
[]XMLQuery{XMLQuery{NodeType: "a", Attr: "xlink:title"}},
func(node *XMLNode) {
if node.Parent != nil && node.Parent.Parent != nil {
title := html.UnescapeString(node.GetAttr("xlink:title"))
title = regexp.MustCompile(`[.-]`).ReplaceAllString(title, "_")
if node.Parent.Parent.GetAttr("data-dot") == "" {
moduleNames = append(moduleNames, title)
node.Parent.Parent.SetAttr("data-dot", title)
node.Parent.Parent.SetAttr("class", "module")
}
}
},
)
// correctly set the dot property for edges
// in order to get dot to render it correctly, the connections are actually between resources,
// not between modules. So we have to trim the resource name off of the dot attribute
// on the connections to make them match what we have in the status JSON object.
svgDoc.WithQuerySelector(
// 'g.edge title'
[]XMLQuery{XMLQuery{NodeType: "g", Class: "edge"}, XMLQuery{NodeType: "title"}},
func(node *XMLNode) {
if node.Parent != nil {
title := html.UnescapeString(string(node.Content))
fromTo := strings.Split(title, "->")
if len(fromTo) != 2 {
fmt.Printf("malformed dot string on svg edge: fromTo.length != 2: %s", title)
}
// if this edge starts or ends at a resource inside a module, override the dot string
// to make it start or end at that module instead.
for _, moduleName := range moduleNames {
if strings.HasPrefix(fromTo[0], moduleName) {
node.Parent.SetAttr("data-dot", html.EscapeString(fmt.Sprintf("%s->%s", moduleName, fromTo[1])))
}
if strings.HasPrefix(fromTo[1], moduleName) {
node.Parent.SetAttr("data-dot", html.EscapeString(fmt.Sprintf("%s->%s", fromTo[0], moduleName)))
}
}
}
},
)
svgDoc.WithQuerySelector(
// '[font-family]'
[]XMLQuery{XMLQuery{Attr: "font-family"}},
func(node *XMLNode) {
node.SetAttr("font-family", terraformSvgFontFamily)
},
)
// two last things to do:
// 1. center each resource horizontally within the bounding box of its parent module
// because dot is dumb and it just kinda throws them wherever
// 2. create little create/delete/modify icons next to each changed resource
// similar to how terraform displays -, +, +/-, or ~ on each line-item in the plan
for moduleName, module := range simpleStatus.Modules {
moduleId := regexp.MustCompile(`[.-]`).ReplaceAllString(moduleName, "_")
svgDoc.WithQuerySelector(
[]XMLQuery{XMLQuery{Attr: "data-dot", AttrValue: moduleId}},
func(node *XMLNode) {
moduleRect := node.GetBoundingBox()
for _, resource := range module.Resources {
resourceId := fmt.Sprintf("%s_%s", moduleId, regexp.MustCompile(`[.-]`).ReplaceAllString(resource.DisplayName, "_"))
svgDoc.WithQuerySelector(
// '[data-dot=module_dns_gandi_gandi_livedns_record_dns_entries]' for example
[]XMLQuery{XMLQuery{Attr: "data-dot", AttrValue: resourceId}},
func(node *XMLNode) {
resourceRect := node.GetBoundingBox()
// Step 1 center the resources
nudgeX := ((moduleRect.Left + moduleRect.Right) - (resourceRect.Left + resourceRect.Right)) * float64(0.5)
node.SetAttr("transform", fmt.Sprintf("scale(1 1) rotate(0) translate(%d %d)", int(nudgeX), 0))
// Step 2 add the terraform action display if there is a planned action
if resource.Plan != "none" && resource.Plan != "" {
actionX := resourceRect.Left + nudgeX + float64(terraformActionOffsetX)
actionY := resourceRect.Top + float64(terraformActionOffsetY)
// start with a circle element
actionContents := []*XMLNode{
&XMLNode{
XMLName: xml.Name{Local: "circle", Space: "http://www.w3.org/2000/svg"},
Attrs: []*XMLAttr{
&XMLAttr{
Local: "cx",
Value: fmt.Sprintf("%.4f", actionX),
},
&XMLAttr{
Local: "fill",
Value: "#ffffff",
},
&XMLAttr{
Local: "cy",
Value: fmt.Sprintf("%.4f", actionY),
},
&XMLAttr{
Local: "r",
Value: strconv.Itoa(terraformActionCircleRadius),
},
},
},
}
// macro to add text to the inside of the circle
createText := func(str, class string, offset int) {
actionContents = append(actionContents, &XMLNode{
XMLName: xml.Name{Local: "text", Space: "http://www.w3.org/2000/svg"},
Attrs: []*XMLAttr{
&XMLAttr{Local: "text-anchor", Value: "middle"},
&XMLAttr{Local: "font-family", Value: terraformSvgFontFamily},
&XMLAttr{Local: "text-size", Value: strconv.Itoa(terraformActionFontSize)},
&XMLAttr{Local: "class", Value: class},
&XMLAttr{Local: "x", Value: fmt.Sprintf("%.4f", actionX+float64(offset*terraformActionTextSpacingX))},
&XMLAttr{Local: "y", Value: fmt.Sprintf("%.4f", actionY+float64(terraformActionTextOffsetY))},
},
Content: []byte(str),
})
}
// add text to the circle depending on the planned action
if resource.Plan == "recreate" {
createText("-", terraformActionRedClass, -1)
createText("/", "", 0)
createText("+", terraformActionGreenClass, 1)
}
if resource.Plan == "update" {
createText("~", terraformActionYellowClass, 0)
}
if resource.Plan == "create" {
createText("+", terraformActionGreenClass, 0)
}
if resource.Plan == "delete" {
createText("-", terraformActionRedClass, 0)
}
// Finally, add all the action content inside a single <g> tag & append to the svg
node.Parent.Children = append(node.Parent.Children, &XMLNode{
XMLName: xml.Name{Local: "g", Space: "http://www.w3.org/2000/svg"},
Attrs: []*XMLAttr{
&XMLAttr{
Local: "class",
Value: "action-circle",
},
&XMLAttr{
Local: "data-dot",
Value: fmt.Sprintf("%s_action_circle", resourceId),
},
},
Children: actionContents,
})
}
},
)
}
},
)
}
var cleanUpSvgRecurse func(*XMLNode)
cleanUpSvgRecurse = func(node *XMLNode) {
// We cant serialize cyclical relationships like parent -> child -> parent
node.Parent = nil
// if we don't zero out the content for non-leaf nodes, we will get duplicates of everything
if len(node.Children) != 0 {
node.Content = []byte{}
}
for _, child := range node.Children {
cleanUpSvgRecurse(child)
}
}
cleanUpSvgRecurse(&svgDoc)
// testJson2, err := json.MarshalIndent(svgDoc, "", " ")
// if err != nil {
// panic(err)
// }
// ioutil.WriteFile("./test-processed.json", testJson2, 0777)
svgBytes, err := xml.MarshalIndent(svgDoc, "", " ")
// it goes crazy spamming xmlns everywhere, lets just remove all of them.
svgBytes = regexp.MustCompile(`xmlns[^=]*="[^"]+"`).ReplaceAll(svgBytes, []byte{})
// now we add the xmlns back to just the root element
startOfSvgTagWithXMLNS := []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" `)
svgBytes = regexp.MustCompile(`^\s*<svg\s*`).ReplaceAll(svgBytes, startOfSvgTagWithXMLNS)
svgHeader := []byte(`<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz & server.garden rootsystem -->
`)
svgBytes = append(svgHeader, svgBytes...)
return svgBytes, nil
}
func padStringForDot(str string) string {
pad := strings.Repeat(" ", int(float32(len(str)+dotStringCharactersMinPadding)*dotStringLinearPadding))
return fmt.Sprintf("%s%s%s", pad, str, pad)
}
func (doc *XMLNode) QuerySelectorAll(querySteps []XMLQuery) []*XMLNode {
iterate := func(search []*XMLNode, q XMLQuery) []*XMLNode {
toReturn := []*XMLNode{}
var recurse func(*XMLNode, bool)
recurse = func(node *XMLNode, isDirectChild bool) {
match := true
if q.NodeType != "" && node.XMLName.Local != q.NodeType {
match = false
}
if q.Class != "" {
found := false
for _, attr := range node.Attrs {
classes := strings.Split(attr.Value, " ")
if attr.Name == "class" {
for _, class := range classes {
if class == q.Class {
found = true
}
}
}
}
if !found {
match = false
}
}
if q.Attr != "" {
found := false
for _, attr := range node.Attrs {
if attr.Name == q.Attr {
if q.AttrValue == "" || attr.Value == q.AttrValue {
found = true
}
}
}
if !found {
match = false
}
}
if match && (isDirectChild || !q.DirectChild) {
toReturn = append(toReturn, node)
}
if isDirectChild || !q.DirectChild {
for _, child := range node.Children {
recurse(child, false)
}
}
}
for _, node := range search {
for _, child := range node.Children {
recurse(child, true)
}
}
return toReturn
}
matched := []*XMLNode{doc}
for _, step := range querySteps {
matched = iterate(matched, step)
}
return matched
}
func (doc *XMLNode) WithQuerySelector(querySteps []XMLQuery, withResult func(*XMLNode)) {
allResults := doc.QuerySelectorAll(querySteps)
for _, res := range allResults {
withResult(res)
}
}
func (node *XMLNode) SetAttr(attr string, value string) {
has := false
for _, a := range node.Attrs {
if a.Name == attr {
a.Value = value
has = true
}
}
if !has {
newAttr := XMLAttr{
Name: attr,
Value: value,
Local: attr,
}
node.Attrs = append(node.Attrs, &newAttr)
}
}
func (node *XMLNode) GetAttr(attr string) string {
for _, a := range node.Attrs {
if a.Name == attr {
return a.Value
}
}
return ""
}
// Get Rect Kid
func (rect *Rect2D) Encapsulate(x, y float64) {
if !rect.NonEmpty {
rect.Bottom = y
rect.Top = y
rect.Left = x
rect.Right = x
rect.NonEmpty = true
} else {
if x < rect.Left {
rect.Left = x
}
if x > rect.Right {
rect.Right = x
}
if y < rect.Top {
rect.Top = y
}
if y > rect.Bottom {
rect.Bottom = y
}
}
}
func (node *XMLNode) GetBoundingBox() *Rect2D {
toReturn := Rect2D{}
polygons := node.QuerySelectorAll([]XMLQuery{XMLQuery{NodeType: "polygon"}})
ellipses := node.QuerySelectorAll([]XMLQuery{XMLQuery{NodeType: "ellipse"}})
for _, polygon := range polygons {
vertices, err := polygon.GetSVGPolygonVertices()
if err == nil {
for _, vertex := range vertices {
toReturn.Encapsulate(vertex[0], vertex[1])
}
}
}
for _, ellipse := range ellipses {
cx, errCx := strconv.ParseFloat(ellipse.GetAttr("cx"), 64)
cy, errCy := strconv.ParseFloat(ellipse.GetAttr("cy"), 64)
rx, errRx := strconv.ParseFloat(ellipse.GetAttr("rx"), 64)
ry, errRy := strconv.ParseFloat(ellipse.GetAttr("ry"), 64)
if errCx == nil && errCy == nil && errRx == nil && errRy == nil {
toReturn.Encapsulate(cx-rx, cy)
toReturn.Encapsulate(cx+rx, cy)
toReturn.Encapsulate(cx, cy-ry)
toReturn.Encapsulate(cx, cy+ry)
}
}
return &toReturn
}
func (node *XMLNode) GetSVGPolygonVertices() ([][]float64, error) {
toReturn := [][]float64{}
var err error
points := strings.Split(node.GetAttr("points"), " ")
for _, point := range points {
coords := strings.Split(point, ",")
if len(coords) == 2 {
x, errX := strconv.ParseFloat(coords[0], 64)
y, errY := strconv.ParseFloat(coords[1], 64)
if errX == nil && errY == nil {
toReturn = append(toReturn, []float64{x, y})
} else {
err = errors.New("parsing svg polygon points failed cuz strconv.ParseFloat failed")
}
} else {
err = errors.New("parsing svg polygon points failed cuz len(coords) != 2")
}
}
return toReturn, err
}
func (node *XMLNode) SetSVGPolygonVertices(points [][]float64) {
coordStrings := []string{}
for _, point := range points {
coordStrings = append(coordStrings, fmt.Sprintf("%.4f,%.4f", point[0], point[1]))
}
node.SetAttr("points", strings.Join(coordStrings, " "))
}