Initial release: Go Jdenticon library v0.1.0

- Core library with SVG and PNG generation
- CLI tool with generate and batch commands
- Cross-platform path handling for Windows compatibility
- Comprehensive test suite with integration tests
This commit is contained in:
Kevin McIntyre
2026-01-02 23:56:48 -05:00
parent f84b511895
commit d9e84812ff
292 changed files with 19725 additions and 38884 deletions

View File

@@ -1,14 +1,28 @@
package renderer
import (
"fmt"
"math"
"strconv"
"strings"
"github.com/kevin/go-jdenticon/internal/engine"
"github.com/ungluedlabs/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
@@ -20,12 +34,19 @@ func (p *SVGPath) AddPolygon(points []engine.Point) {
return
}
// Write directly to main data builder to avoid intermediate allocations
// Move to first point
p.data.WriteString(fmt.Sprintf("M%s %s", svgValue(points[0].X), svgValue(points[0].Y)))
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(fmt.Sprintf("L%s %s", svgValue(points[i].X), svgValue(points[i].Y)))
p.data.WriteString("L")
svgAppendValue(&p.data, points[i].X)
p.data.WriteString(" ")
svgAppendValue(&p.data, points[i].Y)
}
// Close path
@@ -42,18 +63,38 @@ func (p *SVGPath) AddCircle(topLeft engine.Point, size float64, counterClockwise
radius := size / 2
centerX := topLeft.X + radius
centerY := topLeft.Y + radius
svgRadius := svgValue(radius)
svgDiameter := svgValue(size)
svgArc := fmt.Sprintf("a%s,%s 0 1,%s ", svgRadius, svgRadius, sweepFlag)
// Move to start point (left side of circle)
startX := centerX - radius
startY := centerY
p.data.WriteString(fmt.Sprintf("M%s %s", svgValue(startX), svgValue(startY)))
p.data.WriteString(svgArc + svgDiameter + ",0")
p.data.WriteString(svgArc + "-" + svgDiameter + ",0")
// 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
@@ -84,6 +125,14 @@ func (r *SVGRenderer) SetBackground(fillColor string, opacity float64) {
// 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{}
@@ -121,22 +170,49 @@ func (r *SVGRenderer) AddCircle(topLeft engine.Point, size float64, invert bool)
// ToSVG generates the final SVG XML string
func (r *SVGRenderer) ToSVG() string {
var svg strings.Builder
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
svg.WriteString(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`,
iconSize, iconSize, iconSize, iconSize))
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 {
if backgroundOp >= 1.0 {
svg.WriteString(fmt.Sprintf(`<rect width="100%%" height="100%%" fill="%s"/>`, background))
// Validate background color for safe SVG output
if err := engine.ValidateHexColor(background); err != nil {
// Skip invalid background colors to prevent injection
} else {
svg.WriteString(fmt.Sprintf(`<rect width="100%%" height="100%%" fill="%s" opacity="%.2f"/>`,
background, backgroundOp))
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(`"/>`)
}
}
@@ -145,7 +221,16 @@ func (r *SVGRenderer) ToSVG() string {
path := r.pathsByColor[color]
dataString := path.DataString()
if dataString != "" {
svg.WriteString(fmt.Sprintf(`<path fill="%s" d="%s"/>`, color, 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(`"/>`)
}
}
@@ -160,13 +245,33 @@ func (r *SVGRenderer) ToSVG() string {
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*10 + 0.5) / 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)
}
}