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
|
||||
~height: 600
|
||||
~clip: false
|
||||
~selector:
|
||||
~format: png
|
||||
~quality: 100
|
||||
~optimizeForSpeed: false
|
||||
|
||||
@@ -16,6 +16,7 @@ body:multipart-form {
|
||||
~width: 800
|
||||
~height: 600
|
||||
~clip: false
|
||||
~selector:
|
||||
~format: png
|
||||
~quality: 100
|
||||
~optimizeForSpeed: false
|
||||
|
||||
@@ -15,6 +15,7 @@ body:multipart-form {
|
||||
~width: 800
|
||||
~height: 600
|
||||
~clip: false
|
||||
~selector:
|
||||
~format: png
|
||||
~quality: 100
|
||||
~optimizeForSpeed: false
|
||||
|
||||
2
.github/workflows/continuous-integration.yml
vendored
2
.github/workflows/continuous-integration.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Run linters
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.10.1
|
||||
version: v2.12.2
|
||||
|
||||
lint-prettier:
|
||||
name: Lint non-Golang codebase
|
||||
|
||||
@@ -49,6 +49,7 @@ type browserArguments struct {
|
||||
denyPublicIPs bool
|
||||
clearCache bool
|
||||
clearCookies bool
|
||||
clearStorage bool
|
||||
disableJavaScript bool
|
||||
}
|
||||
|
||||
@@ -222,7 +223,7 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
||||
if stopErr != nil {
|
||||
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()
|
||||
@@ -367,6 +368,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out
|
||||
runtime.Enable(),
|
||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||
clearStorageActionFunc(logger, b.arguments.clearStorage, url),
|
||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||
setCookiesActionFunc(logger, options.Cookies),
|
||||
userAgentOverride(logger, options.UserAgent),
|
||||
@@ -393,6 +395,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
|
||||
runtime.Enable(),
|
||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||
clearStorageActionFunc(logger, b.arguments.clearStorage, url),
|
||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||
setCookiesActionFunc(logger, options.Cookies),
|
||||
userAgentOverride(logger, options.UserAgent),
|
||||
|
||||
@@ -43,6 +43,10 @@ var (
|
||||
// or undefined.
|
||||
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
|
||||
// ChromeDevTools are larger than 100 MB.
|
||||
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
|
||||
@@ -347,6 +351,11 @@ type ScreenshotOptions struct {
|
||||
// dimensions.
|
||||
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
|
||||
// "webp".
|
||||
Format string
|
||||
@@ -370,6 +379,7 @@ func DefaultScreenshotOptions() ScreenshotOptions {
|
||||
Width: 800,
|
||||
Height: 600,
|
||||
Clip: false,
|
||||
Selector: "",
|
||||
Format: "png",
|
||||
Quality: 100,
|
||||
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-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-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-routes", false, "Disable the routes")
|
||||
|
||||
@@ -518,6 +529,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
|
||||
clearCache: flags.MustBool("chromium-clear-cache"),
|
||||
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
||||
clearStorage: flags.MustBool("chromium-clear-storage"),
|
||||
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
||||
}
|
||||
|
||||
|
||||
@@ -366,6 +366,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
||||
var (
|
||||
width, height int
|
||||
clip bool
|
||||
selector string
|
||||
format string
|
||||
quality int
|
||||
optimizeForSpeed bool
|
||||
@@ -376,6 +377,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
||||
Int("width", &width, defaultScreenshotOptions.Width).
|
||||
Int("height", &height, defaultScreenshotOptions.Height).
|
||||
Bool("clip", &clip, defaultScreenshotOptions.Clip).
|
||||
String("selector", &selector, defaultScreenshotOptions.Selector).
|
||||
Custom("format", func(value string) error {
|
||||
if value == "" {
|
||||
format = defaultScreenshotOptions.Format
|
||||
@@ -420,6 +422,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
||||
Width: width,
|
||||
Height: height,
|
||||
Clip: clip,
|
||||
Selector: selector,
|
||||
Format: format,
|
||||
Quality: quality,
|
||||
OptimizeForSpeed: optimizeForSpeed,
|
||||
@@ -963,7 +966,17 @@ func screenshotUrl(ctx *api.Context, chromium Api, url string, options Screensho
|
||||
outputPath := ctx.GeneratePath(ext)
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("screenshot: %w", err)
|
||||
}
|
||||
|
||||
@@ -6,13 +6,16 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/cdp"
|
||||
"github.com/chromedp/cdproto/emulation"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/cdproto/storage"
|
||||
"github.com/chromedp/chromedp"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
@@ -54,6 +57,7 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
||||
defer span.End()
|
||||
|
||||
err := func() error {
|
||||
paperWidth := options.PaperWidth
|
||||
paperHeight := options.PaperHeight
|
||||
pageRanges := options.PageRanges
|
||||
|
||||
@@ -67,9 +71,20 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
||||
|
||||
// There are 96 CSS pixels per inch.
|
||||
// See https://issues.chromium.org/issues/40267771#comment14.
|
||||
// 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
|
||||
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
|
||||
// is large enough to fit the entire content.
|
||||
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
||||
}
|
||||
pageRanges = "1" // little dirty hack to avoid leftovers.
|
||||
}
|
||||
|
||||
@@ -78,7 +93,7 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
||||
WithLandscape(options.Landscape).
|
||||
WithPrintBackground(options.PrintBackground).
|
||||
WithScale(options.Scale).
|
||||
WithPaperWidth(options.PaperWidth).
|
||||
WithPaperWidth(paperWidth).
|
||||
WithPaperHeight(paperHeight).
|
||||
WithMarginTop(options.MarginTop).
|
||||
WithMarginBottom(options.MarginBottom).
|
||||
@@ -188,7 +203,16 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
|
||||
WithOptimizeForSpeed(options.OptimizeForSpeed).
|
||||
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{
|
||||
Width: float64(options.Width),
|
||||
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 {
|
||||
return func(ctx context.Context) error {
|
||||
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 {
|
||||
return func(ctx context.Context) error {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/175.
|
||||
|
||||
@@ -62,6 +62,24 @@ Feature: /forms/chromium/convert/html
|
||||
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)
|
||||
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):
|
||||
|
||||
@@ -22,6 +22,41 @@ Feature: /forms/chromium/convert/url
|
||||
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)
|
||||
Given I have a default Gotenberg container
|
||||
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 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)
|
||||
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):
|
||||
|
||||
@@ -83,6 +83,7 @@ Feature: /debug
|
||||
"chromium-auto-start": "false",
|
||||
"chromium-clear-cache": "false",
|
||||
"chromium-clear-cookies": "false",
|
||||
"chromium-clear-storage": "false",
|
||||
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
||||
"chromium-deny-private-ips": "false",
|
||||
"chromium-deny-public-ips": "false",
|
||||
@@ -221,6 +222,7 @@ Feature: /debug
|
||||
"chromium-auto-start": "false",
|
||||
"chromium-clear-cache": "false",
|
||||
"chromium-clear-cookies": "false",
|
||||
"chromium-clear-storage": "false",
|
||||
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
||||
"chromium-deny-private-ips": "false",
|
||||
"chromium-deny-public-ips": "false",
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/png" // Register the PNG decoder for image.DecodeConfig.
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
@@ -1010,6 +1012,51 @@ func (s *scenario) thePdfShouldHaveImages(ctx context.Context, name string, imag
|
||||
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 {
|
||||
var path string
|
||||
if !strings.HasPrefix(name, "*_") {
|
||||
@@ -1047,7 +1094,9 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if len(matches) < 3 {
|
||||
@@ -1056,22 +1105,22 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
|
||||
|
||||
invert := kind == "should NOT"
|
||||
|
||||
width, err := strconv.Atoi(matches[1])
|
||||
width, err := strconv.ParseFloat(matches[1], 64)
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
@@ -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 content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage)
|
||||
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) {
|
||||
if s.gotenbergContainer != nil {
|
||||
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