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
This commit is contained in:
@@ -3,36 +3,85 @@ package engine
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"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)
|
||||
var correctors = []float64{0.55, 0.5, 0.5, 0.46, 0.6, 0.55, 0.55}
|
||||
// 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 both HSL and RGB representations
|
||||
// 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]
|
||||
R, G, B uint8 // RGB values: [0,255]
|
||||
A uint8 // Alpha channel: [0,255]
|
||||
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 {
|
||||
r, g, b := HSLToRGB(h, s, l)
|
||||
return Color{
|
||||
H: h, S: s, L: l,
|
||||
R: r, G: g, B: b,
|
||||
A: 255,
|
||||
A: defaultAlphaValue,
|
||||
corrected: false,
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
A: defaultAlphaValue,
|
||||
corrected: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +90,8 @@ 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,
|
||||
A: defaultAlphaValue,
|
||||
corrected: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,102 +100,134 @@ 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,
|
||||
A: a,
|
||||
corrected: false,
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
return fmt.Sprintf("#%02x%02x%02x%02x", c.R, c.G, c.B, c.A)
|
||||
|
||||
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 {
|
||||
return c.R == other.R && c.G == other.G && c.B == other.B && c.A == other.A
|
||||
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,
|
||||
R: c.R, G: c.G, B: c.B,
|
||||
A: alpha,
|
||||
A: alpha,
|
||||
corrected: c.corrected,
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
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 NewColorCorrectedHSL(c.H, c.S, newL)
|
||||
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 NewColorCorrectedHSL(c.H, c.S, newL)
|
||||
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) / 255.0
|
||||
gf := float64(g) / 255.0
|
||||
bf := float64(b) / 255.0
|
||||
|
||||
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 > 0.5 {
|
||||
if l > hslMidpoint {
|
||||
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
|
||||
})()
|
||||
h = (gf - bf) / delta
|
||||
if gf < bf {
|
||||
h += 6
|
||||
}
|
||||
case gf:
|
||||
h = (bf-rf)/delta + 2
|
||||
case bf:
|
||||
h = (rf-gf)/delta + 4
|
||||
}
|
||||
h /= 6.0
|
||||
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]
|
||||
// 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) {
|
||||
// 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 {
|
||||
@@ -154,50 +235,72 @@ func HSLToRGB(h, s, l float64) (r, g, b uint8) {
|
||||
}
|
||||
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
|
||||
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 <= 0.5 {
|
||||
if l <= hslMidpoint {
|
||||
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
|
||||
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) {
|
||||
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*6 + 0.5)) % len(correctors)
|
||||
hueIndex := int((h*hueSegmentCount + hueRounding)) % len(correctors)
|
||||
corrector := correctors[hueIndex]
|
||||
|
||||
|
||||
// Adjust lightness relative to the corrector
|
||||
if l < 0.5 {
|
||||
if l < hslMidpoint {
|
||||
l = l * corrector * 2
|
||||
} else {
|
||||
l = corrector + (l-0.5)*(1-corrector)*2
|
||||
l = corrector + (l-hslMidpoint)*(1-corrector)*2
|
||||
}
|
||||
|
||||
|
||||
// Clamp the corrected lightness
|
||||
l = clamp(l, 0, 1)
|
||||
|
||||
return HSLToRGB(h, s, l)
|
||||
|
||||
// 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
|
||||
@@ -205,11 +308,11 @@ func CorrectedHSLToRGB(h, s, l float64) (r, g, b uint8) {
|
||||
func hueToRGB(m1, m2, h float64) float64 {
|
||||
// Normalize hue to [0, 6) range
|
||||
if h < 0 {
|
||||
h += 6
|
||||
} else if h > 6 {
|
||||
h -= 6
|
||||
h += hueCycle
|
||||
} else if h > hueCycle {
|
||||
h -= hueCycle
|
||||
}
|
||||
|
||||
|
||||
// Calculate RGB component based on hue position
|
||||
if h < 1 {
|
||||
return m1 + (m2-m1)*h
|
||||
@@ -233,77 +336,35 @@ func clamp(value, min, max float64) float64 {
|
||||
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 {
|
||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||
}
|
||||
// Use a strings.Builder for more efficient hex formatting
|
||||
var buf strings.Builder
|
||||
buf.Grow(hexColorLength)
|
||||
|
||||
// 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
|
||||
}
|
||||
buf.WriteByte('#')
|
||||
writeHexByte(&buf, r)
|
||||
writeHexByte(&buf, g)
|
||||
writeHexByte(&buf, b)
|
||||
|
||||
// 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
|
||||
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)
|
||||
}
|
||||
@@ -311,11 +372,11 @@ func GenerateColor(hue float64, config ColorConfig, lightnessValue float64) Colo
|
||||
// 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
|
||||
|
||||
hue := colorThemeDarkLightness
|
||||
|
||||
// Get lightness from grayscale configuration range
|
||||
lightness := config.GrayscaleLightness.GetLightness(lightnessValue)
|
||||
|
||||
|
||||
// Use grayscale saturation (typically 0)
|
||||
return NewColorCorrectedHSL(hue, config.GrayscaleSaturation, lightness)
|
||||
}
|
||||
@@ -326,21 +387,21 @@ func GenerateGrayscale(config ColorConfig, lightnessValue float64) 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)),
|
||||
|
||||
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(colorThemeDarkLightness)),
|
||||
|
||||
// Mid color (normal color with lightness 0.5)
|
||||
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(0.5)),
|
||||
|
||||
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(colorThemeMidLightness)),
|
||||
|
||||
// Light gray (grayscale with lightness 1)
|
||||
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(1)),
|
||||
|
||||
NewColorCorrectedHSL(restrictedHue, config.GrayscaleSaturation, config.GrayscaleLightness.GetLightness(colorThemeFullLightness)),
|
||||
|
||||
// Light color (normal color with lightness 1)
|
||||
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(1)),
|
||||
|
||||
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(colorThemeFullLightness)),
|
||||
|
||||
// Dark color (normal color with lightness 0)
|
||||
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(0)),
|
||||
NewColorCorrectedHSL(restrictedHue, config.ColorSaturation, config.ColorLightness.GetLightness(colorThemeDarkLightness)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user