diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 00000000..f01d7b13 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,77 @@ +name: Tests + +on: + pull_request: ~ + push: ~ + +jobs: + packages: + runs-on: ubuntu-20.04 + outputs: + packages: ${{ steps.script.outputs.result }} + steps: + - uses: actions/checkout@v4 + - uses: actions/github-script@v7 + id: script + with: + script: | + const fs = require('fs'); + + const composer = JSON.parse( + fs.readFileSync('./composer.json') + ); + + const packages = Object.keys(composer.autoload['psr-4']).map( + (namespace) => { + const path = composer.autoload['psr-4'][namespace]; + const name = JSON.parse(fs.readFileSync(path + 'composer.json')).name; + + return { + name: name, + path: './' + path, + }; + } + ); + + packages.push({name: "knplabs/snappy", path: './'}) + + console.log(packages); + + return packages; + + package: + runs-on: ubuntu-20.04 + needs: packages + strategy: + matrix: + includes: ${{ needs.packages.outputs.packages }} + steps: + - uses: actions/checkout@v4 + - uses: actions/github-script@v7 + id: packages + with: + script: | + const fs = require('fs'); + + const composer = JSON.parse( + fs.readFileSync('./composer.json') + ); + + const packages = Object.keys(composer.autoload['psr-4']).map( + (namespace) => { + const path = composer.autoload['psr-4'][namespace]; + const name = JSON.parse(fs.readFileSync(path + 'composer.json')).name; + + return { + name: name, + path: './' + path, + }; + } + ); + + packages.push({name: "knplabs/snappy", path: './'}) + + console.log(packages); + + return packages; + diff --git a/.phpunit.result.cache b/.phpunit.result.cache new file mode 100644 index 00000000..a50923ef --- /dev/null +++ b/.phpunit.result.cache @@ -0,0 +1 @@ +{"version":1,"defects":{"KNPLabs\\Snappy\\Framework\\Symfony\\Tests\\DependencyInjection\\SnappyExtensionTest::testLoadEmptyConfiguration":8,"KNPLabs\\Snappy\\Framework\\Symfony\\Tests\\DependencyInjection\\SnappyExtensionTest::testConfigure":5,"KNPLabs\\Snappy\\Framework\\Symfony\\Tests\\DependencyInjection\\SnappyExtensionTest::testConfigureTmpDirectory":7,"KNPLabs\\Snappy\\Framework\\Symfony\\Tests\\DependencyInjection\\SnappyExtensionTest::testDompdfBackendConfiguration":8,"KNPLabs\\Snappy\\Core\\Tests\\Stream\\FileStreamTest::testTmpFileIsAutomaticalyRemoved":8},"times":{"KNPLabs\\Snappy\\Framework\\Symfony\\Tests\\DependencyInjection\\SnappyExtensionTest::testLoadEmptyConfiguration":0.003,"KNPLabs\\Snappy\\Framework\\Symfony\\Tests\\DependencyInjection\\SnappyExtensionTest::testConfigure":0,"KNPLabs\\Snappy\\Framework\\Symfony\\Tests\\DependencyInjection\\SnappyExtensionTest::testConfigureTmpDirectory":0,"KNPLabs\\Snappy\\Framework\\Symfony\\Tests\\DependencyInjection\\SnappyExtensionTest::testDompdfBackendConfiguration":0.001,"KNPLabs\\Snappy\\Core\\Tests\\Stream\\FileStreamTest::testTmpFileStream":0.005,"KNPLabs\\Snappy\\Core\\Tests\\Stream\\FileStreamTest::testTmpFileStreamCreateTemporaryFile":0,"KNPLabs\\Snappy\\Core\\Tests\\Stream\\FileStreamTest::testTmpFileStreamReadTheFile":0,"KNPLabs\\Snappy\\Core\\Tests\\Stream\\FileStreamTest::testTmpFileIsAutomaticalyRemoved":0}} \ No newline at end of file diff --git a/bin/sync-composer.php b/bin/sync-composer.php new file mode 100644 index 00000000..9e0cae15 --- /dev/null +++ b/bin/sync-composer.php @@ -0,0 +1,56 @@ + $constraint) { + if (isset($replace[$name])) { + continue; + } + + if (false === isset($dependencies[$name])) { + throw new \Exception( + sprintf( + 'Dependency "%s" not found in %s/composer.json.', + $name, + __DIR__, + ) + ); + } + + $json[$part][$name] = $dependencies[$name]; + } + } + + $content = file_put_contents( + $path . '/composer.json', + json_encode( + $json, + flags: JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR|JSON_UNESCAPED_SLASHES, + ), + ); +} diff --git a/composer.json b/composer.json index e6babd5f..fd30a2c9 100644 --- a/composer.json +++ b/composer.json @@ -1,10 +1,16 @@ { "name": "knplabs/knp-snappy", - "type": "library", "description": "PHP library allowing thumbnail, snapshot or PDF generation from a url or a html page. Wrapper for wkhtmltopdf/wkhtmltoimage.", - "keywords": ["pdf", "thumbnail", "snapshot", "knplabs", "knp", "wkhtmltopdf"], - "homepage": "http://github.com/KnpLabs/snappy", "license": "MIT", + "type": "library", + "keywords": [ + "pdf", + "thumbnail", + "snapshot", + "knplabs", + "knp", + "wkhtmltopdf" + ], "authors": [ { "name": "KNP Labs Team", @@ -15,18 +21,46 @@ "homepage": "http://github.com/KnpLabs/snappy/contributors" } ], + "homepage": "http://github.com/KnpLabs/snappy", "require": { "php": ">=8.1", - "symfony/process": "~5.0||~6.0", - "psr/log": "^2.0||^3.0", - "symfony/http-client": "^6.2", - "psr/http-message": "^2.0" + "dompdf/dompdf": "^3.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "psr/log": "^2.0|^3.0", + "symfony/config": "^5.4|^6.4|^7.1", + "symfony/dependency-injection": "^5.4|^6.4|^7.1", + "symfony/http-client": "^5.4|^6.4|^7.1", + "symfony/http-kernel": "^5.4|^6.4|^7.1", + "symfony/process": "^5.4|^6.4|^7.1" + }, + "require-dev": { + "nyholm/psr7": "^1.8", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-phpunit": "^1.4", + "phpunit/phpunit": "^11.4" + }, + "replace": { + "knplabs/snappy-bundle": "self.version", + "knplabs/snappy-core": "self.version", + "knplabs/snappy-dompdf": "self.version", + "knplabs/snappy-wkhtmltopdf": "self.version" }, "autoload": { "psr-4": { - "KnpLabs\\Snappy\\": "src/" + "KNPLabs\\Snappy\\Backend\\Dompdf\\": "src/Backend/Dompdf/", + "KNPLabs\\Snappy\\Backend\\WkHtmlToPdf\\": "src/Backend/WkHtmlToPdf/", + "KNPLabs\\Snappy\\Core\\": "src/Core/", + "KNPLabs\\Snappy\\Framework\\Symfony\\": "src/Framework/Symfony/" } }, + "config": { + "allow-plugins": { + "phpstan/extension-installer": true + }, + "sort-packages": true + }, "extra": { "branch-alias": { "dev-master": "2.x-dev" diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 00000000..776ccd86 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,4 @@ +parameters: + level: max + paths: + - src diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..f42624f3 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,29 @@ + + + + + + + + + + ./src/Backend/Dompdf + + + ./src/Backend/WkHtmlToPdf + + + ./src/Core/ + + + ./src/Framework/Symfony/ + + + diff --git a/src/Backend/Dompdf/DompdfAdapter.php b/src/Backend/Dompdf/DompdfAdapter.php new file mode 100644 index 00000000..266ffbb3 --- /dev/null +++ b/src/Backend/Dompdf/DompdfAdapter.php @@ -0,0 +1,104 @@ + + */ + use Reconfigurable; + + public function __construct( + DompdfFactory $factory, + Options $options, + private readonly StreamFactoryInterface $streamFactory + ) { + $this->factory = $factory; + $this->options = $options; + } + + public function generateFromDOMDocument(DOMDocument $DOMDocument): StreamInterface + { + $dompdf = $this->buildDompdf(); + $dompdf->loadDOM($DOMDocument); + + return $this->createStream($dompdf); + } + + public function generateFromHtmlFile(SplFileInfo $file): StreamInterface + { + $dompdf = $this->buildDompdf(); + $dompdf->loadHtmlFile($file->getPath()); + + return $this->createStream($dompdf); + } + + public function generateFromHtml(string $html): StreamInterface + { + $dompdf = $this->buildDompdf(); + $dompdf->loadHtml($html); + + return $this->createStream($dompdf); + } + + private function buildDompdf(): Dompdf\Dompdf + { + return new Dompdf\Dompdf( $this->compileInitOptions()); + } + + private function compileInitOptions(): Dompdf\Options + { + $options = new Dompdf\Options( + is_array($this->options->extraOptions['initOptions']) + ? $this->options->extraOptions['initOptions'] + : null + ); + + if (null !== $this->options->pageOrientation) { + $options->setDefaultPaperOrientation( + $this->options->pageOrientation->value + ); + } + + return $options; + } + + /** + * @return array + */ + private function compileOutputOptions(): array + { + $options = $this->options->extraOptions['outputOptions']; + + if (false === is_array($options)) { + $options = []; + } + + return $options; + } + + private function createStream(Dompdf\Dompdf $dompdf): StreamInterface + { + $output = $dompdf->output($this->compileOutputOptions()); + + return $this + ->streamFactory + ->createStream($output ?: '') + ; + } +} diff --git a/src/Backend/Dompdf/DompdfFactory.php b/src/Backend/Dompdf/DompdfFactory.php new file mode 100644 index 00000000..17b08f6b --- /dev/null +++ b/src/Backend/Dompdf/DompdfFactory.php @@ -0,0 +1,30 @@ + + */ +final readonly class DompdfFactory implements Factory +{ + public function __construct(private readonly StreamFactoryInterface $streamFactory) + { + } + + public function create(Options $options): DompdfAdapter + { + return new DompdfAdapter( + factory: $this, + options: $options, + streamFactory: $this->streamFactory, + ); + } +} diff --git a/src/Backend/Dompdf/composer.json b/src/Backend/Dompdf/composer.json new file mode 100644 index 00000000..9089d2db --- /dev/null +++ b/src/Backend/Dompdf/composer.json @@ -0,0 +1,35 @@ +{ + "name": "knplabs/snappy-dompdf", + "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", + "dompdf/dompdf": "^3.0", + "knplabs/snappy-core": "^2.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0" + }, + "require-dev": { + "nyholm/psr7": "^1.8", + "phpunit/phpunit": "^11.4" + }, + "autoload": { + "psr-4": { + "KNPLabs\\Snappy\\Backend\\Dompdf\\": "src/" + } + }, + "config": { + "sort-packages": true + } +} \ No newline at end of file diff --git a/src/Backend/WkHtmlToPdf/WkHtmlToPdf.php b/src/Backend/WkHtmlToPdf/WkHtmlToPdf.php deleted file mode 100644 index a17fda7e..00000000 --- a/src/Backend/WkHtmlToPdf/WkHtmlToPdf.php +++ /dev/null @@ -1,17 +0,0 @@ - + */ + use Reconfigurable; + + /** + * @param non-empty-string $binary + * @param positive-int $timeout + */ + public function __construct( + private string $binary, + private int $timeout, + WkHtmlToPdfFactory $factory, + Options $options + ) { + $this->factory = $factory; + $this->options = $options; + } + + public function generateFromHtmlFile(SplFileInfo $file): StreamInterface + { + throw new \Exception("Not implemented for {$this->binary} with timeout {$this->timeout}."); + } +} diff --git a/src/Backend/WkHtmlToPdf/WkHtmlToPdfFactory.php b/src/Backend/WkHtmlToPdf/WkHtmlToPdfFactory.php new file mode 100644 index 00000000..77829e36 --- /dev/null +++ b/src/Backend/WkHtmlToPdf/WkHtmlToPdfFactory.php @@ -0,0 +1,35 @@ + + */ +final class WkHtmlToPdfFactory implements Factory +{ + /** + * @param non-empty-string $binary + * @param positive-int $timeout + */ + public function __construct(private readonly string $binary, private readonly int $timeout) + { + + } + + public function create(Options $options): Adapter + { + return new WkHtmlToPdfAdapter( + $this->binary, + $this->timeout, + $this, + $options, + ); + } +} diff --git a/src/Backend/WkHtmlToPdf/composer.json b/src/Backend/WkHtmlToPdf/composer.json new file mode 100644 index 00000000..150de1f5 --- /dev/null +++ b/src/Backend/WkHtmlToPdf/composer.json @@ -0,0 +1,35 @@ +{ + "name": "knplabs/snappy-wkhtmltopdf", + "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\\WkHtmlToPdf\\": "src/" + } + }, + "config": { + "sort-packages": true + } +} \ No newline at end of file diff --git a/src/Bundle/DependencyInjection/Configuration.php b/src/Bundle/DependencyInjection/Configuration.php deleted file mode 100644 index ae9d19ef..00000000 --- a/src/Bundle/DependencyInjection/Configuration.php +++ /dev/null @@ -1,49 +0,0 @@ -getRootNode() - ->children() - ->arrayNode('backends') - ->useAttributeAsKey('name') - ->arrayPrototype() - ->children() - ->scalarNode('driver') - ->isRequired() - ->validate() - ->ifNotInArray(['wkhtmltopdf', 'chromium']) - ->thenInvalid('Invalid backend driver %s') - ->end() - ->end() - ->integerNode('timeout') - ->min(1) - ->defaultValue(30) - ->end() - ->scalarNode('binary_path') - ->isRequired() - ->cannotBeEmpty() - ->end() - ->arrayNode('options') - ->useAttributeAsKey('name') - ->scalarPrototype()->end() - ->end() - ->end() - ->end() - ->end() - ->end() - ; - - return $treeBuilder; - } -} diff --git a/src/Bundle/DependencyInjection/SnappyExtension.php b/src/Bundle/DependencyInjection/SnappyExtension.php deleted file mode 100644 index 52d5f46c..00000000 --- a/src/Bundle/DependencyInjection/SnappyExtension.php +++ /dev/null @@ -1,18 +0,0 @@ -processConfiguration( - new Configuration(), - [[ - 'backends' => [ - 'my_minimally_configured_wkhtmltopdf_backend' => [ - 'driver' => 'wkhtmltopdf', - 'binary_path' => '/usr/bin/wkhtmltopdf', - ], - ], - ]] - ); - - $expected = [ - 'backends' => [ - 'my_minimally_configured_wkhtmltopdf_backend' => [ - 'driver' => 'wkhtmltopdf', - 'timeout' => 30, - 'binary_path' => '/usr/bin/wkhtmltopdf', - 'options' => [], - ], - ], - ]; - - $this->assertEquals($config, $expected); - } - - public function testItProcessesAFullWkhtmltopdfConfiguration(): void - { - $config = (new Processor())->processConfiguration( - new Configuration(), - [[ - 'backends' => [ - 'my_fully_configured_wkhtmltopdf_backend' => [ - 'driver' => 'wkhtmltopdf', - 'timeout' => 60, - 'binary_path' => '/usr/bin/wkhtmltopdf', - 'options' => [ - 'key1' => 'val', - 'key2' => null, - 'key3', - ], - ], - ], - ]] - ); - - $expected = [ - 'backends' => [ - 'my_fully_configured_wkhtmltopdf_backend' => [ - 'driver' => 'wkhtmltopdf', - 'timeout' => 60, - 'binary_path' => '/usr/bin/wkhtmltopdf', - 'options' => [ - 'key1' => 'val', - 'key2' => null, - 'key3', - ], - ], - ], - ]; - - $this->assertEquals($config, $expected); - } - - public function testItProcessesAMinimalChromiumConfiguration(): void - { - $config = (new Processor())->processConfiguration( - new Configuration(), - [[ - 'backends' => [ - 'my_minimally_configured_chromium_backend' => [ - 'driver' => 'chromium', - 'binary_path' => '/usr/bin/chromium', - ], - ], - ]] - ); - - $expected = [ - 'backends' => [ - 'my_minimally_configured_chromium_backend' => [ - 'driver' => 'chromium', - 'timeout' => 30, - 'binary_path' => '/usr/bin/chromium', - 'options' => [], - ], - ], - ]; - - $this->assertEquals($config, $expected); - } - - public function testItProcessesAFullChromiumConfiguration(): void - { - $config = (new Processor())->processConfiguration( - new Configuration(), - [[ - 'backends' => [ - 'my_fully_configured_chromium_backend' => [ - 'driver' => 'chromium', - 'timeout' => 60, - 'binary_path' => '/usr/bin/chromium', - 'options' => [ - 'key1' => 'val', - 'key2' => null, - 'key3', - ], - ], - ], - ]] - ); - - $expected = [ - 'backends' => [ - 'my_fully_configured_chromium_backend' => [ - 'driver' => 'chromium', - 'timeout' => 60, - 'binary_path' => '/usr/bin/chromium', - 'options' => [ - 'key1' => 'val', - 'key2' => null, - 'key3', - ], - ], - ], - ]; - - $this->assertEquals($config, $expected); - } - - public function testItProcessesAMultiBackendConfiguration(): void - { - $config = (new Processor())->processConfiguration( - new Configuration(), - [[ - 'backends' => [ - 'my_minimally_configured_wkhtmltopdf_backend' => [ - 'driver' => 'wkhtmltopdf', - 'binary_path' => '/usr/bin/wkhtmltopdf', - ], - 'my_fully_configured_wkhtmltopdf_backend' => [ - 'driver' => 'wkhtmltopdf', - 'timeout' => 60, - 'binary_path' => '/usr/bin/wkhtmltopdf', - 'options' => [ - 'key1' => 'val', - 'key2' => null, - 'key3', - ], - ], - 'my_fully_configured_chromium_backend' => [ - 'driver' => 'chromium', - 'timeout' => 60, - 'binary_path' => '/usr/bin/chromium', - 'options' => [ - 'key1' => 'val', - 'key2' => null, - 'key3', - ], - ], - ], - ]] - ); - - $expected = [ - 'backends' => [ - 'my_minimally_configured_wkhtmltopdf_backend' => [ - 'driver' => 'wkhtmltopdf', - 'timeout' => 30, - 'binary_path' => '/usr/bin/wkhtmltopdf', - 'options' => [], - ], - 'my_fully_configured_wkhtmltopdf_backend' => [ - 'driver' => 'wkhtmltopdf', - 'timeout' => 60, - 'binary_path' => '/usr/bin/wkhtmltopdf', - 'options' => [ - 'key1' => 'val', - 'key2' => null, - 'key3', - ], - ], - 'my_fully_configured_chromium_backend' => [ - 'driver' => 'chromium', - 'timeout' => 60, - 'binary_path' => '/usr/bin/chromium', - 'options' => [ - 'key1' => 'val', - 'key2' => null, - 'key3', - ], - ], - ], - ]; - - $this->assertEquals($config, $expected); - } - - public function testItThrowsWhenProcessingAnInvalidDriverConfiguration(): void - { - $this->expectException(InvalidConfigurationException::class); - - (new Processor())->processConfiguration( - new Configuration(), - [[ - 'backends' => [ - 'invalid_backend' => [ - 'driver' => 'non-existing-driver', - 'binary_path' => '/usr/bin/non-existing-binary', - ], - ], - ]] - ); - } - - public function testItThrowsWhenProcessingAnInvalidBinaryPathConfiguration(): void - { - $this->expectException(InvalidConfigurationException::class); - - (new Processor())->processConfiguration( - new Configuration(), - [[ - 'backends' => [ - 'invalid_backend' => [ - 'driver' => 'wkhtmltopdf', - ], - ], - ]] - ); - } - - public function testItThrowsWhenProcessingAnInvalidTimeoutConfiguration(): void - { - $this->expectException(InvalidConfigurationException::class); - - (new Processor())->processConfiguration( - new Configuration(), - [[ - 'backends' => [ - 'invalid_backend' => [ - 'driver' => 'wkhtmltopdf', - 'timeout' => 0, - 'binary_path' => '/usr/bin/wkhtmltopdf', - ], - ], - ]] - ); - } -} diff --git a/src/Core/Backend/Adapter.php b/src/Core/Backend/Adapter.php new file mode 100644 index 00000000..b06af24c --- /dev/null +++ b/src/Core/Backend/Adapter.php @@ -0,0 +1,13 @@ + + */ + private readonly Factory $factory; + + private readonly Options $options; + + /** + * @return TAdapter + */ + public function withOptions(Options|callable $options): static + { + if (is_callable($options)) { + $options = $options($this->options); + } + + return $this + ->factory + ->create($options) + ; + } +} diff --git a/src/Core/Backend/Adapter/UriToPdf.php b/src/Core/Backend/Adapter/UriToPdf.php new file mode 100644 index 00000000..df2c21e1 --- /dev/null +++ b/src/Core/Backend/Adapter/UriToPdf.php @@ -0,0 +1,14 @@ + $extraOptions + */ + public function __construct( + public readonly ?PageOrientation $pageOrientation, + public readonly array $extraOptions + ) { + } + + public static function create(): self + { + return new self( + pageOrientation: null, + extraOptions: [] + ); + } + + public function withPageOrientation(?PageOrientation $pageOrientation): self + { + return new self( + pageOrientation: $pageOrientation, + extraOptions: $this->extraOptions, + ); + } + + /** + * @param array $extraOptions + */ + public function withExtraOptions(array $extraOptions): self + { + return new self( + pageOrientation: $this->pageOrientation, + extraOptions: $extraOptions, + ); + } +} diff --git a/src/Core/Backend/Options/PageOrientation.php b/src/Core/Backend/Options/PageOrientation.php new file mode 100644 index 00000000..46d466ee --- /dev/null +++ b/src/Core/Backend/Options/PageOrientation.php @@ -0,0 +1,12 @@ +stringToPdf->generateFromString( - file_get_contents($file->getPathname()), - $options, - ); - } -} diff --git a/src/Core/Bridge/FromHtmlFileToHtmlToPdf.php b/src/Core/Bridge/FromHtmlFileToHtmlToPdf.php new file mode 100644 index 00000000..25d99414 --- /dev/null +++ b/src/Core/Bridge/FromHtmlFileToHtmlToPdf.php @@ -0,0 +1,36 @@ +getPathname()); + + if (false === $html) { + throw new \RuntimeException('Unable to read file.'); + } + + return $this->adapter->generateFromHtml($html); + } + + public function withOptions(Options|callable $options): self + { + return new self( $this->adapter->withOptions($options)); + } +} diff --git a/src/Core/Bridge/FromHtmlToHtmlFileToPdf.php b/src/Core/Bridge/FromHtmlToHtmlFileToPdf.php new file mode 100644 index 00000000..03f2e397 --- /dev/null +++ b/src/Core/Bridge/FromHtmlToHtmlFileToPdf.php @@ -0,0 +1,39 @@ +streamFactory); + + file_put_contents($temporary->file->getPathname(), $html); + + return $this->adapter->generateFromHtmlFile($temporary->file); + } + + public function withOptions(Options|callable $options): self + { + return new self( + $this->adapter->withOptions($options), + $this->streamFactory, + ); + } +} diff --git a/src/Core/Bridge/FromStringToFileToPdf.php b/src/Core/Bridge/FromStringToFileToPdf.php deleted file mode 100644 index fd22e359..00000000 --- a/src/Core/Bridge/FromStringToFileToPdf.php +++ /dev/null @@ -1,33 +0,0 @@ -fileToPdf->generateFromFile($file, $options); - } finally { - unlink($path); - } - - return $stream; - } -} diff --git a/src/Core/FileToPdf.php b/src/Core/FileToPdf.php deleted file mode 100644 index 9cd2db76..00000000 --- a/src/Core/FileToPdf.php +++ /dev/null @@ -1,10 +0,0 @@ -createStreamFromResource(tmpFile()); + $filename = $stream->getMetadata('uri'); + + if (false === is_string($filename)) { + throw new \UnexpectedValueException('Unable to retrieve the uri of the temporary file created.'); + } + + return new self( + new SplFileInfo($filename), + $stream + ); + } + + public function __construct(public readonly SplFileInfo $file, StreamInterface $stream) + { + $this->stream = $stream; + } +} diff --git a/src/Core/Stream/StreamWrapper.php b/src/Core/Stream/StreamWrapper.php new file mode 100644 index 00000000..efc85e70 --- /dev/null +++ b/src/Core/Stream/StreamWrapper.php @@ -0,0 +1,85 @@ +stream; + } + + public function close(): void + { + $this->stream->close(); + } + + public function detach() + { + return $this->stream->detach(); + } + + public function getSize(): ?int + { + return $this->stream->getSize(); + } + + public function tell(): int + { + return $this->stream->tell(); + } + + public function eof(): bool + { + return $this->stream->eof(); + } + + public function isSeekable(): bool + { + return $this->stream->isSeekable(); + } + + public function seek(int $offset, int $whence = 0): void + { + $this->stream->seek($offset, $whence); + } + + public function rewind(): void + { + $this->stream->rewind(); + } + + public function isWritable(): bool + { + return $this->stream->isWritable(); + } + + public function write(string $string): int + { + return $this->stream->write($string); + } + + public function isReadable(): bool + { + return $this->stream->isWritable(); + } + + public function read(int $length): string + { + return $this->stream->read($length); + } + + public function getContents(): string + { + return $this->stream->getContents(); + } + + public function getMetadata(?string $key = null) + { + return $this->stream->getMetadata($key); + } +} diff --git a/src/Core/StringToPdf.php b/src/Core/StringToPdf.php deleted file mode 100644 index aebb85fe..00000000 --- a/src/Core/StringToPdf.php +++ /dev/null @@ -1,10 +0,0 @@ -stream = FileStream::createTmpFile( + new Psr17Factory, + ); + } + + public function testTmpFileStreamCreateTemporaryFile(): void + { + $file = $this->stream->file; + + $this->assertFileExists($file->getPathname()); + $this->assertFileIsReadable( $file->getPathname()); + $this->assertFileIsWritable( $file->getPathname()); + } + + public function testTmpFileStreamReadTheFile(): void + { + $file = $this->stream->file; + + file_put_contents($file->getPathname(), 'the content'); + + $this->assertEquals( + (string) $this->stream, + 'the content', + ); + } + + public function testTmpFileIsAutomaticalyRemoved(): void + { + $file = $this->stream->file; + + $this->assertFileExists($file->getPathname()); + + unset($this->stream); + + $this->assertFileDoesNotExist($file->getPathname()); + } +} diff --git a/src/Core/UriToPdf.php b/src/Core/UriToPdf.php deleted file mode 100644 index f30a6ecf..00000000 --- a/src/Core/UriToPdf.php +++ /dev/null @@ -1,11 +0,0 @@ -=7.2.5", + "php": ">=8.1", "psr/http-message": "^2.0" }, "autoload": { "psr-4": { - "KnpLabs\\Snappy\\Core\\": "./" + "KNPLabs\\Snappy\\Core\\": "./" } } -} +} \ No newline at end of file diff --git a/src/Bundle/.gitattributes b/src/Framework/Symfony/.gitattributes similarity index 100% rename from src/Bundle/.gitattributes rename to src/Framework/Symfony/.gitattributes diff --git a/src/Bundle/.gitignore b/src/Framework/Symfony/.gitignore similarity index 100% rename from src/Bundle/.gitignore rename to src/Framework/Symfony/.gitignore diff --git a/src/Framework/Symfony/DependencyInjection/Configuration.php b/src/Framework/Symfony/DependencyInjection/Configuration.php new file mode 100644 index 00000000..d5e5548c --- /dev/null +++ b/src/Framework/Symfony/DependencyInjection/Configuration.php @@ -0,0 +1,91 @@ + + */ + private array $factories; + + public function __construct(BackendConfigurationFactory ...$factories) + { + $this->factories = $factories; + } + + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('snappy'); + $rootNode = $treeBuilder->getRootNode(); + + $backendNodeBuilder = $rootNode + ->children() + ->arrayNode('backends') + ->useAttributeAsKey('name') + ->example( + array_merge( + ...array_map( + fn(BackendConfigurationFactory $factory): array => [ + $factory->getKey() => [ + 'pageOrientation' => PageOrientation::PORTRAIT->value, + 'options' => [], + ...$factory->getExample() + ], + ], + $this->factories + ) + ) + ) + ->arrayPrototype() + ; + + foreach ($this->factories as $factory) { + $name = str_replace('-', '_', $factory->getKey()); + + $factoryNode = $backendNodeBuilder + ->children() + ->arrayNode($name) + ->canBeUnset() + ; + + $this->buildOptionsConfiguration($factoryNode); + + $factory->addConfiguration($factoryNode); + } + + return $treeBuilder; + } + + private function buildOptionsConfiguration(ArrayNodeDefinition $node): void + { + $optionsNode = $node + ->children() + ->arrayNode('options') + ; + + $optionsNode + ->children() + ->enumNode('pageOrientation') + ->values( + array_map( + fn(PageOrientation $pageOrientation): string => $pageOrientation->value, + PageOrientation::cases(), + ) + ) + ; + + $optionsNode + ->children() + ->arrayNode('extraOptions') + ; + } +} diff --git a/src/Framework/Symfony/DependencyInjection/Configuration/BackendConfigurationFactory.php b/src/Framework/Symfony/DependencyInjection/Configuration/BackendConfigurationFactory.php new file mode 100644 index 00000000..39970846 --- /dev/null +++ b/src/Framework/Symfony/DependencyInjection/Configuration/BackendConfigurationFactory.php @@ -0,0 +1,35 @@ + + */ + public function getExample(): array; + + /** + * @param array $configuration + * @param non-empty-string $backendId + * @param non-empty-string $factoryId + * @param non-empty-string $backendName + */ + public function create(ContainerBuilder $container, array $configuration, string $backendId, string $backendName, string $factoryId, Definition $options): void; + + public function addConfiguration(ArrayNodeDefinition $node): void; +} diff --git a/src/Framework/Symfony/DependencyInjection/Configuration/DompdfConfigurationFactory.php b/src/Framework/Symfony/DependencyInjection/Configuration/DompdfConfigurationFactory.php new file mode 100644 index 00000000..da03c235 --- /dev/null +++ b/src/Framework/Symfony/DependencyInjection/Configuration/DompdfConfigurationFactory.php @@ -0,0 +1,87 @@ +setDefinition( + $factoryId, + new Definition( + DompdfFactory::class, + [ + '$streamFactory' => $container->getDefinition(StreamFactoryInterface::class) + ] + ), + ); + + $container + ->setDefinition( + $backendId, + (new Definition(DompdfAdapter::class)) + ->setFactory([$container->getDefinition($factoryId), 'create']) + ->setArgument('$options', $options) + ) + ; + + $container->registerAliasForArgument($backendId, DompdfAdapter::class, $backendName); + } + + public function getExample(): array + { + return [ + 'extraOptions' => [ + 'initOptions' => [], + 'outputOptions' => [], + ] + ]; + } + + public function addConfiguration(ArrayNodeDefinition $node): void + { + $optionsNode = $node + ->getChildNodeDefinitions()['options'] + ; + + $extraOptionsNode = $optionsNode + ->getChildNodeDefinitions()['extraOptions'] + ; + + $extraOptionsNode + ->children() + ->variableNode('initOptions') + ->info(sprintf('Configuration passed to %s::__construct().', Dompdf::class)) + ; + + $extraOptionsNode + ->children() + ->variableNode('outputOptions') + ->info(sprintf('Configuration passed to %s::output().', Dompdf::class)) + ; + } +} diff --git a/src/Framework/Symfony/DependencyInjection/Configuration/WkHtmlToPdfConfigurationFactory.php b/src/Framework/Symfony/DependencyInjection/Configuration/WkHtmlToPdfConfigurationFactory.php new file mode 100644 index 00000000..96b61888 --- /dev/null +++ b/src/Framework/Symfony/DependencyInjection/Configuration/WkHtmlToPdfConfigurationFactory.php @@ -0,0 +1,81 @@ +setDefinition( + $factoryId, + new Definition( + WkHtmlToPdfFactory::class, + [ + '$streamFactory' => $container->getDefinition(StreamFactoryInterface::class), + '$binary' => $configuration['binary'], + '$timeout' => $configuration['timeout'], + ] + ), + ); + + $container + ->setDefinition( + $backendId, + (new Definition(WkHtmlToPdfAdapter::class)) + ->setFactory([$container->getDefinition($factoryId), 'create']) + ->setArgument('$options', $options) + ) + ; + + $container->registerAliasForArgument($backendId, WkHtmlToPdfAdapter::class, $backendName); + } + + public function getExample(): array + { + return [ + 'binary' => '/usr/local/bin/wkhtmltopdf', + ]; + } + + public function addConfiguration(ArrayNodeDefinition $node): void + { + $node + ->children() + ->scalarNode('binary') + ->defaultValue('wkhtmltopdf') + ->info('Path or command to run wkdtmltopdf') + ; + + $node + ->children() + ->integerNode('timeout') + ->defaultValue(60) + ->min(1) + ->info('Timeout (seconds) for wkhtmltopdf command') + ; + } +} diff --git a/src/Framework/Symfony/DependencyInjection/SnappyExtension.php b/src/Framework/Symfony/DependencyInjection/SnappyExtension.php new file mode 100644 index 00000000..7e138013 --- /dev/null +++ b/src/Framework/Symfony/DependencyInjection/SnappyExtension.php @@ -0,0 +1,135 @@ +processConfiguration( + $this->getConfiguration($configuration, $container), + $configuration + ); + + $factories = array_merge( + ...array_map( + static fn(BackendConfigurationFactory $factory): array => [$factory->getKey() => $factory], + $this->getFactories(), + ), + ); + + foreach ($configuration['backends'] as $backendName => $subConfiguration) { + foreach ($subConfiguration as $backendType => $backendConfiguration) { + $backendId = $this->buildBackendServiceId($backendName); + $factoryId = $this->buildFactoryServiceId($backendName); + $options = $this->buildOptions($backendName, $backendType, $backendConfiguration['options']); + + $factories[$backendType] + ->create( + $container, + $backendConfiguration, + $backendId, + $backendName, + $factoryId, + $options, + ) + ; + } + } + } + + /** + * @param array $configuration + */ + public function getConfiguration(array $configuration, ContainerBuilder $container): Configuration + { + return new Configuration(...$this->getFactories()); + } + + /** + * @return array + */ + private function getFactories(): array + { + return array_filter( + [ + new DompdfConfigurationFactory, + new WkHtmlToPdfConfigurationFactory, + ], + static fn (BackendConfigurationFactory $factory): bool => $factory->isAvailable(), + ); + } + + /** + * @return non-empty-string + */ + private function buildBackendServiceId(string $name): string + { + return "snappy.backend.$name"; + } + + /** + * @return non-empty-string + */ + private function buildFactoryServiceId(string $name): string + { + return "snappy.backend.$name.factory"; + } + + /** + * @param array $configuration + */ + private function buildOptions(string $backendName, string $backendType, array $configuration): Definition + { + $arguments = [ + '$pageOrientation' => null, + '$extraOptions' => [], + ]; + + 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']) + ), + ); + } + + $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']) + ), + ); + } + + $arguments[ '$extraOptions'] = $configuration['extraOptions']; + } + + return new Definition( Options::class, $arguments); + } +} diff --git a/src/Bundle/Makefile b/src/Framework/Symfony/Makefile similarity index 100% rename from src/Bundle/Makefile rename to src/Framework/Symfony/Makefile diff --git a/src/Framework/Symfony/SnappyBundle.php b/src/Framework/Symfony/SnappyBundle.php new file mode 100644 index 00000000..e5f0de9f --- /dev/null +++ b/src/Framework/Symfony/SnappyBundle.php @@ -0,0 +1,17 @@ +extension = new SnappyExtension; + $this->container = new ContainerBuilder; + + $this->container->setDefinition( + StreamFactoryInterface::class, + new Definition(Psr17Factory::class), + ); + } + + public function testLoadEmptyConfiguration(): void + { + $configuration = []; + + $this->extension->load( + $configuration, + $this->container, + ); + + $this->assertEquals( + array_keys($this->container->getDefinitions()), + [ + 'service_container', + StreamFactoryInterface::class, + ], + ); + } + + public function testDompdfBackendConfiguration(): void + { + $configuration = [ + 'snappy' => [ + 'backends' => [ + 'myBackend' => [ + 'dompdf' => [ + 'options' => [ + 'pageOrientation' => PageOrientation::LANDSCAPE->value, + 'extraOptions' => [ + 'initOptions' => [ + 'foo' => 'bar', + ], + 'outputOptions' => [ + 'baz' => 'yol', + ], + ] + ] + ] + ] + ] + ], + ]; + + $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(DompdfFactory::class, $factory); + $this->assertEquals( + $factory, + new DompdfFactory($streamFactory) + ); + + $backend = $this->container->get('snappy.backend.myBackend'); + + $this->assertInstanceOf(DompdfAdapter::class, $backend); + $this->assertEquals( + $factory, + new DompdfFactory($streamFactory), + ) ; + + $this->assertEquals( + $backend, + new DompdfAdapter( + $factory, + new Options( + PageOrientation::LANDSCAPE, + [ + 'initOptions' => ['foo' => 'bar'], + 'outputOptions' => ['baz' => 'yol'], + ], + ), + $streamFactory, + ), + ); + } +} diff --git a/src/Bundle/composer.json b/src/Framework/Symfony/composer.json similarity index 58% rename from src/Bundle/composer.json rename to src/Framework/Symfony/composer.json index c6afaa35..feeb791d 100644 --- a/src/Bundle/composer.json +++ b/src/Framework/Symfony/composer.json @@ -2,7 +2,13 @@ "name": "knplabs/snappy-bundle", "type": "symfony-bundle", "description": "Easily create PDF and images in Symfony from HTML inputs", - "keywords": ["knplabs", "knp", "snappy", "pdf", "bundle"], + "keywords": [ + "knplabs", + "knp", + "snappy", + "pdf", + "bundle" + ], "homepage": "http://github.com/KnpLabs/snappy", "license": "MIT", "authors": [ @@ -16,23 +22,26 @@ } ], "require": { - "php": ">=7.2.5", - "symfony/config": "^5.4|^6.0", - "symfony/dependency-injection": "^5.4|^6.0", - "symfony/http-kernel": "^5.4|^6.0" + "php": ">=8.1", + "knplabs/snappy-core": "^2.0", + "symfony/config": "^5.4|^6.4|^7.1", + "symfony/dependency-injection": "^5.4|^6.4|^7.1", + "symfony/http-kernel": "^5.4|^6.4|^7.1" }, "autoload": { - "psr-4": { "KnpLabs\\Snappy\\Bundle\\": "" }, + "psr-4": { + "KNPLabs\\Snappy\\Bundle\\": "" + }, "exclude-from-classmap": [ "/Tests/" ] }, "autoload-dev": { "psr-4": { - "Tests\\KnpLabs\\Snappy\\Bundle\\": "Tests/" + "Tests\\KNPLabs\\Snappy\\Bundle\\": "Tests/" } }, "require-dev": { - "phpunit/phpunit": "<7.5|^9.6" + "phpunit/phpunit": "^11.4" } } diff --git a/src/Bundle/phpunit.xml.dist b/src/Framework/Symfony/phpunit.xml.dist similarity index 100% rename from src/Bundle/phpunit.xml.dist rename to src/Framework/Symfony/phpunit.xml.dist