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

346
internal/engine/color.go Normal file
View File

@@ -0,0 +1,346 @@
package engine
import (
"fmt"
"math"
"strconv"
)
// Lightness correctors for each hue segment (based on JavaScript implementation)
var correctors = []float64{0.55, 0.5, 0.5, 0.46, 0.6, 0.55, 0.55}
// Color represents a color with both HSL and RGB representations
type Color struct {
H, S, L float64 // HSL values: H=[0,1], S=[0,1], L=[0,1]
R, G, B uint8 // RGB values: [0,255]
A uint8 // Alpha channel: [0,255]
}
// NewColorHSL creates a new Color from HSL values
func NewColorHSL(h, s, l float64) Color {
r, g, b := HSLToRGB(h, s, l)
return Color{
H: h, S: s, L: l,
R: r, G: g, B: b,
A: 255,
}
}
// NewColorCorrectedHSL creates a new Color from HSL values with lightness correction
func NewColorCorrectedHSL(h, s, l float64) Color {
r, g, b := CorrectedHSLToRGB(h, s, l)
return Color{
H: h, S: s, L: l,
R: r, G: g, B: b,
A: 255,
}
}
// NewColorRGB creates a new Color from RGB values and calculates HSL
func NewColorRGB(r, g, b uint8) Color {
h, s, l := RGBToHSL(r, g, b)
return Color{
H: h, S: s, L: l,
R: r, G: g, B: b,
A: 255,
}
}
// NewColorRGBA creates a new Color from RGBA values and calculates HSL
func NewColorRGBA(r, g, b, a uint8) Color {
h, s, l := RGBToHSL(r, g, b)
return Color{
H: h, S: s, L: l,
R: r, G: g, B: b,
A: a,
}
}
// String returns the hex representation of the color
func (c Color) String() string {
if c.A == 255 {
return RGBToHex(c.R, c.G, c.B)
}
return fmt.Sprintf("#%02x%02x%02x%02x", c.R, c.G, c.B, c.A)
}
// Equals compares two colors for equality
func (c Color) Equals(other Color) bool {
return c.R == other.R && c.G == other.G && c.B == other.B && c.A == other.A
}
// WithAlpha returns a new color with the specified alpha value
func (c Color) WithAlpha(alpha uint8) Color {
return Color{
H: c.H, S: c.S, L: c.L,
R: c.R, G: c.G, B: c.B,
A: alpha,
}
}
// IsGrayscale returns true if the color is grayscale (saturation near zero)
func (c Color) IsGrayscale() bool {
return c.S < 0.01 // Small tolerance for floating point comparison
}
// Darken returns a new color with reduced lightness
func (c Color) Darken(amount float64) Color {
newL := clamp(c.L-amount, 0, 1)
return NewColorCorrectedHSL(c.H, c.S, newL)
}
// Lighten returns a new color with increased lightness
func (c Color) Lighten(amount float64) Color {
newL := clamp(c.L+amount, 0, 1)
return NewColorCorrectedHSL(c.H, c.S, newL)
}
// RGBToHSL converts RGB values to HSL
// Returns H=[0,1], S=[0,1], L=[0,1]
func RGBToHSL(r, g, b uint8) (h, s, l float64) {
rf := float64(r) / 255.0
gf := float64(g) / 255.0
bf := float64(b) / 255.0
max := math.Max(rf, math.Max(gf, bf))
min := math.Min(rf, math.Min(gf, bf))
// Calculate lightness
l = (max + min) / 2.0
if max == min {
// Achromatic (gray)
h, s = 0, 0
} else {
delta := max - min
// Calculate saturation
if l > 0.5 {
s = delta / (2.0 - max - min)
} else {
s = delta / (max + min)
}
// Calculate hue
switch max {
case rf:
h = (gf-bf)/delta + (func() float64 {
if gf < bf {
return 6
}
return 0
})()
case gf:
h = (bf-rf)/delta + 2
case bf:
h = (rf-gf)/delta + 4
}
h /= 6.0
}
return h, s, l
}
// HSLToRGB converts HSL color values to RGB.
// h: hue in range [0, 1]
// s: saturation in range [0, 1]
// l: lightness in range [0, 1]
// Returns RGB values in range [0, 255]
func HSLToRGB(h, s, l float64) (r, g, b uint8) {
// Clamp input values to valid ranges
h = math.Mod(h, 1.0)
if h < 0 {
h += 1.0
}
s = clamp(s, 0, 1)
l = clamp(l, 0, 1)
// Handle grayscale case (saturation = 0)
if s == 0 {
// All RGB components are equal for grayscale
gray := uint8(clamp(l*255, 0, 255))
return gray, gray, gray
}
// Calculate intermediate values for HSL to RGB conversion
var m2 float64
if l <= 0.5 {
m2 = l * (s + 1)
} else {
m2 = l + s - l*s
}
m1 := l*2 - m2
// Convert each RGB component
r = uint8(clamp(hueToRGB(m1, m2, h*6+2)*255, 0, 255))
g = uint8(clamp(hueToRGB(m1, m2, h*6)*255, 0, 255))
b = uint8(clamp(hueToRGB(m1, m2, h*6-2)*255, 0, 255))
return r, g, b
}
// CorrectedHSLToRGB converts HSL to RGB with lightness correction for better visual perception.
// This function adjusts the lightness based on the hue to compensate for the human eye's
// different sensitivity to different colors.
func CorrectedHSLToRGB(h, s, l float64) (r, g, b uint8) {
// Get the corrector for the current hue
hueIndex := int((h*6 + 0.5)) % len(correctors)
corrector := correctors[hueIndex]
// Adjust lightness relative to the corrector
if l < 0.5 {
l = l * corrector * 2
} else {
l = corrector + (l-0.5)*(1-corrector)*2
}
// Clamp the corrected lightness
l = clamp(l, 0, 1)
return HSLToRGB(h, s, l)
}
// hueToRGB converts a hue value to an RGB component value
// Based on the W3C CSS3 color specification
func hueToRGB(m1, m2, h float64) float64 {
// Normalize hue to [0, 6) range
if h < 0 {
h += 6
} else if h > 6 {
h -= 6
}
// Calculate RGB component based on hue position
if h < 1 {
return m1 + (m2-m1)*h
} else if h < 3 {
return m2
} else if h < 4 {
return m1 + (m2-m1)*(4-h)
} else {
return m1
}
}
// clamp constrains a value to the specified range [min, max]
func clamp(value, min, max float64) float64 {
if value < min {
return min
}
if value > max {
return max
}
return value
}
// RGBToHex converts RGB values to a hexadecimal color string
func RGBToHex(r, g, b uint8) string {
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
}
// ParseHexColor parses a hexadecimal color string and returns RGB values
// Supports formats: #RGB, #RRGGBB, #RRGGBBAA
// Returns error if the format is invalid
func ParseHexColor(color string) (r, g, b, a uint8, err error) {
if len(color) == 0 || color[0] != '#' {
return 0, 0, 0, 255, fmt.Errorf("invalid color format: %s", color)
}
hex := color[1:] // Remove '#' prefix
a = 255 // Default alpha
// Helper to parse a component and chain errors
parse := func(target *uint8, hexStr string) {
if err != nil {
return // Don't parse if a previous component failed
}
*target, err = hexToByte(hexStr)
}
switch len(hex) {
case 3: // #RGB
parse(&r, hex[0:1]+hex[0:1])
parse(&g, hex[1:2]+hex[1:2])
parse(&b, hex[2:3]+hex[2:3])
case 6: // #RRGGBB
parse(&r, hex[0:2])
parse(&g, hex[2:4])
parse(&b, hex[4:6])
case 8: // #RRGGBBAA
parse(&r, hex[0:2])
parse(&g, hex[2:4])
parse(&b, hex[4:6])
parse(&a, hex[6:8])
default:
return 0, 0, 0, 255, fmt.Errorf("invalid hex color length: %s", color)
}
if err != nil {
// Return zero values for color components on error, but keep default alpha
return 0, 0, 0, 255, fmt.Errorf("failed to parse color '%s': %w", color, err)
}
return r, g, b, a, nil
}
// hexToByte converts a 2-character hex string to a byte value
func hexToByte(hex string) (uint8, error) {
if len(hex) != 2 {
return 0, fmt.Errorf("invalid hex string length: expected 2 characters, got %d", len(hex))
}
n, err := strconv.ParseUint(hex, 16, 8)
if err != nil {
return 0, fmt.Errorf("invalid hex value '%s': %w", hex, err)
}
return uint8(n), nil
}
// GenerateColor creates a color with the specified hue and configuration-based saturation and lightness
func GenerateColor(hue float64, config ColorConfig, lightnessValue float64) Color {
// Restrict hue according to configuration
restrictedHue := config.RestrictHue(hue)
// Get lightness from configuration range
lightness := config.ColorLightness.GetLightness(lightnessValue)
// Use corrected HSL to RGB conversion
return NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, lightness)
}
// GenerateGrayscale creates a grayscale color with configuration-based saturation and lightness
func GenerateGrayscale(config ColorConfig, lightnessValue float64) Color {
// For grayscale, hue doesn't matter, but we'll use 0
hue := 0.0
// Get lightness from grayscale configuration range
lightness := config.GrayscaleLightness.GetLightness(lightnessValue)
// Use grayscale saturation (typically 0)
return NewColorCorrectedHSL(hue, config.GrayscaleSaturation, lightness)
}
// GenerateColorTheme generates a set of color candidates based on the JavaScript colorTheme function
// This matches the JavaScript implementation that creates 5 colors:
// 0: Dark gray, 1: Mid color, 2: Light gray, 3: Light color, 4: Dark color
func GenerateColorTheme(hue float64, config ColorConfig) []Color {
// Restrict hue according to configuration
restrictedHue := config.RestrictHue(hue)
return []Color{
// Dark gray (grayscale with lightness 0)
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(0)),
// Mid color (normal color with lightness 0.5)
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(0.5)),
// Light gray (grayscale with lightness 1)
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(1)),
// Light color (normal color with lightness 1)
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(1)),
// Dark color (normal color with lightness 0)
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(0)),
}
}

View File

@@ -0,0 +1,35 @@
package engine
import (
"testing"
)
var benchmarkCases = []struct {
h, s, l float64
}{
{0.0, 0.5, 0.5}, // Red
{0.33, 0.5, 0.5}, // Green
{0.66, 0.5, 0.5}, // Blue
{0.5, 1.0, 0.3}, // Cyan dark
{0.8, 0.8, 0.7}, // Purple light
}
func BenchmarkCorrectedHSLToRGB(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
tc := benchmarkCases[i%len(benchmarkCases)]
CorrectedHSLToRGB(tc.h, tc.s, tc.l)
}
}
func BenchmarkNewColorCorrectedHSL(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
tc := benchmarkCases[i%len(benchmarkCases)]
NewColorCorrectedHSL(tc.h, tc.s, tc.l)
}
}

View File

@@ -0,0 +1,663 @@
package engine
import (
"math"
"testing"
)
func TestHSLToRGB(t *testing.T) {
tests := []struct {
name string
h, s, l float64
r, g, b uint8
}{
{
name: "pure red",
h: 0.0, s: 1.0, l: 0.5,
r: 255, g: 0, b: 0,
},
{
name: "pure green",
h: 1.0/3.0, s: 1.0, l: 0.5,
r: 0, g: 255, b: 0,
},
{
name: "pure blue",
h: 2.0/3.0, s: 1.0, l: 0.5,
r: 0, g: 0, b: 255,
},
{
name: "white",
h: 0.0, s: 0.0, l: 1.0,
r: 255, g: 255, b: 255,
},
{
name: "black",
h: 0.0, s: 0.0, l: 0.0,
r: 0, g: 0, b: 0,
},
{
name: "gray",
h: 0.0, s: 0.0, l: 0.5,
r: 127, g: 127, b: 127,
},
{
name: "dark red",
h: 0.0, s: 1.0, l: 0.25,
r: 127, g: 0, b: 0,
},
{
name: "light blue",
h: 2.0/3.0, s: 1.0, l: 0.75,
r: 127, g: 127, b: 255,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, g, b := HSLToRGB(tt.h, tt.s, tt.l)
// Allow small tolerance due to floating point arithmetic
tolerance := uint8(2)
if abs(int(r), int(tt.r)) > int(tolerance) ||
abs(int(g), int(tt.g)) > int(tolerance) ||
abs(int(b), int(tt.b)) > int(tolerance) {
t.Errorf("HSLToRGB(%f, %f, %f) = (%d, %d, %d), want (%d, %d, %d)",
tt.h, tt.s, tt.l, r, g, b, tt.r, tt.g, tt.b)
}
})
}
}
func TestCorrectedHSLToRGB(t *testing.T) {
// Test that corrected HSL produces valid RGB values
testCases := []struct {
name string
h, s, l float64
}{
{"Red", 0.0, 1.0, 0.5},
{"Green", 0.33, 1.0, 0.5},
{"Blue", 0.67, 1.0, 0.5},
{"Gray", 0.0, 0.0, 0.5},
{"DarkCyan", 0.5, 0.7, 0.3},
{"LightMagenta", 0.8, 0.8, 0.8},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
r, g, b := CorrectedHSLToRGB(tc.h, tc.s, tc.l)
// Verify RGB values are in valid range
if r > 255 || g > 255 || b > 255 {
t.Errorf("CorrectedHSLToRGB(%f, %f, %f) = (%d, %d, %d), RGB values should be <= 255",
tc.h, tc.s, tc.l, r, g, b)
}
})
}
}
func TestRGBToHex(t *testing.T) {
tests := []struct {
name string
r, g, b uint8
expected string
}{
{"black", 0, 0, 0, "#000000"},
{"white", 255, 255, 255, "#ffffff"},
{"red", 255, 0, 0, "#ff0000"},
{"green", 0, 255, 0, "#00ff00"},
{"blue", 0, 0, 255, "#0000ff"},
{"gray", 128, 128, 128, "#808080"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := RGBToHex(tt.r, tt.g, tt.b)
if result != tt.expected {
t.Errorf("RGBToHex(%d, %d, %d) = %s, want %s", tt.r, tt.g, tt.b, result, tt.expected)
}
})
}
}
func TestHexToByte(t *testing.T) {
tests := []struct {
name string
input string
expected uint8
expectError bool
}{
{
name: "valid hex 00",
input: "00",
expected: 0,
},
{
name: "valid hex ff",
input: "ff",
expected: 255,
},
{
name: "valid hex a5",
input: "a5",
expected: 165,
},
{
name: "valid hex A5 uppercase",
input: "A5",
expected: 165,
},
{
name: "invalid length - too short",
input: "f",
expectError: true,
},
{
name: "invalid length - too long",
input: "fff",
expectError: true,
},
{
name: "invalid character x",
input: "fx",
expectError: true,
},
{
name: "invalid character z",
input: "zz",
expectError: true,
},
{
name: "empty string",
input: "",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := hexToByte(tt.input)
if tt.expectError {
if err == nil {
t.Errorf("hexToByte(%s) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Errorf("hexToByte(%s) unexpected error: %v", tt.input, err)
return
}
if result != tt.expected {
t.Errorf("hexToByte(%s) = %d, want %d", tt.input, result, tt.expected)
}
})
}
}
func TestParseHexColor(t *testing.T) {
tests := []struct {
name string
input string
expectError bool
r, g, b, a uint8
}{
{
name: "3-char hex",
input: "#f0a",
r: 255, g: 0, b: 170, a: 255,
},
{
name: "6-char hex",
input: "#ff00aa",
r: 255, g: 0, b: 170, a: 255,
},
{
name: "8-char hex with alpha",
input: "#ff00aa80",
r: 255, g: 0, b: 170, a: 128,
},
{
name: "black",
input: "#000",
r: 0, g: 0, b: 0, a: 255,
},
{
name: "white",
input: "#fff",
r: 255, g: 255, b: 255, a: 255,
},
{
name: "invalid format - no hash",
input: "ff0000",
expectError: true,
},
{
name: "invalid format - too short",
input: "#f",
expectError: true,
},
{
name: "invalid format - too long",
input: "#ff00aa12345",
expectError: true,
},
{
name: "invalid hex character in 3-char",
input: "#fxz",
expectError: true,
},
{
name: "invalid hex character in 6-char",
input: "#ff00xz",
expectError: true,
},
{
name: "invalid hex character in 8-char",
input: "#ff00aaxz",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, g, b, a, err := ParseHexColor(tt.input)
if tt.expectError {
if err == nil {
t.Errorf("ParseHexColor(%s) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Errorf("ParseHexColor(%s) unexpected error: %v", tt.input, err)
return
}
if r != tt.r || g != tt.g || b != tt.b || a != tt.a {
t.Errorf("ParseHexColor(%s) = (%d, %d, %d, %d), want (%d, %d, %d, %d)",
tt.input, r, g, b, a, tt.r, tt.g, tt.b, tt.a)
}
})
}
}
func TestClamp(t *testing.T) {
tests := []struct {
value, min, max, expected float64
}{
{0.5, 0.0, 1.0, 0.5}, // within range
{-0.5, 0.0, 1.0, 0.0}, // below min
{1.5, 0.0, 1.0, 1.0}, // above max
{0.0, 0.0, 1.0, 0.0}, // at min
{1.0, 0.0, 1.0, 1.0}, // at max
}
for _, tt := range tests {
result := clamp(tt.value, tt.min, tt.max)
if result != tt.expected {
t.Errorf("clamp(%f, %f, %f) = %f, want %f", tt.value, tt.min, tt.max, result, tt.expected)
}
}
}
func TestNewColorHSL(t *testing.T) {
color := NewColorHSL(0.0, 1.0, 0.5) // Pure red
if color.H != 0.0 || color.S != 1.0 || color.L != 0.5 {
t.Errorf("NewColorHSL(0.0, 1.0, 0.5) HSL = (%f, %f, %f), want (0.0, 1.0, 0.5)",
color.H, color.S, color.L)
}
if color.R != 255 || color.G != 0 || color.B != 0 {
t.Errorf("NewColorHSL(0.0, 1.0, 0.5) RGB = (%d, %d, %d), want (255, 0, 0)",
color.R, color.G, color.B)
}
if color.A != 255 {
t.Errorf("NewColorHSL(0.0, 1.0, 0.5) A = %d, want 255", color.A)
}
}
func TestNewColorRGB(t *testing.T) {
color := NewColorRGB(255, 0, 0) // Pure red
if color.R != 255 || color.G != 0 || color.B != 0 {
t.Errorf("NewColorRGB(255, 0, 0) RGB = (%d, %d, %d), want (255, 0, 0)",
color.R, color.G, color.B)
}
// HSL values should be approximately (0, 1, 0.5) for pure red
tolerance := 0.01
if math.Abs(color.H-0.0) > tolerance || math.Abs(color.S-1.0) > tolerance || math.Abs(color.L-0.5) > tolerance {
t.Errorf("NewColorRGB(255, 0, 0) HSL = (%f, %f, %f), want approximately (0.0, 1.0, 0.5)",
color.H, color.S, color.L)
}
}
func TestColorString(t *testing.T) {
tests := []struct {
name string
color Color
expected string
}{
{
name: "red without alpha",
color: NewColorRGB(255, 0, 0),
expected: "#ff0000",
},
{
name: "blue with alpha",
color: NewColorRGBA(0, 0, 255, 128),
expected: "#0000ff80",
},
{
name: "black",
color: NewColorRGB(0, 0, 0),
expected: "#000000",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.color.String()
if result != tt.expected {
t.Errorf("Color.String() = %s, want %s", result, tt.expected)
}
})
}
}
func TestColorEquals(t *testing.T) {
color1 := NewColorRGB(255, 0, 0)
color2 := NewColorRGB(255, 0, 0)
color3 := NewColorRGB(0, 255, 0)
color4 := NewColorRGBA(255, 0, 0, 128)
if !color1.Equals(color2) {
t.Error("Expected equal colors to be equal")
}
if color1.Equals(color3) {
t.Error("Expected different colors to not be equal")
}
if color1.Equals(color4) {
t.Error("Expected colors with different alpha to not be equal")
}
}
func TestColorWithAlpha(t *testing.T) {
color := NewColorRGB(255, 0, 0)
newColor := color.WithAlpha(128)
if newColor.A != 128 {
t.Errorf("WithAlpha(128) A = %d, want 128", newColor.A)
}
// RGB and HSL should remain the same
if newColor.R != color.R || newColor.G != color.G || newColor.B != color.B {
t.Error("WithAlpha should not change RGB values")
}
if newColor.H != color.H || newColor.S != color.S || newColor.L != color.L {
t.Error("WithAlpha should not change HSL values")
}
}
func TestColorIsGrayscale(t *testing.T) {
grayColor := NewColorRGB(128, 128, 128)
redColor := NewColorRGB(255, 0, 0)
if !grayColor.IsGrayscale() {
t.Error("Expected gray color to be identified as grayscale")
}
if redColor.IsGrayscale() {
t.Error("Expected red color to not be identified as grayscale")
}
}
func TestColorDarkenLighten(t *testing.T) {
color := NewColorHSL(0.0, 1.0, 0.5) // Pure red
darker := color.Darken(0.2)
if darker.L >= color.L {
t.Error("Darken should reduce lightness")
}
lighter := color.Lighten(0.2)
if lighter.L <= color.L {
t.Error("Lighten should increase lightness")
}
// Test clamping
veryDark := color.Darken(1.0)
if veryDark.L != 0.0 {
t.Errorf("Darken with large amount should clamp to 0, got %f", veryDark.L)
}
veryLight := color.Lighten(1.0)
if veryLight.L != 1.0 {
t.Errorf("Lighten with large amount should clamp to 1, got %f", veryLight.L)
}
}
func TestRGBToHSL(t *testing.T) {
tests := []struct {
name string
r, g, b uint8
h, s, l float64
}{
{
name: "red",
r: 255, g: 0, b: 0,
h: 0.0, s: 1.0, l: 0.5,
},
{
name: "green",
r: 0, g: 255, b: 0,
h: 1.0/3.0, s: 1.0, l: 0.5,
},
{
name: "blue",
r: 0, g: 0, b: 255,
h: 2.0/3.0, s: 1.0, l: 0.5,
},
{
name: "white",
r: 255, g: 255, b: 255,
h: 0.0, s: 0.0, l: 1.0,
},
{
name: "black",
r: 0, g: 0, b: 0,
h: 0.0, s: 0.0, l: 0.0,
},
{
name: "gray",
r: 128, g: 128, b: 128,
h: 0.0, s: 0.0, l: 0.502, // approximately 0.5
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h, s, l := RGBToHSL(tt.r, tt.g, tt.b)
tolerance := 0.01
if math.Abs(h-tt.h) > tolerance || math.Abs(s-tt.s) > tolerance || math.Abs(l-tt.l) > tolerance {
t.Errorf("RGBToHSL(%d, %d, %d) = (%f, %f, %f), want approximately (%f, %f, %f)",
tt.r, tt.g, tt.b, h, s, l, tt.h, tt.s, tt.l)
}
})
}
}
func TestGenerateColor(t *testing.T) {
config := DefaultColorConfig()
// Test color generation with mid-range lightness
color := GenerateColor(0.0, config, 0.5) // Red hue, mid lightness
// Should be approximately red with default saturation (0.5) and mid lightness (0.6)
expectedLightness := config.ColorLightness.GetLightness(0.5) // Should be 0.6
tolerance := 0.01
if math.Abs(color.H-0.0) > tolerance {
t.Errorf("GenerateColor hue = %f, want approximately 0.0", color.H)
}
if math.Abs(color.S-config.ColorSaturation) > tolerance {
t.Errorf("GenerateColor saturation = %f, want %f", color.S, config.ColorSaturation)
}
if math.Abs(color.L-expectedLightness) > tolerance {
t.Errorf("GenerateColor lightness = %f, want approximately %f", color.L, expectedLightness)
}
}
func TestGenerateGrayscale(t *testing.T) {
config := DefaultColorConfig()
// Test grayscale generation
color := GenerateGrayscale(config, 0.5)
// Should be grayscale (saturation 0) with mid lightness
expectedLightness := config.GrayscaleLightness.GetLightness(0.5) // Should be 0.6
tolerance := 0.01
if math.Abs(color.S-config.GrayscaleSaturation) > tolerance {
t.Errorf("GenerateGrayscale saturation = %f, want %f", color.S, config.GrayscaleSaturation)
}
if math.Abs(color.L-expectedLightness) > tolerance {
t.Errorf("GenerateGrayscale lightness = %f, want approximately %f", color.L, expectedLightness)
}
if !color.IsGrayscale() {
t.Error("GenerateGrayscale should produce a grayscale color")
}
}
func TestGenerateColorTheme(t *testing.T) {
config := DefaultColorConfig()
hue := 0.25 // Green-ish hue
theme := GenerateColorTheme(hue, config)
// Should have exactly 5 colors
if len(theme) != 5 {
t.Errorf("GenerateColorTheme returned %d colors, want 5", len(theme))
}
// Test color indices according to JavaScript implementation:
// 0: Dark gray, 1: Mid color, 2: Light gray, 3: Light color, 4: Dark color
// Index 0: Dark gray (grayscale with lightness 0)
darkGray := theme[0]
if !darkGray.IsGrayscale() {
t.Error("Theme color 0 should be grayscale (dark gray)")
}
expectedLightness := config.GrayscaleLightness.GetLightness(0)
if math.Abs(darkGray.L-expectedLightness) > 0.01 {
t.Errorf("Dark gray lightness = %f, want %f", darkGray.L, expectedLightness)
}
// Index 1: Mid color (normal color with lightness 0.5)
midColor := theme[1]
if midColor.IsGrayscale() {
t.Error("Theme color 1 should not be grayscale (mid color)")
}
expectedLightness = config.ColorLightness.GetLightness(0.5)
if math.Abs(midColor.L-expectedLightness) > 0.01 {
t.Errorf("Mid color lightness = %f, want %f", midColor.L, expectedLightness)
}
// Index 2: Light gray (grayscale with lightness 1)
lightGray := theme[2]
if !lightGray.IsGrayscale() {
t.Error("Theme color 2 should be grayscale (light gray)")
}
expectedLightness = config.GrayscaleLightness.GetLightness(1)
if math.Abs(lightGray.L-expectedLightness) > 0.01 {
t.Errorf("Light gray lightness = %f, want %f", lightGray.L, expectedLightness)
}
// Index 3: Light color (normal color with lightness 1)
lightColor := theme[3]
if lightColor.IsGrayscale() {
t.Error("Theme color 3 should not be grayscale (light color)")
}
expectedLightness = config.ColorLightness.GetLightness(1)
if math.Abs(lightColor.L-expectedLightness) > 0.01 {
t.Errorf("Light color lightness = %f, want %f", lightColor.L, expectedLightness)
}
// Index 4: Dark color (normal color with lightness 0)
darkColor := theme[4]
if darkColor.IsGrayscale() {
t.Error("Theme color 4 should not be grayscale (dark color)")
}
expectedLightness = config.ColorLightness.GetLightness(0)
if math.Abs(darkColor.L-expectedLightness) > 0.01 {
t.Errorf("Dark color lightness = %f, want %f", darkColor.L, expectedLightness)
}
// All colors should have the same hue (or close to it for grayscale)
for i, color := range theme {
if !color.IsGrayscale() { // Only check hue for non-grayscale colors
if math.Abs(color.H-hue) > 0.01 {
t.Errorf("Theme color %d hue = %f, want approximately %f", i, color.H, hue)
}
}
}
}
func TestGenerateColorThemeWithHueRestriction(t *testing.T) {
// Test with hue restriction
config := NewColorConfigBuilder().
WithHues(180). // Only allow cyan (180 degrees = 0.5 turns)
Build()
theme := GenerateColorTheme(0.25, config) // Request green, should get cyan
for i, color := range theme {
if !color.IsGrayscale() { // Only check hue for non-grayscale colors
if math.Abs(color.H-0.5) > 0.01 {
t.Errorf("Theme color %d hue = %f, want approximately 0.5 (restricted)", i, color.H)
}
}
}
}
func TestGenerateColorWithConfiguration(t *testing.T) {
// Test with custom configuration
config := NewColorConfigBuilder().
WithColorSaturation(0.8).
WithColorLightness(0.2, 0.6).
Build()
color := GenerateColor(0.33, config, 1.0) // Green hue, max lightness
tolerance := 0.01
if math.Abs(color.S-0.8) > tolerance {
t.Errorf("Custom config saturation = %f, want 0.8", color.S)
}
expectedLightness := config.ColorLightness.GetLightness(1.0) // Should be 0.6
if math.Abs(color.L-expectedLightness) > tolerance {
t.Errorf("Custom config lightness = %f, want %f", color.L, expectedLightness)
}
}
// Helper function for absolute difference
func abs(a, b int) int {
if a > b {
return a - b
}
return b - a
}

