package renderer import ( "bytes" "fmt" "image" "image/color" "image/png" "math" "sync" "github.com/ungluedlabs/go-jdenticon/internal/engine" ) // 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 finalImg *image.RGBA // Single buffer at target resolution finalSize int // Target output size bgColor color.RGBA // Background color shapes []ShapeCommand // Queued rendering commands } // 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), finalImg: finalImg, finalSize: iconSize, shapes: make([]ShapeCommand, 0, 16), // Pre-allocate for typical use } } // SetBackground sets the background color - queues background command func (r *PNGRenderer) SetBackground(fillColor string, opacity float64) { r.BaseRenderer.SetBackground(fillColor, opacity) 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.BaseRenderer.BeginShape(fillColor) } // EndShape marks the end of the currently drawn shape (no-op for queuing renderer) func (r *PNGRenderer) EndShape() { // No-op for command queuing approach } // AddPolygon queues a polygon command with pre-calculated bounding box func (r *PNGRenderer) AddPolygon(points []engine.Point) { if len(points) < 3 { return // Can't render polygon with < 3 points } // 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) } 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 } } // 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 queues a circle command with pre-calculated bounding box func (r *PNGRenderer) AddCircle(topLeft engine.Point, size float64, invert bool) { // Scale to supersampled coordinates scaledTopLeft := engine.Point{ X: topLeft.X * defaultSupersamplingFactor, Y: topLeft.Y * defaultSupersamplingFactor, } scaledSize := size * defaultSupersamplingFactor centerX := scaledTopLeft.X + scaledSize/2.0 centerY := scaledTopLeft.Y + scaledSize/2.0 radius := scaledSize / 2.0 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 using streaming row processing func (r *PNGRenderer) ToPNG() ([]byte, error) { return r.ToPNGWithSize(r.GetSize()) } // ToPNGWithSize generates PNG image data with streaming row processing func (r *PNGRenderer) ToPNGWithSize(outputSize int) ([]byte, error) { // Execute streaming rendering pipeline r.renderWithStreaming() 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, } err := encoder.Encode(&buf, resultImg) if err != nil { return nil, fmt.Errorf("jdenticon: optimized renderer: PNG encoding failed: %w", err) } return buf.Bytes(), nil } // renderWithStreaming executes the main streaming rendering pipeline func (r *PNGRenderer) renderWithStreaming() { supersampledWidth := r.finalSize * defaultSupersamplingFactor // 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) }() // Ensure buffer has correct size requiredSize := supersampledWidth * 2 if cap(rowBufferSlice) < requiredSize { rowBufferSlice = make([]color.RGBA, requiredSize) } else { rowBufferSlice = rowBufferSlice[:requiredSize] } // 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) } } // 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 } // 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 } if p.Y > maxY { maxY = p.Y } } return minX, minY, maxX, maxY } func (r *PNGRenderer) scaleImage(src *image.RGBA, newSize int) image.Image { oldSize := r.finalSize if oldSize == newSize { return src } scaled := image.NewRGBA(image.Rect(0, 0, newSize, newSize)) ratio := float64(oldSize) / float64(newSize) 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)) } } return scaled }