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