Files
scriptorium/internal/diagram/svg.go
T

146 lines
3.4 KiB
Go
Raw Normal View History

// 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("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
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()
}