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

BIN
CLAUDE.md Normal file

Binary file not shown.

188
FIXUP.md Normal file
View File

@@ -0,0 +1,188 @@
# FIXUP Plan: Go Jdenticon JavaScript Reference Compatibility
## Problem Summary
The Go implementation of Jdenticon generates completely different SVG output compared to the JavaScript reference implementation, despite having identical hash generation. The test case `TestJavaScriptReferenceCompatibility` reveals fundamental differences in the generation algorithm.
## Root Cause Analysis
Based on test results comparing Go vs JavaScript output for identical inputs:
### ✅ What's Working
- Hash generation (SHA1) is identical between implementations
- `svgValue()` rounding behavior now matches JavaScript "round half up"
- Basic SVG structure and syntax
### ❌ What's Broken
1. **Shape Generation Logic**: Completely different shapes and paths generated
2. **Coordinate Calculations**: Different coordinate values (e.g., JS: `35.9`, `39.8` vs Go: `37.2`, `41.1`)
3. **Path Ordering**: SVG paths appear in different sequence
4. **Circle Positioning**: Circles generated at different locations
5. **Transform Application**: Rotation/positioning logic differs
### Evidence from Test Case
**Input**: `"test-hash"` (size 64)
- **JavaScript**: `<path fill="#e8e8e8" d="M19 6L32 6L32 19Z..."/>` (first path)
- **Go**: `<path fill="#d175c5" d="M19 19L6 19L6 12.5Z..."/>` (first path)
- Completely different shapes, colors, and coordinates
## Investigation Plan
### Phase 1: Algorithm Deep Dive (High Priority)
1. **Study JavaScript IconGenerator**
- Examine `jdenticon-js/src/renderer/iconGenerator.js`
- Understand shape selection and positioning logic
- Document the exact algorithm flow
2. **Study JavaScript Shape Generation**
- Examine `jdenticon-js/src/renderer/shapes.js`
- Understand how shapes are created and positioned
- Document shape types and their generation rules
3. **Study JavaScript Layout System**
- Examine how the 4x4 grid layout works
- Understand cell positioning and sizing
- Document the exact coordinate calculation logic
### Phase 2: Go Implementation Analysis (High Priority)
1. **Audit Go Generator Logic**
- Compare `internal/engine/generator.go` with JavaScript equivalent
- Identify algorithmic differences in shape selection
- Check if we're using the same hash parsing logic
2. **Audit Go Shape Generation**
- Compare `internal/engine/shapes.go` with JavaScript
- Verify shape types and their implementation
- Check transform application
3. **Audit Go Layout System**
- Compare `internal/engine/layout.go` with JavaScript
- Verify grid calculations and cell positioning
- Check coordinate generation logic
### Phase 3: Systematic Fixes (High Priority)
#### 3.1 Fix Shape Selection Algorithm
**Files to modify**: `internal/engine/generator.go`
- Ensure hash bit extraction matches JavaScript exactly
- Verify shape type selection logic
- Fix shape positioning and rotation logic
#### 3.2 Fix Layout System
**Files to modify**: `internal/engine/layout.go`
- Match JavaScript grid calculations exactly
- Fix cell size and positioning calculations
- Ensure transforms are applied correctly
#### 3.3 Fix Shape Implementation
**Files to modify**: `internal/engine/shapes.go`
- Verify each shape type matches JavaScript geometry
- Fix coordinate calculations for polygons and circles
- Ensure proper transform application
#### 3.4 Fix Generation Order
**Files to modify**: `internal/engine/generator.go`, `internal/renderer/svg.go`
- Match the exact order of shape generation
- Ensure SVG paths are written in same sequence as JavaScript
- Fix color assignment order
### Phase 4: Validation (Medium Priority)
#### 4.1 Expand Test Coverage
**Files to modify**: `jdenticon/reference_test.go`
- Add more test inputs with known JavaScript outputs
- Test different icon sizes (64, 128, 256)
- Test edge cases and different hash patterns
#### 4.2 Coordinate-by-Coordinate Validation
- Create debug output showing step-by-step coordinate generation
- Compare each transform operation with JavaScript
- Validate grid positioning calculations
#### 4.3 Shape-by-Shape Validation
- Test individual shape generation in isolation
- Verify each shape type produces identical output
- Test rotation and transform application
### Phase 5: Performance & Polish (Low Priority)
#### 5.1 Optimize Performance
- Ensure fixes don't degrade performance
- Profile generation time vs JavaScript
- Optimize hot paths if needed
#### 5.2 Documentation
- Document the JavaScript compatibility
- Update comments explaining the algorithm
- Add examples showing identical output
## Implementation Strategy
### Step 1: JavaScript Reference Study (Day 1)
1. Read and document JavaScript `iconGenerator.js` algorithm
2. Create flowchart of JavaScript generation process
3. Document exact hash bit usage and shape selection
### Step 2: Go Algorithm Audit (Day 1-2)
1. Compare Go implementation line-by-line with JavaScript
2. Identify all algorithmic differences
3. Create detailed list of required changes
### Step 3: Systematic Implementation (Day 2-3)
1. Fix most critical differences first (shape selection)
2. Fix layout and coordinate calculation
3. Fix shape implementation details
4. Fix generation order and path sequencing
### Step 4: Validation Loop (Day 3-4)
1. Run reference compatibility tests after each fix
2. Add debug output to trace differences
3. Iterate until tests pass
4. Expand test coverage
## Success Criteria
### Primary Goals
- [ ] `TestJavaScriptReferenceCompatibility` passes for all test cases
- [ ] Byte-for-byte identical SVG output for same input hash/size
- [ ] No regression in existing functionality
### Secondary Goals
- [ ] Performance comparable to current implementation
- [ ] Code remains maintainable and well-documented
- [ ] All existing tests continue to pass
## Risk Assessment
### High Risk
- **Scope Creep**: The fixes might require rewriting major portions of the generation engine
- **Breaking Changes**: Existing users might rely on current (incorrect) output
### Medium Risk
- **Performance Impact**: Algorithm changes might affect generation speed
- **Test Maintenance**: Need to maintain both Go and JavaScript reference outputs
### Low Risk
- **API Changes**: Public API should remain unchanged
- **Backward Compatibility**: Hash generation stays the same
## Rollback Plan
If fixes prove too complex or risky:
1. Keep current implementation as `v1-legacy`
2. Implement JavaScript-compatible version as `v2`
3. Provide migration guide for users
4. Allow users to choose implementation version
## Notes
- The `svgValue()` rounding fix was correct but insufficient
- This is not a minor coordinate issue - it's a fundamental algorithmic difference
- Success requires matching JavaScript behavior exactly, not just approximating it
- Consider this a "port" rather than a "reimplementation"
---
**Created**: Based on failing `TestJavaScriptReferenceCompatibility` test results
**Priority**: High - Core functionality incorrectly implemented
**Estimated Effort**: 3-4 days of focused development

105
README.md Normal file
View File

