From de359744078223d51840362777967539dabc6b5e Mon Sep 17 00:00:00 2001 From: ae Date: Sun, 1 Jun 2025 18:21:18 +0300 Subject: [PATCH] feat: full generation workflow with static perlin params --- banner.go | 327 +++++++++++++++++++++++++++++++++++ font.go | 75 ++++++++ fonts/PixelOperator-Bold.ttf | Bin 0 -> 16984 bytes go.mod | 30 ++++ go.sum | 44 +++++ main.go | 95 ++++++++++ palette.go | 239 +++++++++++++++++++++++++ 7 files changed, 810 insertions(+) create mode 100644 banner.go create mode 100644 font.go create mode 100644 fonts/PixelOperator-Bold.ttf create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 palette.go diff --git a/banner.go b/banner.go new file mode 100644 index 0000000..11bae86 --- /dev/null +++ b/banner.go @@ -0,0 +1,327 @@ +package main + +import ( + "fmt" + "image" + "image/color" + "image/png" + "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} +} + +// Consistent outline thickness based on font size +func getOutlineThickness(size float64) int { + thickness := int(size / 8) + if thickness < 1 { + thickness = 1 + } else if thickness > 3 { + thickness = 3 + } + return thickness +} + +// Actual bounding box for text including outline +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) + } +} + +// Place all elements to canvas without overlaps +func findTextPositions(textData []*TextData) { + 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 + } + + // 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 + + // bounding box for cur. text + currentRect := 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 := 0; j < i; j++ { + 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(currentRect, 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: use grid positioning + if !placed { + log.Warnf("Using fallback position for text '%v'", data.Text) + + gridCols := 2 + col := i % gridCols + row := i / gridCols + + cellWidth := config.BannerWidth / gridCols + cellHeight := config.BannerHeight / 3 // NOTE: assuming max 3 rows + + data.X = col*cellWidth + (cellWidth-w)/2 + data.Y = row*cellHeight + (cellHeight-h)/2 + + // ensure within bounds + if data.X < padding { + data.X = padding + } + if data.Y < padding { + data.Y = padding + } + if data.X+w > config.BannerWidth-padding { + data.X = config.BannerWidth - w - padding + } + if data.Y+h > config.BannerHeight-padding { + data.Y = config.BannerHeight - h - padding + } + + data.Positioned = true + } + } +} + +func genPerlinBG() *image.RGBA { + palette, theme := getRandomThemePalette() + img := image.NewRGBA(image.Rect(0, 0, config.BannerWidth, config.BannerHeight)) + + // noise params for interesting patterns + scale1 := 0.01 + rand.Float64()*0.02 // primary pattern scale (adjusted by lib) + scale2 := 0.03 + rand.Float64()*0.03 // secondary detail + scale3 := 0.08 + rand.Float64()*0.05 // fine detail + + // variation offsets + offsetX := rand.Float64() * 1000 + offsetY := rand.Float64() * 1000 + + log.Infof("Generating background with theme: %s, scales: %.4f, %.4f, %.4f", theme, scale1, scale2, scale3) + + for y := range config.BannerHeight { + for x := range config.BannerWidth { + // multi-octave noise + fx := (float64(x) + offsetX) * scale1 + fy := (float64(y) + offsetY) * scale1 + + // noise values at different scales + noise1 := perlinNoise.Noise2D(fx, fy) + noise2 := perlinNoise.Noise2D(fx/scale1*scale2, fy/scale1*scale2) + noise3 := perlinNoise.Noise2D(fx/scale1*scale3, fy/scale1*scale3) + + // combined noise layers with different weights + combined := noise1*0.6 + noise2*0.3 + noise3*0.1 + + // position-based gradient for a more dynamic look + gradientX := float64(x) / float64(config.BannerWidth) * 0.2 + gradientY := float64(y) / float64(config.BannerHeight) * 0.1 + + // combined and normalized to [0, 1] range + noise := (combined + gradientX + gradientY + 1) / 2 + if noise < 0 { + noise = 0 + } + if noise > 1 { + noise = 1 + } + + pixelColor := palette.interpolate(noise) + img.Set(x, y, pixelColor) + } + } + + return img +} + +func genBanner(textData []*TextData) error { + canvas := genPerlinBG() + + findTextPositions(textData) + + for _, td := range textData { + if td.Positioned { + textColor, outlineColor := getRandomTextColors() + drawTextWithOutline(canvas, td.Text, td.X, td.Y, textColor, outlineColor, td.Size) + } + } + + fn := fmt.Sprintf("banner_%d.png", time.Now().UnixMicro()) + fp := filepath.Join(bannersDir, fn) + file, err := os.Create(fp) + if err != nil { + return err + } + defer file.Close() + + return png.Encode(file, canvas) +} + +// TODO: functions for banner rotation implementation diff --git a/font.go b/font.go new file mode 100644 index 0000000..a77d7f7 --- /dev/null +++ b/font.go @@ -0,0 +1,75 @@ +package main + +import ( + "errors" + "os" + + "github.com/charmbracelet/log" + "github.com/golang/freetype/truetype" + "golang.org/x/image/font" + "golang.org/x/image/font/opentype" +) + +const defaultFontSize = 24.0 + +var fontManager *FontManager + +type FontManager struct { + fontData []byte + fontType string // opentype or truetype +} + +func initFontManager() error { + fontBytes, err := os.ReadFile(fontPath) + if err != nil { + return err + } + + // initially try to parse as opentype/truetype + if _, err := opentype.Parse(fontBytes); err == nil { + fontManager = &FontManager{ + fontData: fontBytes, + fontType: "opentype", + } + log.Debug("Initialized OpenType font manager") + } else if _, err := truetype.Parse(fontBytes); err == nil { + fontManager = &FontManager{ + fontData: fontBytes, + fontType: "truetype", + } + log.Debug("Initialized TrueType font manager") + } else { + return err + } + + return nil +} + +func createFontFace(size float64) (font.Face, error) { + if fontManager == nil { + return nil, errors.New("font manager not initialized") + } + + switch fontManager.fontType { + case "opentype": + parsedFont, err := opentype.Parse(fontManager.fontData) + if err != nil { + return nil, err + } + return opentype.NewFace(parsedFont, &opentype.FaceOptions{ + Size: size, + DPI: 72, + }) + case "truetype": + ttf, err := truetype.Parse(fontManager.fontData) + if err != nil { + return nil, err + } + return truetype.NewFace(ttf, &truetype.Options{ + Size: size, + DPI: 72, + }), nil + default: + return nil, errors.New("unknown font type") + } +} diff --git a/fonts/PixelOperator-Bold.ttf b/fonts/PixelOperator-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..745734f6c92d6c2855e477a32a00f98d2936db1d GIT binary patch literal 16984 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 literal 0 HcmV?d00001 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0a80a21 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module git.umbrella.haus/ae/mandala + +go 1.24.2 + +require ( + github.com/aquilax/go-perlin v1.1.0 + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/log v0.4.2 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/fogleman/gg v1.3.0 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/image v0.27.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.25.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..294a45c --- /dev/null +++ b/go.sum @@ -0,0 +1,44 @@ +github.com/aquilax/go-perlin v1.1.0 h1:Gg+3jQ24wT4Y5GI7TCRLmYarzUG0k+n/JATFqOimb7s= +github.com/aquilax/go-perlin v1.1.0/go.mod h1:z9Rl7EM4BZY0Ikp2fEN1I5mKSOJ26HQpk0O2TBdN2HE= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= diff --git a/main.go b/main.go new file mode 100644 index 0000000..6ac2456 --- /dev/null +++ b/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "encoding/json" + "math/rand" + "os" + "time" + + "github.com/aquilax/go-perlin" + "github.com/charmbracelet/log" +) + +const ( + bannersDir = "./banners" // mountable + fontPath = "./fonts/PixelOperator-Bold.ttf" + configFile = "./config.json" // mountable +) + +var ( + config Config + perlinNoise perlin.Perlin +) + +type Config struct { + ClearwebURL string `json:"clearweb_url"` + OnionURL string `json:"onion_url"` + ColorThemes []string `json:"color_themes"` + MaxBanners uint `json:"max_banners"` + BannerWidth int `json:"banner_width"` + BannerHeight int `json:"banner_height"` + LogLevel log.Level `json:"log_level"` // -4=DBG, 0=INFO, 4=WARN, 8=ERR, 12=FATAL +} + +type BannerInfo struct { + Filename string + CreatedAt time.Time +} + +func init() { + config = Config{ + ClearwebURL: "https://example.com", + OnionURL: "http://example.onion", + ColorThemes: []string{"vibrant", "sunset", "ocean", "neon", "forest", "cosmic"}, + MaxBanners: 50, + BannerWidth: 600, + BannerHeight: 120, + LogLevel: log.InfoLevel, + } + + if data, err := os.ReadFile(configFile); err == nil { + if err := json.Unmarshal(data, &config); err != nil { + log.Fatalf("Error parsing config: %s", err) + os.Exit(1) + } + } else { + data, _ := json.MarshalIndent(config, "", " ") + os.WriteFile(configFile, data, 0644) + log.Info("Created default config - please update your URLs into it") + os.Exit(0) + } + + log.SetLevel(config.LogLevel) + + if err := os.MkdirAll(bannersDir, 0755); err != nil { + log.Fatalf("Error creating banner output directory: %s", err) + os.Exit(1) + } + + if err := initFontManager(); err != nil { + log.Fatalf("Error initializing font manager: %s", err) + os.Exit(1) + } + + // perlin input params: alpha, beta, octaves, seed + perlinNoise = *perlin.NewPerlin(2.0, 2.0, 3, rand.Int63()) +} + +func main() { + // TODO: cli param `--portable ` for instant generation of n banners + + // TODO: ticker setup for banner rotation + + // NOTE: makes the life a lot easier to draw the bigger element first, + // then find a place for the smaller one + textData := []*TextData{ + NewTextData(config.OnionURL, defaultFontSize, true), + NewTextData(config.ClearwebURL, defaultFontSize+4.0, false), + } + + for range 10 { + if err := genBanner(textData); err != nil { + log.Errorf("Error generating banner: %s", err) + } + } +} diff --git a/palette.go b/palette.go new file mode 100644 index 0000000..eba739e --- /dev/null +++ b/palette.go @@ -0,0 +1,239 @@ +package main + +import ( + "image/color" + "math/rand/v2" +) + +type Palette []color.RGBA + +var textColors = []color.RGBA{ + // basic bright colors + {255, 100, 100, 255}, // red + {100, 255, 100, 255}, // green + {100, 100, 255, 255}, // blue + {255, 255, 100, 255}, // yellow + {255, 100, 255, 255}, // magenta + {100, 255, 255, 255}, // cyan + {255, 255, 255, 255}, // white + {255, 150, 50, 255}, // orange + {150, 255, 50, 255}, // lime + {255, 50, 150, 255}, // pink + {50, 150, 255, 255}, // sky blue + {200, 200, 255, 255}, // light blue + + // extended primary variations + {255, 50, 50, 255}, // bright red + {255, 150, 150, 255}, // light red + {50, 255, 50, 255}, // bright green + {150, 255, 150, 255}, // light green + {50, 50, 255, 255}, // bright blue + {150, 150, 255, 255}, // light blue variant + + // warm colors + {255, 200, 50, 255}, // golden yellow + {255, 175, 0, 255}, // amber + {255, 69, 0, 255}, // red orange + {255, 20, 147, 255}, // deep pink + {255, 105, 180, 255}, // hot pink + {255, 182, 193, 255}, // light pink + {255, 160, 122, 255}, // light salmon + {255, 99, 71, 255}, // tomato + {255, 127, 80, 255}, // coral + + // cool colors + {0, 255, 127, 255}, // spring green + {0, 250, 154, 255}, // medium spring green + {64, 224, 208, 255}, // turquoise + {72, 209, 204, 255}, // medium turquoise + {135, 206, 250, 255}, // light sky blue + {70, 130, 180, 255}, // steel blue + {100, 149, 237, 255}, // cornflower blue + {123, 104, 238, 255}, // medium slate blue + {138, 43, 226, 255}, // blue violet + + // purple spectrum + {147, 112, 219, 255}, // medium purple + {186, 85, 211, 255}, // medium orchid + {218, 112, 214, 255}, // orchid + {221, 160, 221, 255}, // plum + {238, 130, 238, 255}, // violet + {255, 0, 255, 255}, // pure magenta + {199, 21, 133, 255}, // medium violet red + {219, 112, 147, 255}, // pale violet red + + // neon/electric colors + {57, 255, 20, 255}, // electric green + {255, 20, 147, 255}, // electric pink + {0, 255, 255, 255}, // electric cyan + {255, 255, 0, 255}, // electric yellow + {255, 0, 127, 255}, // electric rose + {127, 255, 0, 255}, // electric lime + {255, 127, 0, 255}, // electric orange + {0, 127, 255, 255}, // electric blue + + // pastel variants + {255, 218, 185, 255}, // peach puff + {255, 228, 196, 255}, // bisque + {255, 239, 213, 255}, // papaya whip + {240, 248, 255, 255}, // alice blue + {230, 230, 250, 255}, // lavender + {255, 240, 245, 255}, // lavender blush + {255, 228, 225, 255}, // misty rose + {245, 255, 250, 255}, // mint cream + + // earth tones (brighter versions) + {210, 180, 140, 255}, // tan + {222, 184, 135, 255}, // burlywood + {238, 203, 173, 255}, // navajo white + {255, 218, 185, 255}, // peach + {255, 165, 79, 255}, // sandy brown + {205, 133, 63, 255}, // peru + {160, 82, 45, 255}, // saddle brown (lighter) + {210, 105, 30, 255}, // chocolate (lighter) + + // jewel tones + {50, 205, 50, 255}, // lime green + {220, 20, 60, 255}, // crimson + {75, 0, 130, 255}, // indigo (brightened) + {128, 0, 128, 255}, // purple (brightened) + {165, 42, 42, 255}, // brown (brightened) + {128, 128, 0, 255}, // olive (brightened) + {0, 128, 128, 255}, // teal (brightened) + {128, 0, 0, 255}, // maroon (brightened) + + // unique/exotic colors + {255, 215, 0, 255}, // gold + {192, 192, 192, 255}, // silver + {255, 20, 147, 255}, // deep pink + {0, 191, 255, 255}, // deep sky blue + {30, 144, 255, 255}, // dodger blue + {255, 69, 0, 255}, // orange red + {154, 205, 50, 255}, // yellow green + {32, 178, 170, 255}, // light sea green + {60, 179, 113, 255}, // medium sea green + + // vibrant spectrum + {127, 255, 212, 255}, // aquamarine + {240, 230, 140, 255}, // khaki + {238, 232, 170, 255}, // pale goldenrod + {250, 240, 230, 255}, // linen + {253, 245, 230, 255}, // old lace + {255, 250, 240, 255}, // floral white + {248, 248, 255, 255}, // ghost white + {245, 245, 220, 255}, // beige +} + +var themePalettes = map[string]Palette{ + "vibrant": { + {255, 0, 150, 255}, // hot pink + {255, 100, 0, 255}, // orange + {255, 255, 0, 255}, // yellow + {0, 255, 100, 255}, // lime + {0, 150, 255, 255}, // sky blue + {150, 0, 255, 255}, // purple + }, + "sunset": { + {255, 94, 77, 255}, // coral + {255, 154, 0, 255}, // orange + {255, 206, 84, 255}, // yellow + {255, 118, 117, 255}, // light coral + {240, 147, 43, 255}, // dark orange + {235, 77, 75, 255}, // red + }, + "ocean": { + {0, 123, 255, 255}, // blue + {0, 200, 255, 255}, // light blue + {0, 255, 200, 255}, // cyan + {100, 200, 255, 255}, // sky blue + {0, 150, 200, 255}, // dark blue + {50, 255, 150, 255}, // aqua + }, + "neon": { + {255, 0, 255, 255}, // magenta + {0, 255, 255, 255}, // cyan + {255, 255, 0, 255}, // yellow + {255, 0, 100, 255}, // hot pink + {100, 255, 0, 255}, // lime + {150, 0, 255, 255}, // purple + }, + "forest": { + {34, 139, 34, 255}, // forest green + {107, 142, 35, 255}, // olive + {154, 205, 50, 255}, // yellow green + {50, 205, 50, 255}, // lime green + {0, 128, 0, 255}, // green + {85, 107, 47, 255}, // dark olive + }, + "cosmic": { + {138, 43, 226, 255}, // blue violet + {75, 0, 130, 255}, // indigo + {148, 0, 211, 255}, // dark violet + {255, 20, 147, 255}, // deep pink + {255, 0, 255, 255}, // magenta + {72, 61, 139, 255}, // dark slate blue + }, +} + +func getRandomTextColors() (color.RGBA, color.RGBA) { + textColor := textColors[rand.IntN(len(textColors))] + + // calculate perceived brightness using luminance formula + // std. weights for human eye sensitivity: 0.299*R + 0.587*G + 0.114*B + brightness := 0.299*float64(textColor.R) + 0.587*float64(textColor.G) + 0.114*float64(textColor.B) + + var outlineColor color.RGBA + if brightness < 128 { // Dark text - use white outline + outlineColor = color.RGBA{255, 255, 255, 255} + } else { // Bright text - use black outline + outlineColor = color.RGBA{0, 0, 0, 255} + } + + return textColor, outlineColor +} + +func getThemePalette(name string) Palette { + if palette, exists := themePalettes[name]; exists { + return palette + } + return themePalettes["vibrant"] // default +} + +func getRandomThemePalette() (Palette, string) { + name := config.ColorThemes[rand.IntN(len(config.ColorThemes))] + return getThemePalette(name), name +} + +func (p Palette) interpolate(t float64) color.RGBA { + if len(p) == 0 { + return color.RGBA{128, 128, 128, 255} + } + + // clamp to to [0, 1] + if t < 0 { + t = 0 + } + if t > 1 { + t = 1 + } + + // calculate which colors to interpolate between + scaledT := t * float64(len(p)-1) + index := int(scaledT) + localT := scaledT - float64(index) + + if index >= len(p)-1 { + return p[len(p)-1] + } + + c1 := p[index] + c2 := p[index+1] + + // linear interpolation between the two colors + r := uint8(float64(c1.R)*(1-localT) + float64(c2.R)*localT) + g := uint8(float64(c1.G)*(1-localT) + float64(c2.G)*localT) + b := uint8(float64(c1.B)*(1-localT) + float64(c2.B)*localT) + a := uint8(float64(c1.A)*(1-localT) + float64(c2.A)*localT) + + return color.RGBA{r, g, b, a} +}