146 lines
3.4 KiB
Go
146 lines
3.4 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|||
|
|
// SPDX-License-Identifier: MIT
|
||
|
|
|
||
|
|
package diagram
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
)
|
||
|
|
|
||
|
|
// point is a coordinate in the layout. Everything is an integer, so the
|
||
|
|
// same layout always writes the same bytes.
|
||
|
|
type point struct {
|
||
|
|
x, y int
|
||
|
|
}
|
||
|
|
|
||
|
|
// svgBuilder writes SVG elements in the order they are given.
|
||
|
|
type svgBuilder struct {
|
||
|
|
b strings.Builder
|
||
|
|
width int
|
||
|
|
height int
|
||
|
|
}
|
||
|
|
|
||
|
|
func newSVGBuilder(width, height int) *svgBuilder {
|
||
|
|
s := &svgBuilder{width: width, height: height}
|
||
|
|
fmt.Fprintf(&s.b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" font-family="sans-serif">`, width, height)
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
|
||
|
|
// rect draws a rectangle, rounded when rx is positive.
|
||
|
|
func (s *svgBuilder) rect(x, y, w, h, rx int, attrs string) {
|
||
|
|
if w < 0 || h < 0 {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if rx > 0 {
|
||
|
|
fmt.Fprintf(&s.b, `<rect x="%d" y="%d" width="%d" height="%d" rx="%d"%s/>`, x, y, w, h, rx, attrs)
|
||
|
|
} else {
|
||
|
|
fmt.Fprintf(&s.b, `<rect x="%d" y="%d" width="%d" height="%d"%s/>`, x, y, w, h, attrs)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *svgBuilder) line(x1, y1, x2, y2 int, attrs string) {
|
||
|
|
fmt.Fprintf(&s.b, `<line x1="%d" y1="%d" x2="%d" y2="%d"%s/>`, x1, y1, x2, y2, attrs)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *svgBuilder) path(d, attrs string) {
|
||
|
|
s.b.WriteString(`<path d="`)
|
||
|
|
s.b.WriteString(d)
|
||
|
|
s.b.WriteString(`"`)
|
||
|
|
s.b.WriteString(attrs)
|
||
|
|
s.b.WriteString(`/>`)
|
||
|
|
}
|
||
|
|
|
||
|
|
// text writes one line of text. The anchor is middle, start or end.
|
||
|
|
func (s *svgBuilder) text(p point, str, anchor, attrs string) {
|
||
|
|
s.b.WriteString(`<text x="`)
|
||
|
|
fmt.Fprintf(&s.b, `%d`, p.x)
|
||
|
|
s.b.WriteString(`" y="`)
|
||
|
|
fmt.Fprintf(&s.b, `%d`, p.y)
|
||
|
|
if anchor != "" {
|
||
|
|
s.b.WriteString(`" text-anchor="`)
|
||
|
|
s.b.WriteString(anchor)
|
||
|
|
}
|
||
|
|
s.b.WriteString(`"`)
|
||
|
|
s.b.WriteString(attrs)
|
||
|
|
s.b.WriteString(`>`)
|
||
|
|
s.b.WriteString(escapeXML(str))
|
||
|
|
s.b.WriteString(`</text>`)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *svgBuilder) polygon(pts []point, attrs string) {
|
||
|
|
if len(pts) < 3 {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.b.WriteString(`<polygon points="`)
|
||
|
|
for i, p := range pts {
|
||
|
|
if i > 0 {
|
||
|
|
s.b.WriteString(" ")
|
||
|
|
}
|
||
|
|
fmt.Fprintf(&s.b, `%d,%d`, p.x, p.y)
|
||
|
|
}
|
||
|
|
s.b.WriteString(`"`)
|
||
|
|
s.b.WriteString(attrs)
|
||
|
|
s.b.WriteString(`/>`)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *svgBuilder) finish() []byte {
|
||
|
|
s.b.WriteString("</svg>")
|
||
|
|
return []byte(s.b.String())
|
||
|
|
}
|
||
|
|
|
||
|
|
var xmlEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
||
|
|
|
||
|
|
func escapeXML(s string) string {
|
||
|
|
return xmlEscaper.Replace(s)
|
||
|
|
}
|
||
|
|
|
||
|
|
// textWidth estimates the width a string needs at the given font size.
|
||
|
|
// Characters outside the basic plane are wide: the estimate stays on the
|
||
|
|
// generous side so labels keep their padding.
|
||
|
|
func textWidth(s string, size int) int {
|
||
|
|
w := 0
|
||
|
|
for _, r := range s {
|
||
|
|
if r > 0x2E7F {
|
||
|
|
w += size
|
||
|
|
} else {
|
||
|
|
w += (size*6 + 4) / 10
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return w
|
||
|
|
}
|
||
|
|
|
||
|
|
// labelLines splits a label into its lines.
|
||
|
|
func labelLines(s string) []string {
|
||
|
|
if s == "" {
|
||
|
|
return []string{""}
|
||
|
|
}
|
||
|
|
normalised := strings.ReplaceAll(s, "<br/>", "<br>")
|
||
|
|
normalised = strings.ReplaceAll(normalised, "<br />", "<br>")
|
||
|
|
lines := strings.Split(normalised, "<br>")
|
||
|
|
if len(lines) == 0 {
|
||
|
|
return []string{""}
|
||
|
|
}
|
||
|
|
return lines
|
||
|
|
}
|
||
|
|
|
||
|
|
// styleString joins style pairs into a style attribute, declaration order
|
||
|
|
// preserved.
|
||
|
|
func styleString(pairs []stylePair) string {
|
||
|
|
if len(pairs) == 0 {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
var b strings.Builder
|
||
|
|
b.WriteString(` style="`)
|
||
|
|
for i, p := range pairs {
|
||
|
|
if i > 0 {
|
||
|
|
b.WriteString(";")
|
||
|
|
}
|
||
|
|
b.WriteString(p.key)
|
||
|
|
b.WriteString(":")
|
||
|
|
b.WriteString(p.value)
|
||
|
|
}
|
||
|
|
b.WriteString(`"`)
|
||
|
|
return b.String()
|
||
|
|
}
|