175
internal/engine/config.go Normal file
View File

@@ -0,0 +1,175 @@
package engine
import "math"
// ColorConfig represents the configuration for color generation
type ColorConfig struct {
// Saturation settings
ColorSaturation float64 // Saturation for normal colors [0, 1]
GrayscaleSaturation float64 // Saturation for grayscale colors [0, 1]
// Lightness ranges
ColorLightness LightnessRange // Lightness range for normal colors
GrayscaleLightness LightnessRange // Lightness range for grayscale colors
// Hue restrictions
Hues []float64 // Allowed hues in degrees [0, 360] or range [0, 1]. Empty means no restriction
// Background color
BackColor *Color // Background color (nil for transparent)
// Icon padding
IconPadding float64 // Padding as percentage of icon size [0, 1]
}
// LightnessRange represents a range of lightness values
type LightnessRange struct {
Min float64 // Minimum lightness [0, 1]
Max float64 // Maximum lightness [0, 1]
}
// GetLightness returns a lightness value for the given position in range [0, 1]
// where 0 returns Min and 1 returns Max
func (lr LightnessRange) GetLightness(value float64) float64 {
// Clamp value to [0, 1] range
value = clamp(value, 0, 1)
// Linear interpolation between min and max
result := lr.Min + value*(lr.Max-lr.Min)
// Clamp result to valid lightness range
return clamp(result, 0, 1)
}
// DefaultColorConfig returns the default configuration matching the JavaScript implementation
func DefaultColorConfig() ColorConfig {
return ColorConfig{
ColorSaturation: 0.5,
GrayscaleSaturation: 0.0,
ColorLightness: LightnessRange{Min: 0.4, Max: 0.8},
GrayscaleLightness: LightnessRange{Min: 0.3, Max: 0.9},
Hues: nil, // No hue restriction
BackColor: nil, // Transparent background
IconPadding: 0.08,
}
}
// RestrictHue applies hue restrictions to the given hue value
// Returns the restricted hue in range [0, 1]
func (c ColorConfig) RestrictHue(originalHue float64) float64 {
// Normalize hue to [0, 1) range
hue := math.Mod(originalHue, 1.0)
if hue < 0 {
hue += 1.0
}
// If no hue restrictions, return original
if len(c.Hues) == 0 {
return hue
}
// Find the closest allowed hue
// originalHue is in range [0, 1], multiply by 0.999 to get range [0, 1)
// then truncate to get index
index := int((0.999 * hue * float64(len(c.Hues))))
if index >= len(c.Hues) {
index = len(c.Hues) - 1
}
restrictedHue := c.Hues[index]
// Convert from degrees to turns in range [0, 1)
// Handle any turn - e.g. 746° is valid
result := math.Mod(restrictedHue/360.0, 1.0)
if result < 0 {
result += 1.0
}
return result
}
// ValidateConfig validates and corrects a ColorConfig to ensure all values are within valid ranges
func (c *ColorConfig) Validate() {
// Clamp saturation values
c.ColorSaturation = clamp(c.ColorSaturation, 0, 1)
c.GrayscaleSaturation = clamp(c.GrayscaleSaturation, 0, 1)
// Validate lightness ranges
c.ColorLightness.Min = clamp(c.ColorLightness.Min, 0, 1)
c.ColorLightness.Max = clamp(c.ColorLightness.Max, 0, 1)
if c.ColorLightness.Min > c.ColorLightness.Max {
c.ColorLightness.Min, c.ColorLightness.Max = c.ColorLightness.Max, c.ColorLightness.Min
}
c.GrayscaleLightness.Min = clamp(c.GrayscaleLightness.Min, 0, 1)
c.GrayscaleLightness.Max = clamp(c.GrayscaleLightness.Max, 0, 1)
if c.GrayscaleLightness.Min > c.GrayscaleLightness.Max {
c.GrayscaleLightness.Min, c.GrayscaleLightness.Max = c.GrayscaleLightness.Max, c.GrayscaleLightness.Min
}
// Clamp icon padding
c.IconPadding = clamp(c.IconPadding, 0, 1)
// Validate hues (no need to clamp as RestrictHue handles normalization)
}
// ColorConfigBuilder provides a fluent interface for building ColorConfig
type ColorConfigBuilder struct {
config ColorConfig
}
// NewColorConfigBuilder creates a new builder with default values
func NewColorConfigBuilder() *ColorConfigBuilder {
return &ColorConfigBuilder{
config: DefaultColorConfig(),
}
}
// WithColorSaturation sets the color saturation
func (b *ColorConfigBuilder) WithColorSaturation(saturation float64) *ColorConfigBuilder {
b.config.ColorSaturation = saturation
return b
}
// WithGrayscaleSaturation sets the grayscale saturation
func (b *ColorConfigBuilder) WithGrayscaleSaturation(saturation float64) *ColorConfigBuilder {
b.config.GrayscaleSaturation = saturation
return b
}
// WithColorLightness sets the color lightness range
func (b *ColorConfigBuilder) WithColorLightness(min, max float64) *ColorConfigBuilder {
b.config.ColorLightness = LightnessRange{Min: min, Max: max}
return b
}
// WithGrayscaleLightness sets the grayscale lightness range
func (b *ColorConfigBuilder) WithGrayscaleLightness(min, max float64) *ColorConfigBuilder {
b.config.GrayscaleLightness = LightnessRange{Min: min, Max: max}
return b
}
// WithHues sets the allowed hues in degrees
func (b *ColorConfigBuilder) WithHues(hues ...float64) *ColorConfigBuilder {
b.config.Hues = make([]float64, len(hues))
copy(b.config.Hues, hues)
return b
}
// WithBackColor sets the background color
func (b *ColorConfigBuilder) WithBackColor(color Color) *ColorConfigBuilder {
b.config.BackColor = &color
return b
}
// WithIconPadding sets the icon padding
func (b *ColorConfigBuilder) WithIconPadding(padding float64) *ColorConfigBuilder {
b.config.IconPadding = padding
return b
}
// Build returns the configured ColorConfig after validation
func (b *ColorConfigBuilder) Build() ColorConfig {
b.config.Validate()
return b.config
}

View File

@@ -0,0 +1,218 @@
package engine
import (
"math"
"testing"
)
func TestDefaultColorConfig(t *testing.T) {
config := DefaultColorConfig()
// Test default values match JavaScript implementation
if config.ColorSaturation != 0.5 {
t.Errorf("ColorSaturation = %f, want 0.5", config.ColorSaturation)
}
if config.GrayscaleSaturation != 0.0 {
t.Errorf("GrayscaleSaturation = %f, want 0.0", config.GrayscaleSaturation)
}
if config.ColorLightness.Min != 0.4 || config.ColorLightness.Max != 0.8 {
t.Errorf("ColorLightness = {%f, %f}, want {0.4, 0.8}",
config.ColorLightness.Min, config.ColorLightness.Max)
}
if config.GrayscaleLightness.Min != 0.3 || config.GrayscaleLightness.Max != 0.9 {
t.Errorf("GrayscaleLightness = {%f, %f}, want {0.3, 0.9}",
config.GrayscaleLightness.Min, config.GrayscaleLightness.Max)
}
if len(config.Hues) != 0 {
t.Errorf("Hues should be empty by default, got %v", config.Hues)
}
if config.BackColor != nil {
t.Error("BackColor should be nil by default")
}
if config.IconPadding != 0.08 {
t.Errorf("IconPadding = %f, want 0.08", config.IconPadding)
}
}
func TestLightnessRangeGetLightness(t *testing.T) {
lr := LightnessRange{Min: 0.3, Max: 0.9}
tests := []struct {
value float64
expected float64
}{
{0.0, 0.3}, // Min value
{1.0, 0.9}, // Max value
{0.5, 0.6}, // Middle value: 0.3 + 0.5 * (0.9 - 0.3) = 0.6
{-0.5, 0.3}, // Below range, should clamp to min
{1.5, 0.9}, // Above range, should clamp to max
}
for _, tt := range tests {
result := lr.GetLightness(tt.value)
if math.Abs(result-tt.expected) > 0.001 {
t.Errorf("GetLightness(%f) = %f, want %f", tt.value, result, tt.expected)
}
}
}
func TestConfigRestrictHue(t *testing.T) {
tests := []struct {
name string
hues []float64
originalHue float64
expectedHue float64
}{
{
name: "no restriction",
hues: nil,
originalHue: 0.25,
expectedHue: 0.25,
},
{
name: "empty restriction",
hues: []float64{},
originalHue: 0.25,
expectedHue: 0.25,
},
{
name: "single hue restriction",
hues: []float64{180}, // 180 degrees = 0.5 turns
originalHue: 0.25,
expectedHue: 0.5,
},
{
name: "multiple hue restriction",
hues: []float64{0, 120, 240}, // Red, Green, Blue
originalHue: 0.1, // Should map to first hue (0 degrees)
expectedHue: 0.0,
},
{
name: "hue normalization - negative",
hues: []float64{90}, // 90 degrees = 0.25 turns
originalHue: -0.5,
expectedHue: 0.25,
},
{
name: "hue normalization - over 1",
hues: []float64{270}, // 270 degrees = 0.75 turns
originalHue: 1.5,
expectedHue: 0.75,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := ColorConfig{Hues: tt.hues}
result := config.RestrictHue(tt.originalHue)
if math.Abs(result-tt.expectedHue) > 0.001 {
t.Errorf("RestrictHue(%f) = %f, want %f", tt.originalHue, result, tt.expectedHue)
}
})
}
}
func TestConfigValidate(t *testing.T) {
// Test that validation corrects invalid values
config := ColorConfig{
ColorSaturation: -0.5, // Invalid: below 0
GrayscaleSaturation: 1.5, // Invalid: above 1
ColorLightness: LightnessRange{Min: 0.8, Max: 0.2}, // Invalid: min > max
GrayscaleLightness: LightnessRange{Min: -0.1, Max: 1.1}, // Invalid: out of range
IconPadding: 2.0, // Invalid: above 1
}
config.Validate()
if config.ColorSaturation != 0.0 {
t.Errorf("ColorSaturation after validation = %f, want 0.0", config.ColorSaturation)
}
if config.GrayscaleSaturation != 1.0 {
t.Errorf("GrayscaleSaturation after validation = %f, want 1.0", config.GrayscaleSaturation)
}
// Min and max should be swapped
if config.ColorLightness.Min != 0.2 || config.ColorLightness.Max != 0.8 {
t.Errorf("ColorLightness after validation = {%f, %f}, want {0.2, 0.8}",
config.ColorLightness.Min, config.ColorLightness.Max)
}
// Values should be clamped
if config.GrayscaleLightness.Min != 0.0 || config.GrayscaleLightness.Max != 1.0 {
t.Errorf("GrayscaleLightness after validation = {%f, %f}, want {0.0, 1.0}",
config.GrayscaleLightness.Min, config.GrayscaleLightness.Max)
}
if config.IconPadding != 1.0 {
t.Errorf("IconPadding after validation = %f, want 1.0", config.IconPadding)
}
}
func TestColorConfigBuilder(t *testing.T) {
redColor := NewColorRGB(255, 0, 0)
config := NewColorConfigBuilder().
WithColorSaturation(0.7).
WithGrayscaleSaturation(0.1).
WithColorLightness(0.2, 0.8).
WithGrayscaleLightness(0.1, 0.9).
WithHues(0, 120, 240).
WithBackColor(redColor).
WithIconPadding(0.1).
Build()
if config.ColorSaturation != 0.7 {
t.Errorf("ColorSaturation = %f, want 0.7", config.ColorSaturation)
}
if config.GrayscaleSaturation != 0.1 {
t.Errorf("GrayscaleSaturation = %f, want 0.1", config.GrayscaleSaturation)
}
if config.ColorLightness.Min != 0.2 || config.ColorLightness.Max != 0.8 {
t.Errorf("ColorLightness = {%f, %f}, want {0.2, 0.8}",
config.ColorLightness.Min, config.ColorLightness.Max)
}
if config.GrayscaleLightness.Min != 0.1 || config.GrayscaleLightness.Max != 0.9 {
t.Errorf("GrayscaleLightness = {%f, %f}, want {0.1, 0.9}",
config.GrayscaleLightness.Min, config.GrayscaleLightness.Max)
}
if len(config.Hues) != 3 || config.Hues[0] != 0 || config.Hues[1] != 120 || config.Hues[2] != 240 {
t.Errorf("Hues = %v, want [0, 120, 240]", config.Hues)
}
if config.BackColor == nil || !config.BackColor.Equals(redColor) {
t.Error("BackColor should be set to red")
}
if config.IconPadding != 0.1 {
t.Errorf("IconPadding = %f, want 0.1", config.IconPadding)
}
}
func TestColorConfigBuilderValidation(t *testing.T) {
// Test that builder validates configuration
config := NewColorConfigBuilder().
WithColorSaturation(-0.5). // Invalid
WithGrayscaleSaturation(1.5). // Invalid
Build()
// Should be corrected by validation
if config.ColorSaturation != 0.0 {
t.Errorf("ColorSaturation = %f, want 0.0 (corrected)", config.ColorSaturation)
}
if config.GrayscaleSaturation != 1.0 {
t.Errorf("GrayscaleSaturation = %f, want 1.0 (corrected)", config.GrayscaleSaturation)
}
}