@@ -0,0 +1,105 @@
# [Jdenticon-go](https://jdenticon.com)
Go library for generating highly recognizable identicons.
![Sample identicons](https://jdenticon.com/hosted/github-samples.png)
## Features
go-jdenticon is a Go port of the JavaScript library [Jdenticon](https://github.com/dmester/jdenticon).
* Renders identicons as PNG or SVG with no external dependencies
* Generates consistent, deterministic identicons from any input string
* Highly customizable color themes and styling options
* Simple, clean API for easy integration
* Command-line tool included for standalone usage
## Installation
```bash
go get github.com/kevin/go-jdenticon
```
## Usage
### Basic Usage
```go
package main
import (
"fmt"
"github.com/kevin/go-jdenticon/jdenticon"
)
func main() {
// Generate an identicon
icon := jdenticon.Generate("user@example.com", 200)
// Get SVG output
svg := icon.ToSVG()
fmt.Println(svg)
// Get PNG output
png := icon.ToPNG()
// Save or use PNG data...
}
```
### Custom Configuration
```go
config := &jdenticon.Config{
Hue: 0.3,
Saturation: 0.7,
Lightness: 0.5,
Padding: 0.1,
}
icon := jdenticon.GenerateWithConfig("user@example.com", 200, config)
```
### Command Line Tool
```bash
# Generate SVG
go run cmd/jdenticon/main.go -value "user@example.com" -size 200
# Generate PNG file
go run cmd/jdenticon/main.go -value "user@example.com" -format png -output icon.png
```
## API Reference
### Functions
- `Generate(value string, size int) *Icon` - Generate an identicon with default settings
- `GenerateWithConfig(value string, size int, config *Config) *Icon` - Generate with custom configuration
- `DefaultConfig() *Config` - Get default configuration settings
### Types
#### Icon
- `ToSVG() string` - Render as SVG string
- `ToPNG() []byte` - Render as PNG byte data
#### Config
- `Hue float64` - Color hue (0.0-1.0)
- `Saturation float64` - Color saturation (0.0-1.0)
- `Lightness float64` - Color lightness (0.0-1.0)
- `BackgroundColor string` - Background color (hex or empty for transparent)
- `Padding float64` - Padding as percentage of size (0.0-0.5)
## License
MIT License - see the original [Jdenticon](https://github.com/dmester/jdenticon) project for details.
## Contributing
Contributions are welcome! Please ensure all tests pass and follow Go conventions.
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request

1
avatar_custom.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><rect width="100%" height="100%" fill="#ffffff"/><path fill="#47ea47" d="M32.2 19.5a6.3,6.3 0 1,1 12.7,0a6.3,6.3 0 1,1 -12.7,0M51.2 19.5a6.3,6.3 0 1,1 12.7,0a6.3,6.3 0 1,1 -12.7,0M51.2 76.5a6.3,6.3 0 1,1 12.7,0a6.3,6.3 0 1,1 -12.7,0M32.2 76.5a6.3,6.3 0 1,1 12.7,0a6.3,6.3 0 1,1 -12.7,0M13.2 38.5a6.3,6.3 0 1,1 12.7,0a6.3,6.3 0 1,1 -12.7,0M70.2 38.5a6.3,6.3 0 1,1 12.7,0a6.3,6.3 0 1,1 -12.7,0M70.2 57.5a6.3,6.3 0 1,1 12.7,0a6.3,6.3 0 1,1 -12.7,0M13.2 57.5a6.3,6.3 0 1,1 12.7,0a6.3,6.3 0 1,1 -12.7,0M48 38.5L48 48L38.5 48ZM57.5 48L48 48L48 38.5ZM48 57.5L48 48L57.5 48ZM38.5 48L48 48L48 57.5Z"/><path fill="#4c4c4c" d="M29 19.5L19.5 29L10 19.5L19.5 10ZM76.5 29L67 19.5L76.5 10L86 19.5ZM67 76.5L76.5 67L86 76.5L76.5 86ZM19.5 67L29 76.5L19.5 86L10 76.5Z"/></svg>

After

Width:  |  Height:  |  Size: 840 B

1
avatar_email.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><path fill="#553db7" d="M37 23.5L50.5 10L64 23.5L50.5 37ZM77.5 10L91 23.5L77.5 37L64 23.5ZM91 104.5L77.5 118L64 104.5L77.5 91ZM50.5 118L37 104.5L50.5 91L64 104.5ZM10 50.5L23.5 37L37 50.5L23.5 64ZM104.5 37L118 50.5L104.5 64L91 50.5ZM118 77.5L104.5 91L91 77.5L104.5 64ZM23.5 91L10 77.5L23.5 64L37 77.5Z"/><path fill="#eaeaea" d="M37 37L10 37L10 23.5ZM91 37L91 10L104.5 10ZM91 91L118 91L118 104.5ZM37 91L37 118L23.5 118Z"/><path fill="#9484d6" d="M43 43L62 43L62 62L43 62ZM85 43L85 62L66 62L66 43ZM85 85L66 85L66 66L85 66ZM43 85L43 66L62 66L62 85Z"/></svg>

After

Width:  |  Height:  |  Size: 640 B

1
avatar_type_0.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path fill="#e8e8e8" d="M19 14L14 9L19 4L24 9ZM24 9L29 4L34 9L29 14ZM29 34L34 39L29 44L24 39ZM24 39L19 44L14 39L19 34ZM9 24L4 19L9 14L14 19ZM34 19L39 14L44 19L39 24ZM39 24L44 29L39 34L34 29ZM14 29L9 34L4 29L9 24Z"/><path fill="#a83866" d="M9 4L14 9L9 14L4 9ZM44 9L39 14L34 9L39 4ZM39 44L34 39L39 34L44 39ZM4 39L9 34L14 39L9 44Z"/><path fill="#d1759a" d="M14 14L24 14L24 24L14 24ZM17.6 20.2a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0M34 14L34 24L24 24L24 14ZM25.2 20.2a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0M34 34L24 34L24 24L34 24ZM25.2 27.8a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0M14 34L14 24L24 24L24 34ZM17.6 27.8a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0"/></svg>

After

Width:  |  Height:  |  Size: 750 B

1
avatar_type_1.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path fill="#2e8c7e" d="M24 4L24 14L14 14ZM34 14L24 14L24 4ZM24 44L24 34L34 34ZM14 34L24 34L24 44ZM14 14L14 24L4 24ZM44 24L34 24L34 14ZM34 34L34 24L44 24ZM4 24L14 24L14 34Z"/><path fill="#ace3db" d="M4 4L14 4L14 14ZM44 4L44 14L34 14ZM44 44L34 44L34 34ZM4 44L4 34L14 34Z"/><path fill="#59c7b7" d="M17 17L24 17L24 24L17 24ZM31 17L31 24L24 24L24 17ZM31 31L24 31L24 24L31 24ZM17 31L17 24L24 24L24 31Z"/></svg>

After

Width:  |  Height:  |  Size: 488 B

1
avatar_type_2.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path fill="#2e808c" d="M14 9L19 4L24 9L19 14ZM29 4L34 9L29 14L24 9ZM34 39L29 44L24 39L29 34ZM19 44L14 39L19 34L24 39ZM4 19L9 14L14 19L9 24ZM39 14L44 19L39 24L34 19ZM44 29L39 34L34 29L39 24ZM9 34L4 29L9 24L14 29Z"/><path fill="#59b9c7" d="M5.7 9a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M35.7 9a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M35.7 39a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M5.7 39a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0"/><path fill="#e3e3e3" d="M18 24a6,6 0 1,1 12,0a6,6 0 1,1 -12,0"/></svg>

After

Width:  |  Height:  |  Size: 587 B

1
avatar_type_3.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path fill="#c4c1ea" d="M15.7 9a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M25.7 9a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M25.7 39a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M15.7 39a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M5.7 19a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M35.7 19a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M35.7 29a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0M5.7 29a3.3,3.3 0 1,1 6.7,0a3.3,3.3 0 1,1 -6.7,0"/><path fill="#8a84d6" d="M14 4L14 14L9 14ZM44 14L34 14L34 9ZM34 44L34 34L39 34ZM4 34L14 34L14 39Z"/><path fill="#5b5b5b" d="M24 14L24 22L19 14ZM34 24L26 24L34 19ZM24 34L24 26L29 34ZM14 24L22 24L14 29Z"/></svg>

After

Width:  |  Height:  |  Size: 706 B

1
avatar_type_4.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path fill="#e3e3e3" d="M24 4L24 14L19 14ZM34 14L24 14L24 9ZM24 44L24 34L29 34ZM14 34L24 34L24 39ZM14 14L14 24L9 24ZM44 24L34 24L34 19ZM34 34L34 24L39 24ZM4 24L14 24L14 29Z"/><path fill="#464646" d="M4 4L14 4L14 14ZM44 4L44 14L34 14ZM44 44L34 44L34 34ZM4 44L4 34L14 34Z"/><path fill="#59acc7" d="M24 19L24 24L19 24ZM29 24L24 24L24 19ZM24 29L24 24L29 24ZM19 24L24 24L24 29Z"/></svg>

After

Width:  |  Height:  |  Size: 464 B

BIN
avatar_username.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 B

BIN
cmd/jdenticon/jdenticon Executable file

Binary file not shown.

62
cmd/jdenticon/main.go Normal file
View File

@@ -0,0 +1,62 @@
package main
import (
"flag"
"fmt"
"os"
"github.com/kevin/go-jdenticon/jdenticon"
)
func main() {
var (
value = flag.String("value", "", "Input value to generate identicon for (required)")
size = flag.Int("size", 200, "Size of the identicon in pixels")
format = flag.String("format", "svg", "Output format: svg or png")
output = flag.String("output", "", "Output file (if empty, prints to stdout)")
)
flag.Parse()
if *value == "" {
fmt.Fprintf(os.Stderr, "Error: -value is required\n")
flag.Usage()
os.Exit(1)
}
icon, err := jdenticon.Generate(*value, *size)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating identicon: %v\n", err)
os.Exit(1)
}
var result []byte
switch *format {
case "svg":
svgStr, err := icon.ToSVG()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating SVG: %v\n", err)
os.Exit(1)
}
result = []byte(svgStr)
case "png":
pngBytes, err := icon.ToPNG()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating PNG: %v\n", err)
os.Exit(1)
}
result = pngBytes
default:
fmt.Fprintf(os.Stderr, "Error: invalid format %s (use svg or png)\n", *format)
os.Exit(1)
}
if *output != "" {
if err := os.WriteFile(*output, result, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
os.Exit(1)
}
fmt.Printf("Identicon saved to %s\n", *output)
} else {
fmt.Print(string(result))
}
}

25
debug_hash.go Normal file
View File

@@ -0,0 +1,25 @@
package main
import (
"fmt"
"github.com/kevin/go-jdenticon/jdenticon"
)
func main() {
testInputs := []string{"test-hash", "example1@gmail.com"}
for _, input := range testInputs {
hash := jdenticon.ToHash(input)
fmt.Printf("Input: \"%s\"\n", input)
fmt.Printf("Go SHA1: %s\n", hash)
svg, err := jdenticon.ToSVG(input, 64)
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("SVG length: %d\n", len(svg))
}
fmt.Println("---")
}
}

16
debug_hash.js Normal file
View File

@@ -0,0 +1,16 @@
const jdenticon = require('./jdenticon-js/dist/jdenticon-node.js');
const crypto = require('crypto');
const testInputs = ['test-hash', 'example1@gmail.com'];
testInputs.forEach(input => {
// Generate hash using Node.js crypto (similar to what our Go code should do)
const nodeHash = crypto.createHash('sha1').update(input).digest('hex');
console.log(`Input: "${input}"`);
console.log(`Node.js SHA1: ${nodeHash}`);
// See what Jdenticon generates
const svg = jdenticon.toSvg(input, 64);
console.log(`SVG length: ${svg.length}`);
console.log('---');
});

123
example_usage.go Normal file
View File

@@ -0,0 +1,123 @@
package main
import (
"fmt"
"os"
"github.com/kevin/go-jdenticon/jdenticon"
)
func main() {
// Test the new public API with different input types
// 1. Generate SVG from email address
fmt.Println("=== Generating SVG avatar for email ===")
svg, err := jdenticon.ToSVG("user@example.com", 128)
if err != nil {
panic(err)
}
fmt.Printf("SVG length: %d characters\n", len(svg))
fmt.Printf("SVG preview: %s...\n", svg[:100])
// Save SVG to file
err = os.WriteFile("avatar_email.svg", []byte(svg), 0644)
if err != nil {
panic(err)
}
fmt.Println("✅ Saved to avatar_email.svg")
// 2. Generate PNG from username
fmt.Println("\n=== Generating PNG avatar for username ===")
png, err := jdenticon.ToPNG("johndoe", 64)
if err != nil {
panic(err)
}
fmt.Printf("PNG size: %d bytes\n", len(png))
// Save PNG to file
err = os.WriteFile("avatar_username.png", png, 0644)
if err != nil {
panic(err)
}
fmt.Println("✅ Saved to avatar_username.png")
// 3. Generate with custom configuration
fmt.Println("\n=== Generating with custom config ===")
config, err := jdenticon.Configure(
jdenticon.WithHueRestrictions([]float64{120, 240}), // Blue/green hues only
jdenticon.WithColorSaturation(0.8),
jdenticon.WithBackgroundColor("#ffffff"), // White background
jdenticon.WithPadding(0.1),
)
if err != nil {
panic(err)
}
customSvg, err := jdenticon.ToSVG("custom-avatar", 96, config)
if err != nil {
panic(err)
}
err = os.WriteFile("avatar_custom.svg", []byte(customSvg), 0644)
if err != nil {
panic(err)
}
fmt.Println("✅ Saved custom styled avatar to avatar_custom.svg")
// 4. Test different input types
fmt.Println("\n=== Testing different input types ===")
inputs := []interface{}{
"hello world",
42,
3.14159,
true,
[]byte("binary data"),
}
for i, input := range inputs {
svg, err := jdenticon.ToSVG(input, 48)
if err != nil {
panic(err)
}
filename := fmt.Sprintf("avatar_type_%d.svg", i)
err = os.WriteFile(filename, []byte(svg), 0644)
if err != nil {
panic(err)
}
fmt.Printf("✅ Generated avatar for %T: %v -> %s\n", input, input, filename)
}
// 5. Show hash generation
fmt.Println("\n=== Hash generation ===")
testValues := []interface{}{"test", 123, []byte("data")}
for _, val := range testValues {
hash := jdenticon.ToHash(val)
fmt.Printf("Hash of %v (%T): %s\n", val, val, hash)
}
// 6. Generate avatars for a group of users
fmt.Println("\n=== Group avatars ===")
users := []string{
"alice@company.com",
"bob@company.com",
"charlie@company.com",
"diana@company.com",
}
for _, user := range users {
png, err := jdenticon.ToPNG(user, 80)
if err != nil {
panic(err)
}
filename := fmt.Sprintf("user_%s.png", user[:5]) // Use first 5 chars as filename
err = os.WriteFile(filename, png, 0644)
if err != nil {
panic(err)
}
fmt.Printf("✅ Generated avatar for %s -> %s\n", user, filename)
}
fmt.Println("\n🎉 All avatars generated successfully!")
fmt.Println("Check the generated SVG and PNG files in the current directory.")
}

