- 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
408 lines
11 KiB
Go
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)),
|
|
}
|
|
}
|