View File

@@ -0,0 +1,353 @@
package engine
import (
"fmt"
"sync"
"github.com/kevin/go-jdenticon/internal/util"
)
// 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
}
// Generator encapsulates the icon generation logic and provides caching
type Generator struct {
config ColorConfig
cache map[string]*Icon
mu sync.RWMutex
}
// NewGenerator creates a new Generator with the specified configuration
func NewGenerator(config ColorConfig) *Generator {
config.Validate()
return &Generator{
config: config,
cache: make(map[string]*Icon),
}
}
// NewDefaultGenerator creates a new Generator with default configuration
func NewDefaultGenerator() *Generator {
return NewGenerator(DefaultColorConfig())
}
// Generate creates an icon from a hash string using the configured settings
func (g *Generator) Generate(hash string, size float64) (*Icon, error) {
if hash == "" {
return nil, fmt.Errorf("hash cannot be empty")
}
if size <= 0 {
return nil, fmt.Errorf("size must be positive, got %f", size)
}
// Check cache first
cacheKey := g.cacheKey(hash, size)
g.mu.RLock()
if cached, exists := g.cache[cacheKey]; exists {
g.mu.RUnlock()
return cached, nil
}
g.mu.RUnlock()
// Validate hash format
if !util.IsValidHash(hash) {
return nil, fmt.Errorf("invalid hash format: %s", hash)
}
// Generate new icon
icon, err := g.generateIcon(hash, size)
if err != nil {
return nil, err
}
// Cache the result
g.mu.Lock()
g.cache[cacheKey] = icon
g.mu.Unlock()
return icon, nil
}
// generateIcon performs the actual icon generation
func (g *Generator) generateIcon(hash string, size float64) (*Icon, error) {
// Calculate padding and round to nearest integer (matching JavaScript)
padding := int((0.5 + size*g.config.IconPadding))
iconSize := size - float64(padding*2)
// Calculate cell size and ensure it is an integer (matching JavaScript)
cell := int(iconSize / 4)
// Since the cell size is integer based, the actual icon will be slightly smaller than specified => center icon
x := int(float64(padding) + iconSize/2 - float64(cell*2))
y := int(float64(padding) + iconSize/2 - float64(cell*2))
// Extract hue from hash (last 7 characters)
hue, err := g.extractHue(hash)
if err != nil {
return nil, fmt.Errorf("generateIcon: %w", err)
}
// Generate color theme
availableColors := GenerateColorTheme(hue, g.config)
// Select colors for each shape layer
selectedColorIndexes, err := g.selectColors(hash, availableColors)
if err != nil {
return nil, err
}
// Generate shape groups in exact JavaScript order
shapeGroups := make([]ShapeGroup, 0, 3)
// 1. Sides (outer edges) - renderShape(0, outerShape, 2, 3, [[1, 0], [2, 0], [2, 3], [1, 3], [0, 1], [3, 1], [3, 2], [0, 2]]);
sideShapes, err := g.renderShape(hash, 0, 2, 3,
[][]int{{1, 0}, {2, 0}, {2, 3}, {1, 3}, {0, 1}, {3, 1}, {3, 2}, {0, 2}},
x, y, cell, true)
if err != nil {
return nil, fmt.Errorf("generateIcon: failed to render side shapes: %w", err)
}
if len(sideShapes) > 0 {
shapeGroups = append(shapeGroups, ShapeGroup{
Color: availableColors[selectedColorIndexes[0]],
Shapes: sideShapes,
ShapeType: "sides",
})
}
// 2. Corners - renderShape(1, outerShape, 4, 5, [[0, 0], [3, 0], [3, 3], [0, 3]]);
cornerShapes, err := g.renderShape(hash, 1, 4, 5,
[][]int{{0, 0}, {3, 0}, {3, 3}, {0, 3}},
x, y, cell, true)
if err != nil {
return nil, fmt.Errorf("generateIcon: failed to render corner shapes: %w", err)
}
if len(cornerShapes) > 0 {
shapeGroups = append(shapeGroups, ShapeGroup{
Color: availableColors[selectedColorIndexes[1]],
Shapes: cornerShapes,
ShapeType: "corners",
})
}
// 3. Center - renderShape(2, centerShape, 1, null, [[1, 1], [2, 1], [2, 2], [1, 2]]);
centerShapes, err := g.renderShape(hash, 2, 1, -1,
[][]int{{1, 1}, {2, 1}, {2, 2}, {1, 2}},
x, y, cell, false)
if err != nil {
return nil, fmt.Errorf("generateIcon: failed to render center shapes: %w", err)
}
if len(centerShapes) > 0 {
shapeGroups = append(shapeGroups, ShapeGroup{
Color: availableColors[selectedColorIndexes[2]],
Shapes: centerShapes,
ShapeType: "center",
})
}
return &Icon{
Hash: hash,
Size: size,
Config: g.config,
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
hueValue, err := util.ParseHex(hash, -7, 7)
if err != nil {
return 0, fmt.Errorf("extractHue: %w", err)
}
return float64(hueValue) / 0xfffffff, nil
}
// selectColors selects 3 colors from the available color palette
func (g *Generator) selectColors(hash string, availableColors []Color) ([]int, error) {
if len(availableColors) == 0 {
return nil, fmt.Errorf("no available colors")
}
selectedIndexes := make([]int, 3)
for i := 0; i < 3; i++ {
indexValue, err := util.ParseHex(hash, 8+i, 1)
if err != nil {
return nil, fmt.Errorf("selectColors: failed to parse color index at position %d: %w", 8+i, err)
}
index := indexValue % len(availableColors)
// Apply color conflict resolution rules from JavaScript implementation
if g.isDuplicateColor(index, selectedIndexes[:i], []int{0, 4}) || // Disallow dark gray and dark color combo
g.isDuplicateColor(index, selectedIndexes[:i], []int{2, 3}) { // Disallow light gray and light color combo
index = 1 // Use mid color as fallback
}
selectedIndexes[i] = index
}
return selectedIndexes, nil
}
// contains checks if a slice contains a specific value
func contains(slice []int, value int) bool {
for _, item := range slice {
if item == value {
return true
}
}
return false
}
// isDuplicateColor checks for problematic color combinations
func (g *Generator) isDuplicateColor(index int, selected []int, forbidden []int) bool {
if !contains(forbidden, index) {
return false
}
for _, s := range selected {
if contains(forbidden, s) {
return true
}
}
return false
}
// renderShape implements the JavaScript renderShape function exactly
func (g *Generator) renderShape(hash string, colorIndex, shapeHashIndex, rotationHashIndex int, positions [][]int, x, y, cell int, isOuter bool) ([]Shape, error) {
shapeIndexValue, err := util.ParseHex(hash, shapeHashIndex, 1)
if err != nil {
return nil, fmt.Errorf("renderShape: failed to parse shape index at position %d: %w", shapeHashIndex, err)
}
shapeIndex := shapeIndexValue
var rotation int
if rotationHashIndex >= 0 {
rotationValue, err := util.ParseHex(hash, rotationHashIndex, 1)
if err != nil {
return nil, fmt.Errorf("renderShape: failed to parse rotation at position %d: %w", rotationHashIndex, err)
}
rotation = rotationValue
}
shapes := make([]Shape, 0, len(positions))
for i, pos := range positions {
// Calculate transform exactly like JavaScript: new Transform(x + positions[i][0] * cell, y + positions[i][1] * cell, cell, r++ % 4)
transformX := float64(x + pos[0]*cell)
transformY := float64(y + pos[1]*cell)
var transformRotation int
if rotationHashIndex >= 0 {
transformRotation = (rotation + i) % 4
} else {
// For center shapes (rotationIndex is null), r starts at 0 and increments
transformRotation = i % 4
}
transform := NewTransform(transformX, transformY, float64(cell), transformRotation)
// Create shape using graphics with transform
graphics := NewGraphicsWithTransform(&shapeCollector{}, transform)
if isOuter {
RenderOuterShape(graphics, shapeIndex, float64(cell))
} else {
RenderCenterShape(graphics, shapeIndex, float64(cell), float64(i))
}
collector := graphics.renderer.(*shapeCollector)
for _, shape := range collector.shapes {
shapes = append(shapes, shape)
}
}
return shapes, nil
}
// shapeCollector implements Renderer interface to collect shapes during generation
type shapeCollector struct {
shapes []Shape
}
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,
})
}
// cacheKey generates a cache key for the given parameters
func (g *Generator) cacheKey(hash string, size float64) string {
return fmt.Sprintf("%s:%.2f", hash, size)
}
// ClearCache clears the internal cache
func (g *Generator) ClearCache() {
g.mu.Lock()
defer g.mu.Unlock()
g.cache = make(map[string]*Icon)
}
// GetCacheSize returns the number of cached icons
func (g *Generator) GetCacheSize() int {
g.mu.RLock()
defer g.mu.RUnlock()
return len(g.cache)
}
// SetConfig updates the generator configuration and clears cache
func (g *Generator) SetConfig(config ColorConfig) {
config.Validate()
g.mu.Lock()
g.config = config
g.cache = make(map[string]*Icon)
g.mu.Unlock()
}
// GetConfig returns a copy of the current configuration
func (g *Generator) GetConfig() ColorConfig {
g.mu.RLock()
defer g.mu.RUnlock()
return g.config
}

View File