77
generate_go_compare.go Normal file
View File

@@ -0,0 +1,77 @@
package main
import (
"fmt"
"os"
"github.com/kevin/go-jdenticon/jdenticon"
)
func main() {
// Test emails
testEmails := []string{
"example1@gmail.com",
"example2@yahoo.com",
}
// Test sizes
sizes := []int{64, 128}
// Create go-output directory
outDir := "./go-output"
if _, err := os.Stat(outDir); os.IsNotExist(err) {
os.Mkdir(outDir, 0755)
}
// Generate Go versions
for _, email := range testEmails {
for _, size := range sizes {
// Generate SVG
svg, err := jdenticon.ToSVG(email, size)
if err != nil {
fmt.Printf("Error generating SVG for %s@%d: %v\n", email, size, err)
continue
}
svgFilename := fmt.Sprintf("%s/%s_%d.svg", outDir,
email[0:8]+"_at_"+email[9:13]+"_com", size)
err = os.WriteFile(svgFilename, []byte(svg), 0644)
if err != nil {
fmt.Printf("Error writing SVG file: %v\n", err)
continue
}
fmt.Printf("Generated Go SVG: %s\n", svgFilename)
// Generate PNG
pngData, err := jdenticon.ToPNG(email, size)
if err != nil {
fmt.Printf("Error generating PNG for %s@%d: %v\n", email, size, err)
continue
}
pngFilename := fmt.Sprintf("%s/%s_%d.png", outDir,
email[0:8]+"_at_"+email[9:13]+"_com", size)
err = os.WriteFile(pngFilename, pngData, 0644)
if err != nil {
fmt.Printf("Error writing PNG file: %v\n", err)
continue
}
fmt.Printf("Generated Go PNG: %s\n", pngFilename)
}
}
// Also generate test-hash for comparison
testSvg, err := jdenticon.ToSVG("test-hash", 64)
if err != nil {
fmt.Printf("Error generating test-hash SVG: %v\n", err)
} else {
err = os.WriteFile(outDir+"/test-hash_64.svg", []byte(testSvg), 0644)
if err != nil {
fmt.Printf("Error writing test-hash SVG: %v\n", err)
} else {
fmt.Println("Generated test-hash Go SVG")
}
}
fmt.Println("\nGo files generated in ./go-output/ directory")
}

48
generate_reference.js Normal file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const jdenticon = require('./jdenticon-js/dist/jdenticon-node.js');
// Test emails
const testEmails = [
'example1@gmail.com',
'example2@yahoo.com'
];
// Test sizes
const sizes = [64, 128];
// Create reference directory
const refDir = './reference';
if (!fs.existsSync(refDir)) {
fs.mkdirSync(refDir);
}
// Generate reference SVGs and PNGs
testEmails.forEach(email => {
sizes.forEach(size => {
// Generate SVG
const svg = jdenticon.toSvg(email, size);
const svgFilename = `${email.replace('@', '_at_').replace('.', '_')}_${size}.svg`;
fs.writeFileSync(path.join(refDir, svgFilename), svg);
console.log(`Generated reference SVG: ${svgFilename}`);
// Generate PNG (if supported)
try {
const pngBuffer = jdenticon.toPng(email, size);
const pngFilename = `${email.replace('@', '_at_').replace('.', '_')}_${size}.png`;
fs.writeFileSync(path.join(refDir, pngFilename), pngBuffer);
console.log(`Generated reference PNG: ${pngFilename}`);
} catch (err) {
console.log(`PNG generation failed for ${email}@${size}: ${err.message}`);
}
});
});
// Also generate a test with fixed coordinates we can examine
const testSvg = jdenticon.toSvg('test-hash', 64);
fs.writeFileSync(path.join(refDir, 'test-hash_64.svg'), testSvg);
console.log('Generated test-hash reference SVG');
console.log('\nReference files generated in ./reference/ directory');

View File

@@ -0,0 +1,17 @@
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
Get task #1 from taskmaster and implement the solution:
```
tm get-task 1
```
CRITICAL CONSTRAINTS:
⚠️ MUST run reference compatibility tests after any changes:
```bash
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
```
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.

View File

@@ -0,0 +1,19 @@
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
Get task #2 from taskmaster and implement the solution:
```
tm get-task 2
```
CRITICAL CONSTRAINTS:
⚠️ MUST run reference compatibility tests after any changes:
```bash
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
```
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
⚠️ This task depends on Task 1 being completed first - ensure error handling patterns are consistent.
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.

View File

@@ -0,0 +1,19 @@
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
Get task #3 from taskmaster and implement the solution:
```
tm get-task 3
```
CRITICAL CONSTRAINTS:
⚠️ MUST run reference compatibility tests after any changes:
```bash
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
```
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
⚠️ This is a performance optimization task - measure before/after to ensure improvements.
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.

View File

@@ -0,0 +1,19 @@
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
Get task #4 from taskmaster and implement the solution:
```
tm get-task 4
```
CRITICAL CONSTRAINTS:
⚠️ MUST run reference compatibility tests after any changes:
```bash
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
```
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
⚠️ This is a readability improvement task - replace magic numbers with named constants while keeping exact same values.
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.

View File

@@ -0,0 +1,19 @@
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
Get task #5 from taskmaster and implement the solution:
```
tm get-task 5
```
CRITICAL CONSTRAINTS:
⚠️ MUST run reference compatibility tests after any changes:
```bash
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
```
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
⚠️ This is a data structure improvement task - clean up the Shape struct while maintaining all existing functionality.
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.

View File

@@ -0,0 +1,19 @@
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
Get task #6 from taskmaster and implement the solution:
```
tm get-task 6
```
CRITICAL CONSTRAINTS:
⚠️ MUST run reference compatibility tests after any changes:
```bash
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
```
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
⚠️ This is a performance optimization task - replace fmt.Sprintf with more efficient string building.
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.

View File

@@ -0,0 +1,19 @@
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
Get task #7 from taskmaster and implement the solution:
```
tm get-task 7
```
CRITICAL CONSTRAINTS:
⚠️ MUST run reference compatibility tests after any changes:
```bash
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
```
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
⚠️ This is a readability improvement task - simplify complex logic while maintaining exact same behavior.
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.

View File

@@ -0,0 +1,19 @@
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
Get task #8 from taskmaster and implement the solution:
```
tm get-task 8
```
CRITICAL CONSTRAINTS:
⚠️ MUST run reference compatibility tests after any changes:
```bash
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
```
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
⚠️ This is a Go idioms improvement task - replace JavaScript-style patterns with idiomatic Go.
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.

View File

@@ -0,0 +1,19 @@
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
Get task #9 from taskmaster and implement the solution:
```
tm get-task 9
```
CRITICAL CONSTRAINTS:
⚠️ MUST run reference compatibility tests after any changes:
```bash
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
```
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
⚠️ This task depends on Tasks 1 and 2 being completed first - build on their error handling foundations.
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.

View File

@@ -0,0 +1,19 @@
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
Get task #10 from taskmaster and implement the solution:
```
tm get-task 10
```
CRITICAL CONSTRAINTS:
⚠️ MUST run reference compatibility tests after any changes:
```bash
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
```
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
⚠️ This is a performance measurement task - create benchmarks to measure icon generation speed and memory usage.
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.

View File

@@ -0,0 +1,19 @@
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
Get task #11 from taskmaster and implement the solution:
```
tm get-task 11
```
CRITICAL CONSTRAINTS:
⚠️ MUST run reference compatibility tests after any changes:
```bash
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
```
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
⚠️ This is a rendering optimization task - improve polygon rendering efficiency while maintaining exact same output.
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.

View File

@@ -0,0 +1,21 @@
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
Get task #12 from taskmaster and implement the solution:
```
tm get-task 12
```
CRITICAL CONSTRAINTS:
⚠️ MUST run reference compatibility tests after any changes:
```bash
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
```
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
⚠️ This task depends on Tasks 3 and 10 being completed first - ensure optimizations and benchmarks are in place.
⚠️ This is a concurrency feature task - add support for concurrent icon generation while maintaining thread safety.
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.

View File

@@ -0,0 +1,19 @@
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
Get task #13 from taskmaster and implement the solution:
```
tm get-task 13
```
CRITICAL CONSTRAINTS:
⚠️ MUST run reference compatibility tests after any changes:
```bash
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
```
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
⚠️ This is a documentation task - create detailed documentation for public APIs and important internal functions.
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.

View File

@@ -0,0 +1,21 @@
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
Get task #14 from taskmaster and implement the solution:
```
tm get-task 14
```
CRITICAL CONSTRAINTS:
⚠️ MUST run reference compatibility tests after any changes:
```bash
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
```
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
⚠️ This task depends on Task 10 being completed first - ensure benchmarks are available for CI pipeline.
⚠️ This is a CI/CD setup task - create automated testing pipeline for continuous quality assurance.
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.

View File

