Repository: gotenberg/gotenberg

Issue:
Integration tests
While the code coverage is *good enough*, current tests fail to catch some regressions (e.g., Chromium generating tagged PDFs again etc.). They also kinda lock the current implementation.

A better option would be to switch to test the final Docker image (on all available architectures).

Discussion:
@gulien: See:

* https://github.com/cucumber/godog
* https://github.com/testcontainers/testcontainers-go 

Solution patch:
diff --git a/Makefile b/Makefile
index 6066e4f..0a9b212 100644
--- a/Makefile
+++ b/Makefile
@@ -65,6 +65,7 @@ LOG_FIELDS_PREFIX=
 PDFENGINES_ENGINES=
 PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
 PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
+PDFENGINES_FLATTEN_ENGINES=qpdf
 PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
 PDFENGINES_READ_METADATA_ENGINES=exiftool
 PDFENGINES_WRITE_METADATA_ENGINES=exiftool
@@ -134,6 +135,7 @@ run: ## Start a Gotenberg container
 	--pdfengines-engines=$(PDFENGINES_ENGINES) \
 	--pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \
 	--pdfengines-split-engines=$(PDFENGINES_SPLIT_ENGINES) \
+	--pdfengines-convert-engines=$(PDFENGINES_FLATTEN_ENGINES) \
 	--pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \
 	--pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \
 	--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go
index 2ade895..a771c8c 100644
--- a/pkg/gotenberg/mocks.go
+++ b/pkg/gotenberg/mocks.go
@@ -35,9 +35,12 @@ func (mod *ValidatorMock) Validate() error {
 }
 
 // PdfEngineMock is a mock for the [PdfEngine] interface.
+//
+//nolint:dupl
 type PdfEngineMock struct {
 	MergeMock         func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
 	SplitMock         func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
+	FlattenMock       func(ctx context.Context, logger *zap.Logger, inputPath string) error
 	ConvertMock       func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
 	ReadMetadataMock  func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
 	WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
@@ -51,6 +54,10 @@ func (engine *PdfEngineMock) Split(ctx context.Context, logger *zap.Logger, mode
 	return engine.SplitMock(ctx, logger, mode, inputPath, outputDirPath)
 }
 
+func (engine *PdfEngineMock) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+	return engine.FlattenMock(ctx, logger, inputPath)
+}
+
 func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
 	return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
 }
