diff --git a/.env.example.complete b/.env.example.complete
index 124296818..b4beb60cc 100644
--- a/.env.example.complete
+++ b/.env.example.complete
@@ -325,6 +325,14 @@ FILE_UPLOAD_SIZE_LIMIT=50
# Can be 'a4' or 'letter'.
EXPORT_PAGE_SIZE=a4
+# Export PDF Command
+# Set a command which can be used to convert a HTML file into a PDF file.
+# When false this will not be used.
+# String values represent the command to be called for conversion.
+# Supports '{input_html_path}' and '{output_pdf_path}' placeholder values.
+# Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
+EXPORT_PDF_COMMAND=false
+
# Set path to wkhtmltopdf binary for PDF generation.
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
# When false, BookStack will attempt to find a wkhtmltopdf in the application
diff --git a/app/Config/app.php b/app/Config/app.php
index dda787f3f..b96d0bdb7 100644
--- a/app/Config/app.php
+++ b/app/Config/app.php
@@ -116,8 +116,6 @@ return [
// Application Service Providers
'providers' => ServiceProvider::defaultProviders()->merge([
// Third party service providers
- Barryvdh\DomPDF\ServiceProvider::class,
- Barryvdh\Snappy\ServiceProvider::class,
SocialiteProviders\Manager\ServiceProvider::class,
// BookStack custom service providers
diff --git a/app/Config/dompdf.php b/app/Config/exports.php
similarity index 91%
rename from app/Config/dompdf.php
rename to app/Config/exports.php
index 09dd91bcc..88dc08cba 100644
--- a/app/Config/dompdf.php
+++ b/app/Config/exports.php
@@ -1,23 +1,45 @@
'A4',
+ 'letter' => 'Letter',
+];
+
$dompdfPaperSizeMap = [
'a4' => 'a4',
'letter' => 'letter',
];
+$exportPageSize = env('EXPORT_PAGE_SIZE', 'a4');
+
return [
- 'show_warnings' => false, // Throw an Exception on warnings from dompdf
+ // Set a command which can be used to convert a HTML file into a PDF file.
+ // When false this will not be used.
+ // String values represent the command to be called for conversion.
+ // Supports '{input_html_path}' and '{output_pdf_path}' placeholder values.
+ // Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
+ 'pdf_command' => env('EXPORT_PDF_COMMAND', false),
- 'options' => [
+ // 2024-04: Snappy/WKHTMLtoPDF now considered deprecated in regard to BookStack support.
+ 'snappy' => [
+ 'pdf_binary' => env('WKHTMLTOPDF', false),
+ 'options' => [
+ 'print-media-type' => true,
+ 'outline' => true,
+ 'page-size' => $snappyPaperSizeMap[$exportPageSize] ?? 'A4',
+ ],
+ ],
+
+ 'dompdf' => [
/**
* The location of the DOMPDF font directory.
*
@@ -101,7 +123,7 @@ return [
/**
* Whether to enable font subsetting or not.
*/
- 'enable_fontsubsetting' => false,
+ 'enable_font_subsetting' => false,
/**
* The PDF rendering backend to use.
@@ -165,7 +187,7 @@ return [
*
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
*/
- 'default_paper_size' => $dompdfPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'a4',
+ 'default_paper_size' => $dompdfPaperSizeMap[$exportPageSize] ?? 'a4',
/**
* The default paper orientation.
@@ -268,15 +290,6 @@ return [
*/
'font_height_ratio' => 1.1,
- /**
- * Enable CSS float.
- *
- * Allows people to disabled CSS float support
- *
- * @var bool
- */
- 'enable_css_float' => true,
-
/**
* Use the HTML5 Lib parser.
*
@@ -286,5 +299,4 @@ return [
*/
'enable_html5_parser' => true,
],
-
];
diff --git a/app/Config/snappy.php b/app/Config/snappy.php
deleted file mode 100644
index a87ce805f..000000000
--- a/app/Config/snappy.php
+++ /dev/null
@@ -1,34 +0,0 @@
- 'A4',
- 'letter' => 'Letter',
-];
-
-return [
- 'pdf' => [
- 'enabled' => true,
- 'binary' => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
- 'timeout' => false,
- 'options' => [
- 'outline' => true,
- 'page-size' => $snappyPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'A4',
- ],
- 'env' => [],
- ],
- 'image' => [
- 'enabled' => false,
- 'binary' => '/usr/local/bin/wkhtmltoimage',
- 'timeout' => false,
- 'options' => [],
- 'env' => [],
- ],
-];
diff --git a/app/Entities/Tools/PdfGenerator.php b/app/Entities/Tools/PdfGenerator.php
index d0c9158a9..7c6dfaa6e 100644
--- a/app/Entities/Tools/PdfGenerator.php
+++ b/app/Entities/Tools/PdfGenerator.php
@@ -2,27 +2,28 @@
namespace BookStack\Entities\Tools;
-use Barryvdh\DomPDF\Facade\Pdf as DomPDF;
-use Barryvdh\Snappy\Facades\SnappyPdf;
+use BookStack\Exceptions\PdfExportException;
+use Knp\Snappy\Pdf as SnappyPdf;
+use Dompdf\Dompdf;
+use Symfony\Component\Process\Process;
class PdfGenerator
{
const ENGINE_DOMPDF = 'dompdf';
const ENGINE_WKHTML = 'wkhtml';
+ const ENGINE_COMMAND = 'command';
/**
* Generate PDF content from the given HTML content.
+ * @throws PdfExportException
*/
public function fromHtml(string $html): string
{
- if ($this->getActiveEngine() === self::ENGINE_WKHTML) {
- $pdf = SnappyPDF::loadHTML($html);
- $pdf->setOption('print-media-type', true);
- } else {
- $pdf = DomPDF::loadHTML($html);
- }
-
- return $pdf->output();
+ return match ($this->getActiveEngine()) {
+ self::ENGINE_COMMAND => $this->renderUsingCommand($html),
+ self::ENGINE_WKHTML => $this->renderUsingWkhtml($html),
+ default => $this->renderUsingDomPdf($html)
+ };
}
/**
@@ -31,8 +32,101 @@ class PdfGenerator
*/
public function getActiveEngine(): string
{
- $useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
+ if (config('exports.pdf_command')) {
+ return self::ENGINE_COMMAND;
+ }
- return $useWKHTML ? self::ENGINE_WKHTML : self::ENGINE_DOMPDF;
+ if ($this->getWkhtmlBinaryPath() && config('app.allow_untrusted_server_fetching') === true) {
+ return self::ENGINE_WKHTML;
+ }
+
+ return self::ENGINE_DOMPDF;
+ }
+
+ protected function getWkhtmlBinaryPath(): string
+ {
+ $wkhtmlBinaryPath = config('exports.snappy.pdf_binary');
+ if (file_exists(base_path('wkhtmltopdf'))) {
+ $wkhtmlBinaryPath = base_path('wkhtmltopdf');
+ }
+
+ return $wkhtmlBinaryPath ?: '';
+ }
+
+ protected function renderUsingDomPdf(string $html): string
+ {
+ $options = config('exports.dompdf');
+ $domPdf = new Dompdf($options);
+ $domPdf->setBasePath(base_path('public'));
+
+ $domPdf->loadHTML($this->convertEntities($html));
+ $domPdf->render();
+
+ return (string) $domPdf->output();
+ }
+
+ /**
+ * @throws PdfExportException
+ */
+ protected function renderUsingCommand(string $html): string
+ {
+ $command = config('exports.pdf_command');
+ $inputHtml = tempnam(sys_get_temp_dir(), 'bs-pdfgen-html-');
+ $outputPdf = tempnam(sys_get_temp_dir(), 'bs-pdfgen-output-');
+
+ $replacementsByPlaceholder = [
+ '{input_html_path}' => $inputHtml,
+ '{output_pdf_path}' => $outputPdf,
+ ];
+
+ foreach ($replacementsByPlaceholder as $placeholder => $replacement) {
+ $command = str_replace($placeholder, escapeshellarg($replacement), $command);
+ }
+
+ file_put_contents($inputHtml, $html);
+
+ $process = Process::fromShellCommandline($command);
+ $process->setTimeout(15);
+ $process->run();
+
+ if (!$process->isSuccessful()) {
+ throw new PdfExportException("PDF Export via command failed with exit code {$process->getExitCode()}, stdout: {$process->getOutput()}, stderr: {$process->getErrorOutput()}");
+ }
+
+ $pdfContents = file_get_contents($outputPdf);
+ unlink($outputPdf);
+
+ if ($pdfContents === false) {
+ throw new PdfExportException("PDF Export via command failed, unable to read PDF output file");
+ } else if (empty($pdfContents)) {
+ throw new PdfExportException("PDF Export via command failed, PDF output file is empty");
+ }
+
+ return $pdfContents;
+ }
+
+ protected function renderUsingWkhtml(string $html): string
+ {
+ $snappy = new SnappyPdf($this->getWkhtmlBinaryPath());
+ $options = config('exports.snappy.options');
+ return $snappy->getOutputFromHtml($html, $options);
+ }
+
+ /**
+ * Taken from https://github.com/barryvdh/laravel-dompdf/blob/v2.1.1/src/PDF.php
+ * Copyright (c) 2021 barryvdh, MIT License
+ * https://github.com/barryvdh/laravel-dompdf/blob/v2.1.1/LICENSE
+ */
+ protected function convertEntities(string $subject): string
+ {
+ $entities = [
+ '€' => '€',
+ '£' => '£',
+ ];
+
+ foreach ($entities as $search => $replace) {
+ $subject = str_replace($search, $replace, $subject);
+ }
+ return $subject;
}
}
diff --git a/app/Exceptions/PdfExportException.php b/app/Exceptions/PdfExportException.php
new file mode 100644
index 000000000..beeda814f
--- /dev/null
+++ b/app/Exceptions/PdfExportException.php
@@ -0,0 +1,7 @@
+=7.2"
- },
- "require-dev": {
- "orchestra/testbench": "^7|^8|^9.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.0-dev"
- },
- "laravel": {
- "providers": [
- "Barryvdh\\Snappy\\ServiceProvider"
- ],
- "aliases": {
- "PDF": "Barryvdh\\Snappy\\Facades\\SnappyPdf",
- "SnappyImage": "Barryvdh\\Snappy\\Facades\\SnappyImage"
- }
- }
- },
- "autoload": {
- "psr-4": {
- "Barryvdh\\Snappy\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Barry vd. Heuvel",
- "email": "barryvdh@gmail.com"
- }
- ],
- "description": "Snappy PDF/Image for Laravel",
- "keywords": [
- "image",
- "laravel",
- "pdf",
- "snappy",
- "wkhtmltoimage",
- "wkhtmltopdf"
- ],
- "support": {
- "issues": "https://github.com/barryvdh/laravel-snappy/issues",
- "source": "https://github.com/barryvdh/laravel-snappy/tree/v1.0.3"
- },
- "funding": [
- {
- "url": "https://fruitcake.nl",
- "type": "custom"
- },
- {
- "url": "https://github.com/barryvdh",
- "type": "github"
- }
- ],
- "time": "2024-03-09T19:20:39+00:00"
- },
{
"name": "brick/math",
"version": "0.11.0",
@@ -1127,16 +972,16 @@
},
{
"name": "dompdf/dompdf",
- "version": "v2.0.4",
+ "version": "v2.0.7",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
- "reference": "093f2d9739cec57428e39ddadedfd4f3ae862c0f"
+ "reference": "ab0123052b42ad0867348f25df8c228f1ece8f14"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/dompdf/dompdf/zipball/093f2d9739cec57428e39ddadedfd4f3ae862c0f",
- "reference": "093f2d9739cec57428e39ddadedfd4f3ae862c0f",
+ "url": "https://api.github.com/repos/dompdf/dompdf/zipball/ab0123052b42ad0867348f25df8c228f1ece8f14",
+ "reference": "ab0123052b42ad0867348f25df8c228f1ece8f14",
"shasum": ""
},
"require": {
@@ -1144,7 +989,7 @@
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"phenx/php-font-lib": ">=0.5.4 <1.0.0",
- "phenx/php-svg-lib": ">=0.3.3 <1.0.0",
+ "phenx/php-svg-lib": ">=0.5.2 <1.0.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
@@ -1183,9 +1028,9 @@
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
- "source": "https://github.com/dompdf/dompdf/tree/v2.0.4"
+ "source": "https://github.com/dompdf/dompdf/tree/v2.0.7"
},
- "time": "2023-12-12T20:19:39+00:00"
+ "time": "2024-04-15T12:40:33+00:00"
},
{
"name": "dragonmantank/cron-expression",
@@ -4067,16 +3912,16 @@
},
{
"name": "phenx/php-svg-lib",
- "version": "0.5.3",
+ "version": "0.5.4",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
- "reference": "0e46722c154726a5f9ac218197ccc28adba16fcf"
+ "reference": "46b25da81613a9cf43c83b2a8c2c1bdab27df691"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/0e46722c154726a5f9ac218197ccc28adba16fcf",
- "reference": "0e46722c154726a5f9ac218197ccc28adba16fcf",
+ "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/46b25da81613a9cf43c83b2a8c2c1bdab27df691",
+ "reference": "46b25da81613a9cf43c83b2a8c2c1bdab27df691",
"shasum": ""
},
"require": {
@@ -4107,9 +3952,9 @@
"homepage": "https://github.com/PhenX/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
- "source": "https://github.com/dompdf/php-svg-lib/tree/0.5.3"
+ "source": "https://github.com/dompdf/php-svg-lib/tree/0.5.4"
},
- "time": "2024-02-23T20:39:24+00:00"
+ "time": "2024-04-08T12:52:34+00:00"
},
{
"name": "phpoption/phpoption",
diff --git a/phpunit.xml b/phpunit.xml
index a9e97f0c7..21f17685b 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -51,6 +51,7 @@
+
diff --git a/readme.md b/readme.md
index 17e1a05f6..c46e1641f 100644
--- a/readme.md
+++ b/readme.md
@@ -142,8 +142,7 @@ Note: This is not an exhaustive list of all libraries and projects that would be
* [Google Material Icons](https://github.com/google/material-design-icons) - _[Apache-2.0](https://github.com/google/material-design-icons/blob/master/LICENSE)_
* [markdown-it](https://github.com/markdown-it/markdown-it) and [markdown-it-task-lists](https://github.com/revin/markdown-it-task-lists) - _[MIT](https://github.com/markdown-it/markdown-it/blob/master/LICENSE) and [ISC](https://github.com/revin/markdown-it-task-lists/blob/master/LICENSE)_
* [Dompdf](https://github.com/dompdf/dompdf) - _[LGPL v2.1](https://github.com/dompdf/dompdf/blob/master/LICENSE.LGPL)_
-* [BarryVD/Dompdf](https://github.com/barryvdh/laravel-dompdf) - _[MIT](https://github.com/barryvdh/laravel-dompdf/blob/master/LICENSE)_
-* [BarryVD/Snappy (WKHTML2PDF)](https://github.com/barryvdh/laravel-snappy) - _[MIT](https://github.com/barryvdh/laravel-snappy/blob/master/LICENSE)_
+* [KnpLabs/snappy](https://github.com/KnpLabs/snappy) - _[MIT](https://github.com/KnpLabs/snappy/blob/master/LICENSE)_
* [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html) - _[LGPL v3.0](https://github.com/wkhtmltopdf/wkhtmltopdf/blob/master/LICENSE)_
* [diagrams.net](https://github.com/jgraph/drawio) - _[Embedded Version Terms](https://www.diagrams.net/trust/) / [Source Project - Apache-2.0](https://github.com/jgraph/drawio/blob/dev/LICENSE)_
* [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml) - _[MIT](https://github.com/onelogin/php-saml/blob/master/LICENSE)_
diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php
index eedcb672c..040f69013 100644
--- a/tests/Entity/ExportTest.php
+++ b/tests/Entity/ExportTest.php
@@ -6,8 +6,8 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\PdfGenerator;
+use BookStack\Exceptions\PdfExportException;
use Illuminate\Support\Facades\Storage;
-use Illuminate\Support\Str;
use Tests\TestCase;
class ExportTest extends TestCase
@@ -483,7 +483,7 @@ class ExportTest extends TestCase
{
$page = $this->entities->page();
- config()->set('snappy.pdf.binary', '/abc123');
+ config()->set('exports.snappy.pdf_binary', '/abc123');
config()->set('app.allow_untrusted_server_fetching', false);
$resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
@@ -494,6 +494,41 @@ class ExportTest extends TestCase
$resp->assertStatus(500); // Bad response indicates wkhtml usage
}
+ public function test_pdf_command_option_used_if_set()
+ {
+ $page = $this->entities->page();
+ $command = 'cp {input_html_path} {output_pdf_path}';
+ config()->set('exports.pdf_command', $command);
+
+ $resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
+ $download = $resp->getContent();
+
+ $this->assertStringContainsString(e($page->name), $download);
+ $this->assertStringContainsString('set('exports.pdf_command', $command);
+
+ $this->assertThrows(function () use ($page) {
+ $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf'));
+ }, PdfExportException::class);
+ }
+
+ public function test_pdf_command_option_errors_if_command_returns_error_status()
+ {
+ $page = $this->entities->page();
+ $command = 'exit 1';
+ config()->set('exports.pdf_command', $command);
+
+ $this->assertThrows(function () use ($page) {
+ $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf'));
+ }, PdfExportException::class);
+ }
+
public function test_html_exports_contain_csp_meta_tag()
{
$entities = [
diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php
index aedcb75aa..d5c74392f 100644
--- a/tests/Unit/ConfigTest.php
+++ b/tests/Unit/ConfigTest.php
@@ -80,22 +80,22 @@ class ConfigTest extends TestCase
public function test_dompdf_remote_fetching_controlled_by_allow_untrusted_server_fetching_false()
{
- $this->checkEnvConfigResult('ALLOW_UNTRUSTED_SERVER_FETCHING', 'false', 'dompdf.options.enable_remote', false);
- $this->checkEnvConfigResult('ALLOW_UNTRUSTED_SERVER_FETCHING', 'true', 'dompdf.options.enable_remote', true);
+ $this->checkEnvConfigResult('ALLOW_UNTRUSTED_SERVER_FETCHING', 'false', 'exports.dompdf.enable_remote', false);
+ $this->checkEnvConfigResult('ALLOW_UNTRUSTED_SERVER_FETCHING', 'true', 'exports.dompdf.enable_remote', true);
}
public function test_dompdf_paper_size_options_are_limited()
{
- $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'cat', 'dompdf.options.default_paper_size', 'a4');
- $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'letter', 'dompdf.options.default_paper_size', 'letter');
- $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'a4', 'dompdf.options.default_paper_size', 'a4');
+ $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'cat', 'exports.dompdf.default_paper_size', 'a4');
+ $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'letter', 'exports.dompdf.default_paper_size', 'letter');
+ $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'a4', 'exports.dompdf.default_paper_size', 'a4');
}
public function test_snappy_paper_size_options_are_limited()
{
- $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'cat', 'snappy.pdf.options.page-size', 'A4');
- $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'letter', 'snappy.pdf.options.page-size', 'Letter');
- $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'a4', 'snappy.pdf.options.page-size', 'A4');
+ $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'cat', 'exports.snappy.options.page-size', 'A4');
+ $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'letter', 'exports.snappy.options.page-size', 'Letter');
+ $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'a4', 'exports.snappy.options.page-size', 'A4');
}
public function test_sendmail_command_is_configurable()