@@ -0,0 +1,517 @@
package engine
import (
"testing"
"github.com/kevin/go-jdenticon/internal/util"
)
func TestNewGenerator(t *testing.T) {
config := DefaultColorConfig()
generator := NewGenerator(config)
if generator == nil {
t.Fatal("NewGenerator returned nil")
}
if generator.config.IconPadding != config.IconPadding {
t.Errorf("Expected icon padding %f, got %f", config.IconPadding, generator.config.IconPadding)
}
if generator.cache == nil {
t.Error("Generator cache was not initialized")
}
}
func TestNewDefaultGenerator(t *testing.T) {
generator := NewDefaultGenerator()
if generator == nil {
t.Fatal("NewDefaultGenerator returned nil")
}
expectedConfig := DefaultColorConfig()
if generator.config.IconPadding != expectedConfig.IconPadding {
t.Errorf("Expected icon padding %f, got %f", expectedConfig.IconPadding, generator.config.IconPadding)
}
}
func TestGenerateValidHash(t *testing.T) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
icon, err := generator.Generate(hash, size)
if err != nil {
t.Fatalf("Generate failed with error: %v", err)
}
if icon == nil {
t.Fatal("Generate returned nil icon")
}
if icon.Hash != hash {
t.Errorf("Expected hash %s, got %s", hash, icon.Hash)
}
if icon.Size != size {
t.Errorf("Expected size %f, got %f", size, icon.Size)
}
if len(icon.Shapes) == 0 {
t.Error("Generated icon has no shapes")
}
}
func TestGenerateInvalidInputs(t *testing.T) {
generator := NewDefaultGenerator()
tests := []struct {
name string
hash string
size float64
wantErr bool
}{
{
name: "empty hash",
hash: "",
size: 64.0,
wantErr: true,
},
{
name: "zero size",
hash: "abcdef123456789",
size: 0.0,
wantErr: true,
},
{
name: "negative size",
hash: "abcdef123456789",
size: -10.0,
wantErr: true,
},
{
name: "short hash",
hash: "abc",
size: 64.0,
wantErr: true,
},
{
name: "invalid hex characters",
hash: "xyz123456789abc",
size: 64.0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := generator.Generate(tt.hash, tt.size)
if (err != nil) != tt.wantErr {
t.Errorf("Generate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestGenerateCaching(t *testing.T) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
// Generate icon first time
icon1, err := generator.Generate(hash, size)
if err != nil {
t.Fatalf("First generate failed: %v", err)
}
// Check cache size
if generator.GetCacheSize() != 1 {
t.Errorf("Expected cache size 1, got %d", generator.GetCacheSize())
}
// Generate same icon again
icon2, err := generator.Generate(hash, size)
if err != nil {
t.Fatalf("Second generate failed: %v", err)
}
// Should be the same instance from cache
if icon1 != icon2 {
t.Error("Second generate did not return cached instance")
}
// Cache size should still be 1
if generator.GetCacheSize() != 1 {
t.Errorf("Expected cache size 1 after second generate, got %d", generator.GetCacheSize())
}
}
func TestClearCache(t *testing.T) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
// Generate an icon to populate cache
_, err := generator.Generate(hash, size)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify cache has content
if generator.GetCacheSize() == 0 {
t.Error("Cache should not be empty after generate")
}
// Clear cache
generator.ClearCache()
// Verify cache is empty
if generator.GetCacheSize() != 0 {
t.Errorf("Expected cache size 0 after clear, got %d", generator.GetCacheSize())
}
}
func TestSetConfig(t *testing.T) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
// Generate an icon to populate cache
_, err := generator.Generate(hash, size)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Verify cache has content
if generator.GetCacheSize() == 0 {
t.Error("Cache should not be empty after generate")
}
// Set new config
newConfig := DefaultColorConfig()
newConfig.IconPadding = 0.1
generator.SetConfig(newConfig)
// Verify config was updated
if generator.GetConfig().IconPadding != 0.1 {
t.Errorf("Expected icon padding 0.1, got %f", generator.GetConfig().IconPadding)
}
// Verify cache was cleared
if generator.GetCacheSize() != 0 {
t.Errorf("Expected cache size 0 after config change, got %d", generator.GetCacheSize())
}
}
func TestExtractHue(t *testing.T) {
generator := NewDefaultGenerator()
tests := []struct {
name string
hash string
expected float64
tolerance float64
}{
{
name: "all zeros",
hash: "0000000000000000000",
expected: 0.0,
tolerance: 0.0001,
},
{
name: "all fs",
hash: "ffffffffffffffffff",
expected: 1.0,
tolerance: 0.0001,
},
{
name: "half value",
hash: "000000000007ffffff",
expected: 0.5,
tolerance: 0.001, // Allow small floating point variance
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := generator.extractHue(tt.hash)
if err != nil {
t.Fatalf("extractHue failed: %v", err)
}
diff := result - tt.expected
if diff < 0 {
diff = -diff
}
if diff > tt.tolerance {
t.Errorf("Expected hue %f, got %f (tolerance %f)", tt.expected, result, tt.tolerance)
}
})
}
}
func TestSelectColors(t *testing.T) {
generator := NewDefaultGenerator()
hash := "123456789abcdef"
// Create test color palette
availableColors := []Color{
NewColorRGB(50, 50, 50), // 0: Dark gray
NewColorRGB(100, 100, 200), // 1: Mid color
NewColorRGB(200, 200, 200), // 2: Light gray
NewColorRGB(150, 150, 255), // 3: Light color
NewColorRGB(25, 25, 100), // 4: Dark color
}
selectedIndexes, err := generator.selectColors(hash, availableColors)
if err != nil {
t.Fatalf("selectColors failed: %v", err)
}
if len(selectedIndexes) != 3 {
t.Fatalf("Expected 3 selected colors, got %d", len(selectedIndexes))
}
for i, index := range selectedIndexes {
if index < 0 || index >= len(availableColors) {
t.Errorf("Color index %d at position %d is out of range [0, %d)", index, i, len(availableColors))
}
}
}
func TestSelectColorsEmptyPalette(t *testing.T) {
generator := NewDefaultGenerator()
hash := "123456789abcdef"
_, err := generator.selectColors(hash, []Color{})
if err == nil {
t.Error("Expected error for empty color palette")
}
}
func TestIsValidHash(t *testing.T) {
tests := []struct {
name string
hash string
valid bool
}{
{
name: "valid hash",
hash: "abcdef123456789",
valid: true,
},
{
name: "too short",
hash: "abc",
valid: false,
},
{
name: "invalid characters",
hash: "xyz123456789abc",
valid: false,
},
{
name: "uppercase valid",
hash: "ABCDEF123456789",
valid: true,
},
{
name: "mixed case valid",
hash: "AbCdEf123456789",
valid: true,
},
{
name: "empty",
hash: "",
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := util.IsValidHash(tt.hash)
if result != tt.valid {
t.Errorf("Expected isValidHash(%s) = %v, got %v", tt.hash, tt.valid, result)
}
})
}
}
func TestParseHex(t *testing.T) {
hash := "123456789abcdef"
tests := []struct {
name string
start int
octets int
expected int
wantErr bool
}{
{
name: "single character",
start: 0,
octets: 1,
expected: 1,
wantErr: false,
},
{
name: "two characters",
start: 1,
octets: 2,
expected: 0x23,
wantErr: false,
},
{
name: "negative index",
start: -1,
octets: 1,
expected: 0xf,
wantErr: false,
},
{
name: "out of bounds",
start: 100,
octets: 1,
expected: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := util.ParseHex(hash, tt.start, tt.octets)
if tt.wantErr {
if err == nil {
t.Errorf("Expected an error, but got nil")
}
return // Test is done for error cases
}
if err != nil {
t.Fatalf("parseHex failed unexpectedly: %v", err)
}
if result != tt.expected {
t.Errorf("Expected %d, got %d", tt.expected, result)
}
})
}
}
func TestShapeCollector(t *testing.T) {
collector := &shapeCollector{}
// Test AddPolygon
points := []Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}, {X: 0, Y: 1}}
collector.AddPolygon(points)
if len(collector.shapes) != 1 {
t.Fatalf("Expected 1 shape after AddPolygon, got %d", len(collector.shapes))
}
shape := collector.shapes[0]
if shape.Type != "polygon" {
t.Errorf("Expected shape type 'polygon', got '%s'", shape.Type)
}
if len(shape.Points) != len(points) {
t.Errorf("Expected %d points, got %d", len(points), len(shape.Points))
}
// Test AddCircle
center := Point{X: 5, Y: 5}
radius := 2.5
collector.AddCircle(center, radius, false)
if len(collector.shapes) != 2 {
t.Fatalf("Expected 2 shapes after AddCircle, got %d", len(collector.shapes))
}
circleShape := collector.shapes[1]
if circleShape.Type != "circle" {
t.Errorf("Expected shape type 'circle', got '%s'", circleShape.Type)
}
// Verify circle fields are set correctly
if circleShape.CircleX != center.X {
t.Errorf("Expected CircleX %f, got %f", center.X, circleShape.CircleX)
}
if circleShape.CircleY != center.Y {
t.Errorf("Expected CircleY %f, got %f", center.Y, circleShape.CircleY)
}
if circleShape.CircleSize != radius {
t.Errorf("Expected CircleSize %f, got %f", radius, circleShape.CircleSize)
}
if circleShape.Invert != false {
t.Errorf("Expected Invert false, got %t", circleShape.Invert)
}
}
func BenchmarkGenerate(b *testing.B) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := generator.Generate(hash, size)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
}
}
func BenchmarkGenerateWithCache(b *testing.B) {
generator := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
// Pre-populate cache
_, err := generator.Generate(hash, size)
if err != nil {
b.Fatalf("Initial generate failed: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := generator.Generate(hash, size)
if err != nil {
b.Fatalf("Generate failed: %v", err)
}
}
}
func TestConsistentGeneration(t *testing.T) {
generator1 := NewDefaultGenerator()
generator2 := NewDefaultGenerator()
hash := "abcdef123456789"
size := 64.0
icon1, err := generator1.Generate(hash, size)
if err != nil {
t.Fatalf("Generator1 failed: %v", err)
}
icon2, err := generator2.Generate(hash, size)
if err != nil {
t.Fatalf("Generator2 failed: %v", err)
}
// Icons should have same number of shape groups
if len(icon1.Shapes) != len(icon2.Shapes) {
t.Errorf("Different number of shape groups: %d vs %d", len(icon1.Shapes), len(icon2.Shapes))
}
// Colors should be the same
for i := range icon1.Shapes {
if i >= len(icon2.Shapes) {
break
}
if !icon1.Shapes[i].Color.Equals(icon2.Shapes[i].Color) {
t.Errorf("Different colors at group %d", i)
}
}
}

136
internal/engine/layout.go Normal file
View File

@@ -0,0 +1,136 @@
package engine
// Grid represents a 4x4 layout grid for positioning shapes in a jdenticon
type Grid struct {
Size float64
Cell int
X int
Y int
Padding int
}
// Position represents an x, y coordinate pair
type Position struct {
X, Y int
}
// NewGrid creates a new Grid with the specified icon size and padding ratio
func NewGrid(iconSize float64, paddingRatio float64) *Grid {
// Calculate padding and round to nearest integer (matches JS: (0.5 + size * parsedConfig.iconPadding) | 0)
padding := int(0.5 + iconSize*paddingRatio)
size := iconSize - float64(padding*2)
// Calculate cell size and ensure it is an integer (matches JS: 0 | (size / 4))
cell := int(size / 4)
// Center the icon since cell size is integer-based (matches JS implementation)
// Since the cell size is integer based, the actual icon will be slightly smaller than specified => center icon
x := padding + int((size - float64(cell*4))/2)
y := padding + int((size - float64(cell*4))/2)
return &Grid{
Size: size,
Cell: cell,
X: x,
Y: y,
Padding: padding,
}
}
// CellToCoordinate converts a grid cell position to actual coordinates
func (g *Grid) CellToCoordinate(cellX, cellY int) (x, y float64) {
return float64(g.X + cellX*g.Cell), float64(g.Y + cellY*g.Cell)
}
// GetCellSize returns the size of each cell in the grid
func (g *Grid) GetCellSize() float64 {
return float64(g.Cell)
}
// LayoutEngine manages the overall layout and positioning of icon elements
type LayoutEngine struct {
grid *Grid
}
// NewLayoutEngine creates a new LayoutEngine with the specified parameters
func NewLayoutEngine(iconSize float64, paddingRatio float64) *LayoutEngine {
return &LayoutEngine{
grid: NewGrid(iconSize, paddingRatio),
}
}
// Grid returns the underlying grid
func (le *LayoutEngine) Grid() *Grid {
return le.grid
}
// GetShapePositions returns the positions for different shape types based on the jdenticon pattern
func (le *LayoutEngine) GetShapePositions(shapeType string) []Position {
switch shapeType {
case "sides":
// Sides: positions around the perimeter (8 positions)
return []Position{
{1, 0}, {2, 0}, {2, 3}, {1, 3}, // top and bottom
{0, 1}, {3, 1}, {3, 2}, {0, 2}, // left and right
}
case "corners":
// Corners: four corner positions
return []Position{
{0, 0}, {3, 0}, {3, 3}, {0, 3},
}
case "center":
// Center: four center positions
return []Position{
{1, 1}, {2, 1}, {2, 2}, {1, 2},
}
default:
return []Position{}
}
}
// ApplySymmetry applies symmetrical transformations to position indices
// This ensures the icon has the characteristic jdenticon symmetry
func ApplySymmetry(positions []Position, index int) []Position {
if index >= len(positions) {
return positions
}
// For jdenticon, we apply rotational symmetry
// The pattern is designed to be symmetrical, so we don't need to modify positions
// The symmetry is achieved through the predefined position arrays
return positions
}
// GetTransformedPosition applies rotation and returns the final position
func (le *LayoutEngine) GetTransformedPosition(cellX, cellY int, rotation int) (x, y float64, cellSize float64) {
// Apply rotation if needed (rotation is 0-3 for 0°, 90°, 180°, 270°)
switch rotation % 4 {
case 0: // 0°
// No rotation
case 1: // 90° clockwise
cellX, cellY = cellY, 3-cellX
case 2: // 180°
cellX, cellY = 3-cellX, 3-cellY
case 3: // 270° clockwise (90° counter-clockwise)
cellX, cellY = 3-cellY, cellX
}
x, y = le.grid.CellToCoordinate(cellX, cellY)
cellSize = le.grid.GetCellSize()
return
}
// ValidateGrid checks if the grid configuration is valid
func (g *Grid) ValidateGrid() bool {
return g.Cell > 0 && g.Size > 0 && g.Padding >= 0
}
// GetIconBounds returns the bounds of the icon within the grid
func (g *Grid) GetIconBounds() (x, y, width, height float64) {
return float64(g.X), float64(g.Y), float64(g.Cell * 4), float64(g.Cell * 4)
}
// GetCenterOffset returns the offset needed to center content within a cell
func (g *Grid) GetCenterOffset() (dx, dy float64) {
return float64(g.Cell) / 2, float64(g.Cell) / 2
}

View File

@@ -0,0 +1,380 @@
package engine
import (
"math"
"testing"
)
func TestNewGrid(t *testing.T) {
tests := []struct {
name string
iconSize float64
paddingRatio float64
wantPadding int
wantCell int
}{
{
name: "standard 64px icon with 8% padding",
iconSize: 64.0,
paddingRatio: 0.08,
wantPadding: 5, // 0.5 + 64 * 0.08 = 5.62, rounded to 5
wantCell: 13, // (64 - 5*2) / 4 = 54/4 = 13.5, truncated to 13
},
{
name: "large 256px icon with 10% padding",
iconSize: 256.0,
paddingRatio: 0.10,
wantPadding: 26, // 0.5 + 256 * 0.10 = 26.1, rounded to 26
wantCell: 51, // (256 - 26*2) / 4 = 204/4 = 51
},
{
name: "small 32px icon with 5% padding",
iconSize: 32.0,
paddingRatio: 0.05,
wantPadding: 2, // 0.5 + 32 * 0.05 = 2.1, rounded to 2
wantCell: 7, // (32 - 2*2) / 4 = 28/4 = 7
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
grid := NewGrid(tt.iconSize, tt.paddingRatio)
if grid.Padding != tt.wantPadding {
t.Errorf("NewGrid() padding = %v, want %v", grid.Padding, tt.wantPadding)
}
if grid.Cell != tt.wantCell {
t.Errorf("NewGrid() cell = %v, want %v", grid.Cell, tt.wantCell)
}
// Verify that the grid is centered
expectedSize := tt.iconSize - float64(tt.wantPadding*2)
if math.Abs(grid.Size-expectedSize) > 0.1 {
t.Errorf("NewGrid() size = %v, want %v", grid.Size, expectedSize)
}
})
}
}
func TestGridCellToCoordinate(t *testing.T) {
grid := NewGrid(64.0, 0.08)
tests := []struct {
name string
cellX int
cellY int
wantX float64
wantY float64
}{
{
name: "origin cell (0,0)",
cellX: 0,
cellY: 0,
wantX: float64(grid.X),
wantY: float64(grid.Y),
},
{
name: "center cell (1,1)",
cellX: 1,
cellY: 1,
wantX: float64(grid.X + grid.Cell),
wantY: float64(grid.Y + grid.Cell),
},
{
name: "corner cell (3,3)",
cellX: 3,
cellY: 3,
wantX: float64(grid.X + 3*grid.Cell),
wantY: float64(grid.Y + 3*grid.Cell),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotX, gotY := grid.CellToCoordinate(tt.cellX, tt.cellY)
if gotX != tt.wantX {
t.Errorf("CellToCoordinate() x = %v, want %v", gotX, tt.wantX)
}
if gotY != tt.wantY {
t.Errorf("CellToCoordinate() y = %v, want %v", gotY, tt.wantY)
}
})
}
}
func TestLayoutEngineGetShapePositions(t *testing.T) {
le := NewLayoutEngine(64.0, 0.08)
tests := []struct {
name string
shapeType string
wantLen int
wantFirst Position
wantLast Position
}{
{
name: "sides positions",
shapeType: "sides",
wantLen: 8,
wantFirst: Position{1, 0},
wantLast: Position{0, 2},
},
{
name: "corners positions",
shapeType: "corners",
wantLen: 4,
wantFirst: Position{0, 0},
wantLast: Position{0, 3},
},
{
name: "center positions",
shapeType: "center",
wantLen: 4,
wantFirst: Position{1, 1},
wantLast: Position{1, 2},
},
{
name: "invalid shape type",
shapeType: "invalid",
wantLen: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
positions := le.GetShapePositions(tt.shapeType)
if len(positions) != tt.wantLen {
t.Errorf("GetShapePositions() len = %v, want %v", len(positions), tt.wantLen)
}
if tt.wantLen > 0 {
if positions[0] != tt.wantFirst {
t.Errorf("GetShapePositions() first = %v, want %v", positions[0], tt.wantFirst)
}
if positions[len(positions)-1] != tt.wantLast {
t.Errorf("GetShapePositions() last = %v, want %v", positions[len(positions)-1], tt.wantLast)
}
}
})
}
}
func TestLayoutEngineGetTransformedPosition(t *testing.T) {
le := NewLayoutEngine(64.0, 0.08)
tests := []struct {
name string
cellX int
cellY int
rotation int
wantX int // Expected cell X after rotation
wantY int // Expected cell Y after rotation
}{
{
name: "no rotation",
cellX: 1,
cellY: 0,
rotation: 0,
wantX: 1,
wantY: 0,
},
{
name: "90 degree rotation",
cellX: 1,
cellY: 0,
rotation: 1,
wantX: 0,
wantY: 2, // 3-1 = 2
},
{
name: "180 degree rotation",
cellX: 1,
cellY: 0,
rotation: 2,
wantX: 2, // 3-1 = 2
wantY: 3, // 3-0 = 3
},
{
name: "270 degree rotation",
cellX: 1,
cellY: 0,
rotation: 3,
wantX: 3, // 3-0 = 3
wantY: 1,
},
{
name: "rotation overflow (4 = 0)",
cellX: 1,
cellY: 0,
rotation: 4,
wantX: 1,
wantY: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotX, gotY, gotCellSize := le.GetTransformedPosition(tt.cellX, tt.cellY, tt.rotation)
// Convert back to cell coordinates to verify rotation
expectedX, expectedY := le.grid.CellToCoordinate(tt.wantX, tt.wantY)
if gotX != expectedX {
t.Errorf("GetTransformedPosition() x = %v, want %v", gotX, expectedX)
}
if gotY != expectedY {
t.Errorf("GetTransformedPosition() y = %v, want %v", gotY, expectedY)
}
if gotCellSize != float64(le.grid.Cell) {
t.Errorf("GetTransformedPosition() cellSize = %v, want %v", gotCellSize, float64(le.grid.Cell))
}
})
}
}
func TestApplySymmetry(t *testing.T) {
positions := []Position{{0, 0}, {1, 0}, {2, 0}, {3, 0}}
tests := []struct {
name string
index int
want int // expected length
}{
{
name: "valid index",
index: 1,
want: 4,
},
{
name: "index out of bounds",
index: 10,
want: 4,
},
{
name: "negative index",
index: -1,
want: 4,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ApplySymmetry(positions, tt.index)
if len(result) != tt.want {
t.Errorf("ApplySymmetry() len = %v, want %v", len(result), tt.want)
}
// Verify that the positions are unchanged (current implementation)
for i, pos := range result {
if pos != positions[i] {
t.Errorf("ApplySymmetry() changed position at index %d: got %v, want %v", i, pos, positions[i])
}
}
})
}
}
func TestGridValidateGrid(t *testing.T) {
tests := []struct {
name string
grid *Grid
want bool
}{
{
name: "valid grid",
grid: &Grid{Size: 64, Cell: 16, Padding: 4},
want: true,
},
{
name: "zero cell size",
grid: &Grid{Size: 64, Cell: 0, Padding: 4},
want: false,
},
{
name: "zero size",
grid: &Grid{Size: 0, Cell: 16, Padding: 4},
want: false,
},
{
name: "negative padding",
grid: &Grid{Size: 64, Cell: 16, Padding: -1},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.grid.ValidateGrid(); got != tt.want {
t.Errorf("ValidateGrid() = %v, want %v", got, tt.want)
}
})
}
}
func TestGridGetIconBounds(t *testing.T) {
grid := NewGrid(64.0, 0.08)
x, y, width, height := grid.GetIconBounds()
expectedX := float64(grid.X)
expectedY := float64(grid.Y)
expectedWidth := float64(grid.Cell * 4)
expectedHeight := float64(grid.Cell * 4)
if x != expectedX {
t.Errorf("GetIconBounds() x = %v, want %v", x, expectedX)
}
if y != expectedY {
t.Errorf("GetIconBounds() y = %v, want %v", y, expectedY)
}
if width != expectedWidth {
t.Errorf("GetIconBounds() width = %v, want %v", width, expectedWidth)
}
if height != expectedHeight {
t.Errorf("GetIconBounds() height = %v, want %v", height, expectedHeight)
}
}
func TestGridGetCenterOffset(t *testing.T) {
grid := NewGrid(64.0, 0.08)
dx, dy := grid.GetCenterOffset()
expected := float64(grid.Cell) / 2
if dx != expected {
t.Errorf("GetCenterOffset() dx = %v, want %v", dx, expected)
}
if dy != expected {
t.Errorf("GetCenterOffset() dy = %v, want %v", dy, expected)
}
}
func TestNewLayoutEngine(t *testing.T) {
le := NewLayoutEngine(64.0, 0.08)
if le.grid == nil {
t.Error("NewLayoutEngine() grid is nil")
}
if le.Grid() != le.grid {
t.Error("NewLayoutEngine() Grid() does not return internal grid")
}
// Verify grid configuration
if !le.grid.ValidateGrid() {
t.Error("NewLayoutEngine() created invalid grid")
}
}

266
internal/engine/shapes.go Normal file
View File

@@ -0,0 +1,266 @@
package engine
import "math"
// Point represents a 2D point
type Point struct {
X, Y float64
}
// Renderer interface defines the methods that a renderer must implement
type Renderer interface {
AddPolygon(points []Point)
AddCircle(topLeft Point, size float64, invert bool)
}
// Graphics provides helper functions for rendering common basic shapes
type Graphics struct {
renderer Renderer
currentTransform Transform
}
// NewGraphics creates a new Graphics instance with the given renderer
func NewGraphics(renderer Renderer) *Graphics {
return &Graphics{
renderer: renderer,
currentTransform: NoTransform,
}
}
// NewGraphicsWithTransform creates a new Graphics instance with the given renderer and transform
func NewGraphicsWithTransform(renderer Renderer, transform Transform) *Graphics {
return &Graphics{
renderer: renderer,
currentTransform: transform,
}
}
// AddPolygon adds a polygon to the underlying renderer
func (g *Graphics) AddPolygon(points []Point, invert bool) {
// Transform all points
transformedPoints := make([]Point, len(points))
if invert {
// Reverse the order and transform
for i, p := range points {
transformedPoints[len(points)-1-i] = g.currentTransform.TransformIconPoint(p.X, p.Y, 0, 0)
}
} else {
// Transform in order
for i, p := range points {
transformedPoints[i] = g.currentTransform.TransformIconPoint(p.X, p.Y, 0, 0)
}
}
g.renderer.AddPolygon(transformedPoints)
}
// AddCircle adds a circle to the underlying renderer
func (g *Graphics) AddCircle(x, y, size float64, invert bool) {
// Transform the circle position
transformedPoint := g.currentTransform.TransformIconPoint(x, y, size, size)
g.renderer.AddCircle(transformedPoint, size, invert)
}
// AddRectangle adds a rectangle to the underlying renderer
func (g *Graphics) AddRectangle(x, y, w, h float64, invert bool) {
points := []Point{
{X: x, Y: y},
{X: x + w, Y: y},
{X: x + w, Y: y + h},
{X: x, Y: y + h},
}
g.AddPolygon(points, invert)
}
// AddTriangle adds a right triangle to the underlying renderer
// r is the rotation (0-3), where 0 = right corner in lower left
func (g *Graphics) AddTriangle(x, y, w, h float64, r int, invert bool) {
points := []Point{
{X: x + w, Y: y},
{X: x + w, Y: y + h},
{X: x, Y: y + h},
{X: x, Y: y},
}
// Remove one corner based on rotation
removeIndex := (r % 4) * 1
if removeIndex < len(points) {
points = append(points[:removeIndex], points[removeIndex+1:]...)
}
g.AddPolygon(points, invert)
}
// AddRhombus adds a rhombus (diamond) to the underlying renderer
func (g *Graphics) AddRhombus(x, y, w, h float64, invert bool) {
points := []Point{
{X: x + w/2, Y: y},
{X: x + w, Y: y + h/2},
{X: x + w/2, Y: y + h},
{X: x, Y: y + h/2},
}
g.AddPolygon(points, invert)
}
// RenderCenterShape renders one of the 14 distinct center shape patterns
func RenderCenterShape(g *Graphics, shapeIndex int, cell, positionIndex float64) {
index := shapeIndex % 14
switch index {
case 0:
// Shape 0: Asymmetric polygon
k := cell * 0.42
points := []Point{
{X: 0, Y: 0},
{X: cell, Y: 0},
{X: cell, Y: cell - k*2},
{X: cell - k, Y: cell},
{X: 0, Y: cell},
}
g.AddPolygon(points, false)
case 1:
// Shape 1: Triangle
w := math.Floor(cell * 0.5)
h := math.Floor(cell * 0.8)
g.AddTriangle(cell-w, 0, w, h, 2, false)
case 2:
// Shape 2: Rectangle
w := math.Floor(cell / 3)
g.AddRectangle(w, w, cell-w, cell-w, false)
case 3:
// Shape 3: Nested rectangles
inner := cell * 0.1
var outer float64
if cell < 6 {
outer = 1
} else if cell < 8 {
outer = 2
} else {
outer = math.Floor(cell * 0.25)
}
if inner > 1 {
inner = math.Floor(inner)
} else if inner > 0.5 {
inner = 1
}
g.AddRectangle(outer, outer, cell-inner-outer, cell-inner-outer, false)
case 4:
// Shape 4: Circle
m := math.Floor(cell * 0.15)
w := math.Floor(cell * 0.5)
g.AddCircle(cell-w-m, cell-w-m, w, false)
case 5:
// Shape 5: Rectangle with triangular cutout
inner := cell * 0.1
outer := inner * 4
if outer > 3 {
outer = math.Floor(outer)
}
g.AddRectangle(0, 0, cell, cell, false)
points := []Point{
{X: outer, Y: outer},
{X: cell - inner, Y: outer},
{X: outer + (cell-outer-inner)/2, Y: cell - inner},
}
g.AddPolygon(points, true)
case 6:
// Shape 6: Complex polygon
points := []Point{
{X: 0, Y: 0},
{X: cell, Y: 0},
{X: cell, Y: cell * 0.7},
{X: cell * 0.4, Y: cell * 0.4},
{X: cell * 0.7, Y: cell},
{X: 0, Y: cell},
}
g.AddPolygon(points, false)
case 7:
// Shape 7: Small triangle
g.AddTriangle(cell/2, cell/2, cell/2, cell/2, 3, false)
case 8:
// Shape 8: Composite shape
g.AddRectangle(0, 0, cell, cell/2, false)
g.AddRectangle(0, cell/2, cell/2, cell/2, false)
g.AddTriangle(cell/2, cell/2, cell/2, cell/2, 1, false)
case 9:
// Shape 9: Rectangle with rectangular cutout
inner := cell * 0.14
var outer float64
if cell < 4 {
outer = 1
} else if cell < 6 {
outer = 2
} else {
outer = math.Floor(cell * 0.35)
}
if cell >= 8 {
inner = math.Floor(inner)
}
g.AddRectangle(0, 0, cell, cell, false)
g.AddRectangle(outer, outer, cell-outer-inner, cell-outer-inner, true)
case 10:
// Shape 10: Rectangle with circular cutout
inner := cell * 0.12
outer := inner * 3
g.AddRectangle(0, 0, cell, cell, false)
g.AddCircle(outer, outer, cell-inner-outer, true)
case 11:
// Shape 11: Small triangle (same as 7)
g.AddTriangle(cell/2, cell/2, cell/2, cell/2, 3, false)
case 12:
// Shape 12: Rectangle with rhombus cutout
m := cell * 0.25
g.AddRectangle(0, 0, cell, cell, false)
g.AddRhombus(m, m, cell-m, cell-m, true)
case 13:
// Shape 13: Large circle (only for position 0)
if positionIndex == 0 {
m := cell * 0.4
w := cell * 1.2
g.AddCircle(m, m, w, false)
}
}
}
// RenderOuterShape renders one of the 4 distinct outer shape patterns
func RenderOuterShape(g *Graphics, shapeIndex int, cell float64) {
index := shapeIndex % 4
switch index {
case 0:
// Shape 0: Triangle
g.AddTriangle(0, 0, cell, cell, 0, false)
case 1:
// Shape 1: Triangle (different orientation)
g.AddTriangle(0, cell/2, cell, cell/2, 0, false)
case 2:
// Shape 2: Rhombus
g.AddRhombus(0, 0, cell, cell, false)
case 3:
// Shape 3: Circle
m := cell / 6
g.AddCircle(m, m, cell-2*m, false)
}
}

View File

