mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-17 09:43:17 +01:00
Compare commits
7 Commits
05bde96334
...
7a730cfdc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a730cfdc2 | ||
|
|
b213f2ffed | ||
|
|
8d1eeaa73a | ||
|
|
2f6818d3df | ||
|
|
38f6e466d4 | ||
|
|
e39840d5cf | ||
|
|
9b48d3f84e |
@@ -15,6 +15,7 @@ body:multipart-form {
|
|||||||
~width: 800
|
~width: 800
|
||||||
~height: 600
|
~height: 600
|
||||||
~clip: false
|
~clip: false
|
||||||
|
~selector:
|
||||||
~format: png
|
~format: png
|
||||||
~quality: 100
|
~quality: 100
|
||||||
~optimizeForSpeed: false
|
~optimizeForSpeed: false
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ body:multipart-form {
|
|||||||
~width: 800
|
~width: 800
|
||||||
~height: 600
|
~height: 600
|
||||||
~clip: false
|
~clip: false
|
||||||
|
~selector:
|
||||||
~format: png
|
~format: png
|
||||||
~quality: 100
|
~quality: 100
|
||||||
~optimizeForSpeed: false
|
~optimizeForSpeed: false
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ body:multipart-form {
|
|||||||
~width: 800
|
~width: 800
|
||||||
~height: 600
|
~height: 600
|
||||||
~clip: false
|
~clip: false
|
||||||
|
~selector:
|
||||||
~format: png
|
~format: png
|
||||||
~quality: 100
|
~quality: 100
|
||||||
~optimizeForSpeed: false
|
~optimizeForSpeed: false
|
||||||
|
|||||||
2
.github/workflows/continuous-integration.yml
vendored
2
.github/workflows/continuous-integration.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
|||||||
- name: Run linters
|
- name: Run linters
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: v2.10.1
|
version: v2.12.2
|
||||||
|
|
||||||
lint-prettier:
|
lint-prettier:
|
||||||
name: Lint non-Golang codebase
|
name: Lint non-Golang codebase
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type browserArguments struct {
|
|||||||
denyPublicIPs bool
|
denyPublicIPs bool
|
||||||
clearCache bool
|
clearCache bool
|
||||||
clearCookies bool
|
clearCookies bool
|
||||||
|
clearStorage bool
|
||||||
disableJavaScript bool
|
disableJavaScript bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +223,7 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
|||||||
if stopErr != nil {
|
if stopErr != nil {
|
||||||
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy after failed start: %s", stopErr))
|
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy after failed start: %s", stopErr))
|
||||||
}
|
}
|
||||||
return fmt.Errorf("run exec allocator: %w", err)
|
return fmt.Errorf("run exec allocator: %w; if Chromium is slow to start, raise --chromium-start-timeout (currently %s)", err, b.arguments.wsUrlReadTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
b.ctxMu.Lock()
|
b.ctxMu.Lock()
|
||||||
@@ -367,6 +368,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out
|
|||||||
runtime.Enable(),
|
runtime.Enable(),
|
||||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||||
|
clearStorageActionFunc(logger, b.arguments.clearStorage, url),
|
||||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||||
setCookiesActionFunc(logger, options.Cookies),
|
setCookiesActionFunc(logger, options.Cookies),
|
||||||
userAgentOverride(logger, options.UserAgent),
|
userAgentOverride(logger, options.UserAgent),
|
||||||
@@ -393,6 +395,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
|
|||||||
runtime.Enable(),
|
runtime.Enable(),
|
||||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||||
|
clearStorageActionFunc(logger, b.arguments.clearStorage, url),
|
||||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||||
setCookiesActionFunc(logger, options.Cookies),
|
setCookiesActionFunc(logger, options.Cookies),
|
||||||
userAgentOverride(logger, options.UserAgent),
|
userAgentOverride(logger, options.UserAgent),
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ var (
|
|||||||
// or undefined.
|
// or undefined.
|
||||||
ErrInvalidSelectorQuery = errors.New("invalid selector query")
|
ErrInvalidSelectorQuery = errors.New("invalid selector query")
|
||||||
|
|
||||||
|
// ErrScreenshotSelectorNotFound happens when the CSS selector of a
|
||||||
|
// screenshot matches no element with a rendered box.
|
||||||
|
ErrScreenshotSelectorNotFound = errors.New("screenshot selector not found")
|
||||||
|
|
||||||
// ErrRpccMessageTooLarge happens when the messages received by
|
// ErrRpccMessageTooLarge happens when the messages received by
|
||||||
// ChromeDevTools are larger than 100 MB.
|
// ChromeDevTools are larger than 100 MB.
|
||||||
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
|
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
|
||||||
@@ -347,6 +351,11 @@ type ScreenshotOptions struct {
|
|||||||
// dimensions.
|
// dimensions.
|
||||||
Clip bool
|
Clip bool
|
||||||
|
|
||||||
|
// Selector clips the screenshot to the bounding box of the first element
|
||||||
|
// matching this CSS selector. Empty captures the whole page. Takes
|
||||||
|
// precedence over Clip.
|
||||||
|
Selector string
|
||||||
|
|
||||||
// Format is the image compression format, either "png" or "jpeg" or
|
// Format is the image compression format, either "png" or "jpeg" or
|
||||||
// "webp".
|
// "webp".
|
||||||
Format string
|
Format string
|
||||||
@@ -370,6 +379,7 @@ func DefaultScreenshotOptions() ScreenshotOptions {
|
|||||||
Width: 800,
|
Width: 800,
|
||||||
Height: 600,
|
Height: 600,
|
||||||
Clip: false,
|
Clip: false,
|
||||||
|
Selector: "",
|
||||||
Format: "png",
|
Format: "png",
|
||||||
Quality: 100,
|
Quality: 100,
|
||||||
OptimizeForSpeed: false,
|
OptimizeForSpeed: false,
|
||||||
@@ -467,6 +477,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs.Bool("chromium-deny-public-ips", false, "Reject URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
|
fs.Bool("chromium-deny-public-ips", false, "Reject URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
|
||||||
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
|
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
|
||||||
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
||||||
|
fs.Bool("chromium-clear-storage", false, "Clear Chromium local storage between each conversion (session storage is already isolated per conversion)")
|
||||||
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
||||||
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
||||||
|
|
||||||
@@ -518,6 +529,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
|
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
|
||||||
clearCache: flags.MustBool("chromium-clear-cache"),
|
clearCache: flags.MustBool("chromium-clear-cache"),
|
||||||
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
||||||
|
clearStorage: flags.MustBool("chromium-clear-storage"),
|
||||||
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
var (
|
var (
|
||||||
width, height int
|
width, height int
|
||||||
clip bool
|
clip bool
|
||||||
|
selector string
|
||||||
format string
|
format string
|
||||||
quality int
|
quality int
|
||||||
optimizeForSpeed bool
|
optimizeForSpeed bool
|
||||||
@@ -376,6 +377,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
Int("width", &width, defaultScreenshotOptions.Width).
|
Int("width", &width, defaultScreenshotOptions.Width).
|
||||||
Int("height", &height, defaultScreenshotOptions.Height).
|
Int("height", &height, defaultScreenshotOptions.Height).
|
||||||
Bool("clip", &clip, defaultScreenshotOptions.Clip).
|
Bool("clip", &clip, defaultScreenshotOptions.Clip).
|
||||||
|
String("selector", &selector, defaultScreenshotOptions.Selector).
|
||||||
Custom("format", func(value string) error {
|
Custom("format", func(value string) error {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
format = defaultScreenshotOptions.Format
|
format = defaultScreenshotOptions.Format
|
||||||
@@ -420,6 +422,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
Width: width,
|
Width: width,
|
||||||
Height: height,
|
Height: height,
|
||||||
Clip: clip,
|
Clip: clip,
|
||||||
|
Selector: selector,
|
||||||
Format: format,
|
Format: format,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
OptimizeForSpeed: optimizeForSpeed,
|
OptimizeForSpeed: optimizeForSpeed,
|
||||||
@@ -963,7 +966,17 @@ func screenshotUrl(ctx *api.Context, chromium Api, url string, options Screensho
|
|||||||
outputPath := ctx.GeneratePath(ext)
|
outputPath := ctx.GeneratePath(ext)
|
||||||
|
|
||||||
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
|
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
|
||||||
|
if errors.Is(err, ErrScreenshotSelectorNotFound) {
|
||||||
|
err = api.WrapError(
|
||||||
|
err,
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusBadRequest,
|
||||||
|
fmt.Sprintf("The selector '%s' (selector) matched no element with a visible box", options.Selector),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
err = handleChromiumError(err, options.Options)
|
err = handleChromiumError(err, options.Options)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("screenshot: %w", err)
|
return fmt.Errorf("screenshot: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/cdp"
|
"github.com/chromedp/cdproto/cdp"
|
||||||
"github.com/chromedp/cdproto/emulation"
|
"github.com/chromedp/cdproto/emulation"
|
||||||
"github.com/chromedp/cdproto/network"
|
"github.com/chromedp/cdproto/network"
|
||||||
"github.com/chromedp/cdproto/page"
|
"github.com/chromedp/cdproto/page"
|
||||||
|
"github.com/chromedp/cdproto/storage"
|
||||||
"github.com/chromedp/chromedp"
|
"github.com/chromedp/chromedp"
|
||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
@@ -54,6 +57,7 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
|||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
err := func() error {
|
err := func() error {
|
||||||
|
paperWidth := options.PaperWidth
|
||||||
paperHeight := options.PaperHeight
|
paperHeight := options.PaperHeight
|
||||||
pageRanges := options.PageRanges
|
pageRanges := options.PageRanges
|
||||||
|
|
||||||
@@ -67,9 +71,20 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
|||||||
|
|
||||||
// There are 96 CSS pixels per inch.
|
// There are 96 CSS pixels per inch.
|
||||||
// See https://issues.chromium.org/issues/40267771#comment14.
|
// See https://issues.chromium.org/issues/40267771#comment14.
|
||||||
|
if options.Landscape {
|
||||||
|
// Landscape swaps the paper dimensions, so the page is
|
||||||
|
// WithPaperHeight wide by WithPaperWidth tall. Size both to
|
||||||
|
// the content so the width expands to fit a wide document
|
||||||
|
// (e.g. a table) instead of the height-only expansion
|
||||||
|
// landing on the width axis and truncating it.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1390.
|
||||||
|
paperWidth = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
||||||
|
paperHeight = (cssContentSize.Width / 96) + options.MarginLeft + options.MarginRight
|
||||||
|
} else {
|
||||||
// We add top and bottom margins so that the content area
|
// We add top and bottom margins so that the content area
|
||||||
// is large enough to fit the entire content.
|
// is large enough to fit the entire content.
|
||||||
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
||||||
|
}
|
||||||
pageRanges = "1" // little dirty hack to avoid leftovers.
|
pageRanges = "1" // little dirty hack to avoid leftovers.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +93,7 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
|||||||
WithLandscape(options.Landscape).
|
WithLandscape(options.Landscape).
|
||||||
WithPrintBackground(options.PrintBackground).
|
WithPrintBackground(options.PrintBackground).
|
||||||
WithScale(options.Scale).
|
WithScale(options.Scale).
|
||||||
WithPaperWidth(options.PaperWidth).
|
WithPaperWidth(paperWidth).
|
||||||
WithPaperHeight(paperHeight).
|
WithPaperHeight(paperHeight).
|
||||||
WithMarginTop(options.MarginTop).
|
WithMarginTop(options.MarginTop).
|
||||||
WithMarginBottom(options.MarginBottom).
|
WithMarginBottom(options.MarginBottom).
|
||||||
@@ -188,7 +203,16 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
|
|||||||
WithOptimizeForSpeed(options.OptimizeForSpeed).
|
WithOptimizeForSpeed(options.OptimizeForSpeed).
|
||||||
WithFormat(page.CaptureScreenshotFormat(options.Format))
|
WithFormat(page.CaptureScreenshotFormat(options.Format))
|
||||||
|
|
||||||
if options.Clip {
|
switch {
|
||||||
|
case options.Selector != "":
|
||||||
|
clip, err := elementClip(ctx, options.Selector)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugContext(ctx, fmt.Sprintf("clip screenshot to selector '%s'", options.Selector))
|
||||||
|
captureScreenshot = captureScreenshot.WithClip(clip)
|
||||||
|
case options.Clip:
|
||||||
captureScreenshot = captureScreenshot.WithClip(&page.Viewport{
|
captureScreenshot = captureScreenshot.WithClip(&page.Viewport{
|
||||||
Width: float64(options.Width),
|
Width: float64(options.Width),
|
||||||
Height: float64(options.Height),
|
Height: float64(options.Height),
|
||||||
@@ -229,6 +253,49 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// elementClip resolves the first element matching selector to a page-space clip
|
||||||
|
// rectangle for Page.captureScreenshot.
|
||||||
|
//
|
||||||
|
// getBoundingClientRect reports viewport-relative CSS pixels; adding the scroll
|
||||||
|
// offset puts the rectangle in the document coordinate space that
|
||||||
|
// WithCaptureBeyondViewport expects. It fails with
|
||||||
|
// [ErrScreenshotSelectorNotFound] when nothing matches or the match has no
|
||||||
|
// rendered box (display:none or a zero area), so the caller can answer 400.
|
||||||
|
func elementClip(ctx context.Context, selector string) (*page.Viewport, error) {
|
||||||
|
var rect struct {
|
||||||
|
Found bool `json:"found"`
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
Width float64 `json:"width"`
|
||||||
|
Height float64 `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
expr := fmt.Sprintf(`(() => {
|
||||||
|
const el = document.querySelector(%s);
|
||||||
|
if (!el) {
|
||||||
|
return { found: false };
|
||||||
|
}
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { found: true, x: r.left + window.scrollX, y: r.top + window.scrollY, width: r.width, height: r.height };
|
||||||
|
})()`, strconv.Quote(selector))
|
||||||
|
|
||||||
|
err := chromedp.Evaluate(expr, &rect).Do(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("evaluate selector box: %v: %w", err, ErrScreenshotSelectorNotFound)
|
||||||
|
}
|
||||||
|
if !rect.Found || rect.Width <= 0 || rect.Height <= 0 {
|
||||||
|
return nil, fmt.Errorf("selector %q matched no element with a visible box: %w", selector, ErrScreenshotSelectorNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &page.Viewport{
|
||||||
|
X: rect.X,
|
||||||
|
Y: rect.Y,
|
||||||
|
Width: rect.Width,
|
||||||
|
Height: rect.Height,
|
||||||
|
Scale: 1,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func setDeviceMetricsOverride(logger *slog.Logger, width, height int, deviceScaleFactor float64) chromedp.ActionFunc {
|
func setDeviceMetricsOverride(logger *slog.Logger, width, height int, deviceScaleFactor float64) chromedp.ActionFunc {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger.DebugContext(ctx, "set device metrics override")
|
logger.DebugContext(ctx, "set device metrics override")
|
||||||
@@ -280,6 +347,54 @@ func clearCookiesActionFunc(logger *slog.Logger, clear bool) chromedp.ActionFunc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clearStorageActionFunc clears the converted origin's local storage before the
|
||||||
|
// page loads, so state written by a previous conversion of the same origin does
|
||||||
|
// not leak into this one. See https://github.com/gotenberg/gotenberg/issues/919.
|
||||||
|
//
|
||||||
|
// Session storage is not touched: each conversion runs in its own browsing
|
||||||
|
// context (a fresh tab), so it is already isolated and cannot leak. Local
|
||||||
|
// storage is per-origin and shared across tabs of the long-lived browser, so it
|
||||||
|
// is the only web storage that carries over.
|
||||||
|
func clearStorageActionFunc(logger *slog.Logger, clear bool, rawURL string) chromedp.ActionFunc {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if !clear {
|
||||||
|
logger.DebugContext(ctx, "local storage not cleared")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
origin, ok := httpOrigin(rawURL)
|
||||||
|
if !ok {
|
||||||
|
// A file:// upload gets an opaque, per-request origin that is not
|
||||||
|
// shared between conversions, so there is nothing to clear.
|
||||||
|
logger.DebugContext(ctx, "local storage not cleared: non-http(s) origin is already isolated")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugContext(ctx, fmt.Sprintf("clear local storage for %s", origin))
|
||||||
|
|
||||||
|
err := storage.ClearDataForOrigin(origin, string(storage.TypeLocalStorage)).Do(ctx)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("clear local storage: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpOrigin returns the http(s) security origin (scheme://host[:port]) of
|
||||||
|
// rawURL, and false when rawURL is not http(s). A non-http(s) URL such as a
|
||||||
|
// file:// upload has an opaque origin that no other conversion shares.
|
||||||
|
func httpOrigin(rawURL string) (string, bool) {
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host), true
|
||||||
|
}
|
||||||
|
|
||||||
func disableJavaScriptActionFunc(logger *slog.Logger, disable bool) chromedp.ActionFunc {
|
func disableJavaScriptActionFunc(logger *slog.Logger, disable bool) chromedp.ActionFunc {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/175.
|
// See https://github.com/gotenberg/gotenberg/issues/175.
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ Feature: /forms/chromium/convert/html
|
|||||||
Page 12
|
Page 12
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# A wide table on one landscape page: the page must expand its width to the
|
||||||
|
# content, so the rightmost column is not truncated and the page comes out
|
||||||
|
# landscape instead of the narrow-tall strip the height-only expansion used
|
||||||
|
# to produce. See https://github.com/gotenberg/gotenberg/issues/1390.
|
||||||
|
Scenario: POST /forms/chromium/convert/html (Single Page Landscape)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/wide-table-html/index.html | file |
|
||||||
|
| singlePage | true | field |
|
||||||
|
| landscape | true | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
Then there should be 1 PDF(s) in the response
|
||||||
|
Then the "foo.pdf" PDF should have 1 page(s)
|
||||||
|
Then the "foo.pdf" PDF should be set to landscape orientation
|
||||||
|
Then the "foo.pdf" PDF should have content matching "Column 12" at page 1
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/html (Landscape)
|
Scenario: POST /forms/chromium/convert/html (Landscape)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
|||||||
@@ -22,6 +22,41 @@ Feature: /forms/chromium/convert/url
|
|||||||
Page 1
|
Page 1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# localStorage is per-origin and shared by the long-lived browser, so without
|
||||||
|
# clearing it accumulates across same-origin conversions.
|
||||||
|
# See https://github.com/gotenberg/gotenberg/issues/919.
|
||||||
|
Scenario: POST /forms/chromium/convert/url (localStorage leaks without clearing)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
Given I have a static server
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the "foo.pdf" PDF should have content matching "localStorageCount=2" at page 1
|
||||||
|
|
||||||
|
# With --chromium-clear-storage every conversion clears the origin's
|
||||||
|
# localStorage first, so the counter never carries over.
|
||||||
|
# See https://github.com/gotenberg/gotenberg/issues/919.
|
||||||
|
Scenario: POST /forms/chromium/convert/url (Clear Storage)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_CLEAR_STORAGE | true |
|
||||||
|
Given I have a static server
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/url (Single Page)
|
Scenario: POST /forms/chromium/convert/url (Single Page)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
Given I have a static server
|
Given I have a static server
|
||||||
|
|||||||
@@ -69,6 +69,32 @@ Feature: /forms/chromium/screenshot/html
|
|||||||
Then the response status code should be 200
|
Then the response status code should be 200
|
||||||
Then the response header "Content-Type" should be "image/png"
|
Then the response header "Content-Type" should be "image/png"
|
||||||
|
|
||||||
|
# The target element is 300x200 and sits below a spacer, so a correct clip
|
||||||
|
# proves both the element size and its page offset. See issue #947.
|
||||||
|
Scenario: POST /forms/chromium/screenshot/html (Selector)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/screenshot-selector-html/index.html | file |
|
||||||
|
| selector | #target | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "image/png"
|
||||||
|
Then the "foo.png" image should be 300x200 pixels
|
||||||
|
# A centered red pixel proves the clip landed on the element, not on the
|
||||||
|
# white spacer above it, i.e. the page offset was applied.
|
||||||
|
Then the "foo.png" image pixel at 150,100 should be "#ff0000"
|
||||||
|
|
||||||
|
Scenario: POST /forms/chromium/screenshot/html (Selector Not Found)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/screenshot-selector-html/index.html | file |
|
||||||
|
| selector | #does-not-exist | field |
|
||||||
|
Then the response status code should be 400
|
||||||
|
Then the response body should contain string:
|
||||||
|
"""
|
||||||
|
The selector '#does-not-exist' (selector) matched no element with a visible box
|
||||||
|
"""
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/screenshot/html (Quality)
|
Scenario: POST /forms/chromium/screenshot/html (Quality)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ Feature: /debug
|
|||||||
"chromium-auto-start": "false",
|
"chromium-auto-start": "false",
|
||||||
"chromium-clear-cache": "false",
|
"chromium-clear-cache": "false",
|
||||||
"chromium-clear-cookies": "false",
|
"chromium-clear-cookies": "false",
|
||||||
|
"chromium-clear-storage": "false",
|
||||||
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
||||||
"chromium-deny-private-ips": "false",
|
"chromium-deny-private-ips": "false",
|
||||||
"chromium-deny-public-ips": "false",
|
"chromium-deny-public-ips": "false",
|
||||||
@@ -221,6 +222,7 @@ Feature: /debug
|
|||||||
"chromium-auto-start": "false",
|
"chromium-auto-start": "false",
|
||||||
"chromium-clear-cache": "false",
|
"chromium-clear-cache": "false",
|
||||||
"chromium-clear-cookies": "false",
|
"chromium-clear-cookies": "false",
|
||||||
|
"chromium-clear-storage": "false",
|
||||||
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
||||||
"chromium-deny-private-ips": "false",
|
"chromium-deny-private-ips": "false",
|
||||||
"chromium-deny-public-ips": "false",
|
"chromium-deny-public-ips": "false",
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/png" // Register the PNG decoder for image.DecodeConfig.
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -1010,6 +1012,51 @@ func (s *scenario) thePdfShouldHaveImages(ctx context.Context, name string, imag
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *scenario) theImageShouldBePixels(_ context.Context, name string, width, height int) error {
|
||||||
|
path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
||||||
|
|
||||||
|
file, err := os.Open(path) //nolint:gosec // path is built from test-controlled values.
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open image %q: %w", path, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
config, format, err := image.DecodeConfig(file)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode image %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Width != width || config.Height != height {
|
||||||
|
return fmt.Errorf("expected %s image %dx%d, but actual is %dx%d", format, width, height, config.Width, config.Height)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scenario) theImagePixelShouldBe(_ context.Context, name string, x, y int, want string) error {
|
||||||
|
path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
||||||
|
|
||||||
|
file, err := os.Open(path) //nolint:gosec // path is built from test-controlled values.
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open image %q: %w", path, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
img, format, err := image.Decode(file)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode image %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r, g, b, _ := img.At(x, y).RGBA()
|
||||||
|
// RGBA returns 16-bit channels; shift down to the 8-bit hex form.
|
||||||
|
got := fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
|
||||||
|
if !strings.EqualFold(got, want) {
|
||||||
|
return fmt.Errorf("expected %s pixel at %d,%d to be %s, but actual is %s", format, x, y, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context, name string, kind string) error {
|
func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context, name string, kind string) error {
|
||||||
var path string
|
var path string
|
||||||
if !strings.HasPrefix(name, "*_") {
|
if !strings.HasPrefix(name, "*_") {
|
||||||
@@ -1047,7 +1094,9 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
output = strings.ReplaceAll(output, " ", "")
|
output = strings.ReplaceAll(output, " ", "")
|
||||||
re := regexp.MustCompile(`Pagesize:(\d+)x(\d+).*`)
|
// The dimensions can be fractional (e.g. a content-sized single page),
|
||||||
|
// so match floats, not just integers.
|
||||||
|
re := regexp.MustCompile(`Pagesize:([\d.]+)x([\d.]+)`)
|
||||||
matches := re.FindStringSubmatch(output)
|
matches := re.FindStringSubmatch(output)
|
||||||
|
|
||||||
if len(matches) < 3 {
|
if len(matches) < 3 {
|
||||||
@@ -1056,22 +1105,22 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
|
|||||||
|
|
||||||
invert := kind == "should NOT"
|
invert := kind == "should NOT"
|
||||||
|
|
||||||
width, err := strconv.Atoi(matches[1])
|
width, err := strconv.ParseFloat(matches[1], 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert width value %q to integer: %w", matches[1], err)
|
return fmt.Errorf("convert width value %q to float: %w", matches[1], err)
|
||||||
}
|
}
|
||||||
|
|
||||||
height, err := strconv.Atoi(matches[2])
|
height, err := strconv.ParseFloat(matches[2], 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert height value %q to integer: %w", matches[2], err)
|
return fmt.Errorf("convert height value %q to float: %w", matches[2], err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if invert && height < width {
|
if invert && height < width {
|
||||||
return fmt.Errorf("expected height %d to be greater than width %d", height, width)
|
return fmt.Errorf("expected height %g to be greater than width %g", height, width)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !invert && width < height {
|
if !invert && width < height {
|
||||||
return fmt.Errorf("expected width %d to be greater than height %d", width, height)
|
return fmt.Errorf("expected width %g to be greater than height %g", width, height)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -1599,6 +1648,8 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
|
|||||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
|
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
|
||||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage)
|
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage)
|
||||||
ctx.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages)
|
ctx.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages)
|
||||||
|
ctx.Then(`^the "([^"]*)" image should be (\d+)x(\d+) pixels$`, s.theImageShouldBePixels)
|
||||||
|
ctx.Then(`^the "([^"]*)" image pixel at (\d+),(\d+) should be "([^"]*)"$`, s.theImagePixelShouldBe)
|
||||||
ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
|
ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
|
||||||
if s.gotenbergContainer != nil {
|
if s.gotenbergContainer != nil {
|
||||||
errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0))
|
errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0))
|
||||||
|
|||||||
18
test/integration/testdata/local-storage-html/index.html
vendored
Normal file
18
test/integration/testdata/local-storage-html/index.html
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Local storage counter</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Increments a per-origin localStorage counter and renders it, so a value
|
||||||
|
above 1 means a previous conversion of the same origin leaked into this
|
||||||
|
one. See https://github.com/gotenberg/gotenberg/issues/919. -->
|
||||||
|
<pre id="out"></pre>
|
||||||
|
<script>
|
||||||
|
var n = (parseInt(localStorage.getItem("n"), 10) || 0) + 1;
|
||||||
|
localStorage.setItem("n", n);
|
||||||
|
document.getElementById("out").textContent = "localStorageCount=" + n;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
test/integration/testdata/screenshot-selector-html/index.html
vendored
Normal file
28
test/integration/testdata/screenshot-selector-html/index.html
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Element screenshot</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 40px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
/* A spacer pushes the target away from the origin so the clip has to
|
||||||
|
account for the element offset, not just its size. */
|
||||||
|
.spacer {
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
#target {
|
||||||
|
width: 300px;
|
||||||
|
height: 200px;
|
||||||
|
background: #ff0000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div id="target"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
43
test/integration/testdata/wide-table-html/index.html
vendored
Normal file
43
test/integration/testdata/wide-table-html/index.html
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Wide table</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 8px 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- A single wide row: much wider than tall, so a correct single page is
|
||||||
|
landscape, and the rightmost column only survives if the width was
|
||||||
|
expanded to the content instead of truncated. -->
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Column 1</td>
|
||||||
|
<td>Column 2</td>
|
||||||
|
<td>Column 3</td>
|
||||||
|
<td>Column 4</td>
|
||||||
|
<td>Column 5</td>
|
||||||
|
<td>Column 6</td>
|
||||||
|
<td>Column 7</td>
|
||||||
|
<td>Column 8</td>
|
||||||
|
<td>Column 9</td>
|
||||||
|
<td>Column 10</td>
|
||||||
|
<td>Column 11</td>
|
||||||
|
<td>Column 12</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user