From c30da805b3af537111dd1accd8f027b243f61b46 Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Fri, 20 Dec 2024 15:51:57 +0100
Subject: [PATCH 001/254] feat(pdfengines): add split feature
---
Makefile | 2 +
pkg/gotenberg/fs.go | 67 ++-
pkg/gotenberg/fs_test.go | 195 +++++-
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 | 213 ++++++-
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, 2145 insertions(+), 338 deletions(-)
diff --git a/Makefile b/Makefile
index def2bb19b..b8f0f10e4 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 8c2d0a98c..99b403eb0 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 f074acb66..d7f641204 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()
+
+ if !tc.expectError && err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
- _, 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.Fatal("expected error but got none")
+ }
+ })
}
+}
- err = os.RemoveAll(fs.WorkingDirPath())
- if err != nil {
- t.Fatalf("expected no error while cleaning up but got: %v", err)
+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 49154c32d..2ade89525 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 1be6c658c..953a6ec28 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 87c32c158..bc74f09f2 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 3d6cb4598..7daa18925 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 885076af4..b32eace84 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 7d895810d..d761248b2 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 ddb7e9f35..05649df8a 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 c3939b5ac..78bcb8542 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 6edd1e2e4..8bef12682 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 667cd14e9..6d6c2a5f7 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 5910726c2..ecc2a19cf 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 380dcccfb..1da24d877 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 3ba608b4b..a5698eaf7 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 2ae33b250..ee330a26a 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 1e7363ba6..933b833ed 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 7d2cb8d97..aeffc6a99 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 949087ec8..52c8f31d7 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 f8c4415b2..ee332ad48 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 953cb908b..fce85515c 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 b478c21be..5416ab357 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 8353954d6..1dc4ed737 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 b49677d64..86165833b 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 041e41655..139f57489 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"
@@ -301,18 +304,18 @@ func TestConvertRoute(t *testing.T) {
expectOutputPathsCount: 0,
},
{
- scenario: "PDF engine convert error",
+ 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{
- "pdfa": {
- gotenberg.PdfA1b,
+ "splitMode": {
+ gotenberg.SplitModeIntervals,
},
- "nativePdfFormats": {
- "false",
+ "splitSpan": {
+ "1",
},
})
return ctx
@@ -326,8 +329,8 @@ func TestConvertRoute(t *testing.T) {
},
},
engine: &gotenberg.PdfEngineMock{
- ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
- return errors.New("foo")
+ SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
+ return nil, errors.New("foo")
},
},
expectError: true,
@@ -335,15 +338,18 @@ func TestConvertRoute(t *testing.T) {
expectOutputPathsCount: 0,
},
{
- scenario: "PDF engine write metadata error",
+ scenario: "PDF engine convert 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{
- "metadata": {
- "{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
+ "pdfa": {
+ gotenberg.PdfA1b,
+ },
+ "nativePdfFormats": {
+ "false",
},
})
return ctx
@@ -357,7 +363,7 @@ func TestConvertRoute(t *testing.T) {
},
},
engine: &gotenberg.PdfEngineMock{
- WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
+ ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
@@ -366,17 +372,17 @@ func TestConvertRoute(t *testing.T) {
expectOutputPathsCount: 0,
},
{
- scenario: "cannot rename many files",
+ scenario: "PDF engine write metadata error",
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",
+ "document.docx": "/document.docx",
+ })
+ ctx.SetValues(map[string][]string{
+ "metadata": {
+ "{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
+ },
})
- ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
- return errors.New("cannot rename")
- }})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
@@ -384,7 +390,12 @@ func TestConvertRoute(t *testing.T) {
return nil
},
ExtensionsMock: func() []string {
- return []string{".docx", ".doc"}
+ return []string{".docx"}
+ },
+ },
+ engine: &gotenberg.PdfEngineMock{
+ WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
+ return errors.New("foo")
},
},
expectError: true,
@@ -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 e68e2a61f..6856a27ac 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 ac2d53589..b59573c1e 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 f009218a2..e962fc698 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 4cbbc3eac..c6c9514d6 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 00e706d78..6e5686c0c 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 7bd000187..4f07f83ea 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 fe999432d..505a229f2 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 a0ddb756e..2a76274b7 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 94df1688d..6e0e5e3e6 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 3a01ae417..c65403f72 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 9846ee9df..d8870a26d 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 c7b864eca..d5dcd573e 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 31f61b361..f0d54a548 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 57698281d..2c010d2ec 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 a966928d0..b32976cbf 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{}, "", "")
From 51a913a5e4032d7ed89e62fc64352a38cbbc49f0 Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Fri, 20 Dec 2024 15:59:51 +0100
Subject: [PATCH 002/254] chore(deps): update Go depencies
---
go.mod | 25 ++++++++++++-------------
go.sum | 54 ++++++++++++++++++++++++++----------------------------
2 files changed, 38 insertions(+), 41 deletions(-)
diff --git a/go.mod b/go.mod
index 02b01da49..4edbc38b4 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
github.com/alexliesenfeld/health v0.8.0
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/barasher/go-exiftool v1.10.0
- github.com/chromedp/cdproto v0.0.0-20241110205750-a72e6703cd9b
+ github.com/chromedp/cdproto v0.0.0-20241208230723-d1c7de7e5dd2
github.com/chromedp/chromedp v0.11.2
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0
@@ -14,25 +14,25 @@ require (
github.com/hashicorp/go-retryablehttp v0.7.7
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
- github.com/labstack/echo/v4 v4.12.0
+ github.com/labstack/echo/v4 v4.13.3
github.com/labstack/gommon v0.4.2
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.27
github.com/nwaples/rardecode v1.1.3 // indirect
- github.com/pierrec/lz4/v4 v4.1.21 // indirect
+ github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/prometheus/client_golang v1.20.5
github.com/russross/blackfriday/v2 v2.1.0
github.com/spf13/pflag v1.0.5
github.com/ulikunitz/xz v0.5.12 // indirect
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.0
- golang.org/x/crypto v0.29.0 // indirect
- golang.org/x/net v0.31.0
- golang.org/x/sync v0.9.0
- golang.org/x/sys v0.27.0 // indirect
- golang.org/x/term v0.26.0
- golang.org/x/text v0.20.0
+ golang.org/x/crypto v0.31.0 // indirect
+ golang.org/x/net v0.33.0
+ golang.org/x/sync v0.10.0
+ golang.org/x/sys v0.28.0 // indirect
+ golang.org/x/term v0.27.0
+ golang.org/x/text v0.21.0
)
require github.com/dlclark/regexp2 v1.11.4
@@ -46,18 +46,17 @@ require (
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
- github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
- github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
- github.com/prometheus/common v0.60.1 // indirect
+ github.com/prometheus/common v0.61.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
golang.org/x/time v0.8.0 // indirect
- google.golang.org/protobuf v1.35.2 // indirect
+ google.golang.org/protobuf v1.36.0 // indirect
)
diff --git a/go.sum b/go.sum
index 6f328e5f4..cc6a4a12e 100644
--- a/go.sum
+++ b/go.sum
@@ -11,8 +11,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/chromedp/cdproto v0.0.0-20241110205750-a72e6703cd9b h1:md1Gk5jkNE91SZxFDCMHmKqX0/GsEr1/VTejht0sCbY=
-github.com/chromedp/cdproto v0.0.0-20241110205750-a72e6703cd9b/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
+github.com/chromedp/cdproto v0.0.0-20241208230723-d1c7de7e5dd2 h1:fJob5N/Eprtd427U84kFpQhAHIEqJYuDzveaL6T4Xsk=
+github.com/chromedp/cdproto v0.0.0-20241208230723-d1c7de7e5dd2/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0=
github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
@@ -33,8 +33,6 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
-github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
-github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
@@ -63,14 +61,14 @@ github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
-github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
+github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
+github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
-github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
-github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
+github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -88,16 +86,16 @@ github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWk
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
-github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
-github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
+github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
-github.com/prometheus/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc=
-github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw=
+github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ=
+github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
@@ -108,8 +106,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
@@ -128,24 +126,24 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
-golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
-golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
-golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
-golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
-golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
-golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
+golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
-golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
-golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
-golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
-golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
+golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
+golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
-google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ=
+google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
From 8bc29ad92deef5207bf3c78e7c5d2455f21e58fe Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Sat, 21 Dec 2024 12:14:23 +0100
Subject: [PATCH 003/254] feat(split): add splitUnify form field
---
pkg/gotenberg/pdfengine.go | 4 ++
pkg/modules/pdfcpu/pdfcpu.go | 8 +++-
pkg/modules/pdfcpu/pdfcpu_test.go | 8 ++++
pkg/modules/pdfengines/routes.go | 43 ++++++++++++-----
pkg/modules/pdfengines/routes_test.go | 69 +++++++++++----------------
pkg/modules/pdftk/pdftk.go | 3 ++
pkg/modules/pdftk/pdftk_test.go | 16 +++++--
pkg/modules/qpdf/qpdf.go | 3 ++
pkg/modules/qpdf/qpdf_test.go | 16 +++++--
9 files changed, 106 insertions(+), 64 deletions(-)
diff --git a/pkg/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go
index bc74f09f2..788c07c7a 100644
--- a/pkg/gotenberg/pdfengine.go
+++ b/pkg/gotenberg/pdfengine.go
@@ -43,6 +43,10 @@ type SplitMode struct {
// Span is either the intervals or the page ranges to extract, depending on
// the selected mode.
Span string
+
+ // Unify specifies whether to put extracted pages into a single file or as
+ // many files as there are page ranges. Only works with "pages" mode.
+ Unify bool
}
const (
diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go
index b59573c1e..29803cb38 100644
--- a/pkg/modules/pdfcpu/pdfcpu.go
+++ b/pkg/modules/pdfcpu/pdfcpu.go
@@ -79,8 +79,12 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenb
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)
+ if mode.Unify {
+ outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath))
+ args = append(args, "trim", "-pages", mode.Span, inputPath, outputPath)
+ break
+ }
+ args = append(args, "extract", "-mode", "page", "-pages", mode.Span, inputPath, outputDirPath)
default:
return nil, fmt.Errorf("split PDFs using mode '%s' with pdfcpu: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
diff --git a/pkg/modules/pdfcpu/pdfcpu_test.go b/pkg/modules/pdfcpu/pdfcpu_test.go
index e962fc698..e996aed8b 100644
--- a/pkg/modules/pdfcpu/pdfcpu_test.go
+++ b/pkg/modules/pdfcpu/pdfcpu_test.go
@@ -189,6 +189,14 @@ func TestPdfCpu_Split(t *testing.T) {
expectError: false,
expectOutputPathsCount: 1,
},
+ {
+ scenario: "success (pages & unify)",
+ ctx: context.TODO(),
+ mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
+ inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
+ expectError: false,
+ expectOutputPathsCount: 1,
+ },
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(PdfCpu)
diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go
index 2a76274b7..d164bc77f 100644
--- a/pkg/modules/pdfengines/routes.go
+++ b/pkg/modules/pdfengines/routes.go
@@ -18,8 +18,9 @@ import (
// FormDataPdfSplitMode creates a [gotenberg.SplitMode] from the form data.
func FormDataPdfSplitMode(form *api.FormData, mandatory bool) gotenberg.SplitMode {
var (
- mode string
- span string
+ mode string
+ span string
+ unify bool
)
splitModeFunc := func(value string) error {
@@ -66,9 +67,19 @@ func FormDataPdfSplitMode(form *api.FormData, mandatory bool) gotenberg.SplitMod
})
}
+ form.
+ Bool("splitUnify", &unify, false).
+ Custom("splitUnify", func(value string) error {
+ if value != "" && unify && mode != gotenberg.SplitModePages {
+ return fmt.Errorf("unify is not available for split mode '%s'", mode)
+ }
+ return nil
+ })
+
return gotenberg.SplitMode{
- Mode: mode,
- Span: span,
+ Mode: mode,
+ Span: span,
+ Unify: unify,
}
}
@@ -160,16 +171,20 @@ func SplitPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, mode gotenberg.S
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,
- )
+ var newPath string
+ if mode.Unify && mode.Mode == gotenberg.SplitModePages {
+ newPath = fmt.Sprintf(
+ "%s/%s.pdf",
+ outputDirPath, filenameNoExt,
+ )
+ } else {
+ newPath = fmt.Sprintf(
+ "%s/%s_%d.pdf",
+ outputDirPath, filenameNoExt, i,
+ )
+ }
err = ctx.Rename(path, newPath)
if err != nil {
@@ -177,6 +192,10 @@ func SplitPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, mode gotenberg.S
}
outputPaths = append(outputPaths, newPath)
+
+ if mode.Unify && mode.Mode == gotenberg.SplitModePages {
+ break
+ }
}
}
diff --git a/pkg/modules/pdfengines/routes_test.go b/pkg/modules/pdfengines/routes_test.go
index 6e0e5e3e6..a0b004fb4 100644
--- a/pkg/modules/pdfengines/routes_test.go
+++ b/pkg/modules/pdfengines/routes_test.go
@@ -93,6 +93,27 @@ func TestFormDataPdfSplitMode(t *testing.T) {
expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals},
expectValidationError: true,
},
+ {
+ scenario: "invalid splitUnify (intervals)",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetValues(map[string][]string{
+ "splitMode": {
+ "intervals",
+ },
+ "splitSpan": {
+ "1",
+ },
+ "splitUnify": {
+ "true",
+ },
+ })
+ return ctx
+ }(),
+ mandatory: false,
+ expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1", Unify: true},
+ expectValidationError: true,
+ },
{
scenario: "valid form fields (intervals)",
ctx: func() *api.ContextMock {
@@ -122,11 +143,14 @@ func TestFormDataPdfSplitMode(t *testing.T) {
"splitSpan": {
"1-2",
},
+ "splitUnify": {
+ "true",
+ },
})
return ctx
}(),
mandatory: false,
- expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"},
+ expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
expectValidationError: false,
},
{
@@ -442,7 +466,7 @@ func TestSplitPdfStub(t *testing.T) {
},
{
scenario: "success (pages)",
- mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"},
+ mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
@@ -940,7 +964,7 @@ func TestSplitHandler(t *testing.T) {
expectOutputPathsCount: 0,
},
{
- scenario: "success (intervals)",
+ scenario: "success",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
@@ -978,45 +1002,6 @@ func TestSplitHandler(t *testing.T) {
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())
diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go
index d8870a26d..c3f63d173 100644
--- a/pkg/modules/pdftk/pdftk.go
+++ b/pkg/modules/pdftk/pdftk.go
@@ -59,6 +59,9 @@ func (engine *PdfTk) Split(ctx context.Context, logger *zap.Logger, mode gotenbe
switch mode.Mode {
case gotenberg.SplitModePages:
+ if !mode.Unify {
+ return nil, fmt.Errorf("split PDFs using mode '%s' without unify with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
+ }
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)
diff --git a/pkg/modules/pdftk/pdftk_test.go b/pkg/modules/pdftk/pdftk_test.go
index d5dcd573e..73311f725 100644
--- a/pkg/modules/pdftk/pdftk_test.go
+++ b/pkg/modules/pdftk/pdftk_test.go
@@ -159,25 +159,33 @@ func TestPdfCpu_Split(t *testing.T) {
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
+ {
+ scenario: "ErrPdfSplitModeNotSupported (no unify with pages)",
+ ctx: context.TODO(),
+ mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1", Unify: false},
+ expectError: true,
+ expectedError: gotenberg.ErrPdfSplitModeNotSupported,
+ expectOutputPathsCount: 0,
+ },
{
scenario: "invalid context",
ctx: nil,
- mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"},
+ mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
- mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"},
+ mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "",
expectError: true,
expectOutputPathsCount: 0,
},
{
- scenario: "success (pages)",
+ scenario: "success (pages & unify)",
ctx: context.TODO(),
- mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"},
+ mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 1,
diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go
index 2c010d2ec..34785adef 100644
--- a/pkg/modules/qpdf/qpdf.go
+++ b/pkg/modules/qpdf/qpdf.go
@@ -59,6 +59,9 @@ func (engine *QPdf) Split(ctx context.Context, logger *zap.Logger, mode gotenber
switch mode.Mode {
case gotenberg.SplitModePages:
+ if !mode.Unify {
+ return nil, fmt.Errorf("split PDFs using mode '%s' without unify with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
+ }
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)
diff --git a/pkg/modules/qpdf/qpdf_test.go b/pkg/modules/qpdf/qpdf_test.go
index b32976cbf..9c79721b1 100644
--- a/pkg/modules/qpdf/qpdf_test.go
+++ b/pkg/modules/qpdf/qpdf_test.go
@@ -159,25 +159,33 @@ func TestQPdf_Split(t *testing.T) {
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
+ {
+ scenario: "ErrPdfSplitModeNotSupported (no unify with pages)",
+ ctx: context.TODO(),
+ mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1", Unify: false},
+ expectError: true,
+ expectedError: gotenberg.ErrPdfSplitModeNotSupported,
+ expectOutputPathsCount: 0,
+ },
{
scenario: "invalid context",
ctx: nil,
- mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"},
+ mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
- mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"},
+ mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "",
expectError: true,
expectOutputPathsCount: 0,
},
{
- scenario: "success (pages)",
+ scenario: "success (pages & unify)",
ctx: context.TODO(),
- mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2"},
+ mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 1,
From 16807bd57fcc7235c0b7e65896de17a2b1d90df3 Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Mon, 23 Dec 2024 10:04:24 +0100
Subject: [PATCH 004/254] fix(split): wrong output paths when converting to
PDF/A & PDF/UA
---
pkg/modules/chromium/routes.go | 2 ++
pkg/modules/libreoffice/routes.go | 2 ++
2 files changed, 4 insertions(+)
diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go
index ee330a26a..2c5791719 100644
--- a/pkg/modules/chromium/routes.go
+++ b/pkg/modules/chromium/routes.go
@@ -661,6 +661,8 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("rename output path: %w", err)
}
}
+ } else {
+ outputPaths = convertOutputPaths
}
err = ctx.AddOutputPaths(outputPaths...)
diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go
index 86165833b..b6786189b 100644
--- a/pkg/modules/libreoffice/routes.go
+++ b/pkg/modules/libreoffice/routes.go
@@ -251,6 +251,8 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
return fmt.Errorf("rename output path: %w", err)
}
}
+ } else {
+ outputPaths = convertOutputPaths
}
}
From 910eb9b770fad7a7aac74add8b2573b880a10961 Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Wed, 25 Dec 2024 17:04:25 +0100
Subject: [PATCH 005/254] feat(state): improve clean up when LibreOffice and
Chromium are restarted
---
go.mod | 16 ++++++--
go.sum | 28 +++++++++++--
pkg/gotenberg/gc.go | 7 ++--
pkg/gotenberg/gc_test.go | 3 +-
pkg/modules/chromium/browser.go | 48 ++++++++++++++++++++--
pkg/modules/libreoffice/api/libreoffice.go | 11 ++---
6 files changed, 93 insertions(+), 20 deletions(-)
diff --git a/go.mod b/go.mod
index 4edbc38b4..582f6d7fb 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
github.com/alexliesenfeld/health v0.8.0
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/barasher/go-exiftool v1.10.0
- github.com/chromedp/cdproto v0.0.0-20241208230723-d1c7de7e5dd2
+ github.com/chromedp/cdproto v0.0.0-20241222144035-c16d098c0fb6
github.com/chromedp/chromedp v0.11.2
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0
@@ -35,7 +35,10 @@ require (
golang.org/x/text v0.21.0
)
-require github.com/dlclark/regexp2 v1.11.4
+require (
+ github.com/dlclark/regexp2 v1.11.4
+ github.com/shirou/gopsutil/v4 v4.24.11
+)
require (
github.com/aymerick/douceur v0.2.0 // indirect
@@ -43,20 +46,27 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
+ github.com/ebitengine/purego v0.8.1 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
+ github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.61.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
+ github.com/tklauser/go-sysconf v0.3.14 // indirect
+ github.com/tklauser/numcpus v0.9.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
+ github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/time v0.8.0 // indirect
- google.golang.org/protobuf v1.36.0 // indirect
+ google.golang.org/protobuf v1.36.1 // indirect
)
diff --git a/go.sum b/go.sum
index cc6a4a12e..209cf1a7a 100644
--- a/go.sum
+++ b/go.sum
@@ -11,8 +11,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/chromedp/cdproto v0.0.0-20241208230723-d1c7de7e5dd2 h1:fJob5N/Eprtd427U84kFpQhAHIEqJYuDzveaL6T4Xsk=
-github.com/chromedp/cdproto v0.0.0-20241208230723-d1c7de7e5dd2/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
+github.com/chromedp/cdproto v0.0.0-20241222144035-c16d098c0fb6 h1:dAUcp/W5RpJSZW/HksEHfAAoMBIvSFFIwslAFEte+6g=
+github.com/chromedp/cdproto v0.0.0-20241222144035-c16d098c0fb6/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0=
github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
@@ -25,8 +25,13 @@ github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cn
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
+github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
+github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
@@ -67,6 +72,8 @@ github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
+github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
+github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -90,6 +97,8 @@ github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
@@ -100,6 +109,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/shirou/gopsutil/v4 v4.24.11 h1:WaU9xqGFKvFfsUv94SXcUPD7rCkU0vr/asVdQOBZNj8=
+github.com/shirou/gopsutil/v4 v4.24.11/go.mod h1:s4D/wg+ag4rG0WO7AiTj2BeYCRhym0vM7DHbZRxnIT8=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -108,6 +119,10 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
+github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
+github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
+github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
@@ -120,6 +135,8 @@ github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofm
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -132,7 +149,10 @@ golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@@ -143,7 +163,7 @@ golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ=
-google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
+google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/pkg/gotenberg/gc.go b/pkg/gotenberg/gc.go
index 3de80a4cc..2f53cc385 100644
--- a/pkg/gotenberg/gc.go
+++ b/pkg/gotenberg/gc.go
@@ -5,13 +5,14 @@ import (
"os"
"path/filepath"
"strings"
+ "time"
"go.uber.org/zap"
)
// GarbageCollect scans the root path and deletes files or directories with
-// names containing specific substrings.
-func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string) error {
+// names containing specific substrings and before a given experiation time.
+func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string, expirationTime time.Time) error {
logger = logger.Named("gc")
// To make sure that the next Walk method stays on
@@ -36,7 +37,7 @@ func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string)
}
for _, substr := range includeSubstr {
- if strings.Contains(info.Name(), substr) || path == substr {
+ if (strings.Contains(info.Name(), substr) || path == substr) && info.ModTime().Before(expirationTime) {
err := os.RemoveAll(path)
if err != nil {
return fmt.Errorf("garbage collect '%s': %w", path, err)
diff --git a/pkg/gotenberg/gc_test.go b/pkg/gotenberg/gc_test.go
index 4dd58e4fb..0ed089a55 100644
--- a/pkg/gotenberg/gc_test.go
+++ b/pkg/gotenberg/gc_test.go
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"testing"
+ "time"
"github.com/google/uuid"
"go.uber.org/zap"
@@ -64,7 +65,7 @@ func TestGarbageCollect(t *testing.T) {
}
}()
- err := GarbageCollect(zap.NewNop(), tc.rootPath, tc.includeSubstr)
+ err := GarbageCollect(zap.NewNop(), tc.rootPath, tc.includeSubstr, time.Now())
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go
index 1da24d877..b1fd38c75 100644
--- a/pkg/modules/chromium/browser.go
+++ b/pkg/modules/chromium/browser.go
@@ -15,6 +15,7 @@ import (
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/dlclark/regexp2"
+ "github.com/shirou/gopsutil/v4/process"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -162,21 +163,60 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
// Always remove the user profile directory created by Chromium.
copyUserProfileDirPath := b.userProfileDirPath
- defer func(userProfileDirPath string) {
+ expirationTime := time.Now()
+ defer func(userProfileDirPath string, expirationTime time.Time) {
+ // See:
+ // https://github.com/SeleniumHQ/docker-selenium/blob/7216d060d86872afe853ccda62db0dfab5118dc7/NodeChrome/chrome-cleanup.sh
+ // https://github.com/SeleniumHQ/docker-selenium/blob/7216d060d86872afe853ccda62db0dfab5118dc7/NodeChromium/chrome-cleanup.sh
go func() {
+ // Clean up stuck processes.
+ ps, err := process.Processes()
+ if err != nil {
+ logger.Error(fmt.Sprintf("list processes: %v", err))
+ } else {
+ for _, p := range ps {
+ func() {
+ cmdline, err := p.Cmdline()
+ if err != nil {
+ return
+ }
+
+ if !strings.Contains(cmdline, "chromium/chromium") && !strings.Contains(cmdline, "chrome/chrome") {
+ return
+ }
+
+ killCtx, cancel := context.WithTimeout(context.Background(), time.Second*5)
+ defer cancel()
+
+ err = p.KillWithContext(killCtx)
+ if err != nil {
+ logger.Error(fmt.Sprintf("kill process: %v", err))
+ } else {
+ logger.Info(fmt.Sprintf("Chromium process %d killed", p.Pid))
+ }
+ }()
+ }
+ }
+
// FIXME: Chromium seems to recreate the user profile directory
// right after its deletion if we do not wait a certain amount
// of time before deleting it.
<-time.After(10 * time.Second)
- err := os.RemoveAll(userProfileDirPath)
+ err = os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove Chromium's user profile directory: %s", err))
+ } else {
+ logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath))
}
- logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath))
+ // Also remove Chromium specific files in the temporary directory.
+ err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{".org.chromium.Chromium", ".com.google.Chrome"}, expirationTime)
+ if err != nil {
+ logger.Error(err.Error())
+ }
}()
- }(copyUserProfileDirPath)
+ }(copyUserProfileDirPath, expirationTime)
b.ctxMu.Lock()
defer b.ctxMu.Unlock()
diff --git a/pkg/modules/libreoffice/api/libreoffice.go b/pkg/modules/libreoffice/api/libreoffice.go
index ee332ad48..652d0e05b 100644
--- a/pkg/modules/libreoffice/api/libreoffice.go
+++ b/pkg/modules/libreoffice/api/libreoffice.go
@@ -190,22 +190,23 @@ func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
// Always remove the user profile directory created by LibreOffice.
copyUserProfileDirPath := p.userProfileDirPath
- defer func(userProfileDirPath string) {
+ expirationTime := time.Now()
+ defer func(userProfileDirPath string, expirationTime time.Time) {
go func() {
err := os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
+ } else {
+ logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
}
- logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
-
// Also remove LibreOffice specific files in the temporary directory.
- err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"})
+ err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}, expirationTime)
if err != nil {
logger.Error(err.Error())
}
}()
- }(copyUserProfileDirPath)
+ }(copyUserProfileDirPath, expirationTime)
p.cfgMu.Lock()
defer p.cfgMu.Unlock()
From 1762596fced0545226c74535bef3785cb493f486 Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Thu, 2 Jan 2025 17:18:53 +0100
Subject: [PATCH 006/254] chore(dep)s: update dependencies
---
go.mod | 4 ++--
go.sum | 8 ++++----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/go.mod b/go.mod
index 582f6d7fb..cbb32756c 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
github.com/alexliesenfeld/health v0.8.0
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/barasher/go-exiftool v1.10.0
- github.com/chromedp/cdproto v0.0.0-20241222144035-c16d098c0fb6
+ github.com/chromedp/cdproto v0.0.0-20250101192427-60a0ca35cb84
github.com/chromedp/chromedp v0.11.2
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0
@@ -37,7 +37,7 @@ require (
require (
github.com/dlclark/regexp2 v1.11.4
- github.com/shirou/gopsutil/v4 v4.24.11
+ github.com/shirou/gopsutil/v4 v4.24.12
)
require (
diff --git a/go.sum b/go.sum
index 209cf1a7a..ab8ef6064 100644
--- a/go.sum
+++ b/go.sum
@@ -11,8 +11,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/chromedp/cdproto v0.0.0-20241222144035-c16d098c0fb6 h1:dAUcp/W5RpJSZW/HksEHfAAoMBIvSFFIwslAFEte+6g=
-github.com/chromedp/cdproto v0.0.0-20241222144035-c16d098c0fb6/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
+github.com/chromedp/cdproto v0.0.0-20250101192427-60a0ca35cb84 h1:NXWP4iSVz4BPGk7Z/fPXL0c44hiWbrbyhBY0LwKKZnY=
+github.com/chromedp/cdproto v0.0.0-20250101192427-60a0ca35cb84/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0=
github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
@@ -109,8 +109,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/shirou/gopsutil/v4 v4.24.11 h1:WaU9xqGFKvFfsUv94SXcUPD7rCkU0vr/asVdQOBZNj8=
-github.com/shirou/gopsutil/v4 v4.24.11/go.mod h1:s4D/wg+ag4rG0WO7AiTj2BeYCRhym0vM7DHbZRxnIT8=
+github.com/shirou/gopsutil/v4 v4.24.12 h1:qvePBOk20e0IKA1QXrIIU+jmk+zEiYVVx06WjBRlZo4=
+github.com/shirou/gopsutil/v4 v4.24.12/go.mod h1:DCtMPAad2XceTeIAbGyVfycbYQNBGk2P8cvDi7/VN9o=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
From 984cf77ee0bfc46c69b127c3d4f6877a5657502c Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Thu, 2 Jan 2025 17:22:13 +0100
Subject: [PATCH 007/254] fix(chromium): info log into debug log + slight
improvement of the killing of stuck processes
---
pkg/modules/chromium/browser.go | 57 +++++++++++++++++----------------
1 file changed, 29 insertions(+), 28 deletions(-)
diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go
index b1fd38c75..d8a2d724f 100644
--- a/pkg/modules/chromium/browser.go
+++ b/pkg/modules/chromium/browser.go
@@ -168,36 +168,37 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
// See:
// https://github.com/SeleniumHQ/docker-selenium/blob/7216d060d86872afe853ccda62db0dfab5118dc7/NodeChrome/chrome-cleanup.sh
// https://github.com/SeleniumHQ/docker-selenium/blob/7216d060d86872afe853ccda62db0dfab5118dc7/NodeChromium/chrome-cleanup.sh
- go func() {
- // Clean up stuck processes.
- ps, err := process.Processes()
- if err != nil {
- logger.Error(fmt.Sprintf("list processes: %v", err))
- } else {
- for _, p := range ps {
- func() {
- cmdline, err := p.Cmdline()
- if err != nil {
- return
- }
-
- if !strings.Contains(cmdline, "chromium/chromium") && !strings.Contains(cmdline, "chrome/chrome") {
- return
- }
-
- killCtx, cancel := context.WithTimeout(context.Background(), time.Second*5)
- defer cancel()
-
- err = p.KillWithContext(killCtx)
- if err != nil {
- logger.Error(fmt.Sprintf("kill process: %v", err))
- } else {
- logger.Info(fmt.Sprintf("Chromium process %d killed", p.Pid))
- }
- }()
- }
+
+ // Clean up stuck processes.
+ ps, err := process.Processes()
+ if err != nil {
+ logger.Error(fmt.Sprintf("list processes: %v", err))
+ } else {
+ for _, p := range ps {
+ func() {
+ cmdline, err := p.Cmdline()
+ if err != nil {
+ return
+ }
+
+ if !strings.Contains(cmdline, "chromium/chromium") && !strings.Contains(cmdline, "chrome/chrome") {
+ return
+ }
+
+ killCtx, cancel := context.WithTimeout(context.Background(), time.Second*5)
+ defer cancel()
+
+ err = p.KillWithContext(killCtx)
+ if err != nil {
+ logger.Error(fmt.Sprintf("kill process: %v", err))
+ } else {
+ logger.Debug(fmt.Sprintf("Chromium process %d killed", p.Pid))
+ }
+ }()
}
+ }
+ go func() {
// FIXME: Chromium seems to recreate the user profile directory
// right after its deletion if we do not wait a certain amount
// of time before deleting it.
From 2f69af1a52720e8fffa7f34bdb9a795562659a88 Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Mon, 6 Jan 2025 09:17:13 +0100
Subject: [PATCH 008/254] chore(LICENSE): remove year
---
LICENSE | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/LICENSE b/LICENSE
index 0f9e9ccfa..5a6f068cd 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2024 Julien Neuhart
+Copyright (c) Julien Neuhart
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
From bdb3f11ac706976e89ee757b89886a46f7c2aa6a Mon Sep 17 00:00:00 2001
From: Hugo Tacla <87522622+omni-htg@users.noreply.github.com>
Date: Wed, 8 Jan 2025 17:14:53 +0100
Subject: [PATCH 009/254] fix(typo): medata -> metadata (#1092)
* Update pdfengines.go
* Update pdfengines_test.go
* Update multi.go
* Update multi_test.go
---
pkg/modules/pdfengines/multi.go | 24 +++++++++----------
pkg/modules/pdfengines/multi_test.go | 16 ++++++-------
pkg/modules/pdfengines/pdfengines.go | 24 +++++++++----------
pkg/modules/pdfengines/pdfengines_test.go | 28 +++++++++++------------
4 files changed, 46 insertions(+), 46 deletions(-)
diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go
index c6c9514d6..a78d6466d 100644
--- a/pkg/modules/pdfengines/multi.go
+++ b/pkg/modules/pdfengines/multi.go
@@ -12,11 +12,11 @@ import (
)
type multiPdfEngines struct {
- mergeEngines []gotenberg.PdfEngine
- splitEngines []gotenberg.PdfEngine
- convertEngines []gotenberg.PdfEngine
- readMedataEngines []gotenberg.PdfEngine
- writeMedataEngines []gotenberg.PdfEngine
+ mergeEngines []gotenberg.PdfEngine
+ splitEngines []gotenberg.PdfEngine
+ convertEngines []gotenberg.PdfEngine
+ readMetadataEngines []gotenberg.PdfEngine
+ writeMetadataEngines []gotenberg.PdfEngine
}
func newMultiPdfEngines(
@@ -24,14 +24,14 @@ func newMultiPdfEngines(
splitEngines,
convertEngines,
readMetadataEngines,
- writeMedataEngines []gotenberg.PdfEngine,
+ writeMetadataEngines []gotenberg.PdfEngine,
) *multiPdfEngines {
return &multiPdfEngines{
mergeEngines: mergeEngines,
splitEngines: splitEngines,
convertEngines: convertEngines,
- readMedataEngines: readMetadataEngines,
- writeMedataEngines: writeMedataEngines,
+ readMetadataEngines: readMetadataEngines,
+ writeMetadataEngines: writeMetadataEngines,
}
}
@@ -132,16 +132,16 @@ func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logg
var err error
var mu sync.Mutex // to safely append errors.
- resultChan := make(chan readMetadataResult, len(multi.readMedataEngines))
+ resultChan := make(chan readMetadataResult, len(multi.readMetadataEngines))
- for _, engine := range multi.readMedataEngines {
+ for _, engine := range multi.readMetadataEngines {
go func(engine gotenberg.PdfEngine) {
metadata, err := engine.ReadMetadata(ctx, logger, inputPath)
resultChan <- readMetadataResult{metadata: metadata, err: err}
}(engine)
}
- for range multi.readMedataEngines {
+ for range multi.readMetadataEngines {
select {
case result := <-resultChan:
if result.err != nil {
@@ -163,7 +163,7 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log
var err error
errChan := make(chan error, 1)
- for _, engine := range multi.writeMedataEngines {
+ for _, engine := range multi.writeMetadataEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.WriteMetadata(ctx, logger, metadata, inputPath)
}(engine)
diff --git a/pkg/modules/pdfengines/multi_test.go b/pkg/modules/pdfengines/multi_test.go
index 6e5686c0c..7dddac05e 100644
--- a/pkg/modules/pdfengines/multi_test.go
+++ b/pkg/modules/pdfengines/multi_test.go
@@ -295,7 +295,7 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
- readMedataEngines: []gotenberg.PdfEngine{
+ readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return make(map[string]interface{}), nil
@@ -308,7 +308,7 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
- readMedataEngines: []gotenberg.PdfEngine{
+ readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, errors.New("foo")
@@ -326,7 +326,7 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
- readMedataEngines: []gotenberg.PdfEngine{
+ readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, errors.New("foo")
@@ -345,7 +345,7 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
{
scenario: "context expired",
engine: &multiPdfEngines{
- readMedataEngines: []gotenberg.PdfEngine{
+ readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return make(map[string]interface{}), nil
@@ -386,7 +386,7 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
- writeMedataEngines: []gotenberg.PdfEngine{
+ writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
@@ -399,7 +399,7 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
- writeMedataEngines: []gotenberg.PdfEngine{
+ writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo")
@@ -417,7 +417,7 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
- writeMedataEngines: []gotenberg.PdfEngine{
+ writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo")
@@ -436,7 +436,7 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
{
scenario: "context expired",
engine: &multiPdfEngines{
- writeMedataEngines: []gotenberg.PdfEngine{
+ writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go
index 4f07f83ea..4536ff7b5 100644
--- a/pkg/modules/pdfengines/pdfengines.go
+++ b/pkg/modules/pdfengines/pdfengines.go
@@ -27,13 +27,13 @@ func init() {
// the [api.Router] interface to expose relevant PDF processing routes if
// enabled.
type PdfEngines struct {
- mergeNames []string
- splitNames []string
- convertNames []string
- readMetadataNames []string
- writeMedataNames []string
- engines []gotenberg.PdfEngine
- disableRoutes bool
+ mergeNames []string
+ splitNames []string
+ convertNames []string
+ readMetadataNames []string
+ writeMetadataNames []string
+ engines []gotenberg.PdfEngine
+ disableRoutes bool
}
// Descriptor returns a PdfEngines' module descriptor.
@@ -116,9 +116,9 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.readMetadataNames = readMetadataNames
}
- mod.writeMedataNames = defaultNames
+ mod.writeMetadataNames = defaultNames
if len(writeMetadataNames) > 0 {
- mod.writeMedataNames = writeMetadataNames
+ mod.writeMetadataNames = writeMetadataNames
}
return nil
@@ -172,7 +172,7 @@ func (mod *PdfEngines) Validate() error {
findNonExistingEngines(mod.splitNames)
findNonExistingEngines(mod.convertNames)
findNonExistingEngines(mod.readMetadataNames)
- findNonExistingEngines(mod.writeMedataNames)
+ findNonExistingEngines(mod.writeMetadataNames)
if len(nonExistingEngines) == 0 {
return nil
@@ -189,7 +189,7 @@ func (mod *PdfEngines) SystemMessages() []string {
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[:], " ")),
+ fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")),
}
}
@@ -214,7 +214,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
engines(mod.splitNames),
engines(mod.convertNames),
engines(mod.readMetadataNames),
- engines(mod.writeMedataNames),
+ engines(mod.writeMetadataNames),
), nil
}
diff --git a/pkg/modules/pdfengines/pdfengines_test.go b/pkg/modules/pdfengines/pdfengines_test.go
index 505a229f2..8aeb042cd 100644
--- a/pkg/modules/pdfengines/pdfengines_test.go
+++ b/pkg/modules/pdfengines/pdfengines_test.go
@@ -193,7 +193,7 @@ func TestPdfEngines_Provision(t *testing.T) {
t.Fatalf("expected %d read metadata names but got %d", len(tc.expectedReadMetadataPdfEngines), len(mod.readMetadataNames))
}
- if len(tc.expectedWriteMetadataPdfEngines) != len(mod.writeMedataNames) {
+ if len(tc.expectedWriteMetadataPdfEngines) != len(mod.writeMetadataNames) {
t.Fatalf("expected %d write metadata names but got %d", len(tc.expectedWriteMetadataPdfEngines), len(mod.writeMedataNames))
}
@@ -221,7 +221,7 @@ func TestPdfEngines_Provision(t *testing.T) {
}
}
- for index, name := range mod.writeMedataNames {
+ for index, name := range mod.writeMetadataNames {
if name != tc.expectedWriteMetadataPdfEngines[index] {
t.Fatalf("expected write metadat name at index %d to be %s, but got: %s", index, name, tc.expectedWriteMetadataPdfEngines[index])
}
@@ -289,11 +289,11 @@ func TestPdfEngines_Validate(t *testing.T) {
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := PdfEngines{
- mergeNames: tc.names,
- convertNames: tc.names,
- readMetadataNames: tc.names,
- writeMedataNames: tc.names,
- engines: tc.engines,
+ mergeNames: tc.names,
+ convertNames: tc.names,
+ readMetadataNames: tc.names,
+ writeMetadataNames: tc.names,
+ engines: tc.engines,
}
err := mod.Validate()
@@ -315,7 +315,7 @@ func TestPdfEngines_SystemMessages(t *testing.T) {
mod.splitNames = []string{"foo", "bar"}
mod.convertNames = []string{"foo", "bar"}
mod.readMetadataNames = []string{"foo", "bar"}
- mod.writeMedataNames = []string{"foo", "bar"}
+ mod.writeMetadataNames = []string{"foo", "bar"}
messages := mod.SystemMessages()
if len(messages) != 5 {
@@ -327,7 +327,7 @@ func TestPdfEngines_SystemMessages(t *testing.T) {
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[:], " ")),
+ fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")),
}
for i, message := range messages {
@@ -339,11 +339,11 @@ 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"},
+ mergeNames: []string{"foo", "bar"},
+ splitNames: []string{"foo", "bar"},
+ convertNames: []string{"foo", "bar"},
+ readMetadataNames: []string{"foo", "bar"},
+ writeMetadataNames: []string{"foo", "bar"},
engines: func() []gotenberg.PdfEngine {
engine1 := &struct {
gotenberg.ModuleMock
From f72428056ae285fcfb6630d053c7d3bd59d5764b Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Wed, 8 Jan 2025 17:15:30 +0100
Subject: [PATCH 010/254] chore(code): fmt
---
pkg/modules/pdfengines/multi.go | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go
index a78d6466d..a01515b52 100644
--- a/pkg/modules/pdfengines/multi.go
+++ b/pkg/modules/pdfengines/multi.go
@@ -27,9 +27,9 @@ func newMultiPdfEngines(
writeMetadataEngines []gotenberg.PdfEngine,
) *multiPdfEngines {
return &multiPdfEngines{
- mergeEngines: mergeEngines,
- splitEngines: splitEngines,
- convertEngines: convertEngines,
+ mergeEngines: mergeEngines,
+ splitEngines: splitEngines,
+ convertEngines: convertEngines,
readMetadataEngines: readMetadataEngines,
writeMetadataEngines: writeMetadataEngines,
}
From 1ca27fdba2933555480e06e4f68906b1e1c1b222 Mon Sep 17 00:00:00 2001
From: Hugo Tacla <87522622+omni-htg@users.noreply.github.com>
Date: Wed, 8 Jan 2025 19:24:43 +0100
Subject: [PATCH 011/254] fix(pdfengines): wrong variable name (#1093)
---
pkg/modules/pdfengines/pdfengines_test.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pkg/modules/pdfengines/pdfengines_test.go b/pkg/modules/pdfengines/pdfengines_test.go
index 8aeb042cd..66546ebe4 100644
--- a/pkg/modules/pdfengines/pdfengines_test.go
+++ b/pkg/modules/pdfengines/pdfengines_test.go
@@ -194,7 +194,7 @@ func TestPdfEngines_Provision(t *testing.T) {
}
if len(tc.expectedWriteMetadataPdfEngines) != len(mod.writeMetadataNames) {
- t.Fatalf("expected %d write metadata names but got %d", len(tc.expectedWriteMetadataPdfEngines), len(mod.writeMedataNames))
+ t.Fatalf("expected %d write metadata names but got %d", len(tc.expectedWriteMetadataPdfEngines), len(mod.writeMetadataNames))
}
for index, name := range mod.mergeNames {
From e3a961623b2baa204bb558a1085f2cc7343a223a Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Fri, 10 Jan 2025 16:58:26 +0100
Subject: [PATCH 012/254] feat(logging): add new field log_type with access or
application as values
---
pkg/modules/api/middlewares.go | 18 +++++++++++++-----
1 file changed, 13 insertions(+), 5 deletions(-)
diff --git a/pkg/modules/api/middlewares.go b/pkg/modules/api/middlewares.go
index 78bcb8542..f93545103 100644
--- a/pkg/modules/api/middlewares.go
+++ b/pkg/modules/api/middlewares.go
@@ -161,9 +161,12 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
trace := c.Get("trace").(string)
rootPath := c.Get("rootPath").(string)
- // Create the request logger and add it to our locals.
- reqLogger := logger.With(zap.String("trace", trace))
- c.Set("logger", reqLogger.Named(func() string {
+ // Create the application logger and add it to our locals.
+ appLogger := logger.
+ With(zap.String("log_type", "application")).
+ With(zap.String("trace", trace))
+
+ c.Set("logger", appLogger.Named(func() string {
return strings.ReplaceAll(
strings.ReplaceAll(c.Request().URL.Path, rootPath, ""),
"/",
@@ -177,6 +180,11 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
c.Error(err)
}
+ // Create the access logger.
+ accessLogger := logger.
+ With(zap.String("log_type", "access")).
+ With(zap.String("trace", trace))
+
for _, path := range disableLoggingForPaths {
URI := fmt.Sprintf("%s%s", rootPath, path)
@@ -212,9 +220,9 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
fields[11] = zap.Int64("bytes_out", c.Response().Size)
if err != nil {
- reqLogger.Error(err.Error(), fields...)
+ accessLogger.Error(err.Error(), fields...)
} else {
- reqLogger.Info("request handled", fields...)
+ accessLogger.Info("request handled", fields...)
}
return nil
From e639a1dd73b3cc2e81844530b9821a56e807e93f Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Fri, 10 Jan 2025 16:59:01 +0100
Subject: [PATCH 013/254] chore(deps): update Go dependencies
---
go.mod | 18 +++++++++---------
go.sum | 38 ++++++++++++++++++--------------------
2 files changed, 27 insertions(+), 29 deletions(-)
diff --git a/go.mod b/go.mod
index cbb32756c..161291263 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
github.com/alexliesenfeld/health v0.8.0
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/barasher/go-exiftool v1.10.0
- github.com/chromedp/cdproto v0.0.0-20250101192427-60a0ca35cb84
+ github.com/chromedp/cdproto v0.0.0-20250109193942-1ec2f6cf5d86
github.com/chromedp/chromedp v0.11.2
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0
@@ -27,11 +27,11 @@ require (
github.com/ulikunitz/xz v0.5.12 // indirect
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.0
- golang.org/x/crypto v0.31.0 // indirect
- golang.org/x/net v0.33.0
+ golang.org/x/crypto v0.32.0 // indirect
+ golang.org/x/net v0.34.0
golang.org/x/sync v0.10.0
- golang.org/x/sys v0.28.0 // indirect
- golang.org/x/term v0.27.0
+ golang.org/x/sys v0.29.0 // indirect
+ golang.org/x/term v0.28.0
golang.org/x/text v0.21.0
)
@@ -46,7 +46,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
- github.com/ebitengine/purego v0.8.1 // indirect
+ github.com/ebitengine/purego v0.8.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
@@ -55,7 +55,7 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
- github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
@@ -67,6 +67,6 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
- golang.org/x/time v0.8.0 // indirect
- google.golang.org/protobuf v1.36.1 // indirect
+ golang.org/x/time v0.9.0 // indirect
+ google.golang.org/protobuf v1.36.2 // indirect
)
diff --git a/go.sum b/go.sum
index ab8ef6064..3e6c00516 100644
--- a/go.sum
+++ b/go.sum
@@ -11,8 +11,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/chromedp/cdproto v0.0.0-20250101192427-60a0ca35cb84 h1:NXWP4iSVz4BPGk7Z/fPXL0c44hiWbrbyhBY0LwKKZnY=
-github.com/chromedp/cdproto v0.0.0-20250101192427-60a0ca35cb84/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
+github.com/chromedp/cdproto v0.0.0-20250109193942-1ec2f6cf5d86 h1:FGN/TKeWgmhrgZVyq5crllFY0MlEHX16fUOd2oq6uzs=
+github.com/chromedp/cdproto v0.0.0-20250109193942-1ec2f6cf5d86/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0=
github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
@@ -25,8 +25,8 @@ github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cn
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
-github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
-github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
+github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
@@ -76,9 +76,8 @@ github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMD
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
-github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
@@ -143,27 +142,26 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
-golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
-golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
-golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
+golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
+golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
-golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
-golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
+golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
+golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
+golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
-golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
+golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
-google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU=
+google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
From 291a4d39fed9ddc9fbffeed1b6e274017b309345 Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Fri, 10 Jan 2025 17:02:16 +0100
Subject: [PATCH 014/254] chore(deps): upgrade to golangci-lint v1.63.4
---
.github/workflows/continuous_integration.yml | 2 +-
Makefile | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml
index 35b5df5ad..9d51ff004 100644
--- a/.github/workflows/continuous_integration.yml
+++ b/.github/workflows/continuous_integration.yml
@@ -24,7 +24,7 @@ jobs:
- name: Run linters
uses: golangci/golangci-lint-action@v6
with:
- version: v1.61.0
+ version: v1.63.4
tests:
needs:
diff --git a/Makefile b/Makefile
index b8f0f10e4..6678a8b08 100644
--- a/Makefile
+++ b/Makefile
@@ -14,7 +14,7 @@ GOTENBERG_USER_UID=1001
NOTO_COLOR_EMOJI_VERSION=v2.047 # See https://github.com/googlefonts/noto-emoji/releases.
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
PDFCPU_VERSION=v0.8.1 # See https://github.com/pdfcpu/pdfcpu/releases.
-GOLANGCI_LINT_VERSION=v1.61.0 # See https://github.com/golangci/golangci-lint/releases.
+GOLANGCI_LINT_VERSION=v1.63.4 # See https://github.com/golangci/golangci-lint/releases.
.PHONY: build
build: ## Build the Gotenberg's Docker image
From a6f4c5651cdbb2953ff44f3f3eb78f81526e5667 Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Sat, 11 Jan 2025 11:08:19 +0100
Subject: [PATCH 015/254] feat(chromium): set the default value of the flag
--chromium-restart-after to 10
---
Makefile | 2 +-
pkg/modules/chromium/chromium.go | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Makefile b/Makefile
index 6678a8b08..ed69207cb 100644
--- a/Makefile
+++ b/Makefile
@@ -46,7 +46,7 @@ API-DOWNLOAD-FROM-DENY-LIST=
API-DOWNLOAD-FROM-FROM-MAX-RETRY=4
API-DISABLE-DOWNLOAD-FROM=false
API_DISABLE_HEALTH_CHECK_LOGGING=false
-CHROMIUM_RESTART_AFTER=0
+CHROMIUM_RESTART_AFTER=10
CHROMIUM_MAX_QUEUE_SIZE=0
CHROMIUM_AUTO_START=false
CHROMIUM_START_TIMEOUT=20s
diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go
index b31c5e707..7dfa8fa04 100644
--- a/pkg/modules/chromium/chromium.go
+++ b/pkg/modules/chromium/chromium.go
@@ -352,7 +352,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
ID: "chromium",
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("chromium", flag.ExitOnError)
- fs.Int64("chromium-restart-after", 0, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature")
+ fs.Int64("chromium-restart-after", 10, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature")
fs.Int64("chromium-max-queue-size", 0, "Maximum request queue size for Chromium. Set to 0 to disable this feature")
fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion")
fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")
From 6dc7d70a1f9e26162ddb1e370cf393e35ea91ba1 Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Sat, 11 Jan 2025 11:17:50 +0100
Subject: [PATCH 016/254] ci(continous integration): add concurrency to avoid
concurrent runs
---
.github/workflows/continuous_integration.yml | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml
index 9d51ff004..eded7994e 100644
--- a/.github/workflows/continuous_integration.yml
+++ b/.github/workflows/continuous_integration.yml
@@ -8,6 +8,10 @@ on:
branches:
- main
+concurrency:
+ group: ${{ (github.event_name == 'pull_request' && github.event.pull_request.number) || 'main' }}
+ cancel-in-progress: true
+
jobs:
lint:
From 3e3b24f61147f4a91ca138874c8476e001b15634 Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Sat, 11 Jan 2025 11:28:27 +0100
Subject: [PATCH 017/254] ci(workflows): add permissions
---
.github/workflows/continuous_delivery.yml | 3 +++
.github/workflows/continuous_integration.yml | 3 +++
2 files changed, 6 insertions(+)
diff --git a/.github/workflows/continuous_delivery.yml b/.github/workflows/continuous_delivery.yml
index b6b5e70d0..ca229fe0c 100644
--- a/.github/workflows/continuous_delivery.yml
+++ b/.github/workflows/continuous_delivery.yml
@@ -4,6 +4,9 @@ on:
release:
types: [ published ]
+permissions:
+ contents: read
+
jobs:
release:
name: Release Docker image
diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml
index eded7994e..51cd4eeb8 100644
--- a/.github/workflows/continuous_integration.yml
+++ b/.github/workflows/continuous_integration.yml
@@ -12,6 +12,9 @@ concurrency:
group: ${{ (github.event_name == 'pull_request' && github.event.pull_request.number) || 'main' }}
cancel-in-progress: true
+permissions:
+ contents: write
+
jobs:
lint:
From 6478528ebca11b87b77974ec7a2867e890c86fb2 Mon Sep 17 00:00:00 2001
From: Khiet Tam Nguyen <86177399+nktnet1@users.noreply.github.com>
Date: Fri, 17 Jan 2025 19:58:58 +1100
Subject: [PATCH 018/254] feat(api): add dummy root route pointing to docs
(#1099)
* feat(api): added dummy root route pointing to docs
* test(api): root request test added
* feat(api): added NoContent return for favicon
from @gulien
Co-authored-by: Julien Neuhart
* test(api): added favicon
* lint(api): fix format
* refactor(api): use %s for favicon.ico for consistency with /health
* test(api): added new recorder for each check
* docs(api): consistent comment for favicon
---------
Co-authored-by: Julien Neuhart
---
pkg/modules/api/api.go | 16 ++++++++++++++++
pkg/modules/api/api_test.go | 19 ++++++++++++++++++-
2 files changed, 34 insertions(+), 1 deletion(-)
diff --git a/pkg/modules/api/api.go b/pkg/modules/api/api.go
index 7daa18925..7e4302d55 100644
--- a/pkg/modules/api/api.go
+++ b/pkg/modules/api/api.go
@@ -483,6 +483,22 @@ func (a *Api) Start() error {
)
}
+ // Root route.
+ a.srv.GET(
+ a.rootPath,
+ func(c echo.Context) error {
+ return c.HTML(http.StatusOK, `Hey, this Gotenberg has no UI, it's an API. Head to the documentation to learn how to interact with it 🚀`)
+ },
+ )
+
+ // Favicon route.
+ a.srv.GET(
+ fmt.Sprintf("%s%s", a.rootPath, "favicon.ico"),
+ func(c echo.Context) error {
+ return c.NoContent(http.StatusNoContent)
+ },
+ )
+
// Let's not forget the health check routes...
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
checker := health.NewChecker(checks...)
diff --git a/pkg/modules/api/api_test.go b/pkg/modules/api/api_test.go
index b32eace84..9a1231766 100644
--- a/pkg/modules/api/api_test.go
+++ b/pkg/modules/api/api_test.go
@@ -866,15 +866,31 @@ func TestApi_Start(t *testing.T) {
return
}
- // health requests.
+ // root request.
recorder := httptest.NewRecorder()
+ rootRequest := httptest.NewRequest(http.MethodGet, "/", nil)
+ mod.srv.ServeHTTP(recorder, rootRequest)
+ if recorder.Code != http.StatusOK {
+ t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
+ }
+ // favicon request.
+ recorder = httptest.NewRecorder()
+ faviconRequest := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil)
+ mod.srv.ServeHTTP(recorder, faviconRequest)
+ if recorder.Code != http.StatusNoContent {
+ t.Errorf("expected %d status code but got %d", http.StatusNoContent, recorder.Code)
+ }
+
+ // health requests.
+ recorder = httptest.NewRecorder()
healthGetRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
mod.srv.ServeHTTP(recorder, healthGetRequest)
if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
}
+ recorder = httptest.NewRecorder()
healthHeadRequest := httptest.NewRequest(http.MethodHead, "/health", nil)
mod.srv.ServeHTTP(recorder, healthHeadRequest)
if recorder.Code != http.StatusOK {
@@ -882,6 +898,7 @@ func TestApi_Start(t *testing.T) {
}
// version request.
+ recorder = httptest.NewRecorder()
versionRequest := httptest.NewRequest(http.MethodGet, "/version", nil)
mod.srv.ServeHTTP(recorder, versionRequest)
if recorder.Code != http.StatusOK {
From 8e0cc7dd2f7c45b97b9ed2cc5edd0a760cb8698a Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Fri, 17 Jan 2025 10:00:08 +0100
Subject: [PATCH 019/254] fix(typo): root route HTML
---
pkg/modules/api/api.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pkg/modules/api/api.go b/pkg/modules/api/api.go
index 7e4302d55..4ec91d9f0 100644
--- a/pkg/modules/api/api.go
+++ b/pkg/modules/api/api.go
@@ -487,7 +487,7 @@ func (a *Api) Start() error {
a.srv.GET(
a.rootPath,
func(c echo.Context) error {
- return c.HTML(http.StatusOK, `Hey, this Gotenberg has no UI, it's an API. Head to the documentation to learn how to interact with it 🚀`)
+ return c.HTML(http.StatusOK, `Hey, Gotenberg has no UI, it's an API. Head to the documentation to learn how to interact with it 🚀`)
},
)
From 19c91aff194accad9d45ea12be58209b674a0363 Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Fri, 17 Jan 2025 14:08:01 +0100
Subject: [PATCH 020/254] feat(api): add root, favicon and version routes to
basic auth middleware if enabled
---
Makefile | 2 +-
pkg/modules/api/api.go | 21 ++++++++++++++++-----
pkg/modules/api/api_test.go | 3 +++
3 files changed, 20 insertions(+), 6 deletions(-)
diff --git a/Makefile b/Makefile
index ed69207cb..de2e0dbdf 100644
--- a/Makefile
+++ b/Makefile
@@ -36,7 +36,7 @@ API_BIND_IP=
API_START_TIMEOUT=30s
API_TIMEOUT=30s
API_BODY_LIMIT=
-API_ROOT_PATH=/
+API_ROOT_PATH="/"
API_TRACE_HEADER=Gotenberg-Trace
API_ENABLE_BASIC_AUTH=false
GOTENBERG_API_BASIC_AUTH_USERNAME=
diff --git a/pkg/modules/api/api.go b/pkg/modules/api/api.go
index 4ec91d9f0..e7aaf492c 100644
--- a/pkg/modules/api/api.go
+++ b/pkg/modules/api/api.go
@@ -456,14 +456,22 @@ func (a *Api) Start() error {
hardTimeout := a.timeout + (time.Duration(5) * time.Second)
+ // Basic auth?
+ var securityMiddleware echo.MiddlewareFunc
+ if a.basicAuthUsername != "" {
+ securityMiddleware = basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword)
+ } else {
+ securityMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ return next(c)
+ }
+ }
+ }
+
// Add the modules' routes and their specific middlewares.
for _, route := range a.routes {
var middlewares []echo.MiddlewareFunc
-
- // Basic auth?
- if a.basicAuthUsername != "" {
- middlewares = append(middlewares, basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword))
- }
+ middlewares = append(middlewares, securityMiddleware)
if route.IsMultipart {
middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout, a.bodyLimit, a.downloadFromCfg))
@@ -489,6 +497,7 @@ func (a *Api) Start() error {
func(c echo.Context) error {
return c.HTML(http.StatusOK, `Hey, Gotenberg has no UI, it's an API. Head to the documentation to learn how to interact with it 🚀`)
},
+ securityMiddleware,
)
// Favicon route.
@@ -497,6 +506,7 @@ func (a *Api) Start() error {
func(c echo.Context) error {
return c.NoContent(http.StatusNoContent)
},
+ securityMiddleware,
)
// Let's not forget the health check routes...
@@ -525,6 +535,7 @@ func (a *Api) Start() error {
func(c echo.Context) error {
return c.String(http.StatusOK, gotenberg.Version)
},
+ securityMiddleware,
)
// Wait for all modules to be ready.
diff --git a/pkg/modules/api/api_test.go b/pkg/modules/api/api_test.go
index 9a1231766..cd5c3e3a2 100644
--- a/pkg/modules/api/api_test.go
+++ b/pkg/modules/api/api_test.go
@@ -869,6 +869,7 @@ func TestApi_Start(t *testing.T) {
// root request.
recorder := httptest.NewRecorder()
rootRequest := httptest.NewRequest(http.MethodGet, "/", nil)
+ rootRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
mod.srv.ServeHTTP(recorder, rootRequest)
if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
@@ -877,6 +878,7 @@ func TestApi_Start(t *testing.T) {
// favicon request.
recorder = httptest.NewRecorder()
faviconRequest := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil)
+ faviconRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
mod.srv.ServeHTTP(recorder, faviconRequest)
if recorder.Code != http.StatusNoContent {
t.Errorf("expected %d status code but got %d", http.StatusNoContent, recorder.Code)
@@ -900,6 +902,7 @@ func TestApi_Start(t *testing.T) {
// version request.
recorder = httptest.NewRecorder()
versionRequest := httptest.NewRequest(http.MethodGet, "/version", nil)
+ versionRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
mod.srv.ServeHTTP(recorder, versionRequest)
if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
From ecb5d974c57e2495c75b390cb9ae9bd59e7933d5 Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Fri, 17 Jan 2025 15:24:37 +0100
Subject: [PATCH 021/254] chore(Dockerfile): add apt-get upgrade -yqq
---
build/Dockerfile | 7 +++++++
test/Dockerfile | 1 +
2 files changed, 8 insertions(+)
diff --git a/build/Dockerfile b/build/Dockerfile
index 38771906b..5ec44d214 100644
--- a/build/Dockerfile
+++ b/build/Dockerfile
@@ -82,6 +82,7 @@ RUN \
# Install system dependencies required for the next instructions or debugging.
# Note: tini is a helper for reaping zombie processes.
apt-get update -qq &&\
+ apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg tini python3 default-jre-headless &&\
# Cleanup.
# Note: the Debian image does automatically a clean after each install thanks to a hook.
@@ -96,6 +97,7 @@ RUN \
# https://help.accusoft.com/PrizmDoc/v12.1/HTML/Installing_Asian_Fonts_on_Ubuntu_and_Debian.html.
curl -o ./ttf-mscorefonts-installer_3.8.1_all.deb http://httpredir.debian.org/debian/pool/contrib/m/msttcorefonts/ttf-mscorefonts-installer_3.8.1_all.deb &&\
apt-get update -qq &&\
+ apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
./ttf-mscorefonts-installer_3.8.1_all.deb \
culmus \
@@ -154,10 +156,12 @@ RUN \
curl https://dl.google.com/linux/linux_signing_key.pub | apt-key add - &&\
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list &&\
apt-get update -qq &&\
+ apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends --allow-unauthenticated google-chrome-stable &&\
mv /usr/bin/google-chrome-stable /usr/bin/chromium; \
elif [[ "$(dpkg --print-architecture)" == "armhf" ]]; then \
apt-get update -qq &&\
+ apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
debsnap chromium-common "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
debsnap chromium "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
@@ -166,6 +170,7 @@ RUN \
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
else \
apt-get update -qq &&\
+ apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
fi' &&\
# Verify installation.
@@ -177,6 +182,7 @@ RUN \
# Install LibreOffice & unoconverter.
echo "deb http://deb.debian.org/debian bookworm-backports main" >> /etc/apt/sources.list &&\
apt-get update -qq &&\
+ apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t bookworm-backports libreoffice &&\
curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.0.1/unoconv -o /usr/bin/unoconverter &&\
chmod +x /usr/bin/unoconverter &&\
@@ -196,6 +202,7 @@ RUN \
echo '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
chmod +x /usr/bin/pdftk &&\
apt-get update -qq &&\
+ apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf exiftool &&\
# See https://github.com/nextcloud/docker/issues/380.
mkdir -p /usr/share/man/man1 &&\
diff --git a/test/Dockerfile b/test/Dockerfile
index 5011f5066..a0dad3ff4 100644
--- a/test/Dockerfile
+++ b/test/Dockerfile
@@ -19,6 +19,7 @@ ENV CGO_ENABLED=1
COPY --from=golang /usr/local/go /usr/local/go
RUN apt-get update -qq &&\
+ apt-get upgrade -yqq &&\
apt-get install -y -qq --no-install-recommends \
sudo \
# gcc for cgo.
From 51b907bcf57ed8ee4990e2ff6db1bfe46d54a897 Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Fri, 17 Jan 2025 15:25:17 +0100
Subject: [PATCH 022/254] chore(deps): update Go dependencies
---
go.mod | 6 +++---
go.sum | 12 ++++++------
2 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/go.mod b/go.mod
index 161291263..a8cb2abc6 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
github.com/alexliesenfeld/health v0.8.0
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/barasher/go-exiftool v1.10.0
- github.com/chromedp/cdproto v0.0.0-20250109193942-1ec2f6cf5d86
+ github.com/chromedp/cdproto v0.0.0-20250113203156-3ff4b409e0d4
github.com/chromedp/chromedp v0.11.2
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0
@@ -59,7 +59,7 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
- github.com/prometheus/common v0.61.0 // indirect
+ github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.9.0 // indirect
@@ -68,5 +68,5 @@ require (
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/time v0.9.0 // indirect
- google.golang.org/protobuf v1.36.2 // indirect
+ google.golang.org/protobuf v1.36.3 // indirect
)
diff --git a/go.sum b/go.sum
index 3e6c00516..6c8ffc22f 100644
--- a/go.sum
+++ b/go.sum
@@ -11,8 +11,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/chromedp/cdproto v0.0.0-20250109193942-1ec2f6cf5d86 h1:FGN/TKeWgmhrgZVyq5crllFY0MlEHX16fUOd2oq6uzs=
-github.com/chromedp/cdproto v0.0.0-20250109193942-1ec2f6cf5d86/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
+github.com/chromedp/cdproto v0.0.0-20250113203156-3ff4b409e0d4 h1:xO38R20PvryeuBgQYnRU3WsNXFtr/iMyQVJednQVoZw=
+github.com/chromedp/cdproto v0.0.0-20250113203156-3ff4b409e0d4/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0=
github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
@@ -102,8 +102,8 @@ github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
-github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ=
-github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s=
+github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
+github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
@@ -161,7 +161,7 @@ golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU=
-google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
+google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
From 8fd1fa203e1faba535ee7d26f1e292c75346ef2b Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Fri, 17 Jan 2025 18:01:44 +0100
Subject: [PATCH 023/254] fix(Dockerfile): remove apt-get upgrade -yqq from
armv7 Chromium install
---
build/Dockerfile | 1 -
1 file changed, 1 deletion(-)
diff --git a/build/Dockerfile b/build/Dockerfile
index 5ec44d214..492cb6bc9 100644
--- a/build/Dockerfile
+++ b/build/Dockerfile
@@ -161,7 +161,6 @@ RUN \
mv /usr/bin/google-chrome-stable /usr/bin/chromium; \
elif [[ "$(dpkg --print-architecture)" == "armhf" ]]; then \
apt-get update -qq &&\
- apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
debsnap chromium-common "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
debsnap chromium "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
From c0bf81ad7ceb00e711a18f90c72b5e2183238fd4 Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Thu, 23 Jan 2025 17:04:28 +0100
Subject: [PATCH 024/254] ci(workflows): multiple jobs multiple architectures
build and push
---
.env | 14 ++
.github/workflows/continuous-delivery.yml | 58 ++++++
.github/workflows/continuous-integration.yml | 190 +++++++++++++++++++
.github/workflows/continuous_delivery.yml | 29 ---
.github/workflows/continuous_integration.yml | 102 ----------
.github/workflows/gotenberg-build-push.yml | 51 +++++
.github/workflows/gotenberg-merge-clean.yml | 40 ++++
Makefile | 32 +---
scripts/release.sh | 84 --------
9 files changed, 356 insertions(+), 244 deletions(-)
create mode 100644 .env
create mode 100644 .github/workflows/continuous-delivery.yml
create mode 100644 .github/workflows/continuous-integration.yml
delete mode 100644 .github/workflows/continuous_delivery.yml
delete mode 100644 .github/workflows/continuous_integration.yml
create mode 100644 .github/workflows/gotenberg-build-push.yml
create mode 100644 .github/workflows/gotenberg-merge-clean.yml
delete mode 100755 scripts/release.sh
diff --git a/.env b/.env
new file mode 100644
index 000000000..2c3e2685d
--- /dev/null
+++ b/.env
@@ -0,0 +1,14 @@
+GOLANG_VERSION=1.23
+DOCKER_REGISTRY=gotenberg
+DOCKER_REPOSITORY=gotenberg
+GOTENBERG_VERSION=snapshot
+GOTENBERG_USER_GID=1001
+GOTENBERG_USER_UID=1001
+NOTO_COLOR_EMOJI_VERSION=v2.047 # See https://github.com/googlefonts/noto-emoji/releases.
+PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
+PDFCPU_VERSION=v0.8.1 # See https://github.com/pdfcpu/pdfcpu/releases.
+GOLANGCI_LINT_VERSION=v1.63.4 # See https://github.com/golangci/golangci-lint/releases.
+GOTENBERG_VERSION=snapshot
+DOCKERFILE=build/Dockerfile
+DOCKERFILE_CLOUDRUN=build/Dockerfile.cloudrun
+DOCKER_BUILD_CONTEXT='.'
\ No newline at end of file
diff --git a/.github/workflows/continuous-delivery.yml b/.github/workflows/continuous-delivery.yml
new file mode 100644
index 000000000..449008acd
--- /dev/null
+++ b/.github/workflows/continuous-delivery.yml
@@ -0,0 +1,58 @@
+name: Continuous Delivery
+
+on:
+ release:
+ types: [ published ]
+
+permissions:
+ contents: read
+
+jobs:
+ release_amd64:
+ name: Release linux/amd64
+ uses: ./.github/workflows/gotenberg-build-push.yml
+ secrets: inherit
+ with:
+ runs_on: 'ubuntu-latest'
+ platform: 'linux/amd64'
+ gotenberg_version: ${{ github.event.release.tag_name }}
+
+ release_386:
+ name: Release linux/386
+ uses: ./.github/workflows/gotenberg-build-push.yml
+ secrets: inherit
+ with:
+ runs_on: 'ubuntu-latest'
+ platform: 'linux/386'
+ gotenberg_version: ${{ github.event.release.tag_name }}
+
+ release_arm64:
+ name: Release linux/arm64
+ uses: ./.github/workflows/gotenberg-build-push.yml
+ secrets: inherit
+ with:
+ runs_on: 'ubuntu-24.04-arm'
+ platform: 'linux/arm64'
+ gotenberg_version: ${{ github.event.release.tag_name }}
+
+ release_arm_v7:
+ name: Release linux/arm/v7
+ uses: ./.github/workflows/gotenberg-build-push.yml
+ secrets: inherit
+ with:
+ runs_on: 'ubuntu-24.04-arm'
+ platform: 'linux/arm/v7'
+ gotenberg_version: ${{ github.event.release.tag_name }}
+
+ merge_clean_release_tags:
+ needs:
+ - release_amd64
+ - release_386
+ - release_arm64
+ - release_arm_v7
+ name: Merge and clean release tags
+ uses: ./.github/workflows/gotenberg-merge-clean.yml
+ secrets: inherit
+ with:
+ tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"
+ alternate_registry: 'thecodingmachine'
diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
new file mode 100644
index 000000000..e073bc6cc
--- /dev/null
+++ b/.github/workflows/continuous-integration.yml
@@ -0,0 +1,190 @@
+name: Continuous Integration
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+concurrency:
+ group: ${{ (github.event_name == 'pull_request' && github.event.pull_request.number) || 'main' }}
+ cancel-in-progress: true
+
+permissions:
+ contents: write
+
+jobs:
+
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Setup Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.23'
+ cache: false
+
+ - name: Checkout source code
+ uses: actions/checkout@v4
+
+ - name: Run linters
+ uses: golangci/golangci-lint-action@v6
+ with:
+ version: v1.63.4
+
+ tests:
+ needs:
+ - lint
+ name: Tests
+ # TODO: once arm64 actions are available, also run the tests on this architecture.
+ # See: https://github.com/actions/virtual-environments/issues/2552#issuecomment-771478000.
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Checkout source code
+ uses: actions/checkout@v4
+
+ - name: Build testing environment
+ run: make build build-tests
+
+ - name: Run tests
+ run: make tests-once
+
+ - name: Upload to Codecov
+ uses: codecov/codecov-action@v4
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ verbose: true
+
+ snapshot_amd64:
+ if: github.event_name == 'pull_request'
+ needs:
+ - tests
+ name: Snapshot linux/amd64
+ uses: ./.github/workflows/gotenberg-build-push.yml
+ secrets: inherit
+ with:
+ runs_on: 'ubuntu-latest'
+ platform: 'linux/amd64'
+ gotenberg_version: pr-${{ github.event.pull_request.number }}
+ alternate_repository: 'snapshot'
+
+ snapshot_386:
+ if: github.event_name == 'pull_request'
+ needs:
+ - tests
+ name: Snapshot linux/386
+ uses: ./.github/workflows/gotenberg-build-push.yml
+ secrets: inherit
+ with:
+ runs_on: 'ubuntu-latest'
+ platform: 'linux/386'
+ gotenberg_version: pr-${{ github.event.pull_request.number }}
+ alternate_repository: 'snapshot'
+
+ snapshot_arm64:
+ if: github.event_name == 'pull_request'
+ needs:
+ - tests
+ name: Snapshot linux/arm64
+ uses: ./.github/workflows/gotenberg-build-push.yml
+ secrets: inherit
+ with:
+ runs_on: 'ubuntu-24.04-arm'
+ platform: 'linux/arm64'
+ gotenberg_version: pr-${{ github.event.pull_request.number }}
+ alternate_repository: 'snapshot'
+
+ snapshot_arm_v7:
+ if: github.event_name == 'pull_request'
+ needs:
+ - tests
+ name: Snapshot linux/arm/v7
+ uses: ./.github/workflows/gotenberg-build-push.yml
+ secrets: inherit
+ with:
+ runs_on: 'ubuntu-24.04-arm'
+ platform: 'linux/arm/v7'
+ gotenberg_version: pr-${{ github.event.pull_request.number }}
+ alternate_repository: 'snapshot'
+
+ merge_clean_snapshot_tags:
+ needs:
+ - snapshot_amd64
+ - snapshot_386
+ - snapshot_arm64
+ - snapshot_arm_v7
+ name: Merge and clean snapshot tags
+ uses: ./.github/workflows/gotenberg-merge-clean.yml
+ secrets: inherit
+ with:
+ tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }}"
+
+ edge_amd64:
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ needs:
+ - tests
+ name: Edge linux/amd64
+ uses: ./.github/workflows/gotenberg-build-push.yml
+ secrets: inherit
+ with:
+ runs_on: 'ubuntu-latest'
+ platform: 'linux/amd64'
+ gotenberg_version: 'edge'
+
+ edge_386:
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ needs:
+ - tests
+ name: Edge linux/386
+ uses: ./.github/workflows/gotenberg-build-push.yml
+ secrets: inherit
+ with:
+ runs_on: 'ubuntu-latest'
+ platform: 'linux/386'
+ gotenberg_version: 'edge'
+
+ edge_arm64:
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ needs:
+ - tests
+ name: Edge linux/arm64
+ uses: ./.github/workflows/gotenberg-build-push.yml
+ secrets: inherit
+ with:
+ runs_on: 'ubuntu-24.04-arm'
+ platform: 'linux/arm64'
+ gotenberg_version: 'edge'
+
+ edge_arm_v7:
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ needs:
+ - tests
+ name: Edge linux/arm/v7
+ uses: ./.github/workflows/gotenberg-build-push.yml
+ secrets: inherit
+ with:
+ runs_on: 'ubuntu-24.04-arm'
+ platform: 'linux/arm/v7'
+ gotenberg_version: 'edge'
+
+ merge_clean_edge_tags:
+ needs:
+ - edge_amd64
+ - edge_386
+ - edge_arm64
+ - edge_arm_v7
+ name: Merge and clean edge tags
+ uses: ./.github/workflows/gotenberg-merge-clean.yml
+ secrets: inherit
+ with:
+ tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"
+ alternate_registry: 'thecodingmachine'
diff --git a/.github/workflows/continuous_delivery.yml b/.github/workflows/continuous_delivery.yml
deleted file mode 100644
index ca229fe0c..000000000
--- a/.github/workflows/continuous_delivery.yml
+++ /dev/null
@@ -1,29 +0,0 @@
-name: Continuous Delivery
-
-on:
- release:
- types: [ published ]
-
-permissions:
- contents: read
-
-jobs:
- release:
- name: Release Docker image
- runs-on: ubuntu-latest
- steps:
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
- - name: Checkout source code
- uses: actions/checkout@v4
- - name: Log in to Docker Hub Container Registry
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
- - name: Build and push Docker image for release
- run: |
- make release GOTENBERG_VERSION=${{ github.event.release.tag_name }}
- make release GOTENBERG_VERSION=${{ github.event.release.tag_name }} DOCKER_REGISTRY=thecodingmachine
diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml
deleted file mode 100644
index 51cd4eeb8..000000000
--- a/.github/workflows/continuous_integration.yml
+++ /dev/null
@@ -1,102 +0,0 @@
-name: Continuous Integration
-
-on:
- push:
- branches:
- - main
- pull_request:
- branches:
- - main
-
-concurrency:
- group: ${{ (github.event_name == 'pull_request' && github.event.pull_request.number) || 'main' }}
- cancel-in-progress: true
-
-permissions:
- contents: write
-
-jobs:
-
- lint:
- name: Lint
- runs-on: ubuntu-latest
- steps:
- - name: Setup Go
- uses: actions/setup-go@v5
- with:
- go-version: '1.23'
- cache: false
- - name: Checkout source code
- uses: actions/checkout@v4
- - name: Run linters
- uses: golangci/golangci-lint-action@v6
- with:
- version: v1.63.4
-
- tests:
- needs:
- - lint
- name: Tests
- # TODO: once arm64 actions are available, also run the tests on this architecture.
- # See: https://github.com/actions/virtual-environments/issues/2552#issuecomment-771478000.
- runs-on: ubuntu-latest
- steps:
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
- - name: Checkout source code
- uses: actions/checkout@v4
- - name: Build testing environment
- run: make build build-tests
- - name: Run tests
- run: make tests-once
- - name: Upload to Codecov
- uses: codecov/codecov-action@v4
- with:
- token: ${{ secrets.CODECOV_TOKEN }}
- verbose: true
-
- snapshot_release:
- if: github.event_name == 'pull_request'
- needs:
- - tests
- name: Snapshot release
- runs-on: ubuntu-latest
- steps:
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
- - name: Checkout source code
- uses: actions/checkout@v4
- - name: Log in to Docker Hub Container Registry
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
- - name: Build and push snapshot Docker image (linux/amd64)
- run: make release GOTENBERG_VERSION=${{ github.head_ref }} DOCKER_REPOSITORY=snapshot LINUX_AMD64_RELEASE=true
-
- edge_release:
- if: github.event_name == 'push' && github.ref == 'refs/heads/main'
- needs:
- - tests
- name: Edge release
- runs-on: ubuntu-latest
- steps:
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
- - name: Checkout source code
- uses: actions/checkout@v4
- - name: Log in to Docker Hub Container Registry
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
- - name: Build and push Docker image for main branch
- run: |
- make release GOTENBERG_VERSION=edge
- make release GOTENBERG_VERSION=edge DOCKER_REGISTRY=thecodingmachine
diff --git a/.github/workflows/gotenberg-build-push.yml b/.github/workflows/gotenberg-build-push.yml
new file mode 100644
index 000000000..82384e8d7
--- /dev/null
+++ b/.github/workflows/gotenberg-build-push.yml
@@ -0,0 +1,51 @@
+on:
+ workflow_call:
+ inputs:
+ runs_on:
+ type: string
+ required: true
+ platform:
+ type: string
+ required: true
+ gotenberg_version:
+ type: string
+ required: true
+ alternate_repository:
+ type: string
+ default: ''
+ outputs:
+ tags:
+ value: ${{ jobs.gotenberg_build_push.outputs.tags }}
+
+permissions:
+ contents: read
+
+jobs:
+ gotenberg_build_push:
+ runs-on: ${{ inputs.runs_on }}
+ outputs:
+ tags: ${{ steps.build_push.outputs.tags }}
+ steps:
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Check out code
+ uses: actions/checkout@v4
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Build and push ${{ inputs.platform }}
+ id: build_push
+ uses: gotenberg/gotenberg-release-action@main
+ with:
+ step: build_push
+ version: ${{ inputs.gotenberg_version }}
+ platform: ${{ inputs.platform }}
+ alternate_repository: ${{ inputs.alternate_repository }}
diff --git a/.github/workflows/gotenberg-merge-clean.yml b/.github/workflows/gotenberg-merge-clean.yml
new file mode 100644
index 000000000..e2ddf51f7
--- /dev/null
+++ b/.github/workflows/gotenberg-merge-clean.yml
@@ -0,0 +1,40 @@
+on:
+ workflow_call:
+ inputs:
+ tags:
+ type: string
+ required: true
+ alternate_registry:
+ type: string
+ default: ''
+
+permissions:
+ contents: read
+
+jobs:
+ gotenberg_merge_clean:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Check out code
+ uses: actions/checkout@v4
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Merge and clean
+ uses: gotenberg/gotenberg-release-action@main
+ with:
+ step: merge_clean
+ docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
+ docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
+ tags: ${{ inputs.tags }}
+ alternate_registry: ${{ inputs.alternate_registry }}
diff --git a/Makefile b/Makefile
index de2e0dbdf..6066e4f73 100644
--- a/Makefile
+++ b/Makefile
@@ -1,3 +1,5 @@
+include .env
+
.PHONY: help
help: ## Show the help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
@@ -5,17 +7,6 @@ help: ## Show the help
.PHONY: it
it: build build-tests ## Initialize the development environment
-GOLANG_VERSION=1.23
-DOCKER_REGISTRY=gotenberg
-DOCKER_REPOSITORY=gotenberg
-GOTENBERG_VERSION=snapshot
-GOTENBERG_USER_GID=1001
-GOTENBERG_USER_UID=1001
-NOTO_COLOR_EMOJI_VERSION=v2.047 # See https://github.com/googlefonts/noto-emoji/releases.
-PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
-PDFCPU_VERSION=v0.8.1 # See https://github.com/pdfcpu/pdfcpu/releases.
-GOLANGCI_LINT_VERSION=v1.63.4 # See https://github.com/golangci/golangci-lint/releases.
-
.PHONY: build
build: ## Build the Gotenberg's Docker image
docker build \
@@ -27,7 +18,7 @@ build: ## Build the Gotenberg's Docker image
--build-arg PDFTK_VERSION=$(PDFTK_VERSION) \
--build-arg PDFCPU_VERSION=$(PDFCPU_VERSION) \
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
- -f build/Dockerfile .
+ -f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT)
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
API_PORT=3000
@@ -199,20 +190,3 @@ fmt: ## Format the code and "optimize" the dependencies
godoc: ## Run a webserver with Gotenberg godoc
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v8)
godoc -http=:6060
-
-LINUX_AMD64_RELEASE=false
-
-.PHONY: release
-release: ## Build the Gotenberg's Docker image and push it to a Docker repository
- ./scripts/release.sh \
- $(GOLANG_VERSION) \
- $(GOTENBERG_VERSION) \
- $(GOTENBERG_USER_GID) \
- $(GOTENBERG_USER_UID) \
- $(NOTO_COLOR_EMOJI_VERSION) \
- $(PDFTK_VERSION) \
- $(PDFCPU_VERSION) \
- $(DOCKER_REGISTRY) \
- $(DOCKER_REPOSITORY) \
- $(LINUX_AMD64_RELEASE)
-
diff --git a/scripts/release.sh b/scripts/release.sh
deleted file mode 100755
index f9c014ff9..000000000
--- a/scripts/release.sh
+++ /dev/null
@@ -1,84 +0,0 @@
-#!/bin/bash
-
-set -e
-
-# Args.
-GOLANG_VERSION="$1"
-GOTENBERG_VERSION="$2"
-GOTENBERG_USER_GID="$3"
-GOTENBERG_USER_UID="$4"
-NOTO_COLOR_EMOJI_VERSION="$5"
-PDFTK_VERSION="$6"
-PDFCPU_VERSION="$7"
-DOCKER_REGISTRY="$8"
-DOCKER_REPOSITORY="$9"
-LINUX_AMD64_RELEASE="${10}"
-
-# Find out if given version is "semver".
-GOTENBERG_VERSION="${GOTENBERG_VERSION//v}"
-IFS='.' read -ra SEMVER <<< "$GOTENBERG_VERSION"
-VERSION_LENGTH=${#SEMVER[@]}
-TAGS=()
-TAGS_CLOUD_RUN=()
-
-if [ "$VERSION_LENGTH" -eq 3 ]; then
- MAJOR="${SEMVER[0]}"
- MINOR="${SEMVER[1]}"
- PATCH="${SEMVER[2]}"
-
- TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest")
- TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR")
- TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR")
- TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR.$PATCH")
-
- TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-cloudrun")
- TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR-cloudrun")
- TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR-cloudrun")
- TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR.$PATCH-cloudrun")
-else
- # Normalizes version.
- GOTENBERG_VERSION="${GOTENBERG_VERSION// /-}"
- GOTENBERG_VERSION="$(echo "$GOTENBERG_VERSION" | tr -cd '[:alnum:]._\-')"
-
- if [[ "$GOTENBERG_VERSION" =~ ^[\.\-] ]]; then
- GOTENBERG_VERSION="_${GOTENBERG_VERSION#?}"
- fi
-
- if [ "${#GOTENBERG_VERSION}" -gt 128 ]; then
- GOTENBERG_VERSION="${GOTENBERG_VERSION:0:128}"
- fi
-
- TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION")
- TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION-cloudrun")
-fi
-
-# Multi-arch build takes a lot of time.
-if [ "$LINUX_AMD64_RELEASE" = true ]; then
- PLATFORM_FLAG="--platform linux/amd64"
-else
- PLATFORM_FLAG="--platform linux/amd64,linux/arm64,linux/386,linux/arm/v7"
-fi
-
-docker buildx build \
- --build-arg GOLANG_VERSION="$GOLANG_VERSION" \
- --build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
- --build-arg GOTENBERG_USER_GID="$GOTENBERG_USER_GID" \
- --build-arg GOTENBERG_USER_UID="$GOTENBERG_USER_UID" \
- --build-arg NOTO_COLOR_EMOJI_VERSION="$NOTO_COLOR_EMOJI_VERSION" \
- --build-arg PDFTK_VERSION="$PDFTK_VERSION" \
- --build-arg PDFCPU_VERSION="$PDFCPU_VERSION" \
- $PLATFORM_FLAG \
- "${TAGS[@]}" \
- --push \
- -f build/Dockerfile .
-
-# Cloud Run variant.
-# Only linux/amd64! See https://github.com/gotenberg/gotenberg/issues/505#issuecomment-1264679278.
-docker buildx build \
- --build-arg DOCKER_REGISTRY="$DOCKER_REGISTRY" \
- --build-arg DOCKER_REPOSITORY="$DOCKER_REPOSITORY" \
- --build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
- --platform linux/amd64 \
- "${TAGS_CLOUD_RUN[@]}" \
- --push \
- -f build/Dockerfile.cloudrun .
From 8631a15933745eb74f70cdd825abf28221d0790c Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Thu, 23 Jan 2025 19:46:16 +0100
Subject: [PATCH 025/254] ci(comment): remove comment about arm64 testing
---
.github/workflows/continuous-integration.yml | 2 --
1 file changed, 2 deletions(-)
diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
index e073bc6cc..71c16278d 100644
--- a/.github/workflows/continuous-integration.yml
+++ b/.github/workflows/continuous-integration.yml
@@ -39,8 +39,6 @@ jobs:
needs:
- lint
name: Tests
- # TODO: once arm64 actions are available, also run the tests on this architecture.
- # See: https://github.com/actions/virtual-environments/issues/2552#issuecomment-771478000.
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
From d96058d5894976e4769872b6967158816fddd5af Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Thu, 23 Jan 2025 19:47:28 +0100
Subject: [PATCH 026/254] fix(Dockerfile): use latest Chromium for armhf
---
build/Dockerfile | 12 ------------
1 file changed, 12 deletions(-)
diff --git a/build/Dockerfile b/build/Dockerfile
index 492cb6bc9..0e0756535 100644
--- a/build/Dockerfile
+++ b/build/Dockerfile
@@ -61,7 +61,6 @@ ARG GOTENBERG_USER_GID
ARG GOTENBERG_USER_UID
ARG NOTO_COLOR_EMOJI_VERSION
ARG PDFTK_VERSION
-ARG TMP_CHOMIUM_VERSION_ARMHF="116.0.5845.180-1~deb12u1"
LABEL org.opencontainers.image.title="Gotenberg" \
org.opencontainers.image.description="A Docker-powered stateless API for PDF files." \
@@ -147,9 +146,6 @@ RUN \
# Install either Google Chrome stable on amd64 architecture or
# Chromium on other architectures.
# See https://github.com/gotenberg/gotenberg/issues/328.
- # FIXME:
- # armhf is currently not working with the latest version of Chromium.
- # See: https://github.com/gotenberg/gotenberg/issues/709.
/bin/bash -c \
'set -e &&\
if [[ "$(dpkg --print-architecture)" == "amd64" ]]; then \
@@ -159,14 +155,6 @@ RUN \
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends --allow-unauthenticated google-chrome-stable &&\
mv /usr/bin/google-chrome-stable /usr/bin/chromium; \
- elif [[ "$(dpkg --print-architecture)" == "armhf" ]]; then \
- apt-get update -qq &&\
- DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
- debsnap chromium-common "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
- debsnap chromium "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
- DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHOMIUM_VERSION_ARMHF}_armhf.deb" "./binary-chromium/chromium_${TMP_CHOMIUM_VERSION_ARMHF}_armhf.deb" &&\
- DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
- rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
else \
apt-get update -qq &&\
apt-get upgrade -yqq &&\
From 66ade27383258ecb2f4f6c9b14e6db08778e239e Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Thu, 23 Jan 2025 20:18:18 +0100
Subject: [PATCH 027/254] docs(README): fix continuous integration badge
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 30a702754..39e2e5281 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-
+
From 09e511bc4994b14230ec736648e575bfaf82e8e6 Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Fri, 24 Jan 2025 09:41:31 +0100
Subject: [PATCH 028/254] ci(workflows): use sub-actions
---
.env | 2 +-
...enberg-build-push.yml => __build-push.yml} | 7 +-
...berg-merge-clean.yml => __merge-clean.yml} | 5 +-
.github/workflows/continuous-delivery.yml | 28 ++++----
.github/workflows/continuous-integration.yml | 70 +++++++++----------
5 files changed, 55 insertions(+), 57 deletions(-)
rename .github/workflows/{gotenberg-build-push.yml => __build-push.yml} (87%)
rename .github/workflows/{gotenberg-merge-clean.yml => __merge-clean.yml} (89%)
diff --git a/.env b/.env
index 2c3e2685d..4516acecf 100644
--- a/.env
+++ b/.env
@@ -11,4 +11,4 @@ GOLANGCI_LINT_VERSION=v1.63.4 # See https://github.com/golangci/golangci-lint/re
GOTENBERG_VERSION=snapshot
DOCKERFILE=build/Dockerfile
DOCKERFILE_CLOUDRUN=build/Dockerfile.cloudrun
-DOCKER_BUILD_CONTEXT='.'
\ No newline at end of file
+DOCKER_BUILD_CONTEXT='.'
diff --git a/.github/workflows/gotenberg-build-push.yml b/.github/workflows/__build-push.yml
similarity index 87%
rename from .github/workflows/gotenberg-build-push.yml
rename to .github/workflows/__build-push.yml
index 82384e8d7..6dba616d4 100644
--- a/.github/workflows/gotenberg-build-push.yml
+++ b/.github/workflows/__build-push.yml
@@ -15,13 +15,13 @@ on:
default: ''
outputs:
tags:
- value: ${{ jobs.gotenberg_build_push.outputs.tags }}
+ value: ${{ jobs.build_push.outputs.tags }}
permissions:
contents: read
jobs:
- gotenberg_build_push:
+ build_push:
runs-on: ${{ inputs.runs_on }}
outputs:
tags: ${{ steps.build_push.outputs.tags }}
@@ -43,9 +43,8 @@ jobs:
- name: Build and push ${{ inputs.platform }}
id: build_push
- uses: gotenberg/gotenberg-release-action@main
+ uses: gotenberg/gotenberg-release-action/actions/build-push@main
with:
- step: build_push
version: ${{ inputs.gotenberg_version }}
platform: ${{ inputs.platform }}
alternate_repository: ${{ inputs.alternate_repository }}
diff --git a/.github/workflows/gotenberg-merge-clean.yml b/.github/workflows/__merge-clean.yml
similarity index 89%
rename from .github/workflows/gotenberg-merge-clean.yml
rename to .github/workflows/__merge-clean.yml
index e2ddf51f7..79c4a1a42 100644
--- a/.github/workflows/gotenberg-merge-clean.yml
+++ b/.github/workflows/__merge-clean.yml
@@ -12,7 +12,7 @@ permissions:
contents: read
jobs:
- gotenberg_merge_clean:
+ merge_clean:
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
@@ -31,9 +31,8 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Merge and clean
- uses: gotenberg/gotenberg-release-action@main
+ uses: gotenberg/gotenberg-release-action/actions/merge-clean@main
with:
- step: merge_clean
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: ${{ inputs.tags }}
diff --git a/.github/workflows/continuous-delivery.yml b/.github/workflows/continuous-delivery.yml
index 449008acd..e2d8c402a 100644
--- a/.github/workflows/continuous-delivery.yml
+++ b/.github/workflows/continuous-delivery.yml
@@ -10,38 +10,38 @@ permissions:
jobs:
release_amd64:
name: Release linux/amd64
- uses: ./.github/workflows/gotenberg-build-push.yml
+ uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
- runs_on: 'ubuntu-latest'
- platform: 'linux/amd64'
+ runs_on: ubuntu-latest
+ platform: linux/amd64
gotenberg_version: ${{ github.event.release.tag_name }}
release_386:
name: Release linux/386
- uses: ./.github/workflows/gotenberg-build-push.yml
+ uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
- runs_on: 'ubuntu-latest'
- platform: 'linux/386'
+ runs_on: ubuntu-latest
+ platform: linux/386
gotenberg_version: ${{ github.event.release.tag_name }}
release_arm64:
name: Release linux/arm64
- uses: ./.github/workflows/gotenberg-build-push.yml
+ uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
- runs_on: 'ubuntu-24.04-arm'
- platform: 'linux/arm64'
+ runs_on: ubuntu-24.04-arm
+ platform: linux/arm64
gotenberg_version: ${{ github.event.release.tag_name }}
release_arm_v7:
name: Release linux/arm/v7
- uses: ./.github/workflows/gotenberg-build-push.yml
+ uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
- runs_on: 'ubuntu-24.04-arm'
- platform: 'linux/arm/v7'
+ runs_on: ubuntu-24.04-arm
+ platform: linux/arm/v7
gotenberg_version: ${{ github.event.release.tag_name }}
merge_clean_release_tags:
@@ -51,8 +51,8 @@ jobs:
- release_arm64
- release_arm_v7
name: Merge and clean release tags
- uses: ./.github/workflows/gotenberg-merge-clean.yml
+ uses: ./.github/workflows/__merge-clean.yml
secrets: inherit
with:
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"
- alternate_registry: 'thecodingmachine'
+ alternate_registry: thecodingmachine
diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
index 71c16278d..f65d9d7b0 100644
--- a/.github/workflows/continuous-integration.yml
+++ b/.github/workflows/continuous-integration.yml
@@ -67,52 +67,52 @@ jobs:
needs:
- tests
name: Snapshot linux/amd64
- uses: ./.github/workflows/gotenberg-build-push.yml
+ uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
- runs_on: 'ubuntu-latest'
- platform: 'linux/amd64'
+ runs_on: ubuntu-latest
+ platform: linux/amd64
gotenberg_version: pr-${{ github.event.pull_request.number }}
- alternate_repository: 'snapshot'
+ alternate_repository: snapshot
snapshot_386:
if: github.event_name == 'pull_request'
needs:
- tests
name: Snapshot linux/386
- uses: ./.github/workflows/gotenberg-build-push.yml
+ uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
- runs_on: 'ubuntu-latest'
- platform: 'linux/386'
+ runs_on: ubuntu-latest
+ platform: linux/386
gotenberg_version: pr-${{ github.event.pull_request.number }}
- alternate_repository: 'snapshot'
+ alternate_repository: snapshot
snapshot_arm64:
if: github.event_name == 'pull_request'
needs:
- tests
name: Snapshot linux/arm64
- uses: ./.github/workflows/gotenberg-build-push.yml
+ uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
- runs_on: 'ubuntu-24.04-arm'
- platform: 'linux/arm64'
+ runs_on: ubuntu-24.04-arm
+ platform: linux/arm64
gotenberg_version: pr-${{ github.event.pull_request.number }}
- alternate_repository: 'snapshot'
+ alternate_repository: snapshot
snapshot_arm_v7:
if: github.event_name == 'pull_request'
needs:
- tests
name: Snapshot linux/arm/v7
- uses: ./.github/workflows/gotenberg-build-push.yml
+ uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
- runs_on: 'ubuntu-24.04-arm'
- platform: 'linux/arm/v7'
+ runs_on: ubuntu-24.04-arm
+ platform: linux/arm/v7
gotenberg_version: pr-${{ github.event.pull_request.number }}
- alternate_repository: 'snapshot'
+ alternate_repository: snapshot
merge_clean_snapshot_tags:
needs:
@@ -121,7 +121,7 @@ jobs:
- snapshot_arm64
- snapshot_arm_v7
name: Merge and clean snapshot tags
- uses: ./.github/workflows/gotenberg-merge-clean.yml
+ uses: ./.github/workflows/__merge-clean.yml
secrets: inherit
with:
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }}"
@@ -131,48 +131,48 @@ jobs:
needs:
- tests
name: Edge linux/amd64
- uses: ./.github/workflows/gotenberg-build-push.yml
+ uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
- runs_on: 'ubuntu-latest'
- platform: 'linux/amd64'
- gotenberg_version: 'edge'
+ runs_on: ubuntu-latest
+ platform: linux/amd64
+ gotenberg_version: edge
edge_386:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- tests
name: Edge linux/386
- uses: ./.github/workflows/gotenberg-build-push.yml
+ uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
- runs_on: 'ubuntu-latest'
- platform: 'linux/386'
- gotenberg_version: 'edge'
+ runs_on: ubuntu-latest
+ platform: linux/386
+ gotenberg_version: edge
edge_arm64:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- tests
name: Edge linux/arm64
- uses: ./.github/workflows/gotenberg-build-push.yml
+ uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
- runs_on: 'ubuntu-24.04-arm'
- platform: 'linux/arm64'
- gotenberg_version: 'edge'
+ runs_on: ubuntu-24.04-arm
+ platform: linux/arm64
+ gotenberg_version: edge
edge_arm_v7:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- tests
name: Edge linux/arm/v7
- uses: ./.github/workflows/gotenberg-build-push.yml
+ uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
- runs_on: 'ubuntu-24.04-arm'
- platform: 'linux/arm/v7'
- gotenberg_version: 'edge'
+ runs_on: ubuntu-24.04-arm
+ platform: linux/arm/v7
+ gotenberg_version: edge
merge_clean_edge_tags:
needs:
@@ -181,8 +181,8 @@ jobs:
- edge_arm64
- edge_arm_v7
name: Merge and clean edge tags
- uses: ./.github/workflows/gotenberg-merge-clean.yml
+ uses: ./.github/workflows/__merge-clean.yml
secrets: inherit
with:
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"
- alternate_registry: 'thecodingmachine'
+ alternate_registry: thecodingmachine
From 6ce47e3a20c9a565588b16faab8417f8f13ce1d7 Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Fri, 24 Jan 2025 10:25:25 +0100
Subject: [PATCH 029/254] ci(workflows): cleanup PR Docker images
---
.github/workflows/__delete.yml | 24 ++++++++++++++++++++++
.github/workflows/pull-request-cleanup.yml | 18 ++++++++++++++++
2 files changed, 42 insertions(+)
create mode 100644 .github/workflows/__delete.yml
create mode 100644 .github/workflows/pull-request-cleanup.yml
diff --git a/.github/workflows/__delete.yml b/.github/workflows/__delete.yml
new file mode 100644
index 000000000..4938b8f30
--- /dev/null
+++ b/.github/workflows/__delete.yml
@@ -0,0 +1,24 @@
+on:
+ workflow_call:
+ inputs:
+ gotenberg_version:
+ type: string
+ required: true
+ alternate_repository:
+ type: string
+ default: ''
+
+permissions:
+ contents: read
+
+jobs:
+ delete:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Delete
+ uses: gotenberg/gotenberg-release-action/actions/delete@main
+ with:
+ docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
+ docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
+ version: ${{ inputs.gotenberg_version }}
+ alternate_repository: ${{ inputs.alternate_repository }}
diff --git a/.github/workflows/pull-request-cleanup.yml b/.github/workflows/pull-request-cleanup.yml
new file mode 100644
index 000000000..1815466d7
--- /dev/null
+++ b/.github/workflows/pull-request-cleanup.yml
@@ -0,0 +1,18 @@
+name: Pull Request Cleanup
+
+on:
+ pull_request:
+ types: [ closed ]
+
+permissions:
+ contents: read
+
+jobs:
+
+ cleanup:
+ name: Cleanup
+ uses: ./.github/workflows/__delete.yml
+ secrets: inherit
+ with:
+ gotenberg_version: pr-${{ github.event.pull_request.number }}
+ alternate_repository: snapshot
From cb4835589835124f4e0ca53855c5f603acbebda5 Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Fri, 24 Jan 2025 10:32:52 +0100
Subject: [PATCH 030/254] fix(ci): missing checkout step on reusable workflow
delete
---
.github/workflows/__build-push.yml | 1 +
.github/workflows/__delete.yml | 4 ++++
.github/workflows/__merge-clean.yml | 1 +
3 files changed, 6 insertions(+)
diff --git a/.github/workflows/__build-push.yml b/.github/workflows/__build-push.yml
index 6dba616d4..301ae0587 100644
--- a/.github/workflows/__build-push.yml
+++ b/.github/workflows/__build-push.yml
@@ -22,6 +22,7 @@ permissions:
jobs:
build_push:
+ name: Build and push Docker images
runs-on: ${{ inputs.runs_on }}
outputs:
tags: ${{ steps.build_push.outputs.tags }}
diff --git a/.github/workflows/__delete.yml b/.github/workflows/__delete.yml
index 4938b8f30..96d2156e9 100644
--- a/.github/workflows/__delete.yml
+++ b/.github/workflows/__delete.yml
@@ -13,8 +13,12 @@ permissions:
jobs:
delete:
+ name: Delete Docker images
runs-on: ubuntu-latest
steps:
+ - name: Check out code
+ uses: actions/checkout@v4
+
- name: Delete
uses: gotenberg/gotenberg-release-action/actions/delete@main
with:
diff --git a/.github/workflows/__merge-clean.yml b/.github/workflows/__merge-clean.yml
index 79c4a1a42..baf484447 100644
--- a/.github/workflows/__merge-clean.yml
+++ b/.github/workflows/__merge-clean.yml
@@ -13,6 +13,7 @@ permissions:
jobs:
merge_clean:
+ name: Merge and clean Docker images
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
From a9b44ee39ef2a2ab6f5de1bd3ebd7de3e46fd2ee Mon Sep 17 00:00:00 2001
From: Julien Neuhart
Date: Mon, 27 Jan 2025 16:05:52 +0100
Subject: [PATCH 031/254] chore(lint): remove rule G115 (gosec) exception
---
.golangci.yml | 4 ----
1 file changed, 4 deletions(-)
diff --git a/.golangci.yml b/.golangci.yml
index d4f38a3a2..8fd432f6c 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -6,10 +6,6 @@ linters-settings:
- prefix(github.com/gotenberg/gotenberg/v8)
skip-generated: true
custom-order: true
- # Until https://github.com/securego/gosec/issues/1187 is resolved.
- gosec:
- excludes:
- - G115
linters:
disable-all: true
From b418f1eb05f67f68a8c1f4d7316b93826695ad9c Mon Sep 17 00:00:00 2001
From: Peter Chakalov
Date: Tue, 28 Jan 2025 15:38:27 +0200
Subject: [PATCH 032/254] feat(pdfengines): add support for flattening
annotations (#1105)
* initial changes
* Add tests
* Fix edge case when we need to regenerate appearances
* Fix comments
* Add missing comment
* Add missing comment
* Add missing comment
* Add flatten option to the merge route
* Add flatten option to the libreoffice convert route
* Add flatten option to the chromium convert route
* Revert "Add flatten option to the chromium convert route"
This reverts commit cdab8b4e6bd9254af5192d235f83176a13668724.
* Ignore lint false positives
* Add missing tests
* Add flatten route tests
* Replace input instead of creating a new file
* create copy before flatten in tests
---------
Co-authored-by: Peter Chakalov
---
Makefile | 2 +
pkg/gotenberg/mocks.go | 7 +
pkg/gotenberg/mocks_test.go | 8 +
pkg/gotenberg/pdfengine.go | 7 +
pkg/modules/exiftool/exiftool.go | 5 +
pkg/modules/exiftool/exiftool_test.go | 9 +
.../libreoffice/pdfengine/pdfengine.go | 5 +
.../libreoffice/pdfengine/pdfengine_test.go | 9 +
pkg/modules/libreoffice/routes.go | 9 +
pkg/modules/libreoffice/routes_test.go | 44 +++++
pkg/modules/pdfcpu/pdfcpu.go | 5 +
pkg/modules/pdfcpu/pdfcpu_test.go | 9 +
pkg/modules/pdfengines/multi.go | 29 +++
pkg/modules/pdfengines/multi_test.go | 91 +++++++++
pkg/modules/pdfengines/pdfengines.go | 12 ++
pkg/modules/pdfengines/pdfengines_test.go | 17 +-
pkg/modules/pdfengines/routes.go | 58 ++++++
pkg/modules/pdfengines/routes_test.go | 180 ++++++++++++++++++
pkg/modules/pdftk/pdftk.go | 5 +
pkg/modules/pdftk/pdftk_test.go | 9 +
pkg/modules/qpdf/qpdf.go | 21 ++
pkg/modules/qpdf/qpdf_test.go | 97 ++++++++++
test/testdata/pdfengines/sample3.pdf | Bin 0 -> 224175 bytes
23 files changed, 634 insertions(+), 4 deletions(-)
create mode 100644 test/testdata/pdfengines/sample3.pdf
diff --git a/Makefile b/Makefile
index 6066e4f73..0a9b212bf 100644
--- a/Makefile
+++ b/Makefile
@@ -65,6 +65,7 @@ LOG_FIELDS_PREFIX=
PDFENGINES_ENGINES=
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
+PDFENGINES_FLATTEN_ENGINES=qpdf
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
PDFENGINES_READ_METADATA_ENGINES=exiftool
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
@@ -134,6 +135,7 @@ run: ## Start a Gotenberg container
--pdfengines-engines=$(PDFENGINES_ENGINES) \
--pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \
--pdfengines-split-engines=$(PDFENGINES_SPLIT_ENGINES) \
+ --pdfengines-convert-engines=$(PDFENGINES_FLATTEN_ENGINES) \
--pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \
--pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \
--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go
index 2ade89525..a771c8c3d 100644
--- a/pkg/gotenberg/mocks.go
+++ b/pkg/gotenberg/mocks.go
@@ -35,9 +35,12 @@ func (mod *ValidatorMock) Validate() error {
}
// PdfEngineMock is a mock for the [PdfEngine] interface.
+//
+//nolint:dupl
type PdfEngineMock struct {
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
SplitMock func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
+ FlattenMock func(ctx context.Context, logger *zap.Logger, inputPath string) error
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
@@ -51,6 +54,10 @@ func (engine *PdfEngineMock) Split(ctx context.Context, logger *zap.Logger, mode
return engine.SplitMock(ctx, logger, mode, inputPath, outputDirPath)
}
+func (engine *PdfEngineMock) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return engine.FlattenMock(ctx, logger, inputPath)
+}
+
func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
}
diff --git a/pkg/gotenberg/mocks_test.go b/pkg/gotenberg/mocks_test.go
index 953a6ec28..0608f2c2e 100644
--- a/pkg/gotenberg/mocks_test.go
+++ b/pkg/gotenberg/mocks_test.go
@@ -56,6 +56,9 @@ func TestPDFEngineMock(t *testing.T) {
SplitMock: func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return nil
+ },
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
return nil
},
@@ -77,6 +80,11 @@ func TestPDFEngineMock(t *testing.T) {
t.Errorf("expected no error from PdfEngineMock.Split, but got: %v", err)
}
+ err = mock.Flatten(context.Background(), zap.NewNop(), "")
+ if err != nil {
+ t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err)
+ }
+
err = mock.Convert(context.Background(), zap.NewNop(), PdfFormats{}, "", "")
if err != nil {
t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err)
diff --git a/pkg/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go
index 788c07c7a..dc4a4f7ce 100644
--- a/pkg/gotenberg/pdfengine.go
+++ b/pkg/gotenberg/pdfengine.go
@@ -88,6 +88,8 @@ type PdfFormats struct {
// PdfEngine provides an interface for operations on PDFs. Implementations
// can utilize various tools like PDFtk, or implement functionality directly in
// Go.
+//
+//nolint:dupl
type PdfEngine interface {
// Merge combines multiple PDFs into a single PDF. The resulting page order
// is determined by the order of files provided in inputPaths.
@@ -96,6 +98,11 @@ type PdfEngine interface {
// Split splits a given PDF file.
Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
+ // Flatten merges existing annotation appearances with page content, effectively deleting the original annotations.
+ // This process can flatten forms as well, as forms share a relationship with annotations.
+ // Note that this operation is irreversible.
+ Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error
+
// Convert transforms a given PDF to the specified formats defined in
// PdfFormats. If no format, it does nothing.
Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go
index aeffc6a99..8fafc27d7 100644
--- a/pkg/modules/exiftool/exiftool.go
+++ b/pkg/modules/exiftool/exiftool.go
@@ -63,6 +63,11 @@ func (engine *ExifTool) Split(ctx context.Context, logger *zap.Logger, mode gote
return nil, fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
+// Flatten is not available in this implementation.
+func (engine *ExifTool) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
// Convert is not available in this implementation.
func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
diff --git a/pkg/modules/exiftool/exiftool_test.go b/pkg/modules/exiftool/exiftool_test.go
index 52c8f31d7..00fca5a2b 100644
--- a/pkg/modules/exiftool/exiftool_test.go
+++ b/pkg/modules/exiftool/exiftool_test.go
@@ -91,6 +91,15 @@ func TestExiftool_Split(t *testing.T) {
}
}
+func TestExiftool_Flatten(t *testing.T) {
+ engine := new(ExifTool)
+ err := engine.Flatten(context.Background(), zap.NewNop(), "")
+
+ if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
+ t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
+ }
+}
+
func TestExiftool_Convert(t *testing.T) {
engine := new(ExifTool)
err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go
index 5416ab357..72ae31122 100644
--- a/pkg/modules/libreoffice/pdfengine/pdfengine.go
+++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go
@@ -56,6 +56,11 @@ func (engine *LibreOfficePdfEngine) Split(ctx context.Context, logger *zap.Logge
return nil, fmt.Errorf("split PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
+// Flatten is not available in this implementation.
+func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return fmt.Errorf("Flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
// Convert converts the given PDF to a specific PDF format. Currently, only the
// PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine_test.go b/pkg/modules/libreoffice/pdfengine/pdfengine_test.go
index 1dc4ed737..d7034c2e3 100644
--- a/pkg/modules/libreoffice/pdfengine/pdfengine_test.go
+++ b/pkg/modules/libreoffice/pdfengine/pdfengine_test.go
@@ -127,6 +127,15 @@ func TestLibreOfficePdfEngine_Split(t *testing.T) {
}
}
+func TestLibreOfficePdfEngine_Flatten(t *testing.T) {
+ engine := new(LibreOfficePdfEngine)
+ err := engine.Flatten(context.Background(), zap.NewNop(), "")
+
+ if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
+ t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
+ }
+}
+
func TestLibreOfficePdfEngine_Convert(t *testing.T) {
for _, tc := range []struct {
scenario string
diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go
index b6786189b..cf712c0ee 100644
--- a/pkg/modules/libreoffice/routes.go
+++ b/pkg/modules/libreoffice/routes.go
@@ -60,6 +60,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
maxImageResolution int
nativePdfFormats bool
merge bool
+ flatten bool
)
err := form.
@@ -135,6 +136,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
}
return nil
}).
+ Bool("flatten", &flatten, false).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
@@ -261,6 +263,13 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
return fmt.Errorf("write metadata: %w", err)
}
+ if flatten {
+ err = pdfengines.FlattenStub(ctx, engine, outputPaths)
+ if err != nil {
+ return fmt.Errorf("flatten PDFs: %w", err)
+ }
+ }
+
if len(outputPaths) > 1 && splitMode == zeroValuedSplitMode {
// If .zip archive, document.docx -> document.docx.pdf.
for i, inputPath := range inputPaths {
diff --git a/pkg/modules/libreoffice/routes_test.go b/pkg/modules/libreoffice/routes_test.go
index 139f57489..c033591de 100644
--- a/pkg/modules/libreoffice/routes_test.go
+++ b/pkg/modules/libreoffice/routes_test.go
@@ -402,6 +402,38 @@ func TestConvertRoute(t *testing.T) {
expectHttpError: false,
expectOutputPathsCount: 0,
},
+ {
+ scenario: "PDF engine flatten error",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetFiles(map[string]string{
+ "document.docx": "/document.docx",
+ })
+ ctx.SetValues(map[string][]string{
+ "flatten": {
+ "true",
+ },
+ })
+ ctx.SetCancelled(true)
+ return ctx
+ }(),
+ libreOffice: &libreofficeapi.ApiMock{
+ PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
+ return nil
+ },
+ ExtensionsMock: func() []string {
+ return []string{".docx"}
+ },
+ },
+ engine: &gotenberg.PdfEngineMock{
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return errors.New("foo")
+ },
+ },
+ expectError: true,
+ expectHttpError: false,
+ expectOutputPathsCount: 0,
+ },
{
scenario: "cannot add output paths",
ctx: func() *api.ContextMock {
@@ -454,6 +486,9 @@ func TestConvertRoute(t *testing.T) {
"metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
},
+ "flatten": {
+ "true",
+ },
})
return ctx
}(),
@@ -475,6 +510,9 @@ func TestConvertRoute(t *testing.T) {
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return nil
+ },
},
expectError: false,
expectHttpError: false,
@@ -502,6 +540,9 @@ func TestConvertRoute(t *testing.T) {
"metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
},
+ "flatten": {
+ "true",
+ },
})
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
@@ -526,6 +567,9 @@ func TestConvertRoute(t *testing.T) {
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return nil
+ },
},
expectError: false,
expectHttpError: false,
diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go
index 29803cb38..2e82f8793 100644
--- a/pkg/modules/pdfcpu/pdfcpu.go
+++ b/pkg/modules/pdfcpu/pdfcpu.go
@@ -107,6 +107,11 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenb
return outputPaths, nil
}
+// Flatten is not available in this implementation.
+func (engine *PdfCpu) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return fmt.Errorf("flatten PDF with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
// Convert is not available in this implementation.
func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
diff --git a/pkg/modules/pdfcpu/pdfcpu_test.go b/pkg/modules/pdfcpu/pdfcpu_test.go
index e996aed8b..063f42e6f 100644
--- a/pkg/modules/pdfcpu/pdfcpu_test.go
+++ b/pkg/modules/pdfcpu/pdfcpu_test.go
@@ -239,6 +239,15 @@ func TestPdfCpu_Split(t *testing.T) {
}
}
+func TestPdfCpu_Flatten(t *testing.T) {
+ mod := new(PdfCpu)
+ err := mod.Flatten(context.TODO(), zap.NewNop(), "")
+
+ if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
+ t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
+ }
+}
+
func TestPdfCpu_Convert(t *testing.T) {
mod := new(PdfCpu)
err := mod.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go
index a01515b52..20ae2cdc0 100644
--- a/pkg/modules/pdfengines/multi.go
+++ b/pkg/modules/pdfengines/multi.go
@@ -14,6 +14,7 @@ import (
type multiPdfEngines struct {
mergeEngines []gotenberg.PdfEngine
splitEngines []gotenberg.PdfEngine
+ flattenEngines []gotenberg.PdfEngine
convertEngines []gotenberg.PdfEngine
readMetadataEngines []gotenberg.PdfEngine
writeMetadataEngines []gotenberg.PdfEngine
@@ -22,6 +23,7 @@ type multiPdfEngines struct {
func newMultiPdfEngines(
mergeEngines,
splitEngines,
+ flattenEngines,
convertEngines,
readMetadataEngines,
writeMetadataEngines []gotenberg.PdfEngine,
@@ -29,6 +31,7 @@ func newMultiPdfEngines(
return &multiPdfEngines{
mergeEngines: mergeEngines,
splitEngines: splitEngines,
+ flattenEngines: flattenEngines,
convertEngines: convertEngines,
readMetadataEngines: readMetadataEngines,
writeMetadataEngines: writeMetadataEngines,
@@ -98,6 +101,32 @@ func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mod
return nil, fmt.Errorf("split PDF with multi PDF engines: %w", err)
}
+// Flatten merges existing annotation appearances with page content, effectively deleting the original annotations.
+// This process can flatten forms as well, as forms share a relationship with annotations.
+// Note that this operation is irreversible.
+func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ var err error
+ errChan := make(chan error, 1)
+
+ for _, engine := range multi.flattenEngines {
+ go func(engine gotenberg.PdfEngine) {
+ errChan <- engine.Flatten(ctx, logger, inputPath)
+ }(engine)
+
+ select {
+ case mergeErr := <-errChan:
+ errored := multierr.AppendInto(&err, mergeErr)
+ if !errored {
+ return nil
+ }
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+
+ return fmt.Errorf("flatten PDF with multi PDF engines: %w", err)
+}
+
// Convert converts the given PDF to a specific PDF format. thanks to its
// children. If the context is done, it stops and returns an error.
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
diff --git a/pkg/modules/pdfengines/multi_test.go b/pkg/modules/pdfengines/multi_test.go
index 7dddac05e..f5d5b2111 100644
--- a/pkg/modules/pdfengines/multi_test.go
+++ b/pkg/modules/pdfengines/multi_test.go
@@ -194,6 +194,97 @@ func TestMultiPdfEngines_Split(t *testing.T) {
}
}
+func TestMultiPdfEngines_Flatten(t *testing.T) {
+ for _, tc := range []struct {
+ scenario string
+ engine *multiPdfEngines
+ ctx context.Context
+ expectError bool
+ }{
+ {
+ scenario: "nominal behavior",
+ engine: &multiPdfEngines{
+ flattenEngines: []gotenberg.PdfEngine{
+ &gotenberg.PdfEngineMock{
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return nil
+ },
+ },
+ },
+ },
+ ctx: context.Background(),
+ },
+ {
+ scenario: "at least one engine does not return an error",
+ engine: &multiPdfEngines{
+ flattenEngines: []gotenberg.PdfEngine{
+ &gotenberg.PdfEngineMock{
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return errors.New("foo")
+ },
+ },
+ &gotenberg.PdfEngineMock{
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return nil
+ },
+ },
+ },
+ },
+ ctx: context.Background(),
+ },
+ {
+ scenario: "all engines return an error",
+ engine: &multiPdfEngines{
+ flattenEngines: []gotenberg.PdfEngine{
+ &gotenberg.PdfEngineMock{
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return errors.New("foo")
+ },
+ },
+ &gotenberg.PdfEngineMock{
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return errors.New("foo")
+ },
+ },
+ },
+ },
+ ctx: context.Background(),
+ expectError: true,
+ },
+ {
+ scenario: "context expired",
+ engine: &multiPdfEngines{
+ flattenEngines: []gotenberg.PdfEngine{
+ &gotenberg.PdfEngineMock{
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return nil
+ },
+ },
+ },
+ },
+ ctx: func() context.Context {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ return ctx
+ }(),
+ expectError: true,
+ },
+ } {
+ t.Run(tc.scenario, func(t *testing.T) {
+ err := tc.engine.Flatten(tc.ctx, zap.NewNop(), "")
+
+ if !tc.expectError && err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ if tc.expectError && err == nil {
+ t.Fatal("expected error but got none")
+ }
+ })
+ }
+}
+
func TestMultiPdfEngines_Convert(t *testing.T) {
for _, tc := range []struct {
scenario string
diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go
index 4536ff7b5..f029f0af6 100644
--- a/pkg/modules/pdfengines/pdfengines.go
+++ b/pkg/modules/pdfengines/pdfengines.go
@@ -29,6 +29,7 @@ func init() {
type PdfEngines struct {
mergeNames []string
splitNames []string
+ flattenNames []string
convertNames []string
readMetadataNames []string
writeMetadataNames []string
@@ -44,6 +45,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
fs := flag.NewFlagSet("pdfengines", flag.ExitOnError)
fs.StringSlice("pdfengines-merge-engines", []string{"qpdf", "pdfcpu", "pdftk"}, "Set the PDF engines and their order for the merge feature - empty means all")
fs.StringSlice("pdfengines-split-engines", []string{"pdfcpu", "qpdf", "pdftk"}, "Set the PDF engines and their order for the split feature - empty means all")
+ fs.StringSlice("pdfengines-flatten-engines", []string{"qpdf"}, "Set the PDF engines and their order for the flatten feature - empty means all")
fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all")
fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
@@ -67,6 +69,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
mergeNames := flags.MustStringSlice("pdfengines-merge-engines")
splitNames := flags.MustStringSlice("pdfengines-split-engines")
+ flattenNames := flags.MustStringSlice("pdfengines-flatten-engines")
convertNames := flags.MustStringSlice("pdfengines-convert-engines")
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
@@ -106,6 +109,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.splitNames = splitNames
}
+ mod.flattenNames = defaultNames
+ if len(flattenNames) > 0 {
+ mod.flattenNames = flattenNames
+ }
+
mod.convertNames = defaultNames
if len(convertNames) > 0 {
mod.convertNames = convertNames
@@ -170,6 +178,7 @@ func (mod *PdfEngines) Validate() error {
findNonExistingEngines(mod.mergeNames)
findNonExistingEngines(mod.splitNames)
+ findNonExistingEngines(mod.flattenNames)
findNonExistingEngines(mod.convertNames)
findNonExistingEngines(mod.readMetadataNames)
findNonExistingEngines(mod.writeMetadataNames)
@@ -187,6 +196,7 @@ func (mod *PdfEngines) SystemMessages() []string {
return []string{
fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames[:], " ")),
+ fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames[:], " ")),
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")),
@@ -212,6 +222,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
return newMultiPdfEngines(
engines(mod.mergeNames),
engines(mod.splitNames),
+ engines(mod.flattenNames),
engines(mod.convertNames),
engines(mod.readMetadataNames),
engines(mod.writeMetadataNames),
@@ -234,6 +245,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
return []api.Route{
mergeRoute(engine),
splitRoute(engine),
+ flattenRoute(engine),
convertRoute(engine),
readMetadataRoute(engine),
writeMetadataRoute(engine),
diff --git a/pkg/modules/pdfengines/pdfengines_test.go b/pkg/modules/pdfengines/pdfengines_test.go
index 66546ebe4..b0beed3ea 100644
--- a/pkg/modules/pdfengines/pdfengines_test.go
+++ b/pkg/modules/pdfengines/pdfengines_test.go
@@ -27,6 +27,7 @@ func TestPdfEngines_Provision(t *testing.T) {
ctx *gotenberg.Context
expectedMergePdfEngines []string
expectedSplitPdfEngines []string
+ expectedFlattenPdfEngines []string
expectedConvertPdfEngines []string
expectedReadMetadataPdfEngines []string
expectedWriteMetadataPdfEngines []string
@@ -68,6 +69,7 @@ func TestPdfEngines_Provision(t *testing.T) {
}(),
expectedMergePdfEngines: []string{"qpdf", "pdfcpu", "pdftk"},
expectedSplitPdfEngines: []string{"pdfcpu", "qpdf", "pdftk"},
+ expectedFlattenPdfEngines: []string{"qpdf"},
expectedConvertPdfEngines: []string{"libreoffice-pdfengine"},
expectedReadMetadataPdfEngines: []string{"exiftool"},
expectedWriteMetadataPdfEngines: []string{"exiftool"},
@@ -109,7 +111,7 @@ func TestPdfEngines_Provision(t *testing.T) {
}
fs := new(PdfEngines).Descriptor().FlagSet
- err := fs.Parse([]string{"--pdfengines-merge-engines=b", "--pdfengines-split-engines=a", "--pdfengines-convert-engines=b", "--pdfengines-read-metadata-engines=a", "--pdfengines-write-metadata-engines=a"})
+ err := fs.Parse([]string{"--pdfengines-merge-engines=b", "--pdfengines-split-engines=a", "--pdfengines-flatten-engines=c", "--pdfengines-convert-engines=b", "--pdfengines-read-metadata-engines=a", "--pdfengines-write-metadata-engines=a"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
@@ -128,6 +130,7 @@ func TestPdfEngines_Provision(t *testing.T) {
expectedMergePdfEngines: []string{"b"},
expectedSplitPdfEngines: []string{"a"},
+ expectedFlattenPdfEngines: []string{"c"},
expectedConvertPdfEngines: []string{"b"},
expectedReadMetadataPdfEngines: []string{"a"},
expectedWriteMetadataPdfEngines: []string{"a"},
@@ -185,6 +188,10 @@ func TestPdfEngines_Provision(t *testing.T) {
t.Fatalf("expected %d merge names but got %d", len(tc.expectedMergePdfEngines), len(mod.mergeNames))
}
+ if len(tc.expectedFlattenPdfEngines) != len(mod.flattenNames) {
+ t.Fatalf("expected %d flatten names but got %d", len(tc.expectedFlattenPdfEngines), len(mod.flattenNames))
+ }
+
if len(tc.expectedConvertPdfEngines) != len(mod.convertNames) {
t.Fatalf("expected %d convert names but got %d", len(tc.expectedConvertPdfEngines), len(mod.convertNames))
}
@@ -317,14 +324,16 @@ func TestPdfEngines_SystemMessages(t *testing.T) {
mod.readMetadataNames = []string{"foo", "bar"}
mod.writeMetadataNames = []string{"foo", "bar"}
+ expectedMessages := 6
messages := mod.SystemMessages()
- if len(messages) != 5 {
- t.Errorf("expected one and only one message, but got %d", len(messages))
+ if len(messages) != expectedMessages {
+ t.Errorf("expected %d message(s), but got %d", expectedMessages, len(messages))
}
expect := []string{
fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames[:], " ")),
+ fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames[:], " ")),
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")),
@@ -382,7 +391,7 @@ func TestPdfEngines_Routes(t *testing.T) {
}{
{
scenario: "routes not disabled",
- expectRoutes: 5,
+ expectRoutes: 6,
disableRoutes: false,
},
{
diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go
index d164bc77f..d90dd65e2 100644
--- a/pkg/modules/pdfengines/routes.go
+++ b/pkg/modules/pdfengines/routes.go
@@ -202,6 +202,21 @@ func SplitPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, mode gotenberg.S
return outputPaths, nil
}
+// FlattenStub merges annotation appearances with page content for each given PDF
+// in the input paths, effectively deleting the original annotations. It generates
+// new output paths for the flattened PDFs and returns them. If an error occurs
+// during the flattening process, it returns the error.
+func FlattenStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string) error {
+ for _, inputPath := range inputPaths {
+ err := engine.Flatten(ctx, ctx.Log(), inputPath)
+ if err != nil {
+ return fmt.Errorf("flatten '%s': %w", inputPath, err)
+ }
+ }
+
+ return nil
+}
+
// ConvertStub transforms a given PDF to the specified formats defined in
// [gotenberg.PdfFormats]. If no format, it does nothing and returns the input
// paths.
@@ -255,8 +270,10 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
metadata := FormDataPdfMetadata(form, false)
var inputPaths []string
+ var flatten bool
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
+ Bool("flatten", &flatten, false).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
@@ -278,6 +295,13 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("write metadata: %w", err)
}
+ if flatten {
+ err = FlattenStub(ctx, engine, outputPaths)
+ if err != nil {
+ return fmt.Errorf("flatten PDFs: %w", err)
+ }
+ }
+
err = ctx.AddOutputPaths(outputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
@@ -347,6 +371,40 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
}
}
+// flattenRoute returns an [api.Route] which can flatten PDFs.
+func flattenRoute(engine gotenberg.PdfEngine) api.Route {
+ return api.Route{
+ Method: http.MethodPost,
+ Path: "/forms/pdfengines/flatten",
+ IsMultipart: true,
+ Handler: func(c echo.Context) error {
+ ctx := c.Get("context").(*api.Context)
+
+ form := ctx.FormData()
+
+ var inputPaths []string
+ err := form.
+ MandatoryPaths([]string{".pdf"}, &inputPaths).
+ Validate()
+ if err != nil {
+ return fmt.Errorf("validate form data: %w", err)
+ }
+
+ err = FlattenStub(ctx, engine, inputPaths)
+ if err != nil {
+ return fmt.Errorf("convert PDFs: %w", err)
+ }
+
+ err = ctx.AddOutputPaths(inputPaths...)
+ if err != nil {
+ return fmt.Errorf("add output paths: %w", err)
+ }
+
+ return nil
+ },
+ }
+}
+
// convertRoute returns an [api.Route] which can convert PDFs to a specific ODF
// format.
func convertRoute(engine gotenberg.PdfEngine) api.Route {
diff --git a/pkg/modules/pdfengines/routes_test.go b/pkg/modules/pdfengines/routes_test.go
index a0b004fb4..405b5d053 100644
--- a/pkg/modules/pdfengines/routes_test.go
+++ b/pkg/modules/pdfengines/routes_test.go
@@ -719,6 +719,39 @@ func TestMergeHandler(t *testing.T) {
expectHttpError: false,
expectOutputPathsCount: 0,
},
+ {
+ scenario: "PDF engine flatten error",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetFiles(map[string]string{
+ "file.pdf": "/file.pdf",
+ "file2.pdf": "/file2.pdf",
+ })
+ ctx.SetValues(map[string][]string{
+ "metadata": {
+ "{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
+ },
+ "flatten": {
+ "true",
+ },
+ })
+ return ctx
+ }(),
+ engine: &gotenberg.PdfEngineMock{
+ MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
+ return nil
+ },
+ WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
+ return nil
+ },
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return errors.New("foo")
+ },
+ },
+ expectError: true,
+ expectHttpError: false,
+ expectOutputPathsCount: 0,
+ },
{
scenario: "cannot add output paths",
ctx: func() *api.ContextMock {
@@ -754,6 +787,9 @@ func TestMergeHandler(t *testing.T) {
"metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
},
+ "flatten": {
+ "true",
+ },
})
return ctx
}(),
@@ -767,6 +803,9 @@ func TestMergeHandler(t *testing.T) {
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return nil
+ },
},
expectError: false,
expectHttpError: false,
@@ -1055,6 +1094,147 @@ func TestSplitHandler(t *testing.T) {
}
}
+func TestFlattenHandler(t *testing.T) {
+ for _, tc := range []struct {
+ scenario string
+ ctx *api.ContextMock
+ engine gotenberg.PdfEngine
+ expectError bool
+ expectHttpError bool
+ expectHttpStatus int
+ expectOutputPathsCount int
+ expectOutputPaths []string
+ }{
+ {
+ scenario: "missing at least one mandatory file",
+ ctx: &api.ContextMock{Context: new(api.Context)},
+ expectError: true,
+ expectHttpError: true,
+ expectHttpStatus: http.StatusBadRequest,
+ expectOutputPathsCount: 0,
+ },
+ {
+ scenario: "error from PDF engine",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetFiles(map[string]string{
+ "file.pdf": "/file.pdf",
+ })
+ return ctx
+ }(),
+ engine: &gotenberg.PdfEngineMock{
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return errors.New("foo")
+ },
+ },
+ expectError: true,
+ expectHttpError: false,
+ expectOutputPathsCount: 0,
+ },
+ {
+ scenario: "cannot add output paths",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetFiles(map[string]string{
+ "file.pdf": "/file.pdf",
+ })
+ ctx.SetCancelled(true)
+ return ctx
+ }(),
+ engine: &gotenberg.PdfEngineMock{
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return nil
+ },
+ },
+ expectError: true,
+ expectHttpError: false,
+ expectOutputPathsCount: 0,
+ },
+ {
+ scenario: "success with single file",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetFiles(map[string]string{
+ "file.pdf": "/file.pdf",
+ })
+ return ctx
+ }(),
+ engine: &gotenberg.PdfEngineMock{
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return nil
+ },
+ },
+ expectError: false,
+ expectHttpError: false,
+ expectOutputPathsCount: 1,
+ },
+ {
+ scenario: "success (many files)",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetFiles(map[string]string{
+ "file.pdf": "/file.pdf",
+ "file2.pdf": "/file2.pdf",
+ })
+ return ctx
+ }(),
+ engine: &gotenberg.PdfEngineMock{
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return nil
+ },
+ },
+ expectError: false,
+ expectHttpError: false,
+ expectOutputPathsCount: 2,
+ expectOutputPaths: []string{"/file.pdf", "/file2.pdf"},
+ },
+ } {
+ t.Run(tc.scenario, func(t *testing.T) {
+ tc.ctx.SetLogger(zap.NewNop())
+ c := echo.New().NewContext(nil, nil)
+ c.Set("context", tc.ctx.Context)
+
+ err := flattenRoute(tc.engine).Handler(c)
+
+ if tc.expectError && err == nil {
+ t.Fatal("expected error but got none", err)
+ }
+
+ if !tc.expectError && err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ var httpErr api.HttpError
+ isHttpError := errors.As(err, &httpErr)
+
+ if tc.expectHttpError && !isHttpError {
+ t.Errorf("expected an HTTP error but got: %v", err)
+ }
+
+ if !tc.expectHttpError && isHttpError {
+ t.Errorf("expected no HTTP error but got one: %v", httpErr)
+ }
+
+ if err != nil && tc.expectHttpError && isHttpError {
+ status, _ := httpErr.HttpError()
+ if status != tc.expectHttpStatus {
+ t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
+ }
+ }
+
+ if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
+ t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
+ }
+
+ for _, path := range tc.expectOutputPaths {
+ if !slices.Contains(tc.ctx.OutputPaths(), path) {
+ t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths())
+ }
+ }
+ })
+ }
+}
+
func TestConvertHandler(t *testing.T) {
for _, tc := range []struct {
scenario string
diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go
index c3f63d173..39094fa81 100644
--- a/pkg/modules/pdftk/pdftk.go
+++ b/pkg/modules/pdftk/pdftk.go
@@ -99,6 +99,11 @@ func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths [
return fmt.Errorf("merge PDFs with PDFtk: %w", err)
}
+// Flatten is not available in this implementation.
+func (engine *PdfTk) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return fmt.Errorf("flatten PDF with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
// Convert is not available in this implementation.
func (engine *PdfTk) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with PDFtk: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
diff --git a/pkg/modules/pdftk/pdftk_test.go b/pkg/modules/pdftk/pdftk_test.go
index 73311f725..4ca0415ec 100644
--- a/pkg/modules/pdftk/pdftk_test.go
+++ b/pkg/modules/pdftk/pdftk_test.go
@@ -232,6 +232,15 @@ func TestPdfCpu_Split(t *testing.T) {
}
}
+func TestPdfTk_Flatten(t *testing.T) {
+ engine := new(PdfTk)
+ err := engine.Flatten(context.TODO(), zap.NewNop(), "")
+
+ if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
+ t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
+ }
+}
+
func TestPdfTk_Convert(t *testing.T) {
engine := new(PdfTk)
err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go
index 34785adef..760b65124 100644
--- a/pkg/modules/qpdf/qpdf.go
+++ b/pkg/modules/qpdf/qpdf.go
@@ -101,6 +101,27 @@ func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []
return fmt.Errorf("merge PDFs with QPDF: %w", err)
}
+// Flatten merges annotation appearances with page content, deleting the original annotations.
+func (engine *QPdf) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ var args []string
+ args = append(args, "--generate-appearances")
+ args = append(args, "--flatten-annotations=all")
+ args = append(args, "--replace-input")
+ args = append(args, inputPath)
+
+ cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
+ if err != nil {
+ return fmt.Errorf("create command: %w", err)
+ }
+
+ _, err = cmd.Exec()
+ if err == nil {
+ return nil
+ }
+
+ return fmt.Errorf("flatten PDFs with QPDF: %w", err)
+}
+
// Convert is not available in this implementation.
func (engine *QPdf) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with QPDF: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
diff --git a/pkg/modules/qpdf/qpdf_test.go b/pkg/modules/qpdf/qpdf_test.go
index 9c79721b1..b7eed3920 100644
--- a/pkg/modules/qpdf/qpdf_test.go
+++ b/pkg/modules/qpdf/qpdf_test.go
@@ -3,6 +3,8 @@ package qpdf
import (
"context"
"errors"
+ "fmt"
+ "io"
"os"
"reflect"
"testing"
@@ -232,6 +234,101 @@ func TestQPdf_Split(t *testing.T) {
}
}
+func TestQPdf_Flatten(t *testing.T) {
+ for _, tc := range []struct {
+ scenario string
+ ctx context.Context
+ inputPath string
+ createCopy bool
+ expectError bool
+ }{
+ {
+ scenario: "invalid context",
+ ctx: nil,
+ expectError: true,
+ },
+ {
+ scenario: "invalid input path",
+ ctx: context.TODO(),
+ inputPath: "foo.pdf",
+ expectError: true,
+ },
+ {
+ scenario: "success",
+ ctx: context.TODO(),
+ inputPath: "/tests/test/testdata/pdfengines/sample3.pdf",
+ createCopy: true,
+ expectError: false,
+ },
+ } {
+ t.Run(tc.scenario, func(t *testing.T) {
+ engine := new(QPdf)
+ err := engine.Provision(nil)
+ if err != nil {
+ t.Fatalf("expected error but got: %v", err)
+ }
+
+ var destinationPath string
+ if tc.createCopy {
+ fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
+ outputDir, err := fs.MkdirAll()
+ if err != nil {
+ t.Fatalf("expected error no but got: %v", err)
+ }
+
+ defer func() {
+ err = os.RemoveAll(fs.WorkingDirPath())
+ if err != nil {
+ t.Fatalf("expected no error while cleaning up but got: %v", err)
+ }
+ }()
+
+ destinationPath = fmt.Sprintf("%s/copy_temp.pdf", outputDir)
+ source, err := os.Open(tc.inputPath)
+ if err != nil {
+ t.Fatalf("open source file: %v", err)
+ }
+
+ defer func(source *os.File) {
+ err := source.Close()
+ if err != nil {
+ t.Fatalf("close file: %v", err)
+ }
+ }(source)
+
+ destination, err := os.Create(destinationPath)
+ if err != nil {
+ t.Fatalf("create destination file: %v", err)
+ }
+
+ defer func(destination *os.File) {
+ err := destination.Close()
+ if err != nil {
+ t.Fatalf("close file: %v", err)
+ }
+ }(destination)
+
+ _, err = io.Copy(destination, source)
+ if err != nil {
+ t.Fatalf("copy source into destination: %v", err)
+ }
+ } else {
+ destinationPath = tc.inputPath
+ }
+
+ err = engine.Flatten(tc.ctx, zap.NewNop(), destinationPath)
+
+ if !tc.expectError && err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ if tc.expectError && err == nil {
+ t.Fatal("expected error but got none")
+ }
+ })
+ }
+}
+
func TestQPdf_Convert(t *testing.T) {
engine := new(QPdf)
err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
diff --git a/test/testdata/pdfengines/sample3.pdf b/test/testdata/pdfengines/sample3.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..d079b89242260e3967ad2e2a43d6940e58e9fdc8
GIT binary patch
literal 224175
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