Files
gotenberg/pkg/modules/pdfengines/multi.go
T

358 lines
14 KiB
Go

package pdfengines
import (
"context"
"errors"
"fmt"
"log/slog"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"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
embedMetadataEngines []gotenberg.PdfEngine
readBookmarksEngines []gotenberg.PdfEngine
writeBookmarksEngines []gotenberg.PdfEngine
watermarkEngines []gotenberg.PdfEngine
stampEngines []gotenberg.PdfEngine
rotateEngines []gotenberg.PdfEngine
facturXEngines []gotenberg.PdfEngine
}
func newMultiPdfEngines(
mergeEngines,
splitEngines,
flattenEngines,
convertEngines,
readMetadataEngines,
writeMetadataEngines,
passwordEngines,
embedEngines,
embedMetadataEngines,
readBookmarksEngines,
writeBookmarksEngines,
watermarkEngines,
stampEngines,
rotateEngines,
facturXEngines []gotenberg.PdfEngine,
) *multiPdfEngines {
return &multiPdfEngines{
mergeEngines: mergeEngines,
splitEngines: splitEngines,
flattenEngines: flattenEngines,
convertEngines: convertEngines,
readMetadataEngines: readMetadataEngines,
writeMetadataEngines: writeMetadataEngines,
passwordEngines: passwordEngines,
embedEngines: embedEngines,
embedMetadataEngines: embedMetadataEngines,
readBookmarksEngines: readBookmarksEngines,
writeBookmarksEngines: writeBookmarksEngines,
watermarkEngines: watermarkEngines,
stampEngines: stampEngines,
rotateEngines: rotateEngines,
facturXEngines: facturXEngines,
}
}
// engineName returns the module ID of a PDF engine for telemetry, falling back
// to its type name when it does not expose a descriptor.
func engineName(engine gotenberg.PdfEngine) string {
if module, ok := engine.(gotenberg.Module); ok {
return module.Descriptor().ID
}
return fmt.Sprintf("%T", engine)
}
// runWithFallback runs op against each engine in order and returns the first
// success. It wraps the attempts in a pdfengines span, records the winning
// engine and attempt count, emits a pdf_engine.attempt_failed event for each
// failed engine, and joins all engine errors on total failure. A context
// cancellation marks the span as errored too. wrap applies the op-specific
// final error message.
func runWithFallback[T any](
ctx context.Context,
spanName string,
engines []gotenberg.PdfEngine,
op func(ctx context.Context, engine gotenberg.PdfEngine) (T, error),
wrap func(err error) error,
) (T, error) {
var zero T
ctx, span := gotenberg.Tracer().Start(ctx, spanName, trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
type attemptResult struct {
value T
err error
}
var joined error
for attempt, engine := range engines {
resultChan := make(chan attemptResult, 1)
go func(engine gotenberg.PdfEngine) {
value, err := op(ctx, engine)
resultChan <- attemptResult{value: value, err: err}
}(engine)
select {
case result := <-resultChan:
if result.err == nil {
span.SetAttributes(
attribute.String("gotenberg.pdf_engine.selected", engineName(engine)),
attribute.Int("gotenberg.pdf_engine.attempts", attempt+1),
)
span.SetStatus(codes.Ok, "")
return result.value, nil
}
joined = errors.Join(joined, result.err)
span.AddEvent("pdf_engine.attempt_failed", trace.WithAttributes(
attribute.String("engine", engineName(engine)),
))
case <-ctx.Done():
err := ctx.Err()
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return zero, err
}
}
err := wrap(joined)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return zero, err
}
// runWithFallbackVoid adapts [runWithFallback] for operations that return no
// value beyond an error.
func runWithFallbackVoid(
ctx context.Context,
spanName string,
engines []gotenberg.PdfEngine,
op func(ctx context.Context, engine gotenberg.PdfEngine) error,
wrap func(err error) error,
) error {
_, err := runWithFallback(ctx, spanName, engines,
func(ctx context.Context, engine gotenberg.PdfEngine) (struct{}, error) {
return struct{}{}, op(ctx, engine)
},
wrap,
)
return err
}
// 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 *slog.Logger, inputPaths []string, outputPath string) error {
return runWithFallbackVoid(ctx, "pdfengines.Merge", multi.mergeEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.Merge(ctx, logger, inputPaths, outputPath)
},
func(err error) error { return fmt.Errorf("merge PDFs with multi PDF engines: %w", err) },
)
}
// Split divides the PDF into separate pages using the first available engine
// that supports PDF splitting.
func (multi *multiPdfEngines) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return runWithFallback(ctx, "pdfengines.Split", multi.splitEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) ([]string, error) {
return engine.Split(ctx, logger, mode, inputPath, outputDirPath)
},
func(err error) error { return fmt.Errorf("split PDF with multi PDF engines: %w", 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 *slog.Logger, inputPath string) error {
return runWithFallbackVoid(ctx, "pdfengines.Flatten", multi.flattenEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.Flatten(ctx, logger, inputPath)
},
func(err error) error { return fmt.Errorf("flatten PDF with multi PDF engines: %w", 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 *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return runWithFallbackVoid(ctx, "pdfengines.Convert", multi.convertEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.Convert(ctx, logger, formats, inputPath, outputPath)
},
func(err error) error {
return fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err)
},
)
}
// ReadMetadata extracts metadata from a PDF file using the first available
// engine that supports metadata reading.
func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return runWithFallback(ctx, "pdfengines.ReadMetadata", multi.readMetadataEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) (map[string]any, error) {
return engine.ReadMetadata(ctx, logger, inputPath)
},
func(err error) error { return fmt.Errorf("read PDF metadata with multi PDF engines: %w", err) },
)
}
// WriteMetadata embeds metadata into a PDF file using the first available
// engine that supports metadata writing.
func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return runWithFallbackVoid(ctx, "pdfengines.WriteMetadata", multi.writeMetadataEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.WriteMetadata(ctx, logger, metadata, inputPath)
},
func(err error) error { return fmt.Errorf("write PDF metadata with multi PDF engines: %w", err) },
)
}
// PageCount returns the number of pages in a PDF file using the first available
// engine that supports metadata reading.
func (multi *multiPdfEngines) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
return runWithFallback(ctx, "pdfengines.PageCount", multi.readMetadataEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) (int, error) {
return engine.PageCount(ctx, logger, inputPath)
},
func(err error) error { return fmt.Errorf("page count with multi PDF engines: %w", err) },
)
}
// ReadBookmarks reads bookmarks from a PDF file using the first available
// engine that supports bookmarks reading.
func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
return runWithFallback(ctx, "pdfengines.ReadBookmarks", multi.readBookmarksEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) ([]gotenberg.Bookmark, error) {
return engine.ReadBookmarks(ctx, logger, inputPath)
},
func(err error) error { return 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 *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
return runWithFallbackVoid(ctx, "pdfengines.WriteBookmarks", multi.writeBookmarksEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.WriteBookmarks(ctx, logger, inputPath, bookmarks)
},
func(err error) error { return fmt.Errorf("write PDF bookmarks with multi PDF engines: %w", err) },
)
}
// Encrypt adds password protection to a PDF file using the first available
// engine that supports password protection.
func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
return runWithFallbackVoid(ctx, "pdfengines.Encrypt", multi.passwordEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.Encrypt(ctx, logger, inputPath, opts)
},
func(err error) error { return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err) },
)
}
// EmbedFiles embeds files into a PDF using the first available engine that
// supports file embedding.
func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
return runWithFallbackVoid(ctx, "pdfengines.EmbedFiles", multi.embedEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.EmbedFiles(ctx, logger, filePaths, inputPath)
},
func(err error) error { return fmt.Errorf("embed files into PDF using multi PDF engines: %w", err) },
)
}
// Watermark applies a watermark (behind page content) to a PDF file using the
// first available engine that supports watermarking.
func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
return runWithFallbackVoid(ctx, "pdfengines.Watermark", multi.watermarkEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.Watermark(ctx, logger, inputPath, stamp)
},
func(err error) error { return fmt.Errorf("watermark PDF with multi PDF engines: %w", err) },
)
}
// Stamp applies a stamp (on top of page content) to a PDF file using the first
// available engine that supports stamping.
func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
return runWithFallbackVoid(ctx, "pdfengines.Stamp", multi.stampEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.Stamp(ctx, logger, inputPath, stamp)
},
func(err error) error { return fmt.Errorf("stamp PDF with multi PDF engines: %w", err) },
)
}
// Rotate rotates pages of a PDF file using the first available engine that
// supports rotation.
func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
return runWithFallbackVoid(ctx, "pdfengines.Rotate", multi.rotateEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.Rotate(ctx, logger, inputPath, angle, pages)
},
func(err error) error { return fmt.Errorf("rotate PDF with multi PDF engines: %w", err) },
)
}
// EmbedFilesMetadata sets metadata on embedded files using the first available
// engine that supports it.
func (multi *multiPdfEngines) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
return runWithFallbackVoid(ctx, "pdfengines.EmbedFilesMetadata", multi.embedMetadataEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.EmbedFilesMetadata(ctx, logger, metadata, inputPath)
},
func(err error) error { return fmt.Errorf("set embeds metadata using multi PDF engines: %w", err) },
)
}
// InjectFacturXXMP injects Factur-X/ZUGFeRD XMP metadata using the first
// available engine that supports it.
func (multi *multiPdfEngines) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
return runWithFallbackVoid(ctx, "pdfengines.InjectFacturXXMP", multi.facturXEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.InjectFacturXXMP(ctx, logger, facturX, inputPath)
},
func(err error) error { return fmt.Errorf("inject Factur-X XMP with multi PDF engines: %w", err) },
)
}
// ReadPdfAConformance reads the PDF/A part and conformance using the first
// available engine that supports it.
func (multi *multiPdfEngines) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
type pdfaConf struct {
part string
conformance string
}
result, err := runWithFallback(ctx, "pdfengines.ReadPdfAConformance", multi.facturXEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) (pdfaConf, error) {
part, conformance, err := engine.ReadPdfAConformance(ctx, logger, inputPath)
return pdfaConf{part: part, conformance: conformance}, err
},
func(err error) error { return fmt.Errorf("read PDF/A conformance with multi PDF engines: %w", err) },
)
return result.part, result.conformance, err
}
// Interface guards.
var (
_ gotenberg.PdfEngine = (*multiPdfEngines)(nil)
)