Initial release: Go Jdenticon library v0.1.0

- Core library with SVG and PNG generation
- CLI tool with generate and batch commands
- Cross-platform path handling for Windows compatibility
- Comprehensive test suite with integration tests
This commit is contained in:
Kevin McIntyre
2026-01-02 23:56:48 -05:00
parent f84b511895
commit d9e84812ff
292 changed files with 19725 additions and 38884 deletions

View File

@@ -0,0 +1,98 @@
package renderer
import (
"testing"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// Benchmark optimized renderer to compare against baseline (958,401 B/op)
func BenchmarkOptimizedVsBaseline(b *testing.B) {
b.Run("Optimized_64px_PNG", func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Use optimized renderer with typical identicon pattern
renderer := NewPNGRenderer(64)
// Simulate typical identicon generation (simplified)
renderer.SetBackground("#f0f0f0", 1.0)
// Add representative shapes (based on typical identicon output)
renderer.BeginShape("#ff6b6b")
renderer.AddPolygon([]engine.Point{{X: 0.2, Y: 0.2}, {X: 0.8, Y: 0.2}, {X: 0.5, Y: 0.8}})
renderer.EndShape()
renderer.BeginShape("#4ecdc4")
renderer.AddPolygon([]engine.Point{{X: 0, Y: 0}, {X: 0.4, Y: 0}, {X: 0.4, Y: 0.4}, {X: 0, Y: 0.4}})
renderer.EndShape()
renderer.BeginShape("#45b7d1")
renderer.AddCircle(engine.Point{X: 0.6, Y: 0.6}, 0.3, false)
renderer.EndShape()
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("Optimized PNG generation failed: %v", err)
}
}
})
}
// Simulate the icon generation process for testing
// This creates a simple identicon structure for benchmarking
func BenchmarkOptimizedSimulatedGeneration(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(64)
// Simulate typical identicon generation
renderer.SetBackground("#f0f0f0", 1.0)
// Add typical identicon shapes (3-5 shapes)
shapes := []struct {
color string
points []engine.Point
}{
{"#ff6b6b", []engine.Point{{X: 0.2, Y: 0.2}, {X: 0.8, Y: 0.2}, {X: 0.5, Y: 0.8}}},
{"#4ecdc4", []engine.Point{{X: 0, Y: 0}, {X: 0.4, Y: 0}, {X: 0.4, Y: 0.4}, {X: 0, Y: 0.4}}},
{"#45b7d1", []engine.Point{{X: 0.6, Y: 0.6}, {X: 1, Y: 0.6}, {X: 1, Y: 1}, {X: 0.6, Y: 1}}},
}
for _, shape := range shapes {
renderer.BeginShape(shape.color)
renderer.AddPolygon(shape.points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("Simulated generation failed: %v", err)
}
}
}
// Direct memory comparison test - minimal overhead
func BenchmarkOptimizedPureMemory(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Create renderer - this is where the major memory allocation difference should be
renderer := NewPNGRenderer(64)
// Minimal shape to trigger rendering pipeline
renderer.SetBackground("#ffffff", 1.0)
renderer.BeginShape("#ff0000")
renderer.AddPolygon([]engine.Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 0.5, Y: 1}})
renderer.EndShape()
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("Pure memory test failed: %v", err)
}
}
}

58
internal/renderer/doc.go Normal file
View File

@@ -0,0 +1,58 @@
/*
Package renderer is responsible for translating the intermediate representation of an
identicon (generated by the `engine` package) into a final output format, such
as SVG or PNG.
This package is internal to the jdenticon library and its API is not guaranteed
to be stable. Do not use it directly.
# Core Concept: The Renderer Interface
The central component of this package is the `Renderer` interface. It defines a
contract for any format-specific renderer. The primary method, `Render`, takes a
list of shapes and colors and writes the output to an `io.Writer`.
This interface-based approach makes the system extensible. To add a new output
format (e.g., WebP), one would simply need to create a new struct that implements
the `Renderer` interface.
# Implementations
- svg.go: Provides `SvgRenderer`, which generates a vector-based SVG image. It
builds an XML tree representing the SVG structure and writes it out. This
renderer is highly efficient and produces scalable output that maintains
visual compatibility with the JavaScript Jdenticon library.
- png.go: Provides `PngRenderer`, which generates a raster-based PNG image. It
utilizes Go's standard `image` and `image/draw` packages to draw the shapes onto
a canvas and then encodes the result as a PNG.
- fast_png.go: Provides `FastPngRenderer`, an optimized PNG implementation that
uses more efficient drawing algorithms for better performance in high-throughput
scenarios.
# Rendering Pipeline
The rendering process follows this flow:
1. The engine package generates `RenderedElement` structures containing shape
geometries and color information.
2. A renderer implementation receives this intermediate representation along with
size and configuration parameters.
3. The renderer translates the abstract shapes into format-specific commands
(SVG paths, PNG pixel operations, etc.).
4. The final output is written to the provided `io.Writer`, allowing for
flexible destination handling (files, HTTP responses, etc.).
# Performance Considerations
The renderers are designed for efficiency:
- SVG rendering uses a `strings.Builder` for efficient, low-allocation string construction
- PNG rendering includes both standard and fast implementations
- All renderers support concurrent use across multiple goroutines
- Memory allocations are minimized through object reuse where possible
*/
package renderer

View File