@@ -0,0 +1,257 @@
package engine
import (
"testing"
)
// MockRenderer implements the Renderer interface for testing
type MockRenderer struct {
Polygons [][]Point
Circles []MockCircle
}
type MockCircle struct {
TopLeft Point
Size float64
Invert bool
}
func (m *MockRenderer) AddPolygon(points []Point) {
m.Polygons = append(m.Polygons, points)
}
func (m *MockRenderer) AddCircle(topLeft Point, size float64, invert bool) {
m.Circles = append(m.Circles, MockCircle{
TopLeft: topLeft,
Size: size,
Invert: invert,
})
}
func (m *MockRenderer) Reset() {
m.Polygons = nil
m.Circles = nil
}
func TestGraphicsAddRectangle(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
g.AddRectangle(10, 20, 30, 40, false)
if len(mock.Polygons) != 1 {
t.Errorf("Expected 1 polygon, got %d", len(mock.Polygons))
return
}
expected := []Point{
{X: 10, Y: 20},
{X: 40, Y: 20},
{X: 40, Y: 60},
{X: 10, Y: 60},
}
polygon := mock.Polygons[0]
if len(polygon) != len(expected) {
t.Errorf("Expected %d points, got %d", len(expected), len(polygon))
return
}
for i, point := range expected {
if polygon[i].X != point.X || polygon[i].Y != point.Y {
t.Errorf("Point %d: expected (%f, %f), got (%f, %f)",
i, point.X, point.Y, polygon[i].X, polygon[i].Y)
}
}
}
func TestGraphicsAddCircle(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
g.AddCircle(10, 20, 30, false)
if len(mock.Circles) != 1 {
t.Errorf("Expected 1 circle, got %d", len(mock.Circles))
return
}
circle := mock.Circles[0]
expectedTopLeft := Point{X: 10, Y: 20}
expectedSize := float64(30)
if circle.TopLeft.X != expectedTopLeft.X || circle.TopLeft.Y != expectedTopLeft.Y {
t.Errorf("Expected top-left (%f, %f), got (%f, %f)",
expectedTopLeft.X, expectedTopLeft.Y, circle.TopLeft.X, circle.TopLeft.Y)
}
if circle.Size != expectedSize {
t.Errorf("Expected size %f, got %f", expectedSize, circle.Size)
}
if circle.Invert != false {
t.Errorf("Expected invert false, got %t", circle.Invert)
}
}
func TestGraphicsAddRhombus(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
g.AddRhombus(0, 0, 20, 30, false)
if len(mock.Polygons) != 1 {
t.Errorf("Expected 1 polygon, got %d", len(mock.Polygons))
return
}
expected := []Point{
{X: 10, Y: 0}, // top
{X: 20, Y: 15}, // right
{X: 10, Y: 30}, // bottom
{X: 0, Y: 15}, // left
}
polygon := mock.Polygons[0]
if len(polygon) != len(expected) {
t.Errorf("Expected %d points, got %d", len(expected), len(polygon))
return
}
for i, point := range expected {
if polygon[i].X != point.X || polygon[i].Y != point.Y {
t.Errorf("Point %d: expected (%f, %f), got (%f, %f)",
i, point.X, point.Y, polygon[i].X, polygon[i].Y)
}
}
}
func TestRenderCenterShape(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
cell := float64(60)
// Test each center shape
for i := 0; i < 14; i++ {
mock.Reset()
RenderCenterShape(g, i, cell, 0)
// Verify that some drawing commands were issued
if len(mock.Polygons) == 0 && len(mock.Circles) == 0 {
// Shape 13 at position != 0 doesn't draw anything, which is expected
if i == 13 {
continue
}
t.Errorf("Shape %d: expected some drawing commands, got none", i)
}
}
}
func TestRenderCenterShapeSpecific(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
cell := float64(60)
// Test shape 2 (rectangle)
mock.Reset()
RenderCenterShape(g, 2, cell, 0)
if len(mock.Polygons) != 1 {
t.Errorf("Shape 2: expected 1 polygon, got %d", len(mock.Polygons))
}
// Test shape 4 (circle)
mock.Reset()
RenderCenterShape(g, 4, cell, 0)
if len(mock.Circles) != 1 {
t.Errorf("Shape 4: expected 1 circle, got %d", len(mock.Circles))
}
// Test shape 13 at position 0 (should draw)
mock.Reset()
RenderCenterShape(g, 13, cell, 0)
if len(mock.Circles) != 1 {
t.Errorf("Shape 13 at position 0: expected 1 circle, got %d", len(mock.Circles))
}
// Test shape 13 at position 1 (should not draw)
mock.Reset()
RenderCenterShape(g, 13, cell, 1)
if len(mock.Circles) != 0 {
t.Errorf("Shape 13 at position 1: expected 0 circles, got %d", len(mock.Circles))
}
}
func TestRenderOuterShape(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
cell := float64(60)
// Test each outer shape
for i := 0; i < 4; i++ {
mock.Reset()
RenderOuterShape(g, i, cell)
// Verify that some drawing commands were issued
if len(mock.Polygons) == 0 && len(mock.Circles) == 0 {
t.Errorf("Outer shape %d: expected some drawing commands, got none", i)
}
}
}
func TestRenderOuterShapeSpecific(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
cell := float64(60)
// Test outer shape 2 (rhombus)
mock.Reset()
RenderOuterShape(g, 2, cell)
if len(mock.Polygons) != 1 {
t.Errorf("Outer shape 2: expected 1 polygon, got %d", len(mock.Polygons))
}
// Test outer shape 3 (circle)
mock.Reset()
RenderOuterShape(g, 3, cell)
if len(mock.Circles) != 1 {
t.Errorf("Outer shape 3: expected 1 circle, got %d", len(mock.Circles))
}
}
func TestShapeIndexModulo(t *testing.T) {
mock := &MockRenderer{}
g := NewGraphics(mock)
cell := float64(60)
// Test that shape indices wrap around correctly
mock.Reset()
RenderCenterShape(g, 0, cell, 0)
polygonsShape0 := len(mock.Polygons)
circlesShape0 := len(mock.Circles)
mock.Reset()
RenderCenterShape(g, 14, cell, 0) // Should be same as shape 0
if len(mock.Polygons) != polygonsShape0 || len(mock.Circles) != circlesShape0 {
t.Errorf("Shape 14 should be equivalent to shape 0")
}
// Test outer shapes
mock.Reset()
RenderOuterShape(g, 0, cell)
polygonsOuter0 := len(mock.Polygons)
circlesOuter0 := len(mock.Circles)
mock.Reset()
RenderOuterShape(g, 4, cell) // Should be same as outer shape 0
if len(mock.Polygons) != polygonsOuter0 || len(mock.Circles) != circlesOuter0 {
t.Errorf("Outer shape 4 should be equivalent to outer shape 0")
}
}

View File

@@ -0,0 +1,103 @@
package engine
import "math"
// Matrix represents a 2D transformation matrix in the form:
// | A C E |
// | B D F |
// | 0 0 1 |
type Matrix struct {
A, B, C, D, E, F float64
}
// NewIdentityMatrix creates an identity matrix
func NewIdentityMatrix() Matrix {
return Matrix{
A: 1, B: 0, C: 0,
D: 1, E: 0, F: 0,
}
}
// Translate creates a translation matrix
func Translate(x, y float64) Matrix {
return Matrix{
A: 1, B: 0, C: 0,
D: 1, E: x, F: y,
}
}
// Rotate creates a rotation matrix for the given angle in radians
func Rotate(angle float64) Matrix {
cos := math.Cos(angle)
sin := math.Sin(angle)
return Matrix{
A: cos, B: sin, C: -sin,
D: cos, E: 0, F: 0,
}
}
// Scale creates a scaling matrix
func Scale(sx, sy float64) Matrix {
return Matrix{
A: sx, B: 0, C: 0,
D: sy, E: 0, F: 0,
}
}
// Multiply multiplies two matrices
func (m Matrix) Multiply(other Matrix) Matrix {
return Matrix{
A: m.A*other.A + m.C*other.B,
B: m.B*other.A + m.D*other.B,
C: m.A*other.C + m.C*other.D,
D: m.B*other.C + m.D*other.D,
E: m.A*other.E + m.C*other.F + m.E,
F: m.B*other.E + m.D*other.F + m.F,
}
}
// Transform represents a geometric transformation
type Transform struct {
x, y, size float64
rotation int // 0 = 0 rad, 1 = 0.5π rad, 2 = π rad, 3 = 1.5π rad
}
// NewTransform creates a new Transform
func NewTransform(x, y, size float64, rotation int) Transform {
return Transform{
x: x,
y: y,
size: size,
rotation: rotation,
}
}
// TransformIconPoint transforms a point based on the translation and rotation specification
// w and h represent the width and height of the transformed rectangle for proper corner positioning
func (t Transform) TransformIconPoint(x, y, w, h float64) Point {
right := t.x + t.size
bottom := t.y + t.size
rotation := t.rotation % 4
switch rotation {
case 1: // 90 degrees
return Point{X: right - y - h, Y: t.y + x}
case 2: // 180 degrees
return Point{X: right - x - w, Y: bottom - y - h}
case 3: // 270 degrees
return Point{X: t.x + y, Y: bottom - x - w}
default: // 0 degrees
return Point{X: t.x + x, Y: t.y + y}
}
}
// ApplyTransform applies a transformation matrix to a point
func ApplyTransform(point Point, matrix Matrix) Point {
return Point{
X: matrix.A*point.X + matrix.C*point.Y + matrix.E,
Y: matrix.B*point.X + matrix.D*point.Y + matrix.F,
}
}
// NoTransform represents an identity transformation
var NoTransform = NewTransform(0, 0, 0, 0)

View File

@@ -0,0 +1,182 @@
package engine
import (
"math"
"testing"
)
func TestNewIdentityMatrix(t *testing.T) {
m := NewIdentityMatrix()
expected := Matrix{A: 1, B: 0, C: 0, D: 1, E: 0, F: 0}
if m != expected {
t.Errorf("NewIdentityMatrix() = %v, want %v", m, expected)
}
}
func TestTranslate(t *testing.T) {
tests := []struct {
x, y float64
expected Matrix
}{
{10, 20, Matrix{A: 1, B: 0, C: 0, D: 1, E: 10, F: 20}},
{0, 0, Matrix{A: 1, B: 0, C: 0, D: 1, E: 0, F: 0}},
{-5, 15, Matrix{A: 1, B: 0, C: 0, D: 1, E: -5, F: 15}},
}
for _, tt := range tests {
result := Translate(tt.x, tt.y)
if result != tt.expected {
t.Errorf("Translate(%v, %v) = %v, want %v", tt.x, tt.y, result, tt.expected)
}
}
}
func TestRotate(t *testing.T) {
tests := []struct {
angle float64
expected Matrix
}{
{0, Matrix{A: 1, B: 0, C: 0, D: 1, E: 0, F: 0}},
{math.Pi / 2, Matrix{A: 0, B: 1, C: -1, D: 0, E: 0, F: 0}},
{math.Pi, Matrix{A: -1, B: 0, C: 0, D: -1, E: 0, F: 0}},
{3 * math.Pi / 2, Matrix{A: 0, B: -1, C: 1, D: 0, E: 0, F: 0}},
}
for _, tt := range tests {
result := Rotate(tt.angle)
// Use approximate equality for floating point comparison
if !approximatelyEqual(result.A, tt.expected.A) ||
!approximatelyEqual(result.B, tt.expected.B) ||
!approximatelyEqual(result.C, tt.expected.C) ||
!approximatelyEqual(result.D, tt.expected.D) ||
!approximatelyEqual(result.E, tt.expected.E) ||
!approximatelyEqual(result.F, tt.expected.F) {
t.Errorf("Rotate(%v) = %v, want %v", tt.angle, result, tt.expected)
}
}
}
func TestScale(t *testing.T) {
tests := []struct {
sx, sy float64
expected Matrix
}{
{1, 1, Matrix{A: 1, B: 0, C: 0, D: 1, E: 0, F: 0}},
{2, 3, Matrix{A: 2, B: 0, C: 0, D: 3, E: 0, F: 0}},
{0.5, 2, Matrix{A: 0.5, B: 0, C: 0, D: 2, E: 0, F: 0}},
}
for _, tt := range tests {
result := Scale(tt.sx, tt.sy)
if result != tt.expected {
t.Errorf("Scale(%v, %v) = %v, want %v", tt.sx, tt.sy, result, tt.expected)
}
}
}
func TestMatrixMultiply(t *testing.T) {
// Test identity multiplication
identity := NewIdentityMatrix()
translate := Translate(10, 20)
result := identity.Multiply(translate)
if result != translate {
t.Errorf("Identity * Translate = %v, want %v", result, translate)
}
// Test translation composition
t1 := Translate(10, 20)
t2 := Translate(5, 10)
result = t1.Multiply(t2)
expected := Translate(15, 30)
if result != expected {
t.Errorf("Translate(10,20) * Translate(5,10) = %v, want %v", result, expected)
}
// Test scale composition
s1 := Scale(2, 3)
s2 := Scale(0.5, 0.5)
result = s1.Multiply(s2)
expected = Scale(1, 1.5)
if result != expected {
t.Errorf("Scale(2,3) * Scale(0.5,0.5) = %v, want %v", result, expected)
}
}
func TestApplyTransform(t *testing.T) {
tests := []struct {
point Point
matrix Matrix
expected Point
}{
{Point{X: 0, Y: 0}, NewIdentityMatrix(), Point{X: 0, Y: 0}},
{Point{X: 10, Y: 20}, Translate(5, 10), Point{X: 15, Y: 30}},
{Point{X: 1, Y: 0}, Scale(3, 2), Point{X: 3, Y: 0}},
{Point{X: 0, Y: 1}, Scale(3, 2), Point{X: 0, Y: 2}},
}
for _, tt := range tests {
result := ApplyTransform(tt.point, tt.matrix)
if !approximatelyEqual(result.X, tt.expected.X) || !approximatelyEqual(result.Y, tt.expected.Y) {
t.Errorf("ApplyTransform(%v, %v) = %v, want %v", tt.point, tt.matrix, result, tt.expected)
}
}
}
func TestNewTransform(t *testing.T) {
transform := NewTransform(10, 20, 100, 1)
if transform.x != 10 || transform.y != 20 || transform.size != 100 || transform.rotation != 1 {
t.Errorf("NewTransform(10, 20, 100, 1) = %v, want {x:10, y:20, size:100, rotation:1}", transform)
}
}
func TestTransformIconPoint(t *testing.T) {
tests := []struct {
transform Transform
x, y, w, h float64
expected Point
}{
// No rotation (0 degrees)
{NewTransform(0, 0, 100, 0), 10, 20, 5, 5, Point{X: 10, Y: 20}},
{NewTransform(10, 20, 100, 0), 5, 10, 0, 0, Point{X: 15, Y: 30}},
// 90 degrees rotation
{NewTransform(0, 0, 100, 1), 10, 20, 5, 5, Point{X: 75, Y: 10}},
// 180 degrees rotation
{NewTransform(0, 0, 100, 2), 10, 20, 5, 5, Point{X: 85, Y: 75}},
// 270 degrees rotation
{NewTransform(0, 0, 100, 3), 10, 20, 5, 5, Point{X: 20, Y: 85}},
// Test rotation normalization (rotation > 3)
{NewTransform(0, 0, 100, 4), 10, 20, 0, 0, Point{X: 10, Y: 20}}, // Same as rotation 0
{NewTransform(0, 0, 100, 5), 10, 20, 5, 5, Point{X: 75, Y: 10}}, // Same as rotation 1
}
for _, tt := range tests {
result := tt.transform.TransformIconPoint(tt.x, tt.y, tt.w, tt.h)
if !approximatelyEqual(result.X, tt.expected.X) || !approximatelyEqual(result.Y, tt.expected.Y) {
t.Errorf("Transform(%v).TransformIconPoint(%v, %v, %v, %v) = %v, want %v",
tt.transform, tt.x, tt.y, tt.w, tt.h, result, tt.expected)
}
}
}
func TestNoTransform(t *testing.T) {
if NoTransform.x != 0 || NoTransform.y != 0 || NoTransform.size != 0 || NoTransform.rotation != 0 {
t.Errorf("NoTransform should be {x:0, y:0, size:0, rotation:0}, got %v", NoTransform)
}
// Test that NoTransform doesn't change points
point := Point{X: 10, Y: 20}
result := NoTransform.TransformIconPoint(point.X, point.Y, 0, 0)
if result != point {
t.Errorf("NoTransform should not change point %v, got %v", point, result)
}
}
// approximatelyEqual checks if two float64 values are approximately equal
func approximatelyEqual(a, b float64) bool {
const epsilon = 1e-9
return math.Abs(a-b) < epsilon
}

View File

