Repository: gotenberg/gotenberg (go)

Issue:
Selection of PDF engines per method
Currently, the selection of PDF engines is global (e.g., it applies to all methods). A better solution would be to add a specific selection per method, and apply the global selection by default.

Pull request description:
feat(pdfengines): one flag per PDF engine method for a more granular selection of PDF engines
Closes #1003.

New flags:

* `--pdfengines-merge-engines` - Set the PDF engines and their order for the merge feature (`qpdf`, `pdfcpu`, `pdftk`)
* `--pdfengines-convert-engines` -  Set the PDF engines and their order for the convert feature (`libreoffice-pdfengine`)
* `--pdfengines-read-metadata-engines` - Set the PDF engines and their order for the read metadata feature (`exiftool`)
* `--pdfengines-write-engines` - Set the PDF engines and their order for the write metadata feature (`exiftool`)

Deprecrated flag (no usage anymore):

* `--pdfengines-engines`

Patch:
diff --git a/Makefile b/Makefile
index 67529de..230519d 100644
--- a/Makefile
+++ b/Makefile
@@ -72,6 +72,10 @@ LOG_LEVEL=info
 LOG_FORMAT=auto
 LOG_FIELDS_PREFIX=
 PDFENGINES_ENGINES=
+PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
+PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
+PDFENGINES_READ_METADATA_ENGINES=exiftool
+PDFENGINES_WRITE_METADATA_ENGINES=exiftool
 PDFENGINES_DISABLE_ROUTES=false
 PROMETHEUS_NAMESPACE=gotenberg
 PROMETHEUS_COLLECT_INTERVAL=1s
@@ -136,6 +140,10 @@ run: ## Start a Gotenberg container
 	--log-format=$(LOG_FORMAT) \
 	--log-fields-prefix=$(LOG_FIELDS_PREFIX) \
 	--pdfengines-engines=$(PDFENGINES_ENGINES) \
+	--pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \
+	--pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \
+	--pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \
+	--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
 	--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
 	--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
 	--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go
index d948b41..4cbbc3e 100644
--- a/pkg/modules/pdfengines/multi.go
+++ b/pkg/modules/pdfengines/multi.go
@@ -12,12 +12,23 @@ import (
 )
 
 type multiPdfEngines struct {
-	engines []gotenberg.PdfEngine
+	mergeEngines       []gotenberg.PdfEngine
+	convertEngines     []gotenberg.PdfEngine
+	readMedataEngines  []gotenberg.PdfEngine
+	writeMedataEngines []gotenberg.PdfEngine
 }
 