diff --git a/pkg/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go
index 788c07c..dc4a4f7 100644
--- a/pkg/gotenberg/pdfengine.go
+++ b/pkg/gotenberg/pdfengine.go
@@ -88,6 +88,8 @@ type PdfFormats struct {
 // PdfEngine provides an interface for operations on PDFs. Implementations
 // can utilize various tools like PDFtk, or implement functionality directly in
 // Go.
+//
+//nolint:dupl
 type PdfEngine interface {
 	// Merge combines multiple PDFs into a single PDF. The resulting page order
 	// is determined by the order of files provided in inputPaths.
@@ -96,6 +98,11 @@ type PdfEngine interface {
 	// Split splits a given PDF file.
 	Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
 
+	// Flatten merges existing annotation appearances with page content, effectively deleting the original annotations.
+	// This process can flatten forms as well, as forms share a relationship with annotations.
+	// Note that this operation is irreversible.
+	Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error
+
 	// Convert transforms a given PDF to the specified formats defined in
 	// PdfFormats. If no format, it does nothing.
 	Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go
index aeffc6a..8fafc27 100644
--- a/pkg/modules/exiftool/exiftool.go
+++ b/pkg/modules/exiftool/exiftool.go
@@ -63,6 +63,11 @@ func (engine *ExifTool) Split(ctx context.Context, logger *zap.Logger, mode gote
 	return nil, fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
 }
 
+// Flatten is not available in this implementation.
+func (engine *ExifTool) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+	return fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
 // Convert is not available in this implementation.
 func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
 	return fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go
index 5416ab3..72ae311 100644
--- a/pkg/modules/libreoffice/pdfengine/pdfengine.go
+++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go
@@ -56,6 +56,11 @@ func (engine *LibreOfficePdfEngine) Split(ctx context.Context, logger *zap.Logge
 	return nil, fmt.Errorf("split PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
 }
 
+// Flatten is not available in this implementation.
+func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+	return fmt.Errorf("Flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
 // Convert converts the given PDF to a specific PDF format. Currently, only the
 // PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
 // PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go
index b678618..cf712c0 100644
--- a/pkg/modules/libreoffice/routes.go
+++ b/pkg/modules/libreoffice/routes.go
@@ -60,6 +60,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
 				maxImageResolution              int
 				nativePdfFormats                bool
 				merge                           bool
+				flatten                         bool
 			)
 
 			err := form.
@@ -135,6 +136,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
 					}
 					return nil
 				}).
+				Bool("flatten", &flatten, false).
 				Validate()
 			if err != nil {
 				return fmt.Errorf("validate form data: %w", err)
@@ -261,6 +263,13 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
 				return fmt.Errorf("write metadata: %w", err)
 			}
 
+			if flatten {
+				err = pdfengines.FlattenStub(ctx, engine, outputPaths)
+				if err != nil {
+					return fmt.Errorf("flatten PDFs: %w", err)
+				}
+			}
+
 			if len(outputPaths) > 1 && splitMode == zeroValuedSplitMode {
 				// If .zip archive, document.docx -> document.docx.pdf.
 				for i, inputPath := range inputPaths {
diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go
index 29803cb..2e82f87 100644
--- a/pkg/modules/pdfcpu/pdfcpu.go
+++ b/pkg/modules/pdfcpu/pdfcpu.go
@@ -107,6 +107,11 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenb
 	return outputPaths, nil
 }
 
+// Flatten is not available in this implementation.
+func (engine *PdfCpu) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+	return fmt.Errorf("flatten PDF with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
 // Convert is not available in this implementation.
 func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
 	return fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go
index a01515b..20ae2cd 100644
--- a/pkg/modules/pdfengines/multi.go
+++ b/pkg/modules/pdfengines/multi.go
@@ -14,6 +14,7 @@ import (
 type multiPdfEngines struct {
 	mergeEngines         []gotenberg.PdfEngine
 	splitEngines         []gotenberg.PdfEngine
+	flattenEngines       []gotenberg.PdfEngine
 	convertEngines       []gotenberg.PdfEngine
 	readMetadataEngines  []gotenberg.PdfEngine
 	writeMetadataEngines []gotenberg.PdfEngine
@@ -22,6 +23,7 @@ type multiPdfEngines struct {
 func newMultiPdfEngines(
 	mergeEngines,
 	splitEngines,
+	flattenEngines,
 	convertEngines,
 	readMetadataEngines,
 	writeMetadataEngines []gotenberg.PdfEngine,
@@ -29,6 +31,7 @@ func newMultiPdfEngines(
 	return &multiPdfEngines{
 		mergeEngines:         mergeEngines,
 		splitEngines:         splitEngines,
+		flattenEngines:       flattenEngines,
 		convertEngines:       convertEngines,
 		readMetadataEngines:  readMetadataEngines,
 		writeMetadataEngines: writeMetadataEngines,
@@ -98,6 +101,32 @@ func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mod
 	return nil, fmt.Errorf("split PDF with multi PDF engines: %w", err)
 }
 
+// Flatten merges existing annotation appearances with page content, effectively deleting the original annotations.
+// This process can flatten forms as well, as forms share a relationship with annotations.
+// Note that this operation is irreversible.
+func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+	var err error
+	errChan := make(chan error, 1)
+
+	for _, engine := range multi.flattenEngines {
+		go func(engine gotenberg.PdfEngine) {
+			errChan <- engine.Flatten(ctx, logger, inputPath)
+		}(engine)
+
+		select {
+		case mergeErr := <-errChan:
+			errored := multierr.AppendInto(&err, mergeErr)
+			if !errored {
+				return nil
+			}
+		case <-ctx.Done():
+			return ctx.Err()
+		}
+	}
+
+	return fmt.Errorf("flatten PDF with multi PDF engines: %w", err)
+}
+
 // Convert converts the given PDF to a specific PDF format. thanks to its
 // children. If the context is done, it stops and returns an error.
 func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go
index 4536ff7..f029f0a 100644
--- a/pkg/modules/pdfengines/pdfengines.go
+++ b/pkg/modules/pdfengines/pdfengines.go
@@ -29,6 +29,7 @@ func init() {
 type PdfEngines struct {
 	mergeNames         []string
 	splitNames         []string
+	flattenNames       []string
 	convertNames       []string
 	readMetadataNames  []string
 	writeMetadataNames []string
@@ -44,6 +45,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
 			fs := flag.NewFlagSet("pdfengines", flag.ExitOnError)
 			fs.StringSlice("pdfengines-merge-engines", []string{"qpdf", "pdfcpu", "pdftk"}, "Set the PDF engines and their order for the merge feature - empty means all")
 			fs.StringSlice("pdfengines-split-engines", []string{"pdfcpu", "qpdf", "pdftk"}, "Set the PDF engines and their order for the split feature - empty means all")
+			fs.StringSlice("pdfengines-flatten-engines", []string{"qpdf"}, "Set the PDF engines and their order for the flatten feature - empty means all")
 			fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all")
 			fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
 			fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
@@ -67,6 +69,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
 	flags := ctx.ParsedFlags()
 	mergeNames := flags.MustStringSlice("pdfengines-merge-engines")
 	splitNames := flags.MustStringSlice("pdfengines-split-engines")
+	flattenNames := flags.MustStringSlice("pdfengines-flatten-engines")
 	convertNames := flags.MustStringSlice("pdfengines-convert-engines")
 	readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
 	writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
@@ -106,6 +109,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
 		mod.splitNames = splitNames
 	}
 
+	mod.flattenNames = defaultNames
+	if len(flattenNames) > 0 {
+		mod.flattenNames = flattenNames
+	}
+
 	mod.convertNames = defaultNames
 	if len(convertNames) > 0 {
 		mod.convertNames = convertNames
@@ -170,6 +178,7 @@ func (mod *PdfEngines) Validate() error {
 
 	findNonExistingEngines(mod.mergeNames)
 	findNonExistingEngines(mod.splitNames)
+	findNonExistingEngines(mod.flattenNames)
 	findNonExistingEngines(mod.convertNames)
 	findNonExistingEngines(mod.readMetadataNames)
 	findNonExistingEngines(mod.writeMetadataNames)
@@ -187,6 +196,7 @@ func (mod *PdfEngines) SystemMessages() []string {
 	return []string{
 		fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
 		fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames[:], " ")),
+		fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames[:], " ")),
 		fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
 		fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
 		fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")),
@@ -212,6 +222,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
 	return newMultiPdfEngines(
 		engines(mod.mergeNames),
 		engines(mod.splitNames),
+		engines(mod.flattenNames),
 		engines(mod.convertNames),
 		engines(mod.readMetadataNames),
 		engines(mod.writeMetadataNames),
@@ -234,6 +245,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
 	return []api.Route{
 		mergeRoute(engine),
 		splitRoute(engine),
+		flattenRoute(engine),
 		convertRoute(engine),
 		readMetadataRoute(engine),
 		writeMetadataRoute(engine),
diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go
index d164bc7..d90dd65 100644
--- a/pkg/modules/pdfengines/routes.go
+++ b/pkg/modules/pdfengines/routes.go
@@ -202,6 +202,21 @@ func SplitPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, mode gotenberg.S
 	return outputPaths, nil
 }
 
+// FlattenStub merges annotation appearances with page content for each given PDF
+// in the input paths, effectively deleting the original annotations. It generates
+// new output paths for the flattened PDFs and returns them. If an error occurs
+// during the flattening process, it returns the error.
+func FlattenStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string) error {
+	for _, inputPath := range inputPaths {
+		err := engine.Flatten(ctx, ctx.Log(), inputPath)
+		if err != nil {
+			return fmt.Errorf("flatten '%s': %w", inputPath, err)
+		}
+	}
+
+	return nil
+}
+
 // ConvertStub transforms a given PDF to the specified formats defined in
 // [gotenberg.PdfFormats]. If no format, it does nothing and returns the input
 // paths.
@@ -255,8 +270,10 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
 			metadata := FormDataPdfMetadata(form, false)
 
 			var inputPaths []string
+			var flatten bool
 			err := form.
 				MandatoryPaths([]string{".pdf"}, &inputPaths).
+				Bool("flatten", &flatten, false).
 				Validate()
 			if err != nil {
 				return fmt.Errorf("validate form data: %w", err)
@@ -278,6 +295,13 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
 				return fmt.Errorf("write metadata: %w", err)
 			}
 
+			if flatten {
+				err = FlattenStub(ctx, engine, outputPaths)
+				if err != nil {
+					return fmt.Errorf("flatten PDFs: %w", err)
+				}
+			}
+
 			err = ctx.AddOutputPaths(outputPaths...)
 			if err != nil {
 				return fmt.Errorf("add output paths: %w", err)
@@ -347,6 +371,40 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
 	}
 }
 
+// flattenRoute returns an [api.Route] which can flatten PDFs.
+func flattenRoute(engine gotenberg.PdfEngine) api.Route {
+	return api.Route{
+		Method:      http.MethodPost,
+		Path:        "/forms/pdfengines/flatten",
+		IsMultipart: true,
+		Handler: func(c echo.Context) error {
+			ctx := c.Get("context").(*api.Context)
+
+			form := ctx.FormData()
+
+			var inputPaths []string
+			err := form.
+				MandatoryPaths([]string{".pdf"}, &inputPaths).
+				Validate()
+			if err != nil {
+				return fmt.Errorf("validate form data: %w", err)
+			}
+
+			err = FlattenStub(ctx, engine, inputPaths)
+			if err != nil {
+				return fmt.Errorf("convert PDFs: %w", err)
+			}
+
+			err = ctx.AddOutputPaths(inputPaths...)
+			if err != nil {
+				return fmt.Errorf("add output paths: %w", err)
+			}
+
+			return nil
+		},
+	}
+}
+
 // convertRoute returns an [api.Route] which can convert PDFs to a specific ODF
 // format.
 func convertRoute(engine gotenberg.PdfEngine) api.Route {
diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go
index c3f63d1..39094fa 100644
--- a/pkg/modules/pdftk/pdftk.go
+++ b/pkg/modules/pdftk/pdftk.go
@@ -99,6 +99,11 @@ func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths [
 	return fmt.Errorf("merge PDFs with PDFtk: %w", err)
 }
 
+// Flatten is not available in this implementation.
+func (engine *PdfTk) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+	return fmt.Errorf("flatten PDF with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
 // Convert is not available in this implementation.
 func (engine *PdfTk) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
 	return fmt.Errorf("convert PDF to '%+v' with PDFtk: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go
index 34785ad..760b651 100644
--- a/pkg/modules/qpdf/qpdf.go
+++ b/pkg/modules/qpdf/qpdf.go
@@ -101,6 +101,27 @@ func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []
 	return fmt.Errorf("merge PDFs with QPDF: %w", err)
 }
 
+// Flatten merges annotation appearances with page content, deleting the original annotations.
+func (engine *QPdf) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+	var args []string
+	args = append(args, "--generate-appearances")
+	args = append(args, "--flatten-annotations=all")
+	args = append(args, "--replace-input")
+	args = append(args, inputPath)
+
+	cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
+	if err != nil {
+		return fmt.Errorf("create command: %w", err)
+	}
+
+	_, err = cmd.Exec()
+	if err == nil {
+		return nil
+	}
+
+	return fmt.Errorf("flatten PDFs with QPDF: %w", err)
+}
+
 // Convert is not available in this implementation.
 func (engine *QPdf) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
 	return fmt.Errorf("convert PDF to '%+v' with QPDF: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)



Test patch:
diff --git a/pkg/gotenberg/mocks_test.go b/pkg/gotenberg/mocks_test.go
index 953a6ec..0608f2c 100644
--- a/pkg/gotenberg/mocks_test.go
+++ b/pkg/gotenberg/mocks_test.go
@@ -56,6 +56,9 @@ func TestPDFEngineMock(t *testing.T) {
 		SplitMock: func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) {
 			return nil, nil
 		},
+		FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+			return nil
+		},
 		ConvertMock: func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
 			return nil
 		},
@@ -77,6 +80,11 @@ func TestPDFEngineMock(t *testing.T) {
 		t.Errorf("expected no error from PdfEngineMock.Split, but got: %v", err)
 	}
 
+	err = mock.Flatten(context.Background(), zap.NewNop(), "")
+	if err != nil {
+		t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err)
+	}
+
 	err = mock.Convert(context.Background(), zap.NewNop(), PdfFormats{}, "", "")
 	if err != nil {
 		t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err)
diff --git a/pkg/modules/exiftool/exiftool_test.go b/pkg/modules/exiftool/exiftool_test.go
index 52c8f31..00fca5a 100644
--- a/pkg/modules/exiftool/exiftool_test.go
+++ b/pkg/modules/exiftool/exiftool_test.go
@@ -91,6 +91,15 @@ func TestExiftool_Split(t *testing.T) {
 	}
 }
 
+func TestExiftool_Flatten(t *testing.T) {
+	engine := new(ExifTool)
+	err := engine.Flatten(context.Background(), zap.NewNop(), "")
+
+	if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
+		t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
+	}
+}
+
 func TestExiftool_Convert(t *testing.T) {
 	engine := new(ExifTool)
 	err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine_test.go b/pkg/modules/libreoffice/pdfengine/pdfengine_test.go
index 1dc4ed7..d7034c2 100644
--- a/pkg/modules/libreoffice/pdfengine/pdfengine_test.go
+++ b/pkg/modules/libreoffice/pdfengine/pdfengine_test.go
@@ -127,6 +127,15 @@ func TestLibreOfficePdfEngine_Split(t *testing.T) {
 	}
 }
 
+func TestLibreOfficePdfEngine_Flatten(t *testing.T) {
+	engine := new(LibreOfficePdfEngine)
+	err := engine.Flatten(context.Background(), zap.NewNop(), "")
+
+	if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
+		t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
+	}
+}
+
 func TestLibreOfficePdfEngine_Convert(t *testing.T) {
 	for _, tc := range []struct {
 		scenario      string
diff --git a/pkg/modules/libreoffice/routes_test.go b/pkg/modules/libreoffice/routes_test.go
index 139f574..c033591 100644
--- a/pkg/modules/libreoffice/routes_test.go
+++ b/pkg/modules/libreoffice/routes_test.go
@@ -402,6 +402,38 @@ func TestConvertRoute(t *testing.T) {
 			expectHttpError:        false,
 			expectOutputPathsCount: 0,
 		},
+		{
+			scenario: "PDF engine flatten error",
+			ctx: func() *api.ContextMock {
+				ctx := &api.ContextMock{Context: new(api.Context)}
+				ctx.SetFiles(map[string]string{
+					"document.docx": "/document.docx",
+				})
+				ctx.SetValues(map[string][]string{
+					"flatten": {
+						"true",
+					},
+				})
+				ctx.SetCancelled(true)
+				return ctx
+			}(),
+			libreOffice: &libreofficeapi.ApiMock{
+				PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
+					return nil
+				},
+				ExtensionsMock: func() []string {
+					return []string{".docx"}
+				},
+			},
+			engine: &gotenberg.PdfEngineMock{
+				FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+					return errors.New("foo")
+				},
+			},
+			expectError:            true,
+			expectHttpError:        false,
+			expectOutputPathsCount: 0,
+		},
 		{
 			scenario: "cannot add output paths",
 			ctx: func() *api.ContextMock {
@@ -454,6 +486,9 @@ func TestConvertRoute(t *testing.T) {
 					"metadata": {
 						"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
 					},
+					"flatten": {
+						"true",
+					},
 				})
 				return ctx
 			}(),
@@ -475,6 +510,9 @@ func TestConvertRoute(t *testing.T) {
 				WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
 					return nil
 				},
+				FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+					return nil
+				},
 			},
 			expectError:            false,
 			expectHttpError:        false,
@@ -502,6 +540,9 @@ func TestConvertRoute(t *testing.T) {
 					"metadata": {
 						"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
 					},
+					"flatten": {
+						"true",
+					},
 				})
 				ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
 					return nil
