diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c429e0..da0fb39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: - name: Install packages uses: awalsh128/cache-apt-pkgs-action@v1 with: - packages: xfonts-base xfonts-75dpi wkhtmltopdf + packages: xfonts-base xfonts-75dpi wkhtmltopdf weasyprint version: ubuntu-24.04 - name: Composer install @@ -51,9 +51,9 @@ jobs: - name: Run PHPUnit run: | if [[ ${{ matrix.php-version }} == '8.5' ]]; then - vendor/bin/phpunit --display-warnings --display-deprecations --display-phpunit-deprecations --display-incomplete --display-skipped --coverage-clover=coverage.xml + vendor/bin/phpunit --display-all-issues --fail-on-all-issues --do-not-fail-on-skipped --do-not-fail-on-incomplete --coverage-clover=coverage.xml else - vendor/bin/phpunit --display-warnings --display-deprecations + vendor/bin/phpunit --display-phpunit-deprecations --display-warnings --display-deprecations fi - name: Code Coverage Report diff --git a/README.md b/README.md index ccd08c7..0281bf7 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,17 @@ Engines included in the plugin: * DomPdf (^3.0) * Mpdf (^8.0.4) * Tcpdf (^6.3) -* WkHtmlToPdf **RECOMMENDED ENGINE** +* WeasyPrint (**Recommended** if you have the privileges to install something on your server) +* WkHtmlToPdf (project no longer maintained but binaries still available for various environments) Community maintained engines: * [PDFreactor](https://github.com/jmischer/cake-pdfreactor) - ## Requirements -* One of the following render engines: DomPdf, Mpdf, Tcpdf or wkhtmltopdf +* One of the following render engines: DomPdf, Mpdf, Tcpdf, WeasyPrint or wkhtmltopdf * pdftk (optional) See: http://www.pdflabs.com/tools/pdftk-the-pdf-toolkit/ - ## Installation Using [Composer](http://getcomposer.org): @@ -33,7 +32,7 @@ composer require friendsofcake/cakepdf CakePdf does not include any of the supported PDF engines, you need to install the ones you intend to use yourself. -Packages for the recommend `wkhtmltopdf` engine can be downloaded from https://wkhtmltopdf.org/downloads.html. +Check [WeasyPrint's](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#installation) installation guide to install it on your system. DomPdf, Mpdf and Tcpdf can be installed via composer using one of the following commands: ``` @@ -74,22 +73,23 @@ pass the config array to constructor. The value for engine should have the Configuration options: * engine: Engine to be used (required), or an array of engine config options * className: Engine class to use - * binary: Binary file to use (Only for wkhtmltopdf) - * cwd: current working directory (Only for wkhtmltopdf) + * binary: Binary file to use (Only for weasyprint/wkhtmltopdf) + * cwd: current working directory (Only for weasyprint/wkhtmltopdf) * options: Engine specific options. Currently used for following engine: * `WkHtmlToPdfEngine`: The options are passed as CLI arguments + * `WeasyPrintEngine`: The options are passed as CLI arguments * `TexToPdfEngine`: The options are passed as CLI arguments * `DomPdfEngine`: The options are passed to constructor of `Dompdf` class * `MpdfEngine`: The options are passed to constructor of `Mpdf` class * crypto: Crypto engine to be used, or an array of crypto config options * className: Crypto class to use * binary: Binary file to use -* pageSize: Change the default size, defaults to A4 -* orientation: Change the default orientation, defaults to portrait -* margin: Array or margins with the keys: bottom, left, right, top and their values -* title: Title of the document -* delay: A delay in milliseconds to wait before rendering the pdf -* windowStatus: The required window status before rendering the pdf +* pageSize: Change the default size, defaults to A4 (Needs to be set via CSS for WeasyPrint) +* orientation: Change the default orientation, defaults to portrait (Needs to be set via CSS for WeasyPrint) +* margin: Array or margins with the keys: bottom, left, right, top and their values (Needs to be set via CSS for WeasyPrint) +* title: Title of the document (Needs to be set via CSS for WeasyPrint) +* delay: A delay in milliseconds to wait before rendering the pdf (wkhtmltopdf only) +* windowStatus: The required window status before rendering the pdf (wkhtmltopdf only) * encoding: Change the encoding, defaults to UTF-8 * download: Set to true to force a download, only when using PdfView * filename: Filename for the document when using forced download @@ -97,14 +97,7 @@ Configuration options: Example: ```php Configure::write('CakePdf', [ - 'engine' => 'CakePdf.WkHtmlToPdf', - 'margin' => [ - 'bottom' => 15, - 'left' => 50, - 'right' => 30, - 'top' => 45, - ], - 'orientation' => 'landscape', + 'engine' => 'CakePdf.WeasyPrint', 'download' => true, ]); ``` @@ -118,13 +111,13 @@ class InvoicesController extends AppController { parent::initialize(); - // https://book.cakephp.org/5/en/controllers.html#content-type-negotiation + // https://book.cakephp.org/5.x/controllers.html#content-type-negotiation $this->addViewClasses([PdfView::class]); } // In your Invoices controller you could set additional configs, // or override the global ones: - public function view($id = null) + public function view($id = null): void { $invoice = $this->Invoice->get($id); $this->viewBuilder()->setOption( @@ -145,32 +138,25 @@ options for the relevant class. For example: ```php Configure::write('CakePdf', [ 'engine' => [ - 'className' => 'CakePdf.WkHtmlToPdf', + 'className' => 'CakePdf.WeasyPrint', // Options usable depend on the engine used. 'options' => [ - 'print-media-type' => false, - 'outline' => true, 'dpi' => 96, - 'cover' => [ - 'url' => 'cover.html', - 'enable-smart-shrinking' => true, - ], - 'toc' => true, ], /** - * For Mac OS X / Linux by default the `wkhtmltopdf` binary should + * For Mac OS X / Linux by default the `weasyprint` binary should * be available through environment path or you can specify location as: */ - // 'binary' => '/usr/local/bin/wkhtmltopdf', + // 'binary' => '/usr/local/bin/weasyprint', /** * On Windows the engine uses the path shown below as default. * You NEED to use the path like old fashioned MS-DOS Paths, * otherwise you will get error like: - * "WKHTMLTOPDF didn't return any data" + * "weasyprint didn't return any data" */ - // 'binary' => 'C:\\Progra~1\\wkhtmltopdf\\bin\\wkhtmltopdf.exe', + // 'binary' => 'C:/Progra~1/WeasyPrint/bin/weasyprint.exe', ], ]); ``` diff --git a/psalm-baseline.xml b/psalm-baseline.xml index dd6f180..2bd6c76 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,12 +1,10 @@ - + - - @@ -29,59 +27,5 @@ - - - - - - - - - - - - - - - - - - - - html()]]> - _Pdf->orientation()]]> - _Pdf->pageSize()]]> - - - - - - _Pdf->html()]]> - - - - - - - - _Pdf->html()]]> - _Pdf->orientation()]]> - - - - - - - _Pdf->encoding()]]> - - - - - - - - _Pdf->html()]]> - - diff --git a/psalm.xml b/psalm.xml index c90aaac..8cdd551 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,5 +1,6 @@ - - - - - - - - - - - - - - - - - - + diff --git a/src/Pdf/CakePdf.php b/src/Pdf/CakePdf.php index ddd1e9c..1f4e30e 100644 --- a/src/Pdf/CakePdf.php +++ b/src/Pdf/CakePdf.php @@ -314,8 +314,7 @@ public function output(?string $html = null): string * Get/Set Html. * * @param string|null $html Html to set - * @return $this|string - * @psalm-return ($html is null ? string : $this) + * @return ($html is null ? string : $this) */ public function html(?string $html = null): static|string { @@ -438,8 +437,7 @@ public function crypto(string|array|null $name = null): AbstractPdfCrypto * Get/Set Page size. * * @param string|null $pageSize Page size to set - * @return $this|string - * @psalm-return ($pageSize is null ? string : $this) + * @return ($pageSize is null ? string : $this) */ public function pageSize(?string $pageSize = null): static|string { @@ -455,8 +453,7 @@ public function pageSize(?string $pageSize = null): static|string * Get/Set Orientation. * * @param string|null $orientation orientation to set - * @return $this|string - * @psalm-return ($orientation is null ? string : $this) + * @return ($orientation is null ? string : $this) */ public function orientation(?string $orientation = null): static|string { @@ -605,8 +602,7 @@ public function margin( * Get/Set bottom margin. * * @param string|int|null $margin margin to set - * @return $this|string|int|null - * @psalm-return ($margin is null ? string|int|null : $this) + * @return ($margin is null ? string|int|null : $this) */ public function marginBottom(string|int|null $margin = null): static|string|int|null { @@ -622,8 +618,7 @@ public function marginBottom(string|int|null $margin = null): static|string|int| * Get/Set left margin. * * @param string|int|null $margin margin to set - * @return $this|string|int|null - * @psalm-return ($margin is null ? string|int|null : $this) + * @return ($margin is null ? string|int|null : $this) */ public function marginLeft(string|int|null $margin = null): static|string|int|null { @@ -639,8 +634,7 @@ public function marginLeft(string|int|null $margin = null): static|string|int|nu * Get/Set right margin. * * @param string|int|null $margin margin to set - * @return $this|string|int|null - * @psalm-return ($margin is null ? string|int|null : $this) + * @return ($margin is null ? string|int|null : $this) */ public function marginRight(string|int|null $margin = null): static|string|int|null { @@ -656,8 +650,7 @@ public function marginRight(string|int|null $margin = null): static|string|int|n * Get/Set top margin. * * @param string|int|null $margin margin to set - * @return $this|string|int|null - * @psalm-return ($margin is null ? string|int|null : $this) + * @return ($margin is null ? string|int|null : $this) */ public function marginTop(string|int|null $margin = null): static|string|int|null { @@ -673,8 +666,7 @@ public function marginTop(string|int|null $margin = null): static|string|int|nul * Get/Set document title. * * @param string|null $title title to set - * @return $this|string|null - * @psalm-return ($title is null ? string|null : $this) + * @return ($title is null ? string|null : $this) */ public function title(?string $title = null): static|string|null { @@ -690,8 +682,7 @@ public function title(?string $title = null): static|string|null * Get/Set javascript delay. * * @param int|null $delay delay to set in milliseconds - * @return $this|int|null - * @psalm-return ($delay is null ? int|null : $this) + * @return ($delay is null ? int|null : $this) */ public function delay(?int $delay = null): static|int|null { @@ -708,8 +699,7 @@ public function delay(?int $delay = null): static|int|null * Waits until the status is equal to the string before rendering the pdf * * @param string|null $status status to set as string - * @return $this|string|null - * @psalm-return ($status is null ? string|null : $this) + * @return ($status is null ? string|null : $this) */ public function windowStatus(?string $status = null): static|string|null { @@ -725,8 +715,7 @@ public function windowStatus(?string $status = null): static|string|null * Get/Set protection. * * @param bool|null $protect True or false - * @return $this|bool - * @psalm-return ($protect is null ? bool : $this) + * @return ($protect is null ? bool : $this) */ public function protect(?bool $protect = null): static|bool { @@ -744,8 +733,7 @@ public function protect(?bool $protect = null): static|bool * The user password is used to control who can open the PDF document. * * @param string|null $password password to set - * @return $this|string|null - * @psalm-return ($password is null ? string|null : $this) + * @return ($password is null ? string|null : $this) */ public function userPassword(?string $password = null): static|string|null { @@ -763,8 +751,7 @@ public function userPassword(?string $password = null): static|string|null * The owner password is used to control who can modify, print, manage the PDF document. * * @param string|null $password password to set - * @return $this|string|null - * @psalm-return ($password is null ? string|null : $this) + * @return ($password is null ? string|null : $this) */ public function ownerPassword(?string $password = null): static|string|null { @@ -785,8 +772,7 @@ public function ownerPassword(?string $password = null): static|string|null * * @param array|string|bool|null $permissions Permissions to set * @throws \Cake\Core\Exception\CakeException - * @return $this|array|string|bool|null - * @psalm-return ($permissions is null ? array|string|bool|null : $this) + * @return ($permissions is null ? array|string|bool|null : $this) */ public function permissions(bool|array|string|null $permissions = null): static|array|string|bool|null { @@ -827,8 +813,7 @@ public function permissions(bool|array|string|null $permissions = null): static| * * @param string|bool|null $cache Cache config name to use, If true is passed, 'cake_pdf' will be used. * @throws \Cake\Core\Exception\CakeException - * @return $this|string|false - * @psalm-return ($cache is null ? string|false : $this) + * @return ($cache is null ? string|false : $this) */ public function cache(bool|string|null $cache = null): static|string|false { @@ -882,8 +867,7 @@ public function template(string|false|null $template = false, ?string $layout = * Template path * * @param ?string $templatePath The path of the template to use - * @return $this|string - * @psalm-return ($templatePath is null ? string : $this) + * @return ($templatePath is null ? string : $this) */ public function templatePath(?string $templatePath = null): static|string { @@ -900,8 +884,7 @@ public function templatePath(?string $templatePath = null): static|string * Layout path * * @param ?string $layoutPath The path of the layout file to use - * @return $this|string - * @psalm-return ($layoutPath is null ? string : $this) + * @return ($layoutPath is null ? string : $this) */ public function layoutPath(?string $layoutPath = null): static|string { @@ -918,8 +901,7 @@ public function layoutPath(?string $layoutPath = null): static|string * View class for render * * @param string|null $viewClass name of the view class to use - * @return $this|string - * @psalm-return ($viewClass is null ? string : $this) + * @return ($viewClass is null ? string : $this) */ public function viewRender(?string $viewClass = null): static|string { @@ -935,8 +917,7 @@ public function viewRender(?string $viewClass = null): static|string * Variables to be set on render * * @param array $viewVars view variables to set - * @return $this|array - * @psalm-return ($viewVars is null ? array : $this) + * @return ($viewVars is null ? array : $this) */ public function viewVars(?array $viewVars = null): static|array { @@ -952,8 +933,7 @@ public function viewVars(?array $viewVars = null): static|array * Theme to use when rendering * * @param string $theme theme to use - * @return $this|string|null - * @psalm-return ($theme is null ? string|null : $this) + * @return ($theme is null ? string|null : $this) */ public function theme(?string $theme = null): static|string|null { diff --git a/src/Pdf/Engine/WeasyPrintEngine.php b/src/Pdf/Engine/WeasyPrintEngine.php new file mode 100644 index 0000000..6d63753 --- /dev/null +++ b/src/Pdf/Engine/WeasyPrintEngine.php @@ -0,0 +1,175 @@ +_windowsEnvironment = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; + + if ($this->_windowsEnvironment) { + $this->_binary = 'C:/Progra~1/WeasyPrint/bin/weasyprint.exe'; + } + } + + /** + * Generates Pdf from html + * + * @return string Raw PDF data + * @throws \Cake\Core\Exception\CakeException If no output is generated to stdout by weasyprint. + */ + public function output(): string + { + $command = $this->_getCommand(); + $content = $this->_exec($command, $this->_Pdf->html()); + + if (!empty($content['stdout'])) { + return $content['stdout']; + } + + if (!empty($content['stderr'])) { + throw new CakeException(sprintf( + 'System error "%s" when executing command "%s".', + $content['stderr'], + $command, + )); + } + + throw new CakeException("weasyprint didn't return any data"); + } + + /** + * Execute the weasyprint commands for rendering pdfs + * + * @param string $cmd the command to execute + * @param string $input Html to pass to weasyprint + * @return array{stdout: string, stderr: string, return: int} the result of running the command to generate the pdf + */ + protected function _exec(string $cmd, string $input): array + { + $result = ['stdout' => '', 'stderr' => '', 'return' => 0]; + + $cwd = $this->getConfig('cwd'); + + $proc = proc_open($cmd, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd); + if ($proc === false) { + throw new CakeException('Unable to execute weasyprint, proc_open() failed'); + } + + fwrite($pipes[0], $input); + fclose($pipes[0]); + + $result['stdout'] = stream_get_contents($pipes[1]); + fclose($pipes[1]); + + $result['stderr'] = stream_get_contents($pipes[2]); + fclose($pipes[2]); + + $result['return'] = proc_close($proc); + + return $result; + } + + /** + * Get the command to render a pdf + * + * @return string the command for generating the pdf + * @throws \Cake\Core\Exception\CakeException + */ + protected function _getCommand(): string + { + $binary = $this->getBinaryPath(); + + $options = [ + 'encoding' => $this->_Pdf->encoding(), + ]; + + $options = array_merge($options, (array)$this->getConfig('options')); + + if ($this->_windowsEnvironment) { + $command = '"' . $binary . '"'; + } else { + $command = $binary; + } + + foreach ($options as $key => $value) { + if (!$value && $value !== 0 && $value !== '0') { + continue; + } + $command .= $this->parseOptions($key, $value); + } + + $command .= ' - -'; + + return $command; + } + + /** + * Parses a value of options to create a part of the command. + * + * @param string $key the option key name + * @param array|string|float|int|true $value the option value + * @return string part of the command + */ + protected function parseOptions(string $key, string|bool|array|float|int $value): string + { + $command = ''; + if (is_array($value)) { + foreach ($value as $v) { + $command .= sprintf(' --%s %s', $key, escapeshellarg((string)$v)); + } + } elseif ($value === true) { + $command .= ' --' . $key; + } else { + $command .= sprintf(' --%s %s', $key, escapeshellarg((string)$value)); + } + + return $command; + } + + /** + * Get path to weasyprint binary. + * + * @return string + */ + public function getBinaryPath(): string + { + $binary = $this->getConfig('binary', $this->_binary); + + /** @psalm-suppress ForbiddenCode */ + if ( + is_executable($binary) || + (!$this->_windowsEnvironment && shell_exec('which ' . escapeshellarg($binary))) + ) { + return $binary; + } + + throw new CakeException(sprintf('weasyprint binary is not found or not executable: %s', $binary)); + } +} diff --git a/tests/TestCase/Pdf/Engine/WeasyPrintEngineTest.php b/tests/TestCase/Pdf/Engine/WeasyPrintEngineTest.php new file mode 100644 index 0000000..93f07f8 --- /dev/null +++ b/tests/TestCase/Pdf/Engine/WeasyPrintEngineTest.php @@ -0,0 +1,102 @@ +markTestSkipped('weasyprint not found'); + } + + $class = new ReflectionClass(WeasyPrintEngine::class); + $method = $class->getMethod('_getCommand'); + + // Default options only + $Pdf = new CakePdf([ + 'engine' => [ + 'className' => 'CakePdf.WeasyPrint', + ], + ]); + $result = $method->invokeArgs($Pdf->engine(), []); + $expected = "weasyprint --encoding 'UTF-8' - -"; + $this->assertEquals($expected, $result); + + // A falsy option (false) must be skipped; a string option must be included + $Pdf = new CakePdf([ + 'engine' => [ + 'className' => 'CakePdf.WeasyPrint', + 'options' => [ + 'quiet' => false, + 'encoding' => 'UTF-8', + ], + ], + ]); + $result = $method->invokeArgs($Pdf->engine(), []); + $expected = "weasyprint --encoding 'UTF-8' - -"; + $this->assertEquals($expected, $result); + + // Various option types: boolean, string, integer, array + $Pdf = new CakePdf([ + 'engine' => [ + 'className' => 'CakePdf.WeasyPrint', + 'options' => [ + 'boolean' => true, + 'string' => 'value', + 'integer' => 42, + 'array' => [ + 'firstValue', + 'secondValue', + ], + ], + ], + ]); + $result = $method->invokeArgs($Pdf->engine(), []); + $expected = "weasyprint --encoding 'UTF-8' --boolean --string 'value' --integer '42' --array 'firstValue' --array 'secondValue' - -"; + $this->assertEquals($expected, $result); + + $Pdf = new CakePdf([ + 'engine' => [ + 'className' => 'CakePdf.WeasyPrint', + 'options' => [ + 'presentational-hints' => true, + 'pdf-variant' => 'pdf/ua-1', + ], + ], + ]); + $result = $method->invokeArgs($Pdf->engine(), []); + $expected = "weasyprint --encoding 'UTF-8' --presentational-hints --pdf-variant 'pdf/ua-1' - -"; + $this->assertEquals($expected, $result); + } + + public function testGetBinaryPath() + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('weasyprint binary is not found or not executable: /foo/bar'); + + $Pdf = new CakePdf([ + 'engine' => [ + 'className' => 'CakePdf.WeasyPrint', + 'binary' => '/foo/bar', + ], + ]); + + /** @var \CakePdf\Pdf\Engine\WeasyPrintEngine $engine */ + $engine = $Pdf->engine(); + $engine->getBinaryPath(); + } +} diff --git a/tests/TestCase/Pdf/Engine/WkHtmlToPdfEngineTest.php b/tests/TestCase/Pdf/Engine/WkHtmlToPdfEngineTest.php index ca4bc23..95a8033 100644 --- a/tests/TestCase/Pdf/Engine/WkHtmlToPdfEngineTest.php +++ b/tests/TestCase/Pdf/Engine/WkHtmlToPdfEngineTest.php @@ -25,7 +25,6 @@ public function testGetCommand() $class = new ReflectionClass(WkHtmlToPdfEngine::class); $method = $class->getMethod('_getCommand'); - $method->setAccessible(true); $Pdf = new CakePdf([ 'engine' => [ @@ -160,7 +159,6 @@ public function testCoverUrlMissing() $class = new ReflectionClass(WkHtmlToPdfEngine::class); $method = $class->getMethod('_getCommand'); - $method->setAccessible(true); $Pdf = new CakePdf([ 'engine' => [ 'className' => 'CakePdf.WkHtmlToPdf',