-func newMultiPdfEngines(engines ...gotenberg.PdfEngine) *multiPdfEngines {
+func newMultiPdfEngines(
+	mergeEngines,
+	convertEngines,
+	readMetadataEngines,
+	writeMedataEngines []gotenberg.PdfEngine,
+) *multiPdfEngines {
 	return &multiPdfEngines{
-		engines: engines,
+		mergeEngines:       mergeEngines,
+		convertEngines:     convertEngines,
+		readMedataEngines:  readMetadataEngines,
+		writeMedataEngines: writeMedataEngines,
 	}
 }
 
@@ -27,7 +38,7 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp
 	var err error
 	errChan := make(chan error, 1)
 
-	for _, engine := range multi.engines {
+	for _, engine := range multi.mergeEngines {
 		go func(engine gotenberg.PdfEngine) {
 			errChan <- engine.Merge(ctx, logger, inputPaths, outputPath)
 		}(engine)
@@ -52,7 +63,7 @@ func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, f
 	var err error
 	errChan := make(chan error, 1)
 
-	for _, engine := range multi.engines {
+	for _, engine := range multi.convertEngines {
 		go func(engine gotenberg.PdfEngine) {
 			errChan <- engine.Convert(ctx, logger, formats, inputPath, outputPath)
 		}(engine)
@@ -80,16 +91,16 @@ func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logg
 	var err error
 	var mu sync.Mutex // to safely append errors.
 
-	resultChan := make(chan readMetadataResult, len(multi.engines))
+	resultChan := make(chan readMetadataResult, len(multi.readMedataEngines))
 
-	for _, engine := range multi.engines {
+	for _, engine := range multi.readMedataEngines {
 		go func(engine gotenberg.PdfEngine) {
 			metadata, err := engine.ReadMetadata(ctx, logger, inputPath)
 			resultChan <- readMetadataResult{metadata: metadata, err: err}
 		}(engine)
 	}
 
-	for range multi.engines {
+	for range multi.readMedataEngines {
 		select {
 		case result := <-resultChan:
 			if result.err != nil {
@@ -111,7 +122,7 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log
 	var err error
 	errChan := make(chan error, 1)
 
-	for _, engine := range multi.engines {
+	for _, engine := range multi.writeMedataEngines {
 		go func(engine gotenberg.PdfEngine) {
 			errChan <- engine.WriteMetadata(ctx, logger, metadata, inputPath)
 		}(engine)
diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go
index c71e955..7303f34 100644
--- a/pkg/modules/pdfengines/pdfengines.go
+++ b/pkg/modules/pdfengines/pdfengines.go
@@ -27,9 +27,12 @@ func init() {
 // the [api.Router] interface to expose relevant PDF processing routes if
 // enabled.
 type PdfEngines struct {
-	names         []string
-	engines       []gotenberg.PdfEngine
-	disableRoutes bool
+	mergeNames        []string
+	convertNames      []string
+	readMetadataNames []string
+	writeMedataNames  []string
+	engines           []gotenberg.PdfEngine
+	disableRoutes     bool
 }
 
 // Descriptor returns a PdfEngines' module descriptor.
@@ -38,9 +41,18 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
 		ID: "pdfengines",
 		FlagSet: func() *flag.FlagSet {
 			fs := flag.NewFlagSet("pdfengines", flag.ExitOnError)
-			fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the PDF engines and their order - all by default")
+			fs.StringSlice("pdfengines-merge-engines", []string{"qpdf", "pdfcpu", "pdftk"}, "Set the PDF engines and their order for the merge feature")
+			fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature")
+			fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature")
+			fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature")
 			fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
 
+			fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the default PDF engines and their default order - all by default")
+			err := fs.MarkDeprecated("pdfengines-engines", "use other flags for a more granular selection of PDF engines per method")
+			if err != nil {
+				panic(err)
+			}
+
 			return fs
 		}(),
 		New: func() gotenberg.Module { return new(PdfEngines) },
@@ -51,7 +63,10 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
 // selected by the user thanks to the "engines" flag.
 func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
 	flags := ctx.ParsedFlags()
-	names := flags.MustStringSlice("pdfengines-engines")
+	mergeNames := flags.MustStringSlice("pdfengines-merge-engines")
+	convertNames := flags.MustStringSlice("pdfengines-convert-engines")
+	readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
+	writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
 	mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
 
 	engines, err := ctx.Modules(new(gotenberg.PdfEngine))
@@ -65,26 +80,37 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
 		mod.engines[i] = engine.(gotenberg.PdfEngine)
 	}
 
-	if len(names) > 0 {
-		// Selection from user.
-		mod.names = names
+	defaultNames := make([]string, len(mod.engines))
+	for i, engine := range mod.engines {
+		defaultNames[i] = engine.(gotenberg.Module).Descriptor().ID
+	}
 
-		// Example in case of deprecated module name.
-		//for i, name := range names {
-		//	if name == "unoconv-pdfengine" || name == "uno-pdfengine" {
-		//		logger.Warn(fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name))
-		//		mod.names[i] = "libreoffice-pdfengine"
-		//	}
-		//}
+	// Example in case of deprecated module name.
+	//for i, name := range defaultNames {
+	//	if name == "unoconv-pdfengine" || name == "uno-pdfengine" {
+	//		logger.Warn(fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name))
+	//		mod.defaultNames[i] = "libreoffice-pdfengine"
+	//	}
+	//}
+
+	mod.mergeNames = defaultNames
+	if len(mergeNames) > 0 {
+		mod.mergeNames = mergeNames
+	}
 
-		return nil
+	mod.convertNames = defaultNames
+	if len(convertNames) > 0 {
+		mod.convertNames = convertNames
 	}
 
-	// No selection from user, use all PDF engines available.
-	mod.names = make([]string, len(mod.engines))
+	mod.readMetadataNames = defaultNames
+	if len(readMetadataNames) > 0 {
+		mod.readMetadataNames = readMetadataNames
+	}
 
-	for i, engine := range mod.engines {
-		mod.names[i] = engine.(gotenberg.Module).Descriptor().ID
+	mod.writeMedataNames = defaultNames
+	if len(writeMetadataNames) > 0 {
+		mod.writeMedataNames = writeMetadataNames
 	}
 
 	return nil
@@ -105,22 +131,40 @@ func (mod *PdfEngines) Validate() error {
 	}
 
 	nonExistingEngines := make([]string, 0)
+	findNonExistingEngines := func(names []string) {
+		for _, name := range names {
+			engineExists := false
+
+			for _, engine := range mod.engines {
+				if name == engine.(gotenberg.Module).Descriptor().ID {
+					engineExists = true
+					break
+				}
+			}
 
-	for _, name := range mod.names {
-		engineExists := false
+			if engineExists {
+				continue
+			}
 
-		for _, engine := range mod.engines {
-			if name == engine.(gotenberg.Module).Descriptor().ID {
-				engineExists = true
-				break
+			alreadyInSlice := false
+			for _, engine := range nonExistingEngines {
+				if engine == name {
+					alreadyInSlice = true
+					break
+				}
 			}
-		}
 
-		if !engineExists {
-			nonExistingEngines = append(nonExistingEngines, name)
+			if !alreadyInSlice {
+				nonExistingEngines = append(nonExistingEngines, name)
+			}
 		}
 	}
 
+	findNonExistingEngines(mod.mergeNames)
+	findNonExistingEngines(mod.convertNames)
+	findNonExistingEngines(mod.readMetadataNames)
+	findNonExistingEngines(mod.writeMedataNames)
+
 	if len(nonExistingEngines) == 0 {
 		return nil
 	}
@@ -132,24 +176,35 @@ func (mod *PdfEngines) Validate() error {
 // modules.
 func (mod *PdfEngines) SystemMessages() []string {
 	return []string{
-		strings.Join(mod.names[:], " "),
+		fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
+		fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
+		fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
+		fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")),
 	}
 }
 
 // PdfEngine returns a [gotenberg.PdfEngine].
 func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
-	engines := make([]gotenberg.PdfEngine, len(mod.names))
-
-	for i, name := range mod.names {
-		for _, engine := range mod.engines {
-			if name == engine.(gotenberg.Module).Descriptor().ID {
-				engines[i] = engine
-				break
+	engines := func(names []string) []gotenberg.PdfEngine {
+		list := make([]gotenberg.PdfEngine, len(names))
+		for i, name := range names {
+			for _, engine := range mod.engines {
+				if name == engine.(gotenberg.Module).Descriptor().ID {
+					list[i] = engine
+					break
+				}
 			}
 		}
+
+		return list
 	}
 
-	return newMultiPdfEngines(engines...), nil
+	return newMultiPdfEngines(
+		engines(mod.mergeNames),
+		engines(mod.convertNames),
+		engines(mod.readMetadataNames),
+		engines(mod.writeMedataNames),
+	), nil
 }
 
 // Routes returns the HTTP routes.


Test patch:
diff --git a/pkg/modules/pdfengines/multi_test.go b/pkg/modules/pdfengines/multi_test.go
index 3a204b7..00e706d 100644
--- a/pkg/modules/pdfengines/multi_test.go
+++ b/pkg/modules/pdfengines/multi_test.go
@@ -20,11 +20,16 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
 		{
 			scenario: "nominal behavior",
 			engine: newMultiPdfEngines(
-				&gotenberg.PdfEngineMock{
-					MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
-						return nil
+				[]gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
+							return nil
+						},
 					},
 				},
+				nil,
+				nil,
+				nil,
 			),
 			ctx:         context.Background(),
 			expectError: false,
@@ -32,16 +37,21 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
 		{
 			scenario: "at least one engine does not return an error",
 			engine: newMultiPdfEngines(
-				&gotenberg.PdfEngineMock{
-					MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
-						return errors.New("foo")
+				[]gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
+							return errors.New("foo")
+						},
 					},
-				},
-				&gotenberg.PdfEngineMock{
-					MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
-						return nil
+					&gotenberg.PdfEngineMock{
+						MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
+							return nil
+						},
 					},
 				},
+				nil,
+				nil,
+				nil,
 			),
 			ctx:         context.Background(),
 			expectError: false,
@@ -49,16 +59,21 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
 		{
 			scenario: "all engines return an error",
 			engine: newMultiPdfEngines(
-				&gotenberg.PdfEngineMock{
-					MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
-						return errors.New("foo")
+				[]gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
+							return errors.New("foo")
+						},
 					},
-				},
-				&gotenberg.PdfEngineMock{
-					MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
-						return errors.New("foo")
+					&gotenberg.PdfEngineMock{
+						MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
+							return errors.New("foo")
+						},
 					},
 				},
+				nil,
+				nil,
+				nil,
 			),
 			ctx:         context.Background(),
 			expectError: true,
@@ -66,11 +81,16 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
 		{
 			scenario: "context expired",
 			engine: newMultiPdfEngines(
-				&gotenberg.PdfEngineMock{
-					MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
-						return nil
+				[]gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
+							return nil
+						},
 					},
 				},
+				nil,
+				nil,
+				nil,
 			),
 			ctx: func() context.Context {
 				ctx, cancel := context.WithCancel(context.Background())
@@ -105,43 +125,58 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
 		{
 			scenario: "nominal behavior",
 			engine: newMultiPdfEngines(
-				&gotenberg.PdfEngineMock{
-					ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
-						return nil
+				nil,
+				[]gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
+							return nil
+						},
 					},
 				},
+				nil,
+				nil,
 			),
 			ctx: context.Background(),
 		},
 		{
 			scenario: "at least one engine does not return an error",
 			engine: newMultiPdfEngines(
-				&gotenberg.PdfEngineMock{
-					ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
-						return errors.New("foo")
+				nil,
+				[]gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
+							return errors.New("foo")
+						},
 					},
-				},
-				&gotenberg.PdfEngineMock{
-					ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
-						return nil
+					&gotenberg.PdfEngineMock{
+						ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
+							return nil
+						},
 					},
 				},
+				nil,
+				nil,
 			),
 			ctx: context.Background(),
 		},
 		{
 			scenario: "all engines return an error",
 			engine: newMultiPdfEngines(
-				&gotenberg.PdfEngineMock{
-					ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
-						return errors.New("foo")
+				nil,
+				[]gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
+							return errors.New("foo")
+						},
 					},
-				},
-				&gotenberg.PdfEngineMock{
-					ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
-						return errors.New("foo")
+					&gotenberg.PdfEngineMock{
+						ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
+							return errors.New("foo")
+						},
 					},
 				},
+				nil,
+				nil,
 			),
 			ctx:         context.Background(),
 			expectError: true,
@@ -149,11 +184,16 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
 		{
 			scenario: "context expired",
 			engine: newMultiPdfEngines(
-				&gotenberg.PdfEngineMock{
-					ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
-						return nil
+				nil,
+				[]gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
+							return nil
+						},
 					},
 				},
+				nil,
+				nil,
 			),
 			ctx: func() context.Context {
 				ctx, cancel := context.WithCancel(context.Background())
@@ -188,43 +228,58 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
 		{
 			scenario: "nominal behavior",
 			engine: newMultiPdfEngines(
-				&gotenberg.PdfEngineMock{
-					ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
-						return make(map[string]interface{}), nil
+				nil,
+				nil,
+				[]gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
+							return make(map[string]interface{}), nil
+						},
 					},
 				},
+				nil,
 			),
 			ctx: context.Background(),
 		},
 		{
 			scenario: "at least one engine does not return an error",
 			engine: newMultiPdfEngines(
-				&gotenberg.PdfEngineMock{
-					ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
-						return nil, errors.New("foo")
+				nil,
+				nil,
+				[]gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
+							return nil, errors.New("foo")
+						},
 					},
-				},
-				&gotenberg.PdfEngineMock{
-					ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
-						return make(map[string]interface{}), nil
+					&gotenberg.PdfEngineMock{
+						ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
+							return make(map[string]interface{}), nil
+						},
 					},
 				},
+				nil,
 			),
 			ctx: context.Background(),
 		},
 		{
 			scenario: "all engines return an error",
 			engine: newMultiPdfEngines(
-				&gotenberg.PdfEngineMock{
-					ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
-						return nil, errors.New("foo")
+				nil,
+				nil,
+				[]gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
+							return nil, errors.New("foo")
+						},
 					},
-				},
-				&gotenberg.PdfEngineMock{
-					ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
-						return nil, errors.New("foo")
+					&gotenberg.PdfEngineMock{
+						ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
+							return nil, errors.New("foo")
+						},
 					},
 				},
+				nil,
 			),
 			ctx:         context.Background(),
 			expectError: true,
@@ -232,11 +287,16 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
 		{
 			scenario: "context expired",
 			engine: newMultiPdfEngines(
-				&gotenberg.PdfEngineMock{
-					ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
-						return make(map[string]interface{}), nil
+				nil,
+				nil,
+				[]gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
+							return make(map[string]interface{}), nil
+						},
 					},
 				},
+				nil,
 			),
 			ctx: func() context.Context {
 				ctx, cancel := context.WithCancel(context.Background())
@@ -271,9 +331,14 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
 		{
 			scenario: "nominal behavior",
 			engine: newMultiPdfEngines(
-				&gotenberg.PdfEngineMock{
-					WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
-						return nil
+				nil,
+				nil,
+				nil,
+				[]gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
+							return nil
+						},
 					},
 				},
 			),
