Initial release: Go Jdenticon library v0.1.0

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

View File

@@ -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
}