// Package imageprocessor provides image format conversion and resizing using libvips. package imageprocessor import ( "bytes" "context" "errors" "fmt" "io" "sync" "github.com/davidbyttow/govips/v2/vips" ) // vipsOnce ensures vips is initialized exactly once. var vipsOnce sync.Once //nolint:gochecknoglobals // package-level sync.Once for one-time vips init // initVips initializes libvips with quiet logging. func initVips() { vipsOnce.Do(func() { vips.LoggingSettings(nil, vips.LogLevelError) vips.Startup(nil) }) } // Format represents supported output image formats. type Format string // Supported image output formats. const ( FormatOriginal Format = "orig" FormatJPEG Format = "jpeg" FormatPNG Format = "png" FormatWebP Format = "webp" FormatAVIF Format = "avif" FormatGIF Format = "gif" ) // FitMode represents how to fit an image into requested dimensions. type FitMode string // Supported image fit modes. const ( FitCover FitMode = "cover" FitContain FitMode = "contain" FitFill FitMode = "fill" FitInside FitMode = "inside" FitOutside FitMode = "outside" ) // ErrInvalidFitMode is returned when an invalid fit mode is provided. var ErrInvalidFitMode = errors.New("invalid fit mode") // Size represents requested image dimensions. type Size struct { Width int Height int } // Request holds the parameters for image processing. type Request struct { Size Size Format Format Quality int FitMode FitMode } // Result contains the output of image processing. type Result struct { // Content is the processed image data. Content io.ReadCloser // ContentLength is the size in bytes. ContentLength int64 // ContentType is the MIME type of the output. ContentType string // Width is the output image width. Width int // Height is the output image height. Height int // InputWidth is the original image width before processing. InputWidth int // InputHeight is the original image height before processing. InputHeight int // InputFormat is the detected input format (e.g., "jpeg", "png"). InputFormat string } // MaxInputDimension is the maximum allowed width or height for input images. // Images larger than this are rejected to prevent DoS via decompression bombs. const MaxInputDimension = 8192 // DefaultMaxInputBytes is the default maximum input size in bytes (50 MiB). // This matches the default upstream fetcher limit. const DefaultMaxInputBytes = 50 << 20 // ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension. var ErrInputTooLarge = errors.New("input image dimensions exceed maximum") // ErrInputDataTooLarge is returned when the raw input data exceeds the configured byte limit. var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size") // ErrUnsupportedOutputFormat is returned when the requested output format is not supported. var ErrUnsupportedOutputFormat = errors.New("unsupported output format") // ImageProcessor implements image transformation using libvips via govips. type ImageProcessor struct { maxInputBytes int64 } // Params holds configuration for creating an ImageProcessor. // Zero values use sensible defaults (MaxInputBytes defaults to DefaultMaxInputBytes). type Params struct { // MaxInputBytes is the maximum allowed input size in bytes. // If <= 0, DefaultMaxInputBytes is used. MaxInputBytes int64 } // New creates a new image processor with the given parameters. // A zero-value Params{} uses sensible defaults. func New(params Params) *ImageProcessor { initVips() maxInputBytes := params.MaxInputBytes if maxInputBytes <= 0 { maxInputBytes = DefaultMaxInputBytes } return &ImageProcessor{ maxInputBytes: maxInputBytes, } } // Process transforms an image according to the request. func (p *ImageProcessor) Process( _ context.Context, input io.Reader, req *Request, ) (*Result, error) { // Read input with a size limit to prevent unbounded memory consumption. // We read at most maxInputBytes+1 so we can detect if the input exceeds // the limit without consuming additional memory. limited := io.LimitReader(input, p.maxInputBytes+1) data, err := io.ReadAll(limited) if err != nil { return nil, fmt.Errorf("failed to read input: %w", err) } if int64(len(data)) > p.maxInputBytes { return nil, ErrInputDataTooLarge } // Decode image img, err := vips.NewImageFromBuffer(data) if err != nil { return nil, fmt.Errorf("failed to decode image: %w", err) } defer img.Close() // Get original dimensions origWidth := img.Width() origHeight := img.Height() // Detect input format inputFormat := p.detectFormat(img) // Validate input dimensions to prevent DoS via decompression bombs if origWidth > MaxInputDimension || origHeight > MaxInputDimension { return nil, ErrInputTooLarge } // Determine target dimensions targetWidth := req.Size.Width targetHeight := req.Size.Height // Handle dimension calculation if targetWidth == 0 && targetHeight == 0 { // Both are 0: keep original size targetWidth = origWidth targetHeight = origHeight } else if targetWidth == 0 { // Only height specified: calculate width proportionally targetWidth = origWidth * targetHeight / origHeight } else if targetHeight == 0 { // Only width specified: calculate height proportionally targetHeight = origHeight * targetWidth / origWidth } // Resize if needed if targetWidth != origWidth || targetHeight != origHeight { if err := p.resize(img, targetWidth, targetHeight, req.FitMode); err != nil { return nil, fmt.Errorf("failed to resize: %w", err) } } // Determine output format outputFormat := req.Format if outputFormat == FormatOriginal || outputFormat == "" { outputFormat = p.formatFromString(inputFormat) } // Encode to target format output, err := p.encode(img, outputFormat, req.Quality) if err != nil { return nil, fmt.Errorf("failed to encode: %w", err) } return &Result{ Content: io.NopCloser(bytes.NewReader(output)), ContentLength: int64(len(output)), ContentType: FormatToMIME(outputFormat), Width: img.Width(), Height: img.Height(), InputWidth: origWidth, InputHeight: origHeight, InputFormat: inputFormat, }, nil } // SupportedInputFormats returns MIME types this processor can read. func (p *ImageProcessor) SupportedInputFormats() []string { return []string{ "image/jpeg", "image/png", "image/gif", "image/webp", "image/avif", } } // SupportedOutputFormats returns formats this processor can write. func (p *ImageProcessor) SupportedOutputFormats() []Format { return []Format{ FormatJPEG, FormatPNG, FormatGIF, FormatWebP, FormatAVIF, } } // FormatToMIME converts a Format to its MIME type string. func FormatToMIME(format Format) string { switch format { case FormatJPEG: return "image/jpeg" case FormatPNG: return "image/png" case FormatWebP: return "image/webp" case FormatGIF: return "image/gif" case FormatAVIF: return "image/avif" default: return "application/octet-stream" } } // detectFormat returns the format string from a vips image. func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string { format := img.Format() switch format { case vips.ImageTypeJPEG: return "jpeg" case vips.ImageTypePNG: return "png" case vips.ImageTypeGIF: return "gif" case vips.ImageTypeWEBP: return "webp" case vips.ImageTypeAVIF, vips.ImageTypeHEIF: return "avif" default: return "unknown" } } // resize resizes the image according to the fit mode. func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMode) error { switch fit { case FitCover, "": // Resize and crop to fill exact dimensions (default) return img.Thumbnail(width, height, vips.InterestingCentre) case FitContain: // Resize to fit within dimensions, maintaining aspect ratio imgW, imgH := img.Width(), img.Height() scaleW := float64(width) / float64(imgW) scaleH := float64(height) / float64(imgH) scale := min(scaleW, scaleH) newW := int(float64(imgW) * scale) newH := int(float64(imgH) * scale) return img.Thumbnail(newW, newH, vips.InterestingNone) case FitFill: // Resize to exact dimensions (may distort) return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce) case FitInside: // Same as contain, but only shrink if img.Width() <= width && img.Height() <= height { return nil // Already fits } imgW, imgH := img.Width(), img.Height() scaleW := float64(width) / float64(imgW) scaleH := float64(height) / float64(imgH) scale := min(scaleW, scaleH) newW := int(float64(imgW) * scale) newH := int(float64(imgH) * scale) return img.Thumbnail(newW, newH, vips.InterestingNone) case FitOutside: // Resize so smallest dimension fits, may exceed target on other dimension imgW, imgH := img.Width(), img.Height() scaleW := float64(width) / float64(imgW) scaleH := float64(height) / float64(imgH) scale := max(scaleW, scaleH) newW := int(float64(imgW) * scale) newH := int(float64(imgH) * scale) return img.Thumbnail(newW, newH, vips.InterestingNone) default: return fmt.Errorf("%w: %s", ErrInvalidFitMode, fit) } } const defaultQuality = 85 // encode encodes an image to the specified format. func (p *ImageProcessor) encode(img *vips.ImageRef, format Format, quality int) ([]byte, error) { if quality <= 0 { quality = defaultQuality } var params vips.ExportParams switch format { case FormatJPEG: params = vips.ExportParams{ Format: vips.ImageTypeJPEG, Quality: quality, } case FormatPNG: params = vips.ExportParams{ Format: vips.ImageTypePNG, } case FormatGIF: params = vips.ExportParams{ Format: vips.ImageTypeGIF, } case FormatWebP: params = vips.ExportParams{ Format: vips.ImageTypeWEBP, Quality: quality, } case FormatAVIF: params = vips.ExportParams{ Format: vips.ImageTypeAVIF, Quality: quality, } default: return nil, fmt.Errorf("unsupported output format: %s", format) } output, _, err := img.Export(¶ms) if err != nil { return nil, err } return output, nil } // formatFromString converts a format string to Format. func (p *ImageProcessor) formatFromString(format string) Format { switch format { case "jpeg": return FormatJPEG case "png": return FormatPNG case "gif": return FormatGIF case "webp": return FormatWebP case "avif": return FormatAVIF default: return FormatJPEG } }