From c30da805b3af537111dd1accd8f027b243f61b46 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Fri, 20 Dec 2024 15:51:57 +0100 Subject: [PATCH] feat(pdfengines): add split feature --- Makefile | 2 + pkg/gotenberg/fs.go | 67 ++- pkg/gotenberg/fs_test.go | 197 +++++- pkg/gotenberg/mocks.go | 17 + pkg/gotenberg/mocks_test.go | 22 + pkg/gotenberg/pdfengine.go | 27 + pkg/modules/api/api.go | 2 +- pkg/modules/api/api_test.go | 2 +- pkg/modules/api/context.go | 27 +- pkg/modules/api/context_test.go | 97 ++- pkg/modules/api/middlewares.go | 4 + pkg/modules/api/middlewares_test.go | 7 +- pkg/modules/api/mocks.go | 8 + pkg/modules/api/mocks_test.go | 17 +- pkg/modules/chromium/browser.go | 2 +- pkg/modules/chromium/browser_test.go | 122 ++-- pkg/modules/chromium/routes.go | 41 +- pkg/modules/chromium/routes_test.go | 60 +- pkg/modules/exiftool/exiftool.go | 5 + pkg/modules/exiftool/exiftool_test.go | 11 +- pkg/modules/libreoffice/api/libreoffice.go | 2 +- .../libreoffice/api/libreoffice_test.go | 26 +- .../libreoffice/pdfengine/pdfengine.go | 5 + .../libreoffice/pdfengine/pdfengine_test.go | 25 +- pkg/modules/libreoffice/routes.go | 48 +- pkg/modules/libreoffice/routes_test.go | 227 ++++++- pkg/modules/pdfcpu/doc.go | 1 + pkg/modules/pdfcpu/pdfcpu.go | 33 + pkg/modules/pdfcpu/pdfcpu_test.go | 91 ++- pkg/modules/pdfengines/multi.go | 41 ++ pkg/modules/pdfengines/multi_test.go | 235 +++++--- pkg/modules/pdfengines/pdfengines.go | 12 + pkg/modules/pdfengines/pdfengines_test.go | 18 +- pkg/modules/pdfengines/routes.go | 205 ++++++- pkg/modules/pdfengines/routes_test.go | 569 +++++++++++++++++- pkg/modules/pdftk/doc.go | 1 + pkg/modules/pdftk/pdftk.go | 26 + pkg/modules/pdftk/pdftk_test.go | 84 ++- pkg/modules/qpdf/doc.go | 1 + pkg/modules/qpdf/qpdf.go | 28 +- pkg/modules/qpdf/qpdf_test.go | 84 ++- 41 files changed, 2153 insertions(+), 346 deletions(-) diff --git a/Makefile b/Makefile index def2bb19..b8f0f10e 100644 --- a/Makefile +++ b/Makefile @@ -73,6 +73,7 @@ LOG_FORMAT=auto LOG_FIELDS_PREFIX= PDFENGINES_ENGINES= PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk +PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine PDFENGINES_READ_METADATA_ENGINES=exiftool PDFENGINES_WRITE_METADATA_ENGINES=exiftool @@ -141,6 +142,7 @@ run: ## Start a Gotenberg container --log-fields-prefix=$(LOG_FIELDS_PREFIX) \ --pdfengines-engines=$(PDFENGINES_ENGINES) \ --pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \ + --pdfengines-split-engines=$(PDFENGINES_SPLIT_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/fs.go b/pkg/gotenberg/fs.go index 8c2d0a98..99b403eb 100644 --- a/pkg/gotenberg/fs.go +++ b/pkg/gotenberg/fs.go @@ -3,22 +3,56 @@ package gotenberg import ( "fmt" "os" + "path/filepath" + "strings" "github.com/google/uuid" ) +// MkdirAll defines the method signature for create a directory. Implement this +// interface if you don't want to rely on [os.MkdirAll], notably for testing +// purpose. +type MkdirAll interface { + // MkdirAll uses the same signature as [os.MkdirAll]. + MkdirAll(path string, perm os.FileMode) error +} + +// OsMkdirAll implements the [MkdirAll] interface with [os.MkdirAll]. +type OsMkdirAll struct{} + +// MkdirAll is a wrapper around [os.MkdirAll]. +func (o *OsMkdirAll) MkdirAll(path string, perm os.FileMode) error { return os.MkdirAll(path, perm) } + +// PathRename defines the method signature for renaming files. Implement this +// interface if you don't want to rely on [os.Rename], notably for testing +// purpose. +type PathRename interface { + // Rename uses the same signature as [os.Rename]. + Rename(oldpath, newpath string) error +} + +// OsPathRename implements the [PathRename] interface with [os.Rename]. +type OsPathRename struct{} + +// Rename is a wrapper around [os.Rename]. +func (o *OsPathRename) Rename(oldpath, newpath string) error { + return os.Rename(oldpath, newpath) +} + // FileSystem provides utilities for managing temporary directories. It creates // unique directory names based on UUIDs to ensure isolation of temporary files // for different modules. type FileSystem struct { workingDir string + mkdirAll MkdirAll } // NewFileSystem initializes a new [FileSystem] instance with a unique working // directory. -func NewFileSystem() *FileSystem { +func NewFileSystem(mkdirAll MkdirAll) *FileSystem { return &FileSystem{ workingDir: uuid.NewString(), + mkdirAll: mkdirAll, } } @@ -44,7 +78,7 @@ func (fs *FileSystem) NewDirPath() string { func (fs *FileSystem) MkdirAll() (string, error) { path := fs.NewDirPath() - err := os.MkdirAll(path, 0o755) + err := fs.mkdirAll.MkdirAll(path, 0o755) if err != nil { return "", fmt.Errorf("create directory %s: %w", path, err) } @@ -52,10 +86,27 @@ func (fs *FileSystem) MkdirAll() (string, error) { return path, nil } -// PathRename defines the method signature for renaming files. Implement this -// interface if you don't want to rely on [os.Rename], notably for testing -// purpose. -type PathRename interface { - // Rename uses the same signature as [os.Rename]. - Rename(oldpath, newpath string) error +// WalkDir walks through the root level of a directory and returns a list of +// files paths that match the specified file extension. +func WalkDir(dir, ext string) ([]string, error) { + var files []string + err := filepath.Walk(dir, func(path string, info os.FileInfo, pathErr error) error { + if pathErr != nil { + return pathErr + } + if info.IsDir() { + return nil + } + if strings.EqualFold(filepath.Ext(info.Name()), ext) { + files = append(files, path) + } + return nil + }) + return files, err } + +// Interface guards. +var ( + _ MkdirAll = (*OsMkdirAll)(nil) + _ PathRename = (*OsPathRename)(nil) +) diff --git a/pkg/gotenberg/fs_test.go b/pkg/gotenberg/fs_test.go index f074acb6..d7f64120 100644 --- a/pkg/gotenberg/fs_test.go +++ b/pkg/gotenberg/fs_test.go @@ -1,14 +1,84 @@ package gotenberg import ( + "errors" "fmt" + "io" "os" + "path/filepath" + "reflect" "strings" "testing" + + "github.com/google/uuid" ) +func TestOsMkdirAll_MkdirAll(t *testing.T) { + dirPath, err := NewFileSystem(new(OsMkdirAll)).MkdirAll() + if err != nil { + t.Fatalf("create working directory: %v", err) + } + + err = os.RemoveAll(dirPath) + if err != nil { + t.Fatalf("remove working directory: %v", err) + } +} + +func TestOsPathRename_Rename(t *testing.T) { + dirPath, err := NewFileSystem(new(OsMkdirAll)).MkdirAll() + if err != nil { + t.Fatalf("create working directory: %v", err) + } + + path := "/tests/test/testdata/api/sample1.txt" + copyPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString())) + + in, err := os.Open(path) + if err != nil { + t.Fatalf("open file: %v", err) + } + + defer func() { + err := in.Close() + if err != nil { + t.Fatalf("close file: %v", err) + } + }() + + out, err := os.Create(copyPath) + if err != nil { + t.Fatalf("create new file: %v", err) + } + + defer func() { + err := out.Close() + if err != nil { + t.Fatalf("close new file: %v", err) + } + }() + + _, err = io.Copy(out, in) + if err != nil { + t.Fatalf("copy file to new file: %v", err) + } + + rename := new(OsPathRename) + newPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString())) + + err = rename.Rename(copyPath, newPath) + if err != nil { + t.Errorf("expected no error but got: %v", err) + } + + err = os.RemoveAll(dirPath) + if err != nil { + t.Fatalf("remove working directory: %v", err) + } +} + func TestFileSystem_WorkingDir(t *testing.T) { - fs := NewFileSystem() + fs := NewFileSystem(new(MkdirAllMock)) dirName := fs.WorkingDir() if dirName == "" { @@ -17,7 +87,7 @@ func TestFileSystem_WorkingDir(t *testing.T) { } func TestFileSystem_WorkingDirPath(t *testing.T) { - fs := NewFileSystem() + fs := NewFileSystem(new(MkdirAllMock)) expectedPath := fmt.Sprintf("%s/%s", os.TempDir(), fs.WorkingDir()) if fs.WorkingDirPath() != expectedPath { @@ -26,7 +96,7 @@ func TestFileSystem_WorkingDirPath(t *testing.T) { } func TestFileSystem_NewDirPath(t *testing.T) { - fs := NewFileSystem() + fs := NewFileSystem(new(MkdirAllMock)) newDir := fs.NewDirPath() expectedPrefix := fs.WorkingDirPath() @@ -36,20 +106,117 @@ func TestFileSystem_NewDirPath(t *testing.T) { } func TestFileSystem_MkdirAll(t *testing.T) { - fs := NewFileSystem() + for _, tc := range []struct { + scenario string + mkdirAll MkdirAll + expectError bool + }{ + { + scenario: "error", + mkdirAll: &MkdirAllMock{ + MkdirAllMock: func(path string, perm os.FileMode) error { + return errors.New("foo") + }, + }, + expectError: true, + }, + { + scenario: "success", + mkdirAll: &MkdirAllMock{ + MkdirAllMock: func(path string, perm os.FileMode) error { + return nil + }, + }, + expectError: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + fs := NewFileSystem(tc.mkdirAll) - newPath, err := fs.MkdirAll() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } + _, err := fs.MkdirAll() - _, err = os.Stat(newPath) - if os.IsNotExist(err) { - t.Errorf("expected directory '%s' to exist but it doesn't", newPath) - } + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } - err = os.RemoveAll(fs.WorkingDirPath()) - if err != nil { - t.Fatalf("expected no error while cleaning up but got: %v", err) + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} + +func TestWalkDir(t *testing.T) { + for _, tc := range []struct { + scenario string + dir string + ext string + expectError bool + expectFiles []string + }{ + { + scenario: "directory does not exist", + dir: uuid.NewString(), + ext: ".pdf", + expectError: true, + }, + { + scenario: "find PDF files", + dir: func() string { + path := fmt.Sprintf("%s/a_directory", os.TempDir()) + + err := os.MkdirAll(path, 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/a_foo_file.pdf", path), []byte{1}, 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + err = os.WriteFile(fmt.Sprintf("%s/a_bar_file.PDF", path), []byte{1}, 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + err = os.WriteFile(fmt.Sprintf("%s/a_baz_file.txt", path), []byte{1}, 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return path + }(), + ext: ".pdf", + expectError: false, + expectFiles: []string{"/tmp/a_directory/a_bar_file.PDF", "/tmp/a_directory/a_foo_file.pdf"}, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + defer func() { + err := os.RemoveAll(tc.dir) + if err != nil { + t.Fatalf("expected no error while cleaning up but got: %v", err) + } + }() + + files, err := WalkDir(tc.dir, tc.ext) + + 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") + } + + if tc.expectError && err != nil { + return + } + + if !reflect.DeepEqual(files, tc.expectFiles) { + t.Errorf("expected files %+v, but got %+v", tc.expectFiles, files) + } + }) } } diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go index 49154c32..2ade8952 100644 --- a/pkg/gotenberg/mocks.go +++ b/pkg/gotenberg/mocks.go @@ -2,6 +2,7 @@ package gotenberg import ( "context" + "os" "go.uber.org/zap" ) @@ -36,6 +37,7 @@ func (mod *ValidatorMock) Validate() error { // PdfEngineMock is a mock for the [PdfEngine] interface. 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) 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 @@ -45,6 +47,10 @@ func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inpu return engine.MergeMock(ctx, logger, inputPaths, outputPath) } +func (engine *PdfEngineMock) Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) { + return engine.SplitMock(ctx, logger, mode, inputPath, outputDirPath) +} + func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error { return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath) } @@ -137,6 +143,15 @@ func (provider *MetricsProviderMock) Metrics() ([]Metric, error) { return provider.MetricsMock() } +// MkdirAllMock is a mock for the [MkdirAll] interface. +type MkdirAllMock struct { + MkdirAllMock func(path string, perm os.FileMode) error +} + +func (mkdirAll *MkdirAllMock) MkdirAll(path string, perm os.FileMode) error { + return mkdirAll.MkdirAllMock(path, perm) +} + // PathRenameMock is a mock for the [PathRename] interface. type PathRenameMock struct { RenameMock func(oldpath, newpath string) error @@ -156,4 +171,6 @@ var ( _ ProcessSupervisor = (*ProcessSupervisorMock)(nil) _ LoggerProvider = (*LoggerProviderMock)(nil) _ MetricsProvider = (*MetricsProviderMock)(nil) + _ MkdirAll = (*MkdirAllMock)(nil) + _ PathRename = (*PathRenameMock)(nil) ) diff --git a/pkg/gotenberg/mocks_test.go b/pkg/gotenberg/mocks_test.go index 1be6c658..953a6ec2 100644 --- a/pkg/gotenberg/mocks_test.go +++ b/pkg/gotenberg/mocks_test.go @@ -2,6 +2,7 @@ package gotenberg import ( "context" + "os" "testing" "go.uber.org/zap" @@ -52,6 +53,9 @@ func TestPDFEngineMock(t *testing.T) { MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { return nil }, + SplitMock: func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) { + return nil, nil + }, ConvertMock: func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error { return nil }, @@ -68,6 +72,11 @@ func TestPDFEngineMock(t *testing.T) { t.Errorf("expected no error from PdfEngineMock.Merge, but got: %v", err) } + _, err = mock.Split(context.Background(), zap.NewNop(), SplitMode{}, "", "") + if err != nil { + t.Errorf("expected no error from PdfEngineMock.Split, 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) @@ -205,6 +214,19 @@ func TestMetricsProviderMock(t *testing.T) { } } +func TestMkdirAllMock(t *testing.T) { + mock := &MkdirAllMock{ + MkdirAllMock: func(dir string, perm os.FileMode) error { + return nil + }, + } + + err := mock.MkdirAll("/foo", 0o755) + if err != nil { + t.Errorf("expected no error from MkdirAllMock.MkdirAll, but got: %v", err) + } +} + func TestPathRenameMock(t *testing.T) { mock := &PathRenameMock{ RenameMock: func(oldpath, newpath string) error { diff --git a/pkg/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go index 87c32c15..bc74f09f 100644 --- a/pkg/gotenberg/pdfengine.go +++ b/pkg/gotenberg/pdfengine.go @@ -12,6 +12,10 @@ var ( // PdfEngine interface is not supported by its current implementation. ErrPdfEngineMethodNotSupported = errors.New("method not supported") + // ErrPdfSplitModeNotSupported is returned when the Split method of the + // PdfEngine interface does not sumport a requested PDF split mode. + ErrPdfSplitModeNotSupported = errors.New("split mode not supported") + // ErrPdfFormatNotSupported is returned when the Convert method of the // PdfEngine interface does not support a requested PDF format conversion. ErrPdfFormatNotSupported = errors.New("PDF format not supported") @@ -21,6 +25,26 @@ var ( ErrPdfEngineMetadataValueNotSupported = errors.New("metadata value not supported") ) +const ( + // SplitModeIntervals represents a mode where a PDF is split at specific + // intervals. + SplitModeIntervals string = "intervals" + + // SplitModePages represents a mode where a PDF is split at specific page + // ranges. + SplitModePages string = "pages" +) + +// SplitMode gathers the data required to split a PDF into multiple parts. +type SplitMode struct { + // Mode is either "intervals" or "pages". + Mode string + + // Span is either the intervals or the page ranges to extract, depending on + // the selected mode. + Span string +} + const ( // PdfA1a represents the PDF/A-1a format. PdfA1a string = "PDF/A-1a" @@ -65,6 +89,9 @@ type PdfEngine interface { // is determined by the order of files provided in inputPaths. Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error + // Split splits a given PDF file. + Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]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/api/api.go b/pkg/modules/api/api.go index 3d6cb459..7daa1892 100644 --- a/pkg/modules/api/api.go +++ b/pkg/modules/api/api.go @@ -318,7 +318,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error { a.logger = logger // File system. - a.fs = gotenberg.NewFileSystem() + a.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) return nil } diff --git a/pkg/modules/api/api_test.go b/pkg/modules/api/api_test.go index 885076af..b32eace8 100644 --- a/pkg/modules/api/api_test.go +++ b/pkg/modules/api/api_test.go @@ -850,7 +850,7 @@ func TestApi_Start(t *testing.T) { }, } mod.readyFn = tc.readyFn - mod.fs = gotenberg.NewFileSystem() + mod.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) mod.logger = zap.NewNop() err := mod.Start() diff --git a/pkg/modules/api/context.go b/pkg/modules/api/context.go index 7d895810..d761248b 100644 --- a/pkg/modules/api/context.go +++ b/pkg/modules/api/context.go @@ -47,6 +47,7 @@ type Context struct { logger *zap.Logger echoCtx echo.Context + mkdirAll gotenberg.MkdirAll pathRename gotenberg.PathRename context.Context } @@ -81,12 +82,6 @@ type downloadFrom struct { ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"` } -type osPathRename struct{} - -func (o *osPathRename) Rename(oldpath, newpath string) error { - return os.Rename(oldpath, newpath) -} - // newContext returns a [Context] by parsing a "multipart/form-data" request. func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) { processCtx, processCancel := context.WithTimeout(context.Background(), timeout) @@ -112,7 +107,8 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst cancelled: false, logger: logger, echoCtx: echoCtx, - pathRename: new(osPathRename), + mkdirAll: new(gotenberg.OsMkdirAll), + pathRename: new(gotenberg.OsPathRename), Context: processCtx, } @@ -414,9 +410,21 @@ func (ctx *Context) GeneratePath(extension string) string { return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New().String(), extension) } +// CreateSubDirectory creates a subdirectory within the context's working +// directory. +func (ctx *Context) CreateSubDirectory(dirName string) (string, error) { + path := fmt.Sprintf("%s/%s", ctx.dirPath, dirName) + err := ctx.mkdirAll.MkdirAll(path, 0o755) + if err != nil { + return "", fmt.Errorf("create sub-directory %s: %w", path, err) + } + return path, nil +} + // Rename is just a wrapper around [os.Rename], as we need to mock this // behavior in our tests. func (ctx *Context) Rename(oldpath, newpath string) error { + ctx.Log().Debug(fmt.Sprintf("rename %s to %s", oldpath, newpath)) err := ctx.pathRename.Rename(oldpath, newpath) if err != nil { return fmt.Errorf("rename path: %w", err) @@ -496,8 +504,3 @@ func (ctx *Context) OutputFilename(outputPath string) string { return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath)) } - -// Interface guard. -var ( - _ gotenberg.PathRename = (*osPathRename)(nil) -) diff --git a/pkg/modules/api/context_test.go b/pkg/modules/api/context_test.go index ddb7e9f3..05649df8 100644 --- a/pkg/modules/api/context_test.go +++ b/pkg/modules/api/context_test.go @@ -4,78 +4,22 @@ import ( "bytes" "context" "errors" - "fmt" - "io" "mime/multipart" "net/http" "net/http/httptest" "os" - "path/filepath" "reflect" "strings" "testing" "time" "github.com/dlclark/regexp2" - "github.com/google/uuid" "github.com/labstack/echo/v4" "go.uber.org/zap" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) -func TestOsPathRename_Rename(t *testing.T) { - dirPath, err := gotenberg.NewFileSystem().MkdirAll() - if err != nil { - t.Fatalf("create working directory: %v", err) - } - - path := "/tests/test/testdata/api/sample1.txt" - copyPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString())) - - in, err := os.Open(path) - if err != nil { - t.Fatalf("open file: %v", err) - } - - defer func() { - err := in.Close() - if err != nil { - t.Fatalf("close file: %v", err) - } - }() - - out, err := os.Create(copyPath) - if err != nil { - t.Fatalf("create new file: %v", err) - } - - defer func() { - err := out.Close() - if err != nil { - t.Fatalf("close new file: %v", err) - } - }() - - _, err = io.Copy(out, in) - if err != nil { - t.Fatalf("copy file to new file: %v", err) - } - - rename := new(osPathRename) - newPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString())) - - err = rename.Rename(copyPath, newPath) - if err != nil { - t.Errorf("expected no error but got: %v", err) - } - - err = os.RemoveAll(dirPath) - if err != nil { - t.Fatalf("remove working directory: %v", err) - } -} - func TestNewContext(t *testing.T) { defaultAllowList, err := regexp2.Compile("", 0) if err != nil { @@ -548,7 +492,7 @@ func TestNewContext(t *testing.T) { } handler := func(c echo.Context) error { - ctx, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(), time.Duration(10)*time.Second, tc.bodyLimit, tc.downloadFromCfg, "Gotenberg-Trace", "123") + ctx, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), time.Duration(10)*time.Second, tc.bodyLimit, tc.downloadFromCfg, "Gotenberg-Trace", "123") defer cancel() // Context already cancelled. defer cancel() @@ -647,6 +591,42 @@ func TestContext_FormData(t *testing.T) { } } +func TestContext_CreateSubDirectory(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx *Context + expectError bool + }{ + { + scenario: "failure", + ctx: &Context{mkdirAll: &gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error { + return errors.New("cannot rename") + }}}, + expectError: true, + }, + { + scenario: "success", + ctx: &Context{mkdirAll: &gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error { + return nil + }}}, + expectError: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + tc.ctx.logger = zap.NewNop() + _, err := tc.ctx.CreateSubDirectory("foo") + + 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) + } + }) + } +} + func TestContext_GeneratePath(t *testing.T) { ctx := &Context{ dirPath: "/foo", @@ -680,6 +660,7 @@ func TestContext_Rename(t *testing.T) { }, } { t.Run(tc.scenario, func(t *testing.T) { + tc.ctx.logger = zap.NewNop() err := tc.ctx.Rename("", "") if tc.expectError && err == nil { @@ -788,7 +769,7 @@ func TestContext_BuildOutputFile(t *testing.T) { }, } { t.Run(tc.scenario, func(t *testing.T) { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) dirPath, err := fs.MkdirAll() if err != nil { t.Fatalf("expected no erro but got: %v", err) diff --git a/pkg/modules/api/middlewares.go b/pkg/modules/api/middlewares.go index c3939b5a..78bcb854 100644 --- a/pkg/modules/api/middlewares.go +++ b/pkg/modules/api/middlewares.go @@ -48,6 +48,10 @@ func ParseError(err error) (int, string) { return http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests) } + if errors.Is(err, gotenberg.ErrPdfSplitModeNotSupported) { + return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues" + } + if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) { return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues" } diff --git a/pkg/modules/api/middlewares_test.go b/pkg/modules/api/middlewares_test.go index 6edd1e2e..8bef1268 100644 --- a/pkg/modules/api/middlewares_test.go +++ b/pkg/modules/api/middlewares_test.go @@ -38,6 +38,11 @@ func TestParseError(t *testing.T) { expectStatus: http.StatusTooManyRequests, expectMessage: http.StatusText(http.StatusTooManyRequests), }, + { + err: gotenberg.ErrPdfSplitModeNotSupported, + expectStatus: http.StatusBadRequest, + expectMessage: "At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues", + }, { err: gotenberg.ErrPdfFormatNotSupported, expectStatus: http.StatusBadRequest, @@ -462,7 +467,7 @@ func TestContextMiddleware(t *testing.T) { c.Set("trace", "foo") c.Set("startTime", time.Now()) - err := contextMiddleware(gotenberg.NewFileSystem(), time.Duration(10)*time.Second, 0, downloadFromConfig{})(tc.next)(c) + err := contextMiddleware(gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), time.Duration(10)*time.Second, 0, downloadFromConfig{})(tc.next)(c) if tc.expectErr && err == nil { t.Errorf("test %d: expected error but got: %v", i, err) diff --git a/pkg/modules/api/mocks.go b/pkg/modules/api/mocks.go index 667cd14e..6d6c2a5f 100644 --- a/pkg/modules/api/mocks.go +++ b/pkg/modules/api/mocks.go @@ -86,6 +86,14 @@ func (ctx *ContextMock) SetEchoContext(c echo.Context) { ctx.Context.echoCtx = c } +// SetMkdirAll sets the [gotenberg.MkdirAll]. +// +// ctx := &api.ContextMock{Context: &api.Context{}} +// ctx.SetMkdirAll(mkdirAll) +func (ctx *ContextMock) SetMkdirAll(mkdirAll gotenberg.MkdirAll) { + ctx.Context.mkdirAll = mkdirAll +} + // SetPathRename sets the [gotenberg.PathRename]. // // ctx := &api.ContextMock{Context: &api.Context{}} diff --git a/pkg/modules/api/mocks_test.go b/pkg/modules/api/mocks_test.go index 5910726c..ecc2a19c 100644 --- a/pkg/modules/api/mocks_test.go +++ b/pkg/modules/api/mocks_test.go @@ -7,6 +7,8 @@ import ( "github.com/alexliesenfeld/health" "github.com/labstack/echo/v4" "go.uber.org/zap" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) func TestContextMock_SetDirPath(t *testing.T) { @@ -117,10 +119,23 @@ func TestContextMock_SetEchoContext(t *testing.T) { } } +func TestContextMock_SetMkdirAll(t *testing.T) { + mock := ContextMock{&Context{}} + + expect := new(gotenberg.OsMkdirAll) + mock.SetMkdirAll(expect) + + actual := mock.mkdirAll + + if actual != expect { + t.Errorf("expected %v but got %v", expect, actual) + } +} + func TestContextMock_SetPathRename(t *testing.T) { mock := ContextMock{&Context{}} - expect := new(osPathRename) + expect := new(gotenberg.OsPathRename) mock.SetPathRename(expect) actual := mock.pathRename diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go index 380dcccf..1da24d87 100644 --- a/pkg/modules/chromium/browser.go +++ b/pkg/modules/chromium/browser.go @@ -62,7 +62,7 @@ func newChromiumBrowser(arguments browserArguments) browser { b := &chromiumBrowser{ initialCtx: context.Background(), arguments: arguments, - fs: gotenberg.NewFileSystem(), + fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), } b.isStarted.Store(false) diff --git a/pkg/modules/chromium/browser_test.go b/pkg/modules/chromium/browser_test.go index 3ba608b4..a5698eaf 100644 --- a/pkg/modules/chromium/browser_test.go +++ b/pkg/modules/chromium/browser_test.go @@ -263,7 +263,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { b.isStarted.Store(false) return b }(), - fs: gotenberg.NewFileSystem(), + fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), noDeadline: false, start: false, expectError: true, @@ -275,7 +275,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { b.isStarted.Store(true) return b }(), - fs: gotenberg.NewFileSystem(), + fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), noDeadline: true, start: false, expectError: true, @@ -291,7 +291,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { b.isStarted.Store(true) return b }(), - fs: gotenberg.NewFileSystem(), + fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), noDeadline: false, start: false, expectError: true, @@ -308,7 +308,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { b.isStarted.Store(true) return b }(), - fs: gotenberg.NewFileSystem(), + fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), noDeadline: false, start: false, expectError: true, @@ -325,7 +325,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -357,7 +357,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -389,7 +389,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -424,7 +424,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -457,7 +457,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -495,7 +495,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -528,7 +528,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -554,7 +554,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -588,7 +588,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -621,7 +621,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -654,7 +654,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -688,7 +688,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -723,7 +723,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -758,7 +758,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -812,7 +812,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -845,7 +845,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -881,7 +881,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -914,7 +914,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -949,7 +949,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -984,7 +984,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1019,7 +1019,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1065,7 +1065,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1100,7 +1100,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1146,7 +1146,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1181,7 +1181,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1217,7 +1217,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1255,7 +1255,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1288,7 +1288,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1421,7 +1421,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { b.isStarted.Store(false) return b }(), - fs: gotenberg.NewFileSystem(), + fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), noDeadline: false, start: false, expectError: true, @@ -1437,7 +1437,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { b.isStarted.Store(true) return b }(), - fs: gotenberg.NewFileSystem(), + fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), noDeadline: true, start: false, expectError: true, @@ -1453,7 +1453,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { b.isStarted.Store(true) return b }(), - fs: gotenberg.NewFileSystem(), + fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), noDeadline: false, start: false, expectError: true, @@ -1470,7 +1470,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { b.isStarted.Store(true) return b }(), - fs: gotenberg.NewFileSystem(), + fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), noDeadline: false, start: false, expectError: true, @@ -1487,7 +1487,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1519,7 +1519,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1551,7 +1551,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1586,7 +1586,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1619,7 +1619,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1657,7 +1657,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1690,7 +1690,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1716,7 +1716,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1750,7 +1750,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1783,7 +1783,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1816,7 +1816,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1850,7 +1850,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1885,7 +1885,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1920,7 +1920,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -1974,7 +1974,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -2009,7 +2009,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -2042,7 +2042,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -2077,7 +2077,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -2112,7 +2112,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -2147,7 +2147,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -2193,7 +2193,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -2228,7 +2228,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -2274,7 +2274,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -2317,7 +2317,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -2365,7 +2365,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go index 2ae33b25..ee330a26 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -326,8 +326,9 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { Handler: func(c echo.Context) error { ctx := c.Get("context").(*api.Context) form, options := FormDataChromiumPdfOptions(ctx) + mode := pdfengines.FormDataPdfSplitMode(form, false) pdfFormats := pdfengines.FormDataPdfFormats(form) - metadata := pdfengines.FormDataPdfMetadata(form) + metadata := pdfengines.FormDataPdfMetadata(form, false) var url string err := form. @@ -337,7 +338,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } - err = convertUrl(ctx, chromium, engine, url, options, pdfFormats, metadata) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata) if err != nil { return fmt.Errorf("convert URL to PDF: %w", err) } @@ -386,8 +387,9 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { Handler: func(c echo.Context) error { ctx := c.Get("context").(*api.Context) form, options := FormDataChromiumPdfOptions(ctx) + mode := pdfengines.FormDataPdfSplitMode(form, false) pdfFormats := pdfengines.FormDataPdfFormats(form) - metadata := pdfengines.FormDataPdfMetadata(form) + metadata := pdfengines.FormDataPdfMetadata(form, false) var inputPath string err := form. @@ -398,7 +400,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { } url := fmt.Sprintf("file://%s", inputPath) - err = convertUrl(ctx, chromium, engine, url, options, pdfFormats, metadata) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata) if err != nil { return fmt.Errorf("convert HTML to PDF: %w", err) } @@ -448,8 +450,9 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { Handler: func(c echo.Context) error { ctx := c.Get("context").(*api.Context) form, options := FormDataChromiumPdfOptions(ctx) + mode := pdfengines.FormDataPdfSplitMode(form, false) pdfFormats := pdfengines.FormDataPdfFormats(form) - metadata := pdfengines.FormDataPdfMetadata(form) + metadata := pdfengines.FormDataPdfMetadata(form, false) var ( inputPath string @@ -469,7 +472,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("transform markdown file(s) to HTML: %w", err) } - err = convertUrl(ctx, chromium, engine, url, options, pdfFormats, metadata) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata) if err != nil { return fmt.Errorf("convert markdown to PDF: %w", err) } @@ -593,7 +596,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string) return fmt.Sprintf("file://%s", inputPath), nil } -func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}) error { +func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}) error { outputPath := ctx.GeneratePath(".pdf") err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options) @@ -632,16 +635,34 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url return fmt.Errorf("convert to PDF: %w", err) } - outputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, []string{outputPath}) + outputPaths, err := pdfengines.SplitPdfStub(ctx, engine, mode, []string{outputPath}) if err != nil { - return fmt.Errorf("convert PDF: %w", err) + return fmt.Errorf("split PDF: %w", err) } - err = pdfengines.WriteMetadataStub(ctx, engine, metadata, outputPaths) + convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths) + if err != nil { + return fmt.Errorf("convert PDF(s): %w", err) + } + + err = pdfengines.WriteMetadataStub(ctx, engine, metadata, convertOutputPaths) if err != nil { return fmt.Errorf("write metadata: %w", err) } + zeroValuedSplitMode := gotenberg.SplitMode{} + zeroValuedPdfFormats := gotenberg.PdfFormats{} + if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats { + // The PDF has been split and split parts have been converted to a + // specific format. We want to keep the split naming. + for i, convertOutputPath := range convertOutputPaths { + err = ctx.Rename(convertOutputPath, outputPaths[i]) + if err != nil { + return fmt.Errorf("rename output path: %w", err) + } + } + } + err = ctx.AddOutputPaths(outputPaths...) if err != nil { return fmt.Errorf("add output paths: %w", err) diff --git a/pkg/modules/chromium/routes_test.go b/pkg/modules/chromium/routes_test.go index 1e7363ba..933b833e 100644 --- a/pkg/modules/chromium/routes_test.go +++ b/pkg/modules/chromium/routes_test.go @@ -1428,6 +1428,7 @@ func TestConvertUrl(t *testing.T) { api Api engine gotenberg.PdfEngine options PdfOptions + splitMode gotenberg.SplitMode pdfFormats gotenberg.PdfFormats metadata map[string]interface{} expectError bool @@ -1570,6 +1571,36 @@ func TestConvertUrl(t *testing.T) { expectHttpError: false, expectOutputPathsCount: 0, }, + { + scenario: "PDF engine split error", + ctx: &api.ContextMock{Context: new(api.Context)}, + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { + return nil + }}, + engine: &gotenberg.PdfEngineMock{SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return nil, errors.New("foo") + }}, + options: DefaultPdfOptions(), + splitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"}, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, + { + scenario: "success with split mode", + ctx: &api.ContextMock{Context: new(api.Context)}, + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { + return nil + }}, + engine: &gotenberg.PdfEngineMock{SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return []string{inputPath}, nil + }}, + options: DefaultPdfOptions(), + splitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1"}, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 1, + }, { scenario: "PDF engine convert error", ctx: &api.ContextMock{Context: new(api.Context)}, @@ -1600,6 +1631,27 @@ func TestConvertUrl(t *testing.T) { expectHttpError: false, expectOutputPathsCount: 1, }, + { + scenario: "success with split mode and PDF formats", + ctx: &api.ContextMock{Context: new(api.Context)}, + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { + return nil + }}, + engine: &gotenberg.PdfEngineMock{ + SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return []string{inputPath}, nil + }, + ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { + return nil + }, + }, + options: DefaultPdfOptions(), + splitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1"}, + pdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b}, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 1, + }, { scenario: "PDF engine write metadata error", ctx: &api.ContextMock{Context: new(api.Context)}, @@ -1659,7 +1711,13 @@ func TestConvertUrl(t *testing.T) { } { t.Run(tc.scenario, func(t *testing.T) { tc.ctx.SetLogger(zap.NewNop()) - err := convertUrl(tc.ctx.Context, tc.api, tc.engine, "", tc.options, tc.pdfFormats, tc.metadata) + tc.ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error { + return nil + }}) + tc.ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { + return nil + }}) + err := convertUrl(tc.ctx.Context, tc.api, tc.engine, "", tc.options, tc.splitMode, tc.pdfFormats, tc.metadata) if tc.expectError && err == nil { t.Fatal("expected error but got none", err) diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go index 7d2cb8d9..aeffc6a9 100644 --- a/pkg/modules/exiftool/exiftool.go +++ b/pkg/modules/exiftool/exiftool.go @@ -58,6 +58,11 @@ func (engine *ExifTool) Merge(ctx context.Context, logger *zap.Logger, inputPath return fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// Split is not available in this implementation. +func (engine *ExifTool) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return nil, fmt.Errorf("split 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 949087ec..52c8f31d 100644 --- a/pkg/modules/exiftool/exiftool_test.go +++ b/pkg/modules/exiftool/exiftool_test.go @@ -82,6 +82,15 @@ func TestExiftool_Merge(t *testing.T) { } } +func TestExiftool_Split(t *testing.T) { + engine := new(ExifTool) + _, err := engine.Split(context.Background(), zap.NewNop(), gotenberg.SplitMode{}, "", "") + + 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{}, "", "") @@ -257,7 +266,7 @@ func TestExiftool_WriteMetadata(t *testing.T) { var destinationPath string if tc.createCopy { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) outputDir, err := fs.MkdirAll() if err != nil { t.Fatalf("expected error no but got: %v", err) diff --git a/pkg/modules/libreoffice/api/libreoffice.go b/pkg/modules/libreoffice/api/libreoffice.go index f8c4415b..ee332ad4 100644 --- a/pkg/modules/libreoffice/api/libreoffice.go +++ b/pkg/modules/libreoffice/api/libreoffice.go @@ -44,7 +44,7 @@ type libreOfficeProcess struct { func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice { p := &libreOfficeProcess{ arguments: arguments, - fs: gotenberg.NewFileSystem(), + fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), } p.isStarted.Store(false) diff --git a/pkg/modules/libreoffice/api/libreoffice_test.go b/pkg/modules/libreoffice/api/libreoffice_test.go index 953cb908..fce85515 100644 --- a/pkg/modules/libreoffice/api/libreoffice_test.go +++ b/pkg/modules/libreoffice/api/libreoffice_test.go @@ -230,7 +230,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) { p.isStarted.Store(false) return p }(), - fs: gotenberg.NewFileSystem(), + fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), cancelledCtx: false, start: false, expectError: true, @@ -243,7 +243,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) { p.isStarted.Store(true) return p }(), - fs: gotenberg.NewFileSystem(), + fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: "foo"}}, cancelledCtx: false, start: false, @@ -261,7 +261,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) { ), options: Options{PageRanges: "foo"}, fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -291,7 +291,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) { ), options: Options{Password: "foo"}, fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -344,7 +344,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -372,7 +372,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -400,7 +400,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -452,7 +452,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -481,7 +481,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -510,7 +510,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -539,7 +539,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) { }, ), fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -625,7 +625,7 @@ func TestNonBasicLatinCharactersGuard(t *testing.T) { { scenario: "basic latin characters", fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { @@ -646,7 +646,7 @@ func TestNonBasicLatinCharactersGuard(t *testing.T) { { scenario: "non-basic latin characters", fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) err := os.MkdirAll(fs.WorkingDirPath(), 0o755) if err != nil { diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go index b478c21b..5416ab35 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go @@ -51,6 +51,11 @@ func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *zap.Logge return fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// Split is not available in this implementation. +func (engine *LibreOfficePdfEngine) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return nil, fmt.Errorf("split 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 8353954d..1dc4ed73 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine_test.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine_test.go @@ -118,11 +118,21 @@ func TestLibreOfficePdfEngine_Merge(t *testing.T) { } } +func TestLibreOfficePdfEngine_Split(t *testing.T) { + engine := new(LibreOfficePdfEngine) + _, err := engine.Split(context.Background(), zap.NewNop(), gotenberg.SplitMode{}, "", "") + + 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 - api api.Uno - expectError bool + scenario string + api api.Uno + expectError bool + expectedError error }{ { scenario: "convert success", @@ -134,13 +144,14 @@ func TestLibreOfficePdfEngine_Convert(t *testing.T) { expectError: false, }, { - scenario: "invalid PDF format", + scenario: "ErrInvalidPdfFormats", api: &api.ApiMock{ PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error { return api.ErrInvalidPdfFormats }, }, - expectError: true, + expectError: true, + expectedError: gotenberg.ErrPdfFormatNotSupported, }, { scenario: "convert fail", @@ -163,6 +174,10 @@ func TestLibreOfficePdfEngine_Convert(t *testing.T) { if tc.expectError && err == nil { t.Fatal("expected error but got none") } + + if tc.expectedError != nil && !errors.Is(err, tc.expectedError) { + t.Fatalf("expected error %v but got: %v", tc.expectedError, err) + } }) } } diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go index b49677d6..86165833 100644 --- a/pkg/modules/libreoffice/routes.go +++ b/pkg/modules/libreoffice/routes.go @@ -28,8 +28,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap defaultOptions := libreofficeapi.DefaultOptions() form := ctx.FormData() + splitMode := pdfengines.FormDataPdfSplitMode(form, false) pdfFormats := pdfengines.FormDataPdfFormats(form) - metadata := pdfengines.FormDataPdfMetadata(form) + metadata := pdfengines.FormDataPdfMetadata(form, false) + + zeroValuedSplitMode := gotenberg.SplitMode{} var ( inputPaths []string @@ -165,7 +168,9 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap MaxImageResolution: maxImageResolution, } - if nativePdfFormats { + if nativePdfFormats && splitMode == zeroValuedSplitMode { + // Only apply natively given PDF formats if we're not + // splitting the PDF later. options.PdfFormats = pdfFormats } @@ -209,11 +214,44 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap outputPaths = []string{outputPath} } - if !nativePdfFormats { - outputPaths, err = pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths) + if splitMode != zeroValuedSplitMode { + if !merge { + // document.docx -> document.docx.pdf, so that split naming + // document.docx_0.pdf, etc. + for i, inputPath := range inputPaths { + outputPath := fmt.Sprintf("%s.pdf", inputPath) + + err = ctx.Rename(outputPaths[i], outputPath) + if err != nil { + return fmt.Errorf("rename output path: %w", err) + } + + outputPaths[i] = outputPath + } + } + + outputPaths, err = pdfengines.SplitPdfStub(ctx, engine, splitMode, outputPaths) + if err != nil { + return fmt.Errorf("split PDFs: %w", err) + } + } + + if !nativePdfFormats || (nativePdfFormats && splitMode != zeroValuedSplitMode) { + convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths) if err != nil { return fmt.Errorf("convert PDFs: %w", err) } + + if splitMode != zeroValuedSplitMode { + // The PDF has been split and split parts have been converted to + // specific formats. We want to keep the split naming. + for i, convertOutputPath := range convertOutputPaths { + err = ctx.Rename(convertOutputPath, outputPaths[i]) + if err != nil { + return fmt.Errorf("rename output path: %w", err) + } + } + } } err = pdfengines.WriteMetadataStub(ctx, engine, metadata, outputPaths) @@ -221,7 +259,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap return fmt.Errorf("write metadata: %w", err) } - if len(outputPaths) > 1 { + if len(outputPaths) > 1 && splitMode == zeroValuedSplitMode { // If .zip archive, document.docx -> document.docx.pdf. for i, inputPath := range inputPaths { outputPath := fmt.Sprintf("%s.pdf", inputPath) diff --git a/pkg/modules/libreoffice/routes_test.go b/pkg/modules/libreoffice/routes_test.go index 041e4165..139f5748 100644 --- a/pkg/modules/libreoffice/routes_test.go +++ b/pkg/modules/libreoffice/routes_test.go @@ -3,7 +3,10 @@ package libreoffice import ( "context" "errors" + "fmt" "net/http" + "os" + "path/filepath" "slices" "testing" @@ -300,6 +303,40 @@ func TestConvertRoute(t *testing.T) { expectHttpError: false, expectOutputPathsCount: 0, }, + { + scenario: "PDF engine split 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{ + "splitMode": { + gotenberg.SplitModeIntervals, + }, + "splitSpan": { + "1", + }, + }) + 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{ + SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return nil, errors.New("foo") + }, + }, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, { scenario: "PDF engine convert error", ctx: func() *api.ContextMock { @@ -365,32 +402,6 @@ func TestConvertRoute(t *testing.T) { expectHttpError: false, expectOutputPathsCount: 0, }, - { - scenario: "cannot rename many files", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "document.docx": "/document.docx", - "document2.docx": "/document2.docx", - "document2.doc": "/document2.doc", - }) - ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { - return errors.New("cannot rename") - }}) - 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", ".doc"} - }, - }, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, { scenario: "cannot add output paths", ctx: func() *api.ContextMock { @@ -550,9 +561,173 @@ func TestConvertRoute(t *testing.T) { expectHttpError: false, expectOutputPathsCount: 1, }, + { + scenario: "success with split (many files)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "document.docx": "/document.docx", + "document2.docx": "/document2.docx", + }) + ctx.SetValues(map[string][]string{ + "splitMode": { + gotenberg.SplitModeIntervals, + }, + "splitSpan": { + "1", + }, + }) + 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{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return nil + }, + SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))] + filenameNoExt := filepath.Base(inputPathNoExt) + return []string{ + fmt.Sprintf( + "%s/%s_%d.pdf", + outputDirPath, filenameNoExt, 0, + ), + fmt.Sprintf( + "%s/%s_%d.pdf", + outputDirPath, filenameNoExt, 1, + ), + }, nil + }, + }, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 4, + expectOutputPaths: []string{"/document_docx/document.docx_0.pdf", "/document_docx/document.docx_1.pdf", "/document2_docx/document2.docx_0.pdf", "/document2_docx/document2.docx_1.pdf"}, + }, + { + scenario: "success with merge and split", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "document.docx": "/document.docx", + "document2.docx": "/document2.docx", + }) + ctx.SetValues(map[string][]string{ + "merge": { + "true", + }, + "splitMode": { + gotenberg.SplitModeIntervals, + }, + "splitSpan": { + "1", + }, + }) + 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{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return nil + }, + SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))] + filenameNoExt := filepath.Base(inputPathNoExt) + return []string{ + fmt.Sprintf( + "%s/%s_%d.pdf", + outputDirPath, filenameNoExt, 0, + ), + fmt.Sprintf( + "%s/%s_%d.pdf", + outputDirPath, filenameNoExt, 1, + ), + }, nil + }, + }, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 2, + }, + { + scenario: "success with split and native PDF/A & PDF/UA (many files)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "document.docx": "/document.docx", + "document2.docx": "/document2.docx", + }) + ctx.SetValues(map[string][]string{ + "splitMode": { + gotenberg.SplitModeIntervals, + }, + "splitSpan": { + "1", + }, + "pdfa": { + gotenberg.PdfA1b, + }, + "pdfua": { + "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{ + SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))] + filenameNoExt := filepath.Base(inputPathNoExt) + return []string{ + fmt.Sprintf( + "%s/%s_%d.pdf", + outputDirPath, filenameNoExt, 0, + ), + fmt.Sprintf( + "%s/%s_%d.pdf", + outputDirPath, filenameNoExt, 1, + ), + }, nil + }, + ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { + return nil + }, + }, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 4, + expectOutputPaths: []string{"/document_docx/document.docx_0.pdf", "/document_docx/document.docx_1.pdf", "/document2_docx/document2.docx_0.pdf", "/document2_docx/document2.docx_1.pdf"}, + }, } { t.Run(tc.scenario, func(t *testing.T) { tc.ctx.SetLogger(zap.NewNop()) + tc.ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error { + return nil + }}) + tc.ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { + return nil + }}) c := echo.New().NewContext(nil, nil) c.Set("context", tc.ctx.Context) diff --git a/pkg/modules/pdfcpu/doc.go b/pkg/modules/pdfcpu/doc.go index e68e2a61..6856a27a 100644 --- a/pkg/modules/pdfcpu/doc.go +++ b/pkg/modules/pdfcpu/doc.go @@ -2,6 +2,7 @@ // interface using the pdfcpu command-line tool. This package allows for: // // 1. The merging of PDF files. +// 2. The splitting of PDF files. // // See: https://github.com/pdfcpu/pdfcpu. package pdfcpu diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go index ac2d5358..b59573c1 100644 --- a/pkg/modules/pdfcpu/pdfcpu.go +++ b/pkg/modules/pdfcpu/pdfcpu.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "go.uber.org/zap" @@ -70,6 +71,38 @@ func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths return fmt.Errorf("merge PDFs with pdfcpu: %w", err) } +// Split splits a given PDF file. +func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + var args []string + + switch mode.Mode { + case gotenberg.SplitModeIntervals: + args = append(args, "split", "-mode", "span", inputPath, outputDirPath, mode.Span) + case gotenberg.SplitModePages: + outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath)) + args = append(args, "trim", "-pages", mode.Span, inputPath, outputPath) + default: + return nil, fmt.Errorf("split PDFs using mode '%s' with pdfcpu: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported) + } + + cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) + if err != nil { + return nil, fmt.Errorf("create command: %w", err) + } + + _, err = cmd.Exec() + if err != nil { + return nil, fmt.Errorf("split PDFs with pdfcpu: %w", err) + } + + outputPaths, err := gotenberg.WalkDir(outputDirPath, ".pdf") + if err != nil { + return nil, fmt.Errorf("walk directory to find resulting PDFs from split with pdfcpu: %w", err) + } + + return outputPaths, nil +} + // 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 f009218a..e962fc69 100644 --- a/pkg/modules/pdfcpu/pdfcpu_test.go +++ b/pkg/modules/pdfcpu/pdfcpu_test.go @@ -116,7 +116,7 @@ func TestPdfCpu_Merge(t *testing.T) { t.Fatalf("expected error but got: %v", err) } - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) outputDir, err := fs.MkdirAll() if err != nil { t.Fatalf("expected error but got: %v", err) @@ -142,6 +142,95 @@ func TestPdfCpu_Merge(t *testing.T) { } } +func TestPdfCpu_Split(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx context.Context + mode gotenberg.SplitMode + inputPath string + expectError bool + expectedError error + expectOutputPathsCount int + }{ + { + scenario: "ErrPdfSplitModeNotSupported", + expectError: true, + expectedError: gotenberg.ErrPdfSplitModeNotSupported, + expectOutputPathsCount: 0, + }, + { + scenario: "invalid context", + ctx: nil, + mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"}, + expectError: true, + expectOutputPathsCount: 0, + }, + { + scenario: "invalid input path", + ctx: context.TODO(), + mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"}, + inputPath: "", + expectError: true, + expectOutputPathsCount: 0, + }, + { + scenario: "success (intervals)", + ctx: context.TODO(), + mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"}, + inputPath: "/tests/test/testdata/pdfengines/sample1.pdf", + expectError: false, + expectOutputPathsCount: 3, + }, + { + scenario: "success (pages)", + ctx: context.TODO(), + mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1"}, + inputPath: "/tests/test/testdata/pdfengines/sample1.pdf", + expectError: false, + expectOutputPathsCount: 1, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + engine := new(PdfCpu) + err := engine.Provision(nil) + if err != nil { + t.Fatalf("expected error but got: %v", err) + } + + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) + outputDir, err := fs.MkdirAll() + if err != nil { + t.Fatalf("expected error 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) + } + }() + + outputPaths, err := engine.Split(tc.ctx, zap.NewNop(), tc.mode, tc.inputPath, outputDir) + + 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") + } + + if tc.expectedError != nil && !errors.Is(err, tc.expectedError) { + t.Fatalf("expected error %v but got: %v", tc.expectedError, err) + } + + if tc.expectOutputPathsCount != len(outputPaths) { + t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(outputPaths)) + } + }) + } +} + 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 4cbbc3ea..c6c9514d 100644 --- a/pkg/modules/pdfengines/multi.go +++ b/pkg/modules/pdfengines/multi.go @@ -13,6 +13,7 @@ import ( type multiPdfEngines struct { mergeEngines []gotenberg.PdfEngine + splitEngines []gotenberg.PdfEngine convertEngines []gotenberg.PdfEngine readMedataEngines []gotenberg.PdfEngine writeMedataEngines []gotenberg.PdfEngine @@ -20,12 +21,14 @@ type multiPdfEngines struct { func newMultiPdfEngines( mergeEngines, + splitEngines, convertEngines, readMetadataEngines, writeMedataEngines []gotenberg.PdfEngine, ) *multiPdfEngines { return &multiPdfEngines{ mergeEngines: mergeEngines, + splitEngines: splitEngines, convertEngines: convertEngines, readMedataEngines: readMetadataEngines, writeMedataEngines: writeMedataEngines, @@ -57,6 +60,44 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp return fmt.Errorf("merge PDFs with multi PDF engines: %w", err) } +type splitResult struct { + outputPaths []string + err error +} + +// Split tries to split at intervals a given PDF thanks to its children. If the +// context is done, it stops and returns an error. +func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + var err error + var mu sync.Mutex // to safely append errors. + + resultChan := make(chan splitResult, len(multi.splitEngines)) + + for _, engine := range multi.splitEngines { + go func(engine gotenberg.PdfEngine) { + outputPaths, err := engine.Split(ctx, logger, mode, inputPath, outputDirPath) + resultChan <- splitResult{outputPaths: outputPaths, err: err} + }(engine) + } + + for range multi.splitEngines { + select { + case result := <-resultChan: + if result.err != nil { + mu.Lock() + err = multierr.Append(err, result.err) + mu.Unlock() + } else { + return result.outputPaths, nil + } + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + return nil, fmt.Errorf("split 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 00e706d7..6e5686c0 100644 --- a/pkg/modules/pdfengines/multi_test.go +++ b/pkg/modules/pdfengines/multi_test.go @@ -19,25 +19,22 @@ func TestMultiPdfEngines_Merge(t *testing.T) { }{ { scenario: "nominal behavior", - engine: newMultiPdfEngines( - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + mergeEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { return nil }, }, }, - nil, - nil, - nil, - ), + }, ctx: context.Background(), expectError: false, }, { scenario: "at least one engine does not return an error", - engine: newMultiPdfEngines( - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + mergeEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { return errors.New("foo") @@ -49,17 +46,14 @@ func TestMultiPdfEngines_Merge(t *testing.T) { }, }, }, - nil, - nil, - nil, - ), + }, ctx: context.Background(), expectError: false, }, { scenario: "all engines return an error", - engine: newMultiPdfEngines( - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + mergeEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { return errors.New("foo") @@ -71,27 +65,21 @@ func TestMultiPdfEngines_Merge(t *testing.T) { }, }, }, - nil, - nil, - nil, - ), + }, ctx: context.Background(), expectError: true, }, { scenario: "context expired", - engine: newMultiPdfEngines( - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + mergeEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { return nil }, }, }, - nil, - nil, - nil, - ), + }, ctx: func() context.Context { ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -115,6 +103,97 @@ func TestMultiPdfEngines_Merge(t *testing.T) { } } +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_Convert(t *testing.T) { for _, tc := range []struct { scenario string @@ -124,25 +203,21 @@ func TestMultiPdfEngines_Convert(t *testing.T) { }{ { scenario: "nominal behavior", - engine: newMultiPdfEngines( - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + convertEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { return nil }, }, }, - nil, - nil, - ), + }, ctx: context.Background(), }, { scenario: "at least one engine does not return an error", - engine: newMultiPdfEngines( - nil, - []gotenberg.PdfEngine{ + 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") @@ -154,16 +229,13 @@ func TestMultiPdfEngines_Convert(t *testing.T) { }, }, }, - nil, - nil, - ), + }, ctx: context.Background(), }, { scenario: "all engines return an error", - engine: newMultiPdfEngines( - nil, - []gotenberg.PdfEngine{ + 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") @@ -175,26 +247,21 @@ func TestMultiPdfEngines_Convert(t *testing.T) { }, }, }, - nil, - nil, - ), + }, ctx: context.Background(), expectError: true, }, { scenario: "context expired", - engine: newMultiPdfEngines( - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + convertEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { return nil }, }, }, - nil, - nil, - ), + }, ctx: func() context.Context { ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -227,26 +294,21 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) { }{ { scenario: "nominal behavior", - engine: newMultiPdfEngines( - nil, - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + readMedataEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { return make(map[string]interface{}), nil }, }, }, - nil, - ), + }, ctx: context.Background(), }, { scenario: "at least one engine does not return an error", - engine: newMultiPdfEngines( - nil, - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + readMedataEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { return nil, errors.New("foo") @@ -258,16 +320,13 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) { }, }, }, - nil, - ), + }, ctx: context.Background(), }, { scenario: "all engines return an error", - engine: newMultiPdfEngines( - nil, - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + readMedataEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { return nil, errors.New("foo") @@ -279,25 +338,21 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) { }, }, }, - nil, - ), + }, ctx: context.Background(), expectError: true, }, { scenario: "context expired", - engine: newMultiPdfEngines( - nil, - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + readMedataEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { return make(map[string]interface{}), nil }, }, }, - nil, - ), + }, ctx: func() context.Context { ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -330,27 +385,21 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) { }{ { scenario: "nominal behavior", - engine: newMultiPdfEngines( - nil, - nil, - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + writeMedataEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { return nil }, }, }, - ), + }, ctx: context.Background(), }, { scenario: "at least one engine does not return an error", - engine: newMultiPdfEngines( - nil, - nil, - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + writeMedataEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { return errors.New("foo") @@ -362,16 +411,13 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) { }, }, }, - ), + }, ctx: context.Background(), }, { scenario: "all engines return an error", - engine: newMultiPdfEngines( - nil, - nil, - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + writeMedataEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { return errors.New("foo") @@ -383,24 +429,21 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) { }, }, }, - ), + }, ctx: context.Background(), expectError: true, }, { scenario: "context expired", - engine: newMultiPdfEngines( - nil, - nil, - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + writeMedataEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { return nil }, }, }, - ), + }, ctx: func() context.Context { ctx, cancel := context.WithCancel(context.Background()) cancel() diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go index 7bd00018..4f07f83e 100644 --- a/pkg/modules/pdfengines/pdfengines.go +++ b/pkg/modules/pdfengines/pdfengines.go @@ -28,6 +28,7 @@ func init() { // enabled. type PdfEngines struct { mergeNames []string + splitNames []string convertNames []string readMetadataNames []string writeMedataNames []string @@ -42,6 +43,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor { FlagSet: func() *flag.FlagSet { 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-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") @@ -64,6 +66,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor { func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { flags := ctx.ParsedFlags() mergeNames := flags.MustStringSlice("pdfengines-merge-engines") + splitNames := flags.MustStringSlice("pdfengines-split-engines") convertNames := flags.MustStringSlice("pdfengines-convert-engines") readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines") writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines") @@ -98,6 +101,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { mod.mergeNames = mergeNames } + mod.splitNames = defaultNames + if len(splitNames) > 0 { + mod.splitNames = splitNames + } + mod.convertNames = defaultNames if len(convertNames) > 0 { mod.convertNames = convertNames @@ -161,6 +169,7 @@ func (mod *PdfEngines) Validate() error { } findNonExistingEngines(mod.mergeNames) + findNonExistingEngines(mod.splitNames) findNonExistingEngines(mod.convertNames) findNonExistingEngines(mod.readMetadataNames) findNonExistingEngines(mod.writeMedataNames) @@ -177,6 +186,7 @@ func (mod *PdfEngines) Validate() error { 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("convert engines - %s", strings.Join(mod.convertNames[:], " ")), fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")), fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")), @@ -201,6 +211,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) { return newMultiPdfEngines( engines(mod.mergeNames), + engines(mod.splitNames), engines(mod.convertNames), engines(mod.readMetadataNames), engines(mod.writeMedataNames), @@ -222,6 +233,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) { return []api.Route{ mergeRoute(engine), + splitRoute(engine), convertRoute(engine), readMetadataRoute(engine), writeMetadataRoute(engine), diff --git a/pkg/modules/pdfengines/pdfengines_test.go b/pkg/modules/pdfengines/pdfengines_test.go index fe999432..505a229f 100644 --- a/pkg/modules/pdfengines/pdfengines_test.go +++ b/pkg/modules/pdfengines/pdfengines_test.go @@ -26,6 +26,7 @@ func TestPdfEngines_Provision(t *testing.T) { scenario string ctx *gotenberg.Context expectedMergePdfEngines []string + expectedSplitPdfEngines []string expectedConvertPdfEngines []string expectedReadMetadataPdfEngines []string expectedWriteMetadataPdfEngines []string @@ -66,6 +67,7 @@ func TestPdfEngines_Provision(t *testing.T) { ) }(), expectedMergePdfEngines: []string{"qpdf", "pdfcpu", "pdftk"}, + expectedSplitPdfEngines: []string{"pdfcpu", "qpdf", "pdftk"}, expectedConvertPdfEngines: []string{"libreoffice-pdfengine"}, expectedReadMetadataPdfEngines: []string{"exiftool"}, expectedWriteMetadataPdfEngines: []string{"exiftool"}, @@ -107,7 +109,7 @@ func TestPdfEngines_Provision(t *testing.T) { } fs := new(PdfEngines).Descriptor().FlagSet - err := fs.Parse([]string{"--pdfengines-merge-engines=b", "--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-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) } @@ -125,6 +127,7 @@ func TestPdfEngines_Provision(t *testing.T) { }(), expectedMergePdfEngines: []string{"b"}, + expectedSplitPdfEngines: []string{"a"}, expectedConvertPdfEngines: []string{"b"}, expectedReadMetadataPdfEngines: []string{"a"}, expectedWriteMetadataPdfEngines: []string{"a"}, @@ -200,6 +203,12 @@ func TestPdfEngines_Provision(t *testing.T) { } } + for index, name := range mod.splitNames { + if name != tc.expectedSplitPdfEngines[index] { + t.Fatalf("expected split name at index %d to be %s, but got: %s", index, name, tc.expectedSplitPdfEngines[index]) + } + } + for index, name := range mod.convertNames { if name != tc.expectedConvertPdfEngines[index] { t.Fatalf("expected convert name at index %d to be %s, but got: %s", index, name, tc.expectedConvertPdfEngines[index]) @@ -303,17 +312,19 @@ func TestPdfEngines_Validate(t *testing.T) { func TestPdfEngines_SystemMessages(t *testing.T) { mod := new(PdfEngines) mod.mergeNames = []string{"foo", "bar"} + mod.splitNames = []string{"foo", "bar"} mod.convertNames = []string{"foo", "bar"} mod.readMetadataNames = []string{"foo", "bar"} mod.writeMedataNames = []string{"foo", "bar"} messages := mod.SystemMessages() - if len(messages) != 4 { + if len(messages) != 5 { t.Errorf("expected one and only one message, but got %d", len(messages)) } expect := []string{ fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")), + fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames[:], " ")), fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")), fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")), fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")), @@ -329,6 +340,7 @@ func TestPdfEngines_SystemMessages(t *testing.T) { func TestPdfEngines_PdfEngine(t *testing.T) { mod := PdfEngines{ mergeNames: []string{"foo", "bar"}, + splitNames: []string{"foo", "bar"}, convertNames: []string{"foo", "bar"}, readMetadataNames: []string{"foo", "bar"}, writeMedataNames: []string{"foo", "bar"}, @@ -370,7 +382,7 @@ func TestPdfEngines_Routes(t *testing.T) { }{ { scenario: "routes not disabled", - expectRoutes: 4, + expectRoutes: 5, disableRoutes: false, }, { diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index a0ddb756..2a76274b 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "path/filepath" + "strconv" + "strings" "github.com/labstack/echo/v4" @@ -13,6 +15,63 @@ import ( "github.com/gotenberg/gotenberg/v8/pkg/modules/api" ) +// FormDataPdfSplitMode creates a [gotenberg.SplitMode] from the form data. +func FormDataPdfSplitMode(form *api.FormData, mandatory bool) gotenberg.SplitMode { + var ( + mode string + span string + ) + + splitModeFunc := func(value string) error { + if value != "" && value != gotenberg.SplitModeIntervals && value != gotenberg.SplitModePages { + return fmt.Errorf("wrong value, expected either '%s' or '%s'", gotenberg.SplitModeIntervals, gotenberg.SplitModePages) + } + mode = value + return nil + } + + splitSpanFunc := func(value string) error { + value = strings.Join(strings.Fields(value), "") + + if mode == gotenberg.SplitModeIntervals { + intValue, err := strconv.Atoi(value) + if err != nil { + return err + } + if intValue < 1 { + return errors.New("value is inferior to 1") + } + } + + span = value + + return nil + } + + if mandatory { + form. + MandatoryCustom("splitMode", func(value string) error { + return splitModeFunc(value) + }). + MandatoryCustom("splitSpan", func(value string) error { + return splitSpanFunc(value) + }) + } else { + form. + Custom("splitMode", func(value string) error { + return splitModeFunc(value) + }). + Custom("splitSpan", func(value string) error { + return splitSpanFunc(value) + }) + } + + return gotenberg.SplitMode{ + Mode: mode, + Span: span, + } +} + // FormDataPdfFormats creates [gotenberg.PdfFormats] from the form data. // Fallback to default value if the considered key is not present. func FormDataPdfFormats(form *api.FormData) gotenberg.PdfFormats { @@ -32,9 +91,10 @@ func FormDataPdfFormats(form *api.FormData) gotenberg.PdfFormats { } // FormDataPdfMetadata creates metadata object from the form data. -func FormDataPdfMetadata(form *api.FormData) map[string]interface{} { +func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]interface{} { var metadata map[string]interface{} - form.Custom("metadata", func(value string) error { + + metadataFunc := func(value string) error { if len(value) > 0 { err := json.Unmarshal([]byte(value), &metadata) if err != nil { @@ -42,7 +102,18 @@ func FormDataPdfMetadata(form *api.FormData) map[string]interface{} { } } return nil - }) + } + + if mandatory { + form.MandatoryCustom("metadata", func(value string) error { + return metadataFunc(value) + }) + } else { + form.Custom("metadata", func(value string) error { + return metadataFunc(value) + }) + } + return metadata } @@ -66,6 +137,52 @@ func MergeStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string return outputPath, nil } +// SplitPdfStub splits a list of PDF files based on [gotenberg.SplitMode]. +// It returns a list of output paths or the list of provided input paths if no +// split requested. +func SplitPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, mode gotenberg.SplitMode, inputPaths []string) ([]string, error) { + zeroValued := gotenberg.SplitMode{} + if mode == zeroValued { + return inputPaths, nil + } + + var outputPaths []string + for _, inputPath := range inputPaths { + inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))] + filenameNoExt := filepath.Base(inputPathNoExt) + outputDirPath, err := ctx.CreateSubDirectory(strings.ReplaceAll(filepath.Base(filenameNoExt), ".", "_")) + if err != nil { + return nil, fmt.Errorf("create subdirectory from input path: %w", err) + } + + paths, err := engine.Split(ctx, ctx.Log(), mode, inputPath, outputDirPath) + if err != nil { + return nil, fmt.Errorf("split PDF '%s': %w", inputPath, err) + } + + if mode.Mode == gotenberg.SplitModePages { + return paths, nil + } + + // Keep the original filename. + for i, path := range paths { + newPath := fmt.Sprintf( + "%s/%s_%d.pdf", + outputDirPath, filenameNoExt, i, + ) + + err = ctx.Rename(path, newPath) + if err != nil { + return nil, fmt.Errorf("rename path: %w", err) + } + + outputPaths = append(outputPaths, newPath) + } + } + + return outputPaths, 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. @@ -116,7 +233,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { form := ctx.FormData() pdfFormats := FormDataPdfFormats(form) - metadata := FormDataPdfMetadata(form) + metadata := FormDataPdfMetadata(form, false) var inputPaths []string err := form. @@ -152,6 +269,65 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { } } +// splitRoute returns an [api.Route] which can extract pages from a PDF. +func splitRoute(engine gotenberg.PdfEngine) api.Route { + return api.Route{ + Method: http.MethodPost, + Path: "/forms/pdfengines/split", + IsMultipart: true, + Handler: func(c echo.Context) error { + ctx := c.Get("context").(*api.Context) + + form := ctx.FormData() + mode := FormDataPdfSplitMode(form, true) + pdfFormats := FormDataPdfFormats(form) + metadata := FormDataPdfMetadata(form, false) + + var inputPaths []string + err := form. + MandatoryPaths([]string{".pdf"}, &inputPaths). + Validate() + if err != nil { + return fmt.Errorf("validate form data: %w", err) + } + + outputPaths, err := SplitPdfStub(ctx, engine, mode, inputPaths) + if err != nil { + return fmt.Errorf("split PDFs: %w", err) + } + + convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths) + if err != nil { + return fmt.Errorf("convert PDFs: %w", err) + } + + err = WriteMetadataStub(ctx, engine, metadata, convertOutputPaths) + if err != nil { + return fmt.Errorf("write metadata: %w", err) + } + + zeroValuedSplitMode := gotenberg.SplitMode{} + zeroValuedPdfFormats := gotenberg.PdfFormats{} + if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats { + // Rename the files to keep the split naming. + for i, convertOutputPath := range convertOutputPaths { + err = ctx.Rename(convertOutputPath, outputPaths[i]) + if err != nil { + return fmt.Errorf("rename output path: %w", err) + } + } + } + + err = ctx.AddOutputPaths(outputPaths...) + 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 { @@ -258,25 +434,12 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route { Handler: func(c echo.Context) error { ctx := c.Get("context").(*api.Context) - var ( - inputPaths []string - metadata map[string]interface{} - ) + form := ctx.FormData() + metadata := FormDataPdfMetadata(form, true) - err := ctx.FormData(). + var inputPaths []string + err := form. MandatoryPaths([]string{".pdf"}, &inputPaths). - MandatoryCustom("metadata", func(value string) error { - if len(value) > 0 { - err := json.Unmarshal([]byte(value), &metadata) - if err != nil { - return fmt.Errorf("unmarshal metadata: %w", err) - } - } - if len(metadata) == 0 { - return errors.New("no metadata") - } - return nil - }). Validate() if err != nil { return fmt.Errorf("validate form data: %w", err) diff --git a/pkg/modules/pdfengines/routes_test.go b/pkg/modules/pdfengines/routes_test.go index 94df1688..6e0e5e3e 100644 --- a/pkg/modules/pdfengines/routes_test.go +++ b/pkg/modules/pdfengines/routes_test.go @@ -3,13 +3,16 @@ package pdfengines import ( "context" "errors" + "fmt" "net/http" "net/http/httptest" + "os" "reflect" "slices" "strings" "testing" + "github.com/google/uuid" "github.com/labstack/echo/v4" "go.uber.org/zap" @@ -17,6 +20,156 @@ import ( "github.com/gotenberg/gotenberg/v8/pkg/modules/api" ) +func TestFormDataPdfSplitMode(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx *api.ContextMock + mandatory bool + expectedSplitMode gotenberg.SplitMode + expectValidationError bool + }{ + { + scenario: "no custom form fields", + ctx: &api.ContextMock{Context: new(api.Context)}, + mandatory: false, + expectedSplitMode: gotenberg.SplitMode{}, + expectValidationError: false, + }, + { + scenario: "no custom form fields (mandatory)", + ctx: &api.ContextMock{Context: new(api.Context)}, + mandatory: true, + expectedSplitMode: gotenberg.SplitMode{}, + expectValidationError: true, + }, + { + scenario: "invalid splitMode", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "splitMode": { + "foo", + }, + }) + return ctx + }(), + mandatory: false, + expectedSplitMode: gotenberg.SplitMode{}, + expectValidationError: true, + }, + { + scenario: "invalid splitSpan (intervals)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "splitMode": { + "intervals", + }, + "splitSpan": { + "1-2", + }, + }) + return ctx + }(), + mandatory: false, + expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals}, + expectValidationError: true, + }, + { + scenario: "splitSpan inferior to 1 (intervals)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "splitMode": { + "intervals", + }, + "splitSpan": { + "-1", + }, + }) + return ctx + }(), + mandatory: false, + expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals}, + expectValidationError: true, + }, + { + scenario: "valid form fields (intervals)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "splitMode": { + "intervals", + }, + "splitSpan": { + "1", + }, + }) + return ctx + }(), + mandatory: false, + expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"}, + expectValidationError: false, + }, + { + scenario: "valid form fields (pages)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "splitMode": { + "pages", + }, + "splitSpan": { + "1-2", + }, + }) + return ctx + }(), + mandatory: false, + expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"}, + expectValidationError: false, + }, + { + scenario: "valid form fields (mandatory)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "splitMode": { + "intervals", + }, + "splitSpan": { + "1", + }, + }) + return ctx + }(), + mandatory: true, + expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"}, + expectValidationError: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + tc.ctx.SetLogger(zap.NewNop()) + form := tc.ctx.Context.FormData() + actual := FormDataPdfSplitMode(form, tc.mandatory) + + if !reflect.DeepEqual(actual, tc.expectedSplitMode) { + t.Fatalf("expected %+v but got: %+v", tc.expectedSplitMode, actual) + } + + err := form.Validate() + + if tc.expectValidationError && err == nil { + t.Fatal("expected validation error but got none", err) + } + + if !tc.expectValidationError && err != nil { + t.Fatalf("expected no validation error but got: %v", err) + } + }) + } +} + func TestFormDataPdfFormats(t *testing.T) { for _, tc := range []struct { scenario string @@ -74,15 +227,24 @@ func TestFormDataPdfMetadata(t *testing.T) { for _, tc := range []struct { scenario string ctx *api.ContextMock + mandatory bool expectedMetadata map[string]interface{} expectValidationError bool }{ { scenario: "no metadata form field", ctx: &api.ContextMock{Context: new(api.Context)}, + mandatory: false, expectedMetadata: nil, expectValidationError: false, }, + { + scenario: "no metadata form field (mandatory)", + ctx: &api.ContextMock{Context: new(api.Context)}, + mandatory: true, + expectedMetadata: nil, + expectValidationError: true, + }, { scenario: "invalid metadata form field", ctx: func() *api.ContextMock { @@ -94,6 +256,7 @@ func TestFormDataPdfMetadata(t *testing.T) { }) return ctx }(), + mandatory: false, expectedMetadata: nil, expectValidationError: true, }, @@ -108,6 +271,7 @@ func TestFormDataPdfMetadata(t *testing.T) { }) return ctx }(), + mandatory: false, expectedMetadata: map[string]interface{}{ "foo": "bar", }, @@ -117,7 +281,7 @@ func TestFormDataPdfMetadata(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) { tc.ctx.SetLogger(zap.NewNop()) form := tc.ctx.Context.FormData() - actual := FormDataPdfMetadata(form) + actual := FormDataPdfMetadata(form, tc.mandatory) if !reflect.DeepEqual(actual, tc.expectedMetadata) { t.Fatalf("expected %+v but got: %+v", tc.expectedMetadata, actual) @@ -193,6 +357,128 @@ func TestMergeStub(t *testing.T) { } } +func TestSplitPdfStub(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx *api.ContextMock + engine gotenberg.PdfEngine + mode gotenberg.SplitMode + expectError bool + }{ + { + scenario: "no split mode", + mode: gotenberg.SplitMode{}, + ctx: &api.ContextMock{Context: new(api.Context)}, + expectError: false, + }, + { + scenario: "cannot create subdirectory", + mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"}, + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error { + return errors.New("cannot create subdirectory") + }}) + return ctx + }(), + expectError: true, + }, + { + scenario: "split error", + mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"}, + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error { + return nil + }}) + return ctx + }(), + engine: &gotenberg.PdfEngineMock{ + SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return nil, errors.New("foo") + }, + }, + expectError: true, + }, + { + scenario: "rename error", + mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"}, + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error { + return nil + }}) + ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { + return errors.New("cannot rename") + }}) + return ctx + }(), + engine: &gotenberg.PdfEngineMock{ + SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return []string{inputPath}, nil + }, + }, + expectError: true, + }, + { + scenario: "success (intervals)", + mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"}, + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error { + return nil + }}) + ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { + return nil + }}) + return ctx + }(), + engine: &gotenberg.PdfEngineMock{ + SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return []string{inputPath}, nil + }, + }, + expectError: false, + }, + { + scenario: "success (pages)", + mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"}, + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error { + return nil + }}) + ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { + return nil + }}) + return ctx + }(), + engine: &gotenberg.PdfEngineMock{ + SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return []string{inputPath}, nil + }, + }, + expectError: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString()) + tc.ctx.SetDirPath(dirPath) + tc.ctx.SetLogger(zap.NewNop()) + + _, err := SplitPdfStub(tc.ctx.Context, tc.engine, tc.mode, []string{"my.pdf", "my2.pdf"}) + + 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) + } + }) + } +} + func TestConvertStub(t *testing.T) { for _, tc := range []struct { scenario string @@ -503,6 +789,287 @@ func TestMergeHandler(t *testing.T) { } } +func TestSplitHandler(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: "no split mode", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "file.pdf": "/file.pdf", + }) + return ctx + }(), + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, + }, + { + scenario: "error from PDF engine (split)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "file.pdf": "/file.pdf", + }) + ctx.SetValues(map[string][]string{ + "splitMode": { + gotenberg.SplitModeIntervals, + }, + "splitSpan": { + "1", + }, + }) + return ctx + }(), + engine: &gotenberg.PdfEngineMock{ + SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return nil, errors.New("foo") + }, + }, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, + { + scenario: "error from PDF engine (convert)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "file.pdf": "/file.pdf", + }) + ctx.SetValues(map[string][]string{ + "splitMode": { + gotenberg.SplitModeIntervals, + }, + "splitSpan": { + "1", + }, + "pdfua": { + "true", + }, + }) + return ctx + }(), + engine: &gotenberg.PdfEngineMock{ + SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return []string{inputPath}, nil + }, + ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { + return errors.New("foo") + }, + }, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, + { + scenario: "error from PDF engine (write metadata)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "file.pdf": "/file.pdf", + }) + ctx.SetValues(map[string][]string{ + "splitMode": { + gotenberg.SplitModeIntervals, + }, + "splitSpan": { + "1", + }, + "metadata": { + "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", + }, + }) + return ctx + }(), + engine: &gotenberg.PdfEngineMock{ + SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return []string{inputPath}, nil + }, + WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, 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.SetValues(map[string][]string{ + "splitMode": { + gotenberg.SplitModeIntervals, + }, + "splitSpan": { + "1", + }, + }) + ctx.SetCancelled(true) + return ctx + }(), + engine: &gotenberg.PdfEngineMock{ + SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return []string{inputPath}, nil + }, + }, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, + { + scenario: "success (intervals)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "file.pdf": "/file.pdf", + }) + ctx.SetValues(map[string][]string{ + "splitMode": { + gotenberg.SplitModeIntervals, + }, + "splitSpan": { + "1", + }, + "pdfua": { + "true", + }, + "metadata": { + "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", + }, + }) + return ctx + }(), + engine: &gotenberg.PdfEngineMock{ + SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return []string{"file_split_1.pdf", "file_split_2.pdf"}, nil + }, + ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { + return nil + }, + WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { + return nil + }, + }, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 2, + expectOutputPaths: []string{"/file/file_0.pdf", "/file/file_1.pdf"}, + }, + { + scenario: "success (pages)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "file.pdf": "/file.pdf", + }) + ctx.SetValues(map[string][]string{ + "splitMode": { + gotenberg.SplitModePages, + }, + "splitSpan": { + "1-2", + }, + "pdfua": { + "true", + }, + "metadata": { + "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", + }, + }) + return ctx + }(), + engine: &gotenberg.PdfEngineMock{ + SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return []string{"/file/file.pdf"}, nil + }, + ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { + return nil + }, + WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { + return nil + }, + }, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 1, + expectOutputPaths: []string{"/file/file.pdf"}, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + tc.ctx.SetLogger(zap.NewNop()) + tc.ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error { + return nil + }}) + tc.ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { + return nil + }}) + c := echo.New().NewContext(nil, nil) + c.Set("context", tc.ctx.Context) + + err := splitRoute(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/doc.go b/pkg/modules/pdftk/doc.go index 3a01ae41..c65403f7 100644 --- a/pkg/modules/pdftk/doc.go +++ b/pkg/modules/pdftk/doc.go @@ -2,6 +2,7 @@ // interface using the PDFtk command-line tool. This package allows for: // // 1. The merging of PDF files. +// 2. The splitting of PDF files. // // The path to the PDFtk binary must be specified using the PDFTK_BIN_PATH // environment variable. diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go index 9846ee9d..d8870a26 100644 --- a/pkg/modules/pdftk/pdftk.go +++ b/pkg/modules/pdftk/pdftk.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "go.uber.org/zap" @@ -51,6 +52,31 @@ func (engine *PdfTk) Validate() error { return nil } +// Split splits a given PDF file. +func (engine *PdfTk) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + var args []string + outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath)) + + switch mode.Mode { + case gotenberg.SplitModePages: + args = append(args, inputPath, "cat", mode.Span, "output", outputPath) + default: + return nil, fmt.Errorf("split PDFs using mode '%s' with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported) + } + + cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) + if err != nil { + return nil, fmt.Errorf("create command: %w", err) + } + + _, err = cmd.Exec() + if err != nil { + return nil, fmt.Errorf("split PDFs with PDFtk: %w", err) + } + + return []string{outputPath}, nil +} + // Merge combines multiple PDFs into a single PDF. func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { var args []string diff --git a/pkg/modules/pdftk/pdftk_test.go b/pkg/modules/pdftk/pdftk_test.go index c7b864ec..d5dcd573 100644 --- a/pkg/modules/pdftk/pdftk_test.go +++ b/pkg/modules/pdftk/pdftk_test.go @@ -116,7 +116,7 @@ func TestPdfTk_Merge(t *testing.T) { t.Fatalf("expected error but got: %v", err) } - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) outputDir, err := fs.MkdirAll() if err != nil { t.Fatalf("expected error but got: %v", err) @@ -142,6 +142,88 @@ func TestPdfTk_Merge(t *testing.T) { } } +func TestPdfCpu_Split(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx context.Context + mode gotenberg.SplitMode + inputPath string + expectError bool + expectedError error + expectOutputPathsCount int + expectOutputPaths []string + }{ + { + scenario: "ErrPdfSplitModeNotSupported", + expectError: true, + expectedError: gotenberg.ErrPdfSplitModeNotSupported, + expectOutputPathsCount: 0, + }, + { + scenario: "invalid context", + ctx: nil, + mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"}, + expectError: true, + expectOutputPathsCount: 0, + }, + { + scenario: "invalid input path", + ctx: context.TODO(), + mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"}, + inputPath: "", + expectError: true, + expectOutputPathsCount: 0, + }, + { + scenario: "success (pages)", + ctx: context.TODO(), + mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"}, + inputPath: "/tests/test/testdata/pdfengines/sample1.pdf", + expectError: false, + expectOutputPathsCount: 1, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + engine := new(PdfTk) + err := engine.Provision(nil) + if err != nil { + t.Fatalf("expected error but got: %v", err) + } + + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) + outputDir, err := fs.MkdirAll() + if err != nil { + t.Fatalf("expected error 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) + } + }() + + outputPaths, err := engine.Split(tc.ctx, zap.NewNop(), tc.mode, tc.inputPath, outputDir) + + 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") + } + + if tc.expectedError != nil && !errors.Is(err, tc.expectedError) { + t.Fatalf("expected error %v but got: %v", tc.expectedError, err) + } + + if tc.expectOutputPathsCount != len(outputPaths) { + t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(outputPaths)) + } + }) + } +} + func TestPdfTk_Convert(t *testing.T) { engine := new(PdfTk) err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "") diff --git a/pkg/modules/qpdf/doc.go b/pkg/modules/qpdf/doc.go index 31f61b36..f0d54a54 100644 --- a/pkg/modules/qpdf/doc.go +++ b/pkg/modules/qpdf/doc.go @@ -2,6 +2,7 @@ // interface using the QPDF command-line tool. This package allows for: // // 1. The merging of PDF files. +// 2. The splitting of PDF files. // // The path to the QPDF binary must be specified using the QPDK_BIN_PATH // environment variable. diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index 57698281..2c010d2e 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "go.uber.org/zap" @@ -45,12 +46,37 @@ func (engine *QPdf) Provision(ctx *gotenberg.Context) error { func (engine *QPdf) Validate() error { _, err := os.Stat(engine.binPath) if os.IsNotExist(err) { - return fmt.Errorf("QPdf binary path does not exist: %w", err) + return fmt.Errorf("QPDF binary path does not exist: %w", err) } return nil } +// Split splits a given PDF file. +func (engine *QPdf) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + var args []string + outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath)) + + switch mode.Mode { + case gotenberg.SplitModePages: + args = append(args, inputPath, "--pages", ".", mode.Span, "--", outputPath) + default: + return nil, fmt.Errorf("split PDFs using mode '%s' with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported) + } + + cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) + if err != nil { + return nil, fmt.Errorf("create command: %w", err) + } + + _, err = cmd.Exec() + if err != nil { + return nil, fmt.Errorf("split PDFs with QPDF: %w", err) + } + + return []string{outputPath}, nil +} + // Merge combines multiple PDFs into a single PDF. func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { var args []string diff --git a/pkg/modules/qpdf/qpdf_test.go b/pkg/modules/qpdf/qpdf_test.go index a966928d..b32976cb 100644 --- a/pkg/modules/qpdf/qpdf_test.go +++ b/pkg/modules/qpdf/qpdf_test.go @@ -116,7 +116,7 @@ func TestQPdf_Merge(t *testing.T) { t.Fatalf("expected error but got: %v", err) } - fs := gotenberg.NewFileSystem() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) outputDir, err := fs.MkdirAll() if err != nil { t.Fatalf("expected error but got: %v", err) @@ -142,6 +142,88 @@ func TestQPdf_Merge(t *testing.T) { } } +func TestQPdf_Split(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx context.Context + mode gotenberg.SplitMode + inputPath string + expectError bool + expectedError error + expectOutputPathsCount int + expectOutputPaths []string + }{ + { + scenario: "ErrPdfSplitModeNotSupported", + expectError: true, + expectedError: gotenberg.ErrPdfSplitModeNotSupported, + expectOutputPathsCount: 0, + }, + { + scenario: "invalid context", + ctx: nil, + mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"}, + expectError: true, + expectOutputPathsCount: 0, + }, + { + scenario: "invalid input path", + ctx: context.TODO(), + mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"}, + inputPath: "", + expectError: true, + expectOutputPathsCount: 0, + }, + { + scenario: "success (pages)", + ctx: context.TODO(), + mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"}, + inputPath: "/tests/test/testdata/pdfengines/sample1.pdf", + expectError: false, + expectOutputPathsCount: 1, + }, + } { + 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) + } + + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) + outputDir, err := fs.MkdirAll() + if err != nil { + t.Fatalf("expected error 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) + } + }() + + outputPaths, err := engine.Split(tc.ctx, zap.NewNop(), tc.mode, tc.inputPath, outputDir) + + 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") + } + + if tc.expectedError != nil && !errors.Is(err, tc.expectedError) { + t.Fatalf("expected error %v but got: %v", tc.expectedError, err) + } + + if tc.expectOutputPathsCount != len(outputPaths) { + t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(outputPaths)) + } + }) + } +} + func TestQPdf_Convert(t *testing.T) { engine := new(QPdf) err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")