Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,21 +89,41 @@ 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
env:
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
74 changes: 70 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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

Expand Down Expand Up @@ -364,9 +364,75 @@ $screenshot->html('<h1>Hello, world!</h1>')

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('<h1>Hello!</h1>')->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('<h1>Hello!</h1>')->capture();
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
20 changes: 18 additions & 2 deletions config/screenshot.php
Original file line number Diff line number Diff line change
Expand Up @@ -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().
|
*/

Expand All @@ -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
Expand Down
40 changes: 23 additions & 17 deletions src/Drivers/Chrome/ImageCapture.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,37 +14,39 @@ class ImageCapture implements CapturesPage
{
/**
* Create an image capture strategy.
*
* @param CaptureOptions<ImageOptions> $options
*/
public function __construct(
protected BrowserOptions $browser,
protected ImageOptions $options,
protected ?string $element = null,
protected CaptureOptions $options,
) {}

/**
* Capture the selected element, full page, or viewport within the limits.
*/
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()
)
);
}
Expand All @@ -54,29 +56,33 @@ 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());
}

/**
* Get the element's border bounds using Chrome's DOM API.
*/
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());
Expand Down
12 changes: 2 additions & 10 deletions src/Drivers/Chrome/PageCapture.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}

Expand Down
22 changes: 12 additions & 10 deletions src/Drivers/Chrome/PdfCapture.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,27 +12,27 @@ class PdfCapture implements CapturesPage
{
/**
* Create a PDF capture strategy.
*
* @param CaptureOptions<PdfOptions> $options
*/
public function __construct(
protected BrowserOptions $browser,
protected PdfOptions $options,
protected ?string $element = null,
protected CaptureOptions $options,
) {}

/**
* Print the prepared page using the configured paper and layout.
*/
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()
)
);
}
Expand All @@ -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(<<<JS
(() => {
Expand All @@ -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}].");
}
}
}
Loading
Loading