package engine import ( "context" "fmt" "strconv" "sync" 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 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 }