From 11a13ad2a85b702110be064aa9577f9e43b9208d Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Fri, 24 Jul 2026 22:04:29 +0200 Subject: [PATCH] Confine the first-page header and footer to the first physical page The first-page header and footer were set with runtime SetHTMLHeader and SetHTMLFooter calls that were not bound to a specific page, so they spread beyond the first physical page onto the rest of the first content block and, for the header, even onto the following content while leaving the very first page without a header. They are now defined as named blocks bound through @page CSS rules: @page :first restricts the first-page block to the first physical page, while the odd and even blocks cover the remaining pages. Those blocks are redefined before each wiki page so their per-page placeholders (@ID@, @PAGEURL@, @QRCODE@, ...) resolve against the current page, and the first-page context is set before the document starts so the blocks defined at start-up resolve too. Fixes #548 --- src/PdfExportService.php | 5 +- src/Writer.php | 118 ++++++++++++++++++++++++++------------- 2 files changed, 81 insertions(+), 42 deletions(-) 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; } /**