464 lines
11 KiB
Go
464 lines
11 KiB
Go
package gliffy2drawio
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"math"
|
|
"strings"
|
|
)
|
|
|
|
type MxGraph struct {
|
|
Model *MxGraphModel
|
|
nextID int
|
|
root *MxCell
|
|
defaultParent *MxCell
|
|
}
|
|
|
|
func NewMxGraph() *MxGraph {
|
|
root := &MxCell{ID: "0"}
|
|
layer := &MxCell{ID: "1", Parent: "0"}
|
|
graph := &MxGraph{
|
|
Model: &MxGraphModel{
|
|
Root: &mxRoot{Cells: []*MxCell{root, layer}},
|
|
},
|
|
nextID: 2,
|
|
root: root,
|
|
defaultParent: layer,
|
|
}
|
|
return graph
|
|
}
|
|
|
|
func (g *MxGraph) SetDefaultParent(cell *MxCell) {
|
|
g.defaultParent = cell
|
|
}
|
|
|
|
func (g *MxGraph) NextID() string {
|
|
id := g.nextID
|
|
g.nextID++
|
|
return intToString(id)
|
|
}
|
|
|
|
func (g *MxGraph) AddCell(cell *MxCell, parent *MxCell) {
|
|
if cell == nil {
|
|
return
|
|
}
|
|
// Allow preassigned IDs; otherwise generate.
|
|
if cell.ID == "" {
|
|
cell.ID = g.NextID()
|
|
}
|
|
if parent == nil {
|
|
parent = g.defaultParent
|
|
}
|
|
cell.Parent = parent.ID
|
|
g.Model.Root.Cells = append(g.Model.Root.Cells, cell)
|
|
}
|
|
|
|
// HasCell returns true if a cell with the given ID exists in the root.
|
|
func (g *MxGraph) HasCell(id string) bool {
|
|
for _, cell := range g.Model.Root.Cells {
|
|
if cell != nil && cell.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type MxGraphModel struct {
|
|
XMLName xml.Name `xml:"mxGraphModel"`
|
|
Style string `xml:"style,attr,omitempty"`
|
|
Background string `xml:"background,attr,omitempty"`
|
|
Grid int `xml:"grid,attr,omitempty"`
|
|
GridSize int `xml:"gridSize,attr,omitempty"`
|
|
Guides int `xml:"guides,attr,omitempty"`
|
|
Dx int `xml:"dx,attr,omitempty"`
|
|
Dy int `xml:"dy,attr,omitempty"`
|
|
Connect int `xml:"connect,attr,omitempty"`
|
|
Arrows int `xml:"arrows,attr,omitempty"`
|
|
Fold int `xml:"fold,attr,omitempty"`
|
|
Page int `xml:"page,attr,omitempty"`
|
|
PageScale int `xml:"pageScale,attr,omitempty"`
|
|
PageWidth int `xml:"pageWidth,attr,omitempty"`
|
|
PageHeight int `xml:"pageHeight,attr,omitempty"`
|
|
Tooltips int `xml:"tooltips,attr,omitempty"`
|
|
Math int `xml:"math,attr,omitempty"`
|
|
Shadow int `xml:"shadow,attr,omitempty"`
|
|
Root *mxRoot `xml:"root"`
|
|
}
|
|
|
|
type mxRoot struct {
|
|
Cells []*MxCell
|
|
}
|
|
|
|
type UserObject struct {
|
|
Label string
|
|
Link string
|
|
Tooltip string
|
|
}
|
|
|
|
type MxCell struct {
|
|
ID string
|
|
Value string
|
|
Style string
|
|
Vertex bool
|
|
Edge bool
|
|
Parent string
|
|
Source string
|
|
Target string
|
|
|
|
Geometry *MxGeometry
|
|
UserObject *UserObject
|
|
}
|
|
|
|
type MxGeometry struct {
|
|
X float64 `xml:"x,attr,omitempty"`
|
|
Y float64 `xml:"y,attr,omitempty"`
|
|
Width float64 `xml:"width,attr,omitempty"`
|
|
Height float64 `xml:"height,attr,omitempty"`
|
|
Relative int `xml:"relative,attr,omitempty"`
|
|
As string `xml:"as,attr,omitempty"`
|
|
|
|
Points *MxPointArray `xml:"Array,omitempty"`
|
|
SourcePoint *MxPoint `xml:"-"`
|
|
TargetPoint *MxPoint `xml:"-"`
|
|
}
|
|
|
|
type MxPoint struct {
|
|
X float64 `xml:"x,attr,omitempty"`
|
|
Y float64 `xml:"y,attr,omitempty"`
|
|
As string `xml:"as,attr,omitempty"`
|
|
}
|
|
|
|
type MxPointArray struct {
|
|
As string `xml:"as,attr,omitempty"`
|
|
Points []*MxPoint `xml:"mxPoint"`
|
|
}
|
|
|
|
// MarshalXML customizes geometry output to allow multiple mxPoint children.
|
|
func (g *MxGeometry) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
|
|
if start.Name.Local == "" {
|
|
start.Name.Local = "mxGeometry"
|
|
}
|
|
as := g.As
|
|
if as == "" {
|
|
as = "geometry"
|
|
}
|
|
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Local: "as"}, Value: as})
|
|
if g.X != 0 {
|
|
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Local: "x"}, Value: floatToString(g.X)})
|
|
}
|
|
if g.Y != 0 {
|
|
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Local: "y"}, Value: floatToString(g.Y)})
|
|
}
|
|
if g.Width != 0 {
|
|
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Local: "width"}, Value: floatToString(g.Width)})
|
|
}
|
|
if g.Height != 0 {
|
|
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Local: "height"}, Value: floatToString(g.Height)})
|
|
}
|
|
if g.Relative != 0 {
|
|
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Local: "relative"}, Value: intToString(g.Relative)})
|
|
}
|
|
|
|
if err := enc.EncodeToken(start); err != nil {
|
|
return err
|
|
}
|
|
|
|
if g.Points != nil && len(g.Points.Points) > 0 {
|
|
asVal := g.Points.As
|
|
if asVal == "" {
|
|
asVal = "points"
|
|
}
|
|
arrStart := xml.StartElement{Name: xml.Name{Local: "Array"}, Attr: []xml.Attr{{Name: xml.Name{Local: "as"}, Value: asVal}}}
|
|
if err := enc.EncodeToken(arrStart); err != nil {
|
|
return err
|
|
}
|
|
for _, p := range g.Points.Points {
|
|
if p == nil {
|
|
continue
|
|
}
|
|
if err := enc.EncodeElement(p, xml.StartElement{Name: xml.Name{Local: "mxPoint"}}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := enc.EncodeToken(xml.EndElement{Name: arrStart.Name}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if g.SourcePoint != nil {
|
|
p := *g.SourcePoint
|
|
if p.As == "" {
|
|
p.As = "sourcePoint"
|
|
}
|
|
if err := enc.EncodeElement(p, xml.StartElement{Name: xml.Name{Local: "mxPoint"}}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if g.TargetPoint != nil {
|
|
p := *g.TargetPoint
|
|
if p.As == "" {
|
|
p.As = "targetPoint"
|
|
}
|
|
if err := enc.EncodeElement(p, xml.StartElement{Name: xml.Name{Local: "mxPoint"}}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return enc.EncodeToken(xml.EndElement{Name: start.Name})
|
|
}
|
|
|
|
// MarshalXML customizes how the mxRoot emits cells, handling UserObject wrappers.
|
|
func (r *mxRoot) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
|
|
start.Name.Local = "root"
|
|
if err := enc.EncodeToken(start); err != nil {
|
|
return err
|
|
}
|
|
for _, cell := range r.Cells {
|
|
if cell == nil {
|
|
continue
|
|
}
|
|
if cell.UserObject != nil {
|
|
uoStart := xml.StartElement{Name: xml.Name{Local: "UserObject"}}
|
|
if cell.UserObject.Label != "" {
|
|
uoStart.Attr = append(uoStart.Attr, xml.Attr{Name: xml.Name{Local: "label"}, Value: cell.UserObject.Label})
|
|
}
|
|
if cell.UserObject.Link != "" {
|
|
uoStart.Attr = append(uoStart.Attr, xml.Attr{Name: xml.Name{Local: "link"}, Value: cell.UserObject.Link})
|
|
}
|
|
if cell.UserObject.Tooltip != "" {
|
|
uoStart.Attr = append(uoStart.Attr, xml.Attr{Name: xml.Name{Local: "tooltip"}, Value: cell.UserObject.Tooltip})
|
|
}
|
|
if err := enc.EncodeToken(uoStart); err != nil {
|
|
return err
|
|
}
|
|
if err := enc.EncodeElement(cell.alias(), xml.StartElement{Name: xml.Name{Local: "mxCell"}}); err != nil {
|
|
return err
|
|
}
|
|
if err := enc.EncodeToken(xml.EndElement{Name: uoStart.Name}); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if err := enc.EncodeElement(cell.alias(), xml.StartElement{Name: xml.Name{Local: "mxCell"}}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return enc.EncodeToken(xml.EndElement{Name: start.Name})
|
|
}
|
|
|
|
func (c *MxCell) alias() cellAlias {
|
|
return cellAlias{
|
|
ID: c.ID,
|
|
Value: c.Value,
|
|
Style: c.Style,
|
|
Vertex: boolToAttr(c.Vertex),
|
|
Edge: boolToAttr(c.Edge),
|
|
Parent: c.Parent,
|
|
Source: c.Source,
|
|
Target: c.Target,
|
|
Geometry: c.geometryWithDefault(),
|
|
}
|
|
}
|
|
|
|
func (c *MxCell) geometryWithDefault() *MxGeometry {
|
|
if c.Geometry == nil {
|
|
return nil
|
|
}
|
|
g := *c.Geometry
|
|
if g.As == "" {
|
|
g.As = "geometry"
|
|
}
|
|
if g.Points != nil && g.Points.As == "" {
|
|
g.Points.As = "points"
|
|
}
|
|
return &g
|
|
}
|
|
|
|
type cellAlias struct {
|
|
ID string `xml:"id,attr"`
|
|
Value string `xml:"value,attr,omitempty"`
|
|
Style string `xml:"style,attr,omitempty"`
|
|
Vertex string `xml:"vertex,attr,omitempty"`
|
|
Edge string `xml:"edge,attr,omitempty"`
|
|
Parent string `xml:"parent,attr,omitempty"`
|
|
Source string `xml:"source,attr,omitempty"`
|
|
Target string `xml:"target,attr,omitempty"`
|
|
Geometry *MxGeometry `xml:"mxGeometry,omitempty"`
|
|
}
|
|
|
|
func boolToAttr(v bool) string {
|
|
if v {
|
|
return "1"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// rotateGeometry rotates a rectangle around the given pivot.
|
|
func rotateGeometry(geo *MxGeometry, rotation, cx, cy float64) {
|
|
if geo == nil || rotation == 0 {
|
|
return
|
|
}
|
|
rads := rotation * math.Pi / 180
|
|
cos := math.Cos(rads)
|
|
sin := math.Sin(rads)
|
|
x := geo.X - cx
|
|
y := geo.Y - cy
|
|
geo.X = x*cos - y*sin + cx
|
|
geo.Y = x*sin + y*cos + cy
|
|
}
|
|
|
|
// ToXML renders the graph model to draw.io compatible XML.
|
|
func (g *MxGraph) ToXML(style, background string, grid, guides bool, width, height float64) (string, error) {
|
|
model := g.Model
|
|
model.Style = style
|
|
model.Background = background
|
|
if grid {
|
|
model.Grid = 1
|
|
}
|
|
if guides {
|
|
model.Guides = 1
|
|
}
|
|
model.GridSize = 10
|
|
model.Connect = 1
|
|
model.Arrows = 1
|
|
model.Fold = 1
|
|
model.Page = 1
|
|
model.PageScale = 1
|
|
model.Dx = 0
|
|
model.Dy = 0
|
|
if width == 0 {
|
|
width = 850
|
|
}
|
|
if height == 0 {
|
|
height = 1100
|
|
}
|
|
model.PageWidth = int(width)
|
|
model.PageHeight = int(height)
|
|
model.Tooltips = 1
|
|
model.Math = 0
|
|
model.Shadow = 0
|
|
|
|
output, err := xml.Marshal(model)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(output), nil
|
|
}
|
|
|
|
// NormalizeOrigin shifts all absolute coordinates so the top-left-most point is at (0,0).
|
|
func (g *MxGraph) NormalizeOrigin() {
|
|
minX, minY := math.MaxFloat64, math.MaxFloat64
|
|
|
|
for _, cell := range g.Model.Root.Cells {
|
|
if cell == nil || cell.Geometry == nil {
|
|
continue
|
|
}
|
|
geo := cell.Geometry
|
|
if geo.Relative == 0 {
|
|
if geo.X < minX {
|
|
minX = geo.X
|
|
}
|
|
if geo.Y < minY {
|
|
minY = geo.Y
|
|
}
|
|
}
|
|
if geo.Points != nil {
|
|
for _, p := range geo.Points.Points {
|
|
if p == nil {
|
|
continue
|
|
}
|
|
if p.X < minX {
|
|
minX = p.X
|
|
}
|
|
if p.Y < minY {
|
|
minY = p.Y
|
|
}
|
|
}
|
|
}
|
|
if geo.SourcePoint != nil {
|
|
if geo.SourcePoint.X < minX {
|
|
minX = geo.SourcePoint.X
|
|
}
|
|
if geo.SourcePoint.Y < minY {
|
|
minY = geo.SourcePoint.Y
|
|
}
|
|
}
|
|
if geo.TargetPoint != nil {
|
|
if geo.TargetPoint.X < minX {
|
|
minX = geo.TargetPoint.X
|
|
}
|
|
if geo.TargetPoint.Y < minY {
|
|
minY = geo.TargetPoint.Y
|
|
}
|
|
}
|
|
}
|
|
|
|
if minX == math.MaxFloat64 || minY == math.MaxFloat64 {
|
|
return
|
|
}
|
|
|
|
dx, dy := -minX, -minY
|
|
|
|
for _, cell := range g.Model.Root.Cells {
|
|
if cell == nil || cell.Geometry == nil {
|
|
continue
|
|
}
|
|
geo := cell.Geometry
|
|
if geo.Relative == 0 {
|
|
geo.X += dx
|
|
geo.Y += dy
|
|
}
|
|
if geo.Points != nil {
|
|
for _, p := range geo.Points.Points {
|
|
if p == nil {
|
|
continue
|
|
}
|
|
p.X += dx
|
|
p.Y += dy
|
|
}
|
|
}
|
|
if geo.SourcePoint != nil {
|
|
geo.SourcePoint.X += dx
|
|
geo.SourcePoint.Y += dy
|
|
}
|
|
if geo.TargetPoint != nil {
|
|
geo.TargetPoint.X += dx
|
|
geo.TargetPoint.Y += dy
|
|
}
|
|
}
|
|
}
|
|
|
|
// helper to ensure geometry has relative flag set to 1.
|
|
func setRelative(geo *MxGeometry, v bool) {
|
|
if geo == nil {
|
|
return
|
|
}
|
|
if v {
|
|
geo.Relative = 1
|
|
} else {
|
|
geo.Relative = 0
|
|
}
|
|
}
|
|
|
|
// linearly interpolated center used for rotations.
|
|
func midpoint(p1, p2 *MxPoint) *MxPoint {
|
|
return &MxPoint{
|
|
X: (p1.X + p2.X) / 2,
|
|
Y: (p1.Y + p2.Y) / 2,
|
|
}
|
|
}
|
|
|
|
// joinStyle appends style fragments with trailing semicolon when needed.
|
|
func joinStyle(parts ...string) string {
|
|
var sb strings.Builder
|
|
for _, p := range parts {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
if !strings.HasSuffix(p, ";") {
|
|
p += ";"
|
|
}
|
|
sb.WriteString(p)
|
|
}
|
|
return sb.String()
|
|
}
|