xref: /plugin/dw2pdf/src/Writer.php (revision 6444f9a19e8eb11cea8df23eb06057f6078ff510)
1<?php
2
3namespace dokuwiki\plugin\dw2pdf\src;
4
5use DOMDocument;
6use dokuwiki\ErrorHandler;
7use Mpdf\HTMLParserMode;
8use Mpdf\MpdfException;
9
10class Writer
11{
12    /** @var DokuMpdf Our MPDF instance */
13    protected DokuMpdf $mpdf;
14
15    /** @var Config The configuration */
16    protected Config $config;
17
18    /** @var Template The template used */
19    protected Template $template;
20
21    /** @var Styles The style parser */
22    protected Styles $styles;
23
24    /** @var bool Signal to output a page break before the next output */
25    protected bool $breakBeforeNext = false;
26
27    /** @var bool Are we debugging? */
28    protected bool $debug = false;
29
30    /** @var string Store HTML when debugging */
31    protected string $debugHTML = '';
32
33    /** @var false Is this the first page being written? */
34    protected bool $isFirstPage = true;
35
36    /**
37     * @param DokuMpdf $mpdf
38     * @param Template $template
39     * @param Styles $styles
40     */
41    public function __construct(DokuMpdf $mpdf, Config $config, Template $template, Styles $styles)
42    {
43        $this->mpdf = $mpdf;
44        $this->config = $config;
45        $this->template = $template;
46        $this->styles = $styles;
47        $this->debug = $config->isDebugEnabled();
48
49        /**
50         * initialize a new renderer instance (singleton instance will be reused in later p_* calls)
51         * @var \renderer_plugin_dw2pdf $renderer
52         */
53        $renderer = plugin_load('renderer', 'dw2pdf', true);
54        $renderer->setConfig($config);
55    }
56
57    /**
58     * Initialize the document
59     *
60     * @param string $title
61     * @return void
62     * @throws MpdfException
63     */
64    public function startDocument(string $title): void
65    {
66        $this->mpdf->SetTitle($title);
67
68        // Set the styles
69        $styles = '@page landscape-page { size:landscape }';
70        $styles .= 'div.dw2pdf-landscape { page:landscape-page }';
71        $styles .= '@page portrait-page { size:portrait }';
72        $styles .= 'div.dw2pdf-portrait { page:portrait-page }';
73        $styles .= $this->styles->getCSS();
74        $this->write($styles, HTMLParserMode::HEADER_CSS);
75
76        //start body html
77        $this->write('<div class="dokuwiki">', HTMLParserMode::HTML_BODY, true, false);
78    }
79
80    /**
81     * Insert a page break
82     *
83     * @return void
84     * @throws MpdfException
85     */
86    public function pageBreak(): void
87    {
88        $this->write('<pagebreak />', 2, false, false);
89    }
90
91    /**
92     * Write a wiki page into the PDF
93     *
94     * @param string $html The rendered HTML of the wiki page
95     * @return void
96     * @throws MpdfException
97     */
98    public function wikiPage(string $html): void
99    {
100        $this->conditionalPageBreak();
101        $this->applyHeaderFooters();
102        $this->write($html, HTMLParserMode::HTML_BODY, false, false);
103
104        // add citation box if any
105        $cite = $this->template->getHTML('citation');
106        if ($cite) {
107            $this->write($cite, HTMLParserMode::HTML_BODY, false, false);
108        }
109
110        $this->breakAfterMe();
111    }
112
113    /**
114     * Render and write a wiki page into the PDF
115     *
116     * This caches the rendered page individually (unless a specific revision is requested). So even
117     * when PDF needs to be regenerated, pages that have not changed will be loaded from cache.
118     *
119     * @param AbstractCollector $collector The collector providing the page context
120     * @param string $pageId The page ID to render
121     * @return void
122     * @throws MpdfException
123     */
124    public function renderWikiPage(AbstractCollector $collector, string $pageId): void
125    {
126        $rev = $collector->getRev();
127        $at = $collector->getAt();
128        $file = wikiFN($pageId, $rev);
129
130        //ensure $id is in global $ID (needed for parsing)
131        global $ID;
132        $keep = $ID;
133        $ID = $pageId;
134
135        if ($collector->getRev()) {
136            //no caching on old revisions
137            $html = p_render('dw2pdf', p_get_instructions(io_readWikiPage($file, $pageId, $rev)), $info, $at);
138        } else {
139            $html = p_cached_output($file, 'dw2pdf', $pageId);
140        }
141
142        //restore ID (just in case)
143        $ID = $keep;
144
145        // Fix internal links then write the page
146        $html = $this->fixInternalLinks($collector, $html);
147        $this->wikiPage($html);
148    }
149
150    /**
151     * If the given HTML contains internal links to pages that are part of the exported PDF,
152     * fix the links to point to the correct section within the PDF.
153     *
154     * @param AbstractCollector $collector
155     * @param string $html The rendered HTML of the wiki page
156     * @return string
157     */
158    protected function fixInternalLinks(AbstractCollector $collector, string $html): string
159    {
160        if ($html === '') return $html;
161
162        // quick bail out if the page has no internal link markers
163        if (!str_contains($html, 'data-dw2pdf-target')) {
164            return $html;
165        }
166
167        $pages = $collector->getPages();
168        if ($pages === []) {
169            return $html;
170        }
171        $pages = array_fill_keys($pages, true);
172
173        $dom = new DOMDocument('1.0', 'UTF-8');
174        $previous = libxml_use_internal_errors(true);
175        $loaded = $dom->loadHTML(
176            '<?xml encoding="utf-8"?>' . '<div>' . $html . '</div>',
177            LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
178        );
179        libxml_clear_errors();
180        libxml_use_internal_errors($previous);
181
182        if (!$loaded) {
183            return $html;
184        }
185
186        $anchors = $dom->getElementsByTagName('a');
187        if ($anchors->length === 0) {
188            return $html;
189        }
190
191        $pageAnchors = [];
192        foreach ($anchors as $anchor) {
193            /** @var \DOMElement $anchor */
194            if (!$anchor->hasAttribute('data-dw2pdf-target')) {
195                continue;
196            }
197
198            $target = $anchor->getAttribute('data-dw2pdf-target');
199            if ($target === '' || !isset($pages[$target])) {
200                $anchor->removeAttribute('data-dw2pdf-target');
201                $anchor->removeAttribute('data-dw2pdf-hash');
202                continue;
203            }
204
205            if (!isset($pageAnchors[$target])) {
206                $check = false;
207                $pageAnchors[$target] = sectionID($target, $check);
208            }
209
210            $hash = $anchor->getAttribute('data-dw2pdf-hash');
211            $anchor->setAttribute(
212                'href',
213                '#' . $pageAnchors[$target] . '__' . $hash
214            );
215
216            $anchor->removeAttribute('data-dw2pdf-target');
217            $anchor->removeAttribute('data-dw2pdf-hash');
218        }
219
220        $wrapper = $dom->getElementsByTagName('div')->item(0);
221        if (!$wrapper) {
222            return $html;
223        }
224
225        $result = '';
226        foreach ($wrapper->childNodes as $node) {
227            $result .= $dom->saveHTML($node);
228        }
229
230        return $result;
231    }
232
233    /**
234     * Write the Table of Contents
235     *
236     * For double-sided documents the ToC is always on an even number of pages, so that the
237     * following content is on the correct odd/even page.
238     * The first page of ToC starts always at an odd page, so an additional blank page might
239     * be included before.
240     * There is no page numbering at the pages of the ToC.
241     *
242     * @param string $header The header text for the ToC (localized))
243     * @return void
244     * @throws MpdfException
245     */
246    public function toc(string $header): void
247    {
248        $this->mpdf->TOCpagebreakByArray([
249            'toc-preHTML' => '<h2>' . $header . '</h2>',
250            'toc-bookmarkText' => $header,
251            'links' => true,
252            'outdent' => '1em',
253            'pagenumstyle' => '1'
254        ]);
255
256        $this->write('<tocpagebreak>', HTMLParserMode::HTML_BODY, false, false);
257    }
258
259    /**
260     * Insert a cover page
261     *
262     * Should be called once at the beginning of the PDF generation. Will do nothing if
263     * no cover page is configured.
264     *
265     * @return void
266     * @throws MpdfException
267     */
268    public function cover(): void
269    {
270        $html = $this->template->getHTML('cover');
271        if (!$html) return;
272
273        $this->conditionalPageBreak();
274        $this->applyHeaderFooters();
275        $this->write($html, HTMLParserMode::HTML_BODY, false, false);
276
277        $this->breakAfterMe();
278    }
279
280    /**
281     * Insert a back page
282     *
283     * Should be called once at the end of the PDF generation. Will do nothing if
284     * no back page is configured.
285     *
286     * @return void
287     * @throws MpdfException
288     */
289    public function back(): void
290    {
291        $html = $this->template->getHTML('back');
292        if (!$html) return;
293
294        $this->conditionalPageBreak();
295        $this->write($html, HTMLParserMode::HTML_BODY, false, false);
296    }
297
298    /**
299     * Finalize the document
300     *
301     * @return void
302     * @throws MpdfException
303     */
304    public function endDocument(): void
305    {
306        // adds the closing div and finalizes the document
307        $this->write('</div>', HTMLParserMode::HTML_BODY, false, true);
308    }
309
310    /**
311     * Set new headers and footers
312     *
313     * This will call the appropriate mpdf methods to set headers and footers. It should be called
314     * before each wiki page is added to the PDF.
315     *
316     * On first call on this instance it will set the headers/footers for the first page, afterwards
317     * it will use the standard headers/footers.
318     *
319     * We always set even and odd headers/footers, though they may be identical.
320     * @return void
321     */
322    protected function applyHeaderFooters(): void
323    {
324        if ($this->isFirstPage) {
325            $header = $this->template->getHTML('header', 'first');
326            $footer = $this->template->getHTML('footer', 'first');
327
328            if ($header) {
329                $this->mpdf->SetHTMLHeader($header, 'O');
330                $this->mpdf->SetHTMLHeader($header, 'E');
331            }
332            if ($footer) {
333                $this->mpdf->SetHTMLFooter($footer, 'O');
334                $this->mpdf->SetHTMLFooter($footer, 'E');
335            }
336            $this->isFirstPage = false;
337        } else {
338            $headerOdd = $this->template->getHTML('header', 'odd');
339            $headerEven = $this->template->getHTML('header', 'even');
340            $footerOdd = $this->template->getHTML('footer', 'odd');
341            $footerEven = $this->template->getHTML('footer', 'even');
342
343            if ($headerOdd) {
344                $this->mpdf->SetHTMLHeader($headerOdd, 'O');
345            }
346            if ($headerEven) {
347                $this->mpdf->SetHTMLHeader($headerEven, 'E');
348            }
349            if ($footerOdd) {
350                $this->mpdf->SetHTMLFooter($footerOdd, 'O');
351            }
352            if ($footerEven) {
353                $this->mpdf->SetHTMLFooter($footerEven, 'E');
354            }
355        }
356    }
357
358    /**
359     * Insert a page break if there was previous content
360     *
361     * @return void
362     * @throws MpdfException
363     */
364    protected function conditionalPageBreak(): void
365    {
366        if ($this->breakBeforeNext) {
367            $this->pageBreak();
368            $this->breakBeforeNext = false;
369        }
370    }
371
372    /**
373     * Signal that a page break should be inserted before the next content
374     *
375     * @return void
376     */
377    protected function breakAfterMe(): void
378    {
379        $this->breakBeforeNext = true;
380    }
381
382    /**
383     * Return the debug HTML collected so far
384     *
385     * Will return an empty string if debugging is not enabled.
386     *
387     * @return string The collected debug HTML
388     */
389    public function getDebugHTML(): string
390    {
391        return $this->debugHTML;
392    }
393
394    /**
395     * Persist the generated PDF to the provided destination file.
396     *
397     * @param string $cacheFile Absolute file path that should receive the PDF output
398     * @return void
399     * @throws MpdfException
400     */
401    public function outputToFile(string $cacheFile): void
402    {
403        $this->mpdf->Output($cacheFile, 'F');
404    }
405
406    /**
407     * A wrapper around MPDF::WriteHTML
408     *
409     * When debugging is enabled, the output is written to a debug buffer instead of the PDF.
410     *
411     * @param string $html The HTML code to write
412     * @param int $mode Use HTMLParserMode constants. Controls what parts of the $html code is parsed.
413     * @param bool $init Clears and sets buffers to Top level block etc.
414     * @param bool $close If false leaves buffers etc. in current state, so that it can continue a block etc.
415     * @throws MpdfException
416     */
417    protected function write(
418        string $html,
419        int $mode = HTMLParserMode::DEFAULT_MODE,
420        bool $init = true,
421        bool $close = true
422    ) {
423        if (!$this->debug) {
424            try {
425                $this->mpdf->WriteHTML($html, $mode, $init, $close);
426            } catch (MpdfException $e) {
427                ErrorHandler::logException($e); // ensure the issue is logged
428                throw $e;
429            }
430            return;
431        }
432
433        // when debugging, just store the HTML
434        if ($mode === HTMLParserMode::HEADER_CSS) {
435            $this->debugHTML .= "\n<style>\n" . $html . "\n</style>\n";
436        } else {
437            $this->debugHTML .= "\n" . $html . "\n";
438        }
439    }
440}
441