Quellcode durchsuchen

Universal options

DarthSim vor 5 Monaten
Ursprung
Commit
0426e97e3f
59 geänderte Dateien mit 2636 neuen und 1548 gelöschten Zeilen
  1. 2 2
      auximageprovider/provider.go
  2. 1 1
      auximageprovider/static_provider.go
  3. 2 2
      auximageprovider/static_provider_test.go
  4. 6 6
      config.go
  5. 9 8
      handlers/processing/handler.go
  6. 10 5
      handlers/processing/request.go
  7. 9 8
      handlers/processing/request_methods.go
  8. 6 5
      handlers/stream/handler.go
  9. 14 15
      handlers/stream/handler_test.go
  10. 5 5
      imgproxy.go
  11. 6 8
      integration/processing_handler_test.go
  12. 1 1
      monitoring/monitoring.go
  13. 177 201
      options/apply.go
  14. 5 70
      options/config.go
  15. 9 0
      options/errors.go
  16. 0 45
      options/factory.go
  17. 0 196
      options/gravity_options.go
  18. 90 0
      options/gravity_type.go
  19. 124 0
      options/keys/keys.go
  20. 394 0
      options/options.go
  21. 374 0
      options/options_test.go
  22. 96 66
      options/parse.go
  23. 40 0
      options/parser.go
  24. 13 13
      options/presets.go
  25. 12 23
      options/presets_test.go
  26. 72 271
      options/processing_options.go
  27. 242 179
      options/processing_options_test.go
  28. 12 12
      options/url.go
  29. 2 2
      options/url_options.go
  30. 6 2
      processing/apply_filters.go
  31. 1 1
      processing/calc_position.go
  32. 1 1
      processing/colorspace_to_result.go
  33. 28 6
      processing/config.go
  34. 6 4
      processing/crop.go
  35. 7 6
      processing/extend.go
  36. 1 1
      processing/fix_size.go
  37. 2 2
      processing/flatten.go
  38. 124 0
      processing/gravity.go
  39. 281 0
      processing/options.go
  40. 5 5
      processing/padding.go
  41. 15 7
      processing/pipeline.go
  42. 34 31
      processing/prepare.go
  43. 85 64
      processing/processing.go
  44. 144 182
      processing/processing_test.go
  45. 4 2
      processing/rotate_and_flip.go
  46. 9 7
      processing/save_fit_bytes.go
  47. 2 1
      processing/scale.go
  48. 5 2
      processing/scale_on_load.go
  49. 6 4
      processing/strip_metadata.go
  50. 8 2
      processing/trim.go
  51. 2 2
      processing/vector_guard_scale.go
  52. 55 38
      processing/watermark.go
  53. 31 3
      security/checker.go
  54. 3 3
      server/responsewriter/writer.go
  55. 4 4
      server/responsewriter/writer_test.go
  56. 9 4
      vips/color/color.go
  57. 19 0
      vips/color/errors.go
  58. 1 16
      vips/errors.go
  59. 5 4
      vips/vips.go

+ 2 - 2
auximageprovider/provider.go

@@ -12,7 +12,7 @@ import (
 )
 
 // Provider is an interface that provides image data and headers based
-// on processing options. It is used to retrieve WatermarkImage and FallbackImage.
+// on options. It is used to retrieve WatermarkImage and FallbackImage.
 type Provider interface {
-	Get(context.Context, *options.ProcessingOptions) (imagedata.ImageData, http.Header, error)
+	Get(context.Context, *options.Options) (imagedata.ImageData, http.Header, error)
 }

+ 1 - 1
auximageprovider/static_provider.go

@@ -16,7 +16,7 @@ type staticProvider struct {
 }
 
 // Get returns the static image data and headers stored in the provider.
