mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-14 19:32:15 +01:00
feat(pdfengines): apply multiple stamps in one request (#1601)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
|
||||
158
pkg/modules/pdfengines/stamp_test.go
Normal file
158
pkg/modules/pdfengines/stamp_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user