From 19db80bc2ede0391fe0e15dcc0088f45f3719626 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Wed, 18 Mar 2026 04:46:12 +0100 Subject: [PATCH] feat(pdfengines): add watermark and stamp feature --- .agents/TESTER.md | 2 +- Makefile | 4 + go.sum | 24 +- pkg/gotenberg/mocks.go | 10 + pkg/gotenberg/pdfengine.go | 37 +++ pkg/modules/api/formdata.go | 50 +++- pkg/modules/api/middlewares.go | 4 + pkg/modules/chromium/routes.go | 51 +++- pkg/modules/exiftool/exiftool.go | 10 + pkg/modules/libreoffice/api/api.go | 29 ++ pkg/modules/libreoffice/api/libreoffice.go | 24 ++ .../libreoffice/pdfengine/pdfengine.go | 10 + pkg/modules/libreoffice/routes.go | 75 +++++ pkg/modules/pdfcpu/pdfcpu.go | 51 ++++ pkg/modules/pdfengines/multi.go | 58 +++- pkg/modules/pdfengines/pdfengines.go | 24 ++ pkg/modules/pdfengines/routes.go | 260 ++++++++++++++++++ pkg/modules/pdftk/pdftk.go | 58 ++++ pkg/modules/qpdf/qpdf.go | 10 + .../features/chromium_convert_html.feature | 28 +- .../features/libreoffice_convert.feature | 68 ++++- .../features/pdfengines_merge.feature | 36 ++- .../features/pdfengines_split.feature | 56 +++- .../features/pdfengines_stamp.feature | 211 ++++++++++++++ .../features/pdfengines_watermark.feature | 211 ++++++++++++++ test/integration/testdata/watermark.png | Bin 0 -> 77 bytes 26 files changed, 1351 insertions(+), 50 deletions(-) create mode 100644 test/integration/features/pdfengines_stamp.feature create mode 100644 test/integration/features/pdfengines_watermark.feature create mode 100644 test/integration/testdata/watermark.png diff --git a/.agents/TESTER.md b/.agents/TESTER.md index dce305e..5558fbc 100644 --- a/.agents/TESTER.md +++ b/.agents/TESTER.md @@ -26,7 +26,7 @@ make test-integration TAGS=chromium-convert-html make test-integration TAGS="merge,split" ``` -Available tags: `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `debug`, `health`, `libreoffice`, `libreoffice-convert`, `output-filename`, `pdfengines`, `pdfengines-convert`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-flatten`, `flatten`, `pdfengines-merge`, `merge`, `pdfengines-metadata`, `metadata`, `pdfengines-split`, `split`, `pdfengines-bookmarks`, `bookmarks`, `prometheus-metrics`, `root`, `version`, `webhook`, `download-from`. +Available tags: `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `debug`, `health`, `libreoffice`, `libreoffice-convert`, `output-filename`, `pdfengines`, `pdfengines-convert`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-flatten`, `flatten`, `pdfengines-merge`, `merge`, `pdfengines-metadata`, `metadata`, `pdfengines-split`, `split`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-bookmarks`, `bookmarks`, `prometheus-metrics`, `root`, `version`, `webhook`, `download-from`. Other useful flags: diff --git a/Makefile b/Makefile index 2c1bcca..eca99aa 100644 --- a/Makefile +++ b/Makefile @@ -195,6 +195,10 @@ NO_CONCURRENCY=false # metadata # pdfengines-split # split +# pdfengines-watermark +# watermark +# pdfengines-stamp +# stamp # pdfengines-bookmarks # bookmarks # prometheus-metrics diff --git a/go.sum b/go.sum index dc740a9..b82c359 100644 --- a/go.sum +++ b/go.sum @@ -166,8 +166,6 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0= github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= -github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= -github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/minio/minlz v1.1.0 h1:rUOGu3EP4EqJC5k3qCsIwEnZiJULKqtRyDdqbhlvMmQ= github.com/minio/minlz v1.1.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -198,8 +196,6 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 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.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= -github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -214,8 +210,6 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -265,30 +259,20 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= -go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= @@ -299,8 +283,6 @@ 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.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= @@ -322,8 +304,6 @@ golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go index 1642256..1f7b0c4 100644 --- a/pkg/gotenberg/mocks.go +++ b/pkg/gotenberg/mocks.go @@ -57,6 +57,8 @@ type PdfEngineMock struct { EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error EmbedFilesMock func(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error WriteBookmarksMock func(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error + WatermarkMock func(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error + StampMock func(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error } func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { @@ -103,6 +105,14 @@ func (engine *PdfEngineMock) WriteBookmarks(ctx context.Context, logger *zap.Log return engine.WriteBookmarksMock(ctx, logger, inputPath, bookmarks) } +func (engine *PdfEngineMock) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error { + return engine.WatermarkMock(ctx, logger, inputPath, stamp) +} + +func (engine *PdfEngineMock) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error { + return engine.StampMock(ctx, logger, inputPath, stamp) +} + // PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface. type PdfEngineProviderMock struct { PdfEngineMock func() (PdfEngine, error) diff --git a/pkg/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go index fd70599..2ec8901 100644 --- a/pkg/gotenberg/pdfengine.go +++ b/pkg/gotenberg/pdfengine.go @@ -28,6 +28,10 @@ var ( // ErrPdfEncryptionNotSupported is returned when encryption // is not supported by the PDF engine. ErrPdfEncryptionNotSupported = errors.New("encryption not supported") + + // ErrPdfStampSourceNotSupported is returned when a stamp source type + // is not supported by the PDF engine. + ErrPdfStampSourceNotSupported = errors.New("stamp source not supported") ) // PdfEngineInvalidArgsError represents an error returned by a PDF engine when @@ -49,6 +53,33 @@ func NewPdfEngineInvalidArgs(engine, msg string) error { return &PdfEngineInvalidArgsError{engine, msg} } +const ( + // StampSourceText represents a text-based stamp source. + StampSourceText string = "text" + + // StampSourceImage represents an image-based stamp source. + StampSourceImage string = "image" + + // StampSourcePDF represents a PDF-based stamp source. + StampSourcePDF string = "pdf" +) + +// Stamp gathers the data required to apply a watermark or stamp to a PDF. +type Stamp struct { + // Source is one of "text", "image", or "pdf". + Source string + + // Expression is the text content (for text source) or file path (for + // image/pdf source). + Expression string + + // Pages is the optional page range to apply the stamp to. + Pages string + + // Options holds engine-specific styling options. + Options map[string]string +} + const ( // SplitModeIntervals represents a mode where a PDF is split at specific // intervals. @@ -166,6 +197,12 @@ type PdfEngine interface { // without modifying the main PDF content. // TODO: attachments instead? Rename the route? EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error + + // Watermark applies a watermark (behind page content) to a PDF file. + Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error + + // Stamp applies a stamp (on top of page content) to a PDF file. + Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error } // PdfEngineProvider offers an interface to instantiate a [PdfEngine]. diff --git a/pkg/modules/api/formdata.go b/pkg/modules/api/formdata.go index 632a67d..426988b 100644 --- a/pkg/modules/api/formdata.go +++ b/pkg/modules/api/formdata.go @@ -17,9 +17,15 @@ import ( "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) -// EmbedsFormField represents the form field name for embedding files. const ( + // EmbedsFormField represents the form field name for embedding files. EmbedsFormField string = "embeds" + + // WatermarksFormField represents the form field name for watermark files. + WatermarksFormField string = "watermarks" + + // StampsFormField represents the form field name for stamp files. + StampsFormField string = "stamps" ) // FormData is a helper for validating and hydrating values from a @@ -406,17 +412,57 @@ func (form *FormData) MandatoryPaths(extensions []string, target *[]string) *For return form } +// Watermarks binds the absolute paths of form data files that should be +// used as watermark sources. Only files uploaded with the "watermarks" +// field name will be included. +func (form *FormData) Watermarks(target *[]string) *FormData { + if form.errors != nil { + return form + } + + if paths, ok := form.filesByField[WatermarksFormField]; ok { + *target = append(*target, paths...) + } + + return form +} + +// Stamps binds the absolute paths of form data files that should be +// used as stamp sources. Only files uploaded with the "stamps" +// field name will be included. +func (form *FormData) Stamps(target *[]string) *FormData { + if form.errors != nil { + return form + } + + if paths, ok := form.filesByField[StampsFormField]; ok { + *target = append(*target, paths...) + } + + return form +} + // paths bind the absolute paths of form data files, according to a list of // file extensions, to a string slice variable. -// embeds are excluded. +// embeds, watermarks, and stamps are excluded. func (form *FormData) paths(extensions []string, target *[]string) *FormData { embeds, ok := form.filesByField[EmbedsFormField] + watermarks, wmOk := form.filesByField[WatermarksFormField] + stamps, stOk := form.filesByField[StampsFormField] for filename, path := range form.files { if ok && slices.Contains(embeds, path) { continue } + if wmOk && slices.Contains(watermarks, path) { + continue + } + + if stOk && slices.Contains(stamps, path) { + continue + } + for _, ext := range extensions { // See https://github.com/gotenberg/gotenberg/issues/228. if strings.ToLower(filepath.Ext(filename)) == ext { diff --git a/pkg/modules/api/middlewares.go b/pkg/modules/api/middlewares.go index a9149ef..b9833fa 100644 --- a/pkg/modules/api/middlewares.go +++ b/pkg/modules/api/middlewares.go @@ -61,6 +61,10 @@ func ParseError(err error) (int, string) { return http.StatusBadRequest, "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues" } + if errors.Is(err, gotenberg.ErrPdfStampSourceNotSupported) { + return http.StatusBadRequest, "At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues" + } + var invalidArgsError *gotenberg.PdfEngineInvalidArgsError if errors.As(err, &invalidArgsError) { return http.StatusBadRequest, invalidArgsError.Error() diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go index 426b97a..d7e07df 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -415,6 +415,10 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { metadata := pdfengines.FormDataPdfMetadata(form, false) userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) embedPaths := pdfengines.FormDataPdfEmbeds(form) + watermark := pdfengines.FormDataPdfWatermark(form, false) + watermarkFiles := pdfengines.FormDataPdfWatermarkFiles(form) + stamp := pdfengines.FormDataPdfStamp(form, false) + stampFiles := pdfengines.FormDataPdfStampFiles(form) var url string err := form. @@ -424,7 +428,14 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths) + if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && len(watermarkFiles) > 0 { + watermark.Expression = watermarkFiles[0] + } + if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && len(stampFiles) > 0 { + stamp.Expression = stampFiles[0] + } + + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp) if err != nil { return fmt.Errorf("convert URL to PDF: %w", err) } @@ -478,6 +489,10 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { metadata := pdfengines.FormDataPdfMetadata(form, false) userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) embedPaths := pdfengines.FormDataPdfEmbeds(form) + watermark := pdfengines.FormDataPdfWatermark(form, false) + watermarkFiles := pdfengines.FormDataPdfWatermarkFiles(form) + stamp := pdfengines.FormDataPdfStamp(form, false) + stampFiles := pdfengines.FormDataPdfStampFiles(form) var inputPath string err := form. @@ -487,8 +502,15 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } + if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && len(watermarkFiles) > 0 { + watermark.Expression = watermarkFiles[0] + } + if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && len(stampFiles) > 0 { + stamp.Expression = stampFiles[0] + } + url := fmt.Sprintf("file://%s", inputPath) - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp) if err != nil { return fmt.Errorf("convert HTML to PDF: %w", err) } @@ -543,6 +565,10 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { metadata := pdfengines.FormDataPdfMetadata(form, false) userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) embedPaths := pdfengines.FormDataPdfEmbeds(form) + watermark := pdfengines.FormDataPdfWatermark(form, false) + watermarkFiles := pdfengines.FormDataPdfWatermarkFiles(form) + stamp := pdfengines.FormDataPdfStamp(form, false) + stampFiles := pdfengines.FormDataPdfStampFiles(form) var ( inputPath string @@ -557,12 +583,19 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } + if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && len(watermarkFiles) > 0 { + watermark.Expression = watermarkFiles[0] + } + if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && len(stampFiles) > 0 { + stamp.Expression = stampFiles[0] + } + url, err := markdownToHtml(ctx, inputPath, markdownPaths) if err != nil { return fmt.Errorf("transform markdown file(s) to HTML: %w", err) } - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp) if err != nil { return fmt.Errorf("convert markdown to PDF: %w", err) } @@ -686,7 +719,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, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string) error { +func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, watermark, stamp gotenberg.Stamp) error { outputPath := ctx.GeneratePath(".pdf") // See https://github.com/gotenberg/gotenberg/issues/1130. filename := ctx.OutputFilename(outputPath) @@ -758,6 +791,16 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url return fmt.Errorf("convert PDF(s): %w", err) } + err = pdfengines.WatermarkStub(ctx, engine, watermark, convertOutputPaths) + if err != nil { + return fmt.Errorf("watermark PDFs: %w", err) + } + + err = pdfengines.StampStub(ctx, engine, stamp, convertOutputPaths) + if err != nil { + return fmt.Errorf("stamp PDFs: %w", err) + } + err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths) if err != nil { return fmt.Errorf("embed files into PDFs: %w", err) diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go index 8f12d6b..2a0177b 100644 --- a/pkg/modules/exiftool/exiftool.go +++ b/pkg/modules/exiftool/exiftool.go @@ -254,6 +254,16 @@ func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *zap.Logger, file return fmt.Errorf("embed files with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// Watermark is not available in this implementation. +func (engine *ExifTool) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { + return fmt.Errorf("watermark PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + +// Stamp is not available in this implementation. +func (engine *ExifTool) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { + return fmt.Errorf("stamp PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Interface guards. var ( _ gotenberg.Module = (*ExifTool)(nil) diff --git a/pkg/modules/libreoffice/api/api.go b/pkg/modules/libreoffice/api/api.go index 90d2689..6df6ad6 100644 --- a/pkg/modules/libreoffice/api/api.go +++ b/pkg/modules/libreoffice/api/api.go @@ -147,6 +147,29 @@ type Options struct { // Possible values are: 75, 150, 300, 600 and 1200. MaxImageResolution int + // NativeWatermarkText specifies the text for a watermark to be drawn on + // every page of the exported PDF file. + // See https://help.libreoffice.org/latest/en-US/text/shared/guide/pdf_params.html. + NativeWatermarkText string + + // NativeWatermarkColor specifies the color for the watermark text as a + // decimal long value. Default is 8388223 (light green). + NativeWatermarkColor int + + // NativeWatermarkFontHeight specifies the font size for the watermark text. + NativeWatermarkFontHeight int + + // NativeWatermarkRotateAngle specifies the rotation angle for the watermark + // text in tenths of a degree (e.g., 450 = 45°). + NativeWatermarkRotateAngle int + + // NativeWatermarkFontName specifies the font name for the watermark text. + // Default is "Helvetica". + NativeWatermarkFontName string + + // NativeTiledWatermarkText specifies the tiled watermark text. + NativeTiledWatermarkText string + // PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b, // PDF/A-3b and PDF/UA. PdfFormats gotenberg.PdfFormats @@ -178,6 +201,12 @@ func DefaultOptions() Options { Quality: 90, ReduceImageResolution: false, MaxImageResolution: 300, + NativeWatermarkText: "", + NativeWatermarkColor: 8388223, + NativeWatermarkFontHeight: 0, + NativeWatermarkRotateAngle: 0, + NativeWatermarkFontName: "Helvetica", + NativeTiledWatermarkText: "", PdfFormats: gotenberg.PdfFormats{ PdfA: "", PdfUa: false, diff --git a/pkg/modules/libreoffice/api/libreoffice.go b/pkg/modules/libreoffice/api/libreoffice.go index 9ee9e35..a7cac2d 100644 --- a/pkg/modules/libreoffice/api/libreoffice.go +++ b/pkg/modules/libreoffice/api/libreoffice.go @@ -302,6 +302,30 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP args = append(args, "--export", fmt.Sprintf("ReduceImageResolution=%t", options.ReduceImageResolution)) args = append(args, "--export", fmt.Sprintf("MaxImageResolution=%d", options.MaxImageResolution)) + if options.NativeWatermarkText != "" { + args = append(args, "--export", fmt.Sprintf("Watermark=%s", options.NativeWatermarkText)) + } + + if options.NativeWatermarkColor != 0 { + args = append(args, "--export", fmt.Sprintf("WatermarkColor=%d", options.NativeWatermarkColor)) + } + + if options.NativeWatermarkFontHeight > 0 { + args = append(args, "--export", fmt.Sprintf("WatermarkFontHeight=%d", options.NativeWatermarkFontHeight)) + } + + if options.NativeWatermarkRotateAngle != 0 { + args = append(args, "--export", fmt.Sprintf("WatermarkRotateAngle=%d", options.NativeWatermarkRotateAngle)) + } + + if options.NativeWatermarkFontName != "" && options.NativeWatermarkFontName != "Helvetica" { + args = append(args, "--export", fmt.Sprintf("WatermarkFontName=%s", options.NativeWatermarkFontName)) + } + + if options.NativeTiledWatermarkText != "" { + args = append(args, "--export", fmt.Sprintf("TiledWatermark=%s", options.NativeTiledWatermarkText)) + } + switch options.PdfFormats.PdfA { case "": case gotenberg.PdfA1b: diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go index 0dfc1ad..1ce151d 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go @@ -116,6 +116,16 @@ func (engine *LibreOfficePdfEngine) EmbedFiles(ctx context.Context, logger *zap. return fmt.Errorf("embed files with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// Watermark is not available in this implementation. +func (engine *LibreOfficePdfEngine) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { + return fmt.Errorf("watermark PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + +// Stamp is not available in this implementation. +func (engine *LibreOfficePdfEngine) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { + return fmt.Errorf("stamp PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Interface guards. var ( _ gotenberg.Module = (*LibreOfficePdfEngine)(nil) diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go index 854f347..60506cb 100644 --- a/pkg/modules/libreoffice/routes.go +++ b/pkg/modules/libreoffice/routes.go @@ -32,6 +32,10 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap metadata := pdfengines.FormDataPdfMetadata(form, false) userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) embedPaths := pdfengines.FormDataPdfEmbeds(form) + watermark := pdfengines.FormDataPdfWatermark(form, false) + watermarkFiles := pdfengines.FormDataPdfWatermarkFiles(form) + stamp := pdfengines.FormDataPdfStamp(form, false) + stampFiles := pdfengines.FormDataPdfStampFiles(form) zeroValuedSplitMode := gotenberg.SplitMode{} @@ -60,6 +64,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap quality int reduceImageResolution bool maxImageResolution int + nativeWatermarkText string + nativeWatermarkColor int + nativeWatermarkFontHeight int + nativeWatermarkRotateAngle int + nativeWatermarkFontName string + nativeTiledWatermarkText string nativePdfFormats bool merge bool flatten bool @@ -128,6 +138,48 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap maxImageResolution = intValue return nil }). + String("nativeWatermarkText", &nativeWatermarkText, defaultOptions.NativeWatermarkText). + Custom("nativeWatermarkColor", func(value string) error { + if value == "" { + nativeWatermarkColor = defaultOptions.NativeWatermarkColor + return nil + } + intValue, err := strconv.Atoi(value) + if err != nil { + return err + } + nativeWatermarkColor = intValue + return nil + }). + Custom("nativeWatermarkFontHeight", func(value string) error { + if value == "" { + nativeWatermarkFontHeight = defaultOptions.NativeWatermarkFontHeight + return nil + } + intValue, err := strconv.Atoi(value) + if err != nil { + return err + } + if intValue < 0 { + return errors.New("value is inferior to 0") + } + nativeWatermarkFontHeight = intValue + return nil + }). + Custom("nativeWatermarkRotateAngle", func(value string) error { + if value == "" { + nativeWatermarkRotateAngle = defaultOptions.NativeWatermarkRotateAngle + return nil + } + intValue, err := strconv.Atoi(value) + if err != nil { + return err + } + nativeWatermarkRotateAngle = intValue + return nil + }). + String("nativeWatermarkFontName", &nativeWatermarkFontName, defaultOptions.NativeWatermarkFontName). + String("nativeTiledWatermarkText", &nativeTiledWatermarkText, defaultOptions.NativeTiledWatermarkText). Bool("nativePdfFormats", &nativePdfFormats, true). Bool("merge", &merge, false). Bool("flatten", &flatten, false). @@ -136,6 +188,13 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap return fmt.Errorf("validate form data: %w", err) } + if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && len(watermarkFiles) > 0 { + watermark.Expression = watermarkFiles[0] + } + if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && len(stampFiles) > 0 { + stamp.Expression = stampFiles[0] + } + outputPaths := make([]string, len(inputPaths)) for i, inputPath := range inputPaths { outputPaths[i] = ctx.GeneratePath(".pdf") @@ -163,6 +222,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap Quality: quality, ReduceImageResolution: reduceImageResolution, MaxImageResolution: maxImageResolution, + NativeWatermarkText: nativeWatermarkText, + NativeWatermarkColor: nativeWatermarkColor, + NativeWatermarkFontHeight: nativeWatermarkFontHeight, + NativeWatermarkRotateAngle: nativeWatermarkRotateAngle, + NativeWatermarkFontName: nativeWatermarkFontName, + NativeTiledWatermarkText: nativeTiledWatermarkText, } if nativePdfFormats && splitMode == zeroValuedSplitMode { @@ -253,6 +318,16 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap } } + err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths) + if err != nil { + return fmt.Errorf("watermark PDFs: %w", err) + } + + err = pdfengines.StampStub(ctx, engine, stamp, outputPaths) + if err != nil { + return fmt.Errorf("stamp PDFs: %w", err) + } + err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, outputPaths) if err != nil { return fmt.Errorf("embed files into PDFs: %w", err) diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go index 3c75d34..7289dd6 100644 --- a/pkg/modules/pdfcpu/pdfcpu.go +++ b/pkg/modules/pdfcpu/pdfcpu.go @@ -379,6 +379,57 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath return nil } +// Watermark applies a watermark (behind page content) to a PDF file using pdfcpu. +func (engine *PdfCpu) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { + return engine.applyStampOrWatermark(ctx, logger, "watermark", inputPath, stamp) +} + +// Stamp applies a stamp (on top of page content) to a PDF file using pdfcpu. +func (engine *PdfCpu) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { + return engine.applyStampOrWatermark(ctx, logger, "stamp", inputPath, stamp) +} + +func (engine *PdfCpu) applyStampOrWatermark(ctx context.Context, logger *zap.Logger, command string, inputPath string, stamp gotenberg.Stamp) error { + var mode string + switch stamp.Source { + case gotenberg.StampSourceText: + mode = "text" + case gotenberg.StampSourceImage: + mode = "image" + case gotenberg.StampSourcePDF: + mode = "pdf" + default: + return fmt.Errorf("%s PDF with pdfcpu: %w", command, gotenberg.ErrPdfStampSourceNotSupported) + } + + // Build description from Options map. + var descParts []string + for k, v := range stamp.Options { + descParts = append(descParts, fmt.Sprintf("%s:%s", k, v)) + } + description := strings.Join(descParts, ", ") + + args := []string{command, "add", "-mode", mode} + + if stamp.Pages != "" { + args = append(args, "-pages", stamp.Pages) + } + + args = append(args, "--", stamp.Expression, description, inputPath, 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 fmt.Errorf("%s PDF with pdfcpu: %w", command, err) + } + + return nil +} + // Interface guards. var ( _ gotenberg.Module = (*PdfCpu)(nil) diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go index a1cef5b..4a8419d 100644 --- a/pkg/modules/pdfengines/multi.go +++ b/pkg/modules/pdfengines/multi.go @@ -22,6 +22,8 @@ type multiPdfEngines struct { embedEngines []gotenberg.PdfEngine readBookmarksEngines []gotenberg.PdfEngine writeBookmarksEngines []gotenberg.PdfEngine + watermarkEngines []gotenberg.PdfEngine + stampEngines []gotenberg.PdfEngine } func newMultiPdfEngines( @@ -34,7 +36,9 @@ func newMultiPdfEngines( passwordEngines, embedEngines, readBookmarksEngines, - writeBookmarksEngines []gotenberg.PdfEngine, + writeBookmarksEngines, + watermarkEngines, + stampEngines []gotenberg.PdfEngine, ) *multiPdfEngines { return &multiPdfEngines{ mergeEngines: mergeEngines, @@ -47,6 +51,8 @@ func newMultiPdfEngines( embedEngines: embedEngines, readBookmarksEngines: readBookmarksEngines, writeBookmarksEngines: writeBookmarksEngines, + watermarkEngines: watermarkEngines, + stampEngines: stampEngines, } } @@ -369,6 +375,56 @@ func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger 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 *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { + var err error + errChan := make(chan error, 1) + + for _, engine := range multi.watermarkEngines { + go func(engine gotenberg.PdfEngine) { + errChan <- engine.Watermark(ctx, logger, inputPath, stamp) + }(engine) + + select { + case watermarkErr := <-errChan: + errored := multierr.AppendInto(&err, watermarkErr) + if !errored { + return nil + } + case <-ctx.Done(): + return ctx.Err() + } + } + + 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 *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { + var err error + errChan := make(chan error, 1) + + for _, engine := range multi.stampEngines { + go func(engine gotenberg.PdfEngine) { + errChan <- engine.Stamp(ctx, logger, inputPath, stamp) + }(engine) + + select { + case stampErr := <-errChan: + errored := multierr.AppendInto(&err, stampErr) + if !errored { + return nil + } + case <-ctx.Done(): + return ctx.Err() + } + } + + return fmt.Errorf("stamp PDF with multi PDF engines: %w", err) +} + // Interface guards. var ( _ gotenberg.PdfEngine = (*multiPdfEngines)(nil) diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go index 08f29d3..3536c60 100644 --- a/pkg/modules/pdfengines/pdfengines.go +++ b/pkg/modules/pdfengines/pdfengines.go @@ -38,6 +38,8 @@ type PdfEngines struct { embedNames []string readBookmarksNames []string writeBookmarksNames []string + watermarkNames []string + stampNames []string engines []gotenberg.PdfEngine disableRoutes bool } @@ -58,6 +60,8 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor { 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-watermark-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the watermark feature - empty means all") + fs.StringSlice("pdfengines-stamp-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the stamp feature - empty means all") fs.Bool("pdfengines-disable-routes", false, "Disable the routes") // Deprecated flags. @@ -87,6 +91,8 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { embedNames := flags.MustStringSlice("pdfengines-embed-engines") readBookmarksNames := flags.MustStringSlice("pdfengines-read-bookmarks-engines") writeBookmarksNames := flags.MustStringSlice("pdfengines-write-bookmarks-engines") + watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines") + stampNames := flags.MustStringSlice("pdfengines-stamp-engines") mod.disableRoutes = flags.MustBool("pdfengines-disable-routes") engines, err := ctx.Modules(new(gotenberg.PdfEngine)) @@ -163,6 +169,16 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { mod.writeBookmarksNames = writeBookmarksNames } + mod.watermarkNames = defaultNames + if len(watermarkNames) > 0 { + mod.watermarkNames = watermarkNames + } + + mod.stampNames = defaultNames + if len(stampNames) > 0 { + mod.stampNames = stampNames + } + return nil } @@ -214,6 +230,8 @@ func (mod *PdfEngines) Validate() error { findNonExistingEngines(mod.embedNames) findNonExistingEngines(mod.readBookmarksNames) findNonExistingEngines(mod.writeBookmarksNames) + findNonExistingEngines(mod.watermarkNames) + findNonExistingEngines(mod.stampNames) if len(nonExistingEngines) == 0 { return nil @@ -236,6 +254,8 @@ func (mod *PdfEngines) SystemMessages() []string { 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[:], " ")), + fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames[:], " ")), + fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames[:], " ")), } } @@ -266,6 +286,8 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) { engines(mod.embedNames), engines(mod.readBookmarksNames), engines(mod.writeBookmarksNames), + engines(mod.watermarkNames), + engines(mod.stampNames), ), nil } @@ -293,6 +315,8 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) { writeBookmarksRoute(engine), encryptRoute(engine), embedRoute(engine), + watermarkRoute(engine), + stampRoute(engine), }, nil } diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index 3ca9acb..fd67faf 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -391,6 +391,122 @@ func EmbedFilesStub(ctx *api.Context, engine gotenberg.PdfEngine, embedPaths []s return nil } +// FormDataPdfWatermark creates a [gotenberg.Stamp] for watermarking from the +// form data. +func FormDataPdfWatermark(form *api.FormData, mandatory bool) gotenberg.Stamp { + return formDataPdfStampOrWatermark(form, "watermark", mandatory) +} + +// FormDataPdfStamp creates a [gotenberg.Stamp] for stamping from the form data. +func FormDataPdfStamp(form *api.FormData, mandatory bool) gotenberg.Stamp { + return formDataPdfStampOrWatermark(form, "stamp", mandatory) +} + +func formDataPdfStampOrWatermark(form *api.FormData, prefix string, mandatory bool) gotenberg.Stamp { + var ( + source string + expression string + pages string + options map[string]string + ) + + sourceFunc := func(value string) error { + if value != "" && value != gotenberg.StampSourceText && value != gotenberg.StampSourceImage && value != gotenberg.StampSourcePDF { + return fmt.Errorf("wrong value, expected either '%s', '%s' or '%s'", gotenberg.StampSourceText, gotenberg.StampSourceImage, gotenberg.StampSourcePDF) + } + source = value + return nil + } + + optionsFunc := func(value string) error { + if value == "" { + return nil + } + err := json.Unmarshal([]byte(value), &options) + if err != nil { + return fmt.Errorf("unmarshal %s options: %w", prefix, err) + } + return nil + } + + if mandatory { + form. + MandatoryCustom(prefix+"Source", func(value string) error { + return sourceFunc(value) + }). + String(prefix+"Expression", &expression, ""). + String(prefix+"Pages", &pages, ""). + Custom(prefix+"Options", func(value string) error { + return optionsFunc(value) + }) + } else { + form. + Custom(prefix+"Source", func(value string) error { + return sourceFunc(value) + }). + String(prefix+"Expression", &expression, ""). + String(prefix+"Pages", &pages, ""). + Custom(prefix+"Options", func(value string) error { + return optionsFunc(value) + }) + } + + return gotenberg.Stamp{ + Source: source, + Expression: expression, + Pages: pages, + Options: options, + } +} + +// FormDataPdfWatermarkFiles extracts watermark file paths from form data. +func FormDataPdfWatermarkFiles(form *api.FormData) []string { + var paths []string + form.Watermarks(&paths) + return paths +} + +// FormDataPdfStampFiles extracts stamp file paths from form data. +func FormDataPdfStampFiles(form *api.FormData) []string { + var paths []string + form.Stamps(&paths) + return paths +} + +// WatermarkStub applies a watermark to a list of PDF files. If the stamp has +// no source, it does nothing. +func WatermarkStub(ctx *api.Context, engine gotenberg.PdfEngine, stamp gotenberg.Stamp, inputPaths []string) error { + if stamp.Source == "" { + return nil + } + + for _, inputPath := range inputPaths { + err := engine.Watermark(ctx, ctx.Log(), inputPath, stamp) + if err != nil { + return fmt.Errorf("watermark '%s': %w", inputPath, err) + } + } + + return nil +} + +// StampStub applies a stamp to a list of PDF files. If the stamp has +// no source, it does nothing. +func StampStub(ctx *api.Context, engine gotenberg.PdfEngine, stamp gotenberg.Stamp, inputPaths []string) error { + if stamp.Source == "" { + return nil + } + + for _, inputPath := range inputPaths { + err := engine.Stamp(ctx, ctx.Log(), inputPath, stamp) + if err != nil { + return fmt.Errorf("stamp '%s': %w", inputPath, err) + } + } + + return nil +} + // mergeRoute returns an [api.Route] which can merge PDFs. func mergeRoute(engine gotenberg.PdfEngine) api.Route { return api.Route{ @@ -406,6 +522,10 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { bookmarks := FormDataPdfBookmarks(form, false) userPassword, ownerPassword := FormDataPdfEncrypt(form) embedPaths := FormDataPdfEmbeds(form) + watermark := FormDataPdfWatermark(form, false) + watermarkFiles := FormDataPdfWatermarkFiles(form) + stamp := FormDataPdfStamp(form, false) + stampFiles := FormDataPdfStampFiles(form) var inputPaths []string var flatten bool @@ -419,6 +539,13 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } + if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && len(watermarkFiles) > 0 { + watermark.Expression = watermarkFiles[0] + } + if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && len(stampFiles) > 0 { + stamp.Expression = stampFiles[0] + } + outputPath := ctx.GeneratePath(".pdf") err = engine.Merge(ctx, ctx.Log(), inputPaths, outputPath) if err != nil { @@ -430,6 +557,16 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("convert PDF: %w", err) } + err = WatermarkStub(ctx, engine, watermark, outputPaths) + if err != nil { + return fmt.Errorf("watermark PDFs: %w", err) + } + + err = StampStub(ctx, engine, stamp, outputPaths) + if err != nil { + return fmt.Errorf("stamp PDFs: %w", err) + } + err = EmbedFilesStub(ctx, engine, embedPaths, outputPaths) if err != nil { return fmt.Errorf("embed files into PDFs: %w", err) @@ -520,6 +657,10 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { metadata := FormDataPdfMetadata(form, false) userPassword, ownerPassword := FormDataPdfEncrypt(form) embedPaths := FormDataPdfEmbeds(form) + watermark := FormDataPdfWatermark(form, false) + watermarkFiles := FormDataPdfWatermarkFiles(form) + stamp := FormDataPdfStamp(form, false) + stampFiles := FormDataPdfStampFiles(form) var inputPaths []string var flatten bool @@ -531,6 +672,13 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } + if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && len(watermarkFiles) > 0 { + watermark.Expression = watermarkFiles[0] + } + if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && len(stampFiles) > 0 { + stamp.Expression = stampFiles[0] + } + outputPaths, err := SplitPdfStub(ctx, engine, mode, inputPaths) if err != nil { return fmt.Errorf("split PDFs: %w", err) @@ -541,6 +689,16 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("convert PDFs: %w", err) } + err = WatermarkStub(ctx, engine, watermark, convertOutputPaths) + if err != nil { + return fmt.Errorf("watermark PDFs: %w", err) + } + + err = StampStub(ctx, engine, stamp, convertOutputPaths) + if err != nil { + return fmt.Errorf("stamp PDFs: %w", err) + } + err = EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths) if err != nil { return fmt.Errorf("embed files into PDFs: %w", err) @@ -904,3 +1062,105 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route { }, } } + +// watermarkRoute returns an [api.Route] which can add watermarks to PDFs. +// +//nolint:dupl +func watermarkRoute(engine gotenberg.PdfEngine) api.Route { + return api.Route{ + Method: http.MethodPost, + Path: "/forms/pdfengines/watermark", + IsMultipart: true, + Handler: func(c echo.Context) error { + ctx := c.Get("context").(*api.Context) + + form := ctx.FormData() + stamp := FormDataPdfWatermark(form, true) + watermarkFiles := FormDataPdfWatermarkFiles(form) + + var inputPaths []string + err := form. + MandatoryPaths([]string{".pdf"}, &inputPaths). + Validate() + if err != nil { + return fmt.Errorf("validate form data: %w", err) + } + + if stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF { + if len(watermarkFiles) == 0 { + return api.WrapError( + errors.New("no watermark file provided"), + api.NewSentinelHttpError( + http.StatusBadRequest, + "Invalid form data: a watermark file is required for image or pdf source", + ), + ) + } + stamp.Expression = watermarkFiles[0] + } + + err = WatermarkStub(ctx, engine, stamp, inputPaths) + if err != nil { + return fmt.Errorf("watermark PDFs: %w", err) + } + + err = ctx.AddOutputPaths(inputPaths...) + if err != nil { + return fmt.Errorf("add output paths: %w", err) + } + + return nil + }, + } +} + +// stampRoute returns an [api.Route] which can add stamps to PDFs. +// +//nolint:dupl +func stampRoute(engine gotenberg.PdfEngine) api.Route { + return api.Route{ + Method: http.MethodPost, + Path: "/forms/pdfengines/stamp", + IsMultipart: true, + Handler: func(c echo.Context) error { + ctx := c.Get("context").(*api.Context) + + form := ctx.FormData() + stamp := FormDataPdfStamp(form, true) + stampFiles := FormDataPdfStampFiles(form) + + var inputPaths []string + err := form. + MandatoryPaths([]string{".pdf"}, &inputPaths). + Validate() + if err != nil { + return fmt.Errorf("validate form data: %w", err) + } + + if stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF { + if len(stampFiles) == 0 { + return api.WrapError( + errors.New("no stamp file provided"), + api.NewSentinelHttpError( + http.StatusBadRequest, + "Invalid form data: a stamp file is required for image or pdf source", + ), + ) + } + stamp.Expression = stampFiles[0] + } + + err = StampStub(ctx, engine, stamp, inputPaths) + if err != nil { + return fmt.Errorf("stamp PDFs: %w", err) + } + + err = ctx.AddOutputPaths(inputPaths...) + if err != nil { + return fmt.Errorf("add output paths: %w", err) + } + + return nil + }, + } +} diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go index 6abac93..2d0b767 100644 --- a/pkg/modules/pdftk/pdftk.go +++ b/pkg/modules/pdftk/pdftk.go @@ -203,6 +203,64 @@ func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *zap.Logger, filePat return fmt.Errorf("embed files with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// Watermark applies a watermark (behind page content) to a PDF file using PDFtk. +// Only PDF source is supported. +func (engine *PdfTk) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { + if stamp.Source != gotenberg.StampSourcePDF { + return fmt.Errorf("watermark PDF with PDFtk: %w", gotenberg.ErrPdfStampSourceNotSupported) + } + + tmpPath := inputPath + ".tmp" + + args := []string{inputPath, "background", stamp.Expression, "output", tmpPath} + + 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 fmt.Errorf("watermark PDF with PDFtk: %w", err) + } + + err = os.Rename(tmpPath, inputPath) + if err != nil { + return fmt.Errorf("rename temporary output file with input file: %w", err) + } + + return nil +} + +// Stamp applies a stamp (on top of page content) to a PDF file using PDFtk. +// Only PDF source is supported. +func (engine *PdfTk) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { + if stamp.Source != gotenberg.StampSourcePDF { + return fmt.Errorf("stamp PDF with PDFtk: %w", gotenberg.ErrPdfStampSourceNotSupported) + } + + tmpPath := inputPath + ".tmp" + + args := []string{inputPath, "stamp", stamp.Expression, "output", tmpPath} + + 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 fmt.Errorf("stamp PDF with PDFtk: %w", err) + } + + err = os.Rename(tmpPath, inputPath) + if err != nil { + return fmt.Errorf("rename temporary output file with input file: %w", err) + } + + return nil +} + // Interface guards. var ( _ gotenberg.Module = (*PdfTk)(nil) diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index d78613d..bae036c 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -221,6 +221,16 @@ func (engine *QPdf) EmbedFiles(ctx context.Context, logger *zap.Logger, filePath return fmt.Errorf("embed files with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// Watermark is not available in this implementation. +func (engine *QPdf) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { + return fmt.Errorf("watermark PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + +// Stamp is not available in this implementation. +func (engine *QPdf) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { + return fmt.Errorf("stamp PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + var ( _ gotenberg.Module = (*QPdf)(nil) _ gotenberg.Provisioner = (*QPdf)(nil) diff --git a/test/integration/features/chromium_convert_html.feature b/test/integration/features/chromium_convert_html.feature index 899df66..262ef4c 100644 --- a/test/integration/features/chromium_convert_html.feature +++ b/test/integration/features/chromium_convert_html.feature @@ -988,6 +988,28 @@ Feature: /forms/chromium/convert/html Then there should be 1 PDF(s) in the response Then the response PDF(s) should be encrypted + @watermark + Scenario: POST /forms/chromium/convert/html (Watermark - Text) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | watermarkSource | text | field | + | watermarkExpression | CONFIDENTIAL | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + + @stamp + Scenario: POST /forms/chromium/convert/html (Stamp - Text) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | stampSource | text | field | + | stampExpression | DRAFT | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + @embed Scenario: POST /forms/chromium/convert/html (Embeds) Given I have a default Gotenberg container @@ -1007,9 +1029,11 @@ Feature: /forms/chromium/convert/html # FIXME: once decrypt is done, add encrypt and check after the content of the PDF. @convert @metadata + @watermark + @stamp @flatten @embed - Scenario: POST /forms/chromium/convert/html (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds) + Scenario: POST /forms/chromium/convert/html (PDF/A-1b & PDF/UA-1 & Metadata & Watermark & Stamp & Flatten & Embeds) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): | files | testdata/page-1-html/index.html | file | @@ -1025,7 +1049,7 @@ Feature: /forms/chromium/convert/html Then there should be 1 PDF(s) in the response Then there should be the following file(s) in the response: | foo.pdf | - Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 9 failed rule(s) + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 11 failed rule(s) Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s) Then the response PDF(s) should be flatten Then the response PDF(s) should have the "embed_1.xml" file embedded diff --git a/test/integration/features/libreoffice_convert.feature b/test/integration/features/libreoffice_convert.feature index 13279a7..0271d6f 100644 --- a/test/integration/features/libreoffice_convert.feature +++ b/test/integration/features/libreoffice_convert.feature @@ -563,6 +563,62 @@ Feature: /forms/libreoffice/convert Then there should be 1 PDF(s) in the response Then the response PDF(s) should be encrypted + @watermark + Scenario: POST /forms/libreoffice/convert (Watermark - Text) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | watermarkSource | text | field | + | watermarkExpression | CONFIDENTIAL | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + + @stamp + Scenario: POST /forms/libreoffice/convert (Stamp - Text) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | stampSource | text | field | + | stampExpression | DRAFT | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + + @watermark + Scenario: POST /forms/libreoffice/convert (Native Watermark - Text) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | nativeWatermarkText | CONFIDENTIAL | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + + @watermark + Scenario: POST /forms/libreoffice/convert (Native Watermark - Text with Options) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | nativeWatermarkText | DRAFT | field | + | nativeWatermarkColor | 16711680 | field | + | nativeWatermarkFontHeight | 48 | field | + | nativeWatermarkRotateAngle | 450 | field | + | nativeWatermarkFontName | Courier | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + + @watermark + Scenario: POST /forms/libreoffice/convert (Native Watermark - Tiled) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | nativeTiledWatermarkText | CONFIDENTIAL | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + @embed Scenario: POST /forms/libreoffice/convert (Embeds) Given I have a default Gotenberg container @@ -582,15 +638,21 @@ Feature: /forms/libreoffice/convert # FIXME: once decrypt is done, add encrypt and check after the content of the PDF. @convert @metadata + @watermark + @stamp @flatten @embed - Scenario: POST /forms/libreoffice/convert (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds) + Scenario: POST /forms/libreoffice/convert (PDF/A-1b & PDF/UA-1 & Metadata & Watermark & Stamp & Flatten & Embeds) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): | files | testdata/page_1.docx | file | | pdfa | PDF/A-1b | field | | pdfua | true | field | | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | watermarkSource | text | field | + | watermarkExpression | CONFIDENTIAL | field | + | stampSource | text | field | + | stampExpression | DRAFT | field | | flatten | true | field | | embeds | testdata/embed_1.xml | file | | embeds | testdata/embed_2.xml | file | @@ -600,8 +662,8 @@ Feature: /forms/libreoffice/convert Then there should be 1 PDF(s) in the response Then there should be the following file(s) in the response: | foo.pdf | - Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 10 failed rule(s) - Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s) + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 12 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 5 failed rule(s) Then the response PDF(s) should be flatten Then the response PDF(s) should have the "embed_1.xml" file embedded Then the response PDF(s) should have the "embed_2.xml" file embedded diff --git a/test/integration/features/pdfengines_merge.feature b/test/integration/features/pdfengines_merge.feature index 1a02f85..ed74490 100644 --- a/test/integration/features/pdfengines_merge.feature +++ b/test/integration/features/pdfengines_merge.feature @@ -401,6 +401,30 @@ Feature: /forms/pdfengines/merge Then there should be 1 PDF(s) in the response Then the response PDF(s) should be encrypted + @watermark + Scenario: POST /forms/pdfengines/merge (Watermark - Text) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | watermarkSource | text | field | + | watermarkExpression | CONFIDENTIAL | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + + @stamp + Scenario: POST /forms/pdfengines/merge (Stamp - Text) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | stampSource | text | field | + | stampExpression | DRAFT | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + @embed Scenario: POST /foo/forms/pdfengines/merge (Embeds) Given I have a default Gotenberg container @@ -417,10 +441,12 @@ Feature: /forms/pdfengines/merge # FIXME: once decrypt is done, add encrypt and check after the content of the PDF. @convert @metadata + @watermark + @stamp @flatten @embed @bookmarks - Scenario: POST /forms/pdfengines/merge (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds & Bookmarks) + Scenario: POST /forms/pdfengines/merge (PDF/A-1b & PDF/UA-1 & Metadata & Watermark & Stamp & Flatten & Embeds & Bookmarks) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): | files | testdata/page_1.pdf | file | @@ -428,6 +454,10 @@ Feature: /forms/pdfengines/merge | pdfa | PDF/A-1b | field | | pdfua | true | field | | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | watermarkSource | text | field | + | watermarkExpression | CONFIDENTIAL | field | + | stampSource | text | field | + | stampExpression | DRAFT | field | | bookmarks | [{"title":"Merged Index","page":1}] | field | | flatten | true | field | | embeds | testdata/embed_1.xml | file | @@ -447,8 +477,8 @@ Feature: /forms/pdfengines/merge """ Page 2 """ - Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 10 failed rule(s) - Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s) + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 12 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 5 failed rule(s) Then the response PDF(s) should be flatten Then the response PDF(s) should have the "embed_1.xml" file embedded Then the response PDF(s) should have the "embed_2.xml" file embedded diff --git a/test/integration/features/pdfengines_split.feature b/test/integration/features/pdfengines_split.feature index ae4dac4..05914c0 100644 --- a/test/integration/features/pdfengines_split.feature +++ b/test/integration/features/pdfengines_split.feature @@ -473,6 +473,32 @@ Feature: /forms/pdfengines/split Then there should be 2 PDF(s) in the response Then the response PDF(s) should be encrypted + @watermark + Scenario: POST /forms/pdfengines/split (Watermark - Text) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | watermarkSource | text | field | + | watermarkExpression | CONFIDENTIAL | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + + @stamp + Scenario: POST /forms/pdfengines/split (Stamp - Text) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | stampSource | text | field | + | stampExpression | DRAFT | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + @embed Scenario: POST /foo/forms/pdfengines/split (Embeds) Given I have a default Gotenberg container @@ -494,20 +520,26 @@ Feature: /forms/pdfengines/split # FIXME: once decrypt is done, add encrypt and check after the content of the PDFs. @convert @metadata + @watermark + @stamp @flatten @embed - Scenario: POST /forms/pdfengines/split (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds) + Scenario: POST /forms/pdfengines/split (PDF/A-1b & PDF/UA-1 & Metadata & Watermark & Stamp & Flatten & Embeds) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): - | files | testdata/pages_3.pdf | file | - | splitMode | intervals | field | - | splitSpan | 2 | field | - | pdfa | PDF/A-1b | field | - | pdfua | true | field | - | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | - | flatten | true | field | - | embeds | testdata/embed_1.xml | file | - | embeds | testdata/embed_2.xml | file | + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | watermarkSource | text | field | + | watermarkExpression | CONFIDENTIAL | field | + | stampSource | text | field | + | stampExpression | DRAFT | field | + | flatten | true | field | + | embeds | testdata/embed_1.xml | file | + | embeds | testdata/embed_2.xml | file | Then the response status code should be 200 Then the response header "Content-Type" should be "application/zip" Then there should be 2 PDF(s) in the response @@ -528,8 +560,8 @@ Feature: /forms/pdfengines/split """ Page 3 """ - Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 10 failed rule(s) - Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s) + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 12 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 5 failed rule(s) Then the response PDF(s) should be flatten Then the response PDF(s) should have the "embed_1.xml" file embedded Then the response PDF(s) should have the "embed_2.xml" file embedded diff --git a/test/integration/features/pdfengines_stamp.feature b/test/integration/features/pdfengines_stamp.feature new file mode 100644 index 0000000..b5649cc --- /dev/null +++ b/test/integration/features/pdfengines_stamp.feature @@ -0,0 +1,211 @@ +@pdfengines +@pdfengines-stamp +@stamp +Feature: /forms/pdfengines/stamp + + Scenario: POST /forms/pdfengines/stamp (Text - pdfcpu) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_STAMP_ENGINES | pdfcpu | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | stampSource | text | field | + | stampExpression | CONFIDENTIAL | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the "page_1.pdf" PDF should have 1 page(s) + + Scenario: POST /forms/pdfengines/stamp (Text with Pages - pdfcpu) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_STAMP_ENGINES | pdfcpu | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | stampSource | text | field | + | stampExpression | DRAFT | field | + | stampPages | 1-2 | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the "pages_3.pdf" PDF should have 3 page(s) + + Scenario: POST /forms/pdfengines/stamp (Text with Options - pdfcpu) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_STAMP_ENGINES | pdfcpu | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | stampSource | text | field | + | stampExpression | SAMPLE | field | + | stampOptions | {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"} | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + + Scenario: POST /forms/pdfengines/stamp (Image - pdfcpu) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_STAMP_ENGINES | pdfcpu | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | stamps | testdata/watermark.png | file | + | stampSource | image | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + + Scenario: POST /forms/pdfengines/stamp (PDF - pdfcpu) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_STAMP_ENGINES | pdfcpu | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | stamps | testdata/page_2.pdf | file | + | stampSource | pdf | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + + Scenario: POST /forms/pdfengines/stamp (PDF - pdftk) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_STAMP_ENGINES | pdftk | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | stamps | testdata/page_2.pdf | file | + | stampSource | pdf | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + + Scenario: POST /forms/pdfengines/stamp (Text - pdftk unsupported) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_STAMP_ENGINES | pdftk | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | stampSource | text | field | + | stampExpression | CONFIDENTIAL | field | + Then the response status code should be 400 + Then the response body should match string: + """ + At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues + """ + + Scenario: POST /forms/pdfengines/stamp (Many PDFs) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | stampSource | text | field | + | stampExpression | DRAFT | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + + Scenario: POST /forms/pdfengines/stamp (Bad Request - No Source) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: form field 'stampSource' is required + """ + + Scenario: POST /forms/pdfengines/stamp (Bad Request - Invalid Source) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | stampSource | foo | field | + Then the response status code should be 400 + Then the response body should contain string: + """ + Invalid form data: form field 'stampSource' is invalid + """ + + Scenario: POST /forms/pdfengines/stamp (Bad Request - Missing File for Image Source) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | stampSource | image | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: a stamp file is required for image or pdf source + """ + + Scenario: POST /forms/pdfengines/stamp (Bad Request - Missing File for PDF Source) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | stampSource | pdf | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: a stamp file is required for image or pdf source + """ + + Scenario: POST /forms/pdfengines/stamp (Bad Request - No PDF) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | stampSource | text | field | + | stampExpression | CONFIDENTIAL | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: no form file found for extensions: [.pdf] + """ + + Scenario: POST /forms/pdfengines/stamp (Routes Disabled) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_DISABLE_ROUTES | true | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | stampSource | text | field | + | stampExpression | CONFIDENTIAL | field | + Then the response status code should be 404 + + Scenario: POST /forms/pdfengines/stamp (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | stampSource | text | field | + | stampExpression | CONFIDENTIAL | field | + | Gotenberg-Trace | forms_pdfengines_stamp | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then the response header "Gotenberg-Trace" should be "forms_pdfengines_stamp" + Then the Gotenberg container should log the following entries: + | "trace":"forms_pdfengines_stamp" | + + @webhook + Scenario: POST /forms/pdfengines/stamp (Webhook) + Given I have a default Gotenberg container + Given I have a webhook server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | stampSource | text | field | + | stampExpression | CONFIDENTIAL | field | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + Then the response status code should be 204 + When I wait for the asynchronous request to the webhook + Then the webhook request header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the webhook request + + Scenario: POST /forms/pdfengines/stamp (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | stampSource | text | field | + | stampExpression | CONFIDENTIAL | field | + Then the response status code should be 401 + + Scenario: POST /foo/forms/pdfengines/stamp (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_ROOT_PATH | /foo/ | + When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/stamp" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | stampSource | text | field | + | stampExpression | CONFIDENTIAL | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" diff --git a/test/integration/features/pdfengines_watermark.feature b/test/integration/features/pdfengines_watermark.feature new file mode 100644 index 0000000..fc546f2 --- /dev/null +++ b/test/integration/features/pdfengines_watermark.feature @@ -0,0 +1,211 @@ +@pdfengines +@pdfengines-watermark +@watermark +Feature: /forms/pdfengines/watermark + + Scenario: POST /forms/pdfengines/watermark (Text - pdfcpu) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_WATERMARK_ENGINES | pdfcpu | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | watermarkSource | text | field | + | watermarkExpression | CONFIDENTIAL | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the "page_1.pdf" PDF should have 1 page(s) + + Scenario: POST /forms/pdfengines/watermark (Text with Pages - pdfcpu) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_WATERMARK_ENGINES | pdfcpu | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | watermarkSource | text | field | + | watermarkExpression | DRAFT | field | + | watermarkPages | 1-2 | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the "pages_3.pdf" PDF should have 3 page(s) + + Scenario: POST /forms/pdfengines/watermark (Text with Options - pdfcpu) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_WATERMARK_ENGINES | pdfcpu | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | watermarkSource | text | field | + | watermarkExpression | SAMPLE | field | + | watermarkOptions | {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"} | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + + Scenario: POST /forms/pdfengines/watermark (Image - pdfcpu) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_WATERMARK_ENGINES | pdfcpu | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | watermarks | testdata/watermark.png | file | + | watermarkSource | image | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + + Scenario: POST /forms/pdfengines/watermark (PDF - pdfcpu) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_WATERMARK_ENGINES | pdfcpu | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | watermarks | testdata/page_2.pdf | file | + | watermarkSource | pdf | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + + Scenario: POST /forms/pdfengines/watermark (PDF - pdftk) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_WATERMARK_ENGINES | pdftk | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | watermarks | testdata/page_2.pdf | file | + | watermarkSource | pdf | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + + Scenario: POST /forms/pdfengines/watermark (Text - pdftk unsupported) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_WATERMARK_ENGINES | pdftk | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | watermarkSource | text | field | + | watermarkExpression | CONFIDENTIAL | field | + Then the response status code should be 400 + Then the response body should match string: + """ + At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues + """ + + Scenario: POST /forms/pdfengines/watermark (Many PDFs) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | watermarkSource | text | field | + | watermarkExpression | DRAFT | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + + Scenario: POST /forms/pdfengines/watermark (Bad Request - No Source) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: form field 'watermarkSource' is required + """ + + Scenario: POST /forms/pdfengines/watermark (Bad Request - Invalid Source) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | watermarkSource | foo | field | + Then the response status code should be 400 + Then the response body should contain string: + """ + Invalid form data: form field 'watermarkSource' is invalid + """ + + Scenario: POST /forms/pdfengines/watermark (Bad Request - Missing File for Image Source) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | watermarkSource | image | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: a watermark file is required for image or pdf source + """ + + Scenario: POST /forms/pdfengines/watermark (Bad Request - Missing File for PDF Source) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | watermarkSource | pdf | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: a watermark file is required for image or pdf source + """ + + Scenario: POST /forms/pdfengines/watermark (Bad Request - No PDF) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | watermarkSource | text | field | + | watermarkExpression | CONFIDENTIAL | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: no form file found for extensions: [.pdf] + """ + + Scenario: POST /forms/pdfengines/watermark (Routes Disabled) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_DISABLE_ROUTES | true | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | watermarkSource | text | field | + | watermarkExpression | CONFIDENTIAL | field | + Then the response status code should be 404 + + Scenario: POST /forms/pdfengines/watermark (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | watermarkSource | text | field | + | watermarkExpression | CONFIDENTIAL | field | + | Gotenberg-Trace | forms_pdfengines_watermark | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then the response header "Gotenberg-Trace" should be "forms_pdfengines_watermark" + Then the Gotenberg container should log the following entries: + | "trace":"forms_pdfengines_watermark" | + + @webhook + Scenario: POST /forms/pdfengines/watermark (Webhook) + Given I have a default Gotenberg container + Given I have a webhook server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | watermarkSource | text | field | + | watermarkExpression | CONFIDENTIAL | field | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + Then the response status code should be 204 + When I wait for the asynchronous request to the webhook + Then the webhook request header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the webhook request + + Scenario: POST /forms/pdfengines/watermark (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | watermarkSource | text | field | + | watermarkExpression | CONFIDENTIAL | field | + Then the response status code should be 401 + + Scenario: POST /foo/forms/pdfengines/watermark (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_ROOT_PATH | /foo/ | + When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/watermark" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | watermarkSource | text | field | + | watermarkExpression | CONFIDENTIAL | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" diff --git a/test/integration/testdata/watermark.png b/test/integration/testdata/watermark.png new file mode 100644 index 0000000000000000000000000000000000000000..f36400fe25aa799adb2a6eb20901fdca8a129174 GIT binary patch literal 77 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4iFmp=hE&W+{&D`mhxrm`3|39p b$Hh45@Nvu4R)01E