feat(pdfengines): add rotate feature

This commit is contained in:
Julien Neuhart
2026-03-18 22:36:20 +01:00
parent 21e300fcec
commit 0663e5f92b
19 changed files with 472 additions and 9 deletions

View File

@@ -69,6 +69,7 @@ PDFENGINES_WRITE_BOOKMARKS_ENGINES=pdfcpu
PDFENGINES_WATERMARK_ENGINES=pdfcpu,pdftk PDFENGINES_WATERMARK_ENGINES=pdfcpu,pdftk
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
PDFENGINES_EMBED_ENGINES=pdfcpu PDFENGINES_EMBED_ENGINES=pdfcpu
PROMETHEUS_NAMESPACE=gotenberg PROMETHEUS_NAMESPACE=gotenberg
PROMETHEUS_COLLECT_INTERVAL=1s PROMETHEUS_COLLECT_INTERVAL=1s
@@ -151,6 +152,7 @@ run: ## Start a Gotenberg container
--pdfengines-watermark-engines=$(PDFENGINES_WATERMARK_ENGINES) \ --pdfengines-watermark-engines=$(PDFENGINES_WATERMARK_ENGINES) \
--pdfengines-stamp-engines=$(PDFENGINES_STAMP_ENGINES) \ --pdfengines-stamp-engines=$(PDFENGINES_STAMP_ENGINES) \
--pdfengines-encrypt-engines=$(PDFENGINES_ENCRYPT_ENGINES) \ --pdfengines-encrypt-engines=$(PDFENGINES_ENCRYPT_ENGINES) \
--pdfengines-rotate-engines=$(PDFENGINES_ROTATE_ENGINES) \
--pdfengines-embed-engines=$(PDFENGINES_EMBED_ENGINES) \ --pdfengines-embed-engines=$(PDFENGINES_EMBED_ENGINES) \
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \ --prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \ --prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
@@ -203,6 +205,8 @@ NO_CONCURRENCY=false
# watermark # watermark
# pdfengines-stamp # pdfengines-stamp
# stamp # stamp
# pdfengines-rotate
# rotate
# pdfengines-bookmarks # pdfengines-bookmarks
# bookmarks # bookmarks
# prometheus-metrics # prometheus-metrics

View File

@@ -59,6 +59,7 @@ type PdfEngineMock struct {
WriteBookmarksMock func(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) 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 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 StampMock func(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error
RotateMock func(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error
} }
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
@@ -113,6 +114,10 @@ func (engine *PdfEngineMock) Stamp(ctx context.Context, logger *zap.Logger, inpu
return engine.StampMock(ctx, logger, inputPath, stamp) return engine.StampMock(ctx, logger, inputPath, stamp)
} }
func (engine *PdfEngineMock) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
return engine.RotateMock(ctx, logger, inputPath, angle, pages)
}
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface. // PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
type PdfEngineProviderMock struct { type PdfEngineProviderMock struct {
PdfEngineMock func() (PdfEngine, error) PdfEngineMock func() (PdfEngine, error)

View File

@@ -32,6 +32,10 @@ var (
// ErrPdfStampSourceNotSupported is returned when a stamp source type // ErrPdfStampSourceNotSupported is returned when a stamp source type
// is not supported by the PDF engine. // is not supported by the PDF engine.
ErrPdfStampSourceNotSupported = errors.New("stamp source not supported") ErrPdfStampSourceNotSupported = errors.New("stamp source not supported")
// ErrPdfRotateAngleNotSupported is returned when the rotation angle is
// not supported.
ErrPdfRotateAngleNotSupported = errors.New("rotation angle not supported")
) )
// PdfEngineInvalidArgsError represents an error returned by a PDF engine when // PdfEngineInvalidArgsError represents an error returned by a PDF engine when
@@ -203,6 +207,10 @@ type PdfEngine interface {
// Stamp applies a stamp (on top of page content) to a PDF file. // 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 Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error
// Rotate rotates pages of a PDF file by the given angle (90, 180, 270).
// If pages is empty, all pages are rotated.
Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error
} }
// PdfEngineProvider offers an interface to instantiate a [PdfEngine]. // PdfEngineProvider offers an interface to instantiate a [PdfEngine].

