feat(pdfengines): add embed feature

This commit is contained in:
Hubert Lenoir
2025-11-05 13:57:23 +01:00
committed by GitHub
parent f4d3fba306
commit a0ee800002
26 changed files with 479 additions and 17 deletions

View File

@@ -63,6 +63,7 @@ PDFENGINES_READ_METADATA_ENGINES=exiftool
PDFENGINES_WRITE_METADATA_ENGINES=exiftool PDFENGINES_WRITE_METADATA_ENGINES=exiftool
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_DISABLE_ROUTES=false PDFENGINES_DISABLE_ROUTES=false
PDFENGINES_EMBED_ENGINES=pdfcpu
PROMETHEUS_NAMESPACE=gotenberg PROMETHEUS_NAMESPACE=gotenberg
PROMETHEUS_COLLECT_INTERVAL=1s PROMETHEUS_COLLECT_INTERVAL=1s
PROMETHEUS_DISABLE_ROUTE_LOGGING=false PROMETHEUS_DISABLE_ROUTE_LOGGING=false
@@ -137,6 +138,7 @@ run: ## Start a Gotenberg container
--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \ --pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
--pdfengines-encrypt-engines=$(PDFENGINES_ENCRYPT_ENGINES) \ --pdfengines-encrypt-engines=$(PDFENGINES_ENCRYPT_ENGINES) \
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \ --pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
--pdfengines-embed-engines=$(PDFENGINES_EMBED_ENGINES) \
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \ --prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \ --prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \ --prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \

View File

