package renderer import ( "math" "strconv" "strings" "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 } // 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(``) // 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(``) } } // 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(``) } } // SVG closing tag svg.WriteString("") 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) } }