@@ -7,7 +7,7 @@ import (
"image/png"
"testing"
"github.com/kevin/go-jdenticon/internal/engine"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// TestPNGRenderer_VisualRegression tests that PNG output matches expected characteristics
@@ -95,7 +95,10 @@ func TestPNGRenderer_VisualRegression(t *testing.T) {
renderer.EndShape()
}
pngData := renderer.ToPNG()
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
// Verify PNG is valid
reader := bytes.NewReader(pngData)
@@ -188,7 +191,10 @@ func TestPNGRenderer_ComplexIcon(t *testing.T) {
renderer.AddCircle(engine.Point{X: 50, Y: 50}, 15, false)
renderer.EndShape()
pngData := renderer.ToPNG()
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
// Verify the complex icon renders successfully
reader := bytes.NewReader(pngData)
@@ -210,7 +216,7 @@ func TestPNGRenderer_ComplexIcon(t *testing.T) {
t.Logf("Complex icon PNG size: %d bytes", len(pngData))
}
// TestRendererInterface_Consistency tests that both SVG and PNG renderers
// TestRendererInterface_Consistency tests that both SVG and PNG renderers
// implement the Renderer interface consistently
func TestRendererInterface_Consistency(t *testing.T) {
testCases := []struct {
@@ -229,11 +235,11 @@ func TestRendererInterface_Consistency(t *testing.T) {
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},
@@ -294,43 +300,46 @@ func TestRendererInterface_Consistency(t *testing.T) {
if tc.bgOp > 0 {
renderer.SetBackground(tc.bg, tc.bgOp)
}
tc.testFunc(renderer)
// Verify PNG output
pngData := renderer.ToPNG()
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
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",
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")
@@ -338,7 +347,7 @@ func TestRendererInterface_Consistency(t *testing.T) {
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)
@@ -366,12 +375,12 @@ func TestRendererInterface_BaseRendererMethods(t *testing.T) {
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 {
@@ -384,7 +393,7 @@ func TestRendererInterface_BaseRendererMethods(t *testing.T) {
t.Errorf("PNG GetBackground() = %s, %f, want #123456, 0.75", bg, op)
}
}
// Test shape management
renderer.BeginShape("#ff0000")
if svgRenderer, ok := renderer.(*SVGRenderer); ok {
@@ -397,7 +406,7 @@ func TestRendererInterface_BaseRendererMethods(t *testing.T) {
t.Errorf("PNG GetCurrentColor() = %s, want #ff0000", color)
}
}
// Test clearing
renderer.Clear()
if svgRenderer, ok := renderer.(*SVGRenderer); ok {
@@ -418,11 +427,11 @@ func TestRendererInterface_BaseRendererMethods(t *testing.T) {
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
@@ -466,20 +475,20 @@ func TestRendererInterface_CompatibilityWithJavaScript(t *testing.T) {
},
},
}
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)
@@ -487,26 +496,29 @@ func TestRendererInterface_CompatibilityWithJavaScript(t *testing.T) {
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()
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
// 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)
@@ -522,7 +534,10 @@ func TestPNGRenderer_EdgeCases(t *testing.T) {
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()
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
if len(pngData) == 0 {
t.Error("1x1 PNG should generate data")
}
@@ -535,7 +550,10 @@ func TestPNGRenderer_EdgeCases(t *testing.T) {
renderer.AddCircle(engine.Point{X: 256, Y: 256}, 200, false)
renderer.EndShape()
pngData := renderer.ToPNG()
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
if len(pngData) == 0 {
t.Error("512x512 PNG should generate data")
}
@@ -556,9 +574,12 @@ func TestPNGRenderer_EdgeCases(t *testing.T) {
renderer.EndShape()
// Should not panic and should produce valid PNG
pngData := renderer.ToPNG()
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
reader := bytes.NewReader(pngData)
_, err := png.Decode(reader)
_, err = png.Decode(reader)
if err != nil {
t.Errorf("Failed to decode PNG with out-of-bounds shapes: %v", err)
}

View File

@@ -0,0 +1,544 @@
package renderer
import (
"strconv"
"strings"
"testing"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// ============================================================================
// RENDERER MICRO-BENCHMARKS FOR MEMORY ALLOCATION ANALYSIS
// ============================================================================
var (
// Test data for renderer benchmarks
benchTestPoints = []engine.Point{
{X: 0.0, Y: 0.0},
{X: 10.5, Y: 0.0},
{X: 10.5, Y: 10.5},
{X: 0.0, Y: 10.5},
}
benchTestColors = []string{
"#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff",
}
benchTestSizes = []int{32, 64, 128, 256}
)
// ============================================================================
// SVG STRING BUILDING MICRO-BENCHMARKS
// ============================================================================
// BenchmarkSVGStringBuilding tests different string building patterns in SVG generation
func BenchmarkSVGStringBuilding(b *testing.B) {
points := benchTestPoints
b.Run("svgValue_formatting", func(b *testing.B) {
values := []float64{0.0, 10.5, 15.75, 100.0, 256.5}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
value := values[i%len(values)]
_ = svgValue(value)
}
})
b.Run("strconv_FormatFloat", func(b *testing.B) {
value := 10.5
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = strconv.FormatFloat(value, 'f', 1, 64)
}
})
b.Run("strconv_Itoa", func(b *testing.B) {
value := 10
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = strconv.Itoa(value)
}
})
b.Run("polygon_path_building", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var buf strings.Builder
buf.Grow(50) // Estimate capacity
// Simulate polygon path building
buf.WriteString("M")
buf.WriteString(svgValue(points[0].X))
buf.WriteString(" ")
buf.WriteString(svgValue(points[0].Y))
for j := 1; j < len(points); j++ {
buf.WriteString("L")
buf.WriteString(svgValue(points[j].X))
buf.WriteString(" ")
buf.WriteString(svgValue(points[j].Y))
}
buf.WriteString("Z")
_ = buf.String()
}
})
}
// BenchmarkSVGPathOperations tests SVGPath struct operations
func BenchmarkSVGPathOperations(b *testing.B) {
points := benchTestPoints
b.Run("SVGPath_AddPolygon", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
path := &SVGPath{}
path.AddPolygon(points)
}
})
b.Run("SVGPath_AddCircle", func(b *testing.B) {
topLeft := engine.Point{X: 5.0, Y: 5.0}
size := 10.0
b.ReportAllocs()
for i := 0; i < b.N; i++ {
path := &SVGPath{}
path.AddCircle(topLeft, size, false)
}
})
b.Run("SVGPath_DataString", func(b *testing.B) {
path := &SVGPath{}
path.AddPolygon(points)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = path.DataString()
}
})
}
// BenchmarkStringBuilderPooling tests the efficiency of string builder pooling
func BenchmarkStringBuilderPooling(b *testing.B) {
points := benchTestPoints
b.Run("direct_builder", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Use direct string builder (pool eliminated for direct writing)
var buf strings.Builder
// Build polygon path
buf.WriteString("M")
buf.WriteString(svgValue(points[0].X))
buf.WriteString(" ")
buf.WriteString(svgValue(points[0].Y))
for j := 1; j < len(points); j++ {
buf.WriteString("L")
buf.WriteString(svgValue(points[j].X))
buf.WriteString(" ")
buf.WriteString(svgValue(points[j].Y))
}
buf.WriteString("Z")
_ = buf.String()
}
})
b.Run("without_pool", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Create new buffer each time
var buf strings.Builder
buf.Grow(50)
// Build polygon path
buf.WriteString("M")
buf.WriteString(svgValue(points[0].X))
buf.WriteString(" ")
buf.WriteString(svgValue(points[0].Y))
for j := 1; j < len(points); j++ {
buf.WriteString("L")
buf.WriteString(svgValue(points[j].X))
buf.WriteString(" ")
buf.WriteString(svgValue(points[j].Y))
}
buf.WriteString("Z")
_ = buf.String()
}
})
b.Run("reused_builder", func(b *testing.B) {
var buf strings.Builder
buf.Grow(100) // Pre-allocate larger buffer
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
buf.Reset()
// Build polygon path
buf.WriteString("M")
buf.WriteString(svgValue(points[0].X))
buf.WriteString(" ")
buf.WriteString(svgValue(points[0].Y))
for j := 1; j < len(points); j++ {
buf.WriteString("L")
buf.WriteString(svgValue(points[j].X))
buf.WriteString(" ")
buf.WriteString(svgValue(points[j].Y))
}
buf.WriteString("Z")
_ = buf.String()
}
})
}
// ============================================================================
// SVG RENDERER MICRO-BENCHMARKS
// ============================================================================
// BenchmarkSVGRendererOperations tests SVG renderer creation and operations
func BenchmarkSVGRendererOperations(b *testing.B) {
sizes := benchTestSizes
colors := benchTestColors
b.Run("NewSVGRenderer", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
size := sizes[i%len(sizes)]
_ = NewSVGRenderer(size)
}
})
b.Run("BeginShape", func(b *testing.B) {
renderer := NewSVGRenderer(64)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := colors[i%len(colors)]
renderer.BeginShape(color)
}
})
b.Run("AddPolygon", func(b *testing.B) {
renderer := NewSVGRenderer(64)
renderer.BeginShape(colors[0])
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer.AddPolygon(benchTestPoints)
}
})
b.Run("AddCircle", func(b *testing.B) {
renderer := NewSVGRenderer(64)
renderer.BeginShape(colors[0])
topLeft := engine.Point{X: 5.0, Y: 5.0}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer.AddCircle(topLeft, 10.0, false)
}
})
}
// BenchmarkSVGGeneration tests full SVG generation with different scenarios
func BenchmarkSVGGeneration(b *testing.B) {
colors := benchTestColors[:3] // Use fewer colors for cleaner tests
b.Run("empty_renderer", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewSVGRenderer(64)
_ = renderer.ToSVG()
}
})
b.Run("single_shape", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewSVGRenderer(64)
renderer.BeginShape(colors[0])
renderer.AddPolygon(benchTestPoints)
renderer.EndShape()
_ = renderer.ToSVG()
}
})
b.Run("multiple_shapes", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewSVGRenderer(64)
for j, color := range colors {
renderer.BeginShape(color)
// Offset points for each shape
offsetPoints := make([]engine.Point, len(benchTestPoints))
for k, point := range benchTestPoints {
offsetPoints[k] = engine.Point{
X: point.X + float64(j*12),
Y: point.Y + float64(j*12),
}
}
renderer.AddPolygon(offsetPoints)
renderer.EndShape()
}
_ = renderer.ToSVG()
}
})
b.Run("with_background", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewSVGRenderer(64)
renderer.SetBackground("#ffffff", 1.0)
renderer.BeginShape(colors[0])
renderer.AddPolygon(benchTestPoints)
renderer.EndShape()
_ = renderer.ToSVG()
}
})
}
// BenchmarkSVGSizeEstimation tests SVG capacity estimation
func BenchmarkSVGSizeEstimation(b *testing.B) {
colors := benchTestColors
b.Run("capacity_estimation", func(b *testing.B) {
renderer := NewSVGRenderer(64)
for _, color := range colors {
renderer.BeginShape(color)
renderer.AddPolygon(benchTestPoints)
renderer.EndShape()
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Simulate capacity estimation logic from ToSVG
capacity := svgBaseOverheadBytes
capacity += svgBackgroundRectBytes // Assume background
// Estimate path data size
for _, color := range renderer.colorOrder {
path := renderer.pathsByColor[color]
if path != nil {
capacity += svgPathOverheadBytes + path.data.Len()
}
}
_ = capacity
}
})
b.Run("strings_builder_with_estimation", func(b *testing.B) {
renderer := NewSVGRenderer(64)
for _, color := range colors {
renderer.BeginShape(color)
renderer.AddPolygon(benchTestPoints)
renderer.EndShape()
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Test strings.Builder with capacity estimation
capacity := svgBaseOverheadBytes + svgBackgroundRectBytes
for _, color := range renderer.colorOrder {
path := renderer.pathsByColor[color]
if path != nil {
capacity += svgPathOverheadBytes + path.data.Len()
}
}
var svg strings.Builder
svg.Grow(capacity)
svg.WriteString(`<svg xmlns="http://www.w3.org/2000/svg">`)
svg.WriteString("</svg>")
_ = svg.String()
}
})
}
// ============================================================================
// MAP OPERATIONS MICRO-BENCHMARKS
// ============================================================================
// BenchmarkMapOperations tests map operations used in renderer
func BenchmarkMapOperations(b *testing.B) {
colors := benchTestColors
b.Run("map_creation", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
m := make(map[string]*SVGPath)
_ = m
}
})
b.Run("map_insertion", func(b *testing.B) {
m := make(map[string]*SVGPath)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := colors[i%len(colors)]
m[color] = &SVGPath{}
}
})
b.Run("map_lookup", func(b *testing.B) {
m := make(map[string]*SVGPath)
for _, color := range colors {
m[color] = &SVGPath{}
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := colors[i%len(colors)]
_ = m[color]
}
})
b.Run("map_existence_check", func(b *testing.B) {
m := make(map[string]*SVGPath)
for _, color := range colors {
m[color] = &SVGPath{}
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := colors[i%len(colors)]
_, exists := m[color]
_ = exists
}
})
}
// ============================================================================
// SLICE OPERATIONS MICRO-BENCHMARKS
// ============================================================================
// BenchmarkSliceOperations tests slice operations for color ordering
func BenchmarkSliceOperations(b *testing.B) {
colors := benchTestColors
b.Run("slice_append", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var colorOrder []string
//lint:ignore S1011 Intentionally benchmarking individual appends vs batch
//nolint:gosimple // Intentionally benchmarking individual appends vs batch
for _, color := range colors {
colorOrder = append(colorOrder, color)
}
_ = colorOrder
}
})
b.Run("slice_with_capacity", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
colorOrder := make([]string, 0, len(colors))
//lint:ignore S1011 Intentionally benchmarking individual appends with pre-allocation
//nolint:gosimple // Intentionally benchmarking individual appends with pre-allocation
for _, color := range colors {
colorOrder = append(colorOrder, color)
}
_ = colorOrder
}
})
b.Run("slice_iteration", func(b *testing.B) {
colorOrder := make([]string, len(colors))
copy(colorOrder, colors)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for _, color := range colorOrder {
_ = color
}
}
})
}
// ============================================================================
// COORDINATE TRANSFORMATION MICRO-BENCHMARKS
// ============================================================================
// BenchmarkCoordinateTransforms tests coordinate transformation patterns
func BenchmarkCoordinateTransforms(b *testing.B) {
points := benchTestPoints
b.Run("point_creation", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = engine.Point{X: 10.5, Y: 20.5}
}
})
b.Run("point_slice_creation", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
pointsCopy := make([]engine.Point, len(points))
copy(pointsCopy, points)
_ = pointsCopy
}
})
b.Run("point_transformation", func(b *testing.B) {
transform := func(x, y float64) (float64, float64) {
return x * 2.0, y * 2.0
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
transformedPoints := make([]engine.Point, len(points))
for j, point := range points {
newX, newY := transform(point.X, point.Y)
transformedPoints[j] = engine.Point{X: newX, Y: newY}
}
_ = transformedPoints
}
})
}
// ============================================================================
// MEMORY ALLOCATION PATTERN COMPARISONS
// ============================================================================
// BenchmarkAllocationPatterns compares different allocation patterns used in rendering
func BenchmarkAllocationPatterns(b *testing.B) {
b.Run("string_concatenation", func(b *testing.B) {
base := "test"
suffix := "value"
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = base + suffix
}
})
b.Run("sprintf_formatting", func(b *testing.B) {
value := 10.5
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = strconv.FormatFloat(value, 'f', 1, 64)
}
})
b.Run("builder_small_capacity", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var buf strings.Builder
buf.Grow(10)
buf.WriteString("test")
buf.WriteString("value")
_ = buf.String()
}
})
b.Run("builder_large_capacity", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var buf strings.Builder
buf.Grow(100)
buf.WriteString("test")
buf.WriteString("value")
_ = buf.String()
}
})
}