@@ -282,14 +347,19 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
 		{
 			scenario: "at least one engine does not return an error",
 			engine: newMultiPdfEngines(
-				&gotenberg.PdfEngineMock{
-					WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
-						return errors.New("foo")
+				nil,
+				nil,
+				nil,
+				[]gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
+							return errors.New("foo")
+						},
 					},
-				},
-				&gotenberg.PdfEngineMock{
-					WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
-						return nil
+					&gotenberg.PdfEngineMock{
+						WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
+							return nil
+						},
 					},
 				},
 			),
@@ -298,14 +368,19 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
 		{
 			scenario: "all engines return an error",
 			engine: newMultiPdfEngines(
-				&gotenberg.PdfEngineMock{
-					WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
-						return errors.New("foo")
+				nil,
+				nil,
+				nil,
+				[]gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
+							return errors.New("foo")
+						},
 					},
-				},
-				&gotenberg.PdfEngineMock{
-					WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
-						return errors.New("foo")
+					&gotenberg.PdfEngineMock{
+						WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
+							return errors.New("foo")
+						},
 					},
 				},
 			),
@@ -315,9 +390,14 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
 		{
 			scenario: "context expired",
 			engine: newMultiPdfEngines(
-				&gotenberg.PdfEngineMock{
-					WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
-						return nil
+				nil,
+				nil,
+				nil,
+				[]gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
+							return nil
+						},
 					},
 				},
 			),
