Compare commits

...

7 Commits

16 changed files with 382 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
err = handleChromiumError(err, options.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)
}
if err != nil { if err != nil {
return fmt.Errorf("screenshot: %w", err) return fmt.Errorf("screenshot: %w", err)
} }

View File

@@ -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.
// We add top and bottom margins so that the content area if options.Landscape {
// is large enough to fit the entire content. // Landscape swaps the paper dimensions, so the page is
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom // 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
// is large enough to fit the entire content.
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.

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View 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>

View 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>

View 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>