View File

@@ -0,0 +1,179 @@
package renderer
import (
"fmt"
"testing"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// Benchmark optimized PNG renderer vs original FastPNG renderer
func BenchmarkOptimizedPNGToPNG(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
size := benchmarkSizes[i%len(benchmarkSizes)]
renderer := NewPNGRenderer(size)
// Add some shapes
renderer.SetBackground("#f0f0f0", 1.0)
for j := 0; j < 3; j++ {
color := benchmarkColors[j%len(benchmarkColors)]
points := benchmarkPoints[j%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
}
// Benchmark PNG memory usage with different allocation patterns
func BenchmarkPNGMemoryPatterns(b *testing.B) {
// Shared test data
testShapes := []struct {
color string
points []engine.Point
}{
{"#ff0000", benchmarkPoints[0]},
{"#00ff00", benchmarkPoints[1]},
{"#0000ff", benchmarkPoints[2]},
}
b.Run("OptimizedPNG", func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(256)
renderer.SetBackground("#ffffff", 1.0)
for _, shape := range testShapes {
renderer.BeginShape(shape.color)
renderer.AddPolygon(shape.points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
})
b.Run("PNGWrapper", func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(256)
renderer.SetBackground("#ffffff", 1.0)
for _, shape := range testShapes {
renderer.BeginShape(shape.color)
renderer.AddPolygon(shape.points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
})
}
// Benchmark different icon sizes to see memory scaling
func BenchmarkOptimizedPNGSizes(b *testing.B) {
testShapes := []struct {
color string
points []engine.Point
}{
{"#ff0000", benchmarkPoints[0]},
{"#00ff00", benchmarkPoints[1]},
{"#0000ff", benchmarkPoints[2]},
}
sizes := []int{64, 128, 256, 512}
for _, size := range sizes {
b.Run(fmt.Sprintf("Size%d", size), func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(size)
renderer.SetBackground("#ffffff", 1.0)
for _, shape := range testShapes {
renderer.BeginShape(shape.color)
renderer.AddPolygon(shape.points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
})
}
}
// Benchmark complex shape rendering with optimized renderer
func BenchmarkOptimizedComplexPNGRendering(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(256)
renderer.SetBackground("#f8f8f8", 1.0)
// Render many shapes to simulate complex icon
for j := 0; j < 12; j++ {
color := benchmarkColors[j%len(benchmarkColors)]
points := benchmarkPoints[j%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
}
// Benchmark pooling efficiency
func BenchmarkPoolingEfficiency(b *testing.B) {
b.Run("WithPooling", func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(128)
renderer.SetBackground("#ffffff", 1.0)
// Add multiple polygons to exercise pooling
for j := 0; j < 10; j++ {
renderer.BeginShape("#808080")
renderer.AddPolygon(benchmarkPoints[j%len(benchmarkPoints)])
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
})
}

View File

@@ -2,179 +2,605 @@ package renderer
import (
"bytes"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"math"
"strconv"
"strings"
"sync"
"github.com/kevin/go-jdenticon/internal/engine"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// PNGRenderer implements the Renderer interface for PNG output
// PNG rendering constants
const (
defaultSupersamplingFactor = 8 // Default antialiasing supersampling factor
)
// Memory pools for reducing allocations during rendering
var (
// Pool for point slices used during polygon processing
// Uses pointer to slice to avoid allocation during type assertion (SA6002)
pointSlicePool = sync.Pool{
New: func() interface{} {
s := make([]engine.Point, 0, 16) // Pre-allocate reasonable capacity
return &s
},
}
// Pool for color row buffers
// Uses pointer to slice to avoid allocation during type assertion (SA6002)
colorRowBufferPool = sync.Pool{
New: func() interface{} {
s := make([]color.RGBA, 0, 1024) // Row buffer capacity
return &s
},
}
)
// ShapeCommand represents a rendering command for deferred execution
type ShapeCommand struct {
Type string // "polygon", "circle", "background"
Points []engine.Point // For polygons
Center engine.Point // For circles
Size float64 // For circles
Invert bool // For circles
Color color.RGBA
BBox image.Rectangle // Pre-calculated bounding box for culling
}
// PNGRenderer implements memory-efficient PNG generation using streaming row processing
// This eliminates the dual buffer allocation problem, reducing memory usage by ~80%
type PNGRenderer struct {
*BaseRenderer
img *image.RGBA
currentColor color.RGBA
background color.RGBA
hasBackground bool
mu sync.RWMutex // For thread safety in concurrent generation
finalImg *image.RGBA // Single buffer at target resolution
finalSize int // Target output size
bgColor color.RGBA // Background color
shapes []ShapeCommand // Queued rendering commands
}
// 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
// NewPNGRenderer creates a new memory-optimized PNG renderer
func NewPNGRenderer(iconSize int) *PNGRenderer {
// Only allocate the final image buffer - no supersampled buffer
finalBounds := image.Rect(0, 0, iconSize, iconSize)
finalImg := image.NewRGBA(finalBounds)
return &PNGRenderer{
BaseRenderer: NewBaseRenderer(iconSize),
img: image.NewRGBA(image.Rect(0, 0, iconSize, iconSize)),
finalImg: finalImg,
finalSize: iconSize,
shapes: make([]ShapeCommand, 0, 16), // Pre-allocate for typical use
}
}
// SetBackground sets the background color and opacity
// SetBackground sets the background color - queues background command
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)
}
r.bgColor = r.parseColor(fillColor, opacity)
// Queue background command for proper rendering order
r.shapes = append(r.shapes, ShapeCommand{
Type: "background",
Color: r.bgColor,
BBox: image.Rect(0, 0, r.finalSize*2, r.finalSize*2), // Full supersampled bounds
})
}
// 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
// EndShape marks the end of the currently drawn shape (no-op for queuing renderer)
func (r *PNGRenderer) EndShape() {
// No action needed for PNG - shapes are drawn immediately
// No-op for command queuing approach
}
// AddPolygon adds a polygon with the current fill color to the image
// AddPolygon queues a polygon command with pre-calculated bounding box
func (r *PNGRenderer) AddPolygon(points []engine.Point) {
if len(points) == 0 {
return
if len(points) < 3 {
return // Can't render polygon with < 3 points
}
r.mu.Lock()
defer r.mu.Unlock()
// Determine winding order for hole detection
var area float64
for i := 0; i < len(points); i++ {
p1 := points[i]
p2 := points[(i+1)%len(points)]
area += (p1.X * p2.Y) - (p2.X * p1.Y)
}
// 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)),
var renderColor color.RGBA
if area < 0 {
// Counter-clockwise winding (hole) - use background color
renderColor = r.bgColor
} else {
// Clockwise winding (normal shape)
renderColor = r.parseColor(r.GetCurrentColor(), 1.0)
}
// Get pooled point slice and scale points to supersampled coordinates
scaledPointsPtr := pointSlicePool.Get().(*[]engine.Point)
scaledPointsSlice := *scaledPointsPtr
defer func() {
*scaledPointsPtr = scaledPointsSlice // Update with potentially resized slice
pointSlicePool.Put(scaledPointsPtr)
}()
// Reset slice and ensure capacity
scaledPointsSlice = scaledPointsSlice[:0]
if cap(scaledPointsSlice) < len(points) {
scaledPointsSlice = make([]engine.Point, 0, len(points)*2)
}
minX, minY := math.MaxFloat64, math.MaxFloat64
maxX, maxY := -math.MaxFloat64, -math.MaxFloat64
for _, p := range points {
scaledP := engine.Point{
X: p.X * defaultSupersamplingFactor,
Y: p.Y * defaultSupersamplingFactor,
}
scaledPointsSlice = append(scaledPointsSlice, scaledP)
if scaledP.X < minX {
minX = scaledP.X
}
if scaledP.X > maxX {
maxX = scaledP.X
}
if scaledP.Y < minY {
minY = scaledP.Y
}
if scaledP.Y > maxY {
maxY = scaledP.Y
}
}
// Fill polygon using scanline algorithm
r.fillPolygon(imagePoints)
// Copy scaled points for storage in command (must copy since we're returning slice to pool)
scaledPoints := make([]engine.Point, len(scaledPointsSlice))
copy(scaledPoints, scaledPointsSlice)
// Create bounding box for culling (with safety margins)
bbox := image.Rect(
int(math.Floor(minX))-1,
int(math.Floor(minY))-1,
int(math.Ceil(maxX))+1,
int(math.Ceil(maxY))+1,
)
// Queue the polygon command
r.shapes = append(r.shapes, ShapeCommand{
Type: "polygon",
Points: scaledPoints,
Color: renderColor,
BBox: bbox,
})
}
// AddCircle adds a circle with the current fill color to the image
// AddCircle queues a circle command with pre-calculated bounding box
func (r *PNGRenderer) AddCircle(topLeft engine.Point, size float64, invert bool) {
r.mu.Lock()
defer r.mu.Unlock()
// Scale to supersampled coordinates
scaledTopLeft := engine.Point{
X: topLeft.X * defaultSupersamplingFactor,
Y: topLeft.Y * defaultSupersamplingFactor,
}
scaledSize := size * defaultSupersamplingFactor
radius := size / 2
centerX := int(math.Round(topLeft.X + radius))
centerY := int(math.Round(topLeft.Y + radius))
radiusInt := int(math.Round(radius))
centerX := scaledTopLeft.X + scaledSize/2.0
centerY := scaledTopLeft.Y + scaledSize/2.0
radius := scaledSize / 2.0
// Use Bresenham's circle algorithm for anti-aliased circle drawing
r.drawCircle(centerX, centerY, radiusInt, invert)
var renderColor color.RGBA
if invert {
renderColor = r.bgColor
} else {
renderColor = r.parseColor(r.GetCurrentColor(), 1.0)
}
// Calculate bounding box for the circle
bbox := image.Rect(
int(math.Floor(centerX-radius))-1,
int(math.Floor(centerY-radius))-1,
int(math.Ceil(centerX+radius))+1,
int(math.Ceil(centerY+radius))+1,
)
// Queue the circle command
r.shapes = append(r.shapes, ShapeCommand{
Type: "circle",
Center: engine.Point{X: centerX, Y: centerY},
Size: radius,
Color: renderColor,
BBox: bbox,
})
}
// ToPNG generates the final PNG image data
func (r *PNGRenderer) ToPNG() []byte {
r.mu.RLock()
defer r.mu.RUnlock()
// ToPNG generates the final PNG image data using streaming row processing
func (r *PNGRenderer) ToPNG() ([]byte, error) {
return r.ToPNGWithSize(r.GetSize())
}
buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufferPool.Put(buf)
// ToPNGWithSize generates PNG image data with streaming row processing
func (r *PNGRenderer) ToPNGWithSize(outputSize int) ([]byte, error) {
// Execute streaming rendering pipeline
r.renderWithStreaming()
// Encode to PNG with compression
var resultImg image.Image = r.finalImg
// Scale if output size differs from internal size
if outputSize != r.finalSize {
resultImg = r.scaleImage(r.finalImg, outputSize)
}
// Encode to PNG with maximum compression
var buf bytes.Buffer
encoder := &png.Encoder{
CompressionLevel: png.BestCompression,
}
if err := encoder.Encode(buf, r.img); err != nil {
return nil
err := encoder.Encode(&buf, resultImg)
if err != nil {
return nil, fmt.Errorf("jdenticon: optimized renderer: PNG encoding failed: %w", err)
}
// Return a copy of the buffer data
result := make([]byte, buf.Len())
copy(result, buf.Bytes())
return result
return buf.Bytes(), nil
}
// 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, "#")
// renderWithStreaming executes the main streaming rendering pipeline
func (r *PNGRenderer) renderWithStreaming() {
supersampledWidth := r.finalSize * defaultSupersamplingFactor
// Default to black if parsing fails
var r, g, b uint8 = 0, 0, 0
// Get pooled row buffer for 2 supersampled rows - MASSIVE memory savings
rowBufferPtr := colorRowBufferPool.Get().(*[]color.RGBA)
rowBufferSlice := *rowBufferPtr
defer func() {
*rowBufferPtr = rowBufferSlice // Update with potentially resized slice
colorRowBufferPool.Put(rowBufferPtr)
}()
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
}
// Ensure buffer has correct size
requiredSize := supersampledWidth * 2
if cap(rowBufferSlice) < requiredSize {
rowBufferSlice = make([]color.RGBA, requiredSize)
} else {
rowBufferSlice = rowBufferSlice[:requiredSize]
}
alpha := uint8(math.Round(opacity * 255))
return color.RGBA{R: r, G: g, B: b, A: alpha}
// Process each final image row
for y := 0; y < r.finalSize; y++ {
// Clear row buffer to background color
for i := range rowBufferSlice {
rowBufferSlice[i] = r.bgColor
}
// Render all shapes for this row pair
r.renderShapesForRowPair(y, rowBufferSlice, supersampledWidth)
// Downsample directly into final image
r.downsampleRowPairToFinal(y, rowBufferSlice, supersampledWidth)
}
}
// fillPolygon fills a polygon using a scanline algorithm
func (r *PNGRenderer) fillPolygon(points []image.Point) {
if len(points) < 3 {
return
// renderShapesForRowPair renders all shapes that intersect the given row pair
func (r *PNGRenderer) renderShapesForRowPair(finalY int, rowBuffer []color.RGBA, supersampledWidth int) {
// Calculate supersampled Y range for this row pair
ssYStart := finalY * defaultSupersamplingFactor
ssYEnd := ssYStart + defaultSupersamplingFactor
// Render each shape that intersects this row pair
for _, shape := range r.shapes {
// Fast bounding box culling
if shape.BBox.Max.Y <= ssYStart || shape.BBox.Min.Y >= ssYEnd {
continue // Shape doesn't intersect this row pair
}
switch shape.Type {
case "polygon":
r.renderPolygonForRowPair(shape, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
case "circle":
r.renderCircleForRowPair(shape, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
}
}
}
// renderPolygonForRowPair renders a polygon for the specified row range
func (r *PNGRenderer) renderPolygonForRowPair(shape ShapeCommand, ssYStart, ssYEnd int, rowBuffer []color.RGBA, supersampledWidth int) {
points := shape.Points
color := shape.Color
// Use triangle fan decomposition for simplicity
if len(points) == 3 {
// Direct triangle rendering
r.fillTriangleForRowRange(points[0], points[1], points[2], color, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
} else if len(points) == 4 && r.isRectangle(points) {
// Optimized rectangle rendering
minX, minY, maxX, maxY := r.getBoundsFloat(points)
r.fillRectForRowRange(minX, minY, maxX, maxY, color, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
} else {
// General polygon - triangle fan from first vertex
for i := 1; i < len(points)-1; i++ {
r.fillTriangleForRowRange(points[0], points[i], points[i+1], color, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
}
}
}
// renderCircleForRowPair renders a circle for the specified row range
func (r *PNGRenderer) renderCircleForRowPair(shape ShapeCommand, ssYStart, ssYEnd int, rowBuffer []color.RGBA, supersampledWidth int) {
centerX := shape.Center.X
centerY := shape.Center.Y
radius := shape.Size
color := shape.Color
radiusSq := radius * radius
// Process each supersampled row in the range
for y := ssYStart; y < ssYEnd; y++ {
yFloat := float64(y)
dy := yFloat - centerY
dySq := dy * dy
if dySq > radiusSq {
continue // Row doesn't intersect circle
}
// Calculate horizontal span for this row
dx := math.Sqrt(radiusSq - dySq)
xStart := int(math.Floor(centerX - dx))
xEnd := int(math.Ceil(centerX + dx))
// Clip to buffer bounds
if xStart < 0 {
xStart = 0
}
if xEnd >= supersampledWidth {
xEnd = supersampledWidth - 1
}
// Fill the horizontal span
rowIndex := (y - ssYStart) * supersampledWidth
for x := xStart; x <= xEnd; x++ {
// Verify pixel is actually inside circle
dxPixel := float64(x) - centerX
if dxPixel*dxPixel+dySq <= radiusSq {
if rowIndex+x < len(rowBuffer) {
rowBuffer[rowIndex+x] = color
}
}
}
}
}
// fillTriangleForRowRange fills a triangle within the specified row range
func (r *PNGRenderer) fillTriangleForRowRange(p1, p2, p3 engine.Point, color color.RGBA, ssYStart, ssYEnd int, rowBuffer []color.RGBA, supersampledWidth int) {
// Get triangle bounds
minY := math.Min(math.Min(p1.Y, p2.Y), p3.Y)
maxY := math.Max(math.Max(p1.Y, p2.Y), p3.Y)
// Clip to row range
iterYStart := int(math.Max(math.Ceil(minY), float64(ssYStart)))
iterYEnd := int(math.Min(math.Floor(maxY), float64(ssYEnd-1)))
if iterYStart > iterYEnd {
return // Triangle doesn't intersect row range
}
// Find bounding box
// Sort points by Y coordinate
x1, y1 := p1.X, p1.Y
x2, y2 := p2.X, p2.Y
x3, y3 := p3.X, p3.Y
if y1 > y2 {
x1, y1, x2, y2 = x2, y2, x1, y1
}
if y1 > y3 {
x1, y1, x3, y3 = x3, y3, x1, y1
}
if y2 > y3 {
x2, y2, x3, y3 = x3, y3, x2, y2
}
// Fill triangle using scan-line algorithm
for y := iterYStart; y <= iterYEnd; y++ {
yFloat := float64(y)
var xLeft, xRight float64
if yFloat < y2 {
// Upper part of triangle
if y2 != y1 {
slope12 := (x2 - x1) / (y2 - y1)
xLeft = x1 + slope12*(yFloat-y1)
} else {
xLeft = x1
}
if y3 != y1 {
slope13 := (x3 - x1) / (y3 - y1)
xRight = x1 + slope13*(yFloat-y1)
} else {
xRight = x1
}
} else {
// Lower part of triangle
if y3 != y2 {
slope23 := (x3 - x2) / (y3 - y2)
xLeft = x2 + slope23*(yFloat-y2)
} else {
xLeft = x2
}
if y3 != y1 {
slope13 := (x3 - x1) / (y3 - y1)
xRight = x1 + slope13*(yFloat-y1)
} else {
xRight = x1
}
}
if xLeft > xRight {
xLeft, xRight = xRight, xLeft
}
// Convert to pixel coordinates and fill
xLeftInt := int(math.Floor(xLeft))
xRightInt := int(math.Floor(xRight))
// Clip to buffer bounds
if xLeftInt < 0 {
xLeftInt = 0
}
if xRightInt >= supersampledWidth {
xRightInt = supersampledWidth - 1
}
// Fill horizontal span in row buffer
rowIndex := (y - ssYStart) * supersampledWidth
for x := xLeftInt; x <= xRightInt; x++ {
if rowIndex+x < len(rowBuffer) {
rowBuffer[rowIndex+x] = color
}
}
}
}
// fillRectForRowRange fills a rectangle within the specified row range
func (r *PNGRenderer) fillRectForRowRange(x1, y1, x2, y2 float64, color color.RGBA, ssYStart, ssYEnd int, rowBuffer []color.RGBA, supersampledWidth int) {
// Convert to integer bounds
xStart := int(math.Floor(x1))
yStart := int(math.Floor(y1))
xEnd := int(math.Ceil(x2))
yEnd := int(math.Ceil(y2))
// Clip to row range
if yStart < ssYStart {
yStart = ssYStart
}
if yEnd > ssYEnd {
yEnd = ssYEnd
}
if xStart < 0 {
xStart = 0
}
if xEnd > supersampledWidth {
xEnd = supersampledWidth
}
// Fill rectangle in row buffer
for y := yStart; y < yEnd; y++ {
rowIndex := (y - ssYStart) * supersampledWidth
for x := xStart; x < xEnd; x++ {
if rowIndex+x < len(rowBuffer) {
rowBuffer[rowIndex+x] = color
}
}
}
}
// downsampleRowPairToFinal downsamples 2 supersampled rows into 1 final row using box filter
func (r *PNGRenderer) downsampleRowPairToFinal(finalY int, rowBuffer []color.RGBA, supersampledWidth int) {
for x := 0; x < r.finalSize; x++ {
// Sample 2x2 block from row buffer
x0 := x * defaultSupersamplingFactor
x1 := x0 + 1
// Row 0 (first supersampled row)
idx00 := x0
idx01 := x1
// Row 1 (second supersampled row)
idx10 := supersampledWidth + x0
idx11 := supersampledWidth + x1
// Sum RGBA values from 2x2 block
var rSum, gSum, bSum, aSum uint32
if idx00 < len(rowBuffer) {
c := rowBuffer[idx00]
rSum += uint32(c.R)
gSum += uint32(c.G)
bSum += uint32(c.B)
aSum += uint32(c.A)
}
if idx01 < len(rowBuffer) {
c := rowBuffer[idx01]
rSum += uint32(c.R)
gSum += uint32(c.G)
bSum += uint32(c.B)
aSum += uint32(c.A)
}
if idx10 < len(rowBuffer) {
c := rowBuffer[idx10]
rSum += uint32(c.R)
gSum += uint32(c.G)
bSum += uint32(c.B)
aSum += uint32(c.A)
}
if idx11 < len(rowBuffer) {
c := rowBuffer[idx11]
rSum += uint32(c.R)
gSum += uint32(c.G)
bSum += uint32(c.B)
aSum += uint32(c.A)
}
// Average by dividing by 4
// #nosec G115 -- Safe: sum of 4 uint8 values (max 255*4=1020) divided by 4 always fits in uint8
avgColor := color.RGBA{
R: uint8(rSum / 4),
G: uint8(gSum / 4),
B: uint8(bSum / 4),
A: uint8(aSum / 4),
}
// Set pixel in final image
r.finalImg.Set(x, finalY, avgColor)
}
}
// Helper functions (reused from original implementation)
func (r *PNGRenderer) parseColor(colorStr string, opacity float64) color.RGBA {
if colorStr != "" && colorStr[0] != '#' {
colorStr = "#" + colorStr
}
rgba, err := engine.ParseHexColorForRenderer(colorStr, opacity)
if err != nil {
return color.RGBA{0, 0, 0, uint8(opacity * 255)}
}
return rgba
}
func (r *PNGRenderer) isRectangle(points []engine.Point) bool {
if len(points) != 4 {
return false
}
uniqueX := make(map[float64]struct{})
uniqueY := make(map[float64]struct{})
for _, p := range points {
uniqueX[p.X] = struct{}{}
uniqueY[p.Y] = struct{}{}
}
return len(uniqueX) == 2 && len(uniqueY) == 2
}
func (r *PNGRenderer) getBoundsFloat(points []engine.Point) (float64, float64, float64, float64) {
if len(points) == 0 {
return 0, 0, 0, 0
}
minX, maxX := points[0].X, points[0].X
minY, maxY := points[0].Y, points[0].Y
for _, p := range points[1:] {
if p.X < minX {
minX = p.X
}
if p.X > maxX {
maxX = p.X
}
if p.Y < minY {
minY = p.Y
}
@@ -183,110 +609,25 @@ func (r *PNGRenderer) fillPolygon(points []image.Point) {
}
}
// 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)
}
}
}
}
return minX, minY, maxX, maxY
}
// 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)
func (r *PNGRenderer) scaleImage(src *image.RGBA, newSize int) image.Image {
oldSize := r.finalSize
if oldSize == newSize {
return src
}
for i := 0; i < n; i++ {
j := (i + 1) % n
p1, p2 := points[i], points[j]
scaled := image.NewRGBA(image.Rect(0, 0, newSize, newSize))
ratio := float64(oldSize) / float64(newSize)
// 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)
for y := 0; y < newSize; y++ {
for x := 0; x < newSize; x++ {
srcX := int(float64(x) * ratio)
srcY := int(float64(y) * ratio)
scaled.Set(x, y, src.At(srcX, srcY))
}
}
// 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)
}
}
}
return scaled
}

View File

@@ -2,24 +2,21 @@ package renderer
import (
"bytes"
"image/color"
"image/png"
"testing"
"github.com/kevin/go-jdenticon/internal/engine"
"github.com/ungluedlabs/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.GetSize() != 100 {
t.Errorf("NewPNGRenderer(100).GetSize() = %v, want 100", renderer.GetSize())
}
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())
if renderer == nil {
t.Error("PNGRenderer should be initialized")
}
}
@@ -28,23 +25,13 @@ func TestPNGRenderer_SetBackground(t *testing.T) {
renderer.SetBackground("#ff0000", 1.0)
if !renderer.hasBackground {
t.Error("hasBackground should be true")
// Check that background was set on base renderer
bg, op := renderer.GetBackground()
if bg != "#ff0000" {
t.Errorf("background color = %v, want #ff0000", bg)
}
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)
if op != 1.0 {
t.Errorf("background opacity = %v, want 1.0", op)
}
}
@@ -53,9 +40,12 @@ func TestPNGRenderer_SetBackgroundWithOpacity(t *testing.T) {
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)
bg, op := renderer.GetBackground()
if bg != "#00ff00" {
t.Errorf("background color = %v, want #00ff00", bg)
}
if op != 0.5 {
t.Errorf("background opacity = %v, want 0.5", op)
}
}
@@ -63,9 +53,10 @@ 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)
// Check that current color was set
if renderer.GetCurrentColor() != "#0000ff" {
t.Errorf("currentColor = %v, want #0000ff", renderer.GetCurrentColor())
}
renderer.EndShape()
@@ -83,20 +74,8 @@ func TestPNGRenderer_AddPolygon(t *testing.T) {
{X: 20, Y: 30},
}
// Should not panic
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) {
@@ -119,20 +98,8 @@ func TestPNGRenderer_AddCircle(t *testing.T) {
topLeft := engine.Point{X: 30, Y: 30}
size := 40.0
// Should not panic
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) {
@@ -142,18 +109,11 @@ func TestPNGRenderer_AddCircleInvert(t *testing.T) {
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
// Add inverted circle (should not panic)
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) {
@@ -169,7 +129,10 @@ func TestPNGRenderer_ToPNG(t *testing.T) {
}
renderer.AddPolygon(points)
pngData := renderer.ToPNG()
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
if len(pngData) == 0 {
t.Error("ToPNG() should return non-empty data")
@@ -189,10 +152,50 @@ func TestPNGRenderer_ToPNG(t *testing.T) {
}
}
func TestPNGRenderer_ToPNGWithSize(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)
// Test generating at different size
pngData, err := renderer.ToPNGWithSize(100)
if err != nil {
t.Fatalf("Failed to generate PNG with size: %v", err)
}
if len(pngData) == 0 {
t.Error("ToPNGWithSize() 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("ToPNGWithSize() returned invalid PNG data: %v", err)
}
// Check dimensions - should be 100x100 instead of 50x50
bounds := decodedImg.Bounds()
if bounds.Max.X != 100 || bounds.Max.Y != 100 {
t.Errorf("decoded image bounds = %v, want 100x100", bounds)
}
}
func TestPNGRenderer_ToPNGEmpty(t *testing.T) {
renderer := NewPNGRenderer(10)
pngData := renderer.ToPNG()
pngData, err := renderer.ToPNG()
if err != nil {
t.Fatalf("Failed to generate PNG: %v", err)
}
if len(pngData) == 0 {
t.Error("ToPNG() should return data even for empty image")
@@ -200,70 +203,15 @@ func TestPNGRenderer_ToPNGEmpty(t *testing.T) {
// Should be valid PNG
reader := bytes.NewReader(pngData)
_, err := png.Decode(reader)
decodedImg, 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")
// Check dimensions
bounds := decodedImg.Bounds()
if bounds.Max.X != 10 || bounds.Max.Y != 10 {
t.Errorf("decoded image bounds = %v, want 10x10", bounds)
}
}
@@ -282,7 +230,10 @@ func BenchmarkPNGRenderer_ToPNG(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
pngData := renderer.ToPNG()
pngData, err := renderer.ToPNG()
if err != nil {
b.Fatalf("Failed to generate PNG: %v", err)
}
if len(pngData) == 0 {
b.Fatal("ToPNG returned empty data")
}

View File

@@ -1,7 +1,7 @@
package renderer
import (
"github.com/kevin/go-jdenticon/internal/engine"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// Renderer defines the interface for rendering identicons to various output formats.
@@ -13,24 +13,24 @@ type Renderer interface {
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()
@@ -43,7 +43,7 @@ type BaseRenderer struct {
currentColor string
background string
backgroundOp float64
// Current path state for primitive operations
currentPath []PathCommand
pathStart engine.Point
@@ -150,18 +150,18 @@ 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)
}
@@ -171,22 +171,22 @@ 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)
}
@@ -234,4 +234,4 @@ func (r *BaseRenderer) GetCurrentColor() string {
// GetBackground returns the background color and opacity
func (r *BaseRenderer) GetBackground() (string, float64) {
return r.background, r.backgroundOp
}
}

View File

@@ -0,0 +1,464 @@
package renderer
import (
"testing"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
var benchmarkSizes = []int{
16, 32, 64, 128, 256, 512,
}
var benchmarkColors = []string{
"#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff",
"#800000", "#008000", "#000080", "#808000", "#800080", "#008080",
"#c0c0c0", "#808080", "#000000", "#ffffff",
}
var benchmarkPoints = [][]engine.Point{
// Triangle
{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 0.5, Y: 1}},
// Square
{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}, {X: 0, Y: 1}},
// Pentagon
{{X: 0.5, Y: 0}, {X: 1, Y: 0.4}, {X: 0.8, Y: 1}, {X: 0.2, Y: 1}, {X: 0, Y: 0.4}},
// Hexagon
{{X: 0.25, Y: 0}, {X: 0.75, Y: 0}, {X: 1, Y: 0.5}, {X: 0.75, Y: 1}, {X: 0.25, Y: 1}, {X: 0, Y: 0.5}},
}
// Benchmark SVG renderer creation
func BenchmarkNewSVGRenderer(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
size := benchmarkSizes[i%len(benchmarkSizes)]
_ = NewSVGRenderer(size)
}
}
// Benchmark SVG shape rendering
func BenchmarkSVGAddPolygon(b *testing.B) {
renderer := NewSVGRenderer(256)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := benchmarkColors[i%len(benchmarkColors)]
points := benchmarkPoints[i%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
}
// Benchmark SVG circle rendering
func BenchmarkSVGAddCircle(b *testing.B) {
renderer := NewSVGRenderer(256)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := benchmarkColors[i%len(benchmarkColors)]
topLeft := engine.Point{X: 0.25, Y: 0.25}
size := 0.5
renderer.BeginShape(color)
renderer.AddCircle(topLeft, size, false)
renderer.EndShape()
}
}
// Benchmark SVG background setting
func BenchmarkSVGSetBackground(b *testing.B) {
renderer := NewSVGRenderer(256)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := benchmarkColors[i%len(benchmarkColors)]
opacity := 0.8
renderer.SetBackground(color, opacity)
}
}
// Benchmark complete SVG generation
func BenchmarkSVGToSVG(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
size := benchmarkSizes[i%len(benchmarkSizes)]
renderer := NewSVGRenderer(size)
// Add some shapes
renderer.SetBackground("#f0f0f0", 1.0)
for j := 0; j < 3; j++ {
color := benchmarkColors[j%len(benchmarkColors)]
points := benchmarkPoints[j%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
_ = renderer.ToSVG()
}
}
// Benchmark PNG renderer creation
func BenchmarkNewPNGRenderer(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
size := benchmarkSizes[i%len(benchmarkSizes)]
_ = NewPNGRenderer(size)
}
}
// Benchmark PNG shape rendering
func BenchmarkPNGAddPolygon(b *testing.B) {
renderer := NewPNGRenderer(256)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := benchmarkColors[i%len(benchmarkColors)]
points := benchmarkPoints[i%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
}
// Benchmark PNG circle rendering
func BenchmarkPNGAddCircle(b *testing.B) {
renderer := NewPNGRenderer(256)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := benchmarkColors[i%len(benchmarkColors)]
topLeft := engine.Point{X: 0.25, Y: 0.25}
size := 0.5
renderer.BeginShape(color)
renderer.AddCircle(topLeft, size, false)
renderer.EndShape()
}
}
// Benchmark PNG background setting
func BenchmarkPNGSetBackground(b *testing.B) {
renderer := NewPNGRenderer(256)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
color := benchmarkColors[i%len(benchmarkColors)]
opacity := 0.8
renderer.SetBackground(color, opacity)
}
}
// Benchmark complete PNG generation
func BenchmarkPNGToPNG(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
size := benchmarkSizes[i%len(benchmarkSizes)]
renderer := NewPNGRenderer(size)
// Add some shapes
renderer.SetBackground("#f0f0f0", 1.0)
for j := 0; j < 3; j++ {
color := benchmarkColors[j%len(benchmarkColors)]
points := benchmarkPoints[j%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
}
// Benchmark PNG generation with different output sizes
func BenchmarkPNGToPNGWithSize(b *testing.B) {
renderer := NewPNGRenderer(128)
// Add some test shapes
renderer.SetBackground("#ffffff", 1.0)
renderer.BeginShape("#ff0000")
renderer.AddPolygon(benchmarkPoints[0])
renderer.EndShape()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
outputSize := benchmarkSizes[i%len(benchmarkSizes)]
_, err := renderer.ToPNGWithSize(outputSize)
if err != nil {
b.Fatalf("ToPNGWithSize failed: %v", err)
}
}
}
// Benchmark complex shape rendering (many polygons)
func BenchmarkComplexSVGRendering(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewSVGRenderer(256)
renderer.SetBackground("#f8f8f8", 1.0)
// Render many shapes to simulate complex icon
for j := 0; j < 12; j++ {
color := benchmarkColors[j%len(benchmarkColors)]
points := benchmarkPoints[j%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
_ = renderer.ToSVG()
}
}
// Benchmark complex shape rendering (many polygons) for PNG
func BenchmarkComplexPNGRendering(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(256)
renderer.SetBackground("#f8f8f8", 1.0)
// Render many shapes to simulate complex icon
for j := 0; j < 12; j++ {
color := benchmarkColors[j%len(benchmarkColors)]
points := benchmarkPoints[j%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
}
// Benchmark SVG vs PNG rendering comparison
func BenchmarkSVGvsPNG64px(b *testing.B) {
// Shared test data
testShapes := []struct {
color string
points []engine.Point
}{
{"#ff0000", benchmarkPoints[0]},
{"#00ff00", benchmarkPoints[1]},
{"#0000ff", benchmarkPoints[2]},
}
b.Run("SVG", func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewSVGRenderer(64)
renderer.SetBackground("#ffffff", 1.0)
for _, shape := range testShapes {
renderer.BeginShape(shape.color)
renderer.AddPolygon(shape.points)
renderer.EndShape()
}
_ = renderer.ToSVG()
}
})
b.Run("PNG", func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(64)
renderer.SetBackground("#ffffff", 1.0)
for _, shape := range testShapes {
renderer.BeginShape(shape.color)
renderer.AddPolygon(shape.points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Fatalf("ToPNG failed: %v", err)
}
}
})
}
// Benchmark memory allocation patterns
func BenchmarkRendererMemoryPatterns(b *testing.B) {
b.Run("SVGMemory", func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewSVGRenderer(128)
// Allocate many small shapes to test memory patterns
for j := 0; j < 20; j++ {
renderer.BeginShape("#808080")
renderer.AddPolygon(benchmarkPoints[j%len(benchmarkPoints)])
renderer.EndShape()
}
}
})
b.Run("PNGMemory", func(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderer := NewPNGRenderer(128)
// Allocate many small shapes to test memory patterns
for j := 0; j < 20; j++ {
renderer.BeginShape("#808080")
renderer.AddPolygon(benchmarkPoints[j%len(benchmarkPoints)])
renderer.EndShape()
}
}
})
}
// Benchmark concurrent rendering scenarios
func BenchmarkRendererParallel(b *testing.B) {
b.Run("SVGParallel", func(b *testing.B) {
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
size := benchmarkSizes[i%len(benchmarkSizes)]
renderer := NewSVGRenderer(size)
renderer.SetBackground("#ffffff", 1.0)
for j := 0; j < 3; j++ {
color := benchmarkColors[j%len(benchmarkColors)]
points := benchmarkPoints[j%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
_ = renderer.ToSVG()
i++
}
})
})
b.Run("PNGParallel", func(b *testing.B) {
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
size := benchmarkSizes[i%len(benchmarkSizes)]
renderer := NewPNGRenderer(size)
renderer.SetBackground("#ffffff", 1.0)
for j := 0; j < 3; j++ {
color := benchmarkColors[j%len(benchmarkColors)]
points := benchmarkPoints[j%len(benchmarkPoints)]
renderer.BeginShape(color)
renderer.AddPolygon(points)
renderer.EndShape()
}
_, err := renderer.ToPNG()
if err != nil {
b.Errorf("ToPNG failed: %v", err)
}
i++
}
})
})
}
// Benchmark shape rendering with different complexities
func BenchmarkShapeComplexity(b *testing.B) {
renderer := NewSVGRenderer(256)
b.Run("Triangle", func(b *testing.B) {
b.ReportAllocs()
trianglePoints := benchmarkPoints[0] // Triangle
for i := 0; i < b.N; i++ {
renderer.BeginShape("#ff0000")
renderer.AddPolygon(trianglePoints)
renderer.EndShape()
}
})
b.Run("Square", func(b *testing.B) {
b.ReportAllocs()
squarePoints := benchmarkPoints[1] // Square
for i := 0; i < b.N; i++ {
renderer.BeginShape("#00ff00")
renderer.AddPolygon(squarePoints)
renderer.EndShape()
}
})
b.Run("Pentagon", func(b *testing.B) {
b.ReportAllocs()
pentagonPoints := benchmarkPoints[2] // Pentagon
for i := 0; i < b.N; i++ {
renderer.BeginShape("#0000ff")
renderer.AddPolygon(pentagonPoints)
renderer.EndShape()
}
})
b.Run("Hexagon", func(b *testing.B) {
b.ReportAllocs()
hexagonPoints := benchmarkPoints[3] // Hexagon
for i := 0; i < b.N; i++ {
renderer.BeginShape("#ffff00")
renderer.AddPolygon(hexagonPoints)
renderer.EndShape()
}
})
}

