Files
go-jdenticon/internal/renderer/svg.go
Kevin McIntyre f1544ef49c
Some checks failed
CI / Test (Go 1.24.x, ubuntu-latest) (push) Successful in 1m53s
CI / Code Quality (push) Failing after 26s
CI / Security Scan (push) Failing after 11s
CI / Test Coverage (push) Successful in 1m13s
CI / Benchmarks (push) Failing after 10m22s
CI / Build CLI (push) Failing after 8s
Benchmarks / Run Benchmarks (push) Failing after 10m13s
Release / Test (push) Successful in 55s
Release / Build (amd64, darwin, ) (push) Failing after 12s
Release / Build (amd64, linux, ) (push) Failing after 6s
Release / Build (amd64, windows, .exe) (push) Failing after 12s
Release / Build (arm64, darwin, ) (push) Failing after 12s
Release / Build (arm64, linux, ) (push) Failing after 12s
Release / Release (push) Has been skipped
CI / Test (Go 1.24.x, macos-latest) (push) Has been cancelled
CI / Test (Go 1.24.x, windows-latest) (push) Has been cancelled
chore: update module path to gitea.dockr.co/kev/go-jdenticon
Move hosting from GitHub to private Gitea instance.
2026-02-10 10:07:57 -05:00

278 lines
8.1 KiB
Go

