From 419b6bc7a191309e13df4561285ffa1696ec4091 Mon Sep 17 00:00:00 2001 From: Nathan Coad Date: Tue, 6 Jan 2026 19:05:48 +1100 Subject: [PATCH] initial --- converter.go | 896 +++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 3 + mapping.go | 176 ++++++++++ models.go | 961 +++++++++++++++++++++++++++++++++++++++++++++++++++ mxgraph.go | 463 +++++++++++++++++++++++++ svg_utils.go | 22 ++ 6 files changed, 2521 insertions(+) create mode 100644 converter.go create mode 100644 go.mod create mode 100644 mapping.go create mode 100644 models.go create mode 100644 mxgraph.go create mode 100644 svg_utils.go diff --git a/converter.go b/converter.go new file mode 100644 index 0000000..88b4b40 --- /dev/null +++ b/converter.go @@ -0,0 +1,896 @@ +package gliffy2drawio + +import ( + "encoding/json" + "fmt" + "html" + "math" + "regexp" + "sort" + "strconv" + "strings" +) + +type GliffyDiagramConverter struct { + diagramString string + gliffyDiagram Diagram + drawioDiagram *MxGraph + vertices map[string]*GliffyObject + layers map[string]*GliffyLayer + + rotationPattern *regexp.Regexp + pageIDPattern *regexp.Regexp + namePattern *regexp.Regexp + + report strings.Builder + translator *StencilTranslator +} + +func NewGliffyDiagramConverter(gliffyDiagramString string) (*GliffyDiagramConverter, error) { + c := &GliffyDiagramConverter{ + diagramString: gliffyDiagramString, + drawioDiagram: NewMxGraph(), + vertices: make(map[string]*GliffyObject), + layers: make(map[string]*GliffyLayer), + rotationPattern: regexp.MustCompile(`rotation=(\-?\w+)`), + pageIDPattern: regexp.MustCompile(`pageId=([^&]+)`), + namePattern: regexp.MustCompile(`name=([^&]+)`), + translator: NewStencilTranslator(""), + } + if err := c.start(); err != nil { + return nil, err + } + return c, nil +} + +func (c *GliffyDiagramConverter) start() error { + if err := json.Unmarshal([]byte(c.diagramString), &c.gliffyDiagram); err != nil { + return err + } + + // Support newer Gliffy files that wrap content in pages/scene. + if len(c.gliffyDiagram.Stage.Objects) == 0 && len(c.gliffyDiagram.Pages) > 0 { + selected := c.gliffyDiagram.Pages[0] + if c.gliffyDiagram.DefaultPage != "" { + for _, p := range c.gliffyDiagram.Pages { + if p.ID == c.gliffyDiagram.DefaultPage { + selected = p + break + } + } + } + scene := selected.Scene + c.gliffyDiagram.Stage = Stage{ + Background: scene.Background, + Width: scene.Width, + Height: scene.Height, + AutoFit: scene.AutoFit, + GridOn: scene.GridOn, + DrawingGuidesOn: scene.DrawingGuidesOn, + Objects: scene.Objects, + Layers: scene.Layers, + TextStyles: scene.TextStyles, + } + } + + c.collectLayersAndConvert(c.layers, c.gliffyDiagram.Stage.Layers) + c.collectVerticesAndConvert(c.vertices, c.gliffyDiagram.Stage.Objects, nil) + + sortObjectsByOrder(c.gliffyDiagram.Stage.Objects) + + c.importLayers() + for _, obj := range c.gliffyDiagram.Stage.Objects { + if err := c.importObject(obj, obj.Parent); err != nil { + c.report.WriteString("-- Warning, Object " + obj.ID + " cannot be transformed.\n") + } + } + // Shift everything to origin to avoid bottom-right offset in draw.io. + c.drawioDiagram.NormalizeOrigin() + return nil +} + +func (c *GliffyDiagramConverter) importLayers() { + if len(c.gliffyDiagram.Stage.Layers) == 0 { + return + } + sortLayersByOrder(c.gliffyDiagram.Stage.Layers) + for i, layer := range c.gliffyDiagram.Stage.Layers { + // Avoid duplicating the default layer (id=1 already in root). + if i == 0 && layer.MxObject != nil && layer.MxObject.ID == "1" && c.drawioDiagram.HasCell("1") { + continue + } + c.drawioDiagram.AddCell(layer.MxObject, c.drawioDiagram.root) + if i == 0 { + c.drawioDiagram.SetDefaultParent(layer.MxObject) + } + } +} + +func (c *GliffyDiagramConverter) importObject(obj *GliffyObject, gliffyParent *GliffyObject) error { + var parent *MxCell + if gliffyParent != nil { + parent = gliffyParent.MxObject + } + if parent == nil && obj.LayerID != nil { + if layer, ok := c.layers[*obj.LayerID]; ok && layer != nil { + parent = layer.MxObject + } + } + + c.drawioDiagram.AddCell(obj.MxObject, parent) + + if obj.hasChildren() { + if obj.isSwimlane() { + if obj.Rotation != 0 { + reverse(obj.Children) + } + } else { + sortObjectsByOrder(obj.Children) + } + for _, child := range obj.Children { + if err := c.importObject(child, obj); err != nil { + return err + } + } + } + + if obj.isLine() { + startTerminal := c.getTerminalCell(obj, true) + endTerminal := c.getTerminalCell(obj, false) + if startTerminal != nil { + obj.MxObject.Source = startTerminal.ID + } + if endTerminal != nil { + obj.MxObject.Target = endTerminal.ID + } + c.setWaypoints(obj, startTerminal, endTerminal) + } + return nil +} + +func (c *GliffyDiagramConverter) getTerminalCell(edge *GliffyObject, start bool) *MxCell { + cons := edge.GetConstraints() + if cons == nil { + return nil + } + var con *Constraint + if start { + con = cons.StartConstraint + } else { + con = cons.EndConstraint + } + if con == nil { + return nil + } + var data *ConstraintData + if start { + data = con.StartPositionConstraint + } else { + data = con.EndPositionConstraint + } + if data == nil { + return nil + } + terminal := c.vertices[data.NodeID] + if terminal == nil { + return nil + } + return terminal.MxObject +} + +func (c *GliffyDiagramConverter) collectLayersAndConvert(layerMap map[string]*GliffyLayer, layers []*GliffyLayer) { + if len(layers) == 0 { + return + } + + // Ensure only a single default layer (id=1, parent=0) exists. + if len(c.drawioDiagram.Model.Root.Cells) > 2 { + trimmed := []*MxCell{} + for _, cell := range c.drawioDiagram.Model.Root.Cells { + if cell == nil { + continue + } + if cell.ID == "0" || cell.ID == "1" { + trimmed = append(trimmed, cell) + } + } + if len(trimmed) < 2 { + trimmed = append(trimmed, &MxCell{ID: "1", Parent: c.drawioDiagram.root.ID}) + } + c.drawioDiagram.Model.Root.Cells = trimmed + } + + // Reuse existing default layer (id 1, parent 0) for the first Gliffy layer to match draw.io expectations. + var defaultLayer *MxCell + if len(c.drawioDiagram.Model.Root.Cells) >= 2 { + if cell := c.drawioDiagram.Model.Root.Cells[1]; cell != nil && cell.Parent == c.drawioDiagram.root.ID { + defaultLayer = cell + } + } + if defaultLayer == nil { + defaultLayer = &MxCell{ID: "1", Parent: c.drawioDiagram.root.ID} + c.drawioDiagram.Model.Root.Cells = append(c.drawioDiagram.Model.Root.Cells, defaultLayer) + } + + for i, layer := range layers { + var cell *MxCell + if i == 0 { + cell = defaultLayer + } else { + cell = &MxCell{} + c.drawioDiagram.AddCell(cell, c.drawioDiagram.root) + } + cell.Value = layer.Name + if layer.Locked { + cell.Style = joinStyle(cell.Style, "locked=1") + } + if !layer.Visible { + cell.Style = joinStyle(cell.Style, "visible=0") + } + layer.MxObject = cell + layerMap[layer.GUID] = layer + if i == 0 { + c.drawioDiagram.SetDefaultParent(cell) + } + } +} + +func (c *GliffyDiagramConverter) collectVerticesAndConvert(vertices map[string]*GliffyObject, objects []*GliffyObject, parent *GliffyObject) { + for _, obj := range objects { + obj.Parent = parent + obj.MxObject = c.convertGliffyObject(obj, parent) + if !obj.isLine() { + vertices[obj.ID] = obj + } + if obj.isGroup() || obj.isSelection() || (obj.isLine() && obj.hasChildren()) { + c.collectVerticesAndConvert(vertices, obj.Children, obj) + } + } +} + +func (c *GliffyDiagramConverter) GraphXML() (string, error) { + grid := c.gliffyDiagram.Stage.GridOn + guides := c.gliffyDiagram.Stage.DrawingGuidesOn + w := c.gliffyDiagram.Stage.Width + h := c.gliffyDiagram.Stage.Height + if w == 0 { + w = 1200 + } + if h == 0 { + h = 900 + } + xml, err := c.drawioDiagram.ToXML("default-style2", c.gliffyDiagram.Stage.Background, grid, guides, w, h) + if err != nil { + return "", err + } + name := c.diagramName() + return fmt.Sprintf(`%s`, html.EscapeString(name), xml), nil +} + +func (c *GliffyDiagramConverter) diagramName() string { + if c.gliffyDiagram.Title != "" { + return c.gliffyDiagram.Title + } + if len(c.gliffyDiagram.Pages) > 0 { + selected := c.gliffyDiagram.Pages[0] + if c.gliffyDiagram.DefaultPage != "" { + for _, p := range c.gliffyDiagram.Pages { + if p.ID == c.gliffyDiagram.DefaultPage { + selected = p + break + } + } + } + if selected.Title != "" { + return selected.Title + } + } + if c.gliffyDiagram.Metadata.Title != "" { + return c.gliffyDiagram.Metadata.Title + } + return "Page-1" +} + +func (c *GliffyDiagramConverter) convertGliffyObject(obj *GliffyObject, parent *GliffyObject) *MxCell { + cell := &MxCell{} + if obj.ID != "" { + cell.ID = obj.ID + } + if obj.IsUnrecognizedGraphicType() { + return cell + } + + var style strings.Builder + geo := &MxGeometry{X: obj.X, Y: obj.Y, Width: obj.Width, Height: obj.Height} + obj.adjustGeo(geo) + cell.Geometry = geo + + var textObject *GliffyObject + var link string + graphic := obj.GraphicOrChildGraphic() + translatedStyle := c.translator.Translate(obj.UID, obj.TID) + + if obj.isGroup() { + if graphic == nil || translatedStyle == "" { + style.WriteString("group;") + } + cell.Vertex = true + } else { + textObject = obj.TextObject() + } + + if graphic != nil { + link = obj.AdjustedLink() + switch graphic.GetType() { + case GraphicTypeShape, GraphicTypeMindmap: + shape := graphic.Shape + if shape == nil { + shape = &GliffyShape{} + } + cell.Vertex = true + isChevron := strings.Contains(obj.UID, "chevron") + if translatedStyle != "" { + style.WriteString("shape=" + translatedStyle + ";") + } + if !strings.Contains(style.String(), "shadow=") { + style.WriteString("shadow=" + intToString(boolToInt(shape.DropShadow)) + ";") + } + if !strings.Contains(style.String(), "strokeWidth") { + style.WriteString("strokeWidth=" + intToString(shape.StrokeWidthValue()) + ";") + if shape.StrokeWidthValue() == 0 && !isChevron { + style.WriteString("strokeColor=none;") + } + } + if !strings.Contains(style.String(), "fillColor") { + if shape.NoFill() && !isChevron { + style.WriteString("fillColor=none;") + } else { + style.WriteString("fillColor=" + shape.FillColor + ";") + } + if shape.FillColor == "none" { + style.WriteString("pointerEvents=0;") + } + } + if !strings.Contains(style.String(), "strokeColor") && !shape.NoFill() { + stroke := shape.StrokeColor + if obj.IsUseFillColorForStroke() { + stroke = shape.FillColor + } + style.WriteString("strokeColor=" + stroke + ";") + } + if !strings.Contains(style.String(), "gradient") && shape.Gradient && !obj.GradientIgnored() { + style.WriteString("gradientColor=" + obj.GradientColor() + ";gradientDirection=north;") + } + if !obj.isVennCircle() && !strings.Contains(style.String(), "opacity") { + style.WriteString("opacity=" + floatToString(shape.Opacity*100) + ";") + } + style.WriteString(DashStyleMapping(shape.DashStyle, 1)) + if obj.IsSubRoutine() && obj.Width != 0 { + style.WriteString("size=" + floatToString(10/obj.Width) + ";") + } + if fragment := obj.UMLSequenceCombinedFragmentText(); fragment != "" && len(obj.Children) > 0 { + cell.Value = fragment + obj.Children = obj.Children[1:] + } + case GraphicTypeLine: + if graphic.Line == nil { + break + } + line := graphic.Line + cell.Edge = true + style.WriteString("shape=filledEdge;") + style.WriteString("strokeWidth=" + intToString(line.StrokeWidthValue()) + ";") + style.WriteString("strokeColor=" + line.StrokeColor + ";") + style.WriteString("fillColor=" + line.FillColor + ";") + style.WriteString(ArrowMapping(line.StartArrow).ToString(true)) + style.WriteString(ArrowMapping(line.EndArrow).ToString(false)) + if line.CornerRadius != nil { + style.WriteString("rounded=1;") + } else { + style.WriteString("rounded=0;") + } + style.WriteString(DashStyleMapping(line.DashStyle, line.StrokeWidthValue())) + style.WriteString(LineMapping(line.Interpolation)) + cell.Geometry.X = 0 + cell.Geometry.Y = 0 + case GraphicTypeText: + textObject = obj + cell.Vertex = true + style.WriteString("text;html=1;nl2Br=0;") + cell.Value = obj.TextHTML() + if obj.Parent != nil && !obj.Parent.isGroup() { + parentGeo := obj.Parent.MxObject.Geometry + if obj.Parent.isLine() { + mxGeo := &MxGeometry{X: 0, Y: 0, Width: 0, Height: 0} + lineT := 0.0 + if graphic.Text != nil { + lineT = graphic.Text.LineTValue*2 - 1 + } + mxGeo.X = lineT + var lblY, lblX float64 + if graphic.Text != nil && graphic.Text.LinePerpValue != nil { + control := obj.Parent.Graphic.Line.ControlPath + if len(control) >= 2 { + i1 := 0 + i2 := len(control) - 1 + noCardinal := false + switch graphic.Text.CardinalityType { + case "begin": + i2 = 1 + case "end": + i1 = len(control) - 2 + default: + noCardinal = true + } + if noCardinal || control[i1][1] == control[i2][1] { + lblY = *graphic.Text.LinePerpValue + if control[i1][0]-control[i2][0] > 0 { + lblY = -lblY + } + } else { + lblX = *graphic.Text.LinePerpValue + if control[i1][1]-control[i2][1] < 0 { + lblX = -lblX + } + } + } + } + mxGeo.SourcePoint = &MxPoint{X: lblX, Y: lblY} + setRelative(mxGeo, true) + cell.Geometry = mxGeo + style.WriteString("labelBackgroundColor=" + c.gliffyDiagram.Stage.Background + ";") + if graphic.Text != nil { + graphic.Text.SetHAlign("") + } + } else { + cell.Geometry = &MxGeometry{X: 0, Y: 0, Width: parentGeo.Width, Height: parentGeo.Height} + setRelative(cell.Geometry, true) + } + } + case GraphicTypeImage: + img := graphic.Image + cell.Vertex = true + style.WriteString("shape=" + c.translator.Translate(obj.UID, obj.TID) + ";") + style.WriteString("image=" + img.CleanURL() + ";") + case GraphicTypeSVG: + svg := graphic.Svg + cell.Vertex = true + style.WriteString("shape=image;imageAspect=0;") + if svg != nil && svg.EmbeddedResourceID != nil { + if res, ok := c.gliffyDiagram.EmbeddedResources.Get(*svg.EmbeddedResourceID); ok { + svgUtil := SVGImporterUtils{} + data := svgUtil.SetViewBox(res.Data) + style.WriteString("image=data:image/svg+xml," + EmbeddedResource{Data: data}.Base64EncodedData() + ";") + } + } + default: + // Fallback: render as image if possible or apply translated style if found. + if graphic.Image != nil && graphic.Image.URL != "" { + cell.Vertex = true + style.WriteString("shape=image;imageAspect=0;image=" + graphic.Image.CleanURL() + ";") + } else if graphic.Svg != nil && graphic.Svg.EmbeddedResourceID != nil { + cell.Vertex = true + style.WriteString("shape=image;imageAspect=0;") + if res, ok := c.gliffyDiagram.EmbeddedResources.Get(*graphic.Svg.EmbeddedResourceID); ok { + svgUtil := SVGImporterUtils{} + data := svgUtil.SetViewBox(res.Data) + style.WriteString("image=data:image/svg+xml," + EmbeddedResource{Data: data}.Base64EncodedData() + ";") + } + } else if translatedStyle != "" { + cell.Vertex = true + style.WriteString("shape=" + translatedStyle + ";") + } + } + } else if obj.isSwimlane() && len(obj.Children) > 0 { + cell.Vertex = true + style.WriteString(c.translator.Translate(obj.UID, "") + ";") + if obj.Rotation == 0 { + style.WriteString("childLayout=stackLayout;resizeParent=1;resizeParentMax=0;") + } + header := obj.Children[0] + shape := header.Graphic.Shape + style.WriteString("strokeWidth=" + intToString(shape.StrokeWidthValue()) + ";") + style.WriteString("shadow=" + intToString(boolToInt(shape.DropShadow)) + ";") + style.WriteString("fillColor=" + shape.FillColor + ";") + style.WriteString("strokeColor=" + shape.StrokeColor + ";") + style.WriteString("startSize=" + floatToString(header.Height) + ";") + style.WriteString("whiteSpace=wrap;") + + for i := 1; i < len(obj.Children); i++ { + gLane := obj.Children[i] + gLane.Parent = obj + gs := gLane.Graphic.Shape + var laneStyle strings.Builder + laneStyle.WriteString("swimlane;collapsible=0;swimlaneLine=0;") + laneStyle.WriteString("strokeWidth=" + intToString(gs.StrokeWidthValue()) + ";") + laneStyle.WriteString("shadow=" + intToString(boolToInt(gs.DropShadow)) + ";") + laneStyle.WriteString("fillColor=" + gs.FillColor + ";") + laneStyle.WriteString("strokeColor=" + gs.StrokeColor + ";") + laneStyle.WriteString("whiteSpace=wrap;html=1;fontStyle=0;") + + childGeometry := &MxGeometry{X: gLane.X, Y: gLane.Y, Width: gLane.Width, Height: gLane.Height} + if obj.Rotation != 0 { + if obj.Rotation == 270 { + laneStyle.WriteString("horizontal=0;") + w := childGeometry.Width + childGeometry.Width = childGeometry.Height + childGeometry.Height = w + x := childGeometry.X + childGeometry.X = childGeometry.Y + childGeometry.Y = obj.Width - w - x + } else { + laneStyle.WriteString("rotation=" + floatToString(obj.Rotation) + ";") + rotateGeometry(childGeometry, obj.Rotation, obj.Width/2, obj.Height/2) + } + } + mxLane := &MxCell{Vertex: true} + laneTxt := gLane.Children[0] + mxLane.Value = laneTxt.TextHTML() + if laneTxt.Graphic != nil && laneTxt.Graphic.Text != nil { + laneStyle.WriteString(laneTxt.Graphic.Text.GetStyle(0, 0)) + } + laneStyle.WriteString("gliffyId=" + gLane.ID + ";") + mxLane.Style = laneStyle.String() + mxLane.Geometry = childGeometry + gLane.MxObject = mxLane + cell.Geometry = geo + } + } else if obj.isMindmap() && len(obj.Children) > 0 { + rectangle := obj.Children[0] + if rectangle.Graphic == nil || rectangle.Graphic.Mindmap == nil { + obj.MxObject = cell + return cell + } + mindmap := rectangle.Graphic.Mindmap + style.WriteString("shape=" + c.translator.Translate(obj.UID, "") + ";") + style.WriteString("shadow=" + intToString(boolToInt(mindmap.DropShadow)) + ";") + style.WriteString("strokeWidth=" + intToString(mindmap.StrokeWidthValue()) + ";") + style.WriteString("fillColor=" + mindmap.FillColor + ";") + style.WriteString("strokeColor=" + mindmap.StrokeColor + ";") + style.WriteString(DashStyleMapping(mindmap.DashStyle, 1)) + if mindmap.Gradient { + style.WriteString("gradientColor=#FFFFFF;gradientDirection=north;") + } + cell.Vertex = true + } + + if !obj.isLine() { + if strings.Contains(style.String(), "rotation") { + if m := c.rotationPattern.FindStringSubmatch(style.String()); len(m) > 1 { + initial, _ := strconv.ParseFloat(m[1], 64) + rotation := initial + obj.Rotation + styleStr := c.rotationPattern.ReplaceAllString(style.String(), "rotation="+floatToString(rotation)) + style.Reset() + style.WriteString(styleStr) + } + } else if obj.Rotation != 0 { + if strings.Contains(style.String(), "swimlane;collapsible=0;") && obj.Rotation == 270 { + w := geo.Width + h := geo.Height + geo.X = geo.X + (w-h)/2 + geo.Y = geo.Y + (h-w)/2 + geo.Width = h + geo.Height = w + style.WriteString("childLayout=stackLayout;resizeParent=1;resizeParentMax=0;horizontal=0;horizontalStack=0;") + } else { + if obj.isGroup() { + for _, child := range obj.Children { + c.rotateGroupedObject(obj, child) + } + } + style.WriteString("rotation=" + floatToString(obj.Rotation) + ";") + } + } + } + + if textObject != nil { + style.WriteString("html=1;nl2Br=0;") + if !obj.isLine() && textObject.Graphic != nil && textObject.Graphic.Text != nil { + txt := textObject.Graphic.Text + if obj.isSwimlane() { + txt.SetForceTopPaddingShift(true) + txt.SetVAlign("middle") + } + cell.Value = textObject.TextHTML() + obj.adjustTextPos(textObject) + if obj.ContainsTextBracket() { + c.fixFrameTextBorder(obj, &style) + style.WriteString(strings.ReplaceAll(txt.GetStyle(0, 0), "verticalAlign=middle", "verticalAlign=top")) + } else { + isChevron := strings.Contains(obj.UID, "chevron") + if textObject == obj || isChevron { + style.WriteString(txt.GetStyle(0, 0)) + } else { + style.WriteString(txt.GetStyle(textObject.X, textObject.Y)) + } + } + } + } + + popup := c.getGliffyPopup(obj) + if link != "" || popup != nil { + userObj := &UserObject{} + if link != "" { + if lb := c.extractLightboxDataFromGliffyUrl(link); lb != nil { + link = "/plugins/drawio/lightbox.action?ceoId=" + intToString(int(lb.Key)) + "&diagramName=" + lb.Value + ".drawio" + } + userObj.Link = link + if textObject != nil { + userObj.Label = textObject.TextHTML() + } + } + if popup != nil && popup.Graphic != nil && popup.Graphic.PopupNote != nil { + userObj.Tooltip = popup.Graphic.PopupNote.Text + } + cell.UserObject = userObj + } + + style.WriteString("gliffyId=" + obj.ID + ";") + cell.Style = style.String() + obj.MxObject = cell + return cell +} + +func (c *GliffyDiagramConverter) getGliffyPopup(obj *GliffyObject) *GliffyObject { + if !obj.hasChildren() { + return nil + } + for _, child := range obj.Children { + if child.Graphic != nil && child.Graphic.GetType() == GraphicTypePopup { + return child + } + } + return nil +} + +func (c *GliffyDiagramConverter) rotateGroupedObject(group, child *GliffyObject) { + pivot := &MxPoint{X: group.Width/2 - child.Width/2, Y: group.Height/2 - child.Height/2} + temp := &MxPoint{X: child.X, Y: child.Y} + if group.Rotation != 0 { + rads := group.Rotation * math.Pi / 180 + cos := math.Cos(rads) + sin := math.Sin(rads) + temp = &MxPoint{ + X: temp.X*cos - temp.Y*sin + pivot.X, + Y: temp.X*sin + temp.Y*cos + pivot.Y, + } + child.X = temp.X + child.Y = temp.Y + child.Rotation += group.Rotation + } +} + +func (c *GliffyDiagramConverter) fixFrameTextBorder(obj *GliffyObject, style *strings.Builder) { + if obj.TextObject() == nil { + return + } + wrong := "labelX=32" + correct := "labelX=" + floatToString(obj.TextObject().Width*1.1) + styleStr := strings.Replace(style.String(), wrong, correct, 1) + style.Reset() + style.WriteString(styleStr) +} + +func (c *GliffyDiagramConverter) addConstraint(obj *GliffyObject, terminal *MxCell, source bool, orthogonal bool) bool { + cons := obj.GetConstraints() + if cons == nil { + return orthogonal + } + var con *Constraint + if source { + con = cons.StartConstraint + } else { + con = cons.EndConstraint + } + if con == nil { + return orthogonal + } + var data *ConstraintData + if source { + data = con.StartPositionConstraint + } else { + data = con.EndPositionConstraint + } + if data != nil { + direction := getStyleValue(terminal, "direction", "east") + temp := &MxPoint{X: data.PX, Y: data.PY} + rotation := 0 + switch strings.ToLower(direction) { + case "south": + rotation = 270 + case "west": + rotation = 180 + case "north": + rotation = 90 + } + if rotation != 0 { + rad := float64(rotation) * math.Pi / 180 + temp = &MxPoint{ + X: temp.X*math.Cos(rad) - temp.Y*math.Sin(rad), + Y: temp.X*math.Sin(rad) + temp.Y*math.Cos(rad), + } + } + if !orthogonal || (temp.X == 0.5 && temp.Y == 0.5) || obj.ForceConstraints() { + cell := obj.MxObject + if source { + cell.Style += "exitX=" + floatToString(temp.X) + ";exitY=" + floatToString(temp.Y) + ";exitPerimeter=0;" + } else { + cell.Style += "entryX=" + floatToString(temp.X) + ";entryY=" + floatToString(temp.Y) + ";entryPerimeter=0;" + } + } + return true + } + return orthogonal +} + +func (c *GliffyDiagramConverter) setWaypoints(obj *GliffyObject, start, end *MxCell) { + if obj.Graphic == nil || obj.Graphic.Line == nil { + return + } + cell := obj.MxObject + geo := cell.Geometry + if geo == nil { + geo = &MxGeometry{} + cell.Geometry = geo + } + setRelative(geo, true) + + points := obj.Graphic.Line.ControlPath + if len(points) < 2 { + return + } + mxPoints := make([]*MxPoint, 0, len(points)) + pivot := &MxPoint{X: obj.X + obj.Width/2, Y: obj.Y + obj.Height/2} + for _, p := range points { + wp := &MxPoint{X: p[0] + obj.X, Y: p[1] + obj.Y} + if obj.Rotation != 0 { + rads := obj.Rotation * math.Pi / 180 + cos := math.Cos(rads) + sin := math.Sin(rads) + wp = &MxPoint{ + X: pivot.X + (wp.X-pivot.X)*cos - (wp.Y-pivot.Y)*sin, + Y: pivot.Y + (wp.X-pivot.X)*sin + (wp.Y-pivot.Y)*cos, + } + } + mxPoints = append(mxPoints, wp) + } + + orthogonal := true + last := mxPoints[0] + for _, p := range mxPoints[1:] { + orthogonal = orthogonal && (last.X == p.X || last.Y == p.Y) + last = p + } + + p0 := mxPoints[0] + pe := mxPoints[len(mxPoints)-1] + if start == nil { + geo.SourcePoint = &MxPoint{X: p0.X, Y: p0.Y, As: "sourcePoint"} + mxPoints = mxPoints[1:] + } else { + if c.addConstraint(obj, start, true, orthogonal) { + // keep point + } + } + if end == nil { + geo.TargetPoint = &MxPoint{X: pe.X, Y: pe.Y, As: "targetPoint"} + mxPoints = mxPoints[:len(mxPoints)-1] + } else { + if c.addConstraint(obj, end, false, orthogonal) { + // keep + } + } + + if orthogonal { + cell.Style += "edgeStyle=orthogonalEdgeStyle;" + result := make([]*MxPoint, 0, len(mxPoints)) + var prev *MxPoint + for _, p := range mxPoints { + if prev == nil || prev.X != p.X || prev.Y != p.Y { + result = append(result, p) + prev = p + } + } + if len(result) == 0 && ((start == nil && end != nil) || (start != nil && end == nil)) { + center := &MxPoint{X: p0.X + (pe.X-p0.X)/2, Y: p0.Y + (pe.Y-p0.Y)/2} + result = []*MxPoint{center, center} + } + mxPoints = result + } + + if len(mxPoints) > 0 { + geo.Points = &MxPointArray{Points: mxPoints} + } + cell.Geometry = geo +} + +type lightboxInfo struct { + Key int64 + Value string +} + +func (c *GliffyDiagramConverter) extractLightboxDataFromGliffyUrl(link string) *lightboxInfo { + pagem := c.pageIDPattern.FindStringSubmatch(link) + namem := c.namePattern.FindStringSubmatch(link) + if len(pagem) > 1 { + oldPageID, err := strconv.ParseInt(pagem[1], 10, 64) + if err != nil { + return nil + } + if len(namem) > 1 { + return &lightboxInfo{Key: oldPageID, Value: namem[1]} + } + } + return nil +} + +func getStyleValue(cell *MxCell, key, defaultValue string) string { + if cell == nil { + return defaultValue + } + style := cell.Style + if style == "" { + return defaultValue + } + pairs := strings.Split(style, ";") + for _, p := range pairs { + if p == "" { + continue + } + parts := strings.SplitN(p, "=", 2) + if len(parts) == 2 && strings.EqualFold(parts[0], key) { + return parts[1] + } + } + return defaultValue +} + +func sortObjectsByOrder(objs []*GliffyObject) { + sort.SliceStable(objs, func(i, j int) bool { + o1 := objs[i].Order.String() + o2 := objs[j].Order.String() + if o1 == "" && o2 == "" { + return false + } + if o1 == "" { + return false + } + if o2 == "" { + return true + } + f1, err1 := strconv.ParseFloat(o1, 64) + f2, err2 := strconv.ParseFloat(o2, 64) + if err1 == nil && err2 == nil { + return f1 < f2 + } + return o1 < o2 + }) +} + +func sortLayersByOrder(layers []*GliffyLayer) { + sort.SliceStable(layers, func(i, j int) bool { + return layers[i].Order < layers[j].Order + }) +} + +func reverse(objs []*GliffyObject) { + for i, j := 0, len(objs)-1; i < j; i, j = i+1, j-1 { + objs[i], objs[j] = objs[j], objs[i] + } +} + +func boolToInt(v bool) int { + if v { + return 1 + } + return 0 +} + +// Convenience entry point for users wanting a single call. +func ConvertGliffyJSONToDrawioXML(gliffyJSON string) (string, error) { + converter, err := NewGliffyDiagramConverter(gliffyJSON) + if err != nil { + return "", err + } + return converter.GraphXML() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0253c73 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gliffy2drawio + +go 1.21 diff --git a/mapping.go b/mapping.go new file mode 100644 index 0000000..8151542 --- /dev/null +++ b/mapping.go @@ -0,0 +1,176 @@ +package gliffy2drawio + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "sync" +) + +const DefaultTranslationPath = "java/gliffy/importer/gliffyTranslation.properties" + +type StencilTranslator struct { + once sync.Once + path string + translations map[string]string + customFile string +} + +func NewStencilTranslator(path string) *StencilTranslator { + return &StencilTranslator{path: path} +} + +// WithCustomMapping allows specifying an optional JSON file (map[string]string) for extra translations. +func (s *StencilTranslator) WithCustomMapping(jsonPath string) *StencilTranslator { + s.customFile = jsonPath + return s +} + +func (s *StencilTranslator) Translate(uid, tid string) string { + s.once.Do(s.load) + if s.translations == nil { + return "" + } + if v, ok := s.translations[uid]; ok { + return v + } + if tid != "" { + if v, ok := s.translations[tid]; ok { + return v + } + } + return "" +} + +func (s *StencilTranslator) load() { + path := s.path + if path == "" { + path = DefaultTranslationPath + } + file, err := os.Open(filepath.Clean(path)) + if err != nil { + return + } + defer file.Close() + + s.translations = make(map[string]string) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + s.translations[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + // Hard-coded additions for shapes absent from properties. + for k, v := range map[string]string{ + "gshape.aws_v3.database.Amazon_RDS_PostgreSQL_instance_light_bg": "mxgraph.aws4.rds_postgresql_instance;fillColor=#3334B9", + "gstencil.aws_v3.database.Amazon_RDS_PostgreSQL_instance_light_bg": "mxgraph.aws4.rds_postgresql_instance;fillColor=#3334B9", + } { + if _, ok := s.translations[k]; !ok { + s.translations[k] = v + } + } + + // Load optional custom JSON mapping file if provided. + if s.customFile != "" { + if data, err := os.ReadFile(filepath.Clean(s.customFile)); err == nil { + var extra map[string]string + if err := json.Unmarshal(data, &extra); err == nil { + for k, v := range extra { + if _, exists := s.translations[k]; !exists { + s.translations[k] = v + } + } + } + } + } +} + +func DashStyleMapping(value string, width int) string { + if value == "" { + return "" + } + parts := strings.Split(value, ",") + for i, p := range parts { + if f, err := strconv.ParseFloat(strings.TrimSpace(p), 64); err == nil { + parts[i] = floatToString(f * float64(width)) + } + } + return "dashed=1;fixDash=1;dashPattern=" + strings.Join(parts, " ") + ";" +} + +var lineMapping = map[string]string{ + "linear": "", + "orthogonal": "edgeStyle=orthogonal;", + "quadratic": "curved=1;edgeStyle=orthogonalEdgeStyle;", +} + +func LineMapping(style string) string { + return lineMapping[style] +} + +type ArrowStyle struct { + Name string + Fill bool + Size int + Spacing int +} + +func (a ArrowStyle) ToString(start bool) string { + fill := 0 + if a.Fill { + fill = 1 + } + if start { + return "startArrow=" + a.Name + ";startFill=" + strconv.Itoa(fill) + ";startSize=" + strconv.Itoa(a.Size) + optionalSpacing("sourcePerimeterSpacing", a.Spacing) + ";" + } + return "endArrow=" + a.Name + ";endFill=" + strconv.Itoa(fill) + ";endSize=" + strconv.Itoa(a.Size) + optionalSpacing("targetPerimeterSpacing", a.Spacing) + ";" +} + +func optionalSpacing(key string, v int) string { + if v == 0 { + return "" + } + return ";" + key + "=" + strconv.Itoa(v) + ";" +} + +var arrowMapping = map[int]ArrowStyle{ + 0: {Name: "none", Fill: false, Size: 6}, + 1: {Name: "open", Fill: false, Size: 6}, + 2: {Name: "block", Fill: true, Size: 6}, + 3: {Name: "block", Fill: false, Size: 6}, + 4: {Name: "block", Fill: false, Size: 10}, + 5: {Name: "diamond", Fill: false, Size: 12}, + 6: {Name: "open", Fill: false, Size: 10}, + 7: {Name: "diamond", Fill: true, Size: 12}, + 8: {Name: "classic", Fill: true, Size: 6}, + 9: {Name: "ERzeroToMany", Fill: true, Size: 10}, + 10: {Name: "ERoneToMany", Fill: true, Size: 10}, + 11: {Name: "ERmandOne", Fill: true, Size: 10}, + 12: {Name: "ERone", Fill: true, Size: 10}, + 13: {Name: "ERzeroToOne", Fill: true, Size: 10}, + 14: {Name: "ERmany", Fill: true, Size: 10}, + 15: {Name: "oval", Fill: false, Size: 10, Spacing: 6}, + 16: {Name: "dash", Fill: false, Size: 6}, + 17: {Name: "block", Fill: true, Size: 6}, + 18: {Name: "classic", Fill: true, Size: 6}, + 19: {Name: "openAsync", Fill: false, Size: 10}, +} + +func ArrowMapping(id *int) ArrowStyle { + if id == nil { + return arrowMapping[0] + } + if a, ok := arrowMapping[*id]; ok { + return a + } + return arrowMapping[0] +} diff --git a/models.go b/models.go new file mode 100644 index 0000000..d0b773f --- /dev/null +++ b/models.go @@ -0,0 +1,961 @@ +package gliffy2drawio + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "math" + "regexp" + "sort" + "strconv" + "strings" +) + +// Diagram is the root Gliffy document. +type Diagram struct { + ContentType string `json:"contentType"` + Version string `json:"version"` + Title string `json:"title"` + DefaultPage string `json:"defaultPage"` + Stage Stage `json:"stage"` + Pages []GliffyPage `json:"pages"` + Metadata Metadata `json:"metadata"` + EmbeddedResources EmbeddedResources `json:"embeddedResources"` +} + +type Metadata struct { + Title string `json:"title"` +} + +type Stage struct { + Background string `json:"background"` + Width float64 `json:"width"` + Height float64 `json:"height"` + AutoFit bool `json:"autoFit"` + GridOn bool `json:"gridOn"` + DrawingGuidesOn bool `json:"drawingGuidesOn"` + Objects []*GliffyObject `json:"objects"` + Layers []*GliffyLayer `json:"layers"` + TextStyles TextStyles `json:"textStyles"` +} + +type GliffyPage struct { + ID string `json:"id"` + Title string `json:"title"` + Index int `json:"index"` + Scene Scene `json:"scene"` +} + +type Scene struct { + Objects []*GliffyObject `json:"objects"` + Layers []*GliffyLayer `json:"layers"` + Background string `json:"background"` + Width float64 `json:"width"` + Height float64 `json:"height"` + AutoFit bool `json:"autoFit"` + GridOn bool `json:"gridOn"` + DrawingGuidesOn bool `json:"drawingGuidesOn"` + TextStyles TextStyles `json:"textStyles"` +} + +type GliffyLayer struct { + GUID string `json:"guid"` + Order int `json:"order"` + Name string `json:"name"` + Active bool `json:"active"` + Locked bool `json:"locked"` + Visible bool `json:"visible"` + NodeIndex int `json:"nodeIndex"` + + MxObject *MxCell `json:"-"` +} + +type TextStyles struct { + Global GlobalTextStyles `json:"global"` +} + +type GlobalTextStyles struct { + Size string `json:"size"` + Color string `json:"color"` +} + +func (g GlobalTextStyles) SizeValue() string { + if g.Size == "" { + return "" + } + idx := strings.Index(g.Size, "px") + if idx == -1 { + return g.Size + } + return g.Size[:idx] +} + +type GraphicType string + +const ( + GraphicTypeSVG GraphicType = "Svg" + GraphicTypeLine GraphicType = "Line" + GraphicTypeShape GraphicType = "Shape" + GraphicTypeText GraphicType = "Text" + GraphicTypeImage GraphicType = "Image" + GraphicTypeLink GraphicType = "Link" + GraphicTypeMindmap GraphicType = "Mindmap" + GraphicTypePopup GraphicType = "PopupNote" + GraphicTypeUnknown GraphicType = "Unwknown" +) + +type Graphic struct { + Type GraphicType `json:"type"` + Text *GliffyText `json:"Text"` + Line *GliffyLine `json:"Line"` + Link *GliffyLink `json:"Link"` + Shape *GliffyShape `json:"Shape"` + Image *GliffyImage `json:"Image"` + Svg *GliffySvg `json:"Svg"` + Mindmap *GliffyMindmap `json:"Mindmap"` + PopupNote *GliffyPopupNote `json:"PopupNote"` +} + +func (g *Graphic) GetType() GraphicType { + if g == nil { + return GraphicTypeUnknown + } + if g.Type == "" { + return GraphicTypeUnknown + } + return g.Type +} + +type GliffyAbstractShape struct { + StrokeWidth float64 `json:"strokeWidth"` + StrokeColor string `json:"strokeColor"` + FillColor string `json:"fillColor"` + DashStyle string `json:"dashStyle"` +} + +func (s GliffyAbstractShape) StrokeWidthValue() int { + return int(math.Round(s.StrokeWidth)) +} + +type GliffyLine struct { + GliffyAbstractShape + StartArrow *int `json:"startArrow"` + EndArrow *int `json:"endArrow"` + Interpolation string `json:"interpolationType"` + CornerRadius *int `json:"cornerRadius"` + ControlPath [][]float64 `json:"controlPath"` +} + +type GliffyShape struct { + GliffyAbstractShape + TID string `json:"tid"` + Gradient bool `json:"gradient"` + DropShadow bool `json:"dropShadow"` + State int `json:"state"` + ShadowX float64 `json:"shadowX"` + ShadowY float64 `json:"shadowY"` + Opacity float64 `json:"opacity"` +} + +func (s GliffyShape) NoFill() bool { + return s.TID != "" && strings.Contains(s.TID, "no_fill") +} + +type GliffyImage struct { + GliffyShape + URL string `json:"url"` +} + +func (g GliffyImage) CleanURL() string { + return strings.ReplaceAll(g.URL, ";base64", "") +} + +type GliffySvg struct { + GliffyShape + EmbeddedResourceID *int `json:"embeddedResourceId"` +} + +type GliffyMindmap struct { + GliffyShape +} + +type GliffyPopupNote struct { + GliffyShape + Text string `json:"text"` +} + +type GliffyLink struct { + Href string `json:"href"` + RenderIcon bool `json:"renderIcon"` +} + +type EmbeddedResource struct { + ID int `json:"id"` + MimeType string `json:"mimeType"` + Data string `json:"data"` +} + +func (r EmbeddedResource) Base64EncodedData() string { + return base64.StdEncoding.EncodeToString([]byte(r.Data)) +} + +type EmbeddedResources struct { + Resources []EmbeddedResource `json:"resources"` + resourceMap map[int]EmbeddedResource +} + +func (e *EmbeddedResources) Get(id int) (EmbeddedResource, bool) { + if e == nil { + return EmbeddedResource{}, false + } + if e.resourceMap == nil { + e.resourceMap = make(map[int]EmbeddedResource, len(e.Resources)) + for _, r := range e.Resources { + e.resourceMap[r.ID] = r + } + } + r, ok := e.resourceMap[id] + return r, ok +} + +type Constraints struct { + Constraints []*Constraint `json:"constraints"` + StartConstraint *Constraint `json:"startConstraint"` + EndConstraint *Constraint `json:"endConstraint"` +} + +type ConstraintType string + +const ( + ConstraintTypeStart ConstraintType = "StartPositionConstraint" + ConstraintTypeEnd ConstraintType = "EndPositionConstraint" + ConstraintTypeHeight ConstraintType = "HeightConstraint" +) + +type Constraint struct { + Type ConstraintType `json:"type"` + StartPositionConstraint *ConstraintData `json:"StartPositionConstraint"` + EndPositionConstraint *ConstraintData `json:"EndPositionConstraint"` +} + +type ConstraintData struct { + NodeID string `json:"nodeId"` + PX float64 `json:"px"` + PY float64 `json:"py"` +} + +// GliffyText mirrors Gliffy's text object. +type GliffyText struct { + HTML string `json:"html"` + Valign string `json:"valign"` + Halign string `json:"-"` + VPosition string `json:"vposition"` + HPosition string `json:"hposition"` + PaddingLeft int `json:"paddingLeft"` + PaddingRight int `json:"paddingRight"` + PaddingBottom int `json:"paddingBottom"` + PaddingTop int `json:"paddingTop"` + LineTValue float64 `json:"lineTValue"` + LinePerpValue *float64 `json:"linePerpValue"` + CardinalityType string `json:"cardinalityType"` + Overflow string `json:"overflow"` + + forceTopPaddingShift bool +} + +func (t *GliffyText) UnmarshalJSON(data []byte) error { + type raw GliffyText + var r raw + if err := json.Unmarshal(data, &r); err != nil { + return err + } + *t = GliffyText(r) + if t.LineTValue == 0 { + t.LineTValue = 0.5 + } + t.postDeserialize() + return nil +} + +func (t *GliffyText) postDeserialize() { + t.Halign = t.extractHorizontalAlignment() + t.convertTextAlign() + t.replaceParagraphWithDiv() +} + +func (t *GliffyText) GetStyle(x, y float64) string { + var sb strings.Builder + topPaddingShift := 7 + + switch t.VPosition { + case "above": + sb.WriteString("verticalLabelPosition=top;verticalAlign=bottom;") + case "below": + sb.WriteString("verticalLabelPosition=bottom;verticalAlign=top;") + case "none": + sb.WriteString("verticalAlign=" + t.Valign + ";") + if !t.forceTopPaddingShift && strings.EqualFold(t.Valign, "middle") { + topPaddingShift = 0 + } + } + + switch t.HPosition { + case "left": + sb.WriteString("labelPosition=left;align=right;") + case "right": + sb.WriteString("labelPosition=right;align=left;") + case "none": + if t.Halign != "" { + sb.WriteString("align=" + t.Halign + ";") + if strings.EqualFold(t.Halign, "right") { + x = 0 + } + } else { + sb.WriteString("align=center;") + } + } + + t.PaddingLeft = maxInt(0, t.PaddingLeft-2) + t.PaddingRight = maxInt(0, t.PaddingRight-2) + + sb.WriteString("spacingLeft=" + floatToString(float64(t.PaddingLeft)+x) + ";") + sb.WriteString("spacingRight=" + intToString(t.PaddingRight) + ";") + + if t.forceTopPaddingShift || !strings.EqualFold(t.Valign, "middle") { + sb.WriteString("spacingTop=" + floatToString(float64(t.PaddingTop-topPaddingShift)+y) + ";") + sb.WriteString("spacingBottom=" + intToString(t.PaddingBottom) + ";") + } + + if t.Overflow == "none" { + sb.WriteString("whiteSpace=wrap;") + } + + return sb.String() +} + +func (t *GliffyText) SetHAlign(value string) { + t.Halign = value +} + +func (t *GliffyText) SetVAlign(value string) { + t.Valign = value +} + +func (t *GliffyText) SetForceTopPaddingShift(v bool) { + t.forceTopPaddingShift = v +} + +var ( + spanPattern = regexp.MustCompile(``) + textAlign = regexp.MustCompile(`.*(text-align: ?(left|center|right);).*`) + textAlignToDraw = regexp.MustCompile(`style="text-align:\s?(left|center|right);"`) + lineHeight = regexp.MustCompile(`.*(line-height: .*px;).*`) +) + +func (t *GliffyText) replaceParagraphWithDiv() { + m := spanPattern.FindAllStringSubmatchIndex(t.HTML, -1) + if len(m) == 0 { + t.HTML = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(t.HTML, "

", "

"), "

", "
") + return + } + + var mod strings.Builder + last := 0 + for _, match := range m { + _, end := match[0], match[1] + styleStart, styleEnd := match[2], match[3] + span := t.HTML[last:end] + style := t.HTML[styleStart:styleEnd] + if style != "" { + if !lineHeight.MatchString(style) { + if strings.HasPrefix(strings.ToLower(t.HTML[end:]), "", "
"), "

", "
") +} + +func (t *GliffyText) extractHorizontalAlignment() string { + m := textAlign.FindStringSubmatch(t.HTML) + if len(m) == 0 { + return "" + } + return m[2] +} + +func (t *GliffyText) convertTextAlign() { + t.HTML = textAlignToDraw.ReplaceAllString(t.HTML, `align="$1"`) +} + +// GliffyObject represents an element on the canvas. +type GliffyObject struct { + X float64 `json:"x"` + Y float64 `json:"y"` + ID string `json:"id"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Rotation float64 `json:"rotation"` + UID string `json:"uid"` + TID string `json:"tid"` + Order OrderValue `json:"order"` + LockShape bool `json:"lockshape"` + LayerID *string `json:"layerId"` + Graphic *Graphic `json:"graphic"` + Children []*GliffyObject `json:"children"` + Constraints *Constraints `json:"constraints"` + + MxObject *MxCell `json:"-"` + Parent *GliffyObject `json:"-"` +} + +// OrderValue supports both numeric and string orders found in Gliffy files. +type OrderValue string + +func (o *OrderValue) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + return nil + } + if data[0] == '"' { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *o = OrderValue(s) + return nil + } + var num json.Number + if err := json.Unmarshal(data, &num); err == nil { + *o = OrderValue(num.String()) + return nil + } + *o = OrderValue(strings.Trim(string(data), "\"")) + return nil +} + +func (o OrderValue) String() string { + return string(o) +} + +func (o *GliffyObject) UnmarshalJSON(data []byte) error { + type raw GliffyObject + var r raw + if err := json.Unmarshal(data, &r); err != nil { + return err + } + *o = GliffyObject(r) + o.postDeserialize() + return nil +} + +func (o *GliffyObject) postDeserialize() { + if o.isGroup() && o.hasChildren() { + o.normalizeChildrenCoordinates() + o.adjustZOrder() + } +} + +func (o *GliffyObject) hasChildren() bool { + return len(o.Children) > 0 +} + +func (o *GliffyObject) GraphicOrChildGraphic() *Graphic { + if o.Graphic != nil { + return o.Graphic + } + if o.isUml() || graphicalessShapes[o.UID] { + return o.firstChildGraphic() + } + return nil +} + +func (o *GliffyObject) firstChildGraphic() *Graphic { + if len(o.Children) == 0 { + return nil + } + return o.Children[0].Graphic +} + +func (o *GliffyObject) TextObject() *GliffyObject { + return o.textObjectWithOffset(0, 0) +} + +func (o *GliffyObject) textObjectWithOffset(x, y float64) *GliffyObject { + if o.isText() { + return o + } + if len(o.Children) == 0 { + return nil + } + + for _, child := range o.Children { + if g := child.GraphicOrChildGraphic(); g != nil && g.GetType() == GraphicTypeText { + child.X += x + child.Y += y + return child + } + if txt := child.textObjectWithOffset(child.X, child.Y); txt != nil { + return txt + } + } + return nil +} + +func (o *GliffyObject) TextHTML() string { + if o.Graphic == nil || o.Graphic.Text == nil { + return "" + } + text := o.Graphic.Text + widthDiff := -3 + if text.Overflow != "none" { + widthDiff = 6 + } + return "
" + text.HTML + "
" +} + +func (o *GliffyObject) isGroup() bool { + if !o.hasChildren() { + return false + } + if o.UID != "" && (groupShapes[o.UID] || strings.HasPrefix(o.UID, "com.gliffy.shape.table")) { + return true + } + if o.UID == "" && len(o.Children) > 0 && !o.Children[0].isText() { + return true + } + return false +} + +func (o *GliffyObject) isSelection() bool { + return o.UID != "" && strings.Contains(o.UID, "default.selection") +} + +func (o *GliffyObject) isMindmap() bool { + return o.UID != "" && mindmapShapes[o.UID] +} + +func (o *GliffyObject) isLine() bool { + return o.Graphic != nil && o.Graphic.GetType() == GraphicTypeLine +} + +func (o *GliffyObject) isLink() bool { + return o.Graphic != nil && o.Graphic.GetType() == GraphicTypeLink +} + +func (o *GliffyObject) isUml() bool { + return strings.HasPrefix(o.UID, "com.gliffy.shape.uml.uml") +} + +func (o *GliffyObject) isShape() bool { + if o.Graphic != nil { + t := o.Graphic.GetType() + return t == GraphicTypeShape || t == GraphicTypeMindmap + } + g := o.firstChildGraphic() + return g != nil && g.GetType() == GraphicTypeShape +} + +func (o *GliffyObject) isSvg() bool { + return o.Graphic != nil && o.Graphic.GetType() == GraphicTypeSVG +} + +func (o *GliffyObject) isSwimlane() bool { + return o.UID != "" && strings.Contains(o.UID, "com.gliffy.shape.swimlanes") +} + +func (o *GliffyObject) isText() bool { + return o.Graphic != nil && o.Graphic.GetType() == GraphicTypeText +} + +func (o *GliffyObject) isImage() bool { + return o.Graphic != nil && o.Graphic.GetType() == GraphicTypeImage +} + +func (o *GliffyObject) isVennCircle() bool { + return strings.HasPrefix(o.UID, "com.gliffy.shape.venn") +} + +func (o *GliffyObject) isDeeplyNestedLink() bool { + return o.UID != "" && deeplyNestedLinks[o.UID] +} + +func (o *GliffyObject) GradientColor() string { + gradientColor := "#FFFFFF" + if o.Graphic != nil && o.Graphic.Shape != nil && o.UID != "" && !strings.HasPrefix(o.UID, "com.gliffy.shape.radial") { + hex := o.Graphic.Shape.FillColor + if len(hex) == 7 && strings.HasPrefix(hex, "#") { + clr, err := parseHex(hex[1:]) + if err == nil { + r := math.Min(0xFF0000, float64(clr&0xFF0000)+0xAA0000) + g := math.Min(0x00FF00, float64(clr&0x00FF00)+0x00AA00) + b := math.Min(0x0000FF, float64(clr&0x0000FF)+0x0000AA) + gradientColor = "#" + strings.ToUpper(hexInt(int(r+g+b))) + } + } + } + return gradientColor +} + +func (o *GliffyObject) GradientIgnored() bool { + return o.UID != "" && (strings.HasPrefix(o.UID, "com.gliffy.shape.venn.outline") || strings.HasPrefix(o.UID, "com.gliffy.shape.venn.flat")) +} + +func (o *GliffyObject) IsSubRoutine() bool { + return o.UID == "com.gliffy.shape.flowchart.flowchart_v1.default.subroutine" +} + +func (o *GliffyObject) IsUnrecognizedGraphicType() bool { + return o.Graphic != nil && o.Graphic.Type == "" +} + +func (o *GliffyObject) GetConstraints() *Constraints { + return o.Constraints +} + +func (o *GliffyObject) String() string { + if o.UID != "" { + return o.UID + } + return o.TID +} + +func (o *GliffyObject) normalizeChildrenCoordinates() { + if !o.hasChildren() { + return + } + sort.Slice(o.Children, func(i, j int) bool { return o.Children[i].X < o.Children[j].X }) + xMin := o.Children[0].X + if xMin < 0 { + o.Width += -xMin + o.X += xMin + for _, c := range o.Children { + c.X += -xMin + } + } + + sort.Slice(o.Children, func(i, j int) bool { return o.Children[i].Y < o.Children[j].Y }) + yMin := o.Children[0].Y + if yMin < 0 { + o.Height += -yMin + o.Y += yMin + for _, c := range o.Children { + c.Y += -yMin + } + } +} + +func (o *GliffyObject) adjustZOrder() { + var maxOrder *int + for _, c := range o.Children { + if c.UID == "com.gliffy.shape.basic.basic_v1.default.rectangle" && c.X == 0 && c.Y == 0 && c.Width == o.Width && c.Height == o.Height { + if val, err := strconv.Atoi(c.Order.String()); err == nil { + if maxOrder == nil || val > *maxOrder { + maxOrder = &val + } + } + } + } + if maxOrder != nil { + o.Order = OrderValue(intToString(*maxOrder)) + } +} + +func (o *GliffyObject) adjustGeo(geo *MxGeometry) { + arr := shapeCoordFix[o.UID] + if arr == nil && o.Graphic != nil && o.Graphic.Shape != nil { + arr = shapeCoordFix[o.Graphic.Shape.TID] + } + if arr == nil { + return + } + x, y, w, h := geo.X, geo.Y, geo.Width, geo.Height + shifts := o.getAdjustShifts(arr, x, y, w, h) + geo.X = x + shifts.X + geo.Y = y + shifts.Y + geo.Width = w + shifts.Width + geo.Height = h + shifts.Height +} + +func (o *GliffyObject) adjustTextPos(textObj *GliffyObject) { + arr := shapeCoordFix[o.UID] + if arr == nil || len(arr) != 4 { + return + } + shifts := o.getAdjustShifts(arr, o.X, o.Y, o.Width, o.Height) + textObj.X -= shifts.X + textObj.Y -= shifts.Y +} + +func (o *GliffyObject) getAdjustShifts(arr []float64, x, y, w, h float64) *MxGeometry { + xShift := relativeShift(arr[0], w) + yShift := relativeShift(arr[1], h) + wShift := relativeShift(arr[2], w) + hShift := relativeShift(arr[3], h) + mod := &MxGeometry{X: x + xShift, Y: y + yShift, Width: w + wShift, Height: h + hShift} + if o.Rotation > 0 { + orig := &MxGeometry{X: x, Y: y, Width: w, Height: h} + rotateGeometry(orig, o.Rotation, 0, 0) + rotateGeometry(mod, o.Rotation, 0, 0) + xShift += mod.X - orig.X + yShift += mod.Y - orig.Y + } + mod.X = xShift + mod.Y = yShift + mod.Width = wShift + mod.Height = hShift + return mod +} + +func (o *GliffyObject) AdjustedLink() string { + if len(o.Children) == 0 { + return "" + } + if o.isDeeplyNestedLink() { + var sb strings.Builder + o.collectDeepLink(&sb) + return sb.String() + } + for _, c := range o.Children { + if c.isLink() && c.Graphic != nil && c.Graphic.Link != nil { + return c.Graphic.Link.Href + } + } + return "" +} + +func (o *GliffyObject) collectDeepLink(sb *strings.Builder) { + for _, c := range o.Children { + if c.isLink() && c.Graphic != nil && c.Graphic.Link != nil { + sb.WriteString(c.Graphic.Link.Href) + return + } + if len(c.Children) > 0 { + c.collectDeepLink(sb) + } + } +} + +func (o *GliffyObject) IsUseFillColorForStroke() bool { + key := o.UID + if key == "" && o.Graphic != nil && o.Graphic.Shape != nil { + key = o.Graphic.Shape.TID + } + return fillColorIsStroke[key] +} + +func (o *GliffyObject) ContainsTextBracket() bool { + return o.UID != "" && strings.Contains(o.UID, "com.gliffy.shape.uml.uml_v2.activity.frame") +} + +func (o *GliffyObject) ForceConstraints() bool { + return forceConstraintsShapes[o.UID] +} + +func (o *GliffyObject) UMLSequenceCombinedFragmentText() string { + switch o.UID { + case "com.gliffy.shape.uml.uml_v2.sequence.interaction_use": + return "ref" + case "com.gliffy.shape.uml.uml_v2.sequence.opt_combined_fragment": + return "opt" + case "com.gliffy.shape.uml.uml_v2.sequence.loop_combined_fragment": + return "loop" + case "com.gliffy.shape.uml.uml_v2.sequence.alt_combined_fragment": + return "alt" + default: + return "" + } +} + +func relativeShift(v, size float64) float64 { + if math.Abs(v) < 1 { + return size * v + } + return v +} + +func parseHex(s string) (int64, error) { + return strconv.ParseInt(s, 16, 64) +} + +func hexInt(v int) string { + return fmt.Sprintf("%06X", v&0xFFFFFF) +} + +// to avoid fmt in hot paths. +func floatToString(v float64) string { + return strings.TrimRight(strings.TrimRight(strconv.FormatFloat(v, 'f', 4, 64), "0"), ".") +} + +func intToString(v int) string { + return strconv.Itoa(v) +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +// Static shape metadata replicated from Java implementation. +var graphicalessShapes = map[string]bool{ + "com.gliffy.shape.uml.uml_v1.default.package": true, + "com.gliffy.shape.uml.uml_v1.default.class": true, + "com.gliffy.shape.uml.uml_v1.default.simple_class": true, + "com.gliffy.shape.uml.uml_v1.default.object_timeline": true, + "com.gliffy.shape.uml.uml_v1.default.lifeline": true, + "com.gliffy.shape.uml.uml_v1.default.use_case": true, + "com.gliffy.shape.uml.uml_v1.default.actor": true, + "com.gliffy.shape.uml.uml_v1.default.message": true, + "com.gliffy.shape.uml.uml_v1.default.activation": true, + "com.gliffy.shape.uml.uml_v1.default.dependency": true, + "com.gliffy.shape.uml.uml_v1.default.composition": true, + "com.gliffy.shape.uml.uml_v1.default.aggregation": true, + "com.gliffy.shape.uml.uml_v1.default.association": true, + "com.gliffy.shape.uml.uml_v2.class.package": true, + "com.gliffy.shape.uml.uml_v2.class.simple_class": true, + "com.gliffy.shape.uml.uml_v2.class.class": true, + "com.gliffy.shape.uml.uml_v2.class.class2": true, + "com.gliffy.shape.uml.uml_v2.class.interface": true, + "com.gliffy.shape.uml.uml_v2.class.enumeration": true, + "com.gliffy.shape.uml.uml_v2.sequence.lifeline": true, + "com.gliffy.shape.uml.uml_v2.sequence.boundary_lifeline": true, + "com.gliffy.shape.uml.uml_v2.sequence.control_lifeline": true, + "com.gliffy.shape.uml.uml_v2.sequence.entity_lifeline": true, + "com.gliffy.shape.uml.uml_v2.deployment.package": true, + "com.gliffy.shape.uml.uml_v2.component.package": true, + "com.gliffy.shape.uml.uml_v2.use_case.package": true, + "com.gliffy.shape.erd.erd_v1.default.entity_with_attributes": true, + "com.gliffy.shape.erd.erd_v1.default.entity_with_multiple_attributes": true, + "com.gliffy.shape.bpmn.bpmn_v1.data_artifacts.annotation": true, + "com.gliffy.shape.ui.ui_v3.navigation.navbar": true, + "com.gliffy.shape.ui.ui_v3.forms_controls.combo_box": true, + "com.gliffy.shape.ui.ui_v3.containers_content.tooltip_top": true, + "com.gliffy.shape.ui.ui_v3.containers_content.tooltip_bottom": true, + "com.gliffy.shape.ui.ui_v3.containers_content.tooltip_left": true, + "com.gliffy.shape.ui.ui_v3.containers_content.tooltip_right": true, + "com.gliffy.shape.ui.ui_v3.containers_content.popover_top": true, + "com.gliffy.shape.ui.ui_v3.containers_content.popover_bottom": true, + "com.gliffy.shape.ui.ui_v3.forms_controls.selector": true, + "com.gliffy.shape.ui.ui_v3.icon_symbols.annotate_left": true, + "com.gliffy.shape.ui.ui_v3.icon_symbols.annotate_right": true, + "com.gliffy.shape.ui.ui_v3.icon_symbols.annotate_top": true, + "com.gliffy.shape.ui.ui_v3.containers_content.speech_bubble_right": true, + "com.gliffy.shape.ui.ui_v3.containers_content.speech_bubble_left": true, + "com.gliffy.shape.sitemap.sitemap_v2.page": true, + "com.gliffy.shape.sitemap.sitemap_v2.home": true, + "com.gliffy.shape.sitemap.sitemap_v2.gliffy": true, + "com.gliffy.shape.sitemap.sitemap_v2.form": true, + "com.gliffy.shape.sitemap.sitemap_v2.shopping_cart": true, + "com.gliffy.shape.sitemap.sitemap_v2.text": true, + "com.gliffy.shape.sitemap.sitemap_v2.video": true, + "com.gliffy.shape.sitemap.sitemap_v2.upload": true, + "com.gliffy.shape.sitemap.sitemap_v2.slideshow": true, + "com.gliffy.shape.sitemap.sitemap_v2.sitemap": true, + "com.gliffy.shape.sitemap.sitemap_v2.settings": true, + "com.gliffy.shape.sitemap.sitemap_v2.search": true, + "com.gliffy.shape.sitemap.sitemap_v2.script": true, + "com.gliffy.shape.sitemap.sitemap_v2.print": true, + "com.gliffy.shape.sitemap.sitemap_v2.pricing": true, + "com.gliffy.shape.sitemap.sitemap_v2.photo": true, + "com.gliffy.shape.sitemap.sitemap_v2.map": true, + "com.gliffy.shape.sitemap.sitemap_v2.login": true, + "com.gliffy.shape.sitemap.sitemap_v2.game": true, + "com.gliffy.shape.sitemap.sitemap_v2.gallery": true, + "com.gliffy.shape.sitemap.sitemap_v2.download": true, + "com.gliffy.shape.sitemap.sitemap_v2.document": true, + "com.gliffy.shape.sitemap.sitemap_v2.chat": true, + "com.gliffy.shape.sitemap.sitemap_v2.calendar": true, + "com.gliffy.shape.sitemap.sitemap_v2.audio": true, + "com.gliffy.shape.sitemap.sitemap_v2.profile": true, + "com.gliffy.shape.sitemap.sitemap_v2.error": true, + "com.gliffy.shape.sitemap.sitemap_v2.success": true, + "com.gliffy.shape.sitemap.sitemap_v2.cloud": true, +} + +var groupShapes = map[string]bool{ + "com.gliffy.shape.basic.basic_v1.default.group": true, + "com.gliffy.shape.erd.erd_v1.default.entity_with_attributes": true, + "com.gliffy.shape.erd.erd_v1.default.entity_with_multiple_attributes": true, + "com.gliffy.shape.uml.uml_v2.sequence.interaction_use": true, + "com.gliffy.shape.uml.uml_v2.sequence.opt_combined_fragment": true, + "com.gliffy.shape.uml.uml_v2.sequence.loop_combined_fragment": true, + "com.gliffy.shape.uml.uml_v2.sequence.alt_combined_fragment": true, + "com.gliffy.shape.uml.uml_v2.class.object": true, + "com.gliffy.shape.uml.uml_v2.class.enumeration": true, + "com.gliffy.shape.uml.uml_v2.class.interface": true, + "com.gliffy.shape.uml.uml_v2.class.class2": true, + "com.gliffy.shape.uml.uml_v2.class.class": true, + "com.gliffy.shape.uml.uml_v2.class.data_type": true, + "com.gliffy.shape.uml.uml_v2.state_machine.composite_state": true, + "com.gliffy.shape.uml.uml_v2.state_machine.orthoganal_state": true, + "com.gliffy.shape.uml.uml_v2.class.package": true, + "com.gliffy.shape.uml.uml_v2.sequence.boundary_lifeline": true, + "com.gliffy.shape.uml.uml_v2.sequence.lifeline": true, + "com.gliffy.shape.uml.uml_v2.sequence.entity_lifeline": true, + "com.gliffy.shape.uml.uml_v2.sequence.control_lifeline": true, + "com.gliffy.shape.uml.uml_v1.default.object_timeline": true, + "com.gliffy.shape.uml.uml_v1.default.class": true, + "com.gliffy.shape.uml.uml_v1.default.object": true, + "com.gliffy.shape.iphone.iphone_ios7.containers_content.table": true, + "com.gliffy.shape.iphone.iphone_ios7.forms_controls.button_stack": true, + "com.gliffy.shape.iphone.iphone_ios7.forms_controls.alert_2options": true, + "com.gliffy.shape.iphone.iphone_ios7.forms_controls.alert": true, + "com.gliffy.shape.iphone.iphone_ios7.navigation.contextual_menu": true, + "com.gliffy.shape.iphone.iphone_ios7.navigation.nav_3tabs": true, + "com.gliffy.shape.iphone.iphone_ios7.containers_content.title_bar": true, + "com.gliffy.shape.iphone.iphone_ios7.navigation.tab_bar": true, + "com.gliffy.shape.iphone.iphone_ios7.forms_controls.search": true, + "com.gliffy.shape.android.android_v1.general.dialog": true, + "com.gliffy.shape.android.android_v1.general.list_1line": true, + "com.gliffy.shape.android.android_v1.general.list_2lines": true, + "com.gliffy.shape.android.android_v1.general.tabs01": true, + "com.gliffy.shape.android.android_v1.general.tabs02": true, + "com.gliffy.shape.network.network_v3.business.user_group": true, + "com.gliffy.shape.ui.ui_v3.navigation.navbar": true, + "com.gliffy.shape.ui.ui_v3.navigation.navbar_vertical": true, + "com.gliffy.shape.ui.ui_v3.forms_controls.dropdown": true, + "com.gliffy.shape.uml.uml_v2.sequence.recursive_message": true, +} + +var mindmapShapes = map[string]bool{ + "com.gliffy.shape.mindmap.mindmap_v1.default.main_topic": true, + "com.gliffy.shape.mindmap.mindmap_v1.default.subtopic": true, + "com.gliffy.shape.mindmap.mindmap_v1.default.child_node": true, +} + +var fillColorIsStroke = map[string]bool{ + "com.gliffy.stencil.rectangle.no_fill_line_bottom_2px_off": true, +} + +var deeplyNestedLinks = map[string]bool{ + "com.gliffy.shape.basic.basic_v1.default.chevron_box_right": true, + "com.gliffy.shape.basic.basic_v1.default.chevron_box_left": true, + "com.gliffy.shape.basic.basic_v1.default.chevron_tail_right": true, + "com.gliffy.shape.basic.basic_v1.default.chevron_tail_left": true, +} + +var shapeCoordFix = map[string][]float64{ + "com.gliffy.shape.flowchart.flowchart_v1.default.paper_tape": {0, -0.1, 0, 0.2}, + "com.gliffy.shape.uml.uml_v1.default.node": {0, -10, 10, 10}, + "com.gliffy.shape.uml.uml_v2.deployment.node": {0, -10, 10, 10}, + "com.gliffy.shape.uml.uml_v2.deployment.device_node": {0, -10, 10, 10}, + "com.gliffy.shape.uml.uml_v2.deployment.execution_environment_node": {0, -10, 10, 10}, + "com.gliffy.shape.flowchart.flowchart_v1.default.data_storage": {0, 0, 0.115, 0}, + "com.gliffy.shape.flowchart.flowchart_v1.default.database": {0, 0, 0, 0.15}, + "com.gliffy.stencil.entity_lifeline.uml_v2": {10, 0, -20, 0}, + "com.gliffy.stencil.boundary_lifeline.uml_v2": {35, 0, -70, 0}, + "com.gliffy.stencil.control_lifeline.uml_v2": {10, 0, -20, 0}, + "com.gliffy.shape.ui.ui_v3.containers_content.browser": {0, -40, 0, 40, 1}, +} + +var forceConstraintsShapes = map[string]bool{ + "com.gliffy.shape.uml.uml_v2.class.association": true, +} diff --git a/mxgraph.go b/mxgraph.go new file mode 100644 index 0000000..9f29933 --- /dev/null +++ b/mxgraph.go @@ -0,0 +1,463 @@ +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() +} diff --git a/svg_utils.go b/svg_utils.go new file mode 100644 index 0000000..08a737d --- /dev/null +++ b/svg_utils.go @@ -0,0 +1,22 @@ +package gliffy2drawio + +import ( + "regexp" +) + +// SVGImporterUtils mirrors the Java helper but keeps a lightweight implementation. +// It currently preserves the original SVG and only updates the viewBox attribute +// if one is present. +type SVGImporterUtils struct { +} + +var svgPattern = regexp.MustCompile(`viewBox="(.*?)"`) + +func (s SVGImporterUtils) SetViewBox(svg string) string { + m := svgPattern.FindStringSubmatch(svg) + if len(m) == 0 { + return svg + } + // No bounding-box calculation available here; keep the existing viewBox. + return svg +}