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:
244
internal/imgcache/processor.go
Normal file
244
internal/imgcache/processor.go
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user