Skip to content

Commit 5c859d0

Browse files
committed
feat: implement headless-chromium
1 parent 6f407df commit 5c859d0

File tree

5 files changed

+316
-0
lines changed

5 files changed

+316
-0
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace KNPLabs\Snappy\Backend\HeadlessChromium;
6+
7+
use KNPLabs\Snappy\Core\Backend\Adapter\UriToPdf;
8+
use KNPLabs\Snappy\Core\Backend\Adapter\HtmlFileToPdf;
9+
use KNPLabs\Snappy\Core\Backend\Adapter\HtmlToPdf;
10+
use KNPLabs\Snappy\Core\Backend\Adapter\Reconfigurable;
11+
use KNPLabs\Snappy\Core\Backend\Options;
12+
use Psr\Http\Message\StreamFactoryInterface;
13+
use Psr\Http\Message\StreamInterface;
14+
use Psr\Http\Message\UriInterface;
15+
use SplFileInfo;
16+
use Symfony\Component\Process\Exception\ProcessFailedException;
17+
use Symfony\Component\Process\Process;
18+
19+
final class HeadlessChromiumAdapter implements UriToPdf, HtmlFileToPdf, HtmlToPdf
20+
{
21+
private string $tempDir;
22+
23+
/**
24+
* @use Reconfigurable<self>
25+
*/
26+
use Reconfigurable;
27+
28+
public function __construct(
29+
private Options $options,
30+
private StreamFactoryInterface $streamFactory
31+
) {}
32+
33+
public function generateFromUri(UriInterface $url): StreamInterface
34+
{
35+
$this->tempDir = sys_get_temp_dir();
36+
37+
$command = $this->buildChromiumCommand((string) $url, $this->tempDir);
38+
$this->runProcess($command);
39+
40+
return $this->createStreamFromFile($this->tempDir);
41+
}
42+
43+
public function generateFromHtmlFile(SplFileInfo $file): StreamInterface
44+
{
45+
$htmlContent = file_get_contents($file->getPathname());
46+
return $this->generateFromHtml($htmlContent);
47+
}
48+
49+
public function generateFromHtml(string $html): StreamInterface
50+
{
51+
$outputFile = $this->tempDir . '/pdf_output_';
52+
$htmlFile = $this->tempDir . '/html_input_';
53+
file_put_contents($htmlFile, $html);
54+
55+
$command = $this->buildChromiumCommand("file://$htmlFile", $outputFile);
56+
$this->runProcess($command);
57+
58+
unlink($htmlFile);
59+
return $this->createStreamFromFile($outputFile);
60+
}
61+
62+
/**
63+
* @return array<string>
64+
*/
65+
private function buildChromiumCommand(string $inputUri, string $outputPath): array
66+
{
67+
$options = $this->compileConstructOptions();
68+
69+
return array_merge([
70+
'chromium',
71+
'--headless',
72+
'--disable-gpu',
73+
'--no-sandbox',
74+
'--print-to-pdf=' . $outputPath,
75+
], $options, [$inputUri]);
76+
}
77+
78+
/**
79+
* @return array<string>
80+
*/
81+
private function compileConstructOptions(): array
82+
{
83+
$constructOptions = $this->options->extraOptions['construct'] ?? [];
84+
85+
$compiledOptions = [];
86+
if (is_array($constructOptions)) {
87+
foreach ($constructOptions as $key => $value) {
88+
$compiledOptions[] = "--$key=$value";
89+
}
90+
}
91+
92+
return $compiledOptions;
93+
}
94+
95+
private function runProcess(array $command): void
96+
{
97+
$process = new Process($command);
98+
$process->run();
99+
100+
if (!$process->isSuccessful()) {
101+
throw new ProcessFailedException($process);
102+
}
103+
}
104+
105+
private function createStreamFromFile(string $filePath): StreamInterface
106+
{
107+
$output = file_get_contents($filePath);
108+
unlink($filePath);
109+
110+
return $this->streamFactory->createStream($output ?: '');
111+
}
112+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace KNPLabs\Snappy\Backend\HeadlessChromium;
6+
7+
use KNPLabs\Snappy\Core\Backend\Options;
8+
use Psr\Http\Message\StreamFactoryInterface;
9+
10+
final class HeadlessChromiumFactory
11+
{
12+
public function __construct(
13+
private StreamFactoryInterface $streamFactory
14+
) {}
15+
16+
public function create(Options $options): HeadlessChromiumAdapter
17+
{
18+
return new HeadlessChromiumAdapter($options, $this->streamFactory);
19+
}
20+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace KNPLabs\Snappy\Backend\HeadlessChromium\Tests;
6+
7+
use KNPLabs\Snappy\Backend\HeadlessChromium\HeadlessChromiumAdapter;
8+
use KNPLabs\Snappy\Backend\HeadlessChromium\HeadlessChromiumFactory;
9+
use KNPLabs\Snappy\Core\Backend\Options;
10+
use PHPUnit\Framework\TestCase;
11+
use Psr\Http\Message\StreamFactoryInterface;
12+
use Psr\Http\Message\StreamInterface;
13+
use Psr\Http\Message\UriInterface;
14+
use Symfony\Component\Process\Exception\ProcessFailedException;
15+
use Symfony\Component\Process\Process;
16+
17+
final class HeadlessChromiumAdapterTest extends TestCase
18+
{
19+
private Options $options;
20+
private StreamFactoryInterface $streamFactory;
21+
private HeadlessChromiumAdapter $adapter;
22+
private HeadlessChromiumFactory $factory;
23+
24+
protected function setUp(): void
25+
{
26+
$this->options = new Options(null, []);
27+
$this->streamFactory = $this->createMock(StreamFactoryInterface::class);
28+
$this->factory = new HeadlessChromiumFactory($this->streamFactory);
29+
$this->adapter = $this->factory->create($this->options);
30+
}
31+
32+
public function testGenerateFromUri(): void
33+
{
34+
$url = $this->createMock(UriInterface::class);
35+
$url->method('__toString')->willReturn('https://example.com');
36+
}
37+
38+
public function testGenerateFromHtmlFile(): void
39+
{
40+
$file = $this->createMock(\SplFileInfo::class);
41+
$file->method('getPathname')->willReturn('/path/to/test.html');
42+
43+
$outputStream = $this->createMock(StreamInterface::class);
44+
$this->streamFactory->method('createStream')->willReturn($outputStream);
45+
46+
$process = $this->createMockProcess(['chromium', '--headless', '--print-to-pdf', 'output.pdf'], true);
47+
48+
$this->adapter->method('runProcess')->willReturnCallback(function ($command) use ($process) {
49+
$process->run();
50+
if (!$process->isSuccessful()) {
51+
throw new ProcessFailedException($process);
52+
}
53+
});
54+
55+
$result = $this->adapter->generateFromHtmlFile($file);
56+
57+
$this->assertSame($outputStream, $result);
58+
}
59+
60+
public function testGenerateFromHtml(): void
61+
{
62+
$htmlContent = '<html><body>Hello World</body></html>';
63+
64+
$outputStream = $this->createMock(StreamInterface::class);
65+
$this->streamFactory->method('createStream')->willReturn($outputStream);
66+
67+
$process = $this->createMockProcess(['chromium', '--headless', '--print-to-pdf', 'output.pdf'], true);
68+
69+
$this->adapter->method('runProcess')->willReturnCallback(function ($command) use ($process) {
70+
$process->run();
71+
if (!$process->isSuccessful()) {
72+
throw new ProcessFailedException($process);
73+
}
74+
});
75+
76+
$result = $this->adapter->generateFromHtml($htmlContent);
77+
78+
$this->assertSame($outputStream, $result);
79+
}
80+
81+
public function testProcessFailsOnInvalidUri(): void
82+
{
83+
$url = $this->createMock(UriInterface::class);
84+
$url->method('__toString')->willReturn('invalid-url');
85+
86+
$this->expectException(ProcessFailedException::class);
87+
88+
$process = $this->createMockProcess(['chromium', '--headless', '--print-to-pdf', 'output.pdf'], false);
89+
90+
$this->adapter->method('runProcess')->willReturnCallback(function ($command) use ($process) {
91+
$process->run();
92+
if (!$process->isSuccessful()) {
93+
throw new ProcessFailedException($process);
94+
}
95+
});
96+
97+
$this->adapter->generateFromUri($url);
98+
}
99+
100+
public function testProcessFailsOnEmptyHtml(): void
101+
{
102+
$this->expectException(ProcessFailedException::class);
103+
104+
$process = $this->createMockProcess(['chromium', '--headless', '--print-to-pdf', 'output.pdf'], false);
105+
106+
$this->adapter->method('runProcess')->willReturnCallback(function ($command) use ($process) {
107+
$process->run();
108+
if (!$process->isSuccessful()) {
109+
throw new ProcessFailedException($process);
110+
}
111+
});
112+
113+
$this->adapter->generateFromHtml('');
114+
}
115+
116+
private function createMockProcess(array $command, bool $successful = true): Process
117+
{
118+
$process = $this->getMockBuilder(Process::class)
119+
->setConstructorArgs([$command])
120+
->getMock();
121+
122+
$process->method('run');
123+
$process->method('isSuccessful')->willReturn($successful);
124+
125+
return $process;
126+
}
127+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"name": "knplabs/snappy-headless-chromium",
3+
"description": "Headless Chromium adapter for KNP Snappy to generate PDFs from URIs or HTML.",
4+
"license": "MIT",
5+
"type": "library",
6+
"authors": [
7+
{
8+
"name": "KNP Labs Team",
9+
"homepage": "http://knplabs.com"
10+
},
11+
{
12+
"name": "Symfony Community",
13+
"homepage": "http://github.com/KnpLabs/snappy/contributors"
14+
}
15+
],
16+
"homepage": "http://github.com/KnpLabs/snappy",
17+
"require": {
18+
"php": ">=8.1",
19+
"knplabs/snappy-core": "^2.0",
20+
"psr/http-factory": "^1.1",
21+
"psr/http-message": "^2.0",
22+
"symfony/process": "^5.4|^6.4|^7.1"
23+
},
24+
"require-dev": {
25+
"nyholm/psr7": "^1.8",
26+
"phpunit/phpunit": "^11.4"
27+
},
28+
"autoload": {
29+
"psr-4": {
30+
"KNPLabs\\Snappy\\Backend\\HeadlessChromium\\": "src/"
31+
}
32+
},
33+
"config": {
34+
"sort-packages": true
35+
}
36+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace KNPLabs\Snappy\Core\Filesystem;
6+
7+
final class SplResourceInfo extends \SplFileInfo
8+
{
9+
public static function fromTmpFile(): self
10+
{
11+
return new self(tmpfile());
12+
}
13+
14+
/**
15+
* @param resource $resource
16+
*/
17+
public function __construct(public readonly mixed $resource)
18+
{
19+
parent::__construct(stream_get_meta_data($this->resource)['uri']);
20+
}
21+
}

0 commit comments

Comments
 (0)