feat(chromium): screenshot a single element via the selector form field (#947)

This commit is contained in:
Julien Neuhart
2026-08-13 13:56:17 +02:00
parent 2f6818d3df
commit 8d1eeaa73a
9 changed files with 184 additions and 2 deletions

View File

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

View File

@@ -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, "*_") {
@@ -1599,6 +1646,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))

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>