diff --git a/Makefile b/Makefile index 0e018f77..52e470c3 100644 --- a/Makefile +++ b/Makefile @@ -69,6 +69,7 @@ PDFENGINES_WRITE_BOOKMARKS_ENGINES=pdfcpu PDFENGINES_WATERMARK_ENGINES=pdfcpu,pdftk PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk +PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk PDFENGINES_EMBED_ENGINES=pdfcpu PROMETHEUS_NAMESPACE=gotenberg PROMETHEUS_COLLECT_INTERVAL=1s @@ -151,6 +152,7 @@ run: ## Start a Gotenberg container --pdfengines-watermark-engines=$(PDFENGINES_WATERMARK_ENGINES) \ --pdfengines-stamp-engines=$(PDFENGINES_STAMP_ENGINES) \ --pdfengines-encrypt-engines=$(PDFENGINES_ENCRYPT_ENGINES) \ + --pdfengines-rotate-engines=$(PDFENGINES_ROTATE_ENGINES) \ --pdfengines-embed-engines=$(PDFENGINES_EMBED_ENGINES) \ --prometheus-namespace=$(PROMETHEUS_NAMESPACE) \ --prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \ @@ -203,6 +205,8 @@ NO_CONCURRENCY=false # watermark # pdfengines-stamp # stamp +# pdfengines-rotate +# rotate # pdfengines-bookmarks # bookmarks # prometheus-metrics diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go index 1f7b0c4c..eb1463c0 100644 --- a/pkg/gotenberg/mocks.go +++ b/pkg/gotenberg/mocks.go @@ -59,6 +59,7 @@ type PdfEngineMock struct { 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 + 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 { @@ -113,6 +114,10 @@ func (engine *PdfEngineMock) Stamp(ctx context.Context, logger *zap.Logger, inpu 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. type PdfEngineProviderMock struct { PdfEngineMock func() (PdfEngine, error) diff --git a/pkg/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go index 2ec89015..80c5438b 100644 --- a/pkg/gotenberg/pdfengine.go +++ b/pkg/gotenberg/pdfengine.go @@ -32,6 +32,10 @@ var ( // ErrPdfStampSourceNotSupported is returned when a stamp source type // is not supported by the PDF engine. 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 @@ -203,6 +207,10 @@ type PdfEngine interface { // 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 + + // 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]. diff --git a/pkg/modules/api/middlewares.go b/pkg/modules/api/middlewares.go index b9833fad..b13e1996 100644 --- a/pkg/modules/api/middlewares.go +++ b/pkg/modules/api/middlewares.go @@ -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" } + 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 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 a0756642..44f1a0e1 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -419,6 +419,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { watermarkFiles := pdfengines.FormDataPdfWatermarkFiles(form) stamp := pdfengines.FormDataPdfStamp(form, false) stampFiles := pdfengines.FormDataPdfStampFiles(form) + rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false) var url string err := form. @@ -435,7 +436,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { 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 { 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) stamp := pdfengines.FormDataPdfStamp(form, false) stampFiles := pdfengines.FormDataPdfStampFiles(form) + rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false) var inputPath string err := form. @@ -510,7 +512,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { } 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 { 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) stamp := pdfengines.FormDataPdfStamp(form, false) stampFiles := pdfengines.FormDataPdfStampFiles(form) + rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false) var ( 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) } - 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 { 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 } -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") // See https://github.com/gotenberg/gotenberg/issues/1130. 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) } + 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) if err != nil { return fmt.Errorf("convert PDF(s): %w", err) diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go index 2a0177b6..2db64c45 100644 --- a/pkg/modules/exiftool/exiftool.go +++ b/pkg/modules/exiftool/exiftool.go @@ -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) } +// 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. var ( _ gotenberg.Module = (*ExifTool)(nil) diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go index 1ce151d9..7a9afb92 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go @@ -126,6 +126,11 @@ func (engine *LibreOfficePdfEngine) Stamp(ctx context.Context, logger *zap.Logge 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. var ( _ gotenberg.Module = (*LibreOfficePdfEngine)(nil) diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go index 2f85b82e..ab57f865 100644 --- a/pkg/modules/libreoffice/routes.go +++ b/pkg/modules/libreoffice/routes.go @@ -36,6 +36,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap watermarkFiles := pdfengines.FormDataPdfWatermarkFiles(form) stamp := pdfengines.FormDataPdfStamp(form, false) stampFiles := pdfengines.FormDataPdfStampFiles(form) + angle, rotatePages := pdfengines.FormDataPdfRotate(form, false) zeroValuedSplitMode := gotenberg.SplitMode{} @@ -200,7 +201,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap return err } - hasPostProcessing := watermark.Source != "" || stamp.Source != "" || + hasPostProcessing := watermark.Source != "" || stamp.Source != "" || angle != 0 || len(embedPaths) > 0 || len(metadata) > 0 || flatten 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) } + err = pdfengines.RotateStub(ctx, engine, angle, rotatePages, outputPaths) + if err != nil { + return fmt.Errorf("rotate PDFs: %w", err) + } + if flatten { err = pdfengines.FlattenStub(ctx, engine, outputPaths) if err != nil { diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go index 7289dd6d..5fee7623 100644 --- a/pkg/modules/pdfcpu/pdfcpu.go +++ b/pkg/modules/pdfcpu/pdfcpu.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "sort" + "strconv" "strings" "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) } +// 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 { var mode string switch stamp.Source { diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go index 4a8419dd..b73397ae 100644 --- a/pkg/modules/pdfengines/multi.go +++ b/pkg/modules/pdfengines/multi.go @@ -24,6 +24,7 @@ type multiPdfEngines struct { writeBookmarksEngines []gotenberg.PdfEngine watermarkEngines []gotenberg.PdfEngine stampEngines []gotenberg.PdfEngine + rotateEngines []gotenberg.PdfEngine } func newMultiPdfEngines( @@ -38,7 +39,8 @@ func newMultiPdfEngines( readBookmarksEngines, writeBookmarksEngines, watermarkEngines, - stampEngines []gotenberg.PdfEngine, + stampEngines, + rotateEngines []gotenberg.PdfEngine, ) *multiPdfEngines { return &multiPdfEngines{ mergeEngines: mergeEngines, @@ -53,6 +55,7 @@ func newMultiPdfEngines( writeBookmarksEngines: writeBookmarksEngines, watermarkEngines: watermarkEngines, 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) } +// 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. var ( _ gotenberg.PdfEngine = (*multiPdfEngines)(nil) diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go index 3536c602..cc33c215 100644 --- a/pkg/modules/pdfengines/pdfengines.go +++ b/pkg/modules/pdfengines/pdfengines.go @@ -40,6 +40,7 @@ type PdfEngines struct { writeBookmarksNames []string watermarkNames []string stampNames []string + rotateNames []string engines []gotenberg.PdfEngine 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-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-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") // Deprecated flags. @@ -93,6 +95,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { writeBookmarksNames := flags.MustStringSlice("pdfengines-write-bookmarks-engines") watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines") stampNames := flags.MustStringSlice("pdfengines-stamp-engines") + rotateNames := flags.MustStringSlice("pdfengines-rotate-engines") mod.disableRoutes = flags.MustBool("pdfengines-disable-routes") engines, err := ctx.Modules(new(gotenberg.PdfEngine)) @@ -179,6 +182,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { mod.stampNames = stampNames } + mod.rotateNames = defaultNames + if len(rotateNames) > 0 { + mod.rotateNames = rotateNames + } + return nil } @@ -232,6 +240,7 @@ func (mod *PdfEngines) Validate() error { findNonExistingEngines(mod.writeBookmarksNames) findNonExistingEngines(mod.watermarkNames) findNonExistingEngines(mod.stampNames) + findNonExistingEngines(mod.rotateNames) if len(nonExistingEngines) == 0 { return nil @@ -256,6 +265,7 @@ func (mod *PdfEngines) SystemMessages() []string { 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[:], " ")), + 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.watermarkNames), engines(mod.stampNames), + engines(mod.rotateNames), ), nil } @@ -317,6 +328,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) { embedRoute(engine), watermarkRoute(engine), stampRoute(engine), + rotateRoute(engine), }, nil } diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index 9470094b..81db75d1 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -166,6 +166,56 @@ func FormDataPdfBookmarks(form *api.FormData, mandatory bool) any { 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 // with other features and returns an appropriate error if found. func ValidatePdfFormatsCompat(pdfFormats gotenberg.PdfFormats, userPassword string, embedPaths []string) error { @@ -561,6 +611,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { watermarkFiles := FormDataPdfWatermarkFiles(form) stamp := FormDataPdfStamp(form, false) stampFiles := FormDataPdfStampFiles(form) + angle, rotatePages := FormDataPdfRotate(form, false) var inputPaths []string var flatten bool @@ -604,6 +655,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { 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 { err = FlattenStub(ctx, engine, outputPaths) if err != nil { @@ -706,6 +762,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { watermarkFiles := FormDataPdfWatermarkFiles(form) stamp := FormDataPdfStamp(form, false) stampFiles := FormDataPdfStampFiles(form) + angle, rotatePages := FormDataPdfRotate(form, false) var inputPaths []string var flatten bool @@ -744,6 +801,11 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { 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 { err = FlattenStub(ctx, engine, outputPaths) 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 + }, + } +} diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go index 2d0b767e..9af51868 100644 --- a/pkg/modules/pdftk/pdftk.go +++ b/pkg/modules/pdftk/pdftk.go @@ -261,6 +261,47 @@ func (engine *PdfTk) Stamp(ctx context.Context, logger *zap.Logger, inputPath st 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. var ( _ gotenberg.Module = (*PdfTk)(nil) diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index bae036c9..ce6c2d0c 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -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) } +// 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 ( _ 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 5ded17f2..10373196 100644 --- a/test/integration/features/chromium_convert_html.feature +++ b/test/integration/features/chromium_convert_html.feature @@ -1010,6 +1010,16 @@ Feature: /forms/chromium/convert/html Then the response header "Content-Type" should be "application/pdf" 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 Scenario: POST /forms/chromium/convert/html (Embeds) Given I have a default Gotenberg container diff --git a/test/integration/features/libreoffice_convert.feature b/test/integration/features/libreoffice_convert.feature index 86693fee..3cffdf24 100644 --- a/test/integration/features/libreoffice_convert.feature +++ b/test/integration/features/libreoffice_convert.feature @@ -585,6 +585,16 @@ Feature: /forms/libreoffice/convert Then the response header "Content-Type" should be "application/pdf" 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 Scenario: POST /forms/libreoffice/convert (Native Watermark - Text) Given I have a default Gotenberg container @@ -640,9 +650,10 @@ Feature: /forms/libreoffice/convert @metadata @watermark @stamp + @rotate @flatten @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 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 | @@ -653,6 +664,7 @@ Feature: /forms/libreoffice/convert | watermarkExpression | CONFIDENTIAL | field | | stampSource | text | field | | stampExpression | DRAFT | field | + | rotateAngle | 90 | field | | flatten | true | field | | embeds | testdata/embed_1.xml | file | | embeds | testdata/embed_2.xml | file | diff --git a/test/integration/features/pdfengines_merge.feature b/test/integration/features/pdfengines_merge.feature index 7a1eac8e..34b14976 100644 --- a/test/integration/features/pdfengines_merge.feature +++ b/test/integration/features/pdfengines_merge.feature @@ -425,6 +425,17 @@ Feature: /forms/pdfengines/merge Then the response header "Content-Type" should be "application/pdf" 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 Scenario: POST /foo/forms/pdfengines/merge (Embeds) Given I have a default Gotenberg container @@ -443,10 +454,11 @@ Feature: /forms/pdfengines/merge @metadata @watermark @stamp + @rotate @flatten @embed @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 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 | @@ -458,6 +470,7 @@ Feature: /forms/pdfengines/merge | watermarkExpression | CONFIDENTIAL | field | | stampSource | text | field | | stampExpression | DRAFT | field | + | rotateAngle | 90 | field | | bookmarks | [{"title":"Merged Index","page":1}] | field | | flatten | true | field | | embeds | testdata/embed_1.xml | file | diff --git a/test/integration/features/pdfengines_rotate.feature b/test/integration/features/pdfengines_rotate.feature new file mode 100644 index 00000000..a2de0b4a --- /dev/null +++ b/test/integration/features/pdfengines_rotate.feature @@ -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" diff --git a/test/integration/features/pdfengines_split.feature b/test/integration/features/pdfengines_split.feature index 125dce01..bfa90a7f 100644 --- a/test/integration/features/pdfengines_split.feature +++ b/test/integration/features/pdfengines_split.feature @@ -499,6 +499,18 @@ Feature: /forms/pdfengines/split Then the response header "Content-Type" should be "application/zip" 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 Scenario: POST /foo/forms/pdfengines/split (Embeds) Given I have a default Gotenberg container @@ -522,9 +534,10 @@ Feature: /forms/pdfengines/split @metadata @watermark @stamp + @rotate @flatten @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 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 | @@ -537,6 +550,7 @@ Feature: /forms/pdfengines/split | watermarkExpression | CONFIDENTIAL | field | | stampSource | text | field | | stampExpression | DRAFT | field | + | rotateAngle | 90 | field | | flatten | true | field | | embeds | testdata/embed_1.xml | file | | embeds | testdata/embed_2.xml | file |