package renderer
import (
"math"
"strconv"
"strings"
"gitea.dockr.co/kev/go-jdenticon/internal/engine"
)
// SVG rendering constants
const (
// SVG generation size estimation constants
svgBaseOverheadBytes = 150 // Base SVG document overhead
svgBackgroundRectBytes = 100 // Background rectangle overhead
svgPathOverheadBytes = 50 // Per-path element overhead
// Precision constants
svgCoordinatePrecision = 10 // Precision factor for SVG coordinates (0.1 precision)
svgRoundingOffset = 0.5 // Rounding offset for "round half up" behavior
)
// Note: Previously used polygonBufferPool for intermediate buffering, but eliminated
// to write directly to main builder and avoid unnecessary allocations
// SVGPath represents an SVG path element
type SVGPath struct {
data strings.Builder
}
// AddPolygon adds a polygon to the SVG path
func (p *SVGPath) AddPolygon(points []engine.Point) {
if len(points) == 0 {
return
}
// Write directly to main data builder to avoid intermediate allocations
// Move to first point
p.data.WriteString("M")
svgAppendValue(&p.data, points[0].X)
p.data.WriteString(" ")
svgAppendValue(&p.data, points[0].Y)
// Line to subsequent points
for i := 1; i < len(points); i++ {
p.data.WriteString("L")
svgAppendValue(&p.data, points[i].X)
p.data.WriteString(" ")
svgAppendValue(&p.data, points[i].Y)
}
// Close path
p.data.WriteString("Z")
}
// AddCircle adds a circle to the SVG path
func (p *SVGPath) AddCircle(topLeft engine.Point, size float64, counterClockwise bool) {
sweepFlag := "1"
if counterClockwise {
sweepFlag = "0"
}
radius := size / 2
centerX := topLeft.X + radius
centerY := topLeft.Y + radius
// Move to start point (left side of circle)
startX := centerX - radius
startY := centerY
// Build circle path directly in main data builder
p.data.WriteString("M")
svgAppendValue(&p.data, startX)
p.data.WriteString(" ")
svgAppendValue(&p.data, startY)
// Draw first arc
p.data.WriteString("a")
svgAppendValue(&p.data, radius)
p.data.WriteString(",")
svgAppendValue(&p.data, radius)
p.data.WriteString(" 0 1,")
p.data.WriteString(sweepFlag)
p.data.WriteString(" ")
svgAppendValue(&p.data, size)
p.data.WriteString(",0")
// Draw second arc
p.data.WriteString("a")
svgAppendValue(&p.data, radius)
p.data.WriteString(",")
svgAppendValue(&p.data, radius)
p.data.WriteString(" 0 1,")
p.data.WriteString(sweepFlag)
p.data.WriteString(" -")
svgAppendValue(&p.data, size)
p.data.WriteString(",0")
}
// DataString returns the SVG path data string
func (p *SVGPath) DataString() string {
return p.data.String()
}
// SVGRenderer implements the Renderer interface for SVG output
type SVGRenderer struct {
*BaseRenderer
pathsByColor map[string]*SVGPath
colorOrder []string
}
// NewSVGRenderer creates a new SVG renderer
func NewSVGRenderer(iconSize int) *SVGRenderer {
return &SVGRenderer{
BaseRenderer: NewBaseRenderer(iconSize),
pathsByColor: make(map[string]*SVGPath),
colorOrder: make([]string, 0),
}
}
// SetBackground sets the background color and opacity
func (r *SVGRenderer) SetBackground(fillColor string, opacity float64) {
r.BaseRenderer.SetBackground(fillColor, opacity)
}
// BeginShape marks the beginning of a new shape with the specified color
func (r *SVGRenderer) BeginShape(color string) {
// Defense-in-depth validation: ensure color is safe for SVG output
// Invalid colors are silently ignored to maintain interface compatibility
if err := engine.ValidateHexColor(color); err != nil {
// Log validation failure but continue - the shape will not be rendered
// This prevents breaking the interface while maintaining security
return
}
r.BaseRenderer.BeginShape(color)
if _, exists := r.pathsByColor[color]; !exists {
r.pathsByColor[color] = &SVGPath{}
r.colorOrder = append(r.colorOrder, color)
}
}
// EndShape marks the end of the currently drawn shape
func (r *SVGRenderer) EndShape() {
// No action needed for SVG
}
// getCurrentPath returns the current path for the active color
func (r *SVGRenderer) getCurrentPath() *SVGPath {
currentColor := r.GetCurrentColor()
if currentColor == "" {
return nil
}
return r.pathsByColor[currentColor]
}
// AddPolygon adds a polygon with the current fill color to the SVG
func (r *SVGRenderer) AddPolygon(points []engine.Point) {
if path := r.getCurrentPath(); path != nil {
path.AddPolygon(points)
}
}
// AddCircle adds a circle with the current fill color to the SVG
func (r *SVGRenderer) AddCircle(topLeft engine.Point, size float64, invert bool) {
if path := r.getCurrentPath(); path != nil {
path.AddCircle(topLeft, size, invert)
}
}
// ToSVG generates the final SVG XML string
func (r *SVGRenderer) ToSVG() string {
iconSize := r.GetSize()
background, backgroundOp := r.GetBackground()
// Estimate capacity to reduce allocations
capacity := svgBaseOverheadBytes
if background != "" && backgroundOp > 0 {
capacity += svgBackgroundRectBytes
}
// Estimate path data size
for _, color := range r.colorOrder {
path := r.pathsByColor[color]
if path != nil {
capacity += svgPathOverheadBytes + path.data.Len()
}
}
var svg strings.Builder
svg.Grow(capacity)
// SVG opening tag with namespace and dimensions
iconSizeStr := strconv.Itoa(iconSize)
svg.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="`)
svg.WriteString(iconSizeStr)
svg.WriteString(`" height="`)
svg.WriteString(iconSizeStr)
svg.WriteString(`" viewBox="0 0 `)
svg.WriteString(iconSizeStr)
svg.WriteString(` `)
svg.WriteString(iconSizeStr)
svg.WriteString(`">`)
// Add background rectangle if specified
if background != "" && backgroundOp > 0 {
// Validate background color for safe SVG output
if err := engine.ValidateHexColor(background); err != nil {
// Skip invalid background colors to prevent injection
} else {
svg.WriteString(`<rect width="100%" height="100%" fill="`)
svg.WriteString(background) // Now validated
svg.WriteString(`" opacity="`)
svg.WriteString(strconv.FormatFloat(backgroundOp, 'f', 2, 64))
svg.WriteString(`"/>`)
}
}
// Add paths for each color (in insertion order to preserve z-order)
for _, color := range r.colorOrder {
path := r.pathsByColor[color]
dataString := path.DataString()
if dataString != "" {
// Final defense-in-depth validation before writing to SVG
if err := engine.ValidateHexColor(color); err != nil {
// Skip invalid colors to prevent injection attacks
continue
}
svg.WriteString(`<path fill="`)
svg.WriteString(color) // Now validated - safe injection point
svg.WriteString(`" d="`)
svg.WriteString(dataString)
svg.WriteString(`"/>`)
}
}
// SVG closing tag
svg.WriteString("</svg>")
return svg.String()
}
// svgValue rounds a float64 to one decimal place, mimicking the Jdenticon JS implementation's
// "round half up" behavior. It also formats the number to a minimal string representation.
func svgValue(value float64) string {
// Use math.Floor to replicate the "round half up" logic from the JS implementation.
// JavaScript: ((value * 10 + 0.5) | 0) / 10
rounded := math.Floor(value*svgCoordinatePrecision+svgRoundingOffset) / svgCoordinatePrecision
// Format to an integer string if there's no fractional part.
if rounded == math.Trunc(rounded) {
return strconv.Itoa(int(rounded))
}
// Otherwise, format to one decimal place.
return strconv.FormatFloat(rounded, 'f', 1, 64)
}
// svgAppendValue appends a formatted float64 directly to a strings.Builder to avoid string allocations
func svgAppendValue(buf *strings.Builder, value float64) {
// Use math.Floor to replicate the "round half up" logic from the JS implementation.
// JavaScript: ((value * 10 + 0.5) | 0) / 10
rounded := math.Floor(value*svgCoordinatePrecision+svgRoundingOffset) / svgCoordinatePrecision
// Use stack-allocated buffer for AppendFloat to avoid heap allocations
var tempBuf [32]byte
// Format to an integer string if there's no fractional part.
if rounded == math.Trunc(rounded) {
result := strconv.AppendInt(tempBuf[:0], int64(rounded), 10)
buf.Write(result)
} else {
// Otherwise, format to one decimal place using AppendFloat
result := strconv.AppendFloat(tempBuf[:0], rounded, 'f', 1, 64)
buf.Write(result)
}
}