This commit is contained in:
Kevin McIntyre
2025-06-18 01:00:00 -04:00
commit f84b511895
228 changed files with 42509 additions and 0 deletions

235
jdenticon/config.go Normal file
View File

@@ -0,0 +1,235 @@
package jdenticon
import (
"fmt"
"regexp"
)
// Config holds configuration options for identicon generation.
type Config struct {
// HueRestrictions specifies the hues (in degrees) that are allowed for the identicon.
// If empty, any hue can be used. Values should be between 0 and 360.
HueRestrictions []float64
// ColorLightnessRange specifies the lightness range for colored shapes [min, max].
// Values should be between 0.0 and 1.0. Default: [0.4, 0.8]
ColorLightnessRange [2]float64
// GrayscaleLightnessRange specifies the lightness range for grayscale shapes [min, max].
// Values should be between 0.0 and 1.0. Default: [0.3, 0.9]
GrayscaleLightnessRange [2]float64
// ColorSaturation controls the saturation of colored shapes.
// Values should be between 0.0 and 1.0. Default: 0.5
ColorSaturation float64
// GrayscaleSaturation controls the saturation of grayscale shapes.
// Values should be between 0.0 and 1.0. Default: 0.0
GrayscaleSaturation float64
// BackgroundColor sets the background color for the identicon.
// Accepts hex colors like "#fff", "#ffffff", "#ffffff80" (with alpha).
// If empty, the background will be transparent.
BackgroundColor string
// Padding controls the padding around the identicon as a percentage of the size.
// Values should be between 0.0 and 0.5. Default: 0.08
Padding float64
}
// DefaultConfig returns a default configuration that matches jdenticon-js defaults.
func DefaultConfig() Config {
return Config{
HueRestrictions: nil, // No restrictions
ColorLightnessRange: [2]float64{0.4, 0.8},
GrayscaleLightnessRange: [2]float64{0.3, 0.9},
ColorSaturation: 0.5,
GrayscaleSaturation: 0.0,
BackgroundColor: "", // Transparent
Padding: 0.08,
}
}
// ConfigOption represents a configuration option function.
type ConfigOption func(*Config) error
// WithHueRestrictions sets the allowed hues in degrees (0-360).
func WithHueRestrictions(hues []float64) ConfigOption {
return func(c *Config) error {
for _, hue := range hues {
if hue < 0 || hue >= 360 {
return fmt.Errorf("hue must be between 0 and 360, got %f", hue)
}
}
c.HueRestrictions = make([]float64, len(hues))
copy(c.HueRestrictions, hues)
return nil
}
}
// WithColorLightnessRange sets the lightness range for colored shapes.
func WithColorLightnessRange(min, max float64) ConfigOption {
return func(c *Config) error {
if min < 0 || min > 1 || max < 0 || max > 1 {
return fmt.Errorf("lightness values must be between 0 and 1, got min=%f max=%f", min, max)
}
if min > max {
return fmt.Errorf("minimum lightness cannot be greater than maximum, got min=%f max=%f", min, max)
}
c.ColorLightnessRange = [2]float64{min, max}
return nil
}
}
// WithGrayscaleLightnessRange sets the lightness range for grayscale shapes.
func WithGrayscaleLightnessRange(min, max float64) ConfigOption {
return func(c *Config) error {
if min < 0 || min > 1 || max < 0 || max > 1 {
return fmt.Errorf("lightness values must be between 0 and 1, got min=%f max=%f", min, max)
}
if min > max {
return fmt.Errorf("minimum lightness cannot be greater than maximum, got min=%f max=%f", min, max)
}
c.GrayscaleLightnessRange = [2]float64{min, max}
return nil
}
}
// WithColorSaturation sets the saturation for colored shapes.
func WithColorSaturation(saturation float64) ConfigOption {
return func(c *Config) error {
if saturation < 0 || saturation > 1 {
return fmt.Errorf("saturation must be between 0 and 1, got %f", saturation)
}
c.ColorSaturation = saturation
return nil
}
}
// WithGrayscaleSaturation sets the saturation for grayscale shapes.
func WithGrayscaleSaturation(saturation float64) ConfigOption {
return func(c *Config) error {
if saturation < 0 || saturation > 1 {
return fmt.Errorf("saturation must be between 0 and 1, got %f", saturation)
}
c.GrayscaleSaturation = saturation
return nil
}
}
// WithBackgroundColor sets the background color.
func WithBackgroundColor(color string) ConfigOption {
return func(c *Config) error {
if color != "" {
if err := validateHexColor(color); err != nil {
return fmt.Errorf("invalid background color: %w", err)
}
}
c.BackgroundColor = color
return nil
}
}
// WithPadding sets the padding around the identicon.
func WithPadding(padding float64) ConfigOption {
return func(c *Config) error {
if padding < 0 || padding > 0.5 {
return fmt.Errorf("padding must be between 0 and 0.5, got %f", padding)
}
c.Padding = padding
return nil
}
}
// Configure creates a new configuration with the given options.
func Configure(options ...ConfigOption) (Config, error) {
config := DefaultConfig()
for _, option := range options {
if err := option(&config); err != nil {
return Config{}, err
}
}
return config, nil
}
// validateHexColor validates that a color string is a valid hex color.
func validateHexColor(color string) error {
hexColorPattern := regexp.MustCompile(`^#[0-9a-fA-F]{3,8}$`)
if !hexColorPattern.MatchString(color) {
return fmt.Errorf("color must be a hex color like #fff, #ffffff, or #ffffff80")
}
// Validate length - must be 3, 4, 6, or 8 characters after #
length := len(color) - 1
if length != 3 && length != 4 && length != 6 && length != 8 {
return fmt.Errorf("hex color must be 3, 4, 6, or 8 characters after #")
}
return nil
}
// ParseColor normalizes a hex color string to the full #RRGGBB or #RRGGBBAA format.
func ParseColor(color string) (string, error) {
if color == "" {
return "", nil
}
if err := validateHexColor(color); err != nil {
return "", err
}
// Normalize short forms to full forms
switch len(color) {
case 4: // #RGB -> #RRGGBB
r, g, b := color[1], color[2], color[3]
return fmt.Sprintf("#%c%c%c%c%c%c", r, r, g, g, b, b), nil
case 5: // #RGBA -> #RRGGBBAA
r, g, b, a := color[1], color[2], color[3], color[4]
return fmt.Sprintf("#%c%c%c%c%c%c%c%c", r, r, g, g, b, b, a, a), nil
case 7, 9: // #RRGGBB or #RRGGBBAA - already normalized
return color, nil
default:
return "", fmt.Errorf("unsupported color format")
}
}
// GetHue returns the hue for the given original hue value, taking into account hue restrictions.
func (c *Config) GetHue(originalHue float64) float64 {
if len(c.HueRestrictions) == 0 {
return originalHue
}
// Map originalHue [0,1] to one of the restricted hues
index := int(originalHue * 0.999 * float64(len(c.HueRestrictions)))
if index >= len(c.HueRestrictions) {
index = len(c.HueRestrictions) - 1
}
hue := c.HueRestrictions[index]
// Convert degrees to [0,1] range and normalize
return ((hue/360.0)+1.0) - float64(int((hue/360.0)+1.0))
}
// GetColorLightness returns a lightness value within the color lightness range.
func (c *Config) GetColorLightness(value float64) float64 {
return c.getLightness(value, c.ColorLightnessRange)
}
// GetGrayscaleLightness returns a lightness value within the grayscale lightness range.
func (c *Config) GetGrayscaleLightness(value float64) float64 {
return c.getLightness(value, c.GrayscaleLightnessRange)
}
// getLightness maps a value [0,1] to the specified lightness range.
func (c *Config) getLightness(value float64, lightnessRange [2]float64) float64 {
result := lightnessRange[0] + value*(lightnessRange[1]-lightnessRange[0])
if result < 0 {
return 0
}
if result > 1 {
return 1
}
return result
}

