From 5120b49639b82c2525f5de2c9131386a752d5fd5 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Wed, 4 Mar 2026 20:25:06 +0100 Subject: [PATCH] feat(pdfengines): add bookmarks write route and update the merge route with this feature --- pkg/gotenberg/mocks.go | 21 +-- pkg/gotenberg/pdfengine.go | 13 ++ pkg/modules/exiftool/exiftool.go | 5 + .../libreoffice/pdfengine/pdfengine.go | 5 + pkg/modules/pdfcpu/pdfcpu.go | 5 + pkg/modules/pdfengines/multi.go | 30 +++- pkg/modules/pdfengines/pdfengines.go | 13 ++ pkg/modules/pdfengines/routes.go | 128 +++++++++++++++++- pkg/modules/pdftk/pdftk.go | 5 + pkg/modules/qpdf/qpdf.go | 5 + 10 files changed, 219 insertions(+), 11 deletions(-) diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go index 6a0d5035..ad9f5b1d 100644 --- a/pkg/gotenberg/mocks.go +++ b/pkg/gotenberg/mocks.go @@ -46,14 +46,15 @@ func (mod *DebuggableMock) Debug() map[string]any { // //nolint:dupl type PdfEngineMock struct { - MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error - SplitMock func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) - FlattenMock func(ctx context.Context, logger *zap.Logger, inputPath string) error - ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error - ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) - WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error - EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error - EmbedFilesMock func(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error + MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error + SplitMock func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) + FlattenMock func(ctx context.Context, logger *zap.Logger, inputPath string) error + ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error + ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) + WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error + EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error + EmbedFilesMock func(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error + WriteBookmarksMock func(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error } func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { @@ -88,6 +89,10 @@ func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *zap.Logger, return engine.EmbedFilesMock(ctx, logger, filePaths, inputPath) } +func (engine *PdfEngineMock) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error { + return engine.WriteBookmarksMock(ctx, logger, inputPath, bookmarks) +} + // 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 2709b43c..d99ba5b8 100644 --- a/pkg/gotenberg/pdfengine.go +++ b/pkg/gotenberg/pdfengine.go @@ -109,6 +109,14 @@ type PdfFormats struct { PdfUa bool } +// Bookmark represents a node in the PDF document's outline +// (table of contents). +type Bookmark struct { + Title string `json:"title"` + Page int `json:"page"` + Children []Bookmark `json:"children,omitempty"` +} + // PdfEngine provides an interface for operations on PDFs. Implementations // can use various tools like PDFtk, or implement functionality directly in // Go. @@ -138,6 +146,10 @@ type PdfEngine interface { // WriteMetadata writes the metadata into a given PDF file. WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error + // WriteBookmarks adds a document outline (bookmarks) to a PDF file. + // The bookmarks parameter represents the hierarchical tree of the outline. + WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error + // Encrypt adds password protection to a PDF file. // The userPassword is required to open the document. // The ownerPassword provides full access to the document. @@ -146,6 +158,7 @@ type PdfEngine interface { // EmbedFiles embeds files into a PDF. All files are embedded as file attachments // without modifying the main PDF content. + // TODO: attachments instead? Rename the route? EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error } diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go index 21f473a1..eafe2886 100644 --- a/pkg/modules/exiftool/exiftool.go +++ b/pkg/modules/exiftool/exiftool.go @@ -203,6 +203,11 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m return nil } +// WriteBookmarks is not available in this implementation. +func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { + return fmt.Errorf("write PDF bookmarks with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Encrypt is not available in this implementation. func (engine *ExifTool) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { return fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported) diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go index 3fd828ef..44a6f0ae 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go @@ -91,6 +91,11 @@ func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *z return fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// WriteBookmarks is not available in this implementation. +func (engine *LibreOfficePdfEngine) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { + return fmt.Errorf("write PDF bookmarks with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Encrypt is not available in this implementation. func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go index c0de3fbb..8714c7d9 100644 --- a/pkg/modules/pdfcpu/pdfcpu.go +++ b/pkg/modules/pdfcpu/pdfcpu.go @@ -171,6 +171,11 @@ func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, met return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// WriteBookmarks is not available in this implementation. +func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { + return fmt.Errorf("write PDF bookmarks with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // EmbedFiles embeds files into a PDF. All files are embedded as file attachments // without modifying the main PDF content. func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go index 3ec176b0..8567f2f2 100644 --- a/pkg/modules/pdfengines/multi.go +++ b/pkg/modules/pdfengines/multi.go @@ -20,6 +20,7 @@ type multiPdfEngines struct { writeMetadataEngines []gotenberg.PdfEngine passwordEngines []gotenberg.PdfEngine embedEngines []gotenberg.PdfEngine + bookmarksEngines []gotenberg.PdfEngine } func newMultiPdfEngines( @@ -30,7 +31,8 @@ func newMultiPdfEngines( readMetadataEngines, writeMetadataEngines, passwordEngines, - embedEngines []gotenberg.PdfEngine, + embedEngines, + bookmarksEngines []gotenberg.PdfEngine, ) *multiPdfEngines { return &multiPdfEngines{ mergeEngines: mergeEngines, @@ -41,6 +43,7 @@ func newMultiPdfEngines( writeMetadataEngines: writeMetadataEngines, passwordEngines: passwordEngines, embedEngines: embedEngines, + bookmarksEngines: bookmarksEngines, } } @@ -266,6 +269,31 @@ func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger return fmt.Errorf("embed files into PDF using multi PDF engines: %w", err) } +// WriteBookmarks adds a document outline (bookmarks) to a PDF file using the +// first available engine that supports metadata writing. +func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { + var err error + errChan := make(chan error, 1) + + for _, engine := range multi.bookmarksEngines { + go func(engine gotenberg.PdfEngine) { + errChan <- engine.WriteBookmarks(ctx, logger, inputPath, bookmarks) + }(engine) + + select { + case writeBookmarksErr := <-errChan: + errored := multierr.AppendInto(&err, writeBookmarksErr) + if !errored { + return nil + } + case <-ctx.Done(): + return ctx.Err() + } + } + + return fmt.Errorf("write PDF bookmarks 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 1070857e..54654696 100644 --- a/pkg/modules/pdfengines/pdfengines.go +++ b/pkg/modules/pdfengines/pdfengines.go @@ -36,6 +36,7 @@ type PdfEngines struct { writeMetadataNames []string encryptNames []string embedNames []string + bookmarksNames []string engines []gotenberg.PdfEngine disableRoutes bool } @@ -54,6 +55,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor { fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all") fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all") fs.StringSlice("pdfengines-embed-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the file embedding feature - empty means all") + fs.StringSlice("pdfengines-bookmarks-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the bookmarks feature - empty means all") fs.Bool("pdfengines-disable-routes", false, "Disable the routes") // Deprecated flags. @@ -81,6 +83,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines") encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines") embedNames := flags.MustStringSlice("pdfengines-embed-engines") + bookmarksNames := flags.MustStringSlice("pdfengines-bookmarks-engines") mod.disableRoutes = flags.MustBool("pdfengines-disable-routes") engines, err := ctx.Modules(new(gotenberg.PdfEngine)) @@ -147,6 +150,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { mod.embedNames = embedNames } + mod.bookmarksNames = defaultNames + if len(bookmarksNames) > 0 { + mod.bookmarksNames = bookmarksNames + } + return nil } @@ -196,6 +204,7 @@ func (mod *PdfEngines) Validate() error { findNonExistingEngines(mod.writeMetadataNames) findNonExistingEngines(mod.encryptNames) findNonExistingEngines(mod.embedNames) + findNonExistingEngines(mod.bookmarksNames) if len(nonExistingEngines) == 0 { return nil @@ -215,6 +224,8 @@ func (mod *PdfEngines) SystemMessages() []string { fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")), fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")), fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames[:], " ")), + fmt.Sprintf("embed engines - %s", strings.Join(mod.embedNames[:], " ")), + fmt.Sprintf("bookmarks engines - %s", strings.Join(mod.bookmarksNames[:], " ")), } } @@ -243,6 +254,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) { engines(mod.writeMetadataNames), engines(mod.encryptNames), engines(mod.embedNames), + engines(mod.bookmarksNames), ), nil } @@ -266,6 +278,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) { convertRoute(engine), readMetadataRoute(engine), writeMetadataRoute(engine), + writeBookmarksRoute(engine), encryptRoute(engine), embedRoute(engine), }, nil diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index 0da835ed..6052792e 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -84,7 +84,7 @@ func FormDataPdfSplitMode(form *api.FormData, mandatory bool) gotenberg.SplitMod } // FormDataPdfFormats creates [gotenberg.PdfFormats] from the form data. -// Fallback to default value if the considered key is not present. +// Fallback to the default value if the considered key is not present. func FormDataPdfFormats(form *api.FormData) gotenberg.PdfFormats { var ( pdfa string @@ -128,6 +128,44 @@ func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]any { return metadata } +// FormDataPdfBookmarks creates bookmarks from the form data. +func FormDataPdfBookmarks(form *api.FormData, mandatory bool) any { + var bookmarks any + + bookmarksFunc := func(value string) error { + if len(value) > 0 { + var list []gotenberg.Bookmark + err := json.Unmarshal([]byte(value), &list) + if err == nil { + bookmarks = list + return nil + } + + var m map[string][]gotenberg.Bookmark + err = json.Unmarshal([]byte(value), &m) + if err == nil { + bookmarks = m + return nil + } + + return fmt.Errorf("unmarshal bookmarks: %w", err) + } + return nil + } + + if mandatory { + form.MandatoryCustom("bookmarks", func(value string) error { + return bookmarksFunc(value) + }) + } else { + form.Custom("bookmarks", func(value string) error { + return bookmarksFunc(value) + }) + } + + return bookmarks +} + // MergeStub merges given PDFs. If only one input PDF, it does nothing and // returns the corresponding input path. func MergeStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string) (string, error) { @@ -254,6 +292,42 @@ func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata ma return nil } +// WriteBookmarksStub writes the bookmarks into PDF files. If no bookmarks, it +// does nothing. +func WriteBookmarksStub(ctx *api.Context, engine gotenberg.PdfEngine, bookmarks any, inputPaths []string) error { + if bookmarks == nil { + return nil + } + + switch b := bookmarks.(type) { + case []gotenberg.Bookmark: + if len(b) == 0 { + return nil + } + + for _, inputPath := range inputPaths { + err := engine.WriteBookmarks(ctx, ctx.Log(), inputPath, b) + if err != nil { + return fmt.Errorf("write bookmarks into '%s': %w", inputPath, err) + } + } + case map[string][]gotenberg.Bookmark: + for _, inputPath := range inputPaths { + filename := filepath.Base(inputPath) + if specificBookmarks, ok := b[filename]; ok { + err := engine.WriteBookmarks(ctx, ctx.Log(), inputPath, specificBookmarks) + if err != nil { + return fmt.Errorf("write bookmarks into '%s': %w", inputPath, err) + } + } + } + default: + return fmt.Errorf("bookmarks type '%T' not supported", bookmarks) + } + + return nil +} + // FormDataPdfEmbeds extracts embedded file paths from form data. // Only files uploaded with the "embeds" field name are included. func FormDataPdfEmbeds(form *api.FormData) []string { @@ -313,6 +387,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { form := ctx.FormData() pdfFormats := FormDataPdfFormats(form) metadata := FormDataPdfMetadata(form, false) + bookmarks := FormDataPdfBookmarks(form, false) userPassword, ownerPassword := FormDataPdfEncrypt(form) embedPaths := FormDataPdfEmbeds(form) @@ -326,6 +401,13 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } + if b, ok := bookmarks.(map[string][]gotenberg.Bookmark); ok { + err = WriteBookmarksStub(ctx, engine, b, inputPaths) + if err != nil { + return fmt.Errorf("write bookmarks: %w", err) + } + } + outputPath := ctx.GeneratePath(".pdf") err = engine.Merge(ctx, ctx.Log(), inputPaths, outputPath) if err != nil { @@ -342,6 +424,13 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("embed files into PDFs: %w", err) } + if b, ok := bookmarks.([]gotenberg.Bookmark); ok { + err = WriteBookmarksStub(ctx, engine, b, outputPaths) + if err != nil { + return fmt.Errorf("write bookmarks: %w", err) + } + } + err = WriteMetadataStub(ctx, engine, metadata, outputPaths) if err != nil { return fmt.Errorf("write metadata: %w", err) @@ -619,6 +708,41 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route { } } +// writeBookmarksRoute returns an [api.Route] which can write bookmarks into PDFs. +func writeBookmarksRoute(engine gotenberg.PdfEngine) api.Route { + return api.Route{ + Method: http.MethodPost, + Path: "/forms/pdfengines/bookmarks/write", + IsMultipart: true, + Handler: func(c echo.Context) error { + ctx := c.Get("context").(*api.Context) + + form := ctx.FormData() + bookmarks := FormDataPdfBookmarks(form, true) + + var inputPaths []string + err := form. + MandatoryPaths([]string{".pdf"}, &inputPaths). + Validate() + if err != nil { + return fmt.Errorf("validate form data: %w", err) + } + + err = WriteBookmarksStub(ctx, engine, bookmarks, inputPaths) + if err != nil { + return fmt.Errorf("write bookmarks: %w", err) + } + + err = ctx.AddOutputPaths(inputPaths...) + if err != nil { + return fmt.Errorf("add output paths: %w", err) + } + + return nil + }, + } +} + // encryptRoute returns an [api.Route] which can add password protection to PDFs. func encryptRoute(engine gotenberg.PdfEngine) api.Route { return api.Route{ @@ -658,6 +782,7 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route { } // embedRoute returns an [api.Route] which can add embedded files to PDFs. +// TODO: attachments instead? func embedRoute(engine gotenberg.PdfEngine) api.Route { return api.Route{ Method: http.MethodPost, @@ -676,7 +801,6 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route { if err != nil { return fmt.Errorf("validate form data: %w", err) } - err = EmbedFilesStub(ctx, engine, embedPaths, inputPaths) if err != nil { return fmt.Errorf("embed files into PDFs: %w", err) diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go index fa5bebdb..81f995fe 100644 --- a/pkg/modules/pdftk/pdftk.go +++ b/pkg/modules/pdftk/pdftk.go @@ -145,6 +145,11 @@ func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *zap.Logger, meta return fmt.Errorf("write PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// WriteBookmarks is not available in this implementation. +func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { + return fmt.Errorf("write PDF bookmarks with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Encrypt adds password protection to a PDF file using PDFtk. func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { if userPassword == "" { diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index 07a5a35f..440cca60 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -172,6 +172,11 @@ func (engine *QPdf) WriteMetadata(ctx context.Context, logger *zap.Logger, metad return fmt.Errorf("write PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// WriteBookmarks is not available in this implementation. +func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { + return fmt.Errorf("write PDF bookmarks with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Encrypt adds password protection to a PDF file using QPDF. func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { if userPassword == "" {