diff --git a/go.mod b/go.mod
index 4aedbf31..aa70d824 100644
--- a/go.mod
+++ b/go.mod
@@ -13,21 +13,21 @@ require (
github.com/hashicorp/go-retryablehttp v0.7.5
github.com/klauspost/compress v1.17.4 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
- github.com/labstack/echo/v4 v4.11.3
- github.com/labstack/gommon v0.4.1
+ github.com/labstack/echo/v4 v4.11.4
+ github.com/labstack/gommon v0.4.2
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.26
github.com/nwaples/rardecode v1.1.3 // indirect
github.com/pdfcpu/pdfcpu v0.6.0
github.com/pierrec/lz4/v4 v4.1.19 // indirect
- github.com/prometheus/client_golang v1.17.0
+ github.com/prometheus/client_golang v1.18.0
github.com/russross/blackfriday/v2 v2.1.0
github.com/spf13/pflag v1.0.5
github.com/ulikunitz/xz v0.5.11 // indirect
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.26.0
- golang.org/x/crypto v0.16.0 // indirect
+ golang.org/x/crypto v0.17.0 // indirect
golang.org/x/image v0.14.0 // indirect
golang.org/x/net v0.19.0
golang.org/x/sync v0.5.0
@@ -61,6 +61,6 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
- google.golang.org/protobuf v1.31.0 // indirect
+ google.golang.org/protobuf v1.32.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
diff --git a/go.sum b/go.sum
index 1a967cc5..92cdd0b9 100644
--- a/go.sum
+++ b/go.sum
@@ -28,7 +28,6 @@ github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/gobwas/ws v1.3.1 h1:Qi34dfLMWJbiKaNbDVzM9x27nZBjmkaW6i4+Ku+pGVU=
github.com/gobwas/ws v1.3.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
@@ -63,10 +62,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/labstack/echo/v4 v4.11.3 h1:Upyu3olaqSHkCjs1EJJwQ3WId8b8b1hxbogyommKktM=
-github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws=
-github.com/labstack/gommon v0.4.1 h1:gqEff0p/hTENGMABzezPoPSRtIh1Cvw0ueMOe0/dfOk=
-github.com/labstack/gommon v0.4.1/go.mod h1:TyTrpPqxR5KMk8LKVtLmfMjeQ5FEkBYdxLYPw/WfrOM=
+github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
+github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
+github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
+github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
@@ -98,8 +97,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
-github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
+github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
+github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
@@ -136,8 +135,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
-golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
-golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
+golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
+golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
@@ -153,9 +152,8 @@ golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
-google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
-google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
+google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go
index 5dc79651..9fd164fb 100644
--- a/pkg/modules/chromium/browser.go
+++ b/pkg/modules/chromium/browser.go
@@ -22,7 +22,8 @@ import (
type browser interface {
gotenberg.Process
- pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error
+ pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
+ screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
}
type browserArguments struct {
@@ -213,9 +214,53 @@ func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool {
return true
}
-func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
+ // Note: no error wrapping because it leaks on errors we want to display to
+ // the end user.
+ return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
+ network.Enable(),
+ fetch.Enable(),
+ runtime.Enable(),
+ clearCacheActionFunc(logger, b.arguments.clearCache),
+ clearCookiesActionFunc(logger, b.arguments.clearCookies),
+ disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
+ extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
+ navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
+ hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
+ forceExactColorsActionFunc(),
+ emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
+ waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
+ waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
+ // PDF specific.
+ printToPdfActionFunc(logger, outputPath, options),
+ })
+}
+
+func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
+ // Note: no error wrapping because it leaks on errors we want to display to
+ // the end user.
+ return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
+ network.Enable(),
+ fetch.Enable(),
+ runtime.Enable(),
+ clearCacheActionFunc(logger, b.arguments.clearCache),
+ clearCookiesActionFunc(logger, b.arguments.clearCookies),
+ disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
+ extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
+ navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
+ hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
+ forceExactColorsActionFunc(),
+ emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
+ waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
+ waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
+ // Screenshot specific.
+ captureScreenshotActionFunc(logger, outputPath, options),
+ })
+}
+
+func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string, options Options, tasks chromedp.Tasks) error {
if !b.isStarted.Load() {
- return errors.New("browser not started, cannot handle PDF conversion")
+ return errors.New("browser not started, cannot handle tasks")
}
// We validate the "main" URL against our allow / deny lists.
@@ -265,23 +310,6 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
listenForEventExceptionThrown(taskCtx, logger, &consoleExceptions, &consoleExceptionsMu)
}
- tasks := chromedp.Tasks{
- network.Enable(),
- fetch.Enable(),
- runtime.Enable(),
- clearCacheActionFunc(logger, b.arguments.clearCache),
- clearCookiesActionFunc(logger, b.arguments.clearCookies),
- disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
- extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
- navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
- hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
- forceExactColorsActionFunc(),
- emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
- waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
- waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
- printToPdfActionFunc(logger, outputPath, options),
- }
-
err := chromedp.Run(taskCtx, tasks...)
if err != nil {
errMessage := err.Error()
@@ -298,7 +326,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
return ErrRpccMessageTooLarge
}
- return fmt.Errorf("print to PDF: %w", err)
+ return fmt.Errorf("handle tasks: %w", err)
}
// See https://github.com/gotenberg/gotenberg/issues/613.
diff --git a/pkg/modules/chromium/browser_test.go b/pkg/modules/chromium/browser_test.go
index 9c782e14..59b6ce50 100644
--- a/pkg/modules/chromium/browser_test.go
+++ b/pkg/modules/chromium/browser_test.go
@@ -248,7 +248,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
scenario string
browser browser
fs *gotenberg.FileSystem
- options Options
+ options PdfOptions
noDeadline bool
start bool
expectError bool
@@ -405,8 +405,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs
}(),
- options: Options{
- SkipNetworkIdleEvent: true,
+ options: PdfOptions{
+ Options: Options{SkipNetworkIdleEvent: true},
},
noDeadline: false,
start: true,
@@ -440,8 +440,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs
}(),
- options: Options{
- FailOnHttpStatusCodes: []int64{299},
+ options: PdfOptions{
+ Options: Options{FailOnHttpStatusCodes: []int64{299}},
},
noDeadline: false,
start: true,
@@ -473,8 +473,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs
}(),
- options: Options{
- FailOnConsoleExceptions: true,
+ options: PdfOptions{
+ Options: Options{FailOnConsoleExceptions: true},
},
noDeadline: false,
start: true,
@@ -607,10 +607,10 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs
}(),
- options: Options{
- ExtraHttpHeaders: map[string]string{
+ options: PdfOptions{
+ Options: Options{ExtraHttpHeaders: map[string]string{
"X-Foo": "Bar",
- },
+ }},
},
noDeadline: false,
start: true,
@@ -644,8 +644,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs
}(),
- options: Options{
- OmitBackground: true,
+ options: PdfOptions{
+ Options: Options{OmitBackground: true},
},
noDeadline: false,
start: true,
@@ -677,8 +677,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs
}(),
- options: Options{
- OmitBackground: true,
+ options: PdfOptions{
+ Options: Options{OmitBackground: true},
PrintBackground: true,
},
noDeadline: false,
@@ -713,8 +713,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs
}(),
- options: Options{
- EmulatedMediaType: "foo",
+ options: PdfOptions{
+ Options: Options{EmulatedMediaType: "foo"},
},
noDeadline: false,
start: true,
@@ -746,8 +746,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs
}(),
- options: Options{
- EmulatedMediaType: "screen",
+ options: PdfOptions{
+ Options: Options{EmulatedMediaType: "screen"},
},
noDeadline: false,
start: true,
@@ -781,8 +781,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs
}(),
- options: Options{
- WaitDelay: time.Duration(10) * time.Second,
+ options: PdfOptions{
+ Options: Options{WaitDelay: time.Duration(10) * time.Second},
},
noDeadline: false,
start: true,
@@ -816,8 +816,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs
}(),
- options: Options{
- WaitDelay: time.Duration(1) * time.Millisecond,
+ options: PdfOptions{
+ Options: Options{WaitDelay: time.Duration(1) * time.Millisecond},
},
noDeadline: false,
start: true,
@@ -862,8 +862,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs
}(),
- options: Options{
- WaitForExpression: "window.status === 'ready'",
+ options: PdfOptions{
+ Options: Options{WaitForExpression: "window.status === 'ready'"},
},
noDeadline: false,
start: true,
@@ -897,8 +897,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs
}(),
- options: Options{
- WaitForExpression: "return undefined",
+ options: PdfOptions{
+ Options: Options{WaitForExpression: "return undefined"},
},
noDeadline: false,
start: true,
@@ -943,8 +943,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs
}(),
- options: Options{
- WaitForExpression: "window.globalVar === 'ready'",
+ options: PdfOptions{
+ Options: Options{WaitForExpression: "window.globalVar === 'ready'"},
},
noDeadline: false,
start: true,
@@ -978,7 +978,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs
}(),
- options: Options{
+ options: PdfOptions{
HeaderTemplate: "
Header
",
FooterTemplate: "Footer
",
},
@@ -1014,7 +1014,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs
}(),
- options: Options{
+ options: PdfOptions{
PaperWidth: 0,
PaperHeight: 0,
MarginTop: 1000000,
@@ -1052,7 +1052,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs
}(),
- options: Options{
+ options: PdfOptions{
PageRanges: "foo",
},
noDeadline: false,
@@ -1085,7 +1085,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs
}(),
- options: DefaultOptions(),
+ options: DefaultPdfOptions(),
noDeadline: false,
start: true,
expectError: false,
@@ -1176,3 +1176,840 @@ func TestChromiumBrowser_pdf(t *testing.T) {
})
}
}
+
+func TestChromiumBrowser_screenshot(t *testing.T) {
+ for _, tc := range []struct {
+ scenario string
+ browser browser
+ fs *gotenberg.FileSystem
+ options ScreenshotOptions
+ noDeadline bool
+ start bool
+ expectError bool
+ expectedError error
+ expectedLogEntries []string
+ }{
+ {
+ scenario: "browser not started",
+ browser: func() browser {
+ b := new(chromiumBrowser)
+ b.isStarted.Store(false)
+ return b
+ }(),
+ fs: gotenberg.NewFileSystem(),
+ noDeadline: false,
+ start: false,
+ expectError: true,
+ },
+ {
+ scenario: "ErrUrlNotAuthorized: main URL does not match the allowed list",
+ browser: func() browser {
+ b := new(chromiumBrowser)
+ b.arguments = browserArguments{
+ allowList: regexp.MustCompile("^file:///[^tmp].*"),
+ }
+ b.isStarted.Store(true)
+ return b
+ }(),
+ fs: gotenberg.NewFileSystem(),
+ noDeadline: false,
+ start: false,
+ expectError: true,
+ expectedError: ErrUrlNotAuthorized,
+ },
+ {
+ scenario: "ErrUrlNotAuthorized: main URL does match the denied list",
+ browser: func() browser {
+ b := new(chromiumBrowser)
+ b.arguments = browserArguments{
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile("^file:///tmp.*"),
+ }
+ b.isStarted.Store(true)
+ return b
+ }(),
+ fs: gotenberg.NewFileSystem(),
+ noDeadline: false,
+ start: false,
+ expectError: true,
+ expectedError: ErrUrlNotAuthorized,
+ },
+ {
+ scenario: "ErrUrlNotAuthorized: main URL does match the denied list",
+ browser: func() browser {
+ b := new(chromiumBrowser)
+ b.arguments = browserArguments{
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ }
+ b.isStarted.Store(true)
+ return b
+ }(),
+ fs: gotenberg.NewFileSystem(),
+ noDeadline: true,
+ start: false,
+ expectError: true,
+ },
+ {
+ scenario: "a request does not match the allowed list",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile("^file:///tmp.*"),
+ denyList: regexp.MustCompile(""),
+ },
+ ),
+ 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/index.html", fs.WorkingDirPath()), []byte(""), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ noDeadline: false,
+ start: true,
+ expectError: false,
+ expectedLogEntries: []string{
+ "'file:///etc/passwd' does not match the expression from the allowed list",
+ },
+ },
+ {
+ scenario: "a request does match the denied list",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile("^file:///[^tmp].*"),
+ },
+ ),
+ 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/index.html", fs.WorkingDirPath()), []byte(""), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ noDeadline: false,
+ start: true,
+ expectError: false,
+ expectedLogEntries: []string{
+ "'file:///etc/passwd' matches the expression from the denied list",
+ },
+ },
+ {
+ scenario: "skip networkIdle event",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ },
+ ),
+ 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/index.html", fs.WorkingDirPath()), []byte("Skip networkIdle event
"), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ options: ScreenshotOptions{
+ Options: Options{SkipNetworkIdleEvent: true},
+ },
+ noDeadline: false,
+ start: true,
+ expectError: false,
+ expectedLogEntries: []string{
+ "skipping network idle event",
+ },
+ },
+ {
+ scenario: "ErrInvalidHttpStatusCode",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ },
+ ),
+ 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/index.html", fs.WorkingDirPath()), []byte("ErrInvalidHttpStatusCode
"), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ options: ScreenshotOptions{
+ Options: Options{FailOnHttpStatusCodes: []int64{299}},
+ },
+ noDeadline: false,
+ start: true,
+ expectError: true,
+ expectedError: ErrInvalidHttpStatusCode,
+ },
+ {
+ scenario: "ErrConsoleExceptions",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ },
+ ),
+ 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/index.html", fs.WorkingDirPath()), []byte(""), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ options: ScreenshotOptions{
+ Options: Options{FailOnConsoleExceptions: true},
+ },
+ noDeadline: false,
+ start: true,
+ expectError: true,
+ expectedError: ErrConsoleExceptions,
+ },
+ {
+ scenario: "clear cache",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ clearCache: true,
+ },
+ ),
+ 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/index.html", fs.WorkingDirPath()), []byte("Clear cache
"), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ noDeadline: false,
+ start: true,
+ expectError: false,
+ expectedLogEntries: []string{
+ "clear cache",
+ },
+ },
+ {
+ scenario: "clear cookies",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ clearCookies: true,
+ },
+ ),
+ 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/index.html", fs.WorkingDirPath()), []byte("Clear cookies
"), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ noDeadline: false,
+ start: true,
+ expectError: false,
+ expectedLogEntries: []string{
+ "clear cookies",
+ },
+ },
+ {
+ scenario: "disable JavaScript",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ disableJavaScript: true,
+ },
+ ),
+ 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/index.html", fs.WorkingDirPath()), []byte(""), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ noDeadline: false,
+ start: true,
+ expectError: false,
+ expectedLogEntries: []string{
+ "disable JavaScript",
+ "JavaScript disabled, skipping wait delay",
+ "JavaScript disabled, skipping wait expression",
+ },
+ },
+ {
+ scenario: "extra HTTP headers",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ },
+ ),
+ 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/index.html", fs.WorkingDirPath()), []byte("Extra HTTP headers
"), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ options: ScreenshotOptions{
+ Options: Options{ExtraHttpHeaders: map[string]string{
+ "X-Foo": "Bar",
+ }},
+ },
+ noDeadline: false,
+ start: true,
+ expectError: false,
+ expectedLogEntries: []string{
+ "extra HTTP headers:",
+ },
+ },
+ {
+ scenario: "ErrOmitBackgroundWithoutPrintBackground",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ },
+ ),
+ 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/index.html", fs.WorkingDirPath()), []byte("ErrOmitBackgroundWithoutPrintBackground
"), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ options: ScreenshotOptions{
+ Options: Options{OmitBackground: true},
+ },
+ noDeadline: false,
+ start: true,
+ expectError: false,
+ expectedLogEntries: []string{
+ "hide default white background",
+ },
+ },
+ {
+ scenario: "ErrInvalidEmulatedMediaType",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ },
+ ),
+ 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/index.html", fs.WorkingDirPath()), []byte("ErrInvalidEmulatedMediaType
"), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ options: ScreenshotOptions{
+ Options: Options{EmulatedMediaType: "foo"},
+ },
+ noDeadline: false,
+ start: true,
+ expectError: true,
+ expectedError: ErrInvalidEmulatedMediaType,
+ },
+ {
+ scenario: "emulate a media type",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ },
+ ),
+ 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/index.html", fs.WorkingDirPath()), []byte("Screen media type
"), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ options: ScreenshotOptions{
+ Options: Options{EmulatedMediaType: "screen"},
+ },
+ noDeadline: false,
+ start: true,
+ expectError: false,
+ expectedLogEntries: []string{
+ "emulate media type 'screen'",
+ },
+ },
+ {
+ scenario: "wait delay: context done",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ },
+ ),
+ 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/index.html", fs.WorkingDirPath()), []byte(""), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ options: ScreenshotOptions{
+ Options: Options{WaitDelay: time.Duration(10) * time.Second},
+ },
+ noDeadline: false,
+ start: true,
+ expectError: true,
+ expectedLogEntries: []string{
+ "wait '10s' before print",
+ },
+ },
+ {
+ scenario: "wait delay",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ },
+ ),
+ 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/index.html", fs.WorkingDirPath()), []byte(""), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ options: ScreenshotOptions{
+ Options: Options{WaitDelay: time.Duration(1) * time.Millisecond},
+ },
+ noDeadline: false,
+ start: true,
+ expectError: false,
+ expectedLogEntries: []string{
+ "wait '1ms' before print",
+ },
+ },
+ {
+ scenario: "wait for expression: context done",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ },
+ ),
+ 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))
+ }
+
+ html := `
+
+`
+
+ err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(html), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ options: ScreenshotOptions{
+ Options: Options{WaitForExpression: "window.status === 'ready'"},
+ },
+ noDeadline: false,
+ start: true,
+ expectError: true,
+ expectedLogEntries: []string{
+ "wait until 'window.status === 'ready'' is true before print",
+ },
+ },
+ {
+ scenario: "ErrInvalidEvaluationExpression",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ },
+ ),
+ 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/index.html", fs.WorkingDirPath()), []byte("ErrInvalidEvaluationExpression
"), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ options: ScreenshotOptions{
+ Options: Options{WaitForExpression: "return undefined"},
+ },
+ noDeadline: false,
+ start: true,
+ expectError: true,
+ expectedLogEntries: []string{
+ "wait until 'return undefined' is true before print",
+ },
+ },
+ {
+ scenario: "wait for expression",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ },
+ ),
+ 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))
+ }
+
+ html := `
+
+`
+
+ err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(html), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ options: ScreenshotOptions{
+ Options: Options{WaitForExpression: "window.globalVar === 'ready'"},
+ },
+ noDeadline: false,
+ start: true,
+ expectError: false,
+ expectedLogEntries: []string{
+ "wait until 'window.globalVar === 'ready'' is true before print",
+ },
+ },
+ {
+ scenario: "success (default options)",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ },
+ ),
+ 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/index.html", fs.WorkingDirPath()), []byte("Default options
"), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ options: DefaultScreenshotOptions(),
+ noDeadline: false,
+ start: true,
+ expectError: false,
+ expectedLogEntries: []string{
+ "cache not cleared",
+ "cookies not cleared",
+ "JavaScript not disabled",
+ "no extra HTTP headers",
+ "navigate to",
+ "default white background not hidden",
+ "no emulated media type",
+ "no wait delay",
+ "no wait expression",
+ },
+ },
+ {
+ scenario: "success (jpeg)",
+ browser: newChromiumBrowser(
+ browserArguments{
+ binPath: os.Getenv("CHROMIUM_BIN_PATH"),
+ wsUrlReadTimeout: 5 * time.Second,
+ allowList: regexp.MustCompile(""),
+ denyList: regexp.MustCompile(""),
+ },
+ ),
+ 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/index.html", fs.WorkingDirPath()), []byte("Default options
"), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return fs
+ }(),
+ options: func() ScreenshotOptions {
+ options := DefaultScreenshotOptions()
+ options.Format = "jpeg"
+ return options
+ }(),
+ noDeadline: false,
+ start: true,
+ expectError: false,
+ expectedLogEntries: []string{
+ "cache not cleared",
+ "cookies not cleared",
+ "JavaScript not disabled",
+ "no extra HTTP headers",
+ "navigate to",
+ "default white background not hidden",
+ "no emulated media type",
+ "no wait delay",
+ "no wait expression",
+ },
+ },
+ } {
+ t.Run(tc.scenario, func(t *testing.T) {
+ core, recorded := observer.New(zapcore.DebugLevel)
+ logger := zap.New(core)
+
+ 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.browser.Start(logger)
+ if err != nil {
+ t.Fatalf("setup error: %v", err)
+ }
+
+ defer func(b browser, logger *zap.Logger) {
+ err = b.Stop(logger)
+ if err != nil {
+ t.Fatalf("expected no error while cleaning up, but got: %v", err)
+ }
+ }(tc.browser, logger)
+ }
+
+ var (
+ ctx context.Context
+ cancel context.CancelFunc
+ )
+
+ if tc.noDeadline {
+ ctx = context.Background()
+ } else {
+ ctx, cancel = context.WithTimeout(context.Background(), time.Duration(5)*time.Second)
+ defer cancel()
+ }
+
+ err := tc.browser.screenshot(
+ ctx,
+ logger,
+ fmt.Sprintf("file://%s/index.html", 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)
+ }
+
+ for _, entry := range tc.expectedLogEntries {
+ doExist := true
+ for _, log := range recorded.All() {
+ doExist = strings.Contains(log.Message, entry)
+ if doExist {
+ break
+ }
+ }
+
+ if !doExist {
+ t.Errorf("expected '%s' to exist as log entry", entry)
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go
index 2ea1e6a2..202bcadc 100644
--- a/pkg/modules/chromium/chromium.go
+++ b/pkg/modules/chromium/chromium.go
@@ -24,10 +24,6 @@ var (
// allowed/denied lists.
ErrUrlNotAuthorized = errors.New("URL not authorized")
- // ErrOmitBackgroundWithoutPrintBackground happens if
- // Options.OmitBackground is set to true but not Options.PrintBackground.
- ErrOmitBackgroundWithoutPrintBackground = errors.New("omit background without print background")
-
// ErrInvalidEmulatedMediaType happens if the emulated media type is not
// "screen" nor "print". Empty value are allowed though.
ErrInvalidEmulatedMediaType = errors.New("invalid emulated media type")
@@ -36,14 +32,6 @@ var (
// returns an exception or undefined.
ErrInvalidEvaluationExpression = errors.New("invalid evaluation expression")
- // ErrInvalidPrinterSettings happens if the Options have one or more
- // aberrant values.
- ErrInvalidPrinterSettings = errors.New("invalid printer settings")
-
- // ErrPageRangesSyntaxError happens if the Options have an invalid page
- // ranges.
- ErrPageRangesSyntaxError = errors.New("page ranges syntax error")
-
// ErrRpccMessageTooLarge happens when the messages received by
// ChromeDevTools are larger than 100 MB.
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
@@ -56,6 +44,20 @@ var (
// console. It also happens only if the [Options.FailOnConsoleExceptions]
// is set to true.
ErrConsoleExceptions = errors.New("console exceptions")
+
+ // PDF specific.
+
+ // ErrOmitBackgroundWithoutPrintBackground happens if
+ // PdfOptions.OmitBackground is set to true but not PdfOptions.PrintBackground.
+ ErrOmitBackgroundWithoutPrintBackground = errors.New("omit background without print background")
+
+ // ErrInvalidPrinterSettings happens if the PdfOptions have one or more
+ // aberrant values.
+ ErrInvalidPrinterSettings = errors.New("invalid printer settings")
+
+ // ErrPageRangesSyntaxError happens if the PdfOptions have an invalid page
+ // ranges.
+ ErrPageRangesSyntaxError = errors.New("page ranges syntax error")
)
// Chromium is a module which provides both an [Api] and routes for converting
@@ -71,7 +73,7 @@ type Chromium struct {
engine gotenberg.PdfEngine
}
-// Options are the available expectedOptions for converting HTML document to PDF.
+// Options are the common options for all conversions.
type Options struct {
// SkipNetworkIdleEvent set if the conversion should wait for the
// "networkIdle" event, drastically improving the conversion speed. It may
@@ -91,17 +93,17 @@ type Options struct {
FailOnConsoleExceptions bool
// WaitDelay is the duration to wait when loading an HTML document before
- // converting it to PDF.
+ // converting it.
// Optional.
WaitDelay time.Duration
// WaitWindowStatus is the window.status value to wait for before
- // converting an HTML document to PDF.
+ // converting an HTML document.
// Optional.
WaitWindowStatus string
// WaitForExpression is the custom JavaScript expression to wait before
- // converting an HTML document to PDF until it returns true
+ // converting an HTML document until it returns true
// Optional.
WaitForExpression string
@@ -115,6 +117,31 @@ type Options struct {
// Optional.
EmulatedMediaType string
+ // OmitBackground hides default white background and allows generating PDFs
+ // with transparency.
+ // Optional.
+ OmitBackground bool
+}
+
+// DefaultOptions returns the default values for Options.
+func DefaultOptions() Options {
+ return Options{
+ SkipNetworkIdleEvent: false,
+ FailOnHttpStatusCodes: []int64{499, 599},
+ FailOnConsoleExceptions: false,
+ WaitDelay: 0,
+ WaitWindowStatus: "",
+ WaitForExpression: "",
+ ExtraHttpHeaders: nil,
+ EmulatedMediaType: "",
+ OmitBackground: false,
+ }
+}
+
+// PdfOptions are the available options for converting an HTML document to PDF.
+type PdfOptions struct {
+ Options
+
// Landscape sets the paper orientation.
// Optional.
Landscape bool
@@ -123,11 +150,6 @@ type Options struct {
// Optional.
PrintBackground bool
- // OmitBackground hides default white background and allows generating PDFs
- // with transparency.
- // Optional.
- OmitBackground bool
-
// Scale is the scale of the page rendering.
// Optional.
Scale float64
@@ -184,37 +206,60 @@ type Options struct {
PreferCssPageSize bool
}
-// DefaultOptions returns the default values for Options.
-func DefaultOptions() Options {
- return Options{
- SkipNetworkIdleEvent: false,
- FailOnHttpStatusCodes: []int64{499, 599},
- FailOnConsoleExceptions: false,
- WaitDelay: 0,
- WaitWindowStatus: "",
- WaitForExpression: "",
- ExtraHttpHeaders: nil,
- EmulatedMediaType: "",
- Landscape: false,
- PrintBackground: false,
- OmitBackground: false,
- Scale: 1.0,
- PaperWidth: 8.5,
- PaperHeight: 11,
- MarginTop: 0.39,
- MarginBottom: 0.39,
- MarginLeft: 0.39,
- MarginRight: 0.39,
- PageRanges: "",
- HeaderTemplate: "",
- FooterTemplate: "",
- PreferCssPageSize: false,
+// DefaultPdfOptions returns the default values for PdfOptions.
+func DefaultPdfOptions() PdfOptions {
+ return PdfOptions{
+ Options: DefaultOptions(),
+ Landscape: false,
+ PrintBackground: false,
+ Scale: 1.0,
+ PaperWidth: 8.5,
+ PaperHeight: 11,
+ MarginTop: 0.39,
+ MarginBottom: 0.39,
+ MarginLeft: 0.39,
+ MarginRight: 0.39,
+ PageRanges: "",
+ HeaderTemplate: "",
+ FooterTemplate: "",
+ PreferCssPageSize: false,
+ }
+}
+
+// ScreenshotOptions are the available options for capturing a screenshot from
+// an HTML document.
+type ScreenshotOptions struct {
+ Options
+
+ // Format is the image compression format, either "png" or "jpeg" or
+ // "webp".
+ // Optional.
+ Format string
+
+ // Quality is the compression quality from range [0..100] (jpeg only).
+ // Optional.
+ Quality int
+
+ // OptimizeForSpeed defines whether to optimize image encoding for speed,
+ // not for resulting size.
+ // Optional.
+ OptimizeForSpeed bool
+}
+
+// DefaultScreenshotOptions returns the default values for ScreenshotOptions.
+func DefaultScreenshotOptions() ScreenshotOptions {
+ return ScreenshotOptions{
+ Options: DefaultOptions(),
+ Format: "png",
+ Quality: 100,
+ OptimizeForSpeed: false,
}
}
// Api helps to interact with Chromium for converting HTML documents to PDF.
type Api interface {
- Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error
+ Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
+ Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
}
// Provider is a module interface which exposes a method for creating an [Api]
@@ -443,19 +488,31 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
return []api.Route{
convertUrlRoute(mod, mod.engine),
+ screenshotUrlRoute(mod),
convertHtmlRoute(mod, mod.engine),
+ screenshotHtmlRoute(mod),
convertMarkdownRoute(mod, mod.engine),
+ screenshotMarkdownRoute(mod),
}, nil
}
// Pdf converts a URL to PDF.
-func (mod *Chromium) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
- // Note: no error wrapping because it leaks on console exceptions output.
+func (mod *Chromium) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
+ // Note: no error wrapping because it leaks on errors we want to display to
+ // the end user.
return mod.supervisor.Run(ctx, logger, func() error {
return mod.browser.pdf(ctx, logger, url, outputPath, options)
})
}
+func (mod *Chromium) Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
+ // Note: no error wrapping because it leaks on errors we want to display to
+ // the end user.
+ return mod.supervisor.Run(ctx, logger, func() error {
+ return mod.browser.screenshot(ctx, logger, url, outputPath, options)
+ })
+}
+
// Interface guards.
var (
_ gotenberg.Module = (*Chromium)(nil)
diff --git a/pkg/modules/chromium/chromium_test.go b/pkg/modules/chromium/chromium_test.go
index b947df78..840781e5 100644
--- a/pkg/modules/chromium/chromium_test.go
+++ b/pkg/modules/chromium/chromium_test.go
@@ -15,8 +15,8 @@ import (
)
func TestDefaultOptions(t *testing.T) {
- actual := DefaultOptions()
- notExpect := Options{}
+ actual := DefaultPdfOptions()
+ notExpect := PdfOptions{}
if reflect.DeepEqual(actual, notExpect) {
t.Errorf("expected %v and got identical %v", actual, notExpect)
@@ -461,7 +461,7 @@ func TestChromium_Routes(t *testing.T) {
}{
{
scenario: "routes not disabled",
- expectRoutes: 3,
+ expectRoutes: 6,
disableRoutes: false,
},
{
@@ -495,14 +495,14 @@ func TestChromium_Pdf(t *testing.T) {
}{
{
scenario: "PDF task success",
- browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
}},
expectError: false,
},
{
scenario: "PDF task error",
- browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return errors.New("PDF task error")
}},
expectError: true,
@@ -515,7 +515,49 @@ func TestChromium_Pdf(t *testing.T) {
}}
mod.browser = tc.browser
- err := mod.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
+ err := mod.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
+
+ 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 TestChromium_Screenshot(t *testing.T) {
+ for _, tc := range []struct {
+ scenario string
+ supervisor gotenberg.ProcessSupervisor
+ browser browser
+ expectError bool
+ }{
+ {
+ scenario: "Screenshot task success",
+ browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
+ return nil
+ }},
+ expectError: false,
+ },
+ {
+ scenario: "Screenshot task error",
+ browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
+ return errors.New("screenshot task error")
+ }},
+ expectError: true,
+ },
+ } {
+ t.Run(tc.scenario, func(t *testing.T) {
+ mod := new(Chromium)
+ mod.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
+ return task()
+ }}
+ mod.browser = tc.browser
+
+ err := mod.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
diff --git a/pkg/modules/chromium/mocks.go b/pkg/modules/chromium/mocks.go
index 1f1c15f7..9effed77 100644
--- a/pkg/modules/chromium/mocks.go
+++ b/pkg/modules/chromium/mocks.go
@@ -10,23 +10,33 @@ import (
// ApiMock is a mock for the [Api] interface.
type ApiMock struct {
- PdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error
+ PdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
+ ScreenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
}
-func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return api.PdfMock(ctx, logger, url, outputPath, options)
}
+func (api *ApiMock) Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
+ return api.ScreenshotMock(ctx, logger, url, outputPath, options)
+}
+
// browserMock is a mock for the [browser] interface.
type browserMock struct {
gotenberg.ProcessMock
- pdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error
+ pdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
+ screenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
}
-func (b *browserMock) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+func (b *browserMock) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return b.pdfMock(ctx, logger, url, outputPath, options)
}
+func (b *browserMock) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
+ return b.screenshotMock(ctx, logger, url, outputPath, options)
+}
+
// Interface guards.
var (
_ Api = (*ApiMock)(nil)
diff --git a/pkg/modules/chromium/mocks_test.go b/pkg/modules/chromium/mocks_test.go
index 2fe73a2d..1f361a8a 100644
--- a/pkg/modules/chromium/mocks_test.go
+++ b/pkg/modules/chromium/mocks_test.go
@@ -9,26 +9,42 @@ import (
func TestApiMock(t *testing.T) {
mock := &ApiMock{
- PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
+ return nil
+ },
+ ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return nil
},
}
- err := mock.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
+ err := mock.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
if err != nil {
t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err)
}
+
+ err = mock.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
+ if err != nil {
+ t.Errorf("expected no error from ApiMock.Screenshot, but got: %v", err)
+ }
}
func TestBrowserMock(t *testing.T) {
mock := &browserMock{
- pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
+ return nil
+ },
+ screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return nil
},
}
- err := mock.pdf(context.Background(), zap.NewNop(), "", "", Options{})
+ err := mock.pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
if err != nil {
t.Errorf("expected no error from browserMock.pdf, but got: %v", err)
}
+
+ err = mock.screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
+ if err != nil {
+ t.Errorf("expected no error from browserMock.screenshot, but got: %v", err)
+ }
}
diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go
index 3d4d6300..bb5b38c5 100644
--- a/pkg/modules/chromium/routes.go
+++ b/pkg/modules/chromium/routes.go
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "strconv"
"strings"
"time"
@@ -21,26 +22,21 @@ import (
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
)
-// FormDataChromiumPdfOptions creates [Options] from the form data. Fallback to
+// FormDataChromiumOptions creates [Options] from the form data. Fallback to
// default value if the considered key is not present.
-func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, Options) {
+func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
defaultOptions := DefaultOptions()
var (
- skipNetworkIdleEvent bool
- failOnHttpStatusCodes []int64
- failOnConsoleExceptions bool
- waitDelay time.Duration
- waitWindowStatus string
- waitForExpression string
- extraHttpHeaders map[string]string
- emulatedMediaType string
- landscape, printBackground, omitBackground bool
- scale, paperWidth, paperHeight float64
- marginTop, marginBottom, marginLeft, marginRight float64
- pageRanges string
- headerTemplate, footerTemplate string
- preferCssPageSize bool
+ skipNetworkIdleEvent bool
+ failOnHttpStatusCodes []int64
+ failOnConsoleExceptions bool
+ waitDelay time.Duration
+ waitWindowStatus string
+ waitForExpression string
+ extraHttpHeaders map[string]string
+ emulatedMediaType string
+ omitBackground bool
)
form := ctx.FormData().
@@ -89,20 +85,7 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, Options) {
return nil
}).
- Bool("landscape", &landscape, defaultOptions.Landscape).
- Bool("printBackground", &printBackground, defaultOptions.PrintBackground).
- Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground).
- Float64("scale", &scale, defaultOptions.Scale).
- Float64("paperWidth", &paperWidth, defaultOptions.PaperWidth).
- Float64("paperHeight", &paperHeight, defaultOptions.PaperHeight).
- Float64("marginTop", &marginTop, defaultOptions.MarginTop).
- Float64("marginBottom", &marginBottom, defaultOptions.MarginBottom).
- Float64("marginLeft", &marginLeft, defaultOptions.MarginLeft).
- Float64("marginRight", &marginRight, defaultOptions.MarginRight).
- String("nativePageRanges", &pageRanges, defaultOptions.PageRanges).
- Content("header.html", &headerTemplate, defaultOptions.HeaderTemplate).
- Content("footer.html", &footerTemplate, defaultOptions.FooterTemplate).
- Bool("preferCssPageSize", &preferCssPageSize, defaultOptions.PreferCssPageSize)
+ Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
options := Options{
SkipNetworkIdleEvent: skipNetworkIdleEvent,
@@ -113,34 +96,132 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, Options) {
WaitForExpression: waitForExpression,
ExtraHttpHeaders: extraHttpHeaders,
EmulatedMediaType: emulatedMediaType,
- Landscape: landscape,
- PrintBackground: printBackground,
OmitBackground: omitBackground,
- Scale: scale,
- PaperWidth: paperWidth,
- PaperHeight: paperHeight,
- MarginTop: marginTop,
- MarginBottom: marginBottom,
- MarginLeft: marginLeft,
- MarginRight: marginRight,
- PageRanges: pageRanges,
- HeaderTemplate: headerTemplate,
- FooterTemplate: footerTemplate,
- PreferCssPageSize: preferCssPageSize,
}
return form, options
}
+// FormDataChromiumPdfOptions creates [PdfOptions] from the form data. Fallback to
+// default value if the considered key is not present.
+func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) {
+ form, options := FormDataChromiumOptions(ctx)
+ defaultPdfOptions := DefaultPdfOptions()
+
+ var (
+ landscape, printBackground bool
+ scale, paperWidth, paperHeight float64
+ marginTop, marginBottom, marginLeft, marginRight float64
+ pageRanges string
+ headerTemplate, footerTemplate string
+ preferCssPageSize bool
+ )
+
+ form.
+ Bool("landscape", &landscape, defaultPdfOptions.Landscape).
+ Bool("printBackground", &printBackground, defaultPdfOptions.PrintBackground).
+ Float64("scale", &scale, defaultPdfOptions.Scale).
+ Float64("paperWidth", &paperWidth, defaultPdfOptions.PaperWidth).
+ Float64("paperHeight", &paperHeight, defaultPdfOptions.PaperHeight).
+ Float64("marginTop", &marginTop, defaultPdfOptions.MarginTop).
+ Float64("marginBottom", &marginBottom, defaultPdfOptions.MarginBottom).
+ Float64("marginLeft", &marginLeft, defaultPdfOptions.MarginLeft).
+ Float64("marginRight", &marginRight, defaultPdfOptions.MarginRight).
+ String("nativePageRanges", &pageRanges, defaultPdfOptions.PageRanges).
+ Content("header.html", &headerTemplate, defaultPdfOptions.HeaderTemplate).
+ Content("footer.html", &footerTemplate, defaultPdfOptions.FooterTemplate).
+ Bool("preferCssPageSize", &preferCssPageSize, defaultPdfOptions.PreferCssPageSize)
+
+ pdfOptions := PdfOptions{
+ Options: options,
+ Landscape: landscape,
+ PrintBackground: printBackground,
+ Scale: scale,
+ PaperWidth: paperWidth,
+ PaperHeight: paperHeight,
+ MarginTop: marginTop,
+ MarginBottom: marginBottom,
+ MarginLeft: marginLeft,
+ MarginRight: marginRight,
+ PageRanges: pageRanges,
+ HeaderTemplate: headerTemplate,
+ FooterTemplate: footerTemplate,
+ PreferCssPageSize: preferCssPageSize,
+ }
+
+ return form, pdfOptions
+}
+
+// FormDataChromiumScreenshotOptions creates [ScreenshotOptions] from the form
+// data. Fallback to default value if the considered key is not present.
+func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, ScreenshotOptions) {
+ form, options := FormDataChromiumOptions(ctx)
+ defaultScreenshotOptions := DefaultScreenshotOptions()
+
+ var (
+ format string
+ quality int
+ optimizeForSpeed bool
+ )
+
+ form.
+ Custom("format", func(value string) error {
+ if value == "" {
+ format = defaultScreenshotOptions.Format
+ return nil
+ }
+
+ if value != "png" && value != "jpeg" && value != "" {
+ return fmt.Errorf("wrong value, expected either 'png', 'jpeg' or 'webp'")
+ }
+
+ format = value
+
+ return nil
+ }).
+ Custom("quality", func(value string) error {
+ if value == "" {
+ quality = defaultScreenshotOptions.Quality
+ return nil
+ }
+
+ intValue, err := strconv.Atoi(value)
+ if err != nil {
+ return err
+ }
+
+ if intValue < 0 {
+ return errors.New("value is negative")
+ }
+
+ if intValue > 100 {
+ return errors.New("value is superior to 100")
+ }
+
+ quality = intValue
+ return nil
+ }).
+ Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed)
+
+ screenshotOptions := ScreenshotOptions{
+ Options: options,
+ Format: format,
+ Quality: quality,
+ OptimizeForSpeed: optimizeForSpeed,
+ }
+
+ return form, screenshotOptions
+}
+
// FormDataChromiumPdfFormats creates [gotenberg.PdfFormats] from the form
// data. Fallback to default value if the considered key is not present.
-func FormDataChromiumPdfFormats(ctx *api.Context) gotenberg.PdfFormats {
+func FormDataChromiumPdfFormats(form *api.FormData) gotenberg.PdfFormats {
var (
pdfa string
pdfua bool
)
- ctx.FormData().
+ form.
String("pdfa", &pdfa, "").
Bool("pdfua", &pdfua, false)
@@ -159,7 +240,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPdfOptions(ctx)
- pdfFormats := FormDataChromiumPdfFormats(ctx)
+ pdfFormats := FormDataChromiumPdfFormats(form)
var url string
err := form.
@@ -179,6 +260,35 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
}
}
+// screenshotUrlRoute returns an [api.Route] which can take a screenshot from a
+// URL.
+func screenshotUrlRoute(chromium Api) api.Route {
+ return api.Route{
+ Method: http.MethodPost,
+ Path: "/forms/chromium/screenshot/url",
+ IsMultipart: true,
+ Handler: func(c echo.Context) error {
+ ctx := c.Get("context").(*api.Context)
+ form, options := FormDataChromiumScreenshotOptions(ctx)
+
+ var url string
+ err := form.
+ MandatoryString("url", &url).
+ Validate()
+ if err != nil {
+ return fmt.Errorf("validate form data: %w", err)
+ }
+
+ err = screenshotUrl(ctx, chromium, url, options)
+ if err != nil {
+ return fmt.Errorf("URL screenshot: %w", err)
+ }
+
+ return nil
+ },
+ }
+}
+
// convertHtmlRoute returns an [api.Route] which can convert an HTML file to
// PDF.
func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
@@ -189,7 +299,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPdfOptions(ctx)
- pdfFormats := FormDataChromiumPdfFormats(ctx)
+ pdfFormats := FormDataChromiumPdfFormats(form)
var inputPath string
err := form.
@@ -210,6 +320,36 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
}
}
+// screenshotHtmlRoute returns an [api.Route] which can take a screenshot from
+// an HTML file.
+func screenshotHtmlRoute(chromium Api) api.Route {
+ return api.Route{
+ Method: http.MethodPost,
+ Path: "/forms/chromium/screenshot/html",
+ IsMultipart: true,
+ Handler: func(c echo.Context) error {
+ ctx := c.Get("context").(*api.Context)
+ form, options := FormDataChromiumScreenshotOptions(ctx)
+
+ var inputPath string
+ err := form.
+ MandatoryPath("index.html", &inputPath).
+ Validate()
+ if err != nil {
+ return fmt.Errorf("validate form data: %w", err)
+ }
+
+ url := fmt.Sprintf("file://%s", inputPath)
+ err = screenshotUrl(ctx, chromium, url, options)
+ if err != nil {
+ return fmt.Errorf("HTML screenshot: %w", err)
+ }
+
+ return nil
+ },
+ }
+}
+
// convertMarkdownRoute returns an [api.Route] which can convert markdown files
// to PDF.
func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
@@ -220,7 +360,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPdfOptions(ctx)
- pdfFormats := FormDataChromiumPdfFormats(ctx)
+ pdfFormats := FormDataChromiumPdfFormats(form)
var (
inputPath string
@@ -235,79 +375,11 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
- // We have to convert each markdown file referenced in the HTML
- // file to... HTML. Thanks to the "html/template" package, we are
- // able to provide the "toHTML" function which the user may call
- // directly inside the HTML file.
-
- var markdownFilesNotFoundErr error
-
- tmpl, err := template.
- New(filepath.Base(inputPath)).
- Funcs(template.FuncMap{
- "toHTML": func(filename string) (template.HTML, error) {
- var path string
-
- for _, markdownPath := range markdownPaths {
- markdownFilename := filepath.Base(markdownPath)
-
- if filename == markdownFilename {
- path = markdownPath
- break
- }
- }
-
- if path == "" {
- markdownFilesNotFoundErr = multierr.Append(
- markdownFilesNotFoundErr,
- fmt.Errorf("'%s'", filename),
- )
-
- return "", nil
- }
-
- b, err := os.ReadFile(path)
- if err != nil {
- return "", fmt.Errorf("read markdown file '%s': %w", filename, err)
- }
-
- unsafe := blackfriday.Run(b)
- sanitized := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
-
- // #nosec
- return template.HTML(sanitized), nil
- },
- }).ParseFiles(inputPath)
+ url, err := markdownToHtml(ctx, inputPath, markdownPaths)
if err != nil {
- return fmt.Errorf("parse template file: %w", err)
+ return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
}
- var buffer bytes.Buffer
-
- err = tmpl.Execute(&buffer, &struct{}{})
- if err != nil {
- return fmt.Errorf("execute template: %w", err)
- }
-
- if markdownFilesNotFoundErr != nil {
- return api.WrapError(
- fmt.Errorf("markdown files not found: %w", markdownFilesNotFoundErr),
- api.NewSentinelHttpError(
- http.StatusBadRequest,
- fmt.Sprintf("Markdown file(s) not found: %s", markdownFilesNotFoundErr),
- ),
- )
- }
-
- inputPath = ctx.GeneratePath(".html")
-
- err = os.WriteFile(inputPath, buffer.Bytes(), 0o600)
- if err != nil {
- return fmt.Errorf("write template result: %w", err)
- }
-
- url := fmt.Sprintf("file://%s", inputPath)
-
err = convertUrl(ctx, chromium, engine, url, pdfFormats, options)
if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err)
@@ -318,26 +390,129 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
}
}
-// convertUrl is a stub which is called by the other methods of this file.
-func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, pdfFormats gotenberg.PdfFormats, options Options) error {
+// screenshotMarkdownRoute returns an [api.Route] which can take a screenshot
+// from markdown files.
+func screenshotMarkdownRoute(chromium Api) api.Route {
+ return api.Route{
+ Method: http.MethodPost,
+ Path: "/forms/chromium/screenshot/markdown",
+ IsMultipart: true,
+ Handler: func(c echo.Context) error {
+ ctx := c.Get("context").(*api.Context)
+ form, options := FormDataChromiumScreenshotOptions(ctx)
+
+ var (
+ inputPath string
+ markdownPaths []string
+ )
+
+ err := form.
+ MandatoryPath("index.html", &inputPath).
+ MandatoryPaths([]string{".md"}, &markdownPaths).
+ Validate()
+ if err != nil {
+ return fmt.Errorf("validate form data: %w", err)
+ }
+
+ url, err := markdownToHtml(ctx, inputPath, markdownPaths)
+ if err != nil {
+ return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
+ }
+
+ err = screenshotUrl(ctx, chromium, url, options)
+ if err != nil {
+ return fmt.Errorf("markdown screenshot: %w", err)
+ }
+
+ return nil
+ },
+ }
+}
+
+func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string) (string, error) {
+ // We have to convert each markdown file referenced in the HTML
+ // file to... HTML. Thanks to the "html/template" package, we are
+ // able to provide the "toHTML" function which the user may call
+ // directly inside the HTML file.
+
+ var markdownFilesNotFoundErr error
+
+ tmpl, err := template.
+ New(filepath.Base(inputPath)).
+ Funcs(template.FuncMap{
+ "toHTML": func(filename string) (template.HTML, error) {
+ var path string
+
+ for _, markdownPath := range markdownPaths {
+ markdownFilename := filepath.Base(markdownPath)
+
+ if filename == markdownFilename {
+ path = markdownPath
+ break
+ }
+ }
+
+ if path == "" {
+ markdownFilesNotFoundErr = multierr.Append(
+ markdownFilesNotFoundErr,
+ fmt.Errorf("'%s'", filename),
+ )
+
+ return "", nil
+ }
+
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return "", fmt.Errorf("read markdown file '%s': %w", filename, err)
+ }
+
+ unsafe := blackfriday.Run(b)
+ sanitized := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
+
+ // #nosec
+ return template.HTML(sanitized), nil
+ },
+ }).ParseFiles(inputPath)
+ if err != nil {
+ return "", fmt.Errorf("parse template file: %w", err)
+ }
+
+ var buffer bytes.Buffer
+
+ err = tmpl.Execute(&buffer, &struct{}{})
+ if err != nil {
+ return "", fmt.Errorf("execute template: %w", err)
+ }
+
+ if markdownFilesNotFoundErr != nil {
+ return "", api.WrapError(
+ fmt.Errorf("markdown files not found: %w", markdownFilesNotFoundErr),
+ api.NewSentinelHttpError(
+ http.StatusBadRequest,
+ fmt.Sprintf("Markdown file(s) not found: %s", markdownFilesNotFoundErr),
+ ),
+ )
+ }
+
+ inputPath = ctx.GeneratePath(".html")
+
+ err = os.WriteFile(inputPath, buffer.Bytes(), 0o600)
+ if err != nil {
+ return "", fmt.Errorf("write template result: %w", err)
+ }
+
+ return fmt.Sprintf("file://%s", inputPath), nil
+}
+
+func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, pdfFormats gotenberg.PdfFormats, options PdfOptions) error {
outputPath := ctx.GeneratePath(".pdf")
err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options)
+ err = handleChromiumError(err, url, options.Options)
if err != nil {
-
- if errors.Is(err, ErrUrlNotAuthorized) {
- return api.WrapError(
- fmt.Errorf("convert to PDF: %w", err),
- api.NewSentinelHttpError(
- http.StatusForbidden,
- fmt.Sprintf("'%s' does not match the authorized URLs", url),
- ),
- )
- }
-
if errors.Is(err, ErrOmitBackgroundWithoutPrintBackground) {
return api.WrapError(
- fmt.Errorf("convert to PDF: %w", err),
+ err,
api.NewSentinelHttpError(
http.StatusBadRequest,
"omitBackground requires printBackground set to true",
@@ -345,23 +520,6 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
)
}
- if errors.Is(err, ErrInvalidEvaluationExpression) {
- if options.WaitForExpression == "" {
- // We do not expect the 'waitWindowStatus' form field to return
- // an ErrInvalidEvaluationExpression error. In such a scenario,
- // we return a 500.
- return fmt.Errorf("convert to PDF: %w", err)
- }
-
- return api.WrapError(
- fmt.Errorf("convert to PDF: %w", err),
- api.NewSentinelHttpError(
- http.StatusBadRequest,
- fmt.Sprintf("The expression '%s' (waitForExpression) returned an exception or undefined", options.WaitForExpression),
- ),
- )
- }
-
if errors.Is(err, ErrInvalidPrinterSettings) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
@@ -382,26 +540,6 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
)
}
- if errors.Is(err, ErrInvalidHttpStatusCode) {
- return api.WrapError(
- fmt.Errorf("convert to PDF: %w", err),
- api.NewSentinelHttpError(
- http.StatusConflict,
- fmt.Sprintf("Invalid HTTP status code from the main page: %s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidHttpStatusCode.Error()), "")),
- ),
- )
- }
-
- if errors.Is(err, ErrConsoleExceptions) {
- return api.WrapError(
- fmt.Errorf("convert to PDF: %w", err),
- api.NewSentinelHttpError(
- http.StatusConflict,
- fmt.Sprintf("Chromium console exceptions:\n %s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
- ),
- )
- }
-
return fmt.Errorf("convert to PDF: %w", err)
}
@@ -440,3 +578,76 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return nil
}
+
+func screenshotUrl(ctx *api.Context, chromium Api, url string, options ScreenshotOptions) error {
+ ext := fmt.Sprintf(".%s", options.Format)
+ outputPath := ctx.GeneratePath(ext)
+
+ err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
+ err = handleChromiumError(err, url, options.Options)
+ if err != nil {
+ return fmt.Errorf("screenshot: %w", err)
+ }
+
+ err = ctx.AddOutputPaths(outputPath)
+ if err != nil {
+ return fmt.Errorf("add output path: %w", err)
+ }
+
+ return nil
+}
+
+func handleChromiumError(err error, url string, options Options) error {
+ if err == nil {
+ return nil
+ }
+
+ if errors.Is(err, ErrUrlNotAuthorized) {
+ return api.WrapError(
+ err,
+ api.NewSentinelHttpError(
+ http.StatusForbidden,
+ fmt.Sprintf("'%s' does not match the authorized URLs", url),
+ ),
+ )
+ }
+
+ if errors.Is(err, ErrInvalidEvaluationExpression) {
+ if options.WaitForExpression == "" {
+ // We do not expect the 'waitWindowStatus' form field to return
+ // an ErrInvalidEvaluationExpression error. In such a scenario,
+ // we return a 500.
+ return err
+ }
+
+ return api.WrapError(
+ err,
+ api.NewSentinelHttpError(
+ http.StatusBadRequest,
+ fmt.Sprintf("The expression '%s' (waitForExpression) returned an exception or undefined", options.WaitForExpression),
+ ),
+ )
+ }
+
+ if errors.Is(err, ErrInvalidHttpStatusCode) {
+ return api.WrapError(
+ err,
+ api.NewSentinelHttpError(
+ http.StatusConflict,
+ fmt.Sprintf("Invalid HTTP status code from the main page: %s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidHttpStatusCode.Error()), "")),
+ ),
+ )
+ }
+
+ if errors.Is(err, ErrConsoleExceptions) {
+ return api.WrapError(
+ err,
+ api.NewSentinelHttpError(
+ http.StatusConflict,
+ fmt.Sprintf("Chromium console exceptions:\n %s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
+ ),
+ )
+ }
+
+ return err
+}
diff --git a/pkg/modules/chromium/routes_test.go b/pkg/modules/chromium/routes_test.go
index 2222c170..2d79f992 100644
--- a/pkg/modules/chromium/routes_test.go
+++ b/pkg/modules/chromium/routes_test.go
@@ -17,7 +17,7 @@ import (
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
)
-func TestFormDataChromiumPdfOptions(t *testing.T) {
+func TestFormDataChromiumOptions(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *api.ContextMock
@@ -124,6 +124,50 @@ func TestFormDataChromiumPdfOptions(t *testing.T) {
return options
}(),
},
+ } {
+ t.Run(tc.scenario, func(t *testing.T) {
+ tc.ctx.SetLogger(zap.NewNop())
+ _, actual := FormDataChromiumOptions(tc.ctx.Context)
+
+ if !reflect.DeepEqual(actual, tc.expectedOptions) {
+ t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual)
+ }
+ })
+ }
+}
+
+func TestFormDataChromiumPdfOptions(t *testing.T) {
+ for _, tc := range []struct {
+ scenario string
+ ctx *api.ContextMock
+ expectedOptions PdfOptions
+ }{
+ {
+ scenario: "no custom form fields",
+ ctx: &api.ContextMock{Context: new(api.Context)},
+ expectedOptions: DefaultPdfOptions(),
+ },
+ {
+ scenario: "custom form fields (Options & PdfOptions)",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetValues(map[string][]string{
+ "landscape": {
+ "true",
+ },
+ "emulatedMediaType": {
+ "screen",
+ },
+ })
+ return ctx
+ }(),
+ expectedOptions: func() PdfOptions {
+ options := DefaultPdfOptions()
+ options.Landscape = true
+ options.EmulatedMediaType = "screen"
+ return options
+ }(),
+ },
} {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop())
@@ -136,6 +180,152 @@ func TestFormDataChromiumPdfOptions(t *testing.T) {
}
}
+func TestFormDataChromiumScreenshotOptions(t *testing.T) {
+ for _, tc := range []struct {
+ scenario string
+ ctx *api.ContextMock
+ expectedOptions ScreenshotOptions
+ }{
+ {
+ scenario: "no custom form fields",
+ ctx: &api.ContextMock{Context: new(api.Context)},
+ expectedOptions: DefaultScreenshotOptions(),
+ },
+ {
+ scenario: "invalid format form field",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetValues(map[string][]string{
+ "format": {
+ "gif",
+ },
+ })
+ return ctx
+ }(),
+ expectedOptions: func() ScreenshotOptions {
+ options := DefaultScreenshotOptions()
+ options.Format = ""
+ return options
+ }(),
+ },
+ {
+ scenario: "valid format form field",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetValues(map[string][]string{
+ "format": {
+ "jpeg",
+ },
+ })
+ return ctx
+ }(),
+ expectedOptions: func() ScreenshotOptions {
+ options := DefaultScreenshotOptions()
+ options.Format = "jpeg"
+ return options
+ }(),
+ },
+ {
+ scenario: "invalid quality form field (not an integer)",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetValues(map[string][]string{
+ "quality": {
+ "foo",
+ },
+ })
+ return ctx
+ }(),
+ expectedOptions: func() ScreenshotOptions {
+ options := DefaultScreenshotOptions()
+ options.Quality = 0
+ return options
+ }(),
+ },
+ {
+ scenario: "invalid quality form field (< 0)",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetValues(map[string][]string{
+ "quality": {
+ "-1",
+ },
+ })
+ return ctx
+ }(),
+ expectedOptions: func() ScreenshotOptions {
+ options := DefaultScreenshotOptions()
+ options.Quality = 0
+ return options
+ }(),
+ },
+ {
+ scenario: "invalid quality form field (> 100)",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetValues(map[string][]string{
+ "quality": {
+ "101",
+ },
+ })
+ return ctx
+ }(),
+ expectedOptions: func() ScreenshotOptions {
+ options := DefaultScreenshotOptions()
+ options.Quality = 0
+ return options
+ }(),
+ },
+ {
+ scenario: "valid quality form field",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetValues(map[string][]string{
+ "quality": {
+ "50",
+ },
+ })
+ return ctx
+ }(),
+ expectedOptions: func() ScreenshotOptions {
+ options := DefaultScreenshotOptions()
+ options.Quality = 50
+ return options
+ }(),
+ },
+ {
+ scenario: "custom form fields (Options & ScreenshotOptions)",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetValues(map[string][]string{
+ "optimizeForSpeed": {
+ "true",
+ },
+ "emulatedMediaType": {
+ "screen",
+ },
+ })
+ return ctx
+ }(),
+ expectedOptions: func() ScreenshotOptions {
+ options := DefaultScreenshotOptions()
+ options.OptimizeForSpeed = true
+ options.EmulatedMediaType = "screen"
+ return options
+ }(),
+ },
+ } {
+ t.Run(tc.scenario, func(t *testing.T) {
+ tc.ctx.SetLogger(zap.NewNop())
+ _, actual := FormDataChromiumScreenshotOptions(tc.ctx.Context)
+
+ if !reflect.DeepEqual(actual, tc.expectedOptions) {
+ t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual)
+ }
+ })
+ }
+}
+
func TestFormDataChromiumPdfFormats(t *testing.T) {
for _, tc := range []struct {
scenario string
@@ -166,7 +356,7 @@ func TestFormDataChromiumPdfFormats(t *testing.T) {
} {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop())
- actual := FormDataChromiumPdfFormats(tc.ctx.Context)
+ actual := FormDataChromiumPdfFormats(tc.ctx.Context.FormData())
if !reflect.DeepEqual(actual, tc.expectedPdfFormats) {
t.Fatalf("expected %+v but got: %+v", tc.expectedPdfFormats, actual)
@@ -220,7 +410,7 @@ func TestConvertUrlRoute(t *testing.T) {
})
return ctx
}(),
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return errors.New("foo")
}},
expectError: true,
@@ -238,7 +428,7 @@ func TestConvertUrlRoute(t *testing.T) {
})
return ctx
}(),
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
}},
expectError: false,
@@ -286,6 +476,117 @@ func TestConvertUrlRoute(t *testing.T) {
}
}
+func TestScreenshotUrlRoute(t *testing.T) {
+ for _, tc := range []struct {
+ scenario string
+ ctx *api.ContextMock
+ api Api
+ expectError bool
+ expectHttpError bool
+ expectHttpStatus int
+ expectOutputPathsCount int
+ }{
+ {
+ scenario: "missing mandatory url form field",
+ ctx: &api.ContextMock{Context: new(api.Context)},
+ expectError: true,
+ expectHttpError: true,
+ expectHttpStatus: http.StatusBadRequest,
+ expectOutputPathsCount: 0,
+ },
+ {
+ scenario: "empty url form field",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetValues(map[string][]string{
+ "url": {
+ "",
+ },
+ })
+ return ctx
+ }(),
+ expectError: true,
+ expectHttpError: true,
+ expectHttpStatus: http.StatusBadRequest,
+ expectOutputPathsCount: 0,
+ },
+ {
+ scenario: "error from Chromium",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetValues(map[string][]string{
+ "url": {
+ "foo",
+ },
+ })
+ return ctx
+ }(),
+ api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
+ return errors.New("foo")
+ }},
+ expectError: true,
+ expectHttpError: false,
+ expectOutputPathsCount: 0,
+ },
+ {
+ scenario: "success",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetValues(map[string][]string{
+ "url": {
+ "foo",
+ },
+ })
+ 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) {
+ tc.ctx.SetLogger(zap.NewNop())
+ c := echo.New().NewContext(nil, nil)
+ c.Set("context", tc.ctx.Context)
+
+ err := screenshotUrlRoute(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 TestConvertHtmlRoute(t *testing.T) {
for _, tc := range []struct {
scenario string
@@ -313,7 +614,7 @@ func TestConvertHtmlRoute(t *testing.T) {
})
return ctx
}(),
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return errors.New("foo")
}},
expectError: true,
@@ -329,7 +630,7 @@ func TestConvertHtmlRoute(t *testing.T) {
})
return ctx
}(),
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
}},
expectError: false,
@@ -377,6 +678,97 @@ func TestConvertHtmlRoute(t *testing.T) {
}
}
+func TestScreenshotHtmlRoute(t *testing.T) {
+ for _, tc := range []struct {
+ scenario string
+ ctx *api.ContextMock
+ api Api
+ expectError bool
+ expectHttpError bool
+ expectHttpStatus int
+ expectOutputPathsCount int
+ }{
+ {
+ scenario: "missing mandatory index.html form file",
+ ctx: &api.ContextMock{Context: new(api.Context)},
+ expectError: true,
+ expectHttpError: true,
+ expectHttpStatus: http.StatusBadRequest,
+ expectOutputPathsCount: 0,
+ },
+ {
+ scenario: "error from Chromium",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetFiles(map[string]string{
+ "index.html": "/index.html",
+ })
+ return ctx
+ }(),
+ api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
+ return errors.New("foo")
+ }},
+ expectError: true,
+ expectHttpError: false,
+ expectOutputPathsCount: 0,
+ },
+ {
+ scenario: "success",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetFiles(map[string]string{
+ "index.html": "/index.html",
+ })
+ 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) {
+ tc.ctx.SetLogger(zap.NewNop())
+ c := echo.New().NewContext(nil, nil)
+ c.Set("context", tc.ctx.Context)
+
+ err := screenshotHtmlRoute(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 TestConvertMarkdownRoute(t *testing.T) {
for _, tc := range []struct {
scenario string
@@ -492,7 +884,7 @@ func TestConvertMarkdownRoute(t *testing.T) {
return ctx
}(),
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return errors.New("foo")
}},
expectError: true,
@@ -527,7 +919,7 @@ func TestConvertMarkdownRoute(t *testing.T) {
return ctx
}(),
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
}},
expectError: false,
@@ -584,6 +976,213 @@ func TestConvertMarkdownRoute(t *testing.T) {
}
}
+func TestScreenshotMarkdownRoute(t *testing.T) {
+ for _, tc := range []struct {
+ scenario string
+ ctx *api.ContextMock
+ api Api
+ expectError bool
+ expectHttpError bool
+ expectHttpStatus int
+ expectOutputPathsCount int
+ }{
+ {
+ scenario: "missing mandatory index.html form file",
+ ctx: &api.ContextMock{Context: new(api.Context)},
+ expectError: true,
+ expectHttpError: true,
+ expectHttpStatus: http.StatusBadRequest,
+ expectOutputPathsCount: 0,
+ },
+ {
+ scenario: "missing mandatory markdown form files",
+ ctx: func() *api.ContextMock {
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetFiles(map[string]string{
+ "index.html": "/index.html",
+ })
+ return ctx
+ }(),
+ expectError: true,
+ expectHttpError: true,
+ expectHttpStatus: http.StatusBadRequest,
+ expectOutputPathsCount: 0,
+ },
+ {
+ scenario: "markdown file requested in index.html not found",
+ ctx: func() *api.ContextMock {
+ dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString())
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetDirPath(dirPath)
+ ctx.SetFiles(map[string]string{
+ "index.html": fmt.Sprintf("%s/index.html", dirPath),
+ "wrong_name.md": fmt.Sprintf("%s/wrong_name.md", dirPath),
+ })
+
+ err := os.MkdirAll(dirPath, 0o755)
+ if err != nil {
+ t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
+ }
+
+ err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("{{ toHTML \"markdown.md\" }}
"), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return ctx
+ }(),
+ expectError: true,
+ expectHttpError: true,
+ expectHttpStatus: http.StatusBadRequest,
+ expectOutputPathsCount: 0,
+ },
+ {
+ scenario: "non-existing markdown file",
+ ctx: func() *api.ContextMock {
+ dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString())
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetDirPath(dirPath)
+ ctx.SetFiles(map[string]string{
+ "index.html": fmt.Sprintf("%s/index.html", dirPath),
+ "markdown.md": fmt.Sprintf("%s/markdown.md", dirPath),
+ })
+
+ err := os.MkdirAll(dirPath, 0o755)
+ if err != nil {
+ t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
+ }
+
+ err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("{{ toHTML \"markdown.md\" }}
"), 0o755)
+ if err != nil {
+ t.Fatalf("expected no error but got: %v", err)
+ }
+
+ return ctx
+ }(),
+ expectError: true,
+ expectHttpError: false,
+ expectOutputPathsCount: 0,
+ },
+ {
+ scenario: "error from Chromium",
+ ctx: func() *api.ContextMock {
+ dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString())
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetDirPath(dirPath)
+ ctx.SetFiles(map[string]string{
+ "index.html": fmt.Sprintf("%s/index.html", dirPath),
+ "markdown.md": fmt.Sprintf("%s/markdown.md", dirPath),
+ })
+
+ err := os.MkdirAll(dirPath, 0o755)
+ if err != nil {
+ t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
+ }
+
+ err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("{{ toHTML \"markdown.md\" }}
"), 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 errors.New("foo")
+ }},
+ expectError: true,
+ expectHttpError: false,
+ expectOutputPathsCount: 0,
+ },
+ {
+ scenario: "success",
+ ctx: func() *api.ContextMock {
+ dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString())
+ ctx := &api.ContextMock{Context: new(api.Context)}
+ ctx.SetDirPath(dirPath)
+ ctx.SetFiles(map[string]string{
+ "index.html": fmt.Sprintf("%s/index.html", dirPath),
+ "markdown.md": fmt.Sprintf("%s/markdown.md", dirPath),
+ })
+
+ err := os.MkdirAll(dirPath, 0o755)
+ if err != nil {
+ t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
+ }
+
+ err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("{{ toHTML \"markdown.md\" }}
"), 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
@@ -591,7 +1190,7 @@ func TestConvertUrl(t *testing.T) {
api Api
engine gotenberg.PdfEngine
pdfFormats gotenberg.PdfFormats
- options Options
+ options PdfOptions
expectError bool
expectHttpError bool
expectHttpStatus int
@@ -600,10 +1199,10 @@ func TestConvertUrl(t *testing.T) {
{
scenario: "ErrUrlNotAuthorized",
ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return ErrUrlNotAuthorized
}},
- options: DefaultOptions(),
+ options: DefaultPdfOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusForbidden,
@@ -612,10 +1211,10 @@ func TestConvertUrl(t *testing.T) {
{
scenario: "ErrOmitBackgroundWithoutPrintBackground",
ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return ErrOmitBackgroundWithoutPrintBackground
}},
- options: DefaultOptions(),
+ options: DefaultPdfOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
@@ -624,10 +1223,10 @@ func TestConvertUrl(t *testing.T) {
{
scenario: "ErrInvalidEvaluationExpression (without waitForExpression form field)",
ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return ErrInvalidEvaluationExpression
}},
- options: DefaultOptions(),
+ options: DefaultPdfOptions(),
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
@@ -635,11 +1234,11 @@ func TestConvertUrl(t *testing.T) {
{
scenario: "ErrInvalidEvaluationExpression (with waitForExpression form field)",
ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return ErrInvalidEvaluationExpression
}},
- options: func() Options {
- options := DefaultOptions()
+ options: func() PdfOptions {
+ options := DefaultPdfOptions()
options.WaitForExpression = "foo"
return options
@@ -652,10 +1251,10 @@ func TestConvertUrl(t *testing.T) {
{
scenario: "ErrInvalidPrinterSettings",
ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return ErrInvalidPrinterSettings
}},
- options: DefaultOptions(),
+ options: DefaultPdfOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
@@ -664,10 +1263,10 @@ func TestConvertUrl(t *testing.T) {
{
scenario: "ErrPageRangesSyntaxError",
ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return ErrPageRangesSyntaxError
}},
- options: DefaultOptions(),
+ options: DefaultPdfOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
@@ -676,10 +1275,10 @@ func TestConvertUrl(t *testing.T) {
{
scenario: "ErrInvalidHttpStatusCode",
ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return ErrInvalidHttpStatusCode
}},
- options: DefaultOptions(),
+ options: DefaultPdfOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusConflict,
@@ -688,10 +1287,10 @@ func TestConvertUrl(t *testing.T) {
{
scenario: "ErrConsoleExceptions",
ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return ErrConsoleExceptions
}},
- options: DefaultOptions(),
+ options: DefaultPdfOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusConflict,
@@ -700,10 +1299,10 @@ func TestConvertUrl(t *testing.T) {
{
scenario: "error from Chromium",
ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return errors.New("foo")
}},
- options: DefaultOptions(),
+ options: DefaultPdfOptions(),
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
@@ -711,14 +1310,14 @@ func TestConvertUrl(t *testing.T) {
{
scenario: "ErrPdfFormatNotSupported",
ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ 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 gotenberg.ErrPdfFormatNotSupported
}},
pdfFormats: gotenberg.PdfFormats{PdfA: "foo"},
- options: DefaultOptions(),
+ options: DefaultPdfOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
@@ -727,14 +1326,14 @@ func TestConvertUrl(t *testing.T) {
{
scenario: "error from PDF engine",
ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ 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")
}},
pdfFormats: gotenberg.PdfFormats{PdfA: "foo"},
- options: DefaultOptions(),
+ options: DefaultPdfOptions(),
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
@@ -742,14 +1341,14 @@ func TestConvertUrl(t *testing.T) {
{
scenario: "success with pdfa form field",
ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ 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
}},
pdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b},
- options: DefaultOptions(),
+ options: DefaultPdfOptions(),
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 1,
@@ -761,10 +1360,10 @@ func TestConvertUrl(t *testing.T) {
ctx.SetCancelled(true)
return ctx
}(),
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
}},
- options: DefaultOptions(),
+ options: DefaultPdfOptions(),
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
@@ -772,10 +1371,10 @@ func TestConvertUrl(t *testing.T) {
{
scenario: "success",
ctx: &api.ContextMock{Context: new(api.Context)},
- api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
+ api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
}},
- options: DefaultOptions(),
+ options: DefaultPdfOptions(),
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 1,
@@ -817,3 +1416,153 @@ func TestConvertUrl(t *testing.T) {
})
}
}
+
+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: "ErrUrlNotAuthorized",
+ ctx: &api.ContextMock{Context: new(api.Context)},
+ api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
+ return ErrUrlNotAuthorized
+ }},
+ options: DefaultScreenshotOptions(),
+ expectError: true,
+ expectHttpError: true,
+ expectHttpStatus: http.StatusForbidden,
+ expectOutputPathsCount: 0,
+ },
+ {
+ 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: "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: "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/tasks.go b/pkg/modules/chromium/tasks.go
index 5dea5f53..d77688b6 100644
--- a/pkg/modules/chromium/tasks.go
+++ b/pkg/modules/chromium/tasks.go
@@ -15,7 +15,7 @@ import (
"go.uber.org/zap"
)
-func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options) chromedp.ActionFunc {
+func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
return func(ctx context.Context) error {
printToPdf := page.PrintToPDF().
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
@@ -31,8 +31,8 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options
WithPageRanges(options.PageRanges).
WithPreferCSSPageSize(options.PreferCssPageSize)
- hasCustomHeaderFooter := options.HeaderTemplate != DefaultOptions().HeaderTemplate ||
- options.FooterTemplate != DefaultOptions().FooterTemplate
+ hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
+ options.FooterTemplate != DefaultPdfOptions().FooterTemplate
if !hasCustomHeaderFooter {
logger.Debug("no custom header nor footer")
@@ -63,7 +63,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options
}
defer func() {
- err := reader.Close()
+ err = reader.Close()
if err != nil {
logger.Error(fmt.Sprintf("close reader: %s", err))
}
@@ -75,7 +75,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options
}
defer func() {
- err := file.Close()
+ err = file.Close()
if err != nil {
logger.Error(fmt.Sprintf("close output path: %s", err))
}
@@ -92,6 +92,47 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options
}
}
+func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options ScreenshotOptions) chromedp.ActionFunc {
+ return func(ctx context.Context) error {
+ captureScreenshot := page.CaptureScreenshot().
+ WithCaptureBeyondViewport(true).
+ WithFromSurface(true).
+ WithOptimizeForSpeed(options.OptimizeForSpeed).
+ WithFormat(page.CaptureScreenshotFormat(options.Format))
+
+ if options.Format == "jpeg" {
+ captureScreenshot = captureScreenshot.
+ WithQuality(int64(options.Quality))
+ }
+
+ logger.Debug(fmt.Sprintf("capture screenshot with: %+v", captureScreenshot))
+
+ buffer, err := captureScreenshot.Do(ctx)
+ if err != nil {
+ return fmt.Errorf("capture screenshot: %w", err)
+ }
+
+ file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
+ if err != nil {
+ return fmt.Errorf("open output path: %w", err)
+ }
+
+ defer func() {
+ err = file.Close()
+ if err != nil {
+ logger.Error(fmt.Sprintf("close output path: %s", err))
+ }
+ }()
+
+ _, err = file.Write(buffer)
+ if err != nil {
+ return fmt.Errorf("write result to output path: %w", err)
+ }
+
+ return nil
+ }
+}
+
func clearCacheActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/753.
@@ -211,7 +252,6 @@ func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, pr
// See https://github.com/gotenberg/gotenberg/issues/226.
if !omitBackground {
logger.Debug("default white background not hidden")
-
return nil
}
@@ -270,7 +310,6 @@ func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string) chromedp.A
return func(ctx context.Context) error {
if mediaType == "" {
logger.Debug("no emulated media type")
-
return nil
}
@@ -294,13 +333,11 @@ func waitDelayBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool,
return func(ctx context.Context) error {
if disableJavaScript {
logger.Debug("JavaScript disabled, skipping wait delay")
-
return nil
}
if delay <= 0 {
logger.Debug("no wait delay")
-
return nil
}
@@ -321,13 +358,11 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip
return func(ctx context.Context) error {
if disableJavaScript {
logger.Debug("JavaScript disabled, skipping wait expression")
-
return nil
}
if expression == "" {
logger.Debug("no wait expression")
-
return nil
}