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
52 changes: 44 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ JavaScript is disabled by default. Enable it with `javascript()` when needed. Ca

The default viewport is 1024 × 768 pixels. Full-page capture is enabled by default, cropped to a maximum of 2048 × 10000 pixels.

Viewport dimensions control page layout. Full-page capture sizes the image to the document without changing the viewport:

| Configuration | Effect |
| --- | --- |
| `capture.width` / `capture.height` | Sets the viewport used to lay out the page |
| `image.full_page` (`true`) | Captures the document within the maximum image bounds |
| `image.full_page` (`false`) | Captures only the viewport within the maximum image bounds |
| `image.max_width` / `image.max_height` | Crops the image without scaling it |

```php
use DirectoryTree\Screenshot\Facades\Screenshot;

Expand Down Expand Up @@ -145,6 +154,27 @@ File extensions do not select the format. Use `jpeg()` for JPEG output or `pdf()

Switching between `png()` and `jpeg()` on an image builder preserves its other settings, including JPEG quality.

### Capturing an Element

Call `element()` on an HTML, view, or URL source before selecting an output. It captures the first element matching the CSS selector:

```php
use DirectoryTree\Screenshot\Facades\Screenshot;

$source = Screenshot::view('invoices.preview', ['invoice' => $invoice])
->size(1200, 800)
->element('#invoice');

$source->png()->save($imagePath);
$source->pdf()->save($pdfPath);
```

Images crop to the element's bounds, including padding and borders. The viewport still controls page layout. Element selection takes precedence over `fullPage()` and `viewport()`; `maxSize()` crops from the element's top-left corner.

PDFs move the selected element directly into the document body and print it using the configured paper, margins, print styles, and page breaks. Document stylesheets are retained. Styles inherited from removed wrappers or selectors that depend on those wrappers may need adjustment. The package does not reset the element's layout; use print CSS to control it.

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.

### Saving and Reading Captures

Call `save()` or `store()` on a source or output builder, or capture once and reuse the result:
Expand Down Expand Up @@ -205,7 +235,7 @@ $source->jpeg(90)->maxSize(1600, 6000)->save($imagePath);
$source->pdf()->save($pdfPath);
```

Select each output on the source. Each builder starts with the source's current browser settings and its output defaults. Later changes to one builder do not affect the source or other builders.
Select each output on the source. Each builder starts with the source's current browser settings, element selector, and its output defaults. Later changes to one builder do not affect the source or other builders.

### Timeouts

Expand Down Expand Up @@ -270,7 +300,7 @@ Publish the [configuration file](config/screenshot.php) to set application defau
php artisan vendor:publish --tag=screenshot-config
```

The `capture` settings apply to both images and PDFs. The `image` and `pdf` settings control their respective outputs. For example, these entries configure a shared viewport, JPEG quality, and PDF paper size:
The `capture` settings apply to both images and PDFs. The `image` and `pdf` settings control their respective outputs. For example, these entries configure a shared viewport, full-page image bounds, JPEG quality, and PDF paper size:

```php
// config/screenshot.php
Expand All @@ -283,6 +313,9 @@ return [
],

'image' => [
'full_page' => true,
'max_width' => 1600,
'max_height' => 6000,
'quality' => 85,
],

Expand All @@ -292,6 +325,8 @@ return [
];
```

Images use a 1280 × 720 viewport for layout and capture the document up to 1600 × 6000 pixels. Set `image.full_page` to `false` to capture only the viewport; the maximum image bounds still apply.

See the [configuration file](config/screenshot.php) for all available settings. Omitted entries use the package defaults. Capture dimensions are in pixels, timeouts are in milliseconds, and PDF margins are in millimeters. Use `socket => null` for dedicated browsers.

