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:
98
internal/renderer/baseline_comparison_test.go
Normal file
98
internal/renderer/baseline_comparison_test.go
Normal 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
58
internal/renderer/doc.go
Normal 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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
544
internal/renderer/micro_bench_test.go
Normal file
544
internal/renderer/micro_bench_test.go
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
179
internal/renderer/optimized_bench_test.go
Normal file
179
internal/renderer/optimized_bench_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
464
internal/renderer/renderer_bench_test.go
Normal file
464
internal/renderer/renderer_bench_test.go
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
284
internal/renderer/svg_security_test.go
Normal file
284
internal/renderer/svg_security_test.go
Normal 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<script>",
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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"/>`) {
|
||||
|
||||
Reference in New Issue
Block a user