mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-14 19:32:15 +01:00
feat(chromium): screenshot a single element via the selector form field (#947)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/cdp"
|
||||
@@ -188,7 +189,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 +239,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")
|
||||
|
||||
Reference in New Issue
Block a user