437
jdenticon/config_test.go Normal file
View File

@@ -0,0 +1,437 @@
package jdenticon
import (
"testing"
)
func TestDefaultConfig(t *testing.T) {
config := DefaultConfig()
// Test default values match jdenticon-js
if len(config.HueRestrictions) != 0 {
t.Errorf("Expected no hue restrictions, got %v", config.HueRestrictions)
}
expectedColorRange := [2]float64{0.4, 0.8}
if config.ColorLightnessRange != expectedColorRange {
t.Errorf("Expected color lightness range %v, got %v", expectedColorRange, config.ColorLightnessRange)
}
expectedGrayscaleRange := [2]float64{0.3, 0.9}
if config.GrayscaleLightnessRange != expectedGrayscaleRange {
t.Errorf("Expected grayscale lightness range %v, got %v", expectedGrayscaleRange, config.GrayscaleLightnessRange)
}
if config.ColorSaturation != 0.5 {
t.Errorf("Expected color saturation 0.5, got %f", config.ColorSaturation)
}
if config.GrayscaleSaturation != 0.0 {
t.Errorf("Expected grayscale saturation 0.0, got %f", config.GrayscaleSaturation)
}
if config.BackgroundColor != "" {
t.Errorf("Expected empty background color, got %s", config.BackgroundColor)
}
if config.Padding != 0.08 {
t.Errorf("Expected padding 0.08, got %f", config.Padding)
}
}
func TestWithHueRestrictions(t *testing.T) {
tests := []struct {
name string
hues []float64
wantErr bool
}{
{"valid hues", []float64{0, 90, 180, 270}, false},
{"single hue", []float64{120}, false},
{"empty slice", []float64{}, false},
{"negative hue", []float64{-10}, true},
{"hue too large", []float64{360}, true},
{"mixed valid/invalid", []float64{120, 400}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config, err := Configure(WithHueRestrictions(tt.hues))
if tt.wantErr {
if err == nil {
t.Errorf("Expected error for hues %v, got none", tt.hues)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if len(config.HueRestrictions) != len(tt.hues) {
t.Errorf("Expected %d hue restrictions, got %d", len(tt.hues), len(config.HueRestrictions))
}
for i, hue := range tt.hues {
if config.HueRestrictions[i] != hue {
t.Errorf("Expected hue %f at index %d, got %f", hue, i, config.HueRestrictions[i])
}
}
})
}
}
func TestWithLightnessRanges(t *testing.T) {
tests := []struct {
name string
min float64
max float64
wantErr bool
}{
{"valid range", 0.2, 0.8, false},
{"full range", 0.0, 1.0, false},
{"equal values", 0.5, 0.5, false},
{"min > max", 0.8, 0.2, true},
{"negative min", -0.1, 0.5, true},
{"max > 1", 0.5, 1.1, true},
{"both invalid", -0.1, 1.1, true},
}
for _, tt := range tests {
t.Run("color_"+tt.name, func(t *testing.T) {
config, err := Configure(WithColorLightnessRange(tt.min, tt.max))
if tt.wantErr {
if err == nil {
t.Errorf("Expected error for range [%f, %f], got none", tt.min, tt.max)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
expected := [2]float64{tt.min, tt.max}
if config.ColorLightnessRange != expected {
t.Errorf("Expected range %v, got %v", expected, config.ColorLightnessRange)
}
})
t.Run("grayscale_"+tt.name, func(t *testing.T) {
config, err := Configure(WithGrayscaleLightnessRange(tt.min, tt.max))
if tt.wantErr {
if err == nil {
t.Errorf("Expected error for range [%f, %f], got none", tt.min, tt.max)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
expected := [2]float64{tt.min, tt.max}
if config.GrayscaleLightnessRange != expected {
t.Errorf("Expected range %v, got %v", expected, config.GrayscaleLightnessRange)
}
})
}
}
func TestWithSaturation(t *testing.T) {
tests := []struct {
name string
saturation float64
wantErr bool
}{
{"valid saturation", 0.5, false},
{"zero saturation", 0.0, false},
{"max saturation", 1.0, false},
{"negative saturation", -0.1, true},
{"saturation > 1", 1.1, true},
}
for _, tt := range tests {
t.Run("color_"+tt.name, func(t *testing.T) {
config, err := Configure(WithColorSaturation(tt.saturation))
if tt.wantErr {
if err == nil {
t.Errorf("Expected error for saturation %f, got none", tt.saturation)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if config.ColorSaturation != tt.saturation {
t.Errorf("Expected saturation %f, got %f", tt.saturation, config.ColorSaturation)
}
})
t.Run("grayscale_"+tt.name, func(t *testing.T) {
config, err := Configure(WithGrayscaleSaturation(tt.saturation))
if tt.wantErr {
if err == nil {
t.Errorf("Expected error for saturation %f, got none", tt.saturation)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if config.GrayscaleSaturation != tt.saturation {
t.Errorf("Expected saturation %f, got %f", tt.saturation, config.GrayscaleSaturation)
}
})
}
}
func TestWithBackgroundColor(t *testing.T) {
tests := []struct {
name string
color string
wantErr bool
expected string
}{
{"empty color", "", false, ""},
{"3-char hex", "#fff", false, "#fff"},
{"4-char hex with alpha", "#ffff", false, "#ffff"},
{"6-char hex", "#ffffff", false, "#ffffff"},
{"8-char hex with alpha", "#ffffff80", false, "#ffffff80"},
{"lowercase", "#abc123", false, "#abc123"},
{"uppercase", "#ABC123", false, "#ABC123"},
{"invalid format", "ffffff", true, ""},
{"invalid chars", "#gggggg", true, ""},
{"too short", "#ff", true, ""},
{"5-char hex", "#fffff", true, ""},
{"7-char hex", "#fffffff", true, ""},
{"9-char hex", "#fffffffff", true, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config, err := Configure(WithBackgroundColor(tt.color))
if tt.wantErr {
if err == nil {
t.Errorf("Expected error for color %s, got none", tt.color)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if config.BackgroundColor != tt.expected {
t.Errorf("Expected color %s, got %s", tt.expected, config.BackgroundColor)
}
})
}
}
func TestWithPadding(t *testing.T) {
tests := []struct {
name string
padding float64
wantErr bool
}{
{"valid padding", 0.08, false},
{"zero padding", 0.0, false},
{"max padding", 0.5, false},
{"negative padding", -0.1, true},
{"padding > 0.5", 0.6, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config, err := Configure(WithPadding(tt.padding))
if tt.wantErr {
if err == nil {
t.Errorf("Expected error for padding %f, got none", tt.padding)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if config.Padding != tt.padding {
t.Errorf("Expected padding %f, got %f", tt.padding, config.Padding)
}
})
}
}
func TestConfigureMultipleOptions(t *testing.T) {
config, err := Configure(
WithHueRestrictions([]float64{120, 240}),
WithColorSaturation(0.7),
WithPadding(0.1),
WithBackgroundColor("#ff0000"),
)
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if len(config.HueRestrictions) != 2 || config.HueRestrictions[0] != 120 || config.HueRestrictions[1] != 240 {
t.Errorf("Expected hue restrictions [120, 240], got %v", config.HueRestrictions)
}
if config.ColorSaturation != 0.7 {
t.Errorf("Expected color saturation 0.7, got %f", config.ColorSaturation)
}
if config.Padding != 0.1 {
t.Errorf("Expected padding 0.1, got %f", config.Padding)
}
if config.BackgroundColor != "#ff0000" {
t.Errorf("Expected background color #ff0000, got %s", config.BackgroundColor)
}
// Check that other values are still default
expectedColorRange := [2]float64{0.4, 0.8}
if config.ColorLightnessRange != expectedColorRange {
t.Errorf("Expected default color lightness range %v, got %v", expectedColorRange, config.ColorLightnessRange)
}
}
func TestParseColor(t *testing.T) {
tests := []struct {
name string
input string
expected string
wantErr bool
}{
{"empty", "", "", false},
{"3-char to 6-char", "#abc", "#aabbcc", false},
{"4-char to 8-char", "#abcd", "#aabbccdd", false},
{"6-char unchanged", "#abcdef", "#abcdef", false},
{"8-char unchanged", "#abcdef80", "#abcdef80", false},
{"invalid format", "abcdef", "", true},
{"invalid length", "#abcde", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseColor(tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("Expected error for input %s, got none", tt.input)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if result != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result)
}
})
}
}
func TestConfigGetHue(t *testing.T) {
tests := []struct {
name string
restrictions []float64
input float64
expected float64
}{
{"no restrictions", nil, 0.5, 0.5},
{"single restriction", []float64{180}, 0.5, 0.5},
{"multiple restrictions", []float64{0, 120, 240}, 0.0, 0.0},
{"multiple restrictions mid", []float64{0, 120, 240}, 0.5, 120.0 / 360.0},
{"multiple restrictions high", []float64{0, 120, 240}, 0.99, 240.0 / 360.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := DefaultConfig()
config.HueRestrictions = tt.restrictions
result := config.GetHue(tt.input)
// Allow small floating point differences
if abs(result-tt.expected) > 0.001 {
t.Errorf("Expected hue %f, got %f", tt.expected, result)
}
})
}
}
func TestConfigGetLightness(t *testing.T) {
config := DefaultConfig()
// Test color lightness
colorLight := config.GetColorLightness(0.0)
if colorLight != 0.4 {
t.Errorf("Expected min color lightness 0.4, got %f", colorLight)
}
colorLight = config.GetColorLightness(1.0)
if colorLight != 0.8 {
t.Errorf("Expected max color lightness 0.8, got %f", colorLight)
}
colorLight = config.GetColorLightness(0.5)
expected := 0.4 + 0.5*(0.8-0.4)
if abs(colorLight-expected) > 0.001 {
t.Errorf("Expected mid color lightness %f, got %f", expected, colorLight)
}
// Test grayscale lightness
grayLight := config.GetGrayscaleLightness(0.0)
if grayLight != 0.3 {
t.Errorf("Expected min grayscale lightness 0.3, got %f", grayLight)
}
grayLight = config.GetGrayscaleLightness(1.0)
if abs(grayLight-0.9) > 0.001 {
t.Errorf("Expected max grayscale lightness 0.9, got %f", grayLight)
}
}
func TestConfigureFailing(t *testing.T) {
// Test that Configure fails on invalid options
_, err := Configure(
WithHueRestrictions([]float64{400}), // Invalid hue
WithColorSaturation(0.5), // Valid option after invalid
)
if err == nil {
t.Error("Expected Configure to fail with invalid hue restriction")
}
}
// Helper function for floating point comparison
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}

14
jdenticon/doc.go Normal file
View File

@@ -0,0 +1,14 @@
// Package jdenticon provides highly recognizable identicon generation.
//
// This package is a Go port of the JavaScript library Jdenticon,
// offering the same visual quality and recognizability in Go applications.
//
// Basic usage:
//
// icon := jdenticon.Generate("user@example.com", 200)
// svg := icon.ToSVG()
// png := icon.ToPNG()
//
// The library supports both SVG and PNG output formats, with configurable
// styling options including color themes, saturation, and brightness.
package jdenticon

344
jdenticon/generate.go Normal file
View File

@@ -0,0 +1,344 @@
package jdenticon
import (
"fmt"
"reflect"
"strconv"
"github.com/kevin/go-jdenticon/internal/engine"
"github.com/kevin/go-jdenticon/internal/renderer"
)
// iconRenderer defines the common interface for rendering identicons to different formats
type iconRenderer interface {
SetBackground(fillColor string, opacity float64)
BeginShape(color string)
AddPolygon(points []engine.Point)
AddCircle(topLeft engine.Point, size float64, invert bool)
EndShape()
}
// Icon represents a generated identicon that can be rendered in various formats.
type Icon struct {
icon *engine.Icon
}
// renderTo renders the icon to the given renderer, handling all common rendering logic
func (i *Icon) renderTo(r iconRenderer) {
if i.icon == nil {
return
}
// Set background color if configured
if i.icon.Config.BackColor != nil {
r.SetBackground(i.icon.Config.BackColor.String(), 1.0)
}
// Render each shape group
for _, group := range i.icon.Shapes {
r.BeginShape(group.Color.String())
for _, shape := range group.Shapes {
// Skip empty shapes
if shape.Type == "empty" {
continue
}
switch shape.Type {
case "polygon":
// Transform points
transformedPoints := make([]engine.Point, len(shape.Points))
for j, point := range shape.Points {
transformedPoints[j] = shape.Transform.TransformIconPoint(point.X, point.Y, 0, 0)
}
r.AddPolygon(transformedPoints)
case "circle":
// Use dedicated circle fields - CircleX, CircleY represent top-left corner
topLeft := shape.Transform.TransformIconPoint(shape.CircleX, shape.CircleY, 0, 0)
r.AddCircle(topLeft, shape.CircleSize, shape.Invert)
}
}
r.EndShape()
}
}
// Generate creates an identicon for the given input value and size.
// The input value is typically an email address, username, or any string
// that should produce a consistent visual representation.
func Generate(value string, size int) (*Icon, error) {
// Compute hash from the input value
hash := ComputeHash(value)
// Create generator with default configuration
generator := engine.NewDefaultGenerator()
// Generate the icon
engineIcon, err := generator.Generate(hash, float64(size))
if err != nil {
return nil, err
}
return &Icon{icon: engineIcon}, nil
}
// ToSVG renders the icon as an SVG string.
func (i *Icon) ToSVG() (string, error) {
if i.icon == nil {
return "", nil
}
svgRenderer := renderer.NewSVGRenderer(int(i.icon.Size))
i.renderTo(svgRenderer)
return svgRenderer.ToSVG(), nil
}
// ToPNG renders the icon as PNG image data.
func (i *Icon) ToPNG() ([]byte, error) {
if i.icon == nil {
return nil, nil
}
pngRenderer := renderer.NewPNGRenderer(int(i.icon.Size))
i.renderTo(pngRenderer)
return pngRenderer.ToPNG(), nil
}
// ToSVG generates an identicon as an SVG string for the given input value.
// The value can be any type - it will be converted to a string and hashed.
// Size specifies the icon size in pixels.
// Optional config parameters can be provided to customize the appearance.
func ToSVG(value interface{}, size int, config ...Config) (string, error) {
if size <= 0 {
return "", fmt.Errorf("size must be positive, got %d", size)
}
// Generate icon with the provided configuration
icon, err := generateWithConfig(value, size, config...)
if err != nil {
return "", fmt.Errorf("failed to generate icon: %w", err)
}
// Render as SVG
svg, err := icon.ToSVG()
if err != nil {
return "", fmt.Errorf("failed to render SVG: %w", err)
}
return svg, nil
}
// ToPNG generates an identicon as PNG image data for the given input value.
// The value can be any type - it will be converted to a string and hashed.
// Size specifies the icon size in pixels.
// Optional config parameters can be provided to customize the appearance.
func ToPNG(value interface{}, size int, config ...Config) ([]byte, error) {
if size <= 0 {
return nil, fmt.Errorf("size must be positive, got %d", size)
}
// Generate icon with the provided configuration
icon, err := generateWithConfig(value, size, config...)
if err != nil {
return nil, fmt.Errorf("failed to generate icon: %w", err)
}
// Render as PNG
png, err := icon.ToPNG()
if err != nil {
return nil, fmt.Errorf("failed to render PNG: %w", err)
}
return png, nil
}
// ToHash generates a hash string for the given input value.
// This is a convenience function that wraps ComputeHash with better type handling.
// The hash can be used with other functions or stored for consistent icon generation.
func ToHash(value interface{}) string {
return ComputeHash(value)
}
// generateWithConfig is a helper function that creates an icon with optional configuration.
func generateWithConfig(value interface{}, size int, configs ...Config) (*Icon, error) {
// Convert value to string representation
stringValue := convertToString(value)
// Compute hash from the input value
hash := ComputeHash(stringValue)
// Create generator with configuration
var generator *engine.Generator
if len(configs) > 0 {
// Use the provided configuration
engineConfig := convertToEngineConfig(configs[0])
generator = engine.NewGenerator(engineConfig)
} else {
// Use default configuration
generator = engine.NewDefaultGenerator()
}
// Generate the icon
engineIcon, err := generator.Generate(hash, float64(size))
if err != nil {
return nil, err
}
return &Icon{icon: engineIcon}, nil
}
// convertToString converts any value to its string representation using reflection.
// This handles various types similar to how JavaScript would convert them.
func convertToString(value interface{}) string {
if value == nil {
return ""
}
// Use reflection to handle different types
v := reflect.ValueOf(value)
switch v.Kind() {
case reflect.String:
return v.String()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return fmt.Sprintf("%d", v.Int())
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return fmt.Sprintf("%d", v.Uint())
case reflect.Float32, reflect.Float64:
return fmt.Sprintf("%g", v.Float())
case reflect.Bool:
if v.Bool() {
return "true"
}
return "false"
case reflect.Slice:
// Handle byte slices specially
if v.Type().Elem().Kind() == reflect.Uint8 {
return string(v.Bytes())
}
fallthrough
default:
// For all other types, use fmt.Sprintf
return fmt.Sprintf("%v", value)
}
}
// convertToEngineConfig converts a public Config to an internal engine.ColorConfig.
func convertToEngineConfig(config Config) engine.ColorConfig {
engineConfig := engine.DefaultColorConfig()
// Convert hue restrictions
if len(config.HueRestrictions) > 0 {
engineConfig.Hues = make([]float64, len(config.HueRestrictions))
copy(engineConfig.Hues, config.HueRestrictions)
}
// Convert lightness ranges
engineConfig.ColorLightness = engine.LightnessRange{
Min: config.ColorLightnessRange[0],
Max: config.ColorLightnessRange[1],
}
engineConfig.GrayscaleLightness = engine.LightnessRange{
Min: config.GrayscaleLightnessRange[0],
Max: config.GrayscaleLightnessRange[1],
}
// Convert saturation values
engineConfig.ColorSaturation = config.ColorSaturation
engineConfig.GrayscaleSaturation = config.GrayscaleSaturation
// Convert background color
if config.BackgroundColor != "" {
normalizedColor, err := ParseColor(config.BackgroundColor)
if err == nil {
// Parse the normalized hex color into an engine.Color
color, parseErr := parseHexToEngineColor(normalizedColor)
if parseErr == nil {
engineConfig.BackColor = &color
}
}
}
// Convert padding
engineConfig.IconPadding = config.Padding
return engineConfig
}
// parseHexToEngineColor converts a hex color string to an engine.Color.
func parseHexToEngineColor(hexColor string) (engine.Color, error) {
if hexColor == "" {
return engine.Color{}, fmt.Errorf("empty color string")
}
// Remove # if present
if len(hexColor) > 0 && hexColor[0] == '#' {
hexColor = hexColor[1:]
}
var r, g, b, a uint8 = 0, 0, 0, 255
switch len(hexColor) {
case 6: // RRGGBB
rgb, err := parseHexComponent(hexColor[0:2])
if err != nil {
return engine.Color{}, err
}
r = rgb
rgb, err = parseHexComponent(hexColor[2:4])
if err != nil {
return engine.Color{}, err
}
g = rgb
rgb, err = parseHexComponent(hexColor[4:6])
if err != nil {
return engine.Color{}, err
}
b = rgb
case 8: // RRGGBBAA
rgb, err := parseHexComponent(hexColor[0:2])
if err != nil {
return engine.Color{}, err
}
r = rgb
rgb, err = parseHexComponent(hexColor[2:4])
if err != nil {
return engine.Color{}, err
}
g = rgb
rgb, err = parseHexComponent(hexColor[4:6])
if err != nil {
return engine.Color{}, err
}
b = rgb
rgb, err = parseHexComponent(hexColor[6:8])
if err != nil {
return engine.Color{}, err
}
a = rgb
default:
return engine.Color{}, fmt.Errorf("invalid hex color length: %d", len(hexColor))
}
return engine.NewColorRGBA(r, g, b, a), nil
}
// parseHexComponent parses a 2-character hex string to a uint8 value.
func parseHexComponent(hex string) (uint8, error) {
if len(hex) != 2 {
return 0, fmt.Errorf("hex component must be 2 characters, got %d", len(hex))
}
value, err := strconv.ParseUint(hex, 16, 8)
if err != nil {
return 0, fmt.Errorf("invalid hex component '%s': %w", hex, err)
}
return uint8(value), nil
}

719
jdenticon/generate_test.go Normal file
View File

@@ -0,0 +1,719 @@
package jdenticon
import (
"bytes"
"fmt"
"strings"
"testing"
)
func TestGenerate(t *testing.T) {
tests := []struct {
name string
value string
size int
}{
{
name: "email address",
value: "test@example.com",
size: 64,
},
{
name: "username",
value: "johndoe",
size: 32,
},
{
name: "large icon",
value: "large-icon-test",
size: 256,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
icon, err := Generate(tt.value, tt.size)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if icon == nil {
t.Fatal("Generate returned nil icon")
}
// Test SVG generation
svg, err := icon.ToSVG()
if err != nil {
t.Fatalf("ToSVG failed: %v", err)
}
if svg == "" {
t.Error("ToSVG returned empty string")
}
// Basic SVG validation
if !strings.Contains(svg, "<svg") {
t.Error("SVG output does not contain svg tag")
}
if !strings.Contains(svg, "</svg>") {
t.Error("SVG output does not contain closing svg tag")
}
// Test PNG generation
png, err := icon.ToPNG()
if err != nil {
t.Fatalf("ToPNG failed: %v", err)
}
if len(png) == 0 {
t.Error("ToPNG returned empty data")
}
// Basic PNG validation (check PNG signature)
if len(png) < 8 || string(png[1:4]) != "PNG" {
t.Error("PNG output does not have valid PNG signature")
}
})
}
}
func TestGenerateConsistency(t *testing.T) {
value := "consistency-test"
size := 64
// Generate the same icon multiple times
icon1, err := Generate(value, size)
if err != nil {
t.Fatalf("First generate failed: %v", err)
}
icon2, err := Generate(value, size)
if err != nil {
t.Fatalf("Second generate failed: %v", err)
}
// SVG should be identical
svg1, err := icon1.ToSVG()
if err != nil {
t.Fatalf("First ToSVG failed: %v", err)
}
svg2, err := icon2.ToSVG()
if err != nil {
t.Fatalf("Second ToSVG failed: %v", err)
}
if svg1 != svg2 {
t.Error("SVG outputs are not consistent for same input")
}
// PNG should be identical
png1, err := icon1.ToPNG()
if err != nil {
t.Fatalf("First ToPNG failed: %v", err)
}
png2, err := icon2.ToPNG()
if err != nil {
t.Fatalf("Second ToPNG failed: %v", err)
}
if !bytes.Equal(png1, png2) {
t.Error("PNG outputs are not consistent for same input")
}
}
func TestGenerateInvalidInputs(t *testing.T) {
tests := []struct {
name string
value string
size int
}{
{
name: "zero size",
value: "test",
size: 0,
},
{
name: "negative size",
value: "test",
size: -10,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Generate(tt.value, tt.size)
if err == nil {
t.Error("Expected error for invalid input")
}
})
}
}
func TestGenerateVariety(t *testing.T) {
// Test that different inputs produce different outputs
values := []string{"value1", "value2", "value3", "test@example.com", "another-test"}
size := 64
svgs := make([]string, len(values))
for i, value := range values {
icon, err := Generate(value, size)
if err != nil {
t.Fatalf("Generate failed for %s: %v", value, err)
}
svg, err := icon.ToSVG()
if err != nil {
t.Fatalf("ToSVG failed for %s: %v", value, err)
}
svgs[i] = svg
}
// Check that all SVGs are different
for i := 0; i < len(svgs); i++ {
for j := i + 1; j < len(svgs); j++ {
if svgs[i] == svgs[j] {
t.Errorf("SVG outputs are identical for different inputs: %s and %s", values[i], values[j])
}
}
}
}
func BenchmarkGenerate(b *testing.B) {
value := "benchmark-test"
size := 64
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := Generate(value, size)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
}
}
// BenchmarkGenerateVariousSizes tests generation performance across different icon sizes
func BenchmarkGenerateVariousSizes(b *testing.B) {
sizes := []int{64, 128, 256, 512, 1024}
for _, size := range sizes {
b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := Generate("benchmark@test.com", size)
if err != nil {
b.Fatalf("Generate failed for size %d: %v", size, err)
}
}
})
}
}
// BenchmarkGenerateVariousInputs tests generation performance with different input types
func BenchmarkGenerateVariousInputs(b *testing.B) {
inputs := []struct {
name string
value string
}{
{"email", "user@example.com"},
{"username", "john_doe_123"},
{"uuid", "550e8400-e29b-41d4-a716-446655440000"},
{"short", "abc"},
{"long", "this_is_a_very_long_identifier_that_might_be_used_for_generating_identicons_in_some_applications"},
{"special_chars", "user+test@domain.co.uk"},
{"numbers", "12345678901234567890"},
}
for _, input := range inputs {
b.Run(input.name, func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := Generate(input.value, 128)
if err != nil {
b.Fatalf("Generate failed for input %s: %v", input.name, err)
}
}
})
}
}
func BenchmarkToSVG(b *testing.B) {
icon, err := Generate("benchmark-test", 64)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := icon.ToSVG()
if err != nil {
b.Fatalf("ToSVG failed: %v", err)
}
}
}
func BenchmarkToPNG(b *testing.B) {
icon, err := Generate("benchmark-test", 64)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := icon.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
}
// BenchmarkHashGeneration benchmarks just hash computation performance
func BenchmarkHashGeneration(b *testing.B) {
inputs := []string{
"user@example.com",
"john_doe_123",
"550e8400-e29b-41d4-a716-446655440000",
"abc",
"this_is_a_very_long_identifier_that_might_be_used_for_generating_identicons",
}
for _, input := range inputs {
b.Run(fmt.Sprintf("len_%d", len(input)), func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = ComputeHash(input)
}
})
}
}
// BenchmarkSVGRenderingVariousSizes benchmarks SVG rendering across different sizes
func BenchmarkSVGRenderingVariousSizes(b *testing.B) {
sizes := []int{64, 128, 256, 512}
icons := make(map[int]*Icon)
// Pre-generate icons
for _, size := range sizes {
icon, err := Generate("benchmark@test.com", size)
if err != nil {
b.Fatalf("Failed to generate icon for size %d: %v", size, err)
}
icons[size] = icon
}
for _, size := range sizes {
b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
icon := icons[size]
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := icon.ToSVG()
if err != nil {
b.Fatalf("ToSVG failed for size %d: %v", size, err)
}
}
})
}
}
// BenchmarkPNGRenderingVariousSizes benchmarks PNG rendering across different sizes
func BenchmarkPNGRenderingVariousSizes(b *testing.B) {
sizes := []int{64, 128, 256} // Smaller range for PNG due to higher memory usage
icons := make(map[int]*Icon)
// Pre-generate icons
for _, size := range sizes {
icon, err := Generate("benchmark@test.com", size)
if err != nil {
b.Fatalf("Failed to generate icon for size %d: %v", size, err)
}
icons[size] = icon
}
for _, size := range sizes {
b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
icon := icons[size]
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := icon.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed for size %d: %v", size, err)
}
}
})
}
}
// BenchmarkWithCustomConfig benchmarks generation with custom configuration
func BenchmarkWithCustomConfig(b *testing.B) {
config, err := Configure(
WithHueRestrictions([]float64{0.0, 0.33, 0.66}),
WithColorSaturation(0.6),
WithBackgroundColor("#ffffff"),
WithPadding(0.1),
)
if err != nil {
b.Fatalf("Configure failed: %v", err)
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := ToSVG("benchmark@test.com", 128, config)
if err != nil {
b.Fatalf("ToSVG with config failed: %v", err)
}
}
}
// BenchmarkWithCustomConfigPNG benchmarks PNG generation with custom configuration
func BenchmarkWithCustomConfigPNG(b *testing.B) {
config, err := Configure(
WithColorSaturation(0.6),
WithBackgroundColor("#123456"),
WithPadding(0.1),
)
if err != nil {
b.Fatalf("Configure failed: %v", err)
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := ToPNG("benchmark-png-config@test.com", 128, config)
if err != nil {
b.Fatalf("ToPNG with config failed: %v", err)
}
}
}
// BenchmarkConcurrentGeneration tests performance under concurrent load
func BenchmarkConcurrentGeneration(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
value := fmt.Sprintf("concurrent_user_%d@example.com", i)
_, err := Generate(value, 128)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
i++
}
})
}
// BenchmarkBatchGeneration tests memory allocation patterns for batch generation
func BenchmarkBatchGeneration(b *testing.B) {
const batchSize = 100
b.SetBytes(batchSize) // Report throughput as items/sec
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for j := 0; j < batchSize; j++ {
value := fmt.Sprintf("batch_user_%d@example.com", j)
_, err := Generate(value, 64)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
}
}
}
// Tests for new public API functions
func TestToSVG(t *testing.T) {
tests := []struct {
name string
value interface{}
size int
valid bool
}{
{"string input", "test@example.com", 64, true},
{"int input", 12345, 64, true},
{"float input", 123.45, 64, true},
{"bool input", true, 64, true},
{"nil input", nil, 64, true},
{"byte slice", []byte("hello"), 64, true},
{"zero size", "test", 0, false},
{"negative size", "test", -10, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svg, err := ToSVG(tt.value, tt.size)
if tt.valid {
if err != nil {
t.Fatalf("ToSVG failed: %v", err)
}
if svg == "" {
t.Error("ToSVG returned empty string")
}
// Basic SVG validation
if !strings.Contains(svg, "<svg") {
t.Error("SVG output does not contain svg tag")
}
if !strings.Contains(svg, "</svg>") {
t.Error("SVG output does not contain closing svg tag")
}
} else {
if err == nil {
t.Error("Expected error for invalid input")
}
}
})
}
}
func TestToPNG(t *testing.T) {
tests := []struct {
name string
value interface{}
size int
valid bool
}{
{"string input", "test@example.com", 64, true},
{"int input", 12345, 64, true},
{"float input", 123.45, 64, true},
{"bool input", false, 64, true},
{"nil input", nil, 64, true},
{"byte slice", []byte("hello"), 64, true},
{"zero size", "test", 0, false},
{"negative size", "test", -10, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
png, err := ToPNG(tt.value, tt.size)
if tt.valid {
if err != nil {
t.Fatalf("ToPNG failed: %v", err)
}
if len(png) == 0 {
t.Error("ToPNG returned empty data")
}
// Basic PNG validation (check PNG signature)
if len(png) < 8 || string(png[1:4]) != "PNG" {
t.Error("PNG output does not have valid PNG signature")
}
} else {
if err == nil {
t.Error("Expected error for invalid input")
}
}
})
}
}
func TestToHash(t *testing.T) {
tests := []struct {
name string
value interface{}
expected string
}{
{"string", "test", ComputeHash("test")},
{"int", 123, ComputeHash("123")},
{"float", 123.45, ComputeHash("123.45")},
{"bool true", true, ComputeHash("true")},
{"bool false", false, ComputeHash("false")},
{"nil", nil, ComputeHash("")},
{"byte slice", []byte("hello"), ComputeHash("hello")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hash := ToHash(tt.value)
if hash != tt.expected {
t.Errorf("ToHash(%v) = %s, expected %s", tt.value, hash, tt.expected)
}
// Hash should be non-empty and valid hex
if hash == "" {
t.Error("ToHash returned empty string")
}
// Should be valid hex characters
for _, c := range hash {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Errorf("Hash contains invalid character: %c", c)
break
}
}
})
}
}
func TestToSVGWithConfig(t *testing.T) {
value := "config-test"
size := 64
// Test with custom configuration
config, err := Configure(
WithHueRestrictions([]float64{120, 240}), // Blue/green hues
WithColorSaturation(0.8),
WithBackgroundColor("#ffffff"),
WithPadding(0.1),
)
if err != nil {
t.Fatalf("Configure failed: %v", err)
}
svg, err := ToSVG(value, size, config)
if err != nil {
t.Fatalf("ToSVG with config failed: %v", err)
}
if svg == "" {
t.Error("ToSVG with config returned empty string")
}
// Test that it's different from default
defaultSvg, err := ToSVG(value, size)
if err != nil {
t.Fatalf("ToSVG default failed: %v", err)
}
if svg == defaultSvg {
t.Error("SVG with config is identical to default SVG")
}
}
func TestToPNGWithConfig(t *testing.T) {
value := "config-test"
size := 64
// Test with custom configuration
config, err := Configure(
WithHueRestrictions([]float64{60, 180}), // Yellow/cyan hues
WithColorSaturation(0.9),
WithBackgroundColor("#000000"),
WithPadding(0.05),
)
if err != nil {
t.Fatalf("Configure failed: %v", err)
}
png, err := ToPNG(value, size, config)
if err != nil {
t.Fatalf("ToPNG with config failed: %v", err)
}
if len(png) == 0 {
t.Error("ToPNG with config returned empty data")
}
// Test that it's different from default
defaultPng, err := ToPNG(value, size)
if err != nil {
t.Fatalf("ToPNG default failed: %v", err)
}
if bytes.Equal(png, defaultPng) {
t.Error("PNG with config is identical to default PNG")
}
}
func TestPublicAPIConsistency(t *testing.T) {
value := "consistency-test"
size := 64
// Generate with both old and new APIs
icon, err := Generate(value, size)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
oldSvg, err := icon.ToSVG()
if err != nil {
t.Fatalf("Icon.ToSVG failed: %v", err)
}
oldPng, err := icon.ToPNG()
if err != nil {
t.Fatalf("Icon.ToPNG failed: %v", err)
}
newSvg, err := ToSVG(value, size)
if err != nil {
t.Fatalf("ToSVG failed: %v", err)
}
newPng, err := ToPNG(value, size)
if err != nil {
t.Fatalf("ToPNG failed: %v", err)
}
// Results should be identical
if oldSvg != newSvg {
t.Error("SVG output differs between old and new APIs")
}
if !bytes.Equal(oldPng, newPng) {
t.Error("PNG output differs between old and new APIs")
}
}
func TestTypeConversion(t *testing.T) {
tests := []struct {
name string
input interface{}
expected string
}{
{"string", "hello", "hello"},
{"int", 42, "42"},
{"int64", int64(42), "42"},
{"float64", 3.14, "3.14"},
{"bool true", true, "true"},
{"bool false", false, "false"},
{"nil", nil, ""},
{"byte slice", []byte("test"), "test"},
{"struct", struct{ Name string }{"test"}, "{test}"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := convertToString(tt.input)
if result != tt.expected {
t.Errorf("convertToString(%v) = %s, expected %s", tt.input, result, tt.expected)
}
})
}
}
// Helper function for min
func min(a, b int) int {
if a < b {
return a
}
return b
}

