Files
gliffy2drawio/mxgraph.go
2026-01-06 19:05:48 +11:00

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()
}