Fluent options override defaults for an individual capture. Restart long-running workers after changing configuration.
Expand Down Expand Up @@ -329,12 +364,13 @@ Pass `BrowserOptions`, `ImageOptions`, or `PdfOptions` objects through the `brow

Chrome is the built-in driver. To supply your own, implement [`Driver`](src/Contracts/Driver.php) and bind it in your service provider's `register()` method, or pass it to the standalone `Screenshot` constructor. Drivers supporting `close()` should also implement [`ManagesBrowsers`](src/Contracts/ManagesBrowsers.php).

Drivers receive `SourceType::Html` or `SourceType::Url` and handle every output through `capture(string $source, SourceType $type, CaptureOptions $options)`. Blade views are rendered to HTML first. `CaptureOptions` combines two independent objects:
Drivers receive `SourceType::Html` or `SourceType::Url` and handle every output through `capture(string $source, SourceType $type, CaptureOptions $options)`. Blade views are rendered to HTML first. `CaptureOptions` contains:

- `$options->browser` is a `BrowserOptions` object containing viewport, timeout, JavaScript, and socket settings.
- `$options->output` is an `ImageOptions` or `PdfOptions` object containing output settings.
- `$options->browser()` is a `BrowserOptions` object containing viewport, timeout, JavaScript, and socket settings.
- `$options->output()` is an `ImageOptions` or `PdfOptions` object containing output settings.
- `$options->element()` is the selected CSS selector, or `null` for the whole page.

`$options->getFormat()` returns `OutputFormat::Png`, `OutputFormat::Jpeg`, or `OutputFormat::Pdf`. Return an `ImageResult` for images or a `PdfResult` for PDFs. Each driver call receives an independent snapshot of both option objects.
`$options->format()` returns `OutputFormat::Png`, `OutputFormat::Jpeg`, or `OutputFormat::Pdf`. Return an `ImageResult` for images or a `PdfResult` for PDFs. Each driver call receives an independent snapshot of both option objects.

## Testing

Expand All @@ -353,7 +389,7 @@ Screenshot::html('<h1>Invoice</h1>')

Screenshot::assertCapturedCount(count: 1);
Screenshot::assertCaptured(function (string $source, SourceType $type, CaptureOptions $options) {
return $source === '<h1>Invoice</h1>' && $options->browser->getWidth() === 800;
return $source === '<h1>Invoice</h1>' && $options->browser()->getWidth() === 800;
});
```

Expand All @@ -373,7 +409,7 @@ Screenshot::html('<h1>Invoice</h1>')->pdf()->landscape()->capture();

