diff --git a/Makefile b/Makefile index 43e1b76..5bc5388 100644 --- a/Makefile +++ b/Makefile @@ -7,3 +7,11 @@ build: .PHONY: test test: build $(MAKE) -C src/Bundle test IMAGE_TAG="${IMAGE_TAG}" ARGS="${ARGS}" + +.PHONY: phpstan +phpstan: + php vendor/bin/phpstan analyse --level max src/ + +.PHONY: php-cs-fixer +php-cs-fixer: + tools/php-cs-fixer/vendor/bin/php-cs-fixer fix ./src diff --git a/composer.json b/composer.json index fd30a2c..9652b7f 100644 --- a/composer.json +++ b/composer.json @@ -51,6 +51,7 @@ "psr-4": { "KNPLabs\\Snappy\\Backend\\Dompdf\\": "src/Backend/Dompdf/", "KNPLabs\\Snappy\\Backend\\WkHtmlToPdf\\": "src/Backend/WkHtmlToPdf/", + "KNPLabs\\Snappy\\Backend\\HeadlessChromium\\": "src/Backend/HeadlessChromium/", "KNPLabs\\Snappy\\Core\\": "src/Core/", "KNPLabs\\Snappy\\Framework\\Symfony\\": "src/Framework/Symfony/" } diff --git a/src/Backend/HeadlessChromium/ExtraOption.php b/src/Backend/HeadlessChromium/ExtraOption.php new file mode 100644 index 0000000..bc51288 --- /dev/null +++ b/src/Backend/HeadlessChromium/ExtraOption.php @@ -0,0 +1,13 @@ + */ + public function compile(): array; +} diff --git a/src/Backend/HeadlessChromium/ExtraOption/DisableFeatures.php b/src/Backend/HeadlessChromium/ExtraOption/DisableFeatures.php new file mode 100644 index 0000000..7802a61 --- /dev/null +++ b/src/Backend/HeadlessChromium/ExtraOption/DisableFeatures.php @@ -0,0 +1,27 @@ + $features + */ + public function __construct(private readonly array $features) + { + } + + public function isRepeatable(): bool + { + return false; + } + + public function compile(): array + { + return ['--disable-features=' . \implode(',', $this->features)]; + } +} diff --git a/src/Backend/HeadlessChromium/ExtraOption/DisableGpu.php b/src/Backend/HeadlessChromium/ExtraOption/DisableGpu.php new file mode 100644 index 0000000..a3de166 --- /dev/null +++ b/src/Backend/HeadlessChromium/ExtraOption/DisableGpu.php @@ -0,0 +1,20 @@ +filePath]; + } + + public function getFilePath(): string + { + return $this->filePath; + } +} diff --git a/src/Backend/HeadlessChromium/ExtraOption/WindowSize.php b/src/Backend/HeadlessChromium/ExtraOption/WindowSize.php new file mode 100644 index 0000000..c0c492c --- /dev/null +++ b/src/Backend/HeadlessChromium/ExtraOption/WindowSize.php @@ -0,0 +1,24 @@ +width}x{$this->height}"]; + } +} diff --git a/src/Backend/HeadlessChromium/HeadlessChromiumAdapter.php b/src/Backend/HeadlessChromium/HeadlessChromiumAdapter.php new file mode 100644 index 0000000..4fcd65a --- /dev/null +++ b/src/Backend/HeadlessChromium/HeadlessChromiumAdapter.php @@ -0,0 +1,112 @@ + + */ + use Reconfigurable; + + public function __construct( + private string $binary, + private int $timeout, + HeadlessChromiumFactory $factory, + Options $options, + private readonly StreamFactoryInterface $streamFactory, + ) { + self::validateOptions($options); + + $this->factory = $factory; + $this->options = $options; + } + + public function generateFromUri(UriInterface $url): StreamInterface + { + $process = new Process( + command: [ + $this->binary, + ...$this->compileOptions(), + (string) $url, + ], + timeout: $this->timeout + ); + + $process->run(); + + return $this->streamFactory->createStream($this->getPrintToPdfFilePath()); + } + + public function getPrintToPdfFilePath(): string + { + $printToPdfOption = \array_filter( + $this->options->extraOptions, + fn ($option) => $option instanceof ExtraOption\PrintToPdf + ); + + if (!empty($printToPdfOption)) { + $printToPdfOption = \array_values($printToPdfOption)[0]; + + return $printToPdfOption->getFilePath(); + } + + throw new RuntimeException('Missing option print to pdf.'); + } + + private static function validateOptions(Options $options): void + { + $optionTypes = []; + + foreach ($options->extraOptions as $option) { + if (!$option instanceof ExtraOption) { + throw new InvalidArgumentException(\sprintf('Invalid option type provided. Expected "%s", received "%s".', ExtraOption::class, \gettype($option) === 'object' ? \get_class($option) : \gettype($option), )); + } + + if (\in_array($option::class, $optionTypes, true) && !$option->isRepeatable()) { + throw new InvalidArgumentException(\sprintf('Duplicate option type provided: "%s".', $option::class, )); + } + + $optionTypes[] = $option::class; + } + } + + /** + * @return array + */ + private function compileOptions(): array + { + return \array_reduce( + $this->options->extraOptions, + /** + * @param array $carry + * @param ExtraOption $extraOption + * + * @return array + */ + function (array $carry, $extraOption) { + if ($extraOption instanceof ExtraOption) { + return [ + ...$carry, + ...$extraOption->compile(), + ]; + } + + return $carry; + }, + [] + ); + } +} diff --git a/src/Backend/HeadlessChromium/HeadlessChromiumFactory.php b/src/Backend/HeadlessChromium/HeadlessChromiumFactory.php new file mode 100644 index 0000000..75b7685 --- /dev/null +++ b/src/Backend/HeadlessChromium/HeadlessChromiumFactory.php @@ -0,0 +1,33 @@ + + */ +final class HeadlessChromiumFactory implements Factory +{ + public function __construct( + private readonly string $binary, + private readonly int $timeout, + private readonly StreamFactoryInterface $streamFactory, + ) { + } + + public function create(Options $options): HeadlessChromiumAdapter + { + return new HeadlessChromiumAdapter( + $this->binary, + $this->timeout, + $this, + $options, + $this->streamFactory, + ); + } +} diff --git a/src/Backend/HeadlessChromium/Tests/HeadlessChromiumAdapterTest.php b/src/Backend/HeadlessChromium/Tests/HeadlessChromiumAdapterTest.php new file mode 100644 index 0000000..85bf471 --- /dev/null +++ b/src/Backend/HeadlessChromium/Tests/HeadlessChromiumAdapterTest.php @@ -0,0 +1,74 @@ +directory = __DIR__; + $this->outputFile = new SplFileInfo($this->directory . '/file.pdf'); + $this->options = new Options(null, [new Headless(), new PrintToPdf($this->outputFile->getPathname()), new DisableGpu()]); + $this->streamFactory = $this->createMock(StreamFactoryInterface::class); + $this->factory = new HeadlessChromiumFactory( + 'chromium', + 120, + $this->streamFactory, + ); + $this->adapter = $this->factory->create($this->options); + } + + public function testGenerateFromUri(): void + { + $url = $this->createMock(UriInterface::class); + $url->method('__toString')->willReturn('https://google.com'); + + $resultStream = $this->adapter->generateFromUri($url); + + $this->assertNotNull($resultStream); + $this->assertInstanceOf(StreamInterface::class, $resultStream); + + \unlink($this->directory . '/file.pdf'); + } + + public function testGetPrintToPdfFilePath(): void + { + $filePath = $this->adapter->getPrintToPdfFilePath(); + $this->assertEquals($this->outputFile->getPathname(), $filePath); + + $optionsWithoutPrintToPdf = new Options(null, [new Headless(), new DisableGpu()]); + $adapterWithoutPrintToPdf = $this->factory->create($optionsWithoutPrintToPdf); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Missing option print to pdf.'); + + $adapterWithoutPrintToPdf->getPrintToPdfFilePath(); + } +} diff --git a/src/Backend/HeadlessChromium/composer.json b/src/Backend/HeadlessChromium/composer.json new file mode 100644 index 0000000..a976ce1 --- /dev/null +++ b/src/Backend/HeadlessChromium/composer.json @@ -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 + } +} diff --git a/src/Core/FileSystem/SplFileRessourceInfo.php b/src/Core/FileSystem/SplFileRessourceInfo.php new file mode 100644 index 0000000..8b1c27d --- /dev/null +++ b/src/Core/FileSystem/SplFileRessourceInfo.php @@ -0,0 +1,23 @@ +resource)['uri']); + } + + public static function fromTmpFile(): self + { + return new self(\tmpfile()); + } +} diff --git a/src/Framework/Symfony/DependencyInjection/Configuration/HeadlessChromiumConfigurationFactory.php b/src/Framework/Symfony/DependencyInjection/Configuration/HeadlessChromiumConfigurationFactory.php new file mode 100644 index 0000000..a0df894 --- /dev/null +++ b/src/Framework/Symfony/DependencyInjection/Configuration/HeadlessChromiumConfigurationFactory.php @@ -0,0 +1,111 @@ +setDefinition( + $factoryId, + new Definition( + HeadlessChromiumFactory::class, + [ + '$streamFactory' => $container->getDefinition(Psr17Factory::class), + '$binary' => $configuration['binary'], + '$timeout' => $configuration['timeout'], + ] + ) + ) + ; + + $container + ->setDefinition( + $backendId, + (new Definition(HeadlessChromiumAdapter::class)) + ->setFactory([$container->getDefinition($factoryId), 'create']) + ->setArgument('$options', $options) + ) + ; + + $container->registerAliasForArgument($backendId, HeadlessChromiumAdapter::class, $backendName); + } + + public function getExample(): array + { + return [ + 'extraOptions' => [ + 'construct' => [], + 'output' => [], + ], + ]; + } + + public function addConfiguration(ArrayNodeDefinition $node): void + { + $node + ->children() + ->scalarNode('binary') + ->defaultValue('chromium') + ->info('Path or command to run Chromium') + ; + + $node + ->children() + ->scalarNode('headless') + ->defaultValue('--headless') + ->info('The flag to run Chromium in headless mode') + ; + + $node + ->children() + ->integerNode('timeout') + ->defaultValue(60) + ->info('Timeout for Chromium process') + ; + + $optionsNode = $node + ->children() + ->arrayNode('options') + ->info('Options to configure the Chromium process.') + ->addDefaultsIfNotSet() + ->children() + ; + + $optionsNode + ->arrayNode('extraOptions') + ->info('Extra options passed to the HeadlessChromiumAdapter.') + ->children() + ->scalarNode('printToPdf') + ->info(\sprintf('Configuration passed to %s::__construct().', PrintToPdf::class)) + ; + } +} diff --git a/src/Framework/Symfony/DependencyInjection/SnappyExtension.php b/src/Framework/Symfony/DependencyInjection/SnappyExtension.php index cdd9f59..36037b2 100644 --- a/src/Framework/Symfony/DependencyInjection/SnappyExtension.php +++ b/src/Framework/Symfony/DependencyInjection/SnappyExtension.php @@ -4,10 +4,12 @@ namespace KNPLabs\Snappy\Framework\Symfony\DependencyInjection; +use KNPLabs\Snappy\Backend\HeadlessChromium\ExtraOption\PrintToPdf; use KNPLabs\Snappy\Core\Backend\Options; use KNPLabs\Snappy\Core\Backend\Options\PageOrientation; use KNPLabs\Snappy\Framework\Symfony\DependencyInjection\Configuration\BackendConfigurationFactory; use KNPLabs\Snappy\Framework\Symfony\DependencyInjection\Configuration\DompdfConfigurationFactory; +use KNPLabs\Snappy\Framework\Symfony\DependencyInjection\Configuration\HeadlessChromiumConfigurationFactory; use KNPLabs\Snappy\Framework\Symfony\DependencyInjection\Configuration\WkHtmlToPdfConfigurationFactory; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -67,6 +69,7 @@ private function getFactories(): array [ new DompdfConfigurationFactory(), new WkHtmlToPdfConfigurationFactory(), + new HeadlessChromiumConfigurationFactory(), ], static fn (BackendConfigurationFactory $factory): bool => $factory->isAvailable(), ); @@ -99,19 +102,34 @@ private function buildOptions(string $backendName, string $backendType, array $c ]; if (isset($configuration['pageOrientation'])) { - if (false === \is_string($configuration['pageOrientation'])) { - throw new InvalidConfigurationException(\sprintf('Invalid “%s” type for “snappy.backends.%s.%s.options.pageOrientation”. The expected type is “string”.', $backendName, $backendType, \gettype($configuration['pageOrientation'])), ); + if (!\is_string($configuration['pageOrientation'])) { + throw new InvalidConfigurationException(\sprintf('Invalid type for “snappy.backends.%s.%s.options.pageOrientation”. Expected "string", got "%s".', $backendName, $backendType, \gettype($configuration['pageOrientation']))); } - $arguments['$pageOrientation'] = PageOrientation::from($configuration['pageOrientation']); } if (isset($configuration['extraOptions'])) { - if (false === \is_array($configuration['extraOptions'])) { - throw new InvalidConfigurationException(\sprintf('Invalid “%s” type for “snappy.backends.%s.%s.options.extraOptions”. The expected type is “array”.', $backendName, $backendType, \gettype($configuration['extraOptions'])), ); + if (!\is_array($configuration['extraOptions'])) { + throw new InvalidConfigurationException(\sprintf('Invalid type for “snappy.backends.%s.%s.options.extraOptions”. Expected "array", got "%s".', $backendName, $backendType, \gettype($configuration['extraOptions']))); } - $arguments['$extraOptions'] = $configuration['extraOptions']; + foreach ($configuration['extraOptions'] as $key => $value) { + switch ($key) { + case 'printToPdf': + if (\is_string($value)) { + $arguments['$extraOptions'][] = new PrintToPdf($value); + } else { + throw new InvalidConfigurationException(\sprintf('Invalid type for “snappy.backends.%s.%s.options.extraOptions.printToPdf”. Expected "string", got "%s".', $backendName, $backendType, \gettype($value))); + } + + break; + + default: + $arguments['$extraOptions'][$key] = $value; + + break; + } + } } return new Definition(Options::class, $arguments); diff --git a/src/Framework/Symfony/Tests/DependencyInjection/SnappyExtensionTest.php b/src/Framework/Symfony/Tests/DependencyInjection/SnappyExtensionTest.php index 9630740..be7ff6c 100644 --- a/src/Framework/Symfony/Tests/DependencyInjection/SnappyExtensionTest.php +++ b/src/Framework/Symfony/Tests/DependencyInjection/SnappyExtensionTest.php @@ -6,6 +6,9 @@ use KNPLabs\Snappy\Backend\Dompdf\DompdfAdapter; use KNPLabs\Snappy\Backend\Dompdf\DompdfFactory; +use KNPLabs\Snappy\Backend\HeadlessChromium\ExtraOption; +use KNPLabs\Snappy\Backend\HeadlessChromium\HeadlessChromiumAdapter; +use KNPLabs\Snappy\Backend\HeadlessChromium\HeadlessChromiumFactory; use KNPLabs\Snappy\Core\Backend\Options; use KNPLabs\Snappy\Core\Backend\Options\PageOrientation; use KNPLabs\Snappy\Framework\Symfony\DependencyInjection\SnappyExtension; @@ -14,6 +17,7 @@ use Psr\Http\Message\StreamFactoryInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; +use SplFileInfo; final class SnappyExtensionTest extends TestCase { @@ -121,4 +125,68 @@ public function testDompdfBackendConfiguration(): void ), ); } + + public function testHeadlessChromiumBackendConfiguration(): void + { + $directory = __DIR__; + $outputFile = new SplFileInfo($directory . '/file.pdf'); + + $configuration = [ + 'snappy' => [ + 'backends' => [ + 'myBackend' => [ + 'chromium' => [ + 'binary' => 'chromium', + 'timeout' => 60, + 'options' => [ + 'extraOptions' => [ + 'printToPdf' => $outputFile->getPathname(), + ], + ], + ], + ], + ], + ], + ]; + + $this->extension->load($configuration, $this->container); + + $this->assertEquals( + \array_keys($this->container->getDefinitions()), + [ + 'service_container', + StreamFactoryInterface::class, + 'snappy.backend.myBackend.factory', + 'snappy.backend.myBackend', + ] + ); + + $streamFactory = $this->container->get(StreamFactoryInterface::class); + + $this->assertInstanceOf(StreamFactoryInterface::class, $streamFactory); + + $factory = $this->container->get('snappy.backend.myBackend.factory'); + $this->assertInstanceOf(HeadlessChromiumFactory::class, $factory); + + $backend = $this->container->get('snappy.backend.myBackend'); + + $this->assertInstanceOf(HeadlessChromiumAdapter::class, $backend); + + $expectedOptions = new Options( + null, + [ + new ExtraOption\PrintToPdf($outputFile->getPathname()), + ] + ); + + $expectedBackend = new HeadlessChromiumAdapter( + 'chromium', + 60, + $factory, + $expectedOptions, + $streamFactory + ); + + $this->assertEquals($backend, $expectedBackend); + } }