From fec6437c5d738635329f3a68d3aa93d5e52fd2fe Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Wed, 4 Mar 2026 20:48:33 +0100 Subject: [PATCH] feat(pdfengines): add bookmarks read route --- Makefile | 8 +- pkg/gotenberg/mocks.go | 5 + pkg/gotenberg/pdfengine.go | 3 + pkg/modules/exiftool/exiftool.go | 5 + .../libreoffice/pdfengine/pdfengine.go | 5 + pkg/modules/pdfcpu/pdfcpu.go | 5 + pkg/modules/pdfengines/multi.go | 91 ++- pkg/modules/pdfengines/multi_test.go | 650 ------------------ pkg/modules/pdfengines/pdfengines.go | 46 +- pkg/modules/pdfengines/routes.go | 42 ++ pkg/modules/pdftk/pdftk.go | 5 + pkg/modules/qpdf/qpdf.go | 5 + 12 files changed, 175 insertions(+), 695 deletions(-) delete mode 100644 pkg/modules/pdfengines/multi_test.go diff --git a/Makefile b/Makefile index 73d9d57f..c0b69b89 100644 --- a/Makefile +++ b/Makefile @@ -57,14 +57,16 @@ LOG_LEVEL=info LOG_FORMAT=auto LOG_FIELDS_PREFIX= LOG_ENABLE_GCP_FIELDS=false +PDFENGINES_DISABLE_ROUTES=false 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 +PDFENGINES_READ_BOOKMARKS_ENGINES=pdfcpu +PDFENGINES_WRITE_BOOKMARKS_ENGINES=pdfcpu PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk -PDFENGINES_DISABLE_ROUTES=false PDFENGINES_EMBED_ENGINES=pdfcpu PROMETHEUS_NAMESPACE=gotenberg PROMETHEUS_COLLECT_INTERVAL=1s @@ -135,14 +137,16 @@ run: ## Start a Gotenberg container --log-format=$(LOG_FORMAT) \ --log-fields-prefix=$(LOG_FIELDS_PREFIX) \ --log-enable-gcp-fields=$(LOG_ENABLE_GCP_FIELDS) \ + --pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \ --pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \ --pdfengines-split-engines=$(PDFENGINES_SPLIT_ENGINES) \ --pdfengines-flatten-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) \ + --pdfengines-read-bookmarks-engines=$(PDFENGINES_READ_BOOKMARKS_ENGINES) \ + --pdfengines-write-bookmarks-engines=$(PDFENGINES_WRITE_BOOKMARKS_ENGINES) \ --pdfengines-encrypt-engines=$(PDFENGINES_ENCRYPT_ENGINES) \ - --pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \ --pdfengines-embed-engines=$(PDFENGINES_EMBED_ENGINES) \ --prometheus-namespace=$(PROMETHEUS_NAMESPACE) \ --prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \ diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go index ad9f5b1d..1df5f30f 100644 --- a/pkg/gotenberg/mocks.go +++ b/pkg/gotenberg/mocks.go @@ -52,6 +52,7 @@ type PdfEngineMock struct { 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]any, error) WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error + ReadBookmarksMock func(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error) EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error EmbedFilesMock func(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error WriteBookmarksMock func(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error @@ -81,6 +82,10 @@ func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *zap.Logg return engine.WriteMetadataMock(ctx, logger, metadata, inputPath) } +func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error) { + return engine.ReadBookmarksMock(ctx, logger, inputPath) +} + func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword) } diff --git a/pkg/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go index d99ba5b8..2cd50c0f 100644 --- a/pkg/gotenberg/pdfengine.go +++ b/pkg/gotenberg/pdfengine.go @@ -146,6 +146,9 @@ type PdfEngine interface { // WriteMetadata writes the metadata into a given PDF file. WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error + // ReadBookmarks reads the document outline (bookmarks) of a PDF file. + ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error) + // WriteBookmarks adds a document outline (bookmarks) to a PDF file. // The bookmarks parameter represents the hierarchical tree of the outline. WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go index eafe2886..bbaede3e 100644 --- a/pkg/modules/exiftool/exiftool.go +++ b/pkg/modules/exiftool/exiftool.go @@ -208,6 +208,11 @@ func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *zap.Logger, return fmt.Errorf("write PDF bookmarks with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// ReadBookmarks is not available in this implementation. +func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) { + return nil, fmt.Errorf("read PDF bookmarks with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Encrypt is not available in this implementation. func (engine *ExifTool) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { return fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported) diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go index 44a6f0ae..52aecb5e 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go @@ -96,6 +96,11 @@ func (engine *LibreOfficePdfEngine) WriteBookmarks(ctx context.Context, logger * return fmt.Errorf("write PDF bookmarks with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// ReadBookmarks is not available in this implementation. +func (engine *LibreOfficePdfEngine) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) { + return nil, fmt.Errorf("read PDF bookmarks with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Encrypt is not available in this implementation. func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go index 1a0ee983..4d91c005 100644 --- a/pkg/modules/pdfcpu/pdfcpu.go +++ b/pkg/modules/pdfcpu/pdfcpu.go @@ -172,6 +172,11 @@ func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, met return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// ReadBookmarks is not available in this implementation. +func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) { + return nil, fmt.Errorf("read PDF bookmarks with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // WriteBookmarks adds a document outline (bookmarks) to a PDF file using pdfcpu. func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { if len(bookmarks) == 0 { diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go index 8567f2f2..e2ee07f4 100644 --- a/pkg/modules/pdfengines/multi.go +++ b/pkg/modules/pdfengines/multi.go @@ -21,6 +21,7 @@ type multiPdfEngines struct { passwordEngines []gotenberg.PdfEngine embedEngines []gotenberg.PdfEngine bookmarksEngines []gotenberg.PdfEngine + readBookmarksEngines []gotenberg.PdfEngine } func newMultiPdfEngines( @@ -32,7 +33,8 @@ func newMultiPdfEngines( writeMetadataEngines, passwordEngines, embedEngines, - bookmarksEngines []gotenberg.PdfEngine, + bookmarksEngines, + readBookmarksEngines []gotenberg.PdfEngine, ) *multiPdfEngines { return &multiPdfEngines{ mergeEngines: mergeEngines, @@ -44,6 +46,7 @@ func newMultiPdfEngines( passwordEngines: passwordEngines, embedEngines: embedEngines, bookmarksEngines: bookmarksEngines, + readBookmarksEngines: readBookmarksEngines, } } @@ -219,6 +222,67 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log return fmt.Errorf("write PDF metadata with multi PDF engines: %w", err) } +type readBookmarksResult struct { + bookmarks []gotenberg.Bookmark + err error +} + +// ReadBookmarks reads bookmarks from a PDF file using the first available +// engine that supports bookmarks reading. +func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) { + var err error + var mu sync.Mutex // to safely append errors. + + for _, engine := range multi.readBookmarksEngines { + resultChan := make(chan readBookmarksResult, 1) + + go func(engine gotenberg.PdfEngine) { + bookmarks, err := engine.ReadBookmarks(ctx, logger, inputPath) + resultChan <- readBookmarksResult{bookmarks: bookmarks, err: err} + }(engine) + + select { + case result := <-resultChan: + if result.err != nil { + mu.Lock() + err = multierr.Append(err, result.err) + mu.Unlock() + } else { + return result.bookmarks, nil + } + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + return nil, fmt.Errorf("read PDF bookmarks with multi PDF engines: %w", err) +} + +// WriteBookmarks adds a document outline (bookmarks) to a PDF file using the +// first available engine that supports bookmarks writing. +func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { + var err error + errChan := make(chan error, 1) + + for _, engine := range multi.bookmarksEngines { + go func(engine gotenberg.PdfEngine) { + errChan <- engine.WriteBookmarks(ctx, logger, inputPath, bookmarks) + }(engine) + + select { + case writeBookmarksErr := <-errChan: + errored := multierr.AppendInto(&err, writeBookmarksErr) + if !errored { + return nil + } + case <-ctx.Done(): + return ctx.Err() + } + } + + return fmt.Errorf("write PDF bookmarks with multi PDF engines: %w", err) +} + // Encrypt adds password protection to a PDF file using the first available // engine that supports password protection. func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { @@ -269,31 +333,6 @@ func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger return fmt.Errorf("embed files into PDF using multi PDF engines: %w", err) } -// WriteBookmarks adds a document outline (bookmarks) to a PDF file using the -// first available engine that supports metadata writing. -func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { - var err error - errChan := make(chan error, 1) - - for _, engine := range multi.bookmarksEngines { - go func(engine gotenberg.PdfEngine) { - errChan <- engine.WriteBookmarks(ctx, logger, inputPath, bookmarks) - }(engine) - - select { - case writeBookmarksErr := <-errChan: - errored := multierr.AppendInto(&err, writeBookmarksErr) - if !errored { - return nil - } - case <-ctx.Done(): - return ctx.Err() - } - } - - return fmt.Errorf("write PDF bookmarks with multi PDF engines: %w", err) -} - // Interface guards. var ( _ gotenberg.PdfEngine = (*multiPdfEngines)(nil) diff --git a/pkg/modules/pdfengines/multi_test.go b/pkg/modules/pdfengines/multi_test.go deleted file mode 100644 index 61836ef4..00000000 --- a/pkg/modules/pdfengines/multi_test.go +++ /dev/null @@ -1,650 +0,0 @@ -package pdfengines - -import ( - "context" - "errors" - "testing" - - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" -) - -func TestMultiPdfEngines_Merge(t *testing.T) { - for _, tc := range []struct { - scenario string - engine *multiPdfEngines - ctx context.Context - expectError bool - }{ - { - scenario: "nominal behavior", - engine: &multiPdfEngines{ - mergeEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return nil - }, - }, - }, - }, - ctx: context.Background(), - expectError: false, - }, - { - scenario: "at least one engine does not return an error", - engine: &multiPdfEngines{ - mergeEngines: []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 - }, - }, - }, - }, - ctx: context.Background(), - expectError: false, - }, - { - scenario: "all engines return an error", - engine: &multiPdfEngines{ - mergeEngines: []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") - }, - }, - }, - }, - ctx: context.Background(), - expectError: true, - }, - { - scenario: "context expired", - engine: &multiPdfEngines{ - mergeEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath 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.Merge(tc.ctx, zap.NewNop(), nil, "") - - 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_Encrypt(t *testing.T) { - for _, tc := range []struct { - scenario string - engine *multiPdfEngines - ctx context.Context - expectError bool - }{ - { - scenario: "nominal behavior", - engine: &multiPdfEngines{ - passwordEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { - return nil - }, - }, - }, - }, - ctx: context.Background(), - }, - { - scenario: "at least one engine does not return an error", - engine: &multiPdfEngines{ - passwordEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { - return errors.New("foo") - }, - }, - &gotenberg.PdfEngineMock{ - EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { - return nil - }, - }, - }, - }, - ctx: context.Background(), - }, - { - scenario: "all engines return an error", - engine: &multiPdfEngines{ - passwordEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { - return errors.New("foo") - }, - }, - &gotenberg.PdfEngineMock{ - EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { - return errors.New("foo") - }, - }, - }, - }, - ctx: context.Background(), - expectError: true, - }, - { - scenario: "context expired", - engine: &multiPdfEngines{ - passwordEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword 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.Encrypt(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_Split(t *testing.T) { - for _, tc := range []struct { - scenario string - engine *multiPdfEngines - ctx context.Context - expectError bool - }{ - { - scenario: "nominal behavior", - engine: &multiPdfEngines{ - splitEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { - return nil, nil - }, - }, - }, - }, - ctx: context.Background(), - }, - { - scenario: "at least one engine does not return an error", - engine: &multiPdfEngines{ - splitEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { - return nil, errors.New("foo") - }, - }, - &gotenberg.PdfEngineMock{ - SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { - return nil, nil - }, - }, - }, - }, - ctx: context.Background(), - }, - { - scenario: "all engines return an error", - engine: &multiPdfEngines{ - splitEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { - return nil, errors.New("foo") - }, - }, - &gotenberg.PdfEngineMock{ - SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { - return nil, errors.New("foo") - }, - }, - }, - }, - ctx: context.Background(), - expectError: true, - }, - { - scenario: "context expired", - engine: &multiPdfEngines{ - splitEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { - return nil, 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.Split(tc.ctx, zap.NewNop(), gotenberg.SplitMode{}, "", "") - - 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_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 - engine *multiPdfEngines - ctx context.Context - expectError bool - }{ - { - scenario: "nominal behavior", - engine: &multiPdfEngines{ - convertEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return nil - }, - }, - }, - }, - ctx: context.Background(), - }, - { - scenario: "at least one engine does not return an error", - engine: &multiPdfEngines{ - convertEngines: []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 - }, - }, - }, - }, - ctx: context.Background(), - }, - { - scenario: "all engines return an error", - engine: &multiPdfEngines{ - convertEngines: []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") - }, - }, - }, - }, - ctx: context.Background(), - expectError: true, - }, - { - scenario: "context expired", - engine: &multiPdfEngines{ - convertEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath 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.Convert(tc.ctx, zap.NewNop(), gotenberg.PdfFormats{}, "", "") - - 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_ReadMetadata(t *testing.T) { - for _, tc := range []struct { - scenario string - engine *multiPdfEngines - ctx context.Context - expectError bool - }{ - { - scenario: "nominal behavior", - engine: &multiPdfEngines{ - readMetadataEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { - return make(map[string]any), nil - }, - }, - }, - }, - ctx: context.Background(), - }, - { - scenario: "at least one engine does not return an error", - engine: &multiPdfEngines{ - readMetadataEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { - return nil, errors.New("foo") - }, - }, - &gotenberg.PdfEngineMock{ - ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { - return make(map[string]any), nil - }, - }, - }, - }, - ctx: context.Background(), - }, - { - scenario: "all engines return an error", - engine: &multiPdfEngines{ - readMetadataEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { - return nil, errors.New("foo") - }, - }, - &gotenberg.PdfEngineMock{ - ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { - return nil, errors.New("foo") - }, - }, - }, - }, - ctx: context.Background(), - expectError: true, - }, - { - scenario: "context expired", - engine: &multiPdfEngines{ - readMetadataEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { - return make(map[string]any), 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.ReadMetadata(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_WriteMetadata(t *testing.T) { - for _, tc := range []struct { - scenario string - engine *multiPdfEngines - ctx context.Context - expectError bool - }{ - { - scenario: "nominal behavior", - engine: &multiPdfEngines{ - writeMetadataEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { - return nil - }, - }, - }, - }, - ctx: context.Background(), - }, - { - scenario: "at least one engine does not return an error", - engine: &multiPdfEngines{ - writeMetadataEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { - return errors.New("foo") - }, - }, - &gotenberg.PdfEngineMock{ - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { - return nil - }, - }, - }, - }, - ctx: context.Background(), - }, - { - scenario: "all engines return an error", - engine: &multiPdfEngines{ - writeMetadataEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { - return errors.New("foo") - }, - }, - &gotenberg.PdfEngineMock{ - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { - return errors.New("foo") - }, - }, - }, - }, - ctx: context.Background(), - expectError: true, - }, - { - scenario: "context expired", - engine: &multiPdfEngines{ - writeMetadataEngines: []gotenberg.PdfEngine{ - &gotenberg.PdfEngineMock{ - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]any, 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.WriteMetadata(tc.ctx, zap.NewNop(), nil, "") - - 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") - } - }) - } -} diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go index 54654696..f42cb5ec 100644 --- a/pkg/modules/pdfengines/pdfengines.go +++ b/pkg/modules/pdfengines/pdfengines.go @@ -28,17 +28,18 @@ func init() { // the [api.Router] interface to expose relevant PDF processing routes if // enabled. type PdfEngines struct { - mergeNames []string - splitNames []string - flattenNames []string - convertNames []string - readMetadataNames []string - writeMetadataNames []string - encryptNames []string - embedNames []string - bookmarksNames []string - engines []gotenberg.PdfEngine - disableRoutes bool + mergeNames []string + splitNames []string + flattenNames []string + convertNames []string + readMetadataNames []string + writeMetadataNames []string + encryptNames []string + embedNames []string + writeBookmarksNames []string + readBookmarksNames []string + engines []gotenberg.PdfEngine + disableRoutes bool } // Descriptor returns a PdfEngines' module descriptor. @@ -55,7 +56,8 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor { fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all") fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all") fs.StringSlice("pdfengines-embed-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the file embedding feature - empty means all") - fs.StringSlice("pdfengines-bookmarks-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the bookmarks feature - empty means all") + fs.StringSlice("pdfengines-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the bookmarks feature - empty means all") + fs.StringSlice("pdfengines-read-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the read bookmarks feature - empty means all") fs.Bool("pdfengines-disable-routes", false, "Disable the routes") // Deprecated flags. @@ -84,6 +86,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines") embedNames := flags.MustStringSlice("pdfengines-embed-engines") bookmarksNames := flags.MustStringSlice("pdfengines-bookmarks-engines") + readBookmarksNames := flags.MustStringSlice("pdfengines-read-bookmarks-engines") mod.disableRoutes = flags.MustBool("pdfengines-disable-routes") engines, err := ctx.Modules(new(gotenberg.PdfEngine)) @@ -150,9 +153,14 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { mod.embedNames = embedNames } - mod.bookmarksNames = defaultNames + mod.writeBookmarksNames = defaultNames if len(bookmarksNames) > 0 { - mod.bookmarksNames = bookmarksNames + mod.writeBookmarksNames = bookmarksNames + } + + mod.readBookmarksNames = defaultNames + if len(readBookmarksNames) > 0 { + mod.readBookmarksNames = readBookmarksNames } return nil @@ -204,7 +212,8 @@ func (mod *PdfEngines) Validate() error { findNonExistingEngines(mod.writeMetadataNames) findNonExistingEngines(mod.encryptNames) findNonExistingEngines(mod.embedNames) - findNonExistingEngines(mod.bookmarksNames) + findNonExistingEngines(mod.writeBookmarksNames) + findNonExistingEngines(mod.readBookmarksNames) if len(nonExistingEngines) == 0 { return nil @@ -225,7 +234,8 @@ func (mod *PdfEngines) SystemMessages() []string { fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")), fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames[:], " ")), fmt.Sprintf("embed engines - %s", strings.Join(mod.embedNames[:], " ")), - fmt.Sprintf("bookmarks engines - %s", strings.Join(mod.bookmarksNames[:], " ")), + fmt.Sprintf("write bookmarks engines - %s", strings.Join(mod.writeBookmarksNames[:], " ")), + fmt.Sprintf("read bookmarks engines - %s", strings.Join(mod.readBookmarksNames[:], " ")), } } @@ -254,7 +264,8 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) { engines(mod.writeMetadataNames), engines(mod.encryptNames), engines(mod.embedNames), - engines(mod.bookmarksNames), + engines(mod.writeBookmarksNames), + engines(mod.readBookmarksNames), ), nil } @@ -278,6 +289,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) { convertRoute(engine), readMetadataRoute(engine), writeMetadataRoute(engine), + readBookmarksRoute(engine), writeBookmarksRoute(engine), encryptRoute(engine), embedRoute(engine), diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index 6052792e..f2450323 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -708,6 +708,48 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route { } } +// readBookmarksRoute returns an [api.Route] which returns the bookmarks of PDFs. +func readBookmarksRoute(engine gotenberg.PdfEngine) api.Route { + return api.Route{ + Method: http.MethodPost, + Path: "/forms/pdfengines/bookmarks/read", + IsMultipart: true, + Handler: func(c echo.Context) error { + ctx := c.Get("context").(*api.Context) + + var inputPaths []string + err := ctx.FormData(). + MandatoryPaths([]string{".pdf"}, &inputPaths). + Validate() + if err != nil { + return fmt.Errorf("validate form data: %w", err) + } + + res := make(map[string][]gotenberg.Bookmark, len(inputPaths)) + for _, inputPath := range inputPaths { + bookmarks, err := engine.ReadBookmarks(ctx, ctx.Log(), inputPath) + if err != nil { + return fmt.Errorf("read bookmarks: %w", err) + } + + res[filepath.Base(inputPath)] = bookmarks + } + + err = c.JSON(http.StatusOK, res) + if err != nil { + if strings.Contains(err.Error(), "request method or response status code does not allow body") { + // High probability that the user is using the webhook + // feature. It does not make sense for this route. + return api.ErrNoOutputFile + } + return fmt.Errorf("return JSON response: %w", err) + } + + return api.ErrNoOutputFile + }, + } +} + // writeBookmarksRoute returns an [api.Route] which can write bookmarks into PDFs. func writeBookmarksRoute(engine gotenberg.PdfEngine) api.Route { return api.Route{ diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go index 81f995fe..65298385 100644 --- a/pkg/modules/pdftk/pdftk.go +++ b/pkg/modules/pdftk/pdftk.go @@ -150,6 +150,11 @@ func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *zap.Logger, inp return fmt.Errorf("write PDF bookmarks with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// ReadBookmarks is not available in this implementation. +func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) { + return nil, fmt.Errorf("read PDF bookmarks with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Encrypt adds password protection to a PDF file using PDFtk. func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { if userPassword == "" { diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index 440cca60..70b79be3 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -177,6 +177,11 @@ func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *zap.Logger, inpu return fmt.Errorf("write PDF bookmarks with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// ReadBookmarks is not available in this implementation. +func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) { + return nil, fmt.Errorf("read PDF bookmarks with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Encrypt adds password protection to a PDF file using QPDF. func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { if userPassword == "" {