From 65e5699b7101601d9d4a69f20195ec33112752e6 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Fri, 14 Aug 2026 12:36:13 +0200 Subject: [PATCH] feat(pdfengines): generate document-title bookmarks when merging (#867) --- .bruno/PDF Engines/Merge/Merge PDFs.bru | 1 + pkg/modules/pdfengines/routes.go | 41 +++++++++++++++-- .../features/pdfengines_merge.feature | 43 ++++++++++++++++++ test/integration/testdata/titled_alpha.pdf | Bin 0 -> 11519 bytes test/integration/testdata/titled_bravo.pdf | Bin 0 -> 8096 bytes test/integration/testdata/untitled_gamma.pdf | Bin 0 -> 4560 bytes 6 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 test/integration/testdata/titled_alpha.pdf create mode 100644 test/integration/testdata/titled_bravo.pdf create mode 100644 test/integration/testdata/untitled_gamma.pdf diff --git a/.bruno/PDF Engines/Merge/Merge PDFs.bru b/.bruno/PDF Engines/Merge/Merge PDFs.bru index ee29e25e..6e5b29a9 100644 --- a/.bruno/PDF Engines/Merge/Merge PDFs.bru +++ b/.bruno/PDF Engines/Merge/Merge PDFs.bru @@ -15,6 +15,7 @@ body:multipart-form { files: @file(../../test/integration/testdata/page_2.pdf) ~flatten: false ~autoIndexBookmarks: false + ~titleBookmarks: false ~pdfa: PDF/A-1b ~pdfua: true ~optimizeImages: false diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index 693a8e07..f407cd39 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -442,6 +442,27 @@ func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata ma return nil } +// documentTitle returns the input PDF's Title metadata entry, falling back to +// the original filename without its extension when the entry is absent, blank, +// or cannot be read. It labels the per-document entries the merge route's +// titleBookmarks feature generates. +func documentTitle(ctx *api.Context, engine gotenberg.PdfEngine, inputPath, filename string) string { + fallback := strings.TrimSuffix(filename, filepath.Ext(filename)) + + metadata, err := engine.ReadMetadata(ctx, ctx.Log(), inputPath) + if err != nil { + ctx.Log().WarnContext(ctx, fmt.Sprintf("read metadata of '%s' for title bookmark, using filename: %s", filename, err)) + return fallback + } + + title, ok := metadata["Title"].(string) + if !ok || strings.TrimSpace(title) == "" { + return fallback + } + + return title +} + func shiftBookmarks(bookmarks []gotenberg.Bookmark, offset int) []gotenberg.Bookmark { if offset == 0 { return bookmarks @@ -995,10 +1016,12 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { var inputPaths []string var flatten bool var autoIndexBookmarks bool + var titleBookmarks bool err := form. MandatoryPaths([]string{".pdf"}, &inputPaths). Bool("flatten", &flatten, false). Bool("autoIndexBookmarks", &autoIndexBookmarks, false). + Bool("titleBookmarks", &titleBookmarks, false). Validate() if err != nil { return fmt.Errorf("validate form data: %w", err) @@ -1078,7 +1101,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { finalBookmarks = b } else { bMap, _ := bookmarks.(map[string][]gotenberg.Bookmark) - if bMap != nil || autoIndexBookmarks { + if bMap != nil || autoIndexBookmarks || titleBookmarks { offset := 0 for _, inputPath := range inputPaths { filename := ctx.OriginalFilename(inputPath) @@ -1088,7 +1111,9 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { fileBookmarks = bMap[filename] } - if len(fileBookmarks) == 0 && autoIndexBookmarks { + // titleBookmarks nests each input's own outline under + // its title entry, so its outline is read here too. + if len(fileBookmarks) == 0 && (autoIndexBookmarks || titleBookmarks) { fb, err := engine.ReadBookmarks(ctx, ctx.Log(), inputPath) if err != nil { return fmt.Errorf("read bookmarks of '%s': %w", filename, err) @@ -1096,8 +1121,16 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { fileBookmarks = fb } - if len(fileBookmarks) > 0 { - finalBookmarks = append(finalBookmarks, shiftBookmarks(fileBookmarks, offset)...) + fileBookmarks = shiftBookmarks(fileBookmarks, offset) + + if titleBookmarks { + finalBookmarks = append(finalBookmarks, gotenberg.Bookmark{ + Title: documentTitle(ctx, engine, inputPath, filename), + Page: offset + 1, + Children: fileBookmarks, + }) + } else if len(fileBookmarks) > 0 { + finalBookmarks = append(finalBookmarks, fileBookmarks...) } pageCount, err := engine.PageCount(ctx, ctx.Log(), inputPath) diff --git a/test/integration/features/pdfengines_merge.feature b/test/integration/features/pdfengines_merge.feature index 1c8dd51c..fb39a455 100644 --- a/test/integration/features/pdfengines_merge.feature +++ b/test/integration/features/pdfengines_merge.feature @@ -291,6 +291,49 @@ Feature: /forms/pdfengines/merge } """ + # titleBookmarks adds a top-level bookmark per merged document, labeled by its + # Title metadata (falling back to the filename) and pointing to its first page, + # with the document's own outline nested underneath. + # See https://github.com/gotenberg/gotenberg/issues/867. + @bookmarks + Scenario: POST /forms/pdfengines/merge (Title 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/titled_alpha.pdf | file | + | files | testdata/titled_bravo.pdf | file | + | files | testdata/untitled_gamma.pdf | file | + | titleBookmarks | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s): + | files | teststore/foo.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "foo.pdf": [ + { + "title": "Alpha", + "page": 1, + "children": [ + { "title": "A1", "page": 1 }, + { "title": "A2", "page": 2 } + ] + }, + { + "title": "Bravo", + "page": 3 + }, + { + "title": "untitled_gamma", + "page": 4 + } + ] + } + """ + @bookmarks Scenario: POST /forms/pdfengines/merge (Auto-index Bookmarks) Given I have a default Gotenberg container diff --git a/test/integration/testdata/titled_alpha.pdf b/test/integration/testdata/titled_alpha.pdf new file mode 100644 index 0000000000000000000000000000000000000000..2c043a6011de99a2fe60c0bb5d86a04928258ba6 GIT binary patch literal 11519 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 Qw+aLS