package main import ( "fmt" "image" "image/color" "image/png" "math" "math/rand/v2" "os" "path/filepath" "time" "github.com/charmbracelet/log" "golang.org/x/image/font" "golang.org/x/image/math/fixed" ) const maxPositioningAttempts = 150 type TextData struct { Text []string Size float64 Positioned bool X int // left edge of bounding box Y int // top edge of bounding box W int // width of bounding box H int // height of bounding box } func NewTextData(text string, size float64, multiline bool) *TextData { var splits []string if multiline { cutPos := int(len(text) / 2) splits = []string{text[:cutPos], text[cutPos:]} } else { splits = []string{text} } return &TextData{splits, size, false, -1, -1, -1, -1} } func getOutlineThickness(size float64) int { thickness := int(size / 8) if thickness < 1 { thickness = 1 } else if thickness > 3 { thickness = 3 } return thickness } func getTextBounds(text []string, size float64) (width, height int) { face, err := createFontFace(size) if err != nil { // fallback charWidth := int(size * 0.6) lineHeight := int(size * 1.3) maxWidth := 0 for _, line := range text { w := len(line) * charWidth if w > maxWidth { maxWidth = w } } return maxWidth, len(text) * lineHeight } // dimensions for multiline text drawer := &font.Drawer{Face: face} metrics := face.Metrics() lineHeight := int(metrics.Height >> 6) maxWidth := 0 for _, line := range text { advance := drawer.MeasureString(line) w := int(advance >> 6) if w > maxWidth { maxWidth = w } } // add outline padding outlineThickness := getOutlineThickness(size) padding := outlineThickness * 2 // add line spacing totalHeight := len(text)*lineHeight + (len(text)-1)*int(size*0.3) return maxWidth + padding, totalHeight + padding } func rectsOverlap(r1, r2 struct{ x, y, w, h int }) bool { return !(r1.x+r1.w <= r2.x || r2.x+r2.w <= r1.x || r1.y+r1.h <= r2.y || r2.y+r2.h <= r1.y) } func drawTextWithOutline(canvas *image.RGBA, text []string, x, y int, textColor, outlineColor color.RGBA, size float64) { face, err := createFontFace(size) if err != nil { log.Fatalf("Error creating font face: %s", err) return } outlineThickness := getOutlineThickness(size) metrics := face.Metrics() ascent := int(metrics.Ascent >> 6) lineHeight := int(metrics.Height >> 6) for i, line := range text { // baseline position for this line baselineY := y + ascent + i*(lineHeight+int(size*0.3)) + outlineThickness baselineX := x + outlineThickness // draw outline for dy := -outlineThickness; dy <= outlineThickness; dy++ { for dx := -outlineThickness; dx <= outlineThickness; dx++ { if dx != 0 || dy != 0 { d := &font.Drawer{ Dst: canvas, Src: image.NewUniform(outlineColor), Face: face, Dot: fixed.Point26_6{ X: fixed.I(baselineX + dx), Y: fixed.I(baselineY + dy), }, } d.DrawString(line) } } } // draw main text d := &font.Drawer{ Dst: canvas, Src: image.NewUniform(textColor), Face: face, Dot: fixed.Point26_6{ X: fixed.I(baselineX), Y: fixed.I(baselineY), }, } d.DrawString(line) } } func abs(a int) int { if a < 0 { return -a } return a } func findTextPositions(textData []*TextData, filename string) { type attemptedPos struct { x, y int } padding := 10 for i, data := range textData { // get actual bounding box dimensions w, h := getTextBounds(data.Text, data.Size) data.W = w data.H = h placed := false // safe bounds for positioning minX := padding maxX := config.BannerWidth - w - padding minY := padding maxY := config.BannerHeight - h - padding // check if text can fit if maxX < minX || maxY < minY { log.Warnf("Text '%v' is too large for canvas", data.Text) // place at top-left as fallback data.X = minX data.Y = minY data.Positioned = true continue } // track attempted positions to avoid redundancy attempts := make([]attemptedPos, 0, maxPositioningAttempts) // min. distance between attempts (1/5 of text height) minAttemptDistance := max(h/5, 10) isTooCloseToAttempted := func(x, y int) bool { for _, pos := range attempts { if abs(x-pos.x) < minAttemptDistance && abs(y-pos.y) < minAttemptDistance { return true } } return false } // try random positions for attempt := 0; attempt < maxPositioningAttempts && !placed; attempt++ { // gen. random pos. x := rand.IntN(maxX-minX+1) + minX y := rand.IntN(maxY-minY+1) + minY // skip if too close to a previously attempt pos. if isTooCloseToAttempted(x, y) { continue } attempts = append(attempts, attemptedPos{x: x, y: y}) // bounding box for cur. text curRect := struct{ x, y, w, h int }{ x: x, y: y, w: w, h: h, } // checks of overlaps with already placed texts overlaps := false for j := range i { other := textData[j] if !other.Positioned { continue } otherRect := struct{ x, y, w, h int }{ x: other.X - padding/2, y: other.Y - padding/2, w: other.W + padding, h: other.H + padding, } if rectsOverlap(curRect, otherRect) { overlaps = true break } } if !overlaps { data.X = x data.Y = y data.Positioned = true placed = true log.Debugf("Placed text '%v' at (%d, %d)", data.Text, x, y) } } // fallback: place to a free corner if !placed { log.Debugf("Using fallback position for text '%s' in '%s'", data.Text, filename) corners := []struct { name string x, y int }{ {"top-left", padding, padding}, {"top-right", config.BannerWidth - w - padding, padding}, {"bottom-left", padding, config.BannerHeight - h - padding}, {"bottom-right", config.BannerWidth - w - padding, config.BannerHeight - h - padding}, } bestCorner := 0 minOverlapArea := int(^uint(0) >> 1) // max. int // try each corner and find the one with least overlap for cornerIdx, corner := range corners { curRect := struct{ x, y, w, h int }{ x: corner.x, y: corner.y, w: w, h: h, } totalOverlapArea := 0 // calculate total overlap area with existing texts for j := range i { other := textData[j] if !other.Positioned { continue } otherRect := struct{ x, y, w, h int }{ x: other.X, y: other.Y, w: other.W, h: other.H, } if rectsOverlap(curRect, otherRect) { // intersection rectangle overlapLeft := max(curRect.x, otherRect.x) overlapTop := max(curRect.y, otherRect.y) overlapRight := min(curRect.x+curRect.w, otherRect.x+otherRect.w) overlapBottom := min(curRect.y+curRect.h, otherRect.y+otherRect.h) overlapWidth := overlapRight - overlapLeft overlapHeight := overlapBottom - overlapTop overlapArea := overlapWidth * overlapHeight totalOverlapArea += overlapArea } if totalOverlapArea < minOverlapArea { minOverlapArea = totalOverlapArea bestCorner = cornerIdx } // if no overlap, use the pos. immediately if totalOverlapArea == 0 { break } } } // place at the best corner data.X = corners[bestCorner].x data.Y = corners[bestCorner].y data.Positioned = true if minOverlapArea > 0 { log.Debugf("Placed text '%v' at %s corner with some overlap (area: %d)", data.Text, corners[bestCorner].name, minOverlapArea) } else { log.Debugf("Placed text '%v' at %s corner with no overlap", data.Text, corners[bestCorner].name) } } } } func genPerlinBG() (*image.RGBA, string) { palette, theme := getRandomThemePalette() img := image.NewRGBA(image.Rect(0, 0, config.BannerWidth, config.BannerHeight)) // noise params for interesting patterns scale1 := 0.008 + rand.Float64()*0.015 // 0.008-0.023 (large patterns) scale2 := 0.025 + rand.Float64()*0.035 // 0.025-0.06 (medium detail) scale3 := 0.060 + rand.Float64()*0.040 // 0.060-0.100 (fine detail, reduced grain) // variation offsets offsetX := rand.Float64() * 5000 offsetY := rand.Float64() * 5000 // noise layer weights for different effects weight1 := 0.4 + rand.Float64()*0.4 // 0.4-0.8 (large patterns) weight2 := 0.15 + rand.Float64()*0.25 // 0.15-0.4 (medium detail) weight3 := 0.05 + rand.Float64()*0.15 // 0.15-0.4 (fine detail, reduced grain) // normalize weights to sum to 1.0 totalWeight := weight1 + weight2 + weight3 weight1 /= totalWeight weight2 /= totalWeight weight3 /= totalWeight // FIX: limit gradient strength to prevent overwhelming the noise gradientStrengthX := rand.Float64() * 0.25 // old: 0.4 gradientStrengthY := rand.Float64() * 0.2 // old: 0.3 gradientDirectionX := 1.0 gradientDirectionY := 1.0 // sometimes reverse gradient direction for variety if rand.Float64() < 0.3 { gradientDirectionX = -1.0 } if rand.Float64() < 0.3 { gradientDirectionY = -1.0 } // randomize noise combination method combineMethod := rand.IntN(4) // fix: increase distortion chance and make it more visible useDistortion := rand.Float64() < 0.4 // old: 30% distortionStrength := 0.05 + rand.Float64()*0.15 // 0.05-0.2 (old: 0.0-0.1) log.Debugf("BG theme: %s / Scales: %.4f, %.4f, %.4f / Weights: %.3f, %.3f, %.3f", theme, scale1, scale2, scale3, weight1, weight2, weight3) log.Debugf("Gradient: X=%.3f*%.0f, Y=%.3f*%.0f / Combine method: %d / Distortion %t (%.3f)", gradientStrengthX, gradientDirectionX, gradientStrengthY, gradientDirectionY, combineMethod, useDistortion, distortionStrength) for y := range config.BannerHeight { for x := range config.BannerWidth { baseX := float64(x) + offsetX baseY := float64(y) + offsetY // optional distortion for more organic patterns if useDistortion { baseX += math.Sin(float64(y)*0.015) * distortionStrength * 200 baseY += math.Cos(float64(x)*0.015) * distortionStrength * 200 } // multi-octave noise with randomized scales fx1 := baseX * scale1 fy1 := baseY * scale1 fx2 := baseX * scale2 fy2 := baseY * scale2 fx3 := baseX * scale3 fy3 := baseY * scale3 // noise values at different scales noise1 := perlinNoise.Noise2D(fx1, fy1) noise2 := perlinNoise.Noise2D(fx2, fy2) noise3 := perlinNoise.Noise2D(fx3, fy3) // different combination methods for variety var combined float64 switch combineMethod { case 0: // standard linear combination combined = noise1*weight1 + noise2*weight2 + noise3*weight3 case 1: // multiplicative blend combined = (noise1*weight1)*(1+noise2*weight2)*(1+noise3*weight3) - 1 case 2: // maximum blend (creates sharper patterns) values := []float64{noise1 * weight1, noise2 * weight2, noise3 * weight3} combined = math.Max(math.Max(values[0], values[1]), values[2]) case 3: // turbulence (abs. values create more chaotic patterns) combined = math.Abs(noise1)*weight1 + math.Abs(noise2)*weight2 + math.Abs(noise3)*weight3 } // randomized gradient with variable direction and strength gradientX := (float64(x) / float64(config.BannerWidth)) * gradientStrengthX * gradientDirectionX gradientY := (float64(y) / float64(config.BannerHeight)) * gradientStrengthY * gradientDirectionY // combined and normalized to [0, 1] range noise := min(max((combined+gradientX+gradientY+1)/2, 0), 1) pixelColor := palette.interpolate(noise) img.Set(x, y, pixelColor) } } return img, theme } func genBanner(textData []*TextData) error { fn := fmt.Sprintf("banner_%d.png", time.Now().UnixMicro()) canvas, theme := genPerlinBG() findTextPositions(textData, fn) for _, td := range textData { if td.Positioned { textColor, outlineColor := getRandomTextColors() drawTextWithOutline(canvas, td.Text, td.X, td.Y, textColor, outlineColor, td.Size) } } fp := filepath.Join(bannersDir, fn) file, err := os.Create(fp) if err != nil { return err } defer file.Close() if err := png.Encode(file, canvas); err != nil { return err } log.Infof("Output '%s' with theme '%s'", fn, theme) return nil } // TODO: functions for banner rotation implementation