Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion svg.go
Original file line number Diff line number Diff line change
Expand Up @@ -502,10 +502,11 @@ func (g *SVGGenerator) generateStyles() string {
g.writeNewline(&sb)

// Build optimized keyframes from timeline
keyframeCount := len(g.timeline)
for _, stop := range g.timeline {
offset := -float64(stop.StateIndex) * g.frameSpacing
sb.WriteString(fmt.Sprintf(" %s%% { transform: translateX(%spx); }",
formatCoord(stop.Percentage), formatCoord(offset)))
formatPercentage(stop.Percentage, keyframeCount), formatCoord(offset)))
g.writeNewline(&sb)
}

Expand Down Expand Up @@ -1069,6 +1070,43 @@ func formatCoord(val float64) string {
return formatted
}

// formatPercentage formats a percentage value with appropriate precision
// to avoid keyframe collisions in large animations.
// The precision is dynamically calculated based on the number of keyframes.
func formatPercentage(val float64, keyframeCount int) string {
// For whole numbers, keep minimal format
if val == float64(int(val)) {
return fmt.Sprintf("%d", int(val))
}

// Dynamically determine precision based on keyframe count
// This ensures we have enough precision to avoid collisions
// while keeping the output as compact as possible
var precision int
switch {
case keyframeCount < 100:
precision = 1 // Up to 100 unique values
case keyframeCount < 1000:
precision = 2 // Up to 1,000 unique values
case keyframeCount < 10000:
precision = 3 // Up to 10,000 unique values
case keyframeCount < 100000:
precision = 4 // Up to 100,000 unique values
default:
precision = 5 // Up to 1,000,000 unique values
}

// Format with calculated precision
formatStr := fmt.Sprintf("%%.%df", precision)
formatted := fmt.Sprintf(formatStr, val)

// Remove trailing zeros but keep at least 1 decimal for consistency
formatted = strings.TrimRight(formatted, "0")
formatted = strings.TrimSuffix(formatted, ".")

return formatted
}

// formatDuration formats a duration value with minimal decimal places.
func formatDuration(seconds float64) string {
// If it's a whole number, don't include decimals
Expand Down
161 changes: 161 additions & 0 deletions svg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"fmt"
"os"
"regexp"
"runtime"
"strings"
"testing"
Expand Down Expand Up @@ -1030,3 +1031,163 @@ func TestFormatDuration(t *testing.T) {
}
}
}

// Test formatPercentage function with dynamic precision
func TestFormatPercentage(t *testing.T) {
testCases := []struct {
name string
input float64
keyframeCount int
expected string
}{
// Whole numbers always minimal
{"whole number 0", 0.0, 100, "0"},
{"whole number 100", 100.0, 1000, "100"},
{"whole number 50", 50.0, 10000, "50"},

// Small frame counts (< 100) - 1 decimal
{"small count decimal", 12.345, 50, "12.3"},
{"small count trailing", 10.5000, 75, "10.5"},

// Medium frame counts (< 1000) - 2 decimals
{"medium count decimal", 0.024038, 500, "0.02"},
{"medium count precision", 2.456, 800, "2.46"},
{"medium count trailing", 10.1000, 900, "10.1"},

// Large frame counts (< 10000) - 3 decimals
{"large count decimal", 0.024038, 4161, "0.024"},
{"large count precision", 2.451923, 5000, "2.452"},
{"large count trailing", 99.9000, 8000, "99.9"},

// Very large frame counts (< 100000) - 4 decimals
{"very large decimal", 0.00012, 50000, "0.0001"},
{"very large precision", 33.33333, 75000, "33.3333"},

// Huge frame counts (>= 100000) - 5 decimals
{"huge count decimal", 0.000012, 150000, "0.00001"},
{"huge count precision", 12.345678, 200000, "12.34568"},

// Edge cases
{"negative percentage", -5.5, 100, "-5.5"},
{"zero with decimals", 0.0001, 1000, "0"},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := formatPercentage(tc.input, tc.keyframeCount)
if result != tc.expected {
t.Errorf("formatPercentage(%f, %d) = %s; want %s",
tc.input, tc.keyframeCount, result, tc.expected)
}
})
}
}

// Test dynamic precision prevents collisions
func TestFormatPercentageDynamicPrecision(t *testing.T) {
testCases := []struct {
name string
frameCount int
}{
{"small animation", 50},
{"medium animation", 500},
{"large animation", 4161},
{"very large animation", 50000},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
percentages := make(map[string]bool)

for i := 0; i < tc.frameCount; i++ {
percentage := float64(i) / float64(tc.frameCount-1) * 100
formatted := formatPercentage(percentage, tc.frameCount)

if percentages[formatted] {
t.Errorf("Duplicate percentage at frame %d/%d: %s (%.6f%%)",
i, tc.frameCount, formatted, percentage)
}
percentages[formatted] = true
}

if len(percentages) != tc.frameCount {
t.Errorf("Expected %d unique percentages, got %d",
tc.frameCount, len(percentages))
}
})
}
}

// Test that large animations produce unique percentages
func TestLargeAnimationPercentages(t *testing.T) {
// Test that 4161 frames produce unique percentages
percentages := make(map[string]bool)
totalFrames := 4161

for i := 0; i < totalFrames; i++ {
percentage := float64(i) / float64(totalFrames-1) * 100
formatted := formatPercentage(percentage, totalFrames)

if percentages[formatted] {
t.Errorf("Duplicate percentage at frame %d: %s (%.6f%%)", i, formatted, percentage)
}
percentages[formatted] = true
}

if len(percentages) != totalFrames {
t.Errorf("Expected %d unique percentages, got %d",
totalFrames, len(percentages))
}
}

// Test SVG keyframe generation with many frames
func TestSVGKeyframeGeneration(t *testing.T) {
// Create config with many frames to test keyframe collision
opts := createTestSVGConfig()
opts.Frames = make([]SVGFrame, 1000)
for i := range opts.Frames {
opts.Frames[i] = SVGFrame{
Lines: []string{fmt.Sprintf("Frame %d", i)},
CharWidth: 8.8,
CharHeight: 20,
CursorX: i % 10,
CursorY: 0,
}
}

gen := NewSVGGenerator(opts)
svg := gen.Generate()

// Verify no duplicate keyframe percentages
keyframeRegex := regexp.MustCompile(`(\d+(?:\.\d+)?)\%\s*\{`)
matches := keyframeRegex.FindAllStringSubmatch(svg, -1)

seen := make(map[string]bool)
duplicates := 0
for _, match := range matches {
if seen[match[1]] {
duplicates++
if duplicates <= 5 { // Only report first 5 duplicates
t.Errorf("Duplicate keyframe percentage found: %s%%", match[1])
}
}
seen[match[1]] = true
}

if duplicates > 0 {
t.Errorf("Found %d duplicate keyframe percentages out of %d total keyframes",
duplicates, len(matches))
}

// Verify animation is present
if !strings.Contains(svg, "@keyframes slide") {
t.Error("SVG missing @keyframes slide animation")
}

// Verify reasonable number of keyframes were generated
// With deduplication, we should have fewer keyframes than frames
if len(matches) > len(opts.Frames) {
t.Errorf("Too many keyframes generated: %d keyframes for %d frames",
len(matches), len(opts.Frames))
}
}
Loading