@@ -53,6 +53,7 @@ type PdfEngineMock struct {
ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error
EmbedFilesMock func(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error
} }
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
@@ -83,6 +84,10 @@ func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *zap.Logger, in
return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword) return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword)
} }
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
return engine.EmbedFilesMock(ctx, logger, filePaths, inputPath)
}
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface. // PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
type PdfEngineProviderMock struct { type PdfEngineProviderMock struct {
PdfEngineMock func() (PdfEngine, error) PdfEngineMock func() (PdfEngine, error)

View File

@@ -143,6 +143,10 @@ type PdfEngine interface {
// The ownerPassword provides full access to the document. // The ownerPassword provides full access to the document.
// If the ownerPassword is empty, it defaults to the userPassword. // If the ownerPassword is empty, it defaults to the userPassword.
Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
// without modifying the main PDF content.
EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error
} }
// PdfEngineProvider offers an interface to instantiate a [PdfEngine]. // PdfEngineProvider offers an interface to instantiate a [PdfEngine].

View File

@@ -38,11 +38,12 @@ var (
// Context is the request context for a "multipart/form-data" requests. // Context is the request context for a "multipart/form-data" requests.
type Context struct { type Context struct {
dirPath string dirPath string
values map[string][]string values map[string][]string
files map[string]string files map[string]string
outputPaths []string filesByField map[string][]string
cancelled bool outputPaths []string
cancelled bool
logger *zap.Logger logger *zap.Logger
echoCtx echo.Context echoCtx echo.Context
@@ -79,6 +80,9 @@ type downloadFrom struct {
// ExtraHttpHeaders are the HTTP headers to send alongside. // ExtraHttpHeaders are the HTTP headers to send alongside.
ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"` ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"`
// Download as embed file
Embedded bool `json:"embedded"`
} }
// newContext returns a [Context] by parsing a "multipart/form-data" request. // newContext returns a [Context] by parsing a "multipart/form-data" request.
@@ -184,6 +188,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
ctx.dirPath = dirPath ctx.dirPath = dirPath
ctx.values = form.Value ctx.values = form.Value
ctx.files = make(map[string]string) ctx.files = make(map[string]string)
ctx.filesByField = make(map[string][]string)
// First, try to download files listed in the "downloadFrom" form field, if // First, try to download files listed in the "downloadFrom" form field, if
// any. // any.
@@ -318,6 +323,9 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
} }
ctx.files[filename] = path ctx.files[filename] = path
if dl.Embedded {
ctx.filesByField[EmbedsFormField] = append(ctx.filesByField[EmbedsFormField], path)
}
return nil return nil
}) })
@@ -373,17 +381,22 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
} }
// Then, copy the form files, if any. // Then, copy the form files, if any.
for _, files := range form.File { for fieldName, files := range form.File {
for _, fh := range files { for _, fh := range files {
err = copyToDisk(fh) err = copyToDisk(fh)
if err != nil { if err != nil {
return ctx, cancel, fmt.Errorf("copy to disk: %w", err) return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
} }
// Track files by field name
filename := norm.NFC.String(filepath.Base(fh.Filename))
filePath := ctx.files[filename]
ctx.filesByField[fieldName] = append(ctx.filesByField[fieldName], filePath)
} }
} }
ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values)) ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values))
ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files)) ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files))
ctx.Log().Debug(fmt.Sprintf("form files by field: %+v", ctx.filesByField))
ctx.Log().Debug(fmt.Sprintf("total bytes: %d", totalBytesRead.Load())) ctx.Log().Debug(fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
return ctx, cancel, err return ctx, cancel, err
@@ -397,9 +410,10 @@ func (ctx *Context) Request() *http.Request {
// FormData return a [FormData]. // FormData return a [FormData].
func (ctx *Context) FormData() *FormData { func (ctx *Context) FormData() *FormData {
return &FormData{ return &FormData{
values: ctx.values, values: ctx.values,
files: ctx.files, files: ctx.files,
errors: nil, filesByField: ctx.filesByField,
errors: nil,
} }
} }

View File

@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -16,14 +17,20 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
// EmbedsFormField represents the form field name for embedding files.
const (
EmbedsFormField string = "embeds"
)
// FormData is a helper for validating and hydrating values from a // FormData is a helper for validating and hydrating values from a
// "multipart/form-data" request. // "multipart/form-data" request.
// //
// form := ctx.FormData() // form := ctx.FormData()
type FormData struct { type FormData struct {
values map[string][]string values map[string][]string
files map[string]string files map[string]string
errors error filesByField map[string][]string
errors error
} }
// Validate returns nil or an error related to the [FormData] values, with a // Validate returns nil or an error related to the [FormData] values, with a
@@ -358,6 +365,26 @@ func (form *FormData) Paths(extensions []string, target *[]string) *FormData {
return form.paths(extensions, target) return form.paths(extensions, target)
} }
// Embeds binds the absolute paths of form data files that should be
// embedded in the PDF. Only files uploaded with the "embeds" field name
// will be included.
//
// var embeds []string
//
// ctx.FormData().Embeds(&embeds)
func (form *FormData) Embeds(target *[]string) *FormData {
if form.errors != nil {
return form
}
// Get files from the "embeds" field
if paths, ok := form.filesByField[EmbedsFormField]; ok {
*target = append(*target, paths...)
}
return form
}
// MandatoryPaths binds the absolute paths of form data files, according to a // MandatoryPaths binds the absolute paths of form data files, according to a
// list of file extensions, to a string slice variable. It populates an error // list of file extensions, to a string slice variable. It populates an error
// if there is no file for given file extensions. // if there is no file for given file extensions.
@@ -381,8 +408,15 @@ func (form *FormData) MandatoryPaths(extensions []string, target *[]string) *For
// paths bind the absolute paths of form data files, according to a list of // paths bind the absolute paths of form data files, according to a list of
// file extensions, to a string slice variable. // file extensions, to a string slice variable.
// embeds are excluded.
func (form *FormData) paths(extensions []string, target *[]string) *FormData { func (form *FormData) paths(extensions []string, target *[]string) *FormData {
embeds, ok := form.filesByField[EmbedsFormField]
for filename, path := range form.files { for filename, path := range form.files {
if ok && slices.Contains(embeds, path) {
continue
}
for _, ext := range extensions { for _, ext := range extensions {
// See https://github.com/gotenberg/gotenberg/issues/228. // See https://github.com/gotenberg/gotenberg/issues/228.
if strings.ToLower(filepath.Ext(filename)) == ext { if strings.ToLower(filepath.Ext(filename)) == ext {

View File

@@ -1612,6 +1612,24 @@ func TestFormData_Paths(t *testing.T) {
}, },
expectCount: 2, expectCount: 2,
}, },
{
scenario: "files except embeds",
form: &FormData{
files: map[string]string{
"foo.pdf": "/foo.pdf",
"embed_1.pdf": "/embed_1.pdf",
"embed_2.xml": "/embed_2.xml",
},
filesByField: map[string][]string{
"embeds": {"/embed_1.pdf", "/embed_2.xml"},
},
},
extensions: []string{".pdf"},
expect: []string{
"/foo.pdf",
},
expectCount: 1,
},
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
var actual []string var actual []string
@@ -1740,3 +1758,28 @@ func TestFormData_mustAssign(t *testing.T) {
var target []string var target []string
form.mustAssign("foo", "foo", &target) form.mustAssign("foo", "foo", &target)
} }
func TestFormData_Embeds(t *testing.T) {
expected := []string{"/bar.xml", "/baz.xml"}
var actual []string
form := &FormData{
files: map[string]string{
"foo.pdf": "/foo.pdf",
"bar.xml": "/bar.xml",
"baz.xml": "/baz.xml",
},
filesByField: map[string][]string{
"embeds": {"/bar.xml", "/baz.xml"},
},
}
form.Embeds(&actual)
if len(actual) != len(expected) {
t.Errorf("expected %d embeds but got %d", len(expected), len(actual))
}
if !reflect.DeepEqual(actual, expected) {
t.Errorf("expected %v but got %v", expected, actual)
}
}

View File

@@ -361,6 +361,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
pdfFormats := pdfengines.FormDataPdfFormats(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false) metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
var url string var url string
err := form. err := form.
@@ -370,7 +371,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword) err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths)
if err != nil { if err != nil {
return fmt.Errorf("convert URL to PDF: %w", err) return fmt.Errorf("convert URL to PDF: %w", err)
} }
@@ -423,6 +424,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
pdfFormats := pdfengines.FormDataPdfFormats(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false) metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
var inputPath string var inputPath string
err := form. err := form.
@@ -433,7 +435,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
} }
url := fmt.Sprintf("file://%s", inputPath) url := fmt.Sprintf("file://%s", inputPath)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword) err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths)
if err != nil { if err != nil {
return fmt.Errorf("convert HTML to PDF: %w", err) return fmt.Errorf("convert HTML to PDF: %w", err)
} }
@@ -487,6 +489,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
pdfFormats := pdfengines.FormDataPdfFormats(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false) metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
var ( var (
inputPath string inputPath string
@@ -506,7 +509,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("transform markdown file(s) to HTML: %w", err) return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
} }
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword) err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths)
if err != nil { if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err) return fmt.Errorf("convert markdown to PDF: %w", err)
} }
@@ -630,7 +633,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
return fmt.Sprintf("file://%s", inputPath), nil return fmt.Sprintf("file://%s", inputPath), nil
} }
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}, userPassword, ownerPassword string) error { func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}, userPassword, ownerPassword string, embedPaths []string) error {
outputPath := ctx.GeneratePath(".pdf") outputPath := ctx.GeneratePath(".pdf")
// See https://github.com/gotenberg/gotenberg/issues/1130. // See https://github.com/gotenberg/gotenberg/issues/1130.
filename := ctx.OutputFilename(outputPath) filename := ctx.OutputFilename(outputPath)
@@ -687,6 +690,11 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("write metadata: %w", err) return fmt.Errorf("write metadata: %w", err)
} }
err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths) err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
if err != nil { if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err) return fmt.Errorf("encrypt PDFs: %w", err)

View File

@@ -181,6 +181,11 @@ func (engine *ExifTool) Encrypt(ctx context.Context, logger *zap.Logger, inputPa
return fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported) return fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported)
} }
// EmbedFiles is not available in this implementation.
func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
return fmt.Errorf("embed files with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Interface guards. // Interface guards.
var ( var (
_ gotenberg.Module = (*ExifTool)(nil) _ gotenberg.Module = (*ExifTool)(nil)

View File

@@ -96,6 +96,11 @@ func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *zap.Log
return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// EmbedFiles is not available in this implementation.
func (engine *LibreOfficePdfEngine) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
return fmt.Errorf("embed files with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Interface guards. // Interface guards.
var ( var (
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil) _ gotenberg.Module = (*LibreOfficePdfEngine)(nil)

View File

@@ -31,6 +31,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
pdfFormats := pdfengines.FormDataPdfFormats(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false) metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
zeroValuedSplitMode := gotenberg.SplitMode{} zeroValuedSplitMode := gotenberg.SplitMode{}
@@ -264,6 +265,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
} }
} }
err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, outputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths) err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
if err != nil { if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err) return fmt.Errorf("encrypt PDFs: %w", err)

View File

@@ -171,6 +171,34 @@ func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, met
return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
// without modifying the main PDF content.
func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
if len(filePaths) == 0 {
return nil
}
logger.Debug(fmt.Sprintf("embedding %d file(s) to %s: %v", len(filePaths), inputPath, filePaths))
args := []string{
"attachments", "add",
inputPath,
}
args = append(args, filePaths...)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command for attaching files: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("attach files with pdfcpu: %w", err)
}
return nil
}
// Encrypt adds password protection to a PDF file using pdfcpu. // Encrypt adds password protection to a PDF file using pdfcpu.
func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
if userPassword == "" { if userPassword == "" {

View File

@@ -19,6 +19,7 @@ type multiPdfEngines struct {
readMetadataEngines []gotenberg.PdfEngine readMetadataEngines []gotenberg.PdfEngine
writeMetadataEngines []gotenberg.PdfEngine writeMetadataEngines []gotenberg.PdfEngine
passwordEngines []gotenberg.PdfEngine passwordEngines []gotenberg.PdfEngine
embedEngines []gotenberg.PdfEngine
} }
func newMultiPdfEngines( func newMultiPdfEngines(
@@ -28,7 +29,8 @@ func newMultiPdfEngines(
convertEngines, convertEngines,
readMetadataEngines, readMetadataEngines,
writeMetadataEngines, writeMetadataEngines,
passwordEngines []gotenberg.PdfEngine, passwordEngines,
embedEngines []gotenberg.PdfEngine,
) *multiPdfEngines { ) *multiPdfEngines {
return &multiPdfEngines{ return &multiPdfEngines{
mergeEngines: mergeEngines, mergeEngines: mergeEngines,
@@ -38,6 +40,7 @@ func newMultiPdfEngines(
readMetadataEngines: readMetadataEngines, readMetadataEngines: readMetadataEngines,
writeMetadataEngines: writeMetadataEngines, writeMetadataEngines: writeMetadataEngines,
passwordEngines: passwordEngines, passwordEngines: passwordEngines,
embedEngines: embedEngines,
} }
} }
@@ -238,6 +241,31 @@ func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, i
return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err) return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err)
} }
// EmbedFiles embeds files into a PDF using the first available
// engine that supports file embedding.
func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
var err error
errChan := make(chan error, 1)
for _, engine := range multi.embedEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.EmbedFiles(ctx, logger, filePaths, inputPath)
}(engine)
select {
case embedErr := <-errChan:
errored := multierr.AppendInto(&err, embedErr)
if !errored {
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("embed files into PDF using multi PDF engines: %w", err)
}
// Interface guards. // Interface guards.
var ( var (
_ gotenberg.PdfEngine = (*multiPdfEngines)(nil) _ gotenberg.PdfEngine = (*multiPdfEngines)(nil)

View File

@@ -34,6 +34,7 @@ type PdfEngines struct {
readMetadataNames []string readMetadataNames []string
writeMetadataNames []string writeMetadataNames []string
encryptNames []string encryptNames []string
embedNames []string
engines []gotenberg.PdfEngine engines []gotenberg.PdfEngine
disableRoutes bool disableRoutes bool
} }
@@ -51,6 +52,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all") fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all") fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all") fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all")
fs.StringSlice("pdfengines-embed-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the file embedding feature - empty means all")
fs.Bool("pdfengines-disable-routes", false, "Disable the routes") fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
// Deprecated flags. // Deprecated flags.
@@ -77,6 +79,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines") readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines") writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines") encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
embedNames := flags.MustStringSlice("pdfengines-embed-engines")
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes") mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
engines, err := ctx.Modules(new(gotenberg.PdfEngine)) engines, err := ctx.Modules(new(gotenberg.PdfEngine))
@@ -138,6 +141,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.encryptNames = encryptNames mod.encryptNames = encryptNames
} }
mod.embedNames = defaultNames
if len(embedNames) > 0 {
mod.embedNames = embedNames
}
return nil return nil
} }
@@ -192,6 +200,7 @@ func (mod *PdfEngines) Validate() error {
findNonExistingEngines(mod.readMetadataNames) findNonExistingEngines(mod.readMetadataNames)
findNonExistingEngines(mod.writeMetadataNames) findNonExistingEngines(mod.writeMetadataNames)
findNonExistingEngines(mod.encryptNames) findNonExistingEngines(mod.encryptNames)
findNonExistingEngines(mod.embedNames)
if len(nonExistingEngines) == 0 { if len(nonExistingEngines) == 0 {
return nil return nil
@@ -238,6 +247,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
engines(mod.readMetadataNames), engines(mod.readMetadataNames),
engines(mod.writeMetadataNames), engines(mod.writeMetadataNames),
engines(mod.encryptNames), engines(mod.encryptNames),
engines(mod.embedNames),
), nil ), nil
} }
@@ -262,6 +272,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
readMetadataRoute(engine), readMetadataRoute(engine),
writeMetadataRoute(engine), writeMetadataRoute(engine),
encryptRoute(engine), encryptRoute(engine),
embedRoute(engine),
}, nil }, nil
} }

View File

@@ -254,6 +254,14 @@ func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata ma
return nil return nil
} }
// FormDataPdfEmbeds extracts embedded file paths from form data.
// Only files uploaded with the "embeds" field name are included.
func FormDataPdfEmbeds(form *api.FormData) []string {
var embedPaths []string
form.Embeds(&embedPaths)
return embedPaths
}
// FormDataPdfEncrypt extracts encryption parameters from form data. // FormDataPdfEncrypt extracts encryption parameters from form data.
func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) { func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) {
form.String("userPassword", &userPassword, "") form.String("userPassword", &userPassword, "")
@@ -277,6 +285,22 @@ func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword,
return nil return nil
} }
// EmbedFilesStub embeds files into PDF files.
func EmbedFilesStub(ctx *api.Context, engine gotenberg.PdfEngine, embedPaths []string, inputPaths []string) error {
if len(embedPaths) == 0 {
return nil
}
for _, inputPath := range inputPaths {
err := engine.EmbedFiles(ctx, ctx.Log(), embedPaths, inputPath)
if err != nil {
return fmt.Errorf("embed files into PDF '%s': %w", inputPath, err)
}
}
return nil
}
// mergeRoute returns an [api.Route] which can merge PDFs. // mergeRoute returns an [api.Route] which can merge PDFs.
func mergeRoute(engine gotenberg.PdfEngine) api.Route { func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{ return api.Route{
@@ -290,6 +314,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
pdfFormats := FormDataPdfFormats(form) pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false) metadata := FormDataPdfMetadata(form, false)
userPassword, ownerPassword := FormDataPdfEncrypt(form) userPassword, ownerPassword := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form)
var inputPaths []string var inputPaths []string
var flatten bool var flatten bool
@@ -324,6 +349,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
} }
} }
err = EmbedFilesStub(ctx, engine, embedPaths, outputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths) err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
if err != nil { if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err) return fmt.Errorf("encrypt PDFs: %w", err)
@@ -353,6 +383,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
pdfFormats := FormDataPdfFormats(form) pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false) metadata := FormDataPdfMetadata(form, false)
userPassword, ownerPassword := FormDataPdfEncrypt(form) userPassword, ownerPassword := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form)
var inputPaths []string var inputPaths []string
var flatten bool var flatten bool
@@ -386,6 +417,11 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
} }
} }
err = EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths) err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
if err != nil { if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err) return fmt.Errorf("encrypt PDFs: %w", err)
@@ -620,3 +656,38 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route {
}, },
} }
} }
// embedRoute returns an [api.Route] which can add embedded files to PDFs.
func embedRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/embed",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
embedPaths := FormDataPdfEmbeds(form)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = EmbedFilesStub(ctx, engine, embedPaths, inputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}

View File

@@ -183,6 +183,11 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath,
return nil return nil
} }
// EmbedFiles is not available in this implementation.
func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
return fmt.Errorf("embed files with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Interface guards. // Interface guards.
var ( var (
_ gotenberg.Module = (*PdfTk)(nil) _ gotenberg.Module = (*PdfTk)(nil)

View File

@@ -201,6 +201,11 @@ func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath,
return nil return nil
} }
// EmbedFiles is not available in this implementation.
func (engine *QPdf) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
return fmt.Errorf("embed files with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
var ( var (
_ gotenberg.Module = (*QPdf)(nil) _ gotenberg.Module = (*QPdf)(nil)
_ gotenberg.Provisioner = (*QPdf)(nil) _ gotenberg.Provisioner = (*QPdf)(nil)

View File

@@ -942,3 +942,19 @@ Feature: /forms/chromium/convert/html
| files | testdata/page-1-html/index.html | file | | files | testdata/page-1-html/index.html | file |
Then the response status code should be 200 Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
@embed
Scenario: POST /forms/chromium/convert/html (Embeds)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| embeds | testdata/embed_1.xml | file |
| embeds | testdata/embed_2.xml | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
And the response header "Content-Type" should be "application/pdf"
And there should be 1 PDF(s) in the response
And there should be the following file(s) in the response:
| foo.pdf |
And the "foo.pdf" PDF should have the "embed_1.xml" file embedded in it
And the "foo.pdf" PDF should have the "embed_2.xml" file embedded in it

View File

@@ -1075,3 +1075,20 @@ Feature: /forms/chromium/convert/markdown
| files | testdata/page-1-markdown/page_1.md | file | | files | testdata/page-1-markdown/page_1.md | file |
Then the response status code should be 200 Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
@embed
Scenario: POST /forms/chromium/convert/markdown (Embeds)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| embeds | testdata/embed_1.xml | file |
| embeds | testdata/embed_2.xml | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
And the response header "Content-Type" should be "application/pdf"
And there should be 1 PDF(s) in the response
And there should be the following file(s) in the response:
| foo.pdf |
And the "foo.pdf" PDF should have the "embed_1.xml" file embedded in it
And the "foo.pdf" PDF should have the "embed_2.xml" file embedded in it

View File

@@ -1039,3 +1039,20 @@ Feature: /forms/chromium/convert/url
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
Then the response status code should be 200 Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
@embed
Scenario: POST /foo/forms/chromium/convert/url (Embeds)
Given I have a default Gotenberg container
And I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| embeds | testdata/embed_1.xml | file |
| embeds | testdata/embed_2.xml | file |
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
And the response header "Content-Type" should be "application/pdf"
And there should be 1 PDF(s) in the response
And there should be the following file(s) in the webhook request:
| foo.pdf |
And the "foo.pdf" PDF should have the "embed_1.xml" file embedded in it
And the "foo.pdf" PDF should have the "embed_2.xml" file embedded in it

View File

@@ -652,3 +652,19 @@ Feature: /forms/libreoffice/convert
| files | testdata/page_1.docx | file | | files | testdata/page_1.docx | file |
Then the response status code should be 200 Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
@embed
Scenario: POST /forms/libreoffice/convert (Embeds)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| embeds | testdata/embed_1.xml | file |
| embeds | testdata/embed_2.xml | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
And the response header "Content-Type" should be "application/pdf"
And there should be 1 PDF(s) in the response
And there should be the following file(s) in the response:
| foo.pdf |
And the "foo.pdf" PDF should have the "embed_1.xml" file embedded in it
And the "foo.pdf" PDF should have the "embed_2.xml" file embedded in it

View File

@@ -0,0 +1,32 @@
@embed
Feature: /forms/pdfengines/embed
Scenario: POST /forms/pdfengines/embed
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/embed" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| embeds | testdata/embed_1.xml | file |
| embeds | testdata/embed_2.xml | file |
| embeds | testdata/page_2.pdf | file |
Then the response status code should be 200
And the response header "Content-Type" should be "application/pdf"
And there should be 1 PDF(s) in the response
And there should be the following file(s) in the response:
| page_1.pdf |
And the "page_1.pdf" PDF should have the "embed_1.xml" file embedded in it
And the "page_1.pdf" PDF should have the "embed_2.xml" file embedded in it
And the "page_1.pdf" PDF should have the "page_2.pdf" file embedded in it
Scenario: POST /forms/pdfengines/embed with (Download From)
Given I have a default Gotenberg container
And I have a static server
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/embed" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/embed_1.xml", "embedded": true},{"url":"http://host.docker.internal:%d/static/testdata/embed_2.xml", "embedded": false}] | field |
Then the response status code should be 200
And the response header "Content-Type" should be "application/pdf"
And there should be 1 PDF(s) in the response
And there should be the following file(s) in the response:
| page_1.pdf |
And the "page_1.pdf" PDF should have the "embed_1.xml" file embedded in it
And the "page_1.pdf" PDF should NOT have the "embed_2.xml" file embedded in it

View File

@@ -368,3 +368,16 @@ Feature: /forms/pdfengines/merge
| files | testdata/page_2.pdf | file | | files | testdata/page_2.pdf | file |
Then the response status code should be 200 Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
@embed
Scenario: POST /foo/forms/pdfengines/merge (Embeds)
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 |
| embeds | testdata/embed_1.xml | file |
| embeds | testdata/embed_2.xml | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
And the "foo.pdf" PDF should have the "embed_1.xml" file embedded in it
And the "foo.pdf" PDF should have the "embed_2.xml" file embedded in it

View File

@@ -650,3 +650,23 @@ Feature: /forms/pdfengines/split
| splitSpan | 2 | field | | splitSpan | 2 | field |
Then the response status code should be 200 Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip" Then the response header "Content-Type" should be "application/zip"
@embed
Scenario: POST /foo/forms/pdfengines/split (Embeds)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| embeds | testdata/embed_1.xml | file |
| embeds | testdata/embed_2.xml | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
Then the response status code should be 200
And the response header "Content-Type" should be "application/zip"
And there should be 2 PDF(s) in the response
And there should be the following file(s) in the response:
| pages_3_0.pdf |
| pages_3_1.pdf |
And the "pages_3_0.pdf" PDF should have the "embed_1.xml" file embedded in it
And the "pages_3_0.pdf" PDF should have the "embed_2.xml" file embedded in it
And the "pages_3_1.pdf" PDF should have the "embed_1.xml" file embedded in it
And the "pages_3_1.pdf" PDF should have the "embed_2.xml" file embedded in it

View File

@@ -735,6 +735,41 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
return nil return nil
} }
func (s *scenario) thePdfShouldHaveTheFollowingEmbedsInIt(ctx context.Context, name, should string, embed string) error {
path, err := s.getPath(name)
if err != nil {
return fmt.Errorf("get path %q: %w", name, err)
}
invert := should == "should NOT"
cmd := []string{
"verapdf",
"--off",
"--loglevel",
"0",
"--extract",
"embeddedFile",
name,
}
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
if err != nil {
return fmt.Errorf("exec %q: %w", cmd, err)
}
found := strings.Contains(output, fmt.Sprintf("<fileName>%s</fileName>", embed))
if invert && found {
return fmt.Errorf("embed %q found", embed)
}
if !invert && !found {
return fmt.Errorf("embed %q not found", embed)
}
return nil
}
func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context, name, kind string, page int, expected *godog.DocString) error { func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context, name, kind string, page int, expected *godog.DocString) error {
var path string var path string
if !strings.HasPrefix(name, "*_") { if !strings.HasPrefix(name, "*_") {
@@ -927,6 +962,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages) ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages)
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation)
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the "([^"]*)" file embedded in it$`, s.thePdfShouldHaveTheFollowingEmbedsInIt)
ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) { ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
if s.gotenbergContainer != nil { if s.gotenbergContainer != nil {
errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0)) errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0))
@@ -950,3 +986,14 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
return ctx, nil return ctx, nil
}) })
} }
func (s *scenario) getPath(name string) (string, error) {
path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
_, err := os.Stat(path)
if os.IsNotExist(err) {
return "", fmt.Errorf("PDF %q does not exist", path)
}
return path, nil
}

5
test/integration/testdata/embed_1.xml vendored Normal file
View File

@@ -0,0 +1,5 @@
<xml>
<test>test 1.1</test>
<test>test 1.2</test>
<test>test 1.3</test>
</xml>

5
test/integration/testdata/embed_2.xml vendored Normal file
View File

@@ -0,0 +1,5 @@
<xml>
<test>test 2.1</test>
<test>test 2.2</test>
<test>test 2.3</test>
</xml>