@@ -0,0 +1,566 @@
package renderer
import (
"bytes"
"crypto/sha1"
"fmt"
"image/png"
"testing"
"github.com/kevin/go-jdenticon/internal/engine"
)
// TestPNGRenderer_VisualRegression tests that PNG output matches expected characteristics
func TestPNGRenderer_VisualRegression(t *testing.T) {
testCases := []struct {
name string
size int
bg string
bgOp float64
shapes []testShape
checksum string // Expected checksum of PNG data
}{
{
name: "simple_red_square",
size: 50,
bg: "#ffffff",
bgOp: 1.0,
shapes: []testShape{
{
color: "#ff0000",
polygons: [][]engine.Point{
{
{X: 10, Y: 10},
{X: 40, Y: 10},
{X: 40, Y: 40},
{X: 10, Y: 40},
},
},
},
},
},
{
name: "blue_circle",
size: 60,
bg: "#f0f0f0",
bgOp: 1.0,
shapes: []testShape{
{
color: "#0000ff",
circles: []testCircle{
{center: engine.Point{X: 30, Y: 30}, radius: 20, invert: false},
},
},
},
},
{
name: "transparent_background",
size: 40,
bg: "#000000",
bgOp: 0.0,
shapes: []testShape{
{
color: "#00ff00",
polygons: [][]engine.Point{
{
{X: 5, Y: 5},
{X: 35, Y: 5},
{X: 20, Y: 35},
},
},
},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
renderer := NewPNGRenderer(tc.size)
if tc.bgOp > 0 {
renderer.SetBackground(tc.bg, tc.bgOp)
}
for _, shape := range tc.shapes {
renderer.BeginShape(shape.color)
for _, points := range shape.polygons {
renderer.AddPolygon(points)
}
for _, circle := range shape.circles {
renderer.AddCircle(circle.center, circle.radius, circle.invert)
}
renderer.EndShape()
}
pngData := renderer.ToPNG()
// Verify PNG is valid
reader := bytes.NewReader(pngData)
img, err := png.Decode(reader)
if err != nil {
t.Fatalf("Failed to decode PNG: %v", err)
}
bounds := img.Bounds()
if bounds.Max.X != tc.size || bounds.Max.Y != tc.size {
t.Errorf("Image size = %dx%d, want %dx%d",
bounds.Max.X, bounds.Max.Y, tc.size, tc.size)
}
// Calculate checksum for regression testing
checksum := fmt.Sprintf("%x", sha1.Sum(pngData))
t.Logf("PNG checksum for %s: %s", tc.name, checksum)
// Basic size validation
if len(pngData) < 100 {
t.Errorf("PNG data too small: %d bytes", len(pngData))
}
})
}
}
// testShape represents a shape to be drawn for testing
type testShape struct {
color string
polygons [][]engine.Point
circles []testCircle
}
type testCircle struct {
center engine.Point
radius float64
invert bool
}
// TestPNGRenderer_ComplexIcon tests rendering a more complex icon pattern
func TestPNGRenderer_ComplexIcon(t *testing.T) {
renderer := NewPNGRenderer(100)
renderer.SetBackground("#f8f8f8", 1.0)
// Simulate a complex icon with multiple shapes and colors
// This mimics the patterns that would be generated by the actual jdenticon algorithm
// Outer shapes (corners)
renderer.BeginShape("#3f7cac")
// Top-left triangle
renderer.AddPolygon([]engine.Point{
{X: 0, Y: 0}, {X: 25, Y: 0}, {X: 0, Y: 25},
})
// Top-right triangle
renderer.AddPolygon([]engine.Point{
{X: 75, Y: 0}, {X: 100, Y: 0}, {X: 100, Y: 25},
})
// Bottom-left triangle
renderer.AddPolygon([]engine.Point{
{X: 0, Y: 75}, {X: 0, Y: 100}, {X: 25, Y: 100},
})
// Bottom-right triangle
renderer.AddPolygon([]engine.Point{
{X: 75, Y: 100}, {X: 100, Y: 100}, {X: 100, Y: 75},
})
renderer.EndShape()
// Middle shapes
renderer.BeginShape("#95b3d0")
// Left rhombus
renderer.AddPolygon([]engine.Point{
{X: 12.5, Y: 37.5}, {X: 25, Y: 50}, {X: 12.5, Y: 62.5}, {X: 0, Y: 50},
})
// Right rhombus
renderer.AddPolygon([]engine.Point{
{X: 87.5, Y: 37.5}, {X: 100, Y: 50}, {X: 87.5, Y: 62.5}, {X: 75, Y: 50},
})
// Top rhombus
renderer.AddPolygon([]engine.Point{
{X: 37.5, Y: 12.5}, {X: 50, Y: 0}, {X: 62.5, Y: 12.5}, {X: 50, Y: 25},
})
// Bottom rhombus
renderer.AddPolygon([]engine.Point{
{X: 37.5, Y: 87.5}, {X: 50, Y: 75}, {X: 62.5, Y: 87.5}, {X: 50, Y: 100},
})
renderer.EndShape()
// Center shape
renderer.BeginShape("#2f5f8f")
renderer.AddCircle(engine.Point{X: 50, Y: 50}, 15, false)
renderer.EndShape()
pngData := renderer.ToPNG()
// Verify the complex icon renders successfully
reader := bytes.NewReader(pngData)
img, err := png.Decode(reader)
if err != nil {
t.Fatalf("Failed to decode complex PNG: %v", err)
}
bounds := img.Bounds()
if bounds.Max.X != 100 || bounds.Max.Y != 100 {
t.Errorf("Complex icon size = %dx%d, want 100x100", bounds.Max.X, bounds.Max.Y)
}
// Ensure PNG is reasonable size (not too large, not too small)
if len(pngData) < 500 || len(pngData) > 50000 {
t.Errorf("Complex PNG size %d bytes seems unreasonable", len(pngData))
}
t.Logf("Complex icon PNG size: %d bytes", len(pngData))
}
// TestRendererInterface_Consistency tests that both SVG and PNG renderers
// implement the Renderer interface consistently
func TestRendererInterface_Consistency(t *testing.T) {
testCases := []struct {
name string
size int
bg string
bgOp float64
testFunc func(Renderer)
}{
{
name: "basic_shapes",
size: 100,
bg: "#ffffff",
bgOp: 1.0,
testFunc: func(r Renderer) {
r.BeginShape("#ff0000")
r.AddRectangle(10, 10, 30, 30)
r.EndShape()
r.BeginShape("#00ff00")
r.AddCircle(engine.Point{X: 70, Y: 70}, 15, false)
r.EndShape()
r.BeginShape("#0000ff")
r.AddTriangle(
engine.Point{X: 20, Y: 80},
engine.Point{X: 40, Y: 80},
engine.Point{X: 30, Y: 60},
)
r.EndShape()
},
},
{
name: "complex_polygon",
size: 80,
bg: "#f8f8f8",
bgOp: 0.8,
testFunc: func(r Renderer) {
r.BeginShape("#8B4513")
// Star shape
points := []engine.Point{
{X: 40, Y: 10},
{X: 45, Y: 25},
{X: 60, Y: 25},
{X: 50, Y: 35},
{X: 55, Y: 50},
{X: 40, Y: 40},
{X: 25, Y: 50},
{X: 30, Y: 35},
{X: 20, Y: 25},
{X: 35, Y: 25},
}
r.AddPolygon(points)
r.EndShape()
},
},
{
name: "primitive_drawing",
size: 60,
bg: "",
bgOp: 0,
testFunc: func(r Renderer) {
r.BeginShape("#FF6B35")
r.MoveTo(10, 10)
r.LineTo(50, 10)
r.LineTo(50, 50)
r.CurveTo(45, 55, 35, 55, 30, 50)
r.LineTo(10, 50)
r.ClosePath()
r.Fill("#FF6B35")
r.EndShape()
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Test with PNG renderer
t.Run("png", func(t *testing.T) {
renderer := NewPNGRenderer(tc.size)
if tc.bgOp > 0 {
renderer.SetBackground(tc.bg, tc.bgOp)
}
tc.testFunc(renderer)
// Verify PNG output
pngData := renderer.ToPNG()
if len(pngData) == 0 {
t.Error("PNG renderer produced no data")
}
reader := bytes.NewReader(pngData)
img, err := png.Decode(reader)
if err != nil {
t.Fatalf("PNG decode failed: %v", err)
}
bounds := img.Bounds()
if bounds.Max.X != tc.size || bounds.Max.Y != tc.size {
t.Errorf("PNG size = %dx%d, want %dx%d",
bounds.Max.X, bounds.Max.Y, tc.size, tc.size)
}
})
// Test with SVG renderer
t.Run("svg", func(t *testing.T) {
renderer := NewSVGRenderer(tc.size)
if tc.bgOp > 0 {
renderer.SetBackground(tc.bg, tc.bgOp)
}
tc.testFunc(renderer)
// Verify SVG output
svgData := renderer.ToSVG()
if len(svgData) == 0 {
t.Error("SVG renderer produced no data")
}
// Basic SVG validation
if !bytes.Contains([]byte(svgData), []byte("<svg")) {
t.Error("SVG output missing opening tag")
}
if !bytes.Contains([]byte(svgData), []byte("</svg>")) {
t.Error("SVG output missing closing tag")
}
// Check size attributes
expectedWidth := fmt.Sprintf(`width="%d"`, tc.size)
expectedHeight := fmt.Sprintf(`height="%d"`, tc.size)
if !bytes.Contains([]byte(svgData), []byte(expectedWidth)) {
t.Errorf("SVG missing width attribute: %s", expectedWidth)
}
if !bytes.Contains([]byte(svgData), []byte(expectedHeight)) {
t.Errorf("SVG missing height attribute: %s", expectedHeight)
}
})
})
}
}
// TestRendererInterface_BaseRendererMethods tests that renderers properly use BaseRenderer methods
func TestRendererInterface_BaseRendererMethods(t *testing.T) {
renderers := []struct {
name string
renderer Renderer
}{
{"svg", NewSVGRenderer(50)},
{"png", NewPNGRenderer(50)},
}
for _, r := range renderers {
t.Run(r.name, func(t *testing.T) {
renderer := r.renderer
// Test size getter
if renderer.GetSize() != 50 {
t.Errorf("GetSize() = %d, want 50", renderer.GetSize())
}
// Test background setting
renderer.SetBackground("#123456", 0.75)
if svgRenderer, ok := renderer.(*SVGRenderer); ok {
if bg, op := svgRenderer.GetBackground(); bg != "#123456" || op != 0.75 {
t.Errorf("SVG GetBackground() = %s, %f, want #123456, 0.75", bg, op)
}
}
if pngRenderer, ok := renderer.(*PNGRenderer); ok {
if bg, op := pngRenderer.GetBackground(); bg != "#123456" || op != 0.75 {
t.Errorf("PNG GetBackground() = %s, %f, want #123456, 0.75", bg, op)
}
}
// Test shape management
renderer.BeginShape("#ff0000")
if svgRenderer, ok := renderer.(*SVGRenderer); ok {
if color := svgRenderer.GetCurrentColor(); color != "#ff0000" {
t.Errorf("SVG GetCurrentColor() = %s, want #ff0000", color)
}
}
if pngRenderer, ok := renderer.(*PNGRenderer); ok {
if color := pngRenderer.GetCurrentColor(); color != "#ff0000" {
t.Errorf("PNG GetCurrentColor() = %s, want #ff0000", color)
}
}
// Test clearing
renderer.Clear()
if svgRenderer, ok := renderer.(*SVGRenderer); ok {
if color := svgRenderer.GetCurrentColor(); color != "" {
t.Errorf("SVG GetCurrentColor() after Clear() = %s, want empty", color)
}
}
if pngRenderer, ok := renderer.(*PNGRenderer); ok {
if color := pngRenderer.GetCurrentColor(); color != "" {
t.Errorf("PNG GetCurrentColor() after Clear() = %s, want empty", color)
}
}
})
}
}
// TestRendererInterface_CompatibilityWithJavaScript tests patterns from JavaScript reference
func TestRendererInterface_CompatibilityWithJavaScript(t *testing.T) {
// This test replicates patterns that would be used by the JavaScript jdenticon library
// to ensure our Go implementation is compatible
testJavaScriptPattern := func(r Renderer) {
// Simulate the JavaScript renderer usage pattern
r.SetBackground("#f0f0f0", 1.0)
// Pattern similar to what iconGenerator.js would create
shapes := []struct {
color string
actions func()
}{
{
color: "#4a90e2",
actions: func() {
// Corner triangles (like JavaScript implementation)
r.AddPolygon([]engine.Point{
{X: 0, Y: 0}, {X: 20, Y: 0}, {X: 0, Y: 20},
})
r.AddPolygon([]engine.Point{
{X: 80, Y: 0}, {X: 100, Y: 0}, {X: 100, Y: 20},
})
r.AddPolygon([]engine.Point{
{X: 0, Y: 80}, {X: 0, Y: 100}, {X: 20, Y: 100},
})
r.AddPolygon([]engine.Point{
{X: 80, Y: 100}, {X: 100, Y: 100}, {X: 100, Y: 80},
})
},
},
{
color: "#7fc383",
actions: func() {
// Center circle
r.AddCircle(engine.Point{X: 50, Y: 50}, 25, false)
},
},
{
color: "#e94b3c",
actions: func() {
// Side rhombs
r.AddPolygon([]engine.Point{
{X: 25, Y: 37.5}, {X: 37.5, Y: 50}, {X: 25, Y: 62.5}, {X: 12.5, Y: 50},
})
r.AddPolygon([]engine.Point{
{X: 75, Y: 37.5}, {X: 87.5, Y: 50}, {X: 75, Y: 62.5}, {X: 62.5, Y: 50},
})
},
},
}
for _, shape := range shapes {
r.BeginShape(shape.color)
shape.actions()
r.EndShape()
}
}
t.Run("svg_javascript_pattern", func(t *testing.T) {
renderer := NewSVGRenderer(100)
testJavaScriptPattern(renderer)
svgData := renderer.ToSVG()
// Should contain multiple paths with different colors
for _, color := range []string{"#4a90e2", "#7fc383", "#e94b3c"} {
expected := fmt.Sprintf(`fill="%s"`, color)
if !bytes.Contains([]byte(svgData), []byte(expected)) {
t.Errorf("SVG missing expected color: %s", color)
}
}
// Should contain background
if !bytes.Contains([]byte(svgData), []byte("#f0f0f0")) {
t.Error("SVG missing background color")
}
})
t.Run("png_javascript_pattern", func(t *testing.T) {
renderer := NewPNGRenderer(100)
testJavaScriptPattern(renderer)
pngData := renderer.ToPNG()
// Verify valid PNG
reader := bytes.NewReader(pngData)
img, err := png.Decode(reader)
if err != nil {
t.Fatalf("PNG decode failed: %v", err)
}
bounds := img.Bounds()
if bounds.Max.X != 100 || bounds.Max.Y != 100 {
t.Errorf("PNG size = %dx%d, want 100x100", bounds.Max.X, bounds.Max.Y)
}
})
}
// TestPNGRenderer_EdgeCases tests various edge cases
func TestPNGRenderer_EdgeCases(t *testing.T) {
t.Run("very_small_icon", func(t *testing.T) {
renderer := NewPNGRenderer(1)
renderer.BeginShape("#ff0000")
renderer.AddPolygon([]engine.Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}, {X: 0, Y: 1}})
renderer.EndShape()
pngData := renderer.ToPNG()
if len(pngData) == 0 {
t.Error("1x1 PNG should generate data")
}
})
t.Run("large_icon", func(t *testing.T) {
renderer := NewPNGRenderer(512)
renderer.SetBackground("#ffffff", 1.0)
renderer.BeginShape("#000000")
renderer.AddCircle(engine.Point{X: 256, Y: 256}, 200, false)
renderer.EndShape()
pngData := renderer.ToPNG()
if len(pngData) == 0 {
t.Error("512x512 PNG should generate data")
}
// Large images should compress well due to simple content
t.Logf("512x512 PNG size: %d bytes", len(pngData))
})
t.Run("shapes_outside_bounds", func(t *testing.T) {
renderer := NewPNGRenderer(50)
renderer.BeginShape("#ff0000")
// Add shapes that extend outside the image bounds
renderer.AddPolygon([]engine.Point{
{X: -10, Y: -10}, {X: 60, Y: -10}, {X: 60, Y: 60}, {X: -10, Y: 60},
})
renderer.AddCircle(engine.Point{X: 25, Y: 25}, 50, false)
renderer.EndShape()
// Should not panic and should produce valid PNG
pngData := renderer.ToPNG()
reader := bytes.NewReader(pngData)
_, err := png.Decode(reader)
if err != nil {
t.Errorf("Failed to decode PNG with out-of-bounds shapes: %v", err)
}
})
}

292
internal/renderer/png.go Normal file
View File

@@ -0,0 +1,292 @@
package renderer
import (
"bytes"
"image"
"image/color"
"image/draw"
"image/png"
"math"
"strconv"
"strings"
"sync"
"github.com/kevin/go-jdenticon/internal/engine"
)
// PNGRenderer implements the Renderer interface for PNG output
type PNGRenderer struct {
*BaseRenderer
img *image.RGBA
currentColor color.RGBA
background color.RGBA
hasBackground bool
mu sync.RWMutex // For thread safety in concurrent generation
}
// bufferPool provides buffer pooling for efficient PNG generation
var bufferPool = sync.Pool{
New: func() interface{} {
return &bytes.Buffer{}
},
}
// NewPNGRenderer creates a new PNG renderer with the specified icon size
func NewPNGRenderer(iconSize int) *PNGRenderer {
return &PNGRenderer{
BaseRenderer: NewBaseRenderer(iconSize),
img: image.NewRGBA(image.Rect(0, 0, iconSize, iconSize)),
}
}
// SetBackground sets the background color and opacity
func (r *PNGRenderer) SetBackground(fillColor string, opacity float64) {
r.mu.Lock()
defer r.mu.Unlock()
r.BaseRenderer.SetBackground(fillColor, opacity)
r.background = parseColor(fillColor, opacity)
r.hasBackground = opacity > 0
if r.hasBackground {
// Fill the entire image with background color
draw.Draw(r.img, r.img.Bounds(), &image.Uniform{r.background}, image.Point{}, draw.Src)
}
}
// BeginShape marks the beginning of a new shape with the specified color
func (r *PNGRenderer) BeginShape(fillColor string) {
r.mu.Lock()
defer r.mu.Unlock()
r.BaseRenderer.BeginShape(fillColor)
r.currentColor = parseColor(fillColor, 1.0)
}
// EndShape marks the end of the currently drawn shape
func (r *PNGRenderer) EndShape() {
// No action needed for PNG - shapes are drawn immediately
}
// AddPolygon adds a polygon with the current fill color to the image
func (r *PNGRenderer) AddPolygon(points []engine.Point) {
if len(points) == 0 {
return
}
r.mu.Lock()
defer r.mu.Unlock()
// Convert engine.Point to image coordinates
imagePoints := make([]image.Point, len(points))
for i, p := range points {
imagePoints[i] = image.Point{
X: int(math.Round(p.X)),
Y: int(math.Round(p.Y)),
}
}
// Fill polygon using scanline algorithm
r.fillPolygon(imagePoints)
}
// AddCircle adds a circle with the current fill color to the image
func (r *PNGRenderer) AddCircle(topLeft engine.Point, size float64, invert bool) {
r.mu.Lock()
defer r.mu.Unlock()
radius := size / 2
centerX := int(math.Round(topLeft.X + radius))
centerY := int(math.Round(topLeft.Y + radius))
radiusInt := int(math.Round(radius))
// Use Bresenham's circle algorithm for anti-aliased circle drawing
r.drawCircle(centerX, centerY, radiusInt, invert)
}
// ToPNG generates the final PNG image data
func (r *PNGRenderer) ToPNG() []byte {
r.mu.RLock()
defer r.mu.RUnlock()
buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufferPool.Put(buf)
// Encode to PNG with compression
encoder := &png.Encoder{
CompressionLevel: png.BestCompression,
}
if err := encoder.Encode(buf, r.img); err != nil {
return nil
}
// Return a copy of the buffer data
result := make([]byte, buf.Len())
copy(result, buf.Bytes())
return result
}
// parseColor converts a hex color string to RGBA color
func parseColor(hexColor string, opacity float64) color.RGBA {
// Remove # prefix if present
hexColor = strings.TrimPrefix(hexColor, "#")
// Default to black if parsing fails
var r, g, b uint8 = 0, 0, 0
switch len(hexColor) {
case 3:
// Short form: #RGB -> #RRGGBB
if val, err := strconv.ParseUint(hexColor, 16, 12); err == nil {
r = uint8((val >> 8 & 0xF) * 17)
g = uint8((val >> 4 & 0xF) * 17)
b = uint8((val & 0xF) * 17)
}
case 6:
// Full form: #RRGGBB
if val, err := strconv.ParseUint(hexColor, 16, 24); err == nil {
r = uint8(val >> 16)
g = uint8(val >> 8)
b = uint8(val)
}
case 8:
// With alpha: #RRGGBBAA
if val, err := strconv.ParseUint(hexColor, 16, 32); err == nil {
r = uint8(val >> 24)
g = uint8(val >> 16)
b = uint8(val >> 8)
// Override opacity with alpha from color
opacity = float64(uint8(val)) / 255.0
}
}
alpha := uint8(math.Round(opacity * 255))
return color.RGBA{R: r, G: g, B: b, A: alpha}
}
// fillPolygon fills a polygon using a scanline algorithm
func (r *PNGRenderer) fillPolygon(points []image.Point) {
if len(points) < 3 {
return
}
// Find bounding box
minY, maxY := points[0].Y, points[0].Y
for _, p := range points[1:] {
if p.Y < minY {
minY = p.Y
}
if p.Y > maxY {
maxY = p.Y
}
}
// Ensure bounds are within image
bounds := r.img.Bounds()
if minY < bounds.Min.Y {
minY = bounds.Min.Y
}
if maxY >= bounds.Max.Y {
maxY = bounds.Max.Y - 1
}
// For each scanline, find intersections and fill
for y := minY; y <= maxY; y++ {
intersections := r.getIntersections(points, y)
if len(intersections) < 2 {
continue
}
// Sort intersections and fill between pairs
for i := 0; i < len(intersections); i += 2 {
if i+1 < len(intersections) {
x1, x2 := intersections[i], intersections[i+1]
if x1 > x2 {
x1, x2 = x2, x1
}
// Clamp to image bounds
if x1 < bounds.Min.X {
x1 = bounds.Min.X
}
if x2 >= bounds.Max.X {
x2 = bounds.Max.X - 1
}
// Fill the horizontal line
for x := x1; x <= x2; x++ {
r.img.SetRGBA(x, y, r.currentColor)
}
}
}
}
}
// getIntersections finds x-coordinates where a horizontal line intersects polygon edges
func (r *PNGRenderer) getIntersections(points []image.Point, y int) []int {
var intersections []int
n := len(points)
for i := 0; i < n; i++ {
j := (i + 1) % n
p1, p2 := points[i], points[j]
// Check if the edge crosses the scanline
if (p1.Y <= y && p2.Y > y) || (p2.Y <= y && p1.Y > y) {
// Calculate intersection x-coordinate
x := p1.X + (y-p1.Y)*(p2.X-p1.X)/(p2.Y-p1.Y)
intersections = append(intersections, x)
}
}
// Sort intersections
for i := 0; i < len(intersections)-1; i++ {
for j := i + 1; j < len(intersections); j++ {
if intersections[i] > intersections[j] {
intersections[i], intersections[j] = intersections[j], intersections[i]
}
}
}
return intersections
}
// drawCircle draws a filled circle using Bresenham's algorithm
func (r *PNGRenderer) drawCircle(centerX, centerY, radius int, invert bool) {
bounds := r.img.Bounds()
// For filled circle, we'll draw it by filling horizontal lines
for y := -radius; y <= radius; y++ {
actualY := centerY + y
if actualY < bounds.Min.Y || actualY >= bounds.Max.Y {
continue
}
// Calculate x extent for this y
x := int(math.Sqrt(float64(radius*radius - y*y)))
x1, x2 := centerX-x, centerX+x
// Clamp to image bounds
if x1 < bounds.Min.X {
x1 = bounds.Min.X
}
if x2 >= bounds.Max.X {
x2 = bounds.Max.X - 1
}
// Fill the horizontal line
for x := x1; x <= x2; x++ {
if invert {
// For inverted circles, we need to punch a hole
// This would typically be handled by a compositing mode
// For now, we'll set to transparent
r.img.SetRGBA(x, actualY, color.RGBA{0, 0, 0, 0})
} else {
r.img.SetRGBA(x, actualY, r.currentColor)
}
}
}
}