@@ -0,0 +1,21 @@
You are working on the Go Jdenticon library, a Go port of the JavaScript Jdenticon library that generates deterministic identicons. This library has achieved byte-for-byte identical SVG output with the JavaScript reference implementation, which is CRITICAL to maintain.
Get task #15 from taskmaster and implement the solution:
```
tm get-task 15
```
CRITICAL CONSTRAINTS:
⚠️ MUST run reference compatibility tests after any changes:
```bash
go test ./jdenticon -run TestJavaScriptReferenceCompatibility -v
```
These tests MUST pass - they verify byte-for-byte identical SVG output with the JavaScript implementation.
⚠️ This task depends on Tasks 1-13 being completed first - this is the final review and cleanup task.
⚠️ This is a comprehensive review task - perform final code review and ensure consistent style across all changes.
⚠️ This is a code quality improvement project - maintain all existing functionality while improving error handling, performance, and maintainability.
Focus on the specific requirements in the task and ensure your implementation follows Go best practices while preserving JavaScript compatibility.

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><path fill="#545454" d="M64 37L37 37L37 23.5ZM10 64L10 37L23.5 37ZM37 10L64 10L64 23.5ZM37 37L37 64L23.5 64ZM37 64L10 64L10 50.5ZM37 37L37 10L50.5 10ZM10 37L37 37L37 50.5ZM64 10L64 37L50.5 37Z"/><path fill="#d19575" d="M64 50.5L64 64L50.5 64ZM91 50.5L91 64L77.5 64ZM91 77.5L91 91L77.5 91ZM64 77.5L64 91L50.5 91Z"/><path fill="#e8caba" d="M14.5 23.5a9,9 0 1,1 18,0a9,9 0 1,1 -18,0M14.5 23.5a9,9 0 1,1 18,0a9,9 0 1,1 -18,0M14.5 23.5a9,9 0 1,1 18,0a9,9 0 1,1 -18,0M14.5 23.5a9,9 0 1,1 18,0a9,9 0 1,1 -18,0"/></svg>

After

Width:  |  Height:  |  Size: 598 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><path fill="#545454" d="M32 19L19 19L19 12.5ZM6 32L6 19L12.5 19ZM19 6L32 6L32 12.5ZM19 19L19 32L12.5 32ZM19 32L6 32L6 25.5ZM19 19L19 6L25.5 6ZM6 19L19 19L19 25.5ZM32 6L32 19L25.5 19Z"/><path fill="#d19575" d="M32 25.5L32 32L25.5 32ZM45 25.5L45 32L38.5 32ZM45 38.5L45 45L38.5 45ZM32 38.5L32 45L25.5 45Z"/><path fill="#e8caba" d="M8.2 12.5a4.3,4.3 0 1,1 8.7,0a4.3,4.3 0 1,1 -8.7,0M8.2 12.5a4.3,4.3 0 1,1 8.7,0a4.3,4.3 0 1,1 -8.7,0M8.2 12.5a4.3,4.3 0 1,1 8.7,0a4.3,4.3 0 1,1 -8.7,0M8.2 12.5a4.3,4.3 0 1,1 8.7,0a4.3,4.3 0 1,1 -8.7,0"/></svg>

After

Width:  |  Height:  |  Size: 620 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><path fill="#545454" d="M32 19L19 19L19 12.5ZM32 19L32 6L38.5 6ZM32 45L45 45L45 51.5ZM32 45L32 58L25.5 58ZM19 32L6 32L6 25.5ZM45 32L45 19L51.5 19ZM45 32L58 32L58 38.5ZM19 32L19 45L12.5 45Z"/><path fill="#d19575" d="M32 25.5L32 32L25.5 32ZM45 25.5L45 32L38.5 32ZM45 38.5L45 45L38.5 45ZM32 38.5L32 45L25.5 45Z"/><path fill="#e8caba" d="M8.2 12.5a4.3,4.3 0 1,1 8.7,0a4.3,4.3 0 1,1 -8.7,0M47.2 12.5a4.3,4.3 0 1,1 8.7,0a4.3,4.3 0 1,1 -8.7,0M47.2 51.5a4.3,4.3 0 1,1 8.7,0a4.3,4.3 0 1,1 -8.7,0M8.2 51.5a4.3,4.3 0 1,1 8.7,0a4.3,4.3 0 1,1 -8.7,0"/></svg>

After

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><path fill="#a83892" d="M118 37L118 64L104.5 64ZM91 37L64 37L64 23.5ZM91 64L91 37L104.5 37ZM64 10L91 10L91 23.5ZM91 10L91 37L77.5 37ZM118 64L91 64L91 50.5ZM64 37L64 10L77.5 10ZM91 37L118 37L118 50.5Z"/><path fill="#d175bf" d="M118 10L118 37L104.5 37ZM118 37L91 37L91 23.5ZM91 37L91 10L104.5 10ZM91 10L118 10L118 23.5ZM37 37L64 37L64 64L37 64ZM64 37L91 37L91 64L64 64ZM64 64L91 64L91 91L64 91ZM37 64L64 64L64 91L37 91Z"/></svg>

After

Width:  |  Height:  |  Size: 513 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><path fill="#a83892" d="M58 19L58 32L51.5 32ZM45 19L32 19L32 12.5ZM45 32L45 19L51.5 19ZM32 6L45 6L45 12.5ZM45 6L45 19L38.5 19ZM58 32L45 32L45 25.5ZM32 19L32 6L38.5 6ZM45 19L58 19L58 25.5Z"/><path fill="#d175bf" d="M58 6L58 19L51.5 19ZM58 19L45 19L45 12.5ZM45 19L45 6L51.5 6ZM45 6L58 6L58 12.5ZM19 19L32 19L32 32L19 32ZM32 19L45 19L45 32L32 32ZM32 32L45 32L45 45L32 45ZM19 32L32 32L32 45L19 45Z"/></svg>

After

Width:  |  Height:  |  Size: 485 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><path fill="#a83892" d="M32 6L32 19L25.5 19ZM45 19L32 19L32 12.5ZM32 58L32 45L38.5 45ZM19 45L32 45L32 51.5ZM19 19L19 32L12.5 32ZM58 32L45 32L45 25.5ZM45 45L45 32L51.5 32ZM6 32L19 32L19 38.5Z"/><path fill="#d175bf" d="M19 6L19 19L12.5 19ZM58 19L45 19L45 12.5ZM45 58L45 45L51.5 45ZM6 45L19 45L19 51.5ZM19 19L32 19L32 32L19 32ZM23 31L31 31L31 23L23 23ZM32 19L45 19L45 32L32 32ZM36 31L44 31L44 23L36 23ZM32 32L45 32L45 45L32 45ZM36 44L44 44L44 36L36 36ZM19 32L32 32L32 45L19 45ZM23 44L31 44L31 36L23 36Z"/></svg>

After

Width:  |  Height:  |  Size: 591 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><path fill="#e8e8e8" d="M19 6L32 6L32 19ZM45 6L45 19L32 19ZM45 58L32 58L32 45ZM19 58L19 45L32 45ZM6 19L19 19L19 32ZM58 19L58 32L45 32ZM58 45L45 45L45 32ZM6 45L6 32L19 32Z"/><path fill="#d175c5" d="M19 19L6 19L6 12.5ZM45 19L45 6L51.5 6ZM45 45L58 45L58 51.5ZM19 45L19 58L12.5 58ZM19 19L32 19L32 28.1L24.2 24.2L28.1 32L19 32ZM32 19L45 19L45 28.1L37.2 24.2L41.1 32L32 32ZM32 32L45 32L45 41.1L37.2 37.2L41.1 45L32 45ZM19 32L32 32L32 41.1L24.2 37.2L28.1 45L19 45Z"/></svg>

After

Width:  |  Height:  |  Size: 549 B

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/kevin/go-jdenticon
go 1.22.5

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
}

14
jdenticon-js/.eslintrc.js Normal file
View File

@@ -0,0 +1,14 @@
module.exports = {
"env": {
"browser": true,
"es2020": true,
"node": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 11,
"sourceType": "module"
},
"rules": {
}
};

