diff --git a/src/PdfExportService.php b/src/PdfExportService.php index 82db648..a68efbb 100644 --- a/src/PdfExportService.php +++ b/src/PdfExportService.php @@ -163,9 +163,10 @@ protected function renderDocument(): Writer : new ExportException('forbidden'); } - $writer->startDocument($this->collector->getTitle()); - // initial context for placeholder replacements, before any pages are loaded + // Set the initial context (first page) before the document is started, so the headers and + // footers defined in startDocument() resolve their placeholders against the first page. $template->setContext($this->collector, $pages[0], null); + $writer->startDocument($this->collector->getTitle()); $writer->cover(); if ($this->config->hasToC()) { diff --git a/src/Writer.php b/src/Writer.php index 75d2cbf..6bfcf65 100644 --- a/src/Writer.php +++ b/src/Writer.php @@ -30,9 +30,6 @@ class Writer /** @var string Store HTML when debugging */ protected string $debugHTML = ''; - /** @var false Is this the first page being written? */ - protected bool $isFirstPage = true; - /** * @param DokuMpdf $mpdf * @param Template $template @@ -71,6 +68,8 @@ public function startDocument(string $title): void $styles .= '@page portrait-page { size:portrait }'; $styles .= 'div.dw2pdf-portrait { page:portrait-page }'; $styles .= $this->styles->getCSS(); + $styles .= $this->defineHeaderFooters(); + $this->write($styles, HTMLParserMode::HEADER_CSS); //start body html @@ -97,8 +96,10 @@ public function pageBreak(): void */ public function wikiPage(string $html): void { - $this->conditionalPageBreak(); + // Redefine the odd/even headers/footers for this page *before* the break: mPDF captures a + // page's header and footer when the page begins. $this->applyHeaderFooters(); + $this->conditionalPageBreak(); $this->write($html, HTMLParserMode::HTML_BODY, false, false); // add citation box if any @@ -270,8 +271,8 @@ public function cover(): void $html = $this->template->getHTML('cover'); if (!$html) return; - $this->conditionalPageBreak(); $this->applyHeaderFooters(); + $this->conditionalPageBreak(); $this->write($html, HTMLParserMode::HTML_BODY, false, false); $this->breakAfterMe(); @@ -308,51 +309,88 @@ public function endDocument(): void } /** - * Set new headers and footers + * Define the named headers/footers and return the CSS that binds them to the pages + * + * mPDF selects a page's header and footer through @page CSS rules that reference named blocks. + * The "first" block is bound to "@page :first" so mPDF places it on the first physical page + * only. The odd/even blocks are bound to "@page" and apply to every following page. + * + * The blocks defined here resolve their placeholders against the first page (the current + * context when startDocument() runs). The first-page block keeps that content, while the + * odd/even blocks are redefined per wiki page by applyHeaderFooters(). + * + * Templates that do not exist are skipped, so their pages simply carry no header/footer. + * + * @return string The @page CSS binding the defined blocks + * @throws MpdfException + */ + protected function defineHeaderFooters(): string + { + $firstRules = ''; + $pageRules = ''; + foreach (['header', 'footer'] as $section) { + foreach (['first', 'odd', 'even'] as $order) { + if (!$this->defineNamedBlock($section, $order)) continue; + + if ($order === 'first') { + $firstRules .= $section . ': html_' . $section . '_first;'; + } else { + $pageRules .= $order . '-' . $section . '-name: html_' . $section . '_' . $order . ';'; + } + } + } + + $css = ''; + if ($firstRules !== '') $css .= '@page :first { ' . $firstRules . ' }'; + if ($pageRules !== '') $css .= '@page { ' . $pageRules . ' }'; + return $css; + } + + /** + * Redefine the odd/even headers and footers for the wiki page about to be written * - * This will call the appropriate mpdf methods to set headers and footers. It should be called - * before each wiki page is added to the PDF. + * Must run *before* the page break that starts the page, because mPDF captures a page's header + * and footer when the page begins. The blocks are re-read from the template on every call so + * per-page placeholders (@ID@, @PAGEURL@, @QRCODE@, ...) resolve against the current page. * - * On first call on this instance it will set the headers/footers for the first page, afterwards - * it will use the standard headers/footers. + * The first-page block is left untouched: "@page :first" consumes it on the first physical page + * only, so it never needs a per-page value. * - * We always set even and odd headers/footers, though they may be identical. * @return void + * @throws MpdfException */ protected function applyHeaderFooters(): void { - if ($this->isFirstPage) { - $header = $this->template->getHTML('header', 'first'); - $footer = $this->template->getHTML('footer', 'first'); - - if ($header) { - $this->mpdf->SetHTMLHeader($header, 'O'); - $this->mpdf->SetHTMLHeader($header, 'E'); - } - if ($footer) { - $this->mpdf->SetHTMLFooter($footer, 'O'); - $this->mpdf->SetHTMLFooter($footer, 'E'); + foreach (['header', 'footer'] as $section) { + foreach (['odd', 'even'] as $order) { + $this->defineNamedBlock($section, $order); } - $this->isFirstPage = false; - } else { - $headerOdd = $this->template->getHTML('header', 'odd'); - $headerEven = $this->template->getHTML('header', 'even'); - $footerOdd = $this->template->getHTML('footer', 'odd'); - $footerEven = $this->template->getHTML('footer', 'even'); + } + } - if ($headerOdd) { - $this->mpdf->SetHTMLHeader($headerOdd, 'O'); - } - if ($headerEven) { - $this->mpdf->SetHTMLHeader($headerEven, 'E'); - } - if ($footerOdd) { - $this->mpdf->SetHTMLFooter($footerOdd, 'O'); - } - if ($footerEven) { - $this->mpdf->SetHTMLFooter($footerEven, 'E'); - } + /** + * Define a single named header or footer block from its template + * + * The block is named "
_" (e.g. "header_odd") and can be referenced in CSS as + * "html_
_". Placeholders are resolved against the template's current context. + * + * @param string $section Either 'header' or 'footer' + * @param string $order The variant to load: 'first', 'odd' or 'even' + * @return bool True if the template existed and the block was defined, false otherwise + * @throws MpdfException + */ + protected function defineNamedBlock(string $section, string $order): bool + { + $html = $this->template->getHTML($section, $order); + if ($html === '') return false; + + $name = $section . '_' . $order; + if ($section === 'header') { + $this->mpdf->DefHTMLHeaderByName($name, $html); + } else { + $this->mpdf->DefHTMLFooterByName($name, $html); } + return true; } /**