diff --git a/README.md b/README.md index a2b16d3..cd05218 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,40 @@ PDFs move the selected element directly into the document body and print it usin Selection works with JavaScript disabled. Missing elements and invalid selectors throw exceptions. Images require an element with nonzero layout bounds; PDFs follow the document's print styles. Each output builder keeps the selector chosen when it was created. +### Customizing a Capture + +Use `before()` on a source, image, or PDF builder to customize the prepared driver resource. Each callback receives a `CaptureContext` implementing the `Contracts\CaptureContext` interface. Its `driver()` method returns the actual driver instance: + +```php +use DirectoryTree\Screenshot\Contracts\CaptureContext; +use DirectoryTree\Screenshot\Drivers\ChromeDriver; +use DirectoryTree\Screenshot\Drivers\GotenbergDriver; +use DirectoryTree\Screenshot\Facades\Screenshot; + +Screenshot::url('https://example.com') + ->javascript() + ->before(function (CaptureContext $context) { + match (true) { + $context->driver() instanceof ChromeDriver => $context->page() + ->evaluate('document.querySelector("#banner")?.remove()') + ->getReturnValue(), + $context->driver() instanceof GotenbergDriver => $context->request() + ->set('waitForExpression', 'window.ready === true'), + default => null, + }; + }) + ->png() + ->capture(); +``` + +Chrome invokes callbacks after the source loads and before image bounds are measured or PDF preparation begins. `page()` returns the live `HeadlessChromium\Page`, allowing JavaScript execution, application-specific readiness waits, and viewport changes. Enable `javascript()` when your callback needs to execute JavaScript. + +Gotenberg invokes callbacks immediately before sending its HTTP request. `request()` returns a mutable `DirectoryTree\Screenshot\Drivers\Gotenberg\Request`. Use `set()` to add or replace a multipart field. Its public `url` and `options` properties expose the destination and Guzzle request options, including headers and multipart files. This does not provide access to Gotenberg's remote browser page. + +`ChromeCaptureContext` and `GotenbergCaptureContext` implement the shared contract. Only Chrome contexts expose `page()`, and only Gotenberg contexts expose `request()`. Callbacks run in registration order, their return values are ignored, and exceptions propagate through the driver's existing cleanup. A callback added to a source is copied into subsequently created output builders; callbacks added to an output affect only that builder. Callbacks run for every actual capture, including captures triggered by `save()` and `store()`. + +The screenshot fake records capture options without executing callbacks. Custom drivers can implement `Contracts\CaptureContext` to expose their own resources and invoke registered callbacks through `$options->prepare($context)`. + ### Saving and Reading Captures Call `save()` or `store()` on a source or output builder, or capture once and reuse the result: diff --git a/src/CaptureOptions.php b/src/CaptureOptions.php index 56bcfcb..a9c4a21 100644 --- a/src/CaptureOptions.php +++ b/src/CaptureOptions.php @@ -2,6 +2,8 @@ namespace DirectoryTree\Screenshot; +use Closure; +use DirectoryTree\Screenshot\Contracts\CaptureContext; use DirectoryTree\Screenshot\Enums\OutputFormat; /** @@ -18,6 +20,7 @@ public function __construct( protected BrowserOptions $browser, protected ImageOptions|PdfOptions $output, protected ?string $element = null, + protected array $callbacks = [], ) {} /** @@ -63,6 +66,26 @@ public function format(): OutputFormat return $this->output->getFormat(); } + /** + * Register a callback to customize the driver before capturing. + */ + public function before(Closure $callback): static + { + $this->callbacks[] = $callback; + + return $this; + } + + /** + * Invoke the callbacks with the prepared driver context. + */ + public function prepare(CaptureContext $context): void + { + foreach ($this->callbacks as $callback) { + $callback($context); + } + } + /** * Validate the browser and output settings before capturing. */ diff --git a/src/Contracts/CaptureContext.php b/src/Contracts/CaptureContext.php new file mode 100644 index 0000000..731a9bf --- /dev/null +++ b/src/Contracts/CaptureContext.php @@ -0,0 +1,11 @@ +driver; + } + + /** + * Get the prepared page. + */ + public function page(): Page + { + return $this->page; + } +} diff --git a/src/Drivers/Chrome/PageCapture.php b/src/Drivers/Chrome/PageCapture.php index 6988a38..ae60cca 100644 --- a/src/Drivers/Chrome/PageCapture.php +++ b/src/Drivers/Chrome/PageCapture.php @@ -3,6 +3,7 @@ namespace DirectoryTree\Screenshot\Drivers\Chrome; use DirectoryTree\Screenshot\CaptureOptions; +use DirectoryTree\Screenshot\Drivers\ChromeDriver; use DirectoryTree\Screenshot\Enums\OutputFormat; use DirectoryTree\Screenshot\Enums\SourceType; use DirectoryTree\Screenshot\ImageResult; @@ -22,6 +23,7 @@ class PageCapture * Prepare to capture a page within the given browser. */ public function __construct( + protected ChromeDriver $driver, protected Browser $browser, protected CaptureOptions $options, protected bool $shared = false, @@ -43,6 +45,8 @@ public function capture(string $source, SourceType $type): ImageResult|PdfResult $this->configurePage($page); $this->loadPage($page, $source, $type); + $this->options->prepare(new ChromeCaptureContext($this->driver, $page)); + $result = $this->newCapture()->capture($page); $failed = false; diff --git a/src/Drivers/ChromeDriver.php b/src/Drivers/ChromeDriver.php index 6a61894..6c4616f 100644 --- a/src/Drivers/ChromeDriver.php +++ b/src/Drivers/ChromeDriver.php @@ -57,7 +57,7 @@ protected function capturePage(Browser $browser, string $source, SourceType $typ $browser->getConnection()->setConnectionHttpHeaders($this->browserOptions['headers']); } - $result = (new PageCapture($browser, $options, $shared))->capture($source, $type); + $result = (new PageCapture($this, $browser, $options, $shared))->capture($source, $type); $failed = false; diff --git a/src/Drivers/Gotenberg/GotenbergCaptureContext.php b/src/Drivers/Gotenberg/GotenbergCaptureContext.php new file mode 100644 index 0000000..97fc344 --- /dev/null +++ b/src/Drivers/Gotenberg/GotenbergCaptureContext.php @@ -0,0 +1,33 @@ +driver; + } + + /** + * Get the prepared request. + */ + public function request(): Request + { + return $this->request; + } +} diff --git a/src/Drivers/Gotenberg/Request.php b/src/Drivers/Gotenberg/Request.php new file mode 100644 index 0000000..78b579f --- /dev/null +++ b/src/Drivers/Gotenberg/Request.php @@ -0,0 +1,32 @@ +options['multipart'] = array_values(array_filter( + $this->options['multipart'], + fn (array $part) => $part['name'] !== $name, + )); + + $this->options['multipart'][] = [ + 'name' => $name, + 'contents' => is_bool($value) ? ($value ? 'true' : 'false') : (string) $value, + ]; + + return $this; + } +} diff --git a/src/Drivers/GotenbergDriver.php b/src/Drivers/GotenbergDriver.php index 2b4c8df..993594a 100644 --- a/src/Drivers/GotenbergDriver.php +++ b/src/Drivers/GotenbergDriver.php @@ -4,9 +4,11 @@ use DirectoryTree\Screenshot\CaptureOptions; use DirectoryTree\Screenshot\Contracts\Driver; +use DirectoryTree\Screenshot\Drivers\Gotenberg\GotenbergCaptureContext; use DirectoryTree\Screenshot\Drivers\Gotenberg\ImageOutput; use DirectoryTree\Screenshot\Drivers\Gotenberg\Output; use DirectoryTree\Screenshot\Drivers\Gotenberg\PdfOutput; +use DirectoryTree\Screenshot\Drivers\Gotenberg\Request; use DirectoryTree\Screenshot\Enums\OutputFormat; use DirectoryTree\Screenshot\Enums\SourceType; use DirectoryTree\Screenshot\ImageResult; @@ -62,13 +64,17 @@ protected function capturePage(string $source, SourceType $type, CaptureOptions { $route = $output->route(); - $response = $this->http->request('POST', rtrim($this->url, '/')."/forms/chromium/{$route}/{$type->value}", [ + $request = new Request(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, ]); + $options->prepare(new GotenbergCaptureContext($this, $request)); + + $response = $this->http->request('POST', $request->url, $request->options); + if ($response->getStatusCode() !== 200) { throw new RuntimeException(sprintf( 'Gotenberg capture failed (HTTP %d, trace: %s): %s', diff --git a/src/PendingImage.php b/src/PendingImage.php index 79003eb..1f04e9a 100644 --- a/src/PendingImage.php +++ b/src/PendingImage.php @@ -2,6 +2,7 @@ namespace DirectoryTree\Screenshot; +use Closure; use DirectoryTree\Screenshot\Enums\OutputFormat; class PendingImage @@ -18,6 +19,16 @@ public function __construct( $this->options = clone $options; } + /** + * Customize the driver immediately before capturing. + */ + public function before(Closure $callback): static + { + $this->options->before($callback); + + return $this; + } + /** * Set the viewport dimensions after validating both values. */ diff --git a/src/PendingPdf.php b/src/PendingPdf.php index 4ae60de..683f3a1 100644 --- a/src/PendingPdf.php +++ b/src/PendingPdf.php @@ -2,6 +2,7 @@ namespace DirectoryTree\Screenshot; +use Closure; use DirectoryTree\Screenshot\Enums\PaperSize; class PendingPdf @@ -18,6 +19,16 @@ public function __construct( $this->options = clone $options; } + /** + * Customize the driver immediately before capturing. + */ + public function before(Closure $callback): static + { + $this->options->before($callback); + + return $this; + } + /** * Set the viewport dimensions after validating both values. */ diff --git a/src/PendingSource.php b/src/PendingSource.php index 3a8d350..1f9a233 100644 --- a/src/PendingSource.php +++ b/src/PendingSource.php @@ -2,6 +2,8 @@ namespace DirectoryTree\Screenshot; +use Closure; + class PendingSource { /** @@ -9,6 +11,13 @@ class PendingSource */ protected ?string $element = null; + /** + * The callbacks invoked before capturing. + * + * @var array + */ + protected array $callbacks = []; + /** * Prepare a source with independent browser settings and shared output defaults. */ @@ -21,6 +30,16 @@ public function __construct( $this->browser = clone $browser; } + /** + * Customize the driver immediately before capturing. + */ + public function before(Closure $callback): static + { + $this->callbacks[] = $callback; + + return $this; + } + /** * Set the viewport dimensions after validating both values. */ @@ -161,6 +180,6 @@ public function capture(): ImageResult */ protected function newCaptureOptions(ImageOptions|PdfOptions $output): CaptureOptions { - return new CaptureOptions($this->browser, $output, $this->element); + return new CaptureOptions($this->browser, $output, $this->element, $this->callbacks); } } diff --git a/tests/Browser/CaptureTest.php b/tests/Browser/CaptureTest.php index 0bbabe3..7de9e6e 100644 --- a/tests/Browser/CaptureTest.php +++ b/tests/Browser/CaptureTest.php @@ -1,6 +1,7 @@ stop(); } }); + +test('before callbacks customize a loaded Chrome page before element capture', function () { + $image = realScreenshot()->html('
') + ->javascript() + ->before(function (CaptureContext $context) { + expect($context->driver())->toBeInstanceOf(ChromeDriver::class); + $context->page()->evaluate('document.querySelector("#card").style.width = "350px"; document.querySelector("#card").style.height = "250px";')->getReturnValue(); + }) + ->element('#card') + ->png() + ->capture(); + + expect($image->width())->toBe(350); + expect($image->height())->toBe(250); +}); diff --git a/tests/Unit/BeforeCaptureTest.php b/tests/Unit/BeforeCaptureTest.php new file mode 100644 index 0000000..79a51d5 --- /dev/null +++ b/tests/Unit/BeforeCaptureTest.php @@ -0,0 +1,97 @@ +push(Middleware::history($history)); + $driver = new GotenbergDriver(http: new Client(['handler' => $stack])); + $calls = []; + + $source = (new Screenshot($driver))->html('

