diff --git a/Makefile b/Makefile index 6066e4f7..0a9b212b 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 2ade8952..a771c8c3 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/mocks_test.go b/pkg/gotenberg/mocks_test.go index 953a6ec2..0608f2c2 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/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go index 788c07c7..dc4a4f7c 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 aeffc6a9..8fafc27d 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/exiftool/exiftool_test.go b/pkg/modules/exiftool/exiftool_test.go index 52c8f31d..00fca5a2 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.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go index 5416ab35..72ae3112 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/pdfengine/pdfengine_test.go b/pkg/modules/libreoffice/pdfengine/pdfengine_test.go index 1dc4ed73..d7034c2e 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.go b/pkg/modules/libreoffice/routes.go index b6786189..cf712c0e 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/libreoffice/routes_test.go b/pkg/modules/libreoffice/routes_test.go index 139f5748..c033591d 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.go b/pkg/modules/pdfcpu/pdfcpu.go index 29803cb3..2e82f879 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/pdfcpu/pdfcpu_test.go b/pkg/modules/pdfcpu/pdfcpu_test.go index e996aed8..063f42e6 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.go b/pkg/modules/pdfengines/multi.go index a01515b5..20ae2cdc 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/multi_test.go b/pkg/modules/pdfengines/multi_test.go index 7dddac05..f5d5b211 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.go b/pkg/modules/pdfengines/pdfengines.go index 4536ff7b..f029f0af 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/pdfengines_test.go b/pkg/modules/pdfengines/pdfengines_test.go index 66546ebe..b0beed3e 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.go b/pkg/modules/pdfengines/routes.go index d164bc77..d90dd65e 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/pdfengines/routes_test.go b/pkg/modules/pdfengines/routes_test.go index a0b004fb..405b5d05 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.go b/pkg/modules/pdftk/pdftk.go index c3f63d17..39094fa8 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/pdftk/pdftk_test.go b/pkg/modules/pdftk/pdftk_test.go index 73311f72..4ca0415e 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.go b/pkg/modules/qpdf/qpdf.go index 34785ade..760b6512 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) diff --git a/pkg/modules/qpdf/qpdf_test.go b/pkg/modules/qpdf/qpdf_test.go index 9c79721b..b7eed392 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 00000000..d079b892 Binary files /dev/null and b/test/testdata/pdfengines/sample3.pdf differ