mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-07-31 15:24:31 +03:00
Merge branch 'portazips' into development
This commit is contained in:
@ -18,17 +18,12 @@ use Illuminate\Http\UploadedFile;
|
||||
|
||||
class Cloner
|
||||
{
|
||||
protected PageRepo $pageRepo;
|
||||
protected ChapterRepo $chapterRepo;
|
||||
protected BookRepo $bookRepo;
|
||||
protected ImageService $imageService;
|
||||
|
||||
public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService)
|
||||
{
|
||||
$this->pageRepo = $pageRepo;
|
||||
$this->chapterRepo = $chapterRepo;
|
||||
$this->bookRepo = $bookRepo;
|
||||
$this->imageService = $imageService;
|
||||
public function __construct(
|
||||
protected PageRepo $pageRepo,
|
||||
protected ChapterRepo $chapterRepo,
|
||||
protected BookRepo $bookRepo,
|
||||
protected ImageService $imageService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,343 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Util\CspService;
|
||||
use BookStack\Util\HtmlDocument;
|
||||
use DOMElement;
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
class ExportFormatter
|
||||
{
|
||||
public function __construct(
|
||||
protected ImageService $imageService,
|
||||
protected PdfGenerator $pdfGenerator,
|
||||
protected CspService $cspService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a page to a self-contained HTML file.
|
||||
* Includes required CSS & image content. Images are base64 encoded into the HTML.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function pageToContainedHtml(Page $page): string
|
||||
{
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$pageHtml = view('exports.page', [
|
||||
'page' => $page,
|
||||
'format' => 'html',
|
||||
'cspContent' => $this->cspService->getCspMetaTagValue(),
|
||||
'locale' => user()->getLocale(),
|
||||
])->render();
|
||||
|
||||
return $this->containHtml($pageHtml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a chapter to a self-contained HTML file.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function chapterToContainedHtml(Chapter $chapter): string
|
||||
{
|
||||
$pages = $chapter->getVisiblePages();
|
||||
$pages->each(function ($page) {
|
||||
$page->html = (new PageContent($page))->render();
|
||||
});
|
||||
$html = view('exports.chapter', [
|
||||
'chapter' => $chapter,
|
||||
'pages' => $pages,
|
||||
'format' => 'html',
|
||||
'cspContent' => $this->cspService->getCspMetaTagValue(),
|
||||
'locale' => user()->getLocale(),
|
||||
])->render();
|
||||
|
||||
return $this->containHtml($html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a book to a self-contained HTML file.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function bookToContainedHtml(Book $book): string
|
||||
{
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$html = view('exports.book', [
|
||||
'book' => $book,
|
||||
'bookChildren' => $bookTree,
|
||||
'format' => 'html',
|
||||
'cspContent' => $this->cspService->getCspMetaTagValue(),
|
||||
'locale' => user()->getLocale(),
|
||||
])->render();
|
||||
|
||||
return $this->containHtml($html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a page to a PDF file.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function pageToPdf(Page $page): string
|
||||
{
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$html = view('exports.page', [
|
||||
'page' => $page,
|
||||
'format' => 'pdf',
|
||||
'engine' => $this->pdfGenerator->getActiveEngine(),
|
||||
'locale' => user()->getLocale(),
|
||||
])->render();
|
||||
|
||||
return $this->htmlToPdf($html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a chapter to a PDF file.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function chapterToPdf(Chapter $chapter): string
|
||||
{
|
||||
$pages = $chapter->getVisiblePages();
|
||||
$pages->each(function ($page) {
|
||||
$page->html = (new PageContent($page))->render();
|
||||
});
|
||||
|
||||
$html = view('exports.chapter', [
|
||||
'chapter' => $chapter,
|
||||
'pages' => $pages,
|
||||
'format' => 'pdf',
|
||||
'engine' => $this->pdfGenerator->getActiveEngine(),
|
||||
'locale' => user()->getLocale(),
|
||||
])->render();
|
||||
|
||||
return $this->htmlToPdf($html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a book to a PDF file.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function bookToPdf(Book $book): string
|
||||
{
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$html = view('exports.book', [
|
||||
'book' => $book,
|
||||
'bookChildren' => $bookTree,
|
||||
'format' => 'pdf',
|
||||
'engine' => $this->pdfGenerator->getActiveEngine(),
|
||||
'locale' => user()->getLocale(),
|
||||
])->render();
|
||||
|
||||
return $this->htmlToPdf($html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert normal web-page HTML to a PDF.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function htmlToPdf(string $html): string
|
||||
{
|
||||
$html = $this->containHtml($html);
|
||||
$doc = new HtmlDocument();
|
||||
$doc->loadCompleteHtml($html);
|
||||
|
||||
$this->replaceIframesWithLinks($doc);
|
||||
$this->openDetailElements($doc);
|
||||
$cleanedHtml = $doc->getHtml();
|
||||
|
||||
return $this->pdfGenerator->fromHtml($cleanedHtml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Within the given HTML content, Open any detail blocks.
|
||||
*/
|
||||
protected function openDetailElements(HtmlDocument $doc): void
|
||||
{
|
||||
$details = $doc->queryXPath('//details');
|
||||
/** @var DOMElement $detail */
|
||||
foreach ($details as $detail) {
|
||||
$detail->setAttribute('open', 'open');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Within the given HTML document, replace any iframe elements
|
||||
* with anchor links within paragraph blocks.
|
||||
*/
|
||||
protected function replaceIframesWithLinks(HtmlDocument $doc): void
|
||||
{
|
||||
$iframes = $doc->queryXPath('//iframe');
|
||||
|
||||
/** @var DOMElement $iframe */
|
||||
foreach ($iframes as $iframe) {
|
||||
$link = $iframe->getAttribute('src');
|
||||
if (str_starts_with($link, '//')) {
|
||||
$link = 'https:' . $link;
|
||||
}
|
||||
|
||||
$anchor = $doc->createElement('a', $link);
|
||||
$anchor->setAttribute('href', $link);
|
||||
$paragraph = $doc->createElement('p');
|
||||
$paragraph->appendChild($anchor);
|
||||
$iframe->parentNode->replaceChild($paragraph, $iframe);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle of the contents of a html file to be self-contained.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function containHtml(string $htmlContent): string
|
||||
{
|
||||
$imageTagsOutput = [];
|
||||
preg_match_all("/\<img.*?src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
|
||||
|
||||
// Replace image src with base64 encoded image strings
|
||||
if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
|
||||
foreach ($imageTagsOutput[0] as $index => $imgMatch) {
|
||||
$oldImgTagString = $imgMatch;
|
||||
$srcString = $imageTagsOutput[2][$index];
|
||||
$imageEncoded = $this->imageService->imageUrlToBase64($srcString);
|
||||
if ($imageEncoded === null) {
|
||||
$imageEncoded = $srcString;
|
||||
}
|
||||
$newImgTagString = str_replace($srcString, $imageEncoded, $oldImgTagString);
|
||||
$htmlContent = str_replace($oldImgTagString, $newImgTagString, $htmlContent);
|
||||
}
|
||||
}
|
||||
|
||||
$linksOutput = [];
|
||||
preg_match_all("/\<a.*href\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $linksOutput);
|
||||
|
||||
// Update relative links to be absolute, with instance url
|
||||
if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
|
||||
foreach ($linksOutput[0] as $index => $linkMatch) {
|
||||
$oldLinkString = $linkMatch;
|
||||
$srcString = $linksOutput[2][$index];
|
||||
if (!str_starts_with(trim($srcString), 'http')) {
|
||||
$newSrcString = url($srcString);
|
||||
$newLinkString = str_replace($srcString, $newSrcString, $oldLinkString);
|
||||
$htmlContent = str_replace($oldLinkString, $newLinkString, $htmlContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $htmlContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the page contents into simple plain text.
|
||||
* This method filters any bad looking content to provide a nice final output.
|
||||
*/
|
||||
public function pageToPlainText(Page $page, bool $pageRendered = false, bool $fromParent = false): string
|
||||
{
|
||||
$html = $pageRendered ? $page->html : (new PageContent($page))->render();
|
||||
// Add proceeding spaces before tags so spaces remain between
|
||||
// text within elements after stripping tags.
|
||||
$html = str_replace('<', " <", $html);
|
||||
$text = trim(strip_tags($html));
|
||||
// Replace multiple spaces with single spaces
|
||||
$text = preg_replace('/ {2,}/', ' ', $text);
|
||||
// Reduce multiple horrid whitespace characters.
|
||||
$text = preg_replace('/(\x0A|\xA0|\x0A|\r|\n){2,}/su', "\n\n", $text);
|
||||
$text = html_entity_decode($text);
|
||||
// Add title
|
||||
$text = $page->name . ($fromParent ? "\n" : "\n\n") . $text;
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a chapter into a plain text string.
|
||||
*/
|
||||
public function chapterToPlainText(Chapter $chapter): string
|
||||
{
|
||||
$text = $chapter->name . "\n" . $chapter->description;
|
||||
$text = trim($text) . "\n\n";
|
||||
|
||||
$parts = [];
|
||||
foreach ($chapter->getVisiblePages() as $page) {
|
||||
$parts[] = $this->pageToPlainText($page, false, true);
|
||||
}
|
||||
|
||||
return $text . implode("\n\n", $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a book into a plain text string.
|
||||
*/
|
||||
public function bookToPlainText(Book $book): string
|
||||
{
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$text = $book->name . "\n" . $book->description;
|
||||
$text = rtrim($text) . "\n\n";
|
||||
|
||||
$parts = [];
|
||||
foreach ($bookTree as $bookChild) {
|
||||
if ($bookChild->isA('chapter')) {
|
||||
$parts[] = $this->chapterToPlainText($bookChild);
|
||||
} else {
|
||||
$parts[] = $this->pageToPlainText($bookChild, true, true);
|
||||
}
|
||||
}
|
||||
|
||||
return $text . implode("\n\n", $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a page to a Markdown file.
|
||||
*/
|
||||
public function pageToMarkdown(Page $page): string
|
||||
{
|
||||
if ($page->markdown) {
|
||||
return '# ' . $page->name . "\n\n" . $page->markdown;
|
||||
}
|
||||
|
||||
return '# ' . $page->name . "\n\n" . (new HtmlToMarkdown($page->html))->convert();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a chapter to a Markdown file.
|
||||
*/
|
||||
public function chapterToMarkdown(Chapter $chapter): string
|
||||
{
|
||||
$text = '# ' . $chapter->name . "\n\n";
|
||||
$text .= $chapter->description . "\n\n";
|
||||
foreach ($chapter->pages as $page) {
|
||||
$text .= $this->pageToMarkdown($page) . "\n\n";
|
||||
}
|
||||
|
||||
return trim($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a book into a plain text string.
|
||||
*/
|
||||
public function bookToMarkdown(Book $book): string
|
||||
{
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$text = '# ' . $book->name . "\n\n";
|
||||
foreach ($bookTree as $bookChild) {
|
||||
if ($bookChild instanceof Chapter) {
|
||||
$text .= $this->chapterToMarkdown($bookChild) . "\n\n";
|
||||
} else {
|
||||
$text .= $this->pageToMarkdown($bookChild) . "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
return trim($text);
|
||||
}
|
||||
}
|
@ -1,139 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Exceptions\PdfExportException;
|
||||
use Knp\Snappy\Pdf as SnappyPdf;
|
||||
use Dompdf\Dompdf;
|
||||
use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
||||
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
|
||||
{
|
||||
return match ($this->getActiveEngine()) {
|
||||
self::ENGINE_COMMAND => $this->renderUsingCommand($html),
|
||||
self::ENGINE_WKHTML => $this->renderUsingWkhtml($html),
|
||||
default => $this->renderUsingDomPdf($html)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active PDF engine.
|
||||
* Returns the value of an `ENGINE_` const on this class.
|
||||
*/
|
||||
public function getActiveEngine(): string
|
||||
{
|
||||
if (config('exports.pdf_command')) {
|
||||
return self::ENGINE_COMMAND;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
$timeout = intval(config('exports.pdf_command_timeout'));
|
||||
$process = Process::fromShellCommandline($command);
|
||||
$process->setTimeout($timeout);
|
||||
|
||||
try {
|
||||
$process->run();
|
||||
} catch (ProcessTimedOutException $e) {
|
||||
throw new PdfExportException("PDF Export via command failed due to timeout at {$timeout} second(s)");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user