Files
go-jdenticon/internal/renderer/png.go
Kevin McIntyre f1544ef49c
Some checks failed
CI / Test (Go 1.24.x, ubuntu-latest) (push) Successful in 1m53s
CI / Code Quality (push) Failing after 26s
CI / Security Scan (push) Failing after 11s
CI / Test Coverage (push) Successful in 1m13s
CI / Benchmarks (push) Failing after 10m22s
CI / Build CLI (push) Failing after 8s
Benchmarks / Run Benchmarks (push) Failing after 10m13s
Release / Test (push) Successful in 55s
Release / Build (amd64, darwin, ) (push) Failing after 12s
Release / Build (amd64, linux, ) (push) Failing after 6s
Release / Build (amd64, windows, .exe) (push) Failing after 12s
Release / Build (arm64, darwin, ) (push) Failing after 12s
Release / Build (arm64, linux, ) (push) Failing after 12s
Release / Release (push) Has been skipped
CI / Test (Go 1.24.x, macos-latest) (push) Has been cancelled
CI / Test (Go 1.24.x, windows-latest) (push) Has been cancelled
chore: update module path to gitea.dockr.co/kev/go-jdenticon
Move hosting from GitHub to private Gitea instance.
2026-02-10 10:07:57 -05:00

634 lines
17 KiB
Go

package renderer
import (
"bytes"
"fmt"
"image"
"image/color"
"image/png"
"math"
"sync"
"gitea.dockr.co/kev/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
}