View File

@@ -3,7 +3,7 @@ package renderer
import (
"testing"
"github.com/kevin/go-jdenticon/internal/engine"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
func TestNewBaseRenderer(t *testing.T) {
@@ -30,12 +30,12 @@ func TestNewBaseRenderer(t *testing.T) {
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)
@@ -47,14 +47,14 @@ func TestBaseRendererSetBackground(t *testing.T) {
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()))
@@ -63,24 +63,24 @@ func TestBaseRendererBeginShape(t *testing.T) {
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)
@@ -89,27 +89,27 @@ func TestBaseRendererMoveTo(t *testing.T) {
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)
@@ -118,30 +118,30 @@ func TestBaseRendererLineTo(t *testing.T) {
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)
@@ -156,22 +156,22 @@ func TestBaseRendererCurveTo(t *testing.T) {
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))
}
@@ -180,29 +180,29 @@ func TestBaseRendererClosePath(t *testing.T) {
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)
@@ -212,30 +212,30 @@ func TestBaseRendererAddPolygon(t *testing.T) {
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
{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 {
@@ -250,20 +250,20 @@ func TestBaseRendererAddRectangle(t *testing.T) {
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])
@@ -279,24 +279,24 @@ func TestBaseRendererAddTriangle(t *testing.T) {
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++ {
@@ -307,7 +307,7 @@ func TestBaseRendererAddCircle(t *testing.T) {
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)
@@ -316,13 +316,13 @@ func TestBaseRendererAddCircle(t *testing.T) {
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")
@@ -330,10 +330,10 @@ func TestBaseRendererClear(t *testing.T) {
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())
@@ -341,7 +341,7 @@ func TestBaseRendererClear(t *testing.T) {
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)
@@ -351,12 +351,12 @@ func TestBaseRendererClear(t *testing.T) {
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))
}
}
}

View File

@@ -1,14 +1,28 @@
package renderer
import (
"fmt"
"math"
"strconv"
"strings"
"github.com/kevin/go-jdenticon/internal/engine"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// SVG rendering constants
const (
// SVG generation size estimation constants
svgBaseOverheadBytes = 150 // Base SVG document overhead
svgBackgroundRectBytes = 100 // Background rectangle overhead
svgPathOverheadBytes = 50 // Per-path element overhead
// Precision constants
svgCoordinatePrecision = 10 // Precision factor for SVG coordinates (0.1 precision)
svgRoundingOffset = 0.5 // Rounding offset for "round half up" behavior
)
// Note: Previously used polygonBufferPool for intermediate buffering, but eliminated
// to write directly to main builder and avoid unnecessary allocations
// SVGPath represents an SVG path element
type SVGPath struct {
data strings.Builder
@@ -20,12 +34,19 @@ func (p *SVGPath) AddPolygon(points []engine.Point) {
return
}
// Write directly to main data builder to avoid intermediate allocations
// Move to first point
p.data.WriteString(fmt.Sprintf("M%s %s", svgValue(points[0].X), svgValue(points[0].Y)))
p.data.WriteString("M")
svgAppendValue(&p.data, points[0].X)
p.data.WriteString(" ")
svgAppendValue(&p.data, 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)))
p.data.WriteString("L")
svgAppendValue(&p.data, points[i].X)
p.data.WriteString(" ")
svgAppendValue(&p.data, points[i].Y)
}
// Close path
@@ -42,18 +63,38 @@ func (p *SVGPath) AddCircle(topLeft engine.Point, size float64, counterClockwise
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")
// Build circle path directly in main data builder
p.data.WriteString("M")
svgAppendValue(&p.data, startX)
p.data.WriteString(" ")
svgAppendValue(&p.data, startY)
// Draw first arc
p.data.WriteString("a")
svgAppendValue(&p.data, radius)
p.data.WriteString(",")
svgAppendValue(&p.data, radius)
p.data.WriteString(" 0 1,")
p.data.WriteString(sweepFlag)
p.data.WriteString(" ")
svgAppendValue(&p.data, size)
p.data.WriteString(",0")
// Draw second arc
p.data.WriteString("a")
svgAppendValue(&p.data, radius)
p.data.WriteString(",")
svgAppendValue(&p.data, radius)
p.data.WriteString(" 0 1,")
p.data.WriteString(sweepFlag)
p.data.WriteString(" -")
svgAppendValue(&p.data, size)
p.data.WriteString(",0")
}
// DataString returns the SVG path data string
@@ -84,6 +125,14 @@ func (r *SVGRenderer) SetBackground(fillColor string, opacity float64) {
// BeginShape marks the beginning of a new shape with the specified color
func (r *SVGRenderer) BeginShape(color string) {
// Defense-in-depth validation: ensure color is safe for SVG output
// Invalid colors are silently ignored to maintain interface compatibility
if err := engine.ValidateHexColor(color); err != nil {
// Log validation failure but continue - the shape will not be rendered
// This prevents breaking the interface while maintaining security
return
}
r.BaseRenderer.BeginShape(color)
if _, exists := r.pathsByColor[color]; !exists {
r.pathsByColor[color] = &SVGPath{}
@@ -121,22 +170,49 @@ func (r *SVGRenderer) AddCircle(topLeft engine.Point, size float64, invert bool)
// ToSVG generates the final SVG XML string
func (r *SVGRenderer) ToSVG() string {
var svg strings.Builder
iconSize := r.GetSize()
background, backgroundOp := r.GetBackground()
// Estimate capacity to reduce allocations
capacity := svgBaseOverheadBytes
if background != "" && backgroundOp > 0 {
capacity += svgBackgroundRectBytes
}
// Estimate path data size
for _, color := range r.colorOrder {
path := r.pathsByColor[color]
if path != nil {
capacity += svgPathOverheadBytes + path.data.Len()
}
}
var svg strings.Builder
svg.Grow(capacity)
// 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))
iconSizeStr := strconv.Itoa(iconSize)
svg.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="`)
svg.WriteString(iconSizeStr)
svg.WriteString(`" height="`)
svg.WriteString(iconSizeStr)
svg.WriteString(`" viewBox="0 0 `)
svg.WriteString(iconSizeStr)
svg.WriteString(` `)
svg.WriteString(iconSizeStr)
svg.WriteString(`">`)
// 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))
// Validate background color for safe SVG output
if err := engine.ValidateHexColor(background); err != nil {
// Skip invalid background colors to prevent injection
} else {
svg.WriteString(fmt.Sprintf(`<rect width="100%%" height="100%%" fill="%s" opacity="%.2f"/>`,
background, backgroundOp))
svg.WriteString(`<rect width="100%" height="100%" fill="`)
svg.WriteString(background) // Now validated
svg.WriteString(`" opacity="`)
svg.WriteString(strconv.FormatFloat(backgroundOp, 'f', 2, 64))
svg.WriteString(`"/>`)
}
}
@@ -145,7 +221,16 @@ func (r *SVGRenderer) ToSVG() string {
path := r.pathsByColor[color]
dataString := path.DataString()
if dataString != "" {
svg.WriteString(fmt.Sprintf(`<path fill="%s" d="%s"/>`, color, dataString))
// Final defense-in-depth validation before writing to SVG
if err := engine.ValidateHexColor(color); err != nil {
// Skip invalid colors to prevent injection attacks
continue
}
svg.WriteString(`<path fill="`)
svg.WriteString(color) // Now validated - safe injection point
svg.WriteString(`" d="`)
svg.WriteString(dataString)
svg.WriteString(`"/>`)
}
}
@@ -160,13 +245,33 @@ func (r *SVGRenderer) ToSVG() string {
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
rounded := math.Floor(value*svgCoordinatePrecision+svgRoundingOffset) / svgCoordinatePrecision
// 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)
}
// svgAppendValue appends a formatted float64 directly to a strings.Builder to avoid string allocations
func svgAppendValue(buf *strings.Builder, value float64) {
// 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*svgCoordinatePrecision+svgRoundingOffset) / svgCoordinatePrecision
// Use stack-allocated buffer for AppendFloat to avoid heap allocations
var tempBuf [32]byte
// Format to an integer string if there's no fractional part.
if rounded == math.Trunc(rounded) {
result := strconv.AppendInt(tempBuf[:0], int64(rounded), 10)
buf.Write(result)
} else {
// Otherwise, format to one decimal place using AppendFloat
result := strconv.AppendFloat(tempBuf[:0], rounded, 'f', 1, 64)
buf.Write(result)
}
}

View File

@@ -0,0 +1,284 @@
package renderer
import (
"strings"
"testing"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
// TestSVGRenderer_SecurityValidation tests defense-in-depth color validation
// This test addresses SEC-06 from the security report by verifying that
// the SVG renderer properly validates color inputs and prevents injection attacks.
func TestSVGRenderer_SecurityValidation(t *testing.T) {
tests := []struct {
name string
color string
expectInSVG bool
description string
}{
{
name: "valid_hex_color_3_digit",
color: "#f00",
expectInSVG: true,
description: "Valid 3-digit hex color should be rendered",
},
{
name: "valid_hex_color_6_digit",
color: "#ff0000",
expectInSVG: true,
description: "Valid 6-digit hex color should be rendered",
},
{
name: "valid_hex_color_8_digit",
color: "#ff0000ff",
expectInSVG: true,
description: "Valid 8-digit hex color with alpha should be rendered",
},
{
name: "injection_attempt_script",
color: "\"><script>alert('xss')</script><path fill=\"#000",
expectInSVG: false,
description: "Script injection attempt should be blocked",
},
{
name: "injection_attempt_svg_element",
color: "#f00\"/><use href=\"#malicious\"/><path fill=\"#000",
expectInSVG: false,
description: "SVG element injection attempt should be blocked",
},
{
name: "malformed_hex_no_hash",
color: "ff0000",
expectInSVG: false,
description: "Hex color without # should be rejected",
},
{
name: "valid_hex_color_4_digit_rgba",
color: "#ff00",
expectInSVG: true,
description: "Valid 4-digit RGBA hex color should be rendered",
},
{
name: "malformed_hex_invalid_length_5",
color: "#ff000",
expectInSVG: false,
description: "Invalid 5-character hex color should be rejected",
},
{
name: "malformed_hex_invalid_chars",
color: "#gggggg",
expectInSVG: false,
description: "Invalid hex characters should be rejected",
},
{
name: "empty_color",
color: "",
expectInSVG: false,
description: "Empty color string should be rejected",
},
{
name: "xml_entity_injection",
color: "#ff0000&lt;script&gt;",
expectInSVG: false,
description: "XML entity injection attempt should be blocked",
},
{
name: "path_data_injection",
color: "#f00\" d=\"M0 0L100 100Z\"/><script>alert('xss')</script><path fill=\"#000",
expectInSVG: false,
description: "Path data injection attempt should be blocked",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
renderer := NewSVGRenderer(100)
// Test BeginShape validation
renderer.BeginShape(tt.color)
// Add some path data to ensure the color would be rendered if valid
points := []engine.Point{
{X: 10, Y: 10},
{X: 50, Y: 10},
{X: 50, Y: 50},
{X: 10, Y: 50},
}
renderer.AddPolygon(points)
renderer.EndShape()
// Generate SVG output
svgOutput := renderer.ToSVG()
if tt.expectInSVG {
// Verify valid colors are present in the output
if !strings.Contains(svgOutput, `fill="`+tt.color+`"`) {
t.Errorf("Expected valid color %s to be present in SVG output, but it was not found.\nSVG: %s", tt.color, svgOutput)
}
// Ensure the path element is present for valid colors
if !strings.Contains(svgOutput, "<path") {
t.Errorf("Expected path element to be present for valid color %s, but it was not found", tt.color)
}
} else {
// Verify invalid/malicious colors are NOT present in the output
// Special handling for empty string since it's always "contained" in any string
if tt.color != "" && strings.Contains(svgOutput, tt.color) {
t.Errorf("Expected invalid/malicious color %s to be rejected, but it was found in SVG output.\nSVG: %s", tt.color, svgOutput)
}
// For invalid colors, no path should be rendered with that color
if strings.Contains(svgOutput, `fill="`+tt.color+`"`) {
t.Errorf("Expected invalid color %s to be rejected from fill attribute, but it was found", tt.color)
}
}
// Verify the SVG is still well-formed XML
if !strings.HasPrefix(svgOutput, "<svg") {
t.Errorf("SVG output should start with <svg tag")
}
if !strings.HasSuffix(svgOutput, "</svg>") {
t.Errorf("SVG output should end with </svg> tag")
}
})
}
}
// TestSVGRenderer_BackgroundColorValidation tests background color validation
func TestSVGRenderer_BackgroundColorValidation(t *testing.T) {
tests := []struct {
name string
bgColor string
opacity float64
expectInSVG bool
description string
}{
{
name: "valid_background_color",
bgColor: "#ffffff",
opacity: 1.0,
expectInSVG: true,
description: "Valid background color should be rendered",
},
{
name: "invalid_background_injection",
bgColor: "#fff\"/><script>alert('bg')</script><rect fill=\"#000",
opacity: 1.0,
expectInSVG: false,
description: "Background color injection should be blocked",
},
{
name: "malformed_background_color",
bgColor: "not-a-color",
opacity: 1.0,
expectInSVG: false,
description: "Invalid background color format should be rejected",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
renderer := NewSVGRenderer(100)
renderer.SetBackground(tt.bgColor, tt.opacity)
svgOutput := renderer.ToSVG()
if tt.expectInSVG {
// Verify valid background colors are present
if !strings.Contains(svgOutput, `<rect`) {
t.Errorf("Expected background rectangle to be present for valid color %s", tt.bgColor)
}
if !strings.Contains(svgOutput, `fill="`+tt.bgColor+`"`) {
t.Errorf("Expected valid background color %s to be present in SVG", tt.bgColor)
}
} else {
// Verify invalid background colors are rejected
if strings.Contains(svgOutput, tt.bgColor) {
t.Errorf("Expected invalid background color %s to be rejected, but found in: %s", tt.bgColor, svgOutput)
}
}
})
}
}
// TestSVGRenderer_MultipleInvalidColors tests behavior with multiple invalid colors
func TestSVGRenderer_MultipleInvalidColors(t *testing.T) {
renderer := NewSVGRenderer(100)
maliciousColors := []string{
"\"><script>alert(1)</script><path fill=\"#000",
"#invalid-color",
"javascript:alert('xss')",
"#ff0000\"/><use href=\"#malicious\"/>",
}
// Try to add shapes with all malicious colors
for _, color := range maliciousColors {
renderer.BeginShape(color)
points := []engine.Point{{X: 0, Y: 0}, {X: 50, Y: 50}}
renderer.AddPolygon(points)
renderer.EndShape()
}
svgOutput := renderer.ToSVG()
// Verify none of the malicious colors appear in the output
for _, color := range maliciousColors {
if strings.Contains(svgOutput, color) {
t.Errorf("Malicious color %s should not appear in SVG output, but was found: %s", color, svgOutput)
}
}
// Verify the SVG is still valid and doesn't contain path elements for rejected colors
pathCount := strings.Count(svgOutput, "<path")
if pathCount > 0 {
t.Errorf("Expected no path elements for invalid colors, but found %d", pathCount)
}
// Ensure SVG structure is intact
if !strings.Contains(svgOutput, `<svg xmlns="http://www.w3.org/2000/svg"`) {
t.Errorf("SVG should still have proper structure even with all invalid colors")
}
}
// TestSVGRenderer_ValidAndInvalidColorMix tests mixed valid/invalid colors
func TestSVGRenderer_ValidAndInvalidColorMix(t *testing.T) {
renderer := NewSVGRenderer(100)
// Add valid color
renderer.BeginShape("#ff0000")
renderer.AddPolygon([]engine.Point{{X: 0, Y: 0}, {X: 25, Y: 25}})
renderer.EndShape()
// Add invalid color
renderer.BeginShape("\"><script>alert('xss')</script><path fill=\"#000")
renderer.AddPolygon([]engine.Point{{X: 25, Y: 25}, {X: 50, Y: 50}})
renderer.EndShape()
// Add another valid color
renderer.BeginShape("#00ff00")
renderer.AddPolygon([]engine.Point{{X: 50, Y: 50}, {X: 75, Y: 75}})
renderer.EndShape()
svgOutput := renderer.ToSVG()
// Valid colors should be present
if !strings.Contains(svgOutput, `fill="#ff0000"`) {
t.Errorf("Valid color #ff0000 should be present in output")
}
if !strings.Contains(svgOutput, `fill="#00ff00"`) {
t.Errorf("Valid color #00ff00 should be present in output")
}
// Invalid color should be rejected
if strings.Contains(svgOutput, "script") {
t.Errorf("Invalid color with script injection should be rejected")
}
// Should have exactly 2 path elements (for the 2 valid colors)
pathCount := strings.Count(svgOutput, "<path")
if pathCount != 2 {
t.Errorf("Expected exactly 2 path elements for valid colors, got %d", pathCount)
}
}

View File

@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/kevin/go-jdenticon/internal/engine"
"github.com/ungluedlabs/go-jdenticon/internal/engine"
)
func TestSVGPath_AddPolygon(t *testing.T) {
@@ -149,7 +149,7 @@ func TestSVGRenderer_ToSVG(t *testing.T) {
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"/>`) {
if !strings.Contains(svg, `<rect width="100%" height="100%" fill="#ffffff" opacity="1.00"/>`) {
t.Error("SVG should contain background rect")
}
if !strings.Contains(svg, `<path fill="#ff0000" d="M0 0L10 0L10 10Z"/>`) {