3
jdenticon-js/.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
# Treat minifyed files as binary to ensure the integrity does not change
dist/*.min.js binary

View File

@@ -0,0 +1,143 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Tests
on: [push]
env:
TAP_COLORS: 1
jobs:
build:
name: Build and run unit tests
runs-on: ubuntu-latest
env:
TAP_NO_ESM: 1
steps:
- uses: actions/checkout@v4.1.5
- name: Use Node.js v14
uses: actions/setup-node@v4.0.2
with:
node-version: 14.x
- run: npm install
- name: Build Jdenticon
run: npm run build
- name: TypeScript typings tests
run: npm run test:types
- name: Unit tests
run: npm run test:unit
- name: Webpack 4 bundle test
run: npm run test:webpack4
- name: Webpack 5 bundle test
run: npm run test:webpack5
- name: Rollup bundle test
run: npm run test:rollup
- name: Node test (CommonJS)
run: npm run test:node-cjs
- name: Node test (ESM)
run: npm run test:node-esm
- name: Publish artifacts
uses: actions/upload-artifact@v4.3.3
if: ${{ always() }}
with:
name: package
path: ./test/node_modules/jdenticon
e2e:
name: E2E tests (Node ${{ matrix.node }})
runs-on: ubuntu-latest
needs: build
strategy:
fail-fast: false
matrix:
node: [ '6.4', '8.x', '10.x', '12.x', '18.x', '20.x' ]
steps:
- uses: actions/checkout@v4.1.5
- uses: actions/download-artifact@v4.1.7
with:
name: package
path: test/node_modules/jdenticon
- name: Use Node.js ${{ matrix.node }}
uses: actions/setup-node@v4.0.2
with:
node-version: ${{ matrix.node }}
# Use an older tap version to ensure compatibility with the old Node version
# bind-obj-methods broke old Node 6 support in 2.0.1
- name: npm install (Node 6.4)
if: ${{ matrix.node == '6.4' }}
run: |
npm install -g npm@6.14.17
npm install tap@12.7.0 bind-obj-methods@2.0.0
- name: npm install (Node 8.x)
if: ${{ matrix.node == '8.x' }}
run: npm install tap@14.11.0
- name: npm install (Node 10+)
if: ${{ matrix.node != '6.4' && matrix.node != '8.x' }}
run: npm install
- name: Node test (CommonJS)
run: npm run test:node-cjs
- name: Node test (ESM, Node 12+)
if: ${{ matrix.node != '6.4' && matrix.node != '8.x' && matrix.node != '10.x' }}
run: npm run test:node-esm
- name: Publish artifacts
uses: actions/upload-artifact@v4.3.3
if: ${{ failure() }}
with:
name: e2e-${{ matrix.node }}
path: ./test/e2e/node/expected
visual:
name: Visual tests
needs: build
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ 'macos-latest', 'windows-latest' ]
env:
ARTIFACTS_DIR: ./artifacts
BROWSER_SCREENSHOT_DIR: ./artifacts/screenshots
BROWSER_DIFF_DIR: ./artifacts/diffs
steps:
- uses: actions/checkout@v4.1.5
- name: Use Node.js
uses: actions/setup-node@v4.0.2
with:
node-version: 16.x
- run: npm install
- uses: actions/download-artifact@v4.1.7
with:
name: package
path: test/node_modules/jdenticon
- name: Run visual tests (Windows)
if: ${{ startsWith(matrix.os, 'windows') }}
run: |
$env:PATH = "C:\SeleniumWebDrivers\IEDriver;$env:PATH"
npm run test:browser-win
- name: Run visual tests (macOS)
if: ${{ startsWith(matrix.os, 'macos') }}
run: npm run test:browser-macos
- name: Publish artifacts
uses: actions/upload-artifact@v4.3.3
if: ${{ always() }}
with:
name: visual-${{ matrix.os }}
path: ${{ env.ARTIFACTS_DIR }}

7
jdenticon-js/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
artifacts
obj
releases
bower_components
node_modules
.vs
.nyc_output

21
jdenticon-js/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2014-2024 Daniel Mester Pirttijärvi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

72
jdenticon-js/README.md Normal file
View File

@@ -0,0 +1,72 @@
# [Jdenticon](https://jdenticon.com)
JavaScript library for generating highly recognizable identicons using HTML5 canvas or SVG.
![Sample identicons](https://jdenticon.com/hosted/github-samples.png)
[![Tests](https://img.shields.io/github/actions/workflow/status/dmester/jdenticon/tests.js.yml?branch=master&style=flat-square)](https://github.com/dmester/jdenticon/actions)
[![Downloads](https://img.shields.io/npm/dt/jdenticon.svg?style=flat-square)](https://www.npmjs.com/package/jdenticon)
[![jsDelivr](https://data.jsdelivr.com/v1/package/npm/jdenticon/badge?style=square)](https://www.jsdelivr.com/package/npm/jdenticon)
[![npm bundle size](https://img.shields.io/bundlephobia/min/jdenticon.svg?style=flat-square)](https://bundlephobia.com/result?p=jdenticon)
[![License MIT](https://img.shields.io/badge/license-MIT-green.svg?style=flat-square)](https://github.com/dmester/jdenticon/blob/master/LICENSE)
## Live demo
https://jdenticon.com
## Getting started
Using Jdenticon is simple. Follow the steps below to integrate Jdenticon into your website.
### 1. Add identicon placeholders
Jdenticon is able to render both raster and vector identicons. Raster icons are rendered
slightly faster than vector icons, but vector icons scale better on high resolution screens.
Add a canvas to render a raster icon, or an inline svg element to render a vector icon.
```HTML
<!-- Vector icon -->
<svg width="80" height="80" data-jdenticon-value="icon value"></svg>
<!-- OR -->
<!-- Raster icon -->
<canvas width="80" height="80" data-jdenticon-value="icon value"></canvas>
```
### 2. Add reference to Jdenticon
Include the Jdenticon library somewhere on your page. You can either host it yourself or
use it right off [jsDelivr](https://www.jsdelivr.com).
```HTML
<!-- Using jsDelivr -->
<script src="https://cdn.jsdelivr.net/npm/jdenticon@3.3.0/dist/jdenticon.min.js"
integrity="sha384-LfouGM03m83ArVtne1JPk926e3SGD0Tz8XHtW2OKGsgeBU/UfR0Fa8eX+UlwSSAZ"
crossorigin="anonymous">
</script>
<!-- OR -->
<!-- Hosting it yourself -->
<script src="-path-to-/jdenticon.min.js"></script>
```
That's it!
## Other resources
### API documentation
For more usage examples and API documentation, please see:
https://jdenticon.com
### Other platforms
There are ports or bindings for Jdenticon available for the following platforms:
* [PHP](https://github.com/dmester/jdenticon-php/)
* [React](https://www.npmjs.com/package/react-jdenticon)
* [Angular](https://www.npmjs.com/package/ngx-jdenticon)
* [.NET](https://github.com/dmester/jdenticon-net/)
* [Rust](https://github.com/jay3332/rdenticon)
* [Polymer](https://github.com/GeoloeG/identicon-element)
* [Swift](https://github.com/aleph7/jdenticon-swift)
* [Java](https://github.com/sunshower-io/sunshower-arcus/tree/master/arcus-identicon)
* [Dart/Flutter](https://pub.dartlang.org/packages/jdenticon_dart)
* [Kotlin](https://github.com/WycliffeAssociates/jdenticon-kotlin)
## License
Jdenticon is available under the [MIT license](https://github.com/dmester/jdenticon/blob/master/LICENSE).

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env node
const fs = require("fs");
const jdenticon = require("../dist/jdenticon-node");
// Handle command
const parsedArgs = parseArgs(process.argv);
if (parsedArgs.help) {
writeHelp();
process.exit(0);
} else if (parsedArgs.version) {
console.log(jdenticon.version);
process.exit(0);
} else {
const validatedArgs = validateArgs(parsedArgs);
if (validatedArgs) {
var output = validatedArgs.svg ?
jdenticon.toSvg(validatedArgs.value, validatedArgs.size, validatedArgs.config) :
jdenticon.toPng(validatedArgs.value, validatedArgs.size, validatedArgs.config);
if (validatedArgs.output) {
fs.writeFileSync(validatedArgs.output, output);
} else {
process.stdout.write(output);
}
process.exit(0);
} else {
writeHelp();
process.exit(1);
}
}
// Functions
function writeHelp() {
console.log("Generates an identicon as a PNG or SVG file for a specified value.");
console.log("");
console.log("Usage: jdenticon <value> [-s <size>] [-o <filename>]");
console.log("");
console.log("Options:");
console.log(" -s, --size <value> Icon size in pixels. (default: 100)");
console.log(" -o, --output <path> Output file. (default: <stdout>)");
console.log(" -f, --format <svg|png> Format of generated icon. Otherwise detected from output path. (default: png)");
console.log(" -b, --back-color <value> Background color on format #rgb, #rgba, #rrggbb or #rrggbbaa. (default: transparent)");
console.log(" -p, --padding <value> Padding in percent in range 0 to 0.5. (default: 0.08)");
console.log(" -v, --version Gets the version of Jdenticon.");
console.log(" -h, --help Show this help information.");
console.log("");
console.log("Examples:");
console.log(" jdenticon user127 -s 100 -o icon.png");
}
function parseArgs(args) {
// Argument 1 is always node
// Argument 2 is always jdenticon
// Argument 3 and forward are actual arguments
args = args.slice(2);
function consume(aliases, hasValue) {
for (var argIndex = 0; argIndex < args.length; argIndex++) {
var arg = args[argIndex];
for (var aliasIndex = 0; aliasIndex < aliases.length; aliasIndex++) {
var alias = aliases[aliasIndex];
if (arg === alias) {
var value;
if (hasValue) {
if (argIndex + 1 < args.length) {
value = args[argIndex + 1];
} else {
console.warn("WARN Missing value of argument " + alias);
}
} else {
value = true;
}
args.splice(argIndex, hasValue ? 2 : 1);
return value;
}
if (arg.startsWith(alias) && arg[alias.length] === "=") {
var value = arg.substr(alias.length + 1);
if (!hasValue) {
value = value !== "false";
}
args.splice(argIndex, 1);
return value;
}
}
}
}
if (consume(["-h", "--help", "-?", "/?", "/h"], false)) {
return {
help: true
};
}
if (consume(["-v", "--version"], false)) {
return {
version: true
};
}
return {
size: consume(["-s", "--size"], true),
output: consume(["-o", "--output"], true),
format: consume(["-f", "--format"], true),
padding: consume(["-p", "--padding"], true),
backColor: consume(["-b", "--back-color"], true),
value: args
};
}
function validateArgs(args) {
if (args.value.length) {
// Size
var size = 100;
if (args.size) {
size = Number(args.size);
if (!size || size < 1) {
size = 100;
console.warn("WARN Invalid size specified. Defaults to 100.");
}
}
// Padding
var padding;
if (args.padding != null) {
padding = Number(args.padding);
if (isNaN(padding) || padding < 0 || padding >= 0.5) {
padding = 0.08;
console.warn("WARN Invalid padding specified. Defaults to 0.08.");
}
}
// Background color
var backColor;
if (args.backColor != null) {
backColor = args.backColor;
if (!/^(#[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(backColor)) {
backColor = undefined;
console.warn("WARN Invalid background color specified. Defaults to transparent.");
}
}
// Format
var generateSvg =
args.format ? /^svg$/i.test(args.format) :
args.output ? /\.svg$/i.test(args.output) :
false;
if (args.format != null && !/^(svg|png)$/i.test(args.format)) {
console.warn("WARN Invalid format specified. Defaults to " + (generateSvg ? "svg" : "png") + ".");
}
return {
config: {
padding: padding,
backColor: backColor
},
output: args.output,
size: size,
svg: generateSvg,
value: args.value.join("")
};
}
}

32
jdenticon-js/bower.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "Jdenticon",
"authors": [
"Daniel Mester Pirttijärvi"
],
"description": "Javascript identicon generator",
"main": "dist/jdenticon.js",
"keywords": [
"javascript",
"identicon",
"avatar",
"library"
],
"license": "MIT",
"homepage": "https://jdenticon.com/",
"ignore": [
".npmignore",
".gitignore",
".vs",
"*.bat",
"*.nuspec",
"build",
"gulpfile.js",
"node_modules",
"obj",
"releases",
"src",
"template.*",
"test",
"utils"
]
}

View File

@@ -0,0 +1,4 @@
{
"main": "../dist/jdenticon-module",
"types": "../types/module.d.ts"
}

View File

@@ -0,0 +1,34 @@
const { Transform } = require("stream");
const { parse } = require("acorn");
const { Replacement } = require("./replacement");
function astTransformStream(transformer) {
return new Transform({
objectMode: true,
transform(inputFile, _, fileDone) {
const input = inputFile.contents.toString();
const comments = [];
const ast = parse(input, {
ecmaVersion: 10,
sourceType: "module",
onComment: comments,
});
const replacement = new Replacement();
transformer(replacement, ast, comments, input);
const output = replacement.replace(input, inputFile.sourceMap);
inputFile.contents = Buffer.from(output.output);
if (inputFile.sourceMap) {
inputFile.sourceMap = output.sourceMap;
}
fileDone(null, inputFile);
}
});
}
module.exports = transformer => () => astTransformStream(transformer);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,186 @@
const { Node } = require("acorn");
const astTransformStream = require("./ast-transform-stream");
const DOMPROPS = require("./domprops");
const RESERVED_NAMES = require("./reserved-keywords");
const CHARACTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_";
function visit(node, visitor) {
if (node instanceof Node) {
visitor(node);
}
for (const key in node) {
const value = node[key];
if (Array.isArray(value)) {
value.forEach(subNode => visit(subNode, visitor));
} else if (value instanceof Node) {
visit(value, visitor);
}
}
}
function generateIdentifier(seed) {
let identifier = "";
seed = Math.abs(Math.floor(seed));
do {
const mod = seed % CHARACTERS.length;
identifier += CHARACTERS[mod];
seed = (seed / CHARACTERS.length) | 0;
} while (seed);
return identifier;
}
function mangleProps(input, ast, replacement) {
const identifierNodes = [];
const longToShortName = new Map();
// Find identifiers
visit(ast, node => {
let identifier;
if (node.type === "MemberExpression" && !node.computed) {
// Matches x.y
// Not x["y"] (computed: true)
identifier = node.property;
} else if (node.type === "MethodDefinition") {
// Matches x() { }
identifier = node.key;
} else if (node.type === "Property") {
// Matches { x: y }
// Not { "x": y }
identifier = node.key;
}
if (identifier && identifier.type === "Identifier") {
identifierNodes.push(identifier);
}
});
// Collect usage statistics per name
const usageMap = new Map();
identifierNodes.forEach(node => {
if (node.name && !RESERVED_NAMES.has(node.name) && !DOMPROPS.has(node.name)) {
usageMap.set(node.name, (usageMap.get(node.name) || 0) + 1);
}
});
// Sort by usage in descending order
const usageStats = Array.from(usageMap).sort((a, b) => b[1] - a[1]);
// Allocate identifiers in order of usage statistics to ensure
// frequently used symbols get as short identifiers as possible.
let runningCounter = 0;
usageStats.forEach(identifier => {
const longName = identifier[0];
if (!longToShortName.has(longName)) {
let shortName;
do {
shortName = generateIdentifier(runningCounter++);
} while (RESERVED_NAMES.has(shortName) || DOMPROPS.has(shortName));
longToShortName.set(longName, shortName);
}
});
// Populate replacements
identifierNodes.forEach(node => {
const minifiedName = longToShortName.get(node.name);
if (minifiedName) {
replacement.addRange({
start: node.start,
end: node.end,
replacement: minifiedName + "/*" + node.name + "*/",
name: node.name,
});
}
});
return replacement;
}
function simplifyES5Class(input, ast, replacement) {
const prototypeMemberExpressions = [];
const duplicateNamedFunctions = [];
visit(ast, node => {
if (node.type === "MemberExpression" &&
!node.computed &&
node.object.type === "Identifier" &&
node.property.type === "Identifier" &&
node.property.name === "prototype"
) {
// Matches: xxx.prototype
prototypeMemberExpressions.push(node);
} else if (
node.type === "VariableDeclaration" &&
node.declarations.length === 1 &&
node.declarations[0].init &&
node.declarations[0].init.type === "FunctionExpression" &&
node.declarations[0].init.id &&
node.declarations[0].init.id.name === node.declarations[0].id.name
) {
// Matches: var xxx = function xxx ();
duplicateNamedFunctions.push(node);
}
});
duplicateNamedFunctions.forEach(duplicateNamedFunction => {
const functionName = duplicateNamedFunction.declarations[0].init.id.name;
// Remove: var xxx =
replacement.addRange({
start: duplicateNamedFunction.start,
end: duplicateNamedFunction.declarations[0].init.start,
replacement: "",
});
// Remove trailing semicolons
let semicolons = 0;
while (input[duplicateNamedFunction.end - semicolons - 1] === ";") semicolons++;
// Find prototype references
const refs = prototypeMemberExpressions.filter(node => node.object.name === functionName);
if (refs.length > 1) {
// Insert: var xx__prototype = xxx.prototype;
replacement.addRange({
start: duplicateNamedFunction.end - semicolons,
end: duplicateNamedFunction.end,
replacement: `\r\nvar ${functionName}__prototype = ${functionName}.prototype;`,
});
// Replace references
refs.forEach(ref => {
replacement.addRange({
start: ref.start,
end: ref.end,
replacement: `${functionName}__prototype`,
});
});
} else if (semicolons) {
replacement.addRange({
start: duplicateNamedFunction.end - semicolons,
end: duplicateNamedFunction.end,
replacement: "",
});
}
});
}
function browserConstants(input, ast, replacement) {
replacement.addText("Node.ELEMENT_NODE", "1");
}
const MINIFIERS = [simplifyES5Class, mangleProps, browserConstants];
module.exports = astTransformStream(function (replacement, ast, comments, input) {
MINIFIERS.forEach(minifier => minifier(input, ast, replacement));
});

