Skip to content

Commit

Permalink
feat: implement headless-chromium
Browse files Browse the repository at this point in the history
  • Loading branch information
Arthurlbc committed Nov 6, 2024
1 parent 6f407df commit 5c859d0
Show file tree
Hide file tree
Showing 5 changed files with 316 additions and 0 deletions.
112 changes: 112 additions & 0 deletions src/Backend/HeadlessChromium/HeadlessChromiumAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

declare(strict_types=1);

namespace KNPLabs\Snappy\Backend\HeadlessChromium;

use KNPLabs\Snappy\Core\Backend\Adapter\UriToPdf;
use KNPLabs\Snappy\Core\Backend\Adapter\HtmlFileToPdf;
use KNPLabs\Snappy\Core\Backend\Adapter\HtmlToPdf;
use KNPLabs\Snappy\Core\Backend\Adapter\Reconfigurable;
use KNPLabs\Snappy\Core\Backend\Options;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
use SplFileInfo;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;

final class HeadlessChromiumAdapter implements UriToPdf, HtmlFileToPdf, HtmlToPdf
{
private string $tempDir;

/**
* @use Reconfigurable<self>
*/
use Reconfigurable;

public function __construct(
private Options $options,
private StreamFactoryInterface $streamFactory
) {}

public function generateFromUri(UriInterface $url): StreamInterface
{
$this->tempDir = sys_get_temp_dir();

$command = $this->buildChromiumCommand((string) $url, $this->tempDir);
$this->runProcess($command);

return $this->createStreamFromFile($this->tempDir);
}

public function generateFromHtmlFile(SplFileInfo $file): StreamInterface
{
$htmlContent = file_get_contents($file->getPathname());
return $this->generateFromHtml($htmlContent);
}

public function generateFromHtml(string $html): StreamInterface
{
$outputFile = $this->tempDir . '/pdf_output_';
$htmlFile = $this->tempDir . '/html_input_';
file_put_contents($htmlFile, $html);

$command = $this->buildChromiumCommand("file://$htmlFile", $outputFile);
$this->runProcess($command);

unlink($htmlFile);
return $this->createStreamFromFile($outputFile);
}

/**
* @return array<string>
*/
private function buildChromiumCommand(string $inputUri, string $outputPath): array
{
$options = $this->compileConstructOptions();

return array_merge([
'chromium',
'--headless',
'--disable-gpu',
'--no-sandbox',
'--print-to-pdf=' . $outputPath,
], $options, [$inputUri]);
}

/**
* @return array<string>
*/
private function compileConstructOptions(): array
{
$constructOptions = $this->options->extraOptions['construct'] ?? [];

$compiledOptions = [];
if (is_array($constructOptions)) {
foreach ($constructOptions as $key => $value) {
$compiledOptions[] = "--$key=$value";
}
}

return $compiledOptions;
}

private function runProcess(array $command): void
{
$process = new Process($command);
$process->run();

if (!$process->isSuccessful()) {
throw new ProcessFailedException($process);
}
}

private function createStreamFromFile(string $filePath): StreamInterface
{
$output = file_get_contents($filePath);
unlink($filePath);

return $this->streamFactory->createStream($output ?: '');
}
}
20 changes: 20 additions & 0 deletions src/Backend/HeadlessChromium/HeadlessChromiumFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace KNPLabs\Snappy\Backend\HeadlessChromium;

use KNPLabs\Snappy\Core\Backend\Options;
use Psr\Http\Message\StreamFactoryInterface;

final class HeadlessChromiumFactory
{
public function __construct(
private StreamFactoryInterface $streamFactory
) {}

public function create(Options $options): HeadlessChromiumAdapter
{
return new HeadlessChromiumAdapter($options, $this->streamFactory);
}
}
127 changes: 127 additions & 0 deletions src/Backend/HeadlessChromium/Tests/HeadlessChromiumAdapterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

declare(strict_types=1);

namespace KNPLabs\Snappy\Backend\HeadlessChromium\Tests;

use KNPLabs\Snappy\Backend\HeadlessChromium\HeadlessChromiumAdapter;
use KNPLabs\Snappy\Backend\HeadlessChromium\HeadlessChromiumFactory;
use KNPLabs\Snappy\Core\Backend\Options;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;

