From 59f96358c4811a4b10f5539f1128c31b8e3ba2fd Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Fri, 6 Mar 2026 13:51:51 +0100 Subject: [PATCH] feat(pdfengines): new form field autoIndexBookmarks for the merge route --- pkg/modules/pdfengines/routes.go | 41 +++++++--- .../features/pdfengines_merge.feature | 71 ++++++++++++++++++ .../testdata/page_1_with_bookmarks.pdf | Bin 0 -> 4576 bytes .../testdata/page_2_with_bookmarks.pdf | Bin 0 -> 4911 bytes 4 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 test/integration/testdata/page_1_with_bookmarks.pdf create mode 100644 test/integration/testdata/page_2_with_bookmarks.pdf diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index e5f01f07..3ca9acb3 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -409,9 +409,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { var inputPaths []string var flatten bool + var autoIndexBookmarks bool err := form. MandatoryPaths([]string{".pdf"}, &inputPaths). Bool("flatten", &flatten, false). + Bool("autoIndexBookmarks", &autoIndexBookmarks, false). Validate() if err != nil { return fmt.Errorf("validate form data: %w", err) @@ -436,19 +438,36 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { var finalBookmarks []gotenberg.Bookmark if b, ok := bookmarks.([]gotenberg.Bookmark); ok { finalBookmarks = b - } else if b, ok := bookmarks.(map[string][]gotenberg.Bookmark); ok { - offset := 0 - for _, inputPath := range inputPaths { - filename := filepath.Base(inputPath) - if fileBookmarks, ok := b[filename]; ok { - finalBookmarks = append(finalBookmarks, shiftBookmarks(fileBookmarks, offset)...) - } + } else { + bMap, _ := bookmarks.(map[string][]gotenberg.Bookmark) + if bMap != nil || autoIndexBookmarks { + offset := 0 + for _, inputPath := range inputPaths { + filename := filepath.Base(inputPath) - pageCount, err := engine.PageCount(ctx, ctx.Log(), inputPath) - if err != nil { - return fmt.Errorf("get page count of '%s': %w", filename, err) + var fileBookmarks []gotenberg.Bookmark + if bMap != nil { + fileBookmarks = bMap[filename] + } + + if len(fileBookmarks) == 0 && autoIndexBookmarks { + fb, err := engine.ReadBookmarks(ctx, ctx.Log(), inputPath) + if err != nil { + return fmt.Errorf("read bookmarks of '%s': %w", filename, err) + } + fileBookmarks = fb + } + + if len(fileBookmarks) > 0 { + finalBookmarks = append(finalBookmarks, shiftBookmarks(fileBookmarks, offset)...) + } + + pageCount, err := engine.PageCount(ctx, ctx.Log(), inputPath) + if err != nil { + return fmt.Errorf("get page count of '%s': %w", filename, err) + } + offset += pageCount } - offset += pageCount } } diff --git a/test/integration/features/pdfengines_merge.feature b/test/integration/features/pdfengines_merge.feature index 1c1676c7..1a02f85c 100644 --- a/test/integration/features/pdfengines_merge.feature +++ b/test/integration/features/pdfengines_merge.feature @@ -140,6 +140,16 @@ Feature: /forms/pdfengines/merge """ Invalid form data: form field 'bookmarks' is invalid (got 'foo', resulting to unmarshal bookmarks: invalid character 'o' in literal false (expecting 'a')) """ + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | autoIndexBookmarks | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'autoIndexBookmarks' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax) + """ @convert Scenario: POST /forms/pdfengines/merge (PDF/A-1b & PDF/UA-1) @@ -281,6 +291,67 @@ Feature: /forms/pdfengines/merge } """ + @bookmarks + Scenario: POST /forms/pdfengines/merge (Auto-index 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/page_1_with_bookmarks.pdf | file | + | files | testdata/page_2_with_bookmarks.pdf | file | + | autoIndexBookmarks | 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": "Page 1", + "page": 1 + }, + { + "title": "Page 2", + "page": 2 + } + ] + } + """ + + @bookmarks + Scenario: POST /forms/pdfengines/merge (Auto-index Bookmarks + Bookmarks Map) + 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/page_1.pdf | file | + | files | testdata/page_2_with_bookmarks.pdf | file | + | bookmarks | {"page_1.pdf":[{"title":"Page 1 Index","page":1}]} | field | + | autoIndexBookmarks | 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": "Page 1 Index", + "page": 1 + }, + { + "title": "Page 2", + "page": 2 + } + ] + } + """ + @flatten Scenario: POST /forms/pdfengines/merge (Flatten) Given I have a default Gotenberg container diff --git a/test/integration/testdata/page_1_with_bookmarks.pdf b/test/integration/testdata/page_1_with_bookmarks.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ba40d0f85d5d729a1542e60d3ab7442c53a45efe GIT binary patch literal 4576 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 EKg4V5NB{r; literal 0 HcmV?d00001 diff --git a/test/integration/testdata/page_2_with_bookmarks.pdf b/test/integration/testdata/page_2_with_bookmarks.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4e213d7fcbad28c0af64f7a3da10081f99b4a811 GIT binary patch literal 4911 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 literal 0 HcmV?d00001