init
This commit is contained in:
566
internal/renderer/integration_test.go
Normal file
566
internal/renderer/integration_test.go
Normal 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
292
internal/renderer/png.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
290
internal/renderer/png_test.go
Normal file
290
internal/renderer/png_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
237
internal/renderer/renderer.go
Normal file
237
internal/renderer/renderer.go
Normal 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
|
||||
}
|
||||
362
internal/renderer/renderer_test.go
Normal file
362
internal/renderer/renderer_test.go
Normal 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
172
internal/renderer/svg.go
Normal 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)
|
||||
}
|
||||
240
internal/renderer/svg_test.go
Normal file
240
internal/renderer/svg_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user