feat(chromium): capture screenshot (#765)

This commit is contained in:
Julien Neuhart
2024-01-04 19:14:38 +01:00
committed by GitHub
parent 93b9ac2d13
commit b2f6e865a7
11 changed files with 2334 additions and 351 deletions

10
go.mod
View File

@@ -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
)

22
go.sum
View File

@@ -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=

View File

@@ -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.

File diff suppressed because it is too large Load Diff

View File

@@ -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: "<html><head></head><body></body></html>",
FooterTemplate: "<html><head></head><body></body></html>",
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: "<html><head></head><body></body></html>",
FooterTemplate: "<html><head></head><body></body></html>",
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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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
}

File diff suppressed because it is too large Load Diff

View File

@@ -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
}