@@ -526,6 +567,9 @@ func TestConvertRoute(t *testing.T) {
 				WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
 					return nil
 				},
+				FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+					return nil
+				},
 			},
 			expectError:            false,
 			expectHttpError:        false,
diff --git a/pkg/modules/pdfcpu/pdfcpu_test.go b/pkg/modules/pdfcpu/pdfcpu_test.go
index e996aed..063f42e 100644
--- a/pkg/modules/pdfcpu/pdfcpu_test.go
+++ b/pkg/modules/pdfcpu/pdfcpu_test.go
@@ -239,6 +239,15 @@ func TestPdfCpu_Split(t *testing.T) {
 	}
 }
 
+func TestPdfCpu_Flatten(t *testing.T) {
+	mod := new(PdfCpu)
+	err := mod.Flatten(context.TODO(), zap.NewNop(), "")
+
+	if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
+		t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
+	}
+}
+
 func TestPdfCpu_Convert(t *testing.T) {
 	mod := new(PdfCpu)
 	err := mod.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
diff --git a/pkg/modules/pdfengines/multi_test.go b/pkg/modules/pdfengines/multi_test.go
index 7dddac0..f5d5b21 100644
--- a/pkg/modules/pdfengines/multi_test.go
+++ b/pkg/modules/pdfengines/multi_test.go
@@ -194,6 +194,97 @@ func TestMultiPdfEngines_Split(t *testing.T) {
 	}
 }
 
