]> BookStack Code Mirror - bookstack/blob - app/Entities/Tools/ExportFormatter.php
Guest create page: name field autofocus
[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('exports.page', [
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('exports.chapter', [
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('exports.book', [
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('exports.page', [
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('exports.chapter', [
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('exports.book', [
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         $imageTagsOutput = [];
219         preg_match_all("/\<img.*?src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
220
221         // Replace image src with base64 encoded image strings
222         if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
223             foreach ($imageTagsOutput[0] as $index => $imgMatch) {
224                 $oldImgTagString = $imgMatch;
225                 $srcString = $imageTagsOutput[2][$index];
226                 $imageEncoded = $this->imageService->imageUriToBase64($srcString);
227                 if ($imageEncoded === null) {
228                     $imageEncoded = $srcString;
229                 }
230                 $newImgTagString = str_replace($srcString, $imageEncoded, $oldImgTagString);
231                 $htmlContent = str_replace($oldImgTagString, $newImgTagString, $htmlContent);
232             }
233         }
234
235         $linksOutput = [];
236         preg_match_all("/\<a.*href\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $linksOutput);
237
238         // Update relative links to be absolute, with instance url
239         if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
240             foreach ($linksOutput[0] as $index => $linkMatch) {
241                 $oldLinkString = $linkMatch;
242                 $srcString = $linksOutput[2][$index];
243                 if (strpos(trim($srcString), 'http') !== 0) {
244                     $newSrcString = url($srcString);
245                     $newLinkString = str_replace($srcString, $newSrcString, $oldLinkString);
246                     $htmlContent = str_replace($oldLinkString, $newLinkString, $htmlContent);
247                 }
248             }
249         }
250
251         return $htmlContent;
252     }
253
254     /**
255      * Converts the page contents into simple plain text.
256      * This method filters any bad looking content to provide a nice final output.
257      */
258     public function pageToPlainText(Page $page): string
259     {
260         $html = (new PageContent($page))->render();
261         $text = strip_tags($html);
262         // Replace multiple spaces with single spaces
263         $text = preg_replace('/\ {2,}/', ' ', $text);
264         // Reduce multiple horrid whitespace characters.
265         $text = preg_replace('/(\x0A|\xA0|\x0A|\r|\n){2,}/su', "\n\n", $text);
266         $text = html_entity_decode($text);
267         // Add title
268         $text = $page->name . "\n\n" . $text;
269
270         return $text;
271     }
272
273     /**
274      * Convert a chapter into a plain text string.
275      */
276     public function chapterToPlainText(Chapter $chapter): string
277     {
278         $text = $chapter->name . "\n\n";
279         $text .= $chapter->description . "\n\n";
280         foreach ($chapter->getVisiblePages() as $page) {
281             $text .= $this->pageToPlainText($page);
282         }
283
284         return $text;
285     }
286
287     /**
288      * Convert a book into a plain text string.
289      */
290     public function bookToPlainText(Book $book): string
291     {
292         $bookTree = (new BookContents($book))->getTree(false, false);
293         $text = $book->name . "\n\n";
294         foreach ($bookTree as $bookChild) {
295             if ($bookChild->isA('chapter')) {
296                 $text .= $this->chapterToPlainText($bookChild);
297             } else {
298                 $text .= $this->pageToPlainText($bookChild);
299             }
300         }
301
302         return $text;
303     }
304
305     /**
306      * Convert a page to a Markdown file.
307      */
308     public function pageToMarkdown(Page $page): string
309     {
310         if ($page->markdown) {
311             return '# ' . $page->name . "\n\n" . $page->markdown;
312         }
313
314         return '# ' . $page->name . "\n\n" . (new HtmlToMarkdown($page->html))->convert();
315     }
316
317     /**
318      * Convert a chapter to a Markdown file.
319      */
320     public function chapterToMarkdown(Chapter $chapter): string
321     {
322         $text = '# ' . $chapter->name . "\n\n";
323         $text .= $chapter->description . "\n\n";
324         foreach ($chapter->pages as $page) {
325             $text .= $this->pageToMarkdown($page) . "\n\n";
326         }
327
328         return trim($text);
329     }
330
331     /**
332      * Convert a book into a plain text string.
333      */
334     public function bookToMarkdown(Book $book): string
335     {
336         $bookTree = (new BookContents($book))->getTree(false, true);
337         $text = '# ' . $book->name . "\n\n";
338         foreach ($bookTree as $bookChild) {
339             if ($bookChild instanceof Chapter) {
340                 $text .= $this->chapterToMarkdown($bookChild) . "\n\n";
341             } else {
342                 $text .= $this->pageToMarkdown($bookChild) . "\n\n";
343             }
344         }
345
346         return trim($text);
347     }
348 }