Screenshot::assertCapturedCount(1);
Screenshot::assertCaptured(function (string $source, SourceType $type, CaptureOptions $options) {
return $options->getFormat() === OutputFormat::Pdf && $options->output->getLandscape();
return $options->format() === OutputFormat::Pdf && $options->output()->getLandscape();
});
```

Expand Down
35 changes: 31 additions & 4 deletions src/CaptureOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@
class CaptureOptions
{
/**
* Combine browser settings with the selected output settings.
* Combine browser and output settings with an optional element selector.
*
* @param TOutput $output
*/
public function __construct(
public BrowserOptions $browser,
public ImageOptions|PdfOptions $output,
protected BrowserOptions $browser,
protected ImageOptions|PdfOptions $output,
protected ?string $element = null,
) {}

/**
Expand All @@ -28,10 +29,36 @@ public function __clone(): void
$this->output = clone $this->output;
}

/**
* Get the browser settings.
*/
public function browser(): BrowserOptions
{
return $this->browser;
}

/**
* Get the output settings.
*
* @return TOutput
*/
public function output(): ImageOptions|PdfOptions
{
return $this->output;
}

/**
* Get the selected CSS selector.
*/
public function element(): ?string
{
return $this->element;
}

/**
* Get the selected output format.
*/
public function getFormat(): OutputFormat
public function format(): OutputFormat
{
return $this->output->getFormat();
}
Expand Down
45 changes: 39 additions & 6 deletions src/Drivers/Chrome/ImageCapture.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use DirectoryTree\Screenshot\ImageResult;
use HeadlessChromium\Clip;
use HeadlessChromium\Page;
use RuntimeException;

class ImageCapture implements CapturesPage
{
Expand All @@ -16,23 +17,22 @@ class ImageCapture implements CapturesPage
*/
public function __construct(
protected BrowserOptions $browser,
protected ImageOptions $options
protected ImageOptions $options,
protected ?string $element = null,
) {}

/**
* Capture the full page or viewport within the configured limits.
* Capture the selected element, full page, or viewport within the limits.
*/
public function capture(Page $page): ImageResult
{
$clip = $this->options->getFullPage()
? $page->getFullPageClip($this->browser->getBrowserTimeout())
: new Clip(0, 0, $this->browser->getWidth(), $this->browser->getHeight());
$clip = $this->clip($page);

$parameters = [
'format' => $this->options->getFormat()->value,
'captureBeyondViewport' => true,
'clip' => new Clip(
0, 0,
$clip->getX(), $clip->getY(),
max(1, min($clip->getWidth(), $this->options->getMaxWidth())),
max(1, min($clip->getHeight(), $this->options->getMaxHeight())),
),
Expand All @@ -48,4 +48,37 @@ public function capture(Page $page): ImageResult
)
);
}

/**
* Resolve the selected element, full page, or viewport bounds.
*/
protected function clip(Page $page): Clip
{
if ($this->element !== null) {
return $this->getElementClip($page);
}

if ($this->options->getFullPage()) {
return $page->getFullPageClip($this->browser->getBrowserTimeout());
}

return new Clip(0, 0, $this->browser->getWidth(), $this->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}].");

$position = $element->getPosition('border');

if (! $position || $position->getWidth() <= 0 || $position->getHeight() <= 0) {
throw new RuntimeException("The element [{$this->element}] has no layout bounds.");
}

return new Clip($position->getX(), $position->getY(), $position->getWidth(), $position->getHeight());
}
}
18 changes: 13 additions & 5 deletions src/Drivers/Chrome/PageCapture.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public function capture(string $source, SourceType $type): ImageResult|PdfResult
*/
protected function configurePage(Page $page): void
{
$options = $this->options->browser;
$options = $this->options->browser();

if ($this->shared) {
$page->setViewport($options->getWidth(), $options->getHeight())->await($options->getBrowserTimeout());
Expand All @@ -78,7 +78,7 @@ protected function configurePage(Page $page): void
*/
protected function loadPage(Page $page, string $source, SourceType $type): void
{
$options = $this->options->browser;
$options = $this->options->browser();

try {
if ($type === SourceType::Html) {
Expand Down Expand Up @@ -106,9 +106,17 @@ protected function newCapture(): CapturesPage
{
$options = $this->options;

return match ($options->getFormat()) {
OutputFormat::Png, OutputFormat::Jpeg => new ImageCapture($options->browser, $options->output),
OutputFormat::Pdf => new PdfCapture($options->browser, $options->output),
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()
),
};
}

Expand Down
37 changes: 36 additions & 1 deletion src/Drivers/Chrome/PdfCapture.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use DirectoryTree\Screenshot\PdfOptions;
use DirectoryTree\Screenshot\PdfResult;
use HeadlessChromium\Page;
use RuntimeException;

class PdfCapture implements CapturesPage
{
Expand All @@ -14,14 +15,19 @@ class PdfCapture implements CapturesPage
*/
public function __construct(
protected BrowserOptions $browser,
protected PdfOptions $options
protected PdfOptions $options,
protected ?string $element = null,
) {}

/**
* Print the prepared page using the configured paper and layout.
*/
public function capture(Page $page): PdfResult
{
if ($this->element !== null) {
$this->isolateElement($page);
}

return new PdfResult(
$page->pdf(
$this->options->toArray()
Expand All @@ -30,4 +36,33 @@ public function capture(Page $page): PdfResult
)
);
}

/**
* Move the selected content into the body, preserving document stylesheets.
*/
protected function isolateElement(Page $page): void
{
$selector = json_encode($this->element, JSON_THROW_ON_ERROR);

$found = $page->evaluate(<<<JS
(() => {
const element = document.querySelector({$selector});

if (!element) return false;
if (element === document.body || element === document.documentElement) return true;

for (const stylesheet of document.querySelectorAll('body style, body link[rel~="stylesheet"]')) {
if (!element.contains(stylesheet)) document.head.appendChild(stylesheet);
}

document.body.replaceChildren(element);

return true;
})()
JS)->getReturnValue($this->browser->getBrowserTimeout());

if (! $found) {
throw new RuntimeException("No element matches the selector [{$this->element}].");
}
}
}
8 changes: 4 additions & 4 deletions src/Drivers/ChromeDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ public function capture(string $source, SourceType $type, CaptureOptions $option
{
$options->validate();

if ($socket = $this->socket($options->browser)) {
if ($socket = $this->socket($options->browser())) {
return $socket->run(
fn () => $this->createBrowser($options->browser),
fn () => $this->createBrowser($options->browser()),
fn (Browser $browser) => $this->capturePage($browser, $source, $type, $options, shared: true),
array_merge($this->browserOptions, ['sendSyncDefaultTimeout' => $options->browser->getBrowserTimeout()]),
array_merge($this->browserOptions, ['sendSyncDefaultTimeout' => $options->browser()->getBrowserTimeout()]),
);
}

return $this->capturePage($this->createBrowser($options->browser), $source, $type, $options);
return $this->capturePage($this->createBrowser($options->browser()), $source, $type, $options);
}

/**
Expand Down
Loading
Loading