diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 5369270..a3b1eeb 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -89,16 +89,32 @@ jobs:
run: vendor/bin/pest tests/Browser/InstallTest.php
browser:
- name: Chromium
+ name: Chrome / Gotenberg
runs-on: ubuntu-latest
timeout-minutes: 10
+ services:
+ gotenberg:
+ image: gotenberg/gotenberg:8.38.1
+ ports:
+ - 3000:3000
+ env:
+ API_ENABLE_BASIC_AUTH: 'true'
+ GOTENBERG_API_BASIC_AUTH_USERNAME: screenshot
+ GOTENBERG_API_BASIC_AUTH_PASSWORD: screenshot-test
+ gotenberg-nojs:
+ image: gotenberg/gotenberg:8.38.1
+ ports:
+ - 3001:3000
+ env:
+ CHROMIUM_DISABLE_JAVASCRIPT: 'true'
+
steps:
- uses: actions/checkout@v6
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- extensions: mbstring, dom, fileinfo, pdo_sqlite, sockets, zip
+ extensions: mbstring, dom, fileinfo, pdo_sqlite, sockets, zip, gd
coverage: none
- run: composer install --prefer-dist --no-interaction
- name: Execute browser tests
@@ -106,4 +122,8 @@ jobs:
CHROME_PATH: /usr/bin/google-chrome
SCREENSHOT_NO_SANDBOX: '1'
SCREENSHOT_TEST_INSTALL: '1'
+ SCREENSHOT_GOTENBERG_URL: http://localhost:3000
+ SCREENSHOT_GOTENBERG_NOJS_URL: http://localhost:3001
+ SCREENSHOT_GOTENBERG_USERNAME: screenshot
+ SCREENSHOT_GOTENBERG_PASSWORD: screenshot-test
run: composer test-browser
diff --git a/README.md b/README.md
index 105a2a9..a2b16d3 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@
---
-Screenshot captures HTML, Blade views, and web pages as PNG, JPEG, or PDF files using Chrome. Save captures locally or store them on any Laravel filesystem disk:
+Screenshot captures HTML, Blade views, and web pages as PNG, JPEG, or PDF files using local Chrome or a remote Gotenberg service. Save captures locally or store them on any Laravel filesystem disk:
```php
use DirectoryTree\Screenshot\Facades\Screenshot;
@@ -37,9 +37,9 @@ Screenshot::view('invoices.preview', data: ['invoice' => $invoice])
## Requirements
- PHP 8.2+ and Laravel 12 or 13 for Laravel integration (PHP 8.3+ with Laravel 13)
-- Chrome or Chromium, installable with `php artisan screenshot:install`
+- Chrome or Chromium, installable with `php artisan screenshot:install`, or Gotenberg 8.38+
-Screenshot uses [Chrome PHP](https://github.com/chrome-php/chrome). Node.js and Puppeteer are not required.
+The Chrome driver uses [Chrome PHP](https://github.com/chrome-php/chrome). Node.js and Puppeteer are not required. The Gotenberg driver uses Guzzle to send captures to your Gotenberg service.
## Installation
@@ -364,9 +364,75 @@ $screenshot->html('
Hello, world!
')
Pass `BrowserOptions`, `ImageOptions`, or `PdfOptions` objects through the `browser`, `image`, or `pdf` constructor arguments to set defaults. `view()` requires an injected Laravel view factory; `store()` requires a booted Laravel filesystem.
+### Gotenberg
+
+Use [Gotenberg](https://gotenberg.dev) 8.38 or later to render PNG, JPEG, and PDF captures on a separate service. Local Chrome installation is unnecessary when using this driver.
+
+Start a service with JavaScript enabled (Gotenberg's default):
+
+```bash
+docker run --rm -p 3000:3000 gotenberg/gotenberg:8.38.1
+```
+
+Configure the service URL and optionally make it the default driver:
+
+```dotenv
+SCREENSHOT_DRIVER=gotenberg
+SCREENSHOT_GOTENBERG_URL=http://localhost:3000
+```
+
+Captures must opt into JavaScript to match this service:
+
+```php
+Screenshot::driver('gotenberg')
+ ->view('invoices.preview', ['invoice' => $invoice])
+ ->javascript()
+ ->pdf()
+ ->save(storage_path('app/invoice.pdf'));
+
+Screenshot::driver('gotenberg')
+ ->url('https://example.com')
+ ->javascript()
+ ->size(1200, 800)
+ ->jpeg(85)
+ ->viewport()
+ ->capture();
+```
+
+Alternatively, publish the configuration and set `capture.javascript` to `true` for all captures. Gotenberg cannot toggle JavaScript per request: `gotenberg.javascript` describes the remote service's setting, and captures with a different setting are rejected before sending content. To keep JavaScript disabled, start Gotenberg with `--chromium-disable-javascript`, set `SCREENSHOT_GOTENBERG_JAVASCRIPT=false`, and retain the default `capture.javascript=false`.
+
+Pass Guzzle options through `gotenberg.options`, for example to authenticate to the service:
+
+```php
+'gotenberg' => [
+ 'url' => env('SCREENSHOT_GOTENBERG_URL', 'http://localhost:3000'),
+ 'javascript' => true,
+ 'options' => [
+ 'auth' => [env('GOTENBERG_USERNAME'), env('GOTENBERG_PASSWORD')],
+ 'connect_timeout' => 5,
+ ],
+],
+```
+
+Outside Laravel, use the same driver directly:
+
+```php
+use DirectoryTree\Screenshot\Drivers\GotenbergDriver;
+use DirectoryTree\Screenshot\Screenshot;
+
+$screenshot = new Screenshot(new GotenbergDriver(url: 'http://localhost:3000'));
+$screenshot->html('Hello!
')->javascript()->png()->capture();
+```
+
+The driver supports image viewport dimensions, full-page and viewport captures, image element selectors, JPEG quality, and the existing PDF paper, margin, orientation, background, and CSS page-size settings. `maxSize()` crops the returned image from the top-left without changing the layout viewport; install PHP's GD extension when images can exceed these limits. Gotenberg renders and transfers the complete image before this crop, and cropped JPEGs are re-encoded at the selected quality.
+
+The remote service must be able to reach URL sources and absolute asset URLs. Local application files and Docker host `localhost` addresses are not automatically accessible from Gotenberg.
+
+Gotenberg manages browser lifetime; shared socket keys and element selection for PDFs are unsupported and throw exceptions. `timeouts(browser: ...)` limits the entire HTTP request in milliseconds. The separate load timeout and `failOnTimeout()` setting do not control Gotenberg: service-side timeouts still apply, and a timed-out request always fails instead of returning partial content. PDF viewport dimensions are managed by Gotenberg. HTTP errors include Gotenberg's response and trace identifier, and redirects are rejected.
+
### Custom Drivers
-The facade uses `ScreenshotManager` to resolve named drivers. Chrome is included as `chrome` and is the default. Calls such as `Screenshot::html()` are forwarded to the configured default driver:
+The facade uses `ScreenshotManager` to resolve named drivers. Chrome (`chrome`) is the default, and Gotenberg is available as `gotenberg`. Calls such as `Screenshot::html()` are forwarded to the configured default driver:
```php
Screenshot::html('Hello!
')->capture();
diff --git a/composer.json b/composer.json
index 5837649..6ed83c4 100644
--- a/composer.json
+++ b/composer.json
@@ -35,6 +35,7 @@
"pestphp/pest-plugin-laravel": "^3.0|^4.0"
},
"suggest": {
+ "ext-gd": "Required to crop Gotenberg images that exceed the configured maximum dimensions.",
"ext-zip": "Required to download Chrome using screenshot:install."
},
"autoload": {
diff --git a/config/screenshot.php b/config/screenshot.php
index 60f10d4..7632054 100644
--- a/config/screenshot.php
+++ b/config/screenshot.php
@@ -7,8 +7,8 @@
| Default Driver
|--------------------------------------------------------------------------
|
- | Captures use this driver unless one is selected explicitly. Chrome is
- | included by default. Register custom drivers using Screenshot::extend().
+ | Captures use this driver unless one is selected explicitly. Chrome and
+ | Gotenberg are included. Register custom drivers using Screenshot::extend().
|
*/
@@ -35,6 +35,22 @@
'options' => [],
],
+ /*
+ |--------------------------------------------------------------------------
+ | Gotenberg
+ |--------------------------------------------------------------------------
+ |
+ | HTTP options are passed to Guzzle. The JavaScript setting must match
+ | the remote service and each capture; it does not configure Gotenberg.
+ |
+ */
+
+ 'gotenberg' => [
+ 'url' => env('SCREENSHOT_GOTENBERG_URL', 'http://localhost:3000'),
+ 'options' => [],
+ 'javascript' => env('SCREENSHOT_GOTENBERG_JAVASCRIPT', true),
+ ],
+
/*
|--------------------------------------------------------------------------
| Shared Browser Sockets
diff --git a/src/Drivers/Chrome/ImageCapture.php b/src/Drivers/Chrome/ImageCapture.php
index cd28285..7a17519 100644
--- a/src/Drivers/Chrome/ImageCapture.php
+++ b/src/Drivers/Chrome/ImageCapture.php
@@ -2,7 +2,7 @@
namespace DirectoryTree\Screenshot\Drivers\Chrome;
-use DirectoryTree\Screenshot\BrowserOptions;
+use DirectoryTree\Screenshot\CaptureOptions;
use DirectoryTree\Screenshot\Enums\OutputFormat;
use DirectoryTree\Screenshot\ImageOptions;
use DirectoryTree\Screenshot\ImageResult;
@@ -14,11 +14,11 @@ class ImageCapture implements CapturesPage
{
/**
* Create an image capture strategy.
+ *
+ * @param CaptureOptions $options
*/
public function __construct(
- protected BrowserOptions $browser,
- protected ImageOptions $options,
- protected ?string $element = null,
+ protected CaptureOptions $options,
) {}
/**
@@ -26,25 +26,27 @@ public function __construct(
*/
public function capture(Page $page): ImageResult
{
+ $options = $this->options->output();
+
$clip = $this->clip($page);
$parameters = [
- 'format' => $this->options->getFormat()->value,
+ 'format' => $options->getFormat()->value,
'captureBeyondViewport' => true,
'clip' => new Clip(
$clip->getX(), $clip->getY(),
- max(1, min($clip->getWidth(), $this->options->getMaxWidth())),
- max(1, min($clip->getHeight(), $this->options->getMaxHeight())),
+ max(1, min($clip->getWidth(), $options->getMaxWidth())),
+ max(1, min($clip->getHeight(), $options->getMaxHeight())),
),
];
- if ($this->options->getFormat() === OutputFormat::Jpeg) {
- $parameters['quality'] = $this->options->getQuality();
+ if ($options->getFormat() === OutputFormat::Jpeg) {
+ $parameters['quality'] = $options->getQuality();
}
return new ImageResult(
$page->screenshot($parameters)->getBase64(
- $this->browser->getBrowserTimeout()
+ $this->options->browser()->getBrowserTimeout()
)
);
}
@@ -54,15 +56,17 @@ public function capture(Page $page): ImageResult
*/
protected function clip(Page $page): Clip
{
- if ($this->element !== null) {
+ if ($this->options->element() !== null) {
return $this->getElementClip($page);
}
- if ($this->options->getFullPage()) {
- return $page->getFullPageClip($this->browser->getBrowserTimeout());
+ $browser = $this->options->browser();
+
+ if ($this->options->output()->getFullPage()) {
+ return $page->getFullPageClip($browser->getBrowserTimeout());
}
- return new Clip(0, 0, $this->browser->getWidth(), $this->browser->getHeight());
+ return new Clip(0, 0, $browser->getWidth(), $browser->getHeight());
}
/**
@@ -70,13 +74,15 @@ protected function clip(Page $page): Clip
*/
protected function getElementClip(Page $page): Clip
{
- $element = $page->dom()->querySelector($this->element)
- ?? throw new RuntimeException("No element matches the selector [{$this->element}].");
+ $selector = $this->options->element();
+
+ $element = $page->dom()->querySelector($selector)
+ ?? throw new RuntimeException("No element matches the selector [{$selector}].");
$position = $element->getPosition('border');
if (! $position || $position->getWidth() <= 0 || $position->getHeight() <= 0) {
- throw new RuntimeException("The element [{$this->element}] has no layout bounds.");
+ throw new RuntimeException("The element [{$selector}] has no layout bounds.");
}
return new Clip($position->getX(), $position->getY(), $position->getWidth(), $position->getHeight());
diff --git a/src/Drivers/Chrome/PageCapture.php b/src/Drivers/Chrome/PageCapture.php
index 37dd097..6988a38 100644
--- a/src/Drivers/Chrome/PageCapture.php
+++ b/src/Drivers/Chrome/PageCapture.php
@@ -107,16 +107,8 @@ protected function newCapture(): CapturesPage
$options = $this->options;
return match ($options->format()) {
- OutputFormat::Png, OutputFormat::Jpeg => new ImageCapture(
- $options->browser(),
- $options->output(),
- $options->element()
- ),
- OutputFormat::Pdf => new PdfCapture(
- $options->browser(),
- $options->output(),
- $options->element()
- ),
+ OutputFormat::Png, OutputFormat::Jpeg => new ImageCapture($options),
+ OutputFormat::Pdf => new PdfCapture($options),
};
}
diff --git a/src/Drivers/Chrome/PdfCapture.php b/src/Drivers/Chrome/PdfCapture.php
index a94e7ee..b34e28e 100644
--- a/src/Drivers/Chrome/PdfCapture.php
+++ b/src/Drivers/Chrome/PdfCapture.php
@@ -2,7 +2,7 @@
namespace DirectoryTree\Screenshot\Drivers\Chrome;
-use DirectoryTree\Screenshot\BrowserOptions;
+use DirectoryTree\Screenshot\CaptureOptions;
use DirectoryTree\Screenshot\PdfOptions;
use DirectoryTree\Screenshot\PdfResult;
use HeadlessChromium\Page;
@@ -12,11 +12,11 @@ class PdfCapture implements CapturesPage
{
/**
* Create a PDF capture strategy.
+ *
+ * @param CaptureOptions $options
*/
public function __construct(
- protected BrowserOptions $browser,
- protected PdfOptions $options,
- protected ?string $element = null,
+ protected CaptureOptions $options,
) {}
/**
@@ -24,15 +24,15 @@ public function __construct(
*/
public function capture(Page $page): PdfResult
{
- if ($this->element !== null) {
+ if ($this->options->element() !== null) {
$this->isolateElement($page);
}
return new PdfResult(
$page->pdf(
- $this->options->toArray()
+ $this->options->output()->toArray()
)->getBase64(
- $this->browser->getBrowserTimeout()
+ $this->options->browser()->getBrowserTimeout()
)
);
}
@@ -42,7 +42,9 @@ public function capture(Page $page): PdfResult
*/
protected function isolateElement(Page $page): void
{
- $selector = json_encode($this->element, JSON_THROW_ON_ERROR);
+ $element = $this->options->element();
+
+ $selector = json_encode($element, JSON_THROW_ON_ERROR);
$found = $page->evaluate(<< {
@@ -59,10 +61,10 @@ protected function isolateElement(Page $page): void
return true;
})()
- JS)->getReturnValue($this->browser->getBrowserTimeout());
+ JS)->getReturnValue($this->options->browser()->getBrowserTimeout());
if (! $found) {
- throw new RuntimeException("No element matches the selector [{$this->element}].");
+ throw new RuntimeException("No element matches the selector [{$element}].");
}
}
}
diff --git a/src/Drivers/Gotenberg/ImageOutput.php b/src/Drivers/Gotenberg/ImageOutput.php
new file mode 100644
index 0000000..983ee8b
--- /dev/null
+++ b/src/Drivers/Gotenberg/ImageOutput.php
@@ -0,0 +1,137 @@
+ $options
+ */
+ public function __construct(
+ protected CaptureOptions $options,
+ ) {}
+
+ /**
+ * Get the Chromium screenshot route.
+ */
+ public function route(): string
+ {
+ return 'screenshot';
+ }
+
+ /**
+ * Get the screenshot form fields without changing the layout viewport.
+ */
+ public function fields(): array
+ {
+ $browser = $this->options->browser();
+ $options = $this->options->output();
+
+ $fields = [
+ 'width' => $browser->getWidth(),
+ 'height' => $browser->getHeight(),
+ 'clip' => ! $options->getFullPage(),
+ 'format' => $options->getFormat()->value,
+ ];
+
+ if ($this->options->element() !== null) {
+ $fields['selector'] = $this->options->element();
+ }
+
+ if ($options->getFormat() === OutputFormat::Jpeg) {
+ $fields['quality'] = $options->getQuality();
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Validate the image and crop oversized results without scaling.
+ */
+ public function result(string $contents): ImageResult
+ {
+ $options = $this->options->output();
+
+ $result = new ImageResult(base64_encode($contents));
+
+ if ($result->mimeType() !== 'image/'.$options->getFormat()->value) {
+ throw new RuntimeException('Gotenberg returned an unexpected image format.');
+ }
+
+ return $this->crop($result);
+ }
+
+ /**
+ * Crop oversized images to their maximum dimensions without scaling.
+ */
+ protected function crop(ImageResult $result): ImageResult
+ {
+ $options = $this->options->output();
+
+ $width = min($result->width(), $options->getMaxWidth());
+ $height = min($result->height(), $options->getMaxHeight());
+
+ if ($width === $result->width() && $height === $result->height()) {
+ return $result;
+ }
+
+ if (! extension_loaded('gd')) {
+ throw new RuntimeException('The GD extension is required to crop Gotenberg images to their maximum dimensions.');
+ }
+
+ $image = ErrorHandler::suppress(fn () => imagecreatefromstring($result->contents()));
+
+ if ($image === false) {
+ throw new RuntimeException('Unable to decode the Gotenberg image for cropping.');
+ }
+
+ $cropped = imagecrop($image, [
+ 'x' => 0,
+ 'y' => 0,
+ 'width' => $width,
+ 'height' => $height,
+ ]);
+
+ if ($cropped === false) {
+ throw new RuntimeException('Unable to crop the Gotenberg image.');
+ }
+
+ imagesavealpha($cropped, true);
+
+ return new ImageResult(base64_encode($this->encode($cropped)));
+ }
+
+ /**
+ * Encode a cropped image using the configured output format.
+ */
+ protected function encode(GdImage $image): string
+ {
+ $options = $this->options->output();
+
+ ob_start();
+
+ try {
+ $written = $options->getFormat() === OutputFormat::Jpeg
+ ? imagejpeg($image, null, $options->getQuality())
+ : imagepng($image);
+
+ if (! $written) {
+ throw new RuntimeException('Unable to encode the cropped Gotenberg image.');
+ }
+
+ return ob_get_contents();
+ } finally {
+ ob_end_clean();
+ }
+ }
+}
diff --git a/src/Drivers/Gotenberg/Output.php b/src/Drivers/Gotenberg/Output.php
new file mode 100644
index 0000000..08cc929
--- /dev/null
+++ b/src/Drivers/Gotenberg/Output.php
@@ -0,0 +1,24 @@
+ $options
+ */
+ public function __construct(
+ protected CaptureOptions $options,
+ ) {
+ if ($options->element() !== null) {
+ throw new InvalidArgumentException('Gotenberg does not support element selection for PDF captures.');
+ }
+ }
+
+ /**
+ * Get the Chromium conversion route.
+ */
+ public function route(): string
+ {
+ return 'convert';
+ }
+
+ /**
+ * Translate the PDF layout into Gotenberg's form fields.
+ */
+ public function fields(): array
+ {
+ $options = $this->options->output();
+
+ $fields = [
+ 'paperWidth' => $options->getPaperWidth().'mm',
+ 'paperHeight' => $options->getPaperHeight().'mm',
+ 'landscape' => $options->getLandscape(),
+ 'printBackground' => $options->getBackground(),
+ 'preferCssPageSize' => $options->getPreferCssPageSize(),
+ ];
+
+ foreach ($options->getMargins() as $side => $margin) {
+ $fields['margin'.ucfirst($side)] = $margin.'mm';
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Validate the PDF and create a capture result.
+ */
+ public function result(string $contents): PdfResult
+ {
+ return new PdfResult(base64_encode($contents));
+ }
+}
diff --git a/src/Drivers/GotenbergDriver.php b/src/Drivers/GotenbergDriver.php
new file mode 100644
index 0000000..2b4c8df
--- /dev/null
+++ b/src/Drivers/GotenbergDriver.php
@@ -0,0 +1,143 @@
+validateUrl($url);
+ }
+
+ /**
+ * Capture HTML or a URL through Gotenberg's Chromium routes.
+ */
+ public function capture(string $source, SourceType $type, CaptureOptions $options): ImageResult|PdfResult
+ {
+ $this->validateOptions($options);
+
+ $output = $this->newOutput($options);
+
+ $contents = $this->capturePage($source, $type, $options, $output);
+
+ return $output->result($contents);
+ }
+
+ /**
+ * Create an output handler for the selected format.
+ */
+ protected function newOutput(CaptureOptions $options): Output
+ {
+ return match ($options->format()) {
+ OutputFormat::Png, OutputFormat::Jpeg => new ImageOutput($options),
+ OutputFormat::Pdf => new PdfOutput($options),
+ };
+ }
+
+ /**
+ * Send a capture request and return its output bytes.
+ */
+ protected function capturePage(string $source, SourceType $type, CaptureOptions $options, Output $output): string
+ {
+ $route = $output->route();
+
+ $response = $this->http->request('POST', rtrim($this->url, '/')."/forms/chromium/{$route}/{$type->value}", [
+ 'multipart' => $this->getMultipart($source, $type, $output->fields()),
+ 'timeout' => $options->browser()->getBrowserTimeout() / 1000,
+ 'http_errors' => false,
+ 'allow_redirects' => false,
+ ]);
+
+ if ($response->getStatusCode() !== 200) {
+ throw new RuntimeException(sprintf(
+ 'Gotenberg capture failed (HTTP %d, trace: %s): %s',
+ $response->getStatusCode(),
+ $response->getHeaderLine('Gotenberg-Trace'),
+ (string) $response->getBody(),
+ ));
+ }
+
+ return (string) $response->getBody();
+ }
+
+ /**
+ * Prepare the source and capture fields for a multipart request.
+ */
+ protected function getMultipart(string $source, SourceType $type, array $fields): array
+ {
+ $multipart = [];
+
+ if ($type === SourceType::Html) {
+ $multipart[] = [
+ 'name' => 'files',
+ 'contents' => $source,
+ 'filename' => 'index.html',
+ 'headers' => ['Content-Type' => 'text/html'],
+ ];
+ } else {
+ $multipart[] = ['name' => 'url', 'contents' => $source];
+ }
+
+ foreach ($fields as $name => $value) {
+ if (is_bool($value)) {
+ $value = $value ? 'true' : 'false';
+ }
+
+ $multipart[] = [
+ 'name' => $name,
+ 'contents' => (string) $value,
+ ];
+ }
+
+ return $multipart;
+ }
+
+ /**
+ * Require an HTTP or HTTPS base URL without a query or fragment.
+ */
+ protected function validateUrl(string $url): void
+ {
+ $parts = parse_url($url);
+
+ if (! $parts || ! Str::isUrl($url, ['http', 'https']) || isset($parts['query']) || isset($parts['fragment'])) {
+ throw new InvalidArgumentException('The Gotenberg URL must be an HTTP or HTTPS base URL without a query or fragment.');
+ }
+ }
+
+ /**
+ * Validate capture settings and their compatibility with the remote browser.
+ */
+ protected function validateOptions(CaptureOptions $options): void
+ {
+ $options->validate();
+
+ if ($options->browser()->getSocket() !== null) {
+ throw new InvalidArgumentException('Gotenberg manages its own browsers and does not support shared socket keys.');
+ }
+
+ if ($options->browser()->getJavascript() !== $this->javascript) {
+ throw new InvalidArgumentException('The capture JavaScript setting must match the Gotenberg service. Configure screenshot.gotenberg.javascript and use javascript() or screenshot.capture.javascript accordingly.');
+ }
+ }
+}
diff --git a/src/Screenshot.php b/src/Screenshot.php
index f1371e5..1e0d8fd 100644
--- a/src/Screenshot.php
+++ b/src/Screenshot.php
@@ -7,6 +7,7 @@
use DirectoryTree\Screenshot\Drivers\ChromeDriver;
use DirectoryTree\Screenshot\Enums\SourceType;
use Illuminate\Contracts\View\Factory as ViewFactory;
+use Illuminate\Support\Str;
use InvalidArgumentException;
use LogicException;
@@ -40,7 +41,7 @@ public function html(string $html): PendingSource
*/
public function url(string $url): PendingSource
{
- if (! filter_var($url, FILTER_VALIDATE_URL) || ! in_array(strtolower(parse_url($url, PHP_URL_SCHEME) ?? ''), ['http', 'https'], true)) {
+ if (! Str::isUrl($url, ['http', 'https'])) {
throw new InvalidArgumentException('The screenshot URL must be a valid HTTP or HTTPS URL.');
}
diff --git a/src/ScreenshotManager.php b/src/ScreenshotManager.php
index 8414346..64505ab 100644
--- a/src/ScreenshotManager.php
+++ b/src/ScreenshotManager.php
@@ -5,11 +5,13 @@
use DirectoryTree\Screenshot\Contracts\Driver;
use DirectoryTree\Screenshot\Contracts\Factory;
use DirectoryTree\Screenshot\Drivers\ChromeDriver;
+use DirectoryTree\Screenshot\Drivers\GotenbergDriver;
use DirectoryTree\Screenshot\Enums\OutputFormat;
use DirectoryTree\Screenshot\Enums\PaperSize;
use DirectoryTree\Screenshot\Support\ChromeInstaller;
use DirectoryTree\Screenshot\Testing\ScreenshotFake;
use DirectoryTree\Screenshot\Testing\ScreenshotManagerFake;
+use GuzzleHttp\Client;
use Illuminate\Support\Manager;
/**
@@ -65,6 +67,18 @@ protected function createChromeDriver(): ChromeDriver
);
}
+ /**
+ * Create the Gotenberg capture driver.
+ */
+ protected function createGotenbergDriver(): GotenbergDriver
+ {
+ return new GotenbergDriver(
+ $this->config->get('screenshot.gotenberg.url', 'http://localhost:3000'),
+ new Client($this->config->get('screenshot.gotenberg.options', [])),
+ $this->config->get('screenshot.gotenberg.javascript', true),
+ );
+ }
+
/**
* Create a screenshot factory with the application's capture defaults.
*/
diff --git a/src/Support/BrowserSocket.php b/src/Support/BrowserSocket.php
index 59cfba4..38c5de6 100644
--- a/src/Support/BrowserSocket.php
+++ b/src/Support/BrowserSocket.php
@@ -6,6 +6,7 @@
use HeadlessChromium\BrowserFactory;
use HeadlessChromium\Exception\BrowserConnectionFailed;
use HeadlessChromium\Exception\OperationTimedOut;
+use Illuminate\Support\Str;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
@@ -224,11 +225,8 @@ protected function read(): ?string
}
$uri = trim($contents);
- $parts = parse_url($uri);
- return $parts && in_array($parts['scheme'] ?? '', ['ws', 'wss'], true) && ! empty($parts['host'])
- ? $uri
- : null;
+ return Str::isUrl($uri, ['ws', 'wss']) ? $uri : null;
}
/**
diff --git a/tests/Browser/ElementTest.php b/tests/Browser/ElementTest.php
index 464b43e..ac354f2 100644
--- a/tests/Browser/ElementTest.php
+++ b/tests/Browser/ElementTest.php
@@ -1,6 +1,7 @@
evaluate("document.querySelector('input').value = 'Current value'")->getReturnValue();
- (new PdfCapture(new BrowserOptions, new PdfOptions, '#invoice'))->capture($page);
+ $capture = new PdfCapture(new CaptureOptions(new BrowserOptions, new PdfOptions, '#invoice'));
+
+ $capture->capture($page);
$page->getSession()->sendMessageSync(new Message('Emulation.setEmulatedMedia', ['media' => 'print']));
$layout = $page->evaluate(<<<'JS'
diff --git a/tests/Browser/GotenbergTest.php b/tests/Browser/GotenbergTest.php
new file mode 100644
index 0000000..423e97f
--- /dev/null
+++ b/tests/Browser/GotenbergTest.php
@@ -0,0 +1,204 @@
+group('browser');
+
+function gotenbergScreenshot(bool $authenticate = true): Screenshot
+{
+ $httpOptions = [];
+
+ if ($authenticate && ($username = getenv('SCREENSHOT_GOTENBERG_USERNAME'))) {
+ $httpOptions['auth'] = [$username, getenv('SCREENSHOT_GOTENBERG_PASSWORD')];
+ }
+
+ return new Screenshot(
+ new GotenbergDriver(getenv('SCREENSHOT_GOTENBERG_URL'), new Client($httpOptions)),
+ );
+}
+
+test('real Gotenberg renders full-page PNG and viewport JPEG captures', function () {
+ $source = gotenbergScreenshot()->html(file_get_contents(__DIR__.'/../Fixtures/document.html'))
+ ->javascript()
+ ->size(640, 480);
+
+ $image = $source->png()->capture();
+
+ expect($image->mimeType())->toBe('image/png');
+ expect($image->width())->toBe(640);
+ expect($image->height())->toBe(960);
+
+ $viewport = $source->jpeg(73)
+ ->viewport()
+ ->capture();
+
+ expect($viewport->mimeType())->toBe('image/jpeg');
+ expect($viewport->width())->toBe(640);
+ expect($viewport->height())->toBe(480);
+})->skip(
+ fn () => ! getenv('SCREENSHOT_GOTENBERG_URL'),
+ 'Set SCREENSHOT_GOTENBERG_URL to run Gotenberg integration tests.',
+);
+
+test('real Gotenberg crops images without changing responsive layout', function (string $format) {
+ $result = gotenbergScreenshot()->html(<<<'HTML'
+
+
+ HTML)
+ ->javascript()
+ ->size(640, 480)
+ ->{$format}()
+ ->maxSize(100, 200)
+ ->capture();
+
+ expect($result->width())->toBe(100);
+ expect($result->height())->toBe(200);
+
+ $image = imagecreatefromstring($result->contents());
+ $color = imagecolorsforindex($image, imagecolorat($image, 0, 0));
+
+ expect($color['green'])->toBeGreaterThan(250);
+ expect($color['red'])->toBeLessThan(5);
+
+ $marker = imagecolorsforindex($image, imagecolorat($image, 90, 10));
+
+ expect($marker['green'])->toBeLessThan(5);
+})->with(['png', 'jpeg'])->skip(
+ fn () => ! getenv('SCREENSHOT_GOTENBERG_URL'),
+ 'Set SCREENSHOT_GOTENBERG_URL to run Gotenberg integration tests.',
+)->skip(fn () => ! extension_loaded('gd'), 'Cropping requires GD.');
+
+test('real Gotenberg applies the configured service JavaScript setting', function () {
+ $html = ''
+ .'';
+ $factory = new Screenshot(
+ new GotenbergDriver(url: getenv('SCREENSHOT_GOTENBERG_NOJS_URL'), javascript: false),
+ );
+ $disabled = $factory->html($html)
+ ->size(400, 300)
+ ->capture();
+ $enabled = gotenbergScreenshot()->html($html)
+ ->javascript()
+ ->size(400, 300)
+ ->capture();
+
+ expect($disabled->height())->toBe(300);
+ expect($enabled->height())->toBe(900);
+})->skip(
+ fn () => ! getenv('SCREENSHOT_GOTENBERG_URL'),
+ 'Set SCREENSHOT_GOTENBERG_URL to run Gotenberg integration tests.',
+)->skip(
+ fn () => ! getenv('SCREENSHOT_GOTENBERG_NOJS_URL'),
+ 'Set SCREENSHOT_GOTENBERG_NOJS_URL to test a service with JavaScript disabled.',
+);
+
+test('real Gotenberg rejects requests without the configured service authentication', function () {
+ $factory = gotenbergScreenshot(authenticate: false);
+
+ expect(
+ fn () => $factory->html('content')
+ ->javascript()
+ ->pdf()
+ ->capture(),
+ )->toThrow(RuntimeException::class, 'HTTP 401');
+})->skip(
+ fn () => ! getenv('SCREENSHOT_GOTENBERG_URL'),
+ 'Set SCREENSHOT_GOTENBERG_URL to run Gotenberg integration tests.',
+)->skip(
+ fn () => ! getenv('SCREENSHOT_GOTENBERG_USERNAME'),
+ 'Configure service authentication to test unauthorized requests.',
+);
+
+test('real Gotenberg crops selected elements to the maximum dimensions', function () {
+ $result = gotenbergScreenshot()->html(<<<'HTML'
+
+ Card
+ HTML)
+ ->javascript()
+ ->element('.card')
+ ->png()
+ ->maxSize(120, 70)
+ ->capture();
+
+ expect($result->width())->toBe(120);
+ expect($result->height())->toBe(70);
+})->skip(
+ fn () => ! getenv('SCREENSHOT_GOTENBERG_URL'),
+ 'Set SCREENSHOT_GOTENBERG_URL to run Gotenberg integration tests.',
+)->skip(fn () => ! extension_loaded('gd'), 'Cropping requires GD.');
+
+test('real Gotenberg renders a URL reachable from the service', function () {
+ $result = gotenbergScreenshot()->url('http://localhost:3000/health')
+ ->javascript()
+ ->size(400, 300)
+ ->png()
+ ->viewport()
+ ->capture();
+
+ expect($result->mimeType())->toBe('image/png');
+ expect($result->width())->toBe(400);
+ expect($result->height())->toBe(300);
+})->skip(
+ fn () => ! getenv('SCREENSHOT_GOTENBERG_URL'),
+ 'Set SCREENSHOT_GOTENBERG_URL to run Gotenberg integration tests.',
+);
+
+test('real Gotenberg prints PDFs with custom dimensions', function () {
+ $result = gotenbergScreenshot()->html('Invoice
')
+ ->javascript()
+ ->pdf()
+ ->paperSize(100, 200)
+ ->margins(0, 0, 0, 0)
+ ->landscape()
+ ->capture();
+
+ expect($result->contents())->toStartWith('%PDF-');
+ expect($result->mimeType())->toBe('application/pdf');
+
+ [$width, $height] = pdfPageSize($result->contents());
+
+ expect($width)->toEqualWithDelta(200 / 25.4 * 72, 1);
+ expect($height)->toEqualWithDelta(100 / 25.4 * 72, 1);
+})->skip(
+ fn () => ! getenv('SCREENSHOT_GOTENBERG_URL'),
+ 'Set SCREENSHOT_GOTENBERG_URL to run Gotenberg integration tests.',
+);
+
+test('real Gotenberg honors CSS page sizes', function () {
+ $result = gotenbergScreenshot()->html('CSS paper
')
+ ->javascript()
+ ->pdf()
+ ->preferCssPageSize()
+ ->capture();
+
+ [$width, $height] = pdfPageSize($result->contents());
+
+ expect($width)->toEqualWithDelta(120 / 25.4 * 72, 1);
+ expect($height)->toEqualWithDelta(180 / 25.4 * 72, 1);
+})->skip(
+ fn () => ! getenv('SCREENSHOT_GOTENBERG_URL'),
+ 'Set SCREENSHOT_GOTENBERG_URL to run Gotenberg integration tests.',
+);
+
+test('real Gotenberg reports selectors that match no element', function () {
+ expect(
+ fn () => gotenbergScreenshot()->html('Content
')
+ ->javascript()
+ ->element('#missing')
+ ->capture(),
+ )->toThrow(RuntimeException::class, 'HTTP 400');
+})->skip(
+ fn () => ! getenv('SCREENSHOT_GOTENBERG_URL'),
+ 'Set SCREENSHOT_GOTENBERG_URL to run Gotenberg integration tests.',
+);
diff --git a/tests/Browser/PdfTest.php b/tests/Browser/PdfTest.php
index 6b61ae0..9dc3c86 100644
--- a/tests/Browser/PdfTest.php
+++ b/tests/Browser/PdfTest.php
@@ -21,13 +21,6 @@ function pdfScreenshot(): Screenshot
);
}
-function pdfPageSize(string $contents): array
-{
- preg_match('~/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]~', $contents, $matches);
-
- return [(float) ($matches[1] ?? 0), (float) ($matches[2] ?? 0)];
-}
-
test('real Chrome prints a paginated PDF using the source viewport', function () {
$result = pdfScreenshot()->html(file_get_contents(__DIR__.'/../Fixtures/document-pdf.html'))
->size(320, 240)
diff --git a/tests/Feature/ScreenshotManagerTest.php b/tests/Feature/ScreenshotManagerTest.php
index 88fbd2a..65df017 100644
--- a/tests/Feature/ScreenshotManagerTest.php
+++ b/tests/Feature/ScreenshotManagerTest.php
@@ -9,6 +9,47 @@
use DirectoryTree\Screenshot\ScreenshotManager;
use DirectoryTree\Screenshot\Support\ChromeInstaller;
use DirectoryTree\Screenshot\Testing\ScreenshotFake;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
+use GuzzleHttp\Psr7\Response;
+
+test('the built-in Gotenberg driver renders views with configured defaults without resolving Chrome', function () {
+ $installer = Mockery::mock(ChromeInstaller::class);
+ $installer->shouldNotReceive('path');
+ app()->instance(ChromeInstaller::class, $installer);
+ $history = [];
+ $mock = new MockHandler([
+ new Response(200, [], file_get_contents(__DIR__.'/../../resources/blank.pdf')),
+ new Response(200, [], base64_decode(imageFixture())),
+ ]);
+ $stack = HandlerStack::create($mock);
+
+ $stack->push(Middleware::history($history));
+
+ config([
+ 'screenshot.default' => 'gotenberg',
+ 'screenshot.gotenberg.url' => 'https://gotenberg.example.test',
+ 'screenshot.gotenberg.options' => ['handler' => $stack],
+ 'screenshot.gotenberg.javascript' => false,
+ 'screenshot.capture.width' => 900,
+ ]);
+
+ $manager = app(ScreenshotManager::class);
+
+ expect($manager->getDrivers())->toBe([]);
+
+ Screenshot::view('card', ['name' => ''])
+ ->pdf()
+ ->capture();
+ Screenshot::driver('gotenberg')
+ ->html('image')
+ ->capture();
+
+ expect($manager->driver())->toBe($manager->driver('gotenberg'));
+ expect((string) $history[0]['request']->getBody())->toContain('<Steve>');
+ expect((string) $history[1]['request']->getBody())->toContain("\r\n900\r\n");
+});
test('Chrome is the default driver and is resolved lazily', function () {
$installer = Mockery::mock(ChromeInstaller::class);
diff --git a/tests/Pest.php b/tests/Pest.php
index 59687af..34ed576 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -13,6 +13,16 @@ function imageFixture(): string
return 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII=';
}
+/**
+ * Get the dimensions of the first PDF page in points.
+ */
+function pdfPageSize(string $contents): array
+{
+ preg_match('~/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]~', $contents, $matches);
+
+ return [(float) ($matches[1] ?? 0), (float) ($matches[2] ?? 0)];
+}
+
/**
* Get a path within an isolated temporary directory for the current test.
*/
diff --git a/tests/Unit/ChromeCaptureTest.php b/tests/Unit/ChromeCaptureTest.php
index 66c8fbb..64740de 100644
--- a/tests/Unit/ChromeCaptureTest.php
+++ b/tests/Unit/ChromeCaptureTest.php
@@ -1,6 +1,7 @@
with(1234)
->andReturn(imageFixture());
- expect((new ImageCapture($browser, $options))->capture($page))->toBeInstanceOf(ImageResult::class);
+ $capture = new ImageCapture(new CaptureOptions($browser, $options));
+
+ expect($capture->capture($page))->toBeInstanceOf(ImageResult::class);
})->with([
'PNG' => OutputFormat::Png,
'JPEG' => OutputFormat::Jpeg,
@@ -98,7 +101,9 @@
->with(4321)
->andReturn(base64_encode(file_get_contents(__DIR__.'/../../resources/blank.pdf')));
- expect((new PdfCapture($browser, $options))->capture($page))->toBeInstanceOf(PdfResult::class);
+ $capture = new PdfCapture(new CaptureOptions($browser, $options));
+
+ expect($capture->capture($page))->toBeInstanceOf(PdfResult::class);
});
test('element image bounds retain their document coordinates when capped', function ($fullPage) {
@@ -127,5 +132,7 @@
})->andReturn($image);
$image->shouldReceive('getBase64')->once()->with(1234)->andReturn(imageFixture());
- expect((new ImageCapture($browser, $options, '#invoice'))->capture($page))->toBeInstanceOf(ImageResult::class);
+ $capture = new ImageCapture(new CaptureOptions($browser, $options, '#invoice'));
+
+ expect($capture->capture($page))->toBeInstanceOf(ImageResult::class);
})->with(['full page' => true, 'viewport' => false]);
diff --git a/tests/Unit/GotenbergDriverTest.php b/tests/Unit/GotenbergDriverTest.php
new file mode 100644
index 0000000..6d2e129
--- /dev/null
+++ b/tests/Unit/GotenbergDriverTest.php
@@ -0,0 +1,272 @@
+push(Middleware::history($history));
+
+ $factory = new Screenshot(
+ new GotenbergDriver(
+ url: 'https://gotenberg.example.test/api/',
+ http: new Client([
+ 'handler' => $stack,
+ 'auth' => ['user', 'password'],
+ ]),
+ ),
+ );
+
+ $result = $factory->html('Invoice
')
+ ->javascript()
+ ->pdf()
+ ->paperSize(100, 200)
+ ->landscape()
+ ->margins(1, 2, 3, 4)
+ ->background(false)
+ ->preferCssPageSize()
+ ->capture();
+
+ expect($result->mimeType())->toBe('application/pdf');
+
+ $request = $history[0]['request'];
+ $body = (string) $request->getBody();
+
+ expect($request->getMethod())->toBe('POST');
+ expect((string) $request->getUri())->toBe('https://gotenberg.example.test/api/forms/chromium/convert/html');
+ expect($request->getHeaderLine('Authorization'))->toBe('Basic '.base64_encode('user:password'));
+ expect($request->getHeaderLine('Content-Type'))->toStartWith('multipart/form-data; boundary=');
+ expect($body)->toContain('name="files"; filename="index.html"', 'Invoice
');
+
+ $fields = [
+ 'paperWidth' => '100mm',
+ 'paperHeight' => '200mm',
+ 'marginTop' => '1mm',
+ 'marginRight' => '2mm',
+ 'marginBottom' => '3mm',
+ 'marginLeft' => '4mm',
+ 'landscape' => 'true',
+ 'printBackground' => 'false',
+ 'preferCssPageSize' => 'true',
+ ];
+
+ foreach ($fields as $field => $value) {
+ expect($body)->toMatch('/name="'.preg_quote($field, '/').'"\r\n(?:[^\r\n]+\r\n)*\r\n'.preg_quote($value, '/').'\r\n/');
+ }
+});
+
+test('Gotenberg selects the URL screenshot route with the original viewport', function () {
+ $history = [];
+ $mock = new MockHandler([
+ new Response(200, [], base64_decode(imageFixture())),
+ ]);
+ $stack = HandlerStack::create($mock);
+
+ $stack->push(Middleware::history($history));
+
+ $factory = new Screenshot(
+ new GotenbergDriver(http: new Client(['handler' => $stack])),
+ );
+
+ $result = $factory->url('https://example.com/report')
+ ->javascript()
+ ->size(640, 480)
+ ->png()
+ ->viewport()
+ ->maxSize(200, 100)
+ ->capture();
+
+ expect($result->mimeType())->toBe('image/png');
+ expect($result->width())->toBe(1);
+ expect((string) $history[0]['request']->getUri())->toEndWith('/forms/chromium/screenshot/url');
+
+ $body = (string) $history[0]['request']->getBody();
+
+ expect($body)->toContain('https://example.com/report', 'name="width"', "\r\n640\r\n", "\r\n480\r\n", "\r\ntrue\r\n");
+ expect($body)->not->toContain('filename=', 'name="quality"', 'name="selector"');
+});
+
+test('Gotenberg selects the URL PDF route', function () {
+ $history = [];
+ $mock = new MockHandler([
+ new Response(200, [], file_get_contents(__DIR__.'/../../resources/blank.pdf')),
+ ]);
+ $stack = HandlerStack::create($mock);
+
+ $stack->push(Middleware::history($history));
+
+ $factory = new Screenshot(
+ new GotenbergDriver(http: new Client(['handler' => $stack])),
+ );
+
+ $factory->url('https://example.com/report')
+ ->javascript()
+ ->pdf()
+ ->capture();
+
+ expect((string) $history[0]['request']->getUri())->toEndWith('/forms/chromium/convert/url');
+});
+
+test('Gotenberg forwards image selectors and rejects unexpected output formats', function () {
+ $history = [];
+ $mock = new MockHandler([
+ new Response(200, [], base64_decode(imageFixture())),
+ ]);
+ $stack = HandlerStack::create($mock);
+
+ $stack->push(Middleware::history($history));
+
+ $factory = new Screenshot(
+ new GotenbergDriver(http: new Client(['handler' => $stack])),
+ );
+
+ expect(
+ fn () => $factory->html('Card
')
+ ->javascript()
+ ->element('#card')
+ ->jpeg(73)
+ ->capture(),
+ )->toThrow(RuntimeException::class, 'unexpected image format');
+
+ $body = (string) $history[0]['request']->getBody();
+
+ expect($body)->toContain('name="selector"', "\r\n#card\r\n", "\r\njpeg\r\n", "\r\n73\r\n");
+});
+
+test('Gotenberg uses the browser timeout for the entire request', function () {
+ $history = [];
+ $mock = new MockHandler([
+ new Response(200, [], base64_decode(imageFixture())),
+ ]);
+ $stack = HandlerStack::create($mock);
+
+ $stack->push(Middleware::history($history));
+
+ $factory = new Screenshot(
+ new GotenbergDriver(http: new Client(['handler' => $stack])),
+ );
+
+ $factory->html('content')
+ ->javascript()
+ ->timeouts(browser: 1250)
+ ->capture();
+
+ expect($history[0]['options']['timeout'])->toBe(1.25);
+ expect($history[0]['options']['http_errors'])->toBeFalse();
+ expect($history[0]['options']['allow_redirects'])->toBeFalse();
+});
+
+test('Gotenberg reports HTTP errors and the remote trace', function (int $status) {
+ $mock = new MockHandler([
+ new Response($status, ['Gotenberg-Trace' => 'trace-id'], 'conversion failed'),
+ ]);
+
+ $factory = new Screenshot(
+ new GotenbergDriver(http: new Client(['handler' => $mock])),
+ );
+
+ expect(
+ fn () => $factory->html('content')
+ ->javascript()
+ ->capture(),
+ )->toThrow(RuntimeException::class, "Gotenberg capture failed (HTTP {$status}, trace: trace-id): conversion failed");
+})->with([302, 400, 401, 503]);
+
+test('Gotenberg preserves connection and timeout errors', function () {
+ $mock = new MockHandler([
+ new ConnectException(
+ 'Connection timed out',
+ new Request('POST', 'https://gotenberg.example.test'),
+ ),
+ ]);
+
+ $factory = new Screenshot(
+ new GotenbergDriver(http: new Client(['handler' => $mock])),
+ );
+
+ expect(
+ fn () => $factory->html('content')
+ ->javascript()
+ ->capture(),
+ )->toThrow(ConnectException::class, 'Connection timed out');
+});
+
+test('Gotenberg rejects unsupported settings before sending requests', function (Closure $capture, string $message) {
+ $history = [];
+ $mock = new MockHandler;
+ $stack = HandlerStack::create($mock);
+
+ $stack->push(Middleware::history($history));
+
+ $factory = new Screenshot(
+ new GotenbergDriver(http: new Client(['handler' => $stack])),
+ );
+
+ expect(fn () => $capture($factory))->toThrow(InvalidArgumentException::class, $message);
+ expect($history)->toBe([]);
+})->with([
+ 'JavaScript mismatch' => [fn ($factory) => $factory->html('content')->capture(), 'JavaScript setting must match'],
+ 'sockets' => [
+ fn ($factory) => $factory->html('content')
+ ->javascript()
+ ->socket('shared')
+ ->capture(),
+ 'shared socket keys',
+ ],
+ 'PDF elements' => [
+ fn ($factory) => $factory->html('content')
+ ->javascript()
+ ->element('#card')
+ ->pdf()
+ ->capture(),
+ 'element selection for PDF',
+ ],
+]);
+
+test('Gotenberg supports services configured with JavaScript disabled', function () {
+ $mock = new MockHandler([
+ new Response(200, [], base64_decode(imageFixture())),
+ ]);
+
+ $driver = new GotenbergDriver(
+ javascript: false,
+ http: new Client(['handler' => $mock]),
+ );
+ $factory = new Screenshot($driver);
+
+ expect($factory->html('content')->capture()->width())->toBe(1);
+});
+
+test('Gotenberg validates successful response bodies', function (string $body, bool $pdf) {
+ $mock = new MockHandler([
+ new Response(200, [], $body),
+ ]);
+
+ $factory = new Screenshot(
+ new GotenbergDriver(http: new Client(['handler' => $mock])),
+ );
+
+ $source = $factory->html('content')->javascript();
+
+ expect(fn () => ($pdf ? $source->pdf() : $source->png())->capture())
+ ->toThrow(InvalidArgumentException::class);
+})->with([
+ ['not an image', false],
+ ['not a PDF', true],
+]);
+
+test('Gotenberg rejects invalid base URLs', function (string $url) {
+ expect(fn () => new GotenbergDriver($url))->toThrow(InvalidArgumentException::class);
+})->with(['', 'file:///tmp', 'localhost:3000', 'https://example.test?token=secret', 'https://example.test/#fragment']);