refactor(pdfengines): embeds => attachments

This commit is contained in:
Julien Neuhart
2026-03-08 15:49:09 +01:00
parent 7af3cd1ff5
commit cf9fd7eedc
92 changed files with 5763 additions and 3082 deletions

View File

@@ -1,27 +1,28 @@
//nolint:dupl
package pdfengines
import (
"context"
"fmt"
"log/slog"
"sync"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"go.uber.org/multierr"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
type multiPdfEngines struct {
mergeEngines []gotenberg.PdfEngine
splitEngines []gotenberg.PdfEngine
flattenEngines []gotenberg.PdfEngine
convertEngines []gotenberg.PdfEngine
readMetadataEngines []gotenberg.PdfEngine
writeMetadataEngines []gotenberg.PdfEngine
passwordEngines []gotenberg.PdfEngine
embedEngines []gotenberg.PdfEngine
readBookmarksEngines []gotenberg.PdfEngine
writeBookmarksEngines []gotenberg.PdfEngine
mergeEngines []gotenberg.PdfEngine
splitEngines []gotenberg.PdfEngine
flattenEngines []gotenberg.PdfEngine
convertEngines []gotenberg.PdfEngine
readMetadataEngines []gotenberg.PdfEngine
writeMetadataEngines []gotenberg.PdfEngine
passwordEngines []gotenberg.PdfEngine
attachmentsEngines []gotenberg.PdfEngine
}
func newMultiPdfEngines(
@@ -32,27 +33,26 @@ func newMultiPdfEngines(
readMetadataEngines,
writeMetadataEngines,
passwordEngines,
embedEngines,
readBookmarksEngines,
writeBookmarksEngines []gotenberg.PdfEngine,
attachmentsEngines []gotenberg.PdfEngine,
) *multiPdfEngines {
return &multiPdfEngines{
mergeEngines: mergeEngines,
splitEngines: splitEngines,
flattenEngines: flattenEngines,
convertEngines: convertEngines,
readMetadataEngines: readMetadataEngines,
writeMetadataEngines: writeMetadataEngines,
passwordEngines: passwordEngines,
embedEngines: embedEngines,
readBookmarksEngines: readBookmarksEngines,
writeBookmarksEngines: writeBookmarksEngines,
mergeEngines: mergeEngines,
splitEngines: splitEngines,
flattenEngines: flattenEngines,
convertEngines: convertEngines,
readMetadataEngines: readMetadataEngines,
writeMetadataEngines: writeMetadataEngines,
passwordEngines: passwordEngines,
attachmentsEngines: attachmentsEngines,
}
}
// Merge combines multiple PDF files into a single document using the first
// available engine that supports PDF merging.
func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
func (multi *multiPdfEngines) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.Merge", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
@@ -72,7 +72,10 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp
}
}
return fmt.Errorf("merge PDFs with multi PDF engines: %w", err)
err = fmt.Errorf("merge PDFs with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
type splitResult struct {
@@ -82,7 +85,10 @@ type splitResult struct {
// Split divides the PDF into separate pages using the first available engine
// that supports PDF splitting.
func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
func (multi *multiPdfEngines) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.Split", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
var mu sync.Mutex // to safely append errors.
@@ -108,12 +114,18 @@ func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mod
}
}
return nil, fmt.Errorf("split PDF with multi PDF engines: %w", err)
err = fmt.Errorf("split PDF with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// Flatten merges existing annotation appearances with page content using the
// first available engine that supports flattening.
func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.Flatten", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
@@ -133,12 +145,18 @@ func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, i
}
}
return fmt.Errorf("flatten PDF with multi PDF engines: %w", err)
err = fmt.Errorf("flatten PDF with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Convert transforms the given PDF to a specific PDF format using the first
// available engine that supports PDF conversion.
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.Convert", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
@@ -158,7 +176,10 @@ func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, f
}
}
return fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err)
err = fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
type readMetadataResult struct {
@@ -168,7 +189,10 @@ type readMetadataResult struct {
// ReadMetadata extracts metadata from a PDF file using the first available
// engine that supports metadata reading.
func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.ReadMetadata", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
var mu sync.Mutex // to safely append errors.
@@ -194,12 +218,18 @@ func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logg
}
}
return nil, fmt.Errorf("read PDF metadata with multi PDF engines: %w", err)
err = fmt.Errorf("read PDF metadata with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// WriteMetadata embeds metadata into a PDF file using the first available
// WriteMetadata attachments metadata into a PDF file using the first available
// engine that supports metadata writing.
func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.WriteMetadata", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
@@ -219,73 +249,18 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log
}
}
return fmt.Errorf("write PDF metadata with multi PDF engines: %w", err)
}
type readBookmarksResult struct {
bookmarks []gotenberg.Bookmark
err error
}
// ReadBookmarks reads bookmarks from a PDF file using the first available
// engine that supports bookmarks reading.
func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
var err error
var mu sync.Mutex // to safely append errors.
for _, engine := range multi.readBookmarksEngines {
resultChan := make(chan readBookmarksResult, 1)
go func(engine gotenberg.PdfEngine) {
bookmarks, err := engine.ReadBookmarks(ctx, logger, inputPath)
resultChan <- readBookmarksResult{bookmarks: bookmarks, err: err}
}(engine)
select {
case result := <-resultChan:
if result.err != nil {
mu.Lock()
err = multierr.Append(err, result.err)
mu.Unlock()
} else {
return result.bookmarks, nil
}
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("read PDF bookmarks with multi PDF engines: %w", err)
}
// WriteBookmarks adds a document outline (bookmarks) to a PDF file using the
// first available engine that supports bookmarks writing.
func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
var err error
errChan := make(chan error, 1)
for _, engine := range multi.writeBookmarksEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.WriteBookmarks(ctx, logger, inputPath, bookmarks)
}(engine)
select {
case writeBookmarksErr := <-errChan:
errored := multierr.AppendInto(&err, writeBookmarksErr)
if !errored {
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("write PDF bookmarks with multi PDF engines: %w", err)
err = fmt.Errorf("write PDF metadata with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Encrypt adds password protection to a PDF file using the first available
// engine that supports password protection.
func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.Encrypt", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
@@ -305,23 +280,29 @@ func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, i
}
}
return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err)
err = fmt.Errorf("encrypt PDF using multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// EmbedFiles embeds files into a PDF using the first available
// engine that supports file embedding.
func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
// AddAttachments adds attachments into a PDF using the first available
// engine that supports file attaching.
func (multi *multiPdfEngines) AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.AddAttachments", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
for _, engine := range multi.embedEngines {
for _, engine := range multi.attachmentsEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.EmbedFiles(ctx, logger, filePaths, inputPath)
errChan <- engine.AddAttachments(ctx, logger, filePaths, inputPath)
}(engine)
select {
case embedErr := <-errChan:
errored := multierr.AppendInto(&err, embedErr)
case attachmentsErr := <-errChan:
errored := multierr.AppendInto(&err, attachmentsErr)
if !errored {
return nil
}
@@ -330,7 +311,10 @@ func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger
}
}
return fmt.Errorf("embed files into PDF using multi PDF engines: %w", err)
err = fmt.Errorf("add attachments into PDF using multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Interface guards.

View File

@@ -0,0 +1,650 @@
package pdfengines
import (
"context"
"errors"
"log/slog"
"os"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestMultiPdfEngines_Merge(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
expectError: false,
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
expectError: false,
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.engine.Merge(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), nil, "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_Encrypt(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
passwordEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
passwordEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
passwordEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
passwordEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.engine.Encrypt(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), "", "", "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_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 *slog.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 *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *slog.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 *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *slog.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 *slog.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, slog.New(slog.NewJSONHandler(os.Stdout, nil)), gotenberg.SplitMode{}, "", "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_Flatten(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
flattenEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *slog.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 *slog.Logger, inputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *slog.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 *slog.Logger, inputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *slog.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 *slog.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, slog.New(slog.NewJSONHandler(os.Stdout, nil)), "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_Convert(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.engine.Convert(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), gotenberg.PdfFormats{}, "", "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return make(map[string]any), nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return make(map[string]any), nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return nil, errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return make(map[string]any), nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
_, err := tc.engine.ReadMetadata(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.engine.WriteMetadata(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), nil, "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}

View File

@@ -28,18 +28,16 @@ func init() {
// the [api.Router] interface to expose relevant PDF processing routes if
// enabled.
type PdfEngines struct {
mergeNames []string
splitNames []string
flattenNames []string
convertNames []string
readMetadataNames []string
writeMetadataNames []string
encryptNames []string
embedNames []string
readBookmarksNames []string
writeBookmarksNames []string
engines []gotenberg.PdfEngine
disableRoutes bool
mergeNames []string
splitNames []string
flattenNames []string
convertNames []string
readMetadataNames []string
writeMetadataNames []string
encryptNames []string
attachmentsNames []string
engines []gotenberg.PdfEngine
disableRoutes bool
}
// Descriptor returns a PdfEngines' module descriptor.
@@ -54,19 +52,10 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
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")
fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all")
fs.StringSlice("pdfengines-embed-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the file embedding feature - empty means all")
fs.StringSlice("pdfengines-read-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the read bookmarks feature - empty means all")
fs.StringSlice("pdfengines-write-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the write bookmarks feature - empty means all")
fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdfcpu", "pdftk"}, "Set the PDF engines and their order for the password protection feature - empty means all")
fs.StringSlice("pdfengines-add-attachments-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the file attaching feature - empty means all")
fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
// Deprecated flags.
fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the default PDF engines and their default order - all by default")
err := fs.MarkDeprecated("pdfengines-engines", "use other flags for a more granular selection of PDF engines per method")
if err != nil {
panic(err)
}
return fs
}(),
New: func() gotenberg.Module { return new(PdfEngines) },
@@ -84,9 +73,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
embedNames := flags.MustStringSlice("pdfengines-embed-engines")
readBookmarksNames := flags.MustStringSlice("pdfengines-read-bookmarks-engines")
writeBookmarksNames := flags.MustStringSlice("pdfengines-write-bookmarks-engines")
attachmentsNames := flags.MustStringSlice("pdfengines-add-attachments-engines")
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
engines, err := ctx.Modules(new(gotenberg.PdfEngine))
@@ -108,7 +95,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
// Example in the case of deprecated module name.
//for i, name := range defaultNames {
// if name == "unoconv-pdfengine" || name == "uno-pdfengine" {
// logger.Warn(fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name))
// logger.WarnContext(ctx, fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name))
// mod.defaultNames[i] = "libreoffice-pdfengine"
// }
//}
@@ -148,19 +135,9 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.encryptNames = encryptNames
}
mod.embedNames = defaultNames
if len(embedNames) > 0 {
mod.embedNames = embedNames
}
mod.readBookmarksNames = defaultNames
if len(readBookmarksNames) > 0 {
mod.readBookmarksNames = readBookmarksNames
}
mod.writeBookmarksNames = defaultNames
if len(writeBookmarksNames) > 0 {
mod.writeBookmarksNames = writeBookmarksNames
mod.attachmentsNames = defaultNames
if len(attachmentsNames) > 0 {
mod.attachmentsNames = attachmentsNames
}
return nil
@@ -211,9 +188,7 @@ func (mod *PdfEngines) Validate() error {
findNonExistingEngines(mod.readMetadataNames)
findNonExistingEngines(mod.writeMetadataNames)
findNonExistingEngines(mod.encryptNames)
findNonExistingEngines(mod.embedNames)
findNonExistingEngines(mod.readBookmarksNames)
findNonExistingEngines(mod.writeBookmarksNames)
findNonExistingEngines(mod.attachmentsNames)
if len(nonExistingEngines) == 0 {
return nil
@@ -233,9 +208,6 @@ func (mod *PdfEngines) SystemMessages() []string {
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")),
fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames[:], " ")),
fmt.Sprintf("embed engines - %s", strings.Join(mod.embedNames[:], " ")),
fmt.Sprintf("read bookmarks engines - %s", strings.Join(mod.readBookmarksNames[:], " ")),
fmt.Sprintf("write bookmarks engines - %s", strings.Join(mod.writeBookmarksNames[:], " ")),
}
}
@@ -263,9 +235,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
engines(mod.readMetadataNames),
engines(mod.writeMetadataNames),
engines(mod.encryptNames),
engines(mod.embedNames),
engines(mod.readBookmarksNames),
engines(mod.writeBookmarksNames),
engines(mod.attachmentsNames),
), nil
}
@@ -289,10 +259,8 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
convertRoute(engine),
readMetadataRoute(engine),
writeMetadataRoute(engine),
readBookmarksRoute(engine),
writeBookmarksRoute(engine),
encryptRoute(engine),
embedRoute(engine),
attachmentsRoute(engine),
}, nil
}

