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