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']);