feat(pdfengines): apply multiple stamps in one request (#1601)

This commit is contained in:
Julien Neuhart
2026-08-14 15:52:14 +02:00
parent 41b33fd6ad
commit e9a67132ec
5 changed files with 361 additions and 8 deletions

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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...)

View File

@@ -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)
}
})
}
}

View File

@@ -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 |