]> BookStack Code Mirror - bookstack/blob - app/Entities/Tools/ExportFormatter.php
Added embed support for contained HTML exports
[bookstack] / app / Entities / Tools / ExportFormatter.php
1 <?php
2
3 namespace BookStack\Entities\Tools;
4
5 use BookStack\Entities\Models\Book;
6 use BookStack\Entities\Models\Chapter;
7 use BookStack\Entities\Models\Page;
8 use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
9 use BookStack\Uploads\ImageService;
10 use BookStack\Util\CspService;
11 use DOMDocument;
12 use DOMElement;
13 use DOMXPath;
14 use Exception;
15 use Throwable;
16
17 class ExportFormatter
18 {
19     protected ImageService $imageService;
20     protected PdfGenerator $pdfGenerator;
21     protected CspService $cspService;
22
23     /**
24      * ExportService constructor.
25      */
26     public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator, CspService $cspService)
27     {
28         $this->imageService = $imageService;
29         $this->pdfGenerator = $pdfGenerator;
30         $this->cspService = $cspService;
31     }
32
33     /**
34      * Convert a page to a self-contained HTML file.
35      * Includes required CSS & image content. Images are base64 encoded into the HTML.
36      *
37      * @throws Throwable
38      */
39     public function pageToContainedHtml(Page $page)
40     {
41         $page->html = (new PageContent($page))->render();
42         $pageHtml = view('pages.export', [
43             'page'       => $page,
44             'format'     => 'html',
45             'cspContent' => $this->cspService->getCspMetaTagValue(),
46         ])->render();
47
48         return $this->containHtml($pageHtml);
49     }
50
51     /**
52      * Convert a chapter to a self-contained HTML file.
53      *
54      * @throws Throwable
55      */
56     public function chapterToContainedHtml(Chapter $chapter)
57     {
58         $pages = $chapter->getVisiblePages();
59         $pages->each(function ($page) {
60             $page->html = (new PageContent($page))->render();
61         });
62         $html = view('chapters.export', [
63             'chapter'    => $chapter,
64             'pages'      => $pages,
65             'format'     => 'html',
66             'cspContent' => $this->cspService->getCspMetaTagValue(),
67         ])->render();
68
69         return $this->containHtml($html);
70     }
71
72     /**
73      * Convert a book to a self-contained HTML file.
74      *
75      * @throws Throwable
76      */
77     public function bookToContainedHtml(Book $book)
78     {
79         $bookTree = (new BookContents($book))->getTree(false, true);
80         $html = view('books.export', [
81             'book'         => $book,
82             'bookChildren' => $bookTree,
83             'format'       => 'html',
84             'cspContent'   => $this->cspService->getCspMetaTagValue(),
85         ])->render();
86
87         return $this->containHtml($html);
88     }
89
90     /**
91      * Convert a page to a PDF file.
92      *
93      * @throws Throwable
94      */
95     public function pageToPdf(Page $page)
96     {
97         $page->html = (new PageContent($page))->render();
98         $html = view('pages.export', [
99             'page'   => $page,
100             'format' => 'pdf',
101             'engine' => $this->pdfGenerator->getActiveEngine(),
102         ])->render();
103
104         return $this->htmlToPdf($html);
105     }
106
107     /**
108      * Convert a chapter to a PDF file.
109      *
110      * @throws Throwable
111      */
112     public function chapterToPdf(Chapter $chapter)
113     {
114         $pages = $chapter->getVisiblePages();
115         $pages->each(function ($page) {
116             $page->html = (new PageContent($page))->render();
117         });
118
119         $html = view('chapters.export', [
120             'chapter' => $chapter,
121             'pages'   => $pages,
122             'format'  => 'pdf',
123             'engine'  => $this->pdfGenerator->getActiveEngine(),
124         ])->render();
125
126         return $this->htmlToPdf($html);
127     }
128
129     /**
130      * Convert a book to a PDF file.
131      *
132      * @throws Throwable
133      */
134     public function bookToPdf(Book $book)
135     {
136         $bookTree = (new BookContents($book))->getTree(false, true);
137         $html = view('books.export', [
138             'book'         => $book,
139             'bookChildren' => $bookTree,
140             'format'       => 'pdf',
141             'engine'       => $this->pdfGenerator->getActiveEngine(),
142         ])->render();
143
144         return $this->htmlToPdf($html);
145     }
146
147     /**
148      * Convert normal web-page HTML to a PDF.
149      *
150      * @throws Exception
151      */
152     protected function htmlToPdf(string $html): string
153     {
154         $html = $this->containHtml($html);
155         $html = $this->replaceIframesWithLinks($html);
156         $html = $this->openDetailElements($html);
157
158         return $this->pdfGenerator->fromHtml($html);
159     }
160
161     /**
162      * Within the given HTML content, Open any detail blocks.
163      */
164     protected function openDetailElements(string $html): string
165     {
166         libxml_use_internal_errors(true);
167
168         $doc = new DOMDocument();
169         $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
170         $xPath = new DOMXPath($doc);
171
172         $details = $xPath->query('//details');
173         /** @var DOMElement $detail */
174         foreach ($details as $detail) {
175             $detail->setAttribute('open', 'open');
176         }
177
178         return $doc->saveHTML();
179     }
180
181     /**
182      * Within the given HTML content, replace any iframe elements
183      * with anchor links within paragraph blocks.
184      */
185     protected function replaceIframesWithLinks(string $html): string
186     {
187         libxml_use_internal_errors(true);
188
189         $doc = new DOMDocument();
190         $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
191         $xPath = new DOMXPath($doc);
192
193         $iframes = $xPath->query('//iframe');
194         /** @var DOMElement $iframe */
195         foreach ($iframes as $iframe) {
196             $link = $iframe->getAttribute('src');
197             if (strpos($link, '//') === 0) {
198                 $link = 'https:' . $link;
199             }
200
201             $anchor = $doc->createElement('a', $link);
202             $anchor->setAttribute('href', $link);
203             $paragraph = $doc->createElement('p');
204             $paragraph->appendChild($anchor);
205             $iframe->parentNode->replaceChild($paragraph, $iframe);
206         }
207
208         return $doc->saveHTML();
209     }
210
211     /**
212      * Bundle of the contents of a html file to be self-contained.
213      *
214      * @throws Exception
215      */
216     protected function containHtml(string $htmlContent): string
217     {
218         // Replace image & embed src attributes with base64 encoded data strings
219         $imageTagsOutput = [];
220         preg_match_all("/<(?:img|embed) .*?src=['\"](.*?)['\"].*?>/i", $htmlContent, $imageTagsOutput);
221         if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
222             foreach ($imageTagsOutput[0] as $index => $imgMatch) {
223                 $oldImgTagString = $imgMatch;
224                 $srcString = $imageTagsOutput[1][$index];
225                 $imageEncoded = $this->imageService->imageUriToBase64($srcString);
226                 if ($imageEncoded === null) {
227                     $imageEncoded = $srcString;
228                 }
229                 $newImgTagString = str_replace($srcString, $imageEncoded, $oldImgTagString);
230                 $htmlContent = str_replace($oldImgTagString, $newImgTagString, $htmlContent);
231             }
232         }
233
234         // Replace any relative links with full system URL
235         $linksOutput = [];
236         preg_match_all("/<a .*href=['\"](.*?)['\"].*?>/i", $htmlContent, $linksOutput);
237         if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
238             foreach ($linksOutput[0] as $index => $linkMatch) {
239                 $oldLinkString = $linkMatch;
240                 $srcString = $linksOutput[1][$index];
241                 if (strpos(trim($srcString), 'http') !== 0) {
242                     $newSrcString = url($srcString);
243                     $newLinkString = str_replace($srcString, $newSrcString, $oldLinkString);
244                     $htmlContent = str_replace($oldLinkString, $newLinkString, $htmlContent);
245                 }
246             }
247         }
248
249         return $htmlContent;
250     }
251
252     /**
253      * Converts the page contents into simple plain text.
254      * This method filters any bad looking content to provide a nice final output.
255      */
256     public function pageToPlainText(Page $page): string
257     {
258         $html = (new PageContent($page))->render();
259         $text = strip_tags($html);
260         // Replace multiple spaces with single spaces
261         $text = preg_replace('/\ {2,}/', ' ', $text);
262         // Reduce multiple horrid whitespace characters.
263         $text = preg_replace('/(\x0A|\xA0|\x0A|\r|\n){2,}/su', "\n\n", $text);
264         $text = html_entity_decode($text);
265         // Add title
266         $text = $page->name . "\n\n" . $text;
267
268         return $text;
269     }
270
271     /**
272      * Convert a chapter into a plain text string.
273      */
274     public function chapterToPlainText(Chapter $chapter): string
275     {
276         $text = $chapter->name . "\n\n";
277         $text .= $chapter->description . "\n\n";
278         foreach ($chapter->getVisiblePages() as $page) {
279             $text .= $this->pageToPlainText($page);
280         }
281
282         return $text;
283     }
284
285     /**
286      * Convert a book into a plain text string.
287      */
288     public function bookToPlainText(Book $book): string
289     {
290         $bookTree = (new BookContents($book))->getTree(false, false);
291         $text = $book->name . "\n\n";
292         foreach ($bookTree as $bookChild) {
293             if ($bookChild->isA('chapter')) {
294                 $text .= $this->chapterToPlainText($bookChild);
295             } else {
296                 $text .= $this->pageToPlainText($bookChild);
297             }
298         }
299
300         return $text;
301     }
302
303     /**
304      * Convert a page to a Markdown file.
305      */
306     public function pageToMarkdown(Page $page): string
307     {
308         if ($page->markdown) {
309             return '# ' . $page->name . "\n\n" . $page->markdown;
310         }
311
312         return '# ' . $page->name . "\n\n" . (new HtmlToMarkdown($page->html))->convert();
313     }
314
315     /**
316      * Convert a chapter to a Markdown file.
317      */
318     public function chapterToMarkdown(Chapter $chapter): string
319     {
320         $text = '# ' . $chapter->name . "\n\n";
321         $text .= $chapter->description . "\n\n";
322         foreach ($chapter->pages as $page) {
323             $text .= $this->pageToMarkdown($page) . "\n\n";
324         }
325
326         return trim($text);
327     }
328
329     /**
330      * Convert a book into a plain text string.
331      */
332     public function bookToMarkdown(Book $book): string
333     {
334         $bookTree = (new BookContents($book))->getTree(false, true);
335         $text = '# ' . $book->name . "\n\n";
336         foreach ($bookTree as $bookChild) {
337             if ($bookChild instanceof Chapter) {
338                 $text .= $this->chapterToMarkdown($bookChild) . "\n\n";
339             } else {
340                 $text .= $this->pageToMarkdown($bookChild) . "\n\n";
341             }
342         }
343
344         return trim($text);
345     }
346 }