// 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. // //nolint:gochecknoglobals // package-level sync.Once for one-time vips init var vipsOnce sync.Once // 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, targetHeight := targetDimensions(req.Size, origWidth, origHeight) // Resize if needed if targetWidth != origWidth || targetHeight != origHeight { err := p.resize(img, targetWidth, targetHeight, req.FitMode) if 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 } // targetDimensions calculates the output dimensions for a requested size, // scaling proportionally when only one dimension is given and keeping the // original dimensions when both are zero. func targetDimensions(size Size, origWidth, origHeight int) (int, int) { switch { case size.Width == 0 && size.Height == 0: // Both are 0: keep original size return origWidth, origHeight case size.Width == 0: // Only height specified: calculate width proportionally return origWidth * size.Height / origHeight, size.Height case size.Height == 0: // Only width specified: calculate height proportionally return size.Width, origHeight * size.Width / origWidth default: return size.Width, size.Height } } // MIME types for the supported image formats. const ( mimeJPEG = "image/jpeg" mimePNG = "image/png" mimeGIF = "image/gif" mimeWebP = "image/webp" mimeAVIF = "image/avif" ) // SupportedInputFormats returns MIME types this processor can read. func (p *ImageProcessor) SupportedInputFormats() []string { return []string{ mimeJPEG, mimePNG, mimeGIF, mimeWebP, mimeAVIF, } } // 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 mimeJPEG case FormatPNG: return mimePNG case FormatWebP: return mimeWebP case FormatGIF: return mimeGIF case FormatAVIF: return mimeAVIF case FormatOriginal: return "application/octet-stream" 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 string(FormatAVIF) case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF, vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP, vips.ImageTypeJP2K, vips.ImageTypeJXL: return "unknown" 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, } case FormatOriginal: return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format) default: return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, 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 string(FormatAVIF): return FormatAVIF default: return FormatJPEG } }