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
Move hosting from GitHub to private Gitea instance.
518 lines
18 KiB
Go
518 lines
18 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"sync"
|
|
|
|
lru "github.com/hashicorp/golang-lru/v2"
|
|
"gitea.dockr.co/kev/go-jdenticon/internal/constants"
|
|
"gitea.dockr.co/kev/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
|
|
type Icon struct {
|
|
Hash string
|
|
Size float64
|
|
Config ColorConfig
|
|
Shapes []ShapeGroup
|
|
}
|
|
|
|
// ShapeGroup represents a group of shapes with the same color
|
|
type ShapeGroup struct {
|
|
Color Color
|
|
Shapes []Shape
|
|
ShapeType string
|
|
}
|
|
|
|
// Shape represents a single geometric shape. It acts as a discriminated union
|
|
// where the `Type` field determines which other fields are valid.
|
|
// - For "polygon", `Points` is used.
|
|
// - For "circle", `CircleX`, `CircleY`, and `CircleSize` are used.
|
|
type Shape struct {
|
|
Type string
|
|
Points []Point
|
|
Transform Transform
|
|
Invert bool
|
|
// Circle-specific fields
|
|
CircleX float64
|
|
CircleY float64
|
|
CircleSize float64
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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, error) {
|
|
generator, err := NewGeneratorWithConfig(DefaultGeneratorConfig())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("jdenticon: engine: default generator creation failed: %w", err)
|
|
}
|
|
return generator, 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
|
|
}
|
|
|
|
// 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.ColorConfig.IconPadding))
|
|
iconSize := size - float64(padding*paddingMultiple)
|
|
|
|
// Calculate cell size and ensure it is an integer (matching JavaScript)
|
|
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*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("jdenticon: engine: icon generation failed: %w", err)
|
|
}
|
|
|
|
// Generate color theme
|
|
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, 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]]);
|
|
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, &sideShapes)
|
|
if err != nil {
|
|
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[shapeColorIndexSides]],
|
|
Shapes: sideShapes,
|
|
ShapeType: "sides",
|
|
})
|
|
}
|
|
|
|
// 2. Corners - renderShape(1, outerShape, 4, 5, [[0, 0], [3, 0], [3, 3], [0, 3]]);
|
|
var cornerShapes []Shape
|
|
err = g.renderShape(ctx, hash, shapeColorIndexCorners, hashPosCornerShape, hashPosCornerRotation,
|
|
[][]int{{0, 0}, {3, 0}, {3, 3}, {0, 3}},
|
|
x, y, cell, true, &cornerShapes)
|
|
if err != nil {
|
|
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[shapeColorIndexCorners]],
|
|
Shapes: cornerShapes,
|
|
ShapeType: "corners",
|
|
})
|
|
}
|
|
|
|
// 3. Center - renderShape(2, centerShape, 1, null, [[1, 1], [2, 1], [2, 2], [1, 2]]);
|
|
var centerShapes []Shape
|
|
err = g.renderShape(ctx, hash, shapeColorIndexCenter, hashPosCenterShape, hashPosCenterRotation,
|
|
[][]int{{1, 1}, {2, 1}, {2, 2}, {1, 2}},
|
|
x, y, cell, false, ¢erShapes)
|
|
if err != nil {
|
|
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[shapeColorIndexCenter]],
|
|
Shapes: centerShapes,
|
|
ShapeType: "center",
|
|
})
|
|
}
|
|
|
|
return &Icon{
|
|
Hash: hash,
|
|
Size: size,
|
|
Config: g.config.ColorConfig,
|
|
Shapes: shapeGroups,
|
|
}, nil
|
|
}
|
|
|
|
// 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
|
|
if len(hash) < hashPosHueLength {
|
|
return 0, fmt.Errorf("jdenticon: engine: hue extraction failed: hash too short for hue extraction")
|
|
}
|
|
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("jdenticon: engine: color selection failed: no available colors")
|
|
}
|
|
|
|
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("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{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
|
|
}
|
|
|
|
// isDuplicateColor checks for problematic color combinations
|
|
func (g *Generator) isDuplicateColor(index int, selected []int, forbidden []int) bool {
|
|
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 util.ContainsInt(forbidden, s) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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 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 fmt.Errorf("jdenticon: engine: shape rendering failed: failed to parse rotation at position %d: %w", rotationHashIndex, err)
|
|
}
|
|
rotation = rotationValue
|
|
}
|
|
|
|
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) % gridSize
|
|
} else {
|
|
// For center shapes (rotationIndex is null), r starts at 0 and increments
|
|
transformRotation = i % gridSize
|
|
}
|
|
|
|
transform := NewTransform(transformX, transformY, float64(cell), transformRotation)
|
|
|
|
// 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))
|
|
}
|
|
|
|
// Append shapes directly to destination slice and return collector to pool
|
|
*dest = append(*dest, collector.shapes...)
|
|
shapeCollectorPool.Put(collector)
|
|
}
|
|
|
|
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
|
|
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",
|
|
Points: points,
|
|
})
|
|
}
|
|
|
|
func (sc *shapeCollector) AddCircle(topLeft Point, size float64, invert bool) {
|
|
// Store circle with dedicated circle geometry fields
|
|
sc.shapes = append(sc.shapes, Shape{
|
|
Type: "circle",
|
|
CircleX: topLeft.X,
|
|
CircleY: topLeft.Y,
|
|
CircleSize: size,
|
|
Invert: invert,
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
// 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
|
|
|
|
// 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
|
|
}
|