+func TestMultiPdfEngines_Flatten(t *testing.T) {
+	for _, tc := range []struct {
+		scenario    string
+		engine      *multiPdfEngines
+		ctx         context.Context
+		expectError bool
+	}{
+		{
+			scenario: "nominal behavior",
+			engine: &multiPdfEngines{
+				flattenEngines: []gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+							return nil
+						},
+					},
+				},
+			},
+			ctx: context.Background(),
+		},
+		{
+			scenario: "at least one engine does not return an error",
+			engine: &multiPdfEngines{
+				flattenEngines: []gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+							return errors.New("foo")
+						},
+					},
+					&gotenberg.PdfEngineMock{
+						FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+							return nil
+						},
+					},
+				},
+			},
+			ctx: context.Background(),
+		},
+		{
+			scenario: "all engines return an error",
+			engine: &multiPdfEngines{
+				flattenEngines: []gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+							return errors.New("foo")
+						},
+					},
+					&gotenberg.PdfEngineMock{
+						FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+							return errors.New("foo")
+						},
+					},
+				},
+			},
+			ctx:         context.Background(),
+			expectError: true,
+		},
+		{
+			scenario: "context expired",
+			engine: &multiPdfEngines{
+				flattenEngines: []gotenberg.PdfEngine{
+					&gotenberg.PdfEngineMock{
+						FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+							return nil
+						},
+					},
+				},
+			},
+			ctx: func() context.Context {
+				ctx, cancel := context.WithCancel(context.Background())
+				cancel()
+
+				return ctx
+			}(),
+			expectError: true,
+		},
+	} {
+		t.Run(tc.scenario, func(t *testing.T) {
+			err := tc.engine.Flatten(tc.ctx, zap.NewNop(), "")
+
+			if !tc.expectError && err != nil {
+				t.Fatalf("expected no error but got: %v", err)
+			}
+
+			if tc.expectError && err == nil {
+				t.Fatal("expected error but got none")
+			}
+		})
+	}
+}
+
 func TestMultiPdfEngines_Convert(t *testing.T) {
 	for _, tc := range []struct {
 		scenario    string
diff --git a/pkg/modules/pdfengines/pdfengines_test.go b/pkg/modules/pdfengines/pdfengines_test.go
index 66546eb..b0beed3 100644
--- a/pkg/modules/pdfengines/pdfengines_test.go
+++ b/pkg/modules/pdfengines/pdfengines_test.go
@@ -27,6 +27,7 @@ func TestPdfEngines_Provision(t *testing.T) {
 		ctx                             *gotenberg.Context
 		expectedMergePdfEngines         []string
 		expectedSplitPdfEngines         []string
+		expectedFlattenPdfEngines       []string
 		expectedConvertPdfEngines       []string
 		expectedReadMetadataPdfEngines  []string
 		expectedWriteMetadataPdfEngines []string
@@ -68,6 +69,7 @@ func TestPdfEngines_Provision(t *testing.T) {
 			}(),
 			expectedMergePdfEngines:         []string{"qpdf", "pdfcpu", "pdftk"},
 			expectedSplitPdfEngines:         []string{"pdfcpu", "qpdf", "pdftk"},
+			expectedFlattenPdfEngines:       []string{"qpdf"},
 			expectedConvertPdfEngines:       []string{"libreoffice-pdfengine"},
 			expectedReadMetadataPdfEngines:  []string{"exiftool"},
 			expectedWriteMetadataPdfEngines: []string{"exiftool"},
@@ -109,7 +111,7 @@ func TestPdfEngines_Provision(t *testing.T) {
 				}
 
 				fs := new(PdfEngines).Descriptor().FlagSet
-				err := fs.Parse([]string{"--pdfengines-merge-engines=b", "--pdfengines-split-engines=a", "--pdfengines-convert-engines=b", "--pdfengines-read-metadata-engines=a", "--pdfengines-write-metadata-engines=a"})
+				err := fs.Parse([]string{"--pdfengines-merge-engines=b", "--pdfengines-split-engines=a", "--pdfengines-flatten-engines=c", "--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)
 				}
@@ -128,6 +130,7 @@ func TestPdfEngines_Provision(t *testing.T) {
 
 			expectedMergePdfEngines:         []string{"b"},
 			expectedSplitPdfEngines:         []string{"a"},
+			expectedFlattenPdfEngines:       []string{"c"},
 			expectedConvertPdfEngines:       []string{"b"},
 			expectedReadMetadataPdfEngines:  []string{"a"},
 			expectedWriteMetadataPdfEngines: []string{"a"},
@@ -185,6 +188,10 @@ func TestPdfEngines_Provision(t *testing.T) {
 				t.Fatalf("expected %d merge names but got %d", len(tc.expectedMergePdfEngines), len(mod.mergeNames))
 			}
 
+			if len(tc.expectedFlattenPdfEngines) != len(mod.flattenNames) {
+				t.Fatalf("expected %d flatten names but got %d", len(tc.expectedFlattenPdfEngines), len(mod.flattenNames))
+			}
+
 			if len(tc.expectedConvertPdfEngines) != len(mod.convertNames) {
 				t.Fatalf("expected %d convert names but got %d", len(tc.expectedConvertPdfEngines), len(mod.convertNames))
 			}
@@ -317,14 +324,16 @@ func TestPdfEngines_SystemMessages(t *testing.T) {
 	mod.readMetadataNames = []string{"foo", "bar"}
 	mod.writeMetadataNames = []string{"foo", "bar"}
 
+	expectedMessages := 6
 	messages := mod.SystemMessages()
-	if len(messages) != 5 {
-		t.Errorf("expected one and only one message, but got %d", len(messages))
+	if len(messages) != expectedMessages {
+		t.Errorf("expected %d message(s), but got %d", expectedMessages, len(messages))
 	}
 
 	expect := []string{
 		fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
 		fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames[:], " ")),
+		fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames[:], " ")),
 		fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
 		fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
 		fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")),
@@ -382,7 +391,7 @@ func TestPdfEngines_Routes(t *testing.T) {
 	}{
 		{
 			scenario:      "routes not disabled",
-			expectRoutes:  5,
+			expectRoutes:  6,
 			disableRoutes: false,
 		},
 		{
diff --git a/pkg/modules/pdfengines/routes_test.go b/pkg/modules/pdfengines/routes_test.go
index a0b004f..405b5d0 100644
--- a/pkg/modules/pdfengines/routes_test.go
+++ b/pkg/modules/pdfengines/routes_test.go
@@ -719,6 +719,39 @@ func TestMergeHandler(t *testing.T) {
 			expectHttpError:        false,
 			expectOutputPathsCount: 0,
 		},
+		{
+			scenario: "PDF engine flatten error",
+			ctx: func() *api.ContextMock {
+				ctx := &api.ContextMock{Context: new(api.Context)}
+				ctx.SetFiles(map[string]string{
+					"file.pdf":  "/file.pdf",
+					"file2.pdf": "/file2.pdf",
+				})
+				ctx.SetValues(map[string][]string{
+					"metadata": {
+						"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
+					},
+					"flatten": {
+						"true",
+					},
+				})
+				return ctx
+			}(),
+			engine: &gotenberg.PdfEngineMock{
+				MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
+					return nil
+				},
+				WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
+					return nil
+				},
+				FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+					return errors.New("foo")
+				},
+			},
+			expectError:            true,
+			expectHttpError:        false,
+			expectOutputPathsCount: 0,
+		},
 		{
 			scenario: "cannot add output paths",
 			ctx: func() *api.ContextMock {
@@ -754,6 +787,9 @@ func TestMergeHandler(t *testing.T) {
 					"metadata": {
 						"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
 					},
+					"flatten": {
+						"true",
+					},
 				})
 				return ctx
 			}(),
@@ -767,6 +803,9 @@ func TestMergeHandler(t *testing.T) {
 				WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
 					return nil
 				},
+				FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+					return nil
+				},
 			},
 			expectError:            false,
 			expectHttpError:        false,
@@ -1055,6 +1094,147 @@ func TestSplitHandler(t *testing.T) {
 	}
 }
 