View File

@@ -65,6 +65,10 @@ func ParseError(err error) (int, string) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues" return http.StatusBadRequest, "At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues"
} }
if errors.Is(err, gotenberg.ErrPdfRotateAngleNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested rotation angle, while others may have failed due to different issues"
}
var invalidArgsError *gotenberg.PdfEngineInvalidArgsError var invalidArgsError *gotenberg.PdfEngineInvalidArgsError
if errors.As(err, &invalidArgsError) { if errors.As(err, &invalidArgsError) {
return http.StatusBadRequest, invalidArgsError.Error() return http.StatusBadRequest, invalidArgsError.Error()

View File

@@ -419,6 +419,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
watermarkFiles := pdfengines.FormDataPdfWatermarkFiles(form) watermarkFiles := pdfengines.FormDataPdfWatermarkFiles(form)
stamp := pdfengines.FormDataPdfStamp(form, false) stamp := pdfengines.FormDataPdfStamp(form, false)
stampFiles := pdfengines.FormDataPdfStampFiles(form) stampFiles := pdfengines.FormDataPdfStampFiles(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
var url string var url string
err := form. err := form.
@@ -435,7 +436,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
stamp.Expression = stampFiles[0] stamp.Expression = stampFiles[0]
} }
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp) err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
if err != nil { if err != nil {
return fmt.Errorf("convert URL to PDF: %w", err) return fmt.Errorf("convert URL to PDF: %w", err)
} }
@@ -493,6 +494,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
watermarkFiles := pdfengines.FormDataPdfWatermarkFiles(form) watermarkFiles := pdfengines.FormDataPdfWatermarkFiles(form)
stamp := pdfengines.FormDataPdfStamp(form, false) stamp := pdfengines.FormDataPdfStamp(form, false)
stampFiles := pdfengines.FormDataPdfStampFiles(form) stampFiles := pdfengines.FormDataPdfStampFiles(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
var inputPath string var inputPath string
err := form. err := form.
@@ -510,7 +512,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
} }
url := fmt.Sprintf("file://%s", inputPath) url := fmt.Sprintf("file://%s", inputPath)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp) err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
if err != nil { if err != nil {
return fmt.Errorf("convert HTML to PDF: %w", err) return fmt.Errorf("convert HTML to PDF: %w", err)
} }
@@ -569,6 +571,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
watermarkFiles := pdfengines.FormDataPdfWatermarkFiles(form) watermarkFiles := pdfengines.FormDataPdfWatermarkFiles(form)
stamp := pdfengines.FormDataPdfStamp(form, false) stamp := pdfengines.FormDataPdfStamp(form, false)
stampFiles := pdfengines.FormDataPdfStampFiles(form) stampFiles := pdfengines.FormDataPdfStampFiles(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
var ( var (
inputPath string inputPath string
@@ -595,7 +598,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("transform markdown file(s) to HTML: %w", err) return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
} }
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp) err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
if err != nil { if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err) return fmt.Errorf("convert markdown to PDF: %w", err)
} }
@@ -719,7 +722,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
return fmt.Sprintf("file://%s", inputPath), nil 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, watermark, stamp gotenberg.Stamp) 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, rotateAngle int, rotatePages string) error {
outputPath := ctx.GeneratePath(".pdf") outputPath := ctx.GeneratePath(".pdf")
// See https://github.com/gotenberg/gotenberg/issues/1130. // See https://github.com/gotenberg/gotenberg/issues/1130.
filename := ctx.OutputFilename(outputPath) filename := ctx.OutputFilename(outputPath)
@@ -801,6 +804,11 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("stamp PDFs: %w", err) return fmt.Errorf("stamp PDFs: %w", err)
} }
err = pdfengines.RotateStub(ctx, engine, rotateAngle, rotatePages, outputPaths)
if err != nil {
return fmt.Errorf("rotate PDFs: %w", err)
}
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths) convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil { if err != nil {
return fmt.Errorf("convert PDF(s): %w", err) return fmt.Errorf("convert PDF(s): %w", err)

View File

@@ -264,6 +264,11 @@ func (engine *ExifTool) Stamp(ctx context.Context, logger *zap.Logger, inputPath
return fmt.Errorf("stamp PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("stamp PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Rotate is not available in this implementation.
func (engine *ExifTool) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
return fmt.Errorf("rotate PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Interface guards. // Interface guards.
var ( var (
_ gotenberg.Module = (*ExifTool)(nil) _ gotenberg.Module = (*ExifTool)(nil)

View File

@@ -126,6 +126,11 @@ func (engine *LibreOfficePdfEngine) Stamp(ctx context.Context, logger *zap.Logge
return fmt.Errorf("stamp PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("stamp PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Rotate is not available in this implementation.
func (engine *LibreOfficePdfEngine) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
return fmt.Errorf("rotate PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Interface guards. // Interface guards.
var ( var (
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil) _ gotenberg.Module = (*LibreOfficePdfEngine)(nil)

View File

@@ -36,6 +36,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
watermarkFiles := pdfengines.FormDataPdfWatermarkFiles(form) watermarkFiles := pdfengines.FormDataPdfWatermarkFiles(form)
stamp := pdfengines.FormDataPdfStamp(form, false) stamp := pdfengines.FormDataPdfStamp(form, false)
stampFiles := pdfengines.FormDataPdfStampFiles(form) stampFiles := pdfengines.FormDataPdfStampFiles(form)
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
zeroValuedSplitMode := gotenberg.SplitMode{} zeroValuedSplitMode := gotenberg.SplitMode{}
@@ -200,7 +201,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
return err return err
} }
hasPostProcessing := watermark.Source != "" || stamp.Source != "" || hasPostProcessing := watermark.Source != "" || stamp.Source != "" || angle != 0 ||
len(embedPaths) > 0 || len(metadata) > 0 || flatten len(embedPaths) > 0 || len(metadata) > 0 || flatten
outputPaths := make([]string, len(inputPaths)) outputPaths := make([]string, len(inputPaths))
@@ -317,6 +318,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
return fmt.Errorf("stamp PDFs: %w", err) return fmt.Errorf("stamp PDFs: %w", err)
} }
err = pdfengines.RotateStub(ctx, engine, angle, rotatePages, outputPaths)
if err != nil {
return fmt.Errorf("rotate PDFs: %w", err)
}
if flatten { if flatten {
err = pdfengines.FlattenStub(ctx, engine, outputPaths) err = pdfengines.FlattenStub(ctx, engine, outputPaths)
if err != nil { if err != nil {

View File

@@ -10,6 +10,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv"
"strings" "strings"
"syscall" "syscall"
@@ -389,6 +390,27 @@ func (engine *PdfCpu) Stamp(ctx context.Context, logger *zap.Logger, inputPath s
return engine.applyStampOrWatermark(ctx, logger, "stamp", inputPath, stamp) return engine.applyStampOrWatermark(ctx, logger, "stamp", inputPath, stamp)
} }
// Rotate rotates pages of a PDF file by the given angle using pdfcpu.
func (engine *PdfCpu) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
args := []string{"rotate"}
if pages != "" {
args = append(args, "-pages", pages)
}
args = append(args, "--", inputPath, strconv.Itoa(angle), 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("rotate PDF with pdfcpu: %w", err)
}
return nil
}
func (engine *PdfCpu) applyStampOrWatermark(ctx context.Context, logger *zap.Logger, command string, inputPath string, stamp gotenberg.Stamp) error { func (engine *PdfCpu) applyStampOrWatermark(ctx context.Context, logger *zap.Logger, command string, inputPath string, stamp gotenberg.Stamp) error {
var mode string var mode string
switch stamp.Source { switch stamp.Source {

View File

@@ -24,6 +24,7 @@ type multiPdfEngines struct {
writeBookmarksEngines []gotenberg.PdfEngine writeBookmarksEngines []gotenberg.PdfEngine
watermarkEngines []gotenberg.PdfEngine watermarkEngines []gotenberg.PdfEngine
stampEngines []gotenberg.PdfEngine stampEngines []gotenberg.PdfEngine
rotateEngines []gotenberg.PdfEngine
} }
func newMultiPdfEngines( func newMultiPdfEngines(
@@ -38,7 +39,8 @@ func newMultiPdfEngines(
readBookmarksEngines, readBookmarksEngines,
writeBookmarksEngines, writeBookmarksEngines,
watermarkEngines, watermarkEngines,
stampEngines []gotenberg.PdfEngine, stampEngines,
rotateEngines []gotenberg.PdfEngine,
) *multiPdfEngines { ) *multiPdfEngines {
return &multiPdfEngines{ return &multiPdfEngines{
mergeEngines: mergeEngines, mergeEngines: mergeEngines,
@@ -53,6 +55,7 @@ func newMultiPdfEngines(
writeBookmarksEngines: writeBookmarksEngines, writeBookmarksEngines: writeBookmarksEngines,
watermarkEngines: watermarkEngines, watermarkEngines: watermarkEngines,
stampEngines: stampEngines, stampEngines: stampEngines,
rotateEngines: rotateEngines,
} }
} }
@@ -425,6 +428,31 @@ func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *zap.Logger, inp
return fmt.Errorf("stamp PDF with multi PDF engines: %w", err) return fmt.Errorf("stamp PDF with multi PDF engines: %w", err)
} }
// Rotate rotates pages of a PDF file using the first available engine that
// supports rotation.
func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
var err error
errChan := make(chan error, 1)
for _, engine := range multi.rotateEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Rotate(ctx, logger, inputPath, angle, pages)
}(engine)
select {
case rotateErr := <-errChan:
errored := multierr.AppendInto(&err, rotateErr)
if !errored {
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("rotate PDF with multi PDF engines: %w", err)
}
// Interface guards. // Interface guards.
var ( var (
_ gotenberg.PdfEngine = (*multiPdfEngines)(nil) _ gotenberg.PdfEngine = (*multiPdfEngines)(nil)

View File

@@ -40,6 +40,7 @@ type PdfEngines struct {
writeBookmarksNames []string writeBookmarksNames []string
watermarkNames []string watermarkNames []string
stampNames []string stampNames []string
rotateNames []string
engines []gotenberg.PdfEngine engines []gotenberg.PdfEngine
disableRoutes bool disableRoutes bool
} }
@@ -62,6 +63,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
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-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-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.StringSlice("pdfengines-stamp-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the stamp feature - empty means all")
fs.StringSlice("pdfengines-rotate-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the rotate feature - empty means all")
fs.Bool("pdfengines-disable-routes", false, "Disable the routes") fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
// Deprecated flags. // Deprecated flags.
@@ -93,6 +95,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
writeBookmarksNames := flags.MustStringSlice("pdfengines-write-bookmarks-engines") writeBookmarksNames := flags.MustStringSlice("pdfengines-write-bookmarks-engines")
watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines") watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines")
stampNames := flags.MustStringSlice("pdfengines-stamp-engines") stampNames := flags.MustStringSlice("pdfengines-stamp-engines")
rotateNames := flags.MustStringSlice("pdfengines-rotate-engines")
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes") mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
engines, err := ctx.Modules(new(gotenberg.PdfEngine)) engines, err := ctx.Modules(new(gotenberg.PdfEngine))
@@ -179,6 +182,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.stampNames = stampNames mod.stampNames = stampNames
} }
mod.rotateNames = defaultNames
if len(rotateNames) > 0 {
mod.rotateNames = rotateNames
}
return nil return nil
} }
@@ -232,6 +240,7 @@ func (mod *PdfEngines) Validate() error {
findNonExistingEngines(mod.writeBookmarksNames) findNonExistingEngines(mod.writeBookmarksNames)
findNonExistingEngines(mod.watermarkNames) findNonExistingEngines(mod.watermarkNames)
findNonExistingEngines(mod.stampNames) findNonExistingEngines(mod.stampNames)
findNonExistingEngines(mod.rotateNames)
if len(nonExistingEngines) == 0 { if len(nonExistingEngines) == 0 {
return nil return nil
@@ -256,6 +265,7 @@ func (mod *PdfEngines) SystemMessages() []string {
fmt.Sprintf("write bookmarks engines - %s", strings.Join(mod.writeBookmarksNames[:], " ")), fmt.Sprintf("write bookmarks engines - %s", strings.Join(mod.writeBookmarksNames[:], " ")),
fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames[:], " ")), fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames[:], " ")),
fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames[:], " ")), fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames[:], " ")),
fmt.Sprintf("rotate engines - %s", strings.Join(mod.rotateNames[:], " ")),
} }
} }
@@ -288,6 +298,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
engines(mod.writeBookmarksNames), engines(mod.writeBookmarksNames),
engines(mod.watermarkNames), engines(mod.watermarkNames),
engines(mod.stampNames), engines(mod.stampNames),
engines(mod.rotateNames),
), nil ), nil
} }
@@ -317,6 +328,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
embedRoute(engine), embedRoute(engine),
watermarkRoute(engine), watermarkRoute(engine),
stampRoute(engine), stampRoute(engine),
rotateRoute(engine),
}, nil }, nil
} }