View File

@@ -0,0 +1,52 @@
const astTransformStream = require("./ast-transform-stream");
function removeJSDocImports(comments, replacement) {
const REGEX = /[ \t]*\*[ \t]*@typedef\s+\{import.+\r?\n?|import\(.*?\)\.([a-zA-Z_]+)/g;
const JSDOC_COMMENT_OFFSET = 2;
comments.forEach(comment => {
if (comment.type === "Block" && comment.value[0] === "*") {
// JSDoc comment
const resultingComment = comment.value.replace(REGEX, (match, importName, matchIndex) => {
matchIndex += comment.start + JSDOC_COMMENT_OFFSET;
if (importName) {
// { import().xxx }
replacement.addRange({
start: matchIndex,
end: matchIndex + match.length,
replacement: importName,
});
return importName;
} else {
// @typedef
replacement.addRange({
start: matchIndex,
end: matchIndex + match.length,
replacement: "",
});
return "";
}
});
if (!/[^\s\*]/.test(resultingComment)) {
// Empty comment left
replacement.addRange({
start: comment.start,
end: comment.end,
replacement: "",
});
}
}
});
}
module.exports = astTransformStream(function (replacement, ast, comments, input) {
removeJSDocImports(comments, replacement);
});

View File

@@ -0,0 +1,54 @@
const { Transform } = require("stream");
const { SourceMapConsumer, SourceMapGenerator } = require("source-map");
function removeMappedSourceStream(...sourceNames) {
const sourceNamesToRemove = new Set(sourceNames);
return new Transform({
objectMode: true,
transform(inputFile, _, fileDone) {
if (inputFile.sourceMap) {
let consumer = inputFile.sourceMap;
if (!(consumer instanceof SourceMapConsumer)) {
consumer = new SourceMapConsumer(consumer);
}
const generator = new SourceMapGenerator({
file: consumer.file,
sourceRoot: consumer.sourceRoot,
});
consumer.sources.forEach(sourceFile => {
const content = consumer.sourceContentFor(sourceFile);
if (content != null && !sourceNamesToRemove.has(sourceFile)) {
generator.setSourceContent(sourceFile, content);
}
});
consumer.eachMapping(mapping => {
if (!sourceNamesToRemove.has(mapping.source)) {
generator.addMapping({
original: {
line: mapping.originalLine,
column: mapping.originalColumn,
},
generated: {
line: mapping.generatedLine,
column: mapping.generatedColumn,
},
source: mapping.source,
name: mapping.name,
});
}
});
inputFile.sourceMap = generator.toJSON();
}
fileDone(null, inputFile);
}
});
}
module.exports = removeMappedSourceStream;

View File

@@ -0,0 +1,672 @@
/**
* Jdenticon
* https://github.com/dmester/jdenticon
* Copyright © Daniel Mester Pirttijärvi
*/
const { Transform } = require("stream");
const { SourceMapConsumer, SourceMapGenerator } = require("source-map");
/**
* Finds substrings and replaces them with other strings, keeping any input source map up-to-date.
*
* @example
* const replacement = new Replacement([
* ["find this", "replace with this"],
* [/find this/gi, "replace with this"]
* ]);
* replacement.replace(input, inputSourceMap);
*
* @example
* const replacement = new Replacement("find this", "replace with this");
* replacement.replace(input, inputSourceMap);
*/
class Replacement {
constructor(...definition) {
/**
* @type {function(Array<OverwriteRange>, string): void}
*/
this._matchers = [];
/**
* @type {Array<OverwriteRange>}
*/
this._ranges = [];
this.add(definition);
}
/**
* @param {string} input
* @returns {Array<OverwriteRange>}
*/
matchAll(input) {
const ranges = [...this._ranges];
this._matchers.forEach(matcher => matcher(ranges, input));
let lastReplacement;
return ranges
.sort((a, b) => a.start - b.start)
.filter(replacement => {
if (!lastReplacement || lastReplacement.end <= replacement.start) {
lastReplacement = replacement;
return true;
}
});
}
/**
* @param {string} input
* @param {SourceMap=} inputSourceMap
* @returns {{ output: string, sourceMap: SourceMap }}
*/
replace(input, inputSourceMap) {
const ranges = this.matchAll(input);
const offset = new Offset();
const reader = new InputReader(input);
const sourceMap = new SourceMapSpooler(inputSourceMap);
const output = [];
if (sourceMap.isEmpty()) {
sourceMap.initEmpty(reader.lines);
}
ranges.forEach((range, rangeIndex) => {
output.push(reader.readTo(range.start));
output.push(range.replacement);
const inputStart = reader.pos;
const replacedText = reader.readTo(range.end);
if (replacedText === range.replacement) {
return; // Nothing to do
}
const inputEnd = reader.pos;
const replacementLines = range.replacement.split(/\n/g);
const lineDifference = replacementLines.length + inputStart.line - inputEnd.line - 1;
const outputStart = {
line: inputStart.line + offset.lineOffset,
column: inputStart.column + offset.getColumnOffset(inputStart.line)
};
const outputEnd = {
line: inputEnd.line + offset.lineOffset + lineDifference,
column: replacementLines.length > 1 ?
replacementLines[replacementLines.length - 1].length :
inputStart.column + offset.getColumnOffset(inputStart.line) +
range.replacement.length
}
sourceMap.spoolTo(inputStart.line, inputStart.column, offset);
offset.lineOffset += lineDifference;
offset.setColumnOffset(inputEnd.line, outputEnd.column - inputEnd.column);
if (range.name || replacementLines.length === 1 && range.replacement) {
const mappingBeforeStart = sourceMap.lastMapping();
const mappingAfterStart = sourceMap.nextMapping();
if (mappingAfterStart &&
mappingAfterStart.generatedLine === inputStart.line &&
mappingAfterStart.generatedColumn === inputStart.column
) {
sourceMap.addMapping({
original: {
line: mappingAfterStart.originalLine,
column: mappingAfterStart.originalColumn,
},
generated: {
line: outputStart.line,
column: outputStart.column
},
source: mappingAfterStart.source,
name: range.name,
});
} else if (mappingBeforeStart && mappingBeforeStart.generatedLine === inputStart.line) {
sourceMap.addMapping({
original: {
line: mappingBeforeStart.originalLine + inputStart.line - mappingBeforeStart.generatedLine,
column: mappingBeforeStart.originalColumn + inputStart.column - mappingBeforeStart.generatedColumn,
},
generated: {
line: outputStart.line,
column: outputStart.column
},
source: mappingBeforeStart.source,
name: range.name,
});
}
} else if (range.replacement) {
// Map longer replacements to a virtual file defined in the source map
const generatedSourceName = sourceMap.addSourceContent(replacedText, range.replacement);
for (var i = 0; i < replacementLines.length; i++) {
// Don't map empty lines
if (replacementLines[i]) {
sourceMap.addMapping({
original: {
line: i + 1,
column: 0,
},
generated: {
line: outputStart.line + i,
column: i ? 0 : outputStart.column,
},
source: generatedSourceName,
});
}
}
}
sourceMap.skipTo(inputEnd.line, inputEnd.column, offset);
// Add a source map node directly after the replacement to terminate the replacement
const mappingAfterEnd = sourceMap.nextMapping();
const mappingBeforeEnd = sourceMap.lastMapping();
if (mappingAfterEnd &&
mappingAfterEnd.generatedLine === inputEnd.line &&
mappingAfterEnd.generatedColumn === inputEnd.column
) {
// No extra source map node needed when the replacement is directly followed by another node
} else if (rangeIndex + 1 < ranges.length && range.end === ranges[rangeIndex + 1].start) {
// The next replacement range is adjacent to this one
} else if (reader.endOfLine()) {
// End of line, no point in adding a following node
} else if (!mappingBeforeEnd || mappingBeforeEnd.generatedLine !== inputEnd.line) {
// No applicable preceding node found
} else {
sourceMap.addMapping({
original: {
line: mappingBeforeEnd.originalLine + inputEnd.line - mappingBeforeEnd.generatedLine,
column: mappingBeforeEnd.originalColumn + inputEnd.column - mappingBeforeEnd.generatedColumn,
},
generated: {
line: outputEnd.line,
column: outputEnd.column
},
source: mappingBeforeEnd.source,
});
}
});
// Flush remaining input to output and source map
output.push(reader.readToEnd());
sourceMap.spoolToEnd(offset);
return {
output: output.join(""),
sourceMap: sourceMap.toJSON(),
};
}
add(value) {
const target = this;
function addRecursive(innerValue) {
if (innerValue != null) {
if (Array.isArray(innerValue)) {
const needle = innerValue[0];
if (typeof needle === "string") {
target.addText(...innerValue);
} else if (needle instanceof RegExp) {
target.addRegExp(...innerValue);
} else {
innerValue.forEach(addRecursive);
}
} else if (innerValue instanceof Replacement) {
target._matchers.push(innerValue._matchers);
target._ranges.push(innerValue._ranges);
} else if (typeof innerValue === "object") {
target.addRange(innerValue);
} else {
throw new Error("Unknown replacement argument specified.");
}
}
}
addRecursive(value);
}
/**
* @param {RegExp} re
* @param {string|function(string, ...):string} replacement
* @param {{ name: string }=} rangeOpts
*/
addRegExp(re, replacement, rangeOpts) {
const replacementFactory = this._createReplacementFactory(replacement);
this._matchers.push((ranges, input) => {
const isGlobalRegExp = /g/.test(re.flags);
let match;
let isFirstIteration = true;
while ((isFirstIteration || isGlobalRegExp) && (match = re.exec(input))) {
ranges.push(new OverwriteRange({
...rangeOpts,
start: match.index,
end: match.index + match[0].length,
replacement: replacementFactory(match, match.index, input),
}));
isFirstIteration = false;
}
});
}
/**
* @param {string} needle
* @param {string|function(string, ...):string} replacement
* @param {{ name: string }=} rangeOpts
*/
addText(needle, replacement, rangeOpts) {
const replacementFactory = this._createReplacementFactory(replacement);
this._matchers.push((ranges, input) => {
let index = -needle.length;
while ((index = input.indexOf(needle, index + needle.length)) >= 0) {
ranges.push(new OverwriteRange({
...rangeOpts,
start: index,
end: index + needle.length,
replacement: replacementFactory([needle], index, input),
}));
}
});
}
/**
* @param {OverwriteRange} range
*/
addRange(range) {
this._ranges.push(new OverwriteRange(range));
}
/**
* @param {string|function(string, ...):string} replacement
* @returns {function(Array<string>, number, string):string}
*/
_createReplacementFactory(replacement) {
if (typeof replacement === "function") {
return (match, index, input) => replacement(...match, index, input);
}
if (replacement == null) {
return () => "";
}
replacement = replacement.toString();
if (replacement.indexOf("$") < 0) {
return () => replacement;
}
return (match, index, input) =>
replacement.replace(/\$(\d+|[$&`'])/g, matchedPattern => {
if (matchedPattern === "$$") {
return "$";
}
if (matchedPattern === "$&") {
return match[0];
}
if (matchedPattern === "$`") {
return input.substring(0, index);
}
if (matchedPattern === "$'") {
return input.substring(index + match[0].length);
}
const matchArrayIndex = Number(matchedPattern.substring(1));
return match[matchArrayIndex];
});
}
}
class InputReader {
/**
* @param {string} input
*/
constructor (input) {
// Find index of all line breaks
const lineBreakIndexes = [];
let index = -1;
while ((index = input.indexOf("\n", index + 1)) >= 0) {
lineBreakIndexes.push(index);
}
this._input = input;
this._inputCursorExclusive = 0;
this._output = [];
this._lineBreakIndexes = lineBreakIndexes;
/**
* Number of lines in the input file.
* @type {number}
*/
this.lines = this._lineBreakIndexes.length + 1;
/**
* Position of the input cursor. Line number is one-based and column number is zero-based.
* @type {{ line: number, column: number }}
*/
this.pos = { line: 1, column: 0 };
}
readTo(exclusiveIndex) {
let result = "";
if (this._inputCursorExclusive < exclusiveIndex) {
result = this._input.substring(this._inputCursorExclusive, exclusiveIndex);
this._inputCursorExclusive = exclusiveIndex;
this._updatePos();
}
return result;
}
readToEnd() {
return this.readTo(this._input.length);
}
endOfLine() {
const nextChar = this._input[this._inputCursorExclusive];
return !nextChar || nextChar === "\r" || nextChar === "\n";
}
_updatePos() {
let line = this.pos.line;
while (
line - 1 < this._lineBreakIndexes.length &&
this._lineBreakIndexes[line - 1] < this._inputCursorExclusive
) {
line++;
}
const lineStartIndex = this._lineBreakIndexes[line - 2];
const column = this._inputCursorExclusive - (lineStartIndex || -1) - 1;
this.pos = { line, column };
}
}
class SourceMapSpooler {
/**
* @param {SourceMap=} inputSourceMap
*/
constructor(inputSourceMap) {
let generator;
let file;
let sources;
let mappings = [];
if (inputSourceMap) {
if (!(inputSourceMap instanceof SourceMapConsumer)) {
inputSourceMap = new SourceMapConsumer(inputSourceMap);
}
generator = new SourceMapGenerator({
file: inputSourceMap.file,
sourceRoot: inputSourceMap.sourceRoot,
});
inputSourceMap.sources.forEach(function(sourceFile) {
const content = inputSourceMap.sourceContentFor(sourceFile);
if (content != null) {
generator.setSourceContent(sourceFile, content);
}
});
inputSourceMap.eachMapping(mapping => {
mappings.push(mapping);
});
mappings.sort((a, b) => a.generatedLine == b.generatedLine
? a.generatedColumn - b.generatedColumn : a.generatedLine - b.generatedLine);
file = inputSourceMap.file;
sources = inputSourceMap.sources;
} else {
generator = new SourceMapGenerator();
file = "input";
sources = [];
}
this._generator = generator;
this._sources = new Set(sources);
this._file = file;
this._mappingsCursor = 0;
this._mappings = mappings;
this._contents = new Map();
}
lastMapping() {
return this._mappings[this._mappingsCursor - 1];
}
nextMapping() {
return this._mappings[this._mappingsCursor];
}
addMapping(mapping) {
this._generator.addMapping(mapping);
}
isEmpty() {
return this._mappings.length === 0;
}
initEmpty(lines) {
this._mappings = [];
for (var i = 0; i < lines; i++) {
this._mappings.push({
originalLine: i + 1,
originalColumn: 0,
generatedLine: i + 1,
generatedColumn: 0,
source: this._file
});
}
}
addSourceContent(replacedText, content) {
let sourceName = this._contents.get(content);
if (!sourceName) {
const PREFIX = "replacement/";
let sourceNameWithoutNumber = PREFIX;
sourceName = sourceNameWithoutNumber + "1";
if (replacedText.length > 0 && replacedText.length < 25) {
replacedText = replacedText
.replace(/^[^0-9a-z-_]+|[^0-9a-z-_]+$/ig, "")
.replace(/\s+/g, "-")
.replace(/[^0-9a-z-_]/ig, "");
if (replacedText) {
sourceNameWithoutNumber = PREFIX + replacedText + "-";
sourceName = PREFIX + replacedText;
}
}
let counter = 2;
while (this._sources.has(sourceName)) {
sourceName = sourceNameWithoutNumber + counter++;
}
this._sources.add(sourceName);
this._contents.set(content, sourceName);
this._generator.setSourceContent(sourceName, content);
}
return sourceName;
}
/**
* Copies source map info from input to output up to but not including the specified position.
* @param {number} line
* @param {number} column
* @param {Offset} offset
*/
spoolTo(line, column, offset) {
this._consume(line, column, offset, true);
}
/**
* Copies remaining source map info from input to output.
* @param {number} line
* @param {number} column
* @param {Offset} offset
*/
spoolToEnd(offset) {
this._consume(null, null, offset, true);
}
/**
* Discards source map info from input up to but not including the specified position.
* @param {number} line
* @param {number} column
* @param {Offset} offset
*/
skipTo(line, column, offset) {
this._consume(line, column, offset, false);
}
toJSON() {
return this._generator.toJSON();
}
_consume(line, column, offset, keep) {
let mapping;
while (
(mapping = this._mappings[this._mappingsCursor]) &&
(
line == null ||
mapping.generatedLine < line ||
mapping.generatedLine == line && mapping.generatedColumn < column
)
) {
if (keep) {
this._generator.addMapping({
original: {
line: mapping.originalLine,
column: mapping.originalColumn,
},
generated: {
line: mapping.generatedLine + offset.lineOffset,
column: mapping.generatedColumn + offset.getColumnOffset(mapping.generatedLine),
},
source: mapping.source,
name: mapping.name,
});
}
this._mappingsCursor++;
}
}
}
class Offset {
constructor() {
this.lineOffset = 0;
this._columnOffset = 0;
this._columnOffsetForLine = 0;
}
setColumnOffset(lineNumber, columnOffset) {
this._columnOffsetForLine = lineNumber;
this._columnOffset = columnOffset;
}
getColumnOffset(lineNumber) {
return this._columnOffsetForLine === lineNumber ?
this._columnOffset : 0;
}
}
class OverwriteRange {
constructor(options) {
if (!isFinite(options.start)) {
throw new Error("A replacement start index is required.");
}
if (!isFinite(options.end)) {
throw new Error("A replacement end index is required.");
}
if (options.end < options.start) {
throw new Error("Replacement end index cannot precede its start index.");
}
/**
* Inclusive start index.
* @type {number}
*/
this.start = options.start;
/**
* Exclusive start index.
* @type {number}
*/
this.end = options.end;
/**
* The replacement interval will be replaced with this string.
* @type string
*/
this.replacement = "" + options.replacement;
/**
* Optional name that will be mapped in the source map.
* @type string
*/
this.name = options.name;
}
}
function gulp(replacements) {
if (typeof replacements === "string" || replacements instanceof RegExp) {
replacements = Array.from(arguments);
}
const replacer = new Replacement(replacements);
return new Transform({
objectMode: true,
transform(inputFile, _, fileDone) {
const input = inputFile.contents.toString();
const output = replacer.replace(input, inputFile.sourceMap);
inputFile.contents = Buffer.from(output.output);
if (inputFile.sourceMap) {
inputFile.sourceMap = output.sourceMap;
}
fileDone(null, inputFile);
}
});
}
module.exports = { Replacement, gulp };

View File

@@ -0,0 +1,70 @@
module.exports = new Set([
"async",
"break",
"case",
"catch",
"class",
"const",
"continue",
"debugger",
"default",
"delete",
"do",
"else",
"export",
"extends",
"finally",
"for",
"function",
"if",
"import",
"in",
"of",
"instanceof",
"new",
"return",
"super",
"switch",
"this",
"throw",
"try",
"typeof",
"var",
"void",
"while",
"with",
"yield",
"enum",
"implements",
"interface",
"let",
"package",
"private",
"protected",
"public",
"static",
"await",
"abstract",
"boolean",
"byte",
"char",
"double",
"final",
"float",
"goto",
"int",
"long",
"native",
"short",
"synchronized",
"throws",
"transient",
"volatile",
"arguments",
"get",
"set",
"null",
"undefined",
"exports",
"module",
]);

View File

@@ -0,0 +1,56 @@
/**
* Jdenticon
* https://github.com/dmester/jdenticon
* Copyright © Daniel Mester Pirttijärvi
*/
const { rollup } = require("rollup");
const Vinyl = require("vinyl");
const applySourceMap = require("vinyl-sourcemaps-apply");
const { Transform } = require("stream");
function rollupStream(options) {
return new Transform({
objectMode: true,
transform(inputFile, _, fileDone) {
const inputOptions = {
onwarn: warn => console.warn(warn.toString()),
...options,
input: inputFile.path,
};
delete inputOptions.output;
rollup(inputOptions).then(bundle => {
return bundle.generate({
...options.output,
sourcemap: !!inputFile.sourceMap
});
}).then(outputs => {
for (const output of outputs.output) {
if (output.type === "chunk") {
const outputFile = new Vinyl({
cwd: inputFile.cwd,
base: inputFile.base,
path: inputFile.path,
contents: Buffer.from(output.code),
});
if (inputFile.sourceMap) {
applySourceMap(outputFile, output.map);
}
this.push(outputFile);
}
}
fileDone();
}, err => fileDone(err));
}
});
}
module.exports = rollupStream;

View File

@@ -0,0 +1,26 @@
const fs = require("fs");
const replace = require("./replacement").gulp;
function wrapTemplate(templatePath, variables) {
let template = fs.readFileSync(templatePath).toString();
if (variables) {
variables.forEach(variable => {
template = template.replace(variable[0], variable[1]);
});
}
template = template.split(/\/\*content\*\//);
const replacements = [];
if (template[0]) {
replacements.push([/^/, template[0]]);
}
if (template[1]) {
replacements.push([/$/, template[1]]);
}
return replace(replacements);
}
module.exports = wrapTemplate;

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd">
<metadata>
<id>Jdenticon</id>
<version>#version#</version>
<title>Jdenticon JS</title>
<authors>Daniel Mester Pirttijärvi</authors>
<license type="expression">MIT</license>
<projectUrl>http://jdenticon.com/</projectUrl>
<iconUrl>http://jdenticon.com/hosted/nuget-logo-js.png</iconUrl>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<description>JavaScript library generating identicons using SVG graphics or HTML5 canvas.
If you intend to generate icons server-side, consider using any of the .NET packages instead:
* Jdenticon-net
* Jdenticon.AspNetCore
* Jdenticon.AspNet.Mvc
* Jdenticon.AspNet.WebApi
* Jdenticon.AspNet.WebForms</description>
<tags>JavaScript identicon avatar library</tags>
</metadata>
<files>
<file src="obj\output\jdenticon-#version#.min.js" target="Content\Scripts\jdenticon-#version#.min.js" />
<file src="obj\output\jdenticon-#version#.js" target="Content\Scripts\jdenticon-#version#.js" />
<file src="obj\output\readme.txt" target="Content\Scripts\jdenticon.readme.txt" />
</files>
</package>

Binary file not shown.

View File

@@ -0,0 +1,70 @@
Apache License 2.0 (Apache)
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License.
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License.
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution.
You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
1. You must give any other recipients of the Work or Derivative Works a copy of this License; and
2. You must cause any modified files to carry prominent notices stating that You changed the files; and
3. You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
4. If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions.
Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks.
This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty.
Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability.
In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability.
While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.

2
jdenticon-js/build/template-min.js vendored Normal file
View File

@@ -0,0 +1,2 @@
// Jdenticon #version# | jdenticon.com | MIT licensed | (c) 2014-#year# Daniel Mester Pirttijärvi
/*content*/

View File

@@ -0,0 +1,10 @@
/**
* Jdenticon #version#
* http://jdenticon.com
*
* Built: #date#
*
* #license#
*/
/*content*/

View File

@@ -0,0 +1,27 @@
/**
* Jdenticon #version#
* http://jdenticon.com
*
* Built: #date#
*
* #license#
*/
(function (umdGlobal, factory) {
var jdenticon = factory(umdGlobal);
// Node.js
if (typeof module !== "undefined" && "exports" in module) {
module["exports"] = jdenticon;
}
// RequireJS
else if (typeof define === "function" && define["amd"]) {
define([], function () { return jdenticon; });
}
// No module loader
else {
umdGlobal["jdenticon"] = jdenticon;
}
})(typeof self !== "undefined" ? self : this, function (umdGlobal) {
/*content*/
});

67
jdenticon-js/dist/README.md vendored Normal file
View File

@@ -0,0 +1,67 @@
# What file should I use?
## Overview
| Platform | Bundle | File name |
|----------|------------------|----------------------|
| Browser | Standalone (UMD) | jdenticon.js |
| | | jdenticon.min.js |
| | ES module | jdenticon-module.mjs |
| | CommonJS module | jdenticon-module.js |
| Node.js | ES module | jdenticon-node.mjs |
| | CommonJS module | jdenticon-node.js |
## Node vs browser
There are separate bundles for Node.js and browsers. The Node.js bundles contain support for generating PNG icons, while the browser bundles have support for updating DOM elements. It is important that the right bundle is used, since a web application bundle will be significally larger if the Node bundle is imported instead of the browser bundle.
## Don't address `dist/*` directly
In first hand, don't import a specific file from the `dist` folder. Instead import the Jdenticon package and let the package decide what file to be imported. If your bundler does not pick the right file, and you cannot configure it to do so, there are explicit exports that you can use to force it to use the correct bundle:
| Platform | Export | Example |
|----------|----------------------|----------------------------------------------|
| Browser | jdenticon/browser | `import { toSvg } from "jdenticon/browser";` |
| Node.js | jdenticon/node | `import { toSvg } from "jdenticon/node";` |
| UMD | jdenticon/standalone | `import "jdenticon/standalone";` |
Jdenticon has multiple public entry points:
### ES module
For browsers `jdenticon-module.mjs` is imported and in Node.js environments `jdenticon-node.mjs` is imported. This is the preferred way of using Jdenticon since your bundler will most likely be able to eliminiate code from Jdenticon not used in your application (a.k.a. tree-shaking).
**Example**
```js
import { toSvg } from "jdenticon";
console.log(toSvg("my value", 100));
```
### CommonJS module
If Jdenticon is imported on platforms not supporting ES modules, `jdenticon-module.js` is imported for browser environments and `jdenticon-node.js` in Node.js environments.
**Example**
```js
const { toSvg } = require("jdenticon");
console.log(toSvg("my value", 100));
// or
const jdenticon = require("jdenticon");
console.log(jdenticon.toSvg("my value", 100));
```
### Standalone browser package
This package will render icons automatically at startup and also provides a legacy jQuery plugin, if jQuery is loaded before Jdenticon.
**Example**
```js
import "jdenticon/standalone";
// or
import { toSvg } from "jdenticon/standalone";
console.log(toSvg("my value", 100));
```

1405
jdenticon-js/dist/jdenticon-module.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

1399
jdenticon-js/dist/jdenticon-module.mjs vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

1276
jdenticon-js/dist/jdenticon-node.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

1240
jdenticon-js/dist/jdenticon-node.mjs vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

1507
jdenticon-js/dist/jdenticon.js vendored Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More