Files
go-jdenticon/internal/engine/color.go
Kevin McIntyre d9e84812ff Initial release: Go Jdenticon library v0.1.0
- Core library with SVG and PNG generation
- CLI tool with generate and batch commands
- Cross-platform path handling for Windows compatibility
- Comprehensive test suite with integration tests
2026-01-03 23:41:48 -05:00

408 lines
11 KiB
Go

package engine
import (
"fmt"
"math"
"strings"
)
// Color-related constants
const (
// Alpha channel constants
defaultAlphaValue = 255 // Default alpha value for opaque colors
// RGB/HSL conversion constants
rgbComponentMax = 255.0 // Maximum RGB component value
rgbMaxValue = 255 // Maximum RGB value as integer
hueCycle = 6.0 // Hue cycle length for HSL conversion
hslMidpoint = 0.5 // HSL lightness midpoint
// Grayscale detection threshold
grayscaleToleranceThreshold = 0.01 // Threshold for detecting grayscale colors
// Hue calculation constants
hueSegmentCount = 6 // Number of hue segments for correction
hueRounding = 0.5 // Rounding offset for hue indexing
// Color theme lightness values (matches JavaScript implementation)
colorThemeDarkLightness = 0.0 // Dark color lightness value
colorThemeMidLightness = 0.5 // Mid color lightness value
colorThemeFullLightness = 1.0 // Full lightness value
// Hex color string buffer sizes
hexColorLength = 7 // #rrggbb = 7 characters
hexColorAlphaLength = 9 // #rrggbbaa = 9 characters
)
// Lightness correctors for each hue segment (based on JavaScript implementation)
// These values are carefully tuned to match the JavaScript reference implementation
var correctors = []float64{
0.55, // Red hues (0°-60°)
0.5, // Yellow hues (60°-120°)
0.5, // Green hues (120°-180°)
0.46, // Cyan hues (180°-240°)
0.6, // Blue hues (240°-300°)
0.55, // Magenta hues (300°-360°)
0.55, // Wrap-around for edge cases
}
// Color represents a color with HSL representation and on-demand RGB conversion
type Color struct {
H, S, L float64 // HSL values: H=[0,1], S=[0,1], L=[0,1]
A uint8 // Alpha channel: [0,255]
corrected bool // Whether to use corrected HSL to RGB conversion
}
// ToRGB returns the RGB values computed from HSL using appropriate conversion
func (c Color) ToRGB() (r, g, b uint8, err error) {
if c.corrected {
return CorrectedHSLToRGB(c.H, c.S, c.L)
}
return HSLToRGB(c.H, c.S, c.L)
}
// ToRGBA returns the RGBA values computed from HSL using appropriate conversion
func (c Color) ToRGBA() (r, g, b, a uint8, err error) {
r, g, b, err = c.ToRGB()
return r, g, b, c.A, err
}
// NewColorHSL creates a new Color from HSL values
func NewColorHSL(h, s, l float64) Color {
return Color{
H: h, S: s, L: l,
A: defaultAlphaValue,
corrected: false,
}
}
// NewColorCorrectedHSL creates a new Color from HSL values with lightness correction
func NewColorCorrectedHSL(h, s, l float64) Color {
return Color{
H: h, S: s, L: l,
A: defaultAlphaValue,
corrected: true,
}
}
// 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,
A: defaultAlphaValue,
corrected: false,
}
}
// 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,
A: a,
corrected: false,
}
}
// String returns the hex representation of the color
func (c Color) String() string {
r, g, b, err := c.ToRGB()
if err != nil {
// Return a fallback color (black) if conversion fails
// This maintains the string contract while indicating an error state
r, g, b = 0, 0, 0
}
if c.A == defaultAlphaValue {
return RGBToHex(r, g, b)
}
// Use strings.Builder for RGBA format
var buf strings.Builder
buf.Grow(hexColorAlphaLength)
buf.WriteByte('#')
writeHexByte(&buf, r)
writeHexByte(&buf, g)
writeHexByte(&buf, b)
writeHexByte(&buf, c.A)
return buf.String()
}
// Equals compares two colors for equality
func (c Color) Equals(other Color) bool {
r1, g1, b1, err1 := c.ToRGB()
r2, g2, b2, err2 := other.ToRGB()
// If either color has a conversion error, they are not equal
if err1 != nil || err2 != nil {
return false
}
return r1 == r2 && g1 == g2 && b1 == b2 && 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,
A: alpha,
corrected: c.corrected,
}
}
// IsGrayscale returns true if the color is grayscale (saturation near zero)
func (c Color) IsGrayscale() bool {
return c.S < grayscaleToleranceThreshold
}
// Darken returns a new color with reduced lightness
func (c Color) Darken(amount float64) Color {
newL := clamp(c.L-amount, 0, 1)
return Color{
H: c.H, S: c.S, L: newL,
A: c.A,
corrected: c.corrected,
}
}
// Lighten returns a new color with increased lightness
func (c Color) Lighten(amount float64) Color {
newL := clamp(c.L+amount, 0, 1)
return Color{
H: c.H, S: c.S, L: newL,
A: c.A,
corrected: c.corrected,
}
}
// 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) / rgbComponentMax
gf := float64(g) / rgbComponentMax
bf := float64(b) / rgbComponentMax
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 > hslMidpoint {
s = delta / (2.0 - max - min)
} else {
s = delta / (max + min)
}
// Calculate hue
switch max {
case rf:
h = (gf - bf) / delta
if gf < bf {
h += 6
}
case gf:
h = (bf-rf)/delta + 2
case bf:
h = (rf-gf)/delta + 4
}
h /= hueCycle
}
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] and an error if conversion fails
func HSLToRGB(h, s, l float64) (r, g, b uint8, err error) {
// 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*rgbComponentMax, 0, rgbComponentMax))
return gray, gray, gray, nil
}
// Calculate intermediate values for HSL to RGB conversion
var m2 float64
if l <= hslMidpoint {
m2 = l * (s + 1)
} else {
m2 = l + s - l*s
}
m1 := l*2 - m2
// Convert each RGB component
rf := hueToRGB(m1, m2, h*hueCycle+2) * rgbComponentMax
gf := hueToRGB(m1, m2, h*hueCycle) * rgbComponentMax
bf := hueToRGB(m1, m2, h*hueCycle-2) * rgbComponentMax
// Validate floating point results before conversion to uint8
if math.IsNaN(rf) || math.IsInf(rf, 0) ||
math.IsNaN(gf) || math.IsInf(gf, 0) ||
math.IsNaN(bf) || math.IsInf(bf, 0) {
return 0, 0, 0, fmt.Errorf("jdenticon: engine: HSL to RGB conversion failed: non-finite value produced during conversion")
}
r = uint8(clamp(rf, 0, rgbComponentMax))
g = uint8(clamp(gf, 0, rgbComponentMax))
b = uint8(clamp(bf, 0, rgbComponentMax))
return r, g, b, nil
}
// 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, err error) {
// Defensive check: ensure correctors table is properly initialized
if len(correctors) == 0 {
return 0, 0, 0, fmt.Errorf("jdenticon: engine: corrected HSL to RGB conversion failed: color correctors table is empty or not initialized")
}
// Get the corrector for the current hue
hueIndex := int((h*hueSegmentCount + hueRounding)) % len(correctors)
corrector := correctors[hueIndex]
// Adjust lightness relative to the corrector
if l < hslMidpoint {
l = l * corrector * 2
} else {
l = corrector + (l-hslMidpoint)*(1-corrector)*2
}
// Clamp the corrected lightness
l = clamp(l, 0, 1)
// Call HSLToRGB and propagate any error
r, g, b, err = HSLToRGB(h, s, l)
if err != nil {
return 0, 0, 0, fmt.Errorf("jdenticon: engine: corrected HSL to RGB conversion failed: %w", err)
}
return r, g, b, nil
}
// 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 += hueCycle
} else if h > hueCycle {
h -= hueCycle
}
// 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
}
// writeHexByte writes a single byte as two hex characters to the builder
func writeHexByte(buf *strings.Builder, b uint8) {
const hexChars = "0123456789abcdef"
buf.WriteByte(hexChars[b>>4])
buf.WriteByte(hexChars[b&0x0f])
}
// RGBToHex converts RGB values to a hexadecimal color string
func RGBToHex(r, g, b uint8) string {
// Use a strings.Builder for more efficient hex formatting
var buf strings.Builder
buf.Grow(hexColorLength)
buf.WriteByte('#')
writeHexByte(&buf, r)
writeHexByte(&buf, g)
writeHexByte(&buf, b)
return buf.String()
}
// 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 := colorThemeDarkLightness
// 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(colorThemeDarkLightness)),
// Mid color (normal color with lightness 0.5)
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(colorThemeMidLightness)),
// Light gray (grayscale with lightness 1)
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(colorThemeFullLightness)),
// Light color (normal color with lightness 1)
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(colorThemeFullLightness)),
// Dark color (normal color with lightness 0)
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(colorThemeDarkLightness)),
}
}