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
Move hosting from GitHub to private Gitea instance.
278 lines
8.1 KiB
Go
278 lines
8.1 KiB
Go
package renderer
|
|
|
|
import (
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gitea.dockr.co/kev/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
|
|
}
|
|
|
|
// AddPolygon adds a polygon to the SVG path
|
|
func (p *SVGPath) AddPolygon(points []engine.Point) {
|
|
if len(points) == 0 {
|
|
return
|
|
}
|
|
|
|
// Write directly to main data builder to avoid intermediate allocations
|
|
// Move to first point
|
|
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("L")
|
|
svgAppendValue(&p.data, points[i].X)
|
|
p.data.WriteString(" ")
|
|
svgAppendValue(&p.data, points[i].Y)
|
|
}
|
|
|
|
// Close path
|
|
p.data.WriteString("Z")
|
|
}
|
|
|
|
// AddCircle adds a circle to the SVG path
|
|
func (p *SVGPath) AddCircle(topLeft engine.Point, size float64, counterClockwise bool) {
|
|
sweepFlag := "1"
|
|
if counterClockwise {
|
|
sweepFlag = "0"
|
|
}
|
|
|
|
radius := size / 2
|
|
centerX := topLeft.X + radius
|
|
centerY := topLeft.Y + radius
|
|
|
|
// Move to start point (left side of circle)
|
|
startX := centerX - radius
|
|
startY := centerY
|
|
|
|
// 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
|
|
func (p *SVGPath) DataString() string {
|
|
return p.data.String()
|
|
}
|
|
|
|
// SVGRenderer implements the Renderer interface for SVG output
|
|
type SVGRenderer struct {
|
|
*BaseRenderer
|
|
pathsByColor map[string]*SVGPath
|
|
colorOrder []string
|
|
}
|
|
|
|
// NewSVGRenderer creates a new SVG renderer
|
|
func NewSVGRenderer(iconSize int) *SVGRenderer {
|
|
return &SVGRenderer{
|
|
BaseRenderer: NewBaseRenderer(iconSize),
|
|
pathsByColor: make(map[string]*SVGPath),
|
|
colorOrder: make([]string, 0),
|
|
}
|
|
}
|
|
|
|
// SetBackground sets the background color and opacity
|
|
func (r *SVGRenderer) SetBackground(fillColor string, opacity float64) {
|
|
r.BaseRenderer.SetBackground(fillColor, opacity)
|
|
}
|
|
|
|
// 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{}
|
|
r.colorOrder = append(r.colorOrder, color)
|
|
}
|
|
}
|
|
|
|
// EndShape marks the end of the currently drawn shape
|
|
func (r *SVGRenderer) EndShape() {
|
|
// No action needed for SVG
|
|
}
|
|
|
|
// getCurrentPath returns the current path for the active color
|
|
func (r *SVGRenderer) getCurrentPath() *SVGPath {
|
|
currentColor := r.GetCurrentColor()
|
|
if currentColor == "" {
|
|
return nil
|
|
}
|
|
return r.pathsByColor[currentColor]
|
|
}
|
|
|
|
// AddPolygon adds a polygon with the current fill color to the SVG
|
|
func (r *SVGRenderer) AddPolygon(points []engine.Point) {
|
|
if path := r.getCurrentPath(); path != nil {
|
|
path.AddPolygon(points)
|
|
}
|
|
}
|
|
|
|
// AddCircle adds a circle with the current fill color to the SVG
|
|
func (r *SVGRenderer) AddCircle(topLeft engine.Point, size float64, invert bool) {
|
|
if path := r.getCurrentPath(); path != nil {
|
|
path.AddCircle(topLeft, size, invert)
|
|
}
|
|
}
|
|
|
|
// ToSVG generates the final SVG XML string
|
|
func (r *SVGRenderer) ToSVG() string {
|
|
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
|
|
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 {
|
|
// Validate background color for safe SVG output
|
|
if err := engine.ValidateHexColor(background); err != nil {
|
|
// Skip invalid background colors to prevent injection
|
|
} else {
|
|
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(`"/>`)
|
|
}
|
|
}
|
|
|
|
// Add paths for each color (in insertion order to preserve z-order)
|
|
for _, color := range r.colorOrder {
|
|
path := r.pathsByColor[color]
|
|
dataString := path.DataString()
|
|
if 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(`"/>`)
|
|
}
|
|
}
|
|
|
|
// SVG closing tag
|
|
svg.WriteString("</svg>")
|
|
|
|
return svg.String()
|
|
}
|
|
|
|
// svgValue rounds a float64 to one decimal place, mimicking the Jdenticon JS implementation's
|
|
// "round half up" behavior. It also formats the number to a minimal string representation.
|
|
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*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)
|
|
}
|
|
}
|