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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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