From e9a67132ecfef4ba933374fd54891e18df6d1e06 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Fri, 14 Aug 2026 15:52:14 +0200 Subject: [PATCH] feat(pdfengines): apply multiple stamps in one request (#1601) --- pkg/modules/api/formdata.go | 30 ++++ pkg/modules/api/formdata_test.go | 43 +++++ pkg/modules/pdfengines/routes.go | 122 +++++++++++++- pkg/modules/pdfengines/stamp_test.go | 158 ++++++++++++++++++ .../features/pdfengines_stamp.feature | 16 ++ 5 files changed, 361 insertions(+), 8 deletions(-) create mode 100644 pkg/modules/pdfengines/stamp_test.go diff --git a/pkg/modules/api/formdata.go b/pkg/modules/api/formdata.go index fa2ddc22..ead61d20 100644 --- a/pkg/modules/api/formdata.go +++ b/pkg/modules/api/formdata.go @@ -479,6 +479,36 @@ func (form *FormData) Stamp(target *string) *FormData { return form } +// Stamps binds the absolute paths of every file uploaded with the "stamp" +// field name, in submission order. Unlike [FormData.Stamp], it keeps all of +// them so a route can apply several stamps in a single request. +func (form *FormData) Stamps(target *[]string) *FormData { + if form.errors != nil { + return form + } + + if paths, ok := form.filesByField[StampFormField]; ok { + *target = paths + } + + return form +} + +// Strings binds every value submitted for key, in submission order. A field +// repeated in the multipart body (e.g. multiple "stampSource") contributes one +// entry per occurrence, which lets a route read parallel field arrays. +func (form *FormData) Strings(key string, target *[]string) *FormData { + if form.errors != nil { + return form + } + + if values, ok := form.values[key]; ok { + *target = values + } + + return form +} + // FacturXXml binds the absolute path of the uploaded Factur-X CII invoice // XML. Only a file uploaded with the "facturxXml" field name is included. func (form *FormData) FacturXXml(target *string) *FormData { diff --git a/pkg/modules/api/formdata_test.go b/pkg/modules/api/formdata_test.go index 391c9d71..c74fdcd1 100644 --- a/pkg/modules/api/formdata_test.go +++ b/pkg/modules/api/formdata_test.go @@ -1837,3 +1837,46 @@ func TestFormData_paths_excludesFacturXXml(t *testing.T) { t.Errorf("expected only the non-Factur-X .xml document, got %+v", paths) } } + +func TestFormData_Strings(t *testing.T) { + form := &FormData{ + values: map[string][]string{ + "foo": {"a", "b", "c"}, + }, + } + + var got []string + form.Strings("foo", &got) + + if want := []string{"a", "b", "c"}; !reflect.DeepEqual(got, want) { + t.Errorf("expected %+v, got %+v", want, got) + } + + var missing []string + form.Strings("bar", &missing) + if missing != nil { + t.Errorf("expected nil for a missing key, got %+v", missing) + } +} + +func TestFormData_Stamps(t *testing.T) { + form := &FormData{ + filesByField: map[string][]string{ + StampFormField: {"/tmp/abc/a.png", "/tmp/abc/b.pdf"}, + }, + } + + var got []string + form.Stamps(&got) + + if want := []string{"/tmp/abc/a.png", "/tmp/abc/b.pdf"}; !reflect.DeepEqual(got, want) { + t.Errorf("expected %+v, got %+v", want, got) + } + + empty := &FormData{} + var none []string + empty.Stamps(&none) + if none != nil { + t.Errorf("expected nil when no stamp file was uploaded, got %+v", none) + } +} diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index f407cd39..2544ee02 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -934,6 +934,92 @@ func EnsureStampFile(stamp *gotenberg.Stamp, uploadedFile string) error { return nil } +// FormDataPdfStamps builds the ordered list of stamps from the repeated stamp +// fields: stampSource, stampExpression, stampPages and stampOptions. The number +// of stamps equals the number of stampSource values, so a single occurrence of +// each field yields one stamp, preserving the single-stamp behavior. Fields are +// aligned by position; a missing expression, pages or options entry defaults to +// empty. Image and pdf stamps take their file from the uploaded stamp files, in +// order (see [BindStampFiles]). +func FormDataPdfStamps(form *api.FormData) ([]gotenberg.Stamp, error) { + var sources, expressions, pages, options []string + form. + Strings("stampSource", &sources). + Strings("stampExpression", &expressions). + Strings("stampPages", &pages). + Strings("stampOptions", &options) + + at := func(values []string, i int) string { + if i < len(values) { + return values[i] + } + return "" + } + + stamps := make([]gotenberg.Stamp, 0, len(sources)) + for i, source := range sources { + if source != gotenberg.StampSourceText && source != gotenberg.StampSourceImage && source != gotenberg.StampSourcePDF { + return nil, api.WrapError( + fmt.Errorf("wrong stampSource value '%s'", source), + api.NewSentinelHttpError( + http.StatusBadRequest, + fmt.Sprintf("Invalid form data: form field 'stampSource' is invalid (got '%s', resulting to wrong value, expected either '%s', '%s' or '%s')", source, gotenberg.StampSourceText, gotenberg.StampSourceImage, gotenberg.StampSourcePDF), + ), + ) + } + + var opts map[string]string + if raw := at(options, i); raw != "" { + err := json.Unmarshal([]byte(raw), &opts) + if err != nil { + return nil, api.WrapError( + fmt.Errorf("unmarshal stampOptions: %w", err), + api.NewSentinelHttpError( + http.StatusBadRequest, + "Invalid form data: form field 'stampOptions' is invalid", + ), + ) + } + } + + stamps = append(stamps, gotenberg.Stamp{ + Source: source, + Expression: at(expressions, i), + Pages: at(pages, i), + Options: opts, + }) + } + + return stamps, nil +} + +// BindStampFiles assigns each image or pdf stamp its uploaded file, consuming +// stampFiles in order. Text stamps take no file. It returns an [api] HTTP 400 +// error when an image or pdf stamp has no file left to consume, which also +// prevents an anonymous caller from passing an arbitrary filesystem path via +// stampExpression. +func BindStampFiles(stamps []gotenberg.Stamp, stampFiles []string) error { + fileIndex := 0 + for i := range stamps { + if stamps[i].Source != gotenberg.StampSourceImage && stamps[i].Source != gotenberg.StampSourcePDF { + continue + } + if fileIndex >= len(stampFiles) { + return api.WrapError( + errors.New("not enough stamp files for the image or pdf stamps"), + api.NewSentinelHttpError( + http.StatusBadRequest, + "Invalid form data: a stamp file is required for image or pdf source", + ), + ) + } + stamps[i].Expression = stampFiles[fileIndex] + fileIndex++ + } + + return nil +} + // EnsureWatermarkFile mirrors [EnsureStampFile] for a watermark. The // shape is identical: image or pdf sources must be accompanied by an // uploaded file, and the file path replaces watermark.Expression to @@ -1773,25 +1859,45 @@ func stampRoute(engine gotenberg.PdfEngine) api.Route { ctx := c.Get("context").(*api.Context) form := ctx.FormData() - stamp := FormDataPdfStamp(form, true) - stampFile := FormDataPdfStampFile(form) + + // Reading the stamp fields as parallel arrays applies several + // stamps in one request. A single occurrence of each field is the + // existing single-stamp behavior. + stamps, err := FormDataPdfStamps(form) + if err != nil { + return fmt.Errorf("form data stamps: %w", err) + } var inputPaths []string - err := form. + var stampFiles []string + err = form. MandatoryPaths([]string{".pdf"}, &inputPaths). + Stamps(&stampFiles). Validate() if err != nil { return fmt.Errorf("validate form data: %w", err) } - err = EnsureStampFile(&stamp, stampFile) - if err != nil { - return fmt.Errorf("validate stamp: %w", err) + if len(stamps) == 0 { + return api.WrapError( + errors.New("no stamp provided"), + api.NewSentinelHttpError( + http.StatusBadRequest, + "Invalid form data: form field 'stampSource' is required", + ), + ) } - err = StampStub(ctx, engine, stamp, inputPaths) + err = BindStampFiles(stamps, stampFiles) if err != nil { - return fmt.Errorf("stamp PDFs: %w", err) + return fmt.Errorf("bind stamp files: %w", err) + } + + for _, stamp := range stamps { + err = StampStub(ctx, engine, stamp, inputPaths) + if err != nil { + return fmt.Errorf("stamp PDFs: %w", err) + } } err = ctx.AddOutputPaths(inputPaths...) diff --git a/pkg/modules/pdfengines/stamp_test.go b/pkg/modules/pdfengines/stamp_test.go new file mode 100644 index 00000000..f9e1658a --- /dev/null +++ b/pkg/modules/pdfengines/stamp_test.go @@ -0,0 +1,158 @@ +package pdfengines + +import ( + "errors" + "net/http" + "reflect" + "testing" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" + "github.com/gotenberg/gotenberg/v8/pkg/modules/api" +) + +func TestFormDataPdfStamps(t *testing.T) { + for _, tc := range []struct { + scenario string + values map[string][]string + expect []gotenberg.Stamp + expectErr bool + expectCode int + }{ + { + scenario: "single text stamp (backward compatible)", + values: map[string][]string{ + "stampSource": {"text"}, + "stampExpression": {"CONFIDENTIAL"}, + "stampOptions": {`{"rot":"45"}`}, + }, + expect: []gotenberg.Stamp{ + {Source: "text", Expression: "CONFIDENTIAL", Options: map[string]string{"rot": "45"}}, + }, + }, + { + scenario: "multiple stamps aligned by position", + values: map[string][]string{ + "stampSource": {"text", "image"}, + "stampExpression": {"ONE"}, + "stampPages": {"1-2", "3"}, + "stampOptions": {`{"pos":"tl"}`, `{"pos":"br"}`}, + }, + expect: []gotenberg.Stamp{ + {Source: "text", Expression: "ONE", Pages: "1-2", Options: map[string]string{"pos": "tl"}}, + {Source: "image", Expression: "", Pages: "3", Options: map[string]string{"pos": "br"}}, + }, + }, + { + scenario: "no stamp fields", + values: map[string][]string{}, + expect: []gotenberg.Stamp{}, + }, + { + scenario: "invalid source", + values: map[string][]string{"stampSource": {"text", "foo"}}, + expectErr: true, + expectCode: http.StatusBadRequest, + }, + { + scenario: "invalid options JSON", + values: map[string][]string{ + "stampSource": {"text"}, + "stampOptions": {"{"}, + }, + expectErr: true, + expectCode: http.StatusBadRequest, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + ctx := &api.ContextMock{Context: &api.Context{}} + ctx.SetValues(tc.values) + form := ctx.FormData() + + got, err := FormDataPdfStamps(form) + + if tc.expectErr { + if err == nil { + t.Fatal("expected an error, got nil") + } + var httpErr api.HttpError + if !errors.As(err, &httpErr) { + t.Fatalf("expected an api.HttpError, got %T", err) + } + if status, _ := httpErr.HttpError(); status != tc.expectCode { + t.Fatalf("status = %d, want %d", status, tc.expectCode) + } + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !reflect.DeepEqual(got, tc.expect) { + t.Fatalf("stamps = %#v, want %#v", got, tc.expect) + } + }) + } +} + +func TestBindStampFiles(t *testing.T) { + for _, tc := range []struct { + scenario string + stamps []gotenberg.Stamp + files []string + expect []gotenberg.Stamp + expectErr bool + expectCode int + }{ + { + scenario: "text stamps consume no files", + stamps: []gotenberg.Stamp{{Source: "text", Expression: "FOO"}}, + expect: []gotenberg.Stamp{{Source: "text", Expression: "FOO"}}, + }, + { + scenario: "image and pdf stamps consume files in order, overwriting expression", + stamps: []gotenberg.Stamp{ + {Source: "image", Expression: "ignored"}, + {Source: "text", Expression: "MIDDLE"}, + {Source: "pdf"}, + }, + files: []string{"/a.png", "/b.pdf"}, + expect: []gotenberg.Stamp{ + {Source: "image", Expression: "/a.png"}, + {Source: "text", Expression: "MIDDLE"}, + {Source: "pdf", Expression: "/b.pdf"}, + }, + }, + { + scenario: "not enough files for the image or pdf stamps", + stamps: []gotenberg.Stamp{{Source: "image"}, {Source: "image"}}, + files: []string{"/a.png"}, + expectErr: true, + expectCode: http.StatusBadRequest, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + err := BindStampFiles(tc.stamps, tc.files) + + if tc.expectErr { + if err == nil { + t.Fatal("expected an error, got nil") + } + var httpErr api.HttpError + if !errors.As(err, &httpErr) { + t.Fatalf("expected an api.HttpError, got %T", err) + } + if status, _ := httpErr.HttpError(); status != tc.expectCode { + t.Fatalf("status = %d, want %d", status, tc.expectCode) + } + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !reflect.DeepEqual(tc.stamps, tc.expect) { + t.Fatalf("stamps = %#v, want %#v", tc.stamps, tc.expect) + } + }) + } +} diff --git a/test/integration/features/pdfengines_stamp.feature b/test/integration/features/pdfengines_stamp.feature index 7dbc464a..9464ceb6 100644 --- a/test/integration/features/pdfengines_stamp.feature +++ b/test/integration/features/pdfengines_stamp.feature @@ -51,6 +51,22 @@ Feature: /forms/pdfengines/stamp Then the response header "Content-Type" should be "application/pdf" Then there should be 1 PDF(s) in the response + # Repeating the stamp fields applies several stamps in one request, in order. + # Image and pdf stamps consume the uploaded stamp files in order; text stamps + # take none. See https://github.com/gotenberg/gotenberg/pull/1601. + Scenario: POST /forms/pdfengines/stamp (Multiple Stamps - 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 | + | stampSource | image | field | + | stamp | testdata/watermark.png | file | + 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 |