---
-**Gotenberg** provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
+**Gotenberg** provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more!
## Quick Start
@@ -42,9 +43,21 @@ Head to the [documentation](https://gotenberg.dev/docs/getting-started/introduct
-
-
+
+
-Sponsorships help maintaining and improving Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) โค๏ธ
+Sponsorships help maintain and improve Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) โค๏ธ
+
+---
+
+
"), 0o755)
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- err = os.WriteFile(fmt.Sprintf("%s/markdown.md", dirPath), []byte("# Hello World!"), 0o755)
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- return ctx
- }(),
- api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
- return nil
- }},
- expectError: false,
- expectHttpError: false,
- expectOutputPathsCount: 1,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- if tc.ctx.DirPath() != "" {
- defer func() {
- err := os.RemoveAll(tc.ctx.DirPath())
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
- }()
- }
-
- tc.ctx.SetLogger(zap.NewNop())
- c := echo.New().NewContext(nil, nil)
- c.Set("context", tc.ctx.Context)
-
- err := screenshotMarkdownRoute(tc.api).Handler(c)
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none", err)
- }
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- var httpErr api.HttpError
- isHttpError := errors.As(err, &httpErr)
-
- if tc.expectHttpError && !isHttpError {
- t.Errorf("expected an HTTP error but got: %v", err)
- }
-
- if !tc.expectHttpError && isHttpError {
- t.Errorf("expected no HTTP error but got one: %v", httpErr)
- }
-
- if err != nil && tc.expectHttpError && isHttpError {
- status, _ := httpErr.HttpError()
- if status != tc.expectHttpStatus {
- t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
- }
- }
-
- if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
- t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
- }
- })
- }
-}
-
-func TestConvertUrl(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- ctx *api.ContextMock
- api Api
- engine gotenberg.PdfEngine
- options PdfOptions
- pdfFormats gotenberg.PdfFormats
- metadata map[string]interface{}
- expectError bool
- expectHttpError bool
- expectHttpStatus int
- expectOutputPathsCount int
- }{
- {
- scenario: "ErrOmitBackgroundWithoutPrintBackground",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
- return ErrOmitBackgroundWithoutPrintBackground
- }},
- options: DefaultPdfOptions(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrInvalidEvaluationExpression (without waitForExpression form field)",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
- return ErrInvalidEvaluationExpression
- }},
- options: DefaultPdfOptions(),
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrInvalidEvaluationExpression (with waitForExpression form field)",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
- return ErrInvalidEvaluationExpression
- }},
- options: func() PdfOptions {
- options := DefaultPdfOptions()
- options.WaitForExpression = "foo"
-
- return options
- }(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrInvalidPrinterSettings",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
- return ErrInvalidPrinterSettings
- }},
- options: DefaultPdfOptions(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrPageRangesSyntaxError",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
- return ErrPageRangesSyntaxError
- }},
- options: DefaultPdfOptions(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrInvalidHttpStatusCode",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
- return ErrInvalidHttpStatusCode
- }},
- options: DefaultPdfOptions(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusConflict,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrInvalidResourceHttpStatusCode",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
- return ErrInvalidResourceHttpStatusCode
- }},
- options: DefaultPdfOptions(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusConflict,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrConsoleExceptions",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
- return ErrConsoleExceptions
- }},
- options: DefaultPdfOptions(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusConflict,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrLoadingFailed",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
- return ErrLoadingFailed
- }},
- options: DefaultPdfOptions(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrResourceLoadingFailed",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
- return ErrResourceLoadingFailed
- }},
- options: DefaultPdfOptions(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusConflict,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "error from Chromium",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
- return errors.New("foo")
- }},
- options: DefaultPdfOptions(),
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "PDF engine convert error",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
- return nil
- }},
- engine: &gotenberg.PdfEngineMock{ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
- return errors.New("foo")
- }},
- options: DefaultPdfOptions(),
- pdfFormats: gotenberg.PdfFormats{PdfA: "foo"},
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "success with PDF formats",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
- return nil
- }},
- engine: &gotenberg.PdfEngineMock{ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
- return nil
- }},
- options: DefaultPdfOptions(),
- pdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b},
- expectError: false,
- expectHttpError: false,
- expectOutputPathsCount: 1,
- },
- {
- scenario: "PDF engine write metadata error",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
- return nil
- }},
- engine: &gotenberg.PdfEngineMock{WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
- return errors.New("foo")
- }},
- options: DefaultPdfOptions(),
- metadata: map[string]interface{}{
- "Creator": "foo",
- "Producer": "bar",
- },
- expectError: true,
- expectHttpError: false,
- },
- {
- scenario: "cannot add output paths",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetCancelled(true)
- return ctx
- }(),
- api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
- return nil
- }},
- options: DefaultPdfOptions(),
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "success",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
- return nil
- }},
- engine: &gotenberg.PdfEngineMock{
- ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
- return nil
- },
- WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
- return nil
- },
- },
- options: DefaultPdfOptions(),
- pdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b},
- metadata: map[string]interface{}{
- "Creator": "foo",
- "Producer": "bar",
- },
- expectError: false,
- expectHttpError: false,
- expectOutputPathsCount: 1,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- tc.ctx.SetLogger(zap.NewNop())
- err := convertUrl(tc.ctx.Context, tc.api, tc.engine, "", tc.options, tc.pdfFormats, tc.metadata)
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none", err)
- }
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- var httpErr api.HttpError
- isHttpError := errors.As(err, &httpErr)
-
- if tc.expectHttpError && !isHttpError {
- t.Errorf("expected an HTTP error but got: %v", err)
- }
-
- if !tc.expectHttpError && isHttpError {
- t.Errorf("expected no HTTP error but got one: %v", httpErr)
- }
-
- if err != nil && tc.expectHttpError && isHttpError {
- status, _ := httpErr.HttpError()
- if status != tc.expectHttpStatus {
- t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
- }
- }
-
- if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
- t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
- }
- })
- }
-}
-
-func TestScreenshotUrl(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- ctx *api.ContextMock
- api Api
- options ScreenshotOptions
- expectError bool
- expectHttpError bool
- expectHttpStatus int
- expectOutputPathsCount int
- }{
- {
- scenario: "ErrInvalidEvaluationExpression (without waitForExpression form field)",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
- return ErrInvalidEvaluationExpression
- }},
- options: DefaultScreenshotOptions(),
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrInvalidEvaluationExpression (with waitForExpression form field)",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
- return ErrInvalidEvaluationExpression
- }},
- options: func() ScreenshotOptions {
- options := DefaultScreenshotOptions()
- options.WaitForExpression = "foo"
-
- return options
- }(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrInvalidHttpStatusCode",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
- return ErrInvalidHttpStatusCode
- }},
- options: DefaultScreenshotOptions(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusConflict,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrInvalidResourceHttpStatusCode",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
- return ErrInvalidResourceHttpStatusCode
- }},
- options: DefaultScreenshotOptions(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusConflict,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrConsoleExceptions",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
- return ErrConsoleExceptions
- }},
- options: DefaultScreenshotOptions(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusConflict,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrLoadingFailed",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
- return ErrLoadingFailed
- }},
- options: DefaultScreenshotOptions(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrResourceLoadingFailed",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
- return ErrResourceLoadingFailed
- }},
- options: DefaultScreenshotOptions(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusConflict,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "error from Chromium",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
- return errors.New("foo")
- }},
- options: DefaultScreenshotOptions(),
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "cannot add output paths",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetCancelled(true)
- return ctx
- }(),
- api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
- return nil
- }},
- options: DefaultScreenshotOptions(),
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "success",
- ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
- return nil
- }},
- options: DefaultScreenshotOptions(),
- expectError: false,
- expectHttpError: false,
- expectOutputPathsCount: 1,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- tc.ctx.SetLogger(zap.NewNop())
- err := screenshotUrl(tc.ctx.Context, tc.api, "", tc.options)
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none", err)
- }
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- var httpErr api.HttpError
- isHttpError := errors.As(err, &httpErr)
-
- if tc.expectHttpError && !isHttpError {
- t.Errorf("expected an HTTP error but got: %v", err)
- }
-
- if !tc.expectHttpError && isHttpError {
- t.Errorf("expected no HTTP error but got one: %v", httpErr)
- }
-
- if err != nil && tc.expectHttpError && isHttpError {
- status, _ := httpErr.HttpError()
- if status != tc.expectHttpStatus {
- t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
- }
- }
-
- if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
- t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
- }
- })
- }
-}
diff --git a/pkg/modules/chromium/stream.go b/pkg/modules/chromium/stream.go
index 70d205764..23092a40f 100644
--- a/pkg/modules/chromium/stream.go
+++ b/pkg/modules/chromium/stream.go
@@ -24,7 +24,7 @@ type streamReader struct {
// Read a chunk of the stream.
func (reader *streamReader) Read(p []byte) (n int, err error) {
if reader.r != nil {
- // Continue reading from buffer.
+ // Continue reading from the buffer.
return reader.read(p)
}
diff --git a/pkg/modules/chromium/tasks.go b/pkg/modules/chromium/tasks.go
index 5f19a2884..13e004323 100644
--- a/pkg/modules/chromium/tasks.go
+++ b/pkg/modules/chromium/tasks.go
@@ -49,9 +49,8 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
WithPageRanges(pageRanges).
WithPreferCSSPageSize(options.PreferCssPageSize).
WithGenerateDocumentOutline(options.GenerateDocumentOutline).
- // Does not seem to work.
- // See https://github.com/gotenberg/gotenberg/issues/831.
- WithGenerateTaggedPDF(false)
+ // See https://github.com/gotenberg/gotenberg/issues/1210.
+ WithGenerateTaggedPDF(options.GenerateTaggedPdf)
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
@@ -331,7 +330,7 @@ func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent boo
return func(ctx context.Context) error {
logger.Debug(fmt.Sprintf("navigate to '%s'", url))
- _, _, _, err := page.Navigate(url).Do(ctx)
+ _, _, _, _, err := page.Navigate(url).Do(ctx)
if err != nil {
return fmt.Errorf("navigate to '%s': %w", url, err)
}
@@ -392,26 +391,30 @@ func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, pr
}
}
-func forceExactColorsActionFunc() chromedp.ActionFunc {
+func forceExactColorsActionFunc(logger *zap.Logger, printBackground bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
- // See:
- // https://github.com/gotenberg/gotenberg/issues/354
- // https://github.com/puppeteer/puppeteer/issues/2685
- // https://github.com/chromedp/chromedp/issues/520
- script := `
-(() => {
- const css = 'html { -webkit-print-color-adjust: exact !important; }';
+ css := "html { -webkit-print-color-adjust: exact !important; }"
+ if !printBackground {
+ // The -webkit-print-color-adjust: exact CSS property forces the
+ // print of the background, whatever the printToPDF args.
+ // See https://github.com/gotenberg/gotenberg/issues/1154.
+ additionalCss := "html, body { background: none !important; }"
+ logger.Debug(fmt.Sprintf("inject %s as printBackground is %t", additionalCss, printBackground))
+ css += additionalCss
+ }
+ script := fmt.Sprintf(`
+(() => {
+ const css = '%s';
const style = document.createElement('style');
style.type = 'text/css';
style.appendChild(document.createTextNode(css));
document.head.appendChild(style);
})();
-`
+`, css)
evaluate := chromedp.Evaluate(script, nil)
err := evaluate.Do(ctx)
-
if err == nil {
return nil
}
diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go
index 7d2cb8d97..3b4294f8a 100644
--- a/pkg/modules/exiftool/exiftool.go
+++ b/pkg/modules/exiftool/exiftool.go
@@ -5,7 +5,10 @@ import (
"errors"
"fmt"
"os"
+ "os/exec"
"reflect"
+ "strings"
+ "syscall"
"github.com/barasher/go-exiftool"
"go.uber.org/zap"
@@ -53,11 +56,38 @@ func (engine *ExifTool) Validate() error {
return nil
}
+// Debug returns additional debug data.
+func (engine *ExifTool) Debug() map[string]interface{} {
+ debug := make(map[string]interface{})
+
+ cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+
+ output, err := cmd.Output()
+ if err != nil {
+ debug["version"] = err.Error()
+ return debug
+ }
+
+ debug["version"] = strings.TrimSpace(string(output))
+ return debug
+}
+
// Merge is not available in this implementation.
func (engine *ExifTool) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
+// Split is not available in this implementation.
+func (engine *ExifTool) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
+ return nil, fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
+// Flatten is not available in this implementation.
+func (engine *ExifTool) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
// Convert is not available in this implementation.
func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
@@ -112,15 +142,15 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m
fileMetadata[0].SetStrings(key, val)
case []interface{}:
// See https://github.com/gotenberg/gotenberg/issues/1048.
- strings := make([]string, len(val))
+ strs := make([]string, len(val))
for i, entry := range val {
if str, ok := entry.(string); ok {
- strings[i] = str
+ strs[i] = str
continue
}
return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported)
}
- fileMetadata[0].SetStrings(key, strings)
+ fileMetadata[0].SetStrings(key, strs)
case bool:
fileMetadata[0].SetString(key, fmt.Sprintf("%t", val))
case int:
@@ -146,10 +176,26 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m
return nil
}
+// Encrypt is not available in this implementation.
+func (engine *ExifTool) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+ 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)
+}
+
+// ImportBookmarks is not available in this implementation.
+func (engine *ExifTool) ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error {
+ return fmt.Errorf("import bookmarks into PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
// Interface guards.
var (
_ gotenberg.Module = (*ExifTool)(nil)
_ gotenberg.Provisioner = (*ExifTool)(nil)
_ gotenberg.Validator = (*ExifTool)(nil)
+ _ gotenberg.Debuggable = (*ExifTool)(nil)
_ gotenberg.PdfEngine = (*ExifTool)(nil)
)
diff --git a/pkg/modules/exiftool/exiftool_test.go b/pkg/modules/exiftool/exiftool_test.go
deleted file mode 100644
index 949087ec8..000000000
--- a/pkg/modules/exiftool/exiftool_test.go
+++ /dev/null
@@ -1,353 +0,0 @@
-package exiftool
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "os"
- "reflect"
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
-)
-
-func TestExifTool_Descriptor(t *testing.T) {
- descriptor := new(ExifTool).Descriptor()
-
- actual := reflect.TypeOf(descriptor.New())
- expect := reflect.TypeOf(new(ExifTool))
-
- if actual != expect {
- t.Errorf("expected '%s' but got '%s'", expect, actual)
- }
-}
-
-func TestExifTool_Provision(t *testing.T) {
- engine := new(ExifTool)
- ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil)
-
- err := engine.Provision(ctx)
- if err != nil {
- t.Errorf("expected no error but got: %v", err)
- }
-}
-
-func TestExifTool_Validate(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- binPath string
- expectError bool
- }{
- {
- scenario: "empty bin path",
- binPath: "",
- expectError: true,
- },
- {
- scenario: "bin path does not exist",
- binPath: "/foo",
- expectError: true,
- },
- {
- scenario: "validate success",
- binPath: os.Getenv("EXIFTOOL_BIN_PATH"),
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- engine := new(ExifTool)
- engine.binPath = tc.binPath
- err := engine.Validate()
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestExiftool_Merge(t *testing.T) {
- engine := new(ExifTool)
- err := engine.Merge(context.Background(), zap.NewNop(), nil, "")
-
- if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
- t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
- }
-}
-
-func TestExiftool_Convert(t *testing.T) {
- engine := new(ExifTool)
- err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
-
- if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
- t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
- }
-}
-
-func TestExiftool_ReadMetadata(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- inputPath string
- expectMetadata map[string]interface{}
- expectError bool
- }{
- {
- scenario: "invalid input path",
- inputPath: "foo",
- expectMetadata: nil,
- expectError: true,
- },
- {
- scenario: "success",
- inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
- expectMetadata: map[string]interface{}{
- "FileName": "sample1.pdf",
- "FileTypeExtension": "pdf",
- "MIMEType": "application/pdf",
- "PDFVersion": 1.4,
- "PageCount": float64(3),
- },
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- engine := new(ExifTool)
- err := engine.Provision(nil)
- if err != nil {
- t.Fatalf("expected error but got: %v", err)
- }
-
- metadata, err := engine.ReadMetadata(context.Background(), zap.NewNop(), tc.inputPath)
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
-
- if tc.expectMetadata != nil && err == nil {
- for k, v := range tc.expectMetadata {
- if v2, ok := metadata[k]; !ok || v != v2 {
- t.Errorf("expected entry %s with value %v to exists", k, v)
- }
- }
- }
- })
- }
-}
-
-func TestExiftool_WriteMetadata(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- createCopy bool
- inputPath string
- metadata map[string]interface{}
- expectMetadata map[string]interface{}
- expectError bool
- expectedError error
- }{
- {
- scenario: "invalid input path",
- createCopy: false,
- inputPath: "foo",
- expectError: true,
- },
- {
- scenario: "gotenberg.ErrPdfEngineMetadataValueNotSupported (not string array)",
- createCopy: true,
- inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
- metadata: map[string]interface{}{
- "Unsupported": []interface{}{
- "foo",
- 1,
- },
- },
- expectError: true,
- expectedError: gotenberg.ErrPdfEngineMetadataValueNotSupported,
- },
- {
- scenario: "gotenberg.ErrPdfEngineMetadataValueNotSupported (default)",
- createCopy: true,
- inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
- metadata: map[string]interface{}{
- "Unsupported": map[string]interface{}{},
- },
- expectError: true,
- expectedError: gotenberg.ErrPdfEngineMetadataValueNotSupported,
- },
- {
- scenario: "success (interface array to string array)",
- createCopy: true,
- inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
- metadata: map[string]interface{}{
- "Keywords": []interface{}{
- "first",
- "second",
- },
- },
- expectMetadata: map[string]interface{}{
- "Keywords": []interface{}{
- "first",
- "second",
- },
- },
- expectError: false,
- },
- {
- scenario: "success",
- createCopy: true,
- inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
- metadata: map[string]interface{}{
- "Author": "Julien Neuhart",
- "Copyright": "Julien Neuhart",
- "CreationDate": "2006-09-18T16:27:50-04:00",
- "Creator": "Gotenberg",
- "Keywords": []string{
- "first",
- "second",
- },
- "Marked": true,
- "ModDate": "2006-09-18T16:27:50-04:00",
- "PDFVersion": 1.7,
- "Producer": "Gotenberg",
- "Subject": "Sample",
- "Title": "Sample",
- "Trapped": "Unknown",
- // Those are not valid PDF metadata.
- "int": 1,
- "int64": int64(2),
- "float32": float32(2.2),
- "float64": 3.3,
- },
- expectMetadata: map[string]interface{}{
- "Author": "Julien Neuhart",
- "Copyright": "Julien Neuhart",
- "CreationDate": "2006:09:18 16:27:50-04:00",
- "Creator": "Gotenberg",
- "Keywords": []interface{}{
- "first",
- "second",
- },
- "Marked": true,
- "ModDate": "2006:09:18 16:27:50-04:00",
- "PDFVersion": 1.7,
- "Producer": "Gotenberg",
- "Subject": "Sample",
- "Title": "Sample",
- "Trapped": "Unknown",
- },
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- engine := new(ExifTool)
- err := engine.Provision(nil)
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- var destinationPath string
- if tc.createCopy {
- fs := gotenberg.NewFileSystem()
- outputDir, err := fs.MkdirAll()
- if err != nil {
- t.Fatalf("expected error no but got: %v", err)
- }
-
- defer func() {
- err = os.RemoveAll(fs.WorkingDirPath())
- if err != nil {
- t.Fatalf("expected no error while cleaning up but got: %v", err)
- }
- }()
-
- destinationPath = fmt.Sprintf("%s/copy_temp.pdf", outputDir)
- source, err := os.Open(tc.inputPath)
- if err != nil {
- t.Fatalf("open source file: %v", err)
- }
-
- defer func(source *os.File) {
- err := source.Close()
- if err != nil {
- t.Fatalf("close file: %v", err)
- }
- }(source)
-
- destination, err := os.Create(destinationPath)
- if err != nil {
- t.Fatalf("create destination file: %v", err)
- }
-
- defer func(destination *os.File) {
- err := destination.Close()
- if err != nil {
- t.Fatalf("close file: %v", err)
- }
- }(destination)
-
- _, err = io.Copy(destination, source)
- if err != nil {
- t.Fatalf("copy source into destination: %v", err)
- }
- } else {
- destinationPath = tc.inputPath
- }
-
- err = engine.WriteMetadata(context.Background(), zap.NewNop(), tc.metadata, destinationPath)
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
-
- if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
- t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
- }
-
- if tc.expectError {
- return
- }
-
- metadata, err := engine.ReadMetadata(context.Background(), zap.NewNop(), destinationPath)
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectMetadata != nil && err == nil {
- for k, v := range tc.expectMetadata {
- v2, ok := metadata[k]
- if !ok {
- t.Errorf("expected entry %s with value %v to exists, but got none", k, v)
- continue
- }
-
- switch v2.(type) {
- case []interface{}:
- for i, entry := range v.([]interface{}) {
- if entry != v2.([]interface{})[i] {
- t.Errorf("expected entry %s to contain value %v, but got %v", k, entry, v2.([]interface{})[i])
- }
- }
- default:
- if v != v2 {
- t.Errorf("expected entry %s with value %v to exists, but got %v", k, v, v2)
- }
- }
- }
- }
- })
- }
-}
diff --git a/pkg/modules/libreoffice/api/api.go b/pkg/modules/libreoffice/api/api.go
index e32712cff..2e0529b46 100644
--- a/pkg/modules/libreoffice/api/api.go
+++ b/pkg/modules/libreoffice/api/api.go
@@ -5,6 +5,9 @@ import (
"errors"
"fmt"
"os"
+ "os/exec"
+ "strings"
+ "syscall"
"time"
"github.com/alexliesenfeld/health"
@@ -21,23 +24,23 @@ func init() {
}
var (
- // ErrInvalidPdfFormats happens if the PDF formats option cannot be handled
- // by LibreOffice.
+ // ErrInvalidPdfFormats happens if LibreOffice cannot handle the PDF
+ // formats option.
ErrInvalidPdfFormats = errors.New("invalid PDF formats")
- // ErrUnoException happens when unoconverter returns an exit code 5.
+ // ErrUnoException happens when unoconverter returns exit code 5.
ErrUnoException = errors.New("uno exception")
- // ErrRuntimeException happens when unoconverter returns an exit code 6.
+ // ErrRuntimeException happens when unoconverter returns exit code 6.
ErrRuntimeException = errors.New("uno exception")
- // ErrCoreDumped happens randomly; sometime a conversion will work as
+ // ErrCoreDumped happens randomly; sometimes a conversion will work as
// expected, and some other time the same conversion will fail.
// See https://github.com/gotenberg/gotenberg/issues/639.
ErrCoreDumped = errors.New("core dumped")
)
-// Api is a module which provides a [Uno] to interact with LibreOffice.
+// Api is a module that provides a [Uno] to interact with LibreOffice.
type Api struct {
autoStart bool
args libreOfficeArguments
@@ -53,12 +56,17 @@ type Options struct {
// Password specifies the password for opening the source file.
Password string
- // Landscape allows to change the orientation of the resulting PDF.
+ // Landscape allows changing the orientation of the resulting PDF.
Landscape bool
- // PageRanges allows to select the pages to convert.
+ // PageRanges allows selecting the pages to convert.
PageRanges string
+ // UpdateIndexes specifies whether to update the indexes before conversion,
+ // keeping in mind that doing so might result in missing links in the final
+ // PDF.
+ UpdateIndexes bool
+
// ExportFormFields specifies whether form fields are exported as widgets
// or only their fixed print representation is exported.
ExportFormFields bool
@@ -75,7 +83,7 @@ type Options struct {
// Named Destination.
ExportBookmarksToPdfDestination bool
- // ExportPlaceholders exports the placeholders fields visual markings only.
+ // ExportPlaceholders exports the placeholder fields visual markings only.
// The exported placeholder is ineffective.
ExportPlaceholders bool
@@ -86,15 +94,16 @@ type Options struct {
// Notes pages are available in Impress documents only.
ExportNotesPages bool
- // ExportOnlyNotesPages specifies, if the property ExportNotesPages is set
- // to true, if only notes pages are exported to PDF.
+ // ExportOnlyNotesPages specifies if the property ExportNotesPages is set
+ // to true if only notes pages are exported to PDF.
ExportOnlyNotesPages bool
- // ExportNotesInMargin specifies if notes in margin are exported to PDF.
+ // ExportNotesInMargin specifies if notes in the margin are exported to
+ // PDF.
ExportNotesInMargin bool
// ConvertOooTargetToPdfTarget specifies that the target documents with
- // .od[tpgs] extension, will have that extension changed to .pdf when the
+ // .od[tpgs] extension will have that extension changed to .pdf when the
// link is exported to PDF. The source document remains untouched.
ConvertOooTargetToPdfTarget bool
@@ -149,6 +158,7 @@ func DefaultOptions() Options {
Password: "",
Landscape: false,
PageRanges: "",
+ UpdateIndexes: true,
ExportFormFields: true,
AllowDuplicateFieldNames: false,
ExportBookmarks: true,
@@ -181,7 +191,7 @@ type Uno interface {
Extensions() []string
}
-// Provider is a module interface which exposes a method for creating a
+// Provider is a module interface that exposes a method for creating a
// [Uno] for other modules.
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
@@ -291,8 +301,8 @@ func (a *Api) StartupMessage() string {
// Stop stops the current browser instance.
func (a *Api) Stop(ctx context.Context) error {
- // Block until the context is done so that other module may gracefully stop
- // before we do a shutdown.
+ // Block until the context is done so that another module may gracefully
+ // stop before we do a shutdown.
a.logger.Debug("wait for the end of grace duration")
<-ctx.Done()
@@ -305,6 +315,23 @@ func (a *Api) Stop(ctx context.Context) error {
return fmt.Errorf("stop LibreOffice: %w", err)
}
+// Debug returns additional debug data.
+func (a *Api) Debug() map[string]interface{} {
+ debug := make(map[string]interface{})
+
+ cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+
+ output, err := cmd.Output()
+ if err != nil {
+ debug["version"] = err.Error()
+ return debug
+ }
+
+ debug["version"] = strings.TrimSpace(string(output))
+ return debug
+}
+
// Metrics returns the metrics.
func (a *Api) Metrics() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
@@ -536,6 +563,7 @@ var (
_ gotenberg.Provisioner = (*Api)(nil)
_ gotenberg.Validator = (*Api)(nil)
_ gotenberg.App = (*Api)(nil)
+ _ gotenberg.Debuggable = (*Api)(nil)
_ gotenberg.MetricsProvider = (*Api)(nil)
_ api.HealthChecker = (*Api)(nil)
_ Uno = (*Api)(nil)
diff --git a/pkg/modules/libreoffice/api/api_test.go b/pkg/modules/libreoffice/api/api_test.go
deleted file mode 100644
index b9480b852..000000000
--- a/pkg/modules/libreoffice/api/api_test.go
+++ /dev/null
@@ -1,474 +0,0 @@
-package api
-
-import (
- "context"
- "errors"
- "os"
- "reflect"
- "testing"
- "time"
-
- "github.com/alexliesenfeld/health"
- "go.uber.org/zap"
-
- "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
-)
-
-func TestDefaultOptions(t *testing.T) {
- actual := DefaultOptions()
- notExpect := Options{}
-
- if reflect.DeepEqual(actual, notExpect) {
- t.Errorf("expected %v and got identical %v", actual, notExpect)
- }
-}
-
-func TestApi_Descriptor(t *testing.T) {
- descriptor := new(Api).Descriptor()
-
- actual := reflect.TypeOf(descriptor.New())
- expect := reflect.TypeOf(new(Api))
-
- if actual != expect {
- t.Errorf("expected '%s' but got '%s'", expect, actual)
- }
-}
-
-func TestApi_Provision(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- ctx *gotenberg.Context
- expectError bool
- }{
- {
- scenario: "no logger provider",
- ctx: func() *gotenberg.Context {
- return gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: new(Api).Descriptor().FlagSet,
- },
- []gotenberg.ModuleDescriptor{},
- )
- }(),
- expectError: true,
- },
- {
- scenario: "no logger from logger provider",
- ctx: func() *gotenberg.Context {
- mod := &struct {
- gotenberg.ModuleMock
- gotenberg.LoggerProviderMock
- }{}
- mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
- }
- mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
- return nil, errors.New("foo")
- }
-
- return gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: new(Api).Descriptor().FlagSet,
- },
- []gotenberg.ModuleDescriptor{
- mod.Descriptor(),
- },
- )
- }(),
- expectError: true,
- },
- {
- scenario: "provision success",
- ctx: func() *gotenberg.Context {
- mod := &struct {
- gotenberg.ModuleMock
- gotenberg.LoggerProviderMock
- }{}
- mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
- }
- mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
- return zap.NewNop(), nil
- }
-
- return gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: new(Api).Descriptor().FlagSet,
- },
- []gotenberg.ModuleDescriptor{
- mod.Descriptor(),
- },
- )
- }(),
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- a := new(Api)
- err := a.Provision(tc.ctx)
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestApi_Validate(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- binPath string
- unoBinPath string
- expectError bool
- }{
- {
- scenario: "empty LibreOffice bin path",
- binPath: "",
- unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
- expectError: true,
- },
- {
- scenario: "LibreOffice bin path does not exist",
- binPath: "/foo",
- unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
- expectError: true,
- },
- {
- scenario: "empty uno bin path",
- binPath: os.Getenv("CHROMIUM_BIN_PATH"),
- unoBinPath: "",
- expectError: true,
- },
- {
- scenario: "uno bin path does not exist",
- binPath: os.Getenv("CHROMIUM_BIN_PATH"),
- unoBinPath: "/foo",
- expectError: true,
- },
- {
- scenario: "validate success",
- binPath: os.Getenv("CHROMIUM_BIN_PATH"),
- unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- a := new(Api)
- a.args = libreOfficeArguments{
- binPath: tc.binPath,
- unoBinPath: tc.unoBinPath,
- }
- err := a.Validate()
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestApi_Start(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- autoStart bool
- supervisor *gotenberg.ProcessSupervisorMock
- expectError bool
- }{
- {
- scenario: "no auto-start",
- autoStart: false,
- expectError: false,
- },
- {
- scenario: "auto-start success",
- autoStart: true,
- supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
- return nil
- }},
- expectError: false,
- },
- {
- scenario: "auto-start failed",
- autoStart: true,
- supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
- return errors.New("foo")
- }},
- expectError: true,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- a := new(Api)
- a.autoStart = tc.autoStart
- a.supervisor = tc.supervisor
-
- err := a.Start()
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestApi_StartupMessage(t *testing.T) {
- a := new(Api)
-
- a.autoStart = true
- autoStartMsg := a.StartupMessage()
-
- a.autoStart = false
- noAutoStartMsg := a.StartupMessage()
-
- if autoStartMsg == noAutoStartMsg {
- t.Errorf("expected differrent startup messages based on auto start, but got '%s'", autoStartMsg)
- }
-}
-
-func TestApi_Stop(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- supervisor *gotenberg.ProcessSupervisorMock
- expectError bool
- }{
- {
- scenario: "stop success",
- supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
- return nil
- }},
- expectError: false,
- },
- {
- scenario: "stop failed",
- supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
- return errors.New("foo")
- }},
- expectError: true,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- a := new(Api)
- a.logger = zap.NewNop()
- a.supervisor = tc.supervisor
-
- ctx, cancel := context.WithTimeout(context.Background(), 0*time.Second)
- cancel()
-
- err := a.Stop(ctx)
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestApi_Metrics(t *testing.T) {
- a := new(Api)
- a.supervisor = &gotenberg.ProcessSupervisorMock{
- ReqQueueSizeMock: func() int64 {
- return 10
- },
- RestartsCountMock: func() int64 {
- return 0
- },
- }
-
- metrics, err := a.Metrics()
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if len(metrics) != 2 {
- t.Fatalf("expected %d metrics, but got %d", 2, len(metrics))
- }
-
- actual := metrics[0].Read()
- if actual != float64(10) {
- t.Errorf("expected %f for libreoffice_requests_queue_size, but got %f", float64(10), actual)
- }
-
- actual = metrics[1].Read()
- if actual != float64(0) {
- t.Errorf("expected %f for libreoffice_restarts_count, but got %f", float64(0), actual)
- }
-}
-
-func TestApi_Checks(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- supervisor gotenberg.ProcessSupervisor
- expectAvailabilityStatus health.AvailabilityStatus
- }{
- {
- scenario: "healthy module",
- supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
- return true
- }},
- expectAvailabilityStatus: health.StatusUp,
- },
- {
- scenario: "unhealthy module",
- supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
- return false
- }},
- expectAvailabilityStatus: health.StatusDown,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- a := new(Api)
- a.supervisor = tc.supervisor
-
- checks, err := a.Checks()
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- checker := health.NewChecker(checks...)
- result := checker.Check(context.Background())
-
- if result.Status != tc.expectAvailabilityStatus {
- t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status)
- }
- })
- }
-}
-
-func TestChromium_Ready(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- autoStart bool
- startTimeout time.Duration
- libreOffice libreOffice
- expectError bool
- }{
- {
- scenario: "no auto-start",
- autoStart: false,
- startTimeout: time.Duration(30) * time.Second,
- libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
- return false
- }}},
- expectError: false,
- },
- {
- scenario: "auto-start: context done",
- autoStart: true,
- startTimeout: time.Duration(200) * time.Millisecond,
- libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
- return false
- }}},
- expectError: true,
- },
- {
- scenario: "auto-start success",
- autoStart: true,
- startTimeout: time.Duration(30) * time.Second,
- libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
- return true
- }}},
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- a := new(Api)
- a.autoStart = tc.autoStart
- a.args = libreOfficeArguments{startTimeout: tc.startTimeout}
- a.libreOffice = tc.libreOffice
-
- err := a.Ready()
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestApi_LibreOffice(t *testing.T) {
- a := new(Api)
-
- _, err := a.LibreOffice()
- if err != nil {
- t.Errorf("expected no error but got: %v", err)
- }
-}
-
-func TestApi_Pdf(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- supervisor gotenberg.ProcessSupervisor
- libreOffice libreOffice
- expectError bool
- }{
- {
- scenario: "PDF task success",
- libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
- return nil
- }},
- expectError: false,
- },
- {
- scenario: "PDF task error",
- libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
- return errors.New("PDF task error")
- }},
- expectError: true,
- },
- {
- scenario: "ErrCoreDumped",
- libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
- return ErrCoreDumped
- }},
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- a := new(Api)
- a.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
- return task()
- }}
- a.libreOffice = tc.libreOffice
-
- err := a.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestApi_Extensions(t *testing.T) {
- a := new(Api)
- extensions := a.Extensions()
-
- actual := len(extensions)
- expect := 130
-
- if actual != expect {
- t.Errorf("expected %d extensions, but got %d", expect, actual)
- }
-}
diff --git a/pkg/modules/libreoffice/api/libreoffice.go b/pkg/modules/libreoffice/api/libreoffice.go
index f8c4415b2..9ee9e3531 100644
--- a/pkg/modules/libreoffice/api/libreoffice.go
+++ b/pkg/modules/libreoffice/api/libreoffice.go
@@ -4,16 +4,13 @@ import (
"context"
"errors"
"fmt"
- "io"
"net"
"os"
- "path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
- "github.com/google/uuid"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -44,7 +41,7 @@ type libreOfficeProcess struct {
func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice {
p := &libreOfficeProcess{
arguments: arguments,
- fs: gotenberg.NewFileSystem(),
+ fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
}
p.isStarted.Store(false)
@@ -190,22 +187,23 @@ func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
// Always remove the user profile directory created by LibreOffice.
copyUserProfileDirPath := p.userProfileDirPath
- defer func(userProfileDirPath string) {
+ expirationTime := time.Now()
+ defer func(userProfileDirPath string, expirationTime time.Time) {
go func() {
err := os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
+ } else {
+ logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
}
- logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
-
- // Also remove LibreOffice specific files in the temporary directory.
- err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"})
+ // Also, remove LibreOffice specific files in the temporary directory.
+ err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}, expirationTime)
if err != nil {
logger.Error(err.Error())
}
}()
- }(copyUserProfileDirPath)
+ }(copyUserProfileDirPath, expirationTime)
p.cfgMu.Lock()
defer p.cfgMu.Unlock()
@@ -274,10 +272,15 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
args = append(args, "--printer", "PaperOrientation=landscape")
}
+ // See: https://github.com/gotenberg/gotenberg/issues/1149.
if options.PageRanges != "" {
args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges))
}
+ if !options.UpdateIndexes {
+ args = append(args, "--disable-update-indexes")
+ }
+
args = append(args, "--export", fmt.Sprintf("ExportFormFields=%t", options.ExportFormFields))
args = append(args, "--export", fmt.Sprintf("AllowDuplicateFieldNames=%t", options.AllowDuplicateFieldNames))
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
@@ -327,11 +330,6 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
)
}
- inputPath, err := nonBasicLatinCharactersGuard(logger, inputPath)
- if err != nil {
- return fmt.Errorf("non-basic latin characters guard: %w", err)
- }
-
args = append(args, "--output", outputPath, inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.unoBinPath, args...)
@@ -347,10 +345,10 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
}
// LibreOffice's errors are not explicit.
- // For instance, an exit code 5 may be explained by a malformed page
- // ranges, but also by a not required password.
+ // For instance, exit code 5 may be explained by a malformed page range
+ // but also by a not required password.
- // We may want to retry in case of a core dumped event.
+ // We may want to retry in case of a core-dumped event.
// See https://github.com/gotenberg/gotenberg/issues/639.
if strings.Contains(err.Error(), "core dumped") {
return ErrCoreDumped
@@ -368,65 +366,6 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
return fmt.Errorf("convert to PDF: %w", err)
}
-// LibreOffice cannot convert a file with a name containing non-basic Latin
-// characters.
-// See:
-// https://github.com/gotenberg/gotenberg/issues/104
-// https://github.com/gotenberg/gotenberg/issues/730
-func nonBasicLatinCharactersGuard(logger *zap.Logger, inputPath string) (string, error) {
- hasNonBasicLatinChars := func(str string) bool {
- for _, r := range str {
- // Check if the character is outside basic Latin.
- if r != '.' && (r < ' ' || r > '~') {
- return true
- }
- }
- return false
- }
-
- filename := filepath.Base(inputPath)
- if !hasNonBasicLatinChars(filename) {
- logger.Debug("no non-basic latin characters in filename, skip copy")
- return inputPath, nil
- }
-
- logger.Warn("non-basic latin characters in filename, copy to a file with a valid filename")
- basePath := filepath.Dir(inputPath)
- ext := filepath.Ext(inputPath)
- newInputPath := filepath.Join(basePath, fmt.Sprintf("%s%s", uuid.NewString(), ext))
-
- in, err := os.Open(inputPath)
- if err != nil {
- return "", fmt.Errorf("open file: %w", err)
- }
-
- defer func() {
- err := in.Close()
- if err != nil {
- logger.Error(fmt.Sprintf("close file: %s", err))
- }
- }()
-
- out, err := os.Create(newInputPath)
- if err != nil {
- return "", fmt.Errorf("create new file: %w", err)
- }
-
- defer func() {
- err := out.Close()
- if err != nil {
- logger.Error(fmt.Sprintf("close new file: %s", err))
- }
- }()
-
- _, err = io.Copy(out, in)
- if err != nil {
- return "", fmt.Errorf("copy file to new file: %w", err)
- }
-
- return newInputPath, nil
-}
-
// Interface guards.
var (
_ gotenberg.Process = (*libreOfficeProcess)(nil)
diff --git a/pkg/modules/libreoffice/api/libreoffice_test.go b/pkg/modules/libreoffice/api/libreoffice_test.go
deleted file mode 100644
index 953cb908b..000000000
--- a/pkg/modules/libreoffice/api/libreoffice_test.go
+++ /dev/null
@@ -1,699 +0,0 @@
-package api
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "os"
- "testing"
- "time"
-
- "github.com/google/uuid"
- "go.uber.org/zap"
-
- "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
-)
-
-func TestLibreOfficeProcess_Start(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- libreOffice libreOffice
- expectError bool
- cleanup bool
- }{
- {
- scenario: "successful start",
- libreOffice: newLibreOfficeProcess(
- libreOfficeArguments{
- binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
- unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
- startTimeout: 5 * time.Second,
- },
- ),
- expectError: false,
- cleanup: true,
- },
- {
- scenario: "LibreOffice already started",
- libreOffice: func() libreOffice {
- p := new(libreOfficeProcess)
- p.isStarted.Store(true)
- return p
- }(),
- expectError: true,
- cleanup: false,
- },
- {
- scenario: "non-exit code 81 on first start",
- libreOffice: newLibreOfficeProcess(
- libreOfficeArguments{
- binPath: "foo",
- unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
- startTimeout: 5 * time.Second,
- },
- ),
- expectError: true,
- cleanup: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- logger := zap.NewNop()
- err := tc.libreOffice.Start(logger)
-
- if tc.cleanup {
- defer func(p libreOffice, logger *zap.Logger) {
- err = p.Stop(logger)
- if err != nil {
- t.Fatalf("expected no error while cleaning up, but got: %v", err)
- }
- }(tc.libreOffice, logger)
- }
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestLibreOfficeProcess_Stop(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- libreOffice libreOffice
- setup func(libreOffice libreOffice, logger *zap.Logger) error
- expectError bool
- }{
- {
- scenario: "successful stop",
- libreOffice: newLibreOfficeProcess(
- libreOfficeArguments{
- binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
- unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
- startTimeout: 5 * time.Second,
- },
- ),
- setup: func(p libreOffice, logger *zap.Logger) error {
- return p.Start(logger)
- },
- expectError: false,
- },
- {
- scenario: "LibreOffice already stopped",
- libreOffice: func() libreOffice {
- p := new(libreOfficeProcess)
- p.isStarted.Store(false)
- return p
- }(),
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- logger := zap.NewNop()
-
- if tc.setup != nil {
- err := tc.setup(tc.libreOffice, logger)
- if err != nil {
- t.Fatalf("setup error: %v", err)
- }
- }
-
- err := tc.libreOffice.Stop(logger)
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestLibreOfficeProcess_Healthy(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- libreOffice libreOffice
- setup func(libreOffice libreOffice, logger *zap.Logger) error
- expectHealthy bool
- cleanup bool
- }{
- {
- scenario: "healthy LibreOffice",
- libreOffice: newLibreOfficeProcess(
- libreOfficeArguments{
- binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
- unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
- startTimeout: 5 * time.Second,
- },
- ),
- setup: func(p libreOffice, logger *zap.Logger) error {
- return p.Start(logger)
- },
- expectHealthy: true,
- cleanup: true,
- },
- {
- scenario: "LibreOffice not started",
- libreOffice: func() libreOffice {
- p := new(libreOfficeProcess)
- p.isStarted.Store(false)
- return p
- }(),
- expectHealthy: false,
- cleanup: false,
- },
- {
- scenario: "unhealthy LibreOffice",
- libreOffice: func() libreOffice {
- p := new(libreOfficeProcess)
- p.isStarted.Store(true)
- p.socketPort = 12345
- return p
- }(),
- expectHealthy: false,
- cleanup: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- logger := zap.NewNop()
-
- if tc.setup != nil {
- err := tc.setup(tc.libreOffice, logger)
- if err != nil {
- t.Fatalf("setup error: %v", err)
- }
- }
-
- if tc.cleanup {
- defer func(p libreOffice, logger *zap.Logger) {
- err := p.Stop(logger)
- if err != nil {
- t.Fatalf("expected no error while cleaning up, but got: %v", err)
- }
- }(tc.libreOffice, logger)
- }
-
- healthy := tc.libreOffice.Healthy(logger)
-
- if !tc.expectHealthy && healthy {
- t.Fatal("expected unhealthy LibreOffice but got an healthy one")
- }
-
- if tc.expectHealthy && !healthy {
- t.Fatal("expected a healthy LibreOffice but got an unhealthy one")
- }
- })
- }
-}
-
-func TestLibreOfficeProcess_pdf(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- libreOffice libreOffice
- fs *gotenberg.FileSystem
- options Options
- cancelledCtx bool
- start bool
- expectError bool
- expectedError error
- }{
- {
- scenario: "LibreOffice not started",
- libreOffice: func() libreOffice {
- p := new(libreOfficeProcess)
- p.isStarted.Store(false)
- return p
- }(),
- fs: gotenberg.NewFileSystem(),
- cancelledCtx: false,
- start: false,
- expectError: true,
- },
- {
- scenario: "ErrInvalidPdfFormats",
- libreOffice: func() libreOffice {
- p := new(libreOfficeProcess)
- p.socketPort = 12345
- p.isStarted.Store(true)
- return p
- }(),
- fs: gotenberg.NewFileSystem(),
- options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: "foo"}},
- cancelledCtx: false,
- start: false,
- expectError: true,
- expectedError: ErrInvalidPdfFormats,
- },
- {
- scenario: "ErrUnoException",
- libreOffice: newLibreOfficeProcess(
- libreOfficeArguments{
- binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
- unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
- startTimeout: 5 * time.Second,
- },
- ),
- options: Options{PageRanges: "foo"},
- fs: func() *gotenberg.FileSystem {
- fs := gotenberg.NewFileSystem()
-
- err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
- if err != nil {
- t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
- }
-
- err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755)
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- return fs
- }(),
- cancelledCtx: false,
- start: true,
- expectError: true,
- expectedError: ErrUnoException,
- },
- {
- scenario: "ErrRuntimeException",
- libreOffice: newLibreOfficeProcess(
- libreOfficeArguments{
- binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
- unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
- startTimeout: 5 * time.Second,
- },
- ),
- options: Options{Password: "foo"},
- fs: func() *gotenberg.FileSystem {
- fs := gotenberg.NewFileSystem()
-
- err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
- if err != nil {
- t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
- }
-
- in, err := os.Open("/tests/test/testdata/libreoffice/protected.docx")
- if err != nil {
- t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
- }
-
- defer func() {
- err := in.Close()
- if err != nil {
- t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
- }
- }()
-
- out, err := os.Create(fmt.Sprintf("%s/protected.docx", fs.WorkingDirPath()))
- if err != nil {
- t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
- }
-
- defer func() {
- err := out.Close()
- if err != nil {
- t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
- }
- }()
-
- _, err = io.Copy(out, in)
- if err != nil {
- t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
- }
-
- return fs
- }(),
- cancelledCtx: false,
- start: true,
- expectError: true,
- expectedError: ErrRuntimeException,
- },
- {
- scenario: "context done",
- libreOffice: newLibreOfficeProcess(
- libreOfficeArguments{
- binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
- unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
- startTimeout: 5 * time.Second,
- },
- ),
- fs: func() *gotenberg.FileSystem {
- fs := gotenberg.NewFileSystem()
-
- err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
- if err != nil {
- t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
- }
-
- err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755)
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- return fs
- }(),
- cancelledCtx: true,
- start: true,
- expectError: true,
- },
- {
- scenario: "success (default options)",
- libreOffice: newLibreOfficeProcess(
- libreOfficeArguments{
- binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
- unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
- startTimeout: 5 * time.Second,
- },
- ),
- fs: func() *gotenberg.FileSystem {
- fs := gotenberg.NewFileSystem()
-
- err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
- if err != nil {
- t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
- }
-
- err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755)
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- return fs
- }(),
- cancelledCtx: false,
- start: true,
- expectError: false,
- },
- {
- scenario: "success (not default options)",
- libreOffice: newLibreOfficeProcess(
- libreOfficeArguments{
- binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
- unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
- startTimeout: 5 * time.Second,
- },
- ),
- fs: func() *gotenberg.FileSystem {
- fs := gotenberg.NewFileSystem()
-
- err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
- if err != nil {
- t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
- }
-
- err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755)
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- return fs
- }(),
- options: Options{
- Password: "", // Ok, the only exception in this list.
- Landscape: true,
- PageRanges: "1",
- ExportFormFields: false,
- AllowDuplicateFieldNames: true,
- ExportBookmarks: false,
- ExportBookmarksToPdfDestination: true,
- ExportPlaceholders: true,
- ExportNotes: true,
- ExportNotesPages: true,
- ExportOnlyNotesPages: true,
- ExportNotesInMargin: true,
- ConvertOooTargetToPdfTarget: true,
- ExportLinksRelativeFsys: true,
- ExportHiddenSlides: true,
- SkipEmptyPages: true,
- AddOriginalDocumentAsStream: true,
- SinglePageSheets: true,
- LosslessImageCompression: true,
- Quality: 100,
- ReduceImageResolution: true,
- MaxImageResolution: 600,
- },
- cancelledCtx: false,
- start: true,
- expectError: false,
- },
- {
- scenario: "success (PDF/A-1b)",
- libreOffice: newLibreOfficeProcess(
- libreOfficeArguments{
- binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
- unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
- startTimeout: 5 * time.Second,
- },
- ),
- fs: func() *gotenberg.FileSystem {
- fs := gotenberg.NewFileSystem()
-
- err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
- if err != nil {
- t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
- }
-
- err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- return fs
- }(),
- options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b}},
- cancelledCtx: false,
- start: true,
- expectError: false,
- },
- {
- scenario: "success (PDF/A-2b)",
- libreOffice: newLibreOfficeProcess(
- libreOfficeArguments{
- binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
- unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
- startTimeout: 5 * time.Second,
- },
- ),
- fs: func() *gotenberg.FileSystem {
- fs := gotenberg.NewFileSystem()
-
- err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
- if err != nil {
- t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
- }
-
- err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- return fs
- }(),
- options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA2b}},
- cancelledCtx: false,
- start: true,
- expectError: false,
- },
- {
- scenario: "success (PDF/A-3b)",
- libreOffice: newLibreOfficeProcess(
- libreOfficeArguments{
- binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
- unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
- startTimeout: 5 * time.Second,
- },
- ),
- fs: func() *gotenberg.FileSystem {
- fs := gotenberg.NewFileSystem()
-
- err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
- if err != nil {
- t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
- }
-
- err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- return fs
- }(),
- options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA3b}},
- cancelledCtx: false,
- start: true,
- expectError: false,
- },
- {
- scenario: "success (PDF/UA)",
- libreOffice: newLibreOfficeProcess(
- libreOfficeArguments{
- binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
- unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
- startTimeout: 5 * time.Second,
- },
- ),
- fs: func() *gotenberg.FileSystem {
- fs := gotenberg.NewFileSystem()
-
- err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
- if err != nil {
- t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
- }
-
- err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- return fs
- }(),
- options: Options{PdfFormats: gotenberg.PdfFormats{PdfUa: true}},
- cancelledCtx: false,
- start: true,
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- // Force the debug level.
- logger := zap.NewExample()
-
- defer func() {
- err := os.RemoveAll(tc.fs.WorkingDirPath())
- if err != nil {
- t.Fatalf("expected no error while cleaning up, but got: %v", err)
- }
- }()
-
- if tc.start {
- err := tc.libreOffice.Start(logger)
- if err != nil {
- t.Fatalf("setup error: %v", err)
- }
-
- defer func(p libreOffice, logger *zap.Logger) {
- err = p.Stop(logger)
- if err != nil {
- t.Fatalf("expected no error while cleaning up, but got: %v", err)
- }
- }(tc.libreOffice, logger)
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), time.Duration(5)*time.Second)
- defer cancel()
-
- if tc.cancelledCtx {
- cancel()
- }
-
- err := tc.libreOffice.pdf(
- ctx,
- logger,
- fmt.Sprintf("%s/document.txt", tc.fs.WorkingDirPath()),
- fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()),
- tc.options,
- )
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
-
- if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
- t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
- }
- })
- }
-}
-
-func TestNonBasicLatinCharactersGuard(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- fs *gotenberg.FileSystem
- filename string
- expectSameInputPath bool
- expectError bool
- }{
- {
- scenario: "basic latin characters",
- fs: func() *gotenberg.FileSystem {
- fs := gotenberg.NewFileSystem()
-
- err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
- if err != nil {
- t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
- }
-
- err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Basic latin characters"), 0o755)
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- return fs
- }(),
- filename: "document.txt",
- expectSameInputPath: true,
- expectError: false,
- },
- {
- scenario: "non-basic latin characters",
- fs: func() *gotenberg.FileSystem {
- fs := gotenberg.NewFileSystem()
-
- err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
- if err != nil {
- t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
- }
-
- err = os.WriteFile(fmt.Sprintf("%s/รฉรจรร รนรค.txt", fs.WorkingDirPath()), []byte("Non-basic latin characters"), 0o755)
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- return fs
- }(),
- filename: "รฉรจรร รนรค.txt",
- expectSameInputPath: false,
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- defer func() {
- err := os.RemoveAll(tc.fs.WorkingDirPath())
- if err != nil {
- t.Fatalf("expected no error while cleaning up, but got: %v", err)
- }
- }()
-
- inputPath := fmt.Sprintf("%s/%s", tc.fs.WorkingDirPath(), tc.filename)
- newInputPath, err := nonBasicLatinCharactersGuard(
- zap.NewNop(),
- inputPath,
- )
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
-
- if tc.expectSameInputPath && newInputPath != inputPath {
- t.Fatalf("expected same input path, but got '%s'", newInputPath)
- }
-
- if !tc.expectSameInputPath && newInputPath == inputPath {
- t.Fatalf("expected different input path, but got same '%s'", newInputPath)
- }
- })
- }
-}
diff --git a/pkg/modules/libreoffice/api/mocks_test.go b/pkg/modules/libreoffice/api/mocks_test.go
deleted file mode 100644
index f2a03530f..000000000
--- a/pkg/modules/libreoffice/api/mocks_test.go
+++ /dev/null
@@ -1,94 +0,0 @@
-package api
-
-import (
- "context"
- "testing"
-
- "go.uber.org/zap"
-)
-
-func TestApiMock(t *testing.T) {
- mock := &ApiMock{
- PdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
- return nil
- },
- ExtensionsMock: func() []string {
- return nil
- },
- }
-
- err := mock.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
- if err != nil {
- t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err)
- }
-
- ext := mock.Extensions()
- if ext != nil {
- t.Errorf("expected nil result from ApiMock.Extensions, but got: %v", ext)
- }
-}
-
-func TestProviderMock(t *testing.T) {
- mock := &ProviderMock{
- LibreOfficeMock: func() (Uno, error) {
- return nil, nil
- },
- }
-
- _, err := mock.LibreOffice()
- if err != nil {
- t.Errorf("expected no error from ProviderMock.LibreOffice, but got: %v", err)
- }
-}
-
-func TestLibreOfficeMock(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- mock *libreOfficeMock
- expectError bool
- }{
- {
- scenario: "success",
- mock: &libreOfficeMock{
- pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
- return nil
- },
- },
- expectError: false,
- },
- {
- scenario: "ErrCoreDumped (first call)",
- mock: &libreOfficeMock{
- pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
- return ErrCoreDumped
- },
- },
- expectError: true,
- },
- {
- scenario: "ErrCoreDumped (second call)",
- mock: func() *libreOfficeMock {
- m := &libreOfficeMock{
- pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
- return ErrCoreDumped
- },
- }
- m.pdf(context.Background(), zap.NewNop(), "", "", Options{})
- return m
- }(),
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- err := tc.mock.pdf(context.Background(), zap.NewNop(), "", "", Options{})
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error from libreOfficeMock.pdf but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error from libreOfficeMock.pdf but got none")
- }
- })
- }
-}
diff --git a/pkg/modules/libreoffice/libreoffice.go b/pkg/modules/libreoffice/libreoffice.go
index 6dd04b82e..fe6573394 100644
--- a/pkg/modules/libreoffice/libreoffice.go
+++ b/pkg/modules/libreoffice/libreoffice.go
@@ -14,7 +14,7 @@ func init() {
gotenberg.MustRegisterModule(new(LibreOffice))
}
-// LibreOffice is a module which provides a route for converting documents to
+// LibreOffice is a module that provides a route for converting documents to
// PDF with LibreOffice.
type LibreOffice struct {
api libeofficeapi.Uno
diff --git a/pkg/modules/libreoffice/libreoffice_test.go b/pkg/modules/libreoffice/libreoffice_test.go
deleted file mode 100644
index 4d756a079..000000000
--- a/pkg/modules/libreoffice/libreoffice_test.go
+++ /dev/null
@@ -1,196 +0,0 @@
-package libreoffice
-
-import (
- "errors"
- "reflect"
- "testing"
-
- "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
- libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
-)
-
-func TestLibreOffice_Descriptor(t *testing.T) {
- descriptor := new(LibreOffice).Descriptor()
-
- actual := reflect.TypeOf(descriptor.New())
- expect := reflect.TypeOf(new(LibreOffice))
-
- if actual != expect {
- t.Errorf("expected '%s' but got '%s'", expect, actual)
- }
-}
-
-func TestLibreOffice_Provision(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- ctx *gotenberg.Context
- expectError bool
- }{
- {
- scenario: "no LibreOffice API provider",
- ctx: func() *gotenberg.Context {
- return gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: new(LibreOffice).Descriptor().FlagSet,
- },
- []gotenberg.ModuleDescriptor{},
- )
- }(),
- expectError: true,
- },
- {
- scenario: "no LibreOffice API from LibreOffice API provider",
- ctx: func() *gotenberg.Context {
- mod := &struct {
- gotenberg.ModuleMock
- libreofficeapi.ProviderMock
- }{}
- mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
- }
- mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
- return nil, errors.New("foo")
- }
-
- return gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: new(LibreOffice).Descriptor().FlagSet,
- },
- []gotenberg.ModuleDescriptor{
- mod.Descriptor(),
- },
- )
- }(),
- expectError: true,
- },
- {
- scenario: "no PDF engine provider",
- ctx: func() *gotenberg.Context {
- mod := &struct {
- gotenberg.ModuleMock
- libreofficeapi.ProviderMock
- }{}
- mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
- }
- mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
- return new(libreofficeapi.ApiMock), nil
- }
-
- return gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: new(LibreOffice).Descriptor().FlagSet,
- },
- []gotenberg.ModuleDescriptor{
- mod.Descriptor(),
- },
- )
- }(),
- expectError: true,
- },
- {
- scenario: "no PDF engine from PDF engine provider",
- ctx: func() *gotenberg.Context {
- mod := &struct {
- gotenberg.ModuleMock
- libreofficeapi.ProviderMock
- gotenberg.PdfEngineProviderMock
- }{}
- mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
- }
- mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
- return new(libreofficeapi.ApiMock), nil
- }
- mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
- return nil, errors.New("foo")
- }
-
- return gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: new(LibreOffice).Descriptor().FlagSet,
- },
- []gotenberg.ModuleDescriptor{
- mod.Descriptor(),
- },
- )
- }(),
- expectError: true,
- },
- {
- scenario: "provision success",
- ctx: func() *gotenberg.Context {
- mod := &struct {
- gotenberg.ModuleMock
- libreofficeapi.ProviderMock
- gotenberg.PdfEngineProviderMock
- }{}
- mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
- }
- mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
- return new(libreofficeapi.ApiMock), nil
- }
- mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
- return new(gotenberg.PdfEngineMock), nil
- }
-
- return gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: new(LibreOffice).Descriptor().FlagSet,
- },
- []gotenberg.ModuleDescriptor{
- mod.Descriptor(),
- },
- )
- }(),
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- mod := new(LibreOffice)
- err := mod.Provision(tc.ctx)
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestLibreOffice_Routes(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- expectRoutes int
- disableRoutes bool
- }{
- {
- scenario: "routes not disabled",
- expectRoutes: 1,
- disableRoutes: false,
- },
- {
- scenario: "routes disabled",
- expectRoutes: 0,
- disableRoutes: true,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- mod := new(LibreOffice)
- mod.disableRoutes = tc.disableRoutes
-
- routes, err := mod.Routes()
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectRoutes != len(routes) {
- t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes))
- }
- })
- }
-}
diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go
index b478c21be..3205aaaaa 100644
--- a/pkg/modules/libreoffice/pdfengine/pdfengine.go
+++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go
@@ -51,6 +51,16 @@ func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *zap.Logge
return fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
+// Split is not available in this implementation.
+func (engine *LibreOfficePdfEngine) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
+ return nil, fmt.Errorf("split PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
+// Flatten is not available in this implementation.
+func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return fmt.Errorf("flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
// Convert converts the given PDF to a specific PDF format. Currently, only the
// PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
@@ -81,6 +91,21 @@ func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *z
return fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
+// Encrypt is not available in this implementation.
+func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+ 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)
+}
+
+// ImportBookmarks is not available in this implementation.
+func (engine *LibreOfficePdfEngine) ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error {
+ return fmt.Errorf("import bookmarks into PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
// Interface guards.
var (
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil)
diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine_test.go b/pkg/modules/libreoffice/pdfengine/pdfengine_test.go
deleted file mode 100644
index 8353954d6..000000000
--- a/pkg/modules/libreoffice/pdfengine/pdfengine_test.go
+++ /dev/null
@@ -1,186 +0,0 @@
-package pdfengine
-
-import (
- "context"
- "errors"
- "reflect"
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
- "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
-)
-
-func TestLibreOfficePdfEngine_Descriptor(t *testing.T) {
- descriptor := new(LibreOfficePdfEngine).Descriptor()
-
- actual := reflect.TypeOf(descriptor.New())
- expect := reflect.TypeOf(new(LibreOfficePdfEngine))
-
- if actual != expect {
- t.Errorf("expected '%s' but got '%s'", expect, actual)
- }
-}
-
-func TestLibreOfficePdfEngine_Provider(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- ctx *gotenberg.Context
- expectError bool
- }{
- {
- scenario: "no LibreOffice API provider",
- ctx: gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
- },
- []gotenberg.ModuleDescriptor{},
- ),
- expectError: true,
- },
- {
- scenario: "no API from LibreOffice API provider",
- ctx: func() *gotenberg.Context {
- provider := &struct {
- gotenberg.ModuleMock
- api.ProviderMock
- }{}
- provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
- return provider
- }}
- }
- provider.LibreOfficeMock = func() (api.Uno, error) {
- return nil, errors.New("foo")
- }
-
- return gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
- },
- []gotenberg.ModuleDescriptor{
- provider.Descriptor(),
- },
- )
- }(),
- expectError: true,
- },
- {
- scenario: "provision success",
- ctx: func() *gotenberg.Context {
- provider := &struct {
- gotenberg.ModuleMock
- api.ProviderMock
- }{}
- provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
- return provider
- }}
- }
- provider.LibreOfficeMock = func() (api.Uno, error) {
- return new(api.ApiMock), nil
- }
-
- return gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
- },
- []gotenberg.ModuleDescriptor{
- provider.Descriptor(),
- },
- )
- }(),
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- engine := new(LibreOfficePdfEngine)
- err := engine.Provision(tc.ctx)
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestLibreOfficePdfEngine_Merge(t *testing.T) {
- engine := new(LibreOfficePdfEngine)
- err := engine.Merge(context.Background(), zap.NewNop(), nil, "")
-
- if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
- t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
- }
-}
-
-func TestLibreOfficePdfEngine_Convert(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- api api.Uno
- expectError bool
- }{
- {
- scenario: "convert success",
- api: &api.ApiMock{
- PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
- return nil
- },
- },
- expectError: false,
- },
- {
- scenario: "invalid PDF format",
- api: &api.ApiMock{
- PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
- return api.ErrInvalidPdfFormats
- },
- },
- expectError: true,
- },
- {
- scenario: "convert fail",
- api: &api.ApiMock{
- PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
- return errors.New("foo")
- },
- },
- expectError: true,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- engine := &LibreOfficePdfEngine{unoApi: tc.api}
- err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) {
- engine := new(LibreOfficePdfEngine)
- _, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "")
-
- if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
- t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
- }
-}
-
-func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) {
- engine := new(LibreOfficePdfEngine)
- err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "")
-
- if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
- t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
- }
-}
diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go
index b49677d64..854f347ab 100644
--- a/pkg/modules/libreoffice/routes.go
+++ b/pkg/modules/libreoffice/routes.go
@@ -1,7 +1,6 @@
package libreoffice
import (
- "encoding/json"
"errors"
"fmt"
"net/http"
@@ -28,14 +27,20 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
defaultOptions := libreofficeapi.DefaultOptions()
form := ctx.FormData()
+ splitMode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
- metadata := pdfengines.FormDataPdfMetadata(form)
+ metadata := pdfengines.FormDataPdfMetadata(form, false)
+ userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
+ embedPaths := pdfengines.FormDataPdfEmbeds(form)
+
+ zeroValuedSplitMode := gotenberg.SplitMode{}
var (
inputPaths []string
password string
landscape bool
nativePageRanges string
+ updateIndexes bool
exportFormFields bool
allowDuplicateFieldNames bool
exportBookmarks bool
@@ -57,6 +62,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
maxImageResolution int
nativePdfFormats bool
merge bool
+ flatten bool
)
err := form.
@@ -64,6 +70,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
String("password", &password, defaultOptions.Password).
Bool("landscape", &landscape, defaultOptions.Landscape).
String("nativePageRanges", &nativePageRanges, defaultOptions.PageRanges).
+ Bool("updateIndexes", &updateIndexes, defaultOptions.UpdateIndexes).
Bool("exportFormFields", &exportFormFields, defaultOptions.ExportFormFields).
Bool("allowDuplicateFieldNames", &allowDuplicateFieldNames, defaultOptions.AllowDuplicateFieldNames).
Bool("exportBookmarks", &exportBookmarks, defaultOptions.ExportBookmarks).
@@ -123,15 +130,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
}).
Bool("nativePdfFormats", &nativePdfFormats, true).
Bool("merge", &merge, false).
- Custom("metadata", func(value string) error {
- if len(value) > 0 {
- err := json.Unmarshal([]byte(value), &metadata)
- if err != nil {
- return fmt.Errorf("unmarshal metadata: %w", err)
- }
- }
- return nil
- }).
+ Bool("flatten", &flatten, false).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
@@ -144,6 +143,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
Password: password,
Landscape: landscape,
PageRanges: nativePageRanges,
+ UpdateIndexes: updateIndexes,
ExportFormFields: exportFormFields,
AllowDuplicateFieldNames: allowDuplicateFieldNames,
ExportBookmarks: exportBookmarks,
@@ -165,7 +165,9 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
MaxImageResolution: maxImageResolution,
}
- if nativePdfFormats {
+ if nativePdfFormats && splitMode == zeroValuedSplitMode {
+ // Only natively apply given PDF formats if we're not
+ // splitting the PDF later.
options.PdfFormats = pdfFormats
}
@@ -209,11 +211,51 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
outputPaths = []string{outputPath}
}
- if !nativePdfFormats {
- outputPaths, err = pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
+ if splitMode != zeroValuedSplitMode {
+ if !merge {
+ // document.docx -> document.docx.pdf, so that split naming
+ // document.docx_0.pdf, etc.
+ for i, inputPath := range inputPaths {
+ outputPath := fmt.Sprintf("%s.pdf", inputPath)
+
+ err = ctx.Rename(outputPaths[i], outputPath)
+ if err != nil {
+ return fmt.Errorf("rename output path: %w", err)
+ }
+
+ outputPaths[i] = outputPath
+ }
+ }
+
+ outputPaths, err = pdfengines.SplitPdfStub(ctx, engine, splitMode, outputPaths)
+ if err != nil {
+ return fmt.Errorf("split PDFs: %w", err)
+ }
+ }
+
+ if !nativePdfFormats || (nativePdfFormats && splitMode != zeroValuedSplitMode) {
+ convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDFs: %w", err)
}
+
+ if splitMode != zeroValuedSplitMode {
+ // The PDF has been split and split parts have been converted to
+ // specific formats. We want to keep the split naming.
+ for i, convertOutputPath := range convertOutputPaths {
+ err = ctx.Rename(convertOutputPath, outputPaths[i])
+ if err != nil {
+ return fmt.Errorf("rename output path: %w", err)
+ }
+ }
+ } else {
+ outputPaths = convertOutputPaths
+ }
+ }
+
+ err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, outputPaths)
+ if err != nil {
+ return fmt.Errorf("embed files into PDFs: %w", err)
}
err = pdfengines.WriteMetadataStub(ctx, engine, metadata, outputPaths)
@@ -221,7 +263,19 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
return fmt.Errorf("write metadata: %w", err)
}
- if len(outputPaths) > 1 {
+ if flatten {
+ err = pdfengines.FlattenStub(ctx, engine, outputPaths)
+ if err != nil {
+ return fmt.Errorf("flatten PDFs: %w", err)
+ }
+ }
+
+ err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
+ if err != nil {
+ return fmt.Errorf("encrypt PDFs: %w", err)
+ }
+
+ if len(outputPaths) > 1 && splitMode == zeroValuedSplitMode {
// If .zip archive, document.docx -> document.docx.pdf.
for i, inputPath := range inputPaths {
outputPath := fmt.Sprintf("%s.pdf", inputPath)
diff --git a/pkg/modules/libreoffice/routes_test.go b/pkg/modules/libreoffice/routes_test.go
deleted file mode 100644
index 041e41655..000000000
--- a/pkg/modules/libreoffice/routes_test.go
+++ /dev/null
@@ -1,598 +0,0 @@
-package libreoffice
-
-import (
- "context"
- "errors"
- "net/http"
- "slices"
- "testing"
-
- "github.com/labstack/echo/v4"
- "go.uber.org/zap"
-
- "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
- "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
- libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
-)
-
-func TestConvertRoute(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- ctx *api.ContextMock
- libreOffice libreofficeapi.Uno
- engine gotenberg.PdfEngine
- expectOptions libreofficeapi.Options
- expectError bool
- expectHttpError bool
- expectHttpStatus int
- expectOutputPathsCount int
- expectOutputPaths []string
- }{
- {
- scenario: "missing at least one mandatory file",
- ctx: &api.ContextMock{Context: new(api.Context)},
- libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
- return []string{".docx"}
- }},
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "invalid quality form field (not an integer)",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- })
- ctx.SetValues(map[string][]string{
- "quality": {
- "foo",
- },
- })
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
- return []string{".docx"}
- }},
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "invalid quality form field (< 1)",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- })
- ctx.SetValues(map[string][]string{
- "quality": {
- "0",
- },
- })
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
- return []string{".docx"}
- }},
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "invalid quality form field (> 100)",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- })
- ctx.SetValues(map[string][]string{
- "quality": {
- "101",
- },
- })
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
- return []string{".docx"}
- }},
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "invalid maxImageResolution form field (not an integer)",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- })
- ctx.SetValues(map[string][]string{
- "maxImageResolution": {
- "foo",
- },
- })
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
- return []string{".docx"}
- }},
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "invalid maxImageResolution form field (not in range)",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- })
- ctx.SetValues(map[string][]string{
- "maxImageResolution": {
- "1",
- },
- })
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
- return []string{".docx"}
- }},
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "invalid metadata form field",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- })
- ctx.SetValues(map[string][]string{
- "metadata": {
- "foo",
- },
- })
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
- return []string{".docx"}
- }},
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrPdfFormatNotSupported (nativePdfFormats)",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- })
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{
- PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
- return libreofficeapi.ErrInvalidPdfFormats
- },
- ExtensionsMock: func() []string {
- return []string{".docx"}
- },
- },
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrUnoException",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- })
- ctx.SetValues(map[string][]string{
- "nativePageRanges": {
- "foo",
- },
- })
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{
- PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
- return libreofficeapi.ErrUnoException
- },
- ExtensionsMock: func() []string {
- return []string{".docx"}
- },
- },
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "ErrRuntimeException",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- })
- ctx.SetValues(map[string][]string{
- "password": {
- "invalid",
- },
- })
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{
- PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
- return libreofficeapi.ErrRuntimeException
- },
- ExtensionsMock: func() []string {
- return []string{".docx"}
- },
- },
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "error from LibreOffice",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- })
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{
- PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
- return errors.New("foo")
- },
- ExtensionsMock: func() []string {
- return []string{".docx"}
- },
- },
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "PDF engine merge error",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- "document2.docx": "/document2.docx",
- })
- ctx.SetValues(map[string][]string{
- "merge": {
- "true",
- },
- })
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{
- PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
- return nil
- },
- ExtensionsMock: func() []string {
- return []string{".docx"}
- },
- },
- engine: &gotenberg.PdfEngineMock{
- MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
- return errors.New("foo")
- },
- },
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "PDF engine convert error",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- })
- ctx.SetValues(map[string][]string{
- "pdfa": {
- gotenberg.PdfA1b,
- },
- "nativePdfFormats": {
- "false",
- },
- })
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{
- PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
- return nil
- },
- ExtensionsMock: func() []string {
- return []string{".docx"}
- },
- },
- engine: &gotenberg.PdfEngineMock{
- ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
- return errors.New("foo")
- },
- },
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "PDF engine write metadata error",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- })
- ctx.SetValues(map[string][]string{
- "metadata": {
- "{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
- },
- })
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{
- PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
- return nil
- },
- ExtensionsMock: func() []string {
- return []string{".docx"}
- },
- },
- engine: &gotenberg.PdfEngineMock{
- WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
- return errors.New("foo")
- },
- },
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "cannot rename many files",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- "document2.docx": "/document2.docx",
- "document2.doc": "/document2.doc",
- })
- ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
- return errors.New("cannot rename")
- }})
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{
- PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
- return nil
- },
- ExtensionsMock: func() []string {
- return []string{".docx", ".doc"}
- },
- },
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "cannot add output paths",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- })
- ctx.SetCancelled(true)
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{
- PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
- return nil
- },
- ExtensionsMock: func() []string {
- return []string{".docx"}
- },
- },
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "success (single file)",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- "document2.docx": "/document2.docx",
- })
- ctx.SetValues(map[string][]string{
- "quality": {
- "100",
- },
- "maxImageResolution": {
- "1200",
- },
- "merge": {
- "true",
- },
- "pdfa": {
- gotenberg.PdfA1b,
- },
- "pdfua": {
- "true",
- },
- "nativePdfFormats": {
- "false",
- },
- "metadata": {
- "{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
- },
- })
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{
- PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
- return nil
- },
- ExtensionsMock: func() []string {
- return []string{".docx"}
- },
- },
- engine: &gotenberg.PdfEngineMock{
- ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
- return nil
- },
- MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
- return nil
- },
- WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
- return nil
- },
- },
- expectError: false,
- expectHttpError: false,
- expectOutputPathsCount: 1,
- },
- {
- scenario: "success (many files)",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- "document2.docx": "/document2.docx",
- "document2.doc": "/document2.doc",
- })
- ctx.SetValues(map[string][]string{
- "pdfa": {
- gotenberg.PdfA1b,
- },
- "pdfua": {
- "true",
- },
- "nativePdfFormats": {
- "false",
- },
- "metadata": {
- "{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
- },
- })
- ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
- return nil
- }})
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{
- PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
- return nil
- },
- ExtensionsMock: func() []string {
- return []string{".docx", ".doc"}
- },
- },
- engine: &gotenberg.PdfEngineMock{
- ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
- return nil
- },
- MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
- return nil
- },
- WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
- return nil
- },
- },
- expectError: false,
- expectHttpError: false,
- expectOutputPathsCount: 3,
- expectOutputPaths: []string{"/document.docx.pdf", "/document2.docx.pdf", "/document2.doc.pdf"},
- },
- {
- scenario: "success with native PDF/A & PDF/UA",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- })
- ctx.SetValues(map[string][]string{
- "pdfa": {
- gotenberg.PdfA1b,
- },
- "pdfua": {
- "true",
- },
- })
- return ctx
- }(),
- libreOffice: &libreofficeapi.ApiMock{
- PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
- return nil
- },
- ExtensionsMock: func() []string {
- return []string{".docx"}
- },
- },
- expectError: false,
- expectHttpError: false,
- expectOutputPathsCount: 1,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- tc.ctx.SetLogger(zap.NewNop())
- c := echo.New().NewContext(nil, nil)
- c.Set("context", tc.ctx.Context)
-
- err := convertRoute(tc.libreOffice, tc.engine).Handler(c)
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none", err)
- }
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- var httpErr api.HttpError
- isHttpError := errors.As(err, &httpErr)
-
- if tc.expectHttpError && !isHttpError {
- t.Errorf("expected an HTTP error but got: %v", err)
- }
-
- if !tc.expectHttpError && isHttpError {
- t.Errorf("expected no HTTP error but got one: %v", httpErr)
- }
-
- if err != nil && tc.expectHttpError && isHttpError {
- status, _ := httpErr.HttpError()
- if status != tc.expectHttpStatus {
- t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
- }
- }
-
- if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
- t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
- }
-
- for _, path := range tc.expectOutputPaths {
- if !slices.Contains(tc.ctx.OutputPaths(), path) {
- t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths())
- }
- }
- })
- }
-}
diff --git a/pkg/modules/logging/color.go b/pkg/modules/logging/color.go
new file mode 100644
index 000000000..e9a9d97b3
--- /dev/null
+++ b/pkg/modules/logging/color.go
@@ -0,0 +1,49 @@
+package logging
+
+import (
+ "fmt"
+
+ "go.uber.org/zap/zapcore"
+)
+
+// Foreground colors.
+// Copy pasted from go.uber.org/zap/internal/color/color.go
+const (
+ black color = iota + 30
+ red
+ green
+ yellow
+ blue
+ magenta
+ cyan
+ white
+)
+
+type color uint8
+
+func (c color) Add(s string) string {
+ return fmt.Sprintf("\x1b[%dm%s\x1b[0m", uint8(c), s)
+}
+
+func levelToColor(l zapcore.Level) color {
+ switch l {
+ case zapcore.DebugLevel:
+ return cyan
+ case zapcore.InfoLevel:
+ return blue
+ case zapcore.WarnLevel:
+ return yellow
+ case zapcore.ErrorLevel:
+ return red
+ case zapcore.DPanicLevel:
+ return red
+ case zapcore.PanicLevel:
+ return red
+ case zapcore.FatalLevel:
+ return red
+ case zapcore.InvalidLevel:
+ return red
+ default:
+ return red
+ }
+}
diff --git a/pkg/modules/logging/gcp.go b/pkg/modules/logging/gcp.go
new file mode 100644
index 000000000..e9ddc9f60
--- /dev/null
+++ b/pkg/modules/logging/gcp.go
@@ -0,0 +1,36 @@
+package logging
+
+import "go.uber.org/zap/zapcore"
+
+func gcpSeverity(l zapcore.Level) string {
+ switch l {
+ case zapcore.DebugLevel:
+ return "DEBUG"
+ case zapcore.InfoLevel:
+ return "INFO"
+ case zapcore.WarnLevel:
+ return "WARNING"
+ case zapcore.ErrorLevel:
+ return "ERROR"
+ case zapcore.DPanicLevel:
+ return "CRITICAL"
+ case zapcore.PanicLevel:
+ return "ALERT"
+ case zapcore.FatalLevel:
+ return "EMERGENCY"
+ case zapcore.InvalidLevel:
+ return "DEFAULT"
+ default:
+ return "DEFAULT"
+ }
+}
+
+func gcpSeverityEncoder(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) {
+ enc.AppendString(gcpSeverity(l))
+}
+
+func gcpSeverityColorEncoder(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) {
+ severity := gcpSeverity(l)
+ c := levelToColor(l)
+ enc.AppendString(c.Add(severity))
+}
diff --git a/pkg/modules/logging/logging.go b/pkg/modules/logging/logging.go
index d9d80023d..91f591b14 100644
--- a/pkg/modules/logging/logging.go
+++ b/pkg/modules/logging/logging.go
@@ -31,12 +31,13 @@ const (
textLoggingFormat = "text"
)
-// Logging is a module which implements the [gotenberg.LoggerProvider]
+// Logging is a module that implements the [gotenberg.LoggerProvider]
// interface.
type Logging struct {
- level string
- format string
- fieldsPrefix string
+ level string
+ format string
+ fieldsPrefix string
+ enableGcpFields bool
}
// Descriptor returns a [Logging]'s module descriptor.
@@ -48,6 +49,14 @@ func (log *Logging) Descriptor() gotenberg.ModuleDescriptor {
fs.String("log-level", infoLoggingLevel, fmt.Sprintf("Choose the level of logging detail. Options include %s, %s, %s, or %s", errorLoggingLevel, warnLoggingLevel, infoLoggingLevel, debugLoggingLevel))
fs.String("log-format", autoLoggingFormat, fmt.Sprintf("Specify the format of logging. Options include %s, %s, or %s", autoLoggingFormat, jsonLoggingFormat, textLoggingFormat))
fs.String("log-fields-prefix", "", "Prepend a specified prefix to each field in the logs")
+ fs.Bool("log-enable-gcp-fields", false, "Enable Google Cloud Platform fields - namely: time, message, severity")
+
+ // Deprecated flags.
+ fs.Bool("log-enable-gcp-severity", false, "Enable Google Cloud Platform severity mapping")
+ err := fs.MarkDeprecated("log-enable-gcp-severity", "use log-enable-gcp-fields instead")
+ if err != nil {
+ panic(err)
+ }
return fs
}(),
@@ -62,6 +71,7 @@ func (log *Logging) Provision(ctx *gotenberg.Context) error {
log.level = flags.MustString("log-level")
log.format = flags.MustString("log-format")
log.fieldsPrefix = flags.MustString("log-fields-prefix")
+ log.enableGcpFields = flags.MustDeprecatedBool("log-enable-gcp-severity", "log-enable-gcp-fields")
return nil
}
@@ -101,7 +111,7 @@ func (log *Logging) Logger(mod gotenberg.Module) (*zap.Logger, error) {
return nil, fmt.Errorf("get log level: %w", err)
}
- encoder, err := newLogEncoder(log.format)
+ encoder, err := newLogEncoder(log.format, log.enableGcpFields)
if err != nil {
return nil, fmt.Errorf("get log encoder: %w", err)
}
@@ -166,26 +176,44 @@ func newLogLevel(level string) (zapcore.Level, error) {
return lvl, nil
}
-func newLogEncoder(format string) (zapcore.Encoder, error) {
+func newLogEncoder(format string, gcpFields bool) (zapcore.Encoder, error) {
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
encCfg := zap.NewProductionEncoderConfig()
+ // Normalize the log format based on the output device.
+ if format == autoLoggingFormat {
+ if isTerminal {
+ format = textLoggingFormat
+ } else {
+ format = jsonLoggingFormat
+ }
+ }
+
+ // Use a human-readable time format if running in a terminal.
if isTerminal {
- // If interactive terminal, make output more human-readable by default.
- // Credits: https://github.com/caddyserver/caddy/blob/v2.1.1/logging.go#L671.
encCfg.EncodeTime = func(ts time.Time, encoder zapcore.PrimitiveArrayEncoder) {
encoder.AppendString(ts.Local().Format("2006/01/02 15:04:05.000"))
}
+ }
- if format == textLoggingFormat || format == autoLoggingFormat {
+ // Configure level encoding based on format and GCP settings.
+ if format == textLoggingFormat && isTerminal {
+ if gcpFields {
+ encCfg.EncodeLevel = gcpSeverityColorEncoder
+ } else {
encCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder
}
}
- if format == autoLoggingFormat && isTerminal {
- format = textLoggingFormat
- } else if format == autoLoggingFormat {
- format = jsonLoggingFormat
+ // For non-text (JSON) or when GCP fields are requested outside a terminal text output,
+ // adjust the configuration to use GCP-specific field names and encoders.
+ if gcpFields && format != textLoggingFormat {
+ encCfg.EncodeLevel = gcpSeverityEncoder
+ encCfg.TimeKey = "time"
+ encCfg.LevelKey = "severity"
+ encCfg.MessageKey = "message"
+ encCfg.EncodeTime = zapcore.ISO8601TimeEncoder
+ encCfg.EncodeDuration = zapcore.MillisDurationEncoder
}
switch format {
diff --git a/pkg/modules/logging/logging_test.go b/pkg/modules/logging/logging_test.go
deleted file mode 100644
index 8d484328b..000000000
--- a/pkg/modules/logging/logging_test.go
+++ /dev/null
@@ -1,347 +0,0 @@
-package logging
-
-import (
- "fmt"
- "reflect"
- "testing"
-
- "go.uber.org/zap"
- "go.uber.org/zap/zapcore"
- "go.uber.org/zap/zaptest/observer"
-
- "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
-)
-
-func TestLogging_Descriptor(t *testing.T) {
- descriptor := new(Logging).Descriptor()
-
- actual := reflect.TypeOf(descriptor.New())
- expect := reflect.TypeOf(new(Logging))
-
- if actual != expect {
- t.Errorf("expected '%s' but got '%s'", expect, actual)
- }
-}
-
-func TestLogging_Provision(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- level string
- format string
- fieldsPrefix string
- expectLevel string
- expectFormat string
- expectFieldsPrefix string
- }{
- {
- scenario: "default values",
- expectLevel: infoLoggingLevel,
- expectFormat: autoLoggingFormat,
- expectFieldsPrefix: "",
- },
- {
- scenario: "explicit values",
- level: "debug",
- format: "json",
- fieldsPrefix: "gotenberg",
- expectLevel: debugLoggingLevel,
- expectFormat: jsonLoggingFormat,
- expectFieldsPrefix: "gotenberg",
- },
- {
- scenario: "wrong values", // no validation at this point.
- level: "foo",
- format: "foo",
- expectLevel: "foo",
- expectFormat: "foo",
- expectFieldsPrefix: "",
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- var flags []string
-
- if tc.level != "" {
- flags = append(flags, "--log-level", tc.level)
- }
-
- if tc.format != "" {
- flags = append(flags, "--log-format", tc.format)
- }
-
- if tc.fieldsPrefix != "" {
- flags = append(flags, "--log-fields-prefix", tc.fieldsPrefix)
- }
-
- logging := new(Logging)
- fs := logging.Descriptor().FlagSet
-
- err := fs.Parse(flags)
- if err != nil {
- t.Fatalf("expected no error while parsing flags but got: %v", err)
- }
-
- ctx := gotenberg.NewContext(gotenberg.ParsedFlags{FlagSet: fs}, nil)
-
- err = logging.Provision(ctx)
- if err != nil {
- t.Fatalf("expected no error while provisioning but got: %v", err)
- }
-
- if logging.level != tc.expectLevel {
- t.Errorf("expected logging level '%s' but got '%s'", tc.expectLevel, logging.level)
- }
-
- if logging.format != tc.expectFormat {
- t.Errorf("expected logging format '%s' but got '%s'", tc.expectFormat, logging.format)
- }
-
- if logging.fieldsPrefix != tc.expectFieldsPrefix {
- t.Errorf("expected logging fields prefix '%s' but got '%s'", tc.expectFieldsPrefix, logging.fieldsPrefix)
- }
- })
- }
-}
-
-func TestLogging_Validate(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- level string
- format string
- expectError bool
- }{
- {
- scenario: "invalid level",
- level: "foo",
- expectError: true,
- },
- {
- scenario: "invalid format",
- level: debugLoggingLevel,
- format: "foo",
- expectError: true,
- },
- {
- scenario: "valid level and format",
- level: debugLoggingLevel,
- format: autoLoggingFormat,
- },
- } {
- logging := new(Logging)
- logging.level = tc.level
- logging.format = tc.format
-
- err := logging.Validate()
-
- if tc.expectError && err == nil {
- t.Errorf("%s: expected error but got: %v", tc.scenario, err)
- }
-
- if !tc.expectError && err != nil {
- t.Errorf("%s: expected no error but got: %v", tc.scenario, err)
- }
- }
-}
-
-func TestLogging_Logger(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- level string
- format string
- fieldsPrefix string
- expectError bool
- }{
- {
- scenario: "invalid level",
- level: "foo",
- expectError: true,
- },
- {
- scenario: "invalid format",
- level: debugLoggingLevel,
- format: "foo",
- expectError: true,
- },
- {
- scenario: "valid level and format",
- level: debugLoggingLevel,
- format: autoLoggingFormat,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- logging := new(Logging)
- logging.level = tc.level
- logging.format = tc.format
- logging.fieldsPrefix = tc.fieldsPrefix
-
- _, err := logging.Logger(&gotenberg.ModuleMock{
- DescriptorMock: func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "mock", New: nil}
- },
- })
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
- })
- }
-}
-
-func TestCustomCore(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- level zapcore.Level
- fieldsPrefix string
- expectEntry bool
- }{
- {
- scenario: "level enabled",
- level: zapcore.DebugLevel,
- fieldsPrefix: "gotenberg",
- expectEntry: true,
- },
- {
- scenario: "no fields prefix",
- level: zapcore.DebugLevel,
- expectEntry: true,
- },
- {
- scenario: "level disabled",
- level: zapcore.ErrorLevel,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- core, obsvr := observer.New(tc.level)
- lgr := zap.New(customCore{
- Core: core,
- fieldsPrefix: tc.fieldsPrefix,
- }).With(zap.String("a_field", "a value"))
-
- lgr.Debug("a debug message", zap.String("another_field", "another value"))
-
- entries := obsvr.TakeAll()
-
- if tc.expectEntry && len(entries) == 0 {
- t.Fatal("expected an entry")
- }
-
- if !tc.expectEntry && len(entries) != 0 {
- t.Fatal("expected no entry")
- }
-
- var prefix string
- if tc.fieldsPrefix != "" {
- prefix = tc.fieldsPrefix + "_"
- }
-
- for _, entry := range entries {
- fields := entry.Context
-
- if len(fields) != 2 {
- t.Fatalf("expected 2 fields but got %d", len(fields))
- }
-
- if fields[0].Key != fmt.Sprintf("%sa_field", prefix) {
- t.Errorf("expected 'gotenberg_a_field' but got '%s'", fields[0].Key)
- }
-
- if fields[1].Key != fmt.Sprintf("%sanother_field", prefix) {
- t.Errorf("expected 'gotenberg_another_field' but got '%s'", fields[1].Key)
- }
- }
- })
- }
-}
-
-func Test_newLogLevel(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- level string
- expectZapLevel zapcore.Level
- expectError bool
- }{
- {
- scenario: "error level",
- level: errorLoggingLevel,
- expectZapLevel: zapcore.ErrorLevel,
- },
- {
- scenario: "warning level",
- level: warnLoggingLevel,
- expectZapLevel: zapcore.WarnLevel,
- },
- {
- scenario: "info level",
- level: infoLoggingLevel,
- expectZapLevel: zapcore.InfoLevel,
- },
- {
- scenario: "debug level",
- level: debugLoggingLevel,
- expectZapLevel: zapcore.DebugLevel,
- },
- {
- scenario: "invalid level",
- level: "foo",
- expectZapLevel: zapcore.InvalidLevel,
- expectError: true,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- actual, err := newLogLevel(tc.level)
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectZapLevel != actual {
- t.Errorf("expected %d level but got %d", tc.expectZapLevel, actual)
- }
- })
- }
-}
-
-func Test_newLogEncoder(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- format string
- expectError bool
- }{
- {
- scenario: "auto format",
- format: autoLoggingFormat,
- },
- {
- scenario: "text format",
- format: textLoggingFormat,
- },
- {
- scenario: "json format",
- format: jsonLoggingFormat,
- },
- {
- scenario: "invalid format",
- format: "foo",
- expectError: true,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- _, err := newLogEncoder(tc.format)
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
-
- if !tc.expectError && err != nil {
- t.Errorf("expected no error but got: %v", err)
- }
- })
- }
-}
diff --git a/pkg/modules/pdfcpu/doc.go b/pkg/modules/pdfcpu/doc.go
index e68e2a61f..689a9a3d5 100644
--- a/pkg/modules/pdfcpu/doc.go
+++ b/pkg/modules/pdfcpu/doc.go
@@ -2,6 +2,8 @@
// interface using the pdfcpu command-line tool. This package allows for:
//
// 1. The merging of PDF files.
+// 2. Import bookmarks in a PDF file.
+// 3. The splitting of PDF files.
//
// See: https://github.com/pdfcpu/pdfcpu.
package pdfcpu
diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go
index ac2d53589..3047d5cd9 100644
--- a/pkg/modules/pdfcpu/pdfcpu.go
+++ b/pkg/modules/pdfcpu/pdfcpu.go
@@ -5,6 +5,11 @@ import (
"errors"
"fmt"
"os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strings"
+ "syscall"
"go.uber.org/zap"
@@ -51,6 +56,32 @@ func (engine *PdfCpu) Validate() error {
return nil
}
+// Debug returns additional debug data.
+func (engine *PdfCpu) Debug() map[string]interface{} {
+ debug := make(map[string]interface{})
+
+ cmd := exec.Command(engine.binPath, "version") //nolint:gosec
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+
+ output, err := cmd.Output()
+ if err != nil {
+ debug["version"] = err.Error()
+ return debug
+ }
+
+ debug["version"] = "Unable to determine pdfcpu version"
+
+ lines := strings.Split(string(output), "\n")
+ for _, line := range lines {
+ if strings.HasPrefix(line, "pdfcpu:") {
+ debug["version"] = strings.TrimSpace(strings.TrimPrefix(line, "pdfcpu:"))
+ break
+ }
+ }
+
+ return debug
+}
+
// Merge combines multiple PDFs into a single PDF.
func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string
@@ -70,6 +101,61 @@ func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths
return fmt.Errorf("merge PDFs with pdfcpu: %w", err)
}
+// Split splits a given PDF file.
+func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
+ var args []string
+
+ switch mode.Mode {
+ case gotenberg.SplitModeIntervals:
+ args = append(args, "split", "-mode", "span", inputPath, outputDirPath, mode.Span)
+ case gotenberg.SplitModePages:
+ if mode.Unify {
+ outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath))
+ args = append(args, "trim", "-pages", mode.Span, inputPath, outputPath)
+ break
+ }
+ args = append(args, "extract", "-mode", "page", "-pages", mode.Span, inputPath, outputDirPath)
+ default:
+ return nil, fmt.Errorf("split PDFs using mode '%s' with pdfcpu: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
+ }
+
+ cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
+ if err != nil {
+ return nil, fmt.Errorf("create command: %w", err)
+ }
+
+ _, err = cmd.Exec()
+ if err != nil {
+ return nil, fmt.Errorf("split PDFs with pdfcpu: %w", err)
+ }
+
+ var outputPaths []string
+ err = filepath.Walk(outputDirPath, func(path string, info os.FileInfo, pathErr error) error {
+ if pathErr != nil {
+ return pathErr
+ }
+ if info.IsDir() {
+ return nil
+ }
+ if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
+ outputPaths = append(outputPaths, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("walk directory to find resulting PDFs from split with pdfcpu: %w", err)
+ }
+
+ sort.Sort(digitSuffixSort(outputPaths))
+
+ return outputPaths, nil
+}
+
+// Flatten is not available in this implementation.
+func (engine *PdfCpu) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return fmt.Errorf("flatten PDF with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
// Convert is not available in this implementation.
func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
@@ -85,10 +171,92 @@ func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, met
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.
+func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+ if userPassword == "" {
+ return errors.New("user password cannot be empty")
+ }
+
+ if ownerPassword == "" {
+ ownerPassword = userPassword
+ }
+
+ var args []string
+ args = append(args, "encrypt")
+ args = append(args, "-mode", "aes")
+ args = append(args, "-upw", userPassword)
+ args = append(args, "-opw", ownerPassword)
+ args = append(args, "-perm", "all")
+ args = append(args, inputPath, inputPath)
+
+ cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
+ if err != nil {
+ return fmt.Errorf("create command: %w", err)
+ }
+
+ _, err = cmd.Exec()
+ if err != nil {
+ return fmt.Errorf("encrypt PDF with pdfcpu: %w", err)
+ }
+
+ return nil
+}
+
+// ImportBookmarks imports bookmarks from a JSON file into a given PDF.
+func (engine *PdfCpu) ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error {
+ if inputBookmarksPath == "" {
+ return nil
+ }
+
+ var args []string
+ args = append(args, "bookmarks", "import", inputPath, inputBookmarksPath, outputPath)
+
+ cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
+ if err != nil {
+ return fmt.Errorf("create command: %w", err)
+ }
+
+ _, err = cmd.Exec()
+ if err == nil {
+ return nil
+ }
+
+ return fmt.Errorf("import bookmarks into PDFs with pdfcpu: %w", err)
+}
+
// Interface guards.
var (
_ gotenberg.Module = (*PdfCpu)(nil)
_ gotenberg.Provisioner = (*PdfCpu)(nil)
_ gotenberg.Validator = (*PdfCpu)(nil)
+ _ gotenberg.Debuggable = (*PdfCpu)(nil)
_ gotenberg.PdfEngine = (*PdfCpu)(nil)
)
diff --git a/pkg/modules/pdfcpu/pdfcpu_test.go b/pkg/modules/pdfcpu/pdfcpu_test.go
deleted file mode 100644
index f009218a2..000000000
--- a/pkg/modules/pdfcpu/pdfcpu_test.go
+++ /dev/null
@@ -1,170 +0,0 @@
-package pdfcpu
-
-import (
- "context"
- "errors"
- "os"
- "reflect"
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
-)
-
-func TestPdfCpu_Descriptor(t *testing.T) {
- descriptor := new(PdfCpu).Descriptor()
-
- actual := reflect.TypeOf(descriptor.New())
- expect := reflect.TypeOf(new(PdfCpu))
-
- if actual != expect {
- t.Errorf("expected '%s' but got '%s'", expect, actual)
- }
-}
-
-func TestPdfCpu_Provision(t *testing.T) {
- engine := new(PdfCpu)
- ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil)
-
- err := engine.Provision(ctx)
- if err != nil {
- t.Errorf("expected no error but got: %v", err)
- }
-}
-
-func TestPdfCpu_Validate(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- binPath string
- expectError bool
- }{
- {
- scenario: "empty bin path",
- binPath: "",
- expectError: true,
- },
- {
- scenario: "bin path does not exist",
- binPath: "/foo",
- expectError: true,
- },
- {
- scenario: "validate success",
- binPath: os.Getenv("PDFTK_BIN_PATH"),
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- engine := new(PdfCpu)
- engine.binPath = tc.binPath
- err := engine.Validate()
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestPdfCpu_Merge(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- ctx context.Context
- inputPaths []string
- expectError bool
- }{
- {
- scenario: "invalid context",
- ctx: nil,
- expectError: true,
- },
- {
- scenario: "invalid input path",
- ctx: context.TODO(),
- inputPaths: []string{
- "foo",
- },
- expectError: true,
- },
- {
- scenario: "single file success",
- ctx: context.TODO(),
- inputPaths: []string{
- "/tests/test/testdata/pdfengines/sample1.pdf",
- },
- expectError: false,
- },
- {
- scenario: "many files success",
- ctx: context.TODO(),
- inputPaths: []string{
- "/tests/test/testdata/pdfengines/sample1.pdf",
- "/tests/test/testdata/pdfengines/sample2.pdf",
- },
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- engine := new(PdfCpu)
- err := engine.Provision(nil)
- if err != nil {
- t.Fatalf("expected error but got: %v", err)
- }
-
- fs := gotenberg.NewFileSystem()
- outputDir, err := fs.MkdirAll()
- if err != nil {
- t.Fatalf("expected error but got: %v", err)
- }
-
- defer func() {
- err = os.RemoveAll(fs.WorkingDirPath())
- if err != nil {
- t.Fatalf("expected no error while cleaning up but got: %v", err)
- }
- }()
-
- err = engine.Merge(tc.ctx, zap.NewNop(), tc.inputPaths, outputDir+"/foo.pdf")
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestPdfCpu_Convert(t *testing.T) {
- mod := new(PdfCpu)
- err := mod.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
-
- if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
- t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
- }
-}
-
-func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) {
- engine := new(PdfCpu)
- _, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "")
-
- if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
- t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
- }
-}
-
-func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) {
- engine := new(PdfCpu)
- err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "")
-
- if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
- t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
- }
-}
diff --git a/pkg/modules/pdfcpu/sort.go b/pkg/modules/pdfcpu/sort.go
new file mode 100644
index 000000000..8ee83487d
--- /dev/null
+++ b/pkg/modules/pdfcpu/sort.go
@@ -0,0 +1,68 @@
+package pdfcpu
+
+import (
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+)
+
+type digitSuffixSort []string
+
+func (s digitSuffixSort) Len() int {
+ return len(s)
+}
+
+func (s digitSuffixSort) Swap(i, j int) {
+ s[i], s[j] = s[j], s[i]
+}
+
+func (s digitSuffixSort) Less(i, j int) bool {
+ numI, restI := extractNumber(s[i])
+ numJ, restJ := extractNumber(s[j])
+
+ // If both strings contain a number, compare them numerically.
+ if numI != -1 && numJ != -1 {
+ if numI != numJ {
+ return numI < numJ
+ }
+ // If the numbers are equal, compare the "rest" strings.
+ return restI < restJ
+ }
+
+ // If one contains a number and the other doesn't, the one with the number
+ // comes first.
+ if numI != -1 {
+ return true
+ }
+ if numJ != -1 {
+ return false
+ }
+
+ // Neither has a number; fall back to lexicographical order.
+ return s[i] < s[j]
+}
+
+func extractNumber(str string) (int, string) {
+ str = filepath.Base(str)
+
+ // Check for a number immediately before an extension.
+ if matches := extensionSuffixRegexp.FindStringSubmatch(str); len(matches) > 3 {
+ if num, err := strconv.Atoi(matches[2]); err == nil {
+ // Remove the numeric block but keep the extension.
+ return num, matches[1] + matches[3]
+ }
+ }
+
+ // No numeric portion found.
+ return -1, str
+}
+
+// Regular expressions used by extractNumber.
+var (
+ // Matches a numeric block immediately before a file extension.
+ extensionSuffixRegexp = regexp.MustCompile(`^(.*?)(\d+)(\.[^.]+)$`)
+)
+
+// Interface guard.
+var _ sort.Interface = (*digitSuffixSort)(nil)
diff --git a/pkg/modules/pdfcpu/sort_test.go b/pkg/modules/pdfcpu/sort_test.go
new file mode 100644
index 000000000..e7d94a789
--- /dev/null
+++ b/pkg/modules/pdfcpu/sort_test.go
@@ -0,0 +1,29 @@
+package pdfcpu
+
+import (
+ "reflect"
+ "sort"
+ "testing"
+)
+
+func TestDigitSuffixSort(t *testing.T) {
+ for _, tc := range []struct {
+ scenario string
+ values []string
+ expectedSort []string
+ }{
+ {
+ scenario: "UUIDs with digit suffixes",
+ values: []string{"2521a33d-1fb4-4279-80fe-8a945285b8f4_12.pdf", "2521a33d-1fb4-4279-80fe-8a945285b8f4_1.pdf", "2521a33d-1fb4-4279-80fe-8a945285b8f4_10.pdf", "2521a33d-1fb4-4279-80fe-8a945285b8f4_3.pdf"},
+ expectedSort: []string{"2521a33d-1fb4-4279-80fe-8a945285b8f4_1.pdf", "2521a33d-1fb4-4279-80fe-8a945285b8f4_3.pdf", "2521a33d-1fb4-4279-80fe-8a945285b8f4_10.pdf", "2521a33d-1fb4-4279-80fe-8a945285b8f4_12.pdf"},
+ },
+ } {
+ t.Run(tc.scenario, func(t *testing.T) {
+ sort.Sort(digitSuffixSort(tc.values))
+
+ if !reflect.DeepEqual(tc.values, tc.expectedSort) {
+ t.Fatalf("expected %+v but got: %+v", tc.expectedSort, tc.values)
+ }
+ })
+ }
+}
diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go
index 4cbbc3eac..935b1d7c2 100644
--- a/pkg/modules/pdfengines/multi.go
+++ b/pkg/modules/pdfengines/multi.go
@@ -12,28 +12,43 @@ import (
)
type multiPdfEngines struct {
- mergeEngines []gotenberg.PdfEngine
- convertEngines []gotenberg.PdfEngine
- readMedataEngines []gotenberg.PdfEngine
- writeMedataEngines []gotenberg.PdfEngine
+ mergeEngines []gotenberg.PdfEngine
+ splitEngines []gotenberg.PdfEngine
+ flattenEngines []gotenberg.PdfEngine
+ convertEngines []gotenberg.PdfEngine
+ readMetadataEngines []gotenberg.PdfEngine
+ writeMetadataEngines []gotenberg.PdfEngine
+ passwordEngines []gotenberg.PdfEngine
+ embedEngines []gotenberg.PdfEngine
+ importBookmarksEngines []gotenberg.PdfEngine
}
func newMultiPdfEngines(
mergeEngines,
+ splitEngines,
+ flattenEngines,
convertEngines,
readMetadataEngines,
- writeMedataEngines []gotenberg.PdfEngine,
+ writeMetadataEngines,
+ passwordEngines,
+ embedEngines,
+ importBookmarksEngines []gotenberg.PdfEngine,
) *multiPdfEngines {
return &multiPdfEngines{
- mergeEngines: mergeEngines,
- convertEngines: convertEngines,
- readMedataEngines: readMetadataEngines,
- writeMedataEngines: writeMedataEngines,
+ mergeEngines: mergeEngines,
+ splitEngines: splitEngines,
+ flattenEngines: flattenEngines,
+ convertEngines: convertEngines,
+ readMetadataEngines: readMetadataEngines,
+ writeMetadataEngines: writeMetadataEngines,
+ passwordEngines: passwordEngines,
+ embedEngines: embedEngines,
+ importBookmarksEngines: importBookmarksEngines,
}
}
-// Merge tries to merge the given PDFs into a unique PDF thanks to its
-// children. If the context is done, it stops and returns an error.
+// Merge combines multiple PDF files into a single document using the first
+// available engine that supports PDF merging.
func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var err error
errChan := make(chan error, 1)
@@ -57,8 +72,69 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp
return fmt.Errorf("merge PDFs with multi PDF engines: %w", err)
}
-// Convert converts the given PDF to a specific PDF format. thanks to its
-// children. If the context is done, it stops and returns an error.
+type splitResult struct {
+ outputPaths []string
+ err error
+}
+
+// Split divides the PDF into separate pages using the first available engine
+// that supports PDF splitting.
+func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
+ var err error
+ var mu sync.Mutex // to safely append errors.
+
+ for _, engine := range multi.splitEngines {
+ resultChan := make(chan splitResult, 1)
+
+ go func(engine gotenberg.PdfEngine) {
+ outputPaths, err := engine.Split(ctx, logger, mode, inputPath, outputDirPath)
+ resultChan <- splitResult{outputPaths: outputPaths, err: err}
+ }(engine)
+
+ select {
+ case result := <-resultChan:
+ if result.err != nil {
+ mu.Lock()
+ err = multierr.Append(err, result.err)
+ mu.Unlock()
+ } else {
+ return result.outputPaths, nil
+ }
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ }
+
+ return nil, fmt.Errorf("split PDF with multi PDF engines: %w", err)
+}
+
+// Flatten merges existing annotation appearances with page content using the
+// first available engine that supports flattening.
+func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ var err error
+ errChan := make(chan error, 1)
+
+ for _, engine := range multi.flattenEngines {
+ go func(engine gotenberg.PdfEngine) {
+ errChan <- engine.Flatten(ctx, logger, inputPath)
+ }(engine)
+
+ select {
+ case mergeErr := <-errChan:
+ errored := multierr.AppendInto(&err, mergeErr)
+ if !errored {
+ return nil
+ }
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+
+ return fmt.Errorf("flatten PDF with multi PDF engines: %w", err)
+}
+
+// Convert transforms the given PDF to a specific PDF format using the first
+// available engine that supports PDF conversion.
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
var err error
errChan := make(chan error, 1)
@@ -87,20 +163,20 @@ type readMetadataResult struct {
err error
}
+// ReadMetadata extracts metadata from a PDF file using the first available
+// engine that supports metadata reading.
func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
var err error
var mu sync.Mutex // to safely append errors.
- resultChan := make(chan readMetadataResult, len(multi.readMedataEngines))
+ for _, engine := range multi.readMetadataEngines {
+ resultChan := make(chan readMetadataResult, 1)
- for _, engine := range multi.readMedataEngines {
go func(engine gotenberg.PdfEngine) {
metadata, err := engine.ReadMetadata(ctx, logger, inputPath)
resultChan <- readMetadataResult{metadata: metadata, err: err}
}(engine)
- }
- for range multi.readMedataEngines {
select {
case result := <-resultChan:
if result.err != nil {
@@ -118,11 +194,13 @@ func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logg
return nil, fmt.Errorf("read PDF metadata with multi PDF engines: %w", err)
}
+// WriteMetadata embeds metadata into a PDF file using the first available
+// engine that supports metadata writing.
func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
var err error
errChan := make(chan error, 1)
- for _, engine := range multi.writeMedataEngines {
+ for _, engine := range multi.writeMetadataEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.WriteMetadata(ctx, logger, metadata, inputPath)
}(engine)
@@ -141,6 +219,81 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log
return fmt.Errorf("write PDF metadata with multi PDF engines: %w", err)
}
+// Encrypt adds password protection to a PDF file using the first available
+// engine that supports password protection.
+func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+ var err error
+ errChan := make(chan error, 1)
+
+ for _, engine := range multi.passwordEngines {
+ go func(engine gotenberg.PdfEngine) {
+ errChan <- engine.Encrypt(ctx, logger, inputPath, userPassword, ownerPassword)
+ }(engine)
+
+ select {
+ case protectErr := <-errChan:
+ errored := multierr.AppendInto(&err, protectErr)
+ if !errored {
+ return nil
+ }
+ case <-ctx.Done():
+ return ctx.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)
+}
+
+// ImportBookmarks imports bookmarks from a JSON file into a PDF using the first available
+// engine that supports bookmark importing.
+func (multi *multiPdfEngines) ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error {
+ var err error
+ errChan := make(chan error, 1)
+
+ for _, engine := range multi.importBookmarksEngines {
+ go func(engine gotenberg.PdfEngine) {
+ errChan <- engine.ImportBookmarks(ctx, logger, inputPath, inputBookmarksPath, outputPath)
+ }(engine)
+
+ select {
+ case mergeErr := <-errChan:
+ errored := multierr.AppendInto(&err, mergeErr)
+ if !errored {
+ return nil
+ }
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+
+ return fmt.Errorf("import bookmarks into PDF with multi PDF engines: %w", err)
+}
+
// Interface guards.
var (
_ gotenberg.PdfEngine = (*multiPdfEngines)(nil)
diff --git a/pkg/modules/pdfengines/multi_test.go b/pkg/modules/pdfengines/multi_test.go
index 00e706d78..496a4403d 100644
--- a/pkg/modules/pdfengines/multi_test.go
+++ b/pkg/modules/pdfengines/multi_test.go
@@ -19,25 +19,22 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
}{
{
scenario: "nominal behavior",
- engine: newMultiPdfEngines(
- []gotenberg.PdfEngine{
+ engine: &multiPdfEngines{
+ mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
},
- nil,
- nil,
- nil,
- ),
+ },
ctx: context.Background(),
expectError: false,
},
{
scenario: "at least one engine does not return an error",
- engine: newMultiPdfEngines(
- []gotenberg.PdfEngine{
+ engine: &multiPdfEngines{
+ mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
@@ -49,17 +46,14 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
},
},
},
- nil,
- nil,
- nil,
- ),
+ },
ctx: context.Background(),
expectError: false,
},
{
scenario: "all engines return an error",
- engine: newMultiPdfEngines(
- []gotenberg.PdfEngine{
+ engine: &multiPdfEngines{
+ mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
@@ -71,27 +65,21 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
},
},
},
- nil,
- nil,
- nil,
- ),
+ },
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
- engine: newMultiPdfEngines(
- []gotenberg.PdfEngine{
+ engine: &multiPdfEngines{
+ mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
},
- nil,
- nil,
- nil,
- ),
+ },
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
@@ -115,6 +103,279 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
}
}
+func TestMultiPdfEngines_Encrypt(t *testing.T) {
+ for _, tc := range []struct {
+ scenario string
+ engine *multiPdfEngines
+ ctx context.Context
+ expectError bool
+ }{
+ {
+ scenario: "nominal behavior",
+ engine: &multiPdfEngines{
+ passwordEngines: []gotenberg.PdfEngine{
+ &gotenberg.PdfEngineMock{
+ EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+ return nil
+ },
+ },
+ },
+ },
+ ctx: context.Background(),
+ },
+ {
+ scenario: "at least one engine does not return an error",
+ engine: &multiPdfEngines{
+ passwordEngines: []gotenberg.PdfEngine{
+ &gotenberg.PdfEngineMock{
+ EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+ return errors.New("foo")
+ },
+ },
+ &gotenberg.PdfEngineMock{
+ EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+ return nil
+ },
+ },
+ },
+ },
+ ctx: context.Background(),
+ },
+ {
+ scenario: "all engines return an error",
+ engine: &multiPdfEngines{
+ passwordEngines: []gotenberg.PdfEngine{
+ &gotenberg.PdfEngineMock{
+ EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+ return errors.New("foo")
+ },
+ },
+ &gotenberg.PdfEngineMock{
+ EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+ return errors.New("foo")
+ },
+ },
+ },
+ },
+ ctx: context.Background(),
+ expectError: true,
+ },
+ {
+ scenario: "context expired",
+ engine: &multiPdfEngines{
+ passwordEngines: []gotenberg.PdfEngine{
+ &gotenberg.PdfEngineMock{
+ EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+ return nil
+ },
+ },
+ },
+ },
+ ctx: func() context.Context {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ return ctx
+ }(),
+ expectError: true,
+ },
+ } {
+ t.Run(tc.scenario, func(t *testing.T) {
+ err := tc.engine.Encrypt(tc.ctx, zap.NewNop(), "", "", "")
+
+ if !tc.expectError && err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ if tc.expectError && err == nil {
+ t.Fatal("expected error but got none")
+ }
+ })
+ }
+}
+
+func TestMultiPdfEngines_Split(t *testing.T) {
+ for _, tc := range []struct {
+ scenario string
+ engine *multiPdfEngines
+ ctx context.Context
+ expectError bool
+ }{
+ {
+ scenario: "nominal behavior",
+ engine: &multiPdfEngines{
+ splitEngines: []gotenberg.PdfEngine{
+ &gotenberg.PdfEngineMock{
+ SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
+ return nil, nil
+ },
+ },
+ },
+ },
+ ctx: context.Background(),
+ },
+ {
+ scenario: "at least one engine does not return an error",
+ engine: &multiPdfEngines{
+ splitEngines: []gotenberg.PdfEngine{
+ &gotenberg.PdfEngineMock{
+ SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
+ return nil, errors.New("foo")
+ },
+ },
+ &gotenberg.PdfEngineMock{
+ SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
+ return nil, nil
+ },
+ },
+ },
+ },
+ ctx: context.Background(),
+ },
+ {
+ scenario: "all engines return an error",
+ engine: &multiPdfEngines{
+ splitEngines: []gotenberg.PdfEngine{
+ &gotenberg.PdfEngineMock{
+ SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
+ return nil, errors.New("foo")
+ },
+ },
+ &gotenberg.PdfEngineMock{
+ SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
+ return nil, errors.New("foo")
+ },
+ },
+ },
+ },
+ ctx: context.Background(),
+ expectError: true,
+ },
+ {
+ scenario: "context expired",
+ engine: &multiPdfEngines{
+ splitEngines: []gotenberg.PdfEngine{
+ &gotenberg.PdfEngineMock{
+ SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
+ return nil, nil
+ },
+ },
+ },
+ },
+ ctx: func() context.Context {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ return ctx
+ }(),
+ expectError: true,
+ },
+ } {
+ t.Run(tc.scenario, func(t *testing.T) {
+ _, err := tc.engine.Split(tc.ctx, zap.NewNop(), gotenberg.SplitMode{}, "", "")
+
+ if !tc.expectError && err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ if tc.expectError && err == nil {
+ t.Fatal("expected error but got none")
+ }
+ })
+ }
+}
+
+func TestMultiPdfEngines_Flatten(t *testing.T) {
+ for _, tc := range []struct {
+ scenario string
+ engine *multiPdfEngines
+ ctx context.Context
+ expectError bool
+ }{
+ {
+ scenario: "nominal behavior",
+ engine: &multiPdfEngines{
+ flattenEngines: []gotenberg.PdfEngine{
+ &gotenberg.PdfEngineMock{
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return nil
+ },
+ },
+ },
+ },
+ ctx: context.Background(),
+ },
+ {
+ scenario: "at least one engine does not return an error",
+ engine: &multiPdfEngines{
+ flattenEngines: []gotenberg.PdfEngine{
+ &gotenberg.PdfEngineMock{
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return errors.New("foo")
+ },
+ },
+ &gotenberg.PdfEngineMock{
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return nil
+ },
+ },
+ },
+ },
+ ctx: context.Background(),
+ },
+ {
+ scenario: "all engines return an error",
+ engine: &multiPdfEngines{
+ flattenEngines: []gotenberg.PdfEngine{
+ &gotenberg.PdfEngineMock{
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return errors.New("foo")
+ },
+ },
+ &gotenberg.PdfEngineMock{
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return errors.New("foo")
+ },
+ },
+ },
+ },
+ ctx: context.Background(),
+ expectError: true,
+ },
+ {
+ scenario: "context expired",
+ engine: &multiPdfEngines{
+ flattenEngines: []gotenberg.PdfEngine{
+ &gotenberg.PdfEngineMock{
+ FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return nil
+ },
+ },
+ },
+ },
+ ctx: func() context.Context {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ return ctx
+ }(),
+ expectError: true,
+ },
+ } {
+ t.Run(tc.scenario, func(t *testing.T) {
+ err := tc.engine.Flatten(tc.ctx, zap.NewNop(), "")
+
+ if !tc.expectError && err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ if tc.expectError && err == nil {
+ t.Fatal("expected error but got none")
+ }
+ })
+ }
+}
+
func TestMultiPdfEngines_Convert(t *testing.T) {
for _, tc := range []struct {
scenario string
@@ -124,25 +385,21 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
}{
{
scenario: "nominal behavior",
- engine: newMultiPdfEngines(
- nil,
- []gotenberg.PdfEngine{
+ engine: &multiPdfEngines{
+ convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
},
- nil,
- nil,
- ),
+ },
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
- engine: newMultiPdfEngines(
- nil,
- []gotenberg.PdfEngine{
+ engine: &multiPdfEngines{
+ convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
@@ -154,16 +411,13 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
},
},
},
- nil,
- nil,
- ),
+ },
ctx: context.Background(),
},
{
scenario: "all engines return an error",
- engine: newMultiPdfEngines(
- nil,
- []gotenberg.PdfEngine{
+ engine: &multiPdfEngines{
+ convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
@@ -175,26 +429,21 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
},
},
},
- nil,
- nil,
- ),
+ },
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
- engine: newMultiPdfEngines(
- nil,
- []gotenberg.PdfEngine{
+ engine: &multiPdfEngines{
+ convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
},
- nil,
- nil,
- ),
+ },
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
@@ -227,26 +476,21 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
}{
{
scenario: "nominal behavior",
- engine: newMultiPdfEngines(
- nil,
- nil,
- []gotenberg.PdfEngine{
+ engine: &multiPdfEngines{
+ readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return make(map[string]interface{}), nil
},
},
},
- nil,
- ),
+ },
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
- engine: newMultiPdfEngines(
- nil,
- nil,
- []gotenberg.PdfEngine{
+ engine: &multiPdfEngines{
+ readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, errors.New("foo")
@@ -258,16 +502,13 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
},
},
},
- nil,
- ),
+ },
ctx: context.Background(),
},
{
scenario: "all engines return an error",
- engine: newMultiPdfEngines(
- nil,
- nil,
- []gotenberg.PdfEngine{
+ engine: &multiPdfEngines{
+ readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, errors.New("foo")
@@ -279,25 +520,21 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
},
},
},
- nil,
- ),
+ },
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
- engine: newMultiPdfEngines(
- nil,
- nil,
- []gotenberg.PdfEngine{
+ engine: &multiPdfEngines{
+ readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return make(map[string]interface{}), nil
},
},
},
- nil,
- ),
+ },
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
@@ -330,27 +567,21 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
}{
{
scenario: "nominal behavior",
- engine: newMultiPdfEngines(
- nil,
- nil,
- nil,
- []gotenberg.PdfEngine{
+ engine: &multiPdfEngines{
+ writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
},
},
- ),
+ },
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
- engine: newMultiPdfEngines(
- nil,
- nil,
- nil,
- []gotenberg.PdfEngine{
+ engine: &multiPdfEngines{
+ writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo")
@@ -362,16 +593,13 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
},
},
},
- ),
+ },
ctx: context.Background(),
},
{
scenario: "all engines return an error",
- engine: newMultiPdfEngines(
- nil,
- nil,
- nil,
- []gotenberg.PdfEngine{
+ engine: &multiPdfEngines{
+ writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo")
@@ -383,24 +611,21 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
},
},
},
- ),
+ },
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
- engine: newMultiPdfEngines(
- nil,
- nil,
- nil,
- []gotenberg.PdfEngine{
+ engine: &multiPdfEngines{
+ writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
},
},
- ),
+ },
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go
index 7bd000187..dbbd1c1c8 100644
--- a/pkg/modules/pdfengines/pdfengines.go
+++ b/pkg/modules/pdfengines/pdfengines.go
@@ -27,12 +27,17 @@ func init() {
// the [api.Router] interface to expose relevant PDF processing routes if
// enabled.
type PdfEngines struct {
- mergeNames []string
- convertNames []string
- readMetadataNames []string
- writeMedataNames []string
- engines []gotenberg.PdfEngine
- disableRoutes bool
+ mergeNames []string
+ splitNames []string
+ flattenNames []string
+ convertNames []string
+ readMetadataNames []string
+ writeMetadataNames []string
+ encryptNames []string
+ embedNames []string
+ importBookmarksNames []string
+ engines []gotenberg.PdfEngine
+ disableRoutes bool
}
// Descriptor returns a PdfEngines' module descriptor.
@@ -42,11 +47,17 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("pdfengines", flag.ExitOnError)
fs.StringSlice("pdfengines-merge-engines", []string{"qpdf", "pdfcpu", "pdftk"}, "Set the PDF engines and their order for the merge feature - empty means all")
+ fs.StringSlice("pdfengines-split-engines", []string{"pdfcpu", "qpdf", "pdftk"}, "Set the PDF engines and their order for the split feature - empty means all")
+ fs.StringSlice("pdfengines-flatten-engines", []string{"qpdf"}, "Set the PDF engines and their order for the flatten feature - empty means all")
fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert 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-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.StringSlice("pdfengines-import-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the import bookmarks feature - empty means all")
fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
+ // Deprecated flags.
fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the default PDF engines and their default order - all by default")
err := fs.MarkDeprecated("pdfengines-engines", "use other flags for a more granular selection of PDF engines per method")
if err != nil {
@@ -64,9 +75,14 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
mergeNames := flags.MustStringSlice("pdfengines-merge-engines")
+ splitNames := flags.MustStringSlice("pdfengines-split-engines")
+ flattenNames := flags.MustStringSlice("pdfengines-flatten-engines")
convertNames := flags.MustStringSlice("pdfengines-convert-engines")
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
+ encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
+ embedNames := flags.MustStringSlice("pdfengines-embed-engines")
+ importBookmarksNames := flags.MustStringSlice("pdfengines-import-bookmarks-engines")
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
engines, err := ctx.Modules(new(gotenberg.PdfEngine))
@@ -85,7 +101,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
defaultNames[i] = engine.(gotenberg.Module).Descriptor().ID
}
- // Example in case of deprecated module name.
+ // Example in the case of deprecated module name.
//for i, name := range defaultNames {
// if name == "unoconv-pdfengine" || name == "uno-pdfengine" {
// logger.Warn(fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name))
@@ -98,6 +114,16 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.mergeNames = mergeNames
}
+ mod.splitNames = defaultNames
+ if len(splitNames) > 0 {
+ mod.splitNames = splitNames
+ }
+
+ mod.flattenNames = defaultNames
+ if len(flattenNames) > 0 {
+ mod.flattenNames = flattenNames
+ }
+
mod.convertNames = defaultNames
if len(convertNames) > 0 {
mod.convertNames = convertNames
@@ -108,9 +134,24 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.readMetadataNames = readMetadataNames
}
- mod.writeMedataNames = defaultNames
+ mod.writeMetadataNames = defaultNames
if len(writeMetadataNames) > 0 {
- mod.writeMedataNames = writeMetadataNames
+ mod.writeMetadataNames = writeMetadataNames
+ }
+
+ mod.encryptNames = defaultNames
+ if len(encryptNames) > 0 {
+ mod.encryptNames = encryptNames
+ }
+
+ mod.embedNames = defaultNames
+ if len(embedNames) > 0 {
+ mod.embedNames = embedNames
+ }
+
+ mod.importBookmarksNames = defaultNames
+ if len(importBookmarksNames) > 0 {
+ mod.importBookmarksNames = importBookmarksNames
}
return nil
@@ -161,9 +202,14 @@ func (mod *PdfEngines) Validate() error {
}
findNonExistingEngines(mod.mergeNames)
+ findNonExistingEngines(mod.splitNames)
+ findNonExistingEngines(mod.flattenNames)
findNonExistingEngines(mod.convertNames)
findNonExistingEngines(mod.readMetadataNames)
- findNonExistingEngines(mod.writeMedataNames)
+ findNonExistingEngines(mod.writeMetadataNames)
+ findNonExistingEngines(mod.encryptNames)
+ findNonExistingEngines(mod.embedNames)
+ findNonExistingEngines(mod.importBookmarksNames)
if len(nonExistingEngines) == 0 {
return nil
@@ -177,9 +223,13 @@ func (mod *PdfEngines) Validate() error {
func (mod *PdfEngines) SystemMessages() []string {
return []string{
fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
+ fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames[:], " ")),
+ fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames[:], " ")),
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
- fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")),
+ fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")),
+ fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames[:], " ")),
+ fmt.Sprintf("import bookmarks engines - %s", strings.Join(mod.importBookmarksNames[:], " ")),
}
}
@@ -201,9 +251,14 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
return newMultiPdfEngines(
engines(mod.mergeNames),
+ engines(mod.splitNames),
+ engines(mod.flattenNames),
engines(mod.convertNames),
engines(mod.readMetadataNames),
- engines(mod.writeMedataNames),
+ engines(mod.writeMetadataNames),
+ engines(mod.encryptNames),
+ engines(mod.embedNames),
+ engines(mod.importBookmarksNames),
), nil
}
@@ -222,9 +277,13 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
return []api.Route{
mergeRoute(engine),
+ splitRoute(engine),
+ flattenRoute(engine),
convertRoute(engine),
readMetadataRoute(engine),
writeMetadataRoute(engine),
+ encryptRoute(engine),
+ embedRoute(engine),
}, nil
}
diff --git a/pkg/modules/pdfengines/pdfengines_test.go b/pkg/modules/pdfengines/pdfengines_test.go
deleted file mode 100644
index fe999432d..000000000
--- a/pkg/modules/pdfengines/pdfengines_test.go
+++ /dev/null
@@ -1,396 +0,0 @@
-package pdfengines
-
-import (
- "errors"
- "fmt"
- "reflect"
- "strings"
- "testing"
-
- "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
-)
-
-func TestPdfEngines_Descriptor(t *testing.T) {
- descriptor := new(PdfEngines).Descriptor()
-
- actual := reflect.TypeOf(descriptor.New())
- expect := reflect.TypeOf(new(PdfEngines))
-
- if actual != expect {
- t.Errorf("expected '%s' but got '%s'", expect, actual)
- }
-}
-
-func TestPdfEngines_Provision(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- ctx *gotenberg.Context
- expectedMergePdfEngines []string
- expectedConvertPdfEngines []string
- expectedReadMetadataPdfEngines []string
- expectedWriteMetadataPdfEngines []string
- expectError bool
- }{
- {
- scenario: "no selection from user",
- ctx: func() *gotenberg.Context {
- provider := &struct {
- gotenberg.ModuleMock
- }{}
- provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
- return provider
- }}
- }
-
- engine := &struct {
- gotenberg.ModuleMock
- gotenberg.ValidatorMock
- gotenberg.PdfEngineMock
- }{}
- engine.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine }}
- }
- engine.ValidateMock = func() error {
- return nil
- }
-
- return gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: new(PdfEngines).Descriptor().FlagSet,
- },
- []gotenberg.ModuleDescriptor{
- provider.Descriptor(),
- engine.Descriptor(),
- },
- )
- }(),
- expectedMergePdfEngines: []string{"qpdf", "pdfcpu", "pdftk"},
- expectedConvertPdfEngines: []string{"libreoffice-pdfengine"},
- expectedReadMetadataPdfEngines: []string{"exiftool"},
- expectedWriteMetadataPdfEngines: []string{"exiftool"},
- expectError: false,
- },
- {
- scenario: "selection from user",
- ctx: func() *gotenberg.Context {
- provider := &struct {
- gotenberg.ModuleMock
- }{}
- provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
- return provider
- }}
- }
- engine1 := &struct {
- gotenberg.ModuleMock
- gotenberg.ValidatorMock
- gotenberg.PdfEngineMock
- }{}
- engine1.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "a", New: func() gotenberg.Module { return engine1 }}
- }
- engine1.ValidateMock = func() error {
- return nil
- }
-
- engine2 := &struct {
- gotenberg.ModuleMock
- gotenberg.ValidatorMock
- gotenberg.PdfEngineMock
- }{}
- engine2.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "b", New: func() gotenberg.Module { return engine2 }}
- }
- engine2.ValidateMock = func() error {
- return nil
- }
-
- fs := new(PdfEngines).Descriptor().FlagSet
- err := fs.Parse([]string{"--pdfengines-merge-engines=b", "--pdfengines-convert-engines=b", "--pdfengines-read-metadata-engines=a", "--pdfengines-write-metadata-engines=a"})
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- return gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: fs,
- },
- []gotenberg.ModuleDescriptor{
- provider.Descriptor(),
- engine1.Descriptor(),
- engine2.Descriptor(),
- },
- )
- }(),
-
- expectedMergePdfEngines: []string{"b"},
- expectedConvertPdfEngines: []string{"b"},
- expectedReadMetadataPdfEngines: []string{"a"},
- expectedWriteMetadataPdfEngines: []string{"a"},
- expectError: false,
- },
- {
- scenario: "no valid PDF engine",
- ctx: func() *gotenberg.Context {
- provider := &struct {
- gotenberg.ModuleMock
- }{}
- provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
- return provider
- }}
- }
- engine := &struct {
- gotenberg.ModuleMock
- gotenberg.ValidatorMock
- gotenberg.PdfEngineMock
- }{}
- engine.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine }}
- }
- engine.ValidateMock = func() error {
- return errors.New("foo")
- }
-
- return gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: new(PdfEngines).Descriptor().FlagSet,
- },
- []gotenberg.ModuleDescriptor{
- provider.Descriptor(),
- engine.Descriptor(),
- },
- )
- }(),
- expectError: true,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- mod := new(PdfEngines)
- err := mod.Provision(tc.ctx)
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
-
- if len(tc.expectedMergePdfEngines) != len(mod.mergeNames) {
- t.Fatalf("expected %d merge names but got %d", len(tc.expectedMergePdfEngines), len(mod.mergeNames))
- }
-
- if len(tc.expectedConvertPdfEngines) != len(mod.convertNames) {
- t.Fatalf("expected %d convert names but got %d", len(tc.expectedConvertPdfEngines), len(mod.convertNames))
- }
-
- if len(tc.expectedReadMetadataPdfEngines) != len(mod.readMetadataNames) {
- t.Fatalf("expected %d read metadata names but got %d", len(tc.expectedReadMetadataPdfEngines), len(mod.readMetadataNames))
- }
-
- if len(tc.expectedWriteMetadataPdfEngines) != len(mod.writeMedataNames) {
- t.Fatalf("expected %d write metadata names but got %d", len(tc.expectedWriteMetadataPdfEngines), len(mod.writeMedataNames))
- }
-
- for index, name := range mod.mergeNames {
- if name != tc.expectedMergePdfEngines[index] {
- t.Fatalf("expected merge name at index %d to be %s, but got: %s", index, name, tc.expectedMergePdfEngines[index])
- }
- }
-
- for index, name := range mod.convertNames {
- if name != tc.expectedConvertPdfEngines[index] {
- t.Fatalf("expected convert name at index %d to be %s, but got: %s", index, name, tc.expectedConvertPdfEngines[index])
- }
- }
-
- for index, name := range mod.readMetadataNames {
- if name != tc.expectedReadMetadataPdfEngines[index] {
- t.Fatalf("expected read metadata name at index %d to be %s, but got: %s", index, name, tc.expectedReadMetadataPdfEngines[index])
- }
- }
-
- for index, name := range mod.writeMedataNames {
- if name != tc.expectedWriteMetadataPdfEngines[index] {
- t.Fatalf("expected write metadat name at index %d to be %s, but got: %s", index, name, tc.expectedWriteMetadataPdfEngines[index])
- }
- }
- })
- }
-}
-
-func TestPdfEngines_Validate(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- names []string
- engines []gotenberg.PdfEngine
- expectError bool
- }{
- {
- scenario: "existing PDF engine",
- names: []string{"foo"},
- engines: func() []gotenberg.PdfEngine {
- engine := &struct {
- gotenberg.ModuleMock
- gotenberg.PdfEngineMock
- }{}
- engine.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine }}
- }
-
- return []gotenberg.PdfEngine{
- engine,
- }
- }(),
- expectError: false,
- },
- {
- scenario: "non-existing bar PDF engine",
- names: []string{"foo", "bar", "baz"},
- engines: func() []gotenberg.PdfEngine {
- engine1 := &struct {
- gotenberg.ModuleMock
- gotenberg.PdfEngineMock
- }{}
- engine1.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine1 }}
- }
-
- engine2 := &struct {
- gotenberg.ModuleMock
- gotenberg.PdfEngineMock
- }{}
- engine2.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "baz", New: func() gotenberg.Module { return engine2 }}
- }
-
- return []gotenberg.PdfEngine{
- engine1,
- engine2,
- }
- }(),
- expectError: true,
- },
- {
- scenario: "no PDF engine",
- expectError: true,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- mod := PdfEngines{
- mergeNames: tc.names,
- convertNames: tc.names,
- readMetadataNames: tc.names,
- writeMedataNames: tc.names,
- engines: tc.engines,
- }
-
- err := mod.Validate()
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestPdfEngines_SystemMessages(t *testing.T) {
- mod := new(PdfEngines)
- mod.mergeNames = []string{"foo", "bar"}
- mod.convertNames = []string{"foo", "bar"}
- mod.readMetadataNames = []string{"foo", "bar"}
- mod.writeMedataNames = []string{"foo", "bar"}
-
- messages := mod.SystemMessages()
- if len(messages) != 4 {
- t.Errorf("expected one and only one message, but got %d", len(messages))
- }
-
- expect := []string{
- fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
- fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
- fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
- fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")),
- }
-
- for i, message := range messages {
- if message != expect[i] {
- t.Errorf("expected message at index %d to be %s, but got %s", i, message, expect[i])
- }
- }
-}
-
-func TestPdfEngines_PdfEngine(t *testing.T) {
- mod := PdfEngines{
- mergeNames: []string{"foo", "bar"},
- convertNames: []string{"foo", "bar"},
- readMetadataNames: []string{"foo", "bar"},
- writeMedataNames: []string{"foo", "bar"},
- engines: func() []gotenberg.PdfEngine {
- engine1 := &struct {
- gotenberg.ModuleMock
- gotenberg.PdfEngineMock
- }{}
- engine1.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine1 }}
- }
-
- engine2 := &struct {
- gotenberg.ModuleMock
- gotenberg.PdfEngineMock
- }{}
- engine2.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine2 }}
- }
-
- return []gotenberg.PdfEngine{
- engine1,
- engine2,
- }
- }(),
- }
-
- _, err := mod.PdfEngine()
- if err != nil {
- t.Errorf("expected no error but got: %v", err)
- }
-}
-
-func TestPdfEngines_Routes(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- expectRoutes int
- disableRoutes bool
- }{
- {
- scenario: "routes not disabled",
- expectRoutes: 4,
- disableRoutes: false,
- },
- {
- scenario: "routes disabled",
- expectRoutes: 0,
- disableRoutes: true,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- mod := new(PdfEngines)
- mod.disableRoutes = tc.disableRoutes
-
- routes, err := mod.Routes()
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectRoutes != len(routes) {
- t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes))
- }
- })
- }
-}
diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go
index a0ddb756e..576d2b7e4 100644
--- a/pkg/modules/pdfengines/routes.go
+++ b/pkg/modules/pdfengines/routes.go
@@ -5,7 +5,10 @@ import (
"errors"
"fmt"
"net/http"
+ "os"
"path/filepath"
+ "strconv"
+ "strings"
"github.com/labstack/echo/v4"
@@ -13,6 +16,74 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
+// FormDataPdfSplitMode creates a [gotenberg.SplitMode] from the form data.
+func FormDataPdfSplitMode(form *api.FormData, mandatory bool) gotenberg.SplitMode {
+ var (
+ mode string
+ span string
+ unify bool
+ )
+
+ splitModeFunc := func(value string) error {
+ if value != "" && value != gotenberg.SplitModeIntervals && value != gotenberg.SplitModePages {
+ return fmt.Errorf("wrong value, expected either '%s' or '%s'", gotenberg.SplitModeIntervals, gotenberg.SplitModePages)
+ }
+ mode = value
+ return nil
+ }
+
+ splitSpanFunc := func(value string) error {
+ value = strings.Join(strings.Fields(value), "")
+
+ if mode == gotenberg.SplitModeIntervals {
+ intValue, err := strconv.Atoi(value)
+ if err != nil {
+ return err
+ }
+ if intValue < 1 {
+ return errors.New("value is inferior to 1")
+ }
+ }
+
+ span = value
+
+ return nil
+ }
+
+ if mandatory {
+ form.
+ MandatoryCustom("splitMode", func(value string) error {
+ return splitModeFunc(value)
+ }).
+ MandatoryCustom("splitSpan", func(value string) error {
+ return splitSpanFunc(value)
+ })
+ } else {
+ form.
+ Custom("splitMode", func(value string) error {
+ return splitModeFunc(value)
+ }).
+ Custom("splitSpan", func(value string) error {
+ return splitSpanFunc(value)
+ })
+ }
+
+ form.
+ Bool("splitUnify", &unify, false).
+ Custom("splitUnify", func(value string) error {
+ if value != "" && unify && mode != gotenberg.SplitModePages {
+ return fmt.Errorf("unify is not available for split mode '%s'", mode)
+ }
+ return nil
+ })
+
+ return gotenberg.SplitMode{
+ Mode: mode,
+ Span: span,
+ Unify: unify,
+ }
+}
+
// FormDataPdfFormats creates [gotenberg.PdfFormats] from the form data.
// Fallback to default value if the considered key is not present.
func FormDataPdfFormats(form *api.FormData) gotenberg.PdfFormats {
@@ -32,9 +103,10 @@ func FormDataPdfFormats(form *api.FormData) gotenberg.PdfFormats {
}
// FormDataPdfMetadata creates metadata object from the form data.
-func FormDataPdfMetadata(form *api.FormData) map[string]interface{} {
+func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]interface{} {
var metadata map[string]interface{}
- form.Custom("metadata", func(value string) error {
+
+ metadataFunc := func(value string) error {
if len(value) > 0 {
err := json.Unmarshal([]byte(value), &metadata)
if err != nil {
@@ -42,7 +114,18 @@ func FormDataPdfMetadata(form *api.FormData) map[string]interface{} {
}
}
return nil
- })
+ }
+
+ if mandatory {
+ form.MandatoryCustom("metadata", func(value string) error {
+ return metadataFunc(value)
+ })
+ } else {
+ form.Custom("metadata", func(value string) error {
+ return metadataFunc(value)
+ })
+ }
+
return metadata
}
@@ -66,6 +149,73 @@ func MergeStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string
return outputPath, nil
}
+// SplitPdfStub splits a list of PDF files based on [gotenberg.SplitMode].
+// It returns a list of output paths or the list of provided input paths if no
+// split requested.
+func SplitPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, mode gotenberg.SplitMode, inputPaths []string) ([]string, error) {
+ zeroValued := gotenberg.SplitMode{}
+ if mode == zeroValued {
+ return inputPaths, nil
+ }
+
+ var outputPaths []string
+ for _, inputPath := range inputPaths {
+ inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
+ filenameNoExt := filepath.Base(inputPathNoExt)
+ outputDirPath, err := ctx.CreateSubDirectory(strings.ReplaceAll(filepath.Base(filenameNoExt), ".", "_"))
+ if err != nil {
+ return nil, fmt.Errorf("create subdirectory from input path: %w", err)
+ }
+
+ paths, err := engine.Split(ctx, ctx.Log(), mode, inputPath, outputDirPath)
+ if err != nil {
+ return nil, fmt.Errorf("split PDF '%s': %w", inputPath, err)
+ }
+
+ // Keep the original filename.
+ for i, path := range paths {
+ var newPath string
+ if mode.Unify && mode.Mode == gotenberg.SplitModePages {
+ newPath = fmt.Sprintf(
+ "%s/%s.pdf",
+ outputDirPath, filenameNoExt,
+ )
+ } else {
+ newPath = fmt.Sprintf(
+ "%s/%s_%d.pdf",
+ outputDirPath, filenameNoExt, i,
+ )
+ }
+
+ err = ctx.Rename(path, newPath)
+ if err != nil {
+ return nil, fmt.Errorf("rename path: %w", err)
+ }
+
+ outputPaths = append(outputPaths, newPath)
+
+ if mode.Unify && mode.Mode == gotenberg.SplitModePages {
+ break
+ }
+ }
+ }
+
+ return outputPaths, nil
+}
+
+// FlattenStub merges annotation appearances with page content for each given
+// PDF, effectively deleting the original annotations.
+func FlattenStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string) error {
+ for _, inputPath := range inputPaths {
+ err := engine.Flatten(ctx, ctx.Log(), inputPath)
+ if err != nil {
+ return fmt.Errorf("flatten '%s': %w", inputPath, err)
+ }
+ }
+
+ return nil
+}
+
// ConvertStub transforms a given PDF to the specified formats defined in
// [gotenberg.PdfFormats]. If no format, it does nothing and returns the input
// paths.
@@ -105,6 +255,73 @@ func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata ma
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.
+func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) {
+ form.String("userPassword", &userPassword, "")
+ form.String("ownerPassword", &ownerPassword, "")
+ return userPassword, ownerPassword
+}
+
+// EncryptPdfStub adds password protection to PDF files.
+func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword, ownerPassword string, inputPaths []string) error {
+ if userPassword == "" {
+ return nil
+ }
+
+ for _, inputPath := range inputPaths {
+ err := engine.Encrypt(ctx, ctx.Log(), inputPath, userPassword, ownerPassword)
+ if err != nil {
+ return fmt.Errorf("encrypt PDF '%s': %w", inputPath, err)
+ }
+ }
+
+ 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
+}
+
+// ImportBookmarksStub imports bookmarks into a PDF file.
+func ImportBookmarksStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPath string, inputBookmarks []byte, outputPath string) (string, error) {
+ if len(inputBookmarks) == 0 {
+ fmt.Println("ImportBookmarksStub BM empty")
+ return inputPath, nil
+ }
+
+ inputBookmarksPath := ctx.GeneratePath(".json")
+ err := os.WriteFile(inputBookmarksPath, inputBookmarks, 0o600)
+ if err != nil {
+ return "", fmt.Errorf("write file %v: %w", inputBookmarksPath, err)
+ }
+ err = engine.ImportBookmarks(ctx, ctx.Log(), inputPath, inputBookmarksPath, outputPath)
+ if err != nil {
+ return "", fmt.Errorf("import bookmarks %v: %w", inputPath, err)
+ }
+
+ return outputPath, nil
+}
+
// mergeRoute returns an [api.Route] which can merge PDFs.
func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
@@ -116,11 +333,15 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
form := ctx.FormData()
pdfFormats := FormDataPdfFormats(form)
- metadata := FormDataPdfMetadata(form)
+ metadata := FormDataPdfMetadata(form, false)
+ userPassword, ownerPassword := FormDataPdfEncrypt(form)
+ embedPaths := FormDataPdfEmbeds(form)
var inputPaths []string
+ var flatten bool
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
+ Bool("flatten", &flatten, false).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
@@ -137,11 +358,28 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("convert PDF: %w", err)
}
+ err = EmbedFilesStub(ctx, engine, embedPaths, outputPaths)
+ if err != nil {
+ return fmt.Errorf("embed files into PDFs: %w", err)
+ }
+
err = WriteMetadataStub(ctx, engine, metadata, outputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
+ if flatten {
+ err = FlattenStub(ctx, engine, outputPaths)
+ if err != nil {
+ return fmt.Errorf("flatten PDFs: %w", err)
+ }
+ }
+
+ err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
+ if err != nil {
+ return fmt.Errorf("encrypt PDFs: %w", err)
+ }
+
err = ctx.AddOutputPaths(outputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
@@ -152,6 +390,120 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
}
}
+// splitRoute returns an [api.Route] which can extract pages from a PDF.
+func splitRoute(engine gotenberg.PdfEngine) api.Route {
+ return api.Route{
+ Method: http.MethodPost,
+ Path: "/forms/pdfengines/split",
+ IsMultipart: true,
+ Handler: func(c echo.Context) error {
+ ctx := c.Get("context").(*api.Context)
+
+ form := ctx.FormData()
+ mode := FormDataPdfSplitMode(form, true)
+ pdfFormats := FormDataPdfFormats(form)
+ metadata := FormDataPdfMetadata(form, false)
+ userPassword, ownerPassword := FormDataPdfEncrypt(form)
+ embedPaths := FormDataPdfEmbeds(form)
+
+ var inputPaths []string
+ var flatten bool
+ err := form.
+ MandatoryPaths([]string{".pdf"}, &inputPaths).
+ Bool("flatten", &flatten, false).
+ Validate()
+ if err != nil {
+ return fmt.Errorf("validate form data: %w", err)
+ }
+
+ outputPaths, err := SplitPdfStub(ctx, engine, mode, inputPaths)
+ if err != nil {
+ return fmt.Errorf("split PDFs: %w", err)
+ }
+
+ convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
+ if err != nil {
+ return fmt.Errorf("convert PDFs: %w", err)
+ }
+
+ err = EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths)
+ if err != nil {
+ return fmt.Errorf("embed files into PDFs: %w", err)
+ }
+
+ err = WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
+ if err != nil {
+ return fmt.Errorf("write metadata: %w", err)
+ }
+
+ if flatten {
+ err = FlattenStub(ctx, engine, convertOutputPaths)
+ if err != nil {
+ return fmt.Errorf("flatten PDFs: %w", err)
+ }
+ }
+
+ err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
+ if err != nil {
+ return fmt.Errorf("encrypt PDFs: %w", err)
+ }
+
+ zeroValuedSplitMode := gotenberg.SplitMode{}
+ zeroValuedPdfFormats := gotenberg.PdfFormats{}
+ if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats {
+ // Rename the files to keep the split naming.
+ for i, convertOutputPath := range convertOutputPaths {
+ err = ctx.Rename(convertOutputPath, outputPaths[i])
+ if err != nil {
+ return fmt.Errorf("rename output path: %w", err)
+ }
+ }
+ }
+
+ err = ctx.AddOutputPaths(outputPaths...)
+ if err != nil {
+ return fmt.Errorf("add output paths: %w", err)
+ }
+
+ return nil
+ },
+ }
+}
+
+// flattenRoute returns an [api.Route] which can flatten PDFs.
+func flattenRoute(engine gotenberg.PdfEngine) api.Route {
+ return api.Route{
+ Method: http.MethodPost,
+ Path: "/forms/pdfengines/flatten",
+ IsMultipart: true,
+ Handler: func(c echo.Context) error {
+ ctx := c.Get("context").(*api.Context)
+
+ form := ctx.FormData()
+
+ var inputPaths []string
+ err := form.
+ MandatoryPaths([]string{".pdf"}, &inputPaths).
+ Validate()
+ if err != nil {
+ return fmt.Errorf("validate form data: %w", err)
+ }
+
+ err = FlattenStub(ctx, engine, inputPaths)
+ if err != nil {
+ return fmt.Errorf("flatten PDFs: %w", err)
+ }
+
+ err = ctx.AddOutputPaths(inputPaths...)
+ if err != nil {
+ return fmt.Errorf("add output paths: %w", err)
+ }
+
+ return nil
+ },
+ }
+}
+
// convertRoute returns an [api.Route] which can convert PDFs to a specific ODF
// format.
func convertRoute(engine gotenberg.PdfEngine) api.Route {
@@ -196,7 +548,6 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
if err != nil {
return fmt.Errorf("rename output path: %w", err)
}
-
outputPaths[i] = inputPath
}
}
@@ -240,6 +591,11 @@ func readMetadataRoute(engine gotenberg.PdfEngine) api.Route {
err = c.JSON(http.StatusOK, res)
if err != nil {
+ if strings.Contains(err.Error(), "request method or response status code does not allow body") {
+ // High probability that the user is using the webhook
+ // feature. It does not make sense for this route.
+ return api.ErrNoOutputFile
+ }
return fmt.Errorf("return JSON response: %w", err)
}
@@ -258,25 +614,12 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
- var (
- inputPaths []string
- metadata map[string]interface{}
- )
+ form := ctx.FormData()
+ metadata := FormDataPdfMetadata(form, true)
- err := ctx.FormData().
+ var inputPaths []string
+ err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
- MandatoryCustom("metadata", func(value string) error {
- if len(value) > 0 {
- err := json.Unmarshal([]byte(value), &metadata)
- if err != nil {
- return fmt.Errorf("unmarshal metadata: %w", err)
- }
- }
- if len(metadata) == 0 {
- return errors.New("no metadata")
- }
- return nil
- }).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
@@ -296,3 +639,76 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route {
},
}
}
+
+// encryptRoute returns an [api.Route] which can add password protection to PDFs.
+func encryptRoute(engine gotenberg.PdfEngine) api.Route {
+ return api.Route{
+ Method: http.MethodPost,
+ Path: "/forms/pdfengines/encrypt",
+ IsMultipart: true,
+ Handler: func(c echo.Context) error {
+ ctx := c.Get("context").(*api.Context)
+
+ form := ctx.FormData()
+
+ var inputPaths []string
+ var userPassword string
+ var ownerPassword string
+ err := form.
+ MandatoryPaths([]string{".pdf"}, &inputPaths).
+ MandatoryString("userPassword", &userPassword).
+ String("ownerPassword", &ownerPassword, "").
+ Validate()
+ if err != nil {
+ return fmt.Errorf("validate form data: %w", err)
+ }
+
+ err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, inputPaths)
+ if err != nil {
+ return fmt.Errorf("encrypt PDFs: %w", err)
+ }
+
+ err = ctx.AddOutputPaths(inputPaths...)
+ if err != nil {
+ return fmt.Errorf("add output paths: %w", err)
+ }
+
+ return nil
+ },
+ }
+}
+
+// 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
+ },
+ }
+}
diff --git a/pkg/modules/pdfengines/routes_test.go b/pkg/modules/pdfengines/routes_test.go
deleted file mode 100644
index 94df1688d..000000000
--- a/pkg/modules/pdfengines/routes_test.go
+++ /dev/null
@@ -1,1010 +0,0 @@
-package pdfengines
-
-import (
- "context"
- "errors"
- "net/http"
- "net/http/httptest"
- "reflect"
- "slices"
- "strings"
- "testing"
-
- "github.com/labstack/echo/v4"
- "go.uber.org/zap"
-
- "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
- "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
-)
-
-func TestFormDataPdfFormats(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- ctx *api.ContextMock
- expectedPdfFormats gotenberg.PdfFormats
- expectValidationError bool
- }{
- {
- scenario: "no custom form fields",
- ctx: &api.ContextMock{Context: new(api.Context)},
- expectedPdfFormats: gotenberg.PdfFormats{},
- expectValidationError: false,
- },
- {
- scenario: "pdfa and pdfua form fields",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetValues(map[string][]string{
- "pdfa": {
- "foo",
- },
- "pdfua": {
- "true",
- },
- })
- return ctx
- }(),
- expectedPdfFormats: gotenberg.PdfFormats{PdfA: "foo", PdfUa: true},
- expectValidationError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- tc.ctx.SetLogger(zap.NewNop())
- form := tc.ctx.Context.FormData()
- actual := FormDataPdfFormats(form)
-
- if !reflect.DeepEqual(actual, tc.expectedPdfFormats) {
- t.Fatalf("expected %+v but got: %+v", tc.expectedPdfFormats, actual)
- }
-
- err := form.Validate()
-
- if tc.expectValidationError && err == nil {
- t.Fatal("expected validation error but got none", err)
- }
-
- if !tc.expectValidationError && err != nil {
- t.Fatalf("expected no validation error but got: %v", err)
- }
- })
- }
-}
-
-func TestFormDataPdfMetadata(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- ctx *api.ContextMock
- expectedMetadata map[string]interface{}
- expectValidationError bool
- }{
- {
- scenario: "no metadata form field",
- ctx: &api.ContextMock{Context: new(api.Context)},
- expectedMetadata: nil,
- expectValidationError: false,
- },
- {
- scenario: "invalid metadata form field",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetValues(map[string][]string{
- "metadata": {
- "foo",
- },
- })
- return ctx
- }(),
- expectedMetadata: nil,
- expectValidationError: true,
- },
- {
- scenario: "valid metadata form field",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetValues(map[string][]string{
- "metadata": {
- "{\"foo\":\"bar\"}",
- },
- })
- return ctx
- }(),
- expectedMetadata: map[string]interface{}{
- "foo": "bar",
- },
- expectValidationError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- tc.ctx.SetLogger(zap.NewNop())
- form := tc.ctx.Context.FormData()
- actual := FormDataPdfMetadata(form)
-
- if !reflect.DeepEqual(actual, tc.expectedMetadata) {
- t.Fatalf("expected %+v but got: %+v", tc.expectedMetadata, actual)
- }
-
- err := form.Validate()
-
- if tc.expectValidationError && err == nil {
- t.Fatal("expected validation error but got none", err)
- }
-
- if !tc.expectValidationError && err != nil {
- t.Fatalf("expected no validation error but got: %v", err)
- }
- })
- }
-}
-
-func TestMergeStub(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- engine gotenberg.PdfEngine
- inputPaths []string
- expectError bool
- }{
- {
- scenario: "no input path (nil)",
- inputPaths: nil,
- expectError: true,
- },
- {
- scenario: "no input path (empty)",
- inputPaths: make([]string, 0),
- expectError: true,
- },
- {
- scenario: "only one input path",
- inputPaths: []string{"my.pdf"},
- expectError: false,
- },
- {
- scenario: "merge error",
- engine: &gotenberg.PdfEngineMock{
- MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
- return errors.New("foo")
- },
- },
- inputPaths: []string{"my.pdf", "my2.pdf"},
- expectError: true,
- },
- {
- scenario: "merge success",
- engine: &gotenberg.PdfEngineMock{
- MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
- return nil
- },
- },
- inputPaths: []string{"my.pdf", "my2.pdf"},
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- _, err := MergeStub(new(api.Context), tc.engine, tc.inputPaths)
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none", err)
- }
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
- })
- }
-}
-
-func TestConvertStub(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- engine gotenberg.PdfEngine
- pdfFormats gotenberg.PdfFormats
- expectError bool
- }{
- {
- scenario: "no PDF formats",
- pdfFormats: gotenberg.PdfFormats{},
- expectError: false,
- },
- {
- scenario: "convert error",
- engine: &gotenberg.PdfEngineMock{
- ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
- return errors.New("foo")
- },
- },
- pdfFormats: gotenberg.PdfFormats{
- PdfA: gotenberg.PdfA3b,
- PdfUa: true,
- },
- expectError: true,
- },
- {
- scenario: "convert success",
- engine: &gotenberg.PdfEngineMock{
- ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
- return nil
- },
- },
- pdfFormats: gotenberg.PdfFormats{
- PdfA: gotenberg.PdfA3b,
- PdfUa: true,
- },
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- _, err := ConvertStub(new(api.Context), tc.engine, tc.pdfFormats, []string{"my.pdf", "my2.pdf"})
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none", err)
- }
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
- })
- }
-}
-
-func TestWriteMetadataStub(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- engine gotenberg.PdfEngine
- metadata map[string]interface{}
- expectError bool
- }{
- {
- scenario: "no metadata (nil)",
- metadata: nil,
- expectError: false,
- },
- {
- scenario: "no metadata (empty)",
- metadata: make(map[string]interface{}, 0),
- expectError: false,
- },
- {
- scenario: "write metadata error",
- engine: &gotenberg.PdfEngineMock{
- WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
- return errors.New("foo")
- },
- },
- metadata: map[string]interface{}{"foo": "bar"},
- expectError: true,
- },
- {
- scenario: "write metadata success",
- engine: &gotenberg.PdfEngineMock{
- WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
- return nil
- },
- },
- metadata: map[string]interface{}{"foo": "bar"},
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- err := WriteMetadataStub(new(api.Context), tc.engine, tc.metadata, []string{"my.pdf", "my2.pdf"})
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none", err)
- }
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
- })
- }
-}
-
-func TestMergeHandler(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- ctx *api.ContextMock
- engine gotenberg.PdfEngine
- expectError bool
- expectHttpError bool
- expectHttpStatus int
- expectOutputPathsCount int
- }{
- {
- scenario: "missing at least one mandatory file",
- ctx: &api.ContextMock{Context: new(api.Context)},
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "invalid metadata form field",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- "file2.pdf": "/file2.pdf",
- })
- ctx.SetValues(map[string][]string{
- "metadata": {
- "foo",
- },
- })
- return ctx
- }(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "PDF engine merge error",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- "file2.pdf": "/file2.pdf",
- })
- return ctx
- }(),
- engine: &gotenberg.PdfEngineMock{
- MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
- return errors.New("foo")
- },
- },
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "PDF engine convert error",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- "file2.pdf": "/file2.pdf",
- })
- ctx.SetValues(map[string][]string{
- "pdfa": {
- gotenberg.PdfA1b,
- },
- })
- return ctx
- }(),
- engine: &gotenberg.PdfEngineMock{
- MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
- return nil
- },
- ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
- return errors.New("foo")
- },
- },
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "PDF engine write metadata error",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- "file2.pdf": "/file2.pdf",
- })
- ctx.SetValues(map[string][]string{
- "metadata": {
- "{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
- },
- })
- return ctx
- }(),
- engine: &gotenberg.PdfEngineMock{
- MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
- return nil
- },
- WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
- return errors.New("foo")
- },
- },
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "cannot add output paths",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- "file2.pdf": "/file2.pdf",
- })
- ctx.SetCancelled(true)
- return ctx
- }(),
- engine: &gotenberg.PdfEngineMock{
- MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
- return nil
- },
- },
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "success",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- "file2.pdf": "/file2.pdf",
- })
- ctx.SetValues(map[string][]string{
- "pdfa": {
- gotenberg.PdfA1b,
- },
- "metadata": {
- "{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
- },
- })
- return ctx
- }(),
- engine: &gotenberg.PdfEngineMock{
- MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
- return nil
- },
- ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
- return nil
- },
- WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
- return nil
- },
- },
- expectError: false,
- expectHttpError: false,
- expectOutputPathsCount: 1,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- tc.ctx.SetLogger(zap.NewNop())
- c := echo.New().NewContext(nil, nil)
- c.Set("context", tc.ctx.Context)
-
- err := mergeRoute(tc.engine).Handler(c)
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none", err)
- }
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- var httpErr api.HttpError
- isHttpError := errors.As(err, &httpErr)
-
- if tc.expectHttpError && !isHttpError {
- t.Errorf("expected an HTTP error but got: %v", err)
- }
-
- if !tc.expectHttpError && isHttpError {
- t.Errorf("expected no HTTP error but got one: %v", httpErr)
- }
-
- if err != nil && tc.expectHttpError && isHttpError {
- status, _ := httpErr.HttpError()
- if status != tc.expectHttpStatus {
- t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
- }
- }
-
- if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
- t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
- }
- })
- }
-}
-
-func TestConvertHandler(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- ctx *api.ContextMock
- engine gotenberg.PdfEngine
- expectError bool
- expectHttpError bool
- expectHttpStatus int
- expectOutputPathsCount int
- expectOutputPaths []string
- }{
- {
- scenario: "missing at least one mandatory file",
- ctx: &api.ContextMock{Context: new(api.Context)},
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "no PDF formats",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- })
- return ctx
- }(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "error from PDF engine",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- })
- ctx.SetValues(map[string][]string{
- "pdfa": {
- gotenberg.PdfA1b,
- },
- })
- return ctx
- }(),
- engine: &gotenberg.PdfEngineMock{
- ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
- return errors.New("foo")
- },
- },
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "cannot add output paths",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- })
- ctx.SetValues(map[string][]string{
- "pdfa": {
- gotenberg.PdfA1b,
- },
- })
- ctx.SetCancelled(true)
- return ctx
- }(),
- engine: &gotenberg.PdfEngineMock{
- ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
- return nil
- },
- },
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "success with PDF/A & PDF/UA form fields (single file)",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- })
- ctx.SetValues(map[string][]string{
- "pdfa": {
- gotenberg.PdfA1b,
- },
- "pdfua": {
- "true",
- },
- })
- return ctx
- }(),
- engine: &gotenberg.PdfEngineMock{
- ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
- return nil
- },
- },
- expectError: false,
- expectHttpError: false,
- expectOutputPathsCount: 1,
- },
- {
- scenario: "cannot rename many files",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- "file2.pdf": "/file2.pdf",
- })
- ctx.SetValues(map[string][]string{
- "pdfa": {
- gotenberg.PdfA1b,
- },
- "pdfua": {
- "true",
- },
- })
- ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
- return errors.New("cannot rename")
- }})
- return ctx
- }(),
- engine: &gotenberg.PdfEngineMock{
- ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
- return nil
- },
- },
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "success with PDF/A & PDF/UA form fields (many files)",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- "file2.pdf": "/file2.pdf",
- })
- ctx.SetValues(map[string][]string{
- "pdfa": {
- gotenberg.PdfA1b,
- },
- "pdfua": {
- "true",
- },
- })
- ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
- return nil
- }})
- return ctx
- }(),
- engine: &gotenberg.PdfEngineMock{
- ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
- return nil
- },
- },
- expectError: false,
- expectHttpError: false,
- expectOutputPathsCount: 2,
- expectOutputPaths: []string{"/file.pdf", "/file2.pdf"},
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- tc.ctx.SetLogger(zap.NewNop())
- c := echo.New().NewContext(nil, nil)
- c.Set("context", tc.ctx.Context)
-
- err := convertRoute(tc.engine).Handler(c)
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none", err)
- }
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- var httpErr api.HttpError
- isHttpError := errors.As(err, &httpErr)
-
- if tc.expectHttpError && !isHttpError {
- t.Errorf("expected an HTTP error but got: %v", err)
- }
-
- if !tc.expectHttpError && isHttpError {
- t.Errorf("expected no HTTP error but got one: %v", httpErr)
- }
-
- if err != nil && tc.expectHttpError && isHttpError {
- status, _ := httpErr.HttpError()
- if status != tc.expectHttpStatus {
- t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
- }
- }
-
- if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
- t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
- }
-
- for _, path := range tc.expectOutputPaths {
- if !slices.Contains(tc.ctx.OutputPaths(), path) {
- t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths())
- }
- }
- })
- }
-}
-
-func TestReadMetadataHandler(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- ctx *api.ContextMock
- engine gotenberg.PdfEngine
- expectError bool
- expectedError error
- expectHttpError bool
- expectHttpStatus int
- expectedJson string
- }{
- {
- scenario: "missing at least one mandatory file",
- ctx: &api.ContextMock{Context: new(api.Context)},
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- },
- {
- scenario: "error from PDF engine",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- })
- return ctx
- }(),
- engine: &gotenberg.PdfEngineMock{
- ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
- return nil, errors.New("foo")
- },
- },
- expectError: true,
- expectHttpError: false,
- },
- {
- scenario: "success",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- })
- return ctx
- }(),
- engine: &gotenberg.PdfEngineMock{
- ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
- return map[string]interface{}{
- "foo": "bar",
- "bar": "foo",
- }, nil
- },
- },
- expectError: true,
- expectedError: api.ErrNoOutputFile,
- expectHttpError: false,
- expectedJson: `{"file.pdf":{"bar":"foo","foo":"bar"}}`,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- tc.ctx.SetLogger(zap.NewNop())
- req := httptest.NewRequest(http.MethodPost, "/forms/pdfengines/metadata/read", nil)
- rec := httptest.NewRecorder()
- c := echo.New().NewContext(req, rec)
- c.Set("context", tc.ctx.Context)
-
- err := readMetadataRoute(tc.engine).Handler(c)
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none", err)
- }
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- var httpErr api.HttpError
- isHttpError := errors.As(err, &httpErr)
-
- if tc.expectHttpError && !isHttpError {
- t.Errorf("expected an HTTP error but got: %v", err)
- }
-
- if !tc.expectHttpError && isHttpError {
- t.Errorf("expected no HTTP error but got one: %v", httpErr)
- }
-
- if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
- t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
- }
-
- if err != nil && tc.expectHttpError && isHttpError {
- status, _ := httpErr.HttpError()
- if status != tc.expectHttpStatus {
- t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
- }
- }
-
- if tc.expectedJson != "" && tc.expectedJson != strings.TrimSpace(rec.Body.String()) {
- t.Errorf("expected '%s' as HTTP response but got '%s'", tc.expectedJson, strings.TrimSpace(rec.Body.String()))
- }
- })
- }
-}
-
-func TestWriteMetadataHandler(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- ctx *api.ContextMock
- engine gotenberg.PdfEngine
- expectError bool
- expectHttpError bool
- expectHttpStatus int
- expectOutputPathsCount int
- expectOutputPaths []string
- }{
- {
- scenario: "missing at least one mandatory file",
- ctx: &api.ContextMock{Context: new(api.Context)},
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "no metadata form field",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- })
- return ctx
- }(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "invalid metadata form field",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- })
- ctx.SetValues(map[string][]string{
- "metadata": {
- "foo",
- },
- })
- return ctx
- }(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "no metadata",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "document.docx": "/document.docx",
- })
- ctx.SetValues(map[string][]string{
- "metadata": {
- "{}",
- },
- })
- return ctx
- }(),
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "error from PDF engine",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- })
- ctx.SetValues(map[string][]string{
- "metadata": {
- "{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
- },
- })
- return ctx
- }(),
- engine: &gotenberg.PdfEngineMock{
- WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
- return errors.New("foo")
- },
- },
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "cannot add output paths",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- })
- ctx.SetValues(map[string][]string{
- "metadata": {
- "{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
- },
- })
- ctx.SetCancelled(true)
- return ctx
- }(),
- engine: &gotenberg.PdfEngineMock{
- WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
- return nil
- },
- },
- expectError: true,
- expectHttpError: false,
- expectOutputPathsCount: 0,
- },
- {
- scenario: "success",
- ctx: func() *api.ContextMock {
- ctx := &api.ContextMock{Context: new(api.Context)}
- ctx.SetFiles(map[string]string{
- "file.pdf": "/file.pdf",
- })
- ctx.SetValues(map[string][]string{
- "metadata": {
- "{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
- },
- })
- return ctx
- }(),
- engine: &gotenberg.PdfEngineMock{
- WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
- return nil
- },
- },
- expectError: false,
- expectHttpError: false,
- expectOutputPathsCount: 1,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- tc.ctx.SetLogger(zap.NewNop())
- c := echo.New().NewContext(nil, nil)
- c.Set("context", tc.ctx.Context)
-
- err := writeMetadataRoute(tc.engine).Handler(c)
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none", err)
- }
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- var httpErr api.HttpError
- isHttpError := errors.As(err, &httpErr)
-
- if tc.expectHttpError && !isHttpError {
- t.Errorf("expected an HTTP error but got: %v", err)
- }
-
- if !tc.expectHttpError && isHttpError {
- t.Errorf("expected no HTTP error but got one: %v", httpErr)
- }
-
- if err != nil && tc.expectHttpError && isHttpError {
- status, _ := httpErr.HttpError()
- if status != tc.expectHttpStatus {
- t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
- }
- }
-
- if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
- t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
- }
-
- for _, path := range tc.expectOutputPaths {
- if !slices.Contains(tc.ctx.OutputPaths(), path) {
- t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths())
- }
- }
- })
- }
-}
diff --git a/pkg/modules/pdftk/doc.go b/pkg/modules/pdftk/doc.go
index 3a01ae417..c65403f72 100644
--- a/pkg/modules/pdftk/doc.go
+++ b/pkg/modules/pdftk/doc.go
@@ -2,6 +2,7 @@
// interface using the PDFtk command-line tool. This package allows for:
//
// 1. The merging of PDF files.
+// 2. The splitting of PDF files.
//
// The path to the PDFtk binary must be specified using the PDFTK_BIN_PATH
// environment variable.
diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go
index 9846ee9df..e67a087c6 100644
--- a/pkg/modules/pdftk/pdftk.go
+++ b/pkg/modules/pdftk/pdftk.go
@@ -1,10 +1,14 @@
package pdftk
import (
+ "bytes"
"context"
"errors"
"fmt"
"os"
+ "os/exec"
+ "path/filepath"
+ "syscall"
"go.uber.org/zap"
@@ -29,7 +33,7 @@ func (engine *PdfTk) Descriptor() gotenberg.ModuleDescriptor {
}
}
-// Provision sets the modules properties.
+// Provision sets the module properties.
func (engine *PdfTk) Provision(ctx *gotenberg.Context) error {
binPath, ok := os.LookupEnv("PDFTK_BIN_PATH")
if !ok {
@@ -51,6 +55,57 @@ func (engine *PdfTk) Validate() error {
return nil
}
+// Debug returns additional debug data.
+func (engine *PdfTk) Debug() map[string]interface{} {
+ debug := make(map[string]interface{})
+
+ cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+
+ output, err := cmd.Output()
+ if err != nil {
+ debug["version"] = err.Error()
+ return debug
+ }
+
+ lines := bytes.SplitN(output, []byte("\n"), 2)
+ if len(lines) > 0 {
+ debug["version"] = string(lines[0])
+ } else {
+ debug["version"] = "Unable to determine PDFtk version"
+ }
+
+ return debug
+}
+
+// Split splits a given PDF file.
+func (engine *PdfTk) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
+ var args []string
+ outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath))
+
+ switch mode.Mode {
+ case gotenberg.SplitModePages:
+ if !mode.Unify {
+ return nil, fmt.Errorf("split PDFs using mode '%s' without unify with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
+ }
+ args = append(args, inputPath, "cat", mode.Span, "output", outputPath)
+ default:
+ return nil, fmt.Errorf("split PDFs using mode '%s' with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
+ }
+
+ cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
+ if err != nil {
+ return nil, fmt.Errorf("create command: %w", err)
+ }
+
+ _, err = cmd.Exec()
+ if err != nil {
+ return nil, fmt.Errorf("split PDFs with PDFtk: %w", err)
+ }
+
+ return []string{outputPath}, nil
+}
+
// Merge combines multiple PDFs into a single PDF.
func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string
@@ -70,6 +125,11 @@ func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths [
return fmt.Errorf("merge PDFs with PDFtk: %w", err)
}
+// Flatten is not available in this implementation.
+func (engine *PdfTk) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ return fmt.Errorf("flatten PDF with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
// Convert is not available in this implementation.
func (engine *PdfTk) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with PDFtk: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
@@ -85,10 +145,59 @@ func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *zap.Logger, meta
return fmt.Errorf("write PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
+// Encrypt adds password protection to a PDF file using PDFtk.
+func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+ if userPassword == "" {
+ return errors.New("user password cannot be empty")
+ }
+
+ if ownerPassword == userPassword || ownerPassword == "" {
+ return gotenberg.NewPdfEngineInvalidArgs("pdftk", "both 'userPassword' and 'ownerPassword' must be provided and different. Consider switching to another PDF engine if this behavior does not work with your workflow")
+ }
+
+ // Create a temp output file in the same directory.
+ tmpPath := inputPath + ".tmp"
+
+ var args []string
+ args = append(args, inputPath)
+ args = append(args, "output", tmpPath)
+ args = append(args, "encrypt_128bit")
+ args = append(args, "user_pw", userPassword)
+ args = append(args, "owner_pw", ownerPassword)
+
+ cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
+ if err != nil {
+ return fmt.Errorf("create command: %w", err)
+ }
+
+ _, err = cmd.Exec()
+ if err != nil {
+ return fmt.Errorf("encrypt PDF with PDFtk: %w", err)
+ }
+
+ err = os.Rename(tmpPath, inputPath)
+ if err != nil {
+ return fmt.Errorf("rename temporary output file with input file: %w", err)
+ }
+
+ 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)
+}
+
+// ImportBookmarks is not available in this implementation.
+func (engine *PdfTk) ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error {
+ return fmt.Errorf("import bookmarks into PDF with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
// Interface guards.
var (
_ gotenberg.Module = (*PdfTk)(nil)
_ gotenberg.Provisioner = (*PdfTk)(nil)
_ gotenberg.Validator = (*PdfTk)(nil)
+ _ gotenberg.Debuggable = (*PdfTk)(nil)
_ gotenberg.PdfEngine = (*PdfTk)(nil)
)
diff --git a/pkg/modules/pdftk/pdftk_test.go b/pkg/modules/pdftk/pdftk_test.go
deleted file mode 100644
index c7b864eca..000000000
--- a/pkg/modules/pdftk/pdftk_test.go
+++ /dev/null
@@ -1,170 +0,0 @@
-package pdftk
-
-import (
- "context"
- "errors"
- "os"
- "reflect"
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
-)
-
-func TestPdfTk_Descriptor(t *testing.T) {
- descriptor := new(PdfTk).Descriptor()
-
- actual := reflect.TypeOf(descriptor.New())
- expect := reflect.TypeOf(new(PdfTk))
-
- if actual != expect {
- t.Errorf("expected '%s' but got '%s'", expect, actual)
- }
-}
-
-func TestPdfTk_Provision(t *testing.T) {
- engine := new(PdfTk)
- ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil)
-
- err := engine.Provision(ctx)
- if err != nil {
- t.Errorf("expected no error but got: %v", err)
- }
-}
-
-func TestPdfTk_Validate(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- binPath string
- expectError bool
- }{
- {
- scenario: "empty bin path",
- binPath: "",
- expectError: true,
- },
- {
- scenario: "bin path does not exist",
- binPath: "/foo",
- expectError: true,
- },
- {
- scenario: "validate success",
- binPath: os.Getenv("PDFTK_BIN_PATH"),
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- engine := new(PdfTk)
- engine.binPath = tc.binPath
- err := engine.Validate()
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestPdfTk_Merge(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- ctx context.Context
- inputPaths []string
- expectError bool
- }{
- {
- scenario: "invalid context",
- ctx: nil,
- expectError: true,
- },
- {
- scenario: "invalid input path",
- ctx: context.TODO(),
- inputPaths: []string{
- "foo",
- },
- expectError: true,
- },
- {
- scenario: "single file success",
- ctx: context.TODO(),
- inputPaths: []string{
- "/tests/test/testdata/pdfengines/sample1.pdf",
- },
- expectError: false,
- },
- {
- scenario: "many files success",
- ctx: context.TODO(),
- inputPaths: []string{
- "/tests/test/testdata/pdfengines/sample1.pdf",
- "/tests/test/testdata/pdfengines/sample2.pdf",
- },
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- engine := new(PdfTk)
- err := engine.Provision(nil)
- if err != nil {
- t.Fatalf("expected error but got: %v", err)
- }
-
- fs := gotenberg.NewFileSystem()
- outputDir, err := fs.MkdirAll()
- if err != nil {
- t.Fatalf("expected error but got: %v", err)
- }
-
- defer func() {
- err = os.RemoveAll(fs.WorkingDirPath())
- if err != nil {
- t.Fatalf("expected no error while cleaning up but got: %v", err)
- }
- }()
-
- err = engine.Merge(tc.ctx, zap.NewNop(), tc.inputPaths, outputDir+"/foo.pdf")
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestPdfTk_Convert(t *testing.T) {
- engine := new(PdfTk)
- err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
-
- if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
- t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
- }
-}
-
-func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) {
- engine := new(PdfTk)
- _, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "")
-
- if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
- t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
- }
-}
-
-func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) {
- engine := new(PdfTk)
- err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "")
-
- if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
- t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
- }
-}
diff --git a/pkg/modules/prometheus/prometheus.go b/pkg/modules/prometheus/prometheus.go
index 6f54d047e..d899e6b42 100644
--- a/pkg/modules/prometheus/prometheus.go
+++ b/pkg/modules/prometheus/prometheus.go
@@ -20,7 +20,7 @@ func init() {
gotenberg.MustRegisterModule(new(Prometheus))
}
-// Prometheus is a module which collects metrics and exposes them via an HTTP
+// Prometheus is a module that collects metrics and exposes them via an HTTP
// route.
type Prometheus struct {
namespace string
@@ -49,7 +49,7 @@ func (mod *Prometheus) Descriptor() gotenberg.ModuleDescriptor {
}
}
-// Provision sets the modules properties.
+// Provision sets the module properties.
func (mod *Prometheus) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
mod.namespace = flags.MustString("prometheus-namespace")
diff --git a/pkg/modules/prometheus/prometheus_test.go b/pkg/modules/prometheus/prometheus_test.go
deleted file mode 100644
index 02816b0f7..000000000
--- a/pkg/modules/prometheus/prometheus_test.go
+++ /dev/null
@@ -1,360 +0,0 @@
-package prometheus
-
-import (
- "errors"
- "reflect"
- "testing"
- "time"
-
- "github.com/prometheus/client_golang/prometheus"
-
- "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
-)
-
-func TestPrometheus_Descriptor(t *testing.T) {
- descriptor := new(Prometheus).Descriptor()
-
- actual := reflect.TypeOf(descriptor.New())
- expect := reflect.TypeOf(new(Prometheus))
-
- if actual != expect {
- t.Errorf("expected '%s' but got '%s'", expect, actual)
- }
-}
-
-func TestPrometheus_Provision(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- ctx *gotenberg.Context
- expectMetrics []gotenberg.Metric
- expectError bool
- }{
- {
- scenario: "disable collect",
- ctx: func() *gotenberg.Context {
- fs := new(Prometheus).Descriptor().FlagSet
- err := fs.Parse([]string{"--prometheus-disable-collect=true"})
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
- return gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: fs,
- },
- nil,
- )
- }(),
- expectError: false,
- },
- {
- scenario: "invalid metrics provider",
- ctx: func() *gotenberg.Context {
- mod := &struct {
- gotenberg.ModuleMock
- gotenberg.ValidatorMock
- gotenberg.MetricsProviderMock
- }{}
- mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
- }
- mod.ValidateMock = func() error {
- return errors.New("foo")
- }
- mod.MetricsMock = func() ([]gotenberg.Metric, error) {
- return nil, nil
- }
- return gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: new(Prometheus).Descriptor().FlagSet,
- },
- []gotenberg.ModuleDescriptor{
- mod.Descriptor(),
- },
- )
- }(),
- expectError: true,
- },
- {
- scenario: "invalid metrics from metrics provider",
- ctx: func() *gotenberg.Context {
- mod := &struct {
- gotenberg.ModuleMock
- gotenberg.ValidatorMock
- gotenberg.MetricsProviderMock
- }{}
- mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
- }
- mod.ValidateMock = func() error {
- return nil
- }
- mod.MetricsMock = func() ([]gotenberg.Metric, error) {
- return nil, errors.New("foo")
- }
- return gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: new(Prometheus).Descriptor().FlagSet,
- },
- []gotenberg.ModuleDescriptor{
- mod.Descriptor(),
- },
- )
- }(),
- expectError: true,
- },
- {
- scenario: "provision success",
- ctx: func() *gotenberg.Context {
- mod := &struct {
- gotenberg.ModuleMock
- gotenberg.ValidatorMock
- gotenberg.MetricsProviderMock
- }{}
- mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
- return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
- }
- mod.ValidateMock = func() error {
- return nil
- }
- mod.MetricsMock = func() ([]gotenberg.Metric, error) {
- return []gotenberg.Metric{
- {
- Name: "foo",
- Description: "Bar.",
- },
- }, nil
- }
- return gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: new(Prometheus).Descriptor().FlagSet,
- },
- []gotenberg.ModuleDescriptor{
- mod.Descriptor(),
- },
- )
- }(),
- expectMetrics: []gotenberg.Metric{
- {
- Name: "foo",
- Description: "Bar.",
- },
- },
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- mod := new(Prometheus)
- err := mod.Provision(tc.ctx)
-
- if !reflect.DeepEqual(mod.metrics, tc.expectMetrics) {
- t.Fatalf("expected metrics %+v, but got: %+v", tc.expectMetrics, mod.metrics)
- }
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestPrometheus_Validate(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- namespace string
- metrics []gotenberg.Metric
- disableCollect bool
- expectError bool
- }{
- {
- scenario: "collect disabled",
- namespace: "foo",
- disableCollect: true,
- expectError: false,
- },
- {
- scenario: "empty namespace",
- namespace: "",
- disableCollect: false,
- expectError: true,
- },
- {
- scenario: "empty metric name",
- namespace: "foo",
- metrics: []gotenberg.Metric{
- {
- Name: "",
- },
- },
- disableCollect: false,
- expectError: true,
- },
- {
- scenario: "nil read metric method",
- namespace: "foo",
- metrics: []gotenberg.Metric{
- {
- Name: "foo",
- Read: nil,
- },
- },
- disableCollect: false,
- expectError: true,
- },
- {
- scenario: "already registered metric",
- namespace: "foo",
- metrics: []gotenberg.Metric{
- {
- Name: "foo",
- Read: func() float64 {
- return 0
- },
- },
- {
- Name: "foo",
- Read: func() float64 {
- return 0
- },
- },
- },
- disableCollect: false,
- expectError: true,
- },
- {
- scenario: "validate success",
- namespace: "foo",
- metrics: []gotenberg.Metric{
- {
- Name: "foo",
- Read: func() float64 {
- return 0
- },
- },
- {
- Name: "bar",
- Read: func() float64 {
- return 0
- },
- },
- },
- disableCollect: false,
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- mod := &Prometheus{
- namespace: tc.namespace,
- metrics: tc.metrics,
- disableCollect: tc.disableCollect,
- }
- err := mod.Validate()
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestPrometheus_Start(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- metrics []gotenberg.Metric
- disableCollect bool
- }{
- {
- scenario: "collect disabled",
- disableCollect: true,
- },
- {
- scenario: "start success",
- metrics: []gotenberg.Metric{
- {
- Name: "foo",
- Read: func() float64 {
- return 0
- },
- },
- },
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- mod := &Prometheus{
- namespace: "foo",
- interval: time.Duration(1) * time.Second,
- metrics: tc.metrics,
- disableCollect: tc.disableCollect,
- registry: prometheus.NewRegistry(),
- }
-
- err := mod.Start()
- if err != nil {
- t.Errorf("expected no error but got: %v", err)
- }
- })
- }
-}
-
-func TestPrometheus_StartupMessage(t *testing.T) {
- mod := new(Prometheus)
-
- mod.disableCollect = true
- disableCollectMsg := mod.StartupMessage()
-
- mod.disableCollect = false
- noDisableCollectMsg := mod.StartupMessage()
-
- if disableCollectMsg == noDisableCollectMsg {
- t.Errorf("expected differrent startup messages if collect is disabled or not, but got '%s'", disableCollectMsg)
- }
-}
-
-func TestPrometheus_Stop(t *testing.T) {
- err := new(Prometheus).Stop(nil)
- if err != nil {
- t.Errorf("expected no error but got: %v", err)
- }
-}
-
-func TestPrometheus_Routes(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- disableCollect bool
- expectRoutes int
- }{
- {
- scenario: "collect disabled",
- disableCollect: true,
- expectRoutes: 0,
- },
- {
- scenario: "routes not disabled",
- disableCollect: false,
- expectRoutes: 1,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- mod := &Prometheus{
- disableCollect: tc.disableCollect,
- registry: prometheus.NewRegistry(),
- }
-
- routes, err := mod.Routes()
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectRoutes != len(routes) {
- t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes))
- }
- })
- }
-}
diff --git a/pkg/modules/qpdf/doc.go b/pkg/modules/qpdf/doc.go
index 31f61b361..5f494a1f0 100644
--- a/pkg/modules/qpdf/doc.go
+++ b/pkg/modules/qpdf/doc.go
@@ -2,6 +2,8 @@
// interface using the QPDF command-line tool. This package allows for:
//
// 1. The merging of PDF files.
+// 2. The splitting of PDF files.
+// 3. Flattening of PDF files
//
// The path to the QPDF binary must be specified using the QPDK_BIN_PATH
// environment variable.
diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go
index 57698281d..2d6e7ba7d 100644
--- a/pkg/modules/qpdf/qpdf.go
+++ b/pkg/modules/qpdf/qpdf.go
@@ -1,10 +1,14 @@
package qpdf
import (
+ "bytes"
"context"
"errors"
"fmt"
"os"
+ "os/exec"
+ "path/filepath"
+ "syscall"
"go.uber.org/zap"
@@ -18,7 +22,8 @@ func init() {
// QPdf abstracts the CLI tool QPDF and implements the [gotenberg.PdfEngine]
// interface.
type QPdf struct {
- binPath string
+ binPath string
+ globalArgs []string
}
// Descriptor returns a [QPdf]'s module descriptor.
@@ -29,7 +34,7 @@ func (engine *QPdf) Descriptor() gotenberg.ModuleDescriptor {
}
}
-// Provision sets the modules properties.
+// Provision sets the module properties.
func (engine *QPdf) Provision(ctx *gotenberg.Context) error {
binPath, ok := os.LookupEnv("QPDF_BIN_PATH")
if !ok {
@@ -37,6 +42,8 @@ func (engine *QPdf) Provision(ctx *gotenberg.Context) error {
}
engine.binPath = binPath
+ // Warnings should not cause errors.
+ engine.globalArgs = []string{"--warning-exit-0"}
return nil
}
@@ -45,16 +52,71 @@ func (engine *QPdf) Provision(ctx *gotenberg.Context) error {
func (engine *QPdf) Validate() error {
_, err := os.Stat(engine.binPath)
if os.IsNotExist(err) {
- return fmt.Errorf("QPdf binary path does not exist: %w", err)
+ return fmt.Errorf("QPDF binary path does not exist: %w", err)
}
return nil
}
+// Debug returns additional debug data.
+func (engine *QPdf) Debug() map[string]interface{} {
+ debug := make(map[string]interface{})
+
+ cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+
+ output, err := cmd.Output()
+ if err != nil {
+ debug["version"] = err.Error()
+ return debug
+ }
+
+ lines := bytes.SplitN(output, []byte("\n"), 2)
+ if len(lines) > 0 {
+ debug["version"] = string(lines[0])
+ } else {
+ debug["version"] = "Unable to determine QPDF version"
+ }
+
+ return debug
+}
+
+// Split splits a given PDF file.
+func (engine *QPdf) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
+ var args []string
+ outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath))
+
+ switch mode.Mode {
+ case gotenberg.SplitModePages:
+ if !mode.Unify {
+ return nil, fmt.Errorf("split PDFs using mode '%s' without unify with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
+ }
+ args = append(args, inputPath)
+ args = append(args, engine.globalArgs...)
+ args = append(args, "--pages", ".", mode.Span)
+ args = append(args, "--", outputPath)
+ default:
+ return nil, fmt.Errorf("split PDFs using mode '%s' with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
+ }
+
+ cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
+ if err != nil {
+ return nil, fmt.Errorf("create command: %w", err)
+ }
+
+ _, err = cmd.Exec()
+ if err != nil {
+ return nil, fmt.Errorf("split PDFs with QPDF: %w", err)
+ }
+
+ return []string{outputPath}, nil
+}
+
// Merge combines multiple PDFs into a single PDF.
func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string
args = append(args, "--empty")
+ args = append(args, engine.globalArgs...)
args = append(args, "--pages")
args = append(args, inputPaths...)
args = append(args, "--", outputPath)
@@ -72,6 +134,29 @@ func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []
return fmt.Errorf("merge PDFs with QPDF: %w", err)
}
+// Flatten merges annotation appearances with page content, deleting the
+// original annotations.
+func (engine *QPdf) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
+ var args []string
+ args = append(args, inputPath)
+ args = append(args, "--generate-appearances")
+ args = append(args, "--flatten-annotations=all")
+ args = append(args, "--replace-input")
+ args = append(args, engine.globalArgs...)
+
+ cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
+ if err != nil {
+ return fmt.Errorf("create command: %w", err)
+ }
+
+ _, err = cmd.Exec()
+ if err == nil {
+ return nil
+ }
+
+ return fmt.Errorf("flatten PDFs with QPDF: %w", err)
+}
+
// Convert is not available in this implementation.
func (engine *QPdf) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with QPDF: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
@@ -87,9 +172,49 @@ func (engine *QPdf) WriteMetadata(ctx context.Context, logger *zap.Logger, metad
return fmt.Errorf("write PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
+// Encrypt adds password protection to a PDF file using QPDF.
+func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
+ if userPassword == "" {
+ return errors.New("user password cannot be empty")
+ }
+
+ if ownerPassword == "" {
+ ownerPassword = userPassword
+ }
+
+ var args []string
+ args = append(args, inputPath)
+ args = append(args, engine.globalArgs...)
+ args = append(args, "--replace-input")
+ args = append(args, "--encrypt", userPassword, ownerPassword, "256", "--")
+
+ cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
+ if err != nil {
+ return fmt.Errorf("create command: %w", err)
+ }
+
+ _, err = cmd.Exec()
+ if err != nil {
+ return fmt.Errorf("encrypt PDF with QPDF: %w", err)
+ }
+
+ 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)
+}
+
+// ImportBookmarks is not available in this implementation.
+func (engine *QPdf) ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error {
+ return fmt.Errorf("import bookmarks into PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
+}
+
var (
_ gotenberg.Module = (*QPdf)(nil)
_ gotenberg.Provisioner = (*QPdf)(nil)
_ gotenberg.Validator = (*QPdf)(nil)
+ _ gotenberg.Debuggable = (*QPdf)(nil)
_ gotenberg.PdfEngine = (*QPdf)(nil)
)
diff --git a/pkg/modules/qpdf/qpdf_test.go b/pkg/modules/qpdf/qpdf_test.go
deleted file mode 100644
index a966928d0..000000000
--- a/pkg/modules/qpdf/qpdf_test.go
+++ /dev/null
@@ -1,170 +0,0 @@
-package qpdf
-
-import (
- "context"
- "errors"
- "os"
- "reflect"
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
-)
-
-func TestQPdf_Descriptor(t *testing.T) {
- descriptor := new(QPdf).Descriptor()
-
- actual := reflect.TypeOf(descriptor.New())
- expect := reflect.TypeOf(new(QPdf))
-
- if actual != expect {
- t.Errorf("expected '%s' but got '%s'", expect, actual)
- }
-}
-
-func TestQPdf_Provision(t *testing.T) {
- engine := new(QPdf)
- ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil)
-
- err := engine.Provision(ctx)
- if err != nil {
- t.Errorf("expected no error but got: %v", err)
- }
-}
-
-func TestQPdf_Validate(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- binPath string
- expectError bool
- }{
- {
- scenario: "empty bin path",
- binPath: "",
- expectError: true,
- },
- {
- scenario: "bin path does not exist",
- binPath: "/foo",
- expectError: true,
- },
- {
- scenario: "validate success",
- binPath: os.Getenv("QPDF_BIN_PATH"),
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- engine := new(QPdf)
- engine.binPath = tc.binPath
- err := engine.Validate()
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestQPdf_Merge(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- ctx context.Context
- inputPaths []string
- expectError bool
- }{
- {
- scenario: "invalid context",
- ctx: nil,
- expectError: true,
- },
- {
- scenario: "invalid input path",
- ctx: context.TODO(),
- inputPaths: []string{
- "foo",
- },
- expectError: true,
- },
- {
- scenario: "single file success",
- ctx: context.TODO(),
- inputPaths: []string{
- "/tests/test/testdata/pdfengines/sample1.pdf",
- },
- expectError: false,
- },
- {
- scenario: "many files success",
- ctx: context.TODO(),
- inputPaths: []string{
- "/tests/test/testdata/pdfengines/sample1.pdf",
- "/tests/test/testdata/pdfengines/sample2.pdf",
- },
- expectError: false,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- engine := new(QPdf)
- err := engine.Provision(nil)
- if err != nil {
- t.Fatalf("expected error but got: %v", err)
- }
-
- fs := gotenberg.NewFileSystem()
- outputDir, err := fs.MkdirAll()
- if err != nil {
- t.Fatalf("expected error but got: %v", err)
- }
-
- defer func() {
- err = os.RemoveAll(fs.WorkingDirPath())
- if err != nil {
- t.Fatalf("expected no error while cleaning up but got: %v", err)
- }
- }()
-
- err = engine.Merge(tc.ctx, zap.NewNop(), tc.inputPaths, outputDir+"/foo.pdf")
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none")
- }
- })
- }
-}
-
-func TestQPdf_Convert(t *testing.T) {
- engine := new(QPdf)
- err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
-
- if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
- t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
- }
-}
-
-func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) {
- engine := new(QPdf)
- _, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "")
-
- if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
- t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
- }
-}
-
-func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) {
- engine := new(QPdf)
- err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "")
-
- if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
- t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
- }
-}
diff --git a/pkg/modules/webhook/client.go b/pkg/modules/webhook/client.go
index 9ce23b190..39d91bd6b 100644
--- a/pkg/modules/webhook/client.go
+++ b/pkg/modules/webhook/client.go
@@ -26,14 +26,14 @@ type client struct {
}
// send call the webhook either to send the success response or the error response.
-func (c client) send(body io.Reader, headers map[string]string, erroed bool) error {
+func (c client) send(body io.Reader, headers map[string]string, errored bool) error {
url := c.url
- if erroed {
+ if errored {
url = c.errorUrl
}
method := c.method
- if erroed {
+ if errored {
method = c.errorMethod
}
@@ -54,9 +54,9 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
contentLength, ok := headers[echo.HeaderContentLength]
if ok {
// Golang "http" package should automatically calculate the size of the
- // body. But, when using a buffered file reader, it does not work.
- // Worse, the "Content-Length" header is also removed. Therefore, in
- // order to keep this valuable information, we have to trust the caller
+ // body. But when using a buffered file reader, it does not work.
+ // Worse, the "Content-Length" header is also removed. Therefore,
+ // to keep this valuable information, we have to trust the caller
// by reading the value of the "Content-Length" entry and set it as the
// content length of the request. It's kinda suboptimal, but hey, at
// least it works.
@@ -100,7 +100,7 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
fields[3] = zap.String("latency_human", finishTime.Sub(c.startTime).String())
fields[4] = zap.Int64("bytes_out", req.ContentLength)
- if erroed {
+ if errored {
c.logger.Warn("request to webhook with error details handled", fields...)
return nil
diff --git a/pkg/modules/webhook/middleware.go b/pkg/modules/webhook/middleware.go
index 63150cce9..f5d870d39 100644
--- a/pkg/modules/webhook/middleware.go
+++ b/pkg/modules/webhook/middleware.go
@@ -20,11 +20,74 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
+type sendOutputFileParams struct {
+ ctx *api.Context
+ outputPath string
+ extraHttpHeaders map[string]string
+ traceHeader string
+ trace string
+ client *client
+ handleError func(error)
+}
+
func webhookMiddleware(w *Webhook) api.Middleware {
return api.Middleware{
Stack: api.MultipartStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
+ sendOutputFile := func(params sendOutputFileParams) {
+ outputFile, err := os.Open(params.outputPath)
+ if err != nil {
+ params.ctx.Log().Error(fmt.Sprintf("open output file: %s", err))
+ params.handleError(err)
+ return
+ }
+ defer func() {
+ err := outputFile.Close()
+ if err != nil {
+ params.ctx.Log().Error(fmt.Sprintf("close output file: %s", err))
+ }
+ }()
+
+ fileHeader := make([]byte, 512)
+ _, err = outputFile.Read(fileHeader)
+ if err != nil {
+ params.ctx.Log().Error(fmt.Sprintf("read header of output file: %s", err))
+ params.handleError(err)
+ return
+ }
+
+ fileStat, err := outputFile.Stat()
+ if err != nil {
+ params.ctx.Log().Error(fmt.Sprintf("get stat from output file: %s", err))
+ params.handleError(err)
+ return
+ }
+
+ _, err = outputFile.Seek(0, 0)
+ if err != nil {
+ params.ctx.Log().Error(fmt.Sprintf("reset output file reader: %s", err))
+ params.handleError(err)
+ return
+ }
+
+ headers := map[string]string{
+ echo.HeaderContentType: http.DetectContentType(fileHeader),
+ echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
+ params.traceHeader: params.trace,
+ }
+ _, ok := params.extraHttpHeaders[echo.HeaderContentDisposition]
+ if !ok {
+ headers[echo.HeaderContentDisposition] = fmt.Sprintf("attachment; filename=%q", params.ctx.OutputFilename(params.outputPath))
+ }
+
+ err = params.client.send(bufio.NewReader(outputFile), headers, false)
+ if err != nil {
+ params.ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err))
+ params.handleError(err)
+ }
+ }
+
return func(c echo.Context) error {
webhookUrl := c.Request().Header.Get("Gotenberg-Webhook-Url")
if webhookUrl == "" {
@@ -113,7 +176,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}
}
- // Retrieve values from echo.Context before it get recycled.
+ // Retrieve values from echo.Context before it gets recycled.
// See https://github.com/gotenberg/gotenberg/issues/1000.
startTime := c.Get("startTime").(time.Time)
traceHeader := c.Get("traceHeader").(string)
@@ -144,7 +207,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
// This method parses an "asynchronous" error and sends a
// request to the webhook error URL with a JSON body
// containing the status and the error message.
- handleAsyncError := func(err error) {
+ handleError := func(err error) {
status, message := api.ParseError(err)
body := struct {
@@ -158,7 +221,6 @@ func webhookMiddleware(w *Webhook) api.Middleware {
b, err := json.Marshal(body)
if err != nil {
ctx.Log().Error(fmt.Sprintf("marshal JSON: %s", err.Error()))
-
return
}
@@ -173,84 +235,88 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}
}
+ if w.enableSyncMode {
+ err := next(c)
+ if err != nil {
+ if errors.Is(err, api.ErrNoOutputFile) {
+ errNoOutputFile := fmt.Errorf("%w - the webhook middleware cannot handle the result of this route", err)
+ handleError(api.WrapError(
+ errNoOutputFile,
+ api.NewSentinelHttpError(
+ http.StatusBadRequest,
+ "The webhook middleware can only work with multipart/form-data routes that results in output files",
+ ),
+ ))
+ return nil
+ }
+ ctx.Log().Error(err.Error())
+ handleError(err)
+ return nil
+ }
+
+ outputPath, err := ctx.BuildOutputFile()
+ if err != nil {
+ ctx.Log().Error(fmt.Sprintf("build output file: %s", err))
+ handleError(err)
+ return nil
+ }
+ // No error, let's send the output file to the webhook URL.
+ sendOutputFile(sendOutputFileParams{
+ ctx: ctx,
+ outputPath: outputPath,
+ extraHttpHeaders: extraHttpHeaders,
+ traceHeader: traceHeader,
+ trace: trace,
+ client: client,
+ handleError: handleError,
+ })
+ return c.NoContent(http.StatusNoContent)
+ }
// As a webhook URL has been given, we handle the request in a
// goroutine and return immediately.
+ w.asyncCount.Add(1)
go func() {
defer cancel()
+ defer w.asyncCount.Add(-1)
// Call the next middleware in the chain.
err := next(c)
if err != nil {
+ if errors.Is(err, api.ErrNoOutputFile) {
+ errNoOutputFile := fmt.Errorf("%w - the webhook middleware cannot handle the result of this route", err)
+ handleError(api.WrapError(
+ errNoOutputFile,
+ api.NewSentinelHttpError(
+ http.StatusBadRequest,
+ "The webhook middleware can only work with multipart/form-data routes that results in output files",
+ ),
+ ))
+ return
+ }
// The process failed for whatever reason. Let's send the
// details to the webhook.
ctx.Log().Error(err.Error())
- handleAsyncError(err)
-
+ handleError(err)
return
}
- // No error, let's get build the output file.
+ // No error, let's get to build the output file.
outputPath, err := ctx.BuildOutputFile()
if err != nil {
ctx.Log().Error(fmt.Sprintf("build output file: %s", err))
- handleAsyncError(err)
-
+ handleError(err)
return
}
- outputFile, err := os.Open(outputPath)
- if err != nil {
- ctx.Log().Error(fmt.Sprintf("open output file: %s", err))
- handleAsyncError(err)
-
- return
- }
-
- defer func() {
- err := outputFile.Close()
- if err != nil {
- ctx.Log().Error(fmt.Sprintf("close output file: %s", err))
- }
- }()
-
- fileHeader := make([]byte, 512)
- _, err = outputFile.Read(fileHeader)
- if err != nil {
- ctx.Log().Error(fmt.Sprintf("read header of output file: %s", err))
- handleAsyncError(err)
-
- return
- }
-
- fileStat, err := outputFile.Stat()
- if err != nil {
- ctx.Log().Error(fmt.Sprintf("get stat from output file: %s", err))
- handleAsyncError(err)
-
- return
- }
-
- _, err = outputFile.Seek(0, 0)
- if err != nil {
- ctx.Log().Error(fmt.Sprintf("reset output file reader: %s", err))
- handleAsyncError(err)
-
- return
- }
-
- headers := map[string]string{
- echo.HeaderContentDisposition: fmt.Sprintf("attachement; filename=%q", ctx.OutputFilename(outputPath)),
- echo.HeaderContentType: http.DetectContentType(fileHeader),
- echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
- traceHeader: trace,
- }
-
- // Send the output file to the webhook.
- err = client.send(bufio.NewReader(outputFile), headers, false)
- if err != nil {
- ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err))
- handleAsyncError(err)
- }
+ sendOutputFile(sendOutputFileParams{
+ ctx: ctx,
+ outputPath: outputPath,
+ extraHttpHeaders: extraHttpHeaders,
+ traceHeader: traceHeader,
+ trace: trace,
+ client: client,
+ handleError: handleError,
+ })
}()
return api.ErrAsyncProcess
diff --git a/pkg/modules/webhook/middleware_test.go b/pkg/modules/webhook/middleware_test.go
deleted file mode 100644
index 8940f4069..000000000
--- a/pkg/modules/webhook/middleware_test.go
+++ /dev/null
@@ -1,582 +0,0 @@
-package webhook
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "math/rand"
- "mime/multipart"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-
- "github.com/dlclark/regexp2"
- "github.com/labstack/echo/v4"
- "go.uber.org/zap"
-
- "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
-)
-
-func TestWebhookMiddlewareGuards(t *testing.T) {
- buildMultipartFormDataRequest := func() *http.Request {
- body := &bytes.Buffer{}
- writer := multipart.NewWriter(body)
-
- defer func() {
- err := writer.Close()
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
- }()
-
- err := writer.WriteField("foo", "foo")
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- req := httptest.NewRequest(http.MethodPost, "/", body)
- req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
-
- return req
- }
-
- buildWebhookModule := func() *Webhook {
- return &Webhook{
- allowList: regexp2.MustCompile("", 0),
- denyList: regexp2.MustCompile("", 0),
- errorAllowList: regexp2.MustCompile("", 0),
- errorDenyList: regexp2.MustCompile("", 0),
- maxRetry: 0,
- retryMinWait: 0,
- retryMaxWait: 0,
- disable: false,
- }
- }
-
- for _, tc := range []struct {
- scenario string
- request *http.Request
- mod *Webhook
- next echo.HandlerFunc
- noDeadline bool
- expectError bool
- expectHttpError bool
- expectHttpStatus int
- }{
- {
- scenario: "no webhook URL, skip middleware",
- request: buildMultipartFormDataRequest(),
- mod: buildWebhookModule(),
- next: func() echo.HandlerFunc {
- return func(c echo.Context) error {
- return nil
- }
- }(),
- noDeadline: false,
- expectError: false,
- expectHttpError: false,
- },
- {
- scenario: "no webhook error URL",
- request: func() *http.Request {
- req := buildMultipartFormDataRequest()
- req.Header.Set("Gotenberg-Webhook-Url", "foo")
- return req
- }(),
- mod: buildWebhookModule(),
- noDeadline: false,
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- },
- {
- scenario: "context has no deadline",
- request: func() *http.Request {
- req := buildMultipartFormDataRequest()
- req.Header.Set("Gotenberg-Webhook-Url", "foo")
- req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
- return req
- }(),
- mod: buildWebhookModule(),
- noDeadline: true,
- expectError: true,
- },
- {
- scenario: "webhook URL is not allowed",
- request: func() *http.Request {
- req := buildMultipartFormDataRequest()
- req.Header.Set("Gotenberg-Webhook-Url", "foo")
- req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
- return req
- }(),
- mod: func() *Webhook {
- mod := buildWebhookModule()
- mod.allowList = regexp2.MustCompile("bar", 0)
- return mod
- }(),
- noDeadline: false,
- expectError: true,
- },
- {
- scenario: "webhook URL is denied",
- request: func() *http.Request {
- req := buildMultipartFormDataRequest()
- req.Header.Set("Gotenberg-Webhook-Url", "foo")
- req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
- return req
- }(),
- mod: func() *Webhook {
- mod := buildWebhookModule()
- mod.denyList = regexp2.MustCompile("foo", 0)
- return mod
- }(),
- noDeadline: false,
- expectError: true,
- },
- {
- scenario: "webhook error URL is not allowed",
- request: func() *http.Request {
- req := buildMultipartFormDataRequest()
- req.Header.Set("Gotenberg-Webhook-Url", "foo")
- req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
- return req
- }(),
- mod: func() *Webhook {
- mod := buildWebhookModule()
- mod.errorAllowList = regexp2.MustCompile("foo", 0)
- return mod
- }(),
- noDeadline: false,
- expectError: true,
- },
- {
- scenario: "webhook error URL is denied",
- request: func() *http.Request {
- req := buildMultipartFormDataRequest()
- req.Header.Set("Gotenberg-Webhook-Url", "foo")
- req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
- return req
- }(),
- mod: func() *Webhook {
- mod := buildWebhookModule()
- mod.errorDenyList = regexp2.MustCompile("bar", 0)
- return mod
- }(),
- noDeadline: false,
- expectError: true,
- },
- {
- scenario: "invalid webhook method (GET)",
- request: func() *http.Request {
- req := buildMultipartFormDataRequest()
- req.Header.Set("Gotenberg-Webhook-Url", "foo")
- req.Header.Set("Gotenberg-Webhook-Method", http.MethodGet)
- req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
- return req
- }(),
- mod: buildWebhookModule(),
- noDeadline: false,
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- },
- {
- scenario: "invalid webhook error method (GET)",
- request: func() *http.Request {
- req := buildMultipartFormDataRequest()
- req.Header.Set("Gotenberg-Webhook-Url", "foo")
- req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
- req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet)
- return req
- }(),
- mod: buildWebhookModule(),
- noDeadline: false,
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- },
- {
- scenario: "valid webhook method (POST) but invalid webhook error method (GET)",
- request: func() *http.Request {
- req := buildMultipartFormDataRequest()
- req.Header.Set("Gotenberg-Webhook-Url", "foo")
- req.Header.Set("Gotenberg-Webhook-Method", http.MethodPost)
- req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
- req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet)
- return req
- }(),
- mod: buildWebhookModule(),
- noDeadline: false,
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- },
- {
- scenario: "valid webhook method (PATH) but invalid webhook error method (GET)",
- request: func() *http.Request {
- req := buildMultipartFormDataRequest()
- req.Header.Set("Gotenberg-Webhook-Url", "foo")
- req.Header.Set("Gotenberg-Webhook-Method", http.MethodPatch)
- req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
- req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet)
- return req
- }(),
- mod: buildWebhookModule(),
- noDeadline: false,
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- },
- {
- scenario: "valid webhook method (PUT) but invalid webhook error method (GET)",
- request: func() *http.Request {
- req := buildMultipartFormDataRequest()
- req.Header.Set("Gotenberg-Webhook-Url", "foo")
- req.Header.Set("Gotenberg-Webhook-Method", http.MethodPut)
- req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
- req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet)
- return req
- }(),
- mod: buildWebhookModule(),
- noDeadline: false,
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- },
- {
- scenario: "invalid webhook extra HTTP headers",
- request: func() *http.Request {
- req := buildMultipartFormDataRequest()
- req.Header.Set("Gotenberg-Webhook-Url", "foo")
- req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
- req.Header.Set("Gotenberg-Webhook-Extra-Http-Headers", "foo")
- return req
- }(),
- mod: buildWebhookModule(),
- noDeadline: false,
- expectError: true,
- expectHttpError: true,
- expectHttpStatus: http.StatusBadRequest,
- },
- } {
- t.Run(tc.scenario, func(t *testing.T) {
- srv := echo.New()
- srv.HideBanner = true
- srv.HidePort = true
-
- c := srv.NewContext(tc.request, httptest.NewRecorder())
-
- if tc.noDeadline {
- ctx := &api.ContextMock{Context: &api.Context{Context: context.Background()}}
- ctx.SetEchoContext(c)
- c.Set("context", ctx.Context)
- c.Set("cancel", func() context.CancelFunc {
- return nil
- }())
- } else {
- timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
- ctx := &api.ContextMock{Context: &api.Context{Context: timeoutCtx}}
- ctx.SetEchoContext(c)
- c.Set("context", ctx.Context)
- c.Set("cancel", cancel)
- }
-
- err := webhookMiddleware(tc.mod).Handler(tc.next)(c)
-
- if tc.expectError && err == nil {
- t.Fatal("expected error but got none", err)
- }
-
- if !tc.expectError && err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- var httpErr api.HttpError
- isHttpError := errors.As(err, &httpErr)
-
- if tc.expectHttpError && !isHttpError {
- t.Errorf("expected an HTTP error but got: %v", err)
- }
-
- if !tc.expectHttpError && isHttpError {
- t.Errorf("expected no HTTP error but got one: %v", httpErr)
- }
-
- if err != nil && tc.expectHttpError && isHttpError {
- status, _ := httpErr.HttpError()
- if status != tc.expectHttpStatus {
- t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
- }
- }
- })
- }
-}
-
-func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
- buildMultipartFormDataRequest := func() *http.Request {
- body := &bytes.Buffer{}
- writer := multipart.NewWriter(body)
-
- defer func() {
- err := writer.Close()
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
- }()
-
- err := writer.WriteField("foo", "foo")
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- req := httptest.NewRequest(http.MethodPost, "/", body)
- req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
-
- return req
- }
-
- buildWebhookModule := func() *Webhook {
- return &Webhook{
- allowList: regexp2.MustCompile("", 0),
- denyList: regexp2.MustCompile("", 0),
- errorAllowList: regexp2.MustCompile("", 0),
- errorDenyList: regexp2.MustCompile("", 0),
- maxRetry: 0,
- retryMinWait: 0,
- retryMaxWait: 0,
- clientTimeout: time.Duration(30) * time.Second,
- disable: false,
- }
- }
-
- for _, tc := range []struct {
- scenario string
- request *http.Request
- mod *Webhook
- next echo.HandlerFunc
- expectWebhookContentType string
- expectWebhookMethod string
- expectWebhookExtraHttpHeaders map[string]string
- expectWebhookFilename string
- expectWebhookErrorStatus int
- expectWebhookErrorMessage string
- returnedError *echo.HTTPError
- }{
- {
- scenario: "next handler return an error",
- request: buildMultipartFormDataRequest(),
- mod: buildWebhookModule(),
- next: func() echo.HandlerFunc {
- return func(c echo.Context) error {
- return errors.New("foo")
- }
- }(),
- expectWebhookContentType: echo.MIMEApplicationJSON,
- expectWebhookMethod: http.MethodPost,
- expectWebhookErrorStatus: http.StatusInternalServerError,
- expectWebhookErrorMessage: http.StatusText(http.StatusInternalServerError),
- },
- {
- scenario: "next handler return an HTTP error",
- request: buildMultipartFormDataRequest(),
- mod: buildWebhookModule(),
- next: func() echo.HandlerFunc {
- return func(c echo.Context) error {
- return api.NewSentinelHttpError(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
- }
- }(),
- expectWebhookContentType: echo.MIMEApplicationJSON,
- expectWebhookMethod: http.MethodPost,
- expectWebhookErrorStatus: http.StatusBadRequest,
- expectWebhookErrorMessage: http.StatusText(http.StatusBadRequest),
- },
- {
- scenario: "success",
- request: func() *http.Request {
- req := buildMultipartFormDataRequest()
- req.Header.Set("Gotenberg-Output-Filename", "foo")
- req.Header.Set("Gotenberg-Webhook-Extra-Http-Headers", `{ "foo": "bar" }`)
- return req
- }(),
- mod: buildWebhookModule(),
- next: func() echo.HandlerFunc {
- return func(c echo.Context) error {
- ctx := c.Get("context").(*api.Context)
- return ctx.AddOutputPaths("/tests/test/testdata/api/sample2.pdf")
- }
- }(),
- expectWebhookContentType: "application/pdf",
- expectWebhookMethod: http.MethodPost,
- expectWebhookFilename: "foo",
- expectWebhookExtraHttpHeaders: map[string]string{"foo": "bar"},
- },
- {
- scenario: "success (return an error)",
- request: func() *http.Request {
- req := buildMultipartFormDataRequest()
- req.Header.Set("Gotenberg-Output-Filename", "foo")
- return req
- }(),
- mod: buildWebhookModule(),
- next: func() echo.HandlerFunc {
- return func(c echo.Context) error {
- ctx := c.Get("context").(*api.Context)
- return ctx.AddOutputPaths("/tests/test/testdata/api/sample1.pdf")
- }
- }(),
- returnedError: echo.ErrInternalServerError,
- expectWebhookContentType: echo.MIMEApplicationJSON,
- expectWebhookMethod: http.MethodPost,
- expectWebhookErrorStatus: http.StatusInternalServerError,
- expectWebhookErrorMessage: http.StatusText(http.StatusInternalServerError),
- expectWebhookFilename: "foo",
- },
- } {
- func() {
- srv := echo.New()
- srv.HideBanner = true
- srv.HidePort = true
-
- c := srv.NewContext(tc.request, httptest.NewRecorder())
- c.Set("logger", zap.NewNop())
- c.Set("traceHeader", "Gotenberg-Trace")
- c.Set("trace", "foo")
- c.Set("startTime", time.Now())
-
- timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
- ctx := &api.ContextMock{Context: &api.Context{Context: timeoutCtx}}
- ctx.SetLogger(zap.NewNop())
- ctx.SetEchoContext(c)
-
- c.Set("context", ctx.Context)
- c.Set("cancel", cancel)
-
- webhook := echo.New()
- webhook.HideBanner = true
- webhook.HidePort = true
- webhookPort := rand.Intn(65535-1025+1) + 1025
-
- c.Request().Header.Set("Gotenberg-Webhook-Url", fmt.Sprintf("http://localhost:%d/", webhookPort))
- c.Request().Header.Set("Gotenberg-Webhook-Error-Url", fmt.Sprintf("http://localhost:%d/", webhookPort))
-
- errChan := make(chan error, 1)
-
- webhook.POST(
- "/",
- func() echo.HandlerFunc {
- return func(c echo.Context) error {
- contentType := c.Request().Header.Get(echo.HeaderContentType)
- if contentType != tc.expectWebhookContentType {
- t.Errorf("expected '%s' '%s' but got '%s'", echo.HeaderContentType, tc.expectWebhookContentType, contentType)
- }
-
- trace := c.Request().Header.Get("Gotenberg-Trace")
- if trace != "foo" {
- t.Errorf("expected '%s' '%s' but got '%s'", "Gotenberg-Trace", "foo", trace)
- }
-
- method := c.Request().Method
- if method != tc.expectWebhookMethod {
- t.Errorf("expected HTTP method '%s' but got '%s'", tc.expectWebhookMethod, method)
- }
-
- for key, expect := range tc.expectWebhookExtraHttpHeaders {
- actual := c.Request().Header.Get(key)
-
- if actual != expect {
- t.Errorf("expected '%s' '%s' but got '%s'", key, expect, actual)
- }
- }
-
- if contentType == echo.MIMEApplicationJSON {
- body, err := io.ReadAll(c.Request().Body)
- if err != nil {
- errChan <- err
- return nil
- }
-
- result := struct {
- Status int `json:"status"`
- Message string `json:"message"`
- }{}
-
- err = json.Unmarshal(body, &result)
- if err != nil {
- errChan <- err
- return nil
- }
-
- if result.Status != tc.expectWebhookErrorStatus {
- t.Errorf("expected status %d from JSON but got %d", tc.expectWebhookErrorStatus, result.Status)
- }
-
- if result.Message != tc.expectWebhookErrorMessage {
- t.Errorf("expected message '%s' from JSON but got '%s'", tc.expectWebhookErrorMessage, result.Message)
- }
-
- errChan <- nil
- return nil
- }
-
- contentLength := c.Request().Header.Get(echo.HeaderContentLength)
- if contentLength == "" {
- t.Errorf("expected non empty '%s'", echo.HeaderContentLength)
- }
-
- contentDisposition := c.Request().Header.Get(echo.HeaderContentDisposition)
- if !strings.Contains(contentDisposition, tc.expectWebhookFilename) {
- t.Errorf("expected '%s' '%s' to contain '%s'", echo.HeaderContentDisposition, contentDisposition, tc.expectWebhookFilename)
- }
-
- body, err := io.ReadAll(c.Request().Body)
- if err != nil {
- errChan <- err
- return nil
- }
-
- if body == nil || len(body) == 0 {
- t.Error("expected non nil body")
- }
-
- errChan <- nil
-
- if tc.returnedError != nil {
- return tc.returnedError
- }
-
- return nil
- }
- }(),
- )
-
- go func() {
- err := webhook.Start(fmt.Sprintf(":%d", webhookPort))
- if !errors.Is(err, http.ErrServerClosed) {
- t.Errorf("expected no error but got: %v", err)
- }
- }()
-
- defer func() {
- err := webhook.Shutdown(context.TODO())
- if err != nil {
- t.Errorf("expected no error but got: %v", err)
- }
- }()
-
- err := webhookMiddleware(tc.mod).Handler(tc.next)(c)
- if err != nil && !errors.Is(err, api.ErrAsyncProcess) {
- t.Errorf("expected no error but got: %v", err)
- }
-
- err = <-errChan
- if err != nil {
- t.Errorf("expected no error but got: %v", err)
- }
- }()
- }
-}
diff --git a/pkg/modules/webhook/webhook.go b/pkg/modules/webhook/webhook.go
index 866122096..dd660ce88 100644
--- a/pkg/modules/webhook/webhook.go
+++ b/pkg/modules/webhook/webhook.go
@@ -1,6 +1,7 @@
package webhook
import (
+ "sync/atomic"
"time"
"github.com/dlclark/regexp2"
@@ -14,9 +15,10 @@ func init() {
gotenberg.MustRegisterModule(new(Webhook))
}
-// Webhook is a module which provides a middleware for uploading output files
+// Webhook is a module that provides a middleware for uploading output files
// to any destinations in an asynchronous fashion.
type Webhook struct {
+ enableSyncMode bool
allowList *regexp2.Regexp
denyList *regexp2.Regexp
errorAllowList *regexp2.Regexp
@@ -25,6 +27,7 @@ type Webhook struct {
retryMinWait time.Duration
retryMaxWait time.Duration
clientTimeout time.Duration
+ asyncCount atomic.Int64
disable bool
}
@@ -34,6 +37,7 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor {
ID: "webhook",
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("webhook", flag.ExitOnError)
+ fs.Bool("webhook-enable-sync-mode", false, "Enable synchronous mode for the webhook feature")
fs.String("webhook-allow-list", "", "Set the allowed URLs for the webhook feature using a regular expression")
fs.String("webhook-deny-list", "", "Set the denied URLs for the webhook feature using a regular expression")
fs.String("webhook-error-allow-list", "", "Set the allowed URLs in case of an error for the webhook feature using a regular expression")
@@ -53,6 +57,7 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor {
// Provision sets the module properties.
func (w *Webhook) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
+ w.enableSyncMode = flags.MustBool("webhook-enable-sync-mode")
w.allowList = flags.MustRegexp("webhook-allow-list")
w.denyList = flags.MustRegexp("webhook-deny-list")
w.errorAllowList = flags.MustRegexp("webhook-error-allow-list")
@@ -62,6 +67,7 @@ func (w *Webhook) Provision(ctx *gotenberg.Context) error {
w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait")
w.clientTimeout = flags.MustDuration("webhook-client-timeout")
w.disable = flags.MustBool("webhook-disable")
+ w.asyncCount.Store(0)
return nil
}
@@ -77,9 +83,15 @@ func (w *Webhook) Middlewares() ([]api.Middleware, error) {
}, nil
}
+// AsyncCount returns the number of asynchronous requests.
+func (w *Webhook) AsyncCount() int64 {
+ return w.asyncCount.Load()
+}
+
// Interface guards.
var (
- _ gotenberg.Module = (*Webhook)(nil)
- _ gotenberg.Provisioner = (*Webhook)(nil)
- _ api.MiddlewareProvider = (*Webhook)(nil)
+ _ gotenberg.Module = (*Webhook)(nil)
+ _ gotenberg.Provisioner = (*Webhook)(nil)
+ _ api.MiddlewareProvider = (*Webhook)(nil)
+ _ api.AsynchronousCounter = (*Webhook)(nil)
)
diff --git a/pkg/modules/webhook/webhook_test.go b/pkg/modules/webhook/webhook_test.go
deleted file mode 100644
index 03928046e..000000000
--- a/pkg/modules/webhook/webhook_test.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package webhook
-
-import (
- "reflect"
- "testing"
-
- "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
-)
-
-func TestWebhook_Descriptor(t *testing.T) {
- descriptor := new(Webhook).Descriptor()
-
- actual := reflect.TypeOf(descriptor.New())
- expect := reflect.TypeOf(new(Webhook))
-
- if actual != expect {
- t.Errorf("expected '%s' but got '%s'", expect, actual)
- }
-}
-
-func TestWebhook_Provision(t *testing.T) {
- mod := new(Webhook)
- ctx := gotenberg.NewContext(
- gotenberg.ParsedFlags{
- FlagSet: new(Webhook).Descriptor().FlagSet,
- },
- nil,
- )
-
- err := mod.Provision(ctx)
- if err != nil {
- t.Errorf("expected no error but got: %v", err)
- }
-}
-
-func TestWebhook_Middlewares(t *testing.T) {
- for _, tc := range []struct {
- scenario string
- disable bool
- expectMiddlewares int
- }{
- {
- scenario: "webhook disabled",
- disable: true,
- expectMiddlewares: 0,
- },
- {
- scenario: "webhook enabled",
- disable: false,
- expectMiddlewares: 1,
- },
- } {
- mod := new(Webhook)
- mod.disable = tc.disable
-
- middlewares, err := mod.Middlewares()
- if err != nil {
- t.Fatalf("expected no error but got: %v", err)
- }
-
- if tc.expectMiddlewares != len(middlewares) {
- t.Errorf("expected %d middlewares but got %d", tc.expectMiddlewares, len(middlewares))
- }
- }
-}
diff --git a/scripts/release.sh b/scripts/release.sh
deleted file mode 100755
index f9c014ff9..000000000
--- a/scripts/release.sh
+++ /dev/null
@@ -1,84 +0,0 @@
-#!/bin/bash
-
-set -e
-
-# Args.
-GOLANG_VERSION="$1"
-GOTENBERG_VERSION="$2"
-GOTENBERG_USER_GID="$3"
-GOTENBERG_USER_UID="$4"
-NOTO_COLOR_EMOJI_VERSION="$5"
-PDFTK_VERSION="$6"
-PDFCPU_VERSION="$7"
-DOCKER_REGISTRY="$8"
-DOCKER_REPOSITORY="$9"
-LINUX_AMD64_RELEASE="${10}"
-
-# Find out if given version is "semver".
-GOTENBERG_VERSION="${GOTENBERG_VERSION//v}"
-IFS='.' read -ra SEMVER <<< "$GOTENBERG_VERSION"
-VERSION_LENGTH=${#SEMVER[@]}
-TAGS=()
-TAGS_CLOUD_RUN=()
-
-if [ "$VERSION_LENGTH" -eq 3 ]; then
- MAJOR="${SEMVER[0]}"
- MINOR="${SEMVER[1]}"
- PATCH="${SEMVER[2]}"
-
- TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest")
- TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR")
- TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR")
- TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR.$PATCH")
-
- TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-cloudrun")
- TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR-cloudrun")
- TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR-cloudrun")
- TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR.$PATCH-cloudrun")
-else
- # Normalizes version.
- GOTENBERG_VERSION="${GOTENBERG_VERSION// /-}"
- GOTENBERG_VERSION="$(echo "$GOTENBERG_VERSION" | tr -cd '[:alnum:]._\-')"
-
- if [[ "$GOTENBERG_VERSION" =~ ^[\.\-] ]]; then
- GOTENBERG_VERSION="_${GOTENBERG_VERSION#?}"
- fi
-
- if [ "${#GOTENBERG_VERSION}" -gt 128 ]; then
- GOTENBERG_VERSION="${GOTENBERG_VERSION:0:128}"
- fi
-
- TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION")
- TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION-cloudrun")
-fi
-
-# Multi-arch build takes a lot of time.
-if [ "$LINUX_AMD64_RELEASE" = true ]; then
- PLATFORM_FLAG="--platform linux/amd64"
-else
- PLATFORM_FLAG="--platform linux/amd64,linux/arm64,linux/386,linux/arm/v7"
-fi
-
-docker buildx build \
- --build-arg GOLANG_VERSION="$GOLANG_VERSION" \
- --build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
- --build-arg GOTENBERG_USER_GID="$GOTENBERG_USER_GID" \
- --build-arg GOTENBERG_USER_UID="$GOTENBERG_USER_UID" \
- --build-arg NOTO_COLOR_EMOJI_VERSION="$NOTO_COLOR_EMOJI_VERSION" \
- --build-arg PDFTK_VERSION="$PDFTK_VERSION" \
- --build-arg PDFCPU_VERSION="$PDFCPU_VERSION" \
- $PLATFORM_FLAG \
- "${TAGS[@]}" \
- --push \
- -f build/Dockerfile .
-
-# Cloud Run variant.
-# Only linux/amd64! See https://github.com/gotenberg/gotenberg/issues/505#issuecomment-1264679278.
-docker buildx build \
- --build-arg DOCKER_REGISTRY="$DOCKER_REGISTRY" \
- --build-arg DOCKER_REPOSITORY="$DOCKER_REPOSITORY" \
- --build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
- --platform linux/amd64 \
- "${TAGS_CLOUD_RUN[@]}" \
- --push \
- -f build/Dockerfile.cloudrun .
diff --git a/test/Dockerfile b/test/Dockerfile
deleted file mode 100644
index 5011f5066..000000000
--- a/test/Dockerfile
+++ /dev/null
@@ -1,49 +0,0 @@
-ARG GOLANG_VERSION
-ARG DOCKER_REGISTRY
-ARG DOCKER_REPOSITORY
-ARG GOTENBERG_VERSION
-ARG GOLANGCI_LINT_VERSION
-
-FROM golang:$GOLANG_VERSION-bookworm AS golang
-
-# We're extending the Gotenberg's Docker image because our code relies on external
-# dependencies like Google Chrome, LibreOffice, etc.
-FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
-
-USER root
-
-ENV GOPATH=/go
-ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
-ENV CGO_ENABLED=1
-
-COPY --from=golang /usr/local/go /usr/local/go
-
-RUN apt-get update -qq &&\
- apt-get install -y -qq --no-install-recommends \
- sudo \
- # gcc for cgo.
- g++ \
- gcc \
- libc6-dev \
- make \
- pkg-config &&\
- rm -rf /var/lib/apt/lists/* &&\
- mkdir -p "$GOPATH/src" "$GOPATH/bin" &&\
- chmod -R 777 "$GOPATH" &&\
- adduser gotenberg sudo &&\
- echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers &&\
- # We cannot use $PATH in the next command (print $PATH instead of the environment variable value).
- sed -i 's#/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin#/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/go/bin:/usr/local/go/bin#g' /etc/sudoers &&\
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin $GOLANGCI_LINT_VERSION &&\
- go install mvdan.cc/gofumpt@latest &&\
- go install github.com/daixiang0/gci@latest
-
-COPY ./test/docker-entrypoint.sh /usr/bin/docker-entrypoint.sh
-COPY ./test/golint.sh /usr/bin/golint
-COPY ./test/gotest.sh /usr/bin/gotest
-COPY ./test/gotodos.sh /usr/bin/gotodos
-
-# Pristine working directory.
-WORKDIR /tests
-
-ENTRYPOINT [ "/usr/bin/tini", "--", "docker-entrypoint.sh" ]
\ No newline at end of file
diff --git a/test/docker-entrypoint.sh b/test/docker-entrypoint.sh
deleted file mode 100755
index 4862f516d..000000000
--- a/test/docker-entrypoint.sh
+++ /dev/null
@@ -1,59 +0,0 @@
-#!/bin/bash
-
-# This entrypoint allows us to set the UID and GID of the host user so that
-# our testing environment does not override files permissions from the host.
-# Credits: https://github.com/thecodingmachine/docker-images-php.
-
-set +e
-mkdir -p testing_file_system_rights.foo
-chmod 700 testing_file_system_rights.foo
-su gotenberg -c "touch testing_file_system_rights.foo/foo > /dev/null 2>&1"
-HAS_CONSISTENT_RIGHTS=$?
-
-if [[ "$HAS_CONSISTENT_RIGHTS" != "0" ]]; then
- # If not specified, the DOCKER_USER is the owner of the current working directory (heuristic!).
- DOCKER_USER=$(stat -c '%u' "$(pwd)")
-else
- # macOs or Windows.
- # Note: in most cases, we don't care about the rights (they are not respected).
- FILE_OWNER=$(stat -c '%u' "testing_file_system_rights.foo/foo")
- if [[ "$FILE_OWNER" == "0" ]]; then
- # If root, we are likely on a Windows host.
- # All files will belong to root, but it does not matter as everybody can write/delete
- # those (0777 access rights).
- DOCKER_USER=gotenberg
- else
- # In case of a NFS mount (common on macOS), the created files will belong to the NFS user.
- DOCKER_USER=$FILE_OWNER
- fi
-fi
-
-rm -rf testing_file_system_rights.foo
-set -e
-unset HAS_CONSISTENT_RIGHTS
-
-# Note: DOCKER_USER is either a username (if the user exists in the container),
-# otherwise a user ID (a user from the host).
-
-# DOCKER_USER is an ID.
-if [[ "$DOCKER_USER" =~ ^[0-9]+$ ]] ; then
- # Let's change the gotenberg user's ID in order to match this free ID.
- usermod -u "$DOCKER_USER" -G sudo gotenberg
- DOCKER_USER=gotenberg
-fi
-
-DOCKER_USER_ID=$(id -ur $DOCKER_USER)
-
-# Fix access rights to stdout and stderr.
-set +e
-chown $DOCKER_USER /proc/self/fd/{1,2}
-set -e
-
-# Install modules.
-set -x
-go mod download
-go mod tidy
-set +x
-
-# Run the command with the correct user.
-exec "sudo" "-E" "-H" "-u" "#$DOCKER_USER_ID" "$@"
diff --git a/test/golint.sh b/test/golint.sh
deleted file mode 100755
index 107c7018c..000000000
--- a/test/golint.sh
+++ /dev/null
@@ -1,5 +0,0 @@
-#!/bin/bash
-
-set -x
-
-golangci-lint run
\ No newline at end of file
diff --git a/test/gotest.sh b/test/gotest.sh
deleted file mode 100755
index 99bb64332..000000000
--- a/test/gotest.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/bin/bash
-
-set -x
-
-go test -race -covermode=atomic -coverprofile=/tests/coverage.txt ./...
-RESULT=$?
-
-go tool cover -html=coverage.txt -o /tests/coverage.html
-
-exit $RESULT
\ No newline at end of file
diff --git a/test/gotodos.sh b/test/gotodos.sh
deleted file mode 100755
index c6201369c..000000000
--- a/test/gotodos.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/bin/bash
-
-set -x
-
-golangci-lint run \
- --no-config \
- --disable-all \
- --enable godox
\ No newline at end of file
diff --git a/test/integration/doc.go b/test/integration/doc.go
new file mode 100644
index 000000000..d52de84a7
--- /dev/null
+++ b/test/integration/doc.go
@@ -0,0 +1,2 @@
+// Package integration contains everything related to integration testing.
+package integration
diff --git a/test/integration/features/chromium_convert_html.feature b/test/integration/features/chromium_convert_html.feature
new file mode 100644
index 000000000..d9306fd75
--- /dev/null
+++ b/test/integration/features/chromium_convert_html.feature
@@ -0,0 +1,997 @@
+# TODO:
+# 1. JavaScript disabled on some feature.
+
+@chromium
+@chromium-convert-html
+Feature: /forms/chromium/convert/html
+
+ Scenario: POST /forms/chromium/convert/html (Default)
+ 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+
+ Scenario: POST /forms/chromium/convert/html (Single Page)
+ 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/pages-12-html/index.html | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 12 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo.pdf" PDF should have the following content at page 12:
+ """
+ Page 12
+ """
+ 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/pages-12-html/index.html | file |
+ | singlePage | 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"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ # page-break-after: always; tells the browser's print engine to force a page break after each element,
+ # even when calculating a large enough paper height, Chromium's PDF rendering will still honor those page break
+ # directives.
+ """
+ Page 12
+ """
+
+ Scenario: POST /forms/chromium/convert/html (Landscape)
+ 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should NOT be set to landscape orientation
+ 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 |
+ | landscape | 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"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should be set to landscape orientation
+
+ Scenario: POST /forms/chromium/convert/html (Native Page Ranges)
+ 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/pages-12-html/index.html | file |
+ | nativePageRanges | 2-3 | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 2 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "foo.pdf" PDF should have the following content at page 2:
+ """
+ Page 3
+ """
+
+ Scenario: POST /forms/chromium/convert/html (Header & Footer)
+ 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/pages-12-html/index.html | file |
+ | files | testdata/header-footer-html/header.html | file |
+ | files | testdata/header-footer-html/footer.html | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 12 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Pages 12
+ """
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ 1 of 12
+ """
+ Then the "foo.pdf" PDF should have the following content at page 12:
+ """
+ Pages 12
+ """
+ Then the "foo.pdf" PDF should have the following content at page 12:
+ """
+ 12 of 12
+ """
+
+ Scenario: POST /forms/chromium/convert/html (Wait Delay)
+ 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/feature-rich-html/index.html | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
+ """
+ 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/feature-rich-html/index.html | file |
+ | waitDelay | 2.5s | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
+ """
+
+ Scenario: POST /forms/chromium/convert/html (Wait For Expression)
+ 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/feature-rich-html/index.html | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
+ """
+ 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/feature-rich-html/index.html | file |
+ | waitForExpression | window.globalVar === 'ready' | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
+ """
+
+ Scenario: POST /forms/chromium/convert/html (Emulated Media Type)
+ 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/feature-rich-html/index.html | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Emulated media type is 'print'.
+ """
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Emulated media type is 'screen'.
+ """
+ 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/feature-rich-html/index.html | file |
+ | emulatedMediaType | print | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Emulated media type is 'print'.
+ """
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Emulated media type is 'screen'.
+ """
+ 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/feature-rich-html/index.html | file |
+ | emulatedMediaType | screen | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Emulated media type is 'screen'.
+ """
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Emulated media type is 'print'.
+ """
+
+ Scenario: POST /forms/chromium/convert/html (Default Allow / Deny Lists)
+ 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/feature-rich-html/index.html | file |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the Gotenberg container should log the following entries:
+ | 'file:///etc/passwd' matches the expression from the denied list |
+
+ Scenario: POST /forms/chromium/convert/html (Main URL does NOT match allowed list)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* |
+ 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/feature-rich-html/index.html | file |
+ Then the response status code should be 403
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should match string:
+ """
+ Forbidden
+ """
+
+ Scenario: POST /forms/chromium/convert/html (Main URL does match denied list)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | CHROMIUM_ALLOW_LIST | |
+ | CHROMIUM_DENY_LIST | ^file:///tmp.* |
+ 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/feature-rich-html/index.html | file |
+ Then the response status code should be 403
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should match string:
+ """
+ Forbidden
+ """
+
+ Scenario: POST /forms/chromium/convert/html (Request does not match the allowed list)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | CHROMIUM_ALLOW_LIST | ^file:///tmp.* |
+ 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/feature-rich-html/index.html | file |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the Gotenberg container should log the following entries:
+ | 'file:///etc/passwd' does not match the expression from the allowed list |
+
+ Scenario: POST /forms/chromium/convert/html (JavaScript Enabled)
+ 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/feature-rich-html/index.html | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ JavaScript is enabled.
+ """
+
+ Scenario: POST /forms/chromium/convert/html (JavaScript Disabled)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | CHROMIUM_DISABLE_JAVASCRIPT | true |
+ 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/feature-rich-html/index.html | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ JavaScript is enabled.
+ """
+
+ Scenario: POST /forms/chromium/convert/html (Fail On Resource HTTP Status Codes)
+ 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/feature-rich-html/index.html | file |
+ | failOnResourceHttpStatusCodes | [499,599] | field |
+ Then the response status code should be 409
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should match string:
+ """
+ Invalid HTTP status code from resources:
+ https://gethttpstatus.com/400 - 400: Bad Request
+ """
+
+ Scenario: POST /forms/chromium/convert/html (Fail On Resource Loading Failed)
+ 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/feature-rich-html/index.html | file |
+ | failOnResourceLoadingFailed | true | field |
+ Then the response status code should be 409
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should contain string:
+ """
+ Chromium failed to load resources
+ """
+ Then the response body should contain string:
+ """
+ resource Stylesheet: net::ERR_CONNECTION_REFUSED
+ """
+ Then the response body should contain string:
+ """
+ resource Stylesheet: net::ERR_FILE_NOT_FOUND
+ """
+
+ Scenario: POST /forms/chromium/convert/html (Fail On Console Exceptions)
+ 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/feature-rich-html/index.html | file |
+ | failOnConsoleExceptions | true | field |
+ Then the response status code should be 409
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should contain string:
+ """
+ Chromium console exceptions
+ """
+ Then the response body should contain string:
+ """
+ Error: Exception 1
+ """
+ Then the response body should contain string:
+ """
+ Error: Exception 2
+ """
+
+ Scenario: POST /forms/chromium/convert/html (Bad Request)
+ 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):
+ | singlePage | foo | field |
+ | paperWidth | foo | field |
+ | paperHeight | foo | field |
+ | marginTop | foo | field |
+ | marginBottom | foo | field |
+ | marginLeft | foo | field |
+ | marginRight | foo | field |
+ | preferCssPageSize | foo | field |
+ | generateDocumentOutline | foo | field |
+ | generateTaggedPdf | foo | field |
+ | printBackground | foo | field |
+ | omitBackground | foo | field |
+ | landscape | foo | field |
+ | scale | foo | field |
+ | waitDelay | foo | field |
+ | emulatedMediaType | foo | field |
+ | failOnHttpStatusCodes | foo | field |
+ | failOnResourceHttpStatusCodes | foo | field |
+ | failOnResourceLoadingFailed | foo | field |
+ | failOnConsoleExceptions | foo | field |
+ | skipNetworkIdleEvent | 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 'skipNetworkIdleEvent' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnResourceHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceLoadingFailed' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnConsoleExceptions' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'waitDelay' is invalid (got 'foo', resulting to time: invalid duration "foo"); form field 'emulatedMediaType' is invalid (got 'foo', resulting to wrong value, expected either 'screen', 'print' or empty); form field 'omitBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'landscape' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'printBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'scale' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'singlePage' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'paperWidth' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'paperHeight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginTop' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginBottom' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginLeft' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginRight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'preferCssPageSize' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'generateDocumentOutline' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'generateTaggedPdf' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form file 'index.html' is required
+ """
+ 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 |
+ | omitBackground | true | 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:
+ """
+ omitBackground requires printBackground set to true
+ """
+ # Does not seems to happen on amd architectures anymore since Chromium 137.
+ # See: https://github.com/gotenberg/gotenberg/actions/runs/15384321883/job/43280184372.
+ # 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 |
+ # | paperWidth | 0 | field |
+ # | paperHeight | 0 | field |
+ # | marginTop | 1000000 | field |
+ # | marginBottom | 1000000 | field |
+ # | marginLeft | 1000000 | field |
+ # | marginRight | 1000000 | 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:
+ # """
+ # Chromium does not handle the provided settings; please check for aberrant form values
+ # """
+ 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 |
+ | nativePageRanges | 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:
+ """
+ Chromium does not handle the page ranges 'foo' (nativePageRanges) syntax
+ """
+ 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 |
+ | nativePageRanges | 2-3 | 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:
+ """
+ The page ranges '2-3' (nativePageRanges) exceeds the page count
+ """
+ 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 |
+ | waitForExpression | undefined | 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:
+ """
+ The expression 'undefined' (waitForExpression) returned an exception or undefined
+ """
+ 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 |
+ | cookies | 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 'cookies' is invalid (got 'foo', resulting to unmarshal cookies: invalid character 'o' in literal false (expecting 'a'))
+ """
+ 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 |
+ | cookies | [{"name":"yummy_cookie","value":"choco"}] | 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 'cookies' is invalid (got '[{"name":"yummy_cookie","value":"choco"}]', resulting to cookie 0 must have its name, value and domain set)
+ """
+ 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 |
+ | extraHttpHeaders | 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 'extraHttpHeaders' is invalid (got 'foo', resulting to unmarshal extraHttpHeaders: invalid character 'o' in literal false (expecting 'a'))
+ """
+ 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 |
+ | extraHttpHeaders | {"foo":"bar;scope;;"} | 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 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope;;"}', resulting to invalid scope '' for header 'foo')
+ """
+ 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 |
+ | extraHttpHeaders | {"foo":"bar;scope=*."} | 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 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope=*."}', resulting to invalid scope regex pattern for header 'foo': error parsing regexp: missing argument to repetition operator in `*.`)
+ """
+ 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 |
+ | splitMode | foo | field |
+ | splitSpan | 2 | 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 'splitMode' is invalid (got 'foo', resulting to wrong value, expected either 'intervals' or 'pages')
+ """
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 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 'splitSpan' is invalid (got 'foo', resulting to strconv.Atoi: parsing "foo": invalid syntax)
+ """
+ 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 |
+ | splitMode | pages | field |
+ | splitSpan | 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:
+ """
+ At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
+ """
+ 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 |
+ | pdfa | 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:
+ """
+ At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
+ """
+ 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 |
+ | pdfua | 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 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
+ """
+ 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 |
+ | metadata | 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 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a'))
+ """
+
+ @split
+ Scenario: POST /forms/chromium/convert/html (Split Intervals)
+ 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/pages-3-html/index.html | file |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | *_0.pdf |
+ | *_1.pdf |
+ Then the "*_0.pdf" PDF should have 2 page(s)
+ Then the "*_1.pdf" PDF should have 1 page(s)
+ Then the "*_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "*_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "*_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+
+ # See https://github.com/gotenberg/gotenberg/issues/1130.
+ @split
+ @output-filename
+ Scenario: POST /forms/chromium/convert/html (Split Output Filename)
+ 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/pages-3-html/index.html | file |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.zip |
+ | foo_0.pdf |
+ | foo_1.pdf |
+ Then the "foo_0.pdf" PDF should have 2 page(s)
+ Then the "foo_1.pdf" PDF should have 1 page(s)
+ Then the "foo_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "foo_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+
+ @split
+ Scenario: POST /forms/chromium/convert/html (Split Pages)
+ 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/pages-3-html/index.html | file |
+ | splitMode | pages | field |
+ | splitSpan | 2- | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | *_0.pdf |
+ | *_1.pdf |
+ Then the "*_0.pdf" PDF should have 1 page(s)
+ Then the "*_1.pdf" PDF should have 1 page(s)
+ Then the "*_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "*_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+
+ @split
+ Scenario: POST /forms/chromium/convert/html (Split Pages & Unify)
+ 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/pages-3-html/index.html | file |
+ | splitMode | pages | field |
+ | splitSpan | 2- | field |
+ | splitUnify | 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"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 2 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "foo.pdf" PDF should have the following content at page 2:
+ """
+ Page 3
+ """
+
+ @split
+ Scenario: POST /forms/chromium/convert/html (Split Many PDFs - Lot of Pages)
+ 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/pages-12-html/index.html | file |
+ | splitMode | intervals | field |
+ | splitSpan | 1 | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 12 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | *_0.pdf |
+ | *_1.pdf |
+ | *_2.pdf |
+ | *_3.pdf |
+ | *_4.pdf |
+ | *_5.pdf |
+ | *_6.pdf |
+ | *_7.pdf |
+ | *_8.pdf |
+ | *_9.pdf |
+ | *_10.pdf |
+ | *_11.pdf |
+ Then the "*_0.pdf" PDF should have 1 page(s)
+ Then the "*_11.pdf" PDF should have 1 page(s)
+ Then the "*_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "*_11.pdf" PDF should have the following content at page 1:
+ """
+ Page 12
+ """
+
+ @convert
+ Scenario: POST /forms/chromium/convert/html (PDF/A-1b & PDF/UA-1)
+ 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 |
+ | pdfa | PDF/A-1b | field |
+ | pdfua | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
+
+ @convert
+ @split
+ Scenario: POST /forms/chromium/convert/html (Split & PDF/A-1b & PDF/UA-1)
+ 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/pages-3-html/index.html | file |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | pdfa | PDF/A-1b | field |
+ | pdfua | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | *_0.pdf |
+ | *_1.pdf |
+ Then the "*_0.pdf" PDF should have 2 page(s)
+ Then the "*_1.pdf" PDF should have 1 page(s)
+ Then the "*_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "*_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "*_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
+
+ # See https://github.com/gotenberg/gotenberg/issues/1130.
+ @convert
+ @split
+ @output-filename
+ Scenario: POST /forms/chromium/convert/html (Split & PDF/A-1b & PDF/UA-1 & Output Filename)
+ 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/pages-3-html/index.html | file |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | pdfa | PDF/A-1b | field |
+ | pdfua | true | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.zip |
+ | foo_0.pdf |
+ | foo_1.pdf |
+ Then the "foo_0.pdf" PDF should have 2 page(s)
+ Then the "foo_1.pdf" PDF should have 1 page(s)
+ Then the "foo_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "foo_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
+
+ @metadata
+ Scenario: POST /forms/chromium/convert/html (Metadata)
+ 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 |
+ | 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ 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
+ Then the response header "Content-Type" should be "application/json"
+ Then the response body should match JSON:
+ """
+ {
+ "foo.pdf": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ }
+ }
+ """
+
+ @flatten
+ Scenario: POST /forms/chromium/convert/html (Flatten)
+ 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 |
+ | flatten | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be flatten
+
+ @encrypt
+ Scenario: POST /forms/chromium/convert/html (Encrypt - user password only)
+ 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 |
+ | userPassword | foo | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ @encrypt
+ Scenario: POST /forms/chromium/convert/html (Encrypt - both user and owner passwords)
+ 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 |
+ | userPassword | foo | field |
+ | ownerPassword | bar | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ @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
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ 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
+
+ # FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
+ @convert
+ @metadata
+ @flatten
+ @embed
+ Scenario: POST /forms/chromium/convert/html (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & 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 |
+ | 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 |
+ | flatten | true | field |
+ | 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
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 9 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
+ 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/metadata/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": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ }
+ }
+ """
+
+ Scenario: POST /forms/chromium/convert/html (Routes Disabled)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | CHROMIUM_DISABLE_ROUTES | true |
+ 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 |
+ Then the response status code should be 404
+
+ Scenario: POST /forms/chromium/convert/html (Gotenberg Trace)
+ 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 |
+ | Gotenberg-Trace | forms_chromium_convert_html | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_html"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"forms_chromium_convert_html" |
+
+ @download-from
+ Scenario: POST /forms/chromium/convert/html (Download From)
+ Given I have a default Gotenberg container
+ Given I have a static server
+ When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
+ | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page-1-html/index.html","extraHttpHeaders":{"X-Foo":"bar"}}] | field |
+ Then the response status code should be 200
+ Then the file request header "X-Foo" should be "bar"
+ Then the response header "Content-Type" should be "application/pdf"
+
+ @webhook
+ Scenario: POST /forms/chromium/convert/html (Webhook)
+ Given I have a default Gotenberg container
+ Given I have a webhook server
+ 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
+ | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+ Then the response status code should be 204
+ When I wait for the asynchronous request to the webhook
+ Then the webhook request header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the webhook request
+ Then there should be the following file(s) in the webhook request:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+
+ Scenario: POST /forms/chromium/convert/html (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ 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 |
+ Then the response status code should be 401
+
+ Scenario: POST /foo/forms/chromium/convert/html (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_DEBUG_ROUTE | true |
+ | API_ROOT_PATH | /foo/ |
+ When I make a "POST" request to Gotenberg at the "/foo/forms/chromium/convert/html" endpoint with the following form data and header(s):
+ | files | testdata/page-1-html/index.html | file |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
diff --git a/test/integration/features/chromium_convert_markdown.feature b/test/integration/features/chromium_convert_markdown.feature
new file mode 100644
index 000000000..8afa1c797
--- /dev/null
+++ b/test/integration/features/chromium_convert_markdown.feature
@@ -0,0 +1,1132 @@
+# TODO:
+# 1. JavaScript disabled on some feature.
+
+@chromium
+@chromium-convert-markdown
+Feature: /forms/chromium/convert/markdown
+
+ Scenario: POST /forms/chromium/convert/markdown (Default)
+ 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+
+ Scenario: POST /forms/chromium/convert/markdown (Single Page)
+ 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/pages-12-markdown/index.html | file |
+ | files | testdata/pages-12-markdown/page_1.md | file |
+ | files | testdata/pages-12-markdown/page_2.md | file |
+ | files | testdata/pages-12-markdown/page_3.md | file |
+ | files | testdata/pages-12-markdown/page_4.md | file |
+ | files | testdata/pages-12-markdown/page_5.md | file |
+ | files | testdata/pages-12-markdown/page_6.md | file |
+ | files | testdata/pages-12-markdown/page_7.md | file |
+ | files | testdata/pages-12-markdown/page_8.md | file |
+ | files | testdata/pages-12-markdown/page_9.md | file |
+ | files | testdata/pages-12-markdown/page_10.md | file |
+ | files | testdata/pages-12-markdown/page_11.md | file |
+ | files | testdata/pages-12-markdown/page_12.md | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 12 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo.pdf" PDF should have the following content at page 12:
+ """
+ Page 12
+ """
+ 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/pages-12-markdown/index.html | file |
+ | files | testdata/pages-12-markdown/page_1.md | file |
+ | files | testdata/pages-12-markdown/page_2.md | file |
+ | files | testdata/pages-12-markdown/page_3.md | file |
+ | files | testdata/pages-12-markdown/page_4.md | file |
+ | files | testdata/pages-12-markdown/page_5.md | file |
+ | files | testdata/pages-12-markdown/page_6.md | file |
+ | files | testdata/pages-12-markdown/page_7.md | file |
+ | files | testdata/pages-12-markdown/page_8.md | file |
+ | files | testdata/pages-12-markdown/page_9.md | file |
+ | files | testdata/pages-12-markdown/page_10.md | file |
+ | files | testdata/pages-12-markdown/page_11.md | file |
+ | files | testdata/pages-12-markdown/page_12.md | file |
+ | singlePage | 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"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ # page-break-after: always; tells the browser's print engine to force a page break after each element,
+ # even when calculating a large enough paper height, Chromium's PDF rendering will still honor those page break
+ # directives.
+ """
+ Page 12
+ """
+
+ Scenario: POST /forms/chromium/convert/markdown (Landscape)
+ 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should NOT be set to landscape orientation
+ 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 |
+ | landscape | 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"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should be set to landscape orientation
+
+ Scenario: POST /forms/chromium/convert/markdown (Native Page Ranges)
+ 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/pages-12-markdown/index.html | file |
+ | files | testdata/pages-12-markdown/page_1.md | file |
+ | files | testdata/pages-12-markdown/page_2.md | file |
+ | files | testdata/pages-12-markdown/page_3.md | file |
+ | files | testdata/pages-12-markdown/page_4.md | file |
+ | files | testdata/pages-12-markdown/page_5.md | file |
+ | files | testdata/pages-12-markdown/page_6.md | file |
+ | files | testdata/pages-12-markdown/page_7.md | file |
+ | files | testdata/pages-12-markdown/page_8.md | file |
+ | files | testdata/pages-12-markdown/page_9.md | file |
+ | files | testdata/pages-12-markdown/page_10.md | file |
+ | files | testdata/pages-12-markdown/page_11.md | file |
+ | files | testdata/pages-12-markdown/page_12.md | file |
+ | nativePageRanges | 2-3 | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 2 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "foo.pdf" PDF should have the following content at page 2:
+ """
+ Page 3
+ """
+
+ Scenario: POST /forms/chromium/convert/markdown (Header & Footer)
+ 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/pages-12-markdown/index.html | file |
+ | files | testdata/pages-12-markdown/page_1.md | file |
+ | files | testdata/pages-12-markdown/page_2.md | file |
+ | files | testdata/pages-12-markdown/page_3.md | file |
+ | files | testdata/pages-12-markdown/page_4.md | file |
+ | files | testdata/pages-12-markdown/page_5.md | file |
+ | files | testdata/pages-12-markdown/page_6.md | file |
+ | files | testdata/pages-12-markdown/page_7.md | file |
+ | files | testdata/pages-12-markdown/page_8.md | file |
+ | files | testdata/pages-12-markdown/page_9.md | file |
+ | files | testdata/pages-12-markdown/page_10.md | file |
+ | files | testdata/pages-12-markdown/page_11.md | file |
+ | files | testdata/pages-12-markdown/page_12.md | file |
+ | files | testdata/header-footer-html/header.html | file |
+ | files | testdata/header-footer-html/footer.html | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 12 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Pages 12
+ """
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ 1 of 12
+ """
+ Then the "foo.pdf" PDF should have the following content at page 12:
+ """
+ Pages 12
+ """
+ Then the "foo.pdf" PDF should have the following content at page 12:
+ """
+ 12 of 12
+ """
+
+ Scenario: POST /forms/chromium/convert/markdown (Wait Delay)
+ 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/feature-rich-markdown/index.html | file |
+ | files | testdata/feature-rich-markdown/table.md | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
+ """
+ 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/feature-rich-markdown/index.html | file |
+ | files | testdata/feature-rich-markdown/table.md | file |
+ | waitDelay | 2.5s | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
+ """
+
+ Scenario: POST /forms/chromium/convert/markdown (Wait For Expression)
+ 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/feature-rich-markdown/index.html | file |
+ | files | testdata/feature-rich-markdown/table.md | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
+ """
+ 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/feature-rich-markdown/index.html | file |
+ | files | testdata/feature-rich-markdown/table.md | file |
+ | waitForExpression | window.globalVar === 'ready' | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
+ """
+
+ Scenario: POST /forms/chromium/convert/markdown (Emulated Media Type)
+ 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/feature-rich-markdown/index.html | file |
+ | files | testdata/feature-rich-markdown/table.md | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Emulated media type is 'print'.
+ """
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Emulated media type is 'screen'.
+ """
+ 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/feature-rich-markdown/index.html | file |
+ | files | testdata/feature-rich-markdown/table.md | file |
+ | emulatedMediaType | print | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Emulated media type is 'print'.
+ """
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Emulated media type is 'screen'.
+ """
+ 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/feature-rich-markdown/index.html | file |
+ | files | testdata/feature-rich-markdown/table.md | file |
+ | emulatedMediaType | screen | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Emulated media type is 'screen'.
+ """
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Emulated media type is 'print'.
+ """
+
+ Scenario: POST /forms/chromium/convert/markdown (Default Allow / Deny Lists)
+ 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/feature-rich-markdown/index.html | file |
+ | files | testdata/feature-rich-markdown/table.md | file |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the Gotenberg container should log the following entries:
+ | 'file:///etc/passwd' matches the expression from the denied list |
+
+ Scenario: POST /forms/chromium/convert/markdown (Main URL does NOT match allowed list)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* |
+ 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/feature-rich-markdown/index.html | file |
+ | files | testdata/feature-rich-markdown/table.md | file |
+ Then the response status code should be 403
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should match string:
+ """
+ Forbidden
+ """
+
+ Scenario: POST /forms/chromium/convert/markdown (Main URL does match denied list)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | CHROMIUM_ALLOW_LIST | |
+ | CHROMIUM_DENY_LIST | ^file:///tmp.* |
+ 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/feature-rich-markdown/index.html | file |
+ | files | testdata/feature-rich-markdown/table.md | file |
+ Then the response status code should be 403
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should match string:
+ """
+ Forbidden
+ """
+
+ Scenario: POST /forms/chromium/convert/markdown (Request does not match the allowed list)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | CHROMIUM_ALLOW_LIST | ^file:///tmp.* |
+ 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/feature-rich-markdown/index.html | file |
+ | files | testdata/feature-rich-markdown/table.md | file |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the Gotenberg container should log the following entries:
+ | 'file:///etc/passwd' does not match the expression from the allowed list |
+
+ Scenario: POST /forms/chromium/convert/markdown (JavaScript Enabled)
+ 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/feature-rich-markdown/index.html | file |
+ | files | testdata/feature-rich-markdown/table.md | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ JavaScript is enabled.
+ """
+
+ Scenario: POST /forms/chromium/convert/markdown (JavaScript Disabled)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | CHROMIUM_DISABLE_JAVASCRIPT | true |
+ 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/feature-rich-markdown/index.html | file |
+ | files | testdata/feature-rich-markdown/table.md | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ JavaScript is enabled.
+ """
+
+ Scenario: POST /forms/chromium/convert/markdown (Fail On Resource HTTP Status Codes)
+ 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/feature-rich-markdown/index.html | file |
+ | files | testdata/feature-rich-markdown/table.md | file |
+ | failOnResourceHttpStatusCodes | [499,599] | field |
+ Then the response status code should be 409
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should match string:
+ """
+ Invalid HTTP status code from resources:
+ https://gethttpstatus.com/400 - 400: Bad Request
+ """
+
+ Scenario: POST /forms/chromium/convert/markdown (Fail On Resource Loading Failed)
+ 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/feature-rich-markdown/index.html | file |
+ | files | testdata/feature-rich-markdown/table.md | file |
+ | failOnResourceLoadingFailed | true | field |
+ Then the response status code should be 409
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should contain string:
+ """
+ Chromium failed to load resources
+ """
+ Then the response body should contain string:
+ """
+ resource Stylesheet: net::ERR_CONNECTION_REFUSED
+ """
+ Then the response body should contain string:
+ """
+ resource Stylesheet: net::ERR_FILE_NOT_FOUND
+ """
+
+ Scenario: POST /forms/chromium/convert/markdown (Fail On Console Exceptions)
+ 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/feature-rich-markdown/index.html | file |
+ | files | testdata/feature-rich-markdown/table.md | file |
+ | failOnConsoleExceptions | true | field |
+ Then the response status code should be 409
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should contain string:
+ """
+ Chromium console exceptions
+ """
+ Then the response body should contain string:
+ """
+ Error: Exception 1
+ """
+ Then the response body should contain string:
+ """
+ Error: Exception 2
+ """
+
+ Scenario: POST /forms/chromium/convert/markdown (Bad Request)
+ 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/pages-3-markdown/index.html | file |
+ | files | testdata/pages-3-markdown/page_1.md | file |
+ 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:
+ """
+ Markdown file(s) not found: 'page_2.md'; 'page_3.md'
+ """
+ When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
+ | singlePage | foo | field |
+ | paperWidth | foo | field |
+ | paperHeight | foo | field |
+ | marginTop | foo | field |
+ | marginBottom | foo | field |
+ | marginLeft | foo | field |
+ | marginRight | foo | field |
+ | preferCssPageSize | foo | field |
+ | generateDocumentOutline | foo | field |
+ | generateTaggedPdf | foo | field |
+ | printBackground | foo | field |
+ | omitBackground | foo | field |
+ | landscape | foo | field |
+ | scale | foo | field |
+ | waitDelay | foo | field |
+ | emulatedMediaType | foo | field |
+ | failOnHttpStatusCodes | foo | field |
+ | failOnResourceHttpStatusCodes | foo | field |
+ | failOnResourceLoadingFailed | foo | field |
+ | failOnConsoleExceptions | foo | field |
+ | skipNetworkIdleEvent | 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 'skipNetworkIdleEvent' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnResourceHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceLoadingFailed' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnConsoleExceptions' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'waitDelay' is invalid (got 'foo', resulting to time: invalid duration "foo"); form field 'emulatedMediaType' is invalid (got 'foo', resulting to wrong value, expected either 'screen', 'print' or empty); form field 'omitBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'landscape' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'printBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'scale' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'singlePage' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'paperWidth' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'paperHeight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginTop' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginBottom' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginLeft' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginRight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'preferCssPageSize' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'generateDocumentOutline' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'generateTaggedPdf' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form file 'index.html' is required; no form file found for extensions: [.md]
+ """
+ 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 |
+ | omitBackground | true | 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:
+ """
+ omitBackground requires printBackground set to true
+ """
+ # Does not seems to happen on amd architectures anymore since Chromium 137.
+ # See: https://github.com/gotenberg/gotenberg/actions/runs/15384321883/job/43280184372.
+ # 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 |
+ # | paperWidth | 0 | field |
+ # | paperHeight | 0 | field |
+ # | marginTop | 1000000 | field |
+ # | marginBottom | 1000000 | field |
+ # | marginLeft | 1000000 | field |
+ # | marginRight | 1000000 | 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:
+ # """
+ # Chromium does not handle the provided settings; please check for aberrant form values
+ # """
+ 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 |
+ | nativePageRanges | 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:
+ """
+ Chromium does not handle the page ranges 'foo' (nativePageRanges) syntax
+ """
+ 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 |
+ | nativePageRanges | 2-3 | 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:
+ """
+ The page ranges '2-3' (nativePageRanges) exceeds the page count
+ """
+ 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 |
+ | waitForExpression | undefined | 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:
+ """
+ The expression 'undefined' (waitForExpression) returned an exception or undefined
+ """
+ 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 |
+ | cookies | 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 'cookies' is invalid (got 'foo', resulting to unmarshal cookies: invalid character 'o' in literal false (expecting 'a'))
+ """
+ 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 |
+ | cookies | [{"name":"yummy_cookie","value":"choco"}] | 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 'cookies' is invalid (got '[{"name":"yummy_cookie","value":"choco"}]', resulting to cookie 0 must have its name, value and domain set)
+ """
+ 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 |
+ | extraHttpHeaders | 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 'extraHttpHeaders' is invalid (got 'foo', resulting to unmarshal extraHttpHeaders: invalid character 'o' in literal false (expecting 'a'))
+ """
+ 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 |
+ | extraHttpHeaders | {"foo":"bar;scope;;"} | 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 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope;;"}', resulting to invalid scope '' for header 'foo')
+ """
+ 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 |
+ | extraHttpHeaders | {"foo":"bar;scope=*."} | 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 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope=*."}', resulting to invalid scope regex pattern for header 'foo': error parsing regexp: missing argument to repetition operator in `*.`)
+ """
+ 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 |
+ | splitMode | foo | field |
+ | splitSpan | 2 | 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 'splitMode' is invalid (got 'foo', resulting to wrong value, expected either 'intervals' or 'pages')
+ """
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 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 'splitSpan' is invalid (got 'foo', resulting to strconv.Atoi: parsing "foo": invalid syntax)
+ """
+ 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 |
+ | splitMode | pages | field |
+ | splitSpan | 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:
+ """
+ At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
+ """
+ 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 |
+ | pdfa | 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:
+ """
+ At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
+ """
+ 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 |
+ | pdfua | 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 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
+ """
+ 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 |
+ | metadata | 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 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a'))
+ """
+
+ @split
+ Scenario: POST /forms/chromium/convert/markdown (Split Intervals)
+ 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/pages-3-markdown/index.html | file |
+ | files | testdata/pages-3-markdown/page_1.md | file |
+ | files | testdata/pages-3-markdown/page_2.md | file |
+ | files | testdata/pages-3-markdown/page_3.md | file |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | *_0.pdf |
+ | *_1.pdf |
+ Then the "*_0.pdf" PDF should have 2 page(s)
+ Then the "*_1.pdf" PDF should have 1 page(s)
+ Then the "*_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "*_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "*_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+
+ # See https://github.com/gotenberg/gotenberg/issues/1130.
+ @split
+ @output-filename
+ Scenario: POST /forms/chromium/convert/markdown (Split Output Filename)
+ 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/pages-3-markdown/index.html | file |
+ | files | testdata/pages-3-markdown/page_1.md | file |
+ | files | testdata/pages-3-markdown/page_2.md | file |
+ | files | testdata/pages-3-markdown/page_3.md | file |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.zip |
+ | foo_0.pdf |
+ | foo_1.pdf |
+ Then the "foo_0.pdf" PDF should have 2 page(s)
+ Then the "foo_1.pdf" PDF should have 1 page(s)
+ Then the "foo_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "foo_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+
+ @split
+ Scenario: POST /forms/chromium/convert/markdown (Split Pages)
+ 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/pages-3-markdown/index.html | file |
+ | files | testdata/pages-3-markdown/page_1.md | file |
+ | files | testdata/pages-3-markdown/page_2.md | file |
+ | files | testdata/pages-3-markdown/page_3.md | file |
+ | splitMode | pages | field |
+ | splitSpan | 2- | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | *_0.pdf |
+ | *_1.pdf |
+ Then the "*_0.pdf" PDF should have 1 page(s)
+ Then the "*_1.pdf" PDF should have 1 page(s)
+ Then the "*_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "*_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+
+ @split
+ Scenario: POST /forms/chromium/convert/markdown (Split Pages & Unify)
+ 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/pages-3-markdown/index.html | file |
+ | files | testdata/pages-3-markdown/page_1.md | file |
+ | files | testdata/pages-3-markdown/page_2.md | file |
+ | files | testdata/pages-3-markdown/page_3.md | file |
+ | splitMode | pages | field |
+ | splitSpan | 2- | field |
+ | splitUnify | 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"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 2 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "foo.pdf" PDF should have the following content at page 2:
+ """
+ Page 3
+ """
+
+ @split
+ Scenario: POST /forms/chromium/convert/markdown (Split Many PDFs - Lot of Pages)
+ 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/pages-12-markdown/index.html | file |
+ | files | testdata/pages-12-markdown/page_1.md | file |
+ | files | testdata/pages-12-markdown/page_2.md | file |
+ | files | testdata/pages-12-markdown/page_3.md | file |
+ | files | testdata/pages-12-markdown/page_4.md | file |
+ | files | testdata/pages-12-markdown/page_5.md | file |
+ | files | testdata/pages-12-markdown/page_6.md | file |
+ | files | testdata/pages-12-markdown/page_7.md | file |
+ | files | testdata/pages-12-markdown/page_8.md | file |
+ | files | testdata/pages-12-markdown/page_9.md | file |
+ | files | testdata/pages-12-markdown/page_10.md | file |
+ | files | testdata/pages-12-markdown/page_11.md | file |
+ | files | testdata/pages-12-markdown/page_12.md | file |
+ | splitMode | intervals | field |
+ | splitSpan | 1 | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 12 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | *_0.pdf |
+ | *_1.pdf |
+ | *_2.pdf |
+ | *_3.pdf |
+ | *_4.pdf |
+ | *_5.pdf |
+ | *_6.pdf |
+ | *_7.pdf |
+ | *_8.pdf |
+ | *_9.pdf |
+ | *_10.pdf |
+ | *_11.pdf |
+ Then the "*_0.pdf" PDF should have 1 page(s)
+ Then the "*_11.pdf" PDF should have 1 page(s)
+ Then the "*_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "*_11.pdf" PDF should have the following content at page 1:
+ """
+ Page 12
+ """
+
+ @convert
+ Scenario: POST /forms/chromium/convert/markdown (PDF/A-1b & PDF/UA-1)
+ 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 |
+ | pdfa | PDF/A-1b | field |
+ | pdfua | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
+
+ @convert
+ @split
+ Scenario: POST /forms/chromium/convert/markdown (Split & PDF/A-1b & PDF/UA-1)
+ 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/pages-3-markdown/index.html | file |
+ | files | testdata/pages-3-markdown/page_1.md | file |
+ | files | testdata/pages-3-markdown/page_2.md | file |
+ | files | testdata/pages-3-markdown/page_3.md | file |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | pdfa | PDF/A-1b | field |
+ | pdfua | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | *_0.pdf |
+ | *_1.pdf |
+ Then the "*_0.pdf" PDF should have 2 page(s)
+ Then the "*_1.pdf" PDF should have 1 page(s)
+ Then the "*_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "*_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "*_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
+
+ # See https://github.com/gotenberg/gotenberg/issues/1130.
+ @convert
+ @split
+ @output-filename
+ Scenario: POST /forms/chromium/convert/markdown (Split & PDF/A-1b & PDF/UA-1 & Output Filename)
+ 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/pages-3-markdown/index.html | file |
+ | files | testdata/pages-3-markdown/page_1.md | file |
+ | files | testdata/pages-3-markdown/page_2.md | file |
+ | files | testdata/pages-3-markdown/page_3.md | file |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | pdfa | PDF/A-1b | field |
+ | pdfua | true | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.zip |
+ | foo_0.pdf |
+ | foo_1.pdf |
+ Then the "foo_0.pdf" PDF should have 2 page(s)
+ Then the "foo_1.pdf" PDF should have 1 page(s)
+ Then the "foo_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "foo_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
+
+ @metadata
+ Scenario: POST /forms/chromium/convert/markdown (Metadata)
+ 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 |
+ | 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ 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
+ Then the response header "Content-Type" should be "application/json"
+ Then the response body should match JSON:
+ """
+ {
+ "foo.pdf": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ }
+ }
+ """
+
+ @flatten
+ Scenario: POST /forms/chromium/convert/markdown (Flatten)
+ 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 |
+ | flatten | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be flatten
+
+ @encrypt
+ Scenario: POST /forms/chromium/convert/markdown (Encrypt - user password only)
+ 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 |
+ | userPassword | foo | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ @encrypt
+ Scenario: POST /forms/chromium/convert/markdown (Encrypt - both user and owner passwords)
+ 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 |
+ | userPassword | foo | field |
+ | ownerPassword | bar | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ @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
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ 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
+
+ # FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
+ @convert
+ @metadata
+ @flatten
+ @embed
+ Scenario: POST /forms/chromium/convert/markdown (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & 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 |
+ | 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 |
+ | flatten | true | field |
+ | 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
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 9 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
+ 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/metadata/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": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ }
+ }
+ """
+
+ Scenario: POST /forms/chromium/convert/markdown (Routes Disabled)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | CHROMIUM_DISABLE_ROUTES | true |
+ 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 |
+ Then the response status code should be 404
+
+ Scenario: POST /forms/chromium/convert/markdown (Gotenberg Trace)
+ 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 |
+ | Gotenberg-Trace | forms_chromium_convert_html | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_html"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"forms_chromium_convert_html" |
+
+ @download-from
+ Scenario: POST /forms/chromium/convert/markdown (Download From)
+ Given I have a default Gotenberg container
+ Given I have a static server
+ When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
+ | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page-1-markdown/index.html","extraHttpHeaders":{"X-Foo":"bar"}}] | field |
+ | files | testdata/page-1-markdown/page_1.md | file |
+ Then the response status code should be 200
+ Then the file request header "X-Foo" should be "bar"
+ Then the response header "Content-Type" should be "application/pdf"
+
+ @webhook
+ Scenario: POST /forms/chromium/convert/markdown (Webhook)
+ Given I have a default Gotenberg container
+ Given I have a webhook server
+ 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
+ | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+ Then the response status code should be 204
+ When I wait for the asynchronous request to the webhook
+ Then the webhook request header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the webhook request
+ Then there should be the following file(s) in the webhook request:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+
+ Scenario: POST /forms/chromium/convert/markdown (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ 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 |
+ Then the response status code should be 401
+
+ Scenario: POST /foo/forms/chromium/convert/markdown (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_DEBUG_ROUTE | true |
+ | API_ROOT_PATH | /foo/ |
+ When I make a "POST" request to Gotenberg at the "/foo/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 |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
diff --git a/test/integration/features/chromium_convert_url.feature b/test/integration/features/chromium_convert_url.feature
new file mode 100644
index 000000000..d352ccc23
--- /dev/null
+++ b/test/integration/features/chromium_convert_url.feature
@@ -0,0 +1,1093 @@
+# TODO:
+# 1. JavaScript disabled on some feature.
+
+@chromium
+@chromium-convert-url
+Feature: /forms/chromium/convert/url
+
+ Scenario: POST /forms/chromium/convert/url (Default)
+ Given I have a default Gotenberg container
+ Given 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):
+ | 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
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+
+ Scenario: POST /forms/chromium/convert/url (Single Page)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/pages-12-html/index.html | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 12 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo.pdf" PDF should have the following content at page 12:
+ """
+ Page 12
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/pages-12-html/index.html | field |
+ | singlePage | 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"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ # page-break-after: always; tells the browser's print engine to force a page break after each element,
+ # even when calculating a large enough paper height, Chromium's PDF rendering will still honor those page break
+ # directives.
+ """
+ Page 12
+ """
+
+ Scenario: POST /forms/chromium/convert/url (Landscape)
+ Given I have a default Gotenberg container
+ Given 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):
+ | 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
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should NOT be set to landscape orientation
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | landscape | 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"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should be set to landscape orientation
+
+ Scenario: POST /forms/chromium/convert/url (Native Page Ranges)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/pages-12-html/index.html | field |
+ | nativePageRanges | 2-3 | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 2 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "foo.pdf" PDF should have the following content at page 2:
+ """
+ Page 3
+ """
+
+ Scenario: POST /forms/chromium/convert/url (Header & Footer)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/pages-12-html/index.html | field |
+ | files | testdata/header-footer-html/header.html | file |
+ | files | testdata/header-footer-html/footer.html | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 12 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Pages 12
+ """
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ 1 of 12
+ """
+ Then the "foo.pdf" PDF should have the following content at page 12:
+ """
+ Pages 12
+ """
+ Then the "foo.pdf" PDF should have the following content at page 12:
+ """
+ 12 of 12
+ """
+
+ Scenario: POST /forms/chromium/convert/url (Custom HTTP Headers)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | userAgent | Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) | field |
+ | extraHttpHeaders | {"X-Header":"foo","X-Scoped-Header-1":"bar;scope=https?:\\/\\/([a-zA-Z0-9-]+\\\\.)*domain\\\\.com\\/.*","X-Scoped-Header-2":"baz;scope=https?:\\/\\/([a-zA-Z0-9-]+\\\\.)*docker\\\\.internal:(\\\\d+)\\/.*"} | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the server request header "User-Agent" should be "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)"
+ Then the server request header "X-Header" should be "foo"
+ Then the server request header "X-Scoped-Header-1" should be ""
+ Then the server request header "X-Scoped-Header-2" should be "baz"
+
+ Scenario: POST /forms/chromium/convert/url (Cookies)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | cookies | [{"name":"cookie_1","value":"foo","domain":"host.docker.internal:%d"},{"name":"cookie_2","value":"bar","domain":"domain.com"}] | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the server request cookie "cookie_1" should be "foo"
+ Then the server request cookie "cookie_2" should be ""
+
+ # See https://github.com/gotenberg/gotenberg/issues/1130.
+ Scenario: POST /forms/chromium/convert/url (case-insensitive sameSite)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | cookies | [{"name":"cookie_1","value":"foo","domain":"host.docker.internal:%d"},{"name":"cookie_2","value":"bar","domain":"domain.com","sameSite":"lax"}] | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the server request cookie "cookie_1" should be "foo"
+ Then the server request cookie "cookie_2" should be ""
+
+ Scenario: POST /forms/chromium/convert/url (Wait Delay)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ | waitDelay | 2.5s | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
+ """
+
+ Scenario: POST /forms/chromium/convert/url (Wait For Expression)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ | waitForExpression | window.globalVar === 'ready' | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
+ """
+
+ Scenario: POST /forms/chromium/convert/url (Emulated Media Type)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Emulated media type is 'print'.
+ """
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Emulated media type is 'screen'.
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ | emulatedMediaType | print | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Emulated media type is 'print'.
+ """
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Emulated media type is 'screen'.
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ | emulatedMediaType | screen | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Emulated media type is 'screen'.
+ """
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Emulated media type is 'print'.
+ """
+
+ Scenario: POST /forms/chromium/convert/url (Default Allow / Deny Lists)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the Gotenberg container should NOT log the following entries:
+ # Modern browsers block file URIs from being loaded into iframes when the parent page is served over HTTP/HTTPS.
+ | 'file:///etc/passwd' matches the expression from the denied list |
+
+ Scenario: POST /forms/chromium/convert/url (Main URL does NOT match allowed list)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* |
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ Then the response status code should be 403
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should match string:
+ """
+ Forbidden
+ """
+
+ Scenario: POST /forms/chromium/convert/url (Main URL does match denied list)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | CHROMIUM_ALLOW_LIST | |
+ | CHROMIUM_DENY_LIST | ^http.* |
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ Then the response status code should be 403
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should match string:
+ """
+ Forbidden
+ """
+
+ Scenario: POST /forms/chromium/convert/url (Request does not match the allowed list)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | CHROMIUM_ALLOW_LIST | ^.* |
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the Gotenberg container should NOT log the following entries:
+ # Modern browsers block file URIs from being loaded into iframes when the parent page is served over HTTP/HTTPS.
+ | 'file:///etc/passwd' does not match the expression from the allowed list |
+
+ Scenario: POST /forms/chromium/convert/url (JavaScript Enabled)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ JavaScript is enabled.
+ """
+
+ Scenario: POST /forms/chromium/convert/url (JavaScript Disabled)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | CHROMIUM_DISABLE_JAVASCRIPT | true |
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ JavaScript is enabled.
+ """
+
+ Scenario: POST /forms/chromium/convert/url (Fail On Resource HTTP Status Codes)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ | failOnResourceHttpStatusCodes | [499,599] | field |
+ Then the response status code should be 409
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should contain string:
+ """
+ Invalid HTTP status code from resources:
+ """
+ Then the response body should contain string:
+ """
+ /favicon.ico - 404: Not Found
+ """
+
+ Scenario: POST /forms/chromium/convert/url (Fail On Resource Loading Failed)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ | failOnResourceLoadingFailed | true | field |
+ Then the response status code should be 409
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should contain string:
+ """
+ Chromium failed to load resources
+ """
+ Then the response body should contain string:
+ """
+ resource Stylesheet: net::ERR_CONNECTION_REFUSED
+ """
+
+ Scenario: POST /forms/chromium/convert/url (Fail On Console Exceptions)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ | failOnConsoleExceptions | true | field |
+ Then the response status code should be 409
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should contain string:
+ """
+ Chromium console exceptions
+ """
+ Then the response body should contain string:
+ """
+ Error: Exception 1
+ """
+ Then the response body should contain string:
+ """
+ Error: Exception 2
+ """
+
+ Scenario: POST /forms/chromium/convert/url (Bad Request)
+ Given I have a default Gotenberg container
+ Given 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):
+ | singlePage | foo | field |
+ | paperWidth | foo | field |
+ | paperHeight | foo | field |
+ | marginTop | foo | field |
+ | marginBottom | foo | field |
+ | marginLeft | foo | field |
+ | marginRight | foo | field |
+ | preferCssPageSize | foo | field |
+ | generateDocumentOutline | foo | field |
+ | generateTaggedPdf | foo | field |
+ | printBackground | foo | field |
+ | omitBackground | foo | field |
+ | landscape | foo | field |
+ | scale | foo | field |
+ | waitDelay | foo | field |
+ | emulatedMediaType | foo | field |
+ | failOnHttpStatusCodes | foo | field |
+ | failOnResourceHttpStatusCodes | foo | field |
+ | failOnResourceLoadingFailed | foo | field |
+ | failOnConsoleExceptions | foo | field |
+ | skipNetworkIdleEvent | 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 'skipNetworkIdleEvent' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnResourceHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceLoadingFailed' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnConsoleExceptions' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'waitDelay' is invalid (got 'foo', resulting to time: invalid duration "foo"); form field 'emulatedMediaType' is invalid (got 'foo', resulting to wrong value, expected either 'screen', 'print' or empty); form field 'omitBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'landscape' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'printBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'scale' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'singlePage' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'paperWidth' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'paperHeight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginTop' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginBottom' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginLeft' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginRight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'preferCssPageSize' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'generateDocumentOutline' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'generateTaggedPdf' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'url' is required
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | omitBackground | true | 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:
+ """
+ omitBackground requires printBackground set to true
+ """
+ # Does not seems to happen on amd architectures anymore since Chromium 137.
+ # See: https://github.com/gotenberg/gotenberg/actions/runs/15384321883/job/43280184372.
+ # Given 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):
+ # | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ # | paperWidth | 0 | field |
+ # | paperHeight | 0 | field |
+ # | marginTop | 1000000 | field |
+ # | marginBottom | 1000000 | field |
+ # | marginLeft | 1000000 | field |
+ # | marginRight | 1000000 | 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:
+ # """
+ # Chromium does not handle the provided settings; please check for aberrant form values
+ # """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | nativePageRanges | 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:
+ """
+ Chromium does not handle the page ranges 'foo' (nativePageRanges) syntax
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | nativePageRanges | 2-3 | 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:
+ """
+ The page ranges '2-3' (nativePageRanges) exceeds the page count
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | waitForExpression | undefined | 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:
+ """
+ The expression 'undefined' (waitForExpression) returned an exception or undefined
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | cookies | 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 'cookies' is invalid (got 'foo', resulting to unmarshal cookies: invalid character 'o' in literal false (expecting 'a'))
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | cookies | [{"name":"yummy_cookie","value":"choco"}] | 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 'cookies' is invalid (got '[{"name":"yummy_cookie","value":"choco"}]', resulting to cookie 0 must have its name, value and domain set)
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | extraHttpHeaders | 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 'extraHttpHeaders' is invalid (got 'foo', resulting to unmarshal extraHttpHeaders: invalid character 'o' in literal false (expecting 'a'))
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | extraHttpHeaders | {"foo":"bar;scope;;"} | 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 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope;;"}', resulting to invalid scope '' for header 'foo')
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | extraHttpHeaders | {"foo":"bar;scope=*."} | 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 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope=*."}', resulting to invalid scope regex pattern for header 'foo': error parsing regexp: missing argument to repetition operator in `*.`)
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | splitMode | foo | field |
+ | splitSpan | 2 | 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 'splitMode' is invalid (got 'foo', resulting to wrong value, expected either 'intervals' or 'pages')
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | splitMode | intervals | field |
+ | splitSpan | 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 'splitSpan' is invalid (got 'foo', resulting to strconv.Atoi: parsing "foo": invalid syntax)
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | splitMode | pages | field |
+ | splitSpan | 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:
+ """
+ At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | pdfa | 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:
+ """
+ At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | pdfua | 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 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
+ """
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | metadata | 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 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a'))
+ """
+
+ @split
+ Scenario: POST /forms/chromium/convert/url (Split Intervals)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/pages-3-html/index.html | field |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | *_0.pdf |
+ | *_1.pdf |
+ Then the "*_0.pdf" PDF should have 2 page(s)
+ Then the "*_1.pdf" PDF should have 1 page(s)
+ Then the "*_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "*_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "*_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+
+ # See https://github.com/gotenberg/gotenberg/issues/1130.
+ @split
+ @output-filename
+ Scenario: POST /forms/chromium/convert/url (Split Output Filename)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/pages-3-html/index.html | field |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.zip |
+ | foo_0.pdf |
+ | foo_1.pdf |
+ Then the "foo_0.pdf" PDF should have 2 page(s)
+ Then the "foo_1.pdf" PDF should have 1 page(s)
+ Then the "foo_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "foo_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+
+ @split
+ Scenario: POST /forms/chromium/convert/url (Split Pages)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/pages-3-html/index.html | field |
+ | splitMode | pages | field |
+ | splitSpan | 2- | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | *_0.pdf |
+ | *_1.pdf |
+ Then the "*_0.pdf" PDF should have 1 page(s)
+ Then the "*_1.pdf" PDF should have 1 page(s)
+ Then the "*_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "*_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+
+ @split
+ Scenario: POST /forms/chromium/convert/url (Split Pages & Unify)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/pages-3-html/index.html | field |
+ | splitMode | pages | field |
+ | splitSpan | 2- | field |
+ | splitUnify | 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"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 2 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "foo.pdf" PDF should have the following content at page 2:
+ """
+ Page 3
+ """
+
+ @split
+ Scenario: POST /forms/chromium/convert/url (Split Many PDFs - Lot of Pages)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/pages-12-html/index.html | field |
+ | splitMode | intervals | field |
+ | splitSpan | 1 | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 12 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | *_0.pdf |
+ | *_1.pdf |
+ | *_2.pdf |
+ | *_3.pdf |
+ | *_4.pdf |
+ | *_5.pdf |
+ | *_6.pdf |
+ | *_7.pdf |
+ | *_8.pdf |
+ | *_9.pdf |
+ | *_10.pdf |
+ | *_11.pdf |
+ Then the "*_0.pdf" PDF should have 1 page(s)
+ Then the "*_11.pdf" PDF should have 1 page(s)
+ Then the "*_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "*_11.pdf" PDF should have the following content at page 1:
+ """
+ Page 12
+ """
+
+ @convert
+ Scenario: POST /forms/chromium/convert/url (PDF/A-1b & PDF/UA-1)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | pdfa | PDF/A-1b | field |
+ | pdfua | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
+
+ Scenario: POST /forms/chromium/convert/url (Split & PDF/A-1b & PDF/UA-1)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/pages-3-html/index.html | field |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | pdfa | PDF/A-1b | field |
+ | pdfua | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | *_0.pdf |
+ | *_1.pdf |
+ Then the "*_0.pdf" PDF should have 2 page(s)
+ Then the "*_1.pdf" PDF should have 1 page(s)
+ Then the "*_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "*_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "*_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
+
+ # See https://github.com/gotenberg/gotenberg/issues/1130.
+ @convert
+ @split
+ @output-filename
+ Scenario: POST /forms/chromium/convert/url (Split & PDF/A-1b & PDF/UA-1 & Output Filename)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/pages-3-html/index.html | field |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | pdfa | PDF/A-1b | field |
+ | pdfua | true | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.zip |
+ | foo_0.pdf |
+ | foo_1.pdf |
+ Then the "foo_0.pdf" PDF should have 2 page(s)
+ Then the "foo_1.pdf" PDF should have 1 page(s)
+ Then the "foo_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "foo_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
+
+ @metadata
+ Scenario: POST /forms/chromium/convert/url (Metadata)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ 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
+ Then the response header "Content-Type" should be "application/json"
+ Then the response body should match JSON:
+ """
+ {
+ "foo.pdf": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ }
+ }
+ """
+
+ @flatten
+ Scenario: POST /forms/chromium/convert/url (Flatten)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | flatten | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be flatten
+
+ @encrypt
+ Scenario: POST /forms/chromium/convert/url (Encrypt - user password only)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | userPassword | foo | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ @encrypt
+ Scenario: POST /forms/chromium/convert/url (Encrypt - both user and owner passwords)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | userPassword | foo | field |
+ | ownerPassword | bar | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ @embed
+ Scenario: POST /foo/forms/chromium/convert/url (Embeds)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | 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
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the webhook request:
+ | foo.pdf |
+ 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
+
+ # FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
+ @convert
+ @metadata
+ @flatten
+ @embed
+ Scenario: POST /forms/chromium/convert/url (PDF/A-1b & PDF/UA-1 & Metadata & Flatten)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | 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 |
+ | flatten | true | field |
+ | 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
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 9 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
+ 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/metadata/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": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ }
+ }
+ """
+
+ Scenario: POST /forms/chromium/convert/url (Routes Disabled)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | CHROMIUM_DISABLE_ROUTES | true |
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ Then the response status code should be 404
+
+ Scenario: POST /forms/chromium/convert/url (Gotenberg Trace)
+ Given I have a default Gotenberg container
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | Gotenberg-Trace | forms_chromium_convert_url | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_url"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"forms_chromium_convert_url" |
+
+ @webhook
+ Scenario: POST /forms/chromium/convert/url (Webhook)
+ Given I have a default Gotenberg container
+ Given I have a webhook server
+ When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | Gotenberg-Output-Filename | foo | header |
+ | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
+ | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+ Then the response status code should be 204
+ When I wait for the asynchronous request to the webhook
+ Then the webhook request header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the webhook request
+ Then there should be the following file(s) in the webhook request:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+
+ Scenario: POST /forms/chromium/convert/url (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ Given 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):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ Then the response status code should be 401
+
+ Scenario: POST /foo/forms/chromium/convert/url (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_DEBUG_ROUTE | true |
+ | API_ROOT_PATH | /foo/ |
+ Given I have a static server
+ When I make a "POST" request to Gotenberg at the "/foo/forms/chromium/convert/url" endpoint with the following form data and header(s):
+ | 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 header "Content-Type" should be "application/pdf"
diff --git a/test/integration/features/debug.feature b/test/integration/features/debug.feature
new file mode 100644
index 000000000..b3f966044
--- /dev/null
+++ b/test/integration/features/debug.feature
@@ -0,0 +1,167 @@
+@debug
+Feature: /debug
+
+ Scenario: GET /debug (Disabled)
+ Given I have a default Gotenberg container
+ When I make a "GET" request to Gotenberg at the "/debug" endpoint
+ Then the response status code should be 404
+
+ Scenario: GET /debug (Enabled)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_DEBUG_ROUTE | true |
+ When I make a "GET" request to Gotenberg at the "/debug" endpoint
+ 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:
+ """
+ {
+ "version": "{version}",
+ "architecture": "ignore",
+ "modules": [
+ "api",
+ "chromium",
+ "exiftool",
+ "libreoffice",
+ "libreoffice-api",
+ "libreoffice-pdfengine",
+ "logging",
+ "pdfcpu",
+ "pdfengines",
+ "pdftk",
+ "prometheus",
+ "qpdf",
+ "webhook"
+ ],
+ "modules_additional_data": {
+ "chromium": {
+ "version": "ignore"
+ },
+ "exiftool": {
+ "version": "ignore"
+ },
+ "libreoffice-api": {
+ "version": "ignore"
+ },
+ "pdfcpu": {
+ "version": "ignore"
+ },
+ "pdftk": {
+ "version": "ignore"
+ },
+ "qpdf": {
+ "version": "ignore"
+ }
+ },
+ "flags": {
+ "api-bind-ip": "",
+ "api-body-limit": "",
+ "api-disable-download-from": "false",
+ "api-disable-health-check-logging": "false",
+ "api-download-from-allow-list": "",
+ "api-download-from-deny-list": "",
+ "api-download-from-max-retry": "4",
+ "api-enable-basic-auth": "false",
+ "api-enable-debug-route": "true",
+ "api-port": "3000",
+ "api-port-from-env": "",
+ "api-root-path": "/",
+ "api-start-timeout": "30s",
+ "api-timeout": "30s",
+ "api-tls-cert-file": "",
+ "api-tls-key-file": "",
+ "api-trace-header": "Gotenberg-Trace",
+ "chromium-allow-file-access-from-files": "false",
+ "chromium-allow-insecure-localhost": "false",
+ "chromium-allow-list": "",
+ "chromium-auto-start": "false",
+ "chromium-clear-cache": "false",
+ "chromium-clear-cookies": "false",
+ "chromium-deny-list": "^file:(?!//\\/tmp/).*",
+ "chromium-disable-javascript": "false",
+ "chromium-disable-routes": "false",
+ "chromium-disable-web-security": "false",
+ "chromium-host-resolver-rules": "",
+ "chromium-ignore-certificate-errors": "false",
+ "chromium-incognito": "false",
+ "chromium-max-queue-size": "0",
+ "chromium-proxy-server": "",
+ "chromium-restart-after": "10",
+ "chromium-start-timeout": "20s",
+ "gotenberg-build-debug-data": "true",
+ "gotenberg-graceful-shutdown-duration": "30s",
+ "libreoffice-auto-start": "false",
+ "libreoffice-disable-routes": "false",
+ "libreoffice-max-queue-size": "0",
+ "libreoffice-restart-after": "10",
+ "libreoffice-start-timeout": "20s",
+ "log-fields-prefix": "",
+ "log-format": "auto",
+ "log-level": "info",
+ "pdfengines-convert-engines": "[libreoffice-pdfengine]",
+ "pdfengines-disable-routes": "false",
+ "pdfengines-engines": "[]",
+ "pdfengines-flatten-engines": "[qpdf]",
+ "pdfengines-merge-engines": "[qpdf,pdfcpu,pdftk]",
+ "pdfengines-read-metadata-engines": "[exiftool]",
+ "pdfengines-split-engines": "[pdfcpu,qpdf,pdftk]",
+ "pdfengines-write-metadata-engines": "[exiftool]",
+ "prometheus-collect-interval": "1s",
+ "prometheus-disable-collect": "false",
+ "prometheus-disable-route-logging": "false",
+ "prometheus-namespace": "gotenberg",
+ "webhook-allow-list": "",
+ "webhook-client-timeout": "30s",
+ "webhook-deny-list": "",
+ "webhook-disable": "false",
+ "webhook-error-allow-list": "",
+ "webhook-error-deny-list": "",
+ "webhook-max-retry": "4",
+ "webhook-retry-max-wait": "30s",
+ "webhook-retry-min-wait": "1s"
+ }
+ }
+ """
+
+ Scenario: GET /debug (No Debug Data)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | GOTENBERG_BUILD_DEBUG_DATA | false |
+ | API_ENABLE_DEBUG_ROUTE | true |
+ When I make a "GET" request to Gotenberg at the "/debug" endpoint
+ 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:
+ """
+ {
+ "version": "",
+ "architecture": "",
+ "modules": null,
+ "modules_additional_data": null,
+ "flags": null
+ }
+ """
+
+ Scenario: GET /debug (Gotenberg Trace)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_DEBUG_ROUTE | true |
+ When I make a "GET" request to Gotenberg at the "/debug" endpoint with the following header(s):
+ | Gotenberg-Trace | debug |
+ Then the response status code should be 200
+ Then the response header "Gotenberg-Trace" should be "debug"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"debug" |
+
+ Scenario: GET /debug (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_DEBUG_ROUTE | true |
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ When I make a "GET" request to Gotenberg at the "/debug" endpoint
+ Then the response status code should be 401
+
+ Scenario: GET /foo/debug (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_DEBUG_ROUTE | true |
+ | API_ROOT_PATH | /foo/ |
+ When I make a "GET" request to Gotenberg at the "/foo/debug" endpoint
+ Then the response status code should be 200
diff --git a/test/integration/features/health.feature b/test/integration/features/health.feature
new file mode 100644
index 000000000..81ae4b759
--- /dev/null
+++ b/test/integration/features/health.feature
@@ -0,0 +1,108 @@
+# TODO:
+# 1. Check if down for each module.
+# 2. Restarting modules do not make health check fail.
+
+@health
+Feature: /health
+
+ Scenario: GET /health
+ Given I have a default Gotenberg container
+ When I make a "GET" request to Gotenberg at the "/health" endpoint
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/json; charset=utf-8"
+ Then the response body should match JSON:
+ """
+ {
+ "status": "up",
+ "details": {
+ "chromium": {
+ "status": "up",
+ "timestamp": "ignore"
+ },
+ "libreoffice": {
+ "status": "up",
+ "timestamp": "ignore"
+ }
+ }
+ }
+ """
+ Then the Gotenberg container should log the following entries:
+ | "path":"/health" |
+
+ Scenario: GET /health (No Logging)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_DISABLE_HEALTH_CHECK_LOGGING | true |
+ When I make a "GET" request to Gotenberg at the "/health" endpoint
+ Then the response status code should be 200
+ Then the Gotenberg container should NOT log the following entries:
+ | "path":"/health" |
+
+ Scenario: GET /health (Gotenberg Trace)
+ Given I have a default Gotenberg container
+ When I make a "GET" request to Gotenberg at the "/health" endpoint with the following header(s):
+ | Gotenberg-Trace | get_health |
+ Then the response status code should be 200
+ Then the response header "Gotenberg-Trace" should be "get_health"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"get_health" |
+
+ Scenario: GET /health (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ When I make a "GET" request to Gotenberg at the "/health" endpoint
+ Then the response status code should be 200
+
+ Scenario: GET /foo/health (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ROOT_PATH | /foo/ |
+ When I make a "GET" request to Gotenberg at the "/foo/health" endpoint
+ Then the response status code should be 200
+
+ Scenario: HEAD /health
+ Given I have a default Gotenberg container
+ When I make a "HEAD" request to Gotenberg at the "/health" endpoint
+ Then the response status code should be 200
+ Then the response body should match string:
+ """
+
+ """
+ Then the Gotenberg container should log the following entries:
+ | "path":"/health" |
+
+ Scenario: HEAD /health (Gotenberg Trace)
+ Given I have a default Gotenberg container
+ When I make a "HEAD" request to Gotenberg at the "/health" endpoint with the following header(s):
+ | Gotenberg-Trace | head_health |
+ Then the response status code should be 200
+ Then the response header "Gotenberg-Trace" should be "head_health"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"head_health" |
+
+ Scenario: HEAD /health (No Logging)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_DISABLE_HEALTH_CHECK_LOGGING | true |
+ When I make a "HEAD" request to Gotenberg at the "/health" endpoint
+ Then the response status code should be 200
+ Then the Gotenberg container should NOT log the following entries:
+ | "path":"/health" |
+
+ Scenario: HEAD /health (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ When I make a "HEAD" request to Gotenberg at the "/health" endpoint
+ Then the response status code should be 200
+
+ Scenario: HEAD /foo/health (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ROOT_PATH | /foo/ |
+ When I make a "HEAD" request to Gotenberg at the "/foo/health" endpoint
+ Then the response status code should be 200
+
+
+# TODO:
+# 1. Check if down for each module.
+# 2. Restarting modules do not make health check fail.
\ No newline at end of file
diff --git a/test/integration/features/libreoffice_convert.feature b/test/integration/features/libreoffice_convert.feature
new file mode 100644
index 000000000..13279a71b
--- /dev/null
+++ b/test/integration/features/libreoffice_convert.feature
@@ -0,0 +1,697 @@
+@libreoffice
+@libreoffice-convert
+Feature: /forms/libreoffice/convert
+
+ Scenario: POST /forms/libreoffice/convert (Single Document)
+ 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+
+ Scenario: POST /forms/libreoffice/convert (Many Documents)
+ 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 |
+ | files | testdata/page_2.docx | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.zip |
+ | page_1.docx.pdf |
+ | page_2.docx.pdf |
+ Then the "page_1.docx.pdf" PDF should have 1 page(s)
+ Then the "page_2.docx.pdf" PDF should have 1 page(s)
+ Then the "page_1.docx.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "page_2.docx.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+
+ # See:
+ # https://github.com/gotenberg/gotenberg/issues/104
+ # https://github.com/gotenberg/gotenberg/issues/730
+ Scenario: POST /forms/libreoffice/convert (Non-basic Latin Characters)
+ 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/Special_Chars_ร.docx | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+
+ Scenario: POST /forms/libreoffice/convert (Protected)
+ 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/protected_page_1.docx | file |
+ 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:
+ """
+ LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain.
+ """
+ When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
+ | files | testdata/protected_page_1.docx | file |
+ | password | foo | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+
+ Scenario: POST /forms/libreoffice/convert (Landscape)
+ 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should NOT be set to landscape orientation
+ 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 |
+ | landscape | true | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should be set to landscape orientation
+
+ Scenario: POST /forms/libreoffice/convert (Native Page Ranges - Single Document)
+ 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/pages_3.docx | file |
+ | nativePageRanges | 2-3 | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 2 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "foo.pdf" PDF should have the following content at page 2:
+ """
+ Page 3
+ """
+
+ Scenario: POST /forms/libreoffice/convert (Native Page Ranges - Many Documents)
+ 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/pages_3.docx | file |
+ | files | testdata/pages_12.docx | file |
+ | nativePageRanges | 2-3 | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.zip |
+ | pages_3.docx.pdf |
+ | pages_12.docx.pdf |
+ Then the "pages_3.docx.pdf" PDF should have 2 page(s)
+ Then the "pages_12.docx.pdf" PDF should have 2 page(s)
+ Then the "pages_3.docx.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "pages_3.docx.pdf" PDF should have the following content at page 2:
+ """
+ Page 3
+ """
+ Then the "pages_12.docx.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "pages_12.docx.pdf" PDF should have the following content at page 2:
+ """
+ Page 3
+ """
+
+ Scenario: POST /forms/libreoffice/convert (Bad Request)
+ 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):
+ | landscape | foo | field |
+ | exportFormFields | foo | field |
+ | allowDuplicateFieldNames | foo | field |
+ | exportBookmarks | foo | field |
+ | exportBookmarksToPdfDestination | foo | field |
+ | exportPlaceholders | foo | field |
+ | exportNotes | foo | field |
+ | exportNotesPages | foo | field |
+ | exportOnlyNotesPages | foo | field |
+ | exportNotesInMargin | foo | field |
+ | convertOooTargetToPdfTarget | foo | field |
+ | exportLinksRelativeFsys | foo | field |
+ | exportHiddenSlides | foo | field |
+ | skipEmptyPages | foo | field |
+ | addOriginalDocumentAsStream | foo | field |
+ | singlePageSheets | foo | field |
+ | losslessImageCompression | foo | field |
+ | quality | -1 | field |
+ | reduceImageResolution | foo | field |
+ | maxImageResolution | 10 | 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: no form file found for extensions: [.123 .602 .abw .bib .bmp .cdr .cgm .cmx .csv .cwk .dbf .dif .doc .docm .docx .dot .dotm .dotx .dxf .emf .eps .epub .fodg .fodp .fods .fodt .fopd .gif .htm .html .hwp .jpeg .jpg .key .ltx .lwp .mcw .met .mml .mw .numbers .odd .odg .odm .odp .ods .odt .otg .oth .otp .ots .ott .pages .pbm .pcd .pct .pcx .pdb .pdf .pgm .png .pot .potm .potx .ppm .pps .ppt .pptm .pptx .psd .psw .pub .pwp .pxl .ras .rtf .sda .sdc .sdd .sdp .sdw .sgl .slk .smf .stc .std .sti .stw .svg .svm .swf .sxc .sxd .sxg .sxi .sxm .sxw .tga .tif .tiff .txt .uof .uop .uos .uot .vdx .vor .vsd .vsdm .vsdx .wb2 .wk1 .wks .wmf .wpd .wpg .wps .xbm .xhtml .xls .xlsb .xlsm .xlsx .xlt .xltm .xltx .xlw .xml .xpm .zabw]; form field 'landscape' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportFormFields' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'allowDuplicateFieldNames' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportBookmarks' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportBookmarksToPdfDestination' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportPlaceholders' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportNotes' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportNotesPages' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportOnlyNotesPages' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportNotesInMargin' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'convertOooTargetToPdfTarget' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportLinksRelativeFsys' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportHiddenSlides' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'skipEmptyPages' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'addOriginalDocumentAsStream' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'singlePageSheets' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'losslessImageCompression' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'quality' is invalid (got '-1', resulting to value is inferior to 1); form field 'reduceImageResolution' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'maxImageResolution' is invalid (got '10', resulting to value is not 75, 150, 300, 600 or 1200)
+ """
+ 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 |
+ | nativePageRanges | 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:
+ """
+ LibreOffice failed to process a document: possible causes include malformed page ranges 'foo' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.
+ """
+ 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 |
+ | password | 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:
+ """
+ LibreOffice failed to process a document: possible causes include malformed page ranges '' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.
+ """
+ When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
+ | files | testdata/protected_page_1.docx | file |
+ | password | bar | 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:
+ """
+ LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain.
+ """
+ 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 |
+ | files | testdata/page_2.docx | file |
+ | merge | 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 'merge' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
+ """
+ When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
+ | files | testdata/pages_3.docx | file |
+ | splitMode | foo | field |
+ | splitSpan | 2 | 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 'splitMode' is invalid (got 'foo', resulting to wrong value, expected either 'intervals' or 'pages')
+ """
+ When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
+ | files | testdata/pages_3.docx | file |
+ | splitMode | intervals | field |
+ | splitSpan | 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 'splitSpan' is invalid (got 'foo', resulting to strconv.Atoi: parsing "foo": invalid syntax)
+ """
+ When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
+ | files | testdata/pages_3.docx | file |
+ | splitMode | pages | field |
+ | splitSpan | 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:
+ """
+ At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
+ """
+ When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
+ | files | testdata/pages_3.docx | file |
+ | pdfa | 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:
+ """
+ A PDF format in '{PdfA:foo PdfUa:false}' is not supported
+ """
+ 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 |
+ | pdfua | 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 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
+ """
+ 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 |
+ | metadata | 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 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a'))
+ """
+
+ @merge
+ Scenario: POST /forms/libreoffice/convert (Merge)
+ 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 |
+ | files | testdata/page_2.docx | file |
+ | merge | 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"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 2 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+
+ @merge
+ @split
+ Scenario: POST /forms/libreoffice/convert (Merge & Split)
+ 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 |
+ | files | testdata/page_2.docx | file |
+ | merge | true | field |
+ | splitMode | intervals | field |
+ | splitSpan | 1 | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | *_0.pdf |
+ | *_1.pdf |
+ Then the "*_0.pdf" PDF should have 1 page(s)
+ Then the "*_1.pdf" PDF should have 1 page(s)
+ Then the "*_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "*_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+
+ @split
+ Scenario: POST /forms/libreoffice/convert (Split Intervals)
+ 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/pages_3.docx | file |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3.docx_0.pdf |
+ | pages_3.docx_1.pdf |
+ Then the "pages_3.docx_0.pdf" PDF should have 2 page(s)
+ Then the "pages_3.docx_1.pdf" PDF should have 1 page(s)
+ Then the "pages_3.docx_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "pages_3.docx_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "pages_3.docx_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+
+ @split
+ Scenario: POST /forms/libreoffice/convert (Split Pages)
+ 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/pages_3.docx | file |
+ | splitMode | pages | field |
+ | splitSpan | 2- | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3.docx_0.pdf |
+ | pages_3.docx_1.pdf |
+ Then the "pages_3.docx_0.pdf" PDF should have 1 page(s)
+ Then the "pages_3.docx_1.pdf" PDF should have 1 page(s)
+ Then the "pages_3.docx_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "pages_3.docx_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+
+ @split
+ Scenario: POST /forms/libreoffice/convert (Split Pages & Unify)
+ 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/pages_3.docx | file |
+ | splitMode | pages | field |
+ | splitSpan | 2- | field |
+ | splitUnify | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3.docx.pdf |
+ Then the "pages_3.docx.pdf" PDF should have 2 page(s)
+ Then the "pages_3.docx.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "pages_3.docx.pdf" PDF should have the following content at page 2:
+ """
+ Page 3
+ """
+
+ @split
+ Scenario: POST /forms/libreoffice/convert (Split Many PDFs - Lot of Pages)
+ 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/pages_12.docx | file |
+ | files | testdata/pages_3.docx | file |
+ | splitMode | intervals | field |
+ | splitSpan | 1 | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 15 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3.docx_0.pdf |
+ | pages_3.docx_1.pdf |
+ | pages_3.docx_2.pdf |
+ | pages_12.docx_0.pdf |
+ | pages_12.docx_1.pdf |
+ | pages_12.docx_2.pdf |
+ | pages_12.docx_3.pdf |
+ | pages_12.docx_4.pdf |
+ | pages_12.docx_5.pdf |
+ | pages_12.docx_6.pdf |
+ | pages_12.docx_7.pdf |
+ | pages_12.docx_8.pdf |
+ | pages_12.docx_9.pdf |
+ | pages_12.docx_10.pdf |
+ | pages_12.docx_11.pdf |
+ Then the "pages_3.docx_0.pdf" PDF should have 1 page(s)
+ Then the "pages_3.docx_2.pdf" PDF should have 1 page(s)
+ Then the "pages_12.docx_0.pdf" PDF should have 1 page(s)
+ Then the "pages_12.docx_11.pdf" PDF should have 1 page(s)
+ Then the "pages_3.docx_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "pages_3.docx_2.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+ Then the "pages_12.docx_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "pages_12.docx_11.pdf" PDF should have the following content at page 1:
+ """
+ Page 12
+ """
+
+ @convert
+ Scenario: POST /forms/libreoffice/convert (PDF/A-1b & PDF/UA-1)
+ 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 |
+ | pdfa | PDF/A-1b | field |
+ | pdfua | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
+
+ @split
+ @convert
+ Scenario: POST /forms/libreoffice/convert (Split & PDF/A-1b & PDF/UA-1)
+ 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/pages_3.docx | file |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | pdfa | PDF/A-1b | field |
+ | pdfua | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3.docx_0.pdf |
+ | pages_3.docx_1.pdf |
+ Then the "pages_3.docx_0.pdf" PDF should have 2 page(s)
+ Then the "pages_3.docx_1.pdf" PDF should have 1 page(s)
+ Then the "pages_3.docx_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "pages_3.docx_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "pages_3.docx_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
+
+ @metadata
+ Scenario: POST /forms/libreoffice/convert (Metadata)
+ 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 |
+ | 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ 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
+ Then the response header "Content-Type" should be "application/json"
+ Then the response body should match JSON:
+ """
+ {
+ "foo.pdf": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ }
+ }
+ """
+
+ @flatten
+ Scenario: POST /forms/libreoffice/convert (Flatten)
+ 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 |
+ | flatten | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be flatten
+
+ @encrypt
+ Scenario: POST /forms/libreoffice/convert (Encrypt - user password only)
+ 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 |
+ | userPassword | foo | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ @encrypt
+ Scenario: POST /forms/libreoffice/convert (Encrypt - both user and owner passwords)
+ 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 |
+ | userPassword | foo | field |
+ | ownerPassword | bar | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ @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
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ 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
+
+ # FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
+ @convert
+ @metadata
+ @flatten
+ @embed
+ Scenario: POST /forms/libreoffice/convert (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & 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 |
+ | 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 |
+ | flatten | true | field |
+ | 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
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 10 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
+ 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/metadata/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": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ }
+ }
+ """
+
+ Scenario: POST /forms/libreoffice/convert (Routes Disabled)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | LIBREOFFICE_DISABLE_ROUTES | true |
+ 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 |
+ Then the response status code should be 404
+
+ Scenario: POST /forms/libreoffice/convert (Gotenberg Trace)
+ 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 |
+ | Gotenberg-Trace | forms_libreoffice_convert | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then the response header "Gotenberg-Trace" should be "forms_libreoffice_convert"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"forms_libreoffice_convert" |
+
+ @download-from
+ Scenario: POST /forms/libreoffice/convert (Download From)
+ Given I have a default Gotenberg container
+ Given I have a static server
+ When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
+ | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.docx","extraHttpHeaders":{"X-Foo":"bar"}}] | field |
+ Then the response status code should be 200
+ Then the file request header "X-Foo" should be "bar"
+ Then the response header "Content-Type" should be "application/pdf"
+
+ @webhook
+ Scenario: POST /forms/libreoffice/convert (Webhook)
+ Given I have a default Gotenberg container
+ Given I have a webhook server
+ 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
+ | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+ Then the response status code should be 204
+ When I wait for the asynchronous request to the webhook
+ Then the webhook request header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the webhook request
+ Then there should be the following file(s) in the webhook request:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+
+ Scenario: POST /forms/libreoffice/convert (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ 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 |
+ Then the response status code should be 401
+
+ Scenario: POST /foo/forms/libreoffice/convert (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_DEBUG_ROUTE | true |
+ | API_ROOT_PATH | /foo/ |
+ When I make a "POST" request to Gotenberg at the "/foo/forms/libreoffice/convert" endpoint with the following form data and header(s):
+ | files | testdata/page_1.docx | file |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
diff --git a/test/integration/features/output_filename.feature b/test/integration/features/output_filename.feature
new file mode 100644
index 000000000..631267866
--- /dev/null
+++ b/test/integration/features/output_filename.feature
@@ -0,0 +1,34 @@
+@output-filename
+Feature: Output Filename
+
+ Scenario: Default (Single Output File)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+
+ Scenario: Default (Many Output Files)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | files | testdata/page_2.pdf | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be the following file(s) in the response:
+ | foo.zip |
+
+ # See https://github.com/gotenberg/gotenberg/issues/1227.
+ Scenario: Path As Filename
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | Gotenberg-Output-Filename | /tmp/foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
diff --git a/test/integration/features/pdfengines_convert.feature b/test/integration/features/pdfengines_convert.feature
new file mode 100644
index 000000000..e2157eb5e
--- /dev/null
+++ b/test/integration/features/pdfengines_convert.feature
@@ -0,0 +1,191 @@
+# TODO:
+# 1. PDF/UA-2.
+
+@pdfengines
+@pdfengines-convert
+Feature: /forms/pdfengines/convert
+
+ Scenario: POST /forms/pdfengines/convert (Single PDF/A-1b)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | pdfa | PDF/A-1b | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
+
+ Scenario: POST /forms/pdfengines/convert (Single PDF/A-2b)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | pdfa | PDF/A-2b | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be valid "PDF/A-2b" with a tolerance of 1 failed rule(s)
+
+ Scenario: POST /forms/pdfengines/convert (Single PDF/A-3b)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | pdfa | PDF/A-3b | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 1 failed rule(s)
+
+ Scenario: POST /forms/pdfengines/convert (Single PDF/UA-1)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | pdfua | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
+
+ Scenario: POST /forms/pdfengines/convert (Single PDF/A-1b & PDF/UA-1)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | pdfa | PDF/A-1b | field |
+ | pdfua | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
+
+ Scenario: POST /forms/pdfengines/convert (Many PDFs)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | files | testdata/page_2.pdf | file |
+ | pdfa | PDF/A-1b | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
+
+ Scenario: POST /forms/pdfengines/convert (Bad Request)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ 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: either 'pdfa' or 'pdfua' form fields must be provided
+ """
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
+ | pdfa | PDF/A-1b | 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: no form file found for extensions: [.pdf]
+ """
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | pdfa | 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:
+ """
+ At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
+ """
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | pdfua | 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 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
+ """
+
+ Scenario: POST /forms/pdfengines/convert (Gotenberg Trace)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | pdfa | PDF/A-1b | field |
+ | Gotenberg-Trace | forms_pdfengines_convert | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then the response header "Gotenberg-Trace" should be "forms_pdfengines_convert"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"forms_pdfengines_convert" |
+
+ @output-filename
+ Scenario: POST /forms/pdfengines/convert (Output Filename - Single PDF)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | pdfa | PDF/A-1b | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+
+ @output-filename
+ Scenario: POST /forms/pdfengines/convert (Output Filename - Many PDFs)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | files | testdata/page_2.pdf | file |
+ | pdfa | PDF/A-1b | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be the following file(s) in the response:
+ | foo.zip |
+ | page_1.pdf |
+ | page_2.pdf |
+
+ @download-from
+ Scenario: POST /forms/pdfengines/convert (Download From)
+ Given I have a default Gotenberg container
+ Given I have a static server
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
+ | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field |
+ | pdfa | PDF/A-1b | field |
+ Then the file request header "X-Foo" should be "bar"
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+
+ @webhook
+ Scenario: POST /forms/pdfengines/convert (Webhook)
+ Given I have a default Gotenberg container
+ Given I have a webhook server
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | pdfa | PDF/A-1b | field |
+ | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
+ | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+ Then the response status code should be 204
+ When I wait for the asynchronous request to the webhook
+ Then the webhook request header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the webhook request
+ Then the webhook request PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
+
+ Scenario: POST /forms/pdfengines/convert (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | pdfa | PDF/A-1b | field |
+ Then the response status code should be 401
+
+ Scenario: POST /foo/forms/pdfengines/convert (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_DEBUG_ROUTE | true |
+ | API_ROOT_PATH | /foo/ |
+ When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/convert" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | pdfa | PDF/A-1b | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
diff --git a/test/integration/features/pdfengines_embed.feature b/test/integration/features/pdfengines_embed.feature
new file mode 100644
index 000000000..4bf9ca7b8
--- /dev/null
+++ b/test/integration/features/pdfengines_embed.feature
@@ -0,0 +1,72 @@
+@pdfengines
+@pdfengines-embed
+@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 |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | page_1.pdf |
+ 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
+
+ @download-from
+ Scenario: POST /forms/pdfengines/embed with (Download From)
+ Given I have a default Gotenberg container
+ Given 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
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | page_1.pdf |
+ Then the response PDF(s) should have the "embed_1.xml" file embedded
+ Then the response PDF(s) should NOT have the "embed_2.xml" file embedded
+
+ @webhook
+ Scenario: POST /forms/pdfengines/embed (Webhook)
+ Given I have a default Gotenberg container
+ Given I have a webhook 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 |
+ | embeds | testdata/embed_1.xml | file |
+ | embeds | testdata/embed_2.xml | file |
+ | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
+ | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+ Then the response status code should be 204
+ When I wait for the asynchronous request to the webhook
+ Then the webhook request header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the webhook request
+ Then the webhook request PDF(s) should have the "embed_1.xml" file embedded
+ Then the webhook request PDF(s) should have the "embed_2.xml" file embedded
+
+ Scenario: POST /forms/pdfengines/embed (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ 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 |
+ Then the response status code should be 401
+
+ Scenario: POST /foo/forms/pdfengines/embed (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_DEBUG_ROUTE | true |
+ | API_ROOT_PATH | /foo/ |
+ When I make a "POST" request to Gotenberg at the "/foo/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 |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
diff --git a/test/integration/features/pdfengines_encrypt.feature b/test/integration/features/pdfengines_encrypt.feature
new file mode 100644
index 000000000..0a072c7e6
--- /dev/null
+++ b/test/integration/features/pdfengines_encrypt.feature
@@ -0,0 +1,182 @@
+@pdfengines
+@pdfengines-encrypt
+@encrypt
+Feature: /forms/pdfengines/encrypt
+
+ Scenario: POST /forms/pdfengines/encrypt (default - user password only)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | userPassword | foo | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ Scenario: POST /forms/pdfengines/encrypt (default - both user and owner passwords)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | userPassword | foo | field |
+ | ownerPassword | bar | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ Scenario: POST /forms/pdfengines/encrypt (QPDF - user password only)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_ENCRYPT_ENGINES | qpdf |
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | userPassword | foo | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ Scenario: POST /forms/pdfengines/encrypt (QPDF - both user and owner passwords)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_ENCRYPT_ENGINES | qpdf |
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | userPassword | foo | field |
+ | ownerPassword | bar | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ Scenario: POST /forms/pdfengines/encrypt (PDFtk - user password only)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_ENCRYPT_ENGINES | pdftk |
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | userPassword | foo | field |
+ Then the response status code should be 400
+ Then the response body should match string:
+ """
+ pdftk: both 'userPassword' and 'ownerPassword' must be provided and different. Consider switching to another PDF engine if this behavior does not work with your workflow
+ """
+
+ Scenario: POST /forms/pdfengines/encrypt (PDFtk - both user and owner passwords)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_ENCRYPT_ENGINES | pdftk |
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | userPassword | foo | field |
+ | ownerPassword | bar | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ Scenario: POST /forms/pdfengines/encrypt (pdfcpu - user password only)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_ENCRYPT_ENGINES | pdfcpu |
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | userPassword | foo | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ Scenario: POST /forms/pdfengines/encrypt (pdfcpu - both user and owner passwords)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_ENCRYPT_ENGINES | pdfcpu |
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | userPassword | foo | field |
+ | ownerPassword | bar | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ Scenario: POST /forms/pdfengines/encrypt (Many PDFs)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | files | testdata/page_2.pdf | file |
+ | userPassword | foo | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ Scenario: POST /forms/pdfengines/encrypt (Bad Request)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ Then the response status code should be 400
+ Then the response body should match string:
+ """
+ Invalid form data: form field 'userPassword' is required
+ """
+
+ Scenario: POST /forms/pdfengines/encrypt (Routes Disabled)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_DISABLE_ROUTES | true |
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ Then the response status code should be 404
+
+ Scenario: POST /forms/pdfengines/encrypt (Gotenberg Trace)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | userPassword | foo | field |
+ | Gotenberg-Trace | forms_pdfengines_encrypt | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then the response header "Gotenberg-Trace" should be "forms_pdfengines_encrypt"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"forms_pdfengines_encrypt" |
+
+ @download-from
+ Scenario: POST /forms/pdfengines/encrypt (Download From)
+ Given I have a default Gotenberg container
+ Given I have a static server
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+ | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field |
+ | userPassword | foo | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ @webhook
+ Scenario: POST /forms/pdfengines/encrypt (Webhook)
+ Given I have a default Gotenberg container
+ Given I have a webhook server
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | userPassword | foo | field |
+ | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
+ | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+ Then the response status code should be 204
+ When I wait for the asynchronous request to the webhook
+ Then the webhook request header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the webhook request
+ Then the response PDF(s) should be encrypted
+
+ Scenario: POST /forms/pdfengines/encrypt (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ Then the response status code should be 401
+
+ Scenario: POST /foo/forms/pdfengines/encrypt (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_DEBUG_ROUTE | true |
+ | API_ROOT_PATH | /foo/ |
+ When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | userPassword | foo | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
diff --git a/test/integration/features/pdfengines_flatten.feature b/test/integration/features/pdfengines_flatten.feature
new file mode 100644
index 000000000..11529a734
--- /dev/null
+++ b/test/integration/features/pdfengines_flatten.feature
@@ -0,0 +1,120 @@
+@pdfengines
+@pdfengines-flatten
+@flatten
+Feature: /forms/pdfengines/flatten
+
+ Scenario: POST /forms/pdfengines/flatten (Single PDF)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be flatten
+
+ Scenario: POST /forms/pdfengines/flatten (Many PDFs)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | files | testdata/page_2.pdf | file |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then the response PDF(s) should be flatten
+
+ Scenario: POST /forms/pdfengines/flatten (Bad Request)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | Gotenberg-Output-Filename | foo | header |
+ 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: no form file found for extensions: [.pdf]
+ """
+
+ Scenario: POST /forms/pdfengines/flatten (Routes Disabled)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_DISABLE_ROUTES | true |
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ Then the response status code should be 404
+
+ Scenario: POST /forms/pdfengines/flatten (Gotenberg Trace)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | Gotenberg-Trace | forms_pdfengines_flatten | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then the response header "Gotenberg-Trace" should be "forms_pdfengines_flatten"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"forms_pdfengines_flatten" |
+
+ @output-filename
+ Scenario: POST /forms/pdfengines/flatten (Output Filename - Single PDF)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+
+ @output-filename
+ Scenario: POST /forms/pdfengines/flatten (Output Filename - Many PDFs)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | files | testdata/page_2.pdf | file |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be the following file(s) in the response:
+ | foo.zip |
+ | page_1.pdf |
+ | page_2.pdf |
+
+ @download-from
+ Scenario: POST /forms/pdfengines/flatten (Download From)
+ Given I have a default Gotenberg container
+ Given I have a static server
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field |
+ Then the file request header "X-Foo" should be "bar"
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then the response PDF(s) should be flatten
+
+ @webhook
+ Scenario: POST /forms/pdfengines/flatten (Webhook)
+ Given I have a default Gotenberg container
+ Given I have a webhook server
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
+ | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+ Then the response status code should be 204
+ When I wait for the asynchronous request to the webhook
+ Then the webhook request header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the webhook request
+ Then the response PDF(s) should be flatten
+
+ Scenario: POST /forms/pdfengines/flatten (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ Then the response status code should be 401
+
+ Scenario: POST /foo/forms/pdfengines/flatten (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_DEBUG_ROUTE | true |
+ | API_ROOT_PATH | /foo/ |
+ When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
diff --git a/test/integration/features/pdfengines_merge.feature b/test/integration/features/pdfengines_merge.feature
new file mode 100644
index 000000000..bb520c78c
--- /dev/null
+++ b/test/integration/features/pdfengines_merge.feature
@@ -0,0 +1,401 @@
+@pdfengines
+@pdfengines-encrypt
+@merge
+Feature: /forms/pdfengines/merge
+
+ Scenario: POST /forms/pdfengines/merge (default)
+ 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 2 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+
+ Scenario: POST /forms/pdfengines/merge (QPDF)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_MERGE_ENGINES | qpdf |
+ 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 2 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+
+ Scenario: POST /forms/pdfengines/merge (pdfcpu)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_MERGE_ENGINES | pdfcpu |
+ 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 2 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+
+ Scenario: POST /forms/pdfengines/merge (PDFtk)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_MERGE_ENGINES | pdftk |
+ 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 2 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+
+ Scenario: POST /forms/pdfengines/merge (Bad Request)
+ 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):
+ | Gotenberg-Output-Filename | foo | header |
+ 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: no form file found for extensions: [.pdf]
+ """
+ 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 |
+ | pdfa | 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:
+ """
+ At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
+ """
+ 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 |
+ | pdfua | 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 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
+ """
+ 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 |
+ | metadata | 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 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a'))
+ """
+
+ @convert
+ Scenario: POST /forms/pdfengines/merge (PDF/A-1b & PDF/UA-1)
+ 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 |
+ | pdfa | PDF/A-1b | field |
+ | pdfua | 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"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 2 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
+
+ @metadata
+ Scenario: POST /forms/pdfengines/merge (Metadata)
+ 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 |
+ | 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 2 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ 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
+ Then the response header "Content-Type" should be "application/json"
+ Then the response body should match JSON:
+ """
+ {
+ "foo.pdf": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ }
+ }
+ """
+
+ @flatten
+ Scenario: POST /forms/pdfengines/merge (Flatten)
+ 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 |
+ | flatten | 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"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 2 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the response PDF(s) should be flatten
+
+ @encrypt
+ Scenario: POST /forms/pdfengines/merge (Encrypt - user password only)
+ 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 |
+ | userPassword | foo | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ @encrypt
+ Scenario: POST /forms/pdfengines/merge (Encrypt - both user and owner passwords)
+ 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 |
+ | userPassword | foo | field |
+ | ownerPassword | bar | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ @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
+ 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
+
+ # FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
+ @convert
+ @metadata
+ @flatten
+ @embed
+ Scenario: POST /forms/pdfengines/merge (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & 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 |
+ | 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 |
+ | flatten | true | field |
+ | 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
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 2 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 10 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
+ 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/metadata/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": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ }
+ }
+ """
+
+ Scenario: POST /forms/pdfengines/merge (Routes Disabled)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_DISABLE_ROUTES | true |
+ 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 |
+ Then the response status code should be 404
+
+ Scenario: POST /forms/pdfengines/merge (Gotenberg Trace)
+ 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 |
+ | Gotenberg-Trace | forms_pdfengines_merge | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then the response header "Gotenberg-Trace" should be "forms_pdfengines_merge"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"forms_pdfengines_merge" |
+
+ @download-from
+ Scenario: POST /forms/pdfengines/merge (Download From)
+ Given I have a default Gotenberg container
+ Given I have a static server
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
+ | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf"},{"url":"http://host.docker.internal:%d/static/testdata/page_2.pdf"}] | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+
+ @webhook
+ Scenario: POST /forms/pdfengines/merge (Webhook)
+ Given I have a default Gotenberg container
+ Given I have a webhook server
+ 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
+ | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+ Then the response status code should be 204
+ When I wait for the asynchronous request to the webhook
+ Then the webhook request header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the webhook request
+ Then there should be the following file(s) in the webhook request:
+ | foo.pdf |
+ Then the "foo.pdf" PDF should have 2 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "foo.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+
+ Scenario: POST /forms/pdfengines/merge (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ 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 |
+ Then the response status code should be 401
+
+ Scenario: POST /foo/forms/pdfengines/merge (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_DEBUG_ROUTE | true |
+ | API_ROOT_PATH | /foo/ |
+ When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/merge" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | files | testdata/page_2.pdf | file |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
diff --git a/test/integration/features/pdfengines_metadata.feature b/test/integration/features/pdfengines_metadata.feature
new file mode 100644
index 000000000..336c35076
--- /dev/null
+++ b/test/integration/features/pdfengines_metadata.feature
@@ -0,0 +1,295 @@
+@pdfengines
+@pdfengines-metadata
+@metadata
+Feature: /forms/pdfengines/{write|read}
+
+ Scenario: POST /forms/pdfengines/metadata/{write|read} (Single PDF)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ 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
+ Then the response header "Content-Type" should be "application/json"
+ Then the response body should match JSON:
+ """
+ {
+ "foo.pdf": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ }
+ }
+ """
+
+ Scenario: POST /forms/pdfengines/metadata/{write|read} (Many PDFs)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
+ | files. | testdata/page_1.pdf | file |
+ | files | testdata/page_2.pdf | file |
+ | 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 |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ 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/page_1.pdf | file |
+ | files | teststore/page_2.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:
+ """
+ {
+ "page_1.pdf": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ },
+ "page_2.pdf": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ }
+ }
+ """
+
+ Scenario: POST /forms/pdfengines/metadata/write (Bad Request)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
+ | Gotenberg-Output-Filename | foo | header |
+ 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 'metadata' is required; no form file found for extensions: [.pdf]
+ """
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | metadata | 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 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a'))
+ """
+
+ Scenario: POST /forms/pdfengines/metadata/read (Bad Request)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):
+ | Gotenberg-Output-Filename | foo | header |
+ 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: no form file found for extensions: [.pdf]
+ """
+
+ Scenario: POST /forms/pdfengines/metadata/write (Routes Disabled)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_DISABLE_ROUTES | true |
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | 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 |
+ Then the response status code should be 404
+
+ Scenario: POST /forms/pdfengines/metadata/read (Routes Disabled)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_DISABLE_ROUTES | true |
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ Then the response status code should be 404
+
+ Scenario: POST /forms/pdfengines/metadata/write (Gotenberg Trace)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | 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 |
+ | Gotenberg-Trace | forms_pdfengines_metadata_write | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then the response header "Gotenberg-Trace" should be "forms_pdfengines_metadata_write"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"forms_pdfengines_metadata_write" |
+
+ Scenario: POST /forms/pdfengines/metadata/read (Gotenberg Trace)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | Gotenberg-Trace | forms_pdfengines_metadata_read | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/json"
+ Then the response header "Gotenberg-Trace" should be "forms_pdfengines_metadata_read"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"forms_pdfengines_metadata_read" |
+
+ @output-filename
+ Scenario: POST /forms/pdfengines/metadata/write (Output Filename - Single PDF)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+
+ @output-filename
+ Scenario: POST /forms/pdfengines/metadata/write (Output Filename - Many PDFs)
+ Given I have a default Gotenberg container
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | files | testdata/page_2.pdf | file |
+ | 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 |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be the following file(s) in the response:
+ | foo.zip |
+ | page_1.pdf |
+ | page_2.pdf |
+
+ @download-from
+ Scenario: POST /forms/pdfengines/metadata/write (Download From)
+ Given I have a default Gotenberg container
+ Given I have a static server
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
+ | 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 |
+ | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the file request header "X-Foo" should be "bar"
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+
+ @download-from
+ Scenario: POST /forms/pdfengines/metadata/read (Download From)
+ Given I have a default Gotenberg container
+ Given I have a static server
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):
+ | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field |
+ Then the file request header "X-Foo" should be "bar"
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/json"
+
+ @webhook
+ Scenario: POST /forms/pdfengines/metadata/write (Webhook)
+ Given I have a default Gotenberg container
+ Given I have a webhook server
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | 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 |
+ | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
+ | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 204
+ When I wait for the asynchronous request to the webhook
+ Then the webhook request header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the webhook request
+ 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
+ Then the response header "Content-Type" should be "application/json"
+ Then the response body should match JSON:
+ """
+ {
+ "foo.pdf": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ }
+ }
+ """
+
+ @webhook
+ Scenario: POST /forms/pdfengines/metadata/read (Webhook)
+ Given I have a default Gotenberg container
+ Given I have a webhook server
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
+ | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+ Then the response status code should be 204
+ When I wait for the asynchronous request to the webhook
+ Then the webhook request header "Content-Type" should be "application/json"
+ Then the webhook request body should match JSON:
+ """
+ {
+ "status": 400,
+ "message": "The webhook middleware can only work with multipart/form-data routes that results in output files"
+ }
+ """
+
+ Scenario: POST /forms/pdfengines/metadata/write (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | 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 |
+ Then the response status code should be 401
+
+ Scenario: POST /forms/pdfengines/metadata/read (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ Then the response status code should be 401
+
+ Scenario: POST /foo/forms/pdfengines/metadata/{write|read} (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_DEBUG_ROUTE | true |
+ | API_ROOT_PATH | /foo/ |
+ When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | 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 |
+ | 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 "/foo/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
+ Then the response header "Content-Type" should be "application/json"
diff --git a/test/integration/features/pdfengines_split.feature b/test/integration/features/pdfengines_split.feature
new file mode 100644
index 000000000..ae4dac4f4
--- /dev/null
+++ b/test/integration/features/pdfengines_split.feature
@@ -0,0 +1,690 @@
+@pdfengines
+@pdfengines-split
+@split
+Feature: /forms/pdfengines/split
+
+ Scenario: POST /forms/pdfengines/split (Intervals - Default)
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3_0.pdf |
+ | pages_3_1.pdf |
+ Then the "pages_3_0.pdf" PDF should have 2 page(s)
+ Then the "pages_3_1.pdf" PDF should have 1 page(s)
+ Then the "pages_3_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "pages_3_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "pages_3_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+
+ Scenario: POST /forms/pdfengines/split (Pages - Default)
+ 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 |
+ | splitMode | pages | field |
+ | splitSpan | 2- | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3_0.pdf |
+ | pages_3_1.pdf |
+ Then the "pages_3_0.pdf" PDF should have 1 page(s)
+ Then the "pages_3_1.pdf" PDF should have 1 page(s)
+ Then the "pages_3_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "pages_3_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+
+ Scenario: POST /forms/pdfengines/split (Pages & Unify - Default)
+ 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 |
+ | splitMode | pages | field |
+ | splitSpan | 2- | field |
+ | splitUnify | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3.pdf |
+ Then the "pages_3.pdf" PDF should have 2 page(s)
+ Then the "pages_3.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "pages_3.pdf" PDF should have the following content at page 2:
+ """
+ Page 3
+ """
+
+ Scenario: POST /forms/pdfengines/split (Intervals - pdfcpu)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_SPLIT_ENGINES | pdfcpu |
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3_0.pdf |
+ | pages_3_1.pdf |
+ Then the "pages_3_0.pdf" PDF should have 2 page(s)
+ Then the "pages_3_1.pdf" PDF should have 1 page(s)
+ Then the "pages_3_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "pages_3_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "pages_3_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+
+ Scenario: POST /forms/pdfengines/split (Pages - pdfcpu)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_SPLIT_ENGINES | pdfcpu |
+ 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 |
+ | splitMode | pages | field |
+ | splitSpan | 2- | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3_0.pdf |
+ | pages_3_1.pdf |
+ Then the "pages_3_0.pdf" PDF should have 1 page(s)
+ Then the "pages_3_1.pdf" PDF should have 1 page(s)
+ Then the "pages_3_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "pages_3_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+
+ Scenario: POST /forms/pdfengines/split (Pages & Unify - pdfcpu)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_SPLIT_ENGINES | pdfcpu |
+ 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 |
+ | splitMode | pages | field |
+ | splitSpan | 2- | field |
+ | splitUnify | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3.pdf |
+ Then the "pages_3.pdf" PDF should have 2 page(s)
+ Then the "pages_3.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "pages_3.pdf" PDF should have the following content at page 2:
+ """
+ Page 3
+ """
+
+ Scenario: POST /forms/pdfengines/split (Pages & Unify - QPDF)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_SPLIT_ENGINES | qpdf |
+ 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 |
+ | splitMode | pages | field |
+ | splitSpan | 2-z | field |
+ | splitUnify | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3.pdf |
+ Then the "pages_3.pdf" PDF should have 2 page(s)
+ Then the "pages_3.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "pages_3.pdf" PDF should have the following content at page 2:
+ """
+ Page 3
+ """
+
+ Scenario: POST /forms/pdfengines/split (Pages & Unify - PDFtk)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_SPLIT_ENGINES | pdftk |
+ 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 |
+ | splitMode | pages | field |
+ | splitSpan | 2-end | field |
+ | splitUnify | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3.pdf |
+ Then the "pages_3.pdf" PDF should have 2 page(s)
+ Then the "pages_3.pdf" PDF should have the following content at page 1:
+ """
+ Page 2
+ """
+ Then the "pages_3.pdf" PDF should have the following content at page 2:
+ """
+ Page 3
+ """
+
+ Scenario: POST /forms/pdfengines/split (Many PDFs - Lot of Pages)
+ 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_12.pdf | file |
+ | files | testdata/pages_3.pdf | file |
+ | splitMode | intervals | field |
+ | splitSpan | 1 | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 15 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3_0.pdf |
+ | pages_3_1.pdf |
+ | pages_3_2.pdf |
+ | pages_12_0.pdf |
+ | pages_12_1.pdf |
+ | pages_12_2.pdf |
+ | pages_12_3.pdf |
+ | pages_12_4.pdf |
+ | pages_12_5.pdf |
+ | pages_12_6.pdf |
+ | pages_12_7.pdf |
+ | pages_12_8.pdf |
+ | pages_12_9.pdf |
+ | pages_12_10.pdf |
+ | pages_12_11.pdf |
+ Then the "pages_3_0.pdf" PDF should have 1 page(s)
+ Then the "pages_3_2.pdf" PDF should have 1 page(s)
+ Then the "pages_12_0.pdf" PDF should have 1 page(s)
+ Then the "pages_12_11.pdf" PDF should have 1 page(s)
+ Then the "pages_3_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "pages_3_2.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+ Then the "pages_12_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "pages_12_11.pdf" PDF should have the following content at page 1:
+ """
+ Page 12
+ """
+
+ Scenario: POST /forms/pdfengines/split (Bad Request)
+ 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):
+ | Gotenberg-Output-Filename | foo | header |
+ 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 'splitMode' is required; form field 'splitSpan' is required; no form file found for extensions: [.pdf]
+ """
+ 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 |
+ | splitMode | foo | field |
+ | splitSpan | 2 | 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 'splitMode' is invalid (got 'foo', resulting to wrong value, expected either 'intervals' or 'pages')
+ """
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 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 'splitSpan' is invalid (got 'foo', resulting to strconv.Atoi: parsing "foo": invalid syntax)
+ """
+ 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 |
+ | splitMode | pages | field |
+ | splitSpan | 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:
+ """
+ At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
+ """
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | pdfa | 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:
+ """
+ At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
+ """
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | pdfua | 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 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
+ """
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | metadata | 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 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a'))
+ """
+
+ @convert
+ Scenario: POST /forms/pdfengines/split (PDF/A-1b & PDF/UA-1)
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | pdfa | PDF/A-1b | field |
+ | pdfua | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3_0.pdf |
+ | pages_3_1.pdf |
+ Then the "pages_3_0.pdf" PDF should have 2 page(s)
+ Then the "pages_3_1.pdf" PDF should have 1 page(s)
+ Then the "pages_3_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "pages_3_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "pages_3_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
+
+ @metadata
+ Scenario: POST /forms/pdfengines/split (Metadata)
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | 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 |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3_0.pdf |
+ | pages_3_1.pdf |
+ Then the "pages_3_0.pdf" PDF should have 2 page(s)
+ Then the "pages_3_1.pdf" PDF should have 1 page(s)
+ Then the "pages_3_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "pages_3_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "pages_3_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+ 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/pages_3_0.pdf | file |
+ | files | teststore/pages_3_1.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:
+ """
+ {
+ "pages_3_0.pdf": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ },
+ "pages_3_1.pdf": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ }
+ }
+ """
+
+ @flatten
+ Scenario: POST /forms/pdfengines/split (Flatten)
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | flatten | true | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3_0.pdf |
+ | pages_3_1.pdf |
+ Then the "pages_3_0.pdf" PDF should have 2 page(s)
+ Then the "pages_3_1.pdf" PDF should have 1 page(s)
+ Then the "pages_3_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "pages_3_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "pages_3_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+ Then the response PDF(s) should be flatten
+
+ @encrypt
+ Scenario: POST /forms/pdfengines/split (Encrypt - user password only)
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | userPassword | foo | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ @encrypt
+ Scenario: POST /forms/pdfengines/split (Encrypt - both user and owner passwords)
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | userPassword | foo | field |
+ | ownerPassword | bar | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then the response PDF(s) should be encrypted
+
+ @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 |
+ 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
+
+ # FIXME: once decrypt is done, add encrypt and check after the content of the PDFs.
+ @convert
+ @metadata
+ @flatten
+ @embed
+ Scenario: POST /forms/pdfengines/split (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | 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 |
+ | flatten | true | field |
+ | embeds | testdata/embed_1.xml | file |
+ | embeds | testdata/embed_2.xml | file |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the response
+ Then there should be the following file(s) in the response:
+ | pages_3_0.pdf |
+ | pages_3_1.pdf |
+ Then the "pages_3_0.pdf" PDF should have 2 page(s)
+ Then the "pages_3_1.pdf" PDF should have 1 page(s)
+ Then the "pages_3_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "pages_3_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "pages_3_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+ Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 10 failed rule(s)
+ Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
+ 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/metadata/read" endpoint with the following form data and header(s):
+ | files | teststore/pages_3_0.pdf | file |
+ | files | teststore/pages_3_1.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:
+ """
+ {
+ "pages_3_0.pdf": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ },
+ "pages_3_1.pdf": {
+ "Author": "Julien Neuhart",
+ "Copyright": "Julien Neuhart",
+ "CreateDate": "2006:09:18 16:27:50-04:00",
+ "Creator": "Gotenberg",
+ "Keywords": ["first", "second"],
+ "Marked": true,
+ "ModDate": "2006:09:18 16:27:50-04:00",
+ "PDFVersion": 1.7,
+ "Producer": "Gotenberg",
+ "Subject": "Sample",
+ "Title": "Sample",
+ "Trapped": "Unknown"
+ }
+ }
+ """
+
+ Scenario: POST /forms/pdfengines/split (Routes Disabled)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PDFENGINES_DISABLE_ROUTES | true |
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ Then the response status code should be 404
+
+ Scenario: POST /forms/pdfengines/split (Gotenberg Trace)
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | Gotenberg-Trace | forms_pdfengines_split | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then the response header "Gotenberg-Trace" should be "forms_pdfengines_split"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"forms_pdfengines_split" |
+
+ @output-filename
+ Scenario: POST /forms/pdfengines/split (Output Filename - Single PDF)
+ 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 |
+ | splitMode | pages | field |
+ | splitSpan | 2- | field |
+ | splitUnify | 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"
+ Then there should be the following file(s) in the response:
+ | foo.pdf |
+
+ @output-filename
+ Scenario: POST /forms/pdfengines/split (Output Filename - Many PDFs)
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | Gotenberg-Output-Filename | foo | header |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
+ Then there should be the following file(s) in the response:
+ | foo.zip |
+ | pages_3_0.pdf |
+ | pages_3_1.pdf |
+
+ @download-from
+ Scenario: POST /forms/pdfengines/split (Download From)
+ Given I have a default Gotenberg container
+ Given I have a static server
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
+ | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/pages_3.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ Then the response status code should be 200
+ Then the file request header "X-Foo" should be "bar"
+ Then the response header "Content-Type" should be "application/zip"
+
+ @webhook
+ Scenario: POST /forms/pdfengines/split (Webhook)
+ Given I have a default Gotenberg container
+ Given I have a webhook server
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
+ | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+ Then the response status code should be 204
+ When I wait for the asynchronous request to the webhook
+ Then the webhook request header "Content-Type" should be "application/zip"
+ Then there should be 2 PDF(s) in the webhook request
+ Then there should be the following file(s) in the webhook request:
+ | pages_3_0.pdf |
+ | pages_3_1.pdf |
+ Then the "pages_3_0.pdf" PDF should have 2 page(s)
+ Then the "pages_3_1.pdf" PDF should have 1 page(s)
+ Then the "pages_3_0.pdf" PDF should have the following content at page 1:
+ """
+ Page 1
+ """
+ Then the "pages_3_0.pdf" PDF should have the following content at page 2:
+ """
+ Page 2
+ """
+ Then the "pages_3_1.pdf" PDF should have the following content at page 1:
+ """
+ Page 3
+ """
+
+ Scenario: POST /forms/pdfengines/split (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ 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 |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ Then the response status code should be 401
+
+ Scenario: POST /foo/forms/pdfengines/split (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_DEBUG_ROUTE | true |
+ | API_ROOT_PATH | /foo/ |
+ When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/split" endpoint with the following form data and header(s):
+ | files | testdata/pages_3.pdf | file |
+ | splitMode | intervals | field |
+ | splitSpan | 2 | field |
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "application/zip"
diff --git a/test/integration/features/prometheus_metrics.feature b/test/integration/features/prometheus_metrics.feature
new file mode 100644
index 000000000..5a12402f3
--- /dev/null
+++ b/test/integration/features/prometheus_metrics.feature
@@ -0,0 +1,91 @@
+# TODO:
+# 1. Count restarts.
+# 2. Count queue size.
+
+@prometheus-metrics
+Feature: /prometheus/metrics
+
+ Scenario: GET /prometheus/metrics (Enabled)
+ Given I have a default Gotenberg container
+ When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "text/plain; version=0.0.4; charset=utf-8; escaping=underscores"
+ Then the response body should match string:
+ """
+ # HELP gotenberg_chromium_requests_queue_size Current number of Chromium conversion requests waiting to be treated.
+ # TYPE gotenberg_chromium_requests_queue_size gauge
+ gotenberg_chromium_requests_queue_size 0
+ # HELP gotenberg_chromium_restarts_count Current number of Chromium restarts.
+ # TYPE gotenberg_chromium_restarts_count gauge
+ gotenberg_chromium_restarts_count 0
+ # HELP gotenberg_libreoffice_requests_queue_size Current number of LibreOffice conversion requests waiting to be treated.
+ # TYPE gotenberg_libreoffice_requests_queue_size gauge
+ gotenberg_libreoffice_requests_queue_size 0
+ # HELP gotenberg_libreoffice_restarts_count Current number of LibreOffice restarts.
+ # TYPE gotenberg_libreoffice_restarts_count gauge
+ gotenberg_libreoffice_restarts_count 0
+
+ """
+ Then the Gotenberg container should log the following entries:
+ | "path":"/prometheus/metrics" |
+
+ Scenario: GET /prometheus/metrics (Custom Namespace)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PROMETHEUS_NAMESPACE | foo |
+ When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "text/plain; version=0.0.4; charset=utf-8; escaping=underscores"
+ Then the response body should match string:
+ """
+ # HELP foo_chromium_requests_queue_size Current number of Chromium conversion requests waiting to be treated.
+ # TYPE foo_chromium_requests_queue_size gauge
+ foo_chromium_requests_queue_size 0
+ # HELP foo_chromium_restarts_count Current number of Chromium restarts.
+ # TYPE foo_chromium_restarts_count gauge
+ foo_chromium_restarts_count 0
+ # HELP foo_libreoffice_requests_queue_size Current number of LibreOffice conversion requests waiting to be treated.
+ # TYPE foo_libreoffice_requests_queue_size gauge
+ foo_libreoffice_requests_queue_size 0
+ # HELP foo_libreoffice_restarts_count Current number of LibreOffice restarts.
+ # TYPE foo_libreoffice_restarts_count gauge
+ foo_libreoffice_restarts_count 0
+
+ """
+
+ Scenario: GET /prometheus/metrics (Disabled)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PROMETHEUS_DISABLE_COLLECT | true |
+ When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint
+ Then the response status code should be 404
+
+ Scenario: GET /prometheus/metrics (No Logging)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | PROMETHEUS_DISABLE_ROUTE_LOGGING | true |
+ When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint
+ Then the response status code should be 200
+ Then the Gotenberg container should NOT log the following entries:
+ | "path":"/prometheus/metrics" |
+
+ Scenario: GET /prometheus/metrics (Gotenberg Trace)
+ Given I have a default Gotenberg container
+ When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint with the following header(s):
+ | Gotenberg-Trace | prometheus_metrics |
+ Then the response status code should be 200
+ Then the response header "Gotenberg-Trace" should be "prometheus_metrics"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"prometheus_metrics" |
+
+ Scenario: GET /prometheus/metrics (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint
+ Then the response status code should be 401
+
+ Scenario: GET /foo/prometheus/metrics (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_DEBUG_ROUTE | true |
+ | API_ROOT_PATH | /foo/ |
+ When I make a "GET" request to Gotenberg at the "/foo/prometheus/metrics" endpoint
+ Then the response status code should be 200
diff --git a/test/integration/features/root.feature b/test/integration/features/root.feature
new file mode 100644
index 000000000..a1c6f5062
--- /dev/null
+++ b/test/integration/features/root.feature
@@ -0,0 +1,63 @@
+@root
+Feature: /
+
+ Scenario: GET /
+ Given I have a default Gotenberg container
+ When I make a "GET" request to Gotenberg at the "/" endpoint
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "text/html; charset=UTF-8"
+ Then the response body should match string:
+ """
+ Hey, Gotenberg has no UI, it's an API. Head to the documentation to learn how to interact with it ๐
+ """
+
+ Scenario: GET / (Gotenberg Trace)
+ Given I have a default Gotenberg container
+ When I make a "GET" request to Gotenberg at the "/" endpoint with the following header(s):
+ | Gotenberg-Trace | root |
+ Then the response status code should be 200
+ Then the response header "Gotenberg-Trace" should be "root"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"root" |
+
+ Scenario: GET / (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ When I make a "GET" request to Gotenberg at the "/" endpoint
+ Then the response status code should be 401
+
+ Scenario: GET /foo/ (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ROOT_PATH | /foo/ |
+ When I make a "GET" request to Gotenberg at the "/foo/" endpoint
+ Then the response status code should be 200
+
+ Scenario: GET /favicon.ico
+ Given I have a default Gotenberg container
+ When I make a "GET" request to Gotenberg at the "/favicon.ico" endpoint
+ Then the response status code should be 204
+
+ Scenario: GET /favicon.ico (Gotenberg Trace)
+ Given I have a default Gotenberg container
+ When I make a "GET" request to Gotenberg at the "/favicon.ico" endpoint with the following header(s):
+ | Gotenberg-Trace | favicon |
+ Then the response status code should be 204
+ Then the response header "Gotenberg-Trace" should be "favicon"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"favicon" |
+
+ Scenario: GET /favicon.ico (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ When I make a "GET" request to Gotenberg at the "/favicon.ico" endpoint
+ Then the response status code should be 401
+
+ Scenario: GET /foo/favicon.ico (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ROOT_PATH | /foo/ |
+ When I make a "GET" request to Gotenberg at the "/foo/favicon.ico" endpoint
+ Then the response status code should be 204
diff --git a/test/integration/features/version.feature b/test/integration/features/version.feature
new file mode 100644
index 000000000..029755053
--- /dev/null
+++ b/test/integration/features/version.feature
@@ -0,0 +1,36 @@
+@version
+Feature: /version
+
+ Scenario: GET /version
+ Given I have a default Gotenberg container
+ When I make a "GET" request to Gotenberg at the "/version" endpoint
+ Then the response status code should be 200
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should match string:
+ """
+ {version}
+ """
+
+ Scenario: GET /version (Gotenberg Trace)
+ Given I have a default Gotenberg container
+ When I make a "GET" request to Gotenberg at the "/version" endpoint with the following header(s):
+ | Gotenberg-Trace | version |
+ Then the response status code should be 200
+ Then the response header "Gotenberg-Trace" should be "version"
+ Then the Gotenberg container should log the following entries:
+ | "trace":"version" |
+
+ Scenario: GET /version (Basic Auth)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_BASIC_AUTH | true |
+ | GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
+ | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
+ When I make a "GET" request to Gotenberg at the "/version" endpoint
+ Then the response status code should be 401
+
+ Scenario: GET /foo/version (Root Path)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | API_ENABLE_DEBUG_ROUTE | true |
+ | API_ROOT_PATH | /foo/ |
+ When I make a "GET" request to Gotenberg at the "/foo/version" endpoint
+ Then the response status code should be 200
diff --git a/test/integration/features/webhook.feature b/test/integration/features/webhook.feature
new file mode 100644
index 000000000..072800eb4
--- /dev/null
+++ b/test/integration/features/webhook.feature
@@ -0,0 +1,46 @@
+# TODO:
+# 1. Other HTTP Methods
+# 2. Errors
+
+@webhook
+Feature: Webhook
+
+ Scenario: Default
+ Given I have a default Gotenberg container
+ Given I have a webhook server
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
+ | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+ Then the response status code should be 204
+ When I wait for the asynchronous request to the webhook
+ Then the webhook request header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the webhook request
+
+ Scenario: Extra HTTP Headers
+ Given I have a default Gotenberg container
+ Given I have a webhook server
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
+ | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+ | Gotenberg-Webhook-Extra-Http-Headers | {"X-Foo":"bar","Content-Disposition":"inline"} | header |
+ Then the response status code should be 204
+ When I wait for the asynchronous request to the webhook
+ Then the webhook request header "Content-Type" should be "application/pdf"
+ Then the webhook request header "X-Foo" should be "bar"
+ # https://github.com/gotenberg/gotenberg/issues/1165
+ Then the webhook request header "Content-Disposition" should be "inline"
+ Then there should be 1 PDF(s) in the webhook request
+
+ Scenario: Synchronous
+ Given I have a Gotenberg container with the following environment variable(s):
+ | WEBHOOK_ENABLE_SYNC_MODE | true |
+ Given I have a webhook server
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
+ | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+ Then the response status code should be 204
+ Then the webhook request header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the webhook request
diff --git a/test/integration/main_test.go b/test/integration/main_test.go
new file mode 100644
index 000000000..67144b22f
--- /dev/null
+++ b/test/integration/main_test.go
@@ -0,0 +1,56 @@
+//go:build integration
+
+package integration
+
+import (
+ "os"
+ "runtime"
+ "testing"
+
+ "github.com/cucumber/godog"
+ "github.com/cucumber/godog/colors"
+ flag "github.com/spf13/pflag"
+
+ "github.com/gotenberg/gotenberg/v8/test/integration/scenario"
+)
+
+func TestMain(m *testing.M) {
+ repository := flag.String("gotenberg-docker-repository", "", "")
+ version := flag.String("gotenberg-version", "", "")
+ platform := flag.String("gotenberg-container-platform", "", "")
+ noConcurrency := flag.Bool("no-concurrency", false, "")
+ tags := flag.String("tags", "", "")
+ flag.Parse()
+
+ if *platform == "" {
+ switch runtime.GOARCH {
+ case "arm64":
+ *platform = "linux/arm64"
+ default:
+ *platform = "linux/amd64"
+ }
+ }
+
+ scenario.GotenbergDockerRepository = *repository
+ scenario.GotenbergVersion = *version
+ scenario.GotenbergContainerPlatform = *platform
+
+ concurrency := runtime.NumCPU()
+ if *noConcurrency {
+ concurrency = 0
+ }
+
+ code := godog.TestSuite{
+ Name: "integration",
+ ScenarioInitializer: scenario.InitializeScenario,
+ Options: &godog.Options{
+ Format: "pretty",
+ Paths: []string{"features"},
+ Output: colors.Colored(os.Stdout),
+ Concurrency: concurrency,
+ Tags: *tags,
+ },
+ }.Run()
+
+ os.Exit(code)
+}
diff --git a/test/integration/scenario/compare.go b/test/integration/scenario/compare.go
new file mode 100644
index 000000000..5f61337f0
--- /dev/null
+++ b/test/integration/scenario/compare.go
@@ -0,0 +1,57 @@
+package scenario
+
+import (
+ "fmt"
+ "reflect"
+)
+
+func compareJson(expected, actual interface{}) error {
+ // Handle maps (JSON objects).
+ expectedMap, ok := expected.(map[string]interface{})
+ if ok {
+ actualMap, ok := actual.(map[string]interface{})
+ if !ok {
+ return fmt.Errorf("expected an object, but actual is: %T", actual)
+ }
+ // For each key in expected, compare if the expected value is not
+ // "ignore".
+ for key, expVal := range expectedMap {
+ if str, isStr := expVal.(string); isStr && str == "ignore" {
+ continue // Skip.
+ }
+ actVal, exists := actualMap[key]
+ if !exists {
+ return fmt.Errorf("missing expected key %q", key)
+ }
+ if err := compareJson(expVal, actVal); err != nil {
+ return fmt.Errorf("key %q: %w", key, err)
+ }
+ }
+ return nil
+ }
+
+ // Handle slices (JSON arrays).
+ expectedSlice, ok := expected.([]interface{})
+ if ok {
+ actualSlice, ok := actual.([]interface{})
+ if !ok {
+ return fmt.Errorf("expected an array, but actual is: %T", actual)
+ }
+ if len(expectedSlice) != len(actualSlice) {
+ return fmt.Errorf("expected array length to be: %d, but actual is: %d", len(expectedSlice), len(actualSlice))
+ }
+ for i := range expectedSlice {
+ if err := compareJson(expectedSlice[i], actualSlice[i]); err != nil {
+ return fmt.Errorf("at index %d: %w", i, err)
+ }
+ }
+ return nil
+ }
+
+ // For other types, compare directly.
+ if !reflect.DeepEqual(expected, actual) {
+ return fmt.Errorf("expected %v (%T) but got %v (%T)", expected, expected, actual, actual)
+ }
+
+ return nil
+}
diff --git a/test/integration/scenario/containers.go b/test/integration/scenario/containers.go
new file mode 100644
index 000000000..c4dc1a559
--- /dev/null
+++ b/test/integration/scenario/containers.go
@@ -0,0 +1,137 @@
+package scenario
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "path/filepath"
+ "time"
+
+ "github.com/docker/docker/api/types/container"
+ "github.com/docker/go-connections/nat"
+ "github.com/testcontainers/testcontainers-go"
+ "github.com/testcontainers/testcontainers-go/network"
+ "github.com/testcontainers/testcontainers-go/wait"
+)
+
+var (
+ GotenbergDockerRepository string
+ GotenbergVersion string
+ GotenbergContainerPlatform string
+)
+
+type noopLogger struct{}
+
+func (n *noopLogger) Printf(format string, v ...interface{}) {
+ // NOOP
+}
+
+func startGotenbergContainer(ctx context.Context, env map[string]string) (*testcontainers.DockerNetwork, testcontainers.Container, error) {
+ ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
+ defer cancel()
+
+ n, err := network.New(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("create Gotenberg container network: %w", err)
+ }
+
+ healthPath := "/health"
+ if env["API_ROOT_PATH"] != "" {
+ healthPath = fmt.Sprintf("%shealth", env["API_ROOT_PATH"])
+ }
+
+ req := testcontainers.ContainerRequest{
+ Image: fmt.Sprintf("gotenberg/%s:%s", GotenbergDockerRepository, GotenbergVersion),
+ ImagePlatform: GotenbergContainerPlatform,
+ ExposedPorts: []string{"3000/tcp"},
+ HostConfigModifier: func(hostConfig *container.HostConfig) {
+ hostConfig.ExtraHosts = []string{"host.docker.internal:host-gateway"}
+ },
+ Networks: []string{n.Name},
+ WaitingFor: wait.ForHTTP(healthPath),
+ Env: env,
+ }
+
+ c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
+ ContainerRequest: req,
+ Started: true,
+ Logger: &noopLogger{},
+ })
+ if err != nil {
+ err = fmt.Errorf("start new Gotenberg container: %w", err)
+ }
+
+ return n, c, err
+}
+
+func execCommandInIntegrationToolsContainer(ctx context.Context, cmd []string, path string) (string, error) {
+ ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
+ defer cancel()
+
+ req := testcontainers.ContainerRequest{
+ Image: "gotenberg/integration-tools:latest",
+ ImagePlatform: GotenbergContainerPlatform,
+ Files: []testcontainers.ContainerFile{
+ {
+ HostFilePath: path,
+ ContainerFilePath: filepath.Base(path),
+ FileMode: 0o700,
+ },
+ },
+ Cmd: []string{"tail", "-f", "/dev/null"}, // Keeps container running indefinitely.
+ }
+
+ c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
+ ContainerRequest: req,
+ Started: true,
+ Logger: &noopLogger{},
+ })
+ if err != nil {
+ return "", fmt.Errorf("start new Integration Tools container: %w", err)
+ }
+ defer func(c testcontainers.Container, ctx context.Context) {
+ err := c.Terminate(ctx)
+ if err != nil {
+ fmt.Printf("terminate container: %v\n", err)
+ }
+ }(c, ctx)
+
+ _, output, err := c.Exec(ctx, cmd)
+ if err != nil {
+ return "", fmt.Errorf("exec %q: %w", cmd, err)
+ }
+
+ b, err := io.ReadAll(output)
+ if err != nil {
+ return "", fmt.Errorf("read output: %w", err)
+ }
+
+ return string(b), nil
+}
+
+func containerHttpEndpoint(ctx context.Context, container testcontainers.Container, port nat.Port) (string, error) {
+ ip, err := container.Host(ctx)
+ if err != nil {
+ return "", fmt.Errorf("get container IP: %w", err)
+ }
+ mapped, err := container.MappedPort(ctx, port)
+ if err != nil {
+ return "", fmt.Errorf("get container port: %w", err)
+ }
+ return fmt.Sprintf("http://%s:%s", ip, mapped.Port()), nil
+}
+
+func containerLogEntries(ctx context.Context, container testcontainers.Container) (string, error) {
+ logReader, err := container.Logs(ctx)
+ if err != nil {
+ return "", fmt.Errorf("get container log entries: %w", err)
+ }
+ defer logReader.Close()
+
+ logsBytes, err := io.ReadAll(logReader)
+ if err != nil {
+ return "", fmt.Errorf("read container log entries: %w", err)
+ }
+
+ return string(logsBytes), nil
+}
diff --git a/test/integration/scenario/doc.go b/test/integration/scenario/doc.go
new file mode 100644
index 000000000..a874ef8f0
--- /dev/null
+++ b/test/integration/scenario/doc.go
@@ -0,0 +1,2 @@
+// Package scenario gathers all steps used in the features.
+package scenario
diff --git a/test/integration/scenario/http.go b/test/integration/scenario/http.go
new file mode 100644
index 000000000..7d88d02b9
--- /dev/null
+++ b/test/integration/scenario/http.go
@@ -0,0 +1,83 @@
+package scenario
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "os"
+ "path/filepath"
+)
+
+func doRequest(method, url string, headers map[string]string, body io.Reader) (*http.Response, error) {
+ req, err := http.NewRequest(method, url, body)
+ if err != nil {
+ return nil, fmt.Errorf("create a request: %w", err)
+ }
+
+ for header, value := range headers {
+ req.Header.Set(header, value)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send a request: %w", err)
+ }
+
+ return resp, nil
+}
+
+func doFormDataRequest(method, url string, fields map[string]string, files map[string][]string, headers map[string]string) (*http.Response, error) {
+ var b bytes.Buffer
+ writer := multipart.NewWriter(&b)
+
+ for name, value := range fields {
+ err := writer.WriteField(name, value)
+ if err != nil {
+ return nil, fmt.Errorf("write field %q: %w", name, err)
+ }
+ }
+
+ for name, paths := range files {
+ for _, path := range paths {
+ part, err := writer.CreateFormFile(name, filepath.Base(path))
+ if err != nil {
+ return nil, fmt.Errorf("create form file %q: %w", filepath.Base(path), err)
+ }
+
+ reader, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("open file %q: %w", path, err)
+ }
+ defer reader.Close()
+
+ _, err = io.Copy(part, reader)
+ if err != nil {
+ return nil, fmt.Errorf("copy file %q: %w", path, err)
+ }
+ }
+ }
+
+ err := writer.Close()
+ if err != nil {
+ return nil, fmt.Errorf("close writer: %w", err)
+ }
+
+ req, err := http.NewRequest(method, url, &b)
+ if err != nil {
+ return nil, fmt.Errorf("create a request: %w", err)
+ }
+
+ for header, value := range headers {
+ req.Header.Set(header, value)
+ }
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send a request: %w", err)
+ }
+
+ return resp, nil
+}
diff --git a/test/integration/scenario/scenario.go b/test/integration/scenario/scenario.go
new file mode 100644
index 000000000..32336f90f
--- /dev/null
+++ b/test/integration/scenario/scenario.go
@@ -0,0 +1,1007 @@
+package scenario
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "mime"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/cucumber/godog"
+ "github.com/google/uuid"
+ "github.com/mholt/archives"
+ "github.com/testcontainers/testcontainers-go"
+)
+
+type scenario struct {
+ resp *httptest.ResponseRecorder
+ workdir string
+ gotenbergContainer testcontainers.Container
+ gotenbergContainerNetwork *testcontainers.DockerNetwork
+ server *server
+ hostPort int
+}
+
+func (s *scenario) reset(ctx context.Context) error {
+ s.resp = httptest.NewRecorder()
+
+ err := os.RemoveAll(s.workdir)
+ if err != nil {
+ return fmt.Errorf("remove workdir %q: %w", s.workdir, err)
+ }
+ s.workdir = ""
+
+ if s.server == nil {
+ return nil
+ }
+
+ err = s.server.stop(ctx)
+ if err != nil {
+ return fmt.Errorf("stop server: %w", err)
+ }
+
+ return nil
+}
+
+func (s *scenario) iHaveADefaultGotenbergContainer(ctx context.Context) error {
+ n, c, err := startGotenbergContainer(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("create Gotenberg container: %s", err)
+ }
+ s.gotenbergContainerNetwork = n
+ s.gotenbergContainer = c
+ return nil
+}
+
+func (s *scenario) iHaveAGotenbergContainerWithTheFollowingEnvironmentVariables(ctx context.Context, envTable *godog.Table) error {
+ env := make(map[string]string)
+ for _, row := range envTable.Rows {
+ env[row.Cells[0].Value] = row.Cells[1].Value
+ }
+ n, c, err := startGotenbergContainer(ctx, env)
+ if err != nil {
+ return fmt.Errorf("create Gotenberg container: %s", err)
+ }
+ s.gotenbergContainerNetwork = n
+ s.gotenbergContainer = c
+ return nil
+}
+
+func (s *scenario) iHaveAServer(ctx context.Context) error {
+ srv, err := newServer(ctx, s.workdir)
+ if err != nil {
+ return fmt.Errorf("create server: %s", err)
+ }
+ s.server = srv
+ port, err := s.server.start(ctx)
+ if err != nil {
+ return fmt.Errorf("start server: %s", err)
+ }
+ s.hostPort = port
+ return nil
+}
+
+func (s *scenario) iMakeARequestToGotenberg(ctx context.Context, method, endpoint string) error {
+ return s.iMakeARequestToGotenbergWithTheFollowingHeaders(ctx, method, endpoint, nil)
+}
+
+func (s *scenario) iMakeARequestToGotenbergWithTheFollowingHeaders(ctx context.Context, method, endpoint string, headersTable *godog.Table) error {
+ if s.gotenbergContainer == nil {
+ return errors.New("no Gotenberg container")
+ }
+
+ base, err := containerHttpEndpoint(ctx, s.gotenbergContainer, "3000")
+ if err != nil {
+ return fmt.Errorf("get container HTTP endpoint: %w", err)
+ }
+
+ headers := make(map[string]string)
+ if headersTable != nil {
+ for _, row := range headersTable.Rows {
+ headers[row.Cells[0].Value] = row.Cells[1].Value
+ }
+ }
+
+ resp, err := doRequest(method, fmt.Sprintf("%s%s", base, endpoint), headers, nil)
+ if err != nil {
+ return fmt.Errorf("do request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("read response body: %w", err)
+ }
+
+ s.resp = httptest.NewRecorder()
+ s.resp.Code = resp.StatusCode
+ for key, values := range resp.Header {
+ for _, v := range values {
+ s.resp.Header().Add(key, v)
+ }
+ }
+ _, err = s.resp.Body.Write(body)
+ if err != nil {
+ return fmt.Errorf("write response body: %w", err)
+ }
+
+ return nil
+}
+
+func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ctx context.Context, method, endpoint string, dataTable *godog.Table) error {
+ if s.gotenbergContainer == nil {
+ return errors.New("no Gotenberg container")
+ }
+
+ fields := make(map[string]string)
+ files := make(map[string][]string)
+ headers := make(map[string]string)
+
+ for _, row := range dataTable.Rows {
+ name := row.Cells[0].Value
+ value := row.Cells[1].Value
+ kind := row.Cells[2].Value
+
+ switch kind {
+ case "field":
+ if name == "downloadFrom" || name == "url" || name == "cookies" {
+ fields[name] = strings.ReplaceAll(value, "%d", fmt.Sprintf("%d", s.hostPort))
+ continue
+ }
+ 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)
+ }
+ value = strings.ReplaceAll(value, "teststore", dirPath)
+ } else {
+ wd, err := os.Getwd()
+ if err != nil {
+ return fmt.Errorf("get current directory: %w", err)
+ }
+ value = fmt.Sprintf("%s/%s", wd, value)
+ }
+ files[name] = append(files[name], value)
+ case "header":
+ if name == "Gotenberg-Webhook-Url" || name == "Gotenberg-Webhook-Error-Url" {
+ headers[name] = fmt.Sprintf(value, s.hostPort)
+ continue
+ }
+ headers[name] = value
+ default:
+ return fmt.Errorf("unexpected %q %q", kind, value)
+ }
+ }
+
+ base, err := containerHttpEndpoint(ctx, s.gotenbergContainer, "3000")
+ if err != nil {
+ return fmt.Errorf("get container HTTP endpoint: %w", err)
+ }
+
+ resp, err := doFormDataRequest(method, fmt.Sprintf("%s%s", base, endpoint), fields, files, headers)
+ if err != nil {
+ return fmt.Errorf("do request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("read response body: %w", err)
+ }
+
+ s.resp = httptest.NewRecorder()
+ s.resp.Code = resp.StatusCode
+ for key, values := range resp.Header {
+ for _, v := range values {
+ s.resp.Header().Add(key, v)
+ }
+ }
+ _, err = s.resp.Body.Write(body)
+ if err != nil {
+ return fmt.Errorf("write response body: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil
+ }
+
+ cd := resp.Header.Get("Content-Disposition")
+ if cd == "" {
+ return nil
+ }
+
+ _, params, err := mime.ParseMediaType(cd)
+ if err != nil {
+ return fmt.Errorf("parse Content-Disposition header: %w", err)
+ }
+
+ filename, ok := params["filename"]
+ if !ok {
+ return errors.New("no filename in Content-Disposition header")
+ }
+
+ dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
+ err = os.MkdirAll(dirPath, 0o755)
+ if err != nil {
+ return fmt.Errorf("create working directory: %w", err)
+ }
+
+ fpath := fmt.Sprintf("%s/%s", dirPath, filename)
+ file, err := os.Create(fpath)
+ if err != nil {
+ return fmt.Errorf("create file %q: %w", fpath, err)
+ }
+ defer file.Close()
+
+ _, err = file.Write(body)
+ if err != nil {
+ return fmt.Errorf("write file %q: %w", fpath, err)
+ }
+
+ if resp.Header.Get("Content-Type") == "application/zip" {
+ var format archives.Zip
+ err = format.Extract(ctx, file, func(ctx context.Context, f archives.FileInfo) error {
+ source, err := f.Open()
+ if err != nil {
+ return fmt.Errorf("open file %q: %w", f.Name(), err)
+ }
+ defer source.Close()
+
+ targetPath := fmt.Sprintf("%s/%s", dirPath, f.Name())
+ target, err := os.Create(targetPath)
+ if err != nil {
+ return fmt.Errorf("create file %q: %w", targetPath, err)
+ }
+ defer target.Close()
+
+ _, err = io.Copy(target, source)
+ if err != nil {
+ return fmt.Errorf("copy file %q: %w", targetPath, err)
+ }
+
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (s *scenario) iWaitForTheAsynchronousRequestToWebhook(ctx context.Context) error {
+ if s.server == nil {
+ return errors.New("server not initialized")
+ }
+ if s.server.req != nil {
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case err := <-s.server.errChan:
+ return err
+ }
+}
+
+func (s *scenario) theGotenbergContainerShouldLogTheFollowingEntries(ctx context.Context, should string, entriesTable *godog.Table) error {
+ if s.gotenbergContainer == nil {
+ return errors.New("no Gotenberg container")
+ }
+
+ expected := make([]string, len(entriesTable.Rows))
+ for i, row := range entriesTable.Rows {
+ expected[i] = row.Cells[0].Value
+ }
+
+ invert := should == "should NOT"
+ check := func() error {
+ logs, err := containerLogEntries(ctx, s.gotenbergContainer)
+ if err != nil {
+ return fmt.Errorf("get log entries: %w", err)
+ }
+
+ for _, entry := range expected {
+ if !invert && !strings.Contains(logs, entry) {
+ return fmt.Errorf("expected log entry %q not found in %q", expected, logs)
+ }
+
+ if invert && strings.Contains(logs, entry) {
+ return fmt.Errorf("log entry %q NOT expected", expected)
+ }
+ }
+
+ return nil
+ }
+
+ var err error
+ for i := 0; i < 3; i++ {
+ err = check()
+ if err != nil && !invert {
+ // We have to retry as not all logs may have been produced.
+ time.Sleep(500 * time.Millisecond)
+ continue
+ }
+ break
+ }
+ return err
+}
+
+func (s *scenario) theResponseStatusCodeShouldBe(expected int) error {
+ if expected != s.resp.Code {
+ return fmt.Errorf("expected response status code to be: %d, but actual is: %d %q", expected, s.resp.Code, s.resp.Body.String())
+ }
+ return nil
+}
+
+func (s *scenario) theHeaderValueShouldBe(kind, name string, expected string) error {
+ var actual string
+ if kind == "response" {
+ actual = s.resp.Header().Get(name)
+ } else if s.server == nil {
+ return errors.New("server not initialized")
+ } else if s.server.req == nil {
+ return errors.New("no webhook request found")
+ } else {
+ actual = s.server.req.Header.Get(name)
+ }
+
+ if expected != actual {
+ return fmt.Errorf("expected %s header %q to be: %q, but actual is: %q", kind, name, expected, actual)
+ }
+ return nil
+}
+
+func (s *scenario) theCookieValueShouldBe(kind, name, expected string) error {
+ var cookies []*http.Cookie
+ if kind == "response" {
+ cookies = s.resp.Result().Cookies()
+ } else if s.server == nil {
+ return errors.New("server not initialized")
+ } else if s.server.req == nil {
+ return errors.New("no webhook request found")
+ } else {
+ cookies = s.server.req.Cookies()
+ }
+
+ var actual *http.Cookie
+ for _, cookie := range cookies {
+ if cookie.Name == name {
+ actual = cookie
+ break
+ }
+ }
+
+ if actual == nil {
+ if expected != "" {
+ return fmt.Errorf("expected %s cookie %q not found", kind, name)
+ }
+ return nil
+ }
+
+ if expected != actual.Value {
+ return fmt.Errorf("expected %s cookie %q to be: %q, but actual is: %q", kind, name, expected, actual.Value)
+ }
+
+ return nil
+}
+
+func (s *scenario) theBodyShouldMatchString(kind string, expectedDoc *godog.DocString) error {
+ var actual string
+ if kind == "response" {
+ actual = s.resp.Body.String()
+ } else if s.server == nil {
+ return errors.New("server not initialized")
+ } else if s.server.req == nil {
+ return errors.New("no webhook request found")
+ } else {
+ actual = string(s.server.bodyCopy)
+ }
+
+ expected := strings.ReplaceAll(expectedDoc.Content, "{version}", GotenbergVersion)
+
+ if actual != expected {
+ return fmt.Errorf("expected %q body to be: %q, but actual is: %q", kind, expected, actual)
+ }
+ return nil
+}
+
+func (s *scenario) theBodyShouldContainString(kind string, expectedDoc *godog.DocString) error {
+ var actual string
+ if kind == "response" {
+ actual = s.resp.Body.String()
+ } else if s.server == nil {
+ return errors.New("server not initialized")
+ } else if s.server.req == nil {
+ return errors.New("no webhook request found")
+ } else {
+ actual = string(s.server.bodyCopy)
+ }
+
+ expected := strings.ReplaceAll(expectedDoc.Content, "{version}", GotenbergVersion)
+
+ if !strings.Contains(actual, expected) {
+ return fmt.Errorf("expected %q body to contain: %q, but actual is: %q", kind, expected, actual)
+ }
+ return nil
+}
+
+func (s *scenario) theBodyShouldMatchJSON(kind string, expectedDoc *godog.DocString) error {
+ var body []byte
+ if kind == "response" {
+ body = s.resp.Body.Bytes()
+ } else if s.server == nil {
+ return errors.New("server not initialized")
+ } else if s.server.req == nil {
+ return errors.New("no webhook request found")
+ } else {
+ body = s.server.bodyCopy
+ }
+
+ var expected, actual interface{}
+
+ content := strings.ReplaceAll(expectedDoc.Content, "{version}", GotenbergVersion)
+ err := json.Unmarshal([]byte(content), &expected)
+ if err != nil {
+ return fmt.Errorf("unmarshal expected JSON: %w", err)
+ }
+
+ err = json.Unmarshal(body, &actual)
+ if err != nil {
+ return fmt.Errorf("unmarshal actual JSON: %w", err)
+ }
+
+ err = compareJson(expected, actual)
+ if err != nil {
+ return fmt.Errorf("expected matching JSON: %w", err)
+ }
+
+ return nil
+}
+
+func (s *scenario) thereShouldBePdfs(expected int, kind string) error {
+ 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)
+ }
+
+ var paths []string
+ err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
+ if pathErr != nil {
+ return pathErr
+ }
+ if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
+ paths = append(paths, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("walk %q: %w", s.workdir, err)
+ }
+
+ if len(paths) != expected {
+ return fmt.Errorf("expected %d PDF(s), but actual is %d", expected, len(paths))
+ }
+
+ return nil
+}
+
+func (s *scenario) thereShouldBeTheFollowingFiles(kind string, filesTable *godog.Table) error {
+ 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)
+ }
+
+ var filenames []string
+ err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
+ if pathErr != nil {
+ return pathErr
+ }
+ if !info.IsDir() {
+ filenames = append(filenames, info.Name())
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("walk %q: %w", s.workdir, err)
+ }
+
+ for _, row := range filesTable.Rows {
+ found := false
+ expected := row.Cells[0].Value
+ for _, filename := range filenames {
+ if strings.HasPrefix(expected, "*_") && strings.Contains(filename, strings.ReplaceAll(expected, "*_", "")) {
+ found = true
+ }
+ if strings.EqualFold(expected, filename) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return fmt.Errorf("expected file %q not found among %q", expected, filenames)
+ }
+ }
+
+ return nil
+}
+
+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"))
+
+ _, err := os.Stat(dirPath)
+ if os.IsNotExist(err) {
+ return fmt.Errorf("directory %q does not exist", dirPath)
+ }
+
+ var paths []string
+ err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
+ if pathErr != nil {
+ return pathErr
+ }
+ if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
+ paths = append(paths, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("walk %q: %w", s.workdir, err)
+ }
+
+ var flavor string
+ switch validate {
+ case "PDF/A-1b":
+ flavor = "1b"
+ case "PDF/A-2b":
+ flavor = "2b"
+ case "PDF/A-3b":
+ flavor = "3b"
+ case "PDF/UA-1":
+ flavor = "ua1"
+ case "PDF/UA-2":
+ flavor = "ua2"
+ default:
+ return fmt.Errorf("unknown %q", validate)
+ }
+
+ re := regexp.MustCompile(`failedRules="(\d+)"`)
+ for _, path := range paths {
+ cmd := []string{
+ "verapdf",
+ "-f",
+ flavor,
+ filepath.Base(path),
+ }
+
+ output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
+ if err != nil {
+ return fmt.Errorf("exec %q: %w", cmd, err)
+ }
+
+ matches := re.FindStringSubmatch(output)
+ if len(matches) < 2 {
+ return errors.New("expected failed rules")
+ }
+
+ failedRules, err := strconv.Atoi(matches[1])
+ if err != nil {
+ return fmt.Errorf("convert failed rules value %q to integer: %w", matches[1], err)
+ }
+
+ if tolerance < failedRules {
+ return fmt.Errorf("expected failed rules to be inferior or equal to: %d, but actual is %d", tolerance, failedRules)
+ }
+ }
+
+ return nil
+}
+
+func (s *scenario) thePdfShouldHavePages(ctx context.Context, name string, pages int) error {
+ var path string
+ if !strings.HasPrefix(name, "*_") {
+ 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)
+ }
+ } 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 {
+ if pathErr != nil {
+ return pathErr
+ }
+ if strings.Contains(info.Name(), substr) {
+ path = currentPath
+ return filepath.SkipDir
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("walk %q: %w", s.workdir, err)
+ }
+ }
+
+ cmd := []string{
+ "pdfinfo",
+ filepath.Base(path),
+ }
+
+ output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
+ if err != nil {
+ return fmt.Errorf("exec %q: %w", cmd, err)
+ }
+
+ output = strings.ReplaceAll(output, " ", "")
+ re := regexp.MustCompile(`Pages:(\d+)`)
+ matches := re.FindStringSubmatch(output)
+
+ if len(matches) < 2 {
+ return errors.New("expected pages")
+ }
+
+ actual, err := strconv.Atoi(matches[1])
+ if err != nil {
+ return fmt.Errorf("convert pages value %q to integer: %w", matches[1], err)
+ }
+
+ if actual != pages {
+ return fmt.Errorf("expected %d pages, but actual is %d", pages, actual)
+ }
+
+ return nil
+}
+
+func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context, name string, kind string) error {
+ var path string
+ if !strings.HasPrefix(name, "*_") {
+ 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)
+ }
+ } 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 {
+ if pathErr != nil {
+ return pathErr
+ }
+ if strings.Contains(info.Name(), substr) {
+ path = currentPath
+ return filepath.SkipDir
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("walk %q: %w", s.workdir, err)
+ }
+ }
+
+ cmd := []string{
+ "pdfinfo",
+ filepath.Base(path),
+ }
+
+ output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
+ if err != nil {
+ return fmt.Errorf("exec %q: %w", cmd, err)
+ }
+
+ output = strings.ReplaceAll(output, " ", "")
+ re := regexp.MustCompile(`Pagesize:(\d+)x(\d+).*`)
+ matches := re.FindStringSubmatch(output)
+
+ if len(matches) < 3 {
+ return errors.New("expected page size")
+ }
+
+ invert := kind == "should NOT"
+
+ width, err := strconv.Atoi(matches[1])
+ if err != nil {
+ return fmt.Errorf("convert width value %q to integer: %w", matches[1], err)
+ }
+
+ height, err := strconv.Atoi(matches[2])
+ if err != nil {
+ return fmt.Errorf("convert height value %q to integer: %w", matches[2], err)
+ }
+
+ if invert && height < width {
+ return fmt.Errorf("expected height %d to be greater than width %d", height, width)
+ }
+
+ if !invert && width < height {
+ return fmt.Errorf("expected width %d to be greater than height %d", width, height)
+ }
+
+ return nil
+}
+
+func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context, name, kind string, page int, expected *godog.DocString) error {
+ var path string
+ if !strings.HasPrefix(name, "*_") {
+ 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)
+ }
+ } 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 {
+ if pathErr != nil {
+ return pathErr
+ }
+ if strings.Contains(info.Name(), substr) {
+ path = currentPath
+ return filepath.SkipDir
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("walk %q: %w", s.workdir, err)
+ }
+ }
+
+ cmd := []string{
+ "pdftotext",
+ "-f",
+ fmt.Sprintf("%d", page),
+ "-l",
+ fmt.Sprintf("%d", page),
+ filepath.Base(path),
+ "-",
+ }
+
+ output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
+ if err != nil {
+ return fmt.Errorf("exec %q: %w", cmd, err)
+ }
+
+ invert := kind == "should NOT"
+
+ if !invert && !strings.Contains(output, expected.Content) {
+ return fmt.Errorf("expected %q not found in %q", expected.Content, output)
+ }
+
+ if invert && strings.Contains(output, expected.Content) {
+ return fmt.Errorf("%q found in %q", expected.Content, output)
+ }
+
+ return nil
+}
+
+func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should string) error {
+ 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)
+ }
+
+ var paths []string
+ err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
+ if pathErr != nil {
+ return pathErr
+ }
+ if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
+ paths = append(paths, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("walk %q: %w", s.workdir, err)
+ }
+
+ invert := should == "should NOT"
+
+ for _, path := range paths {
+ cmd := []string{
+ "verapdf",
+ "-off",
+ "--extract",
+ "annotations",
+ filepath.Base(path),
+ }
+
+ output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
+ if err != nil {
+ return fmt.Errorf("exec %q: %w", cmd, err)
+ }
+
+ if invert && strings.Contains(output, "") {
+ return fmt.Errorf("PDF %q is flatten", path)
+ }
+
+ if !invert && !strings.Contains(output, "") {
+ return fmt.Errorf("PDF %q is not flatten", path)
+ }
+ }
+
+ return nil
+}
+
+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"))
+
+ _, err := os.Stat(dirPath)
+ if os.IsNotExist(err) {
+ return fmt.Errorf("directory %q does not exist", dirPath)
+ }
+
+ var paths []string
+ err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
+ if pathErr != nil {
+ return pathErr
+ }
+ if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
+ paths = append(paths, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("walk %q: %w", dirPath, err)
+ }
+
+ invert := should == "should NOT"
+ re := regexp.MustCompile(`CommandLineError:Incorrectpassword`)
+
+ for _, path := range paths {
+ cmd := []string{
+ "pdfinfo",
+ filepath.Base(path),
+ }
+
+ output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
+ if err != nil {
+ return fmt.Errorf("exec %q: %w", cmd, err)
+ }
+
+ output = strings.ReplaceAll(output, " ", "")
+ output = strings.ReplaceAll(output, "\n", "")
+ matches := re.FindStringSubmatch(output)
+ isEncrypted := len(matches) >= 1 && matches[0] == "CommandLineError:Incorrectpassword"
+
+ if invert && isEncrypted {
+ return fmt.Errorf("PDF %q is encrypted", path)
+ }
+ if !invert && !isEncrypted {
+ return fmt.Errorf("PDF %q is not encrypted: %q", path, output)
+ }
+ }
+
+ return nil
+}
+
+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"))
+
+ _, err := os.Stat(dirPath)
+ if os.IsNotExist(err) {
+ return fmt.Errorf("directory %q does not exist", dirPath)
+ }
+
+ var paths []string
+ err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
+ if pathErr != nil {
+ return pathErr
+ }
+ if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
+ paths = append(paths, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("walk %q: %w", dirPath, err)
+ }
+
+ invert := should == "should NOT"
+
+ for _, path := range paths {
+ cmd := []string{
+ "verapdf",
+ "--off",
+ "--loglevel",
+ "0",
+ "--extract",
+ "embeddedFile",
+ filepath.Base(path),
+ }
+
+ output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
+ if err != nil {
+ return fmt.Errorf("exec %q: %w", cmd, err)
+ }
+
+ found := strings.Contains(output, fmt.Sprintf("%s", 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 InitializeScenario(ctx *godog.ScenarioContext) {
+ s := &scenario{}
+ ctx.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) {
+ wd, err := os.Getwd()
+ if err != nil {
+ return ctx, fmt.Errorf("get current directory: %w", err)
+ }
+ s.workdir = fmt.Sprintf("%s/teststore/%s", wd, uuid.NewString())
+ err = os.MkdirAll(s.workdir, 0o755)
+ if err != nil {
+ return ctx, fmt.Errorf("create working directory: %w", err)
+ }
+ return ctx, nil
+ })
+ ctx.Given(`^I have a default Gotenberg container$`, s.iHaveADefaultGotenbergContainer)
+ ctx.Given(`^I have a Gotenberg container with the following environment variable\(s\):$`, s.iHaveAGotenbergContainerWithTheFollowingEnvironmentVariables)
+ ctx.Given(`^I have a (webhook|static) server$`, s.iHaveAServer)
+ ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint$`, s.iMakeARequestToGotenberg)
+ ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint with the following header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingHeaders)
+ ctx.When(`^I make a "(POST)" request to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders)
+ ctx.When(`^I wait for the asynchronous request to the webhook$`, s.iWaitForTheAsynchronousRequestToWebhook)
+ ctx.Then(`^the Gotenberg container (should|should NOT) log the following entries:$`, s.theGotenbergContainerShouldLogTheFollowingEntries)
+ ctx.Then(`^the response status code should be (\d+)$`, s.theResponseStatusCodeShouldBe)
+ ctx.Then(`^the (response|webhook request|file request|server request) header "([^"]*)" should be "([^"]*)"$`, s.theHeaderValueShouldBe)
+ ctx.Then(`^the (response|webhook request|file request|server request) cookie "([^"]*)" should be "([^"]*)"$`, s.theCookieValueShouldBe)
+ ctx.Then(`^the (response|webhook request) body should match string:$`, s.theBodyShouldMatchString)
+ ctx.Then(`^the (response|webhook request) body should contain string:$`, s.theBodyShouldContainString)
+ ctx.Then(`^the (response|webhook request) body should match JSON:$`, s.theBodyShouldMatchJSON)
+ ctx.Then(`^there should be (\d+) PDF\(s\) in the (response|webhook request)$`, s.thereShouldBePdfs)
+ ctx.Then(`^there should be the following file\(s\) in the (response|webhook request):$`, s.thereShouldBeTheFollowingFiles)
+ ctx.Then(`^the (response|webhook request) PDF\(s\) should be valid "([^"]*)" with a tolerance of (\d+) failed rule\(s\)$`, s.thePdfsShouldBeValidWithAToleranceOf)
+ ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be flatten$`, s.thePdfsShouldBeFlatten)
+ ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be encrypted`, s.thePdfsShouldBeEncrypted)
+ ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) have the "([^"]*)" file embedded$`, s.thePdfsShouldHaveEmbeddedFile)
+ 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) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
+ ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
+ if s.gotenbergContainer != nil {
+ errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0))
+ if errTerminate != nil {
+ return ctx, fmt.Errorf("terminate Gotenberg container: %w", errTerminate)
+ }
+ }
+ if s.gotenbergContainerNetwork != nil {
+ errRemove := s.gotenbergContainerNetwork.Remove(ctx)
+ if errRemove != nil {
+ return ctx, fmt.Errorf("remove Gotenberg container network: %w", errRemove)
+ }
+ }
+ return ctx, nil
+ })
+ ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
+ errReset := s.reset(ctx)
+ if errReset != nil {
+ return ctx, fmt.Errorf("reset scenario: %w", errReset)
+ }
+ return ctx, nil
+ })
+}
diff --git a/test/integration/scenario/server.go b/test/integration/scenario/server.go
new file mode 100644
index 000000000..861d70fb8
--- /dev/null
+++ b/test/integration/scenario/server.go
@@ -0,0 +1,194 @@
+package scenario
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "mime"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/cucumber/godog"
+ "github.com/google/uuid"
+ "github.com/labstack/echo/v4"
+ "github.com/mholt/archives"
+)
+
+type server struct {
+ srv *echo.Echo
+ req *http.Request
+ bodyCopy []byte
+ errChan chan error
+}
+
+func newServer(ctx context.Context, workdir string) (*server, error) {
+ srv := echo.New()
+ srv.HideBanner = true
+ srv.HidePort = true
+ s := &server{
+ srv: srv,
+ errChan: make(chan error, 1),
+ }
+
+ wd, err := os.Getwd()
+ if err != nil {
+ return nil, fmt.Errorf("get current directory: %w", err)
+ }
+
+ webhookErr := func(err error) error {
+ s.errChan <- err
+ return err
+ }
+
+ webhookHandler := func(c echo.Context) error {
+ s.req = c.Request()
+
+ body, err := io.ReadAll(s.req.Body)
+ if err != nil {
+ return webhookErr(fmt.Errorf("read request body: %w", err))
+ }
+
+ s.bodyCopy = body
+
+ cd := s.req.Header.Get("Content-Disposition")
+ if cd == "" {
+ return webhookErr(fmt.Errorf("no Content-Disposition header"))
+ }
+
+ _, params, err := mime.ParseMediaType(cd)
+ if err != nil {
+ return webhookErr(fmt.Errorf("parse Content-Disposition header: %w", err))
+ }
+
+ filename, ok := params["filename"]
+ if !ok {
+ filename = uuid.NewString()
+ contentType := s.req.Header.Get("Content-Type")
+ switch contentType {
+ case "application/zip":
+ filename = fmt.Sprintf("%s.zip", filename)
+ case "application/pdf":
+ filename = fmt.Sprintf("%s.pdf", filename)
+ default:
+ return webhookErr(errors.New("no filename in Content-Disposition header"))
+ }
+ }
+
+ dirPath := fmt.Sprintf("%s/%s", workdir, s.req.Header.Get("Gotenberg-Trace"))
+ err = os.MkdirAll(dirPath, 0o755)
+ if err != nil {
+ return webhookErr(fmt.Errorf("create working directory: %w", err))
+ }
+
+ fpath := fmt.Sprintf("%s/%s", dirPath, filename)
+ file, err := os.Create(fpath)
+ if err != nil {
+ return webhookErr(fmt.Errorf("create file %q: %w", fpath, err))
+ }
+ defer file.Close()
+
+ _, err = file.Write(body)
+ if err != nil {
+ return webhookErr(fmt.Errorf("write file %q: %w", fpath, err))
+ }
+
+ if s.req.Header.Get("Content-Type") == "application/zip" {
+ var format archives.Zip
+ err = format.Extract(ctx, file, func(ctx context.Context, f archives.FileInfo) error {
+ source, err := f.Open()
+ if err != nil {
+ return fmt.Errorf("open file %q: %w", f.Name(), err)
+ }
+ defer source.Close()
+
+ targetPath := fmt.Sprintf("%s/%s", dirPath, f.Name())
+ target, err := os.Create(targetPath)
+ if err != nil {
+ return fmt.Errorf("create file %q: %w", targetPath, err)
+ }
+ defer target.Close()
+
+ _, err = io.Copy(target, source)
+ if err != nil {
+ return fmt.Errorf("copy file %q: %w", targetPath, err)
+ }
+
+ return nil
+ })
+ if err != nil {
+ return webhookErr(err)
+ }
+ }
+
+ return webhookErr(c.String(http.StatusOK, http.StatusText(http.StatusOK)))
+ }
+ webhookErrorHandler := func(c echo.Context) error {
+ s.req = c.Request()
+ body, err := io.ReadAll(s.req.Body)
+ if err != nil {
+ return webhookErr(fmt.Errorf("read request body: %w", err))
+ }
+ s.bodyCopy = body
+ return webhookErr(c.String(http.StatusOK, http.StatusText(http.StatusOK)))
+ }
+
+ srv.POST("/webhook", webhookHandler)
+ srv.PATCH("/webhook", webhookHandler)
+ srv.PUT("/webhook", webhookHandler)
+ srv.POST("/webhook/error", webhookErrorHandler)
+ srv.PATCH("/webhook/error", webhookErrorHandler)
+ srv.PUT("/webhook/error", webhookErrorHandler)
+ srv.GET("/static/:path", func(c echo.Context) error {
+ s.req = c.Request()
+ path := c.Param("path")
+ if strings.Contains(path, "teststore") {
+ return c.Attachment(fmt.Sprintf("%s/%s/%s", workdir, s.req.Header.Get("Gotenberg-Trace"), filepath.Base(path)), filepath.Base(path))
+ }
+ return c.Attachment(fmt.Sprintf("%s/%s", wd, path), filepath.Base(path))
+ })
+ srv.GET("/html/:path", func(c echo.Context) error {
+ s.req = c.Request()
+ path := fmt.Sprintf("%s/%s", wd, c.Param("path"))
+ f, err := os.Open(path)
+ if err != nil {
+ return c.String(http.StatusInternalServerError, fmt.Sprintf("open file %q: %s", path, err))
+ }
+ defer f.Close()
+ b, err := io.ReadAll(f)
+ if err != nil {
+ return c.String(http.StatusInternalServerError, fmt.Sprintf("read file %q: %s", path, err))
+ }
+ return c.HTML(http.StatusOK, string(b))
+ })
+
+ return s, nil
+}
+
+func (s *server) start(ctx context.Context) (int, error) {
+ // #nosec
+ ln, err := net.Listen("tcp", "0.0.0.0:0")
+ if err != nil {
+ return 0, fmt.Errorf("create listener: %w", err)
+ }
+
+ port := ln.Addr().(*net.TCPAddr).Port
+
+ go func() {
+ s.srv.Listener = ln
+ err = s.srv.Start("")
+ if err != nil && !errors.Is(err, http.ErrServerClosed) {
+ godog.Log(ctx, err.Error())
+ }
+ }()
+
+ return port, nil
+}
+
+func (s *server) stop(ctx context.Context) error {
+ close(s.errChan)
+ return s.srv.Shutdown(ctx)
+}
diff --git "a/test/integration/testdata/Special_Chars_\303\237.docx" "b/test/integration/testdata/Special_Chars_\303\237.docx"
new file mode 100644
index 000000000..1868509c5
Binary files /dev/null and "b/test/integration/testdata/Special_Chars_\303\237.docx" differ
diff --git a/test/integration/testdata/embed_1.xml b/test/integration/testdata/embed_1.xml
new file mode 100644
index 000000000..acd3c4731
--- /dev/null
+++ b/test/integration/testdata/embed_1.xml
@@ -0,0 +1,5 @@
+
+ test 1.1
+ test 1.2
+ test 1.3
+
\ No newline at end of file
diff --git a/test/integration/testdata/embed_2.xml b/test/integration/testdata/embed_2.xml
new file mode 100644
index 000000000..44ff7b56a
--- /dev/null
+++ b/test/integration/testdata/embed_2.xml
@@ -0,0 +1,5 @@
+
+ test 2.1
+ test 2.2
+ test 2.3
+
\ No newline at end of file
diff --git a/test/integration/testdata/feature-rich-html-remote/index.html b/test/integration/testdata/feature-rich-html-remote/index.html
new file mode 100644
index 000000000..edfb30779
--- /dev/null
+++ b/test/integration/testdata/feature-rich-html-remote/index.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+ Feature Rich HTML
+
+
+
+
+
+
diff --git a/test/integration/testdata/pages-3-markdown/page_1.md b/test/integration/testdata/pages-3-markdown/page_1.md
new file mode 100644
index 000000000..960800143
--- /dev/null
+++ b/test/integration/testdata/pages-3-markdown/page_1.md
@@ -0,0 +1 @@
+# Page 1
diff --git a/test/integration/testdata/pages-3-markdown/page_2.md b/test/integration/testdata/pages-3-markdown/page_2.md
new file mode 100644
index 000000000..f310be332
--- /dev/null
+++ b/test/integration/testdata/pages-3-markdown/page_2.md
@@ -0,0 +1 @@
+# Page 2
diff --git a/test/integration/testdata/pages-3-markdown/page_3.md b/test/integration/testdata/pages-3-markdown/page_3.md
new file mode 100644
index 000000000..294d95c1b
--- /dev/null
+++ b/test/integration/testdata/pages-3-markdown/page_3.md
@@ -0,0 +1 @@
+# Page 3
diff --git a/test/integration/testdata/pages_12.docx b/test/integration/testdata/pages_12.docx
new file mode 100644
index 000000000..6fa6d1272
Binary files /dev/null and b/test/integration/testdata/pages_12.docx differ
diff --git a/test/integration/testdata/pages_12.pdf b/test/integration/testdata/pages_12.pdf
new file mode 100644
index 000000000..7e7459ff8
Binary files /dev/null and b/test/integration/testdata/pages_12.pdf differ
diff --git a/test/integration/testdata/pages_3.docx b/test/integration/testdata/pages_3.docx
new file mode 100644
index 000000000..792f530be
Binary files /dev/null and b/test/integration/testdata/pages_3.docx differ
diff --git a/test/integration/testdata/pages_3.pdf b/test/integration/testdata/pages_3.pdf
new file mode 100644
index 000000000..2f5c4c030
Binary files /dev/null and b/test/integration/testdata/pages_3.pdf differ
diff --git a/test/testdata/api/README.md b/test/integration/testdata/pem/README.md
similarity index 100%
rename from test/testdata/api/README.md
rename to test/integration/testdata/pem/README.md
diff --git a/test/testdata/api/cert.pem b/test/integration/testdata/pem/cert.pem
similarity index 100%
rename from test/testdata/api/cert.pem
rename to test/integration/testdata/pem/cert.pem
diff --git a/test/testdata/api/key.pem b/test/integration/testdata/pem/key.pem
similarity index 100%
rename from test/testdata/api/key.pem
rename to test/integration/testdata/pem/key.pem
diff --git a/test/integration/testdata/protected_page_1.docx b/test/integration/testdata/protected_page_1.docx
new file mode 100644
index 000000000..a7b0deb29
Binary files /dev/null and b/test/integration/testdata/protected_page_1.docx differ
diff --git a/test/integration/teststore/.gitkeep b/test/integration/teststore/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/testdata/api/sample1.txt b/test/testdata/api/sample1.txt
deleted file mode 100644
index 191028156..000000000
--- a/test/testdata/api/sample1.txt
+++ /dev/null
@@ -1 +0,0 @@
-foo
\ No newline at end of file
diff --git a/test/testdata/api/sample2.pdf b/test/testdata/api/sample2.pdf
deleted file mode 100644
index 0a0b284fa..000000000
Binary files a/test/testdata/api/sample2.pdf and /dev/null differ
diff --git a/test/testdata/chromium/html/font.woff b/test/testdata/chromium/html/font.woff
deleted file mode 100644
index ebd62d592..000000000
Binary files a/test/testdata/chromium/html/font.woff and /dev/null differ
diff --git a/test/testdata/chromium/html/footer.html b/test/testdata/chromium/html/footer.html
deleted file mode 100644
index c355f721b..000000000
--- a/test/testdata/chromium/html/footer.html
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
-
- of
-
-
-
\ No newline at end of file
diff --git a/test/testdata/chromium/html/header.html b/test/testdata/chromium/html/header.html
deleted file mode 100644
index 213ec4fc7..000000000
--- a/test/testdata/chromium/html/header.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/test/testdata/chromium/html/img.gif b/test/testdata/chromium/html/img.gif
deleted file mode 100644
index 6b066b53e..000000000
Binary files a/test/testdata/chromium/html/img.gif and /dev/null differ
diff --git a/test/testdata/chromium/html/index.html b/test/testdata/chromium/html/index.html
deleted file mode 100644
index a19f166e1..000000000
--- a/test/testdata/chromium/html/index.html
+++ /dev/null
@@ -1,105 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- Gutenberg
-
-
-
-
-
-
Gutenberg
-
-
-
-
-
It is a press, certainly, but a press from which shall flow in inexhaustible streams...Through it, God will spread His Word. A spring of truth shall flow from it: like a new star it shall scatter the darkness of ignorance, and cause a light heretofore unknown to shine amongst men.
-
-
-
-
-
-
This paragraph uses the default font
-
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
-
-
This paragraph uses a Google font
-
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
-
-
This paragraph uses a local font
-
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
-
-
-
-
This image is loaded from a URL
-
-
-
-
-
This paragraph appears if wait delay > 2 seconds or if expression window.globalVar === 'ready' returns true
-
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
-
-
This paragraph appears if the emulated media type is 'print'
-
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
-
-
This paragraph appears if the emulated media type is 'screen'
-
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
-
-
-
-
This paragraph appears if JavaScript is NOT disabled
-
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
-
-
-
-
/etc/passwd
-
-
-
\\localhost/etc/passwd
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/test/testdata/chromium/html/style.css b/test/testdata/chromium/html/style.css
deleted file mode 100644
index a32c9a714..000000000
--- a/test/testdata/chromium/html/style.css
+++ /dev/null
@@ -1,29 +0,0 @@
-body {
- font-family: Arial, Helvetica, sans-serif;
-}
-
-.center {
- text-align: center;
-}
-
-.google-font {
- font-family: 'Montserrat', sans-serif;
-}
-
-@font-face {
- font-family: 'Local';
- src: url('font.woff') format('woff');
- font-weight: normal;
- font-style: normal;
-}
-
-.local-font {
- font-family: 'Local'
-}
-
-@media print {
- .page-break-after {
- page-break-after: always;
- }
-}
-
diff --git a/test/testdata/chromium/markdown/defaultfont.md b/test/testdata/chromium/markdown/defaultfont.md
deleted file mode 100644
index 14c830357..000000000
--- a/test/testdata/chromium/markdown/defaultfont.md
+++ /dev/null
@@ -1,3 +0,0 @@
-## This paragraph uses the default font and has been generated from a markdown file
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
\ No newline at end of file
diff --git a/test/testdata/chromium/markdown/font.woff b/test/testdata/chromium/markdown/font.woff
deleted file mode 100644
index ebd62d592..000000000
Binary files a/test/testdata/chromium/markdown/font.woff and /dev/null differ
diff --git a/test/testdata/chromium/markdown/footer.html b/test/testdata/chromium/markdown/footer.html
deleted file mode 100644
index c355f721b..000000000
--- a/test/testdata/chromium/markdown/footer.html
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
-
- of
-
-
-
\ No newline at end of file
diff --git a/test/testdata/chromium/markdown/googlefont.md b/test/testdata/chromium/markdown/googlefont.md
deleted file mode 100644
index d8dd4f23c..000000000
--- a/test/testdata/chromium/markdown/googlefont.md
+++ /dev/null
@@ -1,3 +0,0 @@
-## This paragraph uses a Google font and has been generated from a markdown file
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
\ No newline at end of file
diff --git a/test/testdata/chromium/markdown/header.html b/test/testdata/chromium/markdown/header.html
deleted file mode 100644
index 213ec4fc7..000000000
--- a/test/testdata/chromium/markdown/header.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/test/testdata/chromium/markdown/img.gif b/test/testdata/chromium/markdown/img.gif
deleted file mode 100644
index 6b066b53e..000000000
Binary files a/test/testdata/chromium/markdown/img.gif and /dev/null differ
diff --git a/test/testdata/chromium/markdown/index.html b/test/testdata/chromium/markdown/index.html
deleted file mode 100644
index faa522dbc..000000000
--- a/test/testdata/chromium/markdown/index.html
+++ /dev/null
@@ -1,49 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- Gutenberg
-
-
-
-
-
Gutenberg
-
-
-
-
-
It is a press, certainly, but a press from which shall flow in inexhaustible streams...Through it, God will spread His Word. A spring of truth shall flow from it: like a new star it shall scatter the darkness of ignorance, and cause a light heretofore unknown to shine amongst men.
-
-
-
-
-
- {{ toHTML "defaultfont.md" }}
-
-
- {{ toHTML "googlefont.md" }}
-
-
-
- {{ toHTML "localfont.md" }}
-
-
-
-
- {{ toHTML "table.md" }}
-
-
HTML from previous table
-
-
-
-
\ No newline at end of file
diff --git a/test/testdata/chromium/markdown/localfont.md b/test/testdata/chromium/markdown/localfont.md
deleted file mode 100644
index c0aac6f33..000000000
--- a/test/testdata/chromium/markdown/localfont.md
+++ /dev/null
@@ -1,3 +0,0 @@
-## This paragraph uses a local font and has been generated from a markdown file
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
\ No newline at end of file
diff --git a/test/testdata/chromium/markdown/style.css b/test/testdata/chromium/markdown/style.css
deleted file mode 100644
index e937998f4..000000000
--- a/test/testdata/chromium/markdown/style.css
+++ /dev/null
@@ -1,28 +0,0 @@
-body {
- font-family: Arial, Helvetica, sans-serif;
-}
-
-.center {
- text-align: center;
-}
-
-.google-font {
- font-family: 'Montserrat', sans-serif;
-}
-
-@font-face {
- font-family: 'Local';
- src: url('font.woff') format('woff');
- font-weight: normal;
- font-style: normal;
-}
-
-.local-font {
- font-family: 'Local'
-}
-
-@media print {
- .page-break-after {
- page-break-after: always;
- }
-}
\ No newline at end of file
diff --git a/test/testdata/chromium/markdown/table.md b/test/testdata/chromium/markdown/table.md
deleted file mode 100644
index 057f69b3d..000000000
--- a/test/testdata/chromium/markdown/table.md
+++ /dev/null
@@ -1,7 +0,0 @@
-## This paragraph displays a table from a markdown file
-
-| Tables | Are | Cool |
-|----------|:-------------:|------:|
-| col 1 is | left-aligned | $1600 |
-| col 2 is | centered | $12 |
-| col 3 is | right-aligned | $1 |
\ No newline at end of file
diff --git a/test/testdata/libreoffice/document.docx b/test/testdata/libreoffice/document.docx
deleted file mode 100644
index e745b3e5e..000000000
Binary files a/test/testdata/libreoffice/document.docx and /dev/null differ
diff --git a/test/testdata/libreoffice/protected.docx b/test/testdata/libreoffice/protected.docx
deleted file mode 100644
index 840d00d56..000000000
Binary files a/test/testdata/libreoffice/protected.docx and /dev/null differ
diff --git a/test/testdata/pdfengines/sample1.pdf b/test/testdata/pdfengines/sample1.pdf
deleted file mode 100644
index 0a0b284fa..000000000
Binary files a/test/testdata/pdfengines/sample1.pdf and /dev/null differ
diff --git a/test/testdata/pdfengines/sample2.pdf b/test/testdata/pdfengines/sample2.pdf
deleted file mode 100644
index 0a0b284fa..000000000
Binary files a/test/testdata/pdfengines/sample2.pdf and /dev/null differ