226
jdenticon/hash.go Normal file
View File

@@ -0,0 +1,226 @@
package jdenticon
import (
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"github.com/kevin/go-jdenticon/internal/util"
)
// ComputeHash computes a SHA-1 hash for any value and returns it as a hexadecimal string.
// This function mimics the JavaScript version's behavior for compatibility.
func ComputeHash(value interface{}) string {
var input string
// Handle different input types, converting to string like JavaScript version
switch v := value.(type) {
case nil:
input = ""
case string:
input = v
case []byte:
input = string(v)
case int:
input = strconv.Itoa(v)
case int64:
input = strconv.FormatInt(v, 10)
case float64:
input = fmt.Sprintf("%g", v)
default:
// Convert to string using fmt.Sprintf for other types
input = fmt.Sprintf("%v", v)
}
// Compute SHA-1 hash using Go's crypto/sha1 package
h := sha1.New()
h.Write([]byte(input))
hash := h.Sum(nil)
// Convert to hexadecimal string (lowercase to match JavaScript)
return fmt.Sprintf("%x", hash)
}
// HashValue is a convenience function that wraps ComputeHash for string inputs.
// Kept for backward compatibility.
func HashValue(value string) string {
return ComputeHash(value)
}
// IsValidHash checks if a string is a valid hash for Jdenticon.
// It must be a hexadecimal string with at least 11 characters.
func IsValidHash(hashCandidate string) bool {
return util.IsValidHash(hashCandidate)
}
// isValidHash is a private wrapper for backward compatibility with existing tests
func isValidHash(hashCandidate string) bool {
return IsValidHash(hashCandidate)
}
// parseHex extracts a value from a hex string at a specific position.
// This function is used to deterministically extract shape and color information
// from the hash string, matching the JavaScript implementation.
// When octets is 0 or negative, it reads from startPosition to the end of the string.
func parseHex(hash string, startPosition int, octets int) (int, error) {
return util.ParseHex(hash, startPosition, octets)
}
// ParseHex provides a public API that matches the JavaScript parseHex function exactly.
// It extracts a hexadecimal value from the hash string at the specified position.
// If octets is not provided or is <= 0, it reads from the position to the end of the string.
// Returns 0 on error to maintain compatibility with the JavaScript implementation.
func ParseHex(hash string, startPosition int, octets ...int) int {
octetCount := 0
if len(octets) > 0 {
octetCount = octets[0]
}
result, err := parseHex(hash, startPosition, octetCount)
if err != nil {
return 0 // Maintain JavaScript compatibility: return 0 on error
}
return result
}
// ParseHash converts a hexadecimal hash string into a byte array for further processing.
// It validates the input hash string and handles common prefixes like "0x".
// Returns an error if the hash contains invalid hexadecimal characters.
func ParseHash(hash string) ([]byte, error) {
if hash == "" {
return nil, errors.New("hash string cannot be empty")
}
// Remove "0x" prefix if present
cleanHash := strings.TrimPrefix(hash, "0x")
cleanHash = strings.TrimPrefix(cleanHash, "0X")
// Validate hash length (must be even for proper byte conversion)
if len(cleanHash)%2 != 0 {
return nil, errors.New("hash string must have even length")
}
// Decode hex string to bytes
bytes, err := hex.DecodeString(cleanHash)
if err != nil {
return nil, fmt.Errorf("invalid hexadecimal string: %w", err)
}
return bytes, nil
}
// ExtractInt extracts a specific number of bits from a hash byte array and converts them to an integer.
// The index parameter specifies the starting position (negative values count from the end).
// The bits parameter specifies how many bits to extract.
func ExtractInt(hash []byte, index, bits int) int {
if len(hash) == 0 || bits <= 0 {
return 0
}
// Handle negative indices (count from end)
if index < 0 {
index = len(hash) + index
}
// Ensure index is within bounds
if index < 0 || index >= len(hash) {
return 0
}
// Calculate how many bytes we need to read
bytesNeeded := (bits + 7) / 8 // Round up to nearest byte
// Ensure we don't read past the end of the array
if index+bytesNeeded > len(hash) {
bytesNeeded = len(hash) - index
}
if bytesNeeded <= 0 {
return 0
}
// Extract bytes and convert to integer
var result int
for i := 0; i < bytesNeeded; i++ {
if index+i < len(hash) {
result = (result << 8) | int(hash[index+i])
}
}
// Mask to only include the requested number of bits
if bits < 64 {
mask := (1 << bits) - 1
result &= mask
}
return result
}
// ExtractFloat extracts a specific number of bits from a hash byte array and converts them to a float64 value between 0 and 1.
// The value is normalized by dividing by the maximum possible value for the given number of bits.
func ExtractFloat(hash []byte, index, bits int) float64 {
if bits <= 0 {
return 0.0
}
// Extract integer value
intValue := ExtractInt(hash, index, bits)
// Calculate maximum possible value for the given number of bits
maxValue := (1 << bits) - 1
if maxValue == 0 {
return 0.0
}
// Normalize to [0,1] range
return float64(intValue) / float64(maxValue)
}
// ExtractHue extracts the hue value from a hash string using the same algorithm as the JavaScript version.
// This is a convenience function that extracts the last 7 characters and normalizes to [0,1] range.
// Returns 0.0 on error to maintain compatibility with the JavaScript implementation.
func ExtractHue(hash string) float64 {
hueValue, err := parseHex(hash, -7, 0) // Read from -7 to end
if err != nil {
return 0.0 // Maintain JavaScript compatibility: return 0.0 on error
}
return float64(hueValue) / 0xfffffff
}
// ExtractShapeIndex extracts a shape index from the hash at the specified position.
// This is a convenience function that matches the JavaScript shape selection logic.
// Returns 0 on error to maintain compatibility with the JavaScript implementation.
func ExtractShapeIndex(hash string, position int) int {
result, err := parseHex(hash, position, 1)
if err != nil {
return 0 // Maintain JavaScript compatibility: return 0 on error
}
return result
}
// ExtractRotation extracts a rotation value from the hash at the specified position.
// This is a convenience function that matches the JavaScript rotation logic.
// Returns 0 on error to maintain compatibility with the JavaScript implementation.
func ExtractRotation(hash string, position int) int {
result, err := parseHex(hash, position, 1)
if err != nil {
return 0 // Maintain JavaScript compatibility: return 0 on error
}
return result
}
// ExtractColorIndex extracts a color index from the hash at the specified position.
// This is a convenience function that matches the JavaScript color selection logic.
// Returns 0 on error to maintain compatibility with the JavaScript implementation.
func ExtractColorIndex(hash string, position int, availableColors int) int {
value, err := parseHex(hash, position, 1)
if err != nil {
return 0 // Maintain JavaScript compatibility: return 0 on error
}
if availableColors > 0 {
return value % availableColors
}
return value
}