View File

@@ -166,6 +166,56 @@ func FormDataPdfBookmarks(form *api.FormData, mandatory bool) any {
return bookmarks return bookmarks
} }
// FormDataPdfRotate creates rotation parameters from the form data.
func FormDataPdfRotate(form *api.FormData, mandatory bool) (int, string) {
var angle int
var pages string
angleFunc := func(value string) error {
if value == "" {
return nil
}
v, err := strconv.Atoi(value)
if err != nil {
return err
}
if v != 90 && v != 180 && v != 270 {
return errors.New("wrong value, expected 90, 180, or 270")
}
angle = v
return nil
}
if mandatory {
form.MandatoryCustom("rotateAngle", func(value string) error {
return angleFunc(value)
})
} else {
form.Custom("rotateAngle", func(value string) error {
return angleFunc(value)
})
}
form.String("rotatePages", &pages, "")
return angle, pages
}
// RotateStub rotates pages of PDF files. If angle is 0, it does nothing.
func RotateStub(ctx *api.Context, engine gotenberg.PdfEngine, angle int, pages string, inputPaths []string) error {
if angle == 0 {
return nil
}
for _, inputPath := range inputPaths {
err := engine.Rotate(ctx, ctx.Log(), inputPath, angle, pages)
if err != nil {
return fmt.Errorf("rotate '%s': %w", inputPath, err)
}
}
return nil
}
// ValidatePdfFormatsCompat checks for incompatible combinations of PDF formats // ValidatePdfFormatsCompat checks for incompatible combinations of PDF formats
// with other features and returns an appropriate error if found. // with other features and returns an appropriate error if found.
func ValidatePdfFormatsCompat(pdfFormats gotenberg.PdfFormats, userPassword string, embedPaths []string) error { func ValidatePdfFormatsCompat(pdfFormats gotenberg.PdfFormats, userPassword string, embedPaths []string) error {
@@ -561,6 +611,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
watermarkFiles := FormDataPdfWatermarkFiles(form) watermarkFiles := FormDataPdfWatermarkFiles(form)
stamp := FormDataPdfStamp(form, false) stamp := FormDataPdfStamp(form, false)
stampFiles := FormDataPdfStampFiles(form) stampFiles := FormDataPdfStampFiles(form)
angle, rotatePages := FormDataPdfRotate(form, false)
var inputPaths []string var inputPaths []string
var flatten bool var flatten bool
@@ -604,6 +655,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("stamp PDFs: %w", err) return fmt.Errorf("stamp PDFs: %w", err)
} }
err = RotateStub(ctx, engine, angle, rotatePages, outputPaths)
if err != nil {
return fmt.Errorf("rotate PDFs: %w", err)
}
if flatten { if flatten {
err = FlattenStub(ctx, engine, outputPaths) err = FlattenStub(ctx, engine, outputPaths)
if err != nil { if err != nil {
@@ -706,6 +762,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
watermarkFiles := FormDataPdfWatermarkFiles(form) watermarkFiles := FormDataPdfWatermarkFiles(form)
stamp := FormDataPdfStamp(form, false) stamp := FormDataPdfStamp(form, false)
stampFiles := FormDataPdfStampFiles(form) stampFiles := FormDataPdfStampFiles(form)
angle, rotatePages := FormDataPdfRotate(form, false)
var inputPaths []string var inputPaths []string
var flatten bool var flatten bool
@@ -744,6 +801,11 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("stamp PDFs: %w", err) return fmt.Errorf("stamp PDFs: %w", err)
} }
err = RotateStub(ctx, engine, angle, rotatePages, outputPaths)
if err != nil {
return fmt.Errorf("rotate PDFs: %w", err)
}
if flatten { if flatten {
err = FlattenStub(ctx, engine, outputPaths) err = FlattenStub(ctx, engine, outputPaths)
if err != nil { if err != nil {
@@ -1216,3 +1278,38 @@ func stampRoute(engine gotenberg.PdfEngine) api.Route {
}, },
} }
} }
// rotateRoute returns an [api.Route] which can rotate pages of PDFs.
func rotateRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/rotate",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
angle, pages := FormDataPdfRotate(form, true)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = RotateStub(ctx, engine, angle, pages, inputPaths)
if err != nil {
return fmt.Errorf("rotate PDFs: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}

View File

@@ -261,6 +261,47 @@ func (engine *PdfTk) Stamp(ctx context.Context, logger *zap.Logger, inputPath st
return nil return nil
} }
// Rotate rotates all pages of a PDF file by the given angle using PDFtk.
// Page-specific rotation is not supported; if pages is non-empty,
// ErrPdfEngineMethodNotSupported is returned.
func (engine *PdfTk) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
if pages != "" {
return fmt.Errorf("rotate PDF with PDFtk (page-specific rotation): %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
var direction string
switch angle {
case 90:
direction = "east"
case 180:
direction = "south"
case 270:
direction = "west"
default:
return fmt.Errorf("rotate PDF with PDFtk: %w", gotenberg.ErrPdfRotateAngleNotSupported)
}
tmpPath := inputPath + ".tmp"
args := []string{inputPath, "cat", "1-end" + direction, "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("rotate 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. // Interface guards.
var ( var (
_ gotenberg.Module = (*PdfTk)(nil) _ gotenberg.Module = (*PdfTk)(nil)

View File

@@ -231,6 +231,11 @@ func (engine *QPdf) Stamp(ctx context.Context, logger *zap.Logger, inputPath str
return fmt.Errorf("stamp PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("stamp PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Rotate is not available in this implementation.
func (engine *QPdf) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
return fmt.Errorf("rotate PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
var ( var (
_ gotenberg.Module = (*QPdf)(nil) _ gotenberg.Module = (*QPdf)(nil)
_ gotenberg.Provisioner = (*QPdf)(nil) _ gotenberg.Provisioner = (*QPdf)(nil)

View File

@@ -1010,6 +1010,16 @@ Feature: /forms/chromium/convert/html
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response Then there should be 1 PDF(s) in the response
@rotate
Scenario: POST /forms/chromium/convert/html (Rotate 90)
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 |
| rotateAngle | 90 | 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 @embed
Scenario: POST /forms/chromium/convert/html (Embeds) Scenario: POST /forms/chromium/convert/html (Embeds)
Given I have a default Gotenberg container Given I have a default Gotenberg container

View File

@@ -585,6 +585,16 @@ Feature: /forms/libreoffice/convert
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response Then there should be 1 PDF(s) in the response
@rotate
Scenario: POST /forms/libreoffice/convert (Rotate 90)
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 |
| rotateAngle | 90 | 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 @watermark
Scenario: POST /forms/libreoffice/convert (Native Watermark - Text) Scenario: POST /forms/libreoffice/convert (Native Watermark - Text)
Given I have a default Gotenberg container Given I have a default Gotenberg container
@@ -640,9 +650,10 @@ Feature: /forms/libreoffice/convert
@metadata @metadata
@watermark @watermark
@stamp @stamp
@rotate
@flatten @flatten
@embed @embed
Scenario: POST /forms/libreoffice/convert (PDF/A-3b & PDF/UA-1 & Metadata & Watermark & Stamp & Flatten & Embeds) Scenario: POST /forms/libreoffice/convert (PDF/A-3b & PDF/UA-1 & Metadata & Watermark & Stamp & Rotate & Flatten & Embeds)
Given I have a default Gotenberg container 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): 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 | | files | testdata/page_1.docx | file |
@@ -653,6 +664,7 @@ Feature: /forms/libreoffice/convert
| watermarkExpression | CONFIDENTIAL | field | | watermarkExpression | CONFIDENTIAL | field |
| stampSource | text | field | | stampSource | text | field |
| stampExpression | DRAFT | field | | stampExpression | DRAFT | field |
| rotateAngle | 90 | field |
| flatten | true | field | | flatten | true | field |
| embeds | testdata/embed_1.xml | file | | embeds | testdata/embed_1.xml | file |
| embeds | testdata/embed_2.xml | file | | embeds | testdata/embed_2.xml | file |

View File

@@ -425,6 +425,17 @@ Feature: /forms/pdfengines/merge
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response Then there should be 1 PDF(s) in the response
@rotate
Scenario: POST /forms/pdfengines/merge (Rotate 90)
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 |
| rotateAngle | 90 | 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 @embed
Scenario: POST /foo/forms/pdfengines/merge (Embeds) Scenario: POST /foo/forms/pdfengines/merge (Embeds)
Given I have a default Gotenberg container Given I have a default Gotenberg container
@@ -443,10 +454,11 @@ Feature: /forms/pdfengines/merge
@metadata @metadata
@watermark @watermark
@stamp @stamp
@rotate
@flatten @flatten
@embed @embed
@bookmarks @bookmarks
Scenario: POST /forms/pdfengines/merge (PDF/A-3b & PDF/UA-1 & Metadata & Watermark & Stamp & Flatten & Embeds & Bookmarks) Scenario: POST /forms/pdfengines/merge (PDF/A-3b & PDF/UA-1 & Metadata & Watermark & Stamp & Rotate & Flatten & Embeds & Bookmarks)
Given I have a default Gotenberg container 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): 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_1.pdf | file |
@@ -458,6 +470,7 @@ Feature: /forms/pdfengines/merge
| watermarkExpression | CONFIDENTIAL | field | | watermarkExpression | CONFIDENTIAL | field |
| stampSource | text | field | | stampSource | text | field |
| stampExpression | DRAFT | field | | stampExpression | DRAFT | field |
| rotateAngle | 90 | field |
| bookmarks | [{"title":"Merged Index","page":1}] | field | | bookmarks | [{"title":"Merged Index","page":1}] | field |
| flatten | true | field | | flatten | true | field |
| embeds | testdata/embed_1.xml | file | | embeds | testdata/embed_1.xml | file |

View File

@@ -0,0 +1,164 @@
@pdfengines
@pdfengines-rotate
@rotate
Feature: /forms/pdfengines/rotate
Scenario: POST /forms/pdfengines/rotate (90 - All Pages - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_ROTATE_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| rotateAngle | 90 | 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/rotate (180 - All Pages - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_ROTATE_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 180 | 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/rotate (270 - All Pages - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_ROTATE_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 270 | 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/rotate (90 - Specific Pages - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_ROTATE_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| rotateAngle | 90 | field |
| rotatePages | 1,3 | 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/rotate (90 - All Pages - pdftk)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_ROTATE_ENGINES | pdftk |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 90 | 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/rotate (Specific Pages - pdftk unsupported)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_ROTATE_ENGINES | pdftk |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| rotateAngle | 90 | field |
| rotatePages | 1,3 | field |
Then the response status code should be 500
Scenario: POST /forms/pdfengines/rotate (Bad Request - Invalid Angle)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 45 | field |
Then the response status code should be 400
Then the response body should contain string:
"""
Invalid form data: form field 'rotateAngle' is invalid
"""
Scenario: POST /forms/pdfengines/rotate (Bad Request - Missing Angle)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" 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 'rotateAngle' is required
"""
Scenario: POST /forms/pdfengines/rotate (Bad Request - No PDF)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| rotateAngle | 90 | 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/rotate (Many PDFs)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| rotateAngle | 90 | 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/rotate (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/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 90 | field |
Then the response status code should be 404
Scenario: POST /forms/pdfengines/rotate (Gotenberg Trace)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 90 | field |
| Gotenberg-Trace | forms_pdfengines_rotate | 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_rotate"
Then the Gotenberg container should log the following entries:
| "trace":"forms_pdfengines_rotate" |
@webhook
Scenario: POST /forms/pdfengines/rotate (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/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 90 | 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/rotate (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/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 90 | field |
Then the response status code should be 401
Scenario: POST /foo/forms/pdfengines/rotate (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/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 90 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"

View File

@@ -499,6 +499,18 @@ Feature: /forms/pdfengines/split
Then the response header "Content-Type" should be "application/zip" Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response Then there should be 2 PDF(s) in the response
@rotate
Scenario: POST /forms/pdfengines/split (Rotate 90)
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 |
| rotateAngle | 90 | 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 @embed
Scenario: POST /foo/forms/pdfengines/split (Embeds) Scenario: POST /foo/forms/pdfengines/split (Embeds)
Given I have a default Gotenberg container Given I have a default Gotenberg container
@@ -522,9 +534,10 @@ Feature: /forms/pdfengines/split
@metadata @metadata
@watermark @watermark
@stamp @stamp
@rotate
@flatten @flatten
@embed @embed
Scenario: POST /forms/pdfengines/split (PDF/A-3b & PDF/UA-1 & Metadata & Watermark & Stamp & Flatten & Embeds) Scenario: POST /forms/pdfengines/split (PDF/A-3b & PDF/UA-1 & Metadata & Watermark & Stamp & Rotate & Flatten & Embeds)
Given I have a default Gotenberg container 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): 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 | | files | testdata/pages_3.pdf | file |
@@ -537,6 +550,7 @@ Feature: /forms/pdfengines/split
| watermarkExpression | CONFIDENTIAL | field | | watermarkExpression | CONFIDENTIAL | field |
| stampSource | text | field | | stampSource | text | field |
| stampExpression | DRAFT | field | | stampExpression | DRAFT | field |
| rotateAngle | 90 | field |
| flatten | true | field | | flatten | true | field |
| embeds | testdata/embed_1.xml | file | | embeds | testdata/embed_1.xml | file |
| embeds | testdata/embed_2.xml | file | | embeds | testdata/embed_2.xml | file |