From 99896b9932844367c9dcebad2102cdc5adb8c16c Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 31 Aug 2021 09:04:58 +0200 Subject: [PATCH 1/2] HeadlessChrome: Don't swallow events --- library/Pdfexport/HeadlessChrome.php | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/library/Pdfexport/HeadlessChrome.php b/library/Pdfexport/HeadlessChrome.php index 25d9eb8..1fc72e3 100644 --- a/library/Pdfexport/HeadlessChrome.php +++ b/library/Pdfexport/HeadlessChrome.php @@ -51,6 +51,9 @@ class HeadlessChrome /** @var array */ private $interceptedRequests = []; + /** @var array */ + private $interceptedEvents = []; + /** * Get the path to the Chrome binary * @@ -486,6 +489,8 @@ private function registerEvent($method, $params) $requestData['request']['url'], $params['errorText'] ); + } else { + $this->interceptedEvents[] = ['method' => $method, 'params' => $params]; } } @@ -523,14 +528,24 @@ private function waitFor(Client $ws, $eventName, array $expectedParams = null) } $wait = true; + $interceptedPos = -1; do { - $response = $this->parseApiResponse($ws->receive()); + if (isset($this->interceptedEvents[++$interceptedPos])) { + $response = $this->interceptedEvents[$interceptedPos]; + $intercepted = true; + } else { + $response = $this->parseApiResponse($ws->receive()); + $intercepted = false; + } + if (isset($response['method'])) { $method = $response['method']; $params = $response['params']; - $this->registerEvent($method, $params); + if (! $intercepted) { + $this->registerEvent($method, $params); + } if ($eventName === self::WAIT_FOR_NETWORK) { $wait = ! empty($this->interceptedRequests); @@ -542,6 +557,10 @@ private function waitFor(Client $ws, $eventName, array $expectedParams = null) $wait = false; } } + + if (! $wait && $intercepted) { + unset($this->interceptedEvents[$interceptedPos]); + } } } while ($wait); From a882f3ddb65433b8ac7a9fde3ec2687ebf759228 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 31 Aug 2021 09:05:45 +0200 Subject: [PATCH 2/2] Add automatic page break capability resolves #34 --- library/Pdfexport/HeadlessChrome.php | 54 ++++++++++++++++ library/Pdfexport/PrintableHtmlDocument.php | 69 +++++++++++++++++---- public/js/layout-plugins/page-breaker.js | 44 +++++++++++++ public/js/layout.js | 32 ++++++++++ 4 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 public/js/layout-plugins/page-breaker.js create mode 100644 public/js/layout.js diff --git a/library/Pdfexport/HeadlessChrome.php b/library/Pdfexport/HeadlessChrome.php index 1fc72e3..410cb3b 100644 --- a/library/Pdfexport/HeadlessChrome.php +++ b/library/Pdfexport/HeadlessChrome.php @@ -29,6 +29,26 @@ class HeadlessChrome /** @var string */ const WAIT_FOR_NETWORK = 'wait-for-network'; + /** @var string Javascript Promise to wait for layout initialization */ + const WAIT_FOR_LAYOUT = << { + let timeoutId = setTimeout(() => reject('fail'), 10000); + + if (document.documentElement.dataset.layoutReady === 'yes') { + clearTimeout(timeoutId); + fulfill(null); + return; + } + + document.addEventListener('layout-ready', e => { + clearTimeout(timeoutId); + fulfill(e.detail); + }, { + once: true + }); +}) +JS; + /** @var string Path to the Chrome binary */ protected $binary; @@ -385,6 +405,9 @@ private function printToPDF($socket, $browserId, array $parameters) 'frameId' => $targetId, 'html' => $this->document->render() ]); + + // wait for page to fully load + $this->waitFor($page, 'Page.loadEventFired'); } else { throw new LogicException('Nothing to print'); } @@ -392,6 +415,37 @@ private function printToPDF($socket, $browserId, array $parameters) // Wait for network activity to finish $this->waitFor($page, self::WAIT_FOR_NETWORK); + // Wait for layout to initialize + if (isset($this->document)) { + // Ensure layout scripts work in the same environment as the pdf printing itself + $this->communicate($page, 'Emulation.setEmulatedMedia', ['media' => 'print']); + + $this->communicate($page, 'Runtime.evaluate', [ + 'timeout' => 1000, + 'expression' => 'setTimeout(() => new Layout().apply(), 0)' + ]); + + $promisedResult = $this->communicate($page, 'Runtime.evaluate', [ + 'awaitPromise' => true, + 'returnByValue' => true, + 'timeout' => 1000, // Failsafe, doesn't apply to `await` it seems + 'expression' => static::WAIT_FOR_LAYOUT + ]); + if (isset($promisedResult['exceptionDetails'])) { + if (isset($promisedResult['exceptionDetails']['exception']['description'])) { + Logger::error( + 'PDF layout failed to initialize: %s', + $promisedResult['exceptionDetails']['exception']['description'] + ); + } else { + Logger::warning('PDF layout failed to initialize. Pages might look skewed.'); + } + } + + // Reset media emulation, this may prevent the real media from coming into effect? + $this->communicate($page, 'Emulation.setEmulatedMedia', ['media' => '']); + } + // print pdf $result = $this->communicate($page, 'Page.printToPDF', array_merge( $parameters, diff --git a/library/Pdfexport/PrintableHtmlDocument.php b/library/Pdfexport/PrintableHtmlDocument.php index cd6a938..c149709 100644 --- a/library/Pdfexport/PrintableHtmlDocument.php +++ b/library/Pdfexport/PrintableHtmlDocument.php @@ -4,10 +4,12 @@ namespace Icinga\Module\Pdfexport; +use Icinga\Application\Icinga; use Icinga\Web\StyleSheet; use ipl\Html\BaseHtmlElement; -use ipl\Html\Html; +use ipl\Html\HtmlElement; use ipl\Html\HtmlString; +use ipl\Html\Text; use ipl\Html\ValidHtml; class PrintableHtmlDocument extends BaseHtmlElement @@ -105,6 +107,16 @@ class PrintableHtmlDocument extends BaseHtmlElement */ protected $pageRanges; + /** + * Page height in pixels minus any vertical margins, footer and header + * + * The default is roughly the amount of pixels matching the default paper height of 11 inches at scale 1. + * + * @todo Find out why subtracting the vertical margins leaves unused space behind (with a height of ~980px) + * @var int + */ + protected $pagePixelHeight = 1056; + /** * HTML template for the print header * @@ -248,19 +260,32 @@ public function removeMargins() */ protected function assemble() { - $html = Html::tag('html') - ->add(Html::tag( - 'style', - null, - new HtmlString(new StyleSheet()) - )) - ->add(Html::tag( - 'title', + $this->setWrapper(new HtmlElement( + 'html', + null, + new HtmlElement( + 'head', null, - $this->title - )); - - $this->setWrapper($html); + new HtmlElement( + 'title', + null, + Text::create($this->title) + ), + new HtmlElement( + 'style', + null, + HtmlString::create(new StyleSheet()) + ), + $this->createLayoutScript() + ) + )); + + $this->getAttributes()->registerAttributeCallback('data-content-height', function () { + return $this->pagePixelHeight; + }); + $this->getAttributes()->registerAttributeCallback('style', function () { + return sprintf('width: %sin;', $this->paperWidth ?: 8.5); + }); } /** @@ -332,4 +357,22 @@ public function getPrintParameters() return $parameters; } + + /** + * Create layout javascript + * + * @return ValidHtml + */ + protected function createLayoutScript(): ValidHtml + { + $jsPath = Icinga::app()->getModuleManager()->getModule('pdfexport')->getJsDir(); + $layoutJS = file_get_contents($jsPath . '/layout.js') . "\n\n\n"; + $layoutJS .= file_get_contents($jsPath . '/layout-plugins/page-breaker.js') . "\n\n\n"; + + return new HtmlElement( + 'script', + Attributes::create(['type' => 'application/javascript']), + HtmlString::create($layoutJS) + ); + } } diff --git a/public/js/layout-plugins/page-breaker.js b/public/js/layout-plugins/page-breaker.js new file mode 100644 index 0000000..58217a6 --- /dev/null +++ b/public/js/layout-plugins/page-breaker.js @@ -0,0 +1,44 @@ +/* Icinga PDF Export | (c) 2021 Icinga GmbH | GPLv2 */ + +"use strict"; + +(() => { + Layout.registerPlugin('page-breaker', () => { + let pageBreaksFor = document.querySelector('[data-pdfexport-page-breaks-at]'); + if (! pageBreaksFor) { + return; + } + + let pageBreaksAt = pageBreaksFor.dataset.pdfexportPageBreaksAt; + if (! pageBreaksAt) { + return; + } + + let contentHeight = document.body.dataset.contentHeight; + let items = Array.from(pageBreaksFor.querySelectorAll(':scope > ' + pageBreaksAt)); + + let remainingHeight = contentHeight; + items.forEach((item, i) => { + let requiredHeight; + if (i < items.length - 1) { + requiredHeight = items[i + 1].getBoundingClientRect().top - item.getBoundingClientRect().top; + } else { + requiredHeight = item.parentElement.getBoundingClientRect().bottom - item.getBoundingClientRect().top; + } + + if (remainingHeight < requiredHeight) { + if (!! item.previousSibling) { + item.previousSibling.style.pageBreakAfter = 'always'; + item.previousSibling.classList.add('page-break-follows'); + } else { + item.style.pageBreakAfter = 'always'; + item.classList.add('page-break-follows'); + } + + remainingHeight = contentHeight; + } + + remainingHeight -= requiredHeight; + }); + }); +})(); diff --git a/public/js/layout.js b/public/js/layout.js new file mode 100644 index 0000000..186d6d4 --- /dev/null +++ b/public/js/layout.js @@ -0,0 +1,32 @@ +/* Icinga PDF Export | (c) 2021 Icinga GmbH | GPLv2 */ + +"use strict"; + +class Layout +{ + static #plugins = []; + + static registerPlugin(name, plugin) { + this.#plugins.push([name, plugin]); + } + + apply() { + for (let [name, plugin] of Layout.#plugins) { + try { + plugin(); + } catch (error) { + console.error('Layout plugin ' + name + ' failed run: ' + error); + } + } + + this.finish(); + } + + finish() { + document.documentElement.dataset.layoutReady = 'yes'; + document.dispatchEvent(new CustomEvent('layout-ready', { + cancelable: false, + bubbles: false + })); + } +}