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

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)
}
})
}
}