-func (s *staticProvider) Get(_ context.Context, po *options.ProcessingOptions) (imagedata.ImageData, http.Header, error) {
+func (s *staticProvider) Get(_ context.Context, po *options.Options) (imagedata.ImageData, http.Header, error) {
 	return s.data, s.headers.Clone(), nil
 }
 

+ 2 - 2
auximageprovider/static_provider_test.go

@@ -57,7 +57,7 @@ func (s *ImageProviderTestSuite) SetupSubTest() {
 
 // Helper function to read data from ImageData
 func (s *ImageProviderTestSuite) readImageData(provider Provider) []byte {
-	imgData, _, err := provider.Get(s.T().Context(), &options.ProcessingOptions{})
+	imgData, _, err := provider.Get(s.T().Context(), options.New())
 	s.Require().NoError(err)
 	s.Require().NotNil(imgData)
 	defer imgData.Close()
@@ -136,7 +136,7 @@ func (s *ImageProviderTestSuite) TestNewProvider() {
 				)
 			},
 			validateFunc: func(provider Provider) {
-				imgData, headers, err := provider.Get(s.T().Context(), &options.ProcessingOptions{})
+				imgData, headers, err := provider.Get(s.T().Context(), options.New())
 				s.Require().NoError(err)
 				s.Require().NotNil(imgData)
 				defer imgData.Close()

+ 6 - 6
config.go

@@ -29,7 +29,7 @@ type Config struct {
 	Server         server.Config
 	Security       security.Config
 	Processing     processing.Config
-	Options        options.Config
+	OptionsParser  options.Config
 }
 
 // NewDefaultConfig creates a new default configuration
@@ -43,10 +43,10 @@ func NewDefaultConfig() Config {
 			Processing: processinghandler.NewDefaultConfig(),
 			Stream:     streamhandler.NewDefaultConfig(),
 		},
-		Server:     server.NewDefaultConfig(),
-		Security:   security.NewDefaultConfig(),
-		Processing: processing.NewDefaultConfig(),
-		Options:    options.NewDefaultConfig(),
+		Server:        server.NewDefaultConfig(),
+		Security:      security.NewDefaultConfig(),
+		Processing:    processing.NewDefaultConfig(),
+		OptionsParser: options.NewDefaultConfig(),
 	}
 }
 
@@ -88,7 +88,7 @@ func LoadConfigFromEnv(c *Config) (*Config, error) {
 		return nil, err
 	}
 
-	if _, err = options.LoadConfigFromEnv(&c.Options); err != nil {
+	if _, err = options.LoadConfigFromEnv(&c.OptionsParser); err != nil {
 		return nil, err
 	}
 

+ 9 - 8
handlers/processing/handler.go

@@ -14,6 +14,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/monitoring"
 	"github.com/imgproxy/imgproxy/v3/monitoring/stats"
 	"github.com/imgproxy/imgproxy/v3/options"
+	"github.com/imgproxy/imgproxy/v3/options/keys"
 	"github.com/imgproxy/imgproxy/v3/processing"
 	"github.com/imgproxy/imgproxy/v3/security"
 	"github.com/imgproxy/imgproxy/v3/server"
@@ -24,10 +25,9 @@ import (
 type HandlerContext interface {
 	Workers() *workers.Workers
 	FallbackImage() auximageprovider.Provider
-	WatermarkImage() auximageprovider.Provider
 	ImageDataFactory() *imagedata.Factory
 	Security() *security.Checker
-	OptionsFactory() *options.Factory
+	OptionsParser() *options.Parser
 	Processor() *processing.Processor
 }
 
@@ -75,7 +75,7 @@ func (h *Handler) Execute(
 	}
 
 	// if processing options indicate raw image streaming, stream it and return
-	if po.Raw {
+	if po.GetBool(keys.Raw, false) {
 		return h.stream.Execute(ctx, req, imageURL, reqID, po, rw)
 	}
 
@@ -87,6 +87,7 @@ func (h *Handler) Execute(
 		rw:             rw,
 		config:         h.config,
 		po:             po,
+		secops:         h.Security().NewOptions(po),
 		imageURL:       imageURL,
 		monitoringMeta: mm,
 	}
@@ -98,7 +99,7 @@ func (h *Handler) Execute(
 func (h *Handler) newRequest(
 	ctx context.Context,
 	req *http.Request,
-) (string, *options.ProcessingOptions, monitoring.Meta, error) {
+) (string, *options.Options, monitoring.Meta, error) {
 	// let's extract signature and valid request path from a request
 	path, signature, err := handlers.SplitPathSignature(req)
 	if err != nil {
@@ -111,7 +112,7 @@ func (h *Handler) newRequest(
 	}
 
 	// parse image url and processing options
-	po, imageURL, err := h.OptionsFactory().ParsePath(path, req.Header)
+	po, imageURL, err := h.OptionsParser().ParsePath(path, req.Header)
 	if err != nil {
 		return "", nil, nil, ierrors.Wrap(err, 0, ierrors.WithCategory(handlers.CategoryPathParsing))
 	}
@@ -122,20 +123,20 @@ func (h *Handler) newRequest(
 	mm := monitoring.Meta{
 		monitoring.MetaSourceImageURL:    imageURL,
 		monitoring.MetaSourceImageOrigin: imageOrigin,
-		monitoring.MetaProcessingOptions: po.Diff().Flatten(),
+		monitoring.MetaOptions:           po.Map(),
 	}
 
 	// set error reporting and monitoring context
 	errorreport.SetMetadata(req, "Source Image URL", imageURL)
 	errorreport.SetMetadata(req, "Source Image Origin", imageOrigin)
-	errorreport.SetMetadata(req, "Processing Options", po)
+	errorreport.SetMetadata(req, "Options", po.NestedMap())
 
 	monitoring.SetMetadata(ctx, mm)
 
 	// verify that image URL came from the valid source
 	err = h.Security().VerifySourceURL(imageURL)
 	if err != nil {
-		return "", nil, mm, ierrors.Wrap(err, 0, ierrors.WithCategory(handlers.CategorySecurity))
+		return "", options.New(), mm, ierrors.Wrap(err, 0, ierrors.WithCategory(handlers.CategorySecurity))
 	}
 
 	return imageURL, po, mm, nil

+ 10 - 5
handlers/processing/request.go

@@ -12,6 +12,8 @@ import (
 	"github.com/imgproxy/imgproxy/v3/monitoring"
 	"github.com/imgproxy/imgproxy/v3/monitoring/stats"
 	"github.com/imgproxy/imgproxy/v3/options"
+	"github.com/imgproxy/imgproxy/v3/options/keys"
+	"github.com/imgproxy/imgproxy/v3/security"
 	"github.com/imgproxy/imgproxy/v3/server"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
@@ -24,20 +26,23 @@ type request struct {
 	req            *http.Request
 	rw             server.ResponseWriter
 	config         *Config
-	po             *options.ProcessingOptions
+	po             *options.Options
+	secops         security.Options
 	imageURL       string
 	monitoringMeta monitoring.Meta
 }
 
 // execute handles the actual processing logic
 func (r *request) execute(ctx context.Context) error {
+	outFormat := options.Get(r.po, keys.Format, imagetype.Unknown)
+
 	// Check if we can save the resulting image
-	canSave := vips.SupportsSave(r.po.Format) ||
-		r.po.Format == imagetype.Unknown ||
-		r.po.Format == imagetype.SVG
+	canSave := vips.SupportsSave(outFormat) ||
+		outFormat == imagetype.Unknown ||
+		outFormat == imagetype.SVG
 
 	if !canSave {
-		return handlers.NewCantSaveError(r.po.Format)
+		return handlers.NewCantSaveError(outFormat)
 	}
 
 	// Acquire worker

+ 9 - 8
handlers/processing/request_methods.go

@@ -15,6 +15,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/monitoring"
 	"github.com/imgproxy/imgproxy/v3/options"
+	"github.com/imgproxy/imgproxy/v3/options/keys"
 	"github.com/imgproxy/imgproxy/v3/processing"
 	"github.com/imgproxy/imgproxy/v3/server"
 )
@@ -66,7 +67,7 @@ func (r *request) makeDownloadOptions(ctx context.Context, h http.Header) imaged
 
 	return imagedata.DownloadOptions{
 		Header:           h,
-		MaxSrcFileSize:   r.po.SecurityOptions.MaxSrcFileSize,
+		MaxSrcFileSize:   r.secops.MaxSrcFileSize,
 		DownloadFinished: downloadFinished,
 	}
 }
@@ -135,7 +136,7 @@ func (r *request) handleDownloadError(
 // getFallbackImage returns fallback image if any
 func (r *request) getFallbackImage(
 	ctx context.Context,
-	po *options.ProcessingOptions,
+	po *options.Options,
 ) (imagedata.ImageData, http.Header) {
 	fbi := r.FallbackImage()
 
@@ -159,8 +160,8 @@ func (r *request) getFallbackImage(
 
 // processImage calls actual image processing
 func (r *request) processImage(ctx context.Context, originData imagedata.ImageData) (*processing.Result, error) {
-	defer monitoring.StartProcessingSegment(ctx, r.monitoringMeta.Filter(monitoring.MetaProcessingOptions))()
-	return r.Processor().ProcessImage(ctx, originData, r.po, r.WatermarkImage())
+	defer monitoring.StartProcessingSegment(ctx, r.monitoringMeta.Filter(monitoring.MetaOptions))()
+	return r.Processor().ProcessImage(ctx, originData, r.po, r.secops)
 }
 
 // writeDebugHeaders writes debug headers (X-Origin-*, X-Result-*) to the response
@@ -189,7 +190,7 @@ func (r *request) writeDebugHeaders(result *processing.Result, originData imaged
 
 // respondWithNotModified writes not-modified response
 func (r *request) respondWithNotModified() error {
-	r.rw.SetExpires(r.po.Expires)
+	r.rw.SetExpires(r.po.GetTime(keys.Expires))
 	r.rw.SetVary()
 
 	if r.config.LastModifiedEnabled {
@@ -224,12 +225,12 @@ func (r *request) respondWithImage(statusCode int, resultData imagedata.ImageDat
 	r.rw.SetContentLength(resultSize)
 	r.rw.SetContentDisposition(
 		r.imageURL,
-		r.po.Filename,
+		r.po.GetString(keys.Filename, ""),
 		resultData.Format().Ext(),
 		"",
-		r.po.ReturnAttachment,
+		r.po.GetBool(keys.ReturnAttachment, false),
 	)
-	r.rw.SetExpires(r.po.Expires)
+	r.rw.SetExpires(r.po.GetTime(keys.Expires))
 	r.rw.SetVary()
 	r.rw.SetCanonical(r.imageURL)
 

+ 6 - 5
handlers/stream/handler.go

@@ -14,6 +14,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/monitoring"
 	"github.com/imgproxy/imgproxy/v3/monitoring/stats"
 	"github.com/imgproxy/imgproxy/v3/options"
+	"github.com/imgproxy/imgproxy/v3/options/keys"
 	"github.com/imgproxy/imgproxy/v3/server"
 )
 
@@ -44,7 +45,7 @@ type request struct {
 	imageRequest *http.Request
 	imageURL     string
 	reqID        string
-	po           *options.ProcessingOptions
+	po           *options.Options
 	rw           server.ResponseWriter
 }
 
@@ -66,7 +67,7 @@ func (s *Handler) Execute(
 	userRequest *http.Request,
 	imageURL string,
 	reqID string,
-	po *options.ProcessingOptions,
+	po *options.Options,
 	rw server.ResponseWriter,
 ) error {
 	stream := &request{
@@ -117,7 +118,7 @@ func (s *request) execute(ctx context.Context) error {
 	s.rw.Passthrough(s.handler.config.PassthroughResponseHeaders...) // NOTE: priority? This is lowest as it was
 	s.rw.SetContentLength(int(res.ContentLength))
 	s.rw.SetCanonical(s.imageURL)
-	s.rw.SetExpires(s.po.Expires)
+	s.rw.SetExpires(s.po.GetTime(keys.Expires))
 
 	// Set the Content-Disposition header
 	s.setContentDisposition(r.URL().Path, res)
@@ -160,10 +161,10 @@ func (s *request) setContentDisposition(imagePath string, serverResponse *http.R
 
 	s.rw.SetContentDisposition(
 		imagePath,
-		s.po.Filename,
+		s.po.GetString(keys.Filename, ""),
 		"",
 		ct,
-		s.po.ReturnAttachment,
+		s.po.GetBool(keys.ReturnAttachment, false),
 	)
 }
 

+ 14 - 15
handlers/stream/handler_test.go

@@ -16,6 +16,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/httpheaders"
 	"github.com/imgproxy/imgproxy/v3/logger"
 	"github.com/imgproxy/imgproxy/v3/options"
+	"github.com/imgproxy/imgproxy/v3/options/keys"
 	"github.com/imgproxy/imgproxy/v3/server/responsewriter"
 	"github.com/imgproxy/imgproxy/v3/testutil"
 )
@@ -93,7 +94,7 @@ func (s *HandlerTestSuite) SetupSubTest() {
 func (s *HandlerTestSuite) execute(
 	imageURL string,
 	header http.Header,
-	po *options.ProcessingOptions,
+	po *options.Options,
 ) *http.Response {
 	imageURL = s.testServer().URL() + imageURL
 	req := httptest.NewRequest("GET", "/", nil)
@@ -115,7 +116,7 @@ func (s *HandlerTestSuite) TestHandlerBasicRequest() {
 
 	s.testServer().SetHeaders(httpheaders.ContentType, "image/png").SetBody(data)
 
-	res := s.execute("", nil, &options.ProcessingOptions{})
+	res := s.execute("", nil, options.New())
 
 	s.Require().Equal(200, res.StatusCode)
 	s.Require().Equal("image/png", res.Header.Get(httpheaders.ContentType))
@@ -140,7 +141,7 @@ func (s *HandlerTestSuite) TestHandlerResponseHeadersPassthrough() {
 		httpheaders.LastModified, "Wed, 21 Oct 2015 07:28:00 GMT",
 	).SetBody(data)
 
-	res := s.execute("", nil, &options.ProcessingOptions{})
+	res := s.execute("", nil, options.New())
 
 	s.Require().Equal(200, res.StatusCode)
 	s.Require().Equal("image/png", res.Header.Get(httpheaders.ContentType))
@@ -171,7 +172,7 @@ func (s *HandlerTestSuite) TestHandlerRequestHeadersPassthrough() {
 	h.Set(httpheaders.AcceptEncoding, "gzip")
 	h.Set(httpheaders.Range, "bytes=*")
 
-	res := s.execute("", h, &options.ProcessingOptions{})
+	res := s.execute("", h, options.New())
 
 	s.Require().Equal(200, res.StatusCode)
 	s.Require().Equal(etag, res.Header.Get(httpheaders.Etag))
@@ -183,10 +184,9 @@ func (s *HandlerTestSuite) TestHandlerContentDisposition() {
 
 	s.testServer().SetHeaders(httpheaders.ContentType, "image/png").SetBody(data)
 
-	po := &options.ProcessingOptions{
-		Filename:         "custom_name",
-		ReturnAttachment: true,
-	}
+	po := options.New()
+	po.Set(keys.Filename, "custom_name")
+	po.Set(keys.ReturnAttachment, true)
 
 	// Use a URL with a .png extension to help content disposition logic
 	res := s.execute("/test.png", nil, po)
@@ -344,11 +344,10 @@ func (s *HandlerTestSuite) TestHandlerCacheControl() {
 			s.rwConf().CacheControlPassthrough = tc.cacheControlPassthrough
 			s.rwConf().DefaultTTL = 4242
 
-			po := &options.ProcessingOptions{}
+			po := options.New()
 
 			if tc.timestampOffset != nil {
-				expires := time.Now().Add(*tc.timestampOffset)
-				po.Expires = &expires
+				po.Set(keys.Expires, time.Now().Add(*tc.timestampOffset))
 			}
 
 			res := s.execute("", nil, po)
@@ -375,7 +374,7 @@ func (s *HandlerTestSuite) TestHandlerSecurityHeaders() {
 
 	s.testServer().SetHeaders(httpheaders.ContentType, "image/png").SetBody(data)
 
-	res := s.execute("", nil, &options.ProcessingOptions{})
+	res := s.execute("", nil, options.New())
 
 	s.Require().Equal(http.StatusOK, res.StatusCode)
 	s.Require().Equal("script-src 'none'", res.Header.Get(httpheaders.ContentSecurityPolicy))
@@ -385,7 +384,7 @@ func (s *HandlerTestSuite) TestHandlerSecurityHeaders() {
 func (s *HandlerTestSuite) TestHandlerErrorResponse() {
 	s.testServer().SetStatusCode(http.StatusNotFound).SetBody([]byte("Not Found"))
 
-	res := s.execute("", nil, &options.ProcessingOptions{})
+	res := s.execute("", nil, options.New())
 
 	s.Require().Equal(http.StatusNotFound, res.StatusCode)
 }
@@ -409,7 +408,7 @@ func (s *HandlerTestSuite) TestHandlerCookiePassthrough() {
 	h := make(http.Header)
 	h.Set(httpheaders.Cookie, "test_cookie=test_value")
 
-	res := s.execute("", h, &options.ProcessingOptions{})
+	res := s.execute("", h, options.New())
 
 	s.Require().Equal(200, res.StatusCode)
 }
@@ -423,7 +422,7 @@ func (s *HandlerTestSuite) TestHandlerCanonicalHeader() {
 	for _, sc := range []bool{true, false} {
 		s.rwConf().SetCanonicalHeader = sc
 
-		res := s.execute("", nil, &options.ProcessingOptions{})
+		res := s.execute("", nil, options.New())
 
 		s.Require().Equal(200, res.StatusCode)
 

+ 5 - 5
imgproxy.go

@@ -43,7 +43,7 @@ type Imgproxy struct {
 	imageDataFactory *imagedata.Factory
 	handlers         ImgproxyHandlers
 	security         *security.Checker
-	optionsFactory   *options.Factory
+	optionsParser    *options.Parser
 	processor        *processing.Processor
 	config           *Config
 }
@@ -77,7 +77,7 @@ func New(ctx context.Context, config *Config) (*Imgproxy, error) {
 		return nil, err
 	}
 
-	processingOptionsFactory, err := options.NewFactory(&config.Options, security)
+	optionsParser, err := options.NewParser(&config.OptionsParser)
 	if err != nil {
 		return nil, err
 	}
@@ -95,7 +95,7 @@ func New(ctx context.Context, config *Config) (*Imgproxy, error) {
 		imageDataFactory: idf,
 		config:           config,
 		security:         security,
-		optionsFactory:   processingOptionsFactory,
+		optionsParser:    optionsParser,
 		processor:        processor,
 	}
 
@@ -216,8 +216,8 @@ func (i *Imgproxy) Security() *security.Checker {
 	return i.security
 }
 
-func (i *Imgproxy) OptionsFactory() *options.Factory {
-	return i.optionsFactory
+func (i *Imgproxy) OptionsParser() *options.Parser {
+	return i.optionsParser
 }
 
 func (i *Imgproxy) Processor() *processing.Processor {

+ 6 - 8
integration/processing_handler_test.go

@@ -8,7 +8,6 @@ import (
 	"testing"
 	"time"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/config/configurators"
 	"github.com/imgproxy/imgproxy/v3/fetcher"
 	"github.com/imgproxy/imgproxy/v3/httpheaders"
@@ -26,7 +25,6 @@ type ProcessingHandlerTestSuite struct {
 }
 
 func (s *ProcessingHandlerTestSuite) SetupTest() {
-	config.Reset() // We reset config only at the start of each test
 	s.Config().Fetcher.Transport.HTTP.AllowLoopbackSourceAddresses = true
 }
 
@@ -168,7 +166,7 @@ func (s *ProcessingHandlerTestSuite) TestResultingFormatNotSupported() {
 }
 
 func (s *ProcessingHandlerTestSuite) TestSkipProcessingConfig() {
-	s.Config().Options.SkipProcessingFormats = []imagetype.Type{imagetype.PNG}
+	s.Config().Processing.SkipProcessingFormats = []imagetype.Type{imagetype.PNG}
 
 	res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.png")
 
@@ -184,7 +182,7 @@ func (s *ProcessingHandlerTestSuite) TestSkipProcessingPO() {
 }
 
 func (s *ProcessingHandlerTestSuite) TestSkipProcessingSameFormat() {
-	s.Config().Options.SkipProcessingFormats = []imagetype.Type{imagetype.PNG}
+	s.Config().Processing.SkipProcessingFormats = []imagetype.Type{imagetype.PNG}
 
 	res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.png@png")
 
@@ -193,7 +191,7 @@ func (s *ProcessingHandlerTestSuite) TestSkipProcessingSameFormat() {
 }
 
 func (s *ProcessingHandlerTestSuite) TestSkipProcessingDifferentFormat() {
-	s.Config().Options.SkipProcessingFormats = []imagetype.Type{imagetype.PNG}
+	s.Config().Processing.SkipProcessingFormats = []imagetype.Type{imagetype.PNG}
 
 	res := s.GET("/unsafe/rs:fill:4:4/plain/local:///test1.png@jpg")
 
@@ -477,7 +475,7 @@ func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvg() {
 
 func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvgWithEnforceAvif() {
 	s.Config().Processing.AlwaysRasterizeSvg = true
-	s.Config().Options.EnforceWebp = true
+	s.Config().OptionsParser.EnforceWebp = true
 
 	res := s.GET("/unsafe/plain/local:///test1.svg", http.Header{"Accept": []string{"image/webp"}})
 
@@ -487,7 +485,7 @@ func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvgWithEnforceAvif() {
 
 func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvgDisabled() {
 	s.Config().Processing.AlwaysRasterizeSvg = false
-	s.Config().Options.EnforceWebp = true
+	s.Config().OptionsParser.EnforceWebp = true
 
 	res := s.GET("/unsafe/plain/local:///test1.svg")
 
@@ -497,7 +495,7 @@ func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvgDisabled() {
 
 func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvgWithFormat() {
 	s.Config().Processing.AlwaysRasterizeSvg = true
-	s.Config().Options.SkipProcessingFormats = []imagetype.Type{imagetype.SVG}
+	s.Config().Processing.SkipProcessingFormats = []imagetype.Type{imagetype.SVG}
 
 	res := s.GET("/unsafe/plain/local:///test1.svg@svg")
 

+ 1 - 1
monitoring/monitoring.go

@@ -14,7 +14,7 @@ import (
 const (
 	MetaSourceImageURL    = "imgproxy.source_image_url"
 	MetaSourceImageOrigin = "imgproxy.source_image_origin"
-	MetaProcessingOptions = "imgproxy.processing_options"
+	MetaOptions           = "imgproxy.options"
 )
 
 type Meta map[string]any

+ 177 - 201
options/apply.go

@@ -9,62 +9,63 @@ import (
 	"time"
 
 	"github.com/imgproxy/imgproxy/v3/imagetype"
-	"github.com/imgproxy/imgproxy/v3/vips"
+	"github.com/imgproxy/imgproxy/v3/options/keys"
+	"github.com/imgproxy/imgproxy/v3/vips/color"
 )
 
-func applyWidthOption(po *ProcessingOptions, args []string) error {
-	return parsePositiveInt(&po.Width, "width", args...)
+func applyWidthOption(o *Options, args []string) error {
+	return parsePositiveInt(o, keys.Width, args...)
 }
 
-func applyHeightOption(po *ProcessingOptions, args []string) error {
-	return parsePositiveInt(&po.Height, "height", args...)
+func applyHeightOption(o *Options, args []string) error {
+	return parsePositiveInt(o, keys.Height, args...)
 }
 
-func applyMinWidthOption(po *ProcessingOptions, args []string) error {
-	return parsePositiveInt(&po.MinWidth, "min width", args...)
+func applyMinWidthOption(o *Options, args []string) error {
+	return parsePositiveInt(o, keys.MinWidth, args...)
 }
 
-func applyMinHeightOption(po *ProcessingOptions, args []string) error {
-	return parsePositiveInt(&po.MinHeight, "min height", args...)
+func applyMinHeightOption(o *Options, args []string) error {
+	return parsePositiveInt(o, keys.MinHeight, args...)
 }
 
-func applyEnlargeOption(po *ProcessingOptions, args []string) error {
-	return parseBool(&po.Enlarge, "enlarge", args...)
+func applyEnlargeOption(o *Options, args []string) error {
+	return parseBool(o, keys.Enlarge, args...)
 }
 
-func applyExtendOption(po *ProcessingOptions, args []string) error {
-	return parseExtend(&po.Extend, "extend", args)
+func applyExtendOption(o *Options, args []string) error {
+	return parseExtend(o, keys.PrefixExtend, args)
 }
 
-func applyExtendAspectRatioOption(po *ProcessingOptions, args []string) error {
-	return parseExtend(&po.ExtendAspectRatio, "extend_aspect_ratio", args)
+func applyExtendAspectRatioOption(o *Options, args []string) error {
+	return parseExtend(o, keys.PrefixExtendAspectRatio, args)
 }
 
-func applySizeOption(po *ProcessingOptions, args []string) (err error) {
+func applySizeOption(o *Options, args []string) (err error) {
 	if err = ensureMaxArgs("size", args, 7); err != nil {
 		return
 	}
 
 	if len(args) >= 1 && len(args[0]) > 0 {
-		if err = applyWidthOption(po, args[0:1]); err != nil {
+		if err = applyWidthOption(o, args[0:1]); err != nil {
 			return
 		}
 	}
 
 	if len(args) >= 2 && len(args[1]) > 0 {
-		if err = applyHeightOption(po, args[1:2]); err != nil {
+		if err = applyHeightOption(o, args[1:2]); err != nil {
 			return
 		}
 	}
 
 	if len(args) >= 3 && len(args[2]) > 0 {
-		if err = applyEnlargeOption(po, args[2:3]); err != nil {
+		if err = applyEnlargeOption(o, args[2:3]); err != nil {
 			return
 		}
 	}
 
 	if len(args) >= 4 && len(args[3]) > 0 {
-		if err = applyExtendOption(po, args[3:]); err != nil {
+		if err = applyExtendOption(o, args[3:]); err != nil {
 			return
 		}
 	}
@@ -72,33 +73,33 @@ func applySizeOption(po *ProcessingOptions, args []string) (err error) {
 	return nil
 }
 
-func applyResizingTypeOption(po *ProcessingOptions, args []string) error {
-	if err := ensureMaxArgs("resizing type", args, 1); err != nil {
+func applyResizingTypeOption(o *Options, args []string) error {
+	if err := ensureMaxArgs(keys.ResizingType, args, 1); err != nil {
 		return err
 	}
 
 	if r, ok := resizeTypes[args[0]]; ok {
-		po.ResizingType = r
+		o.Set(keys.ResizingType, r)
 	} else {
-		return newOptionArgumentError("Invalid resize type: %s", args[0])
+		return newOptionArgumentError("Invalid %s: %s", keys.ResizingType, args[0])
 	}
 
 	return nil
 }
 
-func applyResizeOption(po *ProcessingOptions, args []string) error {
+func applyResizeOption(o *Options, args []string) error {
 	if err := ensureMaxArgs("resize", args, 8); err != nil {
 		return err
 	}
 
 	if len(args[0]) > 0 {
-		if err := applyResizingTypeOption(po, args[0:1]); err != nil {
+		if err := applyResizingTypeOption(o, args[0:1]); err != nil {
 			return err
 		}
 	}
 
 	if len(args) > 1 {
-		if err := applySizeOption(po, args[1:]); err != nil {
+		if err := applySizeOption(o, args[1:]); err != nil {
 			return err
 		}
 	}
@@ -106,130 +107,124 @@ func applyResizeOption(po *ProcessingOptions, args []string) error {
 	return nil
 }
 
-func applyZoomOption(po *ProcessingOptions, args []string) error {
-	nArgs := len(args)
-
+func applyZoomOption(o *Options, args []string) error {
 	if err := ensureMaxArgs("zoom", args, 2); err != nil {
 		return err
 	}
 
-	var z float64
-	if err := parsePositiveNonZeroFloat64(&z, "zoom", args[0]); err != nil {
+	if err := parsePositiveNonZeroFloat(o, keys.ZoomWidth, args[0]); err != nil {
 		return err
 	}
 
-	po.ZoomWidth = z
-	po.ZoomHeight = z
+	if len(args) < 2 {
+		o.CopyValue(keys.ZoomWidth, keys.ZoomHeight)
+		return nil
+	}
 
-	if nArgs > 1 {
-		if err := parsePositiveNonZeroFloat64(&po.ZoomHeight, "zoom height", args[1]); err != nil {
-			return err
-		}
+	if err := parsePositiveNonZeroFloat(o, keys.ZoomHeight, args[1]); err != nil {
+		return err
 	}
 
 	return nil
 }
 
-func applyDprOption(po *ProcessingOptions, args []string) error {
-	return parsePositiveNonZeroFloat64(&po.Dpr, "dpr", args...)
+func applyDprOption(o *Options, args []string) error {
+	return parsePositiveNonZeroFloat(o, keys.Dpr, args...)
 }
 
-func applyGravityOption(po *ProcessingOptions, args []string) error {
-	return parseGravity(&po.Gravity, "gravity", args, cropGravityTypes)
+func applyGravityOption(o *Options, args []string) error {
+	return parseGravity(o, keys.Gravity, args, cropGravityTypes)
 }
 
-func applyCropOption(po *ProcessingOptions, args []string) error {
-	if err := parsePositiveFloat64(&po.Crop.Width, "crop width", args[0]); err != nil {
+func applyCropOption(o *Options, args []string) error {
+	if err := parsePositiveFloat(o, keys.CropWidth, args[0]); err != nil {
 		return err
 	}
 
 	if len(args) > 1 {
-		if err := parsePositiveFloat64(&po.Crop.Height, "crop height", args[1]); err != nil {
+		if err := parsePositiveFloat(o, keys.CropHeight, args[1]); err != nil {
 			return err
 		}
 	}
 
 	if len(args) > 2 {
-		return parseGravity(&po.Crop.Gravity, "crop gravity", args[2:], cropGravityTypes)
+		return parseGravity(o, keys.CropGravity, args[2:], cropGravityTypes)
 	}
 
 	return nil
 }
 
-func applyPaddingOption(po *ProcessingOptions, args []string) error {
-	nArgs := len(args)
-
-	if nArgs < 1 || nArgs > 4 {
-		return newOptionArgumentError("Invalid padding arguments: %v", args)
+func applyPaddingOption(o *Options, args []string) error {
+	if err := ensureMaxArgs("padding", args, 4); err != nil {
+		return err
 	}
 
-	po.Padding.Enabled = true
-
-	if nArgs > 0 && len(args[0]) > 0 {
-		if err := parsePositiveInt(&po.Padding.Top, "padding top (+all)", args[0]); err != nil {
+	if len(args) > 0 && len(args[0]) > 0 {
+		if err := parsePositiveInt(o, keys.PaddingTop, args[0]); err != nil {
 			return err
 		}
-		po.Padding.Right = po.Padding.Top
-		po.Padding.Bottom = po.Padding.Top
-		po.Padding.Left = po.Padding.Top
 	}
 
-	if nArgs > 1 && len(args[1]) > 0 {
-		if err := parsePositiveInt(&po.Padding.Right, "padding right (+left)", args[1]); err != nil {
+	if len(args) > 1 && len(args[1]) > 0 {
+		if err := parsePositiveInt(o, keys.PaddingRight, args[1]); err != nil {
 			return err
 		}
-		po.Padding.Left = po.Padding.Right
+	} else {
+		o.CopyValue(keys.PaddingTop, keys.PaddingRight)
 	}
 
-	if nArgs > 2 && len(args[2]) > 0 {
-		if err := parsePositiveInt(&po.Padding.Bottom, "padding bottom", args[2]); err != nil {
+	if len(args) > 2 && len(args[2]) > 0 {
+		if err := parsePositiveInt(o, keys.PaddingBottom, args[2]); err != nil {
 			return err
 		}
+	} else {
+		o.CopyValue(keys.PaddingTop, keys.PaddingBottom)
 	}
 
-	if nArgs > 3 && len(args[3]) > 0 {
-		if err := parsePositiveInt(&po.Padding.Left, "padding left", args[3]); err != nil {
+	if len(args) > 3 && len(args[3]) > 0 {
+		if err := parsePositiveInt(o, keys.PaddingLeft, args[3]); err != nil {
 			return err
 		}
-	}
-
-	if po.Padding.Top == 0 && po.Padding.Right == 0 && po.Padding.Bottom == 0 && po.Padding.Left == 0 {
-		po.Padding.Enabled = false
+	} else {
+		o.CopyValue(keys.PaddingRight, keys.PaddingLeft)
 	}
 
 	return nil
 }
 
-func applyTrimOption(po *ProcessingOptions, args []string) error {
+func applyTrimOption(o *Options, args []string) error {
 	if err := ensureMaxArgs("trim", args, 4); err != nil {
 		return err
 	}
 
 	nArgs := len(args)
 
-	if err := parseFloat64(&po.Trim.Threshold, "trim threshold", args[0]); err != nil {
-		return err
+	if len(args[0]) > 0 {
+		if err := parseFloat(o, keys.TrimThreshold, args[0]); err != nil {
+			return err
+		}
+	} else {
+		o.Delete(keys.TrimThreshold)
 	}
 
-	po.Trim.Enabled = true
-
 	if nArgs > 1 && len(args[1]) > 0 {
-		if c, err := vips.ColorFromHex(args[1]); err == nil {
-			po.Trim.Color = c
-			po.Trim.Smart = false
+		if c, err := color.RGBFromHex(args[1]); err == nil {
+			o.Set(keys.TrimColor, c)
 		} else {
-			return newOptionArgumentError("Invalid trim color: %s", args[1])
+			return newOptionArgumentError("Invalid %s: %s", keys.TrimColor, args[1])
 		}
+	} else {
+		o.Delete(keys.TrimColor)
 	}
 
 	if nArgs > 2 && len(args[2]) > 0 {
-		if err := parseBool(&po.Trim.EqualHor, "trim equal horizontal", args[2]); err != nil {
+		if err := parseBool(o, keys.TrimEqualHor, args[2]); err != nil {
 			return err
 		}
 	}
 
 	if nArgs > 3 && len(args[3]) > 0 {
-		if err := parseBool(&po.Trim.EqualVer, "trim equal vertical", args[3]); err != nil {
+		if err := parseBool(o, keys.TrimEqualVer, args[3]); err != nil {
 			return err
 		}
 	}
@@ -237,26 +232,26 @@ func applyTrimOption(po *ProcessingOptions, args []string) error {
 	return nil
 }
 
-func applyRotateOption(po *ProcessingOptions, args []string) error {
-	if err := parseInt(&po.Rotate, "rotate", args...); err != nil {
+func applyRotateOption(o *Options, args []string) error {
+	if err := parseInt(o, keys.Rotate, args...); err != nil {
 		return err
 	}
 
-	if po.Rotate%90 != 0 {
+	if Get(o, keys.Rotate, 0)%90 != 0 {
 		return newOptionArgumentError("Rotation angle must be a multiple of 90")
 	}
 
 	return nil
 }
 
-func applyQualityOption(po *ProcessingOptions, args []string) error {
-	return parseQualityInt(&po.Quality, "quality", args...)
+func applyQualityOption(o *Options, args []string) error {
+	return parseQualityInt(o, keys.Quality, args...)
 }
 
-func applyFormatQualityOption(po *ProcessingOptions, args []string) error {
+func applyFormatQualityOption(o *Options, args []string) error {
 	argsLen := len(args)
 	if len(args)%2 != 0 {
-		return newOptionArgumentError("Missing quality for: %s", args[argsLen-1])
+		return newOptionArgumentError("Missing %s for: %s", keys.PrefixFormatQuality, args[argsLen-1])
 	}
 
 	for i := 0; i < argsLen; i += 2 {
@@ -265,107 +260,107 @@ func applyFormatQualityOption(po *ProcessingOptions, args []string) error {
 			return newOptionArgumentError("Invalid image format: %s", args[i])
 		}
 
-		var q int
-		if err := parseQualityInt(&q, args[i]+" quality", args[i+1]); err != nil {
+		if err := parseQualityInt(o, keys.FormatQuality(f), args[i+1]); err != nil {
 			return err
 		}
-
-		po.FormatQuality[f] = q
 	}
 
 	return nil
 }
 
-func applyMaxBytesOption(po *ProcessingOptions, args []string) error {
-	return parsePositiveInt(&po.MaxBytes, "max_bytes", args...)
+func applyMaxBytesOption(o *Options, args []string) error {
+	return parsePositiveInt(o, keys.MaxBytes, args...)
 }
 
-func applyBackgroundOption(po *ProcessingOptions, args []string) error {
+func applyBackgroundOption(o *Options, args []string) error {
 	switch len(args) {
 	case 1:
 		if len(args[0]) == 0 {
-			po.Flatten = false
-		} else if c, err := vips.ColorFromHex(args[0]); err == nil {
-			po.Flatten = true
-			po.Background = c
+			o.Delete(keys.Background)
+			return nil
+		}
+
+		if c, err := color.RGBFromHex(args[0]); err == nil {
+			o.Set(keys.Background, c)
 		} else {
-			return newOptionArgumentError("Invalid background argument: %s", err)
+			return newOptionArgumentError("Invalid %s argument: %s", keys.Background, err)
 		}
 
 	case 3:
-		po.Flatten = true
+		var c color.RGB
 
 		if r, err := strconv.ParseUint(args[0], 10, 8); err == nil && r <= 255 {
-			po.Background.R = uint8(r)
+			c.R = uint8(r)
 		} else {
-			return newOptionArgumentError("Invalid background red channel: %s", args[0])
+			return newOptionArgumentError("Invalid %s red channel: %s", keys.Background, args[0])
 		}
 
 		if g, err := strconv.ParseUint(args[1], 10, 8); err == nil && g <= 255 {
-			po.Background.G = uint8(g)
+			c.G = uint8(g)
 		} else {
-			return newOptionArgumentError("Invalid background green channel: %s", args[1])
+			return newOptionArgumentError("Invalid %s green channel: %s", keys.Background, args[1])
 		}
 
 		if b, err := strconv.ParseUint(args[2], 10, 8); err == nil && b <= 255 {
-			po.Background.B = uint8(b)
+			c.B = uint8(b)
 		} else {
-			return newOptionArgumentError("Invalid background blue channel: %s", args[2])
+			return newOptionArgumentError("Invalid %s blue channel: %s", keys.Background, args[2])
 		}
 
+		o.Set(keys.Background, c)
+
 	default:
-		return newOptionArgumentError("Invalid background arguments: %v", args)
+		return newOptionArgumentError("Invalid %s arguments: %v", keys.Background, args)
 	}
 
 	return nil
 }
 
-func applyBlurOption(po *ProcessingOptions, args []string) error {
-	return parsePositiveNonZeroFloat32(&po.Blur, "blur", args...)
+func applyBlurOption(o *Options, args []string) error {
+	return parsePositiveNonZeroFloat(o, keys.Blur, args...)
 }
 
-func applySharpenOption(po *ProcessingOptions, args []string) error {
-	return parsePositiveNonZeroFloat32(&po.Sharpen, "sharpen", args...)
+func applySharpenOption(o *Options, args []string) error {
+	return parsePositiveNonZeroFloat(o, keys.Sharpen, args...)
 }
 
-func applyPixelateOption(po *ProcessingOptions, args []string) error {
-	return parsePositiveInt(&po.Pixelate, "pixelate", args...)
+func applyPixelateOption(o *Options, args []string) error {
+	return parsePositiveInt(o, keys.Pixelate, args...)
 }
 
-func applyWatermarkOption(po *ProcessingOptions, args []string) error {
+func applyWatermarkOption(o *Options, args []string) error {
 	if err := ensureMaxArgs("watermark", args, 7); err != nil {
 		return err
 	}
 
-	if o, err := strconv.ParseFloat(args[0], 64); err == nil && o >= 0 && o <= 1 {
-		po.Watermark.Enabled = o > 0
-		po.Watermark.Opacity = o
+	if wo, err := strconv.ParseFloat(args[0], 64); err == nil && wo >= 0 && wo <= 1 {
+		o.Set(keys.WatermarkOpacity, wo)
 	} else {
-		return newOptionArgumentError("Invalid watermark opacity: %s", args[0])
+		return newOptionArgumentError("Invalid %s: %s", keys.WatermarkOpacity, args[0])
 	}
 
 	if len(args) > 1 && len(args[1]) > 0 {
-		if g, ok := gravityTypes[args[1]]; ok && slices.Contains(watermarkGravityTypes, g) {
-			po.Watermark.Position.Type = g
+		if pos, ok := gravityTypes[args[1]]; ok && slices.Contains(watermarkGravityTypes, pos) {
+			o.Set(keys.WatermarkPosition, pos)
 		} else {
-			return newOptionArgumentError("Invalid watermark position: %s", args[1])
+			return newOptionArgumentError("Invalid %s: %s", keys.WatermarkPosition, args[1])
 		}
 	}
 
 	if len(args) > 2 && len(args[2]) > 0 {
-		if err := parseFloat64(&po.Watermark.Position.X, "watermark X offset", args[2]); err != nil {
+		if err := parseFloat(o, keys.WatermarkXOffset, args[2]); err != nil {
 			return err
 		}
 	}
 
 	if len(args) > 3 && len(args[3]) > 0 {
-		if err := parseFloat64(&po.Watermark.Position.Y, "watermark Y offset", args[3]); err != nil {
+		if err := parseFloat(o, keys.WatermarkYOffset, args[3]); err != nil {
 			return err
 		}
 	}
 
 	if len(args) > 4 && len(args[4]) > 0 {
-		if err := parsePositiveNonZeroFloat64(&po.Watermark.Scale, "watermark scale", args[4]); err == nil {
+		if err := parsePositiveNonZeroFloat(o, keys.WatermarkScale, args[4]); err == nil {
 			return err
 		}
 	}
@@ -373,13 +368,13 @@ func applyWatermarkOption(po *ProcessingOptions, args []string) error {
 	return nil
 }
 
-func applyFormatOption(po *ProcessingOptions, args []string) error {
-	if err := ensureMaxArgs("format", args, 1); err != nil {
+func applyFormatOption(o *Options, args []string) error {
+	if err := ensureMaxArgs(keys.Format, args, 1); err != nil {
 		return err
 	}
 
 	if f, ok := imagetype.GetTypeByName(args[0]); ok {
-		po.Format = f
+		o.Set(keys.Format, f)
 	} else {
 		return newOptionArgumentError("Invalid image format: %s", args[0])
 	}
@@ -387,167 +382,148 @@ func applyFormatOption(po *ProcessingOptions, args []string) error {
 	return nil
 }
 
-func applyCacheBusterOption(po *ProcessingOptions, args []string) error {
-	if err := ensureMaxArgs("cache buster", args, 1); err != nil {
+func applyCacheBusterOption(o *Options, args []string) error {
+	if err := ensureMaxArgs(keys.CacheBuster, args, 1); err != nil {
 		return err
 	}
 
-	po.CacheBuster = args[0]
+	o.Set(keys.CacheBuster, args[0])
 
 	return nil
 }
 
-func applySkipProcessingFormatsOption(po *ProcessingOptions, args []string) error {
+func applySkipProcessingFormatsOption(o *Options, args []string) error {
 	for _, format := range args {
 		if f, ok := imagetype.GetTypeByName(format); ok {
-			po.SkipProcessingFormats = append(po.SkipProcessingFormats, f)
+			AppendToSlice(o, keys.SkipProcessing, f)
 		} else {
-			return newOptionArgumentError("Invalid image format in skip processing: %s", format)
+			return newOptionArgumentError("Invalid image format in %s: %s", keys.SkipProcessing, format)
 		}
 	}
 
 	return nil
 }
 
-func applyRawOption(po *ProcessingOptions, args []string) error {
-	return parseBool(&po.Raw, "raw", args...)
+func applyRawOption(o *Options, args []string) error {
+	return parseBool(o, keys.Raw, args...)
 }
 
-func applyFilenameOption(po *ProcessingOptions, args []string) error {
-	if err := ensureMaxArgs("filename", args, 2); err != nil {
+func applyFilenameOption(o *Options, args []string) error {
+	if err := ensureMaxArgs(keys.Filename, args, 2); err != nil {
 		return err
 	}
 
-	po.Filename = args[0]
+	filename := args[0]
 
-	if len(args) == 1 {
-		return nil
-	}
-
-	var b bool
-	if err := parseBool(&b, "filename is base64", args[1]); err != nil || !b {
-		return err
-	}
-
-	decoded, err := base64.RawURLEncoding.DecodeString(po.Filename)
-	if err != nil {
-		return newOptionArgumentError("Invalid filename encoding: %s", err)
+	if len(args) > 1 && len(args[1]) > 0 {
+		if encoded, _ := strconv.ParseBool(args[1]); encoded {
+			if decoded, err := base64.RawURLEncoding.DecodeString(filename); err == nil {
+				filename = string(decoded)
+			} else {
+				return newOptionArgumentError("Invalid %s encoding: %s", keys.Filename, err)
+			}
+		}
 	}
 
-	po.Filename = string(decoded)
+	o.Set(keys.Filename, filename)
 
 	return nil
 }
 
-func applyExpiresOption(po *ProcessingOptions, args []string) error {
-	if err := ensureMaxArgs("expires", args, 1); err != nil {
+func applyExpiresOption(o *Options, args []string) error {
+	if err := ensureMaxArgs(keys.Expires, args, 1); err != nil {
 		return err
 	}
 
 	timestamp, err := strconv.ParseInt(args[0], 10, 64)
 	if err != nil {
-		return newOptionArgumentError("Invalid expires argument: %v", args[0])
+		return newOptionArgumentError("Invalid %s argument: %v", keys.Expires, args[0])
 	}
 
 	if timestamp > 0 && timestamp < time.Now().Unix() {
 		return newOptionArgumentError("Expired URL")
 	}
 
-	expires := time.Unix(timestamp, 0)
-	po.Expires = &expires
+	o.Set(keys.Expires, time.Unix(timestamp, 0))
 
 	return nil
 }
 
-func applyStripMetadataOption(po *ProcessingOptions, args []string) error {
-	return parseBool(&po.StripMetadata, "strip metadata", args...)
+func applyStripMetadataOption(o *Options, args []string) error {
+	return parseBool(o, keys.StripMetadata, args...)
 }
 
-func applyKeepCopyrightOption(po *ProcessingOptions, args []string) error {
-	return parseBool(&po.KeepCopyright, "keep copyright", args...)
+func applyKeepCopyrightOption(o *Options, args []string) error {
+	return parseBool(o, keys.KeepCopyright, args...)
 }
 
-func applyStripColorProfileOption(po *ProcessingOptions, args []string) error {
-	return parseBool(&po.StripColorProfile, "strip color profile", args...)
+func applyStripColorProfileOption(o *Options, args []string) error {
+	return parseBool(o, keys.StripColorProfile, args...)
 }
 
-func applyAutoRotateOption(po *ProcessingOptions, args []string) error {
-	return parseBool(&po.AutoRotate, "auto rotate", args...)
+func applyAutoRotateOption(o *Options, args []string) error {
+	return parseBool(o, keys.AutoRotate, args...)
 }
 
-func applyEnforceThumbnailOption(po *ProcessingOptions, args []string) error {
-	return parseBool(&po.EnforceThumbnail, "enforce thumbnail", args...)
+func applyEnforceThumbnailOption(o *Options, args []string) error {
+	return parseBool(o, keys.EnforceThumbnail, args...)
 }
 
-func applyReturnAttachmentOption(po *ProcessingOptions, args []string) error {
-	return parseBool(&po.ReturnAttachment, "return_attachment", args...)
+func applyReturnAttachmentOption(o *Options, args []string) error {
+	return parseBool(o, keys.ReturnAttachment, args...)
 }
 
-func applyMaxSrcResolutionOption(po *ProcessingOptions, args []string) error {
-	if err := po.isSecurityOptionsAllowed(); err != nil {
+func applyMaxSrcResolutionOption(p *Parser, o *Options, args []string) error {
+	if err := p.IsSecurityOptionsAllowed(); err != nil {
 		return err
 	}
 
-	var v float64
-	if err := parsePositiveNonZeroFloat64(&v, "max_src_resolution", args...); err != nil {
-		return err
-	}
-
-	po.SecurityOptions.MaxSrcResolution = int(v * 1000000)
-
-	return nil
+	return parseResolution(o, keys.MaxSrcResolution, args...)
 }
 
-func applyMaxSrcFileSizeOption(po *ProcessingOptions, args []string) error {
-	if err := po.isSecurityOptionsAllowed(); err != nil {
+func applyMaxSrcFileSizeOption(p *Parser, o *Options, args []string) error {
+	if err := p.IsSecurityOptionsAllowed(); err != nil {
 		return err
 	}
 
-	return parseInt(&po.SecurityOptions.MaxSrcFileSize, "max_src_file_size", args...)
+	return parseInt(o, keys.MaxSrcFileSize, args...)
 }
 
-func applyMaxAnimationFramesOption(po *ProcessingOptions, args []string) error {
-	if err := po.isSecurityOptionsAllowed(); err != nil {
+func applyMaxAnimationFramesOption(p *Parser, o *Options, args []string) error {
+	if err := p.IsSecurityOptionsAllowed(); err != nil {
 		return err
 	}
 
-	return parsePositiveNonZeroInt(&po.SecurityOptions.MaxAnimationFrames, "max_animation_frames", args...)
+	return parsePositiveNonZeroInt(o, keys.MaxAnimationFrames, args...)
 }
 
-func applyMaxAnimationFrameResolutionOption(po *ProcessingOptions, args []string) error {
-	if err := po.isSecurityOptionsAllowed(); err != nil {
-		return err
-	}
-
-	var v float64
-	if err := parseFloat64(&v, "max_animation_frame_resolution", args...); err != nil {
+func applyMaxAnimationFrameResolutionOption(p *Parser, o *Options, args []string) error {
+	if err := p.IsSecurityOptionsAllowed(); err != nil {
 		return err
 	}
 
-	po.SecurityOptions.MaxAnimationFrameResolution = int(v * 1000000)
-
-	return nil
+	return parseResolution(o, keys.MaxAnimationFrameResolution, args...)
 }
 
-func applyMaxResultDimensionOption(po *ProcessingOptions, args []string) error {
-	if err := po.isSecurityOptionsAllowed(); err != nil {
+func applyMaxResultDimensionOption(p *Parser, o *Options, args []string) error {
+	if err := p.IsSecurityOptionsAllowed(); err != nil {
 		return err
 	}
 
-	return parseInt(&po.SecurityOptions.MaxResultDimension, "max_result_dimension", args...)
+	return parseInt(o, keys.MaxResultDimension, args...)
 }
 
-func applyPresetOption(f *Factory, po *ProcessingOptions, args []string, usedPresets ...string) error {
+func applyPresetOption(p *Parser, o *Options, args []string, usedPresets ...string) error {
 	for _, preset := range args {
-		if p, ok := f.presets[preset]; ok {
+		if pr, ok := p.presets[preset]; ok {
 			if slices.Contains(usedPresets, preset) {
 				slog.Warn(fmt.Sprintf("Recursive preset usage is detected: %s", preset))
 				continue
 			}
 
-			po.UsedPresets = append(po.UsedPresets, preset)
+			AppendToSlice(o, keys.UsedPresets, preset)
 
-			if err := f.applyURLOptions(po, p, true, append(usedPresets, preset)...); err != nil {
+			if err := p.applyURLOptions(o, pr, true, append(usedPresets, preset)...); err != nil {
 				return err
 			}
 		} else {

+ 5 - 70
options/config.go

@@ -2,13 +2,10 @@ package options
 
 import (
 	"errors"
-	"fmt"
-	"maps"
 	"slices"
 
 	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ensure"
-	"github.com/imgproxy/imgproxy/v3/imagetype"
 )
 
 // URLReplacement represents a URL replacement configuration
@@ -16,27 +13,13 @@ type URLReplacement = config.URLReplacement
 
 // Config represents the configuration for options processing
 type Config struct {
-	// Processing behavior defaults
-	StripMetadata     bool // Whether to strip metadata by default
-	KeepCopyright     bool // Whether to keep copyright information when stripping metadata
-	StripColorProfile bool // Whether to strip color profile by default
-	AutoRotate        bool // Whether to auto-rotate images by default
-	EnforceThumbnail  bool // Whether to enforce thumbnail extraction by default
-	ReturnAttachment  bool // Whether to return images as attachments by default
-
-	// Image processing formats
-	SkipProcessingFormats []imagetype.Type // List of formats to skip processing for
-
 	// Presets configuration
 	Presets     []string // Available presets
 	OnlyPresets bool     // Whether to allow only presets
 
-	// Quality settings
-	Quality       int                    // Default quality for image processing
-	FormatQuality map[imagetype.Type]int // Quality settings per image format
-
 	// Security and validation
 	AllowedProcessingOptions []string // List of allowed processing options
+	AllowSecurityOptions     bool     // Whether to allow security options in URLs
 
 	// Format preference and enforcement
 	AutoWebp    bool // Whether to automatically serve WebP when supported
@@ -54,30 +37,16 @@ type Config struct {
 	BaseURL                   string           // Base URL for relative URLs
 	URLReplacements           []URLReplacement // URL replacement rules
 	Base64URLIncludesFilename bool             // Whether base64 URLs include filename
-
-	AllowSecurityOptions bool // Whether to allow security options in URLs
 }
 
 // NewDefaultConfig creates a new default configuration for options processing
 func NewDefaultConfig() Config {
 	return Config{
-		// Processing behavior defaults (copied from global config defaults)
-		StripMetadata:     true,
-		KeepCopyright:     true,
-		StripColorProfile: true,
-		AutoRotate:        true,
-		EnforceThumbnail:  false,
-		ReturnAttachment:  false,
-
+		// Presets configuration
 		OnlyPresets: false,
 
-		// Quality settings (copied from global config defaults)
-		Quality: 80,
-		FormatQuality: map[imagetype.Type]int{
-			imagetype.WEBP: 79,
-			imagetype.AVIF: 63,
-			imagetype.JXL:  77,
-		},
+		// Security and validation
+		AllowSecurityOptions: false,
 
 		// Format preference and enforcement (copied from global config defaults)
 		AutoWebp:    false,
@@ -94,8 +63,6 @@ func NewDefaultConfig() Config {
 		ArgumentsSeparator:        ":",
 		BaseURL:                   "",
 		Base64URLIncludesFilename: false,
-
-		AllowSecurityOptions: false,
 	}
 }
 
@@ -103,27 +70,13 @@ func NewDefaultConfig() Config {
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
-	// Copy from global config variables
-	c.StripMetadata = config.StripMetadata
-	c.KeepCopyright = config.KeepCopyright
-	c.StripColorProfile = config.StripColorProfile
-	c.AutoRotate = config.AutoRotate
-	c.EnforceThumbnail = config.EnforceThumbnail
-	c.ReturnAttachment = config.ReturnAttachment
-
-	// Image processing formats
-	c.SkipProcessingFormats = slices.Clone(config.SkipProcessingFormats)
-
 	// Presets configuration
 	c.Presets = slices.Clone(config.Presets)
 	c.OnlyPresets = config.OnlyPresets
 
-	// Quality settings
-	c.Quality = config.Quality
-	c.FormatQuality = maps.Clone(config.FormatQuality)
-
 	// Security and validation
 	c.AllowedProcessingOptions = slices.Clone(config.AllowedProcessingOptions)
+	c.AllowSecurityOptions = config.AllowSecurityOptions
 
 	// Format preference and enforcement
 	c.AutoWebp = config.AutoWebp
@@ -142,29 +95,11 @@ func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c.URLReplacements = slices.Clone(config.URLReplacements)
 	c.Base64URLIncludesFilename = config.Base64URLIncludesFilename
 
-	c.AllowSecurityOptions = config.AllowSecurityOptions
-
 	return c, nil
 }
 
 // Validate validates the configuration values
 func (c *Config) Validate() error {
-	// Quality validation (copied from global config validation)
-	if c.Quality <= 0 {
-		return fmt.Errorf("quality should be greater than 0, now - %d", c.Quality)
-	} else if c.Quality > 100 {
-		return fmt.Errorf("quality can't be greater than 100, now - %d", c.Quality)
-	}
-
-	// Format quality validation
-	for format, quality := range c.FormatQuality {
-		if quality <= 0 {
-			return fmt.Errorf("format quality for %s should be greater than 0, now - %d", format, quality)
-		} else if quality > 100 {
-			return fmt.Errorf("format quality for %s can't be greater than 100, now - %d", format, quality)
-		}
-	}
-
 	// Arguments separator validation
 	if c.ArgumentsSeparator == "" {
 		return errors.New("arguments separator cannot be empty")

+ 9 - 0
options/errors.go

@@ -9,12 +9,21 @@ import (
 )
 
 type (
+	TypeMismatchError struct{ error }
+
 	InvalidURLError      string
 	UnknownOptionError   string
 	OptionArgumentError  string
 	SecurityOptionsError struct{}
 )
 
+func newTypeMismatchError(key string, exp, got any) error {
+	return ierrors.Wrap(
+		TypeMismatchError{fmt.Errorf("option %s is %T, not %T", key, exp, got)},
+		1,
+	)
+}
+
 func newInvalidURLError(format string, args ...interface{}) error {
 	return ierrors.Wrap(
 		InvalidURLError(fmt.Sprintf(format, args...)),

+ 0 - 45
options/factory.go

@@ -1,45 +0,0 @@
-package options
-
-import (
-	"github.com/imgproxy/imgproxy/v3/security"
-)
-
-// Presets is a map of preset names to their corresponding urlOptions
-type Presets = map[string]urlOptions
-
-// Factory creates ProcessingOptions instances
-type Factory struct {
-	config    *Config            // Factory configuration
-	security  *security.Checker  // Security checker for generating security options
-	presets   Presets            // Parsed presets
-	defaultPO *ProcessingOptions // Default processing options
-}
-
-// NewFactory creates new Factory instance
-func NewFactory(config *Config, security *security.Checker) (*Factory, error) {
-	if err := config.Validate(); err != nil {
-		return nil, err
-	}
-
-	f := &Factory{
-		config:    config,
-		security:  security,
-		presets:   make(map[string]urlOptions),
-		defaultPO: newDefaultProcessingOptions(config, security),
-	}
-
-	if err := f.parsePresets(); err != nil {
-		return nil, err
-	}
-
-	if err := f.validatePresets(); err != nil {
-		return nil, err
-	}
-
-	return f, nil
-}
-
-// NewProcessingOptions creates new ProcessingOptions instance
-func (f *Factory) NewProcessingOptions() *ProcessingOptions {
-	return f.defaultPO.clone()
-}

+ 0 - 196
options/gravity_options.go

@@ -1,196 +0,0 @@
-package options
-
-import (
-	"fmt"
-)
-
-type GravityType int
-
-const (
-	GravityUnknown GravityType = iota
-	GravityCenter
-	GravityNorth
-	GravityEast
-	GravitySouth
-	GravityWest
-	GravityNorthWest
-	GravityNorthEast
-	GravitySouthWest
-	GravitySouthEast
-	GravitySmart
-	GravityFocusPoint
-
-	// Watermark gravity types
-	GravityReplicate
-)
-
-var gravityTypes = map[string]GravityType{
-	"ce":   GravityCenter,
-	"no":   GravityNorth,
-	"ea":   GravityEast,
-	"so":   GravitySouth,
-	"we":   GravityWest,
-	"nowe": GravityNorthWest,
-	"noea": GravityNorthEast,
-	"sowe": GravitySouthWest,
-	"soea": GravitySouthEast,
-	"sm":   GravitySmart,
-	"fp":   GravityFocusPoint,
-	"re":   GravityReplicate,
-}
-
-var commonGravityTypes = []GravityType{
-	GravityCenter,
-	GravityNorth,
-	GravityEast,
-	GravitySouth,
-	GravityWest,
-	GravityNorthWest,
-	GravityNorthEast,
-	GravitySouthWest,
-	GravitySouthEast,
-}
-
-var cropGravityTypes = append(
-	[]GravityType{
-		GravitySmart,
-		GravityFocusPoint,
-	},
-	commonGravityTypes...,
-)
-
-var extendGravityTypes = append(
-	[]GravityType{
-		GravityFocusPoint,
-	},
-	commonGravityTypes...,
-)
-
-var watermarkGravityTypes = append(
-	[]GravityType{
-		GravityReplicate,
-	},
-	commonGravityTypes...,
-)
-
-var gravityTypesRotationMap = map[int]map[GravityType]GravityType{
-	90: {
-		GravityNorth:     GravityWest,
-		GravityEast:      GravityNorth,
-		GravitySouth:     GravityEast,
-		GravityWest:      GravitySouth,
-		GravityNorthWest: GravitySouthWest,
-		GravityNorthEast: GravityNorthWest,
-		GravitySouthWest: GravitySouthEast,
-		GravitySouthEast: GravityNorthEast,
-	},
-	180: {
-		GravityNorth:     GravitySouth,
-		GravityEast:      GravityWest,
-		GravitySouth:     GravityNorth,
-		GravityWest:      GravityEast,
-		GravityNorthWest: GravitySouthEast,
-		GravityNorthEast: GravitySouthWest,
-		GravitySouthWest: GravityNorthEast,
-		GravitySouthEast: GravityNorthWest,
-	},
-	270: {
-		GravityNorth:     GravityEast,
-		GravityEast:      GravitySouth,
-		GravitySouth:     GravityWest,
-		GravityWest:      GravityNorth,
-		GravityNorthWest: GravityNorthEast,
-		GravityNorthEast: GravitySouthEast,
-		GravitySouthWest: GravityNorthWest,
-		GravitySouthEast: GravitySouthWest,
-	},
-}
-
-var gravityTypesFlipMap = map[GravityType]GravityType{
-	GravityEast:      GravityWest,
-	GravityWest:      GravityEast,
-	GravityNorthWest: GravityNorthEast,
-	GravityNorthEast: GravityNorthWest,
-	GravitySouthWest: GravitySouthEast,
-	GravitySouthEast: GravitySouthWest,
-}
-
-func (gt GravityType) String() string {
-	for k, v := range gravityTypes {
-		if v == gt {
-			return k
-		}
-	}
-	return ""
-}
-
-func (gt GravityType) MarshalJSON() ([]byte, error) {
-	for k, v := range gravityTypes {
-		if v == gt {
-			return []byte(fmt.Sprintf("%q", k)), nil
-		}
-	}
-	return []byte("null"), nil
-}
-
-type GravityOptions struct {
-	Type GravityType
-	X, Y float64
-}
-
-func (g *GravityOptions) RotateAndFlip(angle int, flip bool) {
-	angle %= 360
-
-	if flip {
-		if gt, ok := gravityTypesFlipMap[g.Type]; ok {
-			g.Type = gt
-		}
-
-		switch g.Type {
-		case GravityCenter, GravityNorth, GravitySouth:
-			g.X = -g.X
-		case GravityFocusPoint:
-			g.X = 1.0 - g.X
-		}
-	}
-
-	if angle > 0 {
-		if rotMap := gravityTypesRotationMap[angle]; rotMap != nil {
-			if gt, ok := rotMap[g.Type]; ok {
-				g.Type = gt
-			}
-
-			switch angle {
-			case 90:
-				switch g.Type {
-				case GravityCenter, GravityEast, GravityWest:
-					g.X, g.Y = g.Y, -g.X
-				case GravityFocusPoint:
-					g.X, g.Y = g.Y, 1.0-g.X
-				default:
-					g.X, g.Y = g.Y, g.X
-				}
-			case 180:
-				switch g.Type {
-				case GravityCenter:
-					g.X, g.Y = -g.X, -g.Y
-				case GravityNorth, GravitySouth:
-					g.X = -g.X
-				case GravityEast, GravityWest:
-					g.Y = -g.Y
-				case GravityFocusPoint:
-					g.X, g.Y = 1.0-g.X, 1.0-g.Y
-				}
-			case 270:
-				switch g.Type {
-				case GravityCenter, GravityNorth, GravitySouth:
-					g.X, g.Y = -g.Y, g.X
-				case GravityFocusPoint:
-					g.X, g.Y = 1.0-g.Y, g.X
-				default:
-					g.X, g.Y = g.Y, g.X
-				}
-			}
-		}
-	}
-}

+ 90 - 0
options/gravity_type.go

@@ -0,0 +1,90 @@
+package options
+
+import "fmt"
+
+type GravityType int
+
+func (gt GravityType) String() string {
+	for k, v := range gravityTypes {
+		if v == gt {
+			return k
+		}
+	}
+	return ""
+}
+
+func (gt GravityType) MarshalJSON() ([]byte, error) {
+	for k, v := range gravityTypes {
+		if v == gt {
+			return []byte(fmt.Sprintf("%q", k)), nil
+		}
+	}
+	return []byte("null"), nil
+}
+
+const (
+	GravityUnknown GravityType = iota
+	GravityCenter
+	GravityNorth
+	GravityEast
+	GravitySouth
+	GravityWest
+	GravityNorthWest
+	GravityNorthEast
+	GravitySouthWest
+	GravitySouthEast
+	GravitySmart
+	GravityFocusPoint
+
+	// Watermark gravity types
+	GravityReplicate
+)
+
+var gravityTypes = map[string]GravityType{
+	"ce":   GravityCenter,
+	"no":   GravityNorth,
+	"ea":   GravityEast,
+	"so":   GravitySouth,
+	"we":   GravityWest,
+	"nowe": GravityNorthWest,
+	"noea": GravityNorthEast,
+	"sowe": GravitySouthWest,
+	"soea": GravitySouthEast,
+	"sm":   GravitySmart,
+	"fp":   GravityFocusPoint,
+	"re":   GravityReplicate,
+}
+
+var commonGravityTypes = []GravityType{
+	GravityCenter,
+	GravityNorth,
+	GravityEast,
+	GravitySouth,
+	GravityWest,
+	GravityNorthWest,
+	GravityNorthEast,
+	GravitySouthWest,
+	GravitySouthEast,
+}
+
+var cropGravityTypes = append(
+	[]GravityType{
+		GravitySmart,
+		GravityFocusPoint,
+	},
+	commonGravityTypes...,
+)
+
+var extendGravityTypes = append(
+	[]GravityType{
+		GravityFocusPoint,
+	},
+	commonGravityTypes...,
+)
+
+var watermarkGravityTypes = append(
+	[]GravityType{
+		GravityReplicate,
+	},
+	commonGravityTypes...,
+)

+ 124 - 0
options/keys/keys.go

@@ -0,0 +1,124 @@
+package keys
+
+import "fmt"
+
+const (
+	Width  = "width"
+	Height = "height"
+
+	MinWidth  = "min-width"
+	MinHeight = "min-height"
+
+	Enlarge = "enlarge"
+
+	ExtendEnabled        = PrefixExtend + SuffixEnabled
+	ExtendGravity        = PrefixExtend + SuffixGravity
+	ExtendGravityType    = ExtendGravity + SuffixType
+	ExtendGravityXOffset = ExtendGravity + SuffixXOffset
+	ExtendGravityYOffset = ExtendGravity + SuffixYOffset
+
+	ExtendAspectRatioEnabled        = PrefixExtendAspectRatio + SuffixEnabled
+	ExtendAspectRatioGravity        = PrefixExtendAspectRatio + SuffixGravity
+	ExtendAspectRatioGravityType    = ExtendAspectRatioGravity + SuffixType
+	ExtendAspectRatioGravityXOffset = ExtendAspectRatioGravity + SuffixXOffset
+	ExtendAspectRatioGravityYOffset = ExtendAspectRatioGravity + SuffixYOffset
+
+	ResizingType = "resizing_type"
+
+	ZoomWidth  = "zoom_width"
+	ZoomHeight = "zoom_height"
+
+	Dpr = "dpr"
+
+	Gravity        = "gravity"
+	GravityType    = Gravity + SuffixType
+	GravityXOffset = Gravity + SuffixXOffset
+	GravityYOffset = Gravity + SuffixYOffset
+
+	CropWidth          = "crop.width"
+	CropHeight         = "crop.height"
+	CropGravity        = "crop" + SuffixGravity
+	CropGravityType    = CropGravity + SuffixType
+	CropGravityXOffset = CropGravity + SuffixXOffset
+	CropGravityYOffset = CropGravity + SuffixYOffset
+
+	PaddingTop    = "padding.top"
+	PaddingRight  = "padding.right"
+	PaddingBottom = "padding.bottom"
+	PaddingLeft   = "padding.left"
+
+	TrimThreshold = "trim.threshold"
+	TrimColor     = "trim.color"
+	TrimEqualHor  = "trim.equal_horizontal"
+	TrimEqualVer  = "trim.equal_vertical"
+
+	Rotate = "rotate"
+
+	Quality = "quality"
+
+	MaxBytes = "max_bytes"
+
+	Background = "background"
+
+	Blur     = "blur"
+	Sharpen  = "sharpen"
+	Pixelate = "pixelate"
+
+	WatermarkOpacity  = "watermark.opacity"
+	WatermarkPosition = "watermark.position"
+	WatermarkXOffset  = "watermark" + SuffixXOffset
+	WatermarkYOffset  = "watermark" + SuffixYOffset
+	WatermarkScale    = "watermark.scale"
+
+	Format = "format"
+
+	CacheBuster = "cachebuster"
+
+	SkipProcessing = "skip_processing"
+
+	Raw = "raw"
+
+	Filename = "filename"
+
+	Expires = "expires"
+
+	StripMetadata     = "strip_metadata"
+	KeepCopyright     = "keep_copyright"
+	StripColorProfile = "strip_color_profile"
+
+	AutoRotate = "auto_rotate"
+
+	EnforceThumbnail = "enforce_thumbnail"
+
+	ReturnAttachment = "return_attachment"
+
+	MaxSrcResolution            = "max_src_resolution"
+	MaxSrcFileSize              = "max_src_file_size"
+	MaxAnimationFrames          = "max_animation_frames"
+	MaxAnimationFrameResolution = "max_animation_frame_resolution"
+	MaxResultDimension          = "max_result_dimension"
+
+	PreferWebP  = "prefer_webp"
+	EnforceWebP = "enforce_webp"
+	PreferAvif  = "prefer_avif"
+	EnforceAvif = "enforce_avif"
+	PreferJxl   = "prefer_jxl"
+	EnforceJxl  = "enforce_jxl"
+
+	UsedPresets = "used_presets"
+
+	PrefixExtend            = "extend"
+	PrefixExtendAspectRatio = "extend_aspect_ratio"
+
+	PrefixFormatQuality = "format_quality"
+
+	SuffixEnabled = ".enabled"
+	SuffixGravity = ".gravity"
+	SuffixType    = ".type"
+	SuffixXOffset = ".x_offset"
+	SuffixYOffset = ".y_offset"
+)
+
+func FormatQuality(format fmt.Stringer) string {
+	return PrefixFormatQuality + "." + format.String()
+}

+ 394 - 0
options/options.go

@@ -0,0 +1,394 @@
+package options
+
+import (
+	"encoding/json"
+	"fmt"
+	"iter"
+	"log/slog"
+	"maps"
+	"slices"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// Options is an interface for storing and retrieving dynamic option values.
+//
+// Copies of Options are shallow, meaning the underlying map is shared.
+type Options struct {
+	m map[string]any
+
+	main  *Options // Pointer to the main Options if this is a child
+	child *Options // Pointer to the child Options
+}
+
+// New creates a new Options map
+func New() *Options {
+	return &Options{
+		m: make(map[string]any),
+	}
+}
+
+// Main returns the main Options if this is a child Options.
+// If this is the main Options, it returns itself.
+func (o *Options) Main() *Options {
+	if o.main == nil {
+		return o
+	}
+
+	return o.main
+}
+
+// Child returns the child Options if any.
+func (o *Options) Child() *Options {
+	return o.child
+}
+
+// Descendants returns an iterator over the child Options if any.
+func (o *Options) Descendants() iter.Seq[*Options] {
+	return func(yield func(*Options) bool) {
+		for c := o.child; c != nil; c = c.child {
+			if !yield(c) {
+				return
+			}
+		}
+	}
+}
+
+// HasChild checks if the Options has a child Options.
+func (o *Options) HasChild() bool {
+	return o.child != nil
+}
+
+// AddChild creates a new child Options that inherits from the current Options.
+// If the current Options already has a child, it returns the existing child.
+func (o *Options) AddChild() *Options {
+	if o.child != nil {
+		return o.child
+	}
+
+	child := New()
+	child.main = o.Main()
+	o.child = child
+
+	return child
+}
+
+// Depth returns the depth of the Options in the hierarchy.
+// The main Options has a depth of 0, its child has a depth of 1, and so on.
+func (o *Options) Depth() int {
+	depth := 0
+
+	for p := o.main; p != nil && p != o; p = p.child {
+		depth++
+	}
+
+	return depth
+}
+
+// Get retrieves a value of the specified type from the options.
+// If the key does not exist, it returns the provided default value.
+// If the value exists but is of a different type, it panics.
+func Get[T any](o *Options, key string, def T) T {
+	v, ok := o.m[key]
+	if !ok {
+		return def
+	}
+
+	if vt, ok := v.(T); ok {
+		return vt
+	}
+
+	panic(newTypeMismatchError(key, v, def))
+}
+
+// AppendToSlice appends a value to a slice option.
+// If the option does not exist, it creates a new slice with the value.
+func AppendToSlice[T any](o *Options, key string, value ...T) {
+	if v, ok := o.m[key]; ok {
+		vt := v.([]T)
+		o.m[key] = append(vt, value...)
+		return
+	}
+
+	o.m[key] = append([]T(nil), value...)
+}
+
+// SliceContains checks if a slice option contains a specific value.
+// If the option does not exist, it returns false.
+// If the value exists but is of a different type, it panics.
+func SliceContains[T comparable](o *Options, key string, value T) bool {
+	arr := Get(o, key, []T(nil))
+	return slices.Contains(arr, value)
+}
+
+// Set sets a value for a specific option key.
+func (o *Options) Set(key string, value any) {
+	o.m[key] = value
+}
+
+// Propagate propagates a value under the given key to the child Options if any.
+func (o *Options) Propagate(key string) {
+	if o.child == nil {
+		return
+	}
+
+	if v, ok := o.m[key]; ok {
+		for c := range o.Descendants() {
+			c.m[key] = v
+		}
+	}
+}
+
+// Delete removes an option by its key.
+func (o *Options) Delete(key string) {
+	delete(o.m, key)
+}
+
+// DeleteFromChildren removes an option by its key from the child Options if any.
+func (o *Options) DeleteFromChildren(key string) {
+	if o.child == nil {
+		return
+	}
+
+	for c := range o.Descendants() {
+		delete(c.m, key)
+	}
+}
+
+// CopyValue copies a value from one option key to another.
+func (o *Options) CopyValue(fromKey, toKey string) {
+	if v, ok := o.m[fromKey]; ok {
+		o.m[toKey] = v
+	}
+}
+
+// Has checks if an option key exists.
+func (o *Options) Has(key string) bool {
+	_, ok := o.m[key]
+	return ok
+}
+
+// GetInt retrieves an int value from the options.
+// If the key does not exist, GetInt returns the provided default value.
+// If the key exists but the value is of a different integer type,
+// GetInt converts it to int.
+// If the key exists but the value is not an integer type, GetInt panics.
+func (o *Options) GetInt(key string, def int) int {
+	v, ok := o.m[key]
+	if !ok {
+		return def
+	}
+
+	switch t := v.(type) {
+	case int:
+		return t
+	case int8:
+		return int(t)
+	case int16:
+		return int(t)
+	case int32:
+		return int(t)
+	case int64:
+		return int(t)
+	case uint:
+		return int(t)
+	case uint8:
+		return int(t)
+	case uint16:
+		return int(t)
+	case uint32:
+		return int(t)
+	case uint64:
+		return int(t)
+	default:
+		panic(newTypeMismatchError(key, v, def))
+	}
+}
+
+// GetFloat retrieves a float64 value from the options.
+// If the key does not exist, GetFloat returns the provided default value.
+// If the key value exists but the value is of a different float or integer type,
+// GetFloat converts it to float64.
+// If the key exists but the value is not a float or integer type, GetFloat panics.
+func (o *Options) GetFloat(key string, def float64) float64 {
+	v, ok := o.m[key]
+	if !ok {
+		return def
+	}
+
+	switch t := v.(type) {
+	case int:
+		return float64(t)
+	case int8:
+		return float64(t)
+	case int16:
+		return float64(t)
+	case int32:
+		return float64(t)
+	case int64:
+		return float64(t)
+	case uint:
+		return float64(t)
+	case uint8:
+		return float64(t)
+	case uint16:
+		return float64(t)
+	case uint32:
+		return float64(t)
+	case uint64:
+		return float64(t)
+	case float32:
+		return float64(t)
+	case float64:
+		return t
+	default:
+		panic(newTypeMismatchError(key, v, def))
+	}
+}
+
+// GetString retrieves a string value.
+// If the key doesn't exist, it returns the provided default value.
+// If the value exists but is of a different type, it panics.
+func (o *Options) GetString(key string, def string) string {
+	return Get(o, key, def)
+}
+
+// GetBool retrieves a bool value.
+// If the key doesn't exist, it returns the provided default value.
+// If the value exists but is of a different type, it panics.
+func (o *Options) GetBool(key string, def bool) bool {
+	return Get(o, key, def)
+}
+
+// GetTime retrieves a [time.Time] value.
+// If the key doesn't exist, it returns the zero time.
+// If the value exists but is of a different type, it panics.
+func (o *Options) GetTime(key string) time.Time {
+	return Get(o, key, time.Time{})
+}
+
+// Map returns a copy of the Options as a map[string]any
+// If the Options has a child, it combines the main and child maps,
+// prepending each key with the options depth
+// (e.g., "0.key" for main options, "1.key" for child options, "2.key" for grandchild options, etc.)
+func (o *Options) Map() map[string]any {
+	if o.child == nil {
+		return maps.Clone(o.m)
+	}
+
+	totalEntries := len(o.m)
+
+	for c := range o.Descendants() {
+		totalEntries += len(c.m)
+	}
+
+	result := make(map[string]any, totalEntries)
+
+	for k, v := range o.m {
+		result["0."+k] = v
+	}
+
+	depth := 1
+	for c := range o.Descendants() {
+		for k, v := range c.m {
+			result[strconv.Itoa(depth)+"."+k] = v
+		}
+		depth++
+	}
+
+	return result
+}
+
+// NestedMap returns Options as a nested map[string]any.
+// Each key is split by dots (.) and the resulting keys are used to create a nested structure.
+// If the Options has a child, it puts the main and child maps under "0", "1", "2", etc. keys
+// representing the options depth
+// (e.g., "0" for main options, "1" for child options, "2" for grandchild options, etc.)
+func (o *Options) NestedMap() map[string]any {
+	if o.child == nil {
+		return o.nestedMap()
+	}
+
+	totalMaps := 1
+	for child := o.child; child != nil; child = child.child {
+		totalMaps++
+	}
+
+	result := make(map[string]any, totalMaps)
+
+	result["0"] = o.nestedMap()
+
+	depth := 1
+	for c := range o.Descendants() {
+		result[strconv.Itoa(depth)] = c.nestedMap()
+		depth++
+	}
+
+	return result
+}
+
+func (o *Options) nestedMap() map[string]any {
+	nm := make(map[string]any)
+
+	for k, v := range o.m {
+		nestedMapSet(nm, k, v)
+	}
+
+	return nm
+}
+
+// String returns Options as a string representation of the map.
+func (o *Options) String() string {
+	return fmt.Sprintf("%v", o.Map())
+}
+
+// MarshalJSON returns Options as a JSON byte slice.
+func (o *Options) MarshalJSON() ([]byte, error) {
+	return json.Marshal(o.NestedMap())
+}
+
+// LogValue returns Options as [slog.Value]
+func (o *Options) LogValue() slog.Value {
+	return toSlogValue(o.NestedMap())
+}
+
+// nestedMapSet sets a value in a nested map[string]any structure.
+// If the key has more than one element, it creates nested maps as needed.
+func nestedMapSet(m map[string]any, key string, value any) {
+	key, rest, isGroup := strings.Cut(key, ".")
+
+	if !isGroup {
+		m[key] = value
+		return
+	}
+
+	mm, ok := m[key].(map[string]any)
+	if !ok {
+		mm = make(map[string]any)
+	}
+
+	nestedMapSet(mm, rest, value)
+
+	m[key] = mm
+}
+
+func toSlogValue(v any) slog.Value {
+	m, ok := v.(map[string]any)
+	if !ok {
+		return slog.AnyValue(v)
+	}
+
+	attrs := make([]slog.Attr, 0, len(m))
+
+	for k, v := range m {
+		attrs = append(attrs, slog.Attr{Key: k, Value: toSlogValue(v)})
+	}
+
+	// Sort attributes by key to have a consistent order
+	slices.SortFunc(attrs, func(a, b slog.Attr) int {
+		return strings.Compare(a.Key, b.Key)
+	})
+
+	return slog.GroupValue(attrs...)
+}

+ 374 - 0
options/options_test.go

@@ -0,0 +1,374 @@
+package options
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/suite"
+)
+
+type OptionsTestSuite struct {
+	suite.Suite
+}
+
+func (s *OptionsTestSuite) TestGet() {
+	o := New()
+	o.Set("string_key", "string_value")
+	o.Set("bool_key", true)
+
+	// Existing keys
+	s.Require().Equal("string_value", Get(o, "string_key", "default_value"))
+	s.Require().True(Get(o, "bool_key", false))
+
+	// Non-existing keys
+	s.Require().Equal("default_value", Get(o, "non_existing_key", "default_value"))
+	s.Require().False(Get(o, "another_non_existing_key", false))
+
+	// Type mismatch
+	s.Require().Panics(func() {
+		_ = Get(o, "string_key", 42)
+	})
+	s.Require().Panics(func() {
+		_ = Get(o, "bool_key", "not_a_bool")
+	})
+}
+
+func (s *OptionsTestSuite) TestAppendToSlice() {
+	o := New()
+	o.Set("slice", []int{1, 2, 3})
+
+	// Append to existing slice
+	AppendToSlice(o, "slice", 4, 5)
+	s.Require().Equal([]int{1, 2, 3, 4, 5}, Get(o, "slice", []int{}))
+
+	// Append to non-existing slice
+	AppendToSlice(o, "new_slice", 10, 20)
+	s.Require().Equal([]int{10, 20}, Get(o, "new_slice", []int{}))
+
+	// Type mismatch
+	s.Require().Panics(func() {
+		AppendToSlice(o, "slice", "not_an_int")
+	})
+}
+
+func (s *OptionsTestSuite) TestSliceContains() {
+	o := New()
+	o.Set("slice", []string{"apple", "banana", "cherry"})
+
+	// Existing values
+	s.Require().True(SliceContains(o, "slice", "banana"))
+	s.Require().False(SliceContains(o, "slice", "date"))
+
+	// Non-existing slice
+	s.Require().False(SliceContains(o, "non_existing_slice", "anything"))
+
+	// Type mismatch
+	s.Require().Panics(func() {
+		SliceContains(o, "slice", 42)
+	})
+}
+
+func (s *OptionsTestSuite) TestPropagate() {
+	o := New()
+	o.Set("key1", "value1")
+	o.Set("key2", 100)
+	o.Set("key3", false)
+
+	child := o.AddChild()
+	child.Set("key1", "child_value1")
+	child.Set("key3", true)
+
+	grandChild := child.AddChild()
+	grandChild.Set("key2", 300)
+
+	o.Propagate("key1")
+	o.Propagate("key2")
+
+	s.Require().Equal("value1", Get(child, "key1", ""))
+	s.Require().Equal(100, Get(child, "key2", 0))
+	s.Require().True(Get(child, "key3", false))
+
+	s.Require().Equal("value1", Get(grandChild, "key1", ""))
+	s.Require().Equal(100, Get(grandChild, "key2", 0))
+	s.Require().False(grandChild.Has("key3"))
+}
+
+func (s *OptionsTestSuite) TestDeleteFromChildren() {
+	o := New()
+	o.Set("key1", "value1")
+
+	child := o.AddChild()
+	child.Set("key1", "child_value1")
+	child.Set("key2", 200)
+
+	grandChild := child.AddChild()
+	grandChild.Set("key1", "grandchild_value1")
+	grandChild.Set("key2", 300)
+
+	o.DeleteFromChildren("key1")
+
+	s.Require().Equal("value1", Get(o, "key1", ""))
+
+	s.Require().False(child.Has("key1"))
+	s.Require().Equal(200, Get(child, "key2", 0))
+
+	s.Require().False(grandChild.Has("key1"))
+	s.Require().Equal(300, Get(grandChild, "key2", 0))
+}
+
+func (s *OptionsTestSuite) TestCopyValue() {
+	o := New()
+	o.Set("key1", 100)
+	o.Set("key2", 200)
+	o.Set("key3", 200)
+
+	// Existing to existing
+	o.CopyValue("key1", "key2")
+	s.Require().Equal(100, Get(o, "key2", 0))
+
+	// Existing to new
+	o.CopyValue("key1", "key4")
+	s.Require().Equal(100, Get(o, "key4", 0))
+
+	// Non-existing to new
+	o.CopyValue("non_existing_key", "key5")
+	s.Require().False(o.Has("key5"))
+
+	// Non-existing to existing
+	o.CopyValue("another_non_existing_key", "key3")
+	s.Require().Equal(200, Get(o, "key3", 0))
+}
+
+func (s *OptionsTestSuite) TestGetInt() {
+	o := New()
+	o.Set("int", 42)
+	o.Set("int32", int32(32))
+	o.Set("int16", int16(16))
+	o.Set("int8", int8(8))
+	o.Set("float", 3.14)
+	o.Set("string", "not_an_int")
+
+	// Integer types
+	s.Require().Equal(42, o.GetInt("int", 0))
+	s.Require().Equal(32, o.GetInt("int32", 0))
+	s.Require().Equal(16, o.GetInt("int16", 0))
+	s.Require().Equal(8, o.GetInt("int8", 0))
+
+	// Non-existing key
+	s.Require().Equal(100, o.GetInt("non_existing_key", 100))
+
+	// Type mismatch
+	s.Require().Panics(func() {
+		o.GetInt("float", 0)
+	})
+	s.Require().Panics(func() {
+		o.GetInt("string", 0)
+	})
+}
+
+func (s *OptionsTestSuite) TestGetFloat() {
+	o := New()
+	o.Set("float64", 3.14)
+	o.Set("float32", float32(2.71))
+	o.Set("int", 42)
+	o.Set("int16", int16(16))
+	o.Set("string", "not_a_float")
+
+	// Float types
+	s.Require().InDelta(3.14, o.GetFloat("float64", 0.0), 0.000001)
+	s.Require().InDelta(2.71, o.GetFloat("float32", 0.0), 0.000001)
+
+	// Integer types
+	s.Require().InDelta(42.0, o.GetFloat("int", 0.0), 0.000001)
+	s.Require().InDelta(16.0, o.GetFloat("int16", 0.0), 0.000001)
+
+	// Non-existing key
+	s.Require().InDelta(1.618, o.GetFloat("non_existing_key", 1.618), 0.000001)
+
+	// Type mismatch
+	s.Require().Panics(func() {
+		o.GetFloat("string", 0.0)
+	})
+}
+
+func testOptions() *Options {
+	o := New()
+	o.Set("string_key", "string_value")
+	o.Set("int_key", 42)
+	o.Set("float_key", 3.14)
+	o.Set("bool_key", true)
+	o.Set("group1.key1", "value1")
+	o.Set("group1.key2", 100)
+	o.Set("group2.key1", false)
+	o.Set("group2.key2", 2.71)
+	o.Set("group2.subgroup.key", "subvalue")
+	o.Set("group2.subgroup.num", 256)
+	return o
+}
+
+func testNestedOptions() *Options {
+	o := testOptions()
+
+	child := o.AddChild()
+	child.Set("string_key", "child_string_value")
+	child.Set("int_key", 84)
+	child.Set("child_only_key", "only_in_child")
+
+	grandChild := child.AddChild()
+	grandChild.Set("string_key", "grandchild_string_value")
+	grandChild.Set("int_key", 168)
+	grandChild.Set("grandchild_only_key", "only_in_grandchild")
+
+	return o
+}
+
+func (s *OptionsTestSuite) TestDepth() {
+	o := testNestedOptions()
+
+	s.Require().Equal(0, o.Depth())
+	s.Require().Equal(1, o.Child().Depth())
+	s.Require().Equal(2, o.Child().Child().Depth())
+}
+
+func (s *OptionsTestSuite) TestMap() {
+	s.Run("WithoutChildren", func() {
+		o := testOptions()
+
+		expected := map[string]any{
+			"string_key":          "string_value",
+			"int_key":             42,
+			"float_key":           3.14,
+			"bool_key":            true,
+			"group1.key1":         "value1",
+			"group1.key2":         100,
+			"group2.key1":         false,
+			"group2.key2":         2.71,
+			"group2.subgroup.key": "subvalue",
+			"group2.subgroup.num": 256,
+		}
+
+		s.Require().Equal(expected, o.Map())
+	})
+
+	s.Run("WithChildren", func() {
+		o := testNestedOptions()
+
+		expected := map[string]any{
+			"0.string_key":          "string_value",
+			"0.int_key":             42,
+			"0.float_key":           3.14,
+			"0.bool_key":            true,
+			"0.group1.key1":         "value1",
+			"0.group1.key2":         100,
+			"0.group2.key1":         false,
+			"0.group2.key2":         2.71,
+			"0.group2.subgroup.key": "subvalue",
+			"0.group2.subgroup.num": 256,
+			"1.string_key":          "child_string_value",
+			"1.int_key":             84,
+			"1.child_only_key":      "only_in_child",
+			"2.string_key":          "grandchild_string_value",
+			"2.int_key":             168,
+			"2.grandchild_only_key": "only_in_grandchild",
+		}
+
+		s.Require().Equal(expected, o.Map())
+	})
+}
+
+func (s *OptionsTestSuite) TestNestedMap() {
+	s.Run("WithoutChildren", func() {
+		o := testOptions()
+
+		expected := map[string]any{
+			"string_key": "string_value",
+			"int_key":    42,
+			"float_key":  3.14,
+			"bool_key":   true,
+			"group1": map[string]any{
+				"key1": "value1",
+				"key2": 100,
+			},
+			"group2": map[string]any{
+				"key1": false,
+				"key2": 2.71,
+				"subgroup": map[string]any{
+					"key": "subvalue",
+					"num": 256,
+				},
+			},
+		}
+
+		s.Require().Equal(expected, o.NestedMap())
+	})
+
+	s.Run("WithChildren", func() {
+		o := testNestedOptions()
+
+		expected := map[string]any{
+			"0": map[string]any{
+				"string_key": "string_value",
+				"int_key":    42,
+				"float_key":  3.14,
+				"bool_key":   true,
+				"group1": map[string]any{
+					"key1": "value1",
+					"key2": 100,
+				},
+				"group2": map[string]any{
+					"key1": false,
+					"key2": 2.71,
+					"subgroup": map[string]any{
+						"key": "subvalue",
+						"num": 256,
+					},
+				},
+			},
+			"1": map[string]any{
+				"string_key":     "child_string_value",
+				"int_key":        84,
+				"child_only_key": "only_in_child",
+			},
+			"2": map[string]any{
+				"string_key":          "grandchild_string_value",
+				"int_key":             168,
+				"grandchild_only_key": "only_in_grandchild",
+			},
+		}
+
+		s.Require().Equal(expected, o.NestedMap())
+	})
+}
+
+func TestOptions(t *testing.T) {
+	suite.Run(t, new(OptionsTestSuite))
+}
+
+func BenchmarkLogValue(b *testing.B) {
+	o := testNestedOptions()
+
+	b.ResetTimer()
+
+	for i := 0; i < b.N; i++ {
+		_ = o.LogValue()
+	}
+}
+
+func BenchmarkNestedMap(b *testing.B) {
+	o := testNestedOptions()
+
+	b.ResetTimer()
+
+	for i := 0; i < b.N; i++ {
+		_ = o.NestedMap()
+	}
+}
+
+func BenchmarkMap(b *testing.B) {
+	o := testNestedOptions()
+
+	b.ResetTimer()
+
+	for i := 0; i < b.N; i++ {
+		_ = o.Map()
+	}
+}

+ 96 - 66
options/parse.go

@@ -5,6 +5,8 @@ import (
 	"log/slog"
 	"slices"
 	"strconv"
+
+	"github.com/imgproxy/imgproxy/v3/options/keys"
 )
 
 // ensureMaxArgs checks if the number of arguments is as expected
@@ -16,131 +18,148 @@ func ensureMaxArgs(name string, args []string, max int) error {
 }
 
 // parseBool parses a boolean option value and warns if the value is invalid
-func parseBool(value *bool, name string, args ...string) error {
-	if err := ensureMaxArgs(name, args, 1); err != nil {
+func parseBool(o *Options, key string, args ...string) error {
+	if err := ensureMaxArgs(key, args, 1); err != nil {
 		return err
 	}
 
 	b, err := strconv.ParseBool(args[0])
 
 	if err != nil {
-		slog.Warn(fmt.Sprintf("%s `%s` is not a valid boolean value. Treated as false", name, args[0]))
+		slog.Warn(fmt.Sprintf("%s `%s` is not a valid boolean value. Treated as false", key, args[0]))
 	}
 
-	*value = b
+	o.Set(key, b)
+
 	return nil
 }
 
-// parseFloat64 parses a float64 option value
-func parseFloat64(value *float64, name string, args ...string) error {
-	if err := ensureMaxArgs(name, args, 1); err != nil {
+// parseFloat parses a float64 option value
+func parseFloat(o *Options, key string, args ...string) error {
+	if err := ensureMaxArgs(key, args, 1); err != nil {
 		return err
 	}
 
 	f, err := strconv.ParseFloat(args[0], 64)
 	if err != nil {
-		return newInvalidArgsError(name, args)
+		return newInvalidArgsError(key, args)
 	}
 
-	*value = f
+	o.Set(key, f)
+
 	return nil
 }
 
-// parsePositiveFloat64 parses a positive float64 option value
-func parsePositiveFloat64(value *float64, name string, args ...string) error {
-	if err := ensureMaxArgs(name, args, 1); err != nil {
+// parsePositiveFloat parses a positive float64 option value
+func parsePositiveFloat(o *Options, key string, args ...string) error {
+	if err := ensureMaxArgs(key, args, 1); err != nil {
 		return err
 	}
 
 	f, err := strconv.ParseFloat(args[0], 64)
 	if err != nil || f < 0 {
-		return newInvalidArgsError(name, args, "positive number or 0")
+		return newInvalidArgsError(key, args, "positive number or 0")
 	}
-	*value = f
+
+	o.Set(key, f)
+
 	return nil
 }
 
-// parsePositiveFloat64 parses a positive float64 option value
-func parsePositiveNonZeroFloat64(value *float64, name string, args ...string) error {
-	if err := ensureMaxArgs(name, args, 1); err != nil {
+// parsePositiveNonZeroFloat parses a positive non-zero float64 option value
+func parsePositiveNonZeroFloat(o *Options, key string, args ...string) error {
+	if err := ensureMaxArgs(key, args, 1); err != nil {
 		return err
 	}
 
 	f, err := strconv.ParseFloat(args[0], 64)
 	if err != nil || f <= 0 {
-		return newInvalidArgsError(name, args, "positive number")
+		return newInvalidArgsError(key, args, "positive number")
 	}
-	*value = f
-	return nil
-}
 
-// parsePositiveFloat32 parses a positive float32 option value
-func parsePositiveNonZeroFloat32(value *float32, name string, args ...string) error {
-	if err := ensureMaxArgs(name, args, 1); err != nil {
-		return err
-	}
+	o.Set(key, f)
 
-	f, err := strconv.ParseFloat(args[0], 32)
-	if err != nil || f <= 0 {
-		return newInvalidArgsError(name, args, "positive number")
-	}
-	*value = float32(f)
 	return nil
 }
 
 // parseInt parses a positive integer option value
-func parseInt(value *int, name string, args ...string) error {
-	if err := ensureMaxArgs(name, args, 1); err != nil {
+func parseInt(o *Options, key string, args ...string) error {
+	if err := ensureMaxArgs(key, args, 1); err != nil {
 		return err
 	}
 
 	i, err := strconv.Atoi(args[0])
 	if err != nil {
-		return newOptionArgumentError(name, args)
+		return newOptionArgumentError(key, args)
 	}
-	*value = i
+
+	o.Set(key, i)
+
 	return nil
 }
 
 // parsePositiveNonZeroInt parses a positive non-zero integer option value
-func parsePositiveNonZeroInt(value *int, name string, args ...string) error {
-	if err := ensureMaxArgs(name, args, 1); err != nil {
+func parsePositiveNonZeroInt(o *Options, key string, args ...string) error {
+	if err := ensureMaxArgs(key, args, 1); err != nil {
 		return err
 	}
 
 	i, err := strconv.Atoi(args[0])
 	if err != nil || i <= 0 {
-		return newInvalidArgsError(name, args, "positive number")
+		return newInvalidArgsError(key, args, "positive number")
 	}
-	*value = i
+
+	o.Set(key, i)
+
 	return nil
 }
 
 // parsePositiveInt parses a positive integer option value
-func parsePositiveInt(value *int, name string, args ...string) error {
-	if err := ensureMaxArgs(name, args, 1); err != nil {
+func parsePositiveInt(o *Options, key string, args ...string) error {
+	if err := ensureMaxArgs(key, args, 1); err != nil {
 		return err
 	}
 
 	i, err := strconv.Atoi(args[0])
 	if err != nil || i < 0 {
-		return newOptionArgumentError("Invalid %s arguments: %s (expected positive number)", name, args)
+		return newOptionArgumentError("Invalid %s arguments: %s (expected positive number)", key, args)
 	}
-	*value = i
+
+	o.Set(key, i)
+
 	return nil
 }
 
 // parseQualityInt parses a quality integer option value (1-100)
-func parseQualityInt(value *int, name string, args ...string) error {
-	if err := ensureMaxArgs(name, args, 1); err != nil {
+func parseQualityInt(o *Options, key string, args ...string) error {
+	if err := ensureMaxArgs(key, args, 1); err != nil {
 		return err
 	}
 
 	i, err := strconv.Atoi(args[0])
 	if err != nil || i < 1 || i > 100 {
-		return newInvalidArgsError(name, args, "number in range 1-100")
+		return newInvalidArgsError(key, args, "number in range 1-100")
+	}
+
+	o.Set(key, i)
+
+	return nil
+}
+
+// parseResolution parses a resolution option value in megapixels and stores it as pixels
+func parseResolution(o *Options, key string, args ...string) error {
+	if err := ensureMaxArgs(key, args, 1); err != nil {
+		return err
+	}
+
+	f, err := strconv.ParseFloat(args[0], 64)
+	if err != nil || f < 0 {
+		return newInvalidArgsError(key, args, "positive number or 0")
 	}
-	*value = i
+
+	// Resolution is defined as megapixels but stored as pixels
+	o.Set(key, int(f*1000000))
+
 	return nil
 }
 
@@ -148,46 +167,57 @@ func isGravityOffcetValid(gravity GravityType, offset float64) bool {
 	return gravity != GravityFocusPoint || (offset >= 0 && offset <= 1)
 }
 
-func parseGravity(g *GravityOptions, name string, args []string, allowedTypes []GravityType) error {
+func parseGravity(
+	o *Options,
+	key string,
+	args []string,
+	allowedTypes []GravityType,
+) error {
 	nArgs := len(args)
 
-	if t, ok := gravityTypes[args[0]]; ok && slices.Contains(allowedTypes, t) {
-		g.Type = t
+	keyType := key + keys.SuffixType
+	keyXOffset := key + keys.SuffixXOffset
+	keyYOffset := key + keys.SuffixYOffset
+
+	gType, ok := gravityTypes[args[0]]
+	if ok && slices.Contains(allowedTypes, gType) {
+		o.Set(keyType, gType)
 	} else {
-		return newOptionArgumentError("Invalid %s: %s", name, args[0])
+		return newOptionArgumentError("Invalid %s: %s", keyType, args[0])
 	}
 
-	switch g.Type {
+	switch gType {
 	case GravitySmart:
 		if nArgs > 1 {
-			return newInvalidArgsError(name, args)
+			return newInvalidArgsError(key, args)
 		}
-		g.X, g.Y = 0.0, 0.0
+		o.Delete(keyXOffset)
+		o.Delete(keyYOffset)
 
 	case GravityFocusPoint:
 		if nArgs != 3 {
-			return newInvalidArgsError(name, args)
+			return newInvalidArgsError(key, args)
 		}
 		fallthrough
 
 	default:
 		if nArgs > 3 {
-			return newInvalidArgsError(name, args)
+			return newInvalidArgsError(key, args)
 		}
 
 		if nArgs > 1 {
-			if x, err := strconv.ParseFloat(args[1], 64); err == nil && isGravityOffcetValid(g.Type, x) {
-				g.X = x
+			if x, err := strconv.ParseFloat(args[1], 64); err == nil && isGravityOffcetValid(gType, x) {
+				o.Set(keyXOffset, x)
 			} else {
-				return newOptionArgumentError("Invalid %s X: %s", name, args[1])
+				return newOptionArgumentError("Invalid %s: %s", keyXOffset, args[1])
 			}
 		}
 
 		if nArgs > 2 {
-			if y, err := strconv.ParseFloat(args[2], 64); err == nil && isGravityOffcetValid(g.Type, y) {
-				g.Y = y
+			if y, err := strconv.ParseFloat(args[2], 64); err == nil && isGravityOffcetValid(gType, y) {
+				o.Set(keyYOffset, y)
 			} else {
-				return newOptionArgumentError("Invalid %s Y: %s", name, args[2])
+				return newOptionArgumentError("Invalid %s: %s", keyYOffset, args[2])
 			}
 		}
 	}
@@ -195,17 +225,17 @@ func parseGravity(g *GravityOptions, name string, args []string, allowedTypes []
 	return nil
 }
 
-func parseExtend(opts *ExtendOptions, name string, args []string) error {
-	if err := ensureMaxArgs(name, args, 4); err != nil {
+func parseExtend(o *Options, key string, args []string) error {
+	if err := ensureMaxArgs(key, args, 4); err != nil {
 		return err
 	}
 
-	if err := parseBool(&opts.Enabled, name+" enabled", args[0]); err != nil {
+	if err := parseBool(o, key+keys.SuffixEnabled, args[0]); err != nil {
 		return err
 	}
 
 	if len(args) > 1 {
-		return parseGravity(&opts.Gravity, name+" gravity", args[1:], extendGravityTypes)
+		return parseGravity(o, key+keys.SuffixGravity, args[1:], extendGravityTypes)
 	}
 
 	return nil

+ 40 - 0
options/parser.go

@@ -0,0 +1,40 @@
+package options
+
+// Presets is a map of preset names to their corresponding urlOptions
+type Presets = map[string]urlOptions
+
+// Parser creates Options instances
+type Parser struct {
+	config  *Config // Parser configuration
+	presets Presets // Parsed presets
+}
+
+// NewParser creates new Parser instance
+func NewParser(config *Config) (*Parser, error) {
+	if err := config.Validate(); err != nil {
+		return nil, err
+	}
+
+	p := &Parser{
+		config:  config,
+		presets: make(map[string]urlOptions),
+	}
+
+	if err := p.parsePresets(); err != nil {
+		return nil, err
+	}
+
+	if err := p.validatePresets(); err != nil {
+		return nil, err
+	}
+
+	return p, nil
+}
+
+func (p *Parser) IsSecurityOptionsAllowed() error {
+	if p.config.AllowSecurityOptions {
+		return nil
+	}
+
+	return newSecurityOptionsError()
+}

+ 13 - 13
options/presets.go

@@ -6,9 +6,9 @@ import (
 )
 
 // parsePresets parses presets from the config and fills the presets map
-func (f *Factory) parsePresets() error {
-	for _, presetStr := range f.config.Presets {
-		if err := f.parsePreset(presetStr); err != nil {
+func (p *Parser) parsePresets() error {
+	for _, presetStr := range p.config.Presets {
+		if err := p.parsePreset(presetStr); err != nil {
 			return err
 		}
 	}
@@ -17,7 +17,7 @@ func (f *Factory) parsePresets() error {
 }
 
 // parsePreset parses a preset string and returns the name and options
-func (f *Factory) parsePreset(presetStr string) error {
+func (p *Parser) parsePreset(presetStr string) error {
 	presetStr = strings.Trim(presetStr, " ")
 
 	if len(presetStr) == 0 || strings.HasPrefix(presetStr, "#") {
@@ -42,26 +42,26 @@ func (f *Factory) parsePreset(presetStr string) error {
 
 	optsStr := strings.Split(value, "/")
 
-	opts, rest := f.parseURLOptions(optsStr)
+	opts, rest := p.parseURLOptions(optsStr)
 
 	if len(rest) > 0 {
 		return fmt.Errorf("invalid preset value: %s", presetStr)
 	}
 
-	if f.presets == nil {
-		f.presets = make(Presets)
+	if p.presets == nil {
+		p.presets = make(Presets)
 	}
 
-	f.presets[name] = opts
+	p.presets[name] = opts
 
 	return nil
 }
 
-// validatePresets validates all presets by applying them to a new ProcessingOptions instance
-func (f *Factory) validatePresets() error {
-	for name, opts := range f.presets {
-		po := f.NewProcessingOptions()
-		if err := f.applyURLOptions(po, opts, true, name); err != nil {
+// validatePresets validates all presets by applying them to a new Options instance
+func (p *Parser) validatePresets() error {
+	for name, opts := range p.presets {
+		po := New()
+		if err := p.applyURLOptions(po, opts, true, name); err != nil {
 			return fmt.Errorf("Error in preset `%s`: %s", name, err)
 		}
 	}

+ 12 - 23
options/presets_test.go

@@ -3,32 +3,21 @@ package options
 import (
 	"testing"
 
-	"github.com/imgproxy/imgproxy/v3/security"
-	"github.com/imgproxy/imgproxy/v3/testutil"
 	"github.com/stretchr/testify/suite"
 )
 
 type PresetsTestSuite struct {
-	testutil.LazySuite
-
-	security *security.Checker
-}
-
-func (s *PresetsTestSuite) SetupSuite() {
-	c := security.NewDefaultConfig()
-	security, err := security.New(&c)
-	s.Require().NoError(err)
-	s.security = security
+	suite.Suite
 }
 
-func (s *PresetsTestSuite) newFactory(presets ...string) (*Factory, error) {
+func (s *PresetsTestSuite) newParser(presets ...string) (*Parser, error) {
 	c := NewDefaultConfig()
 	c.Presets = presets
-	return NewFactory(&c, s.security)
+	return NewParser(&c)
 }
 
 func (s *PresetsTestSuite) TestParsePreset() {
-	f, err := s.newFactory("test=resize:fit:100:200/sharpen:2")
+	f, err := s.newParser("test=resize:fit:100:200/sharpen:2")
 
 	s.Require().NoError(err)
 	s.Require().Equal(urlOptions{
@@ -39,55 +28,55 @@ func (s *PresetsTestSuite) TestParsePreset() {
 
 func (s *PresetsTestSuite) TestParsePresetInvalidString() {
 	presetStr := "resize:fit:100:200/sharpen:2"
-	_, err := s.newFactory(presetStr)
+	_, err := s.newParser(presetStr)
 
 	s.Require().Error(err, "invalid preset string: %s", presetStr)
 }
 
 func (s *PresetsTestSuite) TestParsePresetEmptyName() {
 	presetStr := "=resize:fit:100:200/sharpen:2"
-	_, err := s.newFactory(presetStr)
+	_, err := s.newParser(presetStr)
 
 	s.Require().Error(err, "empty preset name: %s", presetStr)
 }
 
 func (s *PresetsTestSuite) TestParsePresetEmptyValue() {
 	presetStr := "test="
-	_, err := s.newFactory(presetStr)
+	_, err := s.newParser(presetStr)
 
 	s.Require().Error(err, "empty preset value: %s", presetStr)
 }
 
 func (s *PresetsTestSuite) TestParsePresetInvalidValue() {
 	presetStr := "test=resize:fit:100:200/sharpen:2/blur"
-	_, err := s.newFactory(presetStr)
+	_, err := s.newParser(presetStr)
 
 	s.Require().Error(err, "invalid preset value: %s", presetStr)
 }
 
 func (s *PresetsTestSuite) TestParsePresetEmptyString() {
-	f, err := s.newFactory("   ")
+	f, err := s.newParser("   ")
 
 	s.Require().NoError(err)
 	s.Require().Empty(f.presets)
 }
 
 func (s *PresetsTestSuite) TestParsePresetComment() {
-	f, err := s.newFactory("#  test=resize:fit:100:200/sharpen:2")
+	f, err := s.newParser("#  test=resize:fit:100:200/sharpen:2")
 
 	s.Require().NoError(err)
 	s.Require().Empty(f.presets)
 }
 
 func (s *PresetsTestSuite) TestValidatePresets() {
-	f, err := s.newFactory("test=resize:fit:100:200/sharpen:2")
+	f, err := s.newParser("test=resize:fit:100:200/sharpen:2")
 
 	s.Require().NoError(err)
 	s.Require().NotEmpty(f.presets)
 }
 
 func (s *PresetsTestSuite) TestValidatePresetsInvalid() {
-	_, err := s.newFactory("test=resize:fit:-1:-2/sharpen:2")
+	_, err := s.newParser("test=resize:fit:-1:-2/sharpen:2")
 
 	s.Require().Error(err)
 }

+ 72 - 271
options/processing_options.go

@@ -1,222 +1,19 @@
 package options
 
 import (
-	"log/slog"
-	"maps"
 	"net/http"
 	"slices"
 	"strconv"
 	"strings"
-	"time"
 
 	"github.com/imgproxy/imgproxy/v3/ierrors"
-	"github.com/imgproxy/imgproxy/v3/imagetype"
 	"github.com/imgproxy/imgproxy/v3/imath"
-	"github.com/imgproxy/imgproxy/v3/security"
-	"github.com/imgproxy/imgproxy/v3/structdiff"
-	"github.com/imgproxy/imgproxy/v3/vips"
+	"github.com/imgproxy/imgproxy/v3/options/keys"
 )
 
 const maxClientHintDPR = 8
 
-type ExtendOptions struct {
-	Enabled bool
-	Gravity GravityOptions
-}
-
-type CropOptions struct {
-	Width   float64
-	Height  float64
-	Gravity GravityOptions
-}
-
-type PaddingOptions struct {
-	Enabled bool
-	Top     int
-	Right   int
-	Bottom  int
-	Left    int
-}
-
-type TrimOptions struct {
-	Enabled   bool
-	Threshold float64
-	Smart     bool
-	Color     vips.Color
-	EqualHor  bool
-	EqualVer  bool
-}
-
-type WatermarkOptions struct {
-	Enabled  bool
-	Opacity  float64
-	Position GravityOptions
-	Scale    float64
-}
-
-func (wo WatermarkOptions) ShouldReplicate() bool {
-	return wo.Position.Type == GravityReplicate
-}
-
-type ProcessingOptions struct {
-	defaultOptions *ProcessingOptions
-	config         *Config
-
-	ResizingType      ResizeType
-	Width             int
-	Height            int
-	MinWidth          int
-	MinHeight         int
-	ZoomWidth         float64
-	ZoomHeight        float64
-	Dpr               float64
-	Gravity           GravityOptions
-	Enlarge           bool
-	Extend            ExtendOptions
-	ExtendAspectRatio ExtendOptions
-	Crop              CropOptions
-	Padding           PaddingOptions
-	Trim              TrimOptions
-	Rotate            int
-	Format            imagetype.Type
-	Quality           int
-	FormatQuality     map[imagetype.Type]int
-	MaxBytes          int
-	Flatten           bool
-	Background        vips.Color
-	Blur              float32
-	Sharpen           float32
-	Pixelate          int
-	StripMetadata     bool
-	KeepCopyright     bool
-	StripColorProfile bool
-	AutoRotate        bool
-	EnforceThumbnail  bool
-
-	SkipProcessingFormats []imagetype.Type
-
-	CacheBuster string
-
-	Expires *time.Time
-
-	Watermark WatermarkOptions
-
-	PreferWebP  bool
-	EnforceWebP bool
-	PreferAvif  bool
-	EnforceAvif bool
-	PreferJxl   bool
-	EnforceJxl  bool
-
-	Filename         string
-	ReturnAttachment bool
-
-	Raw bool
-
-	UsedPresets []string
-
-	SecurityOptions security.Options
-}
-
-func newDefaultProcessingOptions(config *Config, security *security.Checker) *ProcessingOptions {
-	po := ProcessingOptions{
-		config: config,
-
-		ResizingType:      ResizeFit,
-		Width:             0,
-		Height:            0,
-		ZoomWidth:         1,
-		ZoomHeight:        1,
-		Gravity:           GravityOptions{Type: GravityCenter},
-		Enlarge:           false,
-		Extend:            ExtendOptions{Enabled: false, Gravity: GravityOptions{Type: GravityCenter}},
-		ExtendAspectRatio: ExtendOptions{Enabled: false, Gravity: GravityOptions{Type: GravityCenter}},
-		Padding:           PaddingOptions{Enabled: false},
-		Trim:              TrimOptions{Enabled: false, Threshold: 10, Smart: true},
-		Rotate:            0,
-		Quality:           0,
-		FormatQuality:     maps.Clone(config.FormatQuality),
-		MaxBytes:          0,
-		Format:            imagetype.Unknown,
-		Background:        vips.Color{R: 255, G: 255, B: 255},
-		Blur:              0,
-		Sharpen:           0,
-		Dpr:               1,
-		Watermark:         WatermarkOptions{Opacity: 1, Position: GravityOptions{Type: GravityCenter}},
-		StripMetadata:     config.StripMetadata,
-		KeepCopyright:     config.KeepCopyright,
-		StripColorProfile: config.StripColorProfile,
-		AutoRotate:        config.AutoRotate,
-		EnforceThumbnail:  config.EnforceThumbnail,
-		ReturnAttachment:  config.ReturnAttachment,
-
-		SkipProcessingFormats: slices.Clone(config.SkipProcessingFormats),
-
-		SecurityOptions: security.NewOptions(),
-	}
-
-	return &po
-}
-
-func (po *ProcessingOptions) GetQuality() int {
-	q := po.Quality
-
-	if q == 0 {
-		q = po.FormatQuality[po.Format]
-	}
-
-	if q == 0 {
-		q = po.config.Quality
-	}
-
-	return q
-}
-
-func (po *ProcessingOptions) Diff() structdiff.Entries {
-	return structdiff.Diff(po.defaultOptions, po)
-}
-
-func (po *ProcessingOptions) String() string {
-	return po.Diff().String()
-}
-
-func (po *ProcessingOptions) MarshalJSON() ([]byte, error) {
-	return po.Diff().MarshalJSON()
-}
-
-func (po *ProcessingOptions) LogValue() slog.Value {
-	return po.Diff().LogValue()
-}
-
-// Default returns the ProcessingOptions instance with defaults set
-func (po *ProcessingOptions) Default() *ProcessingOptions {
-	return po.defaultOptions.clone()
-}
-
-// clone clones ProcessingOptions struct and its slices and maps
-func (po *ProcessingOptions) clone() *ProcessingOptions {
-	clone := *po
-
-	clone.FormatQuality = maps.Clone(po.FormatQuality)
-	clone.SkipProcessingFormats = slices.Clone(po.SkipProcessingFormats)
-	clone.UsedPresets = slices.Clone(po.UsedPresets)
-
-	if po.Expires != nil {
-		poExipres := *po.Expires
-		clone.Expires = &poExipres
-	}
-
-	// Copy the pointer to the default options struct from parent.
-	// Nil means that we have just cloned the default options struct itself
-	// so we set it as default options.
-	if clone.defaultOptions == nil {
-		clone.defaultOptions = po
-	}
-
-	return &clone
-}
-
-func (f *Factory) applyURLOption(po *ProcessingOptions, name string, args []string, usedPresets ...string) error {
+func (p *Parser) applyURLOption(po *Options, name string, args []string, usedPresets ...string) error {
 	switch name {
 	case "resize", "rs":
 		return applyResizeOption(po, args)
@@ -265,63 +62,63 @@ func (f *Factory) applyURLOption(po *ProcessingOptions, name string, args []stri
 	case "watermark", "wm":
 		return applyWatermarkOption(po, args)
 	case "strip_metadata", "sm":
-		return applyStripMetadataOption(po, args)
+		return applyStripMetadataOption(po.Main(), args)
 	case "keep_copyright", "kcr":
-		return applyKeepCopyrightOption(po, args)
+		return applyKeepCopyrightOption(po.Main(), args)
 	case "strip_color_profile", "scp":
-		return applyStripColorProfileOption(po, args)
+		return applyStripColorProfileOption(po.Main(), args)
 	case "enforce_thumbnail", "eth":
-		return applyEnforceThumbnailOption(po, args)
+		return applyEnforceThumbnailOption(po.Main(), args)
 	// Saving options
 	case "quality", "q":
-		return applyQualityOption(po, args)
+		return applyQualityOption(po.Main(), args)
 	case "format_quality", "fq":
-		return applyFormatQualityOption(po, args)
+		return applyFormatQualityOption(po.Main(), args)
 	case "max_bytes", "mb":
-		return applyMaxBytesOption(po, args)
+		return applyMaxBytesOption(po.Main(), args)
 	case "format", "f", "ext":
-		return applyFormatOption(po, args)
+		return applyFormatOption(po.Main(), args)
 	// Handling options
 	case "skip_processing", "skp":
-		return applySkipProcessingFormatsOption(po, args)
+		return applySkipProcessingFormatsOption(po.Main(), args)
 	case "raw":
-		return applyRawOption(po, args)
+		return applyRawOption(po.Main(), args)
 	case "cachebuster", "cb":
-		return applyCacheBusterOption(po, args)
+		return applyCacheBusterOption(po.Main(), args)
 	case "expires", "exp":
-		return applyExpiresOption(po, args)
+		return applyExpiresOption(po.Main(), args)
 	case "filename", "fn":
-		return applyFilenameOption(po, args)
+		return applyFilenameOption(po.Main(), args)
 	case "return_attachment", "att":
-		return applyReturnAttachmentOption(po, args)
+		return applyReturnAttachmentOption(po.Main(), args)
 	// Presets
 	case "preset", "pr":
-		return applyPresetOption(f, po, args, usedPresets...)
+		return applyPresetOption(p, po, args, usedPresets...)
 	// Security
 	case "max_src_resolution", "msr":
-		return applyMaxSrcResolutionOption(po, args)
+		return applyMaxSrcResolutionOption(p, po.Main(), args)
 	case "max_src_file_size", "msfs":
-		return applyMaxSrcFileSizeOption(po, args)
+		return applyMaxSrcFileSizeOption(p, po.Main(), args)
 	case "max_animation_frames", "maf":
-		return applyMaxAnimationFramesOption(po, args)
+		return applyMaxAnimationFramesOption(p, po.Main(), args)
 	case "max_animation_frame_resolution", "mafr":
-		return applyMaxAnimationFrameResolutionOption(po, args)
+		return applyMaxAnimationFrameResolutionOption(p, po.Main(), args)
 	case "max_result_dimension", "mrd":
-		return applyMaxResultDimensionOption(po, args)
+		return applyMaxResultDimensionOption(p, po.Main(), args)
 	}
 
 	return newUnknownOptionError("processing", name)
 }
 
-func (f *Factory) applyURLOptions(po *ProcessingOptions, options urlOptions, allowAll bool, usedPresets ...string) error {
-	allowAll = allowAll || len(f.config.AllowedProcessingOptions) == 0
+func (p *Parser) applyURLOptions(po *Options, options urlOptions, allowAll bool, usedPresets ...string) error {
+	allowAll = allowAll || len(p.config.AllowedProcessingOptions) == 0
 
 	for _, opt := range options {
-		if !allowAll && !slices.Contains(f.config.AllowedProcessingOptions, opt.Name) {
+		if !allowAll && !slices.Contains(p.config.AllowedProcessingOptions, opt.Name) {
 			return newForbiddenOptionError("processing", opt.Name)
 		}
 
-		if err := f.applyURLOption(po, opt.Name, opt.Args, usedPresets...); err != nil {
+		if err := p.applyURLOption(po, opt.Name, opt.Args, usedPresets...); err != nil {
 			return err
 		}
 	}
@@ -329,34 +126,46 @@ func (f *Factory) applyURLOptions(po *ProcessingOptions, options urlOptions, all
 	return nil
 }
 
-func (f *Factory) defaultProcessingOptions(headers http.Header) (*ProcessingOptions, error) {
-	po := f.NewProcessingOptions()
+func (p *Parser) defaultProcessingOptions(headers http.Header) (*Options, error) {
+	po := New()
 
 	headerAccept := headers.Get("Accept")
 
-	if strings.Contains(headerAccept, "image/webp") {
-		po.PreferWebP = f.config.AutoWebp || f.config.EnforceWebp
-		po.EnforceWebP = f.config.EnforceWebp
+	if (p.config.AutoWebp || p.config.EnforceWebp) && strings.Contains(headerAccept, "image/webp") {
+		po.Set(keys.PreferWebP, true)
+
+		if p.config.EnforceWebp {
+			po.Set(keys.EnforceWebP, true)
+		}
 	}
 
-	if strings.Contains(headerAccept, "image/avif") {
-		po.PreferAvif = f.config.AutoAvif || f.config.EnforceAvif
-		po.EnforceAvif = f.config.EnforceAvif
+	if (p.config.AutoAvif || p.config.EnforceAvif) && strings.Contains(headerAccept, "image/avif") {
+		po.Set(keys.PreferAvif, true)
+
+		if p.config.EnforceAvif {
+			po.Set(keys.EnforceAvif, true)
+		}
 	}
 
-	if strings.Contains(headerAccept, "image/jxl") {
-		po.PreferJxl = f.config.AutoJxl || f.config.EnforceJxl
-		po.EnforceJxl = f.config.EnforceJxl
+	if (p.config.AutoJxl || p.config.EnforceJxl) && strings.Contains(headerAccept, "image/jxl") {
+		po.Set(keys.PreferJxl, true)
+
+		if p.config.EnforceJxl {
+			po.Set(keys.EnforceJxl, true)
+		}
 	}
 
-	if f.config.EnableClientHints {
+	if p.config.EnableClientHints {
+		dpr := 1.0
+
 		headerDPR := headers.Get("Sec-CH-DPR")
 		if len(headerDPR) == 0 {
 			headerDPR = headers.Get("DPR")
 		}
 		if len(headerDPR) > 0 {
-			if dpr, err := strconv.ParseFloat(headerDPR, 64); err == nil && (dpr > 0 && dpr <= maxClientHintDPR) {
-				po.Dpr = dpr
+			if d, err := strconv.ParseFloat(headerDPR, 64); err == nil && (d > 0 && d <= maxClientHintDPR) {
+				dpr = d
+				po.Set(keys.Dpr, dpr)
 			}
 		}
 
@@ -366,13 +175,13 @@ func (f *Factory) defaultProcessingOptions(headers http.Header) (*ProcessingOpti
 		}
 		if len(headerWidth) > 0 {
 			if w, err := strconv.Atoi(headerWidth); err == nil {
-				po.Width = imath.Shrink(w, po.Dpr)
+				po.Set(keys.Width, imath.Shrink(w, dpr))
 			}
 		}
 	}
 
-	if _, ok := f.presets["default"]; ok {
-		if err := applyPresetOption(f, po, []string{"default"}); err != nil {
+	if _, ok := p.presets["default"]; ok {
+		if err := applyPresetOption(p, po, []string{"default"}); err != nil {
 			return po, err
 		}
 	}
@@ -381,20 +190,20 @@ func (f *Factory) defaultProcessingOptions(headers http.Header) (*ProcessingOpti
 }
 
 // ParsePath parses the given request path and returns the processing options and image URL
-func (f *Factory) ParsePath(
+func (p *Parser) ParsePath(
 	path string,
 	headers http.Header,
-) (po *ProcessingOptions, imageURL string, err error) {
+) (po *Options, imageURL string, err error) {
 	if path == "" || path == "/" {
 		return nil, "", newInvalidURLError("invalid path: %s", path)
 	}
 
 	parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
 
-	if f.config.OnlyPresets {
-		po, imageURL, err = f.parsePathPresets(parts, headers)
+	if p.config.OnlyPresets {
+		po, imageURL, err = p.parsePathPresets(parts, headers)
 	} else {
-		po, imageURL, err = f.parsePathOptions(parts, headers)
+		po, imageURL, err = p.parsePathOptions(parts, headers)
 	}
 
 	if err != nil {
@@ -405,28 +214,28 @@ func (f *Factory) ParsePath(
 }
 
 // parsePathOptions parses processing options from the URL path
-func (f *Factory) parsePathOptions(parts []string, headers http.Header) (*ProcessingOptions, string, error) {
+func (p *Parser) parsePathOptions(parts []string, headers http.Header) (*Options, string, error) {
 	if _, ok := resizeTypes[parts[0]]; ok {
 		return nil, "", newInvalidURLError("It looks like you're using the deprecated basic URL format")
 	}
 
-	po, err := f.defaultProcessingOptions(headers)
+	po, err := p.defaultProcessingOptions(headers)
 	if err != nil {
 		return nil, "", err
 	}
 
-	options, urlParts := f.parseURLOptions(parts)
+	options, urlParts := p.parseURLOptions(parts)
 
-	if err = f.applyURLOptions(po, options, false); err != nil {
+	if err = p.applyURLOptions(po, options, false); err != nil {
 		return nil, "", err
 	}
 
-	url, extension, err := f.DecodeURL(urlParts)
+	url, extension, err := p.DecodeURL(urlParts)
 	if err != nil {
 		return nil, "", err
 	}
 
-	if !po.Raw && len(extension) > 0 {
+	if !Get(po, keys.Raw, false) && len(extension) > 0 {
 		if err = applyFormatOption(po, []string{extension}); err != nil {
 			return nil, "", err
 		}
@@ -436,25 +245,25 @@ func (f *Factory) parsePathOptions(parts []string, headers http.Header) (*Proces
 }
 
 // parsePathPresets parses presets from the URL path
-func (f *Factory) parsePathPresets(parts []string, headers http.Header) (*ProcessingOptions, string, error) {
-	po, err := f.defaultProcessingOptions(headers)
+func (p *Parser) parsePathPresets(parts []string, headers http.Header) (*Options, string, error) {
+	po, err := p.defaultProcessingOptions(headers)
 	if err != nil {
 		return nil, "", err
 	}
 
-	presets := strings.Split(parts[0], f.config.ArgumentsSeparator)
+	presets := strings.Split(parts[0], p.config.ArgumentsSeparator)
 	urlParts := parts[1:]
 
-	if err = applyPresetOption(f, po, presets); err != nil {
+	if err = applyPresetOption(p, po, presets); err != nil {
 		return nil, "", err
 	}
 
-	url, extension, err := f.DecodeURL(urlParts)
+	url, extension, err := p.DecodeURL(urlParts)
 	if err != nil {
 		return nil, "", err
 	}
 
-	if !po.Raw && len(extension) > 0 {
+	if !Get(po, keys.Raw, false) && len(extension) > 0 {
 		if err = applyFormatOption(po, []string{extension}); err != nil {
 			return nil, "", err
 		}
@@ -462,11 +271,3 @@ func (f *Factory) parsePathPresets(parts []string, headers http.Header) (*Proces
 
 	return po, url, nil
 }
-
-func (po *ProcessingOptions) isSecurityOptionsAllowed() error {
-	if po.config.AllowSecurityOptions {
-		return nil
-	}
-
-	return newSecurityOptionsError()
-}

+ 242 - 179
options/processing_options_test.go

@@ -12,19 +12,17 @@ import (
 
 	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
-	"github.com/imgproxy/imgproxy/v3/security"
+	"github.com/imgproxy/imgproxy/v3/options/keys"
 	"github.com/imgproxy/imgproxy/v3/testutil"
+	"github.com/imgproxy/imgproxy/v3/vips/color"
 	"github.com/stretchr/testify/suite"
 )
 
 type ProcessingOptionsTestSuite struct {
 	testutil.LazySuite
 
-	securityCfg testutil.LazyObj[*security.Config]
-	security    testutil.LazyObj[*security.Checker]
-
-	config  testutil.LazyObj[*Config]
-	factory testutil.LazyObj[*Factory]
+	config testutil.LazyObj[*Config]
+	parser testutil.LazyObj[*Parser]
 }
 
 func (s *ProcessingOptionsTestSuite) SetupSuite() {
@@ -36,25 +34,10 @@ func (s *ProcessingOptionsTestSuite) SetupSuite() {
 		},
 	)
 
-	s.securityCfg, _ = testutil.NewLazySuiteObj(
-		s,
-		func() (*security.Config, error) {
-			c := security.NewDefaultConfig()
-			return &c, nil
-		},
-	)
-
-	s.security, _ = testutil.NewLazySuiteObj(
-		s,
-		func() (*security.Checker, error) {
-			return security.New(s.securityCfg())
-		},
-	)
-
-	s.factory, _ = testutil.NewLazySuiteObj(
+	s.parser, _ = testutil.NewLazySuiteObj(
 		s,
-		func() (*Factory, error) {
-			return NewFactory(s.config(), s.security())
+		func() (*Parser, error) {
+			return NewParser(s.config())
 		},
 	)
 }
@@ -66,11 +49,11 @@ func (s *ProcessingOptionsTestSuite) SetupSubTest() {
 func (s *ProcessingOptionsTestSuite) TestParseBase64URL() {
 	originURL := "http://images.dev/lorem/ipsum.jpg?param=value"
 	path := fmt.Sprintf("/size:100:100/%s.png", base64.RawURLEncoding.EncodeToString([]byte(originURL)))
-	po, imageURL, err := s.factory().ParsePath(path, make(http.Header))
+	po, imageURL, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 	s.Require().Equal(originURL, imageURL)
-	s.Require().Equal(imagetype.PNG, po.Format)
+	s.Require().Equal(imagetype.PNG, Get(po, keys.Format, imagetype.Unknown))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParseBase64URLWithFilename() {
@@ -78,21 +61,21 @@ func (s *ProcessingOptionsTestSuite) TestParseBase64URLWithFilename() {
 
 	originURL := "http://images.dev/lorem/ipsum.jpg?param=value"
 	path := fmt.Sprintf("/size:100:100/%s.png/puppy.jpg", base64.RawURLEncoding.EncodeToString([]byte(originURL)))
-	po, imageURL, err := s.factory().ParsePath(path, make(http.Header))
+	po, imageURL, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 	s.Require().Equal(originURL, imageURL)
-	s.Require().Equal(imagetype.PNG, po.Format)
+	s.Require().Equal(imagetype.PNG, Get(po, keys.Format, imagetype.Unknown))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParseBase64URLWithoutExtension() {
 	originURL := "http://images.dev/lorem/ipsum.jpg?param=value"
 	path := fmt.Sprintf("/size:100:100/%s", base64.RawURLEncoding.EncodeToString([]byte(originURL)))
-	po, imageURL, err := s.factory().ParsePath(path, make(http.Header))
+	po, imageURL, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 	s.Require().Equal(originURL, imageURL)
-	s.Require().Equal(imagetype.Unknown, po.Format)
+	s.Require().Equal(imagetype.Unknown, Get(po, keys.Format, imagetype.Unknown))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParseBase64URLWithBase() {
@@ -100,11 +83,11 @@ func (s *ProcessingOptionsTestSuite) TestParseBase64URLWithBase() {
 
 	originURL := "lorem/ipsum.jpg?param=value"
 	path := fmt.Sprintf("/size:100:100/%s.png", base64.RawURLEncoding.EncodeToString([]byte(originURL)))
-	po, imageURL, err := s.factory().ParsePath(path, make(http.Header))
+	po, imageURL, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 	s.Require().Equal(fmt.Sprintf("%s%s", s.config().BaseURL, originURL), imageURL)
-	s.Require().Equal(imagetype.PNG, po.Format)
+	s.Require().Equal(imagetype.PNG, Get(po, keys.Format, imagetype.Unknown))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParseBase64URLWithReplacement() {
@@ -115,41 +98,41 @@ func (s *ProcessingOptionsTestSuite) TestParseBase64URLWithReplacement() {
 
 	originURL := "test://lorem/ipsum.jpg?param=value"
 	path := fmt.Sprintf("/size:100:100/%s.png", base64.RawURLEncoding.EncodeToString([]byte(originURL)))
-	po, imageURL, err := s.factory().ParsePath(path, make(http.Header))
+	po, imageURL, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 	s.Require().Equal("http://images.dev/lorem/dolor/ipsum.jpg?param=value", imageURL)
-	s.Require().Equal(imagetype.PNG, po.Format)
+	s.Require().Equal(imagetype.PNG, Get(po, keys.Format, imagetype.Unknown))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePlainURL() {
 	originURL := "http://images.dev/lorem/ipsum.jpg"
 	path := fmt.Sprintf("/size:100:100/plain/%s@png", originURL)
-	po, imageURL, err := s.factory().ParsePath(path, make(http.Header))
+	po, imageURL, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 	s.Require().Equal(originURL, imageURL)
-	s.Require().Equal(imagetype.PNG, po.Format)
+	s.Require().Equal(imagetype.PNG, Get(po, keys.Format, imagetype.Unknown))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePlainURLWithoutExtension() {
 	originURL := "http://images.dev/lorem/ipsum.jpg"
 	path := fmt.Sprintf("/size:100:100/plain/%s", originURL)
 
-	po, imageURL, err := s.factory().ParsePath(path, make(http.Header))
+	po, imageURL, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 	s.Require().Equal(originURL, imageURL)
-	s.Require().Equal(imagetype.Unknown, po.Format)
+	s.Require().Equal(imagetype.Unknown, Get(po, keys.Format, imagetype.Unknown))
 }
 func (s *ProcessingOptionsTestSuite) TestParsePlainURLEscaped() {
 	originURL := "http://images.dev/lorem/ipsum.jpg?param=value"
 	path := fmt.Sprintf("/size:100:100/plain/%s@png", url.PathEscape(originURL))
-	po, imageURL, err := s.factory().ParsePath(path, make(http.Header))
+	po, imageURL, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 	s.Require().Equal(originURL, imageURL)
-	s.Require().Equal(imagetype.PNG, po.Format)
+	s.Require().Equal(imagetype.PNG, Get(po, keys.Format, imagetype.Unknown))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePlainURLWithBase() {
@@ -157,11 +140,11 @@ func (s *ProcessingOptionsTestSuite) TestParsePlainURLWithBase() {
 
 	originURL := "lorem/ipsum.jpg"
 	path := fmt.Sprintf("/size:100:100/plain/%s@png", originURL)
-	po, imageURL, err := s.factory().ParsePath(path, make(http.Header))
+	po, imageURL, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 	s.Require().Equal(fmt.Sprintf("%s%s", s.config().BaseURL, originURL), imageURL)
-	s.Require().Equal(imagetype.PNG, po.Format)
+	s.Require().Equal(imagetype.PNG, Get(po, keys.Format, imagetype.Unknown))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePlainURLWithReplacement() {
@@ -172,11 +155,11 @@ func (s *ProcessingOptionsTestSuite) TestParsePlainURLWithReplacement() {
 
 	originURL := "test://lorem/ipsum.jpg"
 	path := fmt.Sprintf("/size:100:100/plain/%s@png", originURL)
-	po, imageURL, err := s.factory().ParsePath(path, make(http.Header))
+	po, imageURL, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 	s.Require().Equal("http://images.dev/lorem/dolor/ipsum.jpg", imageURL)
-	s.Require().Equal(imagetype.PNG, po.Format)
+	s.Require().Equal(imagetype.PNG, Get(po, keys.Format, imagetype.Unknown))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePlainURLEscapedWithBase() {
@@ -184,258 +167,260 @@ func (s *ProcessingOptionsTestSuite) TestParsePlainURLEscapedWithBase() {
 
 	originURL := "lorem/ipsum.jpg?param=value"
 	path := fmt.Sprintf("/size:100:100/plain/%s@png", url.PathEscape(originURL))
-	po, imageURL, err := s.factory().ParsePath(path, make(http.Header))
+	po, imageURL, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 	s.Require().Equal(fmt.Sprintf("%s%s", s.config().BaseURL, originURL), imageURL)
-	s.Require().Equal(imagetype.PNG, po.Format)
+	s.Require().Equal(imagetype.PNG, Get(po, keys.Format, imagetype.Unknown))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParseWithArgumentsSeparator() {
 	s.config().ArgumentsSeparator = ","
 
 	path := "/size,100,100,1/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().Equal(100, po.Width)
-	s.Require().Equal(100, po.Height)
-	s.Require().True(po.Enlarge)
+	s.Require().Equal(100, po.GetInt(keys.Width, 0))
+	s.Require().Equal(100, po.GetInt(keys.Height, 0))
+	s.Require().True(po.GetBool(keys.Enlarge, false))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathFormat() {
 	path := "/format:webp/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().Equal(imagetype.WEBP, po.Format)
+	s.Require().Equal(imagetype.WEBP, Get(po, keys.Format, imagetype.Unknown))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathResize() {
 	path := "/resize:fill:100:200:1/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().Equal(ResizeFill, po.ResizingType)
-	s.Require().Equal(100, po.Width)
-	s.Require().Equal(200, po.Height)
-	s.Require().True(po.Enlarge)
+	s.Require().Equal(ResizeFill, Get(po, keys.ResizingType, ResizeFit))
+	s.Require().Equal(100, po.GetInt(keys.Width, 0))
+	s.Require().Equal(200, po.GetInt(keys.Height, 0))
+	s.Require().True(po.GetBool(keys.Enlarge, false))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathResizingType() {
 	path := "/resizing_type:fill/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().Equal(ResizeFill, po.ResizingType)
+	s.Require().Equal(ResizeFill, Get(po, keys.ResizingType, ResizeFit))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathSize() {
 	path := "/size:100:200:1/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().Equal(100, po.Width)
-	s.Require().Equal(200, po.Height)
-	s.Require().True(po.Enlarge)
+	s.Require().Equal(100, po.GetInt(keys.Width, 0))
+	s.Require().Equal(200, po.GetInt(keys.Height, 0))
+	s.Require().True(po.GetBool(keys.Enlarge, false))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathWidth() {
 	path := "/width:100/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().Equal(100, po.Width)
+	s.Require().Equal(100, po.GetInt(keys.Width, 0))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathHeight() {
 	path := "/height:100/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().Equal(100, po.Height)
+	s.Require().Equal(100, po.GetInt(keys.Height, 0))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathEnlarge() {
 	path := "/enlarge:1/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().True(po.Enlarge)
+	s.Require().True(po.GetBool(keys.Enlarge, false))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathExtend() {
 	path := "/extend:1:so:10:20/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().True(po.Extend.Enabled)
-	s.Require().Equal(GravitySouth, po.Extend.Gravity.Type)
-	s.Require().InDelta(10.0, po.Extend.Gravity.X, 0.0001)
-	s.Require().InDelta(20.0, po.Extend.Gravity.Y, 0.0001)
+	s.Require().True(po.GetBool(keys.ExtendEnabled, false))
+	s.Require().Equal(GravitySouth, Get(po, keys.ExtendGravityType, GravityUnknown))
+	s.Require().InDelta(10.0, po.GetFloat(keys.ExtendGravityXOffset, 0.0), 0.0001)
+	s.Require().InDelta(20.0, po.GetFloat(keys.ExtendGravityYOffset, 0.0), 0.0001)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathExtendSmartGravity() {
 	path := "/extend:1:sm/plain/http://images.dev/lorem/ipsum.jpg"
-	_, _, err := s.factory().ParsePath(path, make(http.Header))
+	_, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().Error(err)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathExtendReplicateGravity() {
 	path := "/extend:1:re/plain/http://images.dev/lorem/ipsum.jpg"
-	_, _, err := s.factory().ParsePath(path, make(http.Header))
+	_, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().Error(err)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathGravity() {
 	path := "/gravity:soea/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().Equal(GravitySouthEast, po.Gravity.Type)
+	s.Require().Equal(GravitySouthEast, Get(po, keys.GravityType, GravityUnknown))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathGravityFocusPoint() {
 	path := "/gravity:fp:0.5:0.75/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().Equal(GravityFocusPoint, po.Gravity.Type)
-	s.Require().InDelta(0.5, po.Gravity.X, 0.0001)
-	s.Require().InDelta(0.75, po.Gravity.Y, 0.0001)
+	s.Require().Equal(GravityFocusPoint, Get(po, keys.GravityType, GravityUnknown))
+	s.Require().InDelta(0.5, po.GetFloat(keys.GravityXOffset, 0.0), 0.0001)
+	s.Require().InDelta(0.75, po.GetFloat(keys.GravityYOffset, 0.0), 0.0001)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathGravityReplicate() {
 	path := "/gravity:re/plain/http://images.dev/lorem/ipsum.jpg"
-	_, _, err := s.factory().ParsePath(path, make(http.Header))
+	_, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().Error(err)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathCrop() {
 	path := "/crop:100:200/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().InDelta(100.0, po.Crop.Width, 0.0001)
-	s.Require().InDelta(200.0, po.Crop.Height, 0.0001)
-	s.Require().Equal(GravityUnknown, po.Crop.Gravity.Type)
-	s.Require().InDelta(0.0, po.Crop.Gravity.X, 0.0001)
-	s.Require().InDelta(0.0, po.Crop.Gravity.Y, 0.0001)
+	s.Require().InDelta(100.0, po.GetFloat(keys.CropWidth, 0.0), 0.0001)
+	s.Require().InDelta(200.0, po.GetFloat(keys.CropHeight, 0.0), 0.0001)
+	s.Require().Equal(GravityUnknown, Get(po, keys.CropGravityType, GravityUnknown))
+	s.Require().InDelta(0.0, po.GetFloat(keys.CropGravityXOffset, 0.0), 0.0001)
+	s.Require().InDelta(0.0, po.GetFloat(keys.CropGravityYOffset, 0.0), 0.0001)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathCropGravity() {
 	path := "/crop:100:200:nowe:10:20/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().InDelta(100.0, po.Crop.Width, 0.0001)
-	s.Require().InDelta(200.0, po.Crop.Height, 0.0001)
-	s.Require().Equal(GravityNorthWest, po.Crop.Gravity.Type)
-	s.Require().InDelta(10.0, po.Crop.Gravity.X, 0.0001)
-	s.Require().InDelta(20.0, po.Crop.Gravity.Y, 0.0001)
+	s.Require().InDelta(100.0, po.GetFloat(keys.CropWidth, 0.0), 0.0001)
+	s.Require().InDelta(200.0, po.GetFloat(keys.CropHeight, 0.0), 0.0001)
+	s.Require().Equal(GravityNorthWest, Get(po, keys.CropGravityType, GravityUnknown))
+	s.Require().InDelta(10.0, po.GetFloat(keys.CropGravityXOffset, 0.0), 0.0001)
+	s.Require().InDelta(20.0, po.GetFloat(keys.CropGravityYOffset, 0.0), 0.0001)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathCropGravityReplicate() {
 	path := "/crop:100:200:re/plain/http://images.dev/lorem/ipsum.jpg"
-	_, _, err := s.factory().ParsePath(path, make(http.Header))
+	_, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().Error(err)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathQuality() {
 	path := "/quality:55/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().Equal(55, po.Quality)
+	s.Require().Equal(55, po.GetInt(keys.Quality, 0))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathBackground() {
 	path := "/background:128:129:130/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().True(po.Flatten)
-	s.Require().Equal(uint8(128), po.Background.R)
-	s.Require().Equal(uint8(129), po.Background.G)
-	s.Require().Equal(uint8(130), po.Background.B)
+	s.Require().Equal(
+		color.RGB{R: 128, G: 129, B: 130},
+		Get(po, keys.Background, color.RGB{}),
+	)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathBackgroundHex() {
 	path := "/background:ffddee/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().True(po.Flatten)
-	s.Require().Equal(uint8(0xff), po.Background.R)
-	s.Require().Equal(uint8(0xdd), po.Background.G)
-	s.Require().Equal(uint8(0xee), po.Background.B)
+	s.Require().Equal(
+		color.RGB{R: 0xff, G: 0xdd, B: 0xee},
+		Get(po, keys.Background, color.RGB{}),
+	)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathBackgroundDisable() {
 	path := "/background:fff/background:/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().False(po.Flatten)
+	s.Require().False(po.Has(keys.Background))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathBlur() {
 	path := "/blur:0.2/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().InDelta(float32(0.2), po.Blur, 0.0001)
+	s.Require().InDelta(0.2, po.GetFloat(keys.Blur, 0.0), 0.0001)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathSharpen() {
 	path := "/sharpen:0.2/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().InDelta(float32(0.2), po.Sharpen, 0.0001)
+	s.Require().InDelta(0.2, po.GetFloat(keys.Sharpen, 0.0), 0.0001)
 }
+
 func (s *ProcessingOptionsTestSuite) TestParsePathDpr() {
 	path := "/dpr:2/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().InDelta(2.0, po.Dpr, 0.0001)
+	s.Require().InDelta(2.0, po.GetFloat(keys.Dpr, 1.0), 0.0001)
 }
+
 func (s *ProcessingOptionsTestSuite) TestParsePathWatermark() {
 	path := "/watermark:0.5:soea:10:20:0.6/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().True(po.Watermark.Enabled)
-	s.Require().Equal(GravitySouthEast, po.Watermark.Position.Type)
-	s.Require().InDelta(10.0, po.Watermark.Position.X, 0.0001)
-	s.Require().InDelta(20.0, po.Watermark.Position.Y, 0.0001)
-	s.Require().InDelta(0.6, po.Watermark.Scale, 0.0001)
+	s.Require().InDelta(0.5, po.GetFloat(keys.WatermarkOpacity, 0.0), 0.0001)
+	s.Require().Equal(GravitySouthEast, Get(po, keys.WatermarkPosition, GravityUnknown))
+	s.Require().InDelta(10.0, po.GetFloat(keys.WatermarkXOffset, 0.0), 0.0001)
+	s.Require().InDelta(20.0, po.GetFloat(keys.WatermarkYOffset, 0.0), 0.0001)
+	s.Require().InDelta(0.6, po.GetFloat(keys.WatermarkScale, 0.0), 0.0001)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathPreset() {
@@ -445,13 +430,14 @@ func (s *ProcessingOptionsTestSuite) TestParsePathPreset() {
 	}
 
 	path := "/preset:test1:test2/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().Equal(ResizeFill, po.ResizingType)
-	s.Require().InDelta(float32(0.2), po.Blur, 0.0001)
-	s.Require().Equal(50, po.Quality)
+	s.Require().Equal(ResizeFill, Get(po, keys.ResizingType, ResizeFit))
+	s.Require().InDelta(float32(0.2), po.GetFloat(keys.Blur, 0.0), 0.0001)
+	s.Require().Equal(50, po.GetInt(keys.Quality, 0))
+	s.Require().ElementsMatch([]string{"test1", "test2"}, Get(po, keys.UsedPresets, []string{}))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathPresetDefault() {
@@ -460,13 +446,14 @@ func (s *ProcessingOptionsTestSuite) TestParsePathPresetDefault() {
 	}
 
 	path := "/quality:70/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().Equal(ResizeFill, po.ResizingType)
-	s.Require().InDelta(float32(0.2), po.Blur, 0.0001)
-	s.Require().Equal(70, po.Quality)
+	s.Require().Equal(ResizeFill, Get(po, keys.ResizingType, ResizeFit))
+	s.Require().InDelta(float32(0.2), po.GetFloat(keys.Blur, 0.0), 0.0001)
+	s.Require().Equal(70, po.GetInt(keys.Quality, 0))
+	s.Require().ElementsMatch([]string{"default"}, Get(po, keys.UsedPresets, []string{}))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathPresetLoopDetection() {
@@ -476,29 +463,29 @@ func (s *ProcessingOptionsTestSuite) TestParsePathPresetLoopDetection() {
 	}
 
 	path := "/preset:test1/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().ElementsMatch(po.UsedPresets, []string{"test1", "test2"})
+	s.Require().ElementsMatch([]string{"test1", "test2"}, Get(po, keys.UsedPresets, []string{}))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathCachebuster() {
 	path := "/cachebuster:123/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().Equal("123", po.CacheBuster)
+	s.Require().Equal("123", Get(po, keys.CacheBuster, ""))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathStripMetadata() {
 	path := "/strip_metadata:true/plain/http://images.dev/lorem/ipsum.jpg"
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().True(po.StripMetadata)
+	s.Require().True(po.GetBool(keys.StripMetadata, false))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathWebpDetection() {
@@ -506,12 +493,12 @@ func (s *ProcessingOptionsTestSuite) TestParsePathWebpDetection() {
 
 	path := "/plain/http://images.dev/lorem/ipsum.jpg"
 	headers := http.Header{"Accept": []string{"image/webp"}}
-	po, _, err := s.factory().ParsePath(path, headers)
+	po, _, err := s.parser().ParsePath(path, headers)
 
 	s.Require().NoError(err)
 
-	s.Require().True(po.PreferWebP)
-	s.Require().False(po.EnforceWebP)
+	s.Require().True(po.GetBool(keys.PreferWebP, false))
+	s.Require().False(po.GetBool(keys.EnforceWebP, false))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathWebpEnforce() {
@@ -519,12 +506,64 @@ func (s *ProcessingOptionsTestSuite) TestParsePathWebpEnforce() {
 
 	path := "/plain/http://images.dev/lorem/ipsum.jpg@png"
 	headers := http.Header{"Accept": []string{"image/webp"}}
-	po, _, err := s.factory().ParsePath(path, headers)
+	po, _, err := s.parser().ParsePath(path, headers)
+
+	s.Require().NoError(err)
+
+	s.Require().True(po.GetBool(keys.PreferWebP, false))
+	s.Require().True(po.GetBool(keys.EnforceWebP, false))
+}
+
+func (s *ProcessingOptionsTestSuite) TestParsePathAvifDetection() {
+	s.config().AutoAvif = true
+
+	path := "/plain/http://images.dev/lorem/ipsum.jpg"
+	headers := http.Header{"Accept": []string{"image/avif"}}
+	po, _, err := s.parser().ParsePath(path, headers)
+
+	s.Require().NoError(err)
+
+	s.Require().True(po.GetBool(keys.PreferAvif, false))
+	s.Require().False(po.GetBool(keys.EnforceAvif, false))
+}
+
+func (s *ProcessingOptionsTestSuite) TestParsePathAvifEnforce() {
+	s.config().EnforceAvif = true
+
+	path := "/plain/http://images.dev/lorem/ipsum.jpg@png"
+	headers := http.Header{"Accept": []string{"image/avif"}}
+	po, _, err := s.parser().ParsePath(path, headers)
 
 	s.Require().NoError(err)
 
-	s.Require().True(po.PreferWebP)
-	s.Require().True(po.EnforceWebP)
+	s.Require().True(po.GetBool(keys.PreferAvif, false))
+	s.Require().True(po.GetBool(keys.EnforceAvif, false))
+}
+
+func (s *ProcessingOptionsTestSuite) TestParsePathJxlDetection() {
+	s.config().AutoJxl = true
+
+	path := "/plain/http://images.dev/lorem/ipsum.jpg"
+	headers := http.Header{"Accept": []string{"image/jxl"}}
+	po, _, err := s.parser().ParsePath(path, headers)
+
+	s.Require().NoError(err)
+
+	s.Require().True(po.GetBool(keys.PreferJxl, false))
+	s.Require().False(po.GetBool(keys.EnforceJxl, false))
+}
+
+func (s *ProcessingOptionsTestSuite) TestParsePathJxlEnforce() {
+	s.config().EnforceJxl = true
+
+	path := "/plain/http://images.dev/lorem/ipsum.jpg@png"
+	headers := http.Header{"Accept": []string{"image/jxl"}}
+	po, _, err := s.parser().ParsePath(path, headers)
+
+	s.Require().NoError(err)
+
+	s.Require().True(po.GetBool(keys.PreferJxl, false))
+	s.Require().True(po.GetBool(keys.EnforceJxl, false))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathWidthHeader() {
@@ -532,21 +571,21 @@ func (s *ProcessingOptionsTestSuite) TestParsePathWidthHeader() {
 
 	path := "/plain/http://images.dev/lorem/ipsum.jpg@png"
 	headers := http.Header{"Width": []string{"100"}}
-	po, _, err := s.factory().ParsePath(path, headers)
+	po, _, err := s.parser().ParsePath(path, headers)
 
 	s.Require().NoError(err)
 
-	s.Require().Equal(100, po.Width)
+	s.Require().Equal(100, po.GetInt(keys.Width, 0))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathWidthHeaderDisabled() {
 	path := "/plain/http://images.dev/lorem/ipsum.jpg@png"
 	headers := http.Header{"Width": []string{"100"}}
-	po, _, err := s.factory().ParsePath(path, headers)
+	po, _, err := s.parser().ParsePath(path, headers)
 
 	s.Require().NoError(err)
 
-	s.Require().Equal(0, po.Width)
+	s.Require().Equal(0, po.GetInt(keys.Width, 0))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathWidthHeaderRedefine() {
@@ -554,11 +593,11 @@ func (s *ProcessingOptionsTestSuite) TestParsePathWidthHeaderRedefine() {
 
 	path := "/width:150/plain/http://images.dev/lorem/ipsum.jpg@png"
 	headers := http.Header{"Width": []string{"100"}}
-	po, _, err := s.factory().ParsePath(path, headers)
+	po, _, err := s.parser().ParsePath(path, headers)
 
 	s.Require().NoError(err)
 
-	s.Require().Equal(150, po.Width)
+	s.Require().Equal(150, po.GetInt(keys.Width, 0))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathDprHeader() {
@@ -566,52 +605,72 @@ func (s *ProcessingOptionsTestSuite) TestParsePathDprHeader() {
 
 	path := "/plain/http://images.dev/lorem/ipsum.jpg@png"
 	headers := http.Header{"Dpr": []string{"2"}}
-	po, _, err := s.factory().ParsePath(path, headers)
+	po, _, err := s.parser().ParsePath(path, headers)
 
 	s.Require().NoError(err)
 
-	s.Require().InDelta(2.0, po.Dpr, 0.0001)
+	s.Require().InDelta(2.0, po.GetFloat(keys.Dpr, 1.0), 0.0001)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathDprHeaderDisabled() {
 	path := "/plain/http://images.dev/lorem/ipsum.jpg@png"
 	headers := http.Header{"Dpr": []string{"2"}}
-	po, _, err := s.factory().ParsePath(path, headers)
+	po, _, err := s.parser().ParsePath(path, headers)
 
 	s.Require().NoError(err)
 
-	s.Require().InDelta(1.0, po.Dpr, 0.0001)
+	s.Require().InDelta(1.0, po.GetFloat(keys.Dpr, 1.0), 0.0001)
+}
+
+func (s *ProcessingOptionsTestSuite) TestParsePathWidthAndDprHeaderCombined() {
+	s.config().EnableClientHints = true
+
+	path := "/plain/http://images.dev/lorem/ipsum.jpg@png"
+	headers := http.Header{
+		"Width": []string{"100"},
+		"Dpr":   []string{"2"},
+	}
+	po, _, err := s.parser().ParsePath(path, headers)
+
+	s.Require().NoError(err)
+
+	s.Require().Equal(50, po.GetInt(keys.Width, 0))
+	s.Require().InDelta(2.0, po.GetFloat(keys.Dpr, 1.0), 0.0001)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParseSkipProcessing() {
 	path := "/skp:jpg:png/plain/http://images.dev/lorem/ipsum.jpg"
 
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().Equal([]imagetype.Type{imagetype.JPEG, imagetype.PNG}, po.SkipProcessingFormats)
+	s.Require().ElementsMatch(
+		[]imagetype.Type{imagetype.JPEG, imagetype.PNG},
+		Get(po, keys.SkipProcessing, []imagetype.Type(nil)),
+	)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParseSkipProcessingInvalid() {
 	path := "/skp:jpg:png:bad_format/plain/http://images.dev/lorem/ipsum.jpg"
 
-	_, _, err := s.factory().ParsePath(path, make(http.Header))
+	_, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().Error(err)
-	s.Require().Equal("Invalid image format in skip processing: bad_format", err.Error())
+	s.Require().Equal("Invalid image format in skip_processing: bad_format", err.Error())
 }
 
 func (s *ProcessingOptionsTestSuite) TestParseExpires() {
 	path := "/exp:32503669200/plain/http://images.dev/lorem/ipsum.jpg"
-	_, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
+	s.Require().Equal(time.Unix(32503669200, 0), po.GetTime(keys.Expires))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParseExpiresExpired() {
 	path := "/exp:1609448400/plain/http://images.dev/lorem/ipsum.jpg"
-	_, _, err := s.factory().ParsePath(path, make(http.Header))
+	_, _, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().Error(err, "Expired URL")
 }
@@ -623,14 +682,17 @@ func (s *ProcessingOptionsTestSuite) TestParsePathOnlyPresets() {
 		"test2=quality:50",
 	}
 
-	path := "/test1:test2/plain/http://images.dev/lorem/ipsum.jpg"
+	originURL := "http://images.dev/lorem/ipsum.jpg"
+	path := "/test1:test2/plain/" + originURL + "@png"
 
-	po, _, err := s.factory().ParsePath(path, make(http.Header))
+	po, imageURL, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().InDelta(float32(0.2), po.Blur, 0.0001)
-	s.Require().Equal(50, po.Quality)
+	s.Require().InDelta(0.2, po.GetFloat(keys.Blur, 0.0), 0.0001)
+	s.Require().Equal(50, po.GetInt(keys.Quality, 0))
+	s.Require().Equal(imagetype.PNG, Get(po, keys.Format, imagetype.Unknown))
+	s.Require().Equal(originURL, imageURL)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParseBase64URLOnlyPresets() {
@@ -643,12 +705,13 @@ func (s *ProcessingOptionsTestSuite) TestParseBase64URLOnlyPresets() {
 	originURL := "http://images.dev/lorem/ipsum.jpg?param=value"
 	path := fmt.Sprintf("/test1:test2/%s.png", base64.RawURLEncoding.EncodeToString([]byte(originURL)))
 
-	po, imageURL, err := s.factory().ParsePath(path, make(http.Header))
+	po, imageURL, err := s.parser().ParsePath(path, make(http.Header))
 
 	s.Require().NoError(err)
 
-	s.Require().InDelta(float32(0.2), po.Blur, 0.0001)
-	s.Require().Equal(50, po.Quality)
+	s.Require().InDelta(0.2, po.GetFloat(keys.Blur, 0.0), 0.0001)
+	s.Require().Equal(50, po.GetInt(keys.Quality, 0))
+	s.Require().Equal(imagetype.PNG, Get(po, keys.Format, imagetype.Unknown))
 	s.Require().Equal(originURL, imageURL)
 }
 
@@ -673,7 +736,7 @@ func (s *ProcessingOptionsTestSuite) TestParseAllowedOptions() {
 			}
 
 			path := fmt.Sprintf("/%s/%s.png", tc.options, base64.RawURLEncoding.EncodeToString([]byte(originURL)))
-			_, _, err := s.factory().ParsePath(path, make(http.Header))
+			_, _, err := s.parser().ParsePath(path, make(http.Header))
 
 			if len(tc.expectedError) > 0 {
 				s.Require().Error(err)
@@ -685,22 +748,22 @@ func (s *ProcessingOptionsTestSuite) TestParseAllowedOptions() {
 	}
 }
 
-func (s *ProcessingOptionsTestSuite) TestProcessingOptionsClone() {
-	now := time.Now()
+// func (s *ProcessingOptionsTestSuite) TestProcessingOptionsClone() {
+// 	now := time.Now()
 
-	// Create ProcessingOptions using factory
-	original := s.factory().NewProcessingOptions()
-	original.SkipProcessingFormats = []imagetype.Type{
-		imagetype.PNG, imagetype.JPEG,
-	}
-	original.UsedPresets = []string{"preset1", "preset2"}
-	original.Expires = &now
+// 	// Create Options using parser
+// 	original := s.parser().NewProcessingOptions()
+// 	original.SkipProcessingFormats = []imagetype.Type{
+// 		imagetype.PNG, imagetype.JPEG,
+// 	}
+// 	original.UsedPresets = []string{"preset1", "preset2"}
+// 	original.Expires = &now
 
-	// Clone the original
-	cloned := original.clone()
+// 	// Clone the original
+// 	cloned := original.clone()
 
-	testutil.EqualButNotSame(s.T(), original, cloned)
-}
+// 	testutil.EqualButNotSame(s.T(), original, cloned)
+// }
 
 func TestProcessingOptions(t *testing.T) {
 	suite.Run(t, new(ProcessingOptionsTestSuite))

+ 12 - 12
options/url.go

@@ -9,22 +9,22 @@ import (
 
 const urlTokenPlain = "plain"
 
-func (f *Factory) preprocessURL(u string) string {
-	for _, repl := range f.config.URLReplacements {
+func (p *Parser) preprocessURL(u string) string {
+	for _, repl := range p.config.URLReplacements {
 		u = repl.Regexp.ReplaceAllString(u, repl.Replacement)
 	}
 
-	if len(f.config.BaseURL) == 0 || strings.HasPrefix(u, f.config.BaseURL) {
+	if len(p.config.BaseURL) == 0 || strings.HasPrefix(u, p.config.BaseURL) {
 		return u
 	}
 
-	return fmt.Sprintf("%s%s", f.config.BaseURL, u)
+	return fmt.Sprintf("%s%s", p.config.BaseURL, u)
 }
 
-func (f *Factory) decodeBase64URL(parts []string) (string, string, error) {
+func (p *Parser) decodeBase64URL(parts []string) (string, string, error) {
 	var format string
 
-	if len(parts) > 1 && f.config.Base64URLIncludesFilename {
+	if len(parts) > 1 && p.config.Base64URLIncludesFilename {
 		parts = parts[:len(parts)-1]
 	}
 
@@ -48,10 +48,10 @@ func (f *Factory) decodeBase64URL(parts []string) (string, string, error) {
 		return "", "", newInvalidURLError("Invalid url encoding: %s", encoded)
 	}
 
-	return f.preprocessURL(string(imageURL)), format, nil
+	return p.preprocessURL(string(imageURL)), format, nil
 }
 
-func (f *Factory) decodePlainURL(parts []string) (string, string, error) {
+func (p *Parser) decodePlainURL(parts []string) (string, string, error) {
 	var format string
 
 	encoded := strings.Join(parts, "/")
@@ -74,17 +74,17 @@ func (f *Factory) decodePlainURL(parts []string) (string, string, error) {
 		return "", "", newInvalidURLError("Invalid url encoding: %s", encoded)
 	}
 
-	return f.preprocessURL(unescaped), format, nil
+	return p.preprocessURL(unescaped), format, nil
 }
 
-func (f *Factory) DecodeURL(parts []string) (string, string, error) {
+func (p *Parser) DecodeURL(parts []string) (string, string, error) {
 	if len(parts) == 0 {
 		return "", "", newInvalidURLError("Image URL is empty")
 	}
 
 	if parts[0] == urlTokenPlain && len(parts) > 1 {
-		return f.decodePlainURL(parts[1:])
+		return p.decodePlainURL(parts[1:])
 	}
 
-	return f.decodeBase64URL(parts)
+	return p.decodeBase64URL(parts)
 }

+ 2 - 2
options/url_options.go

@@ -11,12 +11,12 @@ type urlOption struct {
 
 type urlOptions []urlOption
 
-func (f *Factory) parseURLOptions(opts []string) (urlOptions, []string) {
+func (p *Parser) parseURLOptions(opts []string) (urlOptions, []string) {
 	parsed := make(urlOptions, 0, len(opts))
 	urlStart := len(opts) + 1
 
 	for i, opt := range opts {
-		args := strings.Split(opt, f.config.ArgumentsSeparator)
+		args := strings.Split(opt, p.config.ArgumentsSeparator)
 
 		if len(args) == 1 {
 			urlStart = i

+ 6 - 2
processing/apply_filters.go

@@ -1,7 +1,11 @@
 package processing
 
 func (p *Processor) applyFilters(c *Context) error {
-	if c.PO.Blur == 0 && c.PO.Sharpen == 0 && c.PO.Pixelate <= 1 {
+	blur := c.PO.Blur()
+	sharpen := c.PO.Sharpen()
+	pixelate := c.PO.Pixelate()
+
+	if blur == 0 && sharpen == 0 && pixelate <= 1 {
 		return nil
 	}
 
@@ -13,7 +17,7 @@ func (p *Processor) applyFilters(c *Context) error {
 		return err
 	}
 
-	if err := c.Img.ApplyFilters(c.PO.Blur, c.PO.Sharpen, c.PO.Pixelate); err != nil {
+	if err := c.Img.ApplyFilters(blur, sharpen, pixelate); err != nil {
 		return err
 	}
 

+ 1 - 1
processing/calc_position.go

@@ -7,7 +7,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/options"
 )
 
-func calcPosition(width, height, innerWidth, innerHeight int, gravity *options.GravityOptions, dpr float64, allowOverflow bool) (left, top int) {
+func calcPosition(width, height, innerWidth, innerHeight int, gravity *GravityOptions, dpr float64, allowOverflow bool) (left, top int) {
 	if gravity.Type == options.GravityFocusPoint {
 		pointX := imath.ScaleToEven(width, gravity.X)
 		pointY := imath.ScaleToEven(height, gravity.Y)

+ 1 - 1
processing/colorspace_to_result.go

@@ -1,7 +1,7 @@
 package processing
 
 func (p *Processor) colorspaceToResult(c *Context) error {
-	keepProfile := !c.PO.StripColorProfile && c.PO.Format.SupportsColourProfile()
+	keepProfile := !c.PO.StripColorProfile() && c.PO.Format().SupportsColourProfile()
 
 	if c.Img.IsLinear() {
 		if err := c.Img.RgbColourspace(); err != nil {

+ 28 - 6
processing/config.go

@@ -13,12 +13,20 @@ import (
 
 // Config holds pipeline-related configuration.
 type Config struct {
-	PreferredFormats    []imagetype.Type
-	WatermarkOpacity    float64
-	DisableShrinkOnLoad bool
-	UseLinearColorspace bool
-	SanitizeSvg         bool
-	AlwaysRasterizeSvg  bool
+	PreferredFormats      []imagetype.Type
+	SkipProcessingFormats []imagetype.Type
+	WatermarkOpacity      float64
+	DisableShrinkOnLoad   bool
+	UseLinearColorspace   bool
+	SanitizeSvg           bool
+	AlwaysRasterizeSvg    bool
+	Quality               int
+	FormatQuality         map[imagetype.Type]int
+	StripMetadata         bool
+	KeepCopyright         bool
+	StripColorProfile     bool
+	AutoRotate            bool
+	EnforceThumbnail      bool
 }
 
 // NewConfig creates a new Config instance with the given parameters.
@@ -31,6 +39,17 @@ func NewDefaultConfig() Config {
 			imagetype.GIF,
 		},
 		SanitizeSvg: true,
+		Quality:     80,
+		FormatQuality: map[imagetype.Type]int{
+			imagetype.WEBP: 79,
+			imagetype.AVIF: 63,
+			imagetype.JXL:  77,
+		},
+		StripMetadata:     true,
+		KeepCopyright:     true,
+		StripColorProfile: true,
+		AutoRotate:        true,
+		EnforceThumbnail:  false,
 	}
 }
 
@@ -41,9 +60,12 @@ func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c.WatermarkOpacity = config.WatermarkOpacity
 	c.DisableShrinkOnLoad = config.DisableShrinkOnLoad
 	c.UseLinearColorspace = config.UseLinearColorspace
+	c.SkipProcessingFormats = config.SkipProcessingFormats
 	c.PreferredFormats = config.PreferredFormats
 	c.SanitizeSvg = config.SanitizeSvg
 	c.AlwaysRasterizeSvg = config.AlwaysRasterizeSvg
+	c.AutoRotate = config.AutoRotate
+	c.EnforceThumbnail = config.EnforceThumbnail
 
 	return c, nil
 }

+ 6 - 4
processing/crop.go

@@ -6,7 +6,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
-func cropImage(img *vips.Image, cropWidth, cropHeight int, gravity *options.GravityOptions, offsetScale float64) error {
+func cropImage(img *vips.Image, cropWidth, cropHeight int, gravity *GravityOptions, offsetScale float64) error {
 	if cropWidth == 0 && cropHeight == 0 {
 		return nil
 	}
@@ -33,12 +33,13 @@ func cropImage(img *vips.Image, cropWidth, cropHeight int, gravity *options.Grav
 
 func (p *Processor) crop(c *Context) error {
 	width, height := c.CropWidth, c.CropHeight
+	rotateAngle := c.PO.Rotate()
 
 	opts := c.CropGravity
 	opts.RotateAndFlip(c.Angle, c.Flip)
-	opts.RotateAndFlip(c.PO.Rotate, false)
+	opts.RotateAndFlip(rotateAngle, false)
 
-	if (c.Angle+c.PO.Rotate)%180 == 90 {
+	if (c.Angle+rotateAngle)%180 == 90 {
 		width, height = height, width
 	}
 
@@ -47,5 +48,6 @@ func (p *Processor) crop(c *Context) error {
 }
 
 func (p *Processor) cropToResult(c *Context) error {
-	return cropImage(c.Img, c.ResultCropWidth, c.ResultCropHeight, &c.PO.Gravity, c.DprScale)
+	gravity := c.PO.Gravity()
+	return cropImage(c.Img, c.ResultCropWidth, c.ResultCropHeight, &gravity, c.DprScale)
 }

+ 7 - 6
processing/extend.go

@@ -1,11 +1,10 @@
 package processing
 
 import (
-	"github.com/imgproxy/imgproxy/v3/options"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
-func extendImage(img *vips.Image, width, height int, gravity *options.GravityOptions, offsetScale float64) error {
+func extendImage(img *vips.Image, width, height int, gravity *GravityOptions, offsetScale float64) error {
 	imgWidth := img.Width()
 	imgHeight := img.Height()
 
@@ -25,16 +24,17 @@ func extendImage(img *vips.Image, width, height int, gravity *options.GravityOpt
 }
 
 func (p *Processor) extend(c *Context) error {
-	if !c.PO.Extend.Enabled {
+	if !c.PO.ExtendEnabled() {
 		return nil
 	}
 
 	width, height := c.TargetWidth, c.TargetHeight
-	return extendImage(c.Img, width, height, &c.PO.Extend.Gravity, c.DprScale)
+	gravity := c.PO.ExtendGravity()
+	return extendImage(c.Img, width, height, &gravity, c.DprScale)
 }
 
 func (p *Processor) extendAspectRatio(c *Context) error {
-	if !c.PO.ExtendAspectRatio.Enabled {
+	if !c.PO.ExtendAspectRatioEnabled() {
 		return nil
 	}
 
@@ -43,5 +43,6 @@ func (p *Processor) extendAspectRatio(c *Context) error {
 		return nil
 	}
 
-	return extendImage(c.Img, width, height, &c.PO.ExtendAspectRatio.Gravity, c.DprScale)
+	gravity := c.PO.ExtendAspectRatioGravity()
+	return extendImage(c.Img, width, height, &gravity, c.DprScale)
 }

+ 1 - 1
processing/fix_size.go

@@ -102,7 +102,7 @@ func fixIcoSize(img *vips.Image) error {
 }
 
 func (p *Processor) fixSize(c *Context) error {
-	switch c.PO.Format {
+	switch c.PO.Format() {
 	case imagetype.WEBP:
 		return fixWebpSize(c.Img)
 	case imagetype.AVIF, imagetype.HEIC:

+ 2 - 2
processing/flatten.go

@@ -1,9 +1,9 @@
 package processing
 
 func (p *Processor) flatten(c *Context) error {
-	if !c.PO.Flatten && c.PO.Format.SupportsAlpha() {
+	if !c.PO.ShouldFlatten() && c.PO.Format().SupportsAlpha() {
 		return nil
 	}
 
-	return c.Img.Flatten(c.PO.Background)
+	return c.Img.Flatten(c.PO.Background())
 }

+ 124 - 0
processing/gravity.go

@@ -0,0 +1,124 @@
+package processing
+
+import (
+	"github.com/imgproxy/imgproxy/v3/options"
+	"github.com/imgproxy/imgproxy/v3/options/keys"
+)
+
+var gravityTypesRotationMap = map[int]map[options.GravityType]options.GravityType{
+	90: {
+		options.GravityNorth:     options.GravityWest,
+		options.GravityEast:      options.GravityNorth,
+		options.GravitySouth:     options.GravityEast,
+		options.GravityWest:      options.GravitySouth,
+		options.GravityNorthWest: options.GravitySouthWest,
+		options.GravityNorthEast: options.GravityNorthWest,
+		options.GravitySouthWest: options.GravitySouthEast,
+		options.GravitySouthEast: options.GravityNorthEast,
+	},
+	180: {
+		options.GravityNorth:     options.GravitySouth,
+		options.GravityEast:      options.GravityWest,
+		options.GravitySouth:     options.GravityNorth,
+		options.GravityWest:      options.GravityEast,
+		options.GravityNorthWest: options.GravitySouthEast,
+		options.GravityNorthEast: options.GravitySouthWest,
+		options.GravitySouthWest: options.GravityNorthEast,
+		options.GravitySouthEast: options.GravityNorthWest,
+	},
+	270: {
+		options.GravityNorth:     options.GravityEast,
+		options.GravityEast:      options.GravitySouth,
+		options.GravitySouth:     options.GravityWest,
+		options.GravityWest:      options.GravityNorth,
+		options.GravityNorthWest: options.GravityNorthEast,
+		options.GravityNorthEast: options.GravitySouthEast,
+		options.GravitySouthWest: options.GravityNorthWest,
+		options.GravitySouthEast: options.GravitySouthWest,
+	},
+}
+
+var gravityTypesFlipMap = map[options.GravityType]options.GravityType{
+	options.GravityEast:      options.GravityWest,
+	options.GravityWest:      options.GravityEast,
+	options.GravityNorthWest: options.GravityNorthEast,
+	options.GravityNorthEast: options.GravityNorthWest,
+	options.GravitySouthWest: options.GravitySouthEast,
+	options.GravitySouthEast: options.GravitySouthWest,
+}
+
+type GravityOptions struct {
+	Type options.GravityType
+	X, Y float64
+}
+
+// NewGravityOptions builds a new [GravityOptions] instance.
+// It fills the [GravityOptions] struct with the options values under the given prefix.
+// If the gravity type is not set in the options,
+// it returns a [GravityOptions] with the provided default type.
+func NewGravityOptions(o ProcessingOptions, prefix string, defType options.GravityType) GravityOptions {
+	gr := GravityOptions{
+		Type: options.Get(o.Options, prefix+keys.SuffixType, defType),
+		X:    o.GetFloat(prefix+keys.SuffixXOffset, 0.0),
+		Y:    o.GetFloat(prefix+keys.SuffixYOffset, 0.0),
+	}
+
+	return gr
+}
+
+func (g *GravityOptions) RotateAndFlip(angle int, flip bool) {
+	angle %= 360
+
+	if flip {
+		if gt, ok := gravityTypesFlipMap[g.Type]; ok {
+			g.Type = gt
+		}
+
+		switch g.Type {
+		case options.GravityCenter, options.GravityNorth, options.GravitySouth:
+			g.X = -g.X
+		case options.GravityFocusPoint:
+			g.X = 1.0 - g.X
+		}
+	}
+
+	if angle > 0 {
+		if rotMap := gravityTypesRotationMap[angle]; rotMap != nil {
+			if gt, ok := rotMap[g.Type]; ok {
+				g.Type = gt
+			}
+
+			switch angle {
+			case 90:
+				switch g.Type {
+				case options.GravityCenter, options.GravityEast, options.GravityWest:
+					g.X, g.Y = g.Y, -g.X
+				case options.GravityFocusPoint:
+					g.X, g.Y = g.Y, 1.0-g.X
+				default:
+					g.X, g.Y = g.Y, g.X
+				}
+			case 180:
+				switch g.Type {
+				case options.GravityCenter:
+					g.X, g.Y = -g.X, -g.Y
+				case options.GravityNorth, options.GravitySouth:
+					g.X = -g.X
+				case options.GravityEast, options.GravityWest:
+					g.Y = -g.Y
+				case options.GravityFocusPoint:
+					g.X, g.Y = 1.0-g.X, 1.0-g.Y
+				}
+			case 270:
+				switch g.Type {
+				case options.GravityCenter, options.GravityNorth, options.GravitySouth:
+					g.X, g.Y = -g.Y, g.X
+				case options.GravityFocusPoint:
+					g.X, g.Y = 1.0-g.Y, g.X
+				default:
+					g.X, g.Y = g.Y, g.X
+				}
+			}
+		}
+	}
+}

+ 281 - 0
processing/options.go

@@ -0,0 +1,281 @@
+package processing
+
+import (
+	"slices"
+
+	"github.com/imgproxy/imgproxy/v3/imagetype"
+	"github.com/imgproxy/imgproxy/v3/options"
+	"github.com/imgproxy/imgproxy/v3/options/keys"
+	"github.com/imgproxy/imgproxy/v3/vips/color"
+)
+
+// ProcessingOptions is a thin wrapper around options.Options that provides
+// helpers for image processing options.
+type ProcessingOptions struct {
+	*options.Options
+
+	// Config that contains default values for some options.
+	config *Config
+}
+
+func (p *Processor) NewProcessingOptions(o *options.Options) ProcessingOptions {
+	return ProcessingOptions{
+		Options: o,
+		config:  p.config,
+	}
+}
+
+func (po ProcessingOptions) Width() int {
+	return po.GetInt(keys.Width, 0)
+}
+
+func (po ProcessingOptions) Height() int {
+	return po.GetInt(keys.Height, 0)
+}
+
+func (po ProcessingOptions) MinWidth() int {
+	return po.GetInt(keys.MinWidth, 0)
+}
+
+func (po ProcessingOptions) MinHeight() int {
+	return po.GetInt(keys.MinHeight, 0)
+}
+
+func (po ProcessingOptions) ResizingType() options.ResizeType {
+	return options.Get(po.Options, keys.ResizingType, options.ResizeFit)
+}
+
+func (po ProcessingOptions) ZoomWidth() float64 {
+	return po.GetFloat(keys.ZoomWidth, 1.0)
+}
+
+func (po ProcessingOptions) ZoomHeight() float64 {
+	return po.GetFloat(keys.ZoomHeight, 1.0)
+}
+
+func (po ProcessingOptions) DPR() float64 {
+	return po.GetFloat(keys.Dpr, 1.0)
+}
+
+func (po ProcessingOptions) EnforceThumbnail() bool {
+	return po.Main().GetBool(keys.EnforceThumbnail, po.config.EnforceThumbnail)
+}
+
+func (po ProcessingOptions) Enlarge() bool {
+	return po.GetBool(keys.Enlarge, false)
+}
+
+func (po ProcessingOptions) Gravity() GravityOptions {
+	return NewGravityOptions(po, keys.Gravity, options.GravityCenter)
+}
+
+func (po ProcessingOptions) ExtendEnabled() bool {
+	return po.GetBool(keys.ExtendEnabled, false)
+}
+
+func (po ProcessingOptions) ExtendGravity() GravityOptions {
+	return NewGravityOptions(po, keys.ExtendGravity, options.GravityCenter)
+}
+
+func (po ProcessingOptions) ExtendAspectRatioEnabled() bool {
+	return po.GetBool(keys.ExtendAspectRatioEnabled, false)
+}
+
+func (po ProcessingOptions) ExtendAspectRatioGravity() GravityOptions {
+	return NewGravityOptions(po, keys.ExtendAspectRatioGravity, options.GravityCenter)
+}
+
+func (po ProcessingOptions) Rotate() int {
+	return po.GetInt(keys.Rotate, 0)
+}
+
+func (po ProcessingOptions) AutoRotate() bool {
+	return po.GetBool(keys.AutoRotate, po.config.AutoRotate)
+}
+
+func (po ProcessingOptions) CropWidth() float64 {
+	return po.GetFloat(keys.CropWidth, 0.0)
+}
+
+func (po ProcessingOptions) CropHeight() float64 {
+	return po.GetFloat(keys.CropHeight, 0.0)
+}
+
+func (po ProcessingOptions) CropGravity() GravityOptions {
+	return NewGravityOptions(po, keys.CropGravity, options.GravityUnknown)
+}
+
+func (po ProcessingOptions) Format() imagetype.Type {
+	return options.Get(po.Main(), keys.Format, imagetype.Unknown)
+}
+
+func (po ProcessingOptions) SetFormat(format imagetype.Type) {
+	po.Set(keys.Format, format)
+}
+
+func (po ProcessingOptions) ShouldSkipFormatProcessing(inFormat imagetype.Type) bool {
+	return slices.Contains(po.config.SkipProcessingFormats, inFormat) ||
+		options.SliceContains(po.Main(), keys.SkipProcessing, inFormat)
+}
+
+func (po ProcessingOptions) ShouldFlatten() bool {
+	return po.Has(keys.Background)
+}
+
+func (po ProcessingOptions) Background() color.RGB {
+	return options.Get(po.Options, keys.Background, color.RGB{R: 255, G: 255, B: 255})
+}
+
+func (po ProcessingOptions) PaddingEnabled() bool {
+	return po.PaddingTop() != 0 ||
+		po.PaddingRight() != 0 ||
+		po.PaddingBottom() != 0 ||
+		po.PaddingLeft() != 0
+}
+
+func (po ProcessingOptions) PaddingTop() int {
+	return po.GetInt(keys.PaddingTop, 0)
+}
+
+func (po ProcessingOptions) PaddingRight() int {
+	return po.GetInt(keys.PaddingRight, 0)
+}
+
+func (po ProcessingOptions) PaddingBottom() int {
+	return po.GetInt(keys.PaddingBottom, 0)
+}
+
+func (po ProcessingOptions) PaddingLeft() int {
+	return po.GetInt(keys.PaddingLeft, 0)
+}
+
+func (po ProcessingOptions) Blur() float64 {
+	return po.GetFloat(keys.Blur, 0.0)
+}
+
+func (po ProcessingOptions) Sharpen() float64 {
+	return po.GetFloat(keys.Sharpen, 0.0)
+}
+
+func (po ProcessingOptions) Pixelate() int {
+	return po.GetInt(keys.Pixelate, 1)
+}
+
+func (po ProcessingOptions) PreferWebP() bool {
+	return po.GetBool(keys.PreferWebP, false)
+}
+
+func (po ProcessingOptions) PreferAvif() bool {
+	return po.GetBool(keys.PreferAvif, false)
+}
+
+func (po ProcessingOptions) PreferJxl() bool {
+	return po.GetBool(keys.PreferJxl, false)
+}
+
+func (po ProcessingOptions) EnforceWebP() bool {
+	return po.GetBool(keys.EnforceWebP, false)
+}
+
+func (po ProcessingOptions) EnforceAvif() bool {
+	return po.GetBool(keys.EnforceAvif, false)
+}
+
+func (po ProcessingOptions) EnforceJxl() bool {
+	return po.GetBool(keys.EnforceJxl, false)
+}
+
+func (po ProcessingOptions) TrimEnabled() bool {
+	return po.Has(keys.TrimThreshold)
+}
+
+func (po ProcessingOptions) DisableTrim() {
+	po.Delete(keys.TrimThreshold)
+}
+
+func (po ProcessingOptions) TrimThreshold() float64 {
+	return po.GetFloat(keys.TrimThreshold, 10.0)
+}
+
+func (po ProcessingOptions) TrimSmart() bool {
+	return !po.Has(keys.TrimColor)
+}
+
+func (po ProcessingOptions) TrimColor() color.RGB {
+	return options.Get(po.Options, keys.TrimColor, color.RGB{})
+}
+
+func (po ProcessingOptions) TrimEqualHor() bool {
+	return po.GetBool(keys.TrimEqualHor, false)
+}
+
+func (po ProcessingOptions) TrimEqualVer() bool {
+	return po.GetBool(keys.TrimEqualVer, false)
+}
+
+func (po ProcessingOptions) WatermarkOpacity() float64 {
+	return po.GetFloat(keys.WatermarkOpacity, 0.0)
+}
+
+func (po ProcessingOptions) SetWatermarkOpacity(opacity float64) {
+	po.Set(keys.WatermarkOpacity, opacity)
+}
+
+func (po ProcessingOptions) DeleteWatermarkOpacity() {
+	po.Delete(keys.WatermarkOpacity)
+}
+
+func (po ProcessingOptions) WatermarkPosition() options.GravityType {
+	return options.Get(po.Options, keys.WatermarkPosition, options.GravityCenter)
+}
+
+func (po ProcessingOptions) WatermarkXOffset() float64 {
+	return po.GetFloat(keys.WatermarkXOffset, 0.0)
+}
+
+func (po ProcessingOptions) WatermarkYOffset() float64 {
+	return po.GetFloat(keys.WatermarkYOffset, 0.0)
+}
+
+func (po ProcessingOptions) WatermarkScale() float64 {
+	return po.GetFloat(keys.WatermarkScale, 0.0)
+}
+
+// Quality retrieves the quality setting for a given image format.
+// It first checks for a general quality setting, then for a format-specific setting,
+// and finally falls back to the configured default quality.
+func (po ProcessingOptions) Quality(format imagetype.Type) int {
+	// First, check if quality is explicitly set in options.
+	if q := po.Main().GetInt(keys.Quality, 0); q > 0 {
+		return q
+	}
+
+	// Then, check if format-specific quality is set in options.
+	if q := po.Main().GetInt(keys.FormatQuality(format), 0); q > 0 {
+		return q
+	}
+
+	// Then, check if format-specific quality is set in config.
+	if q := po.config.FormatQuality[format]; q > 0 {
+		return q
+	}
+
+	// Finally, return the general quality setting from config.
+	return po.config.Quality
+}
+
+func (po ProcessingOptions) MaxBytes() int {
+	return po.Main().GetInt(keys.MaxBytes, 0)
+}
+
+func (po ProcessingOptions) StripMetadata() bool {
+	return po.Main().GetBool(keys.StripMetadata, po.config.StripMetadata)
+}
+
+func (po ProcessingOptions) KeepCopyright() bool {
+	return po.Main().GetBool(keys.KeepCopyright, po.config.KeepCopyright)
+}
+
+func (po ProcessingOptions) StripColorProfile() bool {
+	return po.Main().GetBool(keys.StripColorProfile, po.config.StripColorProfile)
+}

+ 5 - 5
processing/padding.go

@@ -5,14 +5,14 @@ import (
 )
 
 func (p *Processor) padding(c *Context) error {
-	if !c.PO.Padding.Enabled {
+	if !c.PO.PaddingEnabled() {
 		return nil
 	}
 
-	paddingTop := imath.ScaleToEven(c.PO.Padding.Top, c.DprScale)
-	paddingRight := imath.ScaleToEven(c.PO.Padding.Right, c.DprScale)
-	paddingBottom := imath.ScaleToEven(c.PO.Padding.Bottom, c.DprScale)
-	paddingLeft := imath.ScaleToEven(c.PO.Padding.Left, c.DprScale)
+	paddingTop := imath.ScaleToEven(c.PO.PaddingTop(), c.DprScale)
+	paddingRight := imath.ScaleToEven(c.PO.PaddingRight(), c.DprScale)
+	paddingBottom := imath.ScaleToEven(c.PO.PaddingBottom(), c.DprScale)
+	paddingLeft := imath.ScaleToEven(c.PO.PaddingLeft(), c.DprScale)
 
 	return c.Img.Embed(
 		c.Img.Width()+paddingLeft+paddingRight,

+ 15 - 7
processing/pipeline.go

@@ -6,6 +6,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/auximageprovider"
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/options"
+	"github.com/imgproxy/imgproxy/v3/security"
 	"github.com/imgproxy/imgproxy/v3/server"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
@@ -18,7 +19,10 @@ type Context struct {
 	Img *vips.Image
 
 	// Processing options this pipeline runs with
-	PO *options.ProcessingOptions
+	PO ProcessingOptions
+
+	// Security options this pipeline runs with
+	SecOps security.Options
 
 	// Original image data
 	ImgData imagedata.ImageData
@@ -33,7 +37,7 @@ type Context struct {
 
 	CropWidth   int
 	CropHeight  int
-	CropGravity options.GravityOptions
+	CropGravity GravityOptions
 
 	WScale float64
 	HScale float64
@@ -80,10 +84,11 @@ type Pipeline []Step
 func (p Pipeline) Run(
 	ctx context.Context,
 	img *vips.Image,
-	po *options.ProcessingOptions,
+	po ProcessingOptions,
+	secops security.Options,
 	imgdata imagedata.ImageData,
 ) error {
-	pctx := p.newContext(ctx, img, po, imgdata)
+	pctx := p.newContext(ctx, img, po, secops, imgdata)
 	pctx.CalcParams()
 
 	for _, step := range p {
@@ -104,13 +109,15 @@ func (p Pipeline) Run(
 func (p Pipeline) newContext(
 	ctx context.Context,
 	img *vips.Image,
-	po *options.ProcessingOptions,
+	po ProcessingOptions,
+	secops security.Options,
 	imgdata imagedata.ImageData,
 ) Context {
 	pctx := Context{
 		Ctx:     ctx,
 		Img:     img,
 		PO:      po,
+		SecOps:  secops,
 		ImgData: imgdata,
 
 		WScale: 1.0,
@@ -119,11 +126,12 @@ func (p Pipeline) newContext(
 		DprScale:        1.0,
 		VectorBaseScale: 1.0,
 
-		CropGravity: po.Crop.Gravity,
+		CropGravity: po.CropGravity(),
 	}
 
+	// If crop gravity is not set, use the general gravity option
 	if pctx.CropGravity.Type == options.GravityUnknown {
-		pctx.CropGravity = po.Gravity
+		pctx.CropGravity = po.Gravity()
 	}
 
 	return pctx

+ 34 - 31
processing/prepare.go

@@ -65,12 +65,15 @@ func CalcCropSize(orig int, crop float64) int {
 	}
 }
 
-func (c *Context) calcScale(width, height int, po *options.ProcessingOptions) {
+func (c *Context) calcScale(width, height int, po ProcessingOptions) {
 	wshrink, hshrink := 1.0, 1.0
 	srcW, srcH := float64(width), float64(height)
 
-	dstW := imath.NonZero(float64(po.Width), srcW)
-	dstH := imath.NonZero(float64(po.Height), srcH)
+	poWidth := po.Width()
+	poHeight := po.Height()
+
+	dstW := imath.NonZero(float64(poWidth), srcW)
+	dstH := imath.NonZero(float64(poHeight), srcH)
 
 	if dstW != srcW {
 		wshrink = srcW / dstW
@@ -81,7 +84,7 @@ func (c *Context) calcScale(width, height int, po *options.ProcessingOptions) {
 	}
 
 	if wshrink != 1 || hshrink != 1 {
-		rt := po.ResizingType
+		rt := po.ResizingType()
 
 		if rt == options.ResizeAuto {
 			srcD := srcW - srcH
@@ -95,9 +98,9 @@ func (c *Context) calcScale(width, height int, po *options.ProcessingOptions) {
 		}
 
 		switch {
-		case po.Width == 0 && rt != options.ResizeForce:
+		case poWidth == 0 && rt != options.ResizeForce:
 			wshrink = hshrink
-		case po.Height == 0 && rt != options.ResizeForce:
+		case poHeight == 0 && rt != options.ResizeForce:
 			hshrink = wshrink
 		case rt == options.ResizeFit:
 			wshrink = math.Max(wshrink, hshrink)
@@ -108,14 +111,14 @@ func (c *Context) calcScale(width, height int, po *options.ProcessingOptions) {
 		}
 	}
 
-	wshrink /= po.ZoomWidth
-	hshrink /= po.ZoomHeight
+	wshrink /= po.ZoomWidth()
+	hshrink /= po.ZoomHeight()
 
-	c.DprScale = po.Dpr
+	c.DprScale = po.DPR()
 
 	isVector := c.ImgData != nil && c.ImgData.Format().IsVector()
 
-	if !po.Enlarge && !isVector {
+	if !po.Enlarge() && !isVector {
 		minShrink := math.Min(wshrink, hshrink)
 		if minShrink < 1 {
 			wshrink /= minShrink
@@ -131,7 +134,7 @@ func (c *Context) calcScale(width, height int, po *options.ProcessingOptions) {
 			// If the Extend option is enabled, we want to keep the resulting image
 			// composition the same regardless of the DPR, so we don't apply this compensation
 			// in this case.
-			if !po.Extend.Enabled {
+			if !po.ExtendEnabled() {
 				c.DprScale /= minShrink
 			}
 		}
@@ -141,15 +144,15 @@ func (c *Context) calcScale(width, height int, po *options.ProcessingOptions) {
 		c.DprScale = math.Min(c.DprScale, math.Min(wshrink, hshrink))
 	}
 
-	if po.MinWidth > 0 {
-		if minShrink := srcW / float64(po.MinWidth); minShrink < wshrink {
+	if minWidth := po.MinWidth(); minWidth > 0 {
+		if minShrink := srcW / float64(minWidth); minShrink < wshrink {
 			hshrink /= wshrink / minShrink
 			wshrink = minShrink
 		}
 	}
 
-	if po.MinHeight > 0 {
-		if minShrink := srcH / float64(po.MinHeight); minShrink < hshrink {
+	if minHeight := po.MinHeight(); minHeight > 0 {
+		if minShrink := srcH / float64(minHeight); minShrink < hshrink {
 			wshrink /= hshrink / minShrink
 			hshrink = minShrink
 		}
@@ -170,14 +173,14 @@ func (c *Context) calcScale(width, height int, po *options.ProcessingOptions) {
 	c.HScale = 1.0 / hshrink
 }
 
-func (c *Context) calcSizes(widthToScale, heightToScale int, po *options.ProcessingOptions) {
-	c.TargetWidth = imath.Scale(po.Width, c.DprScale*po.ZoomWidth)
-	c.TargetHeight = imath.Scale(po.Height, c.DprScale*po.ZoomHeight)
+func (c *Context) calcSizes(widthToScale, heightToScale int, po ProcessingOptions) {
+	c.TargetWidth = imath.Scale(po.Width(), c.DprScale*po.ZoomWidth())
+	c.TargetHeight = imath.Scale(po.Height(), c.DprScale*po.ZoomHeight())
 
 	c.ScaledWidth = imath.Scale(widthToScale, c.WScale)
 	c.ScaledHeight = imath.Scale(heightToScale, c.HScale)
 
-	if po.ResizingType == options.ResizeFillDown && !po.Enlarge {
+	if po.ResizingType() == options.ResizeFillDown && !po.Enlarge() {
 		diffW := float64(c.TargetWidth) / float64(c.ScaledWidth)
 		diffH := float64(c.TargetHeight) / float64(c.ScaledHeight)
 
@@ -199,7 +202,7 @@ func (c *Context) calcSizes(widthToScale, heightToScale int, po *options.Process
 		c.ResultCropHeight = c.TargetHeight
 	}
 
-	if po.ExtendAspectRatio.Enabled && c.TargetWidth > 0 && c.TargetHeight > 0 {
+	if po.ExtendAspectRatioEnabled() && c.TargetWidth > 0 && c.TargetHeight > 0 {
 		outWidth := imath.MinNonZero(c.ScaledWidth, c.ResultCropWidth)
 		outHeight := imath.MinNonZero(c.ScaledHeight, c.ResultCropHeight)
 
@@ -218,8 +221,8 @@ func (c *Context) calcSizes(widthToScale, heightToScale int, po *options.Process
 	}
 }
 
-func (c *Context) limitScale(widthToScale, heightToScale int, po *options.ProcessingOptions) {
-	maxresultDim := po.SecurityOptions.MaxResultDimension
+func (c *Context) limitScale(widthToScale, heightToScale int, po ProcessingOptions) {
+	maxresultDim := c.SecOps.MaxResultDimension
 
 	if maxresultDim <= 0 {
 		return
@@ -228,18 +231,18 @@ func (c *Context) limitScale(widthToScale, heightToScale int, po *options.Proces
 	outWidth := imath.MinNonZero(c.ScaledWidth, c.ResultCropWidth)
 	outHeight := imath.MinNonZero(c.ScaledHeight, c.ResultCropHeight)
 
-	if po.Extend.Enabled {
+	if po.ExtendEnabled() {
 		outWidth = max(outWidth, c.TargetWidth)
 		outHeight = max(outHeight, c.TargetHeight)
-	} else if po.ExtendAspectRatio.Enabled {
+	} else if po.ExtendAspectRatioEnabled() {
 		outWidth = max(outWidth, c.ExtendAspectRatioWidth)
 		outHeight = max(outHeight, c.ExtendAspectRatioHeight)
 	}
 
-	if po.Padding.Enabled {
-		outWidth += imath.ScaleToEven(po.Padding.Left, c.DprScale) + imath.ScaleToEven(po.Padding.Right, c.DprScale)
-		outHeight += imath.ScaleToEven(po.Padding.Top, c.DprScale) + imath.ScaleToEven(po.Padding.Bottom, c.DprScale)
-	}
+	outWidth += imath.ScaleToEven(po.PaddingLeft(), c.DprScale)
+	outWidth += imath.ScaleToEven(po.PaddingRight(), c.DprScale)
+	outHeight += imath.ScaleToEven(po.PaddingTop(), c.DprScale)
+	outHeight += imath.ScaleToEven(po.PaddingBottom(), c.DprScale)
 
 	if maxresultDim > 0 && (outWidth > maxresultDim || outHeight > maxresultDim) {
 		downScale := float64(maxresultDim) / float64(max(outWidth, outHeight))
@@ -265,10 +268,10 @@ func (c *Context) limitScale(widthToScale, heightToScale int, po *options.Proces
 // Prepare calculates context image parameters based on the current image size.
 // Some steps (like trim) must call this function when finished.
 func (c *Context) CalcParams() {
-	c.SrcWidth, c.SrcHeight, c.Angle, c.Flip = ExtractGeometry(c.Img, c.PO.Rotate, c.PO.AutoRotate)
+	c.SrcWidth, c.SrcHeight, c.Angle, c.Flip = ExtractGeometry(c.Img, c.PO.Rotate(), c.PO.AutoRotate())
 
-	c.CropWidth = CalcCropSize(c.SrcWidth, c.PO.Crop.Width)
-	c.CropHeight = CalcCropSize(c.SrcHeight, c.PO.Crop.Height)
+	c.CropWidth = CalcCropSize(c.SrcWidth, c.PO.CropWidth())
+	c.CropHeight = CalcCropSize(c.SrcHeight, c.PO.CropHeight())
 
 	widthToScale := imath.MinNonZero(c.CropWidth, c.SrcWidth)
 	heightToScale := imath.MinNonZero(c.CropHeight, c.SrcHeight)

+ 85 - 64
processing/processing.go

@@ -7,7 +7,6 @@ import (
 	"runtime"
 	"slices"
 
-	"github.com/imgproxy/imgproxy/v3/auximageprovider"
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
 	"github.com/imgproxy/imgproxy/v3/options"
@@ -64,8 +63,8 @@ type Result struct {
 func (p *Processor) ProcessImage(
 	ctx context.Context,
 	imgdata imagedata.ImageData,
-	po *options.ProcessingOptions,
-	watermarkProvider auximageprovider.Provider,
+	o *options.Options,
+	secops security.Options,
 ) (*Result, error) {
 	runtime.LockOSThread()
 	defer runtime.UnlockOSThread()
@@ -75,56 +74,59 @@ func (p *Processor) ProcessImage(
 	img := new(vips.Image)
 	defer img.Clear()
 
+	po := p.NewProcessingOptions(o)
+
 	// Load a single page/frame of the image so we can analyze it
 	// and decide how to process it further
-	thumbnailLoaded, err := p.initialLoadImage(img, imgdata, po.EnforceThumbnail)
+	thumbnailLoaded, err := p.initialLoadImage(img, imgdata, po.EnforceThumbnail())
 	if err != nil {
 		return nil, err
 	}
 
 	// Let's check if we should skip standard processing
 	if p.shouldSkipStandardProcessing(imgdata.Format(), po) {
-		return p.skipStandardProcessing(img, imgdata, po)
+		return p.skipStandardProcessing(img, imgdata, po, secops)
 	}
 
 	// Check if we expect image to be processed as animated.
 	// If MaxAnimationFrames is 1, we never process as animated since we can only
 	// process a single frame.
-	animated := po.SecurityOptions.MaxAnimationFrames > 1 &&
+	animated := secops.MaxAnimationFrames > 1 &&
 		img.IsAnimated()
 
 	// Determine output format and check if it's supported.
-	// The determined format is stored in po.Format.
-	if err = p.determineOutputFormat(img, imgdata, po, animated); err != nil {
+	// The determined format is stored in po[KeyFormat].
+	outFormat, err := p.determineOutputFormat(img, imgdata, po, animated)
+	if err != nil {
 		return nil, err
 	}
 
 	// Now, as we know the output format, we know for sure if the image
 	// should be processed as animated
-	animated = animated && po.Format.SupportsAnimationSave()
+	animated = animated && outFormat.SupportsAnimationSave()
 
 	// Load required number of frames/pages for processing
 	// and remove animation-related data if not animated.
 	// Don't reload if we initially loaded a thumbnail.
 	if !thumbnailLoaded {
-		if err = p.reloadImageForProcessing(img, imgdata, po, animated); err != nil {
+		if err = p.reloadImageForProcessing(img, imgdata, po, secops, animated); err != nil {
 			return nil, err
 		}
 	}
 
 	// Check image dimensions and number of frames for security reasons
-	originWidth, originHeight, err := p.checkImageSize(img, imgdata.Format(), po.SecurityOptions)
+	originWidth, originHeight, err := p.checkImageSize(img, imgdata.Format(), secops)
 	if err != nil {
 		return nil, err
 	}
 
 	// Transform the image (resize, crop, etc)
-	if err = p.transformImage(ctx, img, po, imgdata, animated); err != nil {
+	if err = p.transformImage(ctx, img, po, secops, imgdata, animated); err != nil {
 		return nil, err
 	}
 
 	// Finalize the image (colorspace conversion, metadata stripping, etc)
-	if err = p.finalizePipeline().Run(ctx, img, po, imgdata); err != nil {
+	if err = p.finalizePipeline().Run(ctx, img, po, secops, imgdata); err != nil {
 		return nil, err
 	}
 
@@ -168,13 +170,14 @@ func (p *Processor) initialLoadImage(
 func (p *Processor) reloadImageForProcessing(
 	img *vips.Image,
 	imgdata imagedata.ImageData,
-	po *options.ProcessingOptions,
+	po ProcessingOptions,
+	secops security.Options,
 	asAnimated bool,
 ) error {
 	// If we are going to process the image as animated, we need to load all frames
 	// up to MaxAnimationFrames
 	if asAnimated {
-		frames := min(img.Pages(), po.SecurityOptions.MaxAnimationFrames)
+		frames := min(img.Pages(), secops.MaxAnimationFrames)
 		return img.Load(imgdata, 1, 1.0, frames)
 	}
 
@@ -226,10 +229,10 @@ func (p *Processor) getImageSize(img *vips.Image) (int, int, int) {
 // Returns true if image should not be processed as usual
 func (p *Processor) shouldSkipStandardProcessing(
 	inFormat imagetype.Type,
-	po *options.ProcessingOptions,
+	po ProcessingOptions,
 ) bool {
-	outFormat := po.Format
-	skipProcessingFormatEnabled := slices.Contains(po.SkipProcessingFormats, inFormat)
+	outFormat := po.Format()
+	skipProcessingFormatEnabled := po.ShouldSkipFormatProcessing(inFormat)
 
 	if inFormat == imagetype.SVG {
 		isOutUnknown := outFormat == imagetype.Unknown
@@ -255,11 +258,12 @@ func (p *Processor) shouldSkipStandardProcessing(
 func (p *Processor) skipStandardProcessing(
 	img *vips.Image,
 	imgdata imagedata.ImageData,
-	po *options.ProcessingOptions,
+	po ProcessingOptions,
+	secops security.Options,
 ) (*Result, error) {
 	// Even if we skip standard processing, we still need to check image dimensions
 	// to not send an image bomb to the client
-	originWidth, originHeight, err := p.checkImageSize(img, imgdata.Format(), po.SecurityOptions)
+	originWidth, originHeight, err := p.checkImageSize(img, imgdata.Format(), secops)
 	if err != nil {
 		return nil, err
 	}
@@ -297,43 +301,47 @@ func (p *Processor) skipStandardProcessing(
 func (p *Processor) determineOutputFormat(
 	img *vips.Image,
 	imgdata imagedata.ImageData,
-	po *options.ProcessingOptions,
+	po ProcessingOptions,
 	animated bool,
-) error {
+) (imagetype.Type, error) {
 	// Check if the image may have transparency
-	expectTransparency := !po.Flatten &&
-		(img.HasAlpha() || po.Padding.Enabled || po.Extend.Enabled)
+	expectTransparency := !po.ShouldFlatten() &&
+		(img.HasAlpha() || po.PaddingEnabled() || po.ExtendEnabled())
+
+	format := po.Format()
 
 	switch {
-	case po.Format == imagetype.SVG:
+	case format == imagetype.SVG:
 		// At this point we can't allow requested format to be SVG as we can't save SVGs
-		return newSaveFormatError(po.Format)
-	case po.Format == imagetype.Unknown:
+		return imagetype.Unknown, newSaveFormatError(format)
+	case format == imagetype.Unknown:
 		switch {
-		case po.PreferJxl && !animated:
-			po.Format = imagetype.JXL
-		case po.PreferAvif && !animated:
-			po.Format = imagetype.AVIF
-		case po.PreferWebP:
-			po.Format = imagetype.WEBP
+		case po.PreferJxl() && !animated:
+			format = imagetype.JXL
+		case po.PreferAvif() && !animated:
+			format = imagetype.AVIF
+		case po.PreferWebP():
+			format = imagetype.WEBP
 		case p.isImageTypePreferred(imgdata.Format()):
-			po.Format = imgdata.Format()
+			format = imgdata.Format()
 		default:
-			po.Format = p.findPreferredFormat(animated, expectTransparency)
+			format = p.findPreferredFormat(animated, expectTransparency)
 		}
-	case po.EnforceJxl && !animated:
-		po.Format = imagetype.JXL
-	case po.EnforceAvif && !animated:
-		po.Format = imagetype.AVIF
-	case po.EnforceWebP:
-		po.Format = imagetype.WEBP
+	case po.EnforceJxl() && !animated:
+		format = imagetype.JXL
+	case po.EnforceAvif() && !animated:
+		format = imagetype.AVIF
+	case po.EnforceWebP():
+		format = imagetype.WEBP
 	}
 
-	if !vips.SupportsSave(po.Format) {
-		return newSaveFormatError(po.Format)
+	po.SetFormat(format)
+
+	if !vips.SupportsSave(format) {
+		return format, newSaveFormatError(format)
 	}
 
-	return nil
+	return format, nil
 }
 
 // isImageTypePreferred checks if the given image type is in the list of preferred formats.
@@ -371,25 +379,27 @@ func (p *Processor) findPreferredFormat(animated, expectTransparency bool) image
 func (p *Processor) transformImage(
 	ctx context.Context,
 	img *vips.Image,
-	po *options.ProcessingOptions,
+	po ProcessingOptions,
+	secops security.Options,
 	imgdata imagedata.ImageData,
 	asAnimated bool,
 ) error {
 	if asAnimated {
-		return p.transformAnimated(ctx, img, po)
+		return p.transformAnimated(ctx, img, po, secops)
 	}
 
-	return p.mainPipeline().Run(ctx, img, po, imgdata)
+	return p.mainPipeline().Run(ctx, img, po, secops, imgdata)
 }
 
 func (p *Processor) transformAnimated(
 	ctx context.Context,
 	img *vips.Image,
-	po *options.ProcessingOptions,
+	po ProcessingOptions,
+	secops security.Options,
 ) error {
-	if po.Trim.Enabled {
+	if po.TrimEnabled() {
 		slog.Warn("Trim is not supported for animated images")
-		po.Trim.Enabled = false
+		po.DisableTrim()
 	}
 
 	imgWidth := img.Width()
@@ -412,9 +422,11 @@ func (p *Processor) transformAnimated(
 
 	// Disable watermarking for individual frames.
 	// It's more efficient to apply watermark to all frames at once after they are processed.
-	watermarkEnabled := po.Watermark.Enabled
-	po.Watermark.Enabled = false
-	defer func() { po.Watermark.Enabled = watermarkEnabled }()
+	watermarkOpacity := po.WatermarkOpacity()
+	if watermarkOpacity > 0 {
+		po.DeleteWatermarkOpacity()
+		defer func() { po.SetWatermarkOpacity(watermarkOpacity) }()
+	}
 
 	// Make a slice to hold processed frames and ensure they are cleared on function exit
 	frames := make([]*vips.Image, 0, framesCount)
@@ -440,7 +452,7 @@ func (p *Processor) transformAnimated(
 		// Transform the frame using the main pipeline.
 		// We don't provide imgdata here to prevent scale-on-load.
 		// Watermarking is disabled for individual frames (see above)
-		if err = p.mainPipeline().Run(ctx, frame, po, nil); err != nil {
+		if err = p.mainPipeline().Run(ctx, frame, po, secops, nil); err != nil {
 			return err
 		}
 
@@ -464,7 +476,7 @@ func (p *Processor) transformAnimated(
 
 	// Apply watermark to all frames at once if it was requested.
 	// This is much more efficient than applying watermark to individual frames.
-	if watermarkEnabled && p.watermarkProvider != nil {
+	if watermarkOpacity > 0 && p.watermarkProvider != nil {
 		// Get DPR scale to apply watermark correctly on HiDPI images.
 		// `imgproxy-dpr-scale` is set by the pipeline.
 		dprScale, derr := img.GetDoubleDefault("imgproxy-dpr-scale", 1.0)
@@ -472,7 +484,10 @@ func (p *Processor) transformAnimated(
 			dprScale = 1.0
 		}
 
-		if err = p.applyWatermark(ctx, img, po, dprScale, framesCount); err != nil {
+		// Set watermark opacity back
+		po.SetWatermarkOpacity(watermarkOpacity)
+
+		if err = p.applyWatermark(ctx, img, po, secops, dprScale, framesCount); err != nil {
 			return err
 		}
 	}
@@ -502,29 +517,35 @@ func (p *Processor) transformAnimated(
 func (p *Processor) saveImage(
 	ctx context.Context,
 	img *vips.Image,
-	po *options.ProcessingOptions,
+	po ProcessingOptions,
 ) (imagedata.ImageData, error) {
+	outFormat := po.Format()
+
 	// AVIF has a minimal dimension of 16 pixels.
 	// If one of the dimensions is less, we need to switch to another format.
-	if po.Format == imagetype.AVIF && (img.Width() < 16 || img.Height() < 16) {
+	if outFormat == imagetype.AVIF && (img.Width() < 16 || img.Height() < 16) {
 		if img.HasAlpha() {
-			po.Format = imagetype.PNG
+			outFormat = imagetype.PNG
 		} else {
-			po.Format = imagetype.JPEG
+			outFormat = imagetype.JPEG
 		}
 
+		po.SetFormat(outFormat)
+
 		slog.Warn(fmt.Sprintf(
 			"Minimal dimension of AVIF is 16, current image size is %dx%d. Image will be saved as %s",
-			img.Width(), img.Height(), po.Format,
+			img.Width(), img.Height(), outFormat,
 		))
 	}
 
+	quality := po.Quality(outFormat)
+
 	// If we want and can fit the image into the specified number of bytes,
 	// let's do it.
-	if po.MaxBytes > 0 && po.Format.SupportsQuality() {
-		return saveImageToFitBytes(ctx, po, img)
+	if maxBytes := po.MaxBytes(); maxBytes > 0 && outFormat.SupportsQuality() {
+		return saveImageToFitBytes(ctx, img, outFormat, quality, maxBytes)
 	}
 
 	// Otherwise, just save the image with the specified quality.
-	return img.Save(po.Format, po.GetQuality())
+	return img.Save(outFormat, quality)
 }

+ 144 - 182
processing/processing_test.go

@@ -1,7 +1,6 @@
 package processing
 
 import (
-	"context"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -14,6 +13,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/logger"
 	"github.com/imgproxy/imgproxy/v3/options"
+	"github.com/imgproxy/imgproxy/v3/options/keys"
 	"github.com/imgproxy/imgproxy/v3/security"
 	"github.com/imgproxy/imgproxy/v3/testutil"
 	"github.com/imgproxy/imgproxy/v3/vips"
@@ -25,8 +25,6 @@ type ProcessingTestSuite struct {
 	imageDataFactory testutil.LazyObj[*imagedata.Factory]
 	securityConfig   testutil.LazyObj[*security.Config]
 	security         testutil.LazyObj[*security.Checker]
-	poConfig         testutil.LazyObj[*options.Config]
-	po               testutil.LazyObj[*options.Factory]
 	config           testutil.LazyObj[*Config]
 	processor        testutil.LazyObj[*Processor]
 }
@@ -61,15 +59,6 @@ func (s *ProcessingTestSuite) SetupSuite() {
 		return security.New(s.securityConfig())
 	})
 
-	s.poConfig, _ = testutil.NewLazySuiteObj(s, func() (*options.Config, error) {
-		c := options.NewDefaultConfig()
-		return &c, nil
-	})
-
-	s.po, _ = testutil.NewLazySuiteObj(s, func() (*options.Factory, error) {
-		return options.NewFactory(s.poConfig(), s.security())
-	})
-
 	s.config, _ = testutil.NewLazySuiteObj(s, func() (*Config, error) {
 		c := NewDefaultConfig()
 		return &c, nil
@@ -101,8 +90,14 @@ func (s *ProcessingTestSuite) checkSize(r *Result, width, height int) {
 	s.Require().Equal(height, r.ResultHeight, "Height mismatch")
 }
 
-func (s *ProcessingTestSuite) processImageAndCheck(imgdata imagedata.ImageData, po *options.ProcessingOptions, expectedWidth, expectedHeight int) {
-	result, err := s.processor().ProcessImage(context.Background(), imgdata, po, nil)
+func (s *ProcessingTestSuite) processImageAndCheck(
+	imgdata imagedata.ImageData,
+	po *options.Options,
+	expectedWidth, expectedHeight int,
+) {
+	secops := s.security().NewOptions(po)
+
+	result, err := s.processor().ProcessImage(s.T().Context(), imgdata, po, secops)
 	s.Require().NoError(err)
 	s.Require().NotNil(result)
 
@@ -112,8 +107,8 @@ func (s *ProcessingTestSuite) processImageAndCheck(imgdata imagedata.ImageData,
 func (s *ProcessingTestSuite) TestResizeToFit() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.po().NewProcessingOptions()
-	po.ResizingType = options.ResizeFit
+	po := options.New()
+	po.Set(keys.ResizingType, options.ResizeFit)
 
 	testCases := []struct {
 		width     int
@@ -135,8 +130,8 @@ func (s *ProcessingTestSuite) TestResizeToFit() {
 
 	for _, tc := range testCases {
 		s.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func() {
-			po.Width = tc.width
-			po.Height = tc.height
+			po.Set(keys.Width, tc.width)
+			po.Set(keys.Height, tc.height)
 
 			s.processImageAndCheck(imgdata, po, tc.outWidth, tc.outHeight)
 		})
@@ -146,9 +141,9 @@ func (s *ProcessingTestSuite) TestResizeToFit() {
 func (s *ProcessingTestSuite) TestResizeToFitEnlarge() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.po().NewProcessingOptions()
-	po.ResizingType = options.ResizeFit
-	po.Enlarge = true
+	po := options.New()
+	po.Set(keys.ResizingType, options.ResizeFit)
+	po.Set(keys.Enlarge, true)
 
 	testCases := []struct {
 		width     int
@@ -170,8 +165,8 @@ func (s *ProcessingTestSuite) TestResizeToFitEnlarge() {
 
 	for _, tc := range testCases {
 		s.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func() {
-			po.Width = tc.width
-			po.Height = tc.height
+			po.Set(keys.Width, tc.width)
+			po.Set(keys.Height, tc.height)
 
 			s.processImageAndCheck(imgdata, po, tc.outWidth, tc.outHeight)
 		})
@@ -181,14 +176,9 @@ func (s *ProcessingTestSuite) TestResizeToFitEnlarge() {
 func (s *ProcessingTestSuite) TestResizeToFitExtend() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.po().NewProcessingOptions()
-	po.ResizingType = options.ResizeFit
-	po.Extend = options.ExtendOptions{
-		Enabled: true,
-		Gravity: options.GravityOptions{
-			Type: options.GravityCenter,
-		},
-	}
+	po := options.New()
+	po.Set(keys.ResizingType, options.ResizeFit)
+	po.Set(keys.ExtendEnabled, true)
 
 	testCases := []struct {
 		width     int
@@ -210,8 +200,8 @@ func (s *ProcessingTestSuite) TestResizeToFitExtend() {
 
 	for _, tc := range testCases {
 		s.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func() {
-			po.Width = tc.width
-			po.Height = tc.height
+			po.Set(keys.Width, tc.width)
+			po.Set(keys.Height, tc.height)
 
 			s.processImageAndCheck(imgdata, po, tc.outWidth, tc.outHeight)
 		})
@@ -221,14 +211,9 @@ func (s *ProcessingTestSuite) TestResizeToFitExtend() {
 func (s *ProcessingTestSuite) TestResizeToFitExtendAR() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.po().NewProcessingOptions()
-	po.ResizingType = options.ResizeFit
-	po.ExtendAspectRatio = options.ExtendOptions{
-		Enabled: true,
-		Gravity: options.GravityOptions{
-			Type: options.GravityCenter,
-		},
-	}
+	po := options.New()
+	po.Set(keys.ResizingType, options.ResizeFit)
+	po.Set(keys.ExtendAspectRatioEnabled, true)
 
 	testCases := []struct {
 		width     int
@@ -250,8 +235,8 @@ func (s *ProcessingTestSuite) TestResizeToFitExtendAR() {
 
 	for _, tc := range testCases {
 		s.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func() {
-			po.Width = tc.width
-			po.Height = tc.height
+			po.Set(keys.Width, tc.width)
+			po.Set(keys.Height, tc.height)
 
 			s.processImageAndCheck(imgdata, po, tc.outWidth, tc.outHeight)
 		})
@@ -261,8 +246,8 @@ func (s *ProcessingTestSuite) TestResizeToFitExtendAR() {
 func (s *ProcessingTestSuite) TestResizeToFill() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.po().NewProcessingOptions()
-	po.ResizingType = options.ResizeFill
+	po := options.New()
+	po.Set(keys.ResizingType, options.ResizeFill)
 
 	testCases := []struct {
 		width     int
@@ -284,8 +269,8 @@ func (s *ProcessingTestSuite) TestResizeToFill() {
 
 	for _, tc := range testCases {
 		s.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func() {
-			po.Width = tc.width
-			po.Height = tc.height
+			po.Set(keys.Width, tc.width)
+			po.Set(keys.Height, tc.height)
 
 			s.processImageAndCheck(imgdata, po, tc.outWidth, tc.outHeight)
 		})
@@ -295,9 +280,9 @@ func (s *ProcessingTestSuite) TestResizeToFill() {
 func (s *ProcessingTestSuite) TestResizeToFillEnlarge() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.po().NewProcessingOptions()
-	po.ResizingType = options.ResizeFill
-	po.Enlarge = true
+	po := options.New()
+	po.Set(keys.ResizingType, options.ResizeFill)
+	po.Set(keys.Enlarge, true)
 
 	testCases := []struct {
 		width     int
@@ -319,8 +304,8 @@ func (s *ProcessingTestSuite) TestResizeToFillEnlarge() {
 
 	for _, tc := range testCases {
 		s.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func() {
-			po.Width = tc.width
-			po.Height = tc.height
+			po.Set(keys.Width, tc.width)
+			po.Set(keys.Height, tc.height)
 
 			s.processImageAndCheck(imgdata, po, tc.outWidth, tc.outHeight)
 		})
@@ -330,14 +315,9 @@ func (s *ProcessingTestSuite) TestResizeToFillEnlarge() {
 func (s *ProcessingTestSuite) TestResizeToFillExtend() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.po().NewProcessingOptions()
-	po.ResizingType = options.ResizeFill
-	po.Extend = options.ExtendOptions{
-		Enabled: true,
-		Gravity: options.GravityOptions{
-			Type: options.GravityCenter,
-		},
-	}
+	po := options.New()
+	po.Set(keys.ResizingType, options.ResizeFill)
+	po.Set(keys.ExtendEnabled, true)
 
 	testCases := []struct {
 		width     int
@@ -361,8 +341,8 @@ func (s *ProcessingTestSuite) TestResizeToFillExtend() {
 
 	for _, tc := range testCases {
 		s.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func() {
-			po.Width = tc.width
-			po.Height = tc.height
+			po.Set(keys.Width, tc.width)
+			po.Set(keys.Height, tc.height)
 
 			s.processImageAndCheck(imgdata, po, tc.outWidth, tc.outHeight)
 		})
@@ -372,14 +352,10 @@ func (s *ProcessingTestSuite) TestResizeToFillExtend() {
 func (s *ProcessingTestSuite) TestResizeToFillExtendAR() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.po().NewProcessingOptions()
-	po.ResizingType = options.ResizeFill
-	po.ExtendAspectRatio = options.ExtendOptions{
-		Enabled: true,
-		Gravity: options.GravityOptions{
-			Type: options.GravityCenter,
-		},
-	}
+	po := options.New()
+	po.Set(keys.ResizingType, options.ResizeFill)
+	po.Set(keys.ExtendAspectRatioEnabled, true)
+	po.Set(keys.ExtendAspectRatioGravityType, options.GravityCenter)
 
 	testCases := []struct {
 		width     int
@@ -403,8 +379,8 @@ func (s *ProcessingTestSuite) TestResizeToFillExtendAR() {
 
 	for _, tc := range testCases {
 		s.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func() {
-			po.Width = tc.width
-			po.Height = tc.height
+			po.Set(keys.Width, tc.width)
+			po.Set(keys.Height, tc.height)
 
 			s.processImageAndCheck(imgdata, po, tc.outWidth, tc.outHeight)
 		})
@@ -414,8 +390,8 @@ func (s *ProcessingTestSuite) TestResizeToFillExtendAR() {
 func (s *ProcessingTestSuite) TestResizeToFillDown() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.po().NewProcessingOptions()
-	po.ResizingType = options.ResizeFillDown
+	po := options.New()
+	po.Set(keys.ResizingType, options.ResizeFillDown)
 
 	testCases := []struct {
 		width     int
@@ -437,8 +413,8 @@ func (s *ProcessingTestSuite) TestResizeToFillDown() {
 
 	for _, tc := range testCases {
 		s.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func() {
-			po.Width = tc.width
-			po.Height = tc.height
+			po.Set(keys.Width, tc.width)
+			po.Set(keys.Height, tc.height)
 
 			s.processImageAndCheck(imgdata, po, tc.outWidth, tc.outHeight)
 		})
@@ -448,9 +424,9 @@ func (s *ProcessingTestSuite) TestResizeToFillDown() {
 func (s *ProcessingTestSuite) TestResizeToFillDownEnlarge() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.po().NewProcessingOptions()
-	po.ResizingType = options.ResizeFillDown
-	po.Enlarge = true
+	po := options.New()
+	po.Set(keys.ResizingType, options.ResizeFillDown)
+	po.Set(keys.Enlarge, true)
 
 	testCases := []struct {
 		width     int
@@ -472,8 +448,8 @@ func (s *ProcessingTestSuite) TestResizeToFillDownEnlarge() {
 
 	for _, tc := range testCases {
 		s.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func() {
-			po.Width = tc.width
-			po.Height = tc.height
+			po.Set(keys.Width, tc.width)
+			po.Set(keys.Height, tc.height)
 
 			s.processImageAndCheck(imgdata, po, tc.outWidth, tc.outHeight)
 		})
@@ -483,14 +459,9 @@ func (s *ProcessingTestSuite) TestResizeToFillDownEnlarge() {
 func (s *ProcessingTestSuite) TestResizeToFillDownExtend() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.po().NewProcessingOptions()
-	po.ResizingType = options.ResizeFillDown
-	po.Extend = options.ExtendOptions{
-		Enabled: true,
-		Gravity: options.GravityOptions{
-			Type: options.GravityCenter,
-		},
-	}
+	po := options.New()
+	po.Set(keys.ResizingType, options.ResizeFillDown)
+	po.Set(keys.ExtendEnabled, true)
 
 	testCases := []struct {
 		width     int
@@ -514,8 +485,8 @@ func (s *ProcessingTestSuite) TestResizeToFillDownExtend() {
 
 	for _, tc := range testCases {
 		s.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func() {
-			po.Width = tc.width
-			po.Height = tc.height
+			po.Set(keys.Width, tc.width)
+			po.Set(keys.Height, tc.height)
 
 			s.processImageAndCheck(imgdata, po, tc.outWidth, tc.outHeight)
 		})
@@ -525,14 +496,9 @@ func (s *ProcessingTestSuite) TestResizeToFillDownExtend() {
 func (s *ProcessingTestSuite) TestResizeToFillDownExtendAR() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.po().NewProcessingOptions()
-	po.ResizingType = options.ResizeFillDown
-	po.ExtendAspectRatio = options.ExtendOptions{
-		Enabled: true,
-		Gravity: options.GravityOptions{
-			Type: options.GravityCenter,
-		},
-	}
+	po := options.New()
+	po.Set(keys.ResizingType, options.ResizeFillDown)
+	po.Set(keys.ExtendAspectRatioEnabled, true)
 
 	testCases := []struct {
 		width     int
@@ -554,8 +520,8 @@ func (s *ProcessingTestSuite) TestResizeToFillDownExtendAR() {
 
 	for _, tc := range testCases {
 		s.Run(fmt.Sprintf("%dx%d", tc.width, tc.height), func() {
-			po.Width = tc.width
-			po.Height = tc.height
+			po.Set(keys.Width, tc.width)
+			po.Set(keys.Height, tc.height)
 
 			s.processImageAndCheck(imgdata, po, tc.outWidth, tc.outHeight)
 		})
@@ -565,20 +531,21 @@ func (s *ProcessingTestSuite) TestResizeToFillDownExtendAR() {
 func (s *ProcessingTestSuite) TestResultSizeLimit() {
 	imgdata := s.openFile("test2.jpg")
 
-	po := s.po().NewProcessingOptions()
-
 	testCases := []struct {
-		limit        int
-		width        int
-		height       int
-		resizingType options.ResizeType
-		enlarge      bool
-		extend       bool
-		extendAR     bool
-		padding      options.PaddingOptions
-		rotate       int
-		outWidth     int
-		outHeight    int
+		limit         int
+		width         int
+		height        int
+		resizingType  options.ResizeType
+		enlarge       bool
+		extend        bool
+		extendAR      bool
+		paddingTop    int
+		paddingRight  int
+		paddingBottom int
+		paddingLeft   int
+		rotate        int
+		outWidth      int
+		outHeight     int
 	}{
 		{
 			limit:        1000,
@@ -674,19 +641,16 @@ func (s *ProcessingTestSuite) TestResultSizeLimit() {
 			outHeight:    100,
 		},
 		{
-			limit:        200,
-			width:        100,
-			height:       100,
-			resizingType: options.ResizeFit,
-			padding: options.PaddingOptions{
-				Enabled: true,
-				Top:     100,
-				Right:   200,
-				Bottom:  300,
-				Left:    400,
-			},
-			outWidth:  200,
-			outHeight: 129,
+			limit:         200,
+			width:         100,
+			height:        100,
+			resizingType:  options.ResizeFit,
+			paddingTop:    100,
+			paddingRight:  200,
+			paddingBottom: 300,
+			paddingLeft:   400,
+			outWidth:      200,
+			outHeight:     129,
 		},
 		{
 			limit:        1000,
@@ -798,19 +762,16 @@ func (s *ProcessingTestSuite) TestResultSizeLimit() {
 			outHeight:    100,
 		},
 		{
-			limit:        200,
-			width:        100,
-			height:       100,
-			resizingType: options.ResizeFill,
-			padding: options.PaddingOptions{
-				Enabled: true,
-				Top:     100,
-				Right:   200,
-				Bottom:  300,
-				Left:    400,
-			},
-			outWidth:  200,
-			outHeight: 144,
+			limit:         200,
+			width:         100,
+			height:        100,
+			resizingType:  options.ResizeFill,
+			paddingTop:    100,
+			paddingRight:  200,
+			paddingBottom: 300,
+			paddingLeft:   400,
+			outWidth:      200,
+			outHeight:     144,
 		},
 		{
 			limit:        1000,
@@ -922,34 +883,28 @@ func (s *ProcessingTestSuite) TestResultSizeLimit() {
 			outHeight:    100,
 		},
 		{
-			limit:        200,
-			width:        100,
-			height:       100,
-			resizingType: options.ResizeFillDown,
-			padding: options.PaddingOptions{
-				Enabled: true,
-				Top:     100,
-				Right:   200,
-				Bottom:  300,
-				Left:    400,
-			},
-			outWidth:  200,
-			outHeight: 144,
-		},
-		{
-			limit:        200,
-			width:        1000,
-			height:       1000,
-			resizingType: options.ResizeFillDown,
-			padding: options.PaddingOptions{
-				Enabled: true,
-				Top:     100,
-				Right:   200,
-				Bottom:  300,
-				Left:    400,
-			},
-			outWidth:  200,
-			outHeight: 144,
+			limit:         200,
+			width:         100,
+			height:        100,
+			resizingType:  options.ResizeFillDown,
+			paddingTop:    100,
+			paddingRight:  200,
+			paddingBottom: 300,
+			paddingLeft:   400,
+			outWidth:      200,
+			outHeight:     144,
+		},
+		{
+			limit:         200,
+			width:         1000,
+			height:        1000,
+			resizingType:  options.ResizeFillDown,
+			paddingTop:    100,
+			paddingRight:  200,
+			paddingBottom: 300,
+			paddingLeft:   400,
+			outWidth:      200,
+			outHeight:     144,
 		},
 	}
 
@@ -967,20 +922,27 @@ func (s *ProcessingTestSuite) TestResultSizeLimit() {
 		if tc.rotate != 0 {
 			name += fmt.Sprintf("_rot_%d", tc.rotate)
 		}
-		if tc.padding.Enabled {
-			name += fmt.Sprintf("_padding_%dx%dx%dx%d", tc.padding.Top, tc.padding.Right, tc.padding.Bottom, tc.padding.Left)
+		if tc.paddingTop > 0 || tc.paddingRight > 0 || tc.paddingBottom > 0 || tc.paddingLeft > 0 {
+			name += fmt.Sprintf(
+				"_padding_%dx%dx%dx%d",
+				tc.paddingTop, tc.paddingRight, tc.paddingBottom, tc.paddingLeft,
+			)
 		}
 
 		s.Run(name, func() {
-			po.SecurityOptions.MaxResultDimension = tc.limit
-			po.Width = tc.width
-			po.Height = tc.height
-			po.ResizingType = tc.resizingType
-			po.Enlarge = tc.enlarge
-			po.Extend.Enabled = tc.extend
-			po.ExtendAspectRatio.Enabled = tc.extendAR
-			po.Rotate = tc.rotate
-			po.Padding = tc.padding
+			po := options.New()
+			po.Set(keys.MaxResultDimension, tc.limit)
+			po.Set(keys.Width, tc.width)
+			po.Set(keys.Height, tc.height)
+			po.Set(keys.ResizingType, tc.resizingType)
+			po.Set(keys.Enlarge, tc.enlarge)
+			po.Set(keys.ExtendEnabled, tc.extend)
+			po.Set(keys.ExtendAspectRatioEnabled, tc.extendAR)
+			po.Set(keys.Rotate, tc.rotate)
+			po.Set(keys.PaddingTop, tc.paddingTop)
+			po.Set(keys.PaddingRight, tc.paddingRight)
+			po.Set(keys.PaddingBottom, tc.paddingBottom)
+			po.Set(keys.PaddingLeft, tc.paddingLeft)
 
 			s.processImageAndCheck(imgdata, po, tc.outWidth, tc.outHeight)
 		})
@@ -988,11 +950,11 @@ func (s *ProcessingTestSuite) TestResultSizeLimit() {
 }
 
 func (s *ProcessingTestSuite) TestImageResolutionTooLarge() {
-	po := s.po().NewProcessingOptions()
-	po.SecurityOptions.MaxSrcResolution = 1
+	po := options.New()
+	po.Set(keys.MaxSrcResolution, 1)
 
 	imgdata := s.openFile("test2.jpg")
-	_, err := s.processor().ProcessImage(context.Background(), imgdata, po, nil)
+	_, err := s.processor().ProcessImage(s.T().Context(), imgdata, po, s.security().NewOptions(po))
 
 	s.Require().Error(err)
 	s.Require().Equal(422, ierrors.Wrap(err, 0).StatusCode())

+ 4 - 2
processing/rotate_and_flip.go

@@ -1,7 +1,9 @@
 package processing
 
 func (p *Processor) rotateAndFlip(c *Context) error {
-	if c.Angle%360 == 0 && c.PO.Rotate%360 == 0 && !c.Flip {
+	rotateAngle := c.PO.Rotate()
+
+	if c.Angle%360 == 0 && rotateAngle%360 == 0 && !c.Flip {
 		return nil
 	}
 
@@ -19,5 +21,5 @@ func (p *Processor) rotateAndFlip(c *Context) error {
 		}
 	}
 
-	return c.Img.Rotate(c.PO.Rotate)
+	return c.Img.Rotate(rotateAngle)
 }

+ 9 - 7
processing/save_fit_bytes.go

@@ -4,8 +4,8 @@ import (
 	"context"
 
 	"github.com/imgproxy/imgproxy/v3/imagedata"
+	"github.com/imgproxy/imgproxy/v3/imagetype"
 	"github.com/imgproxy/imgproxy/v3/imath"
-	"github.com/imgproxy/imgproxy/v3/options"
 	"github.com/imgproxy/imgproxy/v3/server"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
@@ -15,13 +15,15 @@ import (
 // or the best effort data if it was not possible to fit into the limit.
 func saveImageToFitBytes(
 	ctx context.Context,
-	po *options.ProcessingOptions,
 	img *vips.Image,
+	format imagetype.Type,
+	startQuality int,
+	target int,
 ) (imagedata.ImageData, error) {
 	var newQuality int
 
-	// Start with the quality specified in the options.
-	quality := po.GetQuality()
+	// Start with the specified quality and go down from there.
+	quality := startQuality
 
 	// We will probably save the image multiple times, so we need to process its pixels
 	// to ensure that it is in random access mode.
@@ -36,7 +38,7 @@ func saveImageToFitBytes(
 			return nil, err
 		}
 
-		imgdata, err := img.Save(po.Format, quality)
+		imgdata, err := img.Save(format, quality)
 		if err != nil {
 			return nil, err
 		}
@@ -48,7 +50,7 @@ func saveImageToFitBytes(
 		}
 
 		// If we fit the limit or quality is too low, return the result.
-		if size <= po.MaxBytes || quality <= 10 {
+		if size <= target || quality <= 10 {
 			return imgdata, err
 		}
 
@@ -56,7 +58,7 @@ func saveImageToFitBytes(
 		imgdata.Close()
 
 		// Tune quality for the next attempt based on how much we exceed the limit.
-		delta := float64(size) / float64(po.MaxBytes)
+		delta := float64(size) / float64(target)
 		switch {
 		case delta > 3:
 			newQuality = imath.Scale(quality, 0.25)

+ 2 - 1
processing/scale.go

@@ -6,7 +6,8 @@ func (p *Processor) scale(c *Context) error {
 	}
 
 	wscale, hscale := c.WScale, c.HScale
-	if (c.Angle+c.PO.Rotate)%180 == 90 {
+
+	if (c.Angle+c.PO.Rotate())%180 == 90 {
 		wscale, hscale = hscale, wscale
 	}
 

+ 5 - 2
processing/scale_on_load.go

@@ -60,6 +60,9 @@ func (p *Processor) scaleOnLoad(c *Context) error {
 
 	var newWidth, newHeight int
 
+	rotateAngle := c.PO.Rotate()
+	autoRotate := c.PO.AutoRotate()
+
 	if c.ImgData.Format().SupportsThumbnail() {
 		thumbnail := new(vips.Image)
 		defer thumbnail.Clear()
@@ -70,7 +73,7 @@ func (p *Processor) scaleOnLoad(c *Context) error {
 		}
 
 		angle, flip := 0, false
-		newWidth, newHeight, angle, flip = ExtractGeometry(thumbnail, c.PO.Rotate, c.PO.AutoRotate)
+		newWidth, newHeight, angle, flip = ExtractGeometry(thumbnail, rotateAngle, autoRotate)
 
 		if newWidth >= c.SrcWidth || float64(newWidth)/float64(c.SrcWidth) < prescale {
 			return nil
@@ -90,7 +93,7 @@ func (p *Processor) scaleOnLoad(c *Context) error {
 			return err
 		}
 
-		newWidth, newHeight, _, _ = ExtractGeometry(c.Img, c.PO.Rotate, c.PO.AutoRotate)
+		newWidth, newHeight, _, _ = ExtractGeometry(c.Img, rotateAngle, autoRotate)
 	}
 
 	// Update scales after scale-on-load

+ 6 - 4
processing/strip_metadata.go

@@ -104,22 +104,24 @@ func stripXMP(img *vips.Image) []byte {
 }
 
 func (p *Processor) stripMetadata(c *Context) error {
-	if !c.PO.StripMetadata {
+	if !c.PO.StripMetadata() {
 		return nil
 	}
 
+	keepCopyright := c.PO.KeepCopyright()
+
 	var ps3Data, xmpData []byte
 
-	if c.PO.KeepCopyright {
+	if keepCopyright {
 		ps3Data = stripPS3(c.Img)
 		xmpData = stripXMP(c.Img)
 	}
 
-	if err := c.Img.Strip(c.PO.KeepCopyright); err != nil {
+	if err := c.Img.Strip(keepCopyright); err != nil {
 		return err
 	}
 
-	if c.PO.KeepCopyright {
+	if keepCopyright {
 		if len(ps3Data) > 0 {
 			c.Img.SetBlob("iptc-data", ps3Data)
 		}

+ 8 - 2
processing/trim.go

@@ -1,7 +1,7 @@
 package processing
 
 func (p *Processor) trim(c *Context) error {
-	if !c.PO.Trim.Enabled {
+	if !c.PO.TrimEnabled() {
 		return nil
 	}
 
@@ -10,7 +10,13 @@ func (p *Processor) trim(c *Context) error {
 		return err
 	}
 
-	if err := c.Img.Trim(c.PO.Trim.Threshold, c.PO.Trim.Smart, c.PO.Trim.Color, c.PO.Trim.EqualHor, c.PO.Trim.EqualVer); err != nil {
+	if err := c.Img.Trim(
+		c.PO.TrimThreshold(),
+		c.PO.TrimSmart(),
+		c.PO.TrimColor(),
+		c.PO.TrimEqualHor(),
+		c.PO.TrimEqualVer(),
+	); err != nil {
 		return err
 	}
 	if err := c.Img.CopyMemory(); err != nil {

+ 2 - 2
processing/vector_guard_scale.go

@@ -11,8 +11,8 @@ func (p *Processor) vectorGuardScale(c *Context) error {
 		return nil
 	}
 
-	if resolution := c.Img.Width() * c.Img.Height(); resolution > c.PO.SecurityOptions.MaxSrcResolution {
-		scale := math.Sqrt(float64(c.PO.SecurityOptions.MaxSrcResolution) / float64(resolution))
+	if resolution := c.Img.Width() * c.Img.Height(); resolution > c.SecOps.MaxSrcResolution {
+		scale := math.Sqrt(float64(c.SecOps.MaxSrcResolution) / float64(resolution))
 		c.VectorBaseScale = scale
 
 		if err := c.Img.Load(c.ImgData, 1, scale, 1); err != nil {

+ 55 - 38
processing/watermark.go

@@ -7,6 +7,8 @@ import (
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/imath"
 	"github.com/imgproxy/imgproxy/v3/options"
+	"github.com/imgproxy/imgproxy/v3/options/keys"
+	"github.com/imgproxy/imgproxy/v3/security"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
@@ -23,11 +25,16 @@ func (p *Processor) watermarkPipeline() Pipeline {
 	}
 }
 
+func shouldReplicateWatermark(gt options.GravityType) bool {
+	return gt == options.GravityReplicate
+}
+
 func (p *Processor) prepareWatermark(
 	ctx context.Context,
 	wm *vips.Image,
 	wmData imagedata.ImageData,
-	po *options.ProcessingOptions,
+	po ProcessingOptions,
+	secops security.Options,
 	imgWidth, imgHeight int,
 	offsetScale float64,
 	framesCount int,
@@ -36,42 +43,44 @@ func (p *Processor) prepareWatermark(
 		return err
 	}
 
-	opts := po.Watermark
+	wmPo := p.NewProcessingOptions(options.New())
+	wmPo.Set(keys.ResizingType, options.ResizeFit)
+	wmPo.Set(keys.Dpr, 1)
+	wmPo.Set(keys.Enlarge, true)
+	wmPo.Set(keys.Format, wmData.Format())
 
-	wmPo := po.Default()
-	wmPo.ResizingType = options.ResizeFit
-	wmPo.Dpr = 1
-	wmPo.Enlarge = true
-	wmPo.Format = wmData.Format()
-
-	if opts.Scale > 0 {
-		wmPo.Width = max(imath.ScaleToEven(imgWidth, opts.Scale), 1)
-		wmPo.Height = max(imath.ScaleToEven(imgHeight, opts.Scale), 1)
+	if scale := po.WatermarkScale(); scale > 0 {
+		wmPo.Set(keys.Width, max(imath.ScaleToEven(imgWidth, scale), 1))
+		wmPo.Set(keys.Height, max(imath.ScaleToEven(imgHeight, scale), 1))
 	}
 
-	if opts.ShouldReplicate() {
-		var offX, offY int
+	shouldReplicate := shouldReplicateWatermark(po.WatermarkPosition())
+
+	if shouldReplicate {
+		offsetX := po.WatermarkXOffset()
+		offsetY := po.WatermarkYOffset()
 
-		if math.Abs(opts.Position.X) >= 1.0 {
-			offX = imath.RoundToEven(opts.Position.X * offsetScale)
+		var padX, padY int
+
+		if math.Abs(offsetX) >= 1.0 {
+			padX = imath.RoundToEven(offsetX * offsetScale)
 		} else {
-			offX = imath.ScaleToEven(imgWidth, opts.Position.X)
+			padX = imath.ScaleToEven(imgWidth, offsetX)
 		}
 
-		if math.Abs(opts.Position.Y) >= 1.0 {
-			offY = imath.RoundToEven(opts.Position.Y * offsetScale)
+		if math.Abs(offsetY) >= 1.0 {
+			padY = imath.RoundToEven(offsetY * offsetScale)
 		} else {
-			offY = imath.ScaleToEven(imgHeight, opts.Position.Y)
+			padY = imath.ScaleToEven(imgHeight, offsetY)
 		}
 
-		wmPo.Padding.Enabled = true
-		wmPo.Padding.Left = offX / 2
-		wmPo.Padding.Right = offX - wmPo.Padding.Left
-		wmPo.Padding.Top = offY / 2
-		wmPo.Padding.Bottom = offY - wmPo.Padding.Top
+		wmPo.Set(keys.PaddingLeft, padX/2)
+		wmPo.Set(keys.PaddingRight, padX-padX/2)
+		wmPo.Set(keys.PaddingTop, padY/2)
+		wmPo.Set(keys.PaddingBottom, padY-padY/2)
 	}
 
-	if err := p.watermarkPipeline().Run(ctx, wm, wmPo, wmData); err != nil {
+	if err := p.watermarkPipeline().Run(ctx, wm, wmPo, secops, wmData); err != nil {
 		return err
 	}
 
@@ -81,7 +90,7 @@ func (p *Processor) prepareWatermark(
 		return err
 	}
 
-	if opts.ShouldReplicate() {
+	if shouldReplicate {
 		if err := wm.Replicate(imgWidth, imgHeight, true); err != nil {
 			return err
 		}
@@ -94,7 +103,8 @@ func (p *Processor) prepareWatermark(
 func (p *Processor) applyWatermark(
 	ctx context.Context,
 	img *vips.Image,
-	po *options.ProcessingOptions,
+	po ProcessingOptions,
+	secops security.Options,
 	offsetScale float64,
 	framesCount int,
 ) error {
@@ -102,7 +112,7 @@ func (p *Processor) applyWatermark(
 		return nil
 	}
 
-	wmData, _, err := p.watermarkProvider.Get(ctx, po)
+	wmData, _, err := p.watermarkProvider.Get(ctx, po.Options)
 	if err != nil {
 		return err
 	}
@@ -111,8 +121,6 @@ func (p *Processor) applyWatermark(
 	}
 	defer wmData.Close()
 
-	opts := po.Watermark
-
 	wm := new(vips.Image)
 	defer wm.Clear()
 
@@ -120,7 +128,9 @@ func (p *Processor) applyWatermark(
 	height := img.Height()
 	frameHeight := height / framesCount
 
-	if err := p.prepareWatermark(ctx, wm, wmData, po, width, frameHeight, offsetScale, framesCount); err != nil {
+	if err := p.prepareWatermark(
+		ctx, wm, wmData, po, secops, width, frameHeight, offsetScale, framesCount,
+	); err != nil {
 		return err
 	}
 
@@ -134,12 +144,14 @@ func (p *Processor) applyWatermark(
 		return err
 	}
 
-	// TODO: Use runner config
-	opacity := opts.Opacity * p.config.WatermarkOpacity
+	opacity := po.WatermarkOpacity() * p.config.WatermarkOpacity
+
+	position := po.WatermarkPosition()
+	shouldReplicate := shouldReplicateWatermark(position)
 
 	// If we replicated the watermark and need to apply it to an animated image,
 	// it is faster to replicate the watermark to all the image and apply it single-pass
-	if opts.ShouldReplicate() && framesCount > 1 {
+	if shouldReplicate && framesCount > 1 {
 		if err := wm.Replicate(width, height, false); err != nil {
 			return err
 		}
@@ -151,8 +163,13 @@ func (p *Processor) applyWatermark(
 	wmWidth := wm.Width()
 	wmHeight := wm.Height()
 
-	if !opts.ShouldReplicate() {
-		left, top = calcPosition(width, frameHeight, wmWidth, wmHeight, &opts.Position, offsetScale, true)
+	if !shouldReplicate {
+		gr := GravityOptions{
+			Type: position,
+			X:    po.WatermarkXOffset(),
+			Y:    po.WatermarkYOffset(),
+		}
+		left, top = calcPosition(width, frameHeight, wmWidth, wmHeight, &gr, offsetScale, true)
 	}
 
 	if left >= width || top >= height || -left >= wmWidth || -top >= wmHeight {
@@ -195,9 +212,9 @@ func (p *Processor) applyWatermark(
 }
 
 func (p *Processor) watermark(c *Context) error {
-	if !c.PO.Watermark.Enabled || c.WatermarkProvider == nil {
+	if c.WatermarkProvider == nil || c.PO.WatermarkOpacity() == 0 {
 		return nil
 	}
 
-	return p.applyWatermark(c.Ctx, c.Img, c.PO, c.DprScale, 1)
+	return p.applyWatermark(c.Ctx, c.Img, c.PO, c.SecOps, c.DprScale, 1)
 }

+ 31 - 3
security/checker.go

@@ -1,5 +1,10 @@
 package security
 
+import (
+	"github.com/imgproxy/imgproxy/v3/options"
+	"github.com/imgproxy/imgproxy/v3/options/keys"
+)
+
 // Checker represents the security package instance
 type Checker struct {
 	config *Config
@@ -16,7 +21,30 @@ func New(config *Config) (*Checker, error) {
 	}, nil
 }
 
-// NewOptions creates a new security.Options instance
-func (s *Checker) NewOptions() Options {
-	return s.config.DefaultOptions
+// NewOptions creates a new [security.Options] instance
+// filling it from [options.Options].
+// If opts is nil, it returns default [security.Options].
+func (s *Checker) NewOptions(opts *options.Options) (secops Options) {
+	secops = s.config.DefaultOptions
+	if opts == nil {
+		return
+	}
+
+	secops.MaxSrcResolution = opts.GetInt(
+		keys.MaxSrcResolution, secops.MaxSrcResolution,
+	)
+	secops.MaxSrcFileSize = opts.GetInt(
+		keys.MaxSrcFileSize, secops.MaxSrcFileSize,
+	)
+	secops.MaxAnimationFrames = opts.GetInt(
+		keys.MaxAnimationFrames, secops.MaxAnimationFrames,
+	)
+	secops.MaxAnimationFrameResolution = opts.GetInt(
+		keys.MaxAnimationFrameResolution, secops.MaxAnimationFrameResolution,
+	)
+	secops.MaxResultDimension = opts.GetInt(
+		keys.MaxResultDimension, secops.MaxResultDimension,
+	)
+
+	return
 }

+ 3 - 3
server/responsewriter/writer.go

@@ -61,8 +61,8 @@ func (w *Writer) SetIsFallbackImage() {
 }
 
 // SetExpires sets the TTL from time
-func (w *Writer) SetExpires(expires *time.Time) {
-	if expires == nil {
+func (w *Writer) SetExpires(expires time.Time) {
+	if expires.IsZero() {
 		return
 	}
 
@@ -71,7 +71,7 @@ func (w *Writer) SetExpires(expires *time.Time) {
 
 	// If maxAge outlives expires or was not set, we'll use expires as maxAge.
 	if w.maxAge < 0 || expires.Before(currentMaxAgeTime) {
-		w.maxAge = min(w.config.DefaultTTL, max(0, int(time.Until(*expires).Seconds())))
+		w.maxAge = min(w.config.DefaultTTL, max(0, int(time.Until(expires).Seconds())))
 	}
 }
 

+ 4 - 4
server/responsewriter/writer_test.go

@@ -168,7 +168,7 @@ func (s *ResponseWriterSuite) TestHeaderCases() {
 				WriteResponseTimeout: writeResponseTimeout,
 			},
 			fn: func(w *Writer) {
-				w.SetExpires(&expires)
+				w.SetExpires(expires)
 			},
 		},
 		{
@@ -185,7 +185,7 @@ func (s *ResponseWriterSuite) TestHeaderCases() {
 			},
 			fn: func(w *Writer) {
 				w.SetIsFallbackImage()
-				w.SetExpires(&shortExpires)
+				w.SetExpires(shortExpires)
 			},
 		},
 		{
@@ -269,7 +269,7 @@ func (s *ResponseWriterSuite) TestHeaderCases() {
 			},
 		},
 		{
-			name: "SetMaxAgeFromExpiresNil",
+			name: "SetMaxAgeFromExpiresZero",
 			req:  http.Header{},
 			res: http.Header{
 				httpheaders.CacheControl:          []string{"max-age=3600, public"},
@@ -280,7 +280,7 @@ func (s *ResponseWriterSuite) TestHeaderCases() {
 				WriteResponseTimeout: writeResponseTimeout,
 			},
 			fn: func(w *Writer) {
-				w.SetExpires(nil)
+				w.SetExpires(time.Time{})
 			},
 		},
 	}

+ 9 - 4
vips/color.go → vips/color/color.go

@@ -1,7 +1,8 @@
-package vips
+package color
 
 import (
 	"fmt"
+	"log/slog"
 	"regexp"
 )
 
@@ -12,10 +13,10 @@ const (
 	hexColorShortFormat = "%1x%1x%1x"
 )
 
-type Color struct{ R, G, B uint8 }
+type RGB struct{ R, G, B uint8 }
 
-func ColorFromHex(hexcolor string) (Color, error) {
-	c := Color{}
+func RGBFromHex(hexcolor string) (RGB, error) {
+	c := RGB{}
 
 	if !hexColorRegex.MatchString(hexcolor) {
 		return c, newColorError("Invalid hex color: %s", hexcolor)
@@ -32,3 +33,7 @@ func ColorFromHex(hexcolor string) (Color, error) {
 
 	return c, nil
 }
+
+func (c RGB) LogValue() slog.Value {
+	return slog.StringValue(fmt.Sprintf("#%02x%02x%02x", c.R, c.G, c.B))
+}

+ 19 - 0
vips/color/errors.go

@@ -0,0 +1,19 @@
+package color
+
+import (
+	"fmt"
+
+	"github.com/imgproxy/imgproxy/v3/ierrors"
+)
+
+type ColorError string
+
+func newColorError(format string, args ...interface{}) error {
+	return ierrors.Wrap(
+		ColorError(fmt.Sprintf(format, args...)),
+		1,
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e ColorError) Error() string { return string(e) }

+ 1 - 16
vips/errors.go

@@ -1,28 +1,13 @@
 package vips
 
 import (
-	"fmt"
-
 	"github.com/imgproxy/imgproxy/v3/ierrors"
 )
 
-type (
-	VipsError  string
-	ColorError string
-)
+type VipsError string
 
 func newVipsError(msg string) error {
 	return ierrors.Wrap(VipsError(msg), 1)
 }
 
 func (e VipsError) Error() string { return string(e) }
-
-func newColorError(format string, args ...interface{}) error {
-	return ierrors.Wrap(
-		ColorError(fmt.Sprintf(format, args...)),
-		1,
-		ierrors.WithShouldReport(false),
-	)
-}
-
-func (e ColorError) Error() string { return string(e) }

+ 5 - 4
vips/vips.go

@@ -31,6 +31,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/monitoring/newrelic"
 	"github.com/imgproxy/imgproxy/v3/monitoring/otel"
 	"github.com/imgproxy/imgproxy/v3/monitoring/prometheus"
+	"github.com/imgproxy/imgproxy/v3/vips/color"
 )
 
 type Image struct {
@@ -323,7 +324,7 @@ func gbool(b bool) C.gboolean {
 	return C.gboolean(0)
 }
 
-func cRGB(c Color) C.RGB {
+func cRGB(c color.RGB) C.RGB {
 	return C.RGB{
 		r: C.double(c.R),
 		g: C.double(c.G),
@@ -751,7 +752,7 @@ func (img *Image) SmartCrop(width, height int) error {
 	return nil
 }
 
-func (img *Image) Trim(threshold float64, smart bool, color Color, equalHor bool, equalVer bool) error {
+func (img *Image) Trim(threshold float64, smart bool, color color.RGB, equalHor bool, equalVer bool) error {
 	var tmp *C.VipsImage
 
 	if err := img.CopyMemory(); err != nil {
@@ -767,7 +768,7 @@ func (img *Image) Trim(threshold float64, smart bool, color Color, equalHor bool
 	return nil
 }
 
-func (img *Image) Flatten(bg Color) error {
+func (img *Image) Flatten(bg color.RGB) error {
 	var tmp *C.VipsImage
 
 	if C.vips_flatten_go(img.VipsImage, &tmp, cRGB(bg)) != 0 {
@@ -778,7 +779,7 @@ func (img *Image) Flatten(bg Color) error {
 	return nil
 }
 
-func (img *Image) ApplyFilters(blurSigma, sharpSigma float32, pixelatePixels int) error {
+func (img *Image) ApplyFilters(blurSigma, sharpSigma float64, pixelatePixels int) error {
 	var tmp *C.VipsImage
 
 	if C.vips_apply_filters(img.VipsImage, &tmp, C.double(blurSigma), C.double(sharpSigma), C.int(pixelatePixels)) != 0 {