+func TestFlattenHandler(t *testing.T) {
+	for _, tc := range []struct {
+		scenario               string
+		ctx                    *api.ContextMock
+		engine                 gotenberg.PdfEngine
+		expectError            bool
+		expectHttpError        bool
+		expectHttpStatus       int
+		expectOutputPathsCount int
+		expectOutputPaths      []string
+	}{
+		{
+			scenario:               "missing at least one mandatory file",
+			ctx:                    &api.ContextMock{Context: new(api.Context)},
+			expectError:            true,
+			expectHttpError:        true,
+			expectHttpStatus:       http.StatusBadRequest,
+			expectOutputPathsCount: 0,
+		},
+		{
+			scenario: "error from PDF engine",
+			ctx: func() *api.ContextMock {
+				ctx := &api.ContextMock{Context: new(api.Context)}
+				ctx.SetFiles(map[string]string{
+					"file.pdf": "/file.pdf",
+				})
+				return ctx
+			}(),
+			engine: &gotenberg.PdfEngineMock{
+				FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+					return errors.New("foo")
+				},
+			},
+			expectError:            true,
+			expectHttpError:        false,
+			expectOutputPathsCount: 0,
+		},
+		{
+			scenario: "cannot add output paths",
+			ctx: func() *api.ContextMock {
+				ctx := &api.ContextMock{Context: new(api.Context)}
+				ctx.SetFiles(map[string]string{
+					"file.pdf": "/file.pdf",
+				})
+				ctx.SetCancelled(true)
+				return ctx
+			}(),
+			engine: &gotenberg.PdfEngineMock{
+				FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+					return nil
+				},
+			},
+			expectError:            true,
+			expectHttpError:        false,
+			expectOutputPathsCount: 0,
+		},
+		{
+			scenario: "success with single file",
+			ctx: func() *api.ContextMock {
+				ctx := &api.ContextMock{Context: new(api.Context)}
+				ctx.SetFiles(map[string]string{
+					"file.pdf": "/file.pdf",
+				})
+				return ctx
+			}(),
+			engine: &gotenberg.PdfEngineMock{
+				FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+					return nil
+				},
+			},
+			expectError:            false,
+			expectHttpError:        false,
+			expectOutputPathsCount: 1,
+		},
+		{
+			scenario: "success (many files)",
+			ctx: func() *api.ContextMock {
+				ctx := &api.ContextMock{Context: new(api.Context)}
+				ctx.SetFiles(map[string]string{
+					"file.pdf":  "/file.pdf",
+					"file2.pdf": "/file2.pdf",
+				})
+				return ctx
+			}(),
+			engine: &gotenberg.PdfEngineMock{
+				FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+					return nil
+				},
+			},
+			expectError:            false,
+			expectHttpError:        false,
+			expectOutputPathsCount: 2,
+			expectOutputPaths:      []string{"/file.pdf", "/file2.pdf"},
+		},
+	} {
+		t.Run(tc.scenario, func(t *testing.T) {
+			tc.ctx.SetLogger(zap.NewNop())
+			c := echo.New().NewContext(nil, nil)
+			c.Set("context", tc.ctx.Context)
+
+			err := flattenRoute(tc.engine).Handler(c)
+
+			if tc.expectError && err == nil {
+				t.Fatal("expected error but got none", err)
+			}
+
+			if !tc.expectError && err != nil {
+				t.Fatalf("expected no error but got: %v", err)
+			}
+
+			var httpErr api.HttpError
+			isHttpError := errors.As(err, &httpErr)
+
+			if tc.expectHttpError && !isHttpError {
+				t.Errorf("expected an HTTP error but got: %v", err)
+			}
+
+			if !tc.expectHttpError && isHttpError {
+				t.Errorf("expected no HTTP error but got one: %v", httpErr)
+			}
+
+			if err != nil && tc.expectHttpError && isHttpError {
+				status, _ := httpErr.HttpError()
+				if status != tc.expectHttpStatus {
+					t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
+				}
+			}
+
+			if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
+				t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
+			}
+
+			for _, path := range tc.expectOutputPaths {
+				if !slices.Contains(tc.ctx.OutputPaths(), path) {
+					t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths())
+				}
+			}
+		})
+	}
+}
+
 func TestConvertHandler(t *testing.T) {
 	for _, tc := range []struct {
 		scenario               string
diff --git a/pkg/modules/pdftk/pdftk_test.go b/pkg/modules/pdftk/pdftk_test.go
index 73311f7..4ca0415 100644
--- a/pkg/modules/pdftk/pdftk_test.go
+++ b/pkg/modules/pdftk/pdftk_test.go
@@ -232,6 +232,15 @@ func TestPdfCpu_Split(t *testing.T) {
 	}
 }
 
+func TestPdfTk_Flatten(t *testing.T) {
+	engine := new(PdfTk)
+	err := engine.Flatten(context.TODO(), zap.NewNop(), "")
+
+	if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
+		t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
+	}
+}
+
 func TestPdfTk_Convert(t *testing.T) {
 	engine := new(PdfTk)
 	err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
diff --git a/pkg/modules/qpdf/qpdf_test.go b/pkg/modules/qpdf/qpdf_test.go
index 9c79721..b7eed39 100644
--- a/pkg/modules/qpdf/qpdf_test.go
+++ b/pkg/modules/qpdf/qpdf_test.go
@@ -3,6 +3,8 @@ package qpdf
 import (
 	"context"
 	"errors"
+	"fmt"
+	"io"
 	"os"
 	"reflect"
 	"testing"
@@ -232,6 +234,101 @@ func TestQPdf_Split(t *testing.T) {
 	}
 }
 
+func TestQPdf_Flatten(t *testing.T) {
+	for _, tc := range []struct {
+		scenario    string
+		ctx         context.Context
+		inputPath   string
+		createCopy  bool
+		expectError bool
+	}{
+		{
+			scenario:    "invalid context",
+			ctx:         nil,
+			expectError: true,
+		},
+		{
+			scenario:    "invalid input path",
+			ctx:         context.TODO(),
+			inputPath:   "foo.pdf",
+			expectError: true,
+		},
+		{
+			scenario:    "success",
+			ctx:         context.TODO(),
+			inputPath:   "/tests/test/testdata/pdfengines/sample3.pdf",
+			createCopy:  true,
+			expectError: false,
+		},
+	} {
+		t.Run(tc.scenario, func(t *testing.T) {
+			engine := new(QPdf)
+			err := engine.Provision(nil)
+			if err != nil {
+				t.Fatalf("expected error but got: %v", err)
+			}
+
+			var destinationPath string
+			if tc.createCopy {
+				fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
+				outputDir, err := fs.MkdirAll()
+				if err != nil {
+					t.Fatalf("expected error no but got: %v", err)
+				}
+
+				defer func() {
+					err = os.RemoveAll(fs.WorkingDirPath())
+					if err != nil {
+						t.Fatalf("expected no error while cleaning up but got: %v", err)
+					}
+				}()
+
+				destinationPath = fmt.Sprintf("%s/copy_temp.pdf", outputDir)
+				source, err := os.Open(tc.inputPath)
+				if err != nil {
+					t.Fatalf("open source file: %v", err)
+				}
+
+				defer func(source *os.File) {
+					err := source.Close()
+					if err != nil {
+						t.Fatalf("close file: %v", err)
+					}
+				}(source)
+
+				destination, err := os.Create(destinationPath)
+				if err != nil {
+					t.Fatalf("create destination file: %v", err)
+				}
+
+				defer func(destination *os.File) {
+					err := destination.Close()
+					if err != nil {
+						t.Fatalf("close file: %v", err)
+					}
+				}(destination)
+
+				_, err = io.Copy(destination, source)
+				if err != nil {
+					t.Fatalf("copy source into destination: %v", err)
+				}
+			} else {
+				destinationPath = tc.inputPath
+			}
+
+			err = engine.Flatten(tc.ctx, zap.NewNop(), destinationPath)
+
+			if !tc.expectError && err != nil {
+				t.Fatalf("expected no error but got: %v", err)
+			}
+
+			if tc.expectError && err == nil {
+				t.Fatal("expected error but got none")
+			}
+		})
+	}
+}
+
 func TestQPdf_Convert(t *testing.T) {
 	engine := new(QPdf)
 	err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
diff --git a/test/testdata/pdfengines/sample3.pdf b/test/testdata/pdfengines/sample3.pdf
new file mode 100644
index 0000000..d079b89
Binary files /dev/null and b/test/testdata/pdfengines/sample3.pdf differ