481
jdenticon/hash_test.go Normal file
View File

@@ -0,0 +1,481 @@
package jdenticon
import (
"strings"
"testing"
)
func TestComputeHash(t *testing.T) {
tests := []struct {
name string
input interface{}
expected string // Known SHA-1 hash values
}{
{
name: "empty string",
input: "",
expected: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
},
{
name: "simple string",
input: "hello",
expected: "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d",
},
{
name: "email address",
input: "user@example.com",
expected: "63a710569261a24b3766275b7000ce8d7b32e2f7",
},
{
name: "nil input",
input: nil,
expected: "da39a3ee5e6b4b0d3255bfef95601890afd80709", // Same as empty string
},
{
name: "integer input",
input: 123,
expected: "40bd001563085fc35165329ea1ff5c5ecbdbbeef", // SHA-1 of "123"
},
{
name: "byte slice input",
input: []byte("test"),
expected: "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ComputeHash(tt.input)
if result != tt.expected {
t.Errorf("ComputeHash(%v) = %s, want %s", tt.input, result, tt.expected)
}
})
}
}
func TestHashValue(t *testing.T) {
// Test that HashValue produces the same result as ComputeHash for strings
testStrings := []string{"", "hello", "user@example.com", "test123"}
for _, str := range testStrings {
hashValue := HashValue(str)
computeHash := ComputeHash(str)
if hashValue != computeHash {
t.Errorf("HashValue(%s) = %s, but ComputeHash(%s) = %s", str, hashValue, str, computeHash)
}
}
}
func TestIsValidHash(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{
name: "valid long hash",
input: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
expected: true,
},
{
name: "valid minimum length",
input: "da39a3ee5e6",
expected: true,
},
{
name: "too short",
input: "da39a3ee5e",
expected: false,
},
{
name: "invalid character",
input: "da39a3ee5e6g4b0d3255bfef95601890afd80709",
expected: false,
},
{
name: "uppercase valid",
input: "DA39A3EE5E6B4B0D3255BFEF95601890AFD80709",
expected: true,
},
{
name: "empty string",
input: "",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isValidHash(tt.input)
if result != tt.expected {
t.Errorf("isValidHash(%s) = %v, want %v", tt.input, result, tt.expected)
}
})
}
}
func TestParseHex(t *testing.T) {
hash := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
tests := []struct {
name string
startPosition int
octets int
expected int
}{
{
name: "first character",
startPosition: 0,
octets: 1,
expected: 0xd, // 'd' in hex
},
{
name: "two characters",
startPosition: 0,
octets: 2,
expected: 0xda,
},
{
name: "middle position",
startPosition: 10,
octets: 1,
expected: 0x6, // '6' at position 10
},
{
name: "negative index",
startPosition: -1,
octets: 1,
expected: 0x9, // last character '9'
},
{
name: "negative index multiple chars",
startPosition: -2,
octets: 2,
expected: 0x09, // last two characters "09"
},
{
name: "out of bounds",
startPosition: 100,
octets: 1,
expected: 0,
},
{
name: "zero octets reads to end",
startPosition: -7,
octets: 0,
expected: 0xfd80709, // Last 7 characters "fd80709"
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseHex(hash, tt.startPosition, tt.octets)
if err != nil {
// For out of bounds case, we expect an error but return 0
if tt.expected == 0 && tt.name == "out of bounds" {
// This is expected
return
}
t.Errorf("parseHex(%s, %d, %d) unexpected error: %v", hash, tt.startPosition, tt.octets, err)
return
}
if result != tt.expected {
t.Errorf("parseHex(%s, %d, %d) = %d, want %d", hash, tt.startPosition, tt.octets, result, tt.expected)
}
})
}
}
func TestParseHexErrors(t *testing.T) {
tests := []struct {
name string
hash string
startPosition int
octets int
expectError bool
errorContains string
}{
{
name: "invalid hex character",
hash: "da39g3ee5e6b4b0d3255bfef95601890afd80709",
startPosition: 4,
octets: 1,
expectError: true,
errorContains: "failed to parse hex",
},
{
name: "out of bounds positive",
hash: "da39a3ee",
startPosition: 10,
octets: 1,
expectError: true,
errorContains: "out of bounds",
},
{
name: "out of bounds negative",
hash: "da39a3ee",
startPosition: -20,
octets: 1,
expectError: true,
errorContains: "out of bounds",
},
{
name: "valid hex",
hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
startPosition: 0,
octets: 2,
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseHex(tt.hash, tt.startPosition, tt.octets)
if tt.expectError {
if err == nil {
t.Errorf("parseHex(%s, %d, %d) expected error, got nil", tt.hash, tt.startPosition, tt.octets)
return
}
if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("parseHex(%s, %d, %d) error = %v, want error containing %s", tt.hash, tt.startPosition, tt.octets, err, tt.errorContains)
}
} else {
if err != nil {
t.Errorf("parseHex(%s, %d, %d) unexpected error: %v", tt.hash, tt.startPosition, tt.octets, err)
}
// For valid case, just ensure no error and result >= 0
if result < 0 {
t.Errorf("parseHex(%s, %d, %d) = %d, want >= 0", tt.hash, tt.startPosition, tt.octets, result)
}
}
})
}
}
func TestParseHash(t *testing.T) {
tests := []struct {
name string
input string
expectError bool
expected []byte
}{
{
name: "valid hex string",
input: "da39a3ee",
expectError: false,
expected: []byte{0xda, 0x39, 0xa3, 0xee},
},
{
name: "with 0x prefix",
input: "0xda39a3ee",
expectError: false,
expected: []byte{0xda, 0x39, 0xa3, 0xee},
},
{
name: "with 0X prefix",
input: "0Xda39a3ee",
expectError: false,
expected: []byte{0xda, 0x39, 0xa3, 0xee},
},
{
name: "uppercase hex",
input: "DA39A3EE",
expectError: false,
expected: []byte{0xda, 0x39, 0xa3, 0xee},
},
{
name: "empty string",
input: "",
expectError: true,
expected: nil,
},
{
name: "odd length",
input: "da39a3e",
expectError: true,
expected: nil,
},
{
name: "invalid character",
input: "da39g3ee",
expectError: true,
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseHash(tt.input)
if tt.expectError {
if err == nil {
t.Errorf("ParseHash(%s) expected error, got nil", tt.input)
}
} else {
if err != nil {
t.Errorf("ParseHash(%s) unexpected error: %v", tt.input, err)
}
if len(result) != len(tt.expected) {
t.Errorf("ParseHash(%s) length = %d, want %d", tt.input, len(result), len(tt.expected))
}
for i, b := range result {
if i < len(tt.expected) && b != tt.expected[i] {
t.Errorf("ParseHash(%s)[%d] = %x, want %x", tt.input, i, b, tt.expected[i])
}
}
}
})
}
}
func TestExtractHue(t *testing.T) {
// Test with a known hash to ensure consistent behavior with JavaScript
hash := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
hue := ExtractHue(hash)
// Verify it's in [0,1] range
if hue < 0.0 || hue > 1.0 {
t.Errorf("ExtractHue(%s) = %f, want value in [0,1] range", hash, hue)
}
// Verify it matches manual calculation
expectedValue, err := parseHex(hash, -7, 0)
if err != nil {
t.Fatalf("parseHex failed: %v", err)
}
expectedHue := float64(expectedValue) / 0xfffffff
if hue != expectedHue {
t.Errorf("ExtractHue(%s) = %f, want %f", hash, hue, expectedHue)
}
// Test error cases - should return 0.0 for JavaScript compatibility
t.Run("invalid hash", func(t *testing.T) {
invalidHash := "invalid-hash-with-non-hex-chars"
hue := ExtractHue(invalidHash)
if hue != 0.0 {
t.Errorf("ExtractHue with invalid hash should return 0.0, got %f", hue)
}
})
t.Run("short hash", func(t *testing.T) {
shortHash := "short"
hue := ExtractHue(shortHash)
if hue != 0.0 {
t.Errorf("ExtractHue with short hash should return 0.0, got %f", hue)
}
})
}
func TestExtractShapeIndex(t *testing.T) {
hash := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
tests := []struct {
position int
expected int
}{
{1, 0xa}, // 'a' at position 1
{2, 0x3}, // '3' at position 2
{4, 0xa}, // 'a' at position 4
}
for _, tt := range tests {
result := ExtractShapeIndex(hash, tt.position)
if result != tt.expected {
t.Errorf("ExtractShapeIndex(%s, %d) = %d, want %d", hash, tt.position, result, tt.expected)
}
}
// Test error cases - should return 0 for JavaScript compatibility
t.Run("invalid hash", func(t *testing.T) {
invalidHash := "invalid-hash-with-non-hex-chars"
result := ExtractShapeIndex(invalidHash, 0)
if result != 0 {
t.Errorf("ExtractShapeIndex with invalid hash should return 0, got %d", result)
}
})
t.Run("out of bounds position", func(t *testing.T) {
result := ExtractShapeIndex(hash, 100)
if result != 0 {
t.Errorf("ExtractShapeIndex with out of bounds position should return 0, got %d", result)
}
})
}
func TestExtractColorIndex(t *testing.T) {
hash := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
// Test modulo behavior
availableColors := 5
position := 8
rawValue, err := parseHex(hash, position, 1)
if err != nil {
t.Fatalf("parseHex failed: %v", err)
}
expected := rawValue % availableColors
result := ExtractColorIndex(hash, position, availableColors)
if result != expected {
t.Errorf("ExtractColorIndex(%s, %d, %d) = %d, want %d", hash, position, availableColors, result, expected)
}
// Test with zero availableColors
result = ExtractColorIndex(hash, position, 0)
if result != rawValue {
t.Errorf("ExtractColorIndex(%s, %d, 0) = %d, want %d", hash, position, result, rawValue)
}
// Test error cases - should return 0 for JavaScript compatibility
t.Run("invalid hash", func(t *testing.T) {
invalidHash := "invalid-hash-with-non-hex-chars"
result := ExtractColorIndex(invalidHash, 0, 5)
if result != 0 {
t.Errorf("ExtractColorIndex with invalid hash should return 0, got %d", result)
}
})
t.Run("out of bounds position", func(t *testing.T) {
result := ExtractColorIndex(hash, 100, 5)
if result != 0 {
t.Errorf("ExtractColorIndex with out of bounds position should return 0, got %d", result)
}
})
}
func TestExtractRotation(t *testing.T) {
hash := "da39a3ee5e6b4b0d3255bfef95601890afd80709"
tests := []struct {
position int
expected int
}{
{0, 0xd}, // 'd' at position 0
{1, 0xa}, // 'a' at position 1
{5, 0x3}, // '3' at position 5
}
for _, tt := range tests {
result := ExtractRotation(hash, tt.position)
if result != tt.expected {
t.Errorf("ExtractRotation(%s, %d) = %d, want %d", hash, tt.position, result, tt.expected)
}
}
// Test error cases - should return 0 for JavaScript compatibility
t.Run("invalid hash", func(t *testing.T) {
invalidHash := "invalid-hash-with-non-hex-chars"
result := ExtractRotation(invalidHash, 0)
if result != 0 {
t.Errorf("ExtractRotation with invalid hash should return 0, got %d", result)
}
})
t.Run("out of bounds position", func(t *testing.T) {
result := ExtractRotation(hash, 100)
if result != 0 {
t.Errorf("ExtractRotation with out of bounds position should return 0, got %d", result)
}
})
}

