package utils import ( "bytes" "encoding/base64" "fmt" "image" "image/gif" "image/jpeg" "image/png" "net/http" "golang.org/x/image/draw" ) // CompressImage takes a base‑64‑encoded image (JPEG, PNG or GIF) and returns // a base‑64‑encoded version that is at most maxLength in size, or an error. func CompressImage(b64 string, maxLength int) (string, error) { raw, err := base64.StdEncoding.DecodeString(b64) if err != nil { return "", fmt.Errorf("base64 decode: %w", err) } if len(raw) <= maxLength { return b64, nil // small enough already } switch mime := http.DetectContentType(raw); mime { case "image/gif": return compressGIF(raw, maxLength) default: // jpeg, png, webp, etc. → treat as raster return compressRaster(raw, maxLength) } } // ---------- Raster path (jpeg / png / single‑frame gif) ---------- func compressRaster(src []byte, maxLength int) (string, error) { img, _, err := image.Decode(bytes.NewReader(src)) if err != nil { return "", fmt.Errorf("decode raster: %w", err) } quality := 95 for { var buf bytes.Buffer if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil { return "", fmt.Errorf("jpeg encode: %w", err) } if buf.Len() <= maxLength { return base64.StdEncoding.EncodeToString(buf.Bytes()), nil } if quality > 20 { quality -= 5 continue } // down‑scale 80% b := img.Bounds() if b.Dx() < 100 || b.Dy() < 100 { return "", fmt.Errorf("cannot compress below %.02fMiB without destroying image", float64(maxLength)/1048576.0) } dst := image.NewRGBA(image.Rect(0, 0, int(float64(b.Dx())*0.8), int(float64(b.Dy())*0.8))) draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Over, nil) img = dst quality = 95 // restart ladder } } // ---------- Animated GIF path ---------- func compressGIF(src []byte, maxLength int) (string, error) { g, err := gif.DecodeAll(bytes.NewReader(src)) if err != nil { return "", fmt.Errorf("gif decode: %w", err) } for { var buf bytes.Buffer if err := gif.EncodeAll(&buf, g); err != nil { return "", fmt.Errorf("gif encode: %w", err) } if buf.Len() <= maxLength { return base64.StdEncoding.EncodeToString(buf.Bytes()), nil } // down‑scale every frame by 80% w, h := g.Config.Width, g.Config.Height if w < 100 || h < 100 { return "", fmt.Errorf("cannot compress animated GIF below 5 MiB without excessive quality loss") } nw, nh := int(float64(w)*0.8), int(float64(h)*0.8) for i, frm := range g.Image { // convert paletted frame → RGBA for scaling rgba := image.NewRGBA(frm.Bounds()) draw.Draw(rgba, rgba.Bounds(), frm, frm.Bounds().Min, draw.Src) // scaled destination dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) draw.ApproxBiLinear.Scale(dst, dst.Bounds(), rgba, rgba.Bounds(), draw.Over, nil) // quantize back to paletted using default encoder quantizer paletted := image.NewPaletted(dst.Bounds(), nil) draw.FloydSteinberg.Draw(paletted, paletted.Bounds(), dst, dst.Bounds().Min) g.Image[i] = paletted } g.Config.Width, g.Config.Height = nw, nh // loop back and test size again … } } // ---------- Helpers for precise MIME decodes (optional) ---------- func decode(r *bytes.Reader, mime string) (image.Image, error) { switch mime { case "image/jpeg": return jpeg.Decode(r) case "image/png": return png.Decode(r) case "image/gif": // for single‑frame GIFs only – call DecodeAll in caller if animated return gif.Decode(r) default: i, _, err := image.Decode(r) return i, err // let the stdlib guess the format } }