feat(pdfengines): generate document-title bookmarks when merging (#867)

This commit is contained in:
Julien Neuhart
2026-08-14 12:36:13 +02:00
parent b3c06fb8ae
commit 65e5699b71
6 changed files with 81 additions and 4 deletions

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.