final class HeadlessChromiumAdapterTest extends TestCase
{
private Options $options;
private StreamFactoryInterface $streamFactory;
private HeadlessChromiumAdapter $adapter;
private HeadlessChromiumFactory $factory;

protected function setUp(): void
{
$this->options = new Options(null, []);
$this->streamFactory = $this->createMock(StreamFactoryInterface::class);
$this->factory = new HeadlessChromiumFactory($this->streamFactory);
$this->adapter = $this->factory->create($this->options);
}

public function testGenerateFromUri(): void
{
$url = $this->createMock(UriInterface::class);
$url->method('__toString')->willReturn('https://example.com');
}

public function testGenerateFromHtmlFile(): void
{
$file = $this->createMock(\SplFileInfo::class);
$file->method('getPathname')->willReturn('/path/to/test.html');

$outputStream = $this->createMock(StreamInterface::class);
$this->streamFactory->method('createStream')->willReturn($outputStream);

$process = $this->createMockProcess(['chromium', '--headless', '--print-to-pdf', 'output.pdf'], true);

$this->adapter->method('runProcess')->willReturnCallback(function ($command) use ($process) {
$process->run();
if (!$process->isSuccessful()) {
throw new ProcessFailedException($process);
}
});

$result = $this->adapter->generateFromHtmlFile($file);

$this->assertSame($outputStream, $result);
}

public function testGenerateFromHtml(): void
{
$htmlContent = '<html><body>Hello World</body></html>';

$outputStream = $this->createMock(StreamInterface::class);
$this->streamFactory->method('createStream')->willReturn($outputStream);

$process = $this->createMockProcess(['chromium', '--headless', '--print-to-pdf', 'output.pdf'], true);

$this->adapter->method('runProcess')->willReturnCallback(function ($command) use ($process) {
$process->run();
if (!$process->isSuccessful()) {
throw new ProcessFailedException($process);
}
});

$result = $this->adapter->generateFromHtml($htmlContent);

$this->assertSame($outputStream, $result);
}

public function testProcessFailsOnInvalidUri(): void
{
$url = $this->createMock(UriInterface::class);
$url->method('__toString')->willReturn('invalid-url');

$this->expectException(ProcessFailedException::class);

$process = $this->createMockProcess(['chromium', '--headless', '--print-to-pdf', 'output.pdf'], false);

$this->adapter->method('runProcess')->willReturnCallback(function ($command) use ($process) {
$process->run();
if (!$process->isSuccessful()) {
throw new ProcessFailedException($process);
}
});

$this->adapter->generateFromUri($url);
}

public function testProcessFailsOnEmptyHtml(): void
{
$this->expectException(ProcessFailedException::class);

$process = $this->createMockProcess(['chromium', '--headless', '--print-to-pdf', 'output.pdf'], false);

$this->adapter->method('runProcess')->willReturnCallback(function ($command) use ($process) {
$process->run();
if (!$process->isSuccessful()) {
throw new ProcessFailedException($process);
}
});

$this->adapter->generateFromHtml('');
}

private function createMockProcess(array $command, bool $successful = true): Process
{
$process = $this->getMockBuilder(Process::class)
->setConstructorArgs([$command])
->getMock();

$process->method('run');
$process->method('isSuccessful')->willReturn($successful);

return $process;
}
}
36 changes: 36 additions & 0 deletions src/Backend/HeadlessChromium/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "knplabs/snappy-headless-chromium",
"description": "Headless Chromium adapter for KNP Snappy to generate PDFs from URIs or HTML.",
"license": "MIT",
"type": "library",
"authors": [
{
"name": "KNP Labs Team",
"homepage": "http://knplabs.com"
},
{
"name": "Symfony Community",
"homepage": "http://github.com/KnpLabs/snappy/contributors"
}
],
"homepage": "http://github.com/KnpLabs/snappy",
"require": {
"php": ">=8.1",
"knplabs/snappy-core": "^2.0",
"psr/http-factory": "^1.1",
"psr/http-message": "^2.0",
"symfony/process": "^5.4|^6.4|^7.1"
},
"require-dev": {
"nyholm/psr7": "^1.8",
"phpunit/phpunit": "^11.4"
},
"autoload": {
"psr-4": {
"KNPLabs\\Snappy\\Backend\\HeadlessChromium\\": "src/"
}
},
"config": {
"sort-packages": true
}
}
21 changes: 21 additions & 0 deletions src/Core/FileSystem/SplFileRessourceInfo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace KNPLabs\Snappy\Core\Filesystem;

final class SplResourceInfo extends \SplFileInfo
{
public static function fromTmpFile(): self
{
return new self(tmpfile());
}

/**
* @param resource $resource
*/
public function __construct(public readonly mixed $resource)
{
parent::__construct(stream_get_meta_data($this->resource)['uri']);
}
}

0 comments on commit 5c859d0

Please sign in to comment.