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,10 +1,60 @@
package engine
import (
"context"
"fmt"
"strconv"
"sync"
"github.com/kevin/go-jdenticon/internal/util"
lru "github.com/hashicorp/golang-lru/v2"
"github.com/ungluedlabs/go-jdenticon/internal/constants"
"github.com/ungluedlabs/go-jdenticon/internal/util"
"golang.org/x/sync/singleflight"
)
// Hash position constants for extracting values from the hash string
const (
// Shape type selection positions
hashPosSideShape = 2 // Position for side shape selection
hashPosCornerShape = 4 // Position for corner shape selection
hashPosCenterShape = 1 // Position for center shape selection
// Rotation positions
hashPosSideRotation = 3 // Position for side shape rotation
hashPosCornerRotation = 5 // Position for corner shape rotation
hashPosCenterRotation = -1 // Center shapes use incremental rotation (no hash position)
// Color selection positions
hashPosColorStart = 8 // Starting position for color selection (8, 9, 10)
// Hue extraction
hashPosHueStart = -7 // Start position for hue extraction (last 7 chars)
hashPosHueLength = 7 // Number of characters for hue
hueMaxValue = 0xfffffff // Maximum hue value for normalization
)
// Grid and layout constants
const (
gridSize = 4 // Standard 4x4 grid for jdenticon layout
paddingMultiple = 2 // Padding is applied on both sides (2x)
)
// Color conflict resolution constants
const (
colorDarkGray = 0 // Index for dark gray color
colorDarkMain = 4 // Index for dark main color
colorLightGray = 2 // Index for light gray color
colorLightMain = 3 // Index for light main color
colorMidFallback = 1 // Fallback color index for conflicts
)
// Shape rendering constants
const (
shapeColorIndexSides = 0 // Color index for side shapes
shapeColorIndexCorners = 1 // Color index for corner shapes
shapeColorIndexCenter = 2 // Color index for center shapes
numColorSelections = 3 // Total number of color selections needed
)
// Icon represents a generated jdenticon with its configuration and geometry
@@ -27,155 +77,188 @@ type ShapeGroup struct {
// - For "polygon", `Points` is used.
// - For "circle", `CircleX`, `CircleY`, and `CircleSize` are used.
type Shape struct {
Type string
Points []Point
Transform Transform
Invert bool
Type string
Points []Point
Transform Transform
Invert bool
// Circle-specific fields
CircleX float64
CircleY float64
CircleSize float64
}
// Generator encapsulates the icon generation logic and provides caching
type Generator struct {
config ColorConfig
cache map[string]*Icon
mu sync.RWMutex
// GeneratorConfig holds configuration for the generator including cache settings
type GeneratorConfig struct {
ColorConfig ColorConfig
CacheSize int // Maximum number of items in the LRU cache (default: 1000)
MaxComplexity int // Maximum geometric complexity score (-1 to disable, 0 for default)
MaxIconSize int // Maximum allowed icon size in pixels (0 for default from constants.DefaultMaxIconSize)
}
// NewGenerator creates a new Generator with the specified configuration
func NewGenerator(config ColorConfig) *Generator {
config.Validate()
return &Generator{
config: config,
cache: make(map[string]*Icon),
// DefaultGeneratorConfig returns the default generator configuration
func DefaultGeneratorConfig() GeneratorConfig {
return GeneratorConfig{
ColorConfig: DefaultColorConfig(),
CacheSize: 1000,
MaxComplexity: 0, // Use default from constants
MaxIconSize: 0, // Use default from constants.DefaultMaxIconSize
}
}
// Generator encapsulates the icon generation logic and provides caching
type Generator struct {
config GeneratorConfig
cache *lru.Cache[string, *Icon]
mu sync.RWMutex
metrics CacheMetrics
sf singleflight.Group // Prevents thundering herd on cache misses
maxIconSize int // Resolved maximum icon size (from config or default)
}
// NewGenerator creates a new Generator with the specified color configuration
// and default cache size of 1000 entries
func NewGenerator(colorConfig ColorConfig) (*Generator, error) {
generatorConfig := GeneratorConfig{
ColorConfig: colorConfig,
CacheSize: 1000,
}
return NewGeneratorWithConfig(generatorConfig)
}
// NewGeneratorWithConfig creates a new Generator with the specified configuration
func NewGeneratorWithConfig(config GeneratorConfig) (*Generator, error) {
if config.CacheSize <= 0 {
return nil, fmt.Errorf("jdenticon: engine: cache initialization failed: invalid cache size: %d", config.CacheSize)
}
config.ColorConfig.Normalize()
// Resolve the effective maximum icon size
maxIconSize := config.MaxIconSize
if maxIconSize == 0 || (maxIconSize < 0 && maxIconSize != -1) {
maxIconSize = constants.DefaultMaxIconSize
}
// If maxIconSize is -1, keep it as -1 to disable the limit
// Create LRU cache with specified size
cache, err := lru.New[string, *Icon](config.CacheSize)
if err != nil {
return nil, fmt.Errorf("jdenticon: engine: cache initialization failed: %w", err)
}
return &Generator{
config: config,
cache: cache,
metrics: CacheMetrics{},
maxIconSize: maxIconSize,
}, nil
}
// NewDefaultGenerator creates a new Generator with default configuration
func NewDefaultGenerator() *Generator {
return NewGenerator(DefaultColorConfig())
func NewDefaultGenerator() (*Generator, error) {
generator, err := NewGeneratorWithConfig(DefaultGeneratorConfig())
if err != nil {
return nil, fmt.Errorf("jdenticon: engine: default generator creation failed: %w", err)
}
return generator, nil
}
// Generate creates an icon from a hash string using the configured settings
func (g *Generator) Generate(hash string, size float64) (*Icon, error) {
if hash == "" {
return nil, fmt.Errorf("hash cannot be empty")
}
if size <= 0 {
return nil, fmt.Errorf("size must be positive, got %f", size)
}
// Check cache first
cacheKey := g.cacheKey(hash, size)
g.mu.RLock()
if cached, exists := g.cache[cacheKey]; exists {
g.mu.RUnlock()
return cached, nil
}
g.mu.RUnlock()
// Validate hash format
if !util.IsValidHash(hash) {
return nil, fmt.Errorf("invalid hash format: %s", hash)
}
// Generate new icon
icon, err := g.generateIcon(hash, size)
if err != nil {
// generateIcon performs the actual icon generation with context support and complexity checking
func (g *Generator) generateIcon(ctx context.Context, hash string, size float64) (*Icon, error) {
// Check for cancellation before expensive operations
if err := ctx.Err(); err != nil {
return nil, err
}
// Cache the result
g.mu.Lock()
g.cache[cacheKey] = icon
g.mu.Unlock()
return icon, nil
}
// generateIcon performs the actual icon generation
func (g *Generator) generateIcon(hash string, size float64) (*Icon, error) {
// Complexity validation is now handled at the jdenticon package level
// to ensure proper structured error types are returned
// Calculate padding and round to nearest integer (matching JavaScript)
padding := int((0.5 + size*g.config.IconPadding))
iconSize := size - float64(padding*2)
padding := int((0.5 + size*g.config.ColorConfig.IconPadding))
iconSize := size - float64(padding*paddingMultiple)
// Calculate cell size and ensure it is an integer (matching JavaScript)
cell := int(iconSize / 4)
cell := int(iconSize / gridSize)
// Since the cell size is integer based, the actual icon will be slightly smaller than specified => center icon
x := int(float64(padding) + iconSize/2 - float64(cell*2))
y := int(float64(padding) + iconSize/2 - float64(cell*2))
x := int(float64(padding) + iconSize/2 - float64(cell*paddingMultiple))
y := int(float64(padding) + iconSize/2 - float64(cell*paddingMultiple))
// Extract hue from hash (last 7 characters)
hue, err := g.extractHue(hash)
if err != nil {
return nil, fmt.Errorf("generateIcon: %w", err)
return nil, fmt.Errorf("jdenticon: engine: icon generation failed: %w", err)
}
// Generate color theme
availableColors := GenerateColorTheme(hue, g.config)
availableColors := GenerateColorTheme(hue, g.config.ColorConfig)
// Select colors for each shape layer
selectedColorIndexes, err := g.selectColors(hash, availableColors)
if err != nil {
return nil, err
}
// Generate shape groups in exact JavaScript order
shapeGroups := make([]ShapeGroup, 0, 3)
shapeGroups := make([]ShapeGroup, 0, numColorSelections)
// Check for cancellation before rendering shapes
if err = ctx.Err(); err != nil {
return nil, err
}
// 1. Sides (outer edges) - renderShape(0, outerShape, 2, 3, [[1, 0], [2, 0], [2, 3], [1, 3], [0, 1], [3, 1], [3, 2], [0, 2]]);
sideShapes, err := g.renderShape(hash, 0, 2, 3,
var sideShapes []Shape
err = g.renderShape(ctx, hash, shapeColorIndexSides, hashPosSideShape, hashPosSideRotation,
[][]int{{1, 0}, {2, 0}, {2, 3}, {1, 3}, {0, 1}, {3, 1}, {3, 2}, {0, 2}},
x, y, cell, true)
x, y, cell, true, &sideShapes)
if err != nil {
return nil, fmt.Errorf("generateIcon: failed to render side shapes: %w", err)
return nil, fmt.Errorf("jdenticon: engine: icon generation failed: side shapes rendering failed: %w", err)
}
if len(sideShapes) > 0 {
shapeGroups = append(shapeGroups, ShapeGroup{
Color: availableColors[selectedColorIndexes[0]],
Color: availableColors[selectedColorIndexes[shapeColorIndexSides]],
Shapes: sideShapes,
ShapeType: "sides",
})
}
// 2. Corners - renderShape(1, outerShape, 4, 5, [[0, 0], [3, 0], [3, 3], [0, 3]]);
cornerShapes, err := g.renderShape(hash, 1, 4, 5,
var cornerShapes []Shape
err = g.renderShape(ctx, hash, shapeColorIndexCorners, hashPosCornerShape, hashPosCornerRotation,
[][]int{{0, 0}, {3, 0}, {3, 3}, {0, 3}},
x, y, cell, true)
x, y, cell, true, &cornerShapes)
if err != nil {
return nil, fmt.Errorf("generateIcon: failed to render corner shapes: %w", err)
return nil, fmt.Errorf("jdenticon: engine: icon generation failed: corner shapes rendering failed: %w", err)
}
if len(cornerShapes) > 0 {
shapeGroups = append(shapeGroups, ShapeGroup{
Color: availableColors[selectedColorIndexes[1]],
Color: availableColors[selectedColorIndexes[shapeColorIndexCorners]],
Shapes: cornerShapes,
ShapeType: "corners",
})
}
// 3. Center - renderShape(2, centerShape, 1, null, [[1, 1], [2, 1], [2, 2], [1, 2]]);
centerShapes, err := g.renderShape(hash, 2, 1, -1,
var centerShapes []Shape
err = g.renderShape(ctx, hash, shapeColorIndexCenter, hashPosCenterShape, hashPosCenterRotation,
[][]int{{1, 1}, {2, 1}, {2, 2}, {1, 2}},
x, y, cell, false)
x, y, cell, false, &centerShapes)
if err != nil {
return nil, fmt.Errorf("generateIcon: failed to render center shapes: %w", err)
return nil, fmt.Errorf("jdenticon: engine: icon generation failed: center shapes rendering failed: %w", err)
}
if len(centerShapes) > 0 {
shapeGroups = append(shapeGroups, ShapeGroup{
Color: availableColors[selectedColorIndexes[2]],
Color: availableColors[selectedColorIndexes[shapeColorIndexCenter]],
Shapes: centerShapes,
ShapeType: "center",
})
}
return &Icon{
Hash: hash,
Size: size,
Config: g.config,
Config: g.config.ColorConfig,
Shapes: shapeGroups,
}, nil
}
@@ -183,113 +266,138 @@ func (g *Generator) generateIcon(hash string, size float64) (*Icon, error) {
// extractHue extracts the hue value from the hash string
func (g *Generator) extractHue(hash string) (float64, error) {
// Use the last 7 characters of the hash to determine hue
hueValue, err := util.ParseHex(hash, -7, 7)
if err != nil {
return 0, fmt.Errorf("extractHue: %w", err)
if len(hash) < hashPosHueLength {
return 0, fmt.Errorf("jdenticon: engine: hue extraction failed: hash too short for hue extraction")
}
return float64(hueValue) / 0xfffffff, nil
hueStr := hash[len(hash)-hashPosHueLength:]
hueValue64, err := strconv.ParseInt(hueStr, 16, 64)
if err != nil {
return 0, fmt.Errorf("jdenticon: engine: hue extraction failed: failed to parse hue '%s': %w", hueStr, err)
}
hueValue := int(hueValue64)
return float64(hueValue) / hueMaxValue, nil
}
// selectColors selects 3 colors from the available color palette
func (g *Generator) selectColors(hash string, availableColors []Color) ([]int, error) {
if len(availableColors) == 0 {
return nil, fmt.Errorf("no available colors")
return nil, fmt.Errorf("jdenticon: engine: color selection failed: no available colors")
}
selectedIndexes := make([]int, 3)
for i := 0; i < 3; i++ {
indexValue, err := util.ParseHex(hash, 8+i, 1)
selectedIndexes := make([]int, numColorSelections)
for i := 0; i < numColorSelections; i++ {
indexValue, err := util.ParseHex(hash, hashPosColorStart+i, 1)
if err != nil {
return nil, fmt.Errorf("selectColors: failed to parse color index at position %d: %w", 8+i, err)
return nil, fmt.Errorf("jdenticon: engine: color selection failed: failed to parse color index at position %d: %w", hashPosColorStart+i, err)
}
// Defensive check: ensure availableColors is not empty before modulo operation
// This should never happen due to the check at the start of the function,
// but provides additional safety for future modifications
if len(availableColors) == 0 {
return nil, fmt.Errorf("jdenticon: engine: color selection failed: available colors became empty during selection")
}
index := indexValue % len(availableColors)
// Apply color conflict resolution rules from JavaScript implementation
if g.isDuplicateColor(index, selectedIndexes[:i], []int{0, 4}) || // Disallow dark gray and dark color combo
g.isDuplicateColor(index, selectedIndexes[:i], []int{2, 3}) { // Disallow light gray and light color combo
index = 1 // Use mid color as fallback
if g.isDuplicateColor(index, selectedIndexes[:i], []int{colorDarkGray, colorDarkMain}) || // Disallow dark gray and dark color combo
g.isDuplicateColor(index, selectedIndexes[:i], []int{colorLightGray, colorLightMain}) { // Disallow light gray and light color combo
index = colorMidFallback // Use mid color as fallback
}
selectedIndexes[i] = index
}
return selectedIndexes, nil
}
// contains checks if a slice contains a specific value
func contains(slice []int, value int) bool {
for _, item := range slice {
if item == value {
return true
}
}
return false
return selectedIndexes, nil
}
// isDuplicateColor checks for problematic color combinations
func (g *Generator) isDuplicateColor(index int, selected []int, forbidden []int) bool {
if !contains(forbidden, index) {
if !isColorInForbiddenSet(index, forbidden) {
return false
}
return hasSelectedColorInForbiddenSet(selected, forbidden)
}
// isColorInForbiddenSet checks if the given color index is in the forbidden set
func isColorInForbiddenSet(index int, forbidden []int) bool {
return util.ContainsInt(forbidden, index)
}
// hasSelectedColorInForbiddenSet checks if any selected color is in the forbidden set
func hasSelectedColorInForbiddenSet(selected []int, forbidden []int) bool {
for _, s := range selected {
if contains(forbidden, s) {
if util.ContainsInt(forbidden, s) {
return true
}
}
return false
}
// renderShape implements the JavaScript renderShape function exactly
func (g *Generator) renderShape(hash string, colorIndex, shapeHashIndex, rotationHashIndex int, positions [][]int, x, y, cell int, isOuter bool) ([]Shape, error) {
// renderShape implements the JavaScript renderShape function exactly with context support
// Shapes are appended directly to the provided destination slice to avoid intermediate allocations
func (g *Generator) renderShape(ctx context.Context, hash string, colorIndex, shapeHashIndex, rotationHashIndex int, positions [][]int, x, y, cell int, isOuter bool, dest *[]Shape) error { //nolint:unparam // colorIndex is passed for API consistency with JavaScript implementation
shapeIndexValue, err := util.ParseHex(hash, shapeHashIndex, 1)
if err != nil {
return nil, fmt.Errorf("renderShape: failed to parse shape index at position %d: %w", shapeHashIndex, err)
return fmt.Errorf("jdenticon: engine: shape rendering failed: failed to parse shape index at position %d: %w", shapeHashIndex, err)
}
shapeIndex := shapeIndexValue
var rotation int
if rotationHashIndex >= 0 {
rotationValue, err := util.ParseHex(hash, rotationHashIndex, 1)
if err != nil {
return nil, fmt.Errorf("renderShape: failed to parse rotation at position %d: %w", rotationHashIndex, err)
return fmt.Errorf("jdenticon: engine: shape rendering failed: failed to parse rotation at position %d: %w", rotationHashIndex, err)
}
rotation = rotationValue
}
shapes := make([]Shape, 0, len(positions))
for i, pos := range positions {
// Check for cancellation in the rendering loop
if err := ctx.Err(); err != nil {
return err
}
// Calculate transform exactly like JavaScript: new Transform(x + positions[i][0] * cell, y + positions[i][1] * cell, cell, r++ % 4)
transformX := float64(x + pos[0]*cell)
transformY := float64(y + pos[1]*cell)
var transformRotation int
if rotationHashIndex >= 0 {
transformRotation = (rotation + i) % 4
transformRotation = (rotation + i) % gridSize
} else {
// For center shapes (rotationIndex is null), r starts at 0 and increments
transformRotation = i % 4
transformRotation = i % gridSize
}
transform := NewTransform(transformX, transformY, float64(cell), transformRotation)
// Create shape using graphics with transform
graphics := NewGraphicsWithTransform(&shapeCollector{}, transform)
// Get a collector from the pool and reset it
collector := shapeCollectorPool.Get().(*shapeCollector)
collector.Reset()
// Create shape using graphics with pooled collector
graphics := NewGraphicsWithTransform(collector, transform)
if isOuter {
RenderOuterShape(graphics, shapeIndex, float64(cell))
} else {
RenderCenterShape(graphics, shapeIndex, float64(cell), float64(i))
}
collector := graphics.renderer.(*shapeCollector)
for _, shape := range collector.shapes {
shapes = append(shapes, shape)
}
// Append shapes directly to destination slice and return collector to pool
*dest = append(*dest, collector.shapes...)
shapeCollectorPool.Put(collector)
}
return shapes, nil
return nil
}
// shapeCollectorPool provides pooled shapeCollector instances for efficient reuse
var shapeCollectorPool = sync.Pool{
New: func() interface{} {
// Pre-allocate with reasonable capacity - typical identicon has 4-8 shapes per collector
return &shapeCollector{shapes: make([]Shape, 0, 8)}
},
}
// shapeCollector implements Renderer interface to collect shapes during generation
@@ -297,6 +405,12 @@ type shapeCollector struct {
shapes []Shape
}
// Reset clears the shape collector for reuse while preserving capacity
func (sc *shapeCollector) Reset() {
// Keep capacity but reset length to 0 for efficient reuse
sc.shapes = sc.shapes[:0]
}
func (sc *shapeCollector) AddPolygon(points []Point) {
sc.shapes = append(sc.shapes, Shape{
Type: "polygon",
@@ -315,39 +429,89 @@ func (sc *shapeCollector) AddCircle(topLeft Point, size float64, invert bool) {
})
}
// cacheKey generates a cache key for the given parameters
func (g *Generator) cacheKey(hash string, size float64) string {
return fmt.Sprintf("%s:%.2f", hash, size)
func getOuterShapeComplexity(shapeIndex int) int {
index := shapeIndex % 4
switch index {
case 0: // Triangle
return 3
case 1: // Triangle (different orientation)
return 3
case 2: // Rhombus (diamond)
return 4
case 3: // Circle
return 5 // Circles are more expensive to render
default:
return 1 // Fallback for unknown shapes
}
}
// ClearCache clears the internal cache
func (g *Generator) ClearCache() {
g.mu.Lock()
defer g.mu.Unlock()
g.cache = make(map[string]*Icon)
// getCenterShapeComplexity returns the complexity score for a center shape type.
// Scoring accounts for multiple geometric elements and cutouts.
func getCenterShapeComplexity(shapeIndex int) int {
index := shapeIndex % 14
switch index {
case 0: // Asymmetric polygon (5 points)
return 5
case 1: // Triangle
return 3
case 2: // Rectangle
return 4
case 3: // Nested rectangles (2 rectangles)
return 8
case 4: // Circle
return 5
case 5: // Rectangle with triangular cutout (rect + inverted triangle)
return 7
case 6: // Complex polygon (6 points)
return 6
case 7: // Small triangle
return 3
case 8: // Composite shape (2 rectangles + 1 triangle)
return 11
case 9: // Rectangle with rectangular cutout (rect + inverted rect)
return 8
case 10: // Rectangle with circular cutout (rect + inverted circle)
return 9
case 11: // Small triangle (same as 7)
return 3
case 12: // Rectangle with rhombus cutout (rect + inverted rhombus)
return 8
case 13: // Large circle (conditional rendering)
return 5
default:
return 1 // Fallback for unknown shapes
}
}
// GetCacheSize returns the number of cached icons
func (g *Generator) GetCacheSize() int {
g.mu.RLock()
defer g.mu.RUnlock()
return len(g.cache)
}
// CalculateComplexity calculates the total geometric complexity for an identicon
// based on the hash string. This provides a fast complexity assessment before
// any expensive rendering operations.
func (g *Generator) CalculateComplexity(hash string) (int, error) {
totalComplexity := 0
// SetConfig updates the generator configuration and clears cache
func (g *Generator) SetConfig(config ColorConfig) {
config.Validate()
g.mu.Lock()
g.config = config
g.cache = make(map[string]*Icon)
g.mu.Unlock()
}
// Calculate complexity for side shapes (8 positions)
sideShapeIndexValue, err := util.ParseHex(hash, hashPosSideShape, 1)
if err != nil {
return 0, fmt.Errorf("failed to parse side shape index: %w", err)
}
sideShapeComplexity := getOuterShapeComplexity(sideShapeIndexValue)
totalComplexity += sideShapeComplexity * 8 // 8 side positions
// GetConfig returns a copy of the current configuration
func (g *Generator) GetConfig() ColorConfig {
g.mu.RLock()
defer g.mu.RUnlock()
return g.config
}
// Calculate complexity for corner shapes (4 positions)
cornerShapeIndexValue, err := util.ParseHex(hash, hashPosCornerShape, 1)
if err != nil {
return 0, fmt.Errorf("failed to parse corner shape index: %w", err)
}
cornerShapeComplexity := getOuterShapeComplexity(cornerShapeIndexValue)
totalComplexity += cornerShapeComplexity * 4 // 4 corner positions
// Calculate complexity for center shapes (4 positions)
centerShapeIndexValue, err := util.ParseHex(hash, hashPosCenterShape, 1)
if err != nil {
return 0, fmt.Errorf("failed to parse center shape index: %w", err)
}
centerShapeComplexity := getCenterShapeComplexity(centerShapeIndexValue)
totalComplexity += centerShapeComplexity * 4 // 4 center positions
return totalComplexity, nil
}