Hello

')->javascript() + ->before(function (CaptureContext $context) use ($driver, &$calls) { + expect($context->driver())->toBe($driver); + expect($context->request()->options['multipart'][0]['contents'])->toBe('

Hello

'); + $context->request()->set('waitForExpression', 'window.ready')->set('skipNetworkIdleEvent', true); + $calls[] = 'source'; + }); + + $source->{$format}()->before(function (CaptureContext $context) use (&$calls) { + $context->request()->set('waitForExpression', 'window.finished'); + $context->request()->options['headers']['X-Capture'] = 'custom'; + $calls[] = 'output'; + })->capture(); + + $body = (string) $history[0]['request']->getBody(); + expect($calls)->toBe(['source', 'output']); + expect($body)->toContain('window.finished', 'true')->not->toContain('window.ready'); + expect($history[0]['request']->getHeaderLine('X-Capture'))->toBe('custom'); +})->with(['png', 'jpeg', 'pdf']); + +test('callbacks on an output do not leak into its source or sibling outputs', function () { + $stack = HandlerStack::create(new MockHandler(array_fill(0, 3, new Response(200, [], base64_decode(imageFixture()))))); + $source = (new Screenshot(new GotenbergDriver(http: new Client(['handler' => $stack])))) + ->html('hello')->javascript(); + $calls = 0; + $image = $source->png()->before(function () use (&$calls) { + $calls++; + }); + + $source->capture(); + $source->png()->capture(); + $image->capture(); + + expect($calls)->toBe(1); +}); + +test('each driver context implements the shared contract and exposes its resource', function () { + $driver = new ChromeDriver; + $page = Mockery::mock(Page::class); + $context = new ChromeCaptureContext($driver, $page); + + expect($context)->toBeInstanceOf(CaptureContext::class); + expect($context->driver())->toBe($driver); + expect($context->page())->toBe($page); + + $driver = new GotenbergDriver; + $request = new Request('http://localhost:3000', ['multipart' => []]); + $context = new GotenbergCaptureContext($driver, $request); + + expect($context)->toBeInstanceOf(CaptureContext::class); + expect($context->driver())->toBe($driver); + expect($context->request())->toBe($request); +}); + +test('Gotenberg callback failures propagate without sending the request', function () { + $http = Mockery::mock(ClientInterface::class); + $http->shouldNotReceive('request'); + + expect(fn () => (new Screenshot(new GotenbergDriver(http: $http))) + ->html('hello')->javascript() + ->before(function (CaptureContext $context) { + throw new RuntimeException('preparation failed'); + })->capture())->toThrow(RuntimeException::class, 'preparation failed'); +}); diff --git a/tests/Unit/PageCaptureTest.php b/tests/Unit/PageCaptureTest.php index f8ec88d..251385d 100644 --- a/tests/Unit/PageCaptureTest.php +++ b/tests/Unit/PageCaptureTest.php @@ -2,8 +2,10 @@ use DirectoryTree\Screenshot\BrowserOptions; use DirectoryTree\Screenshot\CaptureOptions; +use DirectoryTree\Screenshot\Contracts\CaptureContext; use DirectoryTree\Screenshot\Drivers\Chrome\CapturesPage; use DirectoryTree\Screenshot\Drivers\Chrome\PageCapture; +use DirectoryTree\Screenshot\Drivers\ChromeDriver; use DirectoryTree\Screenshot\Enums\OutputFormat; use DirectoryTree\Screenshot\Enums\SourceType; use DirectoryTree\Screenshot\ImageOptions; @@ -73,7 +75,7 @@ $browser->shouldNotReceive('close'); expect( - (new PageCapture($browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html)->width(), + (new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html)->width(), )->toBe(1); }); @@ -102,6 +104,7 @@ expect( fn () => (new PageCapture( + new ChromeDriver, $browser, new CaptureOptions((new BrowserOptions)->setFailOnTimeout(true), new ImageOptions), ))->capture('hello', SourceType::Html), @@ -149,7 +152,7 @@ $browser->shouldNotReceive('close'); expect( - fn () => (new PageCapture($browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html), + fn () => (new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html), )->toThrow(RuntimeException::class, 'capture failure'); }); @@ -183,7 +186,7 @@ $browser->shouldNotReceive('close'); expect( - fn () => (new PageCapture($browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html), + fn () => (new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html), )->toThrow(RuntimeException::class, 'screenshot failure'); }); @@ -232,7 +235,7 @@ $page->shouldReceive('close')->once(); $browser->shouldNotReceive('close'); - $capture = new PageCapture($browser, new CaptureOptions(new BrowserOptions, new ImageOptions), shared: true); + $capture = new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions), shared: true); expect(fn () => $capture->capture('hello', SourceType::Html))->toThrow(RuntimeException::class, 'capture failure'); }); @@ -280,7 +283,7 @@ $connection->shouldReceive('readData')->once(); } - $capture = new PageCapture($browser, new CaptureOptions(new BrowserOptions, new ImageOptions), shared: true); + $capture = new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions), shared: true); expect(fn () => $capture->capture('hello', SourceType::Html))->toThrow(OperationTimedOut::class, 'initialization failure'); })->with([false, true]); @@ -321,6 +324,7 @@ $browser->shouldNotReceive('close'); $result = (new PageCapture( + new ChromeDriver, $browser, new CaptureOptions( (new BrowserOptions)->setTimeouts(load: 15000, browser: 12345), @@ -361,7 +365,7 @@ $browser->shouldNotReceive('close'); expect( - fn () => (new PageCapture($browser, new CaptureOptions(new BrowserOptions, new PdfOptions)))->capture('invoice', SourceType::Html), + fn () => (new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new PdfOptions)))->capture('invoice', SourceType::Html), )->toThrow(RuntimeException::class, 'print failure'); }); @@ -412,6 +416,7 @@ expect( (new PageCapture( + new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, (new ImageOptions)->setFormat($format, 91)), ))->capture('image', SourceType::Html), @@ -441,7 +446,7 @@ $page->shouldReceive('close')->once(); $browser->shouldNotReceive('close'); - $capture = new class($browser, new CaptureOptions(new BrowserOptions, new ImageOptions)) extends PageCapture + $capture = new class(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions)) extends PageCapture { protected function newCapture(): CapturesPage { @@ -477,7 +482,7 @@ protected function newCapture(): CapturesPage $browser->shouldNotReceive('close'); expect( - fn () => (new PageCapture($browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html), + fn () => (new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html), )->toThrow(RuntimeException::class, 'Unable to configure JavaScript execution: configuration rejected'); }); @@ -511,7 +516,7 @@ protected function newCapture(): CapturesPage $browser->shouldNotReceive('close'); expect( - fn () => (new PageCapture($browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html), + fn () => (new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html), )->toThrow(RuntimeException::class, 'Unable to stop page loading: stop rejected'); }); @@ -592,10 +597,37 @@ protected function newCapture(): CapturesPage ) ->andReturn(new Response(['error' => ['message' => 'target rejected']], new Message('test'))); - $capture = new PageCapture($browser, new CaptureOptions(new BrowserOptions, new ImageOptions), shared: true); + $capture = new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions), shared: true); expect(fn () => $capture->capture('hello', SourceType::Html))->toThrow( RuntimeException::class, $captureFails ? 'capture failure' : 'Unable to close the screenshot page: target rejected', ); })->with([false, true]); + +test('before callbacks run after loading and failures still close the page', function () { + $browser = Mockery::mock(Browser::class); + $page = Mockery::mock(Page::class); + $session = Mockery::mock(Session::class); + $driver = new ChromeDriver; + $loaded = false; + $options = new CaptureOptions(new BrowserOptions, new ImageOptions); + $options->before(function (CaptureContext $context) use ($page, $driver, &$loaded) { + expect($loaded)->toBeTrue(); + expect($context->driver())->toBe($driver); + expect($context->page())->toBe($page); + throw new RuntimeException('preparation failed'); + }); + + $browser->shouldReceive('createPage')->once()->andReturn($page); + $page->shouldReceive('getSession')->andReturn($session); + $session->shouldReceive('sendMessageSync')->once()->andReturn(new Response([], new Message('test'))); + $page->shouldReceive('setHtml')->once()->andReturnUsing(function () use (&$loaded) { + $loaded = true; + }); + $page->shouldReceive('close')->once(); + $page->shouldNotReceive('screenshot'); + + expect(fn () => (new PageCapture($driver, $browser, $options))->capture('hello', SourceType::Html)) + ->toThrow(RuntimeException::class, 'preparation failed'); +});