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 00000000..2c043a60 Binary files /dev/null and b/test/integration/testdata/titled_alpha.pdf differ diff --git a/test/integration/testdata/titled_bravo.pdf b/test/integration/testdata/titled_bravo.pdf new file mode 100644 index 00000000..5fda0b5c Binary files /dev/null and b/test/integration/testdata/titled_bravo.pdf differ diff --git a/test/integration/testdata/untitled_gamma.pdf b/test/integration/testdata/untitled_gamma.pdf new file mode 100644 index 00000000..c41ff06d Binary files /dev/null and b/test/integration/testdata/untitled_gamma.pdf differ