BIN
jdenticon/main Executable file

Binary file not shown.

View File

@@ -0,0 +1,58 @@
package jdenticon
import (
"os"
"path/filepath"
"testing"
)
func TestJavaScriptReferenceCompatibility(t *testing.T) {
testCases := []struct {
input string
size int
}{
{"test-hash", 64},
{"example1@gmail.com", 64},
{"example2@yahoo.com", 64},
}
for _, tc := range testCases {
t.Run(tc.input+"_"+string(rune(tc.size)), func(t *testing.T) {
// Generate Go SVG
goSvg, err := ToSVG(tc.input, tc.size)
if err != nil {
t.Fatalf("Failed to generate Go SVG: %v", err)
}
// Read reference JavaScript SVG
var refFilename string
if tc.input == "test-hash" {
refFilename = "test-hash_64.svg"
} else if tc.input == "example1@gmail.com" {
refFilename = "example1_at_gmail_com_64.svg"
} else if tc.input == "example2@yahoo.com" {
refFilename = "example2_at_yahoo_com_64.svg"
}
refPath := filepath.Join("../reference", refFilename)
refData, err := os.ReadFile(refPath)
if err != nil {
t.Skipf("Reference file not found: %s", refPath)
return
}
refSvg := string(refData)
// Compare
if goSvg != refSvg {
t.Errorf("SVG output differs from JavaScript reference")
t.Logf("Go output:\n%s", goSvg)
t.Logf("JS reference:\n%s", refSvg)
// Save Go output for manual inspection
goPath := filepath.Join("../go-output", refFilename)
os.WriteFile(goPath, []byte(goSvg), 0644)
t.Logf("Go output saved to: %s", goPath)
}
})
}
}