diff --git a/pkg/modules/pdfengines/pdfengines_test.go b/pkg/modules/pdfengines/pdfengines_test.go
index bf12c8a..fe99943 100644
--- a/pkg/modules/pdfengines/pdfengines_test.go
+++ b/pkg/modules/pdfengines/pdfengines_test.go
@@ -2,6 +2,7 @@ package pdfengines
 
 import (
 	"errors"
+	"fmt"
 	"reflect"
 	"strings"
 	"testing"
@@ -22,10 +23,13 @@ func TestPdfEngines_Descriptor(t *testing.T) {
 
 func TestPdfEngines_Provision(t *testing.T) {
 	for _, tc := range []struct {
-		scenario           string
-		ctx                *gotenberg.Context
-		expectedPdfEngines []string
-		expectError        bool
+		scenario                        string
+		ctx                             *gotenberg.Context
+		expectedMergePdfEngines         []string
+		expectedConvertPdfEngines       []string
+		expectedReadMetadataPdfEngines  []string
+		expectedWriteMetadataPdfEngines []string
+		expectError                     bool
 	}{
 		{
 			scenario: "no selection from user",
@@ -61,8 +65,11 @@ func TestPdfEngines_Provision(t *testing.T) {
 					},
 				)
 			}(),
-			expectedPdfEngines: []string{"bar"},
-			expectError:        false,
+			expectedMergePdfEngines:         []string{"qpdf", "pdfcpu", "pdftk"},
+			expectedConvertPdfEngines:       []string{"libreoffice-pdfengine"},
+			expectedReadMetadataPdfEngines:  []string{"exiftool"},
+			expectedWriteMetadataPdfEngines: []string{"exiftool"},
+			expectError:                     false,
 		},
 		{
 			scenario: "selection from user",
@@ -100,7 +107,7 @@ func TestPdfEngines_Provision(t *testing.T) {
 				}
 
 				fs := new(PdfEngines).Descriptor().FlagSet
-				err := fs.Parse([]string{"--pdfengines-engines=b", "--pdfengines-engines=a"})
+				err := fs.Parse([]string{"--pdfengines-merge-engines=b", "--pdfengines-convert-engines=b", "--pdfengines-read-metadata-engines=a", "--pdfengines-write-metadata-engines=a"})
 				if err != nil {
 					t.Fatalf("expected no error but got: %v", err)
 				}
@@ -116,8 +123,12 @@ func TestPdfEngines_Provision(t *testing.T) {
 					},
 				)
 			}(),
-			expectedPdfEngines: []string{"b", "a"},
-			expectError:        false,
+
+			expectedMergePdfEngines:         []string{"b"},
+			expectedConvertPdfEngines:       []string{"b"},
+			expectedReadMetadataPdfEngines:  []string{"a"},
+			expectedWriteMetadataPdfEngines: []string{"a"},
+			expectError:                     false,
 		},
 		{
 			scenario: "no valid PDF engine",
@@ -167,13 +178,43 @@ func TestPdfEngines_Provision(t *testing.T) {
 				t.Fatal("expected error but got none")
 			}
 
-			if len(tc.expectedPdfEngines) != len(mod.names) {
-				t.Fatalf("expected %d names but got %d", len(tc.expectedPdfEngines), len(mod.names))
+			if len(tc.expectedMergePdfEngines) != len(mod.mergeNames) {
+				t.Fatalf("expected %d merge names but got %d", len(tc.expectedMergePdfEngines), len(mod.mergeNames))
+			}
+
+			if len(tc.expectedConvertPdfEngines) != len(mod.convertNames) {
+				t.Fatalf("expected %d convert names but got %d", len(tc.expectedConvertPdfEngines), len(mod.convertNames))
+			}
+
+			if len(tc.expectedReadMetadataPdfEngines) != len(mod.readMetadataNames) {
+				t.Fatalf("expected %d read metadata names but got %d", len(tc.expectedReadMetadataPdfEngines), len(mod.readMetadataNames))
+			}
+
+			if len(tc.expectedWriteMetadataPdfEngines) != len(mod.writeMedataNames) {
+				t.Fatalf("expected %d write metadata names but got %d", len(tc.expectedWriteMetadataPdfEngines), len(mod.writeMedataNames))
+			}
+
+			for index, name := range mod.mergeNames {
+				if name != tc.expectedMergePdfEngines[index] {
+					t.Fatalf("expected merge name at index %d to be %s, but got: %s", index, name, tc.expectedMergePdfEngines[index])
+				}
 			}
 
-			for index, name := range mod.names {
-				if name != tc.expectedPdfEngines[index] {
-					t.Fatalf("expected scenario at index %d to be %s, but got: %s", index, name, tc.expectedPdfEngines[index])
+			for index, name := range mod.convertNames {
+				if name != tc.expectedConvertPdfEngines[index] {
+					t.Fatalf("expected convert name at index %d to be %s, but got: %s", index, name, tc.expectedConvertPdfEngines[index])
+				}
+			}
+
+			for index, name := range mod.readMetadataNames {
+				if name != tc.expectedReadMetadataPdfEngines[index] {
+					t.Fatalf("expected read metadata name at index %d to be %s, but got: %s", index, name, tc.expectedReadMetadataPdfEngines[index])
+				}
+			}
+
+			for index, name := range mod.writeMedataNames {
+				if name != tc.expectedWriteMetadataPdfEngines[index] {
+					t.Fatalf("expected write metadat name at index %d to be %s, but got: %s", index, name, tc.expectedWriteMetadataPdfEngines[index])
 				}
 			}
 		})
@@ -239,8 +280,11 @@ func TestPdfEngines_Validate(t *testing.T) {
 	} {
 		t.Run(tc.scenario, func(t *testing.T) {
 			mod := PdfEngines{
-				names:   tc.names,
-				engines: tc.engines,
+				mergeNames:        tc.names,
+				convertNames:      tc.names,
+				readMetadataNames: tc.names,
+				writeMedataNames:  tc.names,
+				engines:           tc.engines,
 			}
 
 			err := mod.Validate()
@@ -258,22 +302,36 @@ func TestPdfEngines_Validate(t *testing.T) {
 
 func TestPdfEngines_SystemMessages(t *testing.T) {
 	mod := new(PdfEngines)
-	mod.names = []string{"foo", "bar"}
+	mod.mergeNames = []string{"foo", "bar"}
+	mod.convertNames = []string{"foo", "bar"}
+	mod.readMetadataNames = []string{"foo", "bar"}
+	mod.writeMedataNames = []string{"foo", "bar"}
 
 	messages := mod.SystemMessages()
-	if len(messages) != 1 {
+	if len(messages) != 4 {
 		t.Errorf("expected one and only one message, but got %d", len(messages))
 	}
 
-	expect := strings.Join(mod.names[:], " ")
-	if messages[0] != expect {
-		t.Errorf("expected message '%s', but got '%s'", expect, messages[0])
+	expect := []string{
+		fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
+		fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
+		fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
+		fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")),
+	}
+
+	for i, message := range messages {
+		if message != expect[i] {
+			t.Errorf("expected message at index %d to be %s, but got %s", i, message, expect[i])
+		}
 	}
 }
 
 func TestPdfEngines_PdfEngine(t *testing.T) {
 	mod := PdfEngines{
-		names: []string{"foo", "bar"},
+		mergeNames:        []string{"foo", "bar"},
+		convertNames:      []string{"foo", "bar"},
+		readMetadataNames: []string{"foo", "bar"},
+		writeMedataNames:  []string{"foo", "bar"},
 		engines: func() []gotenberg.PdfEngine {
 			engine1 := &struct {
 				gotenberg.ModuleMock