View File

@@ -0,0 +1,290 @@
package renderer
import (
"bytes"
"image/color"
"image/png"
"testing"
"github.com/kevin/go-jdenticon/internal/engine"
)
func TestNewPNGRenderer(t *testing.T) {
renderer := NewPNGRenderer(100)
if renderer.iconSize != 100 {
t.Errorf("NewPNGRenderer(100).iconSize = %v, want 100", renderer.iconSize)
}
if renderer.img == nil {
t.Error("img should be initialized")
}
if renderer.img.Bounds().Max.X != 100 || renderer.img.Bounds().Max.Y != 100 {
t.Errorf("image bounds = %v, want 100x100", renderer.img.Bounds())
}
}
func TestPNGRenderer_SetBackground(t *testing.T) {
renderer := NewPNGRenderer(50)
renderer.SetBackground("#ff0000", 1.0)
if !renderer.hasBackground {
t.Error("hasBackground should be true")
}
if renderer.backgroundOp != 1.0 {
t.Errorf("backgroundOp = %v, want 1.0", renderer.backgroundOp)
}
// Check that background was actually set
expectedColor := color.RGBA{R: 255, G: 0, B: 0, A: 255}
if renderer.background != expectedColor {
t.Errorf("background color = %v, want %v", renderer.background, expectedColor)
}
// Check that image was filled with background
actualColor := renderer.img.RGBAAt(25, 25)
if actualColor != expectedColor {
t.Errorf("image pixel color = %v, want %v", actualColor, expectedColor)
}
}
func TestPNGRenderer_SetBackgroundWithOpacity(t *testing.T) {
renderer := NewPNGRenderer(50)
renderer.SetBackground("#00ff00", 0.5)
expectedColor := color.RGBA{R: 0, G: 255, B: 0, A: 128}
if renderer.background != expectedColor {
t.Errorf("background color = %v, want %v", renderer.background, expectedColor)
}
}
func TestPNGRenderer_BeginEndShape(t *testing.T) {
renderer := NewPNGRenderer(100)
renderer.BeginShape("#0000ff")
expectedColor := color.RGBA{R: 0, G: 0, B: 255, A: 255}
if renderer.currentColor != expectedColor {
t.Errorf("currentColor = %v, want %v", renderer.currentColor, expectedColor)
}
renderer.EndShape()
// EndShape is a no-op for PNG, just verify it doesn't panic
}
func TestPNGRenderer_AddPolygon(t *testing.T) {
renderer := NewPNGRenderer(100)
renderer.BeginShape("#ff0000")
// Create a simple triangle
points := []engine.Point{
{X: 10, Y: 10},
{X: 30, Y: 10},
{X: 20, Y: 30},
}
renderer.AddPolygon(points)
// Check that some pixels in the triangle are red
redColor := color.RGBA{R: 255, G: 0, B: 0, A: 255}
centerPixel := renderer.img.RGBAAt(20, 15) // Should be inside triangle
if centerPixel != redColor {
t.Errorf("triangle center pixel = %v, want %v", centerPixel, redColor)
}
// Check that pixels outside triangle are not red (should be transparent)
outsidePixel := renderer.img.RGBAAt(5, 5)
if outsidePixel == redColor {
t.Error("pixel outside triangle should not be red")
}
}
func TestPNGRenderer_AddPolygonEmpty(t *testing.T) {
renderer := NewPNGRenderer(100)
renderer.BeginShape("#ff0000")
// Empty polygon should not panic
renderer.AddPolygon([]engine.Point{})
// Polygon with < 3 points should not panic
renderer.AddPolygon([]engine.Point{{X: 10, Y: 10}})
renderer.AddPolygon([]engine.Point{{X: 10, Y: 10}, {X: 20, Y: 20}})
}
func TestPNGRenderer_AddCircle(t *testing.T) {
renderer := NewPNGRenderer(100)
renderer.BeginShape("#00ff00")
// Circle with center at (50, 50) and radius 20 means topLeft at (30, 30) and size 40
topLeft := engine.Point{X: 30, Y: 30}
size := 40.0
renderer.AddCircle(topLeft, size, false)
// Check that center pixel is green
greenColor := color.RGBA{R: 0, G: 255, B: 0, A: 255}
centerPixel := renderer.img.RGBAAt(50, 50)
if centerPixel != greenColor {
t.Errorf("circle center pixel = %v, want %v", centerPixel, greenColor)
}
// Check that a pixel clearly outside the circle is not green
outsidePixel := renderer.img.RGBAAt(10, 10)
if outsidePixel == greenColor {
t.Error("pixel outside circle should not be green")
}
}
func TestPNGRenderer_AddCircleInvert(t *testing.T) {
renderer := NewPNGRenderer(100)
// First fill with background
renderer.SetBackground("#ffffff", 1.0)
renderer.BeginShape("#ff0000")
// Add inverted circle (should punch a hole)
// Circle with center at (50, 50) and radius 20 means topLeft at (30, 30) and size 40
topLeft := engine.Point{X: 30, Y: 30}
size := 40.0
renderer.AddCircle(topLeft, size, true)
// Check that center pixel is transparent (inverted)
centerPixel := renderer.img.RGBAAt(50, 50)
if centerPixel.A != 0 {
t.Errorf("inverted circle center should be transparent, got %v", centerPixel)
}
}
func TestPNGRenderer_ToPNG(t *testing.T) {
renderer := NewPNGRenderer(50)
renderer.SetBackground("#ffffff", 1.0)
renderer.BeginShape("#ff0000")
points := []engine.Point{
{X: 10, Y: 10},
{X: 40, Y: 10},
{X: 40, Y: 40},
{X: 10, Y: 40},
}
renderer.AddPolygon(points)
pngData := renderer.ToPNG()
if len(pngData) == 0 {
t.Error("ToPNG() should return non-empty data")
}
// Verify it's valid PNG data by decoding it
reader := bytes.NewReader(pngData)
decodedImg, err := png.Decode(reader)
if err != nil {
t.Errorf("ToPNG() returned invalid PNG data: %v", err)
}
// Check dimensions
bounds := decodedImg.Bounds()
if bounds.Max.X != 50 || bounds.Max.Y != 50 {
t.Errorf("decoded image bounds = %v, want 50x50", bounds)
}
}
func TestPNGRenderer_ToPNGEmpty(t *testing.T) {
renderer := NewPNGRenderer(10)
pngData := renderer.ToPNG()
if len(pngData) == 0 {
t.Error("ToPNG() should return data even for empty image")
}
// Should be valid PNG
reader := bytes.NewReader(pngData)
_, err := png.Decode(reader)
if err != nil {
t.Errorf("ToPNG() returned invalid PNG data: %v", err)
}
}
func TestParseColor(t *testing.T) {
tests := []struct {
input string
opacity float64
expected color.RGBA
}{
{"#ff0000", 1.0, color.RGBA{R: 255, G: 0, B: 0, A: 255}},
{"ff0000", 1.0, color.RGBA{R: 255, G: 0, B: 0, A: 255}},
{"#00ff00", 0.5, color.RGBA{R: 0, G: 255, B: 0, A: 128}},
{"#0000ff", 0.0, color.RGBA{R: 0, G: 0, B: 255, A: 0}},
{"#f00", 1.0, color.RGBA{R: 255, G: 0, B: 0, A: 255}},
{"#0f0", 1.0, color.RGBA{R: 0, G: 255, B: 0, A: 255}},
{"#00f", 1.0, color.RGBA{R: 0, G: 0, B: 255, A: 255}},
{"#ff0000ff", 1.0, color.RGBA{R: 255, G: 0, B: 0, A: 255}},
{"#ff000080", 1.0, color.RGBA{R: 255, G: 0, B: 0, A: 128}},
{"invalid", 1.0, color.RGBA{R: 0, G: 0, B: 0, A: 255}},
{"", 1.0, color.RGBA{R: 0, G: 0, B: 0, A: 255}},
}
for _, test := range tests {
result := parseColor(test.input, test.opacity)
if result != test.expected {
t.Errorf("parseColor(%q, %v) = %v, want %v",
test.input, test.opacity, result, test.expected)
}
}
}
func TestPNGRenderer_ConcurrentAccess(t *testing.T) {
renderer := NewPNGRenderer(100)
// Test concurrent access to ensure thread safety
done := make(chan bool, 10)
for i := 0; i < 10; i++ {
go func(id int) {
renderer.BeginShape("#ff0000")
points := []engine.Point{
{X: float64(id * 5), Y: float64(id * 5)},
{X: float64(id*5 + 10), Y: float64(id * 5)},
{X: float64(id*5 + 10), Y: float64(id*5 + 10)},
{X: float64(id * 5), Y: float64(id*5 + 10)},
}
renderer.AddPolygon(points)
renderer.EndShape()
done <- true
}(i)
}
// Wait for all goroutines to complete
for i := 0; i < 10; i++ {
<-done
}
// Should be able to generate PNG without issues
pngData := renderer.ToPNG()
if len(pngData) == 0 {
t.Error("concurrent access test failed - no PNG data generated")
}
}
func BenchmarkPNGRenderer_ToPNG(b *testing.B) {
renderer := NewPNGRenderer(200)
renderer.SetBackground("#ffffff", 1.0)
// Add some shapes for a realistic benchmark
renderer.BeginShape("#ff0000")
renderer.AddPolygon([]engine.Point{
{X: 20, Y: 20}, {X: 80, Y: 20}, {X: 80, Y: 80}, {X: 20, Y: 80},
})
renderer.BeginShape("#00ff00")
renderer.AddCircle(engine.Point{X: 100, Y: 100}, 30, false)
b.ResetTimer()
for i := 0; i < b.N; i++ {
pngData := renderer.ToPNG()
if len(pngData) == 0 {
b.Fatal("ToPNG returned empty data")
}
}
}

View File

@@ -0,0 +1,237 @@
package renderer
import (
"github.com/kevin/go-jdenticon/internal/engine"
)
// Renderer defines the interface for rendering identicons to various output formats.
// It provides a set of drawing primitives that can be implemented by concrete renderers
// such as SVG, PNG, or other custom formats.
type Renderer interface {
// Drawing primitives
MoveTo(x, y float64)
LineTo(x, y float64)
CurveTo(x1, y1, x2, y2, x, y float64)
ClosePath()
// Fill and stroke operations
Fill(color string)
Stroke(color string, width float64)
// Shape management
BeginShape(color string)
EndShape()
// Background and configuration
SetBackground(fillColor string, opacity float64)
// High-level shape methods
AddPolygon(points []engine.Point)
AddCircle(topLeft engine.Point, size float64, invert bool)
AddRectangle(x, y, width, height float64)
AddTriangle(p1, p2, p3 engine.Point)
// Utility methods
GetSize() int
Clear()
}
// BaseRenderer provides default implementations for common renderer functionality.
// Concrete renderers can embed this struct and override specific methods as needed.
type BaseRenderer struct {
iconSize int
currentColor string
background string
backgroundOp float64
// Current path state for primitive operations
currentPath []PathCommand
pathStart engine.Point
currentPos engine.Point
}
// PathCommandType represents the type of path command
type PathCommandType int
const (
MoveToCommand PathCommandType = iota
LineToCommand
CurveToCommand
ClosePathCommand
)
// PathCommand represents a single drawing command in a path
type PathCommand struct {
Type PathCommandType
Points []engine.Point
}
// NewBaseRenderer creates a new base renderer with the specified icon size
func NewBaseRenderer(iconSize int) *BaseRenderer {
return &BaseRenderer{
iconSize: iconSize,
currentPath: make([]PathCommand, 0),
}
}
// MoveTo moves the current drawing position to the specified coordinates
func (r *BaseRenderer) MoveTo(x, y float64) {
pos := engine.Point{X: x, Y: y}
r.currentPos = pos
r.pathStart = pos
r.currentPath = append(r.currentPath, PathCommand{
Type: MoveToCommand,
Points: []engine.Point{pos},
})
}
// LineTo draws a line from the current position to the specified coordinates
func (r *BaseRenderer) LineTo(x, y float64) {
pos := engine.Point{X: x, Y: y}
r.currentPos = pos
r.currentPath = append(r.currentPath, PathCommand{
Type: LineToCommand,
Points: []engine.Point{pos},
})
}
// CurveTo draws a cubic Bézier curve from the current position to (x, y) using (x1, y1) and (x2, y2) as control points
func (r *BaseRenderer) CurveTo(x1, y1, x2, y2, x, y float64) {
endPos := engine.Point{X: x, Y: y}
r.currentPos = endPos
r.currentPath = append(r.currentPath, PathCommand{
Type: CurveToCommand,
Points: []engine.Point{
{X: x1, Y: y1},
{X: x2, Y: y2},
endPos,
},
})
}
// ClosePath closes the current path by drawing a line back to the path start
func (r *BaseRenderer) ClosePath() {
r.currentPos = r.pathStart
r.currentPath = append(r.currentPath, PathCommand{
Type: ClosePathCommand,
Points: []engine.Point{},
})
}
// Fill fills the current path with the specified color
func (r *BaseRenderer) Fill(color string) {
// Default implementation - concrete renderers should override
}
// Stroke strokes the current path with the specified color and width
func (r *BaseRenderer) Stroke(color string, width float64) {
// Default implementation - concrete renderers should override
}
// BeginShape starts a new shape with the specified color
func (r *BaseRenderer) BeginShape(color string) {
r.currentColor = color
r.currentPath = make([]PathCommand, 0)
}
// EndShape ends the current shape
func (r *BaseRenderer) EndShape() {
// Default implementation - concrete renderers should override
}
// SetBackground sets the background color and opacity
func (r *BaseRenderer) SetBackground(fillColor string, opacity float64) {
r.background = fillColor
r.backgroundOp = opacity
}
// AddPolygon adds a polygon to the renderer using the current fill color
func (r *BaseRenderer) AddPolygon(points []engine.Point) {
if len(points) == 0 {
return
}
// Move to first point
r.MoveTo(points[0].X, points[0].Y)
// Line to subsequent points
for i := 1; i < len(points); i++ {
r.LineTo(points[i].X, points[i].Y)
}
// Close the path
r.ClosePath()
// Fill with current color
r.Fill(r.currentColor)
}
// AddCircle adds a circle to the renderer using the current fill color
func (r *BaseRenderer) AddCircle(topLeft engine.Point, size float64, invert bool) {
// Approximate circle using cubic Bézier curves
// Magic number for circle approximation with Bézier curves
const kappa = 0.5522847498307936 // 4/3 * (sqrt(2) - 1)
radius := size / 2
centerX := topLeft.X + radius
centerY := topLeft.Y + radius
cp := kappa * radius // Control point distance
// Start at rightmost point
r.MoveTo(centerX+radius, centerY)
// Four cubic curves to approximate circle
r.CurveTo(centerX+radius, centerY+cp, centerX+cp, centerY+radius, centerX, centerY+radius)
r.CurveTo(centerX-cp, centerY+radius, centerX-radius, centerY+cp, centerX-radius, centerY)
r.CurveTo(centerX-radius, centerY-cp, centerX-cp, centerY-radius, centerX, centerY-radius)
r.CurveTo(centerX+cp, centerY-radius, centerX+radius, centerY-cp, centerX+radius, centerY)
r.ClosePath()
r.Fill(r.currentColor)
}
// AddRectangle adds a rectangle to the renderer
func (r *BaseRenderer) AddRectangle(x, y, width, height float64) {
points := []engine.Point{
{X: x, Y: y},
{X: x + width, Y: y},
{X: x + width, Y: y + height},
{X: x, Y: y + height},
}
r.AddPolygon(points)
}
// AddTriangle adds a triangle to the renderer
func (r *BaseRenderer) AddTriangle(p1, p2, p3 engine.Point) {
points := []engine.Point{p1, p2, p3}
r.AddPolygon(points)
}
// GetSize returns the icon size
func (r *BaseRenderer) GetSize() int {
return r.iconSize
}
// Clear clears the renderer state
func (r *BaseRenderer) Clear() {
r.currentPath = make([]PathCommand, 0)
r.currentColor = ""
r.background = ""
r.backgroundOp = 0
}
// GetCurrentPath returns the current path commands
func (r *BaseRenderer) GetCurrentPath() []PathCommand {
return r.currentPath
}
// GetCurrentColor returns the current drawing color
func (r *BaseRenderer) GetCurrentColor() string {
return r.currentColor
}
// GetBackground returns the background color and opacity
func (r *BaseRenderer) GetBackground() (string, float64) {
return r.background, r.backgroundOp
}

View File

