From 412d207408ff5a0d0e6b88a53cb2fe41cfd25106 Mon Sep 17 00:00:00 2001 From: Arthurlbc Date: Wed, 6 Nov 2024 15:00:44 +0100 Subject: [PATCH] feat: implementation of chromium headless --- composer.json | 1 + src/Backend/HeadlessChromium/ExtraOption.php | 13 ++ .../ExtraOption/DisableFeatures.php | 24 ++++ .../ExtraOption/DisableGpu.php | 20 +++ .../HeadlessChromium/ExtraOption/Headless.php | 20 +++ .../ExtraOption/NoSandbox.php | 20 +++ .../ExtraOption/PrintToPdf.php | 30 ++++ .../ExtraOption/WindowSize.php | 24 ++++ .../HeadlessChromiumAdapter.php | 130 ++++++++--------- .../HeadlessChromiumFactory.php | 26 +++- .../Tests/HeadlessChromiumAdapterTest.php | 132 ++++++------------ src/Core/FileSystem/SplFileRessourceInfo.php | 20 +-- 12 files changed, 291 insertions(+), 169 deletions(-) create mode 100644 src/Backend/HeadlessChromium/ExtraOption.php create mode 100644 src/Backend/HeadlessChromium/ExtraOption/DisableFeatures.php create mode 100644 src/Backend/HeadlessChromium/ExtraOption/DisableGpu.php create mode 100644 src/Backend/HeadlessChromium/ExtraOption/Headless.php create mode 100644 src/Backend/HeadlessChromium/ExtraOption/NoSandbox.php create mode 100644 src/Backend/HeadlessChromium/ExtraOption/PrintToPdf.php create mode 100644 src/Backend/HeadlessChromium/ExtraOption/WindowSize.php diff --git a/composer.json b/composer.json index fd30a2c9..9652b7f8 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 00000000..bc51288e --- /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 00000000..5ea5de0a --- /dev/null +++ b/src/Backend/HeadlessChromium/ExtraOption/DisableFeatures.php @@ -0,0 +1,24 @@ +features)]; + } +} diff --git a/src/Backend/HeadlessChromium/ExtraOption/DisableGpu.php b/src/Backend/HeadlessChromium/ExtraOption/DisableGpu.php new file mode 100644 index 00000000..a3de1664 --- /dev/null +++ b/src/Backend/HeadlessChromium/ExtraOption/DisableGpu.php @@ -0,0 +1,20 @@ +file]; + } + + public function getFile(): SplFileInfo + { + return $this->file; + } +} diff --git a/src/Backend/HeadlessChromium/ExtraOption/WindowSize.php b/src/Backend/HeadlessChromium/ExtraOption/WindowSize.php new file mode 100644 index 00000000..c0c492c1 --- /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 index db1acb2a..7dd3c5aa 100644 --- a/src/Backend/HeadlessChromium/HeadlessChromiumAdapter.php +++ b/src/Backend/HeadlessChromium/HeadlessChromiumAdapter.php @@ -1,112 +1,106 @@ */ use Reconfigurable; + private string $tempDir; + public function __construct( - private Options $options, - private StreamFactoryInterface $streamFactory - ) {} + private string $binary, + private int $timeout, + HeadlessChromiumFactory $factory, + Options $options, + private readonly StreamFactoryInterface $streamFactory, + private readonly UriFactoryInterface $uriFactory, + ) { + $this->tempDir = __DIR__; + self::validateOptions($options); + + $this->factory = $factory; + $this->options = $options; + } public function generateFromUri(UriInterface $url): StreamInterface { - $this->tempDir = sys_get_temp_dir(); + $process = new Process( + command: [ + $this->binary, + ...$this->compileOptions(), + (string) $url, + ], + timeout: $this->timeout + ); - $command = $this->buildChromiumCommand((string) $url, $this->tempDir); - $this->runProcess($command); + $process->run(); - return $this->createStreamFromFile($this->tempDir); + return $this->streamFactory->createStream($this->getPrintToPdfFilePath()); } - public function generateFromHtmlFile(SplFileInfo $file): StreamInterface + public function getPrintToPdfFilePath(): string { - $htmlContent = file_get_contents($file->getPathname()); - return $this->generateFromHtml($htmlContent); - } + $printToPdfOption = \array_filter( + $this->options->extraOptions, + fn ($option) => $option instanceof ExtraOption\PrintToPdf + ); - public function generateFromHtml(string $html): StreamInterface - { - $outputFile = $this->tempDir . '/pdf_output_'; - $htmlFile = $this->tempDir . '/html_input_'; - file_put_contents($htmlFile, $html); + if (!empty($printToPdfOption)) { + $printToPdfOption = \array_values($printToPdfOption)[0]; - $command = $this->buildChromiumCommand("file://$htmlFile", $outputFile); - $this->runProcess($command); + return $printToPdfOption->getFile()->getPathname(); + } - unlink($htmlFile); - return $this->createStreamFromFile($outputFile); + throw new RuntimeException('Missing option print to pdf.'); } - /** - * @return array - */ - private function buildChromiumCommand(string $inputUri, string $outputPath): array + private static function validateOptions(Options $options): void { - $options = $this->compileConstructOptions(); - - return array_merge([ - 'chromium', - '--headless', - '--disable-gpu', - '--no-sandbox', - '--print-to-pdf=' . $outputPath, - ], $options, [$inputUri]); - } + $optionTypes = []; - /** - * @return array - */ - private function compileConstructOptions(): array - { - $constructOptions = $this->options->extraOptions['construct'] ?? []; - - $compiledOptions = []; - if (is_array($constructOptions)) { - foreach ($constructOptions as $key => $value) { - $compiledOptions[] = "--$key=$value"; + 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), )); } - } - - return $compiledOptions; - } - private function runProcess(array $command): void - { - $process = new Process($command); - $process->run(); + if (\in_array($option::class, $optionTypes, true) && !$option->isRepeatable()) { + throw new InvalidArgumentException(\sprintf('Duplicate option type provided: "%s".', $option::class, )); + } - if (!$process->isSuccessful()) { - throw new ProcessFailedException($process); + $optionTypes[] = $option::class; } } - private function createStreamFromFile(string $filePath): StreamInterface + /** + * @return array + */ + private function compileOptions(): array { - $output = file_get_contents($filePath); - unlink($filePath); - - return $this->streamFactory->createStream($output ?: ''); + return \array_reduce( + $this->options->extraOptions, + fn (array $carry, ExtraOption $extraOption) => $this->options->pageOrientation !== null + ?: [ + ...$carry, + ...$extraOption->compile(), + ], + [], + ); } } diff --git a/src/Backend/HeadlessChromium/HeadlessChromiumFactory.php b/src/Backend/HeadlessChromium/HeadlessChromiumFactory.php index 6aa55b95..1d5017f0 100644 --- a/src/Backend/HeadlessChromium/HeadlessChromiumFactory.php +++ b/src/Backend/HeadlessChromium/HeadlessChromiumFactory.php @@ -1,20 +1,36 @@ + */ +final class HeadlessChromiumFactory implements Factory { public function __construct( - private StreamFactoryInterface $streamFactory - ) {} + private readonly string $binary, + private readonly int $timeout, + private readonly StreamFactoryInterface $streamFactory, + private readonly UriFactoryInterface $uriFactory, + ) { + } public function create(Options $options): HeadlessChromiumAdapter { - return new HeadlessChromiumAdapter($options, $this->streamFactory); + return new HeadlessChromiumAdapter( + $this->binary, + $this->timeout, + $this, + $options, + $this->streamFactory, + $this->uriFactory, + ); } } diff --git a/src/Backend/HeadlessChromium/Tests/HeadlessChromiumAdapterTest.php b/src/Backend/HeadlessChromium/Tests/HeadlessChromiumAdapterTest.php index 0a35b910..85af1676 100644 --- a/src/Backend/HeadlessChromium/Tests/HeadlessChromiumAdapterTest.php +++ b/src/Backend/HeadlessChromium/Tests/HeadlessChromiumAdapterTest.php @@ -1,127 +1,85 @@ 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'); + private Options $options; - $outputStream = $this->createMock(StreamInterface::class); - $this->streamFactory->method('createStream')->willReturn($outputStream); + private StreamFactoryInterface $streamFactory; - $process = $this->createMockProcess(['chromium', '--headless', '--print-to-pdf', 'output.pdf'], true); + private HeadlessChromiumAdapter $adapter; - $this->adapter->method('runProcess')->willReturnCallback(function ($command) use ($process) { - $process->run(); - if (!$process->isSuccessful()) { - throw new ProcessFailedException($process); - } - }); + private string $directory; - $result = $this->adapter->generateFromHtmlFile($file); + private UriFactoryInterface $uriFactory; - $this->assertSame($outputStream, $result); - } + private SplFileInfo $outputFile; - public function testGenerateFromHtml(): void + protected function setUp(): void { - $htmlContent = 'Hello World'; - - $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); + $this->uriFactory = $this->createMock(UriFactoryInterface::class); + $this->directory = __DIR__; + $this->outputFile = new SplFileInfo($this->directory . '/file.pdf'); + $this->options = new Options(null, [new Headless(), new PrintToPdf($this->outputFile), new DisableGpu()]); + $this->streamFactory = $this->createMock(StreamFactoryInterface::class); + $this->factory = new HeadlessChromiumFactory( + 'chromium', + 120, + $this->streamFactory, + $this->uriFactory + ); + $this->adapter = $this->factory->create($this->options); } - public function testProcessFailsOnInvalidUri(): void + public function testGenerateFromUri(): void { $url = $this->createMock(UriInterface::class); - $url->method('__toString')->willReturn('invalid-url'); + $url->method('__toString')->willReturn('https://google.com'); - $this->expectException(ProcessFailedException::class); + $this->streamFactory->expects($this->once()) + ->method('createStream') + ->with($this->stringContains($this->outputFile->getPathname())) + ->willReturn($this->createMock(StreamInterface::class)) + ; - $process = $this->createMockProcess(['chromium', '--headless', '--print-to-pdf', 'output.pdf'], false); + $resultStream = $this->adapter->generateFromUri($url); - $this->adapter->method('runProcess')->willReturnCallback(function ($command) use ($process) { - $process->run(); - if (!$process->isSuccessful()) { - throw new ProcessFailedException($process); - } - }); + $this->assertNotNull($resultStream); + $this->assertInstanceOf(StreamInterface::class, $resultStream); - $this->adapter->generateFromUri($url); + \unlink($this->directory . '/file.pdf'); } - public function testProcessFailsOnEmptyHtml(): void + public function testGetPrintToPdfFilePath(): void { - $this->expectException(ProcessFailedException::class); - - $process = $this->createMockProcess(['chromium', '--headless', '--print-to-pdf', 'output.pdf'], false); + $filePath = $this->adapter->getPrintToPdfFilePath(); + $this->assertEquals($this->outputFile->getPathname(), $filePath); - $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(); + $optionsWithoutPrintToPdf = new Options(null, [new Headless(), new DisableGpu()]); + $adapterWithoutPrintToPdf = $this->factory->create($optionsWithoutPrintToPdf); - $process->method('run'); - $process->method('isSuccessful')->willReturn($successful); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Missing option print to pdf.'); - return $process; + $adapterWithoutPrintToPdf->getPrintToPdfFilePath(); } } diff --git a/src/Core/FileSystem/SplFileRessourceInfo.php b/src/Core/FileSystem/SplFileRessourceInfo.php index 01e5058e..8b1c27d3 100644 --- a/src/Core/FileSystem/SplFileRessourceInfo.php +++ b/src/Core/FileSystem/SplFileRessourceInfo.php @@ -1,21 +1,23 @@ resource)['uri']); + parent::__construct(\stream_get_meta_data($this->resource)['uri']); + } + + public static function fromTmpFile(): self + { + return new self(\tmpfile()); } -} \ No newline at end of file +}