View File

@@ -84,7 +84,7 @@ func FormDataPdfSplitMode(form *api.FormData, mandatory bool) gotenberg.SplitMod
}
// FormDataPdfFormats creates [gotenberg.PdfFormats] from the form data.
// Fallback to the default value if the considered key is not present.
// Fallback to default value if the considered key is not present.
func FormDataPdfFormats(form *api.FormData) gotenberg.PdfFormats {
var (
pdfa string
@@ -128,44 +128,6 @@ func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]any {
return metadata
}
// FormDataPdfBookmarks creates bookmarks from the form data.
func FormDataPdfBookmarks(form *api.FormData, mandatory bool) any {
var bookmarks any
bookmarksFunc := func(value string) error {
if len(value) > 0 {
var list []gotenberg.Bookmark
err := json.Unmarshal([]byte(value), &list)
if err == nil {
bookmarks = list
return nil
}
var m map[string][]gotenberg.Bookmark
err = json.Unmarshal([]byte(value), &m)
if err == nil {
bookmarks = m
return nil
}
return fmt.Errorf("unmarshal bookmarks: %w", err)
}
return nil
}
if mandatory {
form.MandatoryCustom("bookmarks", func(value string) error {
return bookmarksFunc(value)
})
} else {
form.Custom("bookmarks", func(value string) error {
return bookmarksFunc(value)
})
}
return bookmarks
}
// MergeStub merges given PDFs. If only one input PDF, it does nothing and
// returns the corresponding input path.
func MergeStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string) (string, error) {
@@ -292,49 +254,12 @@ func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata ma
return nil
}
// WriteBookmarksStub writes the bookmarks into PDF files. If no bookmarks, it
// does nothing.
func WriteBookmarksStub(ctx *api.Context, engine gotenberg.PdfEngine, bookmarks any, inputPaths []string) error {
if bookmarks == nil {
return nil
}
switch b := bookmarks.(type) {
case []gotenberg.Bookmark:
if len(b) == 0 {
return nil
}
for _, inputPath := range inputPaths {
err := engine.WriteBookmarks(ctx, ctx.Log(), inputPath, b)
if err != nil {
return fmt.Errorf("write bookmarks into '%s': %w", inputPath, err)
}
}
case map[string][]gotenberg.Bookmark:
for _, inputPath := range inputPaths {
filename := filepath.Base(inputPath)
if specificBookmarks, ok := b[filename]; ok {
err := engine.WriteBookmarks(ctx, ctx.Log(), inputPath, specificBookmarks)
if err != nil {
return fmt.Errorf("write bookmarks into '%s': %w", inputPath, err)
}
}
}
default:
// Should not happen.
return fmt.Errorf("bookmarks type '%T' not supported", bookmarks)
}
return nil
}
// FormDataPdfEmbeds extracts embedded file paths from form data.
// Only files uploaded with the "embeds" field name are included.
func FormDataPdfEmbeds(form *api.FormData) []string {
var embedPaths []string
form.Embeds(&embedPaths)
return embedPaths
// FormDataPdfAttachments extracts attached file paths from form data.
// Only files uploaded with the "attachments" field name are included.
func FormDataPdfAttachments(form *api.FormData) []string {
var attachmentsPaths []string
form.Attachments(&attachmentsPaths)
return attachmentsPaths
}
// FormDataPdfEncrypt extracts encryption parameters from form data.
@@ -360,16 +285,16 @@ func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword,
return nil
}
// EmbedFilesStub embeds files into PDF files.
func EmbedFilesStub(ctx *api.Context, engine gotenberg.PdfEngine, embedPaths []string, inputPaths []string) error {
if len(embedPaths) == 0 {
// AddAttachmentsStub adds attachments into PDF files.
func AddAttachmentsStub(ctx *api.Context, engine gotenberg.PdfEngine, attachmentsPaths []string, inputPaths []string) error {
if len(attachmentsPaths) == 0 {
return nil
}
for _, inputPath := range inputPaths {
err := engine.EmbedFiles(ctx, ctx.Log(), embedPaths, inputPath)
err := engine.AddAttachments(ctx, ctx.Log(), attachmentsPaths, inputPath)
if err != nil {
return fmt.Errorf("embed files into PDF '%s': %w", inputPath, err)
return fmt.Errorf("add attachments into PDF '%s': %w", inputPath, err)
}
}
@@ -388,9 +313,8 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
form := ctx.FormData()
pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false)
bookmarks := FormDataPdfBookmarks(form, false)
userPassword, ownerPassword := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form)
attachmentsPaths := FormDataPdfAttachments(form)
var inputPaths []string
var flatten bool
@@ -402,13 +326,6 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if b, ok := bookmarks.(map[string][]gotenberg.Bookmark); ok {
err = WriteBookmarksStub(ctx, engine, b, inputPaths)
if err != nil {
return fmt.Errorf("write bookmarks: %w", err)
}
}
outputPath := ctx.GeneratePath(".pdf")
err = engine.Merge(ctx, ctx.Log(), inputPaths, outputPath)
if err != nil {
@@ -420,16 +337,9 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("convert PDF: %w", err)
}
err = EmbedFilesStub(ctx, engine, embedPaths, outputPaths)
err = AddAttachmentsStub(ctx, engine, attachmentsPaths, outputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
}
if b, ok := bookmarks.([]gotenberg.Bookmark); ok {
err = WriteBookmarksStub(ctx, engine, b, outputPaths)
if err != nil {
return fmt.Errorf("write bookmarks: %w", err)
}
return fmt.Errorf("add attachments into PDFs: %w", err)
}
err = WriteMetadataStub(ctx, engine, metadata, outputPaths)
@@ -473,7 +383,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false)
userPassword, ownerPassword := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form)
attachmentsPaths := FormDataPdfAttachments(form)
var inputPaths []string
var flatten bool
@@ -495,9 +405,9 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("convert PDFs: %w", err)
}
err = EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths)
err = AddAttachmentsStub(ctx, engine, attachmentsPaths, convertOutputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
return fmt.Errorf("add attachments into PDFs: %w", err)
}
err = WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
@@ -709,83 +619,6 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route {
}
}
// readBookmarksRoute returns an [api.Route] which returns the bookmarks of PDFs.
func readBookmarksRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/bookmarks/read",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
var inputPaths []string
err := ctx.FormData().
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
res := make(map[string][]gotenberg.Bookmark, len(inputPaths))
for _, inputPath := range inputPaths {
bookmarks, err := engine.ReadBookmarks(ctx, ctx.Log(), inputPath)
if err != nil {
return fmt.Errorf("read bookmarks: %w", err)
}
res[filepath.Base(inputPath)] = bookmarks
}
err = c.JSON(http.StatusOK, res)
if err != nil {
if strings.Contains(err.Error(), "request method or response status code does not allow body") {
// High probability that the user is using the webhook
// feature. It does not make sense for this route.
return api.ErrNoOutputFile
}
return fmt.Errorf("return JSON response: %w", err)
}
return api.ErrNoOutputFile
},
}
}
// writeBookmarksRoute returns an [api.Route] which can write bookmarks into PDFs.
func writeBookmarksRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/bookmarks/write",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
bookmarks := FormDataPdfBookmarks(form, true)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = WriteBookmarksStub(ctx, engine, bookmarks, inputPaths)
if err != nil {
return fmt.Errorf("write bookmarks: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}
// encryptRoute returns an [api.Route] which can add password protection to PDFs.
func encryptRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
@@ -824,18 +657,17 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route {
}
}
// embedRoute returns an [api.Route] which can add embedded files to PDFs.
// TODO: attachments instead?
func embedRoute(engine gotenberg.PdfEngine) api.Route {
// attachmentsRoute returns an [api.Route] which can add attached files to PDFs.
func attachmentsRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/embed",
Path: "/forms/pdfengines/attachments/add",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
embedPaths := FormDataPdfEmbeds(form)
attachmentsPaths := FormDataPdfAttachments(form)
var inputPaths []string
err := form.
@@ -844,9 +676,10 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = EmbedFilesStub(ctx, engine, embedPaths, inputPaths)
err = AddAttachmentsStub(ctx, engine, attachmentsPaths, inputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
return fmt.Errorf("add attachments into PDFs: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)