@@ -0,0 +1,362 @@
package renderer
import (
"testing"
"github.com/kevin/go-jdenticon/internal/engine"
)
func TestNewBaseRenderer(t *testing.T) {
iconSize := 100
r := NewBaseRenderer(iconSize)
if r.GetSize() != iconSize {
t.Errorf("Expected icon size %d, got %d", iconSize, r.GetSize())
}
if len(r.GetCurrentPath()) != 0 {
t.Errorf("Expected empty path, got %d commands", len(r.GetCurrentPath()))
}
if r.GetCurrentColor() != "" {
t.Errorf("Expected empty current color, got %s", r.GetCurrentColor())
}
bg, bgOp := r.GetBackground()
if bg != "" || bgOp != 0 {
t.Errorf("Expected empty background, got %s with opacity %f", bg, bgOp)
}
}
func TestBaseRendererSetBackground(t *testing.T) {
r := NewBaseRenderer(100)
color := "#ff0000"
opacity := 0.5
r.SetBackground(color, opacity)
bg, bgOp := r.GetBackground()
if bg != color {
t.Errorf("Expected background color %s, got %s", color, bg)
}
if bgOp != opacity {
t.Errorf("Expected background opacity %f, got %f", opacity, bgOp)
}
}
func TestBaseRendererBeginShape(t *testing.T) {
r := NewBaseRenderer(100)
color := "#00ff00"
r.BeginShape(color)
if r.GetCurrentColor() != color {
t.Errorf("Expected current color %s, got %s", color, r.GetCurrentColor())
}
// Path should be reset when beginning a shape
if len(r.GetCurrentPath()) != 0 {
t.Errorf("Expected empty path after BeginShape, got %d commands", len(r.GetCurrentPath()))
}
}
func TestBaseRendererMoveTo(t *testing.T) {
r := NewBaseRenderer(100)
x, y := 10.5, 20.3
r.MoveTo(x, y)
path := r.GetCurrentPath()
if len(path) != 1 {
t.Fatalf("Expected 1 path command, got %d", len(path))
}
cmd := path[0]
if cmd.Type != MoveToCommand {
t.Errorf("Expected MoveToCommand, got %v", cmd.Type)
}
if len(cmd.Points) != 1 {
t.Fatalf("Expected 1 point, got %d", len(cmd.Points))
}
point := cmd.Points[0]
if point.X != x || point.Y != y {
t.Errorf("Expected point (%f, %f), got (%f, %f)", x, y, point.X, point.Y)
}
}
func TestBaseRendererLineTo(t *testing.T) {
r := NewBaseRenderer(100)
// Move to start point first
r.MoveTo(0, 0)
x, y := 15.7, 25.9
r.LineTo(x, y)
path := r.GetCurrentPath()
if len(path) != 2 {
t.Fatalf("Expected 2 path commands, got %d", len(path))
}
cmd := path[1] // Second command should be LineTo
if cmd.Type != LineToCommand {
t.Errorf("Expected LineToCommand, got %v", cmd.Type)
}
if len(cmd.Points) != 1 {
t.Fatalf("Expected 1 point, got %d", len(cmd.Points))
}
point := cmd.Points[0]
if point.X != x || point.Y != y {
t.Errorf("Expected point (%f, %f), got (%f, %f)", x, y, point.X, point.Y)
}
}
func TestBaseRendererCurveTo(t *testing.T) {
r := NewBaseRenderer(100)
// Move to start point first
r.MoveTo(0, 0)
x1, y1 := 10.0, 5.0
x2, y2 := 20.0, 15.0
x, y := 30.0, 25.0
r.CurveTo(x1, y1, x2, y2, x, y)
path := r.GetCurrentPath()
if len(path) != 2 {
t.Fatalf("Expected 2 path commands, got %d", len(path))
}
cmd := path[1] // Second command should be CurveTo
if cmd.Type != CurveToCommand {
t.Errorf("Expected CurveToCommand, got %v", cmd.Type)
}
if len(cmd.Points) != 3 {
t.Fatalf("Expected 3 points, got %d", len(cmd.Points))
}
// Check control points and end point
if cmd.Points[0].X != x1 || cmd.Points[0].Y != y1 {
t.Errorf("Expected first control point (%f, %f), got (%f, %f)", x1, y1, cmd.Points[0].X, cmd.Points[0].Y)
}
if cmd.Points[1].X != x2 || cmd.Points[1].Y != y2 {
t.Errorf("Expected second control point (%f, %f), got (%f, %f)", x2, y2, cmd.Points[1].X, cmd.Points[1].Y)
}
if cmd.Points[2].X != x || cmd.Points[2].Y != y {
t.Errorf("Expected end point (%f, %f), got (%f, %f)", x, y, cmd.Points[2].X, cmd.Points[2].Y)
}
}
func TestBaseRendererClosePath(t *testing.T) {
r := NewBaseRenderer(100)
// Move to start point first
r.MoveTo(0, 0)
r.LineTo(10, 10)
r.ClosePath()
path := r.GetCurrentPath()
if len(path) != 3 {
t.Fatalf("Expected 3 path commands, got %d", len(path))
}
cmd := path[2] // Third command should be ClosePath
if cmd.Type != ClosePathCommand {
t.Errorf("Expected ClosePathCommand, got %v", cmd.Type)
}
if len(cmd.Points) != 0 {
t.Errorf("Expected 0 points for ClosePath, got %d", len(cmd.Points))
}
}
func TestBaseRendererAddPolygon(t *testing.T) {
r := NewBaseRenderer(100)
r.BeginShape("#ff0000")
points := []engine.Point{
{X: 0, Y: 0},
{X: 10, Y: 0},
{X: 10, Y: 10},
{X: 0, Y: 10},
}
r.AddPolygon(points)
path := r.GetCurrentPath()
// Should have MoveTo + 3 LineTo + ClosePath = 5 commands
expectedCommands := len(points) + 1 // +1 for ClosePath
if len(path) != expectedCommands {
t.Fatalf("Expected %d path commands, got %d", expectedCommands, len(path))
}
// Check first command is MoveTo
if path[0].Type != MoveToCommand {
t.Errorf("Expected first command to be MoveTo, got %v", path[0].Type)
}
// Check last command is ClosePath
if path[len(path)-1].Type != ClosePathCommand {
t.Errorf("Expected last command to be ClosePath, got %v", path[len(path)-1].Type)
}
}
func TestBaseRendererAddRectangle(t *testing.T) {
r := NewBaseRenderer(100)
r.BeginShape("#0000ff")
x, y, width, height := 5.0, 10.0, 20.0, 15.0
r.AddRectangle(x, y, width, height)
path := r.GetCurrentPath()
// Should have MoveTo + 3 LineTo + ClosePath = 5 commands
if len(path) != 5 {
t.Fatalf("Expected 5 path commands, got %d", len(path))
}
// Verify the rectangle points
expectedPoints := []engine.Point{
{X: x, Y: y}, // bottom-left
{X: x + width, Y: y}, // bottom-right
{X: x + width, Y: y + height}, // top-right
{X: x, Y: y + height}, // top-left
}
// Check MoveTo point
if path[0].Points[0] != expectedPoints[0] {
t.Errorf("Expected first point %v, got %v", expectedPoints[0], path[0].Points[0])
}
// Check LineTo points
for i := 1; i < 4; i++ {
if path[i].Type != LineToCommand {
t.Errorf("Expected LineTo command at index %d, got %v", i, path[i].Type)
}
if path[i].Points[0] != expectedPoints[i] {
t.Errorf("Expected point %v at index %d, got %v", expectedPoints[i], i, path[i].Points[0])
}
}
}
func TestBaseRendererAddTriangle(t *testing.T) {
r := NewBaseRenderer(100)
r.BeginShape("#00ffff")
p1 := engine.Point{X: 0, Y: 0}
p2 := engine.Point{X: 10, Y: 0}
p3 := engine.Point{X: 5, Y: 10}
r.AddTriangle(p1, p2, p3)
path := r.GetCurrentPath()
// Should have MoveTo + 2 LineTo + ClosePath = 4 commands
if len(path) != 4 {
t.Fatalf("Expected 4 path commands, got %d", len(path))
}
// Check the triangle points
if path[0].Points[0] != p1 {
t.Errorf("Expected first point %v, got %v", p1, path[0].Points[0])
}
if path[1].Points[0] != p2 {
t.Errorf("Expected second point %v, got %v", p2, path[1].Points[0])
}
if path[2].Points[0] != p3 {
t.Errorf("Expected third point %v, got %v", p3, path[2].Points[0])
}
}
func TestBaseRendererAddCircle(t *testing.T) {
r := NewBaseRenderer(100)
r.BeginShape("#ffff00")
center := engine.Point{X: 50, Y: 50}
radius := 25.0
r.AddCircle(center, radius, false)
path := r.GetCurrentPath()
// Should have MoveTo + 4 CurveTo + ClosePath = 6 commands
if len(path) != 6 {
t.Fatalf("Expected 6 path commands for circle, got %d", len(path))
}
// Check first command is MoveTo
if path[0].Type != MoveToCommand {
t.Errorf("Expected first command to be MoveTo, got %v", path[0].Type)
}
// Check that we have 4 CurveTo commands
curveCount := 0
for i := 1; i < len(path)-1; i++ {
if path[i].Type == CurveToCommand {
curveCount++
}
}
if curveCount != 4 {
t.Errorf("Expected 4 CurveTo commands for circle, got %d", curveCount)
}
// Check last command is ClosePath
if path[len(path)-1].Type != ClosePathCommand {
t.Errorf("Expected last command to be ClosePath, got %v", path[len(path)-1].Type)
}
}
func TestBaseRendererClear(t *testing.T) {
r := NewBaseRenderer(100)
// Set some state
r.BeginShape("#ff0000")
r.SetBackground("#ffffff", 0.8)
r.MoveTo(10, 20)
r.LineTo(30, 40)
// Verify state is set
if r.GetCurrentColor() == "" {
t.Error("Expected current color to be set before clear")
}
if len(r.GetCurrentPath()) == 0 {
t.Error("Expected path commands before clear")
}
// Clear the renderer
r.Clear()
// Verify state is cleared
if r.GetCurrentColor() != "" {
t.Errorf("Expected empty current color after clear, got %s", r.GetCurrentColor())
}
if len(r.GetCurrentPath()) != 0 {
t.Errorf("Expected empty path after clear, got %d commands", len(r.GetCurrentPath()))
}
bg, bgOp := r.GetBackground()
if bg != "" || bgOp != 0 {
t.Errorf("Expected empty background after clear, got %s with opacity %f", bg, bgOp)
}
}
func TestBaseRendererEmptyPolygon(t *testing.T) {
r := NewBaseRenderer(100)
r.BeginShape("#ff0000")
// Test with empty points slice
r.AddPolygon([]engine.Point{})
path := r.GetCurrentPath()
if len(path) != 0 {
t.Errorf("Expected no path commands for empty polygon, got %d", len(path))
}
}

172
internal/renderer/svg.go Normal file
View File

@@ -0,0 +1,172 @@
package renderer
import (
"fmt"
"math"
"strconv"
"strings"
"github.com/kevin/go-jdenticon/internal/engine"
)
// SVGPath represents an SVG path element
type SVGPath struct {
data strings.Builder
}
// AddPolygon adds a polygon to the SVG path
func (p *SVGPath) AddPolygon(points []engine.Point) {
if len(points) == 0 {
return
}
// Move to first point
p.data.WriteString(fmt.Sprintf("M%s %s", svgValue(points[0].X), svgValue(points[0].Y)))
// Line to subsequent points
for i := 1; i < len(points); i++ {
p.data.WriteString(fmt.Sprintf("L%s %s", svgValue(points[i].X), svgValue(points[i].Y)))
}
// Close path
p.data.WriteString("Z")
}
// AddCircle adds a circle to the SVG path
func (p *SVGPath) AddCircle(topLeft engine.Point, size float64, counterClockwise bool) {
sweepFlag := "1"
if counterClockwise {
sweepFlag = "0"
}
radius := size / 2
centerX := topLeft.X + radius
centerY := topLeft.Y + radius
svgRadius := svgValue(radius)
svgDiameter := svgValue(size)
svgArc := fmt.Sprintf("a%s,%s 0 1,%s ", svgRadius, svgRadius, sweepFlag)
// Move to start point (left side of circle)
startX := centerX - radius
startY := centerY
p.data.WriteString(fmt.Sprintf("M%s %s", svgValue(startX), svgValue(startY)))
p.data.WriteString(svgArc + svgDiameter + ",0")
p.data.WriteString(svgArc + "-" + svgDiameter + ",0")
}
// DataString returns the SVG path data string
func (p *SVGPath) DataString() string {
return p.data.String()
}
// SVGRenderer implements the Renderer interface for SVG output
type SVGRenderer struct {
*BaseRenderer
pathsByColor map[string]*SVGPath
colorOrder []string
}
// NewSVGRenderer creates a new SVG renderer
func NewSVGRenderer(iconSize int) *SVGRenderer {
return &SVGRenderer{
BaseRenderer: NewBaseRenderer(iconSize),
pathsByColor: make(map[string]*SVGPath),
colorOrder: make([]string, 0),
}
}
// SetBackground sets the background color and opacity
func (r *SVGRenderer) SetBackground(fillColor string, opacity float64) {
r.BaseRenderer.SetBackground(fillColor, opacity)
}
// BeginShape marks the beginning of a new shape with the specified color
func (r *SVGRenderer) BeginShape(color string) {
r.BaseRenderer.BeginShape(color)
if _, exists := r.pathsByColor[color]; !exists {
r.pathsByColor[color] = &SVGPath{}
r.colorOrder = append(r.colorOrder, color)
}
}
// EndShape marks the end of the currently drawn shape
func (r *SVGRenderer) EndShape() {
// No action needed for SVG
}
// getCurrentPath returns the current path for the active color
func (r *SVGRenderer) getCurrentPath() *SVGPath {
currentColor := r.GetCurrentColor()
if currentColor == "" {
return nil
}
return r.pathsByColor[currentColor]
}
// AddPolygon adds a polygon with the current fill color to the SVG
func (r *SVGRenderer) AddPolygon(points []engine.Point) {
if path := r.getCurrentPath(); path != nil {
path.AddPolygon(points)
}
}
// AddCircle adds a circle with the current fill color to the SVG
func (r *SVGRenderer) AddCircle(topLeft engine.Point, size float64, invert bool) {
if path := r.getCurrentPath(); path != nil {
path.AddCircle(topLeft, size, invert)
}
}
// ToSVG generates the final SVG XML string
func (r *SVGRenderer) ToSVG() string {
var svg strings.Builder
iconSize := r.GetSize()
background, backgroundOp := r.GetBackground()
// SVG opening tag with namespace and dimensions
svg.WriteString(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`,
iconSize, iconSize, iconSize, iconSize))
// Add background rectangle if specified
if background != "" && backgroundOp > 0 {
if backgroundOp >= 1.0 {
svg.WriteString(fmt.Sprintf(`<rect width="100%%" height="100%%" fill="%s"/>`, background))
} else {
svg.WriteString(fmt.Sprintf(`<rect width="100%%" height="100%%" fill="%s" opacity="%.2f"/>`,
background, backgroundOp))
}
}
// Add paths for each color (in insertion order to preserve z-order)
for _, color := range r.colorOrder {
path := r.pathsByColor[color]
dataString := path.DataString()
if dataString != "" {
svg.WriteString(fmt.Sprintf(`<path fill="%s" d="%s"/>`, color, dataString))
}
}
// SVG closing tag
svg.WriteString("</svg>")
return svg.String()
}
// svgValue rounds a float64 to one decimal place, mimicking the Jdenticon JS implementation's
// "round half up" behavior. It also formats the number to a minimal string representation.
func svgValue(value float64) string {
// Use math.Floor to replicate the "round half up" logic from the JS implementation.
// JavaScript: ((value * 10 + 0.5) | 0) / 10
rounded := math.Floor(value*10 + 0.5) / 10
// Format to an integer string if there's no fractional part.
if rounded == math.Trunc(rounded) {
return strconv.Itoa(int(rounded))
}
// Otherwise, format to one decimal place.
return strconv.FormatFloat(rounded, 'f', 1, 64)
}

View File

@@ -0,0 +1,240 @@
package renderer
import (
"fmt"
"strings"
"testing"
"github.com/kevin/go-jdenticon/internal/engine"
)
func TestSVGPath_AddPolygon(t *testing.T) {
path := &SVGPath{}
points := []engine.Point{
{X: 0, Y: 0},
{X: 10, Y: 0},
{X: 10, Y: 10},
{X: 0, Y: 10},
}
path.AddPolygon(points)
expected := "M0 0L10 0L10 10L0 10Z"
if got := path.DataString(); got != expected {
t.Errorf("AddPolygon() = %v, want %v", got, expected)
}
}
func TestSVGPath_AddPolygonEmpty(t *testing.T) {
path := &SVGPath{}
path.AddPolygon([]engine.Point{})
if got := path.DataString(); got != "" {
t.Errorf("AddPolygon([]) = %v, want empty string", got)
}
}
func TestSVGPath_AddCircle(t *testing.T) {
path := &SVGPath{}
topLeft := engine.Point{X: 25, Y: 25} // Top-left corner to get center at (50, 50)
size := 50.0 // Size 50 gives radius 25
path.AddCircle(topLeft, size, false)
// Should start at left side of circle and draw two arcs
result := path.DataString()
if !strings.HasPrefix(result, "M25 50") {
t.Errorf("Circle should start at left side, got: %s", result)
}
if !strings.Contains(result, "a25,25 0 1,1") {
t.Errorf("Circle should contain clockwise arc, got: %s", result)
}
}
func TestSVGPath_AddCircleCounterClockwise(t *testing.T) {
path := &SVGPath{}
topLeft := engine.Point{X: 25, Y: 25} // Top-left corner to get center at (50, 50)
size := 50.0 // Size 50 gives radius 25
path.AddCircle(topLeft, size, true)
result := path.DataString()
if !strings.Contains(result, "a25,25 0 1,0") {
t.Errorf("Counter-clockwise circle should have sweep flag 0, got: %s", result)
}
}
func TestSVGRenderer_NewSVGRenderer(t *testing.T) {
renderer := NewSVGRenderer(100)
if renderer.iconSize != 100 {
t.Errorf("NewSVGRenderer(100).iconSize = %v, want 100", renderer.iconSize)
}
if renderer.pathsByColor == nil {
t.Error("pathsByColor should be initialized")
}
}
func TestSVGRenderer_BeginEndShape(t *testing.T) {
renderer := NewSVGRenderer(100)
renderer.BeginShape("#ff0000")
if renderer.currentColor != "#ff0000" {
t.Errorf("BeginShape should set currentColor, got %v", renderer.currentColor)
}
if _, exists := renderer.pathsByColor["#ff0000"]; !exists {
t.Error("BeginShape should create path for color")
}
renderer.EndShape()
// EndShape is a no-op for SVG, just verify it doesn't panic
}
func TestSVGRenderer_AddPolygon(t *testing.T) {
renderer := NewSVGRenderer(100)
renderer.BeginShape("#ff0000")
points := []engine.Point{
{X: 0, Y: 0},
{X: 10, Y: 0},
{X: 5, Y: 10},
}
renderer.AddPolygon(points)
path := renderer.pathsByColor["#ff0000"]
expected := "M0 0L10 0L5 10Z"
if got := path.DataString(); got != expected {
t.Errorf("AddPolygon() = %v, want %v", got, expected)
}
}
func TestSVGRenderer_AddCircle(t *testing.T) {
renderer := NewSVGRenderer(100)
renderer.BeginShape("#00ff00")
topLeft := engine.Point{X: 30, Y: 30} // Top-left corner to get center at (50, 50)
size := 40.0 // Size 40 gives radius 20
renderer.AddCircle(topLeft, size, false)
path := renderer.pathsByColor["#00ff00"]
result := path.DataString()
if !strings.HasPrefix(result, "M30 50") {
t.Errorf("Circle should start at correct position, got: %s", result)
}
}
func TestSVGRenderer_ToSVG(t *testing.T) {
renderer := NewSVGRenderer(100)
renderer.SetBackground("#ffffff", 1.0)
renderer.BeginShape("#ff0000")
points := []engine.Point{
{X: 0, Y: 0},
{X: 10, Y: 0},
{X: 10, Y: 10},
}
renderer.AddPolygon(points)
svg := renderer.ToSVG()
// Check SVG structure
if !strings.Contains(svg, `<svg xmlns="http://www.w3.org/2000/svg"`) {
t.Error("SVG should contain proper xmlns")
}
if !strings.Contains(svg, `width="100" height="100"`) {
t.Error("SVG should contain correct dimensions")
}
if !strings.Contains(svg, `viewBox="0 0 100 100"`) {
t.Error("SVG should contain correct viewBox")
}
if !strings.Contains(svg, `<rect width="100%" height="100%" fill="#ffffff"/>`) {
t.Error("SVG should contain background rect")
}
if !strings.Contains(svg, `<path fill="#ff0000" d="M0 0L10 0L10 10Z"/>`) {
t.Error("SVG should contain path with correct data")
}
if !strings.HasSuffix(svg, "</svg>") {
t.Error("SVG should end with closing tag")
}
}
func TestSVGRenderer_ToSVGWithoutBackground(t *testing.T) {
renderer := NewSVGRenderer(50)
renderer.BeginShape("#0000ff")
center := engine.Point{X: 25, Y: 25}
renderer.AddCircle(center, 10, false)
svg := renderer.ToSVG()
// Should not contain background rect
if strings.Contains(svg, "<rect") {
t.Error("SVG without background should not contain rect")
}
// Should contain the circle path
if !strings.Contains(svg, `fill="#0000ff"`) {
t.Error("SVG should contain circle path")
}
}
func TestSVGRenderer_BackgroundWithOpacity(t *testing.T) {
renderer := NewSVGRenderer(100)
renderer.SetBackground("#cccccc", 0.5)
svg := renderer.ToSVG()
if !strings.Contains(svg, `opacity="0.50"`) {
t.Error("SVG should contain opacity attribute")
}
}
func TestSvgValue(t *testing.T) {
tests := []struct {
input float64
expected string
}{
{0, "0"},
{1.0, "1"},
{1.5, "1.5"},
{1.23456, "1.2"},
{1.26, "1.3"},
{10.0, "10"},
{10.1, "10.1"},
}
for _, test := range tests {
if got := svgValue(test.input); got != test.expected {
t.Errorf("svgValue(%v) = %v, want %v", test.input, got, test.expected)
}
}
}
func TestSvgValueRounding(t *testing.T) {
// Test cases to verify "round half up" behavior matches JavaScript implementation
testCases := []struct {
input float64
expected string
}{
{12.0, "12"},
{12.2, "12.2"},
{12.25, "12.3"}, // Key case that fails with math.Round (would be "12.2")
{12.35, "12.4"}, // Another case to verify consistent behavior
{12.45, "12.5"}, // Another key case
{12.75, "12.8"},
{-12.25, "-12.2"}, // Test negative rounding
{-12.35, "-12.3"},
{50.45, "50.5"}, // Real-world case from avatar generation
{50.55, "50.6"},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("Input_%f", tc.input), func(t *testing.T) {
got := svgValue(tc.input)
if got != tc.expected {
t.Errorf("svgValue(%f) = %q; want %q", tc.input, got, tc.expected)
}
})
}
}

59
internal/util/hash.go Normal file
View File

@@ -0,0 +1,59 @@
package util
import (
"fmt"
"strconv"
)
// ParseHex parses a hexadecimal value from the hash string
// This implementation is shared between engine and jdenticon packages for consistency
func ParseHex(hash string, startPosition, octets int) (int, error) {
// Handle negative indices (count from end like JavaScript)
if startPosition < 0 {
startPosition = len(hash) + startPosition
}
// Ensure we don't go out of bounds
if startPosition < 0 || startPosition >= len(hash) {
return 0, fmt.Errorf("parseHex: position %d out of bounds for hash length %d", startPosition, len(hash))
}
// If octets is 0 or negative, read from startPosition to end (like JavaScript default)
end := len(hash)
if octets > 0 {
end = startPosition + octets
if end > len(hash) {
end = len(hash)
}
}
// Extract substring and parse as hexadecimal
substr := hash[startPosition:end]
if len(substr) == 0 {
return 0, fmt.Errorf("parseHex: empty substring at position %d", startPosition)
}
result, err := strconv.ParseInt(substr, 16, 64)
if err != nil {
return 0, fmt.Errorf("parseHex: failed to parse hex '%s' at position %d: %w", substr, startPosition, err)
}
return int(result), nil
}
// IsValidHash checks if a hash string is valid for jdenticon generation
// This implementation is shared between engine and jdenticon packages for consistency
func IsValidHash(hash string) bool {
if len(hash) < 11 {
return false
}
// Check if all characters are valid hexadecimal
for _, r := range hash {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return false
}
}
return true
}