feat(pdfengines): add bookmarks write route and update the merge route with this feature

This commit is contained in:
Julien Neuhart
2026-03-04 20:25:06 +01:00
parent 0e13cc3ed8
commit 5120b49639
10 changed files with 219 additions and 11 deletions

View File

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

View File

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

View File

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