fix(pdfengines): correctly update the indexes if the bookmarks form field (map format) is given

This commit is contained in:
Julien Neuhart
2026-03-04 22:45:58 +01:00
parent caea81501d
commit 874e78c6cd
12 changed files with 257 additions and 24 deletions
+4
View File
@@ -104,8 +104,10 @@ Feature: /debug
"pdfengines-engines": "[]",
"pdfengines-flatten-engines": "[qpdf]",
"pdfengines-merge-engines": "[qpdf,pdfcpu,pdftk]",
"pdfengines-read-bookmarks-engines": "[pdfcpu]",
"pdfengines-read-metadata-engines": "[exiftool]",
"pdfengines-split-engines": "[pdfcpu,qpdf,pdftk]",
"pdfengines-write-bookmarks-engines": "[pdfcpu]",
"pdfengines-write-metadata-engines": "[exiftool]",
"prometheus-collect-interval": "1s",
"prometheus-disable-collect": "false",
@@ -224,8 +226,10 @@ Feature: /debug
"pdfengines-engines": "[]",
"pdfengines-flatten-engines": "[qpdf]",
"pdfengines-merge-engines": "[qpdf,pdfcpu,pdftk]",
"pdfengines-read-bookmarks-engines": "[pdfcpu]",
"pdfengines-read-metadata-engines": "[exiftool]",
"pdfengines-split-engines": "[pdfcpu,qpdf,pdftk]",
"pdfengines-write-bookmarks-engines": "[pdfcpu]",
"pdfengines-write-metadata-engines": "[exiftool]",
"prometheus-collect-interval": "1s",
"prometheus-disable-collect": "false",
@@ -1,5 +1,5 @@
@pdfengines
@pdfengines-encrypt
@pdfengines-merge
@merge
Feature: /forms/pdfengines/merge
@@ -130,6 +130,16 @@ Feature: /forms/pdfengines/merge
"""
Invalid form data: form field 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: 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 |
| bookmarks | 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 'bookmarks' is invalid (got 'foo', resulting to unmarshal bookmarks: invalid character 'o' in literal false (expecting 'a'))
"""
@convert
Scenario: POST /forms/pdfengines/merge (PDF/A-1b & PDF/UA-1)
@@ -203,6 +213,74 @@ Feature: /forms/pdfengines/merge
}
"""
@bookmarks
Scenario: POST /forms/pdfengines/merge (Bookmarks List)
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.pdf | file |
| bookmarks | [{"title":"Merged Index","page":1}] | 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": "Merged Index",
"page": 1
}
]
}
"""
@bookmarks
Scenario: POST /forms/pdfengines/merge (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.pdf | file |
| bookmarks | {"page_1.pdf":[{"title":"Page 1 Index","page":1,"children":[{"title":"Page 1 Sub-index","page":1}]}],"page_2.pdf":[{"title":"Page 2 Index","page":1,"children":[{"title":"Page 2 Sub-index","page":1}]}]} | 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,
"children": [
{
"title": "Page 1 Sub-index",
"page": 1
}
]
},
{
"title": "Page 2 Index",
"page": 2,
"children": [
{
"title": "Page 2 Sub-index",
"page": 2
}
]
}
]
}
"""
@flatten
Scenario: POST /forms/pdfengines/merge (Flatten)
Given I have a default Gotenberg container
@@ -270,7 +348,8 @@ Feature: /forms/pdfengines/merge
@metadata
@flatten
@embed
Scenario: POST /forms/pdfengines/merge (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds)
@bookmarks
Scenario: POST /forms/pdfengines/merge (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds & 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.pdf | file |
@@ -278,6 +357,7 @@ Feature: /forms/pdfengines/merge
| pdfa | PDF/A-1b | field |
| pdfua | true | field |
| metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field |
| bookmarks | [{"title":"Merged Index","page":1}] | field |
| flatten | true | field |
| embeds | testdata/embed_1.xml | file |
| embeds | testdata/embed_2.xml | file |
@@ -301,6 +381,21 @@ Feature: /forms/pdfengines/merge
Then the response PDF(s) should be flatten
Then the response PDF(s) should have the "embed_1.xml" file embedded
Then the response PDF(s) should have the "embed_2.xml" file embedded
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": "Merged Index",
"page": 1
}
]
}
"""
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
+26 -14
View File
@@ -27,6 +27,7 @@ type scenario struct {
resp *httptest.ResponseRecorder
concurrentResps []*httptest.ResponseRecorder
workdir string
teststoreDir string
gotenbergContainer testcontainers.Container
gotenbergContainerNetwork *testcontainers.DockerNetwork
server *server
@@ -163,12 +164,14 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
fields[name] = value
case "file":
if strings.Contains(value, "teststore") {
dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
return fmt.Errorf("directory %q does not exist", dirPath)
if s.teststoreDir == "" {
return errors.New("no teststore directory available from previous requests")
}
value = strings.ReplaceAll(value, "teststore", dirPath)
_, err := os.Stat(s.teststoreDir)
if os.IsNotExist(err) {
return fmt.Errorf("directory %q does not exist", s.teststoreDir)
}
value = strings.ReplaceAll(value, "teststore", s.teststoreDir)
} else {
wd, err := os.Getwd()
if err != nil {
@@ -216,6 +219,13 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
return fmt.Errorf("write response body: %w", err)
}
if resp.StatusCode == http.StatusNoContent {
// Gotenberg processes this asynchronously. The webhook test server
// will save the incoming files under this trace ID directory shortly.
s.teststoreDir = fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
return nil
}
if resp.StatusCode != http.StatusOK {
return nil
}
@@ -241,6 +251,8 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
return fmt.Errorf("create working directory: %w", err)
}
s.teststoreDir = dirPath
fpath := fmt.Sprintf("%s/%s", dirPath, filename)
file, err := os.Create(fpath)
if err != nil {
@@ -637,7 +649,7 @@ func (s *scenario) theBodyShouldMatchJSON(kind string, expectedDoc *godog.DocStr
}
func (s *scenario) thereShouldBePdfs(expected int, kind string) error {
dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
dirPath := s.teststoreDir
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
@@ -666,7 +678,7 @@ func (s *scenario) thereShouldBePdfs(expected int, kind string) error {
}
func (s *scenario) thereShouldBeTheFollowingFiles(kind string, filesTable *godog.Table) error {
dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
dirPath := s.teststoreDir
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
@@ -708,7 +720,7 @@ func (s *scenario) thereShouldBeTheFollowingFiles(kind string, filesTable *godog
}
func (s *scenario) thePdfsShouldBeValidWithAToleranceOf(ctx context.Context, kind, validate string, tolerance int) error {
dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
dirPath := s.teststoreDir
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
@@ -788,7 +800,7 @@ func (s *scenario) thePdfShouldHavePages(ctx context.Context, name string, pages
}
} else {
substr := strings.ReplaceAll(name, "*_", "")
err := filepath.Walk(fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")), func(currentPath string, info os.FileInfo, pathErr error) error {
err := filepath.Walk(s.teststoreDir, func(currentPath string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
@@ -844,7 +856,7 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
}
} else {
substr := strings.ReplaceAll(name, "*_", "")
err := filepath.Walk(fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")), func(currentPath string, info os.FileInfo, pathErr error) error {
err := filepath.Walk(s.teststoreDir, func(currentPath string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
@@ -911,7 +923,7 @@ func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context
}
} else {
substr := strings.ReplaceAll(name, "*_", "")
err := filepath.Walk(fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")), func(currentPath string, info os.FileInfo, pathErr error) error {
err := filepath.Walk(s.teststoreDir, func(currentPath string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
@@ -955,7 +967,7 @@ func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context
}
func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should string) error {
dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
dirPath := s.teststoreDir
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
@@ -1005,7 +1017,7 @@ func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should stri
}
func (s *scenario) thePdfsShouldBeEncrypted(ctx context.Context, kind string, should string) error {
dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
dirPath := s.teststoreDir
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
@@ -1057,7 +1069,7 @@ func (s *scenario) thePdfsShouldBeEncrypted(ctx context.Context, kind string, sh
}
func (s *scenario) thePdfsShouldHaveEmbeddedFile(ctx context.Context, kind, should, embed string) error {
dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
dirPath := s.teststoreDir
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {