Add pure Go image processor with resize and format conversion

Implements the Processor interface using disintegration/imaging library.
Supports JPEG, PNG, GIF, WebP decoding and JPEG, PNG, GIF encoding.
Includes all fit modes: cover, contain, fill, inside, outside.
This commit is contained in:
2026-01-08 03:54:50 -08:00
parent 9629139989
commit 5462c9222c
4 changed files with 503 additions and 10 deletions

View File

@@ -0,0 +1,244 @@
package imgcache
import (
"bytes"
"context"
"fmt"
"image"
"image/gif"
"image/jpeg"
"image/png"
"io"
"github.com/disintegration/imaging"
"golang.org/x/image/webp"
)
// ImageProcessor implements the Processor interface using pure Go libraries.
type ImageProcessor struct{}
// NewImageProcessor creates a new image processor.
func NewImageProcessor() *ImageProcessor {
return &ImageProcessor{}
}
// Process transforms an image according to the request.
func (p *ImageProcessor) Process(
_ context.Context,
input io.Reader,
req *ImageRequest,
) (*ProcessResult, error) {
// Read input
data, err := io.ReadAll(input)
if err != nil {
return nil, fmt.Errorf("failed to read input: %w", err)
}
// Decode image
img, format, err := p.decode(data)
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
// Get original dimensions
bounds := img.Bounds()
origWidth := bounds.Dx()
origHeight := bounds.Dy()
// Determine target dimensions
targetWidth := req.Size.Width
targetHeight := req.Size.Height
// If both are 0, keep original size
if targetWidth == 0 && targetHeight == 0 {
targetWidth = origWidth
targetHeight = origHeight
}
// Resize if needed
if targetWidth != origWidth || targetHeight != origHeight {
img = p.resize(img, targetWidth, targetHeight, req.FitMode)
}
// Determine output format
outputFormat := req.Format
if outputFormat == FormatOriginal || outputFormat == "" {
outputFormat = p.formatFromString(format)
}
// Encode to target format
output, err := p.encode(img, outputFormat, req.Quality)
if err != nil {
return nil, fmt.Errorf("failed to encode: %w", err)
}
finalBounds := img.Bounds()
return &ProcessResult{
Content: io.NopCloser(bytes.NewReader(output)),
ContentLength: int64(len(output)),
ContentType: ImageFormatToMIME(outputFormat),
Width: finalBounds.Dx(),
Height: finalBounds.Dy(),
}, nil
}
// SupportedInputFormats returns MIME types this processor can read.
func (p *ImageProcessor) SupportedInputFormats() []string {
return []string{
string(MIMETypeJPEG),
string(MIMETypePNG),
string(MIMETypeGIF),
string(MIMETypeWebP),
}
}
// SupportedOutputFormats returns formats this processor can write.
func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat {
return []ImageFormat{
FormatJPEG,
FormatPNG,
FormatGIF,
// WebP encoding not supported in pure Go, will fall back to PNG
}
}
// decode decodes image data into an image.Image.
func (p *ImageProcessor) decode(data []byte) (image.Image, string, error) {
r := bytes.NewReader(data)
// Try standard formats first
img, format, err := image.Decode(r)
if err == nil {
return img, format, nil
}
// Try WebP
r.Reset(data)
img, err = webp.Decode(r)
if err == nil {
return img, "webp", nil
}
return nil, "", fmt.Errorf("unsupported image format")
}
// resize resizes the image according to the fit mode.
func (p *ImageProcessor) resize(img image.Image, width, height int, fit FitMode) image.Image {
switch fit {
case FitCover:
// Resize and crop to fill exact dimensions
return imaging.Fill(img, width, height, imaging.Center, imaging.Lanczos)
case FitContain:
// Resize to fit within dimensions, maintaining aspect ratio
return imaging.Fit(img, width, height, imaging.Lanczos)
case FitFill:
// Resize to exact dimensions (may distort)
return imaging.Resize(img, width, height, imaging.Lanczos)
case FitInside:
// Same as contain, but only shrink
bounds := img.Bounds()
origW := bounds.Dx()
origH := bounds.Dy()
if origW <= width && origH <= height {
return img // Already fits
}
return imaging.Fit(img, width, height, imaging.Lanczos)
case FitOutside:
// Resize so smallest dimension fits, may exceed target on other dimension
bounds := img.Bounds()
origW := bounds.Dx()
origH := bounds.Dy()
scaleW := float64(width) / float64(origW)
scaleH := float64(height) / float64(origH)
scale := scaleW
if scaleH > scaleW {
scale = scaleH
}
newW := int(float64(origW) * scale)
newH := int(float64(origH) * scale)
return imaging.Resize(img, newW, newH, imaging.Lanczos)
default:
// Default to cover
return imaging.Fill(img, width, height, imaging.Center, imaging.Lanczos)
}
}
const defaultQuality = 85
// encode encodes an image to the specified format.
func (p *ImageProcessor) encode(img image.Image, format ImageFormat, quality int) ([]byte, error) {
if quality <= 0 {
quality = defaultQuality
}
var buf bytes.Buffer
switch format {
case FormatJPEG:
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality})
if err != nil {
return nil, err
}
case FormatPNG:
err := png.Encode(&buf, img)
if err != nil {
return nil, err
}
case FormatGIF:
err := gif.Encode(&buf, img, nil)
if err != nil {
return nil, err
}
case FormatWebP:
// Pure Go doesn't have WebP encoder, fall back to PNG
// TODO: Add WebP encoding support via external library
err := png.Encode(&buf, img)
if err != nil {
return nil, err
}
case FormatAVIF:
// AVIF not supported in pure Go, fall back to JPEG
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality})
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported output format: %s", format)
}
return buf.Bytes(), nil
}
// formatFromString converts a format string to ImageFormat.
func (p *ImageProcessor) formatFromString(format string) ImageFormat {
switch format {
case "jpeg":
return FormatJPEG
case "png":
return FormatPNG
case "gif":
return FormatGIF
case "webp":
return FormatWebP
default:
return FormatJPEG
}
}

View File

@@ -0,0 +1,242 @@
package imgcache
import (
"bytes"
"context"
"image"
"image/color"
"image/jpeg"
"image/png"
"io"
"testing"
)
// createTestJPEG creates a simple test JPEG image.
func createTestJPEG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
// Fill with a gradient
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.RGBA{
R: uint8(x * 255 / width),
G: uint8(y * 255 / height),
B: 128,
A: 255,
})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
t.Fatalf("failed to encode test JPEG: %v", err)
}
return buf.Bytes()
}
// createTestPNG creates a simple test PNG image.
func createTestPNG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.RGBA{
R: uint8(x * 255 / width),
G: uint8(y * 255 / height),
B: 128,
A: 255,
})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("failed to encode test PNG: %v", err)
}
return buf.Bytes()
}
func TestImageProcessor_ResizeJPEG(t *testing.T) {
proc := NewImageProcessor()
ctx := context.Background()
input := createTestJPEG(t, 800, 600)
req := &ImageRequest{
Size: Size{Width: 400, Height: 300},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
if result.Width != 400 {
t.Errorf("Process() width = %d, want 400", result.Width)
}
if result.Height != 300 {
t.Errorf("Process() height = %d, want 300", result.Height)
}
if result.ContentLength == 0 {
t.Error("Process() returned zero content length")
}
// Verify it's valid JPEG by reading the content
data, err := io.ReadAll(result.Content)
if err != nil {
t.Fatalf("failed to read result: %v", err)
}
mime, err := DetectFormat(data)
if err != nil {
t.Fatalf("DetectFormat() error = %v", err)
}
if mime != MIMETypeJPEG {
t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG)
}
}
func TestImageProcessor_ConvertToPNG(t *testing.T) {
proc := NewImageProcessor()
ctx := context.Background()
input := createTestJPEG(t, 200, 150)
req := &ImageRequest{
Size: Size{Width: 200, Height: 150},
Format: FormatPNG,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
data, err := io.ReadAll(result.Content)
if err != nil {
t.Fatalf("failed to read result: %v", err)
}
mime, err := DetectFormat(data)
if err != nil {
t.Fatalf("DetectFormat() error = %v", err)
}
if mime != MIMETypePNG {
t.Errorf("Output format = %v, want %v", mime, MIMETypePNG)
}
}
func TestImageProcessor_OriginalSize(t *testing.T) {
proc := NewImageProcessor()
ctx := context.Background()
input := createTestJPEG(t, 640, 480)
req := &ImageRequest{
Size: Size{Width: 0, Height: 0}, // Original size
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
if result.Width != 640 {
t.Errorf("Process() width = %d, want 640", result.Width)
}
if result.Height != 480 {
t.Errorf("Process() height = %d, want 480", result.Height)
}
}
func TestImageProcessor_FitContain(t *testing.T) {
proc := NewImageProcessor()
ctx := context.Background()
// 800x400 image (2:1 aspect) into 400x400 box with contain
// Should result in 400x200 (maintaining aspect ratio)
input := createTestJPEG(t, 800, 400)
req := &ImageRequest{
Size: Size{Width: 400, Height: 400},
Format: FormatJPEG,
Quality: 85,
FitMode: FitContain,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
// With contain, the image should fit within the box
if result.Width > 400 || result.Height > 400 {
t.Errorf("Process() size %dx%d exceeds 400x400 box", result.Width, result.Height)
}
}
func TestImageProcessor_ProcessPNG(t *testing.T) {
proc := NewImageProcessor()
ctx := context.Background()
input := createTestPNG(t, 400, 300)
req := &ImageRequest{
Size: Size{Width: 200, Height: 150},
Format: FormatPNG,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
if result.Width != 200 {
t.Errorf("Process() width = %d, want 200", result.Width)
}
if result.Height != 150 {
t.Errorf("Process() height = %d, want 150", result.Height)
}
}
func TestImageProcessor_ImplementsInterface(t *testing.T) {
// Verify ImageProcessor implements Processor interface
var _ Processor = (*ImageProcessor)(nil)
}
func TestImageProcessor_SupportedFormats(t *testing.T) {
proc := NewImageProcessor()
inputFormats := proc.SupportedInputFormats()
if len(inputFormats) == 0 {
t.Error("SupportedInputFormats() returned empty slice")
}
outputFormats := proc.SupportedOutputFormats()
if len(outputFormats) == 0 {
t.Error("SupportedOutputFormats() returned empty slice")
}
}