mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-17 04:32:15 +01:00
feat(pdfengines): generate document-title bookmarks when merging (#867)
This commit is contained in:
@@ -15,6 +15,7 @@ body:multipart-form {
|
|||||||
files: @file(../../test/integration/testdata/page_2.pdf)
|
files: @file(../../test/integration/testdata/page_2.pdf)
|
||||||
~flatten: false
|
~flatten: false
|
||||||
~autoIndexBookmarks: false
|
~autoIndexBookmarks: false
|
||||||
|
~titleBookmarks: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
~optimizeImages: false
|
~optimizeImages: false
|
||||||
|
|||||||
@@ -442,6 +442,27 @@ func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata ma
|
|||||||
return nil
|
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 {
|
func shiftBookmarks(bookmarks []gotenberg.Bookmark, offset int) []gotenberg.Bookmark {
|
||||||
if offset == 0 {
|
if offset == 0 {
|
||||||
return bookmarks
|
return bookmarks
|
||||||
@@ -995,10 +1016,12 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
var flatten bool
|
var flatten bool
|
||||||
var autoIndexBookmarks bool
|
var autoIndexBookmarks bool
|
||||||
|
var titleBookmarks bool
|
||||||
err := form.
|
err := form.
|
||||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||||
Bool("flatten", &flatten, false).
|
Bool("flatten", &flatten, false).
|
||||||
Bool("autoIndexBookmarks", &autoIndexBookmarks, false).
|
Bool("autoIndexBookmarks", &autoIndexBookmarks, false).
|
||||||
|
Bool("titleBookmarks", &titleBookmarks, false).
|
||||||
Validate()
|
Validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
@@ -1078,7 +1101,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
finalBookmarks = b
|
finalBookmarks = b
|
||||||
} else {
|
} else {
|
||||||
bMap, _ := bookmarks.(map[string][]gotenberg.Bookmark)
|
bMap, _ := bookmarks.(map[string][]gotenberg.Bookmark)
|
||||||
if bMap != nil || autoIndexBookmarks {
|
if bMap != nil || autoIndexBookmarks || titleBookmarks {
|
||||||
offset := 0
|
offset := 0
|
||||||
for _, inputPath := range inputPaths {
|
for _, inputPath := range inputPaths {
|
||||||
filename := ctx.OriginalFilename(inputPath)
|
filename := ctx.OriginalFilename(inputPath)
|
||||||
@@ -1088,7 +1111,9 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
fileBookmarks = bMap[filename]
|
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)
|
fb, err := engine.ReadBookmarks(ctx, ctx.Log(), inputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read bookmarks of '%s': %w", filename, err)
|
return fmt.Errorf("read bookmarks of '%s': %w", filename, err)
|
||||||
@@ -1096,8 +1121,16 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
fileBookmarks = fb
|
fileBookmarks = fb
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(fileBookmarks) > 0 {
|
fileBookmarks = shiftBookmarks(fileBookmarks, offset)
|
||||||
finalBookmarks = append(finalBookmarks, 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)
|
pageCount, err := engine.PageCount(ctx, ctx.Log(), inputPath)
|
||||||
|
|||||||
@@ -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
|
@bookmarks
|
||||||
Scenario: POST /forms/pdfengines/merge (Auto-index Bookmarks)
|
Scenario: POST /forms/pdfengines/merge (Auto-index Bookmarks)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
|
|||||||
BIN
test/integration/testdata/titled_alpha.pdf
vendored
Normal file
BIN
test/integration/testdata/titled_alpha.pdf
vendored
Normal file
Binary file not shown.
BIN
test/integration/testdata/titled_bravo.pdf
vendored
Normal file
BIN
test/integration/testdata/titled_bravo.pdf
vendored
Normal file
Binary file not shown.
BIN
test/integration/testdata/untitled_gamma.pdf
vendored
Normal file
